PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 / RecurringAvailabilityRepository.php

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

564 lines 19.7 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\Database\Tables\TripAvailabilityRulesTable;
8
9 /**
10 * Recurring Availability Repository
11 * Handles database operations for recurring availability rules
12 */
13 class RecurringAvailabilityRepository extends BaseRepository
14 {
15 private function sanitizeCoordinate($value): ?string
16 {
17 if ($value === null || $value === '') {
18 return null;
19 }
20 if (is_numeric($value)) {
21 return (string) $value;
22 }
23
24 return null;
25 }
26
27 /**
28 * Get table name
29 */
30 protected function getTableName(): string
31 {
32 return TripAvailabilityRulesTable::getTableName();
33 }
34
35 /**
36 * Find all rules by trip ID
37 */
38 public function findByTripId(int $tripId, array $filters = []): array
39 {
40 $table = esc_sql($this->table);
41 $where = ['trip_id = %d'];
42 $params = [$tripId];
43
44 // Status filter
45 if (!empty($filters['status']) && $filters['status'] !== 'all') {
46 $where[] = 'status = %s';
47 $params[] = $filters['status'];
48 }
49
50 // Rule type filter
51 if (!empty($filters['rule_type']) && $filters['rule_type'] !== 'all') {
52 $where[] = 'rule_type = %s';
53 $params[] = $filters['rule_type'];
54 }
55
56 // Search filter
57 if (!empty($filters['search'])) {
58 $where[] = '(name LIKE %s OR from_location LIKE %s OR to_location LIKE %s)';
59 $search = '%' . $this->wpdb->esc_like($filters['search']) . '%';
60 $params[] = $search;
61 $params[] = $search;
62 $params[] = $search;
63 }
64
65 $query = "SELECT * FROM `{$table}` WHERE " . implode(' AND ', $where);
66 $query .= " ORDER BY priority DESC, created_at DESC";
67
68 if (!empty($filters['per_page'])) {
69 $perPage = (int) $filters['per_page'];
70 $page = max(1, (int) ($filters['page'] ?? 1));
71 $offset = ($page - 1) * $perPage;
72 $query .= $this->wpdb->prepare(" LIMIT %d OFFSET %d", $perPage, $offset);
73 }
74
75 $results = $this->wpdb->get_results(
76 $this->wpdb->prepare($query, ...$params)
77 );
78
79 return array_map([$this, 'hydrateRule'], $results ?: []);
80 }
81
82 /**
83 * Find all rules matching ANY of the given trip IDs (batched
84 * counterpart of {@see self::findByTripId()}). Used by callers like
85 * {@see \Yatra\Repositories\DestinationRepository::computeStartingPriceForTripIds()}
86 * that previously issued one query per trip and got N+1 amplification.
87 *
88 * Currently supports just the `status` filter — that's all the
89 * batched callers need; ORDER and pagination are intentionally
90 * dropped because the caller folds the rows in PHP.
91 *
92 * @param list<int> $tripIds
93 */
94 public function findByTripIds(array $tripIds, array $filters = []): array
95 {
96 $tripIds = array_values(array_unique(array_filter(array_map('intval', $tripIds), static fn (int $id): bool => $id > 0)));
97 if ($tripIds === []) {
98 return [];
99 }
100
101 $table = esc_sql($this->table);
102 $placeholders = implode(',', array_fill(0, count($tripIds), '%d'));
103 $where = ["trip_id IN ({$placeholders})"];
104 $params = $tripIds;
105
106 if (!empty($filters['status']) && $filters['status'] !== 'all') {
107 $where[] = 'status = %s';
108 $params[] = (string) $filters['status'];
109 }
110
111 $query = "SELECT * FROM `{$table}` WHERE " . implode(' AND ', $where);
112
113 $results = $this->wpdb->get_results(
114 $this->wpdb->prepare($query, ...$params)
115 );
116
117 return array_map([$this, 'hydrateRule'], $results ?: []);
118 }
119
120 /**
121 * Count rules by trip ID
122 */
123 public function countByTripId(int $tripId, array $filters = []): int
124 {
125 $table = esc_sql($this->table);
126 $where = ['trip_id = %d'];
127 $params = [$tripId];
128
129 if (!empty($filters['status']) && $filters['status'] !== 'all') {
130 $where[] = 'status = %s';
131 $params[] = $filters['status'];
132 }
133
134 if (!empty($filters['rule_type']) && $filters['rule_type'] !== 'all') {
135 $where[] = 'rule_type = %s';
136 $params[] = $filters['rule_type'];
137 }
138
139 $query = "SELECT COUNT(*) FROM `{$table}` WHERE " . implode(' AND ', $where);
140
141 return (int) $this->wpdb->get_var(
142 $this->wpdb->prepare($query, ...$params)
143 );
144 }
145
146 /**
147 * Get status counts for recurring rules by trip ID.
148 *
149 * Returns zeros for every key when the trip has no rules at all so the
150 * admin status badges (All / Active / Inactive) can never report a
151 * phantom "1" while the underlying list is empty. Guarantees:
152 * - Trip ID is required (positive integer).
153 * - SUM() over an empty result set is normalized to 0 via COALESCE.
154 * - $wpdb->get_row() returning null (table missing, transient errors)
155 * also yields a fully-zero payload instead of leaking nulls upstream.
156 */
157 public function getStatusCounts(array $args = []): array
158 {
159 // Extract trip ID from args for backward compatibility
160 $tripId = isset($args['trip_id']) ? (int) $args['trip_id'] : 0;
161
162 if ($tripId <= 0) {
163 throw new \InvalidArgumentException('Trip ID is required for RecurringAvailability status counts');
164 }
165
166 $table = esc_sql($this->table);
167
168 $query = $this->wpdb->prepare(
169 "SELECT
170 COALESCE(COUNT(*), 0) AS all_count,
171 COALESCE(SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END), 0) AS active,
172 COALESCE(SUM(CASE WHEN status = 'inactive' THEN 1 ELSE 0 END), 0) AS inactive
173 FROM `{$table}`
174 WHERE trip_id = %d",
175 $tripId
176 );
177
178 $result = $this->wpdb->get_row($query, ARRAY_A);
179
180 // Defensive default: return all-zero when the row is missing or the
181 // query failed silently (no rules table yet, DB down, etc.).
182 if (!is_array($result)) {
183 return ['all' => 0, 'active' => 0, 'inactive' => 0];
184 }
185
186 return [
187 'all' => max(0, (int) ($result['all_count'] ?? 0)),
188 'active' => max(0, (int) ($result['active'] ?? 0)),
189 'inactive' => max(0, (int) ($result['inactive'] ?? 0)),
190 ];
191 }
192
193 /**
194 * Get active rules for a trip within a date range
195 */
196 public function getActiveRulesForDateRange(int $tripId, string $fromDate, string $toDate): array
197 {
198 $table = esc_sql($this->table);
199
200 $query = $this->wpdb->prepare(
201 "SELECT * FROM `{$table}`
202 WHERE trip_id = %d
203 AND status = 'active'
204 AND start_date <= %s
205 AND (end_date IS NULL OR end_date >= %s)
206 ORDER BY priority DESC",
207 $tripId,
208 $toDate,
209 $fromDate
210 );
211
212 $results = $this->wpdb->get_results($query);
213
214 return array_map([$this, 'hydrateRule'], $results ?: []);
215 }
216
217 /**
218 * Find active rules that apply to a specific date
219 *
220 * @param int $tripId Trip ID
221 * @param string $date Date in YYYY-MM-DD format
222 * @return array Array of matching rules ordered by priority
223 */
224 public function findActiveRulesForDate(int $tripId, string $date): array
225 {
226 $table = esc_sql($this->table);
227
228 // Date-only compare: start_date/end_date are DATE columns, so a datetime
229 // input would break the `end_date >= %s` boundary (DATE treated as midnight).
230 if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $date, $m)) {
231 $date = $m[1];
232 }
233
234 $query = $this->wpdb->prepare(
235 "SELECT * FROM `{$table}`
236 WHERE trip_id = %d
237 AND status = 'active'
238 AND start_date <= %s
239 AND (end_date IS NULL OR end_date >= %s)
240 ORDER BY priority DESC",
241 $tripId,
242 $date,
243 $date
244 );
245
246 $results = $this->wpdb->get_results($query);
247
248 return array_map([$this, 'hydrateRule'], $results ?: []);
249 }
250
251 /**
252 * Create a new rule
253 */
254 public function create(array $data): int
255 {
256 $prepared = $this->prepareData($data);
257
258 $result = $this->wpdb->insert($this->table, $prepared);
259
260 if ($result === false) {
261 throw new \RuntimeException('Failed to create recurring rule: ' . $this->wpdb->last_error);
262 }
263
264 return (int) $this->wpdb->insert_id;
265 }
266
267 /**
268 * Update a rule
269 */
270 public function update(int $id, array $data): bool
271 {
272 $prepared = $this->prepareData($data);
273 $prepared['updated_at'] = current_time('mysql');
274
275 $result = $this->wpdb->update(
276 $this->table,
277 $prepared,
278 ['id' => $id]
279 );
280
281 if ($result === false) {
282 // Bubble the wpdb error up so the controller's catch-all returns a
283 // useful 500 message instead of the opaque "Failed to update rule".
284 throw new \RuntimeException('Failed to update recurring rule: ' . $this->wpdb->last_error);
285 }
286
287 return true;
288 }
289
290 /**
291 * Delete a rule
292 */
293 public function delete(int $id): bool
294 {
295 $result = $this->wpdb->delete($this->table, ['id' => $id]);
296 return $result !== false;
297 }
298
299 /**
300 * Find a rule by ID (override to hydrate data)
301 */
302 public function find(int $id, bool $includeDeleted = false): ?\stdClass
303 {
304 $result = parent::find($id, $includeDeleted);
305
306 if ($result) {
307 return $this->hydrateRule($result);
308 }
309
310 return null;
311 }
312
313 /**
314 * Prepare data for database
315 */
316 private function prepareData(array $data): array
317 {
318 $allowed = [
319 'trip_id', 'name', 'rule_type', 'days_of_week', 'week_of_month',
320 'day_of_week', 'interval_days', 'interval_start_date', 'start_date',
321 'end_date', 'excluded_dates', 'months', 'time_slots', 'original_price',
322 'sale_price', 'traveler_pricing', 'seats_total', 'alert_threshold',
323 'departure_time', 'arrival_time', 'from_location', 'to_location',
324 'from_latitude', 'from_longitude', 'to_latitude', 'to_longitude',
325 'cutoff_hours', 'advance_booking_days', 'day_overrides', 'status', 'priority',
326 ];
327
328 $prepared = [];
329
330 // Map API pricing_type to schema column price_type (enum fixed|percentage)
331 if (array_key_exists('pricing_type', $data)) {
332 $pt = $data['pricing_type'];
333 $prepared['price_type'] = ($pt === 'percentage' || $pt === 'percent') ? 'percentage' : 'fixed';
334 }
335
336 // Columns that are JSON in the schema. Anything written here MUST be a
337 // valid JSON document, otherwise MySQL rejects the row with
338 // "Invalid JSON text: The document root must not be followed by other
339 // values." (e.g. when a legacy CSV string like "0,1,2" is sent).
340 $jsonColumns = ['excluded_dates', 'months', 'time_slots', 'day_overrides', 'traveler_pricing', 'days_of_week'];
341
342 foreach ($allowed as $field) {
343 if (array_key_exists($field, $data)) {
344 $value = $data[$field];
345
346 // Normalise week_of_month (stored as smallint in schema) from the admin UI strings.
347 if ($field === 'week_of_month') {
348 if (is_string($value)) {
349 $map = [
350 'first' => 1,
351 'second' => 2,
352 'third' => 3,
353 'fourth' => 4,
354 'last' => 5,
355 ];
356 $key = strtolower(trim($value));
357 if (isset($map[$key])) {
358 $value = $map[$key];
359 }
360 }
361 if ($value === '' || $value === null) {
362 $value = null;
363 }
364 }
365
366 if (in_array($field, $jsonColumns, true)) {
367 if (is_array($value)) {
368 $value = wp_json_encode($value);
369 } elseif (is_string($value)) {
370 $trimmed = trim($value);
371 // Detect a value that already looks like JSON; otherwise
372 // treat as legacy CSV (only meaningful for days_of_week).
373 if ($trimmed === '' || $trimmed === 'null') {
374 $value = $field === 'days_of_week' ? wp_json_encode([]) : wp_json_encode([]);
375 } elseif ($trimmed[0] === '[' || $trimmed[0] === '{') {
376 $value = $trimmed;
377 } elseif ($field === 'days_of_week') {
378 $parts = array_values(array_filter(
379 array_map('intval', explode(',', $trimmed)),
380 static fn(int $d) => $d >= 0 && $d <= 6
381 ));
382 $value = wp_json_encode($parts);
383 } else {
384 $value = wp_json_encode([]);
385 }
386 } elseif ($value === null) {
387 $value = wp_json_encode([]);
388 } else {
389 $value = wp_json_encode([$value]);
390 }
391 }
392
393 // Handle empty values
394 if ($value === '' || $value === null) {
395 if (in_array($field, ['end_date', 'interval_start_date', 'departure_time', 'arrival_time', 'advance_booking_days'], true)) {
396 $value = null;
397 }
398 }
399
400 if (in_array($field, ['from_latitude', 'from_longitude', 'to_latitude', 'to_longitude'], true)) {
401 $value = $this->sanitizeCoordinate($value);
402 }
403
404 $prepared[$field] = $value;
405 }
406 }
407
408 return $prepared;
409 }
410
411 /**
412 * Hydrate rule data (decode JSON fields)
413 */
414 private function hydrateRule(object $rule): object
415 {
416 // Normalise week_of_month from stored int (1..5) to UI string.
417 if (isset($rule->week_of_month) && $rule->week_of_month !== null && $rule->week_of_month !== '') {
418 $w = is_numeric($rule->week_of_month) ? (int) $rule->week_of_month : null;
419 if ($w !== null) {
420 $map = [
421 1 => 'first',
422 2 => 'second',
423 3 => 'third',
424 4 => 'fourth',
425 5 => 'last',
426 ];
427 if (isset($map[$w])) {
428 $rule->week_of_month = $map[$w];
429 }
430 }
431 }
432
433 // Decode JSON fields
434 if (!empty($rule->excluded_dates)) {
435 $rule->excluded_dates = json_decode($rule->excluded_dates, true) ?: [];
436 } else {
437 $rule->excluded_dates = [];
438 }
439
440 if (!empty($rule->time_slots)) {
441 $rule->time_slots = json_decode($rule->time_slots, true) ?: [];
442 } else {
443 $rule->time_slots = [];
444 }
445
446 if (!empty($rule->day_overrides)) {
447 $rule->day_overrides = json_decode($rule->day_overrides, true) ?: [];
448 } else {
449 $rule->day_overrides = [];
450 }
451
452 if (!empty($rule->traveler_pricing)) {
453 $rule->traveler_pricing = json_decode($rule->traveler_pricing, true) ?: [];
454 // Enrich traveler pricing with category labels
455 $rule->traveler_pricing = $this->enrichTravelerPricing($rule->traveler_pricing);
456 } else {
457 $rule->traveler_pricing = [];
458 }
459
460 // CapacityService reads seats_total; fall back to capacity_value when fixed capacity
461 if (empty($rule->seats_total) && !empty($rule->capacity_value)) {
462 $capType = $rule->capacity_type ?? 'fixed';
463 if ($capType === 'fixed') {
464 $rule->seats_total = (int) $rule->capacity_value;
465 }
466 }
467
468 // Also enrich time_slots traveler_pricing
469 if (!empty($rule->time_slots)) {
470 foreach ($rule->time_slots as &$slot) {
471 if (!empty($slot['traveler_pricing'])) {
472 $slot['traveler_pricing'] = $this->enrichTravelerPricing($slot['traveler_pricing']);
473 }
474 }
475 }
476
477 // Convert days_of_week to array (JSON array from DB, or legacy comma-separated)
478 if (!empty($rule->days_of_week)) {
479 $dow = $rule->days_of_week;
480 if (is_string($dow)) {
481 $decoded = json_decode($dow, true);
482 if (is_array($decoded)) {
483 $rule->days_of_week_array = array_map('intval', $decoded);
484 } else {
485 $rule->days_of_week_array = array_map('intval', explode(',', $dow));
486 }
487 } elseif (is_array($dow)) {
488 $rule->days_of_week_array = array_map('intval', $dow);
489 } else {
490 $rule->days_of_week_array = [];
491 }
492 } else {
493 $rule->days_of_week_array = [];
494 }
495
496 // Decode months (JSON column or longtext)
497 if (!empty($rule->months)) {
498 if (is_string($rule->months)) {
499 $rule->months = json_decode($rule->months, true) ?: [];
500 } elseif (!is_array($rule->months)) {
501 $rule->months = [];
502 }
503 } else {
504 $rule->months = [];
505 }
506
507 return $rule;
508 }
509
510 /**
511 * Enrich traveler pricing with category labels from database
512 */
513 private function enrichTravelerPricing(array $pricing): array
514 {
515 if (empty($pricing)) {
516 return [];
517 }
518
519 // Get all category IDs
520 $categoryIds = array_filter(array_map(function($p) {
521 return isset($p['category_id']) ? (int) $p['category_id'] : null;
522 }, $pricing));
523
524 if (empty($categoryIds)) {
525 return $pricing;
526 }
527
528 // Fetch category details
529 // Using hardcoded table name since there's no dedicated repository for this table
530 $categories_table = $this->wpdb->prefix . 'yatra_traveler_categories';
531 $placeholders = implode(',', array_fill(0, count($categoryIds), '%d'));
532 $sql = $this->wpdb->prepare(
533 "SELECT id, label, slug, description, age_min, age_max
534 FROM {$categories_table}
535 WHERE id IN ({$placeholders})",
536 ...$categoryIds
537 );
538 $categories = $this->wpdb->get_results($sql);
539
540 // Index by ID
541 $categoryIndex = [];
542 foreach ($categories as $cat) {
543 $categoryIndex[(int) $cat->id] = $cat;
544 }
545
546 // Enrich pricing with category info
547 foreach ($pricing as &$p) {
548 $catId = isset($p['category_id']) ? (int) $p['category_id'] : null;
549 if ($catId && isset($categoryIndex[$catId])) {
550 $cat = $categoryIndex[$catId];
551 $p['category_label'] = $cat->label;
552 $p['category_slug'] = $cat->slug;
553 $p['age_min'] = $cat->age_min ? (int) $cat->age_min : null;
554 $p['age_max'] = $cat->age_max ? (int) $cat->age_max : null;
555 // Calculate effective price
556 $p['effective_price'] = \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($p);
557 }
558 }
559
560 return $pricing;
561 }
562 }
563
564