Files
doormile_milderapp/lib/data/route_order.dart
2026-08-28 11:13:15 +05:30

254 lines
11 KiB
Dart

import 'package:flutter/foundation.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE ADMIN'S ROUTE IS THE ROUTE
///
/// One rule, in one place, for the only question that decides what order a
/// rider works his stops in: **has the hub solved this route?**
///
/// If yes — that order is used, exactly, on both legs. The app does not
/// improve it, shorten it, or reorder it by where the rider
/// happens to be standing.
/// If no — the app falls back, and *says* it is falling back.
///
/// ── Why this needed taking out of the screens ──
///
/// It was answered in two places that could not see each other.
/// `Trip.sortStops` — the pickup leg — reads `step` and documents it as
/// authoritative. The Deliveries tab had its own sort: stops with a step
/// first, then everything else **by straight-line distance from the rider's
/// current GPS fix**. Since `step` reached the app only through
/// `GET /miler/assignments`, and that endpoint is deliberately the *active*
/// queue (`Assigned`/`Accepted` only), a booking lost its step at the moment
/// it was collected — which is exactly when the delivery leg starts.
///
/// So every delivery was ordered nearest-first, the hub believed its solved
/// sequence was being followed, and nothing on either side said otherwise.
/// A rider re-optimising a route the hub has planned is not a small
/// difference: it changes promised arrival windows the customer was given.
///
/// ── The fallback is a fallback ──
///
/// Proximity ordering is not wrong in itself — with no assigned sequence the
/// app has to pick something, and nearest-first is the best guess available.
/// What is wrong is presenting a guess as the hub's plan. [RouteOrder.sort]
/// therefore returns *which rule it applied*, so a screen can label the queue
/// honestly and a test can assert the rule rather than the resulting order.
///
/// ── One sequenced stop is enough ──
///
/// If **any** stop in the set carries a sequence, the whole set is treated as
/// admin-ordered: the sequenced stops lead in their solved order, and the rest
/// follow. Mixing the two — sorting the sequenced ones by step and the others
/// by distance — is what the Deliveries tab used to do, and it produces a list
/// that is neither the hub's route nor a sane one.
/// ─────────────────────────────────────────────────────────────────────────
enum RouteOrderSource {
/// The hub solved it. `step` (or an equivalent) came down populated.
adminSequence,
/// No sequence, but the stops carry booked times — work them in the order
/// they were promised for.
bookedTime,
/// No sequence and no times. Whatever order the backend listed them in,
/// preserved rather than replaced.
backendOrder,
/// No sequence, and the app chose nearest-first from the rider's position.
/// **A guess.** Must be labelled as one wherever it reaches a screen.
proximity,
}
extension RouteOrderSourceX on RouteOrderSource {
/// True when the order came from the hub and must not be second-guessed.
bool get isAdmin => this == RouteOrderSource.adminSequence;
/// What the rider is told the list is ordered by. Short, because it sits
/// under a heading and not in a paragraph.
String get label => switch (this) {
RouteOrderSource.adminSequence => 'Assigned route',
RouteOrderSource.bookedTime => 'By booked time',
RouteOrderSource.backendOrder => 'As assigned',
RouteOrderSource.proximity => 'Nearest first',
};
/// The longer form, for a place with room to explain — and specifically to
/// keep the app from implying the hub planned an order it did not.
String get explanation => switch (this) {
RouteOrderSource.adminSequence =>
'Ordered by the route your office assigned.',
RouteOrderSource.bookedTime =>
'No route assigned — ordered by booked time.',
RouteOrderSource.backendOrder =>
'No route assigned — shown in the order they came through.',
RouteOrderSource.proximity =>
'No route assigned — ordered by what is closest to you.',
};
}
/// A stop's place in the hub's solved route, and the sort that honours it.
abstract final class RouteOrder {
/// Field names that have carried the solved sequence.
///
/// `step` is the one the backend writes. The rest are spellings seen in the
/// wild or named in the contract for the delivery leg; reading all of them
/// costs nothing and means a rename does not silently drop the whole rule
/// back to nearest-first — the failure mode that started this.
static const List<String> sequenceKeys = [
'step',
'Step',
'deliverystep',
'deliveryStep',
'routestep',
'routeStep',
'sequence',
'routesequence',
'routeSequence',
'stopsequence',
];
/// This stop's place in the route, or `0` for *not sequenced*.
///
/// **Zero is not "first".** The hub numbers from 1, so a 0 means the
/// optimizer has not run for this stop, and treating it as position zero
/// would put unsequenced work at the head of a solved route.
static int sequenceOf(Map<String, dynamic> stop) {
for (final key in sequenceKeys) {
final raw = stop[key];
if (raw == null) continue;
final n = raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0;
if (n > 0) return n;
}
return 0;
}
/// Field names that have carried the moment the route was solved.
static const List<String> sequencedAtKeys = [
'sequencedat',
'sequencedAt',
'sequenced_at',
'routesequencedat',
];
/// True when this stop carries a solve timestamp.
///
/// ── The authority signal, confirmed by the backend ──
///
/// `sequencedat` is what says a route exists: **non-null → follow `step`
/// exactly; null → no route was assigned, use a fallback.** Sequencing is
/// automatic on every assignment — there is no operator action to wait for —
/// so `step: 0` with a null stamp means one of exactly three things: the
/// rider has fewer than two active stops, a stop is missing coordinates, or
/// the row predates the fix. None of those is a route to follow.
static bool isSequenced(Map<String, dynamic> stop) {
for (final key in sequencedAtKeys) {
final raw = stop[key];
if (raw == null) continue;
if (raw.toString().trim().isEmpty) continue;
return true;
}
return false;
}
/// True when the hub has solved an order for at least one of these stops.
///
/// The stamp is the authority and a positive `step` is accepted alongside
/// it: a deployment that populates one without the other is still telling
/// the app it has a route, and refusing to follow a numbered sequence
/// because its timestamp is missing would be reading the contract against
/// the rider. Either is enough; neither means no route.
static bool hasAdminSequence(Iterable<Map<String, dynamic>> stops) =>
stops.any((s) => isSequenced(s) || sequenceOf(s) > 0);
/// Puts [stops] in the order they are to be worked, and says which rule it
/// used.
///
/// [distanceTo] is the escape hatch for the proximity fallback: the caller
/// supplies metres from the rider to a stop, because this file is pure and
/// has no business knowing about GPS. Omit it and proximity is simply never
/// used — which is the correct behaviour with no fix available, not a
/// reason to leave the list unsorted.
static (List<Map<String, dynamic>>, RouteOrderSource) sort(
List<Map<String, dynamic>> stops, {
double Function(Map<String, dynamic> stop)? distanceTo,
DateTime? Function(Map<String, dynamic> stop)? bookedTimeOf,
}) {
if (stops.length <= 1) {
return (
List<Map<String, dynamic>>.from(stops),
hasAdminSequence(stops)
? RouteOrderSource.adminSequence
: RouteOrderSource.backendOrder,
);
}
// Indexed so every comparison can fall back to the order the backend sent,
// which makes the sort stable and keeps "unordered" meaning *unchanged*.
final indexed = <(int, Map<String, dynamic>)>[
for (var i = 0; i < stops.length; i++) (i, stops[i]),
];
if (hasAdminSequence(stops)) {
indexed.sort((a, b) {
final sa = sequenceOf(a.$2);
final sb = sequenceOf(b.$2);
if (sa > 0 && sb > 0 && sa != sb) return sa - sb;
// Sequenced work leads. An unsequenced stop appended to a solved route
// is a stop the hub did not plan for, and it goes at the end of it.
if (sa > 0 && sb == 0) return -1;
if (sb > 0 && sa == 0) return 1;
return a.$1 - b.$1;
});
return ([for (final e in indexed) e.$2], RouteOrderSource.adminSequence);
}
// ── No admin route. Only now may the app choose. ──
final times = <int, DateTime>{};
if (bookedTimeOf != null) {
for (final e in indexed) {
final t = bookedTimeOf(e.$2);
if (t != null) times[e.$1] = t;
}
}
if (times.length > 1) {
indexed.sort((a, b) {
final ta = times[a.$1];
final tb = times[b.$1];
if (ta != null && tb != null && ta != tb) return ta.compareTo(tb);
if (ta != null && tb == null) return -1;
if (tb != null && ta == null) return 1;
return a.$1 - b.$1;
});
return ([for (final e in indexed) e.$2], RouteOrderSource.bookedTime);
}
if (distanceTo != null) {
final metres = {for (final e in indexed) e.$1: distanceTo(e.$2)};
indexed.sort((a, b) {
final da = metres[a.$1] ?? double.infinity;
final db = metres[b.$1] ?? double.infinity;
if (da != db) return da.compareTo(db);
return a.$1 - b.$1;
});
return ([for (final e in indexed) e.$2], RouteOrderSource.proximity);
}
return ([for (final e in indexed) e.$2], RouteOrderSource.backendOrder);
}
/// Records that a set of stops reached a screen with no assigned order.
///
/// Not a warning about a bug in this app — it is the hub's optimizer not
/// having run. Logged so the gap is visible in a rider's log rather than
/// inferred later from a complaint about stop order.
static void logUnsequenced(String where, int count) {
if (count <= 0) return;
debugPrint(
'[ROUTE][$where] $count stop(s) with no assigned sequence — '
'ordering is the app\'s own fallback, not the hub\'s route',
);
}
}