← 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 { |