522 lines
22 KiB
Dart
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;
|
|
}
|