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

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