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/DepartureService.php +130 -31 3.0.2.9 → 3.0.16 View file →
@@ -56,9 +56,10 @@
56 56 // Calculate capacity based on the date if not provided
57 57 if (empty($data['max_capacity'])) {
58 58 $data['max_capacity'] = $this->capacityService->getCapacityForDate(
59 59 (int) $data['trip_id'],
60 - $startDate
60 + $startDate,
61 + $data['time'] ?? null
61 62 );
62 63
63 64 // If still no capacity, throw an error
64 65 if ($data['max_capacity'] <= 0) {
@@ -155,16 +156,53 @@
155 156 throw new \InvalidArgumentException('A departure already exists for this trip and date');
156 157 }
157 158 }
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 +
159 188 // Mark as manually edited by admin
160 189 if (!isset($data['source'])) {
161 190 $data['source'] = 'manual'; // Admin edits mark as manual
162 191 }
163 -
192 +
164 193 // Update departure
165 194 $result = $this->repository->update($id, $data);
166 -
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 +
167 205 // Trigger hook to sync capacity from availability
168 206 if ($result) {
169 207 do_action('yatra_departure_saved', $id);
170 208 }
@@ -184,48 +222,73 @@
184 222 return $result;
185 223 }
186 224
187 225 /**
188 - * Delete a departure
189 - * Only allowed if source is recurring_generated and booked_count is 0
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.
190 231 */
191 232 public function delete(int $id): bool
192 233 {
193 234 $departure = $this->repository->findModel($id);
194 -
235 +
195 236 if (!$departure) {
196 237 throw new \InvalidArgumentException('Departure not found');
197 238 }
198 -
199 - // Only allow deletion of recurring_generated departures with no bookings
200 - if ($departure->source === 'recurring_generated' && $departure->booked_count === 0) {
201 - return $this->repository->delete($id);
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 + );
202 253 }
203 -
204 - // Manual departures or departures with bookings cannot be deleted
205 - throw new \InvalidArgumentException('Cannot delete departure: Manual departures or departures with bookings cannot be deleted');
254 +
255 + return $this->repository->delete($id);
206 256 }
207 257
208 258 /**
209 - * Increment booked count (when booking is created)
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.
210 269 */
211 - public function incrementBookedCount(int $id, int $amount = 1): bool
270 + public function incrementBookedCount(int $id, int $amount = 1, bool $force = false): bool
212 271 {
213 272 $departure = $this->repository->findModel($id);
214 -
273 +
215 274 if (!$departure) {
216 275 throw new \InvalidArgumentException('Departure not found');
217 276 }
218 -
219 - // Check if capacity allows (only when max_capacity is set)
220 - $currentBooked = (int) ($departure->booked_count ?? 0);
221 - $maxCapacity = $departure->max_capacity !== null ? (int) $departure->max_capacity : 0;
222 - if ($maxCapacity > 0 && ($currentBooked + $amount > $maxCapacity)) {
223 - // Do not throw; just prevent exceeding capacity
224 - return false;
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 + }
225 288 }
226 -
227 - $ok = $this->repository->incrementBookedCount($id, $amount);
289 +
290 + $ok = $this->repository->incrementBookedCount($id, $amount, $force);
228 291 if ($ok) {
229 292 $dep = $this->repository->findModel($id);
230 293 if ($dep) {
231 294 $st = $dep->calculateStatus();
@@ -279,8 +342,27 @@
279 342 return $this->repository->findByTripId($tripId, $filters);
280 343 }
281 344
282 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 + /**
283 365 * Get past departures by trip ID
284 366 */
285 367 public function getPastByTripId(int $tripId, array $filters = []): array
286 368 {
@@ -300,15 +382,18 @@
300 382 * Combines manual departures and dynamically generated recurring rule dates
301 383 *
302 384 * @param int $tripId Trip ID
303 385 * @param string $fromDate Start date (default: today)
304 - * @param string $toDate End date (default: +12 months)
386 + * @param string $toDate End date (default: today + the configurable booking horizon, 12 months unless changed)
305 387 * @return array Available dates with pricing and capacity info
306 388 */
307 389 public function getAvailableDates(int $tripId, ?string $fromDate = null, ?string $toDate = null): array
308 390 {
309 391 $fromDate = $fromDate ?? date('Y-m-d');
310 - $toDate = $toDate ?? date('Y-m-d', strtotime('+12 months'));
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();
311 396
312 397 // Get all manual departures
313 398 $manualDepartures = $this->repository->findByTripId($tripId, [
314 399 'date_from' => $fromDate,
@@ -394,9 +479,9 @@
394 479 */
395 480 public function findOrCreateForBooking(int $tripId, string $startDate, string $endDate, int $travelersCount = 0, ?int $defaultMaxCapacity = null, ?string $time = null): Departure
396 481 {
397 482 // Get capacity based on priority
398 - $maxCapacity = $this->capacityService->getCapacityForDate($tripId, $startDate);
483 + $maxCapacity = $this->capacityService->getCapacityForDate($tripId, $startDate, $time);
399 484
400 485 // If no capacity found from availability or rules, use the provided default
401 486 if ($maxCapacity <= 0 && $defaultMaxCapacity !== null) {
402 487 $maxCapacity = $defaultMaxCapacity;
@@ -577,9 +662,14 @@
577 662 * @param string $newStartDate New start date
578 663 * @param string $newEndDate New end date
579 664 * @return array {success: bool, new_departure_id: int, old_departure_id: int|null}
580 665 */
581 - public function handleBookingDateChange(int $bookingId, string $newStartDate, string $newEndDate): array
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
582 672 {
583 673 // Get booking to find trip_id
584 674 $booking = $this->bookingRepository->find($bookingId);
585 675 if (!$booking) {
@@ -596,10 +686,19 @@
596 686 if ($trip && !empty($trip->max_travelers)) {
597 687 $maxCapacity = (int) $trip->max_travelers;
598 688 }
599 689
600 - // Find or create new departure
601 - $newDeparture = $this->findOrCreateForBooking($tripId, $newStartDate, $newEndDate, 0, $maxCapacity);
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 + );
602 701
603 702 // Link booking to new departure
604 703 $this->bookingDepartureRepository->updateDepartureForBooking($bookingId, $newDeparture->id);
605 704