| 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 |
|