PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.8
Yatra – Travel Booking & Tour Operator Software v3.0.2.8
3.0.16 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 All 84 releases
yatra / app / Services / TripService.php

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

1,259 lines 41.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('/^[a-z0-9-]+$/', $data['slug'])) {
197 throw new \InvalidArgumentException('Trip slug can only contain lowercase 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('/^[a-z0-9-]+$/', $data['slug'])) {
280 throw new \InvalidArgumentException('Trip slug can only contain lowercase 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 // Process JSON fields
361 $data = $this->processJsonFields($data);
362
363 // Ensure difficulty_level is stored as integer ID
364 if (isset($data['difficulty_level'])) {
365 $data['difficulty_level'] = is_numeric($data['difficulty_level']) ? (int) $data['difficulty_level'] : null;
366 }
367
368 return $data;
369 }
370
371 /**
372 * Process JSON fields - serialize arrays
373 */
374 private function processJsonFields(array $data): array
375 {
376 // Note: highlights, gallery_images, faqs, price_types, itinerary_days, availability_dates
377 // are stored in separate tables, not as JSON columns
378 $jsonFields = [
379 'testimonials',
380 'countries',
381 'regions',
382 'landmarks',
383 'tags',
384 'included_items',
385 'excluded_items',
386 'frontend_tabs',
387 'blackout_dates',
388 'custom_fields',
389 'pricing_rules',
390 'booking_rules',
391 ];
392
393 foreach ($jsonFields as $field) {
394 if (isset($data[$field]) && is_array($data[$field])) {
395 $data[$field] = maybe_serialize($data[$field]);
396 }
397 }
398
399 return $data;
400 }
401
402 /**
403 * Generate unique slug from title
404 */
405 private function generateSlug(string $title): string
406 {
407 $slug = sanitize_title($title);
408 $baseSlug = $slug;
409 $counter = 1;
410
411 // Ensure uniqueness
412 while ($this->repository->findBySlug($slug)) {
413 $slug = $baseSlug . '-' . $counter;
414 $counter++;
415 }
416
417 return $slug;
418 }
419
420 /**
421 * Create trip with relationships
422 */
423 public function createWithRelations(array $data, array $relationships = []): int
424 {
425 // Extract relationship fields from data if not already in relationships array
426 // (these should not be in the main table)
427 $relationshipFields = [
428 'highlights',
429 'gallery_images',
430 'faqs',
431 'itinerary_days',
432 'availability_dates',
433 'trip_category',
434 'price_types', // Include price_types in relationship fields
435 'downloadable_items', // Managed in separate yatra_trip_downloads table
436 'attributes',
437 'activities', // Add activities to relationship fields
438 'destinations', // Add destinations to relationship fields
439 ];
440
441 foreach ($relationshipFields as $field) {
442 if (!isset($relationships[$field])) {
443 // Handle field name mapping for activities
444 $dataField = $field === 'activities' ? 'activity_types' : $field;
445 if (isset($data[$dataField])) {
446 $relationships[$field] = $data[$dataField];
447 }
448 }
449 }
450
451 // For validation, we need price_types in data temporarily
452 $priceTypesForValidation = $relationships['price_types'] ?? $data['price_types'] ?? [];
453 $data['price_types'] = $priceTypesForValidation;
454
455 $this->validate($data);
456
457 // Sanitize data using TripValidator
458 $tripValidator = new \Yatra\Validators\TripValidator();
459 $data = $tripValidator->sanitize($data, null);
460
461 // Remove relationship fields from data before processing
462 foreach ($relationshipFields as $field) {
463 // Don't remove featured_priority - it's a direct database field, not a relationship
464 if ($field !== 'featured_priority') {
465 unset($data[$field]);
466 }
467 }
468
469 $data = $this->processBeforeCreate($data);
470
471 $attrPayload = $relationships['attributes'] ?? [];
472 if (!is_array($attrPayload)) {
473 $attrPayload = [];
474 }
475 (new AttributeRepository())->validatePayloadCoversRequiredAttributes($attrPayload);
476
477 return $this->repository->createWithRelations($data, $relationships);
478 }
479
480 /**
481 * Update trip with relationships
482 */
483 public function updateWithRelations(int $id, array $data, array $relationships = []): bool
484 {
485
486 // Extract relationship fields from data if not already in relationships array
487 // (these should not be in the main table)
488 $relationshipFields = [
489 'highlights',
490 'gallery_images',
491 'faqs',
492 'itinerary_days',
493 'availability_dates',
494 'trip_category',
495 'price_types', // Include price_types in relationship fields
496 'downloadable_items', // Managed in separate yatra_trip_downloads table
497 'attributes', // Add attributes to relationship fields
498 'activities', // Add activities to relationship fields
499 'destinations', // Add destinations to relationship fields
500 ];
501
502 foreach ($relationshipFields as $field) {
503 if (!isset($relationships[$field])) {
504 // Handle field name mapping for activities
505 $dataField = $field === 'activities' ? 'activity_types' : $field;
506 if (isset($data[$dataField])) {
507 $relationships[$field] = $data[$dataField];
508 }
509 }
510 }
511
512
513 $data = $this->processBeforeUpdate($id, $data);
514
515 $attributesRelationship = $relationships['attributes'] ?? null;
516
517 unset($relationships['attributes']);
518
519 $result = $this->repository->updateWithRelations($id, $data, $relationships);
520
521 if ($result && is_array($attributesRelationship)) {
522 $this->saveTripAttributes($id, $attributesRelationship);
523 } else {
524 }
525
526 // Always sync pricing_type to availability dates and recurring rules when saving
527 if (isset($data['pricing_type'])) {
528 $this->syncPricingTypeToAvailability($id, $data['pricing_type']);
529 }
530
531 return $result;
532 }
533
534 /**
535 * Sync pricing type to all availability dates and recurring rules for a trip
536 * This ensures availability/rules always match the trip's pricing type
537 *
538 * @param int $tripId Trip ID
539 * @param string $pricingType The pricing type to sync ('regular' or 'traveler_based')
540 */
541 public function syncPricingTypeToAvailability(int $tripId, string $pricingType): void
542 {
543 global $wpdb;
544
545 // Update availability dates
546 $availabilityRepository = new \Yatra\Repositories\AvailabilityRepository();
547 $updated_dates = $availabilityRepository->updatePricingTypeByTripId($tripId, $pricingType);
548 // Note: Recurring rules table doesn't have pricing_type column
549 // Pricing type is derived from the trip, not stored in rules
550 // If changing to regular pricing, clear traveler_pricing and price_types
551 if ($pricingType === 'regular') {
552 // Clear price_types from availability dates
553 $cleared_dates = $availabilityRepository->clearPriceTypesByTripId($tripId);
554 // Clear traveler_pricing from availability dates
555 $cleared_pricing = $availabilityRepository->clearTravelerPricingByTripId($tripId);
556 // Note: Recurring rules don't have pricing_type column
557 // Pricing is derived from the trip, not stored in rules
558 }
559
560 }
561
562 /**
563 * Get trip with relationships
564 */
565 public function getWithRelations(int $id, bool $includeDeleted = false): ?\stdClass
566 {
567 $trip = $this->repository->findWithRelations($id, $includeDeleted);
568
569 if ($trip) {
570 $trip->attributes = $this->attributeService->getTripAttributes($id);
571 }
572
573 return $trip;
574 }
575
576 /**
577 * Duplicate a trip with all relationships
578 *
579 * Creates a new draft trip based on an existing one, copying
580 * core fields and relationships (destinations, activities,
581 * categories, pricing, highlights, gallery, FAQs, itinerary,
582 * availability dates). Slug will be regenerated to ensure
583 * uniqueness and status is always set to draft.
584 *
585 * @param int $id Existing trip ID
586 * @return int New trip ID
587 * @throws \Exception If source trip not found
588 */
589 public function duplicate(int $id): int
590 {
591 $source = $this->getWithRelations($id);
592 if (!$source) {
593 throw new \Exception(__('Trip not found', 'yatra'));
594 }
595
596 $data = (array) $source;
597
598 // Adjust core fields
599 $data['title'] = ($data['title'] ?? '') . ' (Copy)';
600
601 // Generate new unique slug based on existing slug with numeric suffix (-1, -2, ...)
602 $baseSlug = !empty($source->slug) ? sanitize_title((string) $source->slug) : $this->generateSlug($data['title'] ?? 'trip-copy');
603 $suffix = 1;
604 $newSlug = $baseSlug . '-' . $suffix;
605 while ($this->repository->findBySlug($newSlug)) {
606 $suffix++;
607 $newSlug = $baseSlug . '-' . $suffix;
608 }
609 $data['slug'] = $newSlug;
610
611 // Always create as draft copy
612 $data['status'] = 'draft';
613
614 // Ensure created_by is current user; clear technical fields
615 $data['created_by'] = get_current_user_id();
616 unset(
617 $data['id'],
618 $data['created_at'],
619 $data['updated_at'],
620 $data['version'],
621 $data['published_at']
622 );
623
624 // Extract relationships from the hydrated source object in the shapes
625 // expected by TripRepository::createWithRelations (scalar IDs / simple arrays)
626 $relationships = [];
627
628 // Destinations: array of destination IDs
629 $destinations = [];
630 if (!empty($source->destinations) && is_array($source->destinations)) {
631 foreach ($source->destinations as $dest) {
632 $id = (int) ($dest->destination_id ?? $dest->id ?? 0);
633 if ($id > 0 && !in_array($id, $destinations, true)) {
634 $destinations[] = $id;
635 }
636 }
637 }
638 $relationships['destinations'] = $destinations;
639
640 // Activities: array of activity IDs
641 $activities = [];
642 if (!empty($source->activities) && is_array($source->activities)) {
643 foreach ($source->activities as $act) {
644 $id = (int) ($act->activity_id ?? $act->id ?? 0);
645 if ($id > 0 && !in_array($id, $activities, true)) {
646 $activities[] = $id;
647 }
648 }
649 }
650 $relationships['activities'] = $activities;
651
652 // Trip categories: array of category IDs
653 $categories = [];
654 if (!empty($source->trip_category) && is_array($source->trip_category)) {
655 foreach ($source->trip_category as $cat) {
656 $id = (int) ($cat->category_id ?? $cat->id ?? 0);
657 if ($id > 0 && !in_array($id, $categories, true)) {
658 $categories[] = $id;
659 }
660 }
661 }
662 $relationships['trip_category'] = $categories;
663
664 // Price types: normalize to simple associative arrays
665 $priceTypes = [];
666 if (!empty($source->price_types) && is_array($source->price_types)) {
667 foreach ($source->price_types as $pt) {
668 $categoryId = (int) ($pt->category_id ?? 0);
669 if ($categoryId <= 0) {
670 continue;
671 }
672 $item = [
673 'category_id' => $categoryId,
674 'original_price' => (float) ($pt->original_price ?? 0),
675 ];
676 if (isset($pt->discounted_price)) {
677 $item['discounted_price'] = (float) $pt->discounted_price;
678 }
679 $priceTypes[] = $item;
680 }
681 }
682 $relationships['price_types'] = $priceTypes;
683
684 // Highlights: normalize stdClass rows from DB into arrays/strings expected by saveHighlights
685 $highlights = [];
686 $rawHighlights = $data['highlights'] ?? ($source->highlights ?? []);
687 if (!empty($rawHighlights) && is_array($rawHighlights)) {
688 foreach ($rawHighlights as $highlight) {
689 // Already a simple string
690 if (is_string($highlight)) {
691 $highlights[] = $highlight;
692 continue;
693 }
694
695 // Already an array in the expected shape
696 if (is_array($highlight)) {
697 $highlights[] = $highlight;
698 continue;
699 }
700
701 // Convert stdClass from yatra_trip_highlights table into array
702 if (is_object($highlight)) {
703 $text = $highlight->text ?? $highlight->highlight_text ?? '';
704 if ($text === '') {
705 continue;
706 }
707
708 $highlights[] = [
709 'text' => $text,
710 'icon' => $highlight->icon ?? $highlight->highlight_icon ?? null,
711 'image_id' => isset($highlight->image_id)
712 ? (int) $highlight->image_id
713 : (isset($highlight->highlight_image_id) ? (int) $highlight->highlight_image_id : 0),
714 'is_featured' => isset($highlight->is_featured) ? (int) $highlight->is_featured : 0,
715 ];
716 }
717 }
718 }
719 $relationships['highlights'] = $highlights;
720
721 // Gallery images: normalize stdClass rows from DB into arrays/strings expected by saveGalleryImages
722 $galleryImages = [];
723 $rawGalleryImages = $data['gallery_images'] ?? ($source->gallery_images ?? []);
724 if (!empty($rawGalleryImages) && is_array($rawGalleryImages)) {
725 foreach ($rawGalleryImages as $image) {
726 // Already a simple URL string
727 if (is_string($image)) {
728 $galleryImages[] = $image;
729 continue;
730 }
731
732 // Already an array in the expected shape
733 if (is_array($image)) {
734 $galleryImages[] = $image;
735 continue;
736 }
737
738 // Convert stdClass from yatra_trip_gallery_images into array
739 if (is_object($image)) {
740 $url = $image->url ?? $image->image_url ?? '';
741 if ($url === '') {
742 continue;
743 }
744
745 $galleryImages[] = [
746 'url' => $url,
747 'id' => isset($image->image_id) ? (int) $image->image_id : 0,
748 'thumbnail_url' => $image->thumbnail_url ?? null,
749 'alt_text' => $image->alt_text ?? null,
750 'caption' => $image->caption ?? null,
751 'is_featured' => isset($image->is_featured) ? (int) $image->is_featured : 0,
752 ];
753 }
754 }
755 }
756 $relationships['gallery_images'] = $galleryImages;
757
758 // Other relationships can be copied as-is; repository helpers can handle them
759 $relationships['faqs'] = $data['faqs'] ?? ($source->faqs ?? []);
760 $relationships['itinerary_days'] = $data['itinerary_days'] ?? ($source->itinerary_days ?? []);
761 $relationships['availability_dates'] = $data['availability_dates'] ?? ($source->availability_dates ?? []);
762
763 // Remove relationship keys from main data to avoid column issues
764 unset(
765 $data['destinations'],
766 $data['activities'],
767 $data['trip_category'],
768 $data['price_types'],
769 $data['highlights'],
770 $data['gallery_images'],
771 $data['faqs'],
772 $data['itinerary_days'],
773 $data['availability_dates']
774 );
775
776 return $this->createWithRelations($data, $relationships);
777 }
778
779 /**
780 * Soft delete trip
781 */
782 public function softDelete(int $id): bool
783 {
784 $userId = get_current_user_id();
785 return $this->repository->softDelete($id, $userId);
786 }
787
788 /**
789 * Restore trip
790 */
791 public function restore(int $id): bool
792 {
793 return $this->repository->restore($id);
794 }
795
796 /**
797 * Permanently remove a trip (hard delete). Used from trash / permanent-delete REST.
798 */
799 public function permanentDelete(int $id): bool
800 {
801 if ($this->repository->find($id, true) === null) {
802 return false;
803 }
804
805 return $this->delete($id);
806 }
807
808 /**
809 * Publish trip
810 */
811 public function publish(int $id): bool
812 {
813 $data = [
814 'status' => 'publish',
815 'published_at' => current_time('mysql'),
816 ];
817
818 return $this->repository->update($id, $data);
819 }
820
821 /**
822 * Get active trips
823 */
824 public function getActiveTrips(array $args = []): array
825 {
826 return $this->repository->getActive($args);
827 }
828
829 /**
830 * Map trip_id => bookings count for list views.
831 *
832 * @param int[] $tripIds
833 * @param string[]|null $excludeStatuses
834 * @return array<int,int>
835 */
836 public function getBookingsCountMap(array $tripIds, ?array $excludeStatuses = null): array
837 {
838 return $this->repository->getBookingsCountMap($tripIds, $excludeStatuses);
839 }
840
841 /**
842 * Search trips
843 */
844 public function search(string $keyword, array $args = []): array
845 {
846 return $this->repository->search($keyword, $args);
847 }
848
849 /**
850 * Admin trip list/grid: read rows directly from the repository (bypasses BaseService::getAll() list cache).
851 * Count queries do not use that cache; a stale cached empty list made /trips/stats correct while the grid stayed empty.
852 */
853 public function getAllForAdminList(array $args = []): array
854 {
855 return $this->repository->all($args);
856 }
857
858 /**
859 * Count items
860 */
861 public function count(array $args = []): int
862 {
863 return $this->repository->count($args);
864 }
865
866 /**
867 * Count by status
868 */
869 public function countByStatus(string $status): int
870 {
871 return $this->repository->countByStatus($status);
872 }
873
874 /**
875 * Get status counts for admin list views
876 *
877 * Returns a stable set of counts that do not change with filters
878 * so that the UI can show consistent "All / Published / Draft / ..." tabs.
879 */
880 public function getStatusCounts(): array
881 {
882 // For admin stats, include all trips regardless of soft delete status
883 $args = ['include_deleted' => true];
884
885 $statuses = [
886 'publish',
887 'draft',
888 'review',
889 'approved',
890 'archived',
891 'trash',
892 ];
893
894 $counts = [];
895 foreach ($statuses as $status) {
896 $statusArgs = array_merge($args, ['where' => ['status' => $status]]);
897 $counts[$status] = $this->repository->count($statusArgs);
898 }
899
900 // Get total count without status filter
901 $all = $this->repository->count($args);
902
903 return [
904 'all' => (int) $all,
905 'published' => (int) ($counts['publish'] ?? 0),
906 'draft' => (int) ($counts['draft'] ?? 0),
907 'review' => (int) ($counts['review'] ?? 0),
908 'approved' => (int) ($counts['approved'] ?? 0),
909 'archived' => (int) ($counts['archived'] ?? 0),
910 'trash' => (int) ($counts['trash'] ?? 0),
911 ];
912 }
913
914 /**
915 * Restore a revision (WordPress-style)
916 *
917 * This method works like WordPress's revision restore:
918 * 1. Creates a new revision of the current trip state (before restore)
919 * 2. Updates the trip with the revision data
920 * 3. The update will automatically create another revision (the restored state)
921 *
922 * @param int $tripId Trip ID
923 * @param int $revisionId Revision ID to restore
924 * @return bool Success
925 * @throws \Exception If revision not found or restore fails
926 */
927 public function restoreRevision(int $tripId, int $revisionId): bool
928 {
929 // Verify trip exists
930 $trip = $this->repository->find($tripId);
931 if (!$trip) {
932 throw new \Exception(__('Trip not found', 'yatra'));
933 }
934
935 // Get the revision to restore
936 $revision = $this->revisionRepository->findRevision($revisionId);
937 if (!$revision) {
938 throw new \Exception(__('Revision not found', 'yatra'));
939 }
940
941 // Verify revision belongs to this trip
942 if ((int) $revision->trip_id !== $tripId) {
943 throw new \Exception(__('Revision does not belong to this trip', 'yatra'));
944 }
945
946 // Step 1: Create a revision of the current state (before restore)
947 // This preserves the current state, just like WordPress does
948 $currentUserId = get_current_user_id();
949 $currentTripData = (array) $trip;
950 $currentSerializedData = maybe_serialize($currentTripData);
951
952 // Get latest version and create new revision
953 $latestVersion = $this->revisionRepository->getLatestVersion($tripId);
954 $preRestoreVersion = $latestVersion + 1;
955
956 try {
957 // Create revision of current state with 'inherit' status
958 $this->revisionRepository->createRevision(
959 $tripId,
960 $preRestoreVersion,
961 $currentSerializedData,
962 $currentUserId,
963 'inherit'
964 );
965 } catch (\Exception $e) {
966 // Log but continue - revision creation failure shouldn't block restore
967 if (defined('WP_DEBUG') && WP_DEBUG) {
968 }
969 }
970
971 // Step 2: Unserialize the revision data
972 $revisionData = maybe_unserialize($revision->data);
973 if (!is_array($revisionData)) {
974 throw new \Exception(__('Invalid revision data', 'yatra'));
975 }
976
977 // Step 3: Prepare data for update (exclude fields that shouldn't be restored)
978 $restoreData = $revisionData;
979
980 // Don't restore these fields (keep current values)
981 unset($restoreData['id']);
982 unset($restoreData['created_at']);
983 unset($restoreData['created_by']);
984 unset($restoreData['updated_at']);
985 unset($restoreData['version']); // Will be incremented by processBeforeUpdate
986
987 // Set updated_by to current user
988 $restoreData['updated_by'] = $currentUserId;
989
990 // Step 4: Update the trip (this will automatically create a new revision)
991 // The update will create a revision with 'inherit' status
992 $result = $this->updateWithRelations($tripId, $restoreData, []);
993
994 if ($result) {
995 // Clean up old revisions after restore
996 $this->revisionRepository->cleanupRevisions($tripId);
997 }
998
999 return $result;
1000 }
1001
1002 /**
1003 * Get trip with attributes
1004 */
1005 public function getWithAttributes(int $id): ?\stdClass
1006 {
1007 $trip = $this->getById($id);
1008
1009 if ($trip) {
1010 $trip->attributes = $this->attributeService->getTripAttributes($id);
1011 }
1012
1013 return $trip;
1014 }
1015
1016 /**
1017 * Set attribute for a trip
1018 */
1019 public function setAttribute(int $tripId, int $attributeId, $value): bool
1020 {
1021 try {
1022 // Validate trip exists
1023 $trip = $this->getById($tripId);
1024 if (!$trip) {
1025 throw new \InvalidArgumentException('Trip not found');
1026 }
1027
1028 $result = $this->attributeService->setTripAttribute($tripId, $attributeId, $value);
1029
1030 if ($result) {
1031 // Clear trip cache since attributes changed
1032 CacheService::clearTripCache($tripId);
1033
1034 // Log action
1035 Logger::info("Attribute set for trip", [
1036 'trip_id' => $tripId,
1037 'attribute_id' => $attributeId,
1038 'value' => $value
1039 ]);
1040 }
1041
1042 return $result;
1043
1044 } catch (\Exception $e) {
1045 Logger::error("Failed to set trip attribute", [
1046 'trip_id' => $tripId,
1047 'attribute_id' => $attributeId,
1048 'error' => $e->getMessage()
1049 ]);
1050 return false;
1051 }
1052 }
1053
1054 /**
1055 * Remove attribute from a trip
1056 */
1057 public function removeAttribute(int $tripId, int $attributeId): bool
1058 {
1059 try {
1060 // Validate trip exists
1061 $trip = $this->getById($tripId);
1062 if (!$trip) {
1063 throw new \InvalidArgumentException('Trip not found');
1064 }
1065
1066 $result = $this->attributeService->removeTripAttribute($tripId, $attributeId);
1067
1068 if ($result) {
1069 // Clear trip cache since attributes changed
1070 CacheService::clearTripCache($tripId);
1071
1072 // Log action
1073 Logger::info("Attribute removed from trip", [
1074 'trip_id' => $tripId,
1075 'attribute_id' => $attributeId
1076 ]);
1077 }
1078
1079 return $result;
1080
1081 } catch (\Exception $e) {
1082 Logger::error("Failed to remove trip attribute", [
1083 'trip_id' => $tripId,
1084 'attribute_id' => $attributeId,
1085 'error' => $e->getMessage()
1086 ]);
1087 return false;
1088 }
1089 }
1090
1091 /**
1092 * Get all attributes for a trip
1093 */
1094 public function getAttributes(int $tripId): array
1095 {
1096 try {
1097 return $this->attributeService->getTripAttributes($tripId);
1098 } catch (\Exception $e) {
1099 Logger::error("Failed to get trip attributes", [
1100 'trip_id' => $tripId,
1101 'error' => $e->getMessage()
1102 ]);
1103 return [];
1104 }
1105 }
1106
1107 /**
1108 * Update trip attributes (alias for bulkUpdateAttributes)
1109 */
1110 public function updateTripAttributes(int $tripId, array $attributes): bool
1111 {
1112 return $this->bulkUpdateAttributes($tripId, $attributes);
1113 }
1114
1115 /**
1116 * Bulk update trip attributes
1117 */
1118 public function bulkUpdateAttributes(int $tripId, array $attributes): bool
1119 {
1120 try {
1121 // Validate trip exists
1122 $trip = $this->getById($tripId);
1123 if (!$trip) {
1124 throw new \InvalidArgumentException('Trip not found');
1125 }
1126
1127 $result = $this->attributeService->bulkUpdateTripAttributes($tripId, $attributes);
1128 if ($result) {
1129 // Clear trip cache since attributes changed
1130 CacheService::clearTripCache($tripId);
1131
1132 // Log action
1133 Logger::info("Bulk attributes updated for trip", [
1134 'trip_id' => $tripId,
1135 'attribute_count' => count($attributes)
1136 ]);
1137 }
1138
1139 return $result;
1140
1141 } catch (\InvalidArgumentException $e) {
1142 throw $e;
1143 } catch (\Exception $e) {
1144 Logger::error("Failed to bulk update trip attributes", [
1145 'trip_id' => $tripId,
1146 'error' => $e->getMessage()
1147 ]);
1148 return false;
1149 }
1150 }
1151
1152 /**
1153 * Get trips filtered by attribute value
1154 */
1155 public function getByAttributeValue(int $attributeId, string $value): array
1156 {
1157 try {
1158 return $this->attributeService->getTripsByAttributeValue($attributeId, $value);
1159 } catch (\Exception $e) {
1160 Logger::error("Failed to get trips by attribute value", [
1161 'attribute_id' => $attributeId,
1162 'value' => $value,
1163 'error' => $e->getMessage()
1164 ]);
1165 return [];
1166 }
1167 }
1168
1169 /**
1170 * Get available attribute values for filtering
1171 */
1172 public function getAttributeFilterValues(int $attributeId): array
1173 {
1174 try {
1175 return $this->attributeService->getAttributeValues($attributeId);
1176 } catch (\Exception $e) {
1177 Logger::error("Failed to get attribute filter values", [
1178 'attribute_id' => $attributeId,
1179 'error' => $e->getMessage()
1180 ]);
1181 return [];
1182 }
1183 }
1184
1185 /**
1186 * Save trip attributes
1187 */
1188 private function saveTripAttributes(int $tripId, array $attributes): bool
1189 {
1190 (new AttributeRepository())->validatePayloadCoversRequiredAttributes($attributes);
1191
1192 $tripAttributeRepository = new \Yatra\Repositories\TripAttributeRepository();
1193 $result = $tripAttributeRepository->saveTripAttributes($tripId, $attributes);
1194
1195 return $result;
1196 }
1197
1198 /**
1199 * Get trip activities
1200 */
1201 public function getTripActivities(int $tripId): array
1202 {
1203 return $this->repository->getTripActivities($tripId);
1204 }
1205
1206 /**
1207 * Get trip destinations
1208 */
1209 public function getTripDestinations(int $tripId): array
1210 {
1211 return $this->repository->getTripDestinations($tripId);
1212 }
1213
1214 /**
1215 * Get trip attributes
1216 */
1217 public function getTripAttributes(int $tripId): array
1218 {
1219 $tripAttributeRepository = new \Yatra\Repositories\TripAttributeRepository();
1220 return $tripAttributeRepository->getTripAttributes($tripId);
1221 }
1222
1223 /**
1224 * Get trip categories
1225 */
1226 public function getTripCategories(int $tripId): array
1227 {
1228 return $this->repository->getTripCategories($tripId);
1229 }
1230
1231 /**
1232 * Count departures by date
1233 */
1234 public function countDeparturesByDate(int $tripId, string $date): int
1235 {
1236 return $this->repository->countDeparturesByDate($tripId, $date);
1237 }
1238
1239 /**
1240 * Get trip with availability
1241 */
1242 public function getTripWithAvailability(int $tripId): ?\stdClass
1243 {
1244 return $this->repository->getTripWithAvailability($tripId);
1245 }
1246
1247 /**
1248 * Get price range for a trip
1249 *
1250 * @param int $tripId Trip ID
1251 * @return array ['min_price' => float, 'max_price' => float]
1252 */
1253 public function getTripPriceRange(int $tripId): array
1254 {
1255 $tripPriceTypeRepository = new \Yatra\Repositories\TripPriceTypeRepository();
1256 return $tripPriceTypeRepository->getPriceRangeByTripId($tripId);
1257 }
1258 }
1259