| 1 |
<?php |
| 2 |
/** |
| 3 |
* Recurring Availability Service |
| 4 |
* Handles business logic for recurring availability rules and date generation |
| 5 |
* |
| 6 |
* This is a FREE feature - no Pro plugin required |
| 7 |
* |
| 8 |
* @package Yatra\Services |
| 9 |
* @since 3.0.0 |
| 10 |
*/ |
| 11 |
|
| 12 |
declare(strict_types=1); |
| 13 |
|
| 14 |
namespace Yatra\Services; |
| 15 |
|
| 16 |
use Yatra\Repositories\RecurringAvailabilityRepository; |
| 17 |
|
| 18 |
class RecurringAvailabilityService |
| 19 |
{ |
| 20 |
private RecurringAvailabilityRepository $repository; |
| 21 |
|
| 22 |
public function __construct(RecurringAvailabilityRepository $repository) |
| 23 |
{ |
| 24 |
$this->repository = $repository; |
| 25 |
} |
| 26 |
|
| 27 |
/** |
| 28 |
* Validate rule data |
| 29 |
*/ |
| 30 |
public function validate(array $data, ?int $id = null): void |
| 31 |
{ |
| 32 |
// For updates (when $id is provided), allow partial updates |
| 33 |
$isUpdate = $id !== null; |
| 34 |
|
| 35 |
// If this is just a status update or other partial update, skip full validation |
| 36 |
$isPartialUpdate = $isUpdate && count($data) <= 2; // status, or status + one other field |
| 37 |
|
| 38 |
if ($isPartialUpdate) { |
| 39 |
// For partial updates, only validate what's provided |
| 40 |
if (isset($data['status']) && !in_array($data['status'], ['active', 'inactive'], true)) { |
| 41 |
throw new \InvalidArgumentException('Invalid status. Must be active or inactive'); |
| 42 |
} |
| 43 |
return; // Skip full validation for partial updates |
| 44 |
} |
| 45 |
|
| 46 |
// Full validation for create or complete updates |
| 47 |
// Required fields |
| 48 |
if (empty($data['trip_id'])) { |
| 49 |
throw new \InvalidArgumentException('Trip ID is required'); |
| 50 |
} |
| 51 |
|
| 52 |
if (empty($data['rule_type'])) { |
| 53 |
throw new \InvalidArgumentException('Rule type is required'); |
| 54 |
} |
| 55 |
|
| 56 |
if (empty($data['start_date'])) { |
| 57 |
throw new \InvalidArgumentException('Start date is required'); |
| 58 |
} |
| 59 |
|
| 60 |
// Validate rule type |
| 61 |
$validTypes = ['weekly', 'monthly', 'interval']; |
| 62 |
if (!in_array($data['rule_type'], $validTypes, true)) { |
| 63 |
throw new \InvalidArgumentException('Invalid rule type. Must be: ' . implode(', ', $validTypes)); |
| 64 |
} |
| 65 |
|
| 66 |
// Validate based on rule type |
| 67 |
switch ($data['rule_type']) { |
| 68 |
case 'weekly': |
| 69 |
if (empty($data['days_of_week'])) { |
| 70 |
throw new \InvalidArgumentException('Days of week is required for weekly rules'); |
| 71 |
} |
| 72 |
// Validate days are 0-6 |
| 73 |
$days = is_array($data['days_of_week']) |
| 74 |
? $data['days_of_week'] |
| 75 |
: explode(',', $data['days_of_week']); |
| 76 |
foreach ($days as $day) { |
| 77 |
if ((int) $day < 0 || (int) $day > 6) { |
| 78 |
throw new \InvalidArgumentException('Days of week must be 0-6 (Sun-Sat)'); |
| 79 |
} |
| 80 |
} |
| 81 |
break; |
| 82 |
|
| 83 |
case 'monthly': |
| 84 |
// week_of_month may come from the admin UI as a string (first/second/...) |
| 85 |
// or from persisted data as an int (1..5). Accept both and normalize for checks. |
| 86 |
$weekValue = $data['week_of_month'] ?? null; |
| 87 |
if ($weekValue === null || $weekValue === '') { |
| 88 |
throw new \InvalidArgumentException('Week of month is required for monthly rules'); |
| 89 |
} |
| 90 |
if (is_string($weekValue)) { |
| 91 |
$weekValue = strtolower(trim($weekValue)); |
| 92 |
} |
| 93 |
if (is_numeric($weekValue)) { |
| 94 |
$weekInt = (int) $weekValue; |
| 95 |
$map = [ |
| 96 |
1 => 'first', |
| 97 |
2 => 'second', |
| 98 |
3 => 'third', |
| 99 |
4 => 'fourth', |
| 100 |
5 => 'last', |
| 101 |
]; |
| 102 |
if (isset($map[$weekInt])) { |
| 103 |
$weekValue = $map[$weekInt]; |
| 104 |
$data['week_of_month'] = $weekValue; |
| 105 |
} |
| 106 |
} |
| 107 |
if (!isset($data['day_of_week']) || $data['day_of_week'] === '') { |
| 108 |
throw new \InvalidArgumentException('Day of week is required for monthly rules'); |
| 109 |
} |
| 110 |
$validWeeks = ['first', 'second', 'third', 'fourth', 'last']; |
| 111 |
if (!is_string($weekValue) || !in_array($weekValue, $validWeeks, true)) { |
| 112 |
throw new \InvalidArgumentException('Invalid week of month'); |
| 113 |
} |
| 114 |
break; |
| 115 |
|
| 116 |
case 'interval': |
| 117 |
if (empty($data['interval_days']) || (int) $data['interval_days'] < 1) { |
| 118 |
throw new \InvalidArgumentException('Interval days is required and must be at least 1'); |
| 119 |
} |
| 120 |
break; |
| 121 |
} |
| 122 |
|
| 123 |
// Validate date format |
| 124 |
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['start_date'])) { |
| 125 |
throw new \InvalidArgumentException('Invalid start date format. Use YYYY-MM-DD'); |
| 126 |
} |
| 127 |
|
| 128 |
if (!empty($data['end_date'])) { |
| 129 |
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['end_date'])) { |
| 130 |
throw new \InvalidArgumentException('Invalid end date format. Use YYYY-MM-DD'); |
| 131 |
} |
| 132 |
if (strtotime($data['end_date']) < strtotime($data['start_date'])) { |
| 133 |
throw new \InvalidArgumentException('End date must be after start date'); |
| 134 |
} |
| 135 |
} |
| 136 |
|
| 137 |
// Validate pricing |
| 138 |
if (isset($data['original_price']) && (float) $data['original_price'] < 0) { |
| 139 |
throw new \InvalidArgumentException('Price cannot be negative'); |
| 140 |
} |
| 141 |
|
| 142 |
if (isset($data['seats_total']) && (int) $data['seats_total'] < 1) { |
| 143 |
throw new \InvalidArgumentException('Seats must be at least 1'); |
| 144 |
} |
| 145 |
} |
| 146 |
|
| 147 |
/** |
| 148 |
* Create a new rule |
| 149 |
*/ |
| 150 |
public function create(array $data): int |
| 151 |
{ |
| 152 |
$this->validate($data); |
| 153 |
|
| 154 |
// The `days_of_week` column is JSON. Leave the value as an array so |
| 155 |
// the repository can JSON-encode it; if a caller passes a legacy CSV |
| 156 |
// string, normalise it to an array of ints here so the repo always |
| 157 |
// sees a single shape. |
| 158 |
$data = $this->normaliseDaysOfWeek($data); |
| 159 |
|
| 160 |
// Normalise UI strings to match DB column types. |
| 161 |
if (isset($data['week_of_month']) && is_string($data['week_of_month'])) { |
| 162 |
$data['week_of_month'] = strtolower(trim($data['week_of_month'])); |
| 163 |
} |
| 164 |
|
| 165 |
return $this->repository->create($data); |
| 166 |
} |
| 167 |
|
| 168 |
/** |
| 169 |
* Update a rule |
| 170 |
*/ |
| 171 |
public function update(int $id, array $data): bool |
| 172 |
{ |
| 173 |
$this->validate($data, $id); |
| 174 |
|
| 175 |
$data = $this->normaliseDaysOfWeek($data); |
| 176 |
|
| 177 |
if (isset($data['week_of_month']) && is_string($data['week_of_month'])) { |
| 178 |
$data['week_of_month'] = strtolower(trim($data['week_of_month'])); |
| 179 |
} |
| 180 |
|
| 181 |
return $this->repository->update($id, $data); |
| 182 |
} |
| 183 |
|
| 184 |
/** |
| 185 |
* Coerce `days_of_week` to an array of ints (0..6) regardless of how the |
| 186 |
* caller passed it (array of mixed scalars, comma-separated string, JSON |
| 187 |
* string). The repository is responsible for JSON-encoding it for the |
| 188 |
* database column. |
| 189 |
*/ |
| 190 |
private function normaliseDaysOfWeek(array $data): array |
| 191 |
{ |
| 192 |
if (!array_key_exists('days_of_week', $data)) { |
| 193 |
return $data; |
| 194 |
} |
| 195 |
|
| 196 |
$value = $data['days_of_week']; |
| 197 |
|
| 198 |
if (is_string($value)) { |
| 199 |
$trimmed = trim($value); |
| 200 |
if ($trimmed === '') { |
| 201 |
$value = []; |
| 202 |
} else { |
| 203 |
$decoded = json_decode($trimmed, true); |
| 204 |
$value = is_array($decoded) ? $decoded : explode(',', $trimmed); |
| 205 |
} |
| 206 |
} |
| 207 |
|
| 208 |
if (!is_array($value)) { |
| 209 |
$value = []; |
| 210 |
} |
| 211 |
|
| 212 |
$value = array_values(array_unique(array_map(static fn($d) => (int) $d, $value))); |
| 213 |
$value = array_values(array_filter($value, static fn(int $d) => $d >= 0 && $d <= 6)); |
| 214 |
|
| 215 |
$data['days_of_week'] = $value; |
| 216 |
|
| 217 |
return $data; |
| 218 |
} |
| 219 |
|
| 220 |
/** |
| 221 |
* Delete a rule |
| 222 |
*/ |
| 223 |
public function delete(int $id): bool |
| 224 |
{ |
| 225 |
return $this->repository->delete($id); |
| 226 |
} |
| 227 |
|
| 228 |
/** |
| 229 |
* Get rules by trip ID |
| 230 |
*/ |
| 231 |
public function getByTripId(int $tripId, array $filters = []): array |
| 232 |
{ |
| 233 |
return $this->repository->findByTripId($tripId, $filters); |
| 234 |
} |
| 235 |
|
| 236 |
/** |
| 237 |
* Count rules by trip ID |
| 238 |
*/ |
| 239 |
public function countByTripId(int $tripId, array $filters = []): int |
| 240 |
{ |
| 241 |
return $this->repository->countByTripId($tripId, $filters); |
| 242 |
} |
| 243 |
|
| 244 |
/** |
| 245 |
* Get status counts for recurring rules by trip ID |
| 246 |
*/ |
| 247 |
public function getStatusCounts(int $tripId): array |
| 248 |
{ |
| 249 |
return $this->repository->getStatusCounts(['trip_id' => $tripId]); |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* Find rule by ID |
| 254 |
*/ |
| 255 |
public function find(int $id): ?object |
| 256 |
{ |
| 257 |
return $this->repository->find($id); |
| 258 |
} |
| 259 |
|
| 260 |
/** |
| 261 |
* Generate availability dates from rules for a trip within a date range |
| 262 |
*/ |
| 263 |
public function generateDatesForTrip(int $tripId, string $fromDate, string $toDate): array |
| 264 |
{ |
| 265 |
$rules = $this->repository->getActiveRulesForDateRange($tripId, $fromDate, $toDate); |
| 266 |
|
| 267 |
$allDates = []; |
| 268 |
|
| 269 |
foreach ($rules as $rule) { |
| 270 |
$dates = $this->generateDatesFromRule($rule, $fromDate, $toDate); |
| 271 |
$allDates = array_merge($allDates, $dates); |
| 272 |
} |
| 273 |
|
| 274 |
// Sort by date |
| 275 |
usort($allDates, function ($a, $b) { |
| 276 |
return strcmp($a['departure_date'], $b['departure_date']); |
| 277 |
}); |
| 278 |
|
| 279 |
// Remove duplicates (keep first occurrence - higher priority rule) |
| 280 |
$uniqueDates = []; |
| 281 |
$seenDates = []; |
| 282 |
|
| 283 |
foreach ($allDates as $date) { |
| 284 |
$key = $date['departure_date'] . '_' . ($date['departure_time'] ?? ''); |
| 285 |
if (!isset($seenDates[$key])) { |
| 286 |
$seenDates[$key] = true; |
| 287 |
$uniqueDates[] = $date; |
| 288 |
} |
| 289 |
} |
| 290 |
|
| 291 |
return $uniqueDates; |
| 292 |
} |
| 293 |
|
| 294 |
/** |
| 295 |
* Generate dates from a single rule |
| 296 |
*/ |
| 297 |
public function generateDatesFromRule(object $rule, string $fromDate, string $toDate): array |
| 298 |
{ |
| 299 |
// Clamp dates to rule's active period |
| 300 |
$ruleStart = $rule->start_date; |
| 301 |
$ruleEnd = $rule->end_date ?: $toDate; |
| 302 |
|
| 303 |
$effectiveFrom = max($fromDate, $ruleStart); |
| 304 |
$effectiveTo = min($toDate, $ruleEnd); |
| 305 |
|
| 306 |
if ($effectiveFrom > $effectiveTo) { |
| 307 |
return []; |
| 308 |
} |
| 309 |
|
| 310 |
$dates = []; |
| 311 |
|
| 312 |
switch ($rule->rule_type) { |
| 313 |
case 'weekly': |
| 314 |
$dates = $this->generateWeeklyDates($rule, $effectiveFrom, $effectiveTo); |
| 315 |
break; |
| 316 |
case 'monthly': |
| 317 |
$dates = $this->generateMonthlyDates($rule, $effectiveFrom, $effectiveTo); |
| 318 |
break; |
| 319 |
case 'interval': |
| 320 |
$dates = $this->generateIntervalDates($rule, $effectiveFrom, $effectiveTo); |
| 321 |
break; |
| 322 |
} |
| 323 |
|
| 324 |
return $dates; |
| 325 |
} |
| 326 |
|
| 327 |
/** |
| 328 |
* Generate weekly recurring dates |
| 329 |
*/ |
| 330 |
private function generateWeeklyDates(object $rule, string $fromDate, string $toDate): array |
| 331 |
{ |
| 332 |
$dates = []; |
| 333 |
$targetDays = $rule->days_of_week_array; |
| 334 |
$excludedDates = $rule->excluded_dates; |
| 335 |
$selectedMonths = !empty($rule->months) ? $rule->months : []; |
| 336 |
|
| 337 |
$current = strtotime($fromDate); |
| 338 |
$end = strtotime($toDate); |
| 339 |
$today = strtotime('today'); |
| 340 |
|
| 341 |
while ($current <= $end) { |
| 342 |
$dayOfWeek = (int) date('w', $current); |
| 343 |
$dateStr = date('Y-m-d', $current); |
| 344 |
$month = (int) date('n', $current); // 1-12 |
| 345 |
|
| 346 |
// Check if month is allowed (if months filter is set) |
| 347 |
if (!empty($selectedMonths) && !in_array($month, $selectedMonths, true)) { |
| 348 |
$current = strtotime('+1 day', $current); |
| 349 |
continue; |
| 350 |
} |
| 351 |
|
| 352 |
if (in_array($dayOfWeek, $targetDays, true)) { |
| 353 |
// Check if not excluded |
| 354 |
if (!in_array($dateStr, $excludedDates, true)) { |
| 355 |
// Check cutoff |
| 356 |
if ($this->isBookable($current, $rule)) { |
| 357 |
$generatedDates = $this->createAvailabilityFromRule($rule, $dateStr, $dayOfWeek); |
| 358 |
$dates = array_merge($dates, $generatedDates); |
| 359 |
} |
| 360 |
} |
| 361 |
} |
| 362 |
|
| 363 |
$current = strtotime('+1 day', $current); |
| 364 |
} |
| 365 |
|
| 366 |
return $dates; |
| 367 |
} |
| 368 |
|
| 369 |
/** |
| 370 |
* Generate monthly recurring dates (e.g., "last Sunday of each month") |
| 371 |
*/ |
| 372 |
private function generateMonthlyDates(object $rule, string $fromDate, string $toDate): array |
| 373 |
{ |
| 374 |
$dates = []; |
| 375 |
$weekOfMonth = $rule->week_of_month; |
| 376 |
$dayOfWeek = (int) $rule->day_of_week; |
| 377 |
$excludedDates = $rule->excluded_dates; |
| 378 |
$selectedMonths = !empty($rule->months) ? $rule->months : []; |
| 379 |
|
| 380 |
// Start from the first day of the starting month |
| 381 |
$current = strtotime(date('Y-m-01', strtotime($fromDate))); |
| 382 |
$end = strtotime($toDate); |
| 383 |
|
| 384 |
while ($current <= $end) { |
| 385 |
$year = (int) date('Y', $current); |
| 386 |
$month = (int) date('n', $current); |
| 387 |
|
| 388 |
// Check if month is allowed (if months filter is set) |
| 389 |
if (!empty($selectedMonths) && !in_array($month, $selectedMonths, true)) { |
| 390 |
$current = strtotime('first day of next month', $current); |
| 391 |
continue; |
| 392 |
} |
| 393 |
|
| 394 |
$targetDate = $this->getNthWeekdayOfMonth($year, $month, $weekOfMonth, $dayOfWeek); |
| 395 |
|
| 396 |
if ($targetDate) { |
| 397 |
$targetTimestamp = strtotime($targetDate); |
| 398 |
|
| 399 |
// Check if within range |
| 400 |
if ($targetTimestamp >= strtotime($fromDate) && $targetTimestamp <= $end) { |
| 401 |
// Check if not excluded |
| 402 |
if (!in_array($targetDate, $excludedDates, true)) { |
| 403 |
// Check cutoff |
| 404 |
if ($this->isBookable($targetTimestamp, $rule)) { |
| 405 |
$generatedDates = $this->createAvailabilityFromRule($rule, $targetDate, $dayOfWeek); |
| 406 |
$dates = array_merge($dates, $generatedDates); |
| 407 |
} |
| 408 |
} |
| 409 |
} |
| 410 |
} |
| 411 |
|
| 412 |
// Move to next month |
| 413 |
$current = strtotime('first day of next month', $current); |
| 414 |
} |
| 415 |
|
| 416 |
return $dates; |
| 417 |
} |
| 418 |
|
| 419 |
/** |
| 420 |
* Generate interval recurring dates (every X days). |
| 421 |
* |
| 422 |
* Hardened against malformed legacy data (rules carried over from the |
| 423 |
* pre-3.x schema may land here after the legacy→new heal-step in |
| 424 |
* {@see \Yatra\Services\InstallerService::maybeNormalizeAvailabilityRulesLegacyData()}): |
| 425 |
* - `interval_days` defaults to 1 when 0/NULL so we never divide by zero |
| 426 |
* or loop forever. |
| 427 |
* - `interval_start_date`/`start_date` may be NULL or unparseable; we |
| 428 |
* bail out rather than feed `false` into a chain of strtotime() calls, |
| 429 |
* which on PHP 8.1+ raises a TypeError ($baseTimestamp must be ?int). |
| 430 |
* - The previous "+N * M days" string was never a valid strtotime |
| 431 |
* expression (strtotime doesn't multiply); we now compute the skip |
| 432 |
* arithmetic in PHP and pass a single, well-formed relative format. |
| 433 |
*/ |
| 434 |
private function generateIntervalDates(object $rule, string $fromDate, string $toDate): array |
| 435 |
{ |
| 436 |
$dates = []; |
| 437 |
|
| 438 |
$intervalDays = (int) ($rule->interval_days ?? 0); |
| 439 |
if ($intervalDays <= 0) { |
| 440 |
$intervalDays = 1; |
| 441 |
} |
| 442 |
|
| 443 |
$excludedDates = $rule->excluded_dates ?? []; |
| 444 |
$selectedMonths = !empty($rule->months) ? $rule->months : []; |
| 445 |
|
| 446 |
$referenceDate = !empty($rule->interval_start_date) |
| 447 |
? $rule->interval_start_date |
| 448 |
: ($rule->start_date ?? null); |
| 449 |
|
| 450 |
if (empty($referenceDate)) { |
| 451 |
return $dates; |
| 452 |
} |
| 453 |
|
| 454 |
$reference = strtotime((string) $referenceDate); |
| 455 |
$from = strtotime($fromDate); |
| 456 |
$end = strtotime($toDate); |
| 457 |
|
| 458 |
if ($reference === false || $from === false || $end === false) { |
| 459 |
return $dates; |
| 460 |
} |
| 461 |
|
| 462 |
// Snap reference forward to the first occurrence on/after $from. |
| 463 |
if ($reference < $from) { |
| 464 |
$daysDiff = (int) floor(($from - $reference) / 86400); |
| 465 |
$intervalsToSkip = (int) ceil($daysDiff / $intervalDays); |
| 466 |
$skipDays = $intervalsToSkip * $intervalDays; |
| 467 |
$advanced = strtotime("+{$skipDays} days", $reference); |
| 468 |
if ($advanced === false) { |
| 469 |
return $dates; |
| 470 |
} |
| 471 |
$reference = $advanced; |
| 472 |
} |
| 473 |
|
| 474 |
$current = $reference; |
| 475 |
|
| 476 |
while ($current !== false && $current <= $end) { |
| 477 |
if ($current >= $from) { |
| 478 |
$dateStr = date('Y-m-d', $current); |
| 479 |
$dayOfWeek = (int) date('w', $current); |
| 480 |
$month = (int) date('n', $current); // 1-12 |
| 481 |
|
| 482 |
if (empty($selectedMonths) || in_array($month, $selectedMonths, true)) { |
| 483 |
if (!in_array($dateStr, $excludedDates, true)) { |
| 484 |
if ($this->isBookable($current, $rule)) { |
| 485 |
$generatedDates = $this->createAvailabilityFromRule($rule, $dateStr, $dayOfWeek); |
| 486 |
$dates = array_merge($dates, $generatedDates); |
| 487 |
} |
| 488 |
} |
| 489 |
} |
| 490 |
} |
| 491 |
|
| 492 |
$current = strtotime("+{$intervalDays} days", $current); |
| 493 |
} |
| 494 |
|
| 495 |
return $dates; |
| 496 |
} |
| 497 |
|
| 498 |
/** |
| 499 |
* Get the Nth weekday of a month (e.g., "last Sunday of January 2025") |
| 500 |
*/ |
| 501 |
private function getNthWeekdayOfMonth(int $year, int $month, string $position, int $dayOfWeek): ?string |
| 502 |
{ |
| 503 |
$dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; |
| 504 |
$dayName = $dayNames[$dayOfWeek]; |
| 505 |
|
| 506 |
switch ($position) { |
| 507 |
case 'first': |
| 508 |
$descriptor = "first {$dayName}"; |
| 509 |
break; |
| 510 |
case 'second': |
| 511 |
$descriptor = "second {$dayName}"; |
| 512 |
break; |
| 513 |
case 'third': |
| 514 |
$descriptor = "third {$dayName}"; |
| 515 |
break; |
| 516 |
case 'fourth': |
| 517 |
$descriptor = "fourth {$dayName}"; |
| 518 |
break; |
| 519 |
case 'last': |
| 520 |
$descriptor = "last {$dayName}"; |
| 521 |
break; |
| 522 |
default: |
| 523 |
return null; |
| 524 |
} |
| 525 |
|
| 526 |
$monthName = date('F', mktime(0, 0, 0, $month, 1, $year)); |
| 527 |
$dateStr = "{$descriptor} of {$monthName} {$year}"; |
| 528 |
|
| 529 |
$timestamp = strtotime($dateStr); |
| 530 |
|
| 531 |
if ($timestamp === false) { |
| 532 |
return null; |
| 533 |
} |
| 534 |
|
| 535 |
// Verify it's in the correct month (edge case for "last" crossing months) |
| 536 |
if ((int) date('n', $timestamp) !== $month) { |
| 537 |
return null; |
| 538 |
} |
| 539 |
|
| 540 |
return date('Y-m-d', $timestamp); |
| 541 |
} |
| 542 |
|
| 543 |
/** |
| 544 |
* Check if a date is bookable based on cutoff rules |
| 545 |
*/ |
| 546 |
private function isBookable(int $timestamp, object $rule): bool |
| 547 |
{ |
| 548 |
$cutoffHours = (int) ($rule->cutoff_hours ?? 24); |
| 549 |
$departureTime = $rule->departure_time ?? '00:00:00'; |
| 550 |
|
| 551 |
$departureTimestamp = strtotime(date('Y-m-d', $timestamp) . ' ' . $departureTime); |
| 552 |
$cutoffTimestamp = $departureTimestamp - ($cutoffHours * 3600); |
| 553 |
|
| 554 |
// Check advance booking limit |
| 555 |
if (!empty($rule->advance_booking_days)) { |
| 556 |
$maxBookingDate = strtotime('+' . (int) $rule->advance_booking_days . ' days'); |
| 557 |
if ($timestamp > $maxBookingDate) { |
| 558 |
return false; |
| 559 |
} |
| 560 |
} |
| 561 |
|
| 562 |
return time() < $cutoffTimestamp; |
| 563 |
} |
| 564 |
|
| 565 |
/** |
| 566 |
* Create availability array from rule |
| 567 |
* For single-day trips with multiple time slots, returns an array of availabilities |
| 568 |
*/ |
| 569 |
private function createAvailabilityFromRule(object $rule, string $date, int $dayOfWeek): array |
| 570 |
{ |
| 571 |
// Check for day-specific overrides |
| 572 |
$dayOverrides = $rule->day_overrides[$dayOfWeek] ?? []; |
| 573 |
|
| 574 |
// Preview flows pass a pseudo-rule that has no persisted id; fall back |
| 575 |
// to the string "preview" so we still emit a deterministic synthetic |
| 576 |
// availability id without triggering PHP 8 undefined-property warnings. |
| 577 |
$ruleId = $rule->id ?? 'preview'; |
| 578 |
|
| 579 |
// If rule has time_slots, create separate availability for each slot |
| 580 |
if (!empty($rule->time_slots) && is_array($rule->time_slots)) { |
| 581 |
$availabilities = []; |
| 582 |
foreach ($rule->time_slots as $index => $slot) { |
| 583 |
$slotPrice = $slot['price'] ?? $dayOverrides['original_price'] ?? $rule->original_price; |
| 584 |
$slotSeats = $slot['seats'] ?? $dayOverrides['seats_total'] ?? $rule->seats_total; |
| 585 |
$slotTravelerPricing = $slot['traveler_pricing'] ?? $rule->traveler_pricing ?? []; |
| 586 |
|
| 587 |
$availabilities[] = [ |
| 588 |
'id' => 'rule_' . $ruleId . '_' . $date . '_slot_' . $index, |
| 589 |
'rule_id' => $ruleId, |
| 590 |
'trip_id' => $rule->trip_id, |
| 591 |
'departure_date' => $date, |
| 592 |
'departure_time' => $slot['departure_time'] ?? null, |
| 593 |
'arrival_time' => $slot['arrival_time'] ?? null, |
| 594 |
'return_date' => $date, // Same day for day trips |
| 595 |
'seats_total' => (int) $slotSeats, |
| 596 |
'seats_available' => (int) $slotSeats, |
| 597 |
'original_price' => $slotPrice ? (float) $slotPrice : null, |
| 598 |
'discounted_price' => $slotPrice ? (float) $slotPrice : null, |
| 599 |
'from_location' => $rule->from_location, |
| 600 |
'to_location' => $rule->to_location, |
| 601 |
'from_latitude' => $rule->from_latitude ?? null, |
| 602 |
'from_longitude' => $rule->from_longitude ?? null, |
| 603 |
'to_latitude' => $rule->to_latitude ?? null, |
| 604 |
'to_longitude' => $rule->to_longitude ?? null, |
| 605 |
'cutoff_hours' => $rule->cutoff_hours, |
| 606 |
'status' => 'available', |
| 607 |
'is_recurring' => true, |
| 608 |
'rule_name' => $rule->name, |
| 609 |
'slot_index' => $index, |
| 610 |
'pricing_type' => $rule->pricing_type ?? 'regular', |
| 611 |
'traveler_pricing' => $slotTravelerPricing, |
| 612 |
]; |
| 613 |
} |
| 614 |
return $availabilities; |
| 615 |
} |
| 616 |
|
| 617 |
// Default: single availability per date |
| 618 |
$originalPrice = $dayOverrides['original_price'] ?? $rule->original_price; |
| 619 |
$salePrice = $dayOverrides['sale_price'] ?? $rule->sale_price ?? $originalPrice; |
| 620 |
$seats = $dayOverrides['seats_total'] ?? $rule->seats_total; |
| 621 |
$travelerPricing = $rule->traveler_pricing ?? []; |
| 622 |
|
| 623 |
return [[ |
| 624 |
'id' => 'rule_' . $ruleId . '_' . $date, |
| 625 |
'rule_id' => $ruleId, |
| 626 |
'trip_id' => $rule->trip_id, |
| 627 |
'departure_date' => $date, |
| 628 |
'departure_time' => $rule->departure_time, |
| 629 |
'arrival_time' => $rule->arrival_time, |
| 630 |
'return_date' => $date, // Same day for day trips |
| 631 |
'seats_total' => (int) $seats, |
| 632 |
'seats_available' => (int) $seats, // Will be adjusted by actual bookings |
| 633 |
'original_price' => $originalPrice ? (float) $originalPrice : null, |
| 634 |
'discounted_price' => $salePrice ? (float) $salePrice : null, |
| 635 |
'from_location' => $rule->from_location, |
| 636 |
'to_location' => $rule->to_location, |
| 637 |
'from_latitude' => $rule->from_latitude ?? null, |
| 638 |
'from_longitude' => $rule->from_longitude ?? null, |
| 639 |
'to_latitude' => $rule->to_latitude ?? null, |
| 640 |
'to_longitude' => $rule->to_longitude ?? null, |
| 641 |
'cutoff_hours' => $rule->cutoff_hours, |
| 642 |
'status' => 'available', |
| 643 |
'is_recurring' => true, |
| 644 |
'rule_name' => $rule->name, |
| 645 |
'pricing_type' => $rule->pricing_type ?? 'regular', |
| 646 |
'traveler_pricing' => $travelerPricing, |
| 647 |
]]; |
| 648 |
} |
| 649 |
|
| 650 |
/** |
| 651 |
* Preview generated dates (for admin UI) |
| 652 |
*/ |
| 653 |
public function previewDates(array $ruleData, int $limit = 20): array |
| 654 |
{ |
| 655 |
// Create a temporary rule object |
| 656 |
$rule = (object) $ruleData; |
| 657 |
$rule->excluded_dates = $rule->excluded_dates ?? []; |
| 658 |
$rule->day_overrides = $rule->day_overrides ?? []; |
| 659 |
$rule->days_of_week_array = isset($rule->days_of_week) |
| 660 |
? (is_array($rule->days_of_week) ? $rule->days_of_week : array_map('intval', explode(',', (string) $rule->days_of_week))) |
| 661 |
: []; |
| 662 |
|
| 663 |
// Parse time_slots if it's a JSON string |
| 664 |
if (isset($rule->time_slots) && is_string($rule->time_slots)) { |
| 665 |
$rule->time_slots = json_decode($rule->time_slots, true) ?: []; |
| 666 |
} |
| 667 |
|
| 668 |
// Generate for next 365 days or until end_date |
| 669 |
$startDate = $rule->start_date ?? date('Y-m-d'); |
| 670 |
$fromDate = $startDate >= date('Y-m-d') ? $startDate : date('Y-m-d'); |
| 671 |
$toDate = !empty($rule->end_date) ? $rule->end_date : date('Y-m-d', strtotime('+365 days')); |
| 672 |
|
| 673 |
$dates = $this->generateDatesFromRule($rule, $fromDate, $toDate); |
| 674 |
|
| 675 |
return [ |
| 676 |
'total' => count($dates), |
| 677 |
'dates' => $limit > 0 ? array_slice($dates, 0, $limit) : $dates, |
| 678 |
'excluded_count' => count($rule->excluded_dates), |
| 679 |
]; |
| 680 |
} |
| 681 |
} |
| 682 |
|
| 683 |
|