PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 trunk, at app/Repositories/RecurringAvailabilityRepository.php

573 lines 20.3 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 // Callers (notably CapacityService) take the FIRST row as the winning
235 // rule, so the ordering must be total — not just `priority DESC`.
236 // `priority` is not exposed in the rule editor, so every rule carries the
237 // same default and overlapping rules tied, leaving the winner to MySQL's
238 // arbitrary row order. A trip with an older wide rule (e.g. 25 seats) plus
239 // a newer, narrower one (e.g. 1 seat for a private group) could therefore
240 // resolve to the wrong capacity and silently fall back to the trip default.
241 // Break ties by most-recently-created, matching the ordering this same
242 // repository already uses for rule listings.
243 $query = $this->wpdb->prepare(
244 "SELECT * FROM `{$table}`
245 WHERE trip_id = %d
246 AND status = 'active'
247 AND start_date <= %s
248 AND (end_date IS NULL OR end_date >= %s)
249 ORDER BY priority DESC, created_at DESC, id DESC",
250 $tripId,
251 $date,
252 $date
253 );
254
255 $results = $this->wpdb->get_results($query);
256
257 return array_map([$this, 'hydrateRule'], $results ?: []);
258 }
259
260 /**
261 * Create a new rule
262 */
263 public function create(array $data): int
264 {
265 $prepared = $this->prepareData($data);
266
267 $result = $this->wpdb->insert($this->table, $prepared);
268
269 if ($result === false) {
270 throw new \RuntimeException('Failed to create recurring rule: ' . $this->wpdb->last_error);
271 }
272
273 return (int) $this->wpdb->insert_id;
274 }
275
276 /**
277 * Update a rule
278 */
279 public function update(int $id, array $data): bool
280 {
281 $prepared = $this->prepareData($data);
282 $prepared['updated_at'] = current_time('mysql');
283
284 $result = $this->wpdb->update(
285 $this->table,
286 $prepared,
287 ['id' => $id]
288 );
289
290 if ($result === false) {
291 // Bubble the wpdb error up so the controller's catch-all returns a
292 // useful 500 message instead of the opaque "Failed to update rule".
293 throw new \RuntimeException('Failed to update recurring rule: ' . $this->wpdb->last_error);
294 }
295
296 return true;
297 }
298
299 /**
300 * Delete a rule
301 */
302 public function delete(int $id): bool
303 {
304 $result = $this->wpdb->delete($this->table, ['id' => $id]);
305 return $result !== false;
306 }
307
308 /**
309 * Find a rule by ID (override to hydrate data)
310 */
311 public function find(int $id, bool $includeDeleted = false): ?\stdClass
312 {
313 $result = parent::find($id, $includeDeleted);
314
315 if ($result) {
316 return $this->hydrateRule($result);
317 }
318
319 return null;
320 }
321
322 /**
323 * Prepare data for database
324 */
325 private function prepareData(array $data): array
326 {
327 $allowed = [
328 'trip_id', 'name', 'rule_type', 'days_of_week', 'week_of_month',
329 'day_of_week', 'interval_days', 'interval_start_date', 'start_date',
330 'end_date', 'excluded_dates', 'months', 'time_slots', 'original_price',
331 'sale_price', 'traveler_pricing', 'seats_total', 'alert_threshold',
332 'departure_time', 'arrival_time', 'from_location', 'to_location',
333 'from_latitude', 'from_longitude', 'to_latitude', 'to_longitude',
334 'cutoff_hours', 'advance_booking_days', 'day_overrides', 'status', 'priority',
335 ];
336
337 $prepared = [];
338
339 // Map API pricing_type to schema column price_type (enum fixed|percentage)
340 if (array_key_exists('pricing_type', $data)) {
341 $pt = $data['pricing_type'];
342 $prepared['price_type'] = ($pt === 'percentage' || $pt === 'percent') ? 'percentage' : 'fixed';
343 }
344
345 // Columns that are JSON in the schema. Anything written here MUST be a
346 // valid JSON document, otherwise MySQL rejects the row with
347 // "Invalid JSON text: The document root must not be followed by other
348 // values." (e.g. when a legacy CSV string like "0,1,2" is sent).
349 $jsonColumns = ['excluded_dates', 'months', 'time_slots', 'day_overrides', 'traveler_pricing', 'days_of_week'];
350
351 foreach ($allowed as $field) {
352 if (array_key_exists($field, $data)) {
353 $value = $data[$field];
354
355 // Normalise week_of_month (stored as smallint in schema) from the admin UI strings.
356 if ($field === 'week_of_month') {
357 if (is_string($value)) {
358 $map = [
359 'first' => 1,
360 'second' => 2,
361 'third' => 3,
362 'fourth' => 4,
363 'last' => 5,
364 ];
365 $key = strtolower(trim($value));
366 if (isset($map[$key])) {
367 $value = $map[$key];
368 }
369 }
370 if ($value === '' || $value === null) {
371 $value = null;
372 }
373 }
374
375 if (in_array($field, $jsonColumns, true)) {
376 if (is_array($value)) {
377 $value = wp_json_encode($value);
378 } elseif (is_string($value)) {
379 $trimmed = trim($value);
380 // Detect a value that already looks like JSON; otherwise
381 // treat as legacy CSV (only meaningful for days_of_week).
382 if ($trimmed === '' || $trimmed === 'null') {
383 $value = $field === 'days_of_week' ? wp_json_encode([]) : wp_json_encode([]);
384 } elseif ($trimmed[0] === '[' || $trimmed[0] === '{') {
385 $value = $trimmed;
386 } elseif ($field === 'days_of_week') {
387 $parts = array_values(array_filter(
388 array_map('intval', explode(',', $trimmed)),
389 static fn(int $d) => $d >= 0 && $d <= 6
390 ));
391 $value = wp_json_encode($parts);
392 } else {
393 $value = wp_json_encode([]);
394 }
395 } elseif ($value === null) {
396 $value = wp_json_encode([]);
397 } else {
398 $value = wp_json_encode([$value]);
399 }
400 }
401
402 // Handle empty values
403 if ($value === '' || $value === null) {
404 if (in_array($field, ['end_date', 'interval_start_date', 'departure_time', 'arrival_time', 'advance_booking_days'], true)) {
405 $value = null;
406 }
407 }
408
409 if (in_array($field, ['from_latitude', 'from_longitude', 'to_latitude', 'to_longitude'], true)) {
410 $value = $this->sanitizeCoordinate($value);
411 }
412
413 $prepared[$field] = $value;
414 }
415 }
416
417 return $prepared;
418 }
419
420 /**
421 * Hydrate rule data (decode JSON fields)
422 */
423 private function hydrateRule(object $rule): object
424 {
425 // Normalise week_of_month from stored int (1..5) to UI string.
426 if (isset($rule->week_of_month) && $rule->week_of_month !== null && $rule->week_of_month !== '') {
427 $w = is_numeric($rule->week_of_month) ? (int) $rule->week_of_month : null;
428 if ($w !== null) {
429 $map = [
430 1 => 'first',
431 2 => 'second',
432 3 => 'third',
433 4 => 'fourth',
434 5 => 'last',
435 ];
436 if (isset($map[$w])) {
437 $rule->week_of_month = $map[$w];
438 }
439 }
440 }
441
442 // Decode JSON fields
443 if (!empty($rule->excluded_dates)) {
444 $rule->excluded_dates = json_decode($rule->excluded_dates, true) ?: [];
445 } else {
446 $rule->excluded_dates = [];
447 }
448
449 if (!empty($rule->time_slots)) {
450 $rule->time_slots = json_decode($rule->time_slots, true) ?: [];
451 } else {
452 $rule->time_slots = [];
453 }
454
455 if (!empty($rule->day_overrides)) {
456 $rule->day_overrides = json_decode($rule->day_overrides, true) ?: [];
457 } else {
458 $rule->day_overrides = [];
459 }
460
461 if (!empty($rule->traveler_pricing)) {
462 $rule->traveler_pricing = json_decode($rule->traveler_pricing, true) ?: [];
463 // Enrich traveler pricing with category labels
464 $rule->traveler_pricing = $this->enrichTravelerPricing($rule->traveler_pricing);
465 } else {
466 $rule->traveler_pricing = [];
467 }
468
469 // CapacityService reads seats_total; fall back to capacity_value when fixed capacity
470 if (empty($rule->seats_total) && !empty($rule->capacity_value)) {
471 $capType = $rule->capacity_type ?? 'fixed';
472 if ($capType === 'fixed') {
473 $rule->seats_total = (int) $rule->capacity_value;
474 }
475 }
476
477 // Also enrich time_slots traveler_pricing
478 if (!empty($rule->time_slots)) {
479 foreach ($rule->time_slots as &$slot) {
480 if (!empty($slot['traveler_pricing'])) {
481 $slot['traveler_pricing'] = $this->enrichTravelerPricing($slot['traveler_pricing']);
482 }
483 }
484 }
485
486 // Convert days_of_week to array (JSON array from DB, or legacy comma-separated)
487 if (!empty($rule->days_of_week)) {
488 $dow = $rule->days_of_week;
489 if (is_string($dow)) {
490 $decoded = json_decode($dow, true);
491 if (is_array($decoded)) {
492 $rule->days_of_week_array = array_map('intval', $decoded);
493 } else {
494 $rule->days_of_week_array = array_map('intval', explode(',', $dow));
495 }
496 } elseif (is_array($dow)) {
497 $rule->days_of_week_array = array_map('intval', $dow);
498 } else {
499 $rule->days_of_week_array = [];
500 }
501 } else {
502 $rule->days_of_week_array = [];
503 }
504
505 // Decode months (JSON column or longtext)
506 if (!empty($rule->months)) {
507 if (is_string($rule->months)) {
508 $rule->months = json_decode($rule->months, true) ?: [];
509 } elseif (!is_array($rule->months)) {
510 $rule->months = [];
511 }
512 } else {
513 $rule->months = [];
514 }
515
516 return $rule;
517 }
518
519 /**
520 * Enrich traveler pricing with category labels from database
521 */
522 private function enrichTravelerPricing(array $pricing): array
523 {
524 if (empty($pricing)) {
525 return [];
526 }
527
528 // Get all category IDs
529 $categoryIds = array_filter(array_map(function($p) {
530 return isset($p['category_id']) ? (int) $p['category_id'] : null;
531 }, $pricing));
532
533 if (empty($categoryIds)) {
534 return $pricing;
535 }
536
537 // Fetch category details
538 // Using hardcoded table name since there's no dedicated repository for this table
539 $categories_table = $this->wpdb->prefix . 'yatra_traveler_categories';
540 $placeholders = implode(',', array_fill(0, count($categoryIds), '%d'));
541 $sql = $this->wpdb->prepare(
542 "SELECT id, label, slug, description, age_min, age_max
543 FROM {$categories_table}
544 WHERE id IN ({$placeholders})",
545 ...$categoryIds
546 );
547 $categories = $this->wpdb->get_results($sql);
548
549 // Index by ID
550 $categoryIndex = [];
551 foreach ($categories as $cat) {
552 $categoryIndex[(int) $cat->id] = $cat;
553 }
554
555 // Enrich pricing with category info
556 foreach ($pricing as &$p) {
557 $catId = isset($p['category_id']) ? (int) $p['category_id'] : null;
558 if ($catId && isset($categoryIndex[$catId])) {
559 $cat = $categoryIndex[$catId];
560 $p['category_label'] = $cat->label;
561 $p['category_slug'] = $cat->slug;
562 $p['age_min'] = $cat->age_min ? (int) $cat->age_min : null;
563 $p['age_max'] = $cat->age_max ? (int) $cat->age_max : null;
564 // Calculate effective price
565 $p['effective_price'] = \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($p);
566 }
567 }
568
569 return $pricing;
570 }
571 }
572
573