PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
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 / Repositories / DepartureRepository.php

DepartureRepository.php in Yatra – Travel Booking & Tour Operator Software 3.0.15, at app/Repositories/DepartureRepository.php

879 lines 35.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\Repositories;
6
7 use Yatra\Models\Departure;
8 use Yatra\Database\Tables\DeparturesTable;
9
10 /**
11 * Departure Repository
12 * Handles database operations for trip departures
13 *
14 * Table: wp_yatra_trip_departures
15 *
16 * Fields:
17 * - id (primary key)
18 * - trip_id
19 * - date (YYYY-MM-DD)
20 * - time (HH:MM:SS, nullable)
21 * - max_capacity
22 * - booked_count
23 * - status (upcoming|full|past|cancelled)
24 * - source (manual|recurring_generated)
25 * - price_override (nullable)
26 * - price_by_traveler_type (JSON)
27 * - notes (nullable)
28 * - created_at
29 * - updated_at
30 */
31 class DepartureRepository extends BaseRepository
32 {
33 /**
34 * Get table name
35 */
36 protected function getTableName(): string
37 {
38 return DeparturesTable::getTableName();
39 }
40
41 /**
42 * Find by ID
43 */
44 public function find(int $id, bool $includeDeleted = false): ?\stdClass
45 {
46 $result = parent::find($id, $includeDeleted);
47 return $result ? (object) Departure::fromArray((array) $result)->toArray() : null;
48 }
49
50 /**
51 * Find by ID and return Departure model
52 */
53 public function findModel(int $id): ?Departure
54 {
55 $result = parent::find($id);
56 return $result ? Departure::fromArray((array) $result) : null;
57 }
58
59 /**
60 * Find a departure by trip, start date (or date), and optional time
61 */
62 public function findByTripIdAndStartDate(int $tripId, string $date, ?string $time = null): ?Departure
63 {
64 $table = esc_sql($this->table);
65 $where = ['trip_id = %d'];
66 $params = [$tripId];
67
68 // prefer start_date if exists, else fall back to date column
69 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
70 $hasStartDate = in_array('start_date', $columns, true);
71
72 if ($hasStartDate) {
73 $where[] = '((start_date IS NOT NULL AND start_date = %s) OR (start_date IS NULL AND date = %s))';
74 $params[] = $date;
75 $params[] = $date;
76 } else {
77 $where[] = 'date = %s';
78 $params[] = $date;
79 }
80
81 if ($time !== null && $time !== '') {
82 $where[] = 'time = %s';
83 $params[] = $time;
84 }
85
86 $query = "SELECT * FROM {$table} WHERE " . implode(' AND ', $where) . " LIMIT 1";
87 $row = $this->wpdb->get_row($this->wpdb->prepare($query, ...$params), ARRAY_A);
88
89 return $row ? Departure::fromArray($row) : null;
90 }
91
92 /**
93 * Find all departures across all trips
94 *
95 * @param array $filters Filters: status, date_from, date_to, source
96 * @return array Array of Departure models
97 */
98 public function findAll(array $filters = []): array
99 {
100 $table = esc_sql($this->table);
101 [$where, $params] = $this->whereForAll($filters);
102
103 $query = "SELECT * FROM `{$table}` WHERE " . implode(' AND ', $where);
104 // id as a final tiebreaker: rows sharing a date + time would otherwise
105 // have no stable order, so a paginated list could repeat or skip them
106 // across pages.
107 $query .= " ORDER BY date ASC, time ASC, id ASC";
108
109 if (!empty($filters['per_page'])) {
110 $perPage = (int) $filters['per_page'];
111 $page = max(1, (int) ($filters['page'] ?? 1));
112 $offset = ($page - 1) * $perPage;
113 $query .= " LIMIT %d OFFSET %d";
114 $params[] = $perPage;
115 $params[] = $offset;
116 }
117
118 // Every dynamic value in this query goes through $params, so with no
119 // filters applied the SQL carries no placeholders at all — and calling
120 // prepare() on a placeholder-free query is what WordPress warns about
121 // ("The query argument of wpdb::prepare() must have a placeholder").
122 // Only prepare when there is something to bind.
123 $results = empty($params)
124 ? $this->wpdb->get_results($query, ARRAY_A)
125 : $this->wpdb->get_results($this->wpdb->prepare($query, ...$params), ARRAY_A);
126
127 return array_map(function ($row) {
128 return Departure::fromArray($row);
129 }, $results ?: []);
130 }
131
132 /**
133 * Count departures across all trips matching the SAME filters as findAll()
134 * (page / per_page are ignored). This is the true total behind a paginated
135 * list — sharing whereForAll() means the count can never drift from the
136 * rows findAll() returns.
137 *
138 * @param array $filters Same filters as findAll().
139 */
140 public function countAll(array $filters = []): int
141 {
142 $table = esc_sql($this->table);
143 [$where, $params] = $this->whereForAll($filters);
144
145 $query = "SELECT COUNT(*) FROM `{$table}` WHERE " . implode(' AND ', $where);
146
147 // Same prepare guard as findAll(): no placeholders when nothing is bound.
148 return (int) (empty($params)
149 ? $this->wpdb->get_var($query)
150 : $this->wpdb->get_var($this->wpdb->prepare($query, ...$params)));
151 }
152
153 /**
154 * WHERE fragments + prepare params shared by findAll() and countAll(), so
155 * the list and its total are always built from identical conditions.
156 *
157 * @param array $filters Filters: status, availability, date_from, date_to,
158 * source, include_past, past_only.
159 * @return array{0: string[], 1: array}
160 */
161 private function whereForAll(array $filters): array
162 {
163 $where = ['1=1']; // Always true for base condition
164 $params = [];
165
166 // Status filter. 'past' is date-derived (see Departure::calculateStatus),
167 // NOT the stored status column — that column is only kept current by the
168 // daily cron, so filtering status = 'past' hid every departure that had
169 // taken place whenever the cron had not run. Match 'past' by date instead;
170 // all other statuses (upcoming/full/cancelled/trash) use the stored value.
171 $statusIsPast = (!empty($filters['status']) && $filters['status'] === 'past');
172 if (!empty($filters['status']) && $filters['status'] !== 'all') {
173 if ($statusIsPast) {
174 $where[] = "(
175 (end_date IS NOT NULL AND end_date < CURDATE())
176 OR (end_date IS NULL AND start_date IS NOT NULL AND start_date < CURDATE())
177 OR (end_date IS NULL AND start_date IS NULL AND date < CURDATE())
178 )";
179 } else {
180 $this->applyStatusClause((string) $filters['status'], $where, $params);
181 }
182 }
183
184 // Independent capacity filter (see applyAvailabilityClause).
185 $this->applyAvailabilityClause($filters, $where);
186
187 // Free-text search on date / notes (see applySearchClause).
188 $this->applySearchClause($filters, $where, $params);
189
190 // Date range filter - simple approach
191 if (isset($filters['date_from']) && is_string($filters['date_from']) && trim($filters['date_from']) !== '') {
192 $dateFrom = trim($filters['date_from']);
193 // Validate date format AND that it's a real date
194 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) && strtotime($dateFrom) !== false) {
195 $where[] = 'date >= %s';
196 $params[] = $dateFrom;
197 }
198 }
199
200 if (isset($filters['date_to']) && is_string($filters['date_to']) && trim($filters['date_to']) !== '') {
201 $dateTo = trim($filters['date_to']);
202 // Validate date format AND that it's a real date
203 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo) && strtotime($dateTo) !== false) {
204 $where[] = 'date <= %s';
205 $params[] = $dateTo;
206 }
207 }
208
209 // Source filter
210 if (!empty($filters['source']) && $filters['source'] !== 'all') {
211 $where[] = 'source = %s';
212 $params[] = $filters['source'];
213 }
214
215 // Past/upcoming filter. Never exclude past dates when the caller is asking
216 // for the 'past' status — that combination is contradictory and returned
217 // nothing (the exact reason completed departures vanished from the Past tab).
218 if (isset($filters['include_past'])) {
219 if (!$filters['include_past'] && !$statusIsPast) {
220 $where[] = 'date >= CURDATE()';
221 }
222 }
223
224 // Past-only filter — for the "Past Departures" archive. Deliberately
225 // DATE-based (mirrors the end_date ?: start_date ?: date precedence used
226 // when marking departures past) rather than checking status = 'past':
227 // that stored status is only kept current by the daily cron, so relying
228 // on it made departures that had already taken place disappear entirely
229 // whenever the cron had not run. Date is the source of truth.
230 if (!empty($filters['past_only'])) {
231 // start_date / end_date are nullable DATE columns (never ''), so guard
232 // with IS NULL / IS NOT NULL — comparing a DATE column to '' errors
233 // under MySQL strict mode.
234 $where[] = "(
235 (end_date IS NOT NULL AND end_date < CURDATE())
236 OR (end_date IS NULL AND start_date IS NOT NULL AND start_date < CURDATE())
237 OR (end_date IS NULL AND start_date IS NULL AND date < CURDATE())
238 )";
239 }
240
241 return [$where, $params];
242 }
243
244 /**
245 * Find departures by trip ID
246 *
247 * @param int $tripId Trip ID
248 * @param array $filters Filters: status, date_from, date_to, source
249 * @return array Array of Departure models
250 */
251 public function findByTripId(int $tripId, array $filters = []): array
252 {
253 $table = esc_sql($this->table);
254 [$where, $params] = $this->whereForTrip($tripId, $filters);
255
256 $query = "SELECT * FROM `{$table}` WHERE " . implode(' AND ', $where);
257 // id as a final tiebreaker so pagination over rows sharing a date + time
258 // is stable (see findAll()).
259 $query .= " ORDER BY date ASC, time ASC, id ASC";
260
261 if (!empty($filters['per_page'])) {
262 $perPage = (int) $filters['per_page'];
263 $page = max(1, (int) ($filters['page'] ?? 1));
264 $offset = ($page - 1) * $perPage;
265 $query .= " LIMIT %d OFFSET %d";
266 $params[] = $perPage;
267 $params[] = $offset;
268 }
269
270 $results = $this->wpdb->get_results(
271 $this->wpdb->prepare($query, ...$params),
272 ARRAY_A
273 );
274
275 return array_map(function ($row) {
276 return Departure::fromArray($row);
277 }, $results ?: []);
278 }
279
280 /**
281 * WHERE fragments + prepare params shared by findByTripId() and
282 * countByTripId(), so a trip's list and its total are always built from
283 * identical conditions. trip_id is always the first bound param.
284 *
285 * @param int $tripId Trip ID.
286 * @param array $filters Filters: status, availability, date_from, date_to,
287 * source, include_past, past_only.
288 * @return array{0: string[], 1: array}
289 */
290 private function whereForTrip(int $tripId, array $filters): array
291 {
292 $table = esc_sql($this->table);
293 $where = ['trip_id = %d'];
294 $params = [$tripId];
295
296 // Status filter. 'past' is date-derived (see Departure::calculateStatus),
297 // NOT the stored status column — that column is only kept current by the
298 // daily cron, so filtering status = 'past' hid every departure that had
299 // taken place whenever the cron had not run. Match 'past' by date instead;
300 // all other statuses (upcoming/full/cancelled/trash) use the stored value.
301 $statusIsPast = (!empty($filters['status']) && $filters['status'] === 'past');
302 if (!empty($filters['status']) && $filters['status'] !== 'all') {
303 if ($statusIsPast) {
304 $where[] = "(
305 (end_date IS NOT NULL AND end_date < CURDATE())
306 OR (end_date IS NULL AND start_date IS NOT NULL AND start_date < CURDATE())
307 OR (end_date IS NULL AND start_date IS NULL AND date < CURDATE())
308 )";
309 } else {
310 $this->applyStatusClause((string) $filters['status'], $where, $params);
311 }
312 }
313
314 // Independent capacity filter (see applyAvailabilityClause).
315 $this->applyAvailabilityClause($filters, $where);
316
317 // Free-text search on date / notes (see applySearchClause).
318 $this->applySearchClause($filters, $where, $params);
319
320 // Date range filter - check both start_date and date columns
321 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
322 $hasStartDate = in_array('start_date', $columns, true);
323
324 if (!empty($filters['date_from']) && trim($filters['date_from']) !== '') {
325 if ($hasStartDate) {
326 // NOTE: start_date is a DATE column in some installs; comparing to "" can trigger
327 // "Incorrect DATE value: ''" under strict SQL modes. Treat "0000-00-00" as empty.
328 $where[] = '((start_date IS NOT NULL AND start_date <> %s AND start_date >= %s) OR ((start_date IS NULL OR start_date = %s) AND date >= %s))';
329 $params[] = '0000-00-00';
330 $params[] = $filters['date_from'];
331 $params[] = '0000-00-00';
332 $params[] = $filters['date_from'];
333 } else {
334 $where[] = 'date >= %s';
335 $params[] = $filters['date_from'];
336 }
337 }
338
339 if (!empty($filters['date_to']) && trim($filters['date_to']) !== '') {
340 if ($hasStartDate) {
341 // See note above re strict DATE comparisons and empty string.
342 $where[] = '((start_date IS NOT NULL AND start_date <> %s AND start_date <= %s) OR ((start_date IS NULL OR start_date = %s) AND date <= %s))';
343 $params[] = '0000-00-00';
344 $params[] = $filters['date_to'];
345 $params[] = '0000-00-00';
346 $params[] = $filters['date_to'];
347 } else {
348 $where[] = 'date <= %s';
349 $params[] = $filters['date_to'];
350 }
351 }
352
353 // Source filter
354 if (!empty($filters['source']) && $filters['source'] !== 'all') {
355 $where[] = 'source = %s';
356 $params[] = $filters['source'];
357 }
358
359 // Past/upcoming filter. Never exclude past dates when the caller is asking
360 // for the 'past' status — that combination is contradictory and returned
361 // nothing (the exact reason completed departures vanished from the Past tab).
362 if (isset($filters['include_past'])) {
363 if (!$filters['include_past'] && !$statusIsPast) {
364 $where[] = 'date >= CURDATE()';
365 }
366 }
367
368 // Past-only filter — for the "Past Departures" archive. Deliberately
369 // DATE-based (mirrors the end_date ?: start_date ?: date precedence used
370 // when marking departures past) rather than checking status = 'past':
371 // that stored status is only kept current by the daily cron, so relying
372 // on it made departures that had already taken place disappear entirely
373 // whenever the cron had not run. Date is the source of truth.
374 if (!empty($filters['past_only'])) {
375 // start_date / end_date are nullable DATE columns (never ''), so guard
376 // with IS NULL / IS NOT NULL — comparing a DATE column to '' errors
377 // under MySQL strict mode.
378 $where[] = "(
379 (end_date IS NOT NULL AND end_date < CURDATE())
380 OR (end_date IS NULL AND start_date IS NOT NULL AND start_date < CURDATE())
381 OR (end_date IS NULL AND start_date IS NULL AND date < CURDATE())
382 )";
383 }
384
385 return [$where, $params];
386 }
387
388 /**
389 * Find past departures by trip ID
390 */
391 public function findPastByTripId(int $tripId, array $filters = []): array
392 {
393 // Match by date, not the stored status column (see past_only in
394 // findByTripId) — a departure that has taken place must appear here even
395 // if the daily status cron has not yet marked it 'past'.
396 $filters['past_only'] = true;
397 $filters['include_past'] = true;
398 return $this->findByTripId($tripId, $filters);
399 }
400
401 /**
402 * Find upcoming departures by trip ID
403 */
404 public function findUpcomingByTripId(int $tripId, array $filters = []): array
405 {
406 $filters['include_past'] = false;
407 return $this->findByTripId($tripId, $filters);
408 }
409
410 /**
411 * Find departure by trip ID and date (backward compatibility - uses start_date)
412 */
413 public function findByTripIdAndDate(int $tripId, string $date, ?string $time = null): ?Departure
414 {
415 return $this->findByTripIdAndStartDate($tripId, $date, $time);
416 }
417
418 /**
419 * Count departures for one trip matching the SAME filters as findByTripId()
420 * (page / per_page are ignored) — the true total behind a paginated list.
421 *
422 * Previously this kept its own, simpler WHERE (literal status = 'past'
423 * instead of the date-derived match, plain `date` columns instead of the
424 * start_date-aware range, no past_only), so it could disagree with the
425 * rows findByTripId() returned. Sharing whereForTrip() makes that
426 * impossible.
427 *
428 * @param int $tripId Trip ID.
429 * @param array $filters Same filters as findByTripId().
430 */
431 public function countByTripId(int $tripId, array $filters = []): int
432 {
433 $table = esc_sql($this->table);
434 [$where, $params] = $this->whereForTrip($tripId, $filters);
435
436 $query = "SELECT COUNT(*) FROM `{$table}` WHERE " . implode(' AND ', $where);
437
438 // trip_id is always bound, so there is always a placeholder to prepare.
439 return (int) $this->wpdb->get_var($this->wpdb->prepare($query, ...$params));
440 }
441
442 /**
443 * Create a departure
444 */
445 public function create(array $data): int
446 {
447 $table = esc_sql($this->table);
448
449 // Check which columns exist in the table
450 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
451 $hasStartDate = in_array('start_date', $columns, true);
452 $hasEndDate = in_array('end_date', $columns, true);
453
454 // Handle start_date and end_date - support both old 'date' and new format
455 $startDate = !empty($data['start_date']) ? $data['start_date'] : ($data['date'] ?? '');
456 $endDate = $data['end_date'] ?? '';
457
458 $insertData = [
459 'trip_id' => (int) ($data['trip_id'] ?? 0),
460 'date' => sanitize_text_field($startDate), // Always include for backward compatibility
461 'time' => !empty($data['time']) ? sanitize_text_field($data['time']) : null,
462 'max_capacity' => (int) ($data['max_capacity'] ?? 0),
463 'booked_count' => (int) ($data['booked_count'] ?? 0),
464 'status' => sanitize_text_field($data['status'] ?? 'upcoming'),
465 'source' => sanitize_text_field($data['source'] ?? 'booking_created'),
466 'price_override' => !empty($data['price_override']) ? (float) $data['price_override'] : null,
467 'notes' => !empty($data['notes']) ? sanitize_textarea_field($data['notes']) : null,
468 'created_at' => current_time('mysql'),
469 'updated_at' => current_time('mysql'),
470 ];
471
472 // Only add start_date and end_date if columns exist
473 if ($hasStartDate) {
474 $insertData['start_date'] = sanitize_text_field($startDate);
475 }
476 if ($hasEndDate && !empty($endDate)) {
477 $insertData['end_date'] = sanitize_text_field($endDate);
478 }
479
480 // Only add total_revenue if column exists
481 $hasTotalRevenue = in_array('total_revenue', $columns, true);
482 if ($hasTotalRevenue) {
483 $insertData['total_revenue'] = !empty($data['total_revenue']) ? (float) $data['total_revenue'] : 0.00;
484 }
485
486 // Handle price_by_traveler_type as JSON
487 if (!empty($data['price_by_traveler_type'])) {
488 $insertData['price_by_traveler_type'] = is_array($data['price_by_traveler_type'])
489 ? wp_json_encode($data['price_by_traveler_type'])
490 : $data['price_by_traveler_type'];
491 } else {
492 $insertData['price_by_traveler_type'] = null;
493 }
494
495 // Calculate status if not provided
496 if (empty($data['status'])) {
497 $departure = Departure::fromArray($insertData);
498 $insertData['status'] = $departure->calculateStatus();
499 }
500
501 // Build format array dynamically based on what we're inserting
502 $formats = [];
503 foreach ($insertData as $key => $value) {
504 if (in_array($key, ['trip_id', 'max_capacity', 'booked_count'], true)) {
505 $formats[] = '%d';
506 } elseif (in_array($key, ['price_override'], true)) {
507 $formats[] = '%f';
508 } else {
509 $formats[] = '%s';
510 }
511 }
512
513 $this->wpdb->insert($table, $insertData, $formats);
514
515 return $this->wpdb->insert_id;
516 }
517
518 /**
519 * Update a departure
520 */
521 public function update(int $id, array $data): bool
522 {
523 $table = esc_sql($this->table);
524
525 // Check which columns exist
526 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
527 $hasStartDate = in_array('start_date', $columns, true);
528 $hasEndDate = in_array('end_date', $columns, true);
529
530 $updateData = [];
531
532 if (isset($data['trip_id'])) $updateData['trip_id'] = (int) $data['trip_id'];
533
534 // Handle date fields - support both old 'date' and new 'start_date'/'end_date'
535 if (isset($data['start_date'])) {
536 if ($hasStartDate) {
537 $updateData['start_date'] = sanitize_text_field($data['start_date']);
538 }
539 $updateData['date'] = sanitize_text_field($data['start_date']); // Always update date for backward compatibility
540 } elseif (isset($data['date'])) {
541 $updateData['date'] = sanitize_text_field($data['date']);
542 if ($hasStartDate) {
543 $updateData['start_date'] = $updateData['date']; // Sync start_date
544 }
545 }
546
547 if (isset($data['end_date']) && $hasEndDate) {
548 $updateData['end_date'] = sanitize_text_field($data['end_date']);
549 }
550 if (isset($data['time'])) $updateData['time'] = !empty($data['time']) ? sanitize_text_field($data['time']) : null;
551 if (isset($data['max_capacity'])) $updateData['max_capacity'] = (int) $data['max_capacity'];
552 if (isset($data['booked_count'])) $updateData['booked_count'] = (int) $data['booked_count'];
553 if (isset($data['status'])) $updateData['status'] = sanitize_text_field($data['status']);
554 if (isset($data['source'])) $updateData['source'] = sanitize_text_field($data['source']);
555 if (isset($data['price_override'])) $updateData['price_override'] = !empty($data['price_override']) ? (float) $data['price_override'] : null;
556
557 // Only update total_revenue if column exists
558 $hasTotalRevenue = in_array('total_revenue', $columns, true);
559 if (isset($data['total_revenue']) && $hasTotalRevenue) {
560 $updateData['total_revenue'] = !empty($data['total_revenue']) ? (float) $data['total_revenue'] : 0.00;
561 }
562
563 if (isset($data['notes'])) $updateData['notes'] = !empty($data['notes']) ? sanitize_textarea_field($data['notes']) : null;
564
565 if (isset($data['price_by_traveler_type'])) {
566 $updateData['price_by_traveler_type'] = is_array($data['price_by_traveler_type'])
567 ? wp_json_encode($data['price_by_traveler_type'])
568 : $data['price_by_traveler_type'];
569 }
570
571 $updateData['updated_at'] = current_time('mysql');
572
573 // Recalculate status if date or capacity changed
574 if (isset($updateData['start_date']) || isset($updateData['end_date']) || isset($updateData['date']) ||
575 isset($updateData['max_capacity']) || isset($updateData['booked_count'])) {
576 // Get current departure to merge with updates
577 $current = $this->findModel($id);
578 if ($current) {
579 $merged = array_merge($current->toArray(), $updateData);
580 $departure = Departure::fromArray($merged);
581 $updateData['status'] = $departure->calculateStatus();
582 }
583 }
584
585 if (empty($updateData)) {
586 return false;
587 }
588
589 $formats = [];
590 foreach ($updateData as $value) {
591 if (is_int($value)) {
592 $formats[] = '%d';
593 } elseif (is_float($value)) {
594 $formats[] = '%f';
595 } else {
596 $formats[] = '%s';
597 }
598 }
599
600 return (bool) $this->wpdb->update(
601 $table,
602 $updateData,
603 ['id' => $id],
604 $formats,
605 ['%d']
606 );
607 }
608
609 /**
610 * Atomically increment booked count, refusing the write when it
611 * would exceed max_capacity.
612 *
613 * The capacity guard lives in the SQL WHERE clause — not in PHP —
614 * so concurrent writers can't both read "we have room" and then
615 * both succeed. Each writer's UPDATE either updates 1 row (the
616 * reservation succeeded; capacity was decremented atomically) or
617 * 0 rows (the seats were taken between read and write; the caller
618 * should treat this as "departure full").
619 *
620 * `max_capacity = 0` or NULL means "unlimited" — the guard
621 * intentionally allows unlimited writes in that case.
622 *
623 * Returns true only when 1 row was actually updated. Previous
624 * behaviour returned true unconditionally, which created a
625 * check-then-act overbooking race in `DepartureService::
626 * incrementBookedCount()`.
627 */
628 public function incrementBookedCount(int $id, int $amount = 1, bool $force = false): bool
629 {
630 if ($amount <= 0 || $id <= 0) return false;
631
632 $table = esc_sql($this->table);
633
634 // The capacity guard (`booked_count + %d <= max_capacity`) is
635 // the right default for direct bookings — it stops the website
636 // checkout from overselling a seat that's no longer there.
637 //
638 // For external-channel bookings (Viator / GetYourGuide / any
639 // OTA webhook) the seat has ALREADY been sold on the OTA. We
640 // MUST record the booking locally even if our view of capacity
641 // says "no room left" — refusing to record would just hide the
642 // oversell from the operator and make reconciliation impossible.
643 // Callers that own that case pass `$force = true` and the
644 // capacity clause is dropped from the WHERE.
645 $sql = "UPDATE `{$table}`
646 SET booked_count = booked_count + %d,
647 updated_at = %s
648 WHERE id = %d";
649 $args = [$amount, current_time('mysql'), $id];
650
651 if (!$force) {
652 $sql .= "
653 AND (max_capacity IS NULL
654 OR max_capacity = 0
655 OR booked_count + %d <= max_capacity)";
656 $args[] = $amount;
657 }
658
659 $result = $this->wpdb->query($this->wpdb->prepare($sql, $args));
660
661 if ($result === false) return false; // SQL error
662 if ((int) $result === 0) return false; // capacity guard rejected the write (only possible when !$force)
663
664 // Recalculate status only when the reservation actually landed.
665 $departure = $this->findModel($id);
666 if ($departure) {
667 $this->update($id, ['status' => $departure->calculateStatus()]);
668 }
669
670 return true;
671 }
672
673 /**
674 * Decrement booked count
675 */
676 public function decrementBookedCount(int $id, int $amount = 1): bool
677 {
678 $table = esc_sql($this->table);
679
680 $this->wpdb->query($this->wpdb->prepare(
681 "UPDATE `{$table}`
682 SET booked_count = GREATEST(0, booked_count - %d),
683 updated_at = %s
684 WHERE id = %d",
685 $amount,
686 current_time('mysql'),
687 $id
688 ));
689
690 // Recalculate status
691 $departure = $this->findModel($id);
692 if ($departure) {
693 $this->update($id, ['status' => $departure->calculateStatus()]);
694 }
695
696 return true;
697 }
698
699 /**
700 * Delete a departure.
701 *
702 * The booking-level policy lives in DepartureService::delete(), which is the
703 * only caller — this performs the row removal itself.
704 */
705 public function delete(int $id): bool
706 {
707 $departure = $this->findModel($id);
708
709 if (!$departure) {
710 return false;
711 }
712
713 // This previously required source === 'recurring_generated', a value the
714 // plugin never writes (departures are `booking_created` or `manual`, see
715 // Departure::$source), so the guard could never pass and every departure
716 // was undeletable.
717 $table = esc_sql($this->table);
718
719 return (bool) $this->wpdb->delete($table, ['id' => $id], ['%d']);
720 }
721
722 /**
723 * Recalculate status for all departures (for cron job)
724 */
725 public function recalculateAllStatuses(): int
726 {
727 $table = esc_sql($this->table);
728 $today = date('Y-m-d');
729
730 // The effective date is end_date ?: start_date ?: date. start_date and
731 // end_date are nullable DATE columns (never ''), so guard with IS NULL /
732 // IS NOT NULL — comparing a DATE column to '' errors under MySQL strict
733 // mode, which previously made this whole recalculation fail silently.
734 $pastCond = "(
735 (end_date IS NOT NULL AND end_date < %s) OR
736 (end_date IS NULL AND start_date IS NOT NULL AND start_date < %s) OR
737 (end_date IS NULL AND start_date IS NULL AND date < %s)
738 )";
739 $futureCond = "(
740 (end_date IS NOT NULL AND end_date >= CURDATE()) OR
741 (end_date IS NULL AND start_date IS NOT NULL AND start_date >= CURDATE()) OR
742 (end_date IS NULL AND start_date IS NULL AND date >= CURDATE())
743 )";
744
745 // Update past departures.
746 $this->wpdb->query($this->wpdb->prepare(
747 "UPDATE `{$table}`
748 SET status = 'past', updated_at = %s
749 WHERE {$pastCond}
750 AND status != 'cancelled'",
751 current_time('mysql'),
752 $today,
753 $today,
754 $today
755 ));
756
757 // Update full departures - future dates only.
758 $this->wpdb->query(
759 "UPDATE `{$table}`
760 SET status = 'full', updated_at = NOW()
761 WHERE booked_count >= max_capacity
762 AND max_capacity > 0
763 AND {$futureCond}
764 AND status NOT IN ('cancelled', 'past')"
765 );
766
767 // Update upcoming departures - future dates only.
768 $this->wpdb->query(
769 "UPDATE `{$table}`
770 SET status = 'upcoming', updated_at = NOW()
771 WHERE {$futureCond}
772 AND booked_count < max_capacity
773 AND status NOT IN ('cancelled', 'past', 'full')"
774 );
775
776 return $this->wpdb->rows_affected;
777 }
778
779 /**
780 * Add the stored-status clause for a list filter.
781 *
782 * 'upcoming' is INCLUSIVE of 'full': a departure at capacity is still a
783 * future departure. The status column conflates lifecycle with capacity
784 * (the cron overwrites 'upcoming' with 'full'), which made the Upcoming
785 * tab silently drop full departures. Capacity is its own dimension —
786 * filter it with the `availability` filter instead. 'full' remains
787 * matchable on its own so existing API consumers are unaffected.
788 *
789 * @param string $status Requested status (never 'all' / 'past' here).
790 * @param array $where WHERE fragments (by reference).
791 * @param array $params Prepare params (by reference).
792 */
793 private function applyStatusClause(string $status, array &$where, array &$params): void
794 {
795 if ($status === 'upcoming') {
796 // Fixed literals — nothing user-supplied, so no placeholder needed.
797 $where[] = "status IN ('upcoming', 'full')";
798 return;
799 }
800
801 $where[] = 'status = %s';
802 $params[] = $status;
803 }
804
805 /**
806 * Independent capacity filter, derived from booked_count / max_capacity —
807 * the source of truth — rather than the stored status, which the daily
808 * cron can leave stale. max_capacity <= 0 means unlimited (never full).
809 *
810 * available — has room (unbooked or partially booked)
811 * partial — some bookings, but not full
812 * full — at or over capacity
813 *
814 * Absent or unrecognised values add no clause, so existing callers and
815 * API consumers see no change (additive / backward compatible).
816 *
817 * @param array $filters Raw filters.
818 * @param array $where WHERE fragments (by reference).
819 */
820 private function applyAvailabilityClause(array $filters, array &$where): void
821 {
822 $availability = isset($filters['availability']) ? (string) $filters['availability'] : '';
823
824 // Every branch is a fixed literal; the value only selects a branch.
825 switch ($availability) {
826 case 'available':
827 $where[] = '(max_capacity <= 0 OR booked_count < max_capacity)';
828 break;
829 case 'partial':
830 $where[] = '(booked_count > 0 AND (max_capacity <= 0 OR booked_count < max_capacity))';
831 break;
832 case 'full':
833 $where[] = '(max_capacity > 0 AND booked_count >= max_capacity)';
834 break;
835 }
836 }
837
838 /**
839 * Free-text search for the admin list ("Search by date or notes"): matches
840 * the departure date (start — `date` is kept in sync with start_date), the
841 * end date, or the notes. The term is bound through esc_like() and a %s
842 * placeholder, so `%` / `_` / quotes in it are literal and nothing is ever
843 * interpolated into SQL. A blank / whitespace-only term adds no clause.
844 *
845 * Lives in the shared WHERE builders, so a search narrows the rows, the
846 * pagination total and the tab counts identically.
847 *
848 * @param array $filters Raw filters.
849 * @param array $where WHERE fragments (by reference).
850 * @param array $params Prepare params (by reference).
851 */
852 private function applySearchClause(array $filters, array &$where, array &$params): void
853 {
854 $term = isset($filters['search']) ? trim((string) $filters['search']) : '';
855 if ($term === '') {
856 return;
857 }
858
859 $like = '%' . $this->wpdb->esc_like($term) . '%';
860
861 // DATE columns are cast explicitly so this is a plain string LIKE under
862 // every SQL mode (no implicit DATE/string coercion, which strict modes
863 // reject for some comparisons). NULL end_date / notes simply don't match.
864 $where[] = '(CAST(date AS CHAR) LIKE %s OR CAST(end_date AS CHAR) LIKE %s OR notes LIKE %s)';
865 $params[] = $like;
866 $params[] = $like;
867 $params[] = $like;
868 }
869
870 /**
871 * Check if table supports soft delete
872 */
873 protected function hasSoftDelete(): bool
874 {
875 return false;
876 }
877 }
878
879