PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 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 All 84 releases
← All changes | app/Repositories/RecurringAvailabilityRepository.php +163 -27 3.0.3 → 3.0.16 View file →
@@ -79,8 +79,46 @@
79 79 return array_map([$this, 'hydrateRule'], $results ?: []);
80 80 }
81 81
82 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 + /**
83 121 * Count rules by trip ID
84 122 */
85 123 public function countByTripId(int $tripId, array $filters = []): int
86 124 {
@@ -105,37 +143,51 @@
105 143 );
106 144 }
107 145
108 146 /**
109 - * Get status counts for recurring rules by trip ID
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.
110 156 */
111 157 public function getStatusCounts(array $args = []): array
112 158 {
113 159 // Extract trip ID from args for backward compatibility
114 - $tripId = $args['trip_id'] ?? null;
115 -
116 - if (!$tripId) {
160 + $tripId = isset($args['trip_id']) ? (int) $args['trip_id'] : 0;
161 +
162 + if ($tripId <= 0) {
117 163 throw new \InvalidArgumentException('Trip ID is required for RecurringAvailability status counts');
118 164 }
119 -
165 +
120 166 $table = esc_sql($this->table);
121 -
167 +
122 168 $query = $this->wpdb->prepare(
123 - "SELECT
124 - COUNT(*) as all_count,
125 - SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active,
126 - SUM(CASE WHEN status = 'inactive' THEN 1 ELSE 0 END) as inactive
127 - FROM `{$table}`
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}`
128 174 WHERE trip_id = %d",
129 175 $tripId
130 176 );
131 -
177 +
132 178 $result = $this->wpdb->get_row($query, ARRAY_A);
133 -
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 +
134 186 return [
135 - 'all' => (int) ($result['all_count'] ?? 0),
136 - 'active' => (int) ($result['active'] ?? 0),
137 - 'inactive' => (int) ($result['inactive'] ?? 0),
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)),
138 190 ];
139 191 }
140 192
141 193 /**
@@ -171,23 +223,38 @@
171 223 */
172 224 public function findActiveRulesForDate(int $tripId, string $date): array
173 225 {
174 226 $table = esc_sql($this->table);
175 -
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.
176 243 $query = $this->wpdb->prepare(
177 - "SELECT * FROM `{$table}`
178 - WHERE trip_id = %d
244 + "SELECT * FROM `{$table}`
245 + WHERE trip_id = %d
179 246 AND status = 'active'
180 247 AND start_date <= %s
181 248 AND (end_date IS NULL OR end_date >= %s)
182 - ORDER BY priority DESC",
249 + ORDER BY priority DESC, created_at DESC, id DESC",
183 250 $tripId,
184 251 $date,
185 252 $date
186 253 );
187 -
254 +
188 255 $results = $this->wpdb->get_results($query);
189 -
256 +
190 257 return array_map([$this, 'hydrateRule'], $results ?: []);
191 258 }
192 259
193 260 /**
@@ -212,16 +279,22 @@
212 279 public function update(int $id, array $data): bool
213 280 {
214 281 $prepared = $this->prepareData($data);
215 282 $prepared['updated_at'] = current_time('mysql');
216 -
283 +
217 284 $result = $this->wpdb->update(
218 285 $this->table,
219 286 $prepared,
220 287 ['id' => $id]
221 288 );
222 -
223 - return $result !== false;
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;
224 297 }
225 298
226 299 /**
227 300 * Delete a rule
@@ -268,16 +341,62 @@
268 341 $pt = $data['pricing_type'];
269 342 $prepared['price_type'] = ($pt === 'percentage' || $pt === 'percent') ? 'percentage' : 'fixed';
270 343 }
271 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 +
272 351 foreach ($allowed as $field) {
273 352 if (array_key_exists($field, $data)) {
274 353 $value = $data[$field];
275 354
276 - // JSON encode array fields
277 - if (in_array($field, ['excluded_dates', 'months', 'time_slots', 'day_overrides', 'traveler_pricing'], true)) {
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)) {
278 376 if (is_array($value)) {
279 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]);
280 399 }
281 400 }
282 401
283 402 // Handle empty values
@@ -302,8 +421,25 @@
302 421 * Hydrate rule data (decode JSON fields)
303 422 */
304 423 private function hydrateRule(object $rule): object
305 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 +
306 442 // Decode JSON fields
307 443 if (!empty($rule->excluded_dates)) {
308 444 $rule->excluded_dates = json_decode($rule->excluded_dates, true) ?: [];
309 445 } else {