PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
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.13, at app/Repositories/DepartureRepository.php

674 lines 24.5 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
101 $table = esc_sql($this->table);
102 $where = ['1=1']; // Always true for base condition
103 $params = [];
104
105 // Status filter
106 if (!empty($filters['status']) && $filters['status'] !== 'all') {
107 $where[] = 'status = %s';
108 $params[] = $filters['status'];
109 }
110
111 // Date range filter - simple approach
112 if (isset($filters['date_from']) && is_string($filters['date_from']) && trim($filters['date_from']) !== '') {
113 $dateFrom = trim($filters['date_from']);
114 // Validate date format AND that it's a real date
115 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) && strtotime($dateFrom) !== false) {
116 $where[] = 'date >= %s';
117 $params[] = $dateFrom;
118 }
119 }
120
121 if (isset($filters['date_to']) && is_string($filters['date_to']) && trim($filters['date_to']) !== '') {
122 $dateTo = trim($filters['date_to']);
123 // Validate date format AND that it's a real date
124 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo) && strtotime($dateTo) !== false) {
125 $where[] = 'date <= %s';
126 $params[] = $dateTo;
127 }
128 }
129
130 // Source filter
131 if (!empty($filters['source']) && $filters['source'] !== 'all') {
132 $where[] = 'source = %s';
133 $params[] = $filters['source'];
134 }
135
136 // Past/upcoming filter
137 if (isset($filters['include_past'])) {
138 if (!$filters['include_past']) {
139 $where[] = 'date >= CURDATE()';
140 }
141 }
142
143 $query = "SELECT * FROM `{$table}` WHERE " . implode(' AND ', $where);
144 $query .= " ORDER BY date ASC, time ASC";
145
146 if (!empty($filters['per_page'])) {
147 $perPage = (int) $filters['per_page'];
148 $page = max(1, (int) ($filters['page'] ?? 1));
149 $offset = ($page - 1) * $perPage;
150 $query .= " LIMIT %d OFFSET %d";
151 $params[] = $perPage;
152 $params[] = $offset;
153 }
154
155 // Every dynamic value in this query goes through $params, so with no
156 // filters applied the SQL carries no placeholders at all — and calling
157 // prepare() on a placeholder-free query is what WordPress warns about
158 // ("The query argument of wpdb::prepare() must have a placeholder").
159 // Only prepare when there is something to bind.
160 $results = empty($params)
161 ? $this->wpdb->get_results($query, ARRAY_A)
162 : $this->wpdb->get_results($this->wpdb->prepare($query, ...$params), ARRAY_A);
163
164 if ($this->wpdb->last_error) {
165 }
166
167 return array_map(function ($row) {
168 return Departure::fromArray($row);
169 }, $results ?: []);
170 }
171
172 /**
173 * Find departures by trip ID
174 *
175 * @param int $tripId Trip ID
176 * @param array $filters Filters: status, date_from, date_to, source
177 * @return array Array of Departure models
178 */
179 public function findByTripId(int $tripId, array $filters = []): array
180 {
181
182 $table = esc_sql($this->table);
183 $where = ['trip_id = %d'];
184 $params = [$tripId];
185
186 // Status filter
187 if (!empty($filters['status']) && $filters['status'] !== 'all') {
188 $where[] = 'status = %s';
189 $params[] = $filters['status'];
190 }
191
192 // Date range filter - check both start_date and date columns
193 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
194 $hasStartDate = in_array('start_date', $columns, true);
195
196 if (!empty($filters['date_from']) && trim($filters['date_from']) !== '') {
197 if ($hasStartDate) {
198 // NOTE: start_date is a DATE column in some installs; comparing to "" can trigger
199 // "Incorrect DATE value: ''" under strict SQL modes. Treat "0000-00-00" as empty.
200 $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))';
201 $params[] = '0000-00-00';
202 $params[] = $filters['date_from'];
203 $params[] = '0000-00-00';
204 $params[] = $filters['date_from'];
205 } else {
206 $where[] = 'date >= %s';
207 $params[] = $filters['date_from'];
208 }
209 }
210
211 if (!empty($filters['date_to']) && trim($filters['date_to']) !== '') {
212 if ($hasStartDate) {
213 // See note above re strict DATE comparisons and empty string.
214 $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))';
215 $params[] = '0000-00-00';
216 $params[] = $filters['date_to'];
217 $params[] = '0000-00-00';
218 $params[] = $filters['date_to'];
219 } else {
220 $where[] = 'date <= %s';
221 $params[] = $filters['date_to'];
222 }
223 }
224
225 // Source filter
226 if (!empty($filters['source']) && $filters['source'] !== 'all') {
227 $where[] = 'source = %s';
228 $params[] = $filters['source'];
229 }
230
231 // Past/upcoming filter
232 if (isset($filters['include_past'])) {
233 if (!$filters['include_past']) {
234 $where[] = 'date >= CURDATE()';
235 }
236 }
237
238 $query = "SELECT * FROM `{$table}` WHERE " . implode(' AND ', $where);
239 $query .= " ORDER BY date ASC, time ASC";
240
241 if (!empty($filters['per_page'])) {
242 $perPage = (int) $filters['per_page'];
243 $page = max(1, (int) ($filters['page'] ?? 1));
244 $offset = ($page - 1) * $perPage;
245 $query .= " LIMIT %d OFFSET %d";
246 $params[] = $perPage;
247 $params[] = $offset;
248 }
249
250 $results = $this->wpdb->get_results(
251 $this->wpdb->prepare($query, ...$params),
252 ARRAY_A
253 );
254
255 return array_map(function ($row) {
256 return Departure::fromArray($row);
257 }, $results ?: []);
258 }
259
260 /**
261 * Find past departures by trip ID
262 */
263 public function findPastByTripId(int $tripId, array $filters = []): array
264 {
265 $filters['status'] = 'past';
266 $filters['include_past'] = true;
267 return $this->findByTripId($tripId, $filters);
268 }
269
270 /**
271 * Find upcoming departures by trip ID
272 */
273 public function findUpcomingByTripId(int $tripId, array $filters = []): array
274 {
275 $filters['include_past'] = false;
276 return $this->findByTripId($tripId, $filters);
277 }
278
279 /**
280 * Find departure by trip ID and date (backward compatibility - uses start_date)
281 */
282 public function findByTripIdAndDate(int $tripId, string $date, ?string $time = null): ?Departure
283 {
284 return $this->findByTripIdAndStartDate($tripId, $date, $time);
285 }
286
287 /**
288 * Count departures by trip ID
289 */
290 public function countByTripId(int $tripId, array $filters = []): int
291 {
292 $table = esc_sql($this->table);
293 $where = ['trip_id = %d'];
294 $params = [$tripId];
295
296 if (!empty($filters['status']) && $filters['status'] !== 'all') {
297 $where[] = 'status = %s';
298 $params[] = $filters['status'];
299 }
300
301 if (!empty($filters['date_from'])) {
302 $where[] = 'date >= %s';
303 $params[] = $filters['date_from'];
304 }
305
306 if (!empty($filters['date_to'])) {
307 $where[] = 'date <= %s';
308 $params[] = $filters['date_to'];
309 }
310
311 if (!empty($filters['source']) && $filters['source'] !== 'all') {
312 $where[] = 'source = %s';
313 $params[] = $filters['source'];
314 }
315
316 if (isset($filters['include_past']) && !$filters['include_past']) {
317 $where[] = 'date >= CURDATE()';
318 }
319
320 $query = "SELECT COUNT(*) FROM `{$table}` WHERE " . implode(' AND ', $where);
321
322 return (int) $this->wpdb->get_var($this->wpdb->prepare($query, ...$params));
323 }
324
325 /**
326 * Create a departure
327 */
328 public function create(array $data): int
329 {
330 $table = esc_sql($this->table);
331
332 // Check which columns exist in the table
333 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
334 $hasStartDate = in_array('start_date', $columns, true);
335 $hasEndDate = in_array('end_date', $columns, true);
336
337 // Handle start_date and end_date - support both old 'date' and new format
338 $startDate = !empty($data['start_date']) ? $data['start_date'] : ($data['date'] ?? '');
339 $endDate = $data['end_date'] ?? '';
340
341 $insertData = [
342 'trip_id' => (int) ($data['trip_id'] ?? 0),
343 'date' => sanitize_text_field($startDate), // Always include for backward compatibility
344 'time' => !empty($data['time']) ? sanitize_text_field($data['time']) : null,
345 'max_capacity' => (int) ($data['max_capacity'] ?? 0),
346 'booked_count' => (int) ($data['booked_count'] ?? 0),
347 'status' => sanitize_text_field($data['status'] ?? 'upcoming'),
348 'source' => sanitize_text_field($data['source'] ?? 'booking_created'),
349 'price_override' => !empty($data['price_override']) ? (float) $data['price_override'] : null,
350 'notes' => !empty($data['notes']) ? sanitize_textarea_field($data['notes']) : null,
351 'created_at' => current_time('mysql'),
352 'updated_at' => current_time('mysql'),
353 ];
354
355 // Only add start_date and end_date if columns exist
356 if ($hasStartDate) {
357 $insertData['start_date'] = sanitize_text_field($startDate);
358 }
359 if ($hasEndDate && !empty($endDate)) {
360 $insertData['end_date'] = sanitize_text_field($endDate);
361 }
362
363 // Only add total_revenue if column exists
364 $hasTotalRevenue = in_array('total_revenue', $columns, true);
365 if ($hasTotalRevenue) {
366 $insertData['total_revenue'] = !empty($data['total_revenue']) ? (float) $data['total_revenue'] : 0.00;
367 }
368
369 // Handle price_by_traveler_type as JSON
370 if (!empty($data['price_by_traveler_type'])) {
371 $insertData['price_by_traveler_type'] = is_array($data['price_by_traveler_type'])
372 ? wp_json_encode($data['price_by_traveler_type'])
373 : $data['price_by_traveler_type'];
374 } else {
375 $insertData['price_by_traveler_type'] = null;
376 }
377
378 // Calculate status if not provided
379 if (empty($data['status'])) {
380 $departure = Departure::fromArray($insertData);
381 $insertData['status'] = $departure->calculateStatus();
382 }
383
384 // Build format array dynamically based on what we're inserting
385 $formats = [];
386 foreach ($insertData as $key => $value) {
387 if (in_array($key, ['trip_id', 'max_capacity', 'booked_count'], true)) {
388 $formats[] = '%d';
389 } elseif (in_array($key, ['price_override'], true)) {
390 $formats[] = '%f';
391 } else {
392 $formats[] = '%s';
393 }
394 }
395
396 $this->wpdb->insert($table, $insertData, $formats);
397
398 return $this->wpdb->insert_id;
399 }
400
401 /**
402 * Update a departure
403 */
404 public function update(int $id, array $data): bool
405 {
406 $table = esc_sql($this->table);
407
408 // Check which columns exist
409 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
410 $hasStartDate = in_array('start_date', $columns, true);
411 $hasEndDate = in_array('end_date', $columns, true);
412
413 $updateData = [];
414
415 if (isset($data['trip_id'])) $updateData['trip_id'] = (int) $data['trip_id'];
416
417 // Handle date fields - support both old 'date' and new 'start_date'/'end_date'
418 if (isset($data['start_date'])) {
419 if ($hasStartDate) {
420 $updateData['start_date'] = sanitize_text_field($data['start_date']);
421 }
422 $updateData['date'] = sanitize_text_field($data['start_date']); // Always update date for backward compatibility
423 } elseif (isset($data['date'])) {
424 $updateData['date'] = sanitize_text_field($data['date']);
425 if ($hasStartDate) {
426 $updateData['start_date'] = $updateData['date']; // Sync start_date
427 }
428 }
429
430 if (isset($data['end_date']) && $hasEndDate) {
431 $updateData['end_date'] = sanitize_text_field($data['end_date']);
432 }
433 if (isset($data['time'])) $updateData['time'] = !empty($data['time']) ? sanitize_text_field($data['time']) : null;
434 if (isset($data['max_capacity'])) $updateData['max_capacity'] = (int) $data['max_capacity'];
435 if (isset($data['booked_count'])) $updateData['booked_count'] = (int) $data['booked_count'];
436 if (isset($data['status'])) $updateData['status'] = sanitize_text_field($data['status']);
437 if (isset($data['source'])) $updateData['source'] = sanitize_text_field($data['source']);
438 if (isset($data['price_override'])) $updateData['price_override'] = !empty($data['price_override']) ? (float) $data['price_override'] : null;
439
440 // Only update total_revenue if column exists
441 $hasTotalRevenue = in_array('total_revenue', $columns, true);
442 if (isset($data['total_revenue']) && $hasTotalRevenue) {
443 $updateData['total_revenue'] = !empty($data['total_revenue']) ? (float) $data['total_revenue'] : 0.00;
444 }
445
446 if (isset($data['notes'])) $updateData['notes'] = !empty($data['notes']) ? sanitize_textarea_field($data['notes']) : null;
447
448 if (isset($data['price_by_traveler_type'])) {
449 $updateData['price_by_traveler_type'] = is_array($data['price_by_traveler_type'])
450 ? wp_json_encode($data['price_by_traveler_type'])
451 : $data['price_by_traveler_type'];
452 }
453
454 $updateData['updated_at'] = current_time('mysql');
455
456 // Recalculate status if date or capacity changed
457 if (isset($updateData['start_date']) || isset($updateData['end_date']) || isset($updateData['date']) ||
458 isset($updateData['max_capacity']) || isset($updateData['booked_count'])) {
459 // Get current departure to merge with updates
460 $current = $this->findModel($id);
461 if ($current) {
462 $merged = array_merge($current->toArray(), $updateData);
463 $departure = Departure::fromArray($merged);
464 $updateData['status'] = $departure->calculateStatus();
465 }
466 }
467
468 if (empty($updateData)) {
469 return false;
470 }
471
472 $formats = [];
473 foreach ($updateData as $value) {
474 if (is_int($value)) {
475 $formats[] = '%d';
476 } elseif (is_float($value)) {
477 $formats[] = '%f';
478 } else {
479 $formats[] = '%s';
480 }
481 }
482
483 return (bool) $this->wpdb->update(
484 $table,
485 $updateData,
486 ['id' => $id],
487 $formats,
488 ['%d']
489 );
490 }
491
492 /**
493 * Atomically increment booked count, refusing the write when it
494 * would exceed max_capacity.
495 *
496 * The capacity guard lives in the SQL WHERE clause — not in PHP —
497 * so concurrent writers can't both read "we have room" and then
498 * both succeed. Each writer's UPDATE either updates 1 row (the
499 * reservation succeeded; capacity was decremented atomically) or
500 * 0 rows (the seats were taken between read and write; the caller
501 * should treat this as "departure full").
502 *
503 * `max_capacity = 0` or NULL means "unlimited" — the guard
504 * intentionally allows unlimited writes in that case.
505 *
506 * Returns true only when 1 row was actually updated. Previous
507 * behaviour returned true unconditionally, which created a
508 * check-then-act overbooking race in `DepartureService::
509 * incrementBookedCount()`.
510 */
511 public function incrementBookedCount(int $id, int $amount = 1, bool $force = false): bool
512 {
513 if ($amount <= 0 || $id <= 0) return false;
514
515 $table = esc_sql($this->table);
516
517 // The capacity guard (`booked_count + %d <= max_capacity`) is
518 // the right default for direct bookings — it stops the website
519 // checkout from overselling a seat that's no longer there.
520 //
521 // For external-channel bookings (Viator / GetYourGuide / any
522 // OTA webhook) the seat has ALREADY been sold on the OTA. We
523 // MUST record the booking locally even if our view of capacity
524 // says "no room left" — refusing to record would just hide the
525 // oversell from the operator and make reconciliation impossible.
526 // Callers that own that case pass `$force = true` and the
527 // capacity clause is dropped from the WHERE.
528 $sql = "UPDATE `{$table}`
529 SET booked_count = booked_count + %d,
530 updated_at = %s
531 WHERE id = %d";
532 $args = [$amount, current_time('mysql'), $id];
533
534 if (!$force) {
535 $sql .= "
536 AND (max_capacity IS NULL
537 OR max_capacity = 0
538 OR booked_count + %d <= max_capacity)";
539 $args[] = $amount;
540 }
541
542 $result = $this->wpdb->query($this->wpdb->prepare($sql, $args));
543
544 if ($result === false) return false; // SQL error
545 if ((int) $result === 0) return false; // capacity guard rejected the write (only possible when !$force)
546
547 // Recalculate status only when the reservation actually landed.
548 $departure = $this->findModel($id);
549 if ($departure) {
550 $this->update($id, ['status' => $departure->calculateStatus()]);
551 }
552
553 return true;
554 }
555
556 /**
557 * Decrement booked count
558 */
559 public function decrementBookedCount(int $id, int $amount = 1): bool
560 {
561 $table = esc_sql($this->table);
562
563 $this->wpdb->query($this->wpdb->prepare(
564 "UPDATE `{$table}`
565 SET booked_count = GREATEST(0, booked_count - %d),
566 updated_at = %s
567 WHERE id = %d",
568 $amount,
569 current_time('mysql'),
570 $id
571 ));
572
573 // Recalculate status
574 $departure = $this->findModel($id);
575 if ($departure) {
576 $this->update($id, ['status' => $departure->calculateStatus()]);
577 }
578
579 return true;
580 }
581
582 /**
583 * Delete a departure.
584 *
585 * The booking-level policy lives in DepartureService::delete(), which is the
586 * only caller — this performs the row removal itself.
587 */
588 public function delete(int $id): bool
589 {
590 $departure = $this->findModel($id);
591
592 if (!$departure) {
593 return false;
594 }
595
596 // This previously required source === 'recurring_generated', a value the
597 // plugin never writes (departures are `booking_created` or `manual`, see
598 // Departure::$source), so the guard could never pass and every departure
599 // was undeletable.
600 $table = esc_sql($this->table);
601
602 return (bool) $this->wpdb->delete($table, ['id' => $id], ['%d']);
603 }
604
605 /**
606 * Recalculate status for all departures (for cron job)
607 */
608 public function recalculateAllStatuses(): int
609 {
610 $table = esc_sql($this->table);
611 $today = date('Y-m-d');
612
613 // Update past departures - use end_date if available, otherwise start_date or date
614 $this->wpdb->query($this->wpdb->prepare(
615 "UPDATE `{$table}`
616 SET status = 'past', updated_at = %s
617 WHERE (
618 (end_date IS NOT NULL AND end_date != '' AND end_date < %s) OR
619 (end_date IS NULL OR end_date = '') AND (
620 (start_date IS NOT NULL AND start_date != '' AND start_date < %s) OR
621 (start_date IS NULL OR start_date = '') AND date < %s
622 )
623 )
624 AND status != 'cancelled'",
625 current_time('mysql'),
626 $today,
627 $today,
628 $today
629 ));
630
631 // Update full departures - check future dates only
632 $this->wpdb->query(
633 "UPDATE `{$table}`
634 SET status = 'full', updated_at = NOW()
635 WHERE booked_count >= max_capacity
636 AND max_capacity > 0
637 AND (
638 (end_date IS NOT NULL AND end_date != '' AND end_date >= CURDATE()) OR
639 (end_date IS NULL OR end_date = '') AND (
640 (start_date IS NOT NULL AND start_date != '' AND start_date >= CURDATE()) OR
641 (start_date IS NULL OR start_date = '') AND date >= CURDATE()
642 )
643 )
644 AND status NOT IN ('cancelled', 'past')"
645 );
646
647 // Update upcoming departures - check future dates only
648 $this->wpdb->query(
649 "UPDATE `{$table}`
650 SET status = 'upcoming', updated_at = NOW()
651 WHERE (
652 (end_date IS NOT NULL AND end_date != '' AND end_date >= CURDATE()) OR
653 (end_date IS NULL OR end_date = '') AND (
654 (start_date IS NOT NULL AND start_date != '' AND start_date >= CURDATE()) OR
655 (start_date IS NULL OR start_date = '') AND date >= CURDATE()
656 )
657 )
658 AND booked_count < max_capacity
659 AND status NOT IN ('cancelled', 'past', 'full')"
660 );
661
662 return $this->wpdb->rows_affected;
663 }
664
665 /**
666 * Check if table supports soft delete
667 */
668 protected function hasSoftDelete(): bool
669 {
670 return false;
671 }
672 }
673
674