Files
doormile_milderapp/lib/data/next_leg.dart
2026-09-09 12:55:23 +05:30

522 lines
22 KiB
Dart

import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/consignment_state.dart';
import 'package:miler/data/milk_run.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHERE THIS PARCEL GOES NEXT — asked of the order, never of the line
///
/// Once a booking has been collected, exactly one question decides what the
/// rider does with it: **carry it to the receiver, or carry it to a hub?**
///
/// ── Why this is not [ServiceProfile.endsAtHub] ──
///
/// It used to be. `endsAtHub` is a property of the *line* — one constant for
/// every order a logistics rider will ever touch — and it was wired straight
/// into ownership: `work_domain.dart` closed a stop the moment it read
/// `isPicked && endsAtHub`. On the parcel line that is always true, so **every
/// collected parcel was closed at the pickup**.
///
/// That is right for one of the two routings and wrong for the other, and the
/// backend has always known which is which:
///
/// ```
/// hyperlocal pickup-complete → Out_for_Delivery (compatibility mode)
/// → Collected_By_Miler (flag on)
/// hub-routed pickup-complete → Created + next_action: inward_at_hub
/// ```
///
/// So a Gandhipuram → Hopes parcel — collected, released by the pivot, sitting
/// in the rider's own bag with the server calling it `Out_for_Delivery` — was
/// dropped from his screen into Activity before he left the sender's door. He
/// was carrying a parcel the app had already filed as finished.
///
/// A line-level constant cannot answer a per-order question. This can.
///
/// ── The one rule for using this ──
///
/// **The routing decision is the backend's.** Nothing in this file compares
/// pincodes, districts or city names, and nothing may be added that does.
/// Coimbatore-vs-Chennai is a question about a tariff zone and a line-haul
/// schedule, and the app knows neither. It reads the answer and executes it.
/// ─────────────────────────────────────────────────────────────────────────
enum NextLeg {
/// Carry it to the receiver. The rider releases it with **Start delivery**
/// and finishes at a customer's door.
customer,
/// Carry it to a hub and hand it over. The network takes it from there —
/// line-haul, then somebody else's last mile.
hub,
/// Nothing left for this rider: delivered, cancelled, withdrawn, or already
/// taken into hub custody by somebody else.
closed,
/// Collected, and the server has not said where it goes.
///
/// ── Why this is a state and not a default ──
///
/// It would be convenient to fold this into [customer] (the common case) or
/// [closed] (the old behaviour). Both are unsafe, and in opposite directions:
///
/// • As [customer], a Chennai parcel offers **Start delivery** and a
/// Navigate button pointed at a receiver 500km away.
/// • As [closed], a parcel in the rider's bag disappears from his screen —
/// the exact bug this file exists to fix.
///
/// So it is neither. The parcel stays in the rider's custody, on Deliveries,
/// with no destination offered and an escalation route instead. That is an
/// honest screen: he is holding something, and the app does not yet know
/// where it goes.
unknown,
}
/// One order's next leg, and the evidence it was decided on.
///
/// The evidence is carried rather than discarded because "why is this parcel
/// showing hub handover?" is a question the office will ask, and answering it
/// from a log line beats re-deriving it from four sources by hand.
class NextLegDecision {
const NextLegDecision(this.leg, {required this.source, this.evidence = ''});
final NextLeg leg;
/// Which rung of [NextLegResolver]'s precedence answered. See that class.
final String source;
/// What was actually read, in the server's own words.
final String evidence;
bool get isCustomer => leg == NextLeg.customer;
bool get isHub => leg == NextLeg.hub;
bool get isClosed => leg == NextLeg.closed;
bool get isUnknown => leg == NextLeg.unknown;
/// True while the parcel is physically the rider's: he is holding it and
/// owes it a journey, whether or not the destination is known yet.
///
/// This is what puts a row on Deliveries. [NextLeg.unknown] is deliberately
/// in it — see the note on that value.
bool get isInCustody => leg == NextLeg.customer || leg == NextLeg.hub || leg == NextLeg.unknown;
@override
String toString() => 'NextLeg.${leg.name} (via $source: $evidence)';
}
/// The server's own instruction words, exactly as the pivot sends them.
///
/// Not an enum: these are the backend's vocabulary, they are compared against
/// raw response text, and a word this app has not been told about must fall
/// through to [NextLeg.unknown] rather than fail to parse into something.
abstract final class NextActionWord {
/// Nothing collected yet — the rider is still going to fetch it.
static const String pickup = 'pickup';
/// The rider releases it and delivers it himself.
static const String startDelivery = 'start_delivery';
/// Released already; he is carrying it to the receiver.
static const String deliver = 'deliver';
/// The rider carries it to a hub and hands it in.
static const String inwardAtHub = 'inward_at_hub';
/// The hub has taken it in. Off this rider's hands.
static const String handedToHub = 'handed_to_hub';
/// Nothing further for anyone — the row is terminal.
static const String none = 'none';
}
/// Resolves [NextLeg] for one order.
///
/// ── PRECEDENCE ──
///
/// Four sources can speak, they disagree, and which one wins is the whole
/// contract of this file. In order:
///
/// **1. A terminal consignment state on the row.** `Delivered`, `Cancelled`,
/// `Returned_to_Sender` — and `Inwarded_at_Hub` / `Tripsheet_Loaded` /
/// `In_Transit`, which mean the parcel is in the network's hands and out of
/// this rider's. Nothing outranks the parcel being gone.
///
/// **2. Live `next_action` on the row.** The server naming the leg on the row
/// itself is the most direct answer there is. **The backend does not send
/// this on `GET /miler/bookings` today** — it is read here so that the day
/// it does, this resolver already prefers it over everything below. See the
/// backend gap note in [rowNextAction].
///
/// **3. Live `consignmentstatus` on the row, where it is unambiguous.**
/// `Out_for_Delivery` and `Collected_By_Miler` can only mean the rider is
/// carrying it to a customer. **`Created` cannot decide anything** — see
/// below.
///
/// **4. The persisted pivot result.** What `next_action` said at
/// `pickup-complete`, recorded per order. Continuity only: it is what keeps
/// a hub-routed parcel labelled correctly across a rebuild, a poll and a
/// cold start. It is deliberately *below* every live server reading, because
/// a local record must never outrank the server changing its mind.
///
/// Nothing answers → [NextLeg.unknown].
///
/// ── Why `Created` is not allowed to mean "hub" ──
///
/// It is tempting: the documented contract says a hub-routed pivot lands on
/// `Created`, so `Created` looks like a hub signal. It is not, because it is
/// also the state a consignment holds **while the pivot is still routing it**
/// — `delivery_actions.dart` already carries that finding and re-reads the
/// consignment because of it. A freshly-collected hyperlocal parcel and a
/// settled hub-routed one can both read `Created` on the same poll.
///
/// So `Created` is treated as *no answer* and falls through to the persisted
/// pivot result, which knows which of the two actually happened. If there is no
/// record either, the honest answer is [NextLeg.unknown] — not a guess in
/// whichever direction is cheaper to render.
abstract final class NextLegResolver {
/// `next_action` as it appears on a queue row, or `''`.
///
/// ── BACKEND GAP ──
///
/// `GET /miler/bookings` does not carry this. `next_action` is returned
/// **once**, by `POST /miler/bookings/:id/pickup-complete`, and the booking
/// adapter in `ApiConfig.pickupFromBooking` has no key for it — so a poll or
/// a restart cannot re-read the leg from the server at all, and rung 4 (the
/// persisted pivot) is doing work that a server field should be doing.
///
/// This reader exists anyway. It costs one map lookup, and on the day the
/// field is added the resolver starts preferring the live answer over the
/// local record with no further change.
static String rowNextAction(Map<String, dynamic> row) {
for (final k in const [
'next_action',
'nextaction',
'nextAction',
'next_leg',
'nextleg',
]) {
final v = row[k];
if (v == null) continue;
final s = v.toString().trim().toLowerCase();
if (s.isNotEmpty && s != 'null') return s;
}
return '';
}
/// Maps one of the server's instruction words to a leg, or null when the
/// word is absent or not one this app has been told about.
/// ── Every word the server sends, mapped on purpose ──
///
/// Four of these used to fall through to `null` and land on the right answer
/// by accident: `deliver` was caught by `Out_for_Delivery` at rung 3,
/// `handed_to_hub` by `Inwarded_at_Hub` at rung 1. That worked, and it worked
/// for reasons that had nothing to do with the word — so a state field that
/// went missing took the correct answer with it.
///
/// Two stay deliberately unmapped:
///
/// • **`pickup`** is *not yet collected*, which is not a leg. There is
/// nothing in this rider's hands to route. [WorkBoundary.domainOf] gates
/// on the collection having happened for the same reason.
/// • **`none`** means the row is terminal — and rung 1 reads that off the
/// consignment's own state, which is the stronger evidence. Letting a bare
/// word close a parcel whose state does not agree is how a stop in
/// somebody's hands gets written off; the word alone is not enough.
static NextLeg? _fromWord(String word) => switch (word) {
NextActionWord.startDelivery || NextActionWord.deliver => NextLeg.customer,
NextActionWord.inwardAtHub => NextLeg.hub,
NextActionWord.handedToHub => NextLeg.closed,
_ => null,
};
/// Resolves the leg for [row].
///
/// [pivotAction] is the persisted `next_action` recorded for this order at
/// `pickup-complete` — rung 4. Pass `''` when there is none.
///
/// This is a pure function of its arguments: no I/O, no profile read, no
/// clock. Everything it needs is handed to it, which is what makes the whole
/// precedence table testable in one file.
static NextLegDecision resolve(
Map<String, dynamic> row, {
String pivotAction = '',
}) {
final consignment = consignmentStateFromRaw(row['consignmentstatus']);
final status = stopStatusOf(row);
// ── 1. Terminal, from either vocabulary ──
if (consignment.isClosed || status == StopStatus.delivered || status.isCancelled) {
return NextLegDecision(
NextLeg.closed,
source: 'terminal',
evidence: 'consignment="${consignment.name}" status="${status.name}"',
);
}
// The network has it. `awaitsHub` is exactly the set of states that mean
// hub custody — inwarded, loaded onto a tripsheet, in transit — and none of
// them are this rider's problem any more. This is also the **only** way a
// hub-routed parcel currently leaves his queue: hub staff inward it in
// their console and the next poll retires it here. See the backend gap on
// the missing rider-side handover mutation.
if (consignment.awaitsHub) {
return NextLegDecision(
NextLeg.closed,
source: 'network-custody',
evidence: 'consignment="${consignment.name}"',
);
}
// ── 2. Live next_action on the row (not sent today; see rowNextAction) ──
final live = rowNextAction(row);
final fromLive = _fromWord(live);
if (fromLive != null) {
return NextLegDecision(
fromLive,
source: 'row.next_action',
evidence: 'next_action="$live"',
);
}
// ── 3. Live consignmentstatus, where it is unambiguous ──
//
// Both of these mean the parcel is in the rider's hands bound for a
// customer: `Collected_By_Miler` awaits his Start delivery,
// `Out_for_Delivery` is the same journey already released (compatibility
// mode releases it inside the pivot). `Created` is NOT here — see the class
// note.
if (consignment == ConsignmentState.outForDelivery ||
consignment == ConsignmentState.collectedByMiler) {
return NextLegDecision(
NextLeg.customer,
source: 'row.consignmentstatus',
evidence: 'consignment="${consignment.name}"',
);
}
// ── 4. The persisted pivot result — continuity only ──
final fromPivot = _fromWord(pivotAction.trim().toLowerCase());
if (fromPivot != null) {
return NextLegDecision(
fromPivot,
source: 'persisted-pivot',
evidence:
'pivot next_action="$pivotAction" '
'(consignment="${consignment.name}")',
);
}
// ── Nothing answered ──
return NextLegDecision(
NextLeg.unknown,
source: 'none',
evidence:
'consignment="${consignment.name}" status="${status.name}" '
'row.next_action="$live" pivot="$pivotAction"',
);
}
/// Convenience: the leg for a row whose pivot record is already in hand as a
/// map of order id → action, which is the shape the store returns.
static NextLegDecision forStop(
Map<String, dynamic> row, {
Map<String, String> pivotActions = const {},
}) => resolve(row, pivotAction: pivotActions[MilkRun.idOf(row)] ?? '');
}
/// ─────────────────────────────────────────────────────────────────────────
/// HOW THE LEG IS WORDED, IN ONE PLACE
///
/// Four surfaces have to say what the rider does next — the Deliveries row, the
/// map sheet, the stop detail sheet and Activity's record — and the last time a
/// lifecycle vocabulary was left to the widgets they invented three of them for
/// one state (see [StopStatusX.label]). A hub handover worded as a delivery is
/// worse than untidy: "Order delivered" over a parcel handed across a warehouse
/// counter is a false record of custody.
///
/// So the words live next to the decision that produces them.
/// ─────────────────────────────────────────────────────────────────────────
extension NextLegPresentation on NextLeg {
/// The eyebrow above the destination — what kind of journey this is.
String get eyebrow => switch (this) {
NextLeg.customer => 'CUSTOMER DELIVERY',
NextLeg.hub => 'BASE HANDOVER',
NextLeg.unknown => 'AWAITING ROUTING',
NextLeg.closed => '',
};
/// The act, in the rider's own words, for a completed record on Activity.
///
/// Deliberately different verbs: he *delivered* to a person and he *handed
/// over* to a building, and Activity is the one place those two must not read
/// the same.
String get completionVerb => switch (this) {
NextLeg.customer => 'Delivered to customer',
NextLeg.hub => 'Handed over at base',
NextLeg.unknown => 'Collected',
NextLeg.closed => 'Closed',
};
/// Whether the rider may release this parcel into a customer round.
///
/// **The one gate that matters.** `Start delivery` on a hub-routed parcel
/// would move a Chennai consignment to `Out_for_Delivery` against a receiver
/// this rider is never going to reach, and there is no way back from that
/// state on the handset.
bool get allowsCustomerDelivery => this == NextLeg.customer;
/// Whether this leg needs the rider to go to a hub.
bool get needsHubHandover => this == NextLeg.hub;
}
/// ─────────────────────────────────────────────────────────────────────────
/// THE BASE A PARCEL IS TO BE HANDED IN AT
///
/// `next_hub` on the booking row and on the `pickup-complete` response, read
/// once here so no screen picks its own key spellings.
///
/// ── Why the coordinates are the point ──
///
/// The app knew a parcel was hub-routed long before it knew where the base
/// was, and "hand this in somewhere" is not an instruction a rider can follow.
/// Every `hubLat`/`hubLng` in the codebase was the rider's own position
/// standing in for a building, which is fine for anchoring a route estimate and
/// useless for navigating to a gate. This is the real one.
/// ─────────────────────────────────────────────────────────────────────────
class HandoverHub {
const HandoverHub({
required this.id,
required this.name,
this.address = '',
this.pincode = '',
this.latitude = 0,
this.longitude = 0,
});
final String id;
final String name;
final String address;
final String pincode;
final double latitude;
final double longitude;
/// True when this base can actually be navigated to.
///
/// A base with a name and no coordinates is a caption, not a destination —
/// and the screen has to say so rather than opening a map on `0, 0` in the
/// Gulf of Guinea.
bool get isNavigable => latitude != 0 && longitude != 0;
/// Reads `next_hub` off a row, or null when there is no base leg.
///
/// Tolerant of key spellings for the same reason the booking adapter is: a
/// naming mismatch here is a rider with no destination, and the cost of
/// accepting three spellings is three map lookups.
static HandoverHub? from(dynamic raw) {
if (raw is! Map) return null;
final m = raw.map((k, v) => MapEntry(k.toString(), v));
String str(List<String> keys) {
for (final k in keys) {
final v = m[k];
if (v == null) continue;
final s = v.toString().trim();
if (s.isNotEmpty && s != 'null') return s;
}
return '';
}
double num_(List<String> keys) {
for (final k in keys) {
final v = m[k];
if (v == null) continue;
final d = v is num ? v.toDouble() : double.tryParse(v.toString());
if (d != null && d != 0) return d;
}
return 0;
}
final id = str(const ['id', 'hubid', 'hub_id']);
final name = str(const ['name', 'hubname', 'hub_name']);
// Neither an id nor a name is not a base — it is an empty object, and
// returning a blank one would put an untitled destination on the card.
if (id.isEmpty && name.isEmpty) return null;
return HandoverHub(
id: id,
name: name,
address: str(const ['address', 'hubaddress', 'hub_address']),
pincode: str(const ['pincode', 'hubpincode', 'hub_pincode']),
latitude: num_(const ['latitude', 'lat', 'hublatitude']),
longitude: num_(const ['longitude', 'lon', 'lng', 'hublongitude']),
);
}
}
/// What kind of place a stop is collected from — request 28's `pickup_source_type`.
///
/// ── Why this is read and never inferred ──
///
/// The app has no way to work it out. A base and a shop are both just names in
/// the same field, and a missing `pickuplocationid` means either "collected at
/// a person's front door" or "the field was not filled in" — two facts one
/// absence cannot tell apart. So this is the server's word or it is
/// [PickupSource.unknown], and Home draws a plain pickup for the latter rather
/// than guessing a kind and putting the wrong noun on the biggest type on the
/// screen.
enum PickupSource {
hub,
customer,
merchant,
store,
unknown;
/// Parses `pickup_source_type` off a row.
///
/// ── The value names match the wire, so the wire is not spelled twice ──
///
/// Each case's own [name] *is* the word the backend sends, so the primary
/// match is a name comparison rather than a table of string literals that
/// could drift from the enum beside it. The switch below holds only the
/// aliases, which are the spellings that genuinely differ.
static PickupSource of(Map<String, dynamic> row) {
final raw = (row['pickup_source_type'] ?? '')
.toString()
.trim()
.toLowerCase();
if (raw.isEmpty) return PickupSource.unknown;
for (final value in PickupSource.values) {
if (value != PickupSource.unknown && value.name == raw) return value;
}
return switch (raw) {
'base' => PickupSource.hub,
'sender' => PickupSource.customer,
'kitchen' => PickupSource.merchant,
'shop' => PickupSource.store,
// A word this build has not been told about is a generic pickup, never a
// failure and never a guess. New types can ship server-side without
// waiting on an app release.
_ => PickupSource.unknown,
};
}
/// The eyebrow over the place's name on Home.
///
/// "BASE", never "HUB" — hub is internal vocabulary and no rider-facing
/// string may carry it. See `no_hub_in_rider_copy_test.dart`.
String get eyebrow => switch (this) {
PickupSource.hub => 'BASE PICKUP',
PickupSource.customer => 'CUSTOMER PICKUP',
PickupSource.merchant => 'MERCHANT PICKUP',
PickupSource.store => 'STORE PICKUP',
PickupSource.unknown => 'PICKUP',
};
/// True where the rider collects a stack at one counter, rather than one
/// parcel from one person. Drives the bulk-collect control.
bool get isCounter =>
this == PickupSource.hub ||
this == PickupSource.merchant ||
this == PickupSource.store;
}