PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.7
Yatra – Travel Booking & Tour Operator Software v3.0.2.7
3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 2.0.11 All 82 releases
yatra / app / Services / AttributeService.php

AttributeService.php in Yatra – Travel Booking & Tour Operator Software 3.0.2.7, at app/Services/AttributeService.php

900 lines 29.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 use Yatra\Repositories\AttributeRepository;
8 use Yatra\Repositories\TripAttributeRepository;
9 use Yatra\Utils\Logger;
10 use Yatra\Utils\Cache;
11 use Yatra\Helpers\FormatHelper;
12
13 /**
14 * Attribute Service
15 * Handles business logic for trip attributes
16 */
17 class AttributeService extends BaseService
18 {
19 /**
20 * @var AttributeRepository
21 */
22 private $attributeRepository;
23
24 /**
25 * @var TripAttributeRepository
26 */
27 private $tripAttributeRepository;
28
29 /**
30 * Constructor
31 */
32 public function __construct()
33 {
34 $this->attributeRepository = new AttributeRepository();
35 $this->tripAttributeRepository = new TripAttributeRepository();
36 }
37
38 /**
39 * Get repository instance
40 */
41 protected function getRepository()
42 {
43 return $this->attributeRepository;
44 }
45
46 /**
47 * Get all published attributes for frontend
48 */
49 public function getFrontendAttributes(): array
50 {
51 try {
52 return $this->attributeRepository->withQueryCache(
53 'yatra_frontend_attributes',
54 function () {
55 return $this->attributeRepository->getAllPublished();
56 },
57 3600
58 );
59 } catch (\Exception $e) {
60 Logger::error('Failed to get frontend attributes: ' . $e->getMessage());
61 return [];
62 }
63 }
64
65 /**
66 * Get filterable attributes
67 */
68 public function getFilterableAttributes(): array
69 {
70 try {
71 return $this->attributeRepository->withQueryCache(
72 'yatra_filterable_attributes',
73 function () {
74 return $this->attributeRepository->getFilterableAttributes();
75 },
76 1800
77 );
78 } catch (\Exception $e) {
79 Logger::error('Failed to get filterable attributes: ' . $e->getMessage());
80 return [];
81 }
82 }
83
84 /**
85 * Get attributes for a specific trip
86 */
87 public function getTripAttributes(int $tripId): array
88 {
89 try {
90 return $this->tripAttributeRepository->withQueryCache(
91 "yatra_trip_attributes_{$tripId}",
92 function () use ($tripId) {
93 return $this->tripAttributeRepository->getTripAttributes($tripId);
94 },
95 1800
96 );
97 } catch (\Exception $e) {
98 Logger::error("Failed to get attributes for trip {$tripId}: " . $e->getMessage());
99 return [];
100 }
101 }
102
103 /**
104 * Set attribute value for a trip
105 */
106 public function setTripAttribute(int $tripId, int $attributeId, $value): bool
107 {
108 try {
109 if (!$tripId || !$attributeId) {
110 throw new \InvalidArgumentException('Trip ID and Attribute ID are required');
111 }
112
113 // Validate attribute exists
114 $attribute = $this->attributeRepository->find($attributeId);
115 if (!$attribute || $attribute->status !== 'publish') {
116 throw new \InvalidArgumentException('Attribute not found or not published');
117 }
118
119 // Validate value based on field type
120 $this->validateAttributeValue($attribute, $value);
121
122 $result = $this->tripAttributeRepository->setTripAttribute($tripId, $attributeId, $value);
123
124 if ($result) {
125 // Clear cache
126 $this->clearTripAttributeCache($tripId);
127
128 // Log action
129 Logger::info("Set attribute {$attributeId} value for trip {$tripId}");
130
131 // Fire action hook
132 do_action('yatra_trip_attribute_set', $tripId, $attributeId, $value);
133 }
134
135 return $result;
136
137 } catch (\Exception $e) {
138 Logger::error("Failed to set attribute for trip {$tripId}: " . $e->getMessage());
139 return false;
140 }
141 }
142
143 /**
144 * Remove attribute value from a trip
145 */
146 public function removeTripAttribute(int $tripId, int $attributeId): bool
147 {
148 try {
149 if (!$tripId || !$attributeId) {
150 throw new \InvalidArgumentException('Trip ID and Attribute ID are required');
151 }
152
153 $result = $this->tripAttributeRepository->deleteTripAttribute($tripId, $attributeId);
154
155 if ($result) {
156 // Clear cache
157 $this->clearTripAttributeCache($tripId);
158
159 // Log action
160 Logger::info("Removed attribute {$attributeId} from trip {$tripId}");
161
162 // Fire action hook
163 do_action('yatra_trip_attribute_removed', $tripId, $attributeId);
164 }
165
166 return $result;
167
168 } catch (\Exception $e) {
169 Logger::error("Failed to remove attribute from trip {$tripId}: " . $e->getMessage());
170 return false;
171 }
172 }
173
174 /**
175 * Get trips by attribute value
176 */
177 public function getTripsByAttributeValue(int $attributeId, string $value): array
178 {
179 try {
180 return $this->attributeRepository->withQueryCache(
181 "yatra_trips_by_attr_{$attributeId}_" . md5($value),
182 function () use ($attributeId, $value) {
183 $tripAttributeTable = $this->tripAttributeRepository->getTableName();
184 $query = "SELECT DISTINCT t.* FROM {$this->attributeRepository->getTableName()} a
185 INNER JOIN {$tripAttributeTable} ta ON ta.attribute_id = a.id
186 INNER JOIN {$this->attributeRepository->getTripsTableName()} t ON t.id = ta.trip_id
187 WHERE a.id = %d AND ta.value = %s AND t.status = 'publish'";
188
189 return $this->attributeRepository->wpdb->get_results(
190 $this->attributeRepository->wpdb->prepare($query, $attributeId, $value)
191 ) ?: [];
192 },
193 1800
194 );
195 } catch (\Exception $e) {
196 Logger::error("Failed to get trips by attribute value: " . $e->getMessage());
197 return [];
198 }
199 }
200
201 /**
202 * Get all possible values for an attribute (for filter dropdowns)
203 */
204 public function getAttributeValues(int $attributeId): array
205 {
206 try {
207 return $this->attributeRepository->withQueryCache(
208 "yatra_attribute_values_{$attributeId}",
209 function () use ($attributeId) {
210 $tripAttributeTable = $this->tripAttributeRepository->getTableName();
211 $query = "SELECT DISTINCT value, COUNT(*) as count FROM {$tripAttributeTable}
212 WHERE attribute_id = %d AND value IS NOT NULL AND value != ''
213 GROUP BY value ORDER BY count DESC, value ASC";
214
215 return $this->attributeRepository->wpdb->get_results(
216 $this->attributeRepository->wpdb->prepare($query, $attributeId)
217 ) ?: [];
218 },
219 3600
220 );
221 } catch (\Exception $e) {
222 Logger::error("Failed to get attribute values: " . $e->getMessage());
223 return [];
224 }
225 }
226
227 /**
228 * Search attributes
229 */
230 public function search(string $term): array
231 {
232 try {
233 return $this->attributeRepository->withQueryCache(
234 'yatra_search_attributes_' . md5($term),
235 function () use ($term) {
236 return $this->attributeRepository->search($term);
237 },
238 1800
239 );
240 } catch (\Exception $e) {
241 Logger::error("Failed to search attributes: " . $e->getMessage());
242 return [];
243 }
244 }
245
246 /**
247 * Clear related entity caches
248 */
249 protected function clearRelatedEntityCaches(int $id, string $operation): void
250 {
251 // Clear frontend attributes cache
252 $this->clearCacheByPattern('yatra_frontend_attributes');
253
254 // Clear filterable attributes cache
255 $this->clearCacheByPattern('yatra_filterable_attributes');
256
257 // Clear trip-related attribute caches
258 $this->clearCacheByPattern('yatra_trip_attributes_');
259
260 // Clear search attributes cache
261 $this->clearCacheByPattern('yatra_search_attributes_');
262
263 // Clear attribute values cache
264 $this->clearCacheByPattern('yatra_attribute_values_');
265
266 // Clear trips by attribute value cache
267 $this->clearCacheByPattern('yatra_trips_by_attr_');
268
269 // Clear trip listing caches since attributes affect trip filtering
270 $this->clearCacheByPattern('trip_listing_');
271
272 // Clear query result caches
273 $this->clearCacheByPattern(Cache::PREFIX_QUERY_RESULT);
274
275 Logger::debug("Attribute related caches cleared", [
276 'attribute_id' => $id,
277 'operation' => $operation
278 ]);
279 }
280
281 /**
282 * Clear cache by pattern
283 */
284 private function clearCacheByPattern(string $pattern): void
285 {
286 try {
287 Cache::clearByPrefix($pattern);
288 } catch (\Exception $e) {
289 Logger::warning("Failed to clear attribute cache pattern", [
290 'pattern' => $pattern,
291 'error' => $e->getMessage()
292 ]);
293 }
294 }
295
296 /**
297 * Normalize field_options from JSON string or array into a clean list of label/value pairs.
298 *
299 * @param array<string, mixed> $data
300 */
301 private function normalizeFieldOptionsInPlace(array &$data): void
302 {
303 if (!array_key_exists('field_options', $data)) {
304 return;
305 }
306 $fo = $data['field_options'];
307 if (is_string($fo)) {
308 $t = trim($fo);
309 if ($t === '') {
310 $data['field_options'] = [];
311 return;
312 }
313 $decoded = json_decode($t, true);
314 $fo = is_array($decoded) ? $decoded : [];
315 }
316 if (!is_array($fo)) {
317 $data['field_options'] = [];
318 return;
319 }
320 $clean = [];
321 foreach ($fo as $row) {
322 if (!is_array($row)) {
323 continue;
324 }
325 $label = isset($row['label']) ? sanitize_text_field((string) $row['label']) : '';
326 $value = isset($row['value']) ? sanitize_text_field((string) $row['value']) : '';
327 if ($label === '' && $value === '') {
328 continue;
329 }
330 if ($value === '' && $label !== '') {
331 $value = sanitize_title($label);
332 }
333 $clean[] = ['label' => $label, 'value' => $value];
334 }
335 $data['field_options'] = $clean;
336 }
337
338 /**
339 * Create new attribute
340 */
341 public function createAttribute(array $data): int
342 {
343 try {
344 // Validate required fields
345 if (empty($data['name'])) {
346 throw new \InvalidArgumentException('Attribute name is required');
347 }
348
349 // Generate slug if not provided
350 if (empty($data['slug'])) {
351 $data['slug'] = sanitize_title($data['name']);
352 }
353
354 // Check if slug already exists
355 if ($this->attributeRepository->slugExists($data['slug'])) {
356 throw new \InvalidArgumentException('Attribute with this slug already exists');
357 }
358
359 // Set default values
360 $data = array_merge([
361 'field_type' => 'text_field',
362 'required' => 0,
363 'show_on_frontend' => 1,
364 'show_in_filters' => 0,
365 'filter_type' => 'exact',
366 'searchable' => 0,
367 'status' => 'publish',
368 'display_order' => $this->attributeRepository->getMaxDisplayOrder() + 1
369 ], $data);
370
371 $this->normalizeFieldOptionsInPlace($data);
372
373 // Validate field type
374 $validFieldTypes = ['text_field', 'number', 'email', 'url', 'textarea', 'select', 'radio', 'checkbox', 'date', 'time', 'color', 'file'];
375 if (!in_array($data['field_type'], $validFieldTypes)) {
376 throw new \InvalidArgumentException('Invalid field type');
377 }
378
379 // Validate field options for select/radio/checkbox
380 if (in_array($data['field_type'], ['select', 'radio', 'checkbox'], true) && empty($data['field_options'])) {
381 throw new \InvalidArgumentException('Field options are required for select, radio, and checkbox fields');
382 }
383
384 // Process field options
385 if (isset($data['field_options']) && is_array($data['field_options'])) {
386 $data['field_options'] = wp_json_encode($data['field_options']);
387 }
388
389 // Process validation rules
390 if (isset($data['validation_rules']) && is_array($data['validation_rules'])) {
391 $data['validation_rules'] = wp_json_encode($data['validation_rules']);
392 }
393
394 // Sanitize description as rich text
395 if (isset($data['description'])) {
396 $data['description'] = FormatHelper::sanitizeQuillHtml($data['description']);
397 }
398
399 // Process icon field
400 if (isset($data['icon'])) {
401 if (is_array($data['icon'])) {
402 // Sanitize icon array
403 $icon = [
404 'type' => isset($data['icon']['type']) && in_array($data['icon']['type'], ['icon', 'image'], true)
405 ? $data['icon']['type']
406 : 'icon',
407 'value' => isset($data['icon']['value'])
408 ? sanitize_text_field($data['icon']['value'])
409 : '',
410 ];
411 $data['icon'] = maybe_serialize($icon);
412 } elseif (is_string($data['icon'])) {
413 // If it's already a string, sanitize it
414 $data['icon'] = sanitize_text_field($data['icon']);
415 }
416 } else {
417 }
418
419 $attributeId = $this->attributeRepository->create($data);
420
421 if ($attributeId) {
422 // Clear cache
423 $this->clearAttributeCache();
424
425 // Log action
426 Logger::info("Created attribute: {$data['name']} (ID: {$attributeId})");
427
428 // Fire action hook
429 do_action('yatra_attribute_created', $attributeId, $data);
430 }
431
432 return $attributeId;
433
434 } catch (\Exception $e) {
435 Logger::error("Failed to create attribute: " . $e->getMessage());
436 throw $e;
437 }
438 }
439
440 /**
441 * Update attribute
442 */
443 public function updateAttribute(int $id, array $data): bool
444 {
445 try {
446 if (!$id) {
447 throw new \InvalidArgumentException('Attribute ID is required');
448 }
449
450 // Check if attribute exists
451 $existing = $this->attributeRepository->find($id);
452 if (!$existing) {
453 throw new \InvalidArgumentException('Attribute not found');
454 }
455
456 // Update slug if name changed
457 if (isset($data['name']) && !isset($data['slug'])) {
458 $data['slug'] = sanitize_title($data['name']);
459 }
460
461 // Check slug uniqueness (excluding current attribute)
462 if (isset($data['slug']) && $this->attributeRepository->slugExists($data['slug'], $id)) {
463 throw new \InvalidArgumentException('Attribute with this slug already exists');
464 }
465
466 if (array_key_exists('field_options', $data)) {
467 $this->normalizeFieldOptionsInPlace($data);
468 }
469
470 // Process field options
471 if (isset($data['field_options']) && is_array($data['field_options'])) {
472 $data['field_options'] = wp_json_encode($data['field_options']);
473 }
474
475 // Process validation rules
476 if (isset($data['validation_rules']) && is_array($data['validation_rules'])) {
477 $data['validation_rules'] = wp_json_encode($data['validation_rules']);
478 }
479
480 // Sanitize description as rich text
481 if (isset($data['description'])) {
482 $data['description'] = FormatHelper::sanitizeQuillHtml($data['description']);
483 }
484
485 // Process icon field
486 if (isset($data['icon'])) {
487 if (is_array($data['icon'])) {
488 // Sanitize icon array
489 $icon = [
490 'type' => isset($data['icon']['type']) && in_array($data['icon']['type'], ['icon', 'image'], true)
491 ? $data['icon']['type']
492 : 'icon',
493 'value' => isset($data['icon']['value'])
494 ? sanitize_text_field($data['icon']['value'])
495 : '',
496 ];
497 $data['icon'] = maybe_serialize($icon);
498 } elseif (is_string($data['icon'])) {
499 // If it's already a string, sanitize it
500 $data['icon'] = sanitize_text_field($data['icon']);
501 }
502 } else {
503 }
504
505 $result = $this->attributeRepository->update($id, $data);
506
507 if ($result) {
508 // Clear cache
509 $this->clearAttributeCache();
510
511 // Log action
512 Logger::info("Updated attribute ID: {$id}");
513
514 // Fire action hook
515 do_action('yatra_attribute_updated', $id, $data);
516 }
517
518 return $result;
519
520 } catch (\Exception $e) {
521 Logger::error("Failed to update attribute {$id}: " . $e->getMessage());
522 throw $e;
523 }
524 }
525
526 /**
527 * Delete attribute (soft delete)
528 */
529 public function deleteAttribute(int $id): bool
530 {
531 try {
532 if (!$id) {
533 throw new \InvalidArgumentException('Attribute ID is required');
534 }
535
536 $result = $this->attributeRepository->delete($id);
537
538 if ($result) {
539 // Clear cache
540 $this->clearAttributeCache();
541
542 // Log action
543 Logger::info("Deleted attribute ID: {$id}");
544
545 // Fire action hook
546 do_action('yatra_attribute_deleted', $id);
547 }
548
549 return $result;
550
551 } catch (\Exception $e) {
552 Logger::error("Failed to delete attribute {$id}: " . $e->getMessage());
553 throw $e;
554 }
555 }
556
557 /**
558 * Permanently delete attribute
559 */
560 public function forceDeleteAttribute(int $id): bool
561 {
562 try {
563 if (!$id) {
564 throw new \InvalidArgumentException('Attribute ID is required');
565 }
566
567 $result = $this->attributeRepository->forceDelete($id);
568
569 if ($result) {
570 // Clear cache
571 $this->clearAttributeCache();
572
573 // Log action
574 Logger::info("Force deleted attribute ID: {$id}");
575
576 // Fire action hook
577 do_action('yatra_attribute_force_deleted', $id);
578 }
579
580 return $result;
581
582 } catch (\Exception $e) {
583 Logger::error("Failed to force delete attribute {$id}: " . $e->getMessage());
584 throw $e;
585 }
586 }
587
588 /**
589 * Bulk update status for multiple attributes
590 *
591 * @param array $ids Attribute IDs
592 * @param string $status Target status (publish|draft|trash)
593 * @return array{updated: int, failed: int}
594 */
595 public function bulkUpdateStatus(array $ids, string $status): array
596 {
597 $updated = 0;
598 $failed = 0;
599
600 foreach ($ids as $id) {
601 $id = (int) $id;
602 if ($id <= 0) {
603 $failed++;
604 continue;
605 }
606 try {
607 if ($this->attributeRepository->update($id, ['status' => $status])) {
608 $updated++;
609 } else {
610 $failed++;
611 }
612 } catch (\Exception $e) {
613 Logger::error("bulkUpdateStatus failed for attribute {$id}: " . $e->getMessage());
614 $failed++;
615 }
616 }
617
618 if ($updated > 0) {
619 $this->clearAttributeCache();
620 }
621
622 return ['updated' => $updated, 'failed' => $failed];
623 }
624
625 /**
626 * Permanently delete multiple attributes
627 *
628 * @param array $ids Attribute IDs
629 * @return array{deleted: int, failed: int}
630 */
631 public function bulkDelete(array $ids): array
632 {
633 $deleted = 0;
634 $failed = 0;
635
636 foreach ($ids as $id) {
637 $id = (int) $id;
638 if ($id <= 0) {
639 $failed++;
640 continue;
641 }
642 try {
643 if ($this->attributeRepository->forceDelete($id)) {
644 $deleted++;
645 } else {
646 $failed++;
647 }
648 } catch (\Exception $e) {
649 Logger::error("bulkDelete failed for attribute {$id}: " . $e->getMessage());
650 $failed++;
651 }
652 }
653
654 if ($deleted > 0) {
655 $this->clearAttributeCache();
656 }
657
658 return ['deleted' => $deleted, 'failed' => $failed];
659 }
660
661 /**
662 * Update attribute display orders
663 */
664 public function updateDisplayOrders(array $orders): bool
665 {
666 try {
667 if (empty($orders) || !is_array($orders)) {
668 throw new \InvalidArgumentException('Orders array is required');
669 }
670
671 $result = $this->attributeRepository->updateDisplayOrders($orders);
672
673 if ($result) {
674 // Clear cache
675 $this->clearAttributeCache();
676
677 // Log action
678 Logger::info('Updated attribute display orders');
679
680 // Fire action hook
681 do_action('yatra_attribute_orders_updated', $orders);
682 }
683
684 return $result;
685
686 } catch (\Exception $e) {
687 Logger::error("Failed to update attribute display orders: " . $e->getMessage());
688 throw $e;
689 }
690 }
691
692 /**
693 * Validate attribute value based on field type and rules
694 */
695 private function validateAttributeValue(\stdClass $attribute, $value): void
696 {
697 // Basic validation based on field type
698 switch ($attribute->field_type) {
699 case 'number':
700 if (!is_numeric($value)) {
701 throw new \InvalidArgumentException('Value must be a number');
702 }
703 break;
704
705 case 'email':
706 if (!empty($value) && !is_email($value)) {
707 throw new \InvalidArgumentException('Invalid email address');
708 }
709 break;
710
711 case 'url':
712 if (!empty($value) && !filter_var($value, FILTER_VALIDATE_URL)) {
713 throw new \InvalidArgumentException('Invalid URL');
714 }
715 break;
716
717 case 'date':
718 if (!empty($value) && !strtotime($value)) {
719 throw new \InvalidArgumentException('Invalid date format');
720 }
721 break;
722
723 case 'time':
724 if (!empty($value) && !preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $value)) {
725 throw new \InvalidArgumentException('Invalid time format (HH:MM)');
726 }
727 break;
728
729 case 'select':
730 case 'radio':
731 $options = $attribute->field_options ? json_decode($attribute->field_options, true) : [];
732 if (!empty($value) && !in_array($value, array_column($options, 'value'))) {
733 throw new \InvalidArgumentException('Invalid option selected');
734 }
735 break;
736
737 case 'checkbox':
738 $checkboxVals = $value;
739 if (is_bool($value)) {
740 $checkboxVals = $value ? ['1'] : [];
741 } elseif (!is_array($value)) {
742 $checkboxVals = ($value !== '' && $value !== null) ? [(string) $value] : [];
743 }
744 $options = $attribute->field_options ? json_decode($attribute->field_options, true) : [];
745 if (!empty($checkboxVals) && is_array($options)) {
746 $validOptions = array_column($options, 'value');
747 foreach ($checkboxVals as $val) {
748 $match = false;
749 foreach ($validOptions as $vo) {
750 if ((string) $val === (string) $vo) {
751 $match = true;
752 break;
753 }
754 }
755 if (!$match) {
756 throw new \InvalidArgumentException('Invalid checkbox option: ' . $val);
757 }
758 }
759 }
760 break;
761 }
762
763 // Check if required field is empty
764 if ($attribute->required && (empty($value) || (is_string($value) && trim($value) === ''))) {
765 throw new \InvalidArgumentException('This attribute is required');
766 }
767
768 // Additional validation rules
769 if (!empty($attribute->validation_rules)) {
770 $rules = json_decode($attribute->validation_rules, true);
771 if (is_array($rules)) {
772 $this->applyValidationRules($value, $rules);
773 }
774 }
775 }
776
777 /**
778 * Apply custom validation rules
779 */
780 private function applyValidationRules($value, array $rules): void
781 {
782 foreach ($rules as $rule => $ruleValue) {
783 switch ($rule) {
784 case 'min_length':
785 if (strlen($value) < $ruleValue) {
786 throw new \InvalidArgumentException("Minimum length is {$ruleValue} characters");
787 }
788 break;
789
790 case 'max_length':
791 if (strlen($value) > $ruleValue) {
792 throw new \InvalidArgumentException("Maximum length is {$ruleValue} characters");
793 }
794 break;
795
796 case 'min_value':
797 if (is_numeric($value) && $value < $ruleValue) {
798 throw new \InvalidArgumentException("Minimum value is {$ruleValue}");
799 }
800 break;
801
802 case 'max_value':
803 if (is_numeric($value) && $value > $ruleValue) {
804 throw new \InvalidArgumentException("Maximum value is {$ruleValue}");
805 }
806 break;
807
808 case 'pattern':
809 if (!preg_match($ruleValue, $value)) {
810 throw new \InvalidArgumentException('Value format is invalid');
811 }
812 break;
813 }
814 }
815 }
816
817 /**
818 * Clear attribute-related cache
819 */
820 private function clearAttributeCache(): void
821 {
822 Cache::delete('yatra_frontend_attributes');
823 Cache::delete('yatra_filterable_attributes');
824 Cache::delete(\Yatra\Utils\Cache::KEY_AVAILABLE_ATTRIBUTES);
825
826 // Clear search cache by prefix
827 Cache::clearByPrefix('yatra_search_attributes_');
828 }
829
830 /**
831 * Clear trip attribute cache
832 */
833 private function clearTripAttributeCache(int $tripId): void
834 {
835 Cache::delete("yatra_trip_attributes_{$tripId}");
836 }
837
838 /**
839 * Bulk update trip attributes
840 */
841 public function bulkUpdateTripAttributes(int $tripId, array $attributes): bool
842 {
843 try {
844 if (!$tripId) {
845 return false;
846 }
847
848 $this->attributeRepository->validatePayloadCoversRequiredAttributes($attributes);
849
850 // Repository commits, busts trip attribute cache, fires yatra_trip_attributes_bulk_updated.
851 $success = $this->tripAttributeRepository->saveTripAttributes($tripId, $attributes);
852
853 return $success;
854
855 } catch (\InvalidArgumentException $e) {
856 throw $e;
857 } catch (\Exception $e) {
858 Logger::error("Failed to bulk update trip attributes: " . $e->getMessage());
859 return false;
860 }
861 }
862
863 /**
864 * Check if slug already exists
865 */
866 public function slugExists(string $slug, ?int $excludeId = null): bool
867 {
868 try {
869 return $this->attributeRepository->slugExists($slug, $excludeId);
870 } catch (\Exception $e) {
871 Logger::error('Error checking slug existence: ' . $e->getMessage());
872 return false;
873 }
874 }
875
876 /**
877 * Get status counts for attributes
878 */
879 public function getStatusCounts(): array
880 {
881 $counts = $this->attributeRepository->getStatusCounts();
882
883 $publish = $counts['publish'] ?? 0;
884 $draft = $counts['draft'] ?? 0;
885 $trash = $counts['trash'] ?? 0;
886
887 // Calculate total from all statuses, not just the main three
888 $all = array_sum(array_values($counts));
889
890 $result = [
891 'all' => (int) $all,
892 'publish' => (int) $publish,
893 'draft' => (int) $draft,
894 'trash' => (int) $trash,
895 ];
896
897 return $result;
898 }
899 }
900