import 'package:flutter/foundation.dart'; import 'package:miler/data/miler_api.dart'; /// Resolves a BOOKING id into the BOOKING ASSIGNMENT id that the accept/reject /// endpoints key on. /// /// Why this exists: `POST /miler/assignments/{id}/accept` looks the row up by /// `bookingassignmentid`, but everything the app renders comes from /// `GET /miler/bookings`, whose rows only carry `bookingid`. Those two ids come /// from different sequences and do not match, so sending a booking id makes the /// backend answer 404 "assignment not found" — the rider taps Accept, sees /// success, and the booking is never actually accepted server-side. /// /// `GET /miler/assignments` is the only place the pairing is exposed, so we /// fetch it and keep a short-lived bookingid -> bookingassignmentid map. class AssignmentLookup { AssignmentLookup._(); /// bookingid (as string) -> bookingassignmentid static final Map _cache = {}; /// bookingid (as string) -> the hub's solved stop number. /// /// ── Why the sequence lives here and not on the booking ── /// /// `step` is written onto **`bookingassignments`**, not onto the booking: the /// hub's batch-assign endpoint sends each affected rider's whole active set to /// the route optimizer and writes the returned road-network order back onto /// the assignment rows. So `GET /miler/bookings` — the app's one read of the /// day — cannot carry it, and this endpoint is the only place it exists. /// /// It was being thrown away. This class fetched the assignment rows for their /// ids and dropped everything else, so `Trip.sortStops` — which documents /// `step` as authoritative and says the app must never second-guess the /// admin's order — never saw one and fell back to booked time on every run. /// The rider was choosing his own order while the hub believed it had solved /// one for him. /// /// `step: 0` means *not sequenced*, never *first*, and is not stored. static final Map _steps = {}; static DateTime? _fetchedAt; /// Assignments change whenever the hub assigns work, so the map goes stale /// quickly. Short enough to pick up new work, long enough that a burst of /// accepts doesn't refetch per tap. static const Duration _ttl = Duration(seconds: 30); /// Assignment states that are still actionable by the rider. A booking can /// legitimately have several assignment rows over its life (assign → reject → /// reassign), so when more than one exists we want the live one, not the /// rejected corpse. static const Set _actionable = {'Assigned', 'Accepted'}; static bool get _isStale { final at = _fetchedAt; return at == null || DateTime.now().difference(at) > _ttl; } static void invalidate() { _cache.clear(); _steps.clear(); _fetchedAt = null; } /// The hub's stop order, by booking id, refreshed on the same TTL as the ids. /// /// Best-effort by contract: sequencing is a separate service and the flow doc /// is explicit that it being down must leave bookings *assigned but /// unordered* rather than undo anything. An empty map therefore means "no /// solved order available", which the sort reads as "fall back to booked /// time" — not as "every stop is step 0". static Future> steps() async { if (_isStale) await _refresh(); return Map.unmodifiable(_steps); } /// The assignment id for [bookingId], or null when the backend has no /// assignment row for it (or the call fails). /// /// Refetches once on a cache miss before giving up: a booking the hub just /// assigned will not be in a cache populated moments earlier, and that is /// exactly the case the rider hits when accepting fresh work. static Future idForBooking(dynamic bookingId) async { final String key = bookingId?.toString().trim() ?? ''; if (key.isEmpty) return null; if (_isStale) { await _refresh(); } final cached = _cache[key]; if (cached != null) return cached; // Miss against a warm cache — the assignment may have been created since. await _refresh(); return _cache[key]; } static Future _refresh() async { try { // Through [MilerApi], not a hand-rolled `http.get`. It was the latter, // which meant this one call carried its own header building, its own // envelope unwrapping and its own idea of what a 2xx is — and, because it // bypassed `MilerApi.client`, it was the only request in the app that a // test could not stub, so the suite made real network calls to the // production API while checking a repository. final res = await MilerApi.assignments(); if (!res.ok) { debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.status}'); return; } final List data = res.list; if (data.isEmpty && res.data is! List) { debugPrint('[ASSIGNMENTS] no assignment rows in the response'); return; } final next = buildIndex(data); _cache ..clear() ..addAll(next); final nextSteps = buildStepIndex(data); _steps ..clear() ..addAll(nextSteps); _fetchedAt = DateTime.now(); debugPrint( '[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids, ' '${_steps.length} sequenced', ); } catch (e) { debugPrint('[ASSIGNMENTS] fetch error: $e'); } } /// Collapses the assignment rows into one bookingid -> bookingassignmentid /// entry per booking. /// /// The backend orders by `assignedat DESC`, so the first row seen for a /// booking is its newest. Keep that one, but let an actionable row override a /// newer terminal one — a booking that was assigned, rejected, then /// reassigned must resolve to the live assignment, not the rejected corpse. @visibleForTesting static Map buildIndex(List rows) { final index = {}; final tookActionable = {}; for (final row in rows.whereType()) { final bookingKey = row['bookingid']?.toString().trim() ?? ''; final assignmentId = _asInt(row['bookingassignmentid']); if (bookingKey.isEmpty || assignmentId == null) continue; final status = (row['assignmentstatus'] ?? '').toString().trim(); final isActionable = _actionable.contains(status); if (!index.containsKey(bookingKey)) { index[bookingKey] = assignmentId; if (isActionable) tookActionable.add(bookingKey); } else if (isActionable && !tookActionable.contains(bookingKey)) { index[bookingKey] = assignmentId; tookActionable.add(bookingKey); } } return index; } /// Collapses the assignment rows into one bookingid -> step entry. /// /// Same newest-first, actionable-wins rule as [buildIndex] — a booking that /// was assigned, rejected and reassigned must take the live assignment's /// sequence, not the rejected corpse's — so the two indexes cannot describe /// different assignment rows for the same booking. @visibleForTesting static Map buildStepIndex(List rows) { final index = {}; final tookActionable = {}; for (final row in rows.whereType()) { final bookingKey = row['bookingid']?.toString().trim() ?? ''; if (bookingKey.isEmpty) continue; final step = _asInt(row['step']) ?? 0; final status = (row['assignmentstatus'] ?? '').toString().trim(); final isActionable = _actionable.contains(status); final seen = index.containsKey(bookingKey) || tookActionable.contains(bookingKey); if (seen && !(isActionable && !tookActionable.contains(bookingKey))) { continue; } if (isActionable) tookActionable.add(bookingKey); // 0 is "not sequenced". Storing it would make an unsequenced stop look // like it had been solved into position zero. if (step > 0) { index[bookingKey] = step; } else { index.remove(bookingKey); } } return index; } static int? _asInt(dynamic v) { if (v is int) return v; if (v is num) return v.toInt(); return int.tryParse(v?.toString() ?? ''); } }