PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.2
Yatra – Travel Booking & Tour Operator Software v3.0.14.2
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 / DepartureService.php

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

851 lines 32.2 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\DepartureRepository;
8 use Yatra\Repositories\BookingDepartureRepository;
9 use Yatra\Repositories\BookingRepository;
10 use Yatra\Repositories\TripRepository;
11 use Yatra\Models\Departure;
12 use Yatra\Services\CapacityService;
13
14 /**
15 * Departure Service
16 * Handles business logic for trip departures
17 */
18 class DepartureService
19 {
20 private DepartureRepository $repository;
21 private BookingDepartureRepository $bookingDepartureRepository;
22 private BookingRepository $bookingRepository;
23 private TripRepository $tripRepository;
24 private CapacityService $capacityService;
25
26 public function __construct(
27 DepartureRepository $repository,
28 ?BookingDepartureRepository $bookingDepartureRepository = null,
29 ?BookingRepository $bookingRepository = null,
30 ?TripRepository $tripRepository = null,
31 ?CapacityService $capacityService = null
32 ) {
33 $this->repository = $repository;
34 $this->bookingDepartureRepository = $bookingDepartureRepository ?? new BookingDepartureRepository();
35 $this->bookingRepository = $bookingRepository ?? new BookingRepository();
36 $this->tripRepository = $tripRepository ?? new TripRepository();
37 $this->capacityService = $capacityService ?? new CapacityService();
38 }
39
40 /**
41 * Create a departure (for admin editing - departures are normally auto-created)
42 */
43 public function create(array $data): int
44 {
45 // Validate required fields
46 if (empty($data['trip_id'])) {
47 throw new \InvalidArgumentException('Trip ID is required');
48 }
49
50 // Support both old 'date' and new 'start_date' format
51 $startDate = $data['start_date'] ?? $data['date'] ?? '';
52 if (empty($startDate)) {
53 throw new \InvalidArgumentException('Start date is required');
54 }
55
56 // Calculate capacity based on the date if not provided
57 if (empty($data['max_capacity'])) {
58 $data['max_capacity'] = $this->capacityService->getCapacityForDate(
59 (int) $data['trip_id'],
60 $startDate,
61 $data['time'] ?? null
62 );
63
64 // If still no capacity, throw an error
65 if ($data['max_capacity'] <= 0) {
66 throw new \InvalidArgumentException('No valid capacity found for the selected date. Please check availability settings.');
67 }
68 } elseif ((int) $data['max_capacity'] < 1) {
69 throw new \InvalidArgumentException('Max capacity must be at least 1');
70 }
71
72 // Validate date format
73 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
74 throw new \InvalidArgumentException('Invalid date format. Use YYYY-MM-DD');
75 }
76
77 // Calculate end_date if not provided
78 if (empty($data['end_date'])) {
79 $trip = $this->tripRepository->find((int) $data['trip_id']);
80 $durationDays = $trip ? ($trip->duration_days ?? 1) : 1;
81 $data['end_date'] = date('Y-m-d', strtotime($startDate . ' + ' . ($durationDays - 1) . ' days'));
82 }
83
84 $data['start_date'] = $startDate;
85 $data['date'] = $startDate; // Keep for backward compatibility
86
87 // Check if departure already exists for this trip and start_date
88 $existing = $this->repository->findByTripIdAndStartDate(
89 (int) $data['trip_id'],
90 $startDate,
91 $data['time'] ?? null
92 );
93
94 if ($existing) {
95 throw new \InvalidArgumentException('A departure already exists for this trip and date');
96 }
97
98 // Set defaults
99 $data['source'] = $data['source'] ?? 'manual'; // Admin-created departures are 'manual'
100 $data['booked_count'] = $data['booked_count'] ?? 0;
101
102 // Create departure
103 $id = $this->repository->create($data);
104
105 // Trigger hook to sync capacity from availability
106 do_action('yatra_departure_saved', $id);
107
108 // Recalculate status
109 $departure = $this->repository->findModel($id);
110 if ($departure) {
111 $this->repository->update($id, ['status' => $departure->calculateStatus()]);
112 }
113
114 return $id;
115 }
116
117 /**
118 * Update a departure (for admin editing)
119 */
120 public function update(int $id, array $data): bool
121 {
122 $departure = $this->repository->findModel($id);
123
124 if (!$departure) {
125 throw new \InvalidArgumentException('Departure not found');
126 }
127
128 // Handle start_date and end_date
129 $startDate = $data['start_date'] ?? $data['date'] ?? null;
130
131 // Validate date format if provided
132 if ($startDate && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
133 throw new \InvalidArgumentException('Invalid date format. Use YYYY-MM-DD');
134 }
135
136 // Calculate end_date if start_date changed but end_date not provided
137 if ($startDate && $startDate !== ($departure->start_date ?: $departure->date)) {
138 if (empty($data['end_date'])) {
139 $trip = $this->tripRepository->find($departure->trip_id);
140 $durationDays = $trip ? ($trip->duration_days ?? 1) : 1;
141 $data['end_date'] = date('Y-m-d', strtotime($startDate . ' + ' . ($durationDays - 1) . ' days'));
142 }
143 $data['start_date'] = $startDate;
144 $data['date'] = $startDate; // Keep in sync
145 }
146
147 // Check for duplicate if start_date is being changed
148 if ($startDate && $startDate !== ($departure->start_date ?: $departure->date)) {
149 $existing = $this->repository->findByTripIdAndStartDate(
150 $departure->trip_id,
151 $startDate,
152 $data['time'] ?? $departure->time
153 );
154
155 if ($existing && $existing->id !== $id) {
156 throw new \InvalidArgumentException('A departure already exists for this trip and date');
157 }
158 }
159
160 // Detect a tour (trip) reassignment: the operator is moving this
161 // departure from one trip to another. The DB layer already persists
162 // trip_id, but the move must be validated and kept consistent:
163 // 1. the target trip must exist,
164 // 2. it must not collide with an existing departure on the target
165 // trip for the same date/time, and
166 // 3. the departure's bookings must move to the new trip too, so a
167 // booking never ends up pointing at a departure that belongs to a
168 // different trip (the invariant the booking edit path also keeps).
169 $newTripId = isset($data['trip_id']) ? (int) $data['trip_id'] : 0;
170 $tripReassigned = $newTripId > 0 && $newTripId !== (int) $departure->trip_id;
171
172 if ($tripReassigned) {
173 $targetTrip = $this->tripRepository->find($newTripId);
174 if (!$targetTrip) {
175 throw new \InvalidArgumentException('Target tour not found');
176 }
177
178 $checkDate = $data['start_date'] ?? $data['date'] ?? ($departure->start_date ?: $departure->date);
179 $checkTime = $data['time'] ?? $departure->time;
180 $existing = $this->repository->findByTripIdAndStartDate($newTripId, (string) $checkDate, $checkTime);
181 if ($existing && (int) $existing->id !== $id) {
182 throw new \InvalidArgumentException(
183 'A departure already exists for the selected tour on this date'
184 );
185 }
186 }
187
188 // Mark as manually edited by admin
189 if (!isset($data['source'])) {
190 $data['source'] = 'manual'; // Admin edits mark as manual
191 }
192
193 // Update departure
194 $result = $this->repository->update($id, $data);
195
196 // Move the departure's bookings onto the new trip so booking.trip_id and
197 // departure.trip_id stay in sync after a manual reassignment.
198 if ($result && $tripReassigned) {
199 $bookingIds = $this->bookingDepartureRepository->getBookingIdsForDeparture($id);
200 foreach ($bookingIds as $bookingId) {
201 $this->bookingRepository->update((int) $bookingId, ['trip_id' => $newTripId]);
202 }
203 }
204
205 // Trigger hook to sync capacity from availability
206 if ($result) {
207 do_action('yatra_departure_saved', $id);
208 }
209
210 // If status is being explicitly set to 'trash' (admin trash feature),
211 // skip automatic status recalculation so the trashed state is preserved.
212 if (isset($data['status']) && $data['status'] === 'trash') {
213 return $result;
214 }
215
216 // Recalculate status for all other updates
217 $departure = $this->repository->findModel($id);
218 if ($departure) {
219 $this->repository->update($id, ['status' => $departure->calculateStatus()]);
220 }
221
222 return $result;
223 }
224
225 /**
226 * Delete a departure.
227 *
228 * Allowed once the departure no longer has any booking attached, whether it
229 * was created automatically or by hand — an operator who adds a departure by
230 * mistake must be able to remove it again.
231 */
232 public function delete(int $id): bool
233 {
234 $departure = $this->repository->findModel($id);
235
236 if (!$departure) {
237 throw new \InvalidArgumentException('Departure not found');
238 }
239
240 // Bookings themselves are the source of truth here, not `booked_count`:
241 // that counter can drift upwards (a cancelled or expired booking does not
242 // always decrement it), which would otherwise leave a departure
243 // permanently undeletable long after its last booking went away.
244 //
245 // This is the ONLY thing standing between a departure and deletion. The
246 // previous guard also required source === 'recurring_generated', a value
247 // this plugin never writes (departures are `booking_created` or `manual`,
248 // see Departure::$source), so no departure was ever deletable at all.
249 if (!empty($this->bookingRepository->findByDepartureId($id))) {
250 throw new \InvalidArgumentException(
251 __('Cannot delete departure: it still has bookings attached.', 'yatra')
252 );
253 }
254
255 return $this->repository->delete($id);
256 }
257
258 /**
259 * Increment booked count (when booking is created).
260 *
261 * @param bool $force When true, bypasses the capacity guard so the
262 * increment lands even if it would exceed `max_capacity`. Use
263 * only for after-the-fact reconciliation paths — most commonly
264 * external-channel bookings (Viator / GetYourGuide / any OTA
265 * webhook) where the seat has ALREADY been sold on the OTA
266 * side. Refusing to record the increment would hide the oversell
267 * from the operator and break reconciliation. Direct-checkout
268 * callers should leave this false to keep overbooking protection.
269 */
270 public function incrementBookedCount(int $id, int $amount = 1, bool $force = false): bool
271 {
272 $departure = $this->repository->findModel($id);
273
274 if (!$departure) {
275 throw new \InvalidArgumentException('Departure not found');
276 }
277
278 // Capacity pre-check is the same guard as before — but only
279 // for non-forced callers. Forced callers (OTA ingest) skip it
280 // entirely and rely on the repository to write unconditionally.
281 if (!$force) {
282 $currentBooked = (int) ($departure->booked_count ?? 0);
283 $maxCapacity = $departure->max_capacity !== null ? (int) $departure->max_capacity : 0;
284 if ($maxCapacity > 0 && ($currentBooked + $amount > $maxCapacity)) {
285 // Do not throw; just prevent exceeding capacity.
286 return false;
287 }
288 }
289
290 $ok = $this->repository->incrementBookedCount($id, $amount, $force);
291 if ($ok) {
292 $dep = $this->repository->findModel($id);
293 if ($dep) {
294 $st = $dep->calculateStatus();
295 if ($st !== $dep->status) {
296 $this->repository->update($id, ['status' => $st]);
297 }
298 }
299 }
300
301 return $ok;
302 }
303
304 /**
305 * Decrement booked count (when booking is cancelled)
306 */
307 public function decrementBookedCount(int $id, int $amount = 1): bool
308 {
309 $departure = $this->repository->findModel($id);
310
311 if (!$departure) {
312 throw new \InvalidArgumentException('Departure not found');
313 }
314
315 $ok = $this->repository->decrementBookedCount($id, $amount);
316 if ($ok) {
317 $dep = $this->repository->findModel($id);
318 if ($dep) {
319 $st = $dep->calculateStatus();
320 if ($st !== $dep->status) {
321 $this->repository->update($id, ['status' => $st]);
322 }
323 }
324 }
325
326 return $ok;
327 }
328
329 /**
330 * Get all departures across all trips
331 */
332 public function getAllDepartures(array $filters = []): array
333 {
334 return $this->repository->findAll($filters);
335 }
336
337 /**
338 * Get departures by trip ID
339 */
340 public function getByTripId(int $tripId, array $filters = []): array
341 {
342 return $this->repository->findByTripId($tripId, $filters);
343 }
344
345 /**
346 * Get past departures by trip ID
347 */
348 public function getPastByTripId(int $tripId, array $filters = []): array
349 {
350 return $this->repository->findPastByTripId($tripId, $filters);
351 }
352
353 /**
354 * Get upcoming departures by trip ID
355 */
356 public function getUpcomingByTripId(int $tripId, array $filters = []): array
357 {
358 return $this->repository->findUpcomingByTripId($tripId, $filters);
359 }
360
361 /**
362 * Get available dates for frontend
363 * Combines manual departures and dynamically generated recurring rule dates
364 *
365 * @param int $tripId Trip ID
366 * @param string $fromDate Start date (default: today)
367 * @param string $toDate End date (default: +12 months)
368 * @return array Available dates with pricing and capacity info
369 */
370 public function getAvailableDates(int $tripId, ?string $fromDate = null, ?string $toDate = null): array
371 {
372 $fromDate = $fromDate ?? date('Y-m-d');
373 $toDate = $toDate ?? date('Y-m-d', strtotime('+12 months'));
374
375 // Get all manual departures
376 $manualDepartures = $this->repository->findByTripId($tripId, [
377 'date_from' => $fromDate,
378 'date_to' => $toDate,
379 'include_past' => false,
380 ]);
381
382 // Get recurring rule service
383 $ruleRepository = new \Yatra\Repositories\RecurringRuleRepository();
384 $ruleService = new RecurringRuleService($ruleRepository, $this->repository);
385
386 // Generate dates from recurring rules
387 $recurringDates = $ruleService->generateDatesForTrip($tripId, $fromDate, $toDate);
388
389 // Combine and format
390 $availableDates = [];
391
392 // Add manual departures
393 foreach ($manualDepartures as $departure) {
394 if ($departure->isAvailable()) {
395 $availableDates[$departure->date] = [
396 'id' => $departure->id,
397 'date' => $departure->date,
398 'time' => $departure->time,
399 'max_capacity' => $departure->max_capacity,
400 'available_capacity' => $departure->max_capacity - $departure->booked_count,
401 'booked_count' => $departure->booked_count,
402 'status' => $departure->status,
403 'source' => $departure->source,
404 'price_override' => $departure->price_override,
405 'price_by_traveler_type' => $departure->price_by_traveler_type,
406 'is_full' => $departure->booked_count >= $departure->max_capacity,
407 ];
408 }
409 }
410
411 // Add recurring rule dates (only if no manual departure exists)
412 foreach ($recurringDates as $dateInfo) {
413 if (!isset($availableDates[$dateInfo['date']])) {
414 $availableDates[$dateInfo['date']] = [
415 'id' => null,
416 'date' => $dateInfo['date'],
417 'time' => null,
418 'max_capacity' => $dateInfo['max_capacity'],
419 'available_capacity' => $dateInfo['max_capacity'],
420 'booked_count' => 0,
421 'status' => 'upcoming',
422 'source' => 'recurring_rule',
423 'price_override' => $dateInfo['base_price'],
424 'price_by_traveler_type' => $dateInfo['pricing_by_traveler_type'],
425 'is_full' => false,
426 'rule_id' => $dateInfo['rule_id'],
427 ];
428 }
429 }
430
431 // Sort by date
432 ksort($availableDates);
433
434 return array_values($availableDates);
435 }
436
437 /**
438 * Recalculate all departure statuses (for cron job)
439 */
440 public function recalculateAllStatuses(): int
441 {
442 return $this->repository->recalculateAllStatuses();
443 }
444
445 /**
446 * Find or create a departure for a booking
447 * If departure doesn't exist, creates it automatically
448 *
449 * @param int $tripId Trip ID
450 * @param string $startDate Start date (YYYY-MM-DD)
451 * @param string $endDate End date (YYYY-MM-DD)
452 * @param int $travelersCount Number of travelers in the booking
453 * @param int|null $defaultMaxCapacity Default max capacity if creating new departure (null = unlimited)
454 * @param string|null $time Time in HH:MM:SS format (optional)
455 * @return Departure The departure (existing or newly created)
456 * @throws \Exception
457 */
458 public function findOrCreateForBooking(int $tripId, string $startDate, string $endDate, int $travelersCount = 0, ?int $defaultMaxCapacity = null, ?string $time = null): Departure
459 {
460 // Get capacity based on priority
461 $maxCapacity = $this->capacityService->getCapacityForDate($tripId, $startDate, $time);
462
463 // If no capacity found from availability or rules, use the provided default
464 if ($maxCapacity <= 0 && $defaultMaxCapacity !== null) {
465 $maxCapacity = $defaultMaxCapacity;
466 }
467 // Validate date format
468 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
469 throw new \InvalidArgumentException('Invalid start date format. Use YYYY-MM-DD');
470 }
471 if (!empty($endDate) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
472 throw new \InvalidArgumentException('Invalid end date format. Use YYYY-MM-DD');
473 }
474
475 // Try to find existing departure by start_date and time
476 $departure = $this->repository->findByTripIdAndStartDate($tripId, $startDate, $time);
477
478 if ($departure) {
479 // Departure exists, sync capacity from availability before returning
480 if ($maxCapacity > 0 && $departure->max_capacity !== $maxCapacity) {
481 $this->repository->update($departure->id, ['max_capacity' => $maxCapacity]);
482 $departure->max_capacity = $maxCapacity;
483 }
484 return $departure;
485 }
486
487 // Departure doesn't exist, create it
488 $trip = $this->tripRepository->find($tripId);
489
490 // Calculate end_date if not provided
491 if (empty($endDate)) {
492 $durationDays = $trip ? ($trip->duration_days ?? 1) : 1;
493 $endDate = date('Y-m-d', strtotime($startDate . ' + ' . ($durationDays - 1) . ' days'));
494 }
495
496 // Keep capacity from availability/rules/trip (resolved above). Only use caller default if still unknown.
497 if ($maxCapacity <= 0 && $defaultMaxCapacity !== null && $defaultMaxCapacity > 0) {
498 $maxCapacity = $defaultMaxCapacity;
499 }
500 if ($maxCapacity <= 0) {
501 $maxCapacity = $trip ? max(1, (int) ($trip->max_travelers ?? $trip->max_travellers ?? 1)) : 1;
502 }
503
504 // Trip already loaded for end_date / capacity fallbacks
505 $defaultTime = $time;
506
507 // If no time provided, try to get from trip settings or use default
508 if (!$defaultTime && $trip) {
509 // Check if trip has a default departure time
510 $defaultTime = $trip->departure_time ?? '09:00'; // Default to 9:00 AM
511 }
512
513 $departureData = [
514 'trip_id' => $tripId,
515 'start_date' => $startDate,
516 'end_date' => $endDate,
517 'date' => $startDate, // Keep for backward compatibility
518 'time' => $defaultTime, // Add time (default or provided)
519 'max_capacity' => $maxCapacity,
520 'booked_count' => 0, // Will be incremented after creation
521 'total_revenue' => 0.00, // Initialize revenue
522 'source' => 'booking_created', // Created from booking
523 'status' => 'upcoming', // Will be recalculated
524 ];
525
526 $departureId = $this->repository->create($departureData);
527 $departure = $this->repository->findModel($departureId);
528
529 if (!$departure) {
530 throw new \RuntimeException('Failed to create departure');
531 }
532
533 return $departure;
534 }
535
536 /**
537 * Link a booking to a departure
538 *
539 * @param int $bookingId Booking ID
540 * @param int $departureId Departure ID
541 * @return bool Success
542 */
543 public function linkBookingToDeparture(int $bookingId, int $departureId): bool
544 {
545 $result = $this->bookingDepartureRepository->link($bookingId, $departureId);
546
547 if ($result) {
548 $this->recalculateDepartureRevenue($departureId);
549 $departure = $this->repository->findModel($departureId);
550 if ($departure) {
551 $newStatus = $departure->calculateStatus();
552 if ($newStatus !== $departure->status) {
553 $this->repository->update($departureId, ['status' => $newStatus]);
554 }
555 }
556 }
557
558 return $result;
559 }
560
561 /**
562 * Unlink a booking from a departure
563 * If departure has no more bookings, mark it as cancelled
564 *
565 * @param int $bookingId Booking ID
566 * @param int|null $departureId Optional departure ID
567 * @return bool Success
568 */
569 public function unlinkBookingFromDeparture(int $bookingId, ?int $departureId = null): bool
570 {
571 $booking = $this->bookingRepository->find($bookingId);
572 $travelersCount = $booking ? max(0, (int) ($booking->travelers_count ?? 0)) : 0;
573
574 // Get departure ID if not provided
575 if ($departureId === null) {
576 $departureId = $this->bookingDepartureRepository->getDepartureIdForBooking($bookingId);
577 if ($departureId === null) {
578 return true; // No link exists
579 }
580 }
581
582 // Unlink booking
583 $result = $this->bookingDepartureRepository->unlink($bookingId, $departureId);
584
585 if (!$result) {
586 return false;
587 }
588
589 if ($travelersCount > 0) {
590 $this->decrementBookedCount($departureId, $travelersCount);
591 }
592
593 // Recalculate total revenue for the departure
594 $this->recalculateDepartureRevenue($departureId);
595
596 // Check if departure has any more bookings
597 $bookingCount = $this->bookingDepartureRepository->countBookingsForDeparture($departureId);
598
599 if ($bookingCount === 0) {
600 // Mark departure as cancelled with note
601 $this->cancelDeparture($departureId, 'Cancelled - All bookings removed');
602 } else {
603 // Recalculate status
604 $departure = $this->repository->findModel($departureId);
605 if ($departure) {
606 $this->repository->update($departureId, ['status' => $departure->calculateStatus()]);
607 }
608 }
609
610 return true;
611 }
612
613 /**
614 * Cancel a departure (mark as cancelled with note, never delete)
615 *
616 * @param int $departureId Departure ID
617 * @param string $note Cancellation note
618 * @return bool Success
619 */
620 public function cancelDeparture(int $departureId, string $note): bool
621 {
622 $departure = $this->repository->findModel($departureId);
623
624 if (!$departure) {
625 return false;
626 }
627
628 // Update status and notes
629 return $this->repository->update($departureId, [
630 'status' => 'cancelled',
631 'notes' => $note,
632 ]);
633 }
634
635 /**
636 * Handle booking date change
637 * Creates new departure and cancels old one
638 *
639 * @param int $bookingId Booking ID
640 * @param string $newStartDate New start date
641 * @param string $newEndDate New end date
642 * @return array {success: bool, new_departure_id: int, old_departure_id: int|null}
643 */
644 /**
645 * @param string|null $newDepartureTime Departure time (HH:MM/HH:MM:SS) when the trip
646 * runs several departures a day. Optional, so
647 * existing callers keep their behaviour.
648 */
649 public function handleBookingDateChange(int $bookingId, string $newStartDate, string $newEndDate, ?string $newDepartureTime = null): array
650 {
651 // Get booking to find trip_id
652 $booking = $this->bookingRepository->find($bookingId);
653 if (!$booking) {
654 throw new \InvalidArgumentException('Booking not found');
655 }
656
657 $tripId = (int) $booking->trip_id;
658 $oldDepartureId = $this->bookingDepartureRepository->getDepartureIdForBooking($bookingId);
659
660 // Get trip for max capacity
661 $trip = $this->tripRepository->find($tripId);
662 // Get max capacity from trip's max_travelers, or use default
663 $maxCapacity = null;
664 if ($trip && !empty($trip->max_travelers)) {
665 $maxCapacity = (int) $trip->max_travelers;
666 }
667
668 // Find or create new departure. The time matters when a trip runs several
669 // departures a day: without it the booking lands on whichever departure
670 // matches the date alone, so it never occupies the slot it was booked for.
671 $newDeparture = $this->findOrCreateForBooking(
672 $tripId,
673 $newStartDate,
674 $newEndDate,
675 0,
676 $maxCapacity,
677 ($newDepartureTime !== null && trim($newDepartureTime) !== '') ? trim($newDepartureTime) : null
678 );
679
680 // Link booking to new departure
681 $this->bookingDepartureRepository->updateDepartureForBooking($bookingId, $newDeparture->id);
682
683 // Increment booked count for new departure
684 $travelersCount = (int) ($booking->travelers_count ?? 0);
685 $this->incrementBookedCount($newDeparture->id, $travelersCount);
686
687 // Handle old departure (link was moved in updateDepartureForBooking; adjust counts explicitly)
688 if ($oldDepartureId) {
689 if ($travelersCount > 0) {
690 $this->decrementBookedCount($oldDepartureId, $travelersCount);
691 }
692 $oldBookingCount = $this->bookingDepartureRepository->countBookingsForDeparture($oldDepartureId);
693 if ($oldBookingCount === 0) {
694 $this->cancelDeparture($oldDepartureId, "Cancelled - Booking date changed (Booking ID: {$bookingId})");
695 }
696 }
697
698 return [
699 'success' => true,
700 'new_departure_id' => $newDeparture->id,
701 'old_departure_id' => $oldDepartureId,
702 ];
703 }
704
705 /**
706 * Get all bookings for a departure
707 *
708 * @param int $departureId Departure ID
709 * @return array Array of booking objects
710 */
711 public function getBookingsForDeparture(int $departureId): array
712 {
713 // Use repository helper to get booking IDs for this departure
714 $bookingIds = $this->bookingDepartureRepository->getBookingIdsForDeparture($departureId);
715
716 if (empty($bookingIds)) {
717 return [];
718 }
719
720 $bookings = [];
721 foreach ($bookingIds as $bookingId) {
722 $bookingId = (int) $bookingId;
723 if ($bookingId <= 0) {
724 continue;
725 }
726 $booking = $this->bookingRepository->find($bookingId);
727 if ($booking) {
728 $bookings[] = $booking;
729 }
730 }
731
732 return $bookings;
733 }
734
735 /**
736 * Get departure for a booking
737 *
738 * @param int $bookingId Booking ID
739 * @return Departure|null Departure or null
740 */
741 public function getDepartureForBooking(int $bookingId): ?Departure
742 {
743 $departureId = $this->bookingDepartureRepository->getDepartureIdForBooking($bookingId);
744
745 if ($departureId === null) {
746 return null;
747 }
748
749 return $this->repository->findModel($departureId);
750 }
751
752 /**
753 * Check if a date matches a recurring rule
754 *
755 * @param string $date Date to check (YYYY-MM-DD)
756 * @param object $rule Recurring rule object
757 * @return bool True if date matches the rule
758 */
759 private function dateMatchesRecurringRule(string $date, object $rule): bool
760 {
761 // Check date range
762 if (!empty($rule->start_date) && $date < $rule->start_date) {
763 return false;
764 }
765 if (!empty($rule->end_date) && $date > $rule->end_date) {
766 return false;
767 }
768
769 $dayOfWeek = (int) date('w', strtotime($date)); // 0 = Sunday, 6 = Saturday
770 $recurrenceType = $rule->recurrence_type ?? 'daily';
771 $weekdays = is_string($rule->weekdays) ? json_decode($rule->weekdays, true) : ($rule->weekdays ?? []);
772
773 switch ($recurrenceType) {
774 case 'daily':
775 return true;
776
777 case 'weekly':
778 return in_array($dayOfWeek, $weekdays, true);
779
780 case 'monthly':
781 // Check if it's the same day of month
782 $ruleDay = !empty($rule->start_date) ? (int) date('d', strtotime($rule->start_date)) : null;
783 $checkDay = (int) date('d', strtotime($date));
784 return $ruleDay === null || $ruleDay === $checkDay;
785
786 case 'custom_days':
787 return in_array($dayOfWeek, $weekdays, true);
788
789 default:
790 return false;
791 }
792 }
793
794 /**
795 * Get the departure repository
796 *
797 * @return \Yatra\Repositories\DepartureRepository
798 */
799 public function getRepository(): \Yatra\Repositories\DepartureRepository
800 {
801 return $this->repository;
802 }
803
804 /**
805 * Recalculate total revenue for a departure from all linked bookings
806 *
807 * @param int $departureId Departure ID
808 * @return float Total revenue
809 */
810 public function recalculateDepartureRevenue(int $departureId): float
811 {
812 // Use repository helper to fetch booking IDs
813 $bookingIds = $this->bookingDepartureRepository->getBookingIdsForDeparture($departureId);
814
815 if (empty($bookingIds)) {
816 $this->repository->update($departureId, ['total_revenue' => 0.00]);
817 return 0.00;
818 }
819
820 $totalRevenue = 0.00;
821
822 foreach ($bookingIds as $bookingId) {
823 $bookingId = (int) $bookingId;
824 if ($bookingId <= 0) {
825 continue;
826 }
827 $booking = $this->bookingRepository->find($bookingId);
828 if ($booking) {
829 // Only count confirmed/pending bookings, exclude cancelled/refunded
830 if (!in_array($booking->status ?? '', ['cancelled', 'refunded', 'failed'], true)) {
831 // If total_amount is not set, try to calculate from price * travelers
832 if (!empty($booking->total_amount)) {
833 $totalRevenue += (float) $booking->total_amount;
834 } else if (!empty($booking->price) && !empty($booking->traveler_count)) {
835 $totalRevenue += ((float) $booking->price * (int) $booking->traveler_count);
836 } else if (!empty($booking->price)) {
837 // Fallback to just price if traveler count not available
838 $totalRevenue += (float) $booking->price;
839 }
840 }
841 }
842 }
843
844 // Update departure with calculated revenue
845 $this->repository->update($departureId, ['total_revenue' => $totalRevenue]);
846
847 return $totalRevenue;
848 }
849 }
850
851