254 lines
11 KiB
Dart
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',
|
|
);
|
|
}
|
|
}
|