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

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

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