PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | app/Services/RecurringAvailabilityService.php +234 -46 3.0.2.6 → 3.0.16 View file →
@@ -80,16 +80,36 @@
80 80 }
81 81 break;
82 82
83 83 case 'monthly':
84 - if (empty($data['week_of_month'])) {
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 === '') {
85 88 throw new \InvalidArgumentException('Week of month is required for monthly rules');
86 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 + }
87 107 if (!isset($data['day_of_week']) || $data['day_of_week'] === '') {
88 108 throw new \InvalidArgumentException('Day of week is required for monthly rules');
89 109 }
90 110 $validWeeks = ['first', 'second', 'third', 'fourth', 'last'];
91 - if (!in_array($data['week_of_month'], $validWeeks, true)) {
111 + if (!is_string($weekValue) || !in_array($weekValue, $validWeeks, true)) {
92 112 throw new \InvalidArgumentException('Invalid week of month');
93 113 }
94 114 break;
95 115
@@ -129,14 +149,20 @@
129 149 */
130 150 public function create(array $data): int
131 151 {
132 152 $this->validate($data);
133 -
134 - // Convert days array to string if needed
135 - if (isset($data['days_of_week']) && is_array($data['days_of_week'])) {
136 - $data['days_of_week'] = implode(',', $data['days_of_week']);
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']));
137 163 }
138 -
164 +
139 165 return $this->repository->create($data);
140 166 }
141 167
142 168 /**
@@ -144,18 +170,55 @@
144 170 */
145 171 public function update(int $id, array $data): bool
146 172 {
147 173 $this->validate($data, $id);
148 -
149 - // Convert days array to string if needed
150 - if (isset($data['days_of_week']) && is_array($data['days_of_week'])) {
151 - $data['days_of_week'] = implode(',', $data['days_of_week']);
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']));
152 179 }
153 -
180 +
154 181 return $this->repository->update($id, $data);
155 182 }
156 183
157 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 + /**
158 221 * Delete a rule
159 222 */
160 223 public function delete(int $id): bool
161 224 {
@@ -287,9 +350,9 @@
287 350 }
288 351
289 352 if (in_array($dayOfWeek, $targetDays, true)) {
290 353 // Check if not excluded
291 - if (!in_array($dateStr, $excludedDates, true)) {
354 + if (!$this->isDateExcluded($dateStr, $excludedDates)) {
292 355 // Check cutoff
293 356 if ($this->isBookable($current, $rule)) {
294 357 $generatedDates = $this->createAvailabilityFromRule($rule, $dateStr, $dayOfWeek);
295 358 $dates = array_merge($dates, $generatedDates);
@@ -308,11 +371,21 @@
308 371 */
309 372 private function generateMonthlyDates(object $rule, string $fromDate, string $toDate): array
310 373 {
311 374 $dates = [];
312 - $weekOfMonth = $rule->week_of_month;
313 - $dayOfWeek = (int) $rule->day_of_week;
314 - $excludedDates = $rule->excluded_dates;
375 +
376 + // Legacy rows (migrated from the pre-3.x schema) can land here
377 + // with `week_of_month` NULL or missing entirely — the column is
378 + // nullable in the DB but {@see self::getNthWeekdayOfMonth()}'s
379 + // signature requires `string`, so calling it with NULL was
380 + // crashing the trip page on PHP 7.4+. Bail out cleanly instead.
381 + $weekOfMonth = $rule->week_of_month ?? null;
382 + if (!is_string($weekOfMonth) || $weekOfMonth === '') {
383 + return $dates;
384 + }
385 +
386 + $dayOfWeek = (int) ($rule->day_of_week ?? 0);
387 + $excludedDates = $rule->excluded_dates ?? [];
315 388 $selectedMonths = !empty($rule->months) ? $rule->months : [];
316 389
317 390 // Start from the first day of the starting month
318 391 $current = strtotime(date('Y-m-01', strtotime($fromDate)));
@@ -335,9 +408,9 @@
335 408
336 409 // Check if within range
337 410 if ($targetTimestamp >= strtotime($fromDate) && $targetTimestamp <= $end) {
338 411 // Check if not excluded
339 - if (!in_array($targetDate, $excludedDates, true)) {
412 + if (!$this->isDateExcluded($targetDate, $excludedDates)) {
340 413 // Check cutoff
341 414 if ($this->isBookable($targetTimestamp, $rule)) {
342 415 $generatedDates = $this->createAvailabilityFromRule($rule, $targetDate, $dayOfWeek);
343 416 $dates = array_merge($dates, $generatedDates);
@@ -353,43 +426,72 @@
353 426 return $dates;
354 427 }
355 428
356 429 /**
357 - * Generate interval recurring dates (every X days)
430 + * Generate interval recurring dates (every X days).
431 + *
432 + * Hardened against malformed legacy data (rules carried over from the
433 + * pre-3.x schema may land here after the legacy→new heal-step in
434 + * {@see \Yatra\Services\InstallerService::maybeNormalizeAvailabilityRulesLegacyData()}):
435 + * - `interval_days` defaults to 1 when 0/NULL so we never divide by zero
436 + * or loop forever.
437 + * - `interval_start_date`/`start_date` may be NULL or unparseable; we
438 + * bail out rather than feed `false` into a chain of strtotime() calls,
439 + * which on PHP 8.1+ raises a TypeError ($baseTimestamp must be ?int).
440 + * - The previous "+N * M days" string was never a valid strtotime
441 + * expression (strtotime doesn't multiply); we now compute the skip
442 + * arithmetic in PHP and pass a single, well-formed relative format.
358 443 */
359 444 private function generateIntervalDates(object $rule, string $fromDate, string $toDate): array
360 445 {
361 446 $dates = [];
362 - $intervalDays = (int) $rule->interval_days;
363 - $excludedDates = $rule->excluded_dates;
447 +
448 + $intervalDays = (int) ($rule->interval_days ?? 0);
449 + if ($intervalDays <= 0) {
450 + $intervalDays = 1;
451 + }
452 +
453 + $excludedDates = $rule->excluded_dates ?? [];
364 454 $selectedMonths = !empty($rule->months) ? $rule->months : [];
365 -
366 - // Start from interval_start_date or rule start_date
367 - $referenceDate = $rule->interval_start_date ?? $rule->start_date;
368 - $reference = strtotime($referenceDate);
455 +
456 + $referenceDate = !empty($rule->interval_start_date)
457 + ? $rule->interval_start_date
458 + : ($rule->start_date ?? null);
459 +
460 + if (empty($referenceDate)) {
461 + return $dates;
462 + }
463 +
464 + $reference = strtotime((string) $referenceDate);
369 465 $from = strtotime($fromDate);
370 466 $end = strtotime($toDate);
371 -
372 - // Find first occurrence on or after fromDate
467 +
468 + if ($reference === false || $from === false || $end === false) {
469 + return $dates;
470 + }
471 +
472 + // Snap reference forward to the first occurrence on/after $from.
373 473 if ($reference < $from) {
374 - $daysDiff = floor(($from - $reference) / 86400);
375 - $intervalsToSkip = ceil($daysDiff / $intervalDays);
376 - $reference = strtotime("+{$intervalsToSkip} * {$intervalDays} days", $reference);
474 + $daysDiff = (int) floor(($from - $reference) / 86400);
475 + $intervalsToSkip = (int) ceil($daysDiff / $intervalDays);
476 + $skipDays = $intervalsToSkip * $intervalDays;
477 + $advanced = strtotime("+{$skipDays} days", $reference);
478 + if ($advanced === false) {
479 + return $dates;
480 + }
481 + $reference = $advanced;
377 482 }
378 -
483 +
379 484 $current = $reference;
380 -
381 - while ($current <= $end) {
485 +
486 + while ($current !== false && $current <= $end) {
382 487 if ($current >= $from) {
383 488 $dateStr = date('Y-m-d', $current);
384 489 $dayOfWeek = (int) date('w', $current);
385 490 $month = (int) date('n', $current); // 1-12
386 -
387 - // Check if month is allowed (if months filter is set)
491 +
388 492 if (empty($selectedMonths) || in_array($month, $selectedMonths, true)) {
389 - // Check if not excluded
390 - if (!in_array($dateStr, $excludedDates, true)) {
391 - // Check cutoff
493 + if (!$this->isDateExcluded($dateStr, $excludedDates)) {
392 494 if ($this->isBookable($current, $rule)) {
393 495 $generatedDates = $this->createAvailabilityFromRule($rule, $dateStr, $dayOfWeek);
394 496 $dates = array_merge($dates, $generatedDates);
395 497 }
@@ -395,12 +497,12 @@
395 497 }
396 498 }
397 499 }
398 500 }
399 -
501 +
400 502 $current = strtotime("+{$intervalDays} days", $current);
401 503 }
402 -
504 +
403 505 return $dates;
404 506 }
405 507
406 508 /**
@@ -477,9 +579,14 @@
477 579 private function createAvailabilityFromRule(object $rule, string $date, int $dayOfWeek): array
478 580 {
479 581 // Check for day-specific overrides
480 582 $dayOverrides = $rule->day_overrides[$dayOfWeek] ?? [];
481 -
583 +
584 + // Preview flows pass a pseudo-rule that has no persisted id; fall back
585 + // to the string "preview" so we still emit a deterministic synthetic
586 + // availability id without triggering PHP 8 undefined-property warnings.
587 + $ruleId = $rule->id ?? 'preview';
588 +
482 589 // If rule has time_slots, create separate availability for each slot
483 590 if (!empty($rule->time_slots) && is_array($rule->time_slots)) {
484 591 $availabilities = [];
485 592 foreach ($rule->time_slots as $index => $slot) {
@@ -485,12 +592,12 @@
485 592 foreach ($rule->time_slots as $index => $slot) {
486 593 $slotPrice = $slot['price'] ?? $dayOverrides['original_price'] ?? $rule->original_price;
487 594 $slotSeats = $slot['seats'] ?? $dayOverrides['seats_total'] ?? $rule->seats_total;
488 595 $slotTravelerPricing = $slot['traveler_pricing'] ?? $rule->traveler_pricing ?? [];
489 -
596 +
490 597 $availabilities[] = [
491 - 'id' => 'rule_' . $rule->id . '_' . $date . '_slot_' . $index,
492 - 'rule_id' => $rule->id,
598 + 'id' => 'rule_' . $ruleId . '_' . $date . '_slot_' . $index,
599 + 'rule_id' => $ruleId,
493 600 'trip_id' => $rule->trip_id,
494 601 'departure_date' => $date,
495 602 'departure_time' => $slot['departure_time'] ?? null,
496 603 'arrival_time' => $slot['arrival_time'] ?? null,
@@ -523,10 +630,10 @@
523 630 $seats = $dayOverrides['seats_total'] ?? $rule->seats_total;
524 631 $travelerPricing = $rule->traveler_pricing ?? [];
525 632
526 633 return [[
527 - 'id' => 'rule_' . $rule->id . '_' . $date,
528 - 'rule_id' => $rule->id,
634 + 'id' => 'rule_' . $ruleId . '_' . $date,
635 + 'rule_id' => $ruleId,
529 636 'trip_id' => $rule->trip_id,
530 637 'departure_date' => $date,
531 638 'departure_time' => $rule->departure_time,
532 639 'arrival_time' => $rule->arrival_time,
@@ -552,13 +659,94 @@
552 659
553 660 /**
554 661 * Preview generated dates (for admin UI)
555 662 */
663 + /**
664 + * Has the operator explicitly closed this date on any of the trip's active
665 + * rules — a holiday, or a period such as a business vacation?
666 + *
667 + * Only answers for dates the operator deliberately listed. A date that is
668 + * simply not generated by a rule (a Tuesday on a Mon/Wed/Fri pattern) is
669 + * not "excluded" and is left to the rest of the resolution chain.
670 + */
671 + public function isDateExcludedForTrip(int $tripId, string $date): bool
672 + {
673 + if ($tripId < 1 || $date === '') {
674 + return false;
675 + }
676 +
677 + foreach ($this->repository->getActiveRulesForDateRange($tripId, $date, $date) as $rule) {
678 + if ($this->isDateExcluded($date, $rule->excluded_dates ?? [])) {
679 + return true;
680 + }
681 + }
682 +
683 + return false;
684 + }
685 +
686 + /**
687 + * Is this date excluded by the rule?
688 + *
689 + * An entry in `excluded_dates` is either a single 'Y-m-d' string (what the
690 + * admin has always been able to add one day at a time) or an inclusive
691 + * period ['start' => 'Y-m-d', 'end' => 'Y-m-d'] — a business vacation or
692 + * seasonal closure, added in one go instead of day by day.
693 + *
694 + * Both shapes share the one column so existing rules keep working
695 + * untouched: a stored list of plain strings behaves exactly as before.
696 + * Dates are ISO, so plain string comparison is chronological.
697 + *
698 + * @param string $date Date being generated, 'Y-m-d'.
699 + * @param mixed $excluded Stored exclusions (a list; anything else = none).
700 + */
701 + public function isDateExcluded(string $date, $excluded): bool
702 + {
703 + if (!is_array($excluded)) {
704 + return false;
705 + }
706 +
707 + foreach ($excluded as $entry) {
708 + if (is_string($entry)) {
709 + if ($entry === $date) {
710 + return true;
711 + }
712 +
713 + continue;
714 + }
715 +
716 + if (!is_array($entry)) {
717 + continue;
718 + }
719 +
720 + $start = isset($entry['start']) ? (string) $entry['start'] : '';
721 + $end = isset($entry['end']) ? (string) $entry['end'] : '';
722 +
723 + if ($start === '' || $end === '') {
724 + continue;
725 + }
726 +
727 + // Tolerate a reversed period rather than silently ignoring it.
728 + if ($start > $end) {
729 + [$start, $end] = [$end, $start];
730 + }
731 +
732 + if ($date >= $start && $date <= $end) {
733 + return true;
734 + }
735 + }
736 +
737 + return false;
738 + }
739 +
556 740 public function previewDates(array $ruleData, int $limit = 20): array
557 741 {
558 742 // Create a temporary rule object
559 743 $rule = (object) $ruleData;
560 - $rule->excluded_dates = $rule->excluded_dates ?? [];
744 + // Normalise once: a malformed value (e.g. a bare string from a
745 + // hand-rolled API call) must not reach count() below as a fatal.
746 + $rule->excluded_dates = is_array($rule->excluded_dates ?? null)
747 + ? $rule->excluded_dates
748 + : [];
561 749 $rule->day_overrides = $rule->day_overrides ?? [];
562 750 $rule->days_of_week_array = isset($rule->days_of_week)
563 751 ? (is_array($rule->days_of_week) ? $rule->days_of_week : array_map('intval', explode(',', (string) $rule->days_of_week)))
564 752 : [];