PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.7
Yatra – Travel Booking & Tour Operator Software v3.0.7
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 2.0.11 All 82 releases
yatra / app / Repositories / DepartureRepository.php

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

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