PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
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.13, at app/Services/AttributeService.php

884 lines 29.1 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 $data['icon'] = yatra_normalize_icon_picker_for_storage($data['icon']);
403 $data['icon'] = maybe_serialize($data['icon']);
404 } elseif (is_string($data['icon'])) {
405 // If it's already a string, sanitize it
406 $data['icon'] = sanitize_text_field($data['icon']);
407 }
408 } else {
409 }
410
411 $attributeId = $this->attributeRepository->create($data);
412
413 if ($attributeId) {
414 // Clear cache
415 $this->clearAttributeCache();
416
417 // Log action
418 Logger::info("Created attribute: {$data['name']} (ID: {$attributeId})");
419
420 // Fire action hook
421 do_action('yatra_attribute_created', $attributeId, $data);
422 }
423
424 return $attributeId;
425
426 } catch (\Exception $e) {
427 Logger::error("Failed to create attribute: " . $e->getMessage());
428 throw $e;
429 }
430 }
431
432 /**
433 * Update attribute
434 */
435 public function updateAttribute(int $id, array $data): bool
436 {
437 try {
438 if (!$id) {
439 throw new \InvalidArgumentException('Attribute ID is required');
440 }
441
442 // Check if attribute exists
443 $existing = $this->attributeRepository->find($id);
444 if (!$existing) {
445 throw new \InvalidArgumentException('Attribute not found');
446 }
447
448 // Update slug if name changed
449 if (isset($data['name']) && !isset($data['slug'])) {
450 $data['slug'] = sanitize_title($data['name']);
451 }
452
453 // Check slug uniqueness (excluding current attribute)
454 if (isset($data['slug']) && $this->attributeRepository->slugExists($data['slug'], $id)) {
455 throw new \InvalidArgumentException('Attribute with this slug already exists');
456 }
457
458 if (array_key_exists('field_options', $data)) {
459 $this->normalizeFieldOptionsInPlace($data);
460 }
461
462 // Process field options
463 if (isset($data['field_options']) && is_array($data['field_options'])) {
464 $data['field_options'] = wp_json_encode($data['field_options']);
465 }
466
467 // Process validation rules
468 if (isset($data['validation_rules']) && is_array($data['validation_rules'])) {
469 $data['validation_rules'] = wp_json_encode($data['validation_rules']);
470 }
471
472 // Sanitize description as rich text
473 if (isset($data['description'])) {
474 $data['description'] = FormatHelper::sanitizeQuillHtml($data['description']);
475 }
476
477 // Process icon field
478 if (isset($data['icon'])) {
479 if (is_array($data['icon'])) {
480 $data['icon'] = yatra_normalize_icon_picker_for_storage($data['icon']);
481 $data['icon'] = maybe_serialize($data['icon']);
482 } elseif (is_string($data['icon'])) {
483 // If it's already a string, sanitize it
484 $data['icon'] = sanitize_text_field($data['icon']);
485 }
486 } else {
487 }
488
489 $result = $this->attributeRepository->update($id, $data);
490
491 if ($result) {
492 // Clear cache
493 $this->clearAttributeCache();
494
495 // Log action
496 Logger::info("Updated attribute ID: {$id}");
497
498 // Fire action hook
499 do_action('yatra_attribute_updated', $id, $data);
500 }
501
502 return $result;
503
504 } catch (\Exception $e) {
505 Logger::error("Failed to update attribute {$id}: " . $e->getMessage());
506 throw $e;
507 }
508 }
509
510 /**
511 * Delete attribute (soft delete)
512 */
513 public function deleteAttribute(int $id): bool
514 {
515 try {
516 if (!$id) {
517 throw new \InvalidArgumentException('Attribute ID is required');
518 }
519
520 $result = $this->attributeRepository->delete($id);
521
522 if ($result) {
523 // Clear cache
524 $this->clearAttributeCache();
525
526 // Log action
527 Logger::info("Deleted attribute ID: {$id}");
528
529 // Fire action hook
530 do_action('yatra_attribute_deleted', $id);
531 }
532
533 return $result;
534
535 } catch (\Exception $e) {
536 Logger::error("Failed to delete attribute {$id}: " . $e->getMessage());
537 throw $e;
538 }
539 }
540
541 /**
542 * Permanently delete attribute
543 */
544 public function forceDeleteAttribute(int $id): bool
545 {
546 try {
547 if (!$id) {
548 throw new \InvalidArgumentException('Attribute ID is required');
549 }
550
551 $result = $this->attributeRepository->forceDelete($id);
552
553 if ($result) {
554 // Clear cache
555 $this->clearAttributeCache();
556
557 // Log action
558 Logger::info("Force deleted attribute ID: {$id}");
559
560 // Fire action hook
561 do_action('yatra_attribute_force_deleted', $id);
562 }
563
564 return $result;
565
566 } catch (\Exception $e) {
567 Logger::error("Failed to force delete attribute {$id}: " . $e->getMessage());
568 throw $e;
569 }
570 }
571
572 /**
573 * Bulk update status for multiple attributes
574 *
575 * @param array $ids Attribute IDs
576 * @param string $status Target status (publish|draft|trash)
577 * @return array{updated: int, failed: int}
578 */
579 public function bulkUpdateStatus(array $ids, string $status): array
580 {
581 $updated = 0;
582 $failed = 0;
583
584 foreach ($ids as $id) {
585 $id = (int) $id;
586 if ($id <= 0) {
587 $failed++;
588 continue;
589 }
590 try {
591 if ($this->attributeRepository->update($id, ['status' => $status])) {
592 $updated++;
593 } else {
594 $failed++;
595 }
596 } catch (\Exception $e) {
597 Logger::error("bulkUpdateStatus failed for attribute {$id}: " . $e->getMessage());
598 $failed++;
599 }
600 }
601
602 if ($updated > 0) {
603 $this->clearAttributeCache();
604 }
605
606 return ['updated' => $updated, 'failed' => $failed];
607 }
608
609 /**
610 * Permanently delete multiple attributes
611 *
612 * @param array $ids Attribute IDs
613 * @return array{deleted: int, failed: int}
614 */
615 public function bulkDelete(array $ids): array
616 {
617 $deleted = 0;
618 $failed = 0;
619
620 foreach ($ids as $id) {
621 $id = (int) $id;
622 if ($id <= 0) {
623 $failed++;
624 continue;
625 }
626 try {
627 if ($this->attributeRepository->forceDelete($id)) {
628 $deleted++;
629 } else {
630 $failed++;
631 }
632 } catch (\Exception $e) {
633 Logger::error("bulkDelete failed for attribute {$id}: " . $e->getMessage());
634 $failed++;
635 }
636 }
637
638 if ($deleted > 0) {
639 $this->clearAttributeCache();
640 }
641
642 return ['deleted' => $deleted, 'failed' => $failed];
643 }
644
645 /**
646 * Update attribute display orders
647 */
648 public function updateDisplayOrders(array $orders): bool
649 {
650 try {
651 if (empty($orders) || !is_array($orders)) {
652 throw new \InvalidArgumentException('Orders array is required');
653 }
654
655 $result = $this->attributeRepository->updateDisplayOrders($orders);
656
657 if ($result) {
658 // Clear cache
659 $this->clearAttributeCache();
660
661 // Log action
662 Logger::info('Updated attribute display orders');
663
664 // Fire action hook
665 do_action('yatra_attribute_orders_updated', $orders);
666 }
667
668 return $result;
669
670 } catch (\Exception $e) {
671 Logger::error("Failed to update attribute display orders: " . $e->getMessage());
672 throw $e;
673 }
674 }
675
676 /**
677 * Validate attribute value based on field type and rules
678 */
679 private function validateAttributeValue(\stdClass $attribute, $value): void
680 {
681 // Basic validation based on field type
682 switch ($attribute->field_type) {
683 case 'number':
684 if (!is_numeric($value)) {
685 throw new \InvalidArgumentException('Value must be a number');
686 }
687 break;
688
689 case 'email':
690 if (!empty($value) && !is_email($value)) {
691 throw new \InvalidArgumentException('Invalid email address');
692 }
693 break;
694
695 case 'url':
696 if (!empty($value) && !filter_var($value, FILTER_VALIDATE_URL)) {
697 throw new \InvalidArgumentException('Invalid URL');
698 }
699 break;
700
701 case 'date':
702 if (!empty($value) && !strtotime($value)) {
703 throw new \InvalidArgumentException('Invalid date format');
704 }
705 break;
706
707 case 'time':
708 if (!empty($value) && !preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $value)) {
709 throw new \InvalidArgumentException('Invalid time format (HH:MM)');
710 }
711 break;
712
713 case 'select':
714 case 'radio':
715 $options = $attribute->field_options ? json_decode($attribute->field_options, true) : [];
716 if (!empty($value) && !in_array($value, array_column($options, 'value'))) {
717 throw new \InvalidArgumentException('Invalid option selected');
718 }
719 break;
720
721 case 'checkbox':
722 $checkboxVals = $value;
723 if (is_bool($value)) {
724 $checkboxVals = $value ? ['1'] : [];
725 } elseif (!is_array($value)) {
726 $checkboxVals = ($value !== '' && $value !== null) ? [(string) $value] : [];
727 }
728 $options = $attribute->field_options ? json_decode($attribute->field_options, true) : [];
729 if (!empty($checkboxVals) && is_array($options)) {
730 $validOptions = array_column($options, 'value');
731 foreach ($checkboxVals as $val) {
732 $match = false;
733 foreach ($validOptions as $vo) {
734 if ((string) $val === (string) $vo) {
735 $match = true;
736 break;
737 }
738 }
739 if (!$match) {
740 throw new \InvalidArgumentException('Invalid checkbox option: ' . $val);
741 }
742 }
743 }
744 break;
745 }
746
747 // Check if required field is empty
748 if ($attribute->required && (empty($value) || (is_string($value) && trim($value) === ''))) {
749 throw new \InvalidArgumentException('This attribute is required');
750 }
751
752 // Additional validation rules
753 if (!empty($attribute->validation_rules)) {
754 $rules = json_decode($attribute->validation_rules, true);
755 if (is_array($rules)) {
756 $this->applyValidationRules($value, $rules);
757 }
758 }
759 }
760
761 /**
762 * Apply custom validation rules
763 */
764 private function applyValidationRules($value, array $rules): void
765 {
766 foreach ($rules as $rule => $ruleValue) {
767 switch ($rule) {
768 case 'min_length':
769 if (strlen($value) < $ruleValue) {
770 throw new \InvalidArgumentException("Minimum length is {$ruleValue} characters");
771 }
772 break;
773
774 case 'max_length':
775 if (strlen($value) > $ruleValue) {
776 throw new \InvalidArgumentException("Maximum length is {$ruleValue} characters");
777 }
778 break;
779
780 case 'min_value':
781 if (is_numeric($value) && $value < $ruleValue) {
782 throw new \InvalidArgumentException("Minimum value is {$ruleValue}");
783 }
784 break;
785
786 case 'max_value':
787 if (is_numeric($value) && $value > $ruleValue) {
788 throw new \InvalidArgumentException("Maximum value is {$ruleValue}");
789 }
790 break;
791
792 case 'pattern':
793 if (!preg_match($ruleValue, $value)) {
794 throw new \InvalidArgumentException('Value format is invalid');
795 }
796 break;
797 }
798 }
799 }
800
801 /**
802 * Clear attribute-related cache
803 */
804 private function clearAttributeCache(): void
805 {
806 Cache::delete('yatra_frontend_attributes');
807 Cache::delete('yatra_filterable_attributes');
808 Cache::delete(\Yatra\Utils\Cache::KEY_AVAILABLE_ATTRIBUTES);
809
810 // Clear search cache by prefix
811 Cache::clearByPrefix('yatra_search_attributes_');
812 }
813
814 /**
815 * Clear trip attribute cache
816 */
817 private function clearTripAttributeCache(int $tripId): void
818 {
819 Cache::delete("yatra_trip_attributes_{$tripId}");
820 }
821
822 /**
823 * Bulk update trip attributes
824 */
825 public function bulkUpdateTripAttributes(int $tripId, array $attributes): bool
826 {
827 try {
828 if (!$tripId) {
829 return false;
830 }
831
832 $this->attributeRepository->validatePayloadCoversRequiredAttributes($attributes);
833
834 // Repository commits, busts trip attribute cache, fires yatra_trip_attributes_bulk_updated.
835 $success = $this->tripAttributeRepository->saveTripAttributes($tripId, $attributes);
836
837 return $success;
838
839 } catch (\InvalidArgumentException $e) {
840 throw $e;
841 } catch (\Exception $e) {
842 Logger::error("Failed to bulk update trip attributes: " . $e->getMessage());
843 return false;
844 }
845 }
846
847 /**
848 * Check if slug already exists
849 */
850 public function slugExists(string $slug, ?int $excludeId = null): bool
851 {
852 try {
853 return $this->attributeRepository->slugExists($slug, $excludeId);
854 } catch (\Exception $e) {
855 Logger::error('Error checking slug existence: ' . $e->getMessage());
856 return false;
857 }
858 }
859
860 /**
861 * Get status counts for attributes
862 */
863 public function getStatusCounts(): array
864 {
865 $counts = $this->attributeRepository->getStatusCounts();
866
867 $publish = $counts['publish'] ?? 0;
868 $draft = $counts['draft'] ?? 0;
869 $trash = $counts['trash'] ?? 0;
870
871 // Calculate total from all statuses, not just the main three
872 $all = array_sum(array_values($counts));
873
874 $result = [
875 'all' => (int) $all,
876 'publish' => (int) $publish,
877 'draft' => (int) $draft,
878 'trash' => (int) $trash,
879 ];
880
881 return $result;
882 }
883 }
884