PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 trunk, at app/Services/DepartureService.php

873 lines 33.0 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 * Count departures across all trips for the same filters as
347 * getAllDepartures() (pagination keys ignored) — the true total behind a
348 * paginated list.
349 */
350 public function countAllDepartures(array $filters = []): int
351 {
352 return $this->repository->countAll($filters);
353 }
354
355 /**
356 * Count departures for one trip for the same filters as getByTripId()
357 * (pagination keys ignored).
358 */
359 public function countByTripId(int $tripId, array $filters = []): int
360 {
361 return $this->repository->countByTripId($tripId, $filters);
362 }
363
364 /**
365 * Get past departures by trip ID
366 */
367 public function getPastByTripId(int $tripId, array $filters = []): array
368 {
369 return $this->repository->findPastByTripId($tripId, $filters);
370 }
371
372 /**
373 * Get upcoming departures by trip ID
374 */
375 public function getUpcomingByTripId(int $tripId, array $filters = []): array
376 {
377 return $this->repository->findUpcomingByTripId($tripId, $filters);
378 }
379
380 /**
381 * Get available dates for frontend
382 * Combines manual departures and dynamically generated recurring rule dates
383 *
384 * @param int $tripId Trip ID
385 * @param string $fromDate Start date (default: today)
386 * @param string $toDate End date (default: today + the configurable booking horizon, 12 months unless changed)
387 * @return array Available dates with pricing and capacity info
388 */
389 public function getAvailableDates(int $tripId, ?string $fromDate = null, ?string $toDate = null): array
390 {
391 $fromDate = $fromDate ?? date('Y-m-d');
392 // Default counted from today (not $fromDate), exactly as the previous
393 // hard-coded '+12 months' was, so callers passing only a start date
394 // keep the same window.
395 $toDate = $toDate ?? yatra_get_availability_horizon_date();
396
397 // Get all manual departures
398 $manualDepartures = $this->repository->findByTripId($tripId, [
399 'date_from' => $fromDate,
400 'date_to' => $toDate,
401 'include_past' => false,
402 ]);
403
404 // Get recurring rule service
405 $ruleRepository = new \Yatra\Repositories\RecurringRuleRepository();
406 $ruleService = new RecurringRuleService($ruleRepository, $this->repository);
407
408 // Generate dates from recurring rules
409 $recurringDates = $ruleService->generateDatesForTrip($tripId, $fromDate, $toDate);
410
411 // Combine and format
412 $availableDates = [];
413
414 // Add manual departures
415 foreach ($manualDepartures as $departure) {
416 if ($departure->isAvailable()) {
417 $availableDates[$departure->date] = [
418 'id' => $departure->id,
419 'date' => $departure->date,
420 'time' => $departure->time,
421 'max_capacity' => $departure->max_capacity,
422 'available_capacity' => $departure->max_capacity - $departure->booked_count,
423 'booked_count' => $departure->booked_count,
424 'status' => $departure->status,
425 'source' => $departure->source,
426 'price_override' => $departure->price_override,
427 'price_by_traveler_type' => $departure->price_by_traveler_type,
428 'is_full' => $departure->booked_count >= $departure->max_capacity,
429 ];
430 }
431 }
432
433 // Add recurring rule dates (only if no manual departure exists)
434 foreach ($recurringDates as $dateInfo) {
435 if (!isset($availableDates[$dateInfo['date']])) {
436 $availableDates[$dateInfo['date']] = [
437 'id' => null,
438 'date' => $dateInfo['date'],
439 'time' => null,
440 'max_capacity' => $dateInfo['max_capacity'],
441 'available_capacity' => $dateInfo['max_capacity'],
442 'booked_count' => 0,
443 'status' => 'upcoming',
444 'source' => 'recurring_rule',
445 'price_override' => $dateInfo['base_price'],
446 'price_by_traveler_type' => $dateInfo['pricing_by_traveler_type'],
447 'is_full' => false,
448 'rule_id' => $dateInfo['rule_id'],
449 ];
450 }
451 }
452
453 // Sort by date
454 ksort($availableDates);
455
456 return array_values($availableDates);
457 }
458
459 /**
460 * Recalculate all departure statuses (for cron job)
461 */
462 public function recalculateAllStatuses(): int
463 {
464 return $this->repository->recalculateAllStatuses();
465 }
466
467 /**
468 * Find or create a departure for a booking
469 * If departure doesn't exist, creates it automatically
470 *
471 * @param int $tripId Trip ID
472 * @param string $startDate Start date (YYYY-MM-DD)
473 * @param string $endDate End date (YYYY-MM-DD)
474 * @param int $travelersCount Number of travelers in the booking
475 * @param int|null $defaultMaxCapacity Default max capacity if creating new departure (null = unlimited)
476 * @param string|null $time Time in HH:MM:SS format (optional)
477 * @return Departure The departure (existing or newly created)
478 * @throws \Exception
479 */
480 public function findOrCreateForBooking(int $tripId, string $startDate, string $endDate, int $travelersCount = 0, ?int $defaultMaxCapacity = null, ?string $time = null): Departure
481 {
482 // Get capacity based on priority
483 $maxCapacity = $this->capacityService->getCapacityForDate($tripId, $startDate, $time);
484
485 // If no capacity found from availability or rules, use the provided default
486 if ($maxCapacity <= 0 && $defaultMaxCapacity !== null) {
487 $maxCapacity = $defaultMaxCapacity;
488 }
489 // Validate date format
490 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
491 throw new \InvalidArgumentException('Invalid start date format. Use YYYY-MM-DD');
492 }
493 if (!empty($endDate) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
494 throw new \InvalidArgumentException('Invalid end date format. Use YYYY-MM-DD');
495 }
496
497 // Try to find existing departure by start_date and time
498 $departure = $this->repository->findByTripIdAndStartDate($tripId, $startDate, $time);
499
500 if ($departure) {
501 // Departure exists, sync capacity from availability before returning
502 if ($maxCapacity > 0 && $departure->max_capacity !== $maxCapacity) {
503 $this->repository->update($departure->id, ['max_capacity' => $maxCapacity]);
504 $departure->max_capacity = $maxCapacity;
505 }
506 return $departure;
507 }
508
509 // Departure doesn't exist, create it
510 $trip = $this->tripRepository->find($tripId);
511
512 // Calculate end_date if not provided
513 if (empty($endDate)) {
514 $durationDays = $trip ? ($trip->duration_days ?? 1) : 1;
515 $endDate = date('Y-m-d', strtotime($startDate . ' + ' . ($durationDays - 1) . ' days'));
516 }
517
518 // Keep capacity from availability/rules/trip (resolved above). Only use caller default if still unknown.
519 if ($maxCapacity <= 0 && $defaultMaxCapacity !== null && $defaultMaxCapacity > 0) {
520 $maxCapacity = $defaultMaxCapacity;
521 }
522 if ($maxCapacity <= 0) {
523 $maxCapacity = $trip ? max(1, (int) ($trip->max_travelers ?? $trip->max_travellers ?? 1)) : 1;
524 }
525
526 // Trip already loaded for end_date / capacity fallbacks
527 $defaultTime = $time;
528
529 // If no time provided, try to get from trip settings or use default
530 if (!$defaultTime && $trip) {
531 // Check if trip has a default departure time
532 $defaultTime = $trip->departure_time ?? '09:00'; // Default to 9:00 AM
533 }
534
535 $departureData = [
536 'trip_id' => $tripId,
537 'start_date' => $startDate,
538 'end_date' => $endDate,
539 'date' => $startDate, // Keep for backward compatibility
540 'time' => $defaultTime, // Add time (default or provided)
541 'max_capacity' => $maxCapacity,
542 'booked_count' => 0, // Will be incremented after creation
543 'total_revenue' => 0.00, // Initialize revenue
544 'source' => 'booking_created', // Created from booking
545 'status' => 'upcoming', // Will be recalculated
546 ];
547
548 $departureId = $this->repository->create($departureData);
549 $departure = $this->repository->findModel($departureId);
550
551 if (!$departure) {
552 throw new \RuntimeException('Failed to create departure');
553 }
554
555 return $departure;
556 }
557
558 /**
559 * Link a booking to a departure
560 *
561 * @param int $bookingId Booking ID
562 * @param int $departureId Departure ID
563 * @return bool Success
564 */
565 public function linkBookingToDeparture(int $bookingId, int $departureId): bool
566 {
567 $result = $this->bookingDepartureRepository->link($bookingId, $departureId);
568
569 if ($result) {
570 $this->recalculateDepartureRevenue($departureId);
571 $departure = $this->repository->findModel($departureId);
572 if ($departure) {
573 $newStatus = $departure->calculateStatus();
574 if ($newStatus !== $departure->status) {
575 $this->repository->update($departureId, ['status' => $newStatus]);
576 }
577 }
578 }
579
580 return $result;
581 }
582
583 /**
584 * Unlink a booking from a departure
585 * If departure has no more bookings, mark it as cancelled
586 *
587 * @param int $bookingId Booking ID
588 * @param int|null $departureId Optional departure ID
589 * @return bool Success
590 */
591 public function unlinkBookingFromDeparture(int $bookingId, ?int $departureId = null): bool
592 {
593 $booking = $this->bookingRepository->find($bookingId);
594 $travelersCount = $booking ? max(0, (int) ($booking->travelers_count ?? 0)) : 0;
595
596 // Get departure ID if not provided
597 if ($departureId === null) {
598 $departureId = $this->bookingDepartureRepository->getDepartureIdForBooking($bookingId);
599 if ($departureId === null) {
600 return true; // No link exists
601 }
602 }
603
604 // Unlink booking
605 $result = $this->bookingDepartureRepository->unlink($bookingId, $departureId);
606
607 if (!$result) {
608 return false;
609 }
610
611 if ($travelersCount > 0) {
612 $this->decrementBookedCount($departureId, $travelersCount);
613 }
614
615 // Recalculate total revenue for the departure
616 $this->recalculateDepartureRevenue($departureId);
617
618 // Check if departure has any more bookings
619 $bookingCount = $this->bookingDepartureRepository->countBookingsForDeparture($departureId);
620
621 if ($bookingCount === 0) {
622 // Mark departure as cancelled with note
623 $this->cancelDeparture($departureId, 'Cancelled - All bookings removed');
624 } else {
625 // Recalculate status
626 $departure = $this->repository->findModel($departureId);
627 if ($departure) {
628 $this->repository->update($departureId, ['status' => $departure->calculateStatus()]);
629 }
630 }
631
632 return true;
633 }
634
635 /**
636 * Cancel a departure (mark as cancelled with note, never delete)
637 *
638 * @param int $departureId Departure ID
639 * @param string $note Cancellation note
640 * @return bool Success
641 */
642 public function cancelDeparture(int $departureId, string $note): bool
643 {
644 $departure = $this->repository->findModel($departureId);
645
646 if (!$departure) {
647 return false;
648 }
649
650 // Update status and notes
651 return $this->repository->update($departureId, [
652 'status' => 'cancelled',
653 'notes' => $note,
654 ]);
655 }
656
657 /**
658 * Handle booking date change
659 * Creates new departure and cancels old one
660 *
661 * @param int $bookingId Booking ID
662 * @param string $newStartDate New start date
663 * @param string $newEndDate New end date
664 * @return array {success: bool, new_departure_id: int, old_departure_id: int|null}
665 */
666 /**
667 * @param string|null $newDepartureTime Departure time (HH:MM/HH:MM:SS) when the trip
668 * runs several departures a day. Optional, so
669 * existing callers keep their behaviour.
670 */
671 public function handleBookingDateChange(int $bookingId, string $newStartDate, string $newEndDate, ?string $newDepartureTime = null): array
672 {
673 // Get booking to find trip_id
674 $booking = $this->bookingRepository->find($bookingId);
675 if (!$booking) {
676 throw new \InvalidArgumentException('Booking not found');
677 }
678
679 $tripId = (int) $booking->trip_id;
680 $oldDepartureId = $this->bookingDepartureRepository->getDepartureIdForBooking($bookingId);
681
682 // Get trip for max capacity
683 $trip = $this->tripRepository->find($tripId);
684 // Get max capacity from trip's max_travelers, or use default
685 $maxCapacity = null;
686 if ($trip && !empty($trip->max_travelers)) {
687 $maxCapacity = (int) $trip->max_travelers;
688 }
689
690 // Find or create new departure. The time matters when a trip runs several
691 // departures a day: without it the booking lands on whichever departure
692 // matches the date alone, so it never occupies the slot it was booked for.
693 $newDeparture = $this->findOrCreateForBooking(
694 $tripId,
695 $newStartDate,
696 $newEndDate,
697 0,
698 $maxCapacity,
699 ($newDepartureTime !== null && trim($newDepartureTime) !== '') ? trim($newDepartureTime) : null
700 );
701
702 // Link booking to new departure
703 $this->bookingDepartureRepository->updateDepartureForBooking($bookingId, $newDeparture->id);
704
705 // Increment booked count for new departure
706 $travelersCount = (int) ($booking->travelers_count ?? 0);
707 $this->incrementBookedCount($newDeparture->id, $travelersCount);
708
709 // Handle old departure (link was moved in updateDepartureForBooking; adjust counts explicitly)
710 if ($oldDepartureId) {
711 if ($travelersCount > 0) {
712 $this->decrementBookedCount($oldDepartureId, $travelersCount);
713 }
714 $oldBookingCount = $this->bookingDepartureRepository->countBookingsForDeparture($oldDepartureId);
715 if ($oldBookingCount === 0) {
716 $this->cancelDeparture($oldDepartureId, "Cancelled - Booking date changed (Booking ID: {$bookingId})");
717 }
718 }
719
720 return [
721 'success' => true,
722 'new_departure_id' => $newDeparture->id,
723 'old_departure_id' => $oldDepartureId,
724 ];
725 }
726
727 /**
728 * Get all bookings for a departure
729 *
730 * @param int $departureId Departure ID
731 * @return array Array of booking objects
732 */
733 public function getBookingsForDeparture(int $departureId): array
734 {
735 // Use repository helper to get booking IDs for this departure
736 $bookingIds = $this->bookingDepartureRepository->getBookingIdsForDeparture($departureId);
737
738 if (empty($bookingIds)) {
739 return [];
740 }
741
742 $bookings = [];
743 foreach ($bookingIds as $bookingId) {
744 $bookingId = (int) $bookingId;
745 if ($bookingId <= 0) {
746 continue;
747 }
748 $booking = $this->bookingRepository->find($bookingId);
749 if ($booking) {
750 $bookings[] = $booking;
751 }
752 }
753
754 return $bookings;
755 }
756
757 /**
758 * Get departure for a booking
759 *
760 * @param int $bookingId Booking ID
761 * @return Departure|null Departure or null
762 */
763 public function getDepartureForBooking(int $bookingId): ?Departure
764 {
765 $departureId = $this->bookingDepartureRepository->getDepartureIdForBooking($bookingId);
766
767 if ($departureId === null) {
768 return null;
769 }
770
771 return $this->repository->findModel($departureId);
772 }
773
774 /**
775 * Check if a date matches a recurring rule
776 *
777 * @param string $date Date to check (YYYY-MM-DD)
778 * @param object $rule Recurring rule object
779 * @return bool True if date matches the rule
780 */
781 private function dateMatchesRecurringRule(string $date, object $rule): bool
782 {
783 // Check date range
784 if (!empty($rule->start_date) && $date < $rule->start_date) {
785 return false;
786 }
787 if (!empty($rule->end_date) && $date > $rule->end_date) {
788 return false;
789 }
790
791 $dayOfWeek = (int) date('w', strtotime($date)); // 0 = Sunday, 6 = Saturday
792 $recurrenceType = $rule->recurrence_type ?? 'daily';
793 $weekdays = is_string($rule->weekdays) ? json_decode($rule->weekdays, true) : ($rule->weekdays ?? []);
794
795 switch ($recurrenceType) {
796 case 'daily':
797 return true;
798
799 case 'weekly':
800 return in_array($dayOfWeek, $weekdays, true);
801
802 case 'monthly':
803 // Check if it's the same day of month
804 $ruleDay = !empty($rule->start_date) ? (int) date('d', strtotime($rule->start_date)) : null;
805 $checkDay = (int) date('d', strtotime($date));
806 return $ruleDay === null || $ruleDay === $checkDay;
807
808 case 'custom_days':
809 return in_array($dayOfWeek, $weekdays, true);
810
811 default:
812 return false;
813 }
814 }
815
816 /**
817 * Get the departure repository
818 *
819 * @return \Yatra\Repositories\DepartureRepository
820 */
821 public function getRepository(): \Yatra\Repositories\DepartureRepository
822 {
823 return $this->repository;
824 }
825
826 /**
827 * Recalculate total revenue for a departure from all linked bookings
828 *
829 * @param int $departureId Departure ID
830 * @return float Total revenue
831 */
832 public function recalculateDepartureRevenue(int $departureId): float
833 {
834 // Use repository helper to fetch booking IDs
835 $bookingIds = $this->bookingDepartureRepository->getBookingIdsForDeparture($departureId);
836
837 if (empty($bookingIds)) {
838 $this->repository->update($departureId, ['total_revenue' => 0.00]);
839 return 0.00;
840 }
841
842 $totalRevenue = 0.00;
843
844 foreach ($bookingIds as $bookingId) {
845 $bookingId = (int) $bookingId;
846 if ($bookingId <= 0) {
847 continue;
848 }
849 $booking = $this->bookingRepository->find($bookingId);
850 if ($booking) {
851 // Only count confirmed/pending bookings, exclude cancelled/refunded
852 if (!in_array($booking->status ?? '', ['cancelled', 'refunded', 'failed'], true)) {
853 // If total_amount is not set, try to calculate from price * travelers
854 if (!empty($booking->total_amount)) {
855 $totalRevenue += (float) $booking->total_amount;
856 } else if (!empty($booking->price) && !empty($booking->traveler_count)) {
857 $totalRevenue += ((float) $booking->price * (int) $booking->traveler_count);
858 } else if (!empty($booking->price)) {
859 // Fallback to just price if traveler count not available
860 $totalRevenue += (float) $booking->price;
861 }
862 }
863 }
864 }
865
866 // Update departure with calculated revenue
867 $this->repository->update($departureId, ['total_revenue' => $totalRevenue]);
868
869 return $totalRevenue;
870 }
871 }
872
873