PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
3.0.15 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 All 83 releases
yatra / app / Services / TripService.php

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

1,334 lines 44.5 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\TripRepository;
8 use Yatra\Repositories\AttributeRepository;
9 use Yatra\Repositories\TripRevisionRepository;
10 use Yatra\Repositories\TripAttributeRepository;
11 use Yatra\Repositories\TripAvailabilityRepository;
12 use Yatra\Models\Trip;
13 use Yatra\Services\CacheService;
14 use Yatra\Services\AttributeService;
15 use Yatra\Utils\Logger;
16 use Yatra\Database\Tables\TripAvailabilityDatesTable;
17 use Yatra\Database\Tables\TripAvailabilityRulesTable;
18
19 /**
20 * Trip Service
21 * Contains business logic for trips with comprehensive validation
22 *
23 * Expert-level service design:
24 * - Comprehensive validation
25 * - Business rule enforcement
26 * - Relationship management
27 * - Revision handling
28 * - Data transformation
29 */
30 class TripService extends BaseService
31 {
32 /**
33 * @var TripRepository
34 */
35 private TripRepository $repository;
36
37 /**
38 * @var TripRevisionRepository
39 */
40 private TripRevisionRepository $revisionRepository;
41
42 /**
43 * @var AttributeService
44 */
45 private AttributeService $attributeService;
46
47 /**
48 * @var TripAttributeRepository
49 */
50 private TripAttributeRepository $attributeRepository;
51
52 /**
53 * @var TripAvailabilityRepository
54 */
55 private TripAvailabilityRepository $availabilityRepository;
56
57 /**
58 * Constructor
59 */
60 public function __construct()
61 {
62 $this->repository = new TripRepository();
63 $this->revisionRepository = new TripRevisionRepository();
64 $this->attributeRepository = new TripAttributeRepository();
65 $this->availabilityRepository = new TripAvailabilityRepository();
66 $this->attributeService = new AttributeService();
67 }
68
69 /**
70 * Get repository
71 */
72 protected function getRepository(): TripRepository
73 {
74 return $this->repository;
75 }
76
77 /**
78 * Get trip by ID with caching (repository layer)
79 */
80 public function getById(int $id): ?\stdClass
81 {
82 $trip = $this->repository->findByIdCached($id);
83 if ($trip) {
84 Logger::debug('Trip loaded (repository cache)', ['trip_id' => $id]);
85 }
86
87 return $trip;
88 }
89
90 /**
91 * Get trip with relationships and caching (repository layer)
92 */
93 public function getWithRelationsCached(int $id): ?\stdClass
94 {
95 $trip = $this->repository->findWithRelationsCached($id);
96 if ($trip) {
97 Logger::debug('Trip with relationships loaded (repository cache)', ['trip_id' => $id]);
98 }
99
100 return $trip;
101 }
102
103 /**
104 * Create trip with caching
105 */
106 public function create(array $data): int
107 {
108 $attributes = $data['attributes'] ?? null;
109 unset($data['attributes']);
110
111 // Validate data
112 $this->validate($data);
113 $this->validatePricing($data);
114 $this->validateDuration($data);
115
116 // Drop per-trip currency (managed globally)
117 if (isset($data['currency'])) {
118 unset($data['currency']);
119 }
120
121 // Process data
122 $processedData = $this->processBeforeCreate($data);
123
124 // Create trip
125 $id = $this->repository->create($processedData);
126
127 // Save trip attributes if provided
128 if (is_array($attributes)) {
129 $this->saveTripAttributes($id, $attributes);
130 }
131
132 Logger::info("Trip created", ['trip_id' => $id]);
133 return $id;
134 }
135
136 /**
137 * Update trip with cache invalidation
138 */
139 public function update(int $id, array $data): bool
140 {
141 $attributes = $data['attributes'] ?? null;
142 unset($data['attributes']);
143
144 // Validate data
145 $this->validate($data, $id);
146 if (isset($data['pricing_type']) || isset($data['original_price']) || isset($data['price_types'])) {
147 $this->validatePricing($data);
148 }
149 if (isset($data['trip_type']) || isset($data['duration_days']) || isset($data['duration_nights'])) {
150 $this->validateDuration($data);
151 }
152
153 // Update trip
154 $result = $this->repository->update($id, $data);
155
156 // Save trip attributes if provided
157 if ($result && is_array($attributes)) {
158 $this->saveTripAttributes($id, $attributes);
159 }
160
161 if ($result) {
162 Logger::info("Trip updated", ['trip_id' => $id]);
163 }
164
165 return $result;
166 }
167
168 /**
169 * Delete trip with cache invalidation
170 */
171 public function delete(int $id): bool
172 {
173 $result = $this->repository->delete($id);
174
175 if ($result) {
176 Logger::info("Trip deleted", ['trip_id' => $id]);
177 }
178
179 return $result;
180 }
181
182 /**
183 * Validate trip data
184 */
185 protected function validate(array $data, ?int $id = null): void
186 {
187 // Required fields
188 if (empty($data['title'])) {
189 throw new \InvalidArgumentException('Trip title is required');
190 }
191
192 if (empty($data['slug'])) {
193 throw new \InvalidArgumentException('Trip slug is required');
194 }
195
196 if (!preg_match('/^[\pL\pN-]+$/u', $data['slug'])) {
197 throw new \InvalidArgumentException('Trip slug can only contain letters, numbers, and hyphens');
198 }
199
200 // Check if slug is unique (exclude current trip when updating)
201 $existing = $this->repository->findBySlug($data['slug']);
202 if ($existing) {
203 $existingId = (int) $existing->id;
204 // Only throw error if creating new trip OR if existing trip is different from current trip
205 if ($id === null || $existingId !== (int) $id) {
206 throw new \InvalidArgumentException('Trip slug must be unique');
207 }
208 }
209 }
210
211 /**
212 * Validate pricing data
213 */
214 private function validatePricing(array $data): void
215 {
216 $pricingType = $data['pricing_type'] ?? 'regular';
217
218 if ($pricingType === 'regular') {
219 if (!isset($data['original_price']) || (float) $data['original_price'] <= 0) {
220 throw new \InvalidArgumentException('Original price is required and must be greater than 0 for regular pricing');
221 }
222
223 // Validate discounted price
224 if (isset($data['discounted_price']) && !empty($data['discounted_price'])) {
225 if ((float) $data['discounted_price'] >= (float) $data['original_price']) {
226 throw new \InvalidArgumentException('Discounted price must be less than original price');
227 }
228 }
229 } elseif ($pricingType === 'traveler_based') {
230 if (empty($data['price_types']) || !is_array($data['price_types'])) {
231 throw new \InvalidArgumentException('Price types are required for traveler-based pricing');
232 }
233
234 // Validate each price type
235 foreach ($data['price_types'] as $priceType) {
236 if (empty($priceType['category_id'])) {
237 throw new \InvalidArgumentException('Category ID is required for each price type');
238 }
239 if (empty($priceType['original_price']) || (float) $priceType['original_price'] <= 0) {
240 throw new \InvalidArgumentException('Original price is required and must be greater than 0 for each price type');
241 }
242 }
243 }
244 }
245
246 /**
247 * Validate duration data
248 */
249 private function validateDuration(array $data): void
250 {
251 $tripType = $data['trip_type'] ?? 'multi_day';
252 $days = isset($data['duration_days']) ? (int) $data['duration_days'] : null;
253 $nights = isset($data['duration_nights']) ? (int) $data['duration_nights'] : null;
254
255 if ($tripType === 'single_day') {
256 if ($days !== null && $days !== 1) {
257 throw new \InvalidArgumentException('Single day trips must have duration_days = 1');
258 }
259 if ($nights !== null && $nights !== 0) {
260 throw new \InvalidArgumentException('Single day trips must have duration_nights = 0');
261 }
262 } elseif ($tripType === 'multi_day') {
263 if ($days !== null && $days < 2) {
264 throw new \InvalidArgumentException('Multi-day trips must have duration_days >= 2');
265 }
266 if ($days !== null && $nights !== null) {
267 if ($nights >= $days) {
268 throw new \InvalidArgumentException('Nights should be less than days (typically days - 1)');
269 }
270 }
271 }
272 }
273
274 /**
275 * Process before create
276 */
277 protected function processBeforeCreate(array $data): array
278 {
279 if (!preg_match('/^[\pL\pN-]+$/u', $data['slug'])) {
280 throw new \InvalidArgumentException('Trip slug can only contain letters, numbers, and hyphens');
281 }
282
283 if (empty($data['status'])) {
284 $data['status'] = 'draft';
285 }
286
287 // Currency is managed globally (not per-trip); drop any incoming value
288 if (isset($data['currency'])) {
289 unset($data['currency']);
290 }
291
292 // Set created_by
293 if (empty($data['created_by'])) {
294 $data['created_by'] = get_current_user_id();
295 }
296
297 // Set version
298 $data['version'] = 1;
299
300 // Process JSON fields
301 $data = $this->processJsonFields($data);
302
303 // Ensure difficulty_level is stored as integer ID
304 if (isset($data['difficulty_level'])) {
305 $data['difficulty_level'] = is_numeric($data['difficulty_level']) ? (int) $data['difficulty_level'] : null;
306 }
307
308 return $data;
309 }
310
311 /**
312 * Process before update - save revision when publishing
313 */
314 protected function processBeforeUpdate(int $id, array $data): array
315 {
316 // Get current trip data before update
317 $currentTrip = $this->repository->find($id);
318
319 if ($currentTrip) {
320 $currentStatus = $currentTrip->status ?? 'draft';
321 $newStatus = $data['status'] ?? $currentStatus;
322
323 // Create revision every time trip is published (status is 'published')
324 // This captures the state before each publish, allowing users to revert to any published version
325 if ($newStatus === 'publish') {
326 // Get latest version number
327 $latestVersion = $this->revisionRepository->getLatestVersion($id);
328 $newVersion = $latestVersion + 1;
329
330 // Get current user ID
331 $currentUserId = get_current_user_id();
332
333 // Serialize current trip data (state before this publish)
334 $tripDataArray = (array) $currentTrip;
335 $serializedData = maybe_serialize($tripDataArray);
336
337 // Save revision (with 'inherit' status like WordPress)
338 try {
339 $this->revisionRepository->createRevision($id, $newVersion, $serializedData, $currentUserId, 'inherit');
340
341 // Clean up old revisions (similar to WordPress's revision limit)
342 $this->revisionRepository->cleanupRevisions($id);
343 } catch (\Exception $e) {
344 // Log error but don't fail the update
345 if (defined('WP_DEBUG') && WP_DEBUG) {
346 }
347 }
348
349 // Increment version
350 $data['version'] = $newVersion;
351 } else {
352 // Keep current version if not publishing
353 $data['version'] = $currentTrip->version ?? 1;
354 }
355 }
356
357 // Set updated_by
358 $data['updated_by'] = get_current_user_id();
359
360 // Merge custom_fields: keep existing keys that the incoming payload doesn't touch
361 if (isset($data['custom_fields']) && is_array($data['custom_fields'])) {
362 $data['custom_fields'] = $this->mergeCustomFields($id, $data['custom_fields']);
363 }
364
365 // Process JSON fields
366 $data = $this->processJsonFields($data);
367
368 // Ensure difficulty_level is stored as integer ID
369 if (isset($data['difficulty_level'])) {
370 $data['difficulty_level'] = is_numeric($data['difficulty_level']) ? (int) $data['difficulty_level'] : null;
371 }
372
373 return $data;
374 }
375
376 /**
377 * Merge incoming custom_fields with what is already stored in the DB for a trip.
378 * Only keys present in the incoming array are changed; all other existing keys are
379 * kept intact. This is the "append, never override" contract the UI depends on.
380 */
381 private function mergeCustomFields(int $id, array $incoming): array
382 {
383 $existing = $this->repository->find($id);
384 if (!$existing || empty($existing->custom_fields)) {
385 return $incoming;
386 }
387
388 $storedRaw = $existing->custom_fields;
389 if (is_string($storedRaw)) {
390 $decoded = maybe_unserialize($storedRaw);
391 if (!is_array($decoded)) {
392 $decoded = json_decode($storedRaw, true);
393 }
394 $storedRaw = is_array($decoded) ? $decoded : [];
395 }
396
397 return array_merge($storedRaw, $incoming);
398 }
399
400 /**
401 * Process JSON fields - serialize arrays
402 */
403 private function processJsonFields(array $data): array
404 {
405 // Note: highlights, gallery_images, faqs, price_types, itinerary_days, availability_dates
406 // are stored in separate tables, not as JSON columns
407 $jsonFields = [
408 'testimonials',
409 'countries',
410 'regions',
411 'landmarks',
412 'tags',
413 'included_items',
414 'excluded_items',
415 'frontend_tabs',
416 'blackout_dates',
417 'custom_fields',
418 'pricing_rules',
419 'booking_rules',
420 ];
421
422 foreach ($jsonFields as $field) {
423 if (isset($data[$field]) && is_array($data[$field])) {
424 $data[$field] = maybe_serialize($data[$field]);
425 }
426 }
427
428 return $data;
429 }
430
431 /**
432 * Generate unique slug from title
433 */
434 private function generateSlug(string $title): string
435 {
436 $slug = sanitize_title($title);
437 $baseSlug = $slug;
438 $counter = 1;
439
440 // Ensure uniqueness
441 while ($this->repository->findBySlug($slug)) {
442 $slug = $baseSlug . '-' . $counter;
443 $counter++;
444 }
445
446 return $slug;
447 }
448
449 /**
450 * Create trip with relationships
451 */
452 public function createWithRelations(array $data, array $relationships = []): int
453 {
454 // Extract relationship fields from data if not already in relationships array
455 // (these should not be in the main table)
456 $relationshipFields = [
457 'highlights',
458 'gallery_images',
459 'faqs',
460 'itinerary_days',
461 'availability_dates',
462 'trip_category',
463 'price_types', // Include price_types in relationship fields
464 'downloadable_items', // Managed in separate yatra_trip_downloads table
465 'attributes',
466 'activities', // Add activities to relationship fields
467 'destinations', // Add destinations to relationship fields
468 ];
469
470 foreach ($relationshipFields as $field) {
471 if (!isset($relationships[$field])) {
472 // Handle field name mapping for activities
473 $dataField = $field === 'activities' ? 'activity_types' : $field;
474 if (isset($data[$dataField])) {
475 $relationships[$field] = $data[$dataField];
476 }
477 }
478 }
479
480 // For validation, we need price_types in data temporarily
481 $priceTypesForValidation = $relationships['price_types'] ?? $data['price_types'] ?? [];
482 $data['price_types'] = $priceTypesForValidation;
483
484 $this->validate($data);
485
486 // Sanitize data using TripValidator
487 $tripValidator = new \Yatra\Validators\TripValidator();
488 $data = $tripValidator->sanitize($data, null);
489
490 // Remove relationship fields from data before processing
491 foreach ($relationshipFields as $field) {
492 // Don't remove featured_priority - it's a direct database field, not a relationship
493 if ($field !== 'featured_priority') {
494 unset($data[$field]);
495 }
496 }
497
498 $data = $this->processBeforeCreate($data);
499
500 $attrPayload = $relationships['attributes'] ?? [];
501 if (!is_array($attrPayload)) {
502 $attrPayload = [];
503 }
504 (new AttributeRepository())->validatePayloadCoversRequiredAttributes($attrPayload);
505
506 return $this->repository->createWithRelations($data, $relationships);
507 }
508
509 /**
510 * Update trip with relationships
511 */
512 public function updateWithRelations(int $id, array $data, array $relationships = []): bool
513 {
514
515 // Extract relationship fields from data if not already in relationships array
516 // (these should not be in the main table)
517 $relationshipFields = [
518 'highlights',
519 'gallery_images',
520 'faqs',
521 'itinerary_days',
522 'availability_dates',
523 'trip_category',
524 'price_types', // Include price_types in relationship fields
525 'downloadable_items', // Managed in separate yatra_trip_downloads table
526 'attributes', // Add attributes to relationship fields
527 'activities', // Add activities to relationship fields
528 'destinations', // Add destinations to relationship fields
529 ];
530
531 foreach ($relationshipFields as $field) {
532 if (!isset($relationships[$field])) {
533 // Handle field name mapping for activities
534 $dataField = $field === 'activities' ? 'activity_types' : $field;
535 if (isset($data[$dataField])) {
536 $relationships[$field] = $data[$dataField];
537 }
538 }
539 }
540
541
542 $data = $this->processBeforeUpdate($id, $data);
543
544 $attributesRelationship = $relationships['attributes'] ?? null;
545
546 unset($relationships['attributes']);
547
548 $result = $this->repository->updateWithRelations($id, $data, $relationships);
549
550 if ($result && is_array($attributesRelationship)) {
551 $this->saveTripAttributes($id, $attributesRelationship);
552 } else {
553 }
554
555 // Always sync pricing_type to availability dates and recurring rules when saving
556 if (isset($data['pricing_type'])) {
557 $this->syncPricingTypeToAvailability($id, $data['pricing_type']);
558 }
559
560 return $result;
561 }
562
563 /**
564 * Sync pricing type to all availability dates and recurring rules for a trip
565 * This ensures availability/rules always match the trip's pricing type
566 *
567 * @param int $tripId Trip ID
568 * @param string $pricingType The pricing type to sync ('regular' or 'traveler_based')
569 */
570 public function syncPricingTypeToAvailability(int $tripId, string $pricingType): void
571 {
572 global $wpdb;
573
574 // Update availability dates
575 $availabilityRepository = new \Yatra\Repositories\AvailabilityRepository();
576 $updated_dates = $availabilityRepository->updatePricingTypeByTripId($tripId, $pricingType);
577 // Note: Recurring rules table doesn't have pricing_type column
578 // Pricing type is derived from the trip, not stored in rules
579 // If changing to regular pricing, clear traveler_pricing and price_types
580 if ($pricingType === 'regular') {
581 // Clear price_types from availability dates
582 $cleared_dates = $availabilityRepository->clearPriceTypesByTripId($tripId);
583 // Clear traveler_pricing from availability dates
584 $cleared_pricing = $availabilityRepository->clearTravelerPricingByTripId($tripId);
585 // Note: Recurring rules don't have pricing_type column
586 // Pricing is derived from the trip, not stored in rules
587 }
588
589 }
590
591 /**
592 * Get trip with relationships
593 */
594 public function getWithRelations(int $id, bool $includeDeleted = false): ?\stdClass
595 {
596 $trip = $this->repository->findWithRelations($id, $includeDeleted);
597
598 if ($trip) {
599 $trip->attributes = $this->attributeService->getTripAttributes($id);
600 }
601
602 return $trip;
603 }
604
605 /**
606 * Duplicate a trip with all relationships
607 *
608 * Creates a new draft trip based on an existing one, copying
609 * core fields and relationships (destinations, activities,
610 * categories, pricing, highlights, gallery, FAQs, itinerary,
611 * availability dates). Slug will be regenerated to ensure
612 * uniqueness and status is always set to draft.
613 *
614 * @param int $id Existing trip ID
615 * @return int New trip ID
616 * @throws \Exception If source trip not found
617 */
618 public function duplicate(int $id): int
619 {
620 $source = $this->getWithRelations($id);
621 if (!$source) {
622 throw new \Exception(__('Trip not found', 'yatra'));
623 }
624
625 $data = (array) $source;
626
627 // Adjust core fields
628 $data['title'] = ($data['title'] ?? '') . ' (Copy)';
629
630 // Generate new unique slug based on existing slug with numeric suffix (-1, -2, ...)
631 $baseSlug = !empty($source->slug) ? sanitize_title((string) $source->slug) : $this->generateSlug($data['title'] ?? 'trip-copy');
632 $suffix = 1;
633 $newSlug = $baseSlug . '-' . $suffix;
634 while ($this->repository->findBySlug($newSlug)) {
635 $suffix++;
636 $newSlug = $baseSlug . '-' . $suffix;
637 }
638 $data['slug'] = $newSlug;
639
640 // Always create as draft copy
641 $data['status'] = 'draft';
642
643 // Ensure created_by is current user; clear technical fields
644 $data['created_by'] = get_current_user_id();
645 unset(
646 $data['id'],
647 $data['created_at'],
648 $data['updated_at'],
649 $data['version'],
650 $data['published_at']
651 );
652
653 // Extract relationships from the hydrated source object in the shapes
654 // expected by TripRepository::createWithRelations (scalar IDs / simple arrays)
655 $relationships = [];
656
657 // Destinations: array of destination IDs
658 $destinations = [];
659 if (!empty($source->destinations) && is_array($source->destinations)) {
660 foreach ($source->destinations as $dest) {
661 $id = (int) ($dest->destination_id ?? $dest->id ?? 0);
662 if ($id > 0 && !in_array($id, $destinations, true)) {
663 $destinations[] = $id;
664 }
665 }
666 }
667 $relationships['destinations'] = $destinations;
668
669 // Activities: array of activity IDs
670 $activities = [];
671 if (!empty($source->activities) && is_array($source->activities)) {
672 foreach ($source->activities as $act) {
673 $id = (int) ($act->activity_id ?? $act->id ?? 0);
674 if ($id > 0 && !in_array($id, $activities, true)) {
675 $activities[] = $id;
676 }
677 }
678 }
679 $relationships['activities'] = $activities;
680
681 // Trip categories: array of category IDs
682 $categories = [];
683 if (!empty($source->trip_category) && is_array($source->trip_category)) {
684 foreach ($source->trip_category as $cat) {
685 $id = (int) ($cat->category_id ?? $cat->id ?? 0);
686 if ($id > 0 && !in_array($id, $categories, true)) {
687 $categories[] = $id;
688 }
689 }
690 }
691 $relationships['trip_category'] = $categories;
692
693 // Price types: normalize to simple associative arrays
694 $priceTypes = [];
695 if (!empty($source->price_types) && is_array($source->price_types)) {
696 foreach ($source->price_types as $pt) {
697 $categoryId = (int) ($pt->category_id ?? 0);
698 if ($categoryId <= 0) {
699 continue;
700 }
701 $item = [
702 'category_id' => $categoryId,
703 'original_price' => (float) ($pt->original_price ?? 0),
704 ];
705 if (isset($pt->discounted_price)) {
706 $item['discounted_price'] = (float) $pt->discounted_price;
707 }
708 $priceTypes[] = $item;
709 }
710 }
711 $relationships['price_types'] = $priceTypes;
712
713 // Highlights: normalize stdClass rows from DB into arrays/strings expected by saveHighlights
714 $highlights = [];
715 $rawHighlights = $data['highlights'] ?? ($source->highlights ?? []);
716 if (!empty($rawHighlights) && is_array($rawHighlights)) {
717 foreach ($rawHighlights as $highlight) {
718 // Already a simple string
719 if (is_string($highlight)) {
720 $highlights[] = $highlight;
721 continue;
722 }
723
724 // Already an array in the expected shape
725 if (is_array($highlight)) {
726 $highlights[] = $highlight;
727 continue;
728 }
729
730 // Convert stdClass from yatra_trip_highlights table into array
731 if (is_object($highlight)) {
732 $text = $highlight->text ?? $highlight->highlight_text ?? '';
733 if ($text === '') {
734 continue;
735 }
736
737 $highlights[] = [
738 'text' => $text,
739 'icon' => $highlight->icon ?? $highlight->highlight_icon ?? null,
740 'image_id' => isset($highlight->image_id)
741 ? (int) $highlight->image_id
742 : (isset($highlight->highlight_image_id) ? (int) $highlight->highlight_image_id : 0),
743 'is_featured' => isset($highlight->is_featured) ? (int) $highlight->is_featured : 0,
744 ];
745 }
746 }
747 }
748 $relationships['highlights'] = $highlights;
749
750 // Gallery images: normalize stdClass rows from DB into arrays/strings expected by saveGalleryImages
751 $galleryImages = [];
752 $rawGalleryImages = $data['gallery_images'] ?? ($source->gallery_images ?? []);
753 if (!empty($rawGalleryImages) && is_array($rawGalleryImages)) {
754 foreach ($rawGalleryImages as $image) {
755 // Already a simple URL string
756 if (is_string($image)) {
757 $galleryImages[] = $image;
758 continue;
759 }
760
761 // Already an array in the expected shape
762 if (is_array($image)) {
763 $galleryImages[] = $image;
764 continue;
765 }
766
767 // Convert stdClass from yatra_trip_gallery_images into array
768 if (is_object($image)) {
769 $url = $image->url ?? $image->image_url ?? '';
770 if ($url === '') {
771 continue;
772 }
773
774 $galleryImages[] = [
775 'url' => $url,
776 'id' => isset($image->image_id) ? (int) $image->image_id : 0,
777 'thumbnail_url' => $image->thumbnail_url ?? null,
778 'alt_text' => $image->alt_text ?? null,
779 'caption' => $image->caption ?? null,
780 'is_featured' => isset($image->is_featured) ? (int) $image->is_featured : 0,
781 ];
782 }
783 }
784 }
785 $relationships['gallery_images'] = $galleryImages;
786
787 // Other relationships can be copied as-is; repository helpers can handle them
788 $relationships['faqs'] = $data['faqs'] ?? ($source->faqs ?? []);
789 $relationships['itinerary_days'] = $data['itinerary_days'] ?? ($source->itinerary_days ?? []);
790 $relationships['availability_dates'] = $data['availability_dates'] ?? ($source->availability_dates ?? []);
791
792 // Remove relationship keys from main data to avoid column issues
793 unset(
794 $data['destinations'],
795 $data['activities'],
796 $data['trip_category'],
797 $data['price_types'],
798 $data['highlights'],
799 $data['gallery_images'],
800 $data['faqs'],
801 $data['itinerary_days'],
802 $data['availability_dates']
803 );
804
805 return $this->createWithRelations($data, $relationships);
806 }
807
808 /**
809 * Soft delete trip
810 */
811 public function softDelete(int $id): bool
812 {
813 $userId = get_current_user_id();
814 return $this->repository->softDelete($id, $userId);
815 }
816
817 /**
818 * Restore trip
819 */
820 public function restore(int $id): bool
821 {
822 return $this->repository->restore($id);
823 }
824
825 /**
826 * Permanently remove a trip (hard delete). Used from trash / permanent-delete REST.
827 */
828 public function permanentDelete(int $id): bool
829 {
830 if ($this->repository->find($id, true) === null) {
831 return false;
832 }
833
834 return $this->delete($id);
835 }
836
837 /**
838 * Publish trip
839 */
840 public function publish(int $id): bool
841 {
842 $data = [
843 'status' => 'publish',
844 'published_at' => current_time('mysql'),
845 ];
846
847 return $this->repository->update($id, $data);
848 }
849
850 /**
851 * Get active trips
852 */
853 public function getActiveTrips(array $args = []): array
854 {
855 return $this->repository->getActive($args);
856 }
857
858 /**
859 * Map trip_id => bookings count for list views.
860 *
861 * @param int[] $tripIds
862 * @param string[]|null $excludeStatuses
863 * @return array<int,int>
864 */
865 public function getBookingsCountMap(array $tripIds, ?array $excludeStatuses = null): array
866 {
867 return $this->repository->getBookingsCountMap($tripIds, $excludeStatuses);
868 }
869
870 /**
871 * Search trips
872 */
873 public function search(string $keyword, array $args = []): array
874 {
875 return $this->repository->search($keyword, $args);
876 }
877
878 /**
879 * Admin trip list/grid: read rows directly from the repository (bypasses BaseService::getAll() list cache).
880 * Count queries do not use that cache; a stale cached empty list made /trips/stats correct while the grid stayed empty.
881 */
882 public function getAllForAdminList(array $args = []): array
883 {
884 return $this->repository->all($args);
885 }
886
887 /**
888 * Count items
889 */
890 public function count(array $args = []): int
891 {
892 return $this->repository->count($args);
893 }
894
895 /**
896 * Count by status
897 */
898 public function countByStatus(string $status): int
899 {
900 return $this->repository->countByStatus($status);
901 }
902
903 /**
904 * Get status counts for admin list views
905 *
906 * Returns a stable set of counts that do not change with filters
907 * so that the UI can show consistent "All / Published / Draft / ..." tabs.
908 */
909 public function getStatusCounts(): array
910 {
911 // For admin stats, include all trips regardless of soft delete status
912 $args = ['include_deleted' => true];
913
914 $statuses = [
915 'publish',
916 'draft',
917 'review',
918 'approved',
919 'archived',
920 'trash',
921 ];
922
923 $counts = [];
924 foreach ($statuses as $status) {
925 $statusArgs = array_merge($args, ['where' => ['status' => $status]]);
926 $counts[$status] = $this->repository->count($statusArgs);
927 }
928
929 // Get total count without status filter
930 $all = $this->repository->count($args);
931
932 return [
933 'all' => (int) $all,
934 'published' => (int) ($counts['publish'] ?? 0),
935 'draft' => (int) ($counts['draft'] ?? 0),
936 'review' => (int) ($counts['review'] ?? 0),
937 'approved' => (int) ($counts['approved'] ?? 0),
938 'archived' => (int) ($counts['archived'] ?? 0),
939 'trash' => (int) ($counts['trash'] ?? 0),
940 ];
941 }
942
943 /**
944 * Restore a revision (WordPress-style)
945 *
946 * This method works like WordPress's revision restore:
947 * 1. Creates a new revision of the current trip state (before restore)
948 * 2. Updates the trip with the revision data
949 * 3. The update will automatically create another revision (the restored state)
950 *
951 * @param int $tripId Trip ID
952 * @param int $revisionId Revision ID to restore
953 * @return bool Success
954 * @throws \Exception If revision not found or restore fails
955 */
956 public function restoreRevision(int $tripId, int $revisionId): bool
957 {
958 // Verify trip exists
959 $trip = $this->repository->find($tripId);
960 if (!$trip) {
961 throw new \Exception(__('Trip not found', 'yatra'));
962 }
963
964 // Get the revision to restore
965 $revision = $this->revisionRepository->findRevision($revisionId);
966 if (!$revision) {
967 throw new \Exception(__('Revision not found', 'yatra'));
968 }
969
970 // Verify revision belongs to this trip
971 if ((int) $revision->trip_id !== $tripId) {
972 throw new \Exception(__('Revision does not belong to this trip', 'yatra'));
973 }
974
975 // Step 1: Create a revision of the current state (before restore)
976 // This preserves the current state, just like WordPress does
977 $currentUserId = get_current_user_id();
978 $currentTripData = (array) $trip;
979 $currentSerializedData = maybe_serialize($currentTripData);
980
981 // Get latest version and create new revision
982 $latestVersion = $this->revisionRepository->getLatestVersion($tripId);
983 $preRestoreVersion = $latestVersion + 1;
984
985 try {
986 // Create revision of current state with 'inherit' status
987 $this->revisionRepository->createRevision(
988 $tripId,
989 $preRestoreVersion,
990 $currentSerializedData,
991 $currentUserId,
992 'inherit'
993 );
994 } catch (\Exception $e) {
995 // Log but continue - revision creation failure shouldn't block restore
996 if (defined('WP_DEBUG') && WP_DEBUG) {
997 }
998 }
999
1000 // Step 2: Unserialize the revision data
1001 $revisionData = maybe_unserialize($revision->data);
1002 if (!is_array($revisionData)) {
1003 throw new \Exception(__('Invalid revision data', 'yatra'));
1004 }
1005
1006 // Step 3: Prepare data for update (exclude fields that shouldn't be restored)
1007 $restoreData = $revisionData;
1008
1009 // Don't restore these fields (keep current values)
1010 unset($restoreData['id']);
1011 unset($restoreData['created_at']);
1012 unset($restoreData['created_by']);
1013 unset($restoreData['updated_at']);
1014 unset($restoreData['version']); // Will be incremented by processBeforeUpdate
1015
1016 // Set updated_by to current user
1017 $restoreData['updated_by'] = $currentUserId;
1018
1019 // Step 4: Update the trip (this will automatically create a new revision)
1020 // The update will create a revision with 'inherit' status
1021 $result = $this->updateWithRelations($tripId, $restoreData, []);
1022
1023 if ($result) {
1024 // Clean up old revisions after restore
1025 $this->revisionRepository->cleanupRevisions($tripId);
1026 }
1027
1028 return $result;
1029 }
1030
1031 /**
1032 * Get trip with attributes
1033 */
1034 public function getWithAttributes(int $id): ?\stdClass
1035 {
1036 $trip = $this->getById($id);
1037
1038 if ($trip) {
1039 $trip->attributes = $this->attributeService->getTripAttributes($id);
1040 }
1041
1042 return $trip;
1043 }
1044
1045 /**
1046 * Set attribute for a trip
1047 */
1048 public function setAttribute(int $tripId, int $attributeId, $value): bool
1049 {
1050 try {
1051 // Validate trip exists
1052 $trip = $this->getById($tripId);
1053 if (!$trip) {
1054 throw new \InvalidArgumentException('Trip not found');
1055 }
1056
1057 $result = $this->attributeService->setTripAttribute($tripId, $attributeId, $value);
1058
1059 if ($result) {
1060 // Clear trip cache since attributes changed
1061 CacheService::clearTripCache($tripId);
1062
1063 // Log action
1064 Logger::info("Attribute set for trip", [
1065 'trip_id' => $tripId,
1066 'attribute_id' => $attributeId,
1067 'value' => $value
1068 ]);
1069 }
1070
1071 return $result;
1072
1073 } catch (\Exception $e) {
1074 Logger::error("Failed to set trip attribute", [
1075 'trip_id' => $tripId,
1076 'attribute_id' => $attributeId,
1077 'error' => $e->getMessage()
1078 ]);
1079 return false;
1080 }
1081 }
1082
1083 /**
1084 * Remove attribute from a trip
1085 */
1086 public function removeAttribute(int $tripId, int $attributeId): bool
1087 {
1088 try {
1089 // Validate trip exists
1090 $trip = $this->getById($tripId);
1091 if (!$trip) {
1092 throw new \InvalidArgumentException('Trip not found');
1093 }
1094
1095 $result = $this->attributeService->removeTripAttribute($tripId, $attributeId);
1096
1097 if ($result) {
1098 // Clear trip cache since attributes changed
1099 CacheService::clearTripCache($tripId);
1100
1101 // Log action
1102 Logger::info("Attribute removed from trip", [
1103 'trip_id' => $tripId,
1104 'attribute_id' => $attributeId
1105 ]);
1106 }
1107
1108 return $result;
1109
1110 } catch (\Exception $e) {
1111 Logger::error("Failed to remove trip attribute", [
1112 'trip_id' => $tripId,
1113 'attribute_id' => $attributeId,
1114 'error' => $e->getMessage()
1115 ]);
1116 return false;
1117 }
1118 }
1119
1120 /**
1121 * Get all attributes for a trip
1122 */
1123 public function getAttributes(int $tripId): array
1124 {
1125 try {
1126 return $this->attributeService->getTripAttributes($tripId);
1127 } catch (\Exception $e) {
1128 Logger::error("Failed to get trip attributes", [
1129 'trip_id' => $tripId,
1130 'error' => $e->getMessage()
1131 ]);
1132 return [];
1133 }
1134 }
1135
1136 /**
1137 * Update trip attributes (alias for bulkUpdateAttributes)
1138 */
1139 public function updateTripAttributes(int $tripId, array $attributes): bool
1140 {
1141 return $this->bulkUpdateAttributes($tripId, $attributes);
1142 }
1143
1144 /**
1145 * Bulk update trip attributes
1146 */
1147 public function bulkUpdateAttributes(int $tripId, array $attributes): bool
1148 {
1149 try {
1150 // Validate trip exists
1151 $trip = $this->getById($tripId);
1152 if (!$trip) {
1153 throw new \InvalidArgumentException('Trip not found');
1154 }
1155
1156 $result = $this->attributeService->bulkUpdateTripAttributes($tripId, $attributes);
1157 if ($result) {
1158 // Clear trip cache since attributes changed
1159 CacheService::clearTripCache($tripId);
1160
1161 // Log action
1162 Logger::info("Bulk attributes updated for trip", [
1163 'trip_id' => $tripId,
1164 'attribute_count' => count($attributes)
1165 ]);
1166 }
1167
1168 return $result;
1169
1170 } catch (\InvalidArgumentException $e) {
1171 throw $e;
1172 } catch (\Exception $e) {
1173 Logger::error("Failed to bulk update trip attributes", [
1174 'trip_id' => $tripId,
1175 'error' => $e->getMessage()
1176 ]);
1177 return false;
1178 }
1179 }
1180
1181 /**
1182 * Get trips filtered by attribute value
1183 */
1184 public function getByAttributeValue(int $attributeId, string $value): array
1185 {
1186 try {
1187 return $this->attributeService->getTripsByAttributeValue($attributeId, $value);
1188 } catch (\Exception $e) {
1189 Logger::error("Failed to get trips by attribute value", [
1190 'attribute_id' => $attributeId,
1191 'value' => $value,
1192 'error' => $e->getMessage()
1193 ]);
1194 return [];
1195 }
1196 }
1197
1198 /**
1199 * Get available attribute values for filtering
1200 */
1201 public function getAttributeFilterValues(int $attributeId): array
1202 {
1203 try {
1204 return $this->attributeService->getAttributeValues($attributeId);
1205 } catch (\Exception $e) {
1206 Logger::error("Failed to get attribute filter values", [
1207 'attribute_id' => $attributeId,
1208 'error' => $e->getMessage()
1209 ]);
1210 return [];
1211 }
1212 }
1213
1214 /**
1215 * Save trip attributes
1216 */
1217 private function saveTripAttributes(int $tripId, array $attributes): bool
1218 {
1219 (new AttributeRepository())->validatePayloadCoversRequiredAttributes($attributes);
1220
1221 $tripAttributeRepository = new \Yatra\Repositories\TripAttributeRepository();
1222 $result = $tripAttributeRepository->saveTripAttributes($tripId, $attributes);
1223
1224 return $result;
1225 }
1226
1227 /**
1228 * Get trip activities
1229 */
1230 public function getTripActivities(int $tripId): array
1231 {
1232 return $this->repository->getTripActivities($tripId);
1233 }
1234
1235 /**
1236 * Get trip destinations
1237 */
1238 public function getTripDestinations(int $tripId): array
1239 {
1240 return $this->repository->getTripDestinations($tripId);
1241 }
1242
1243 /**
1244 * Get trip attributes
1245 */
1246 public function getTripAttributes(int $tripId): array
1247 {
1248 $tripAttributeRepository = new \Yatra\Repositories\TripAttributeRepository();
1249 return $tripAttributeRepository->getTripAttributes($tripId);
1250 }
1251
1252 /**
1253 * Get trip categories
1254 */
1255 public function getTripCategories(int $tripId): array
1256 {
1257 return $this->repository->getTripCategories($tripId);
1258 }
1259
1260 /**
1261 * Count departures by date
1262 */
1263 public function countDeparturesByDate(int $tripId, string $date): int
1264 {
1265 // The December 2025 service/controller refactor moved this query to
1266 // TripAvailabilityRepository::countAvailableDeparturesByDate() but left
1267 // this call pointing at TripRepository, where no such method exists —
1268 // so the storefront's date-pricing request (fired whenever a customer
1269 // picks a date) has returned a fatal 500 ever since.
1270 return $this->availabilityRepository->countAvailableDeparturesByDate($tripId, $date);
1271 }
1272
1273 /**
1274 * Get trip with availability
1275 */
1276 public function getTripWithAvailability(int $tripId): ?\stdClass
1277 {
1278 return $this->repository->getTripWithAvailability($tripId);
1279 }
1280
1281 /**
1282 * Get price range for a trip
1283 *
1284 * @param int $tripId Trip ID
1285 * @return array ['min_price' => float, 'max_price' => float]
1286 */
1287 public function getTripPriceRange(int $tripId): array
1288 {
1289 // Traveler-based pricing stores per-category prices in `trips.price_types` JSON.
1290 // The old implementation referenced a repository that may not exist in all builds;
1291 // compute the range directly from the persisted JSON so list endpoints can't fatal.
1292 $tripRepository = new \Yatra\Repositories\TripRepository();
1293 $priceTypes = $tripRepository->getPriceTypes($tripId);
1294
1295 $min = PHP_FLOAT_MAX;
1296 $max = 0.0;
1297
1298 foreach ($priceTypes as $pt) {
1299 if (!is_array($pt)) {
1300 continue;
1301 }
1302
1303 $discounted = isset($pt['discounted_price']) ? (float) $pt['discounted_price'] : 0.0;
1304 $sale = isset($pt['sale_price']) ? (float) $pt['sale_price'] : 0.0;
1305 $original = isset($pt['original_price']) ? (float) $pt['original_price'] : 0.0;
1306 $legacyPrice = isset($pt['price']) ? (float) $pt['price'] : 0.0;
1307
1308 $effective = 0.0;
1309 if ($discounted > 0) {
1310 $effective = $discounted;
1311 } elseif ($sale > 0) {
1312 $effective = $sale;
1313 } elseif ($original > 0) {
1314 $effective = $original;
1315 } elseif ($legacyPrice > 0) {
1316 $effective = $legacyPrice;
1317 }
1318
1319 if ($effective <= 0) {
1320 continue;
1321 }
1322
1323 $min = min($min, $effective);
1324 $max = max($max, $effective);
1325 }
1326
1327 if ($min === PHP_FLOAT_MAX) {
1328 return ['min_price' => 0.0, 'max_price' => 0.0];
1329 }
1330
1331 return ['min_price' => (float) $min, 'max_price' => (float) $max];
1332 }
1333 }
1334