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 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 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 row, { Map 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 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 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 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; }