miler map

This commit is contained in:
2026-09-09 12:55:23 +05:30
parent 074cc0eccf
commit 127fa062ed
143 changed files with 4315 additions and 2291 deletions

View File

@@ -69,6 +69,7 @@ Future<void> migrateLegacyStores() async {
_kArrivedOrderIdsKeyBase,
_kCollectedOrderIdsKeyBase,
_kConsignmentIdsKeyBase,
_kPivotNextActionKeyBase,
_kOutForDeliveryKeyBase,
_kNotLoadedKeyBase,
_kOrderLabelsKeyBase,
@@ -115,6 +116,7 @@ Future<void> clearScopedStores() async {
_kArrivedOrderIdsKeyBase,
_kCollectedOrderIdsKeyBase,
_kConsignmentIdsKeyBase,
_kPivotNextActionKeyBase,
_kOutForDeliveryKeyBase,
_kNotLoadedKeyBase,
_kOrderLabelsKeyBase,
@@ -714,6 +716,89 @@ Future<void> forgetConsignmentIds(List<String> orderIds) async {
);
}
// ─────────────────────────────────────────────────────────────────────────
// THE PIVOT'S ROUTING ANSWER — which way this parcel went at pickup-complete
//
// `next_action` is the server naming the next leg: `start_delivery` for a
// parcel this rider delivers himself, `inward_at_hub` for one that enters the
// network. It is returned **once**, by the pivot, and it was being logged and
// thrown away — the only trace of it was `lastPivotNextAction`, a single
// in-memory string on a controller, which is not per order and does not
// survive a rebuild.
//
// So a hub-routed parcel was indistinguishable from a hyperlocal one on the
// very next poll, and `NextLegResolver` had nothing to fall back on when the
// consignment read `Created` — the state that cannot decide anything by itself.
//
// ── This is continuity, not authority ──
//
// It is rung 4 of four, below every live server reading, deliberately: it says
// what the server decided *at the pickup*, and if the server has since said
// something different the server wins. See [NextLegResolver] for the full
// precedence table.
//
// ── It should not have to exist ──
//
// The right home for this fact is the queue row. `GET /miler/bookings` does not
// carry `next_action`, which is the logged backend gap this store works around.
// When that field lands, `NextLegResolver` prefers it automatically and this
// becomes a cache nothing reads.
const String _kPivotNextActionKeyBase = 'pivot_next_action_by_order';
/// Records the `next_action` the pivot returned for [orderId].
///
/// Ignores an empty action rather than storing a blank: an absent instruction
/// and a recorded "no instruction" are different facts, and only the first is
/// true when the server said nothing.
Future<void> rememberPivotNextAction(String orderId, String action) async {
final id = orderId.trim();
final value = action.trim().toLowerCase();
if (id.isEmpty || value.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final map = await getPivotNextActions();
map[id] = value;
await prefs.setString(
await _scopedKey(_kPivotNextActionKeyBase),
jsonEncode(map),
);
debugPrint('[NEXTLEG] recorded $id → $value');
}
/// Every pivot instruction this device recorded, by order id.
Future<Map<String, String>> getPivotNextActions() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(await _scopedKey(_kPivotNextActionKeyBase));
if (raw == null || raw.isEmpty) return <String, String>{};
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) return <String, String>{};
return {
for (final e in decoded.entries)
e.key.toString(): (e.value?.toString() ?? '').toLowerCase(),
}..removeWhere((_, v) => v.isEmpty);
} catch (_) {
return <String, String>{};
}
}
/// Drops instructions for orders that are finished, so the map does not grow
/// for the life of the install.
Future<void> forgetPivotNextActions(List<String> orderIds) async {
if (orderIds.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final map = await getPivotNextActions();
var changed = false;
for (final id in orderIds) {
if (map.remove(id) != null) changed = true;
}
if (changed) {
await prefs.setString(
await _scopedKey(_kPivotNextActionKeyBase),
jsonEncode(map),
);
}
}
/// Clears ids once they are delivered — or once a short pick says they were
/// never in the box to begin with. Without this the set grows for the life of
/// the install and yesterday's run keeps today's cards off Home.

View File

@@ -444,6 +444,59 @@ class ApiConfig {
pick(['consignmentid', 'consignmentId', 'consignment_id']),
),
// ── Where this parcel goes next, in the server's own word ──
//
// Shipped with request 27. Until it existed, `next_action` was returned
// **once** — by `pickup-complete` — and the app had to keep the pivot's
// answer on the handset to survive a poll, because `Created` is both the
// state a hub-routed parcel settles on *and* the state one holds while
// the pivot is still routing it. That cache is now the fallback rather
// than the source: [NextLegResolver] prefers this field over it, so a
// reinstall, a second device or a routing the office changed mid-day all
// read the live answer.
//
// Values: `pickup`, `start_delivery`, `inward_at_hub`, `deliver`,
// `handed_to_hub`, `none`. Carried verbatim — the resolver owns the
// reading of them, and an unrecognised word must reach it intact so it
// can fall through rather than being flattened here.
'next_action': s(
pick(['next_action', 'nextaction', 'nextAction']),
),
// ── The base this parcel is to be handed in at ──
//
// A nested object, carried whole: id, name, address, pincode, latitude,
// longitude. Null on a hyperlocal parcel, which has no base leg. See
// [HandoverHub], which is the only thing that reads it.
//
// This is the field that makes a handover navigable. Before it the app
// knew a parcel was hub-routed and had no idea which building — every
// `hubLat`/`hubLng` in the codebase was the rider's own position standing
// in for one.
'next_hub': booking['next_hub'] ?? booking['nexthub'],
// ── What kind of place this is collected FROM ──
//
// Shipped with request 28. `hub` / `customer` / `merchant` / `store`, on
// the row rather than against a location master — a customer-door pickup
// has no location id at all, so a type held against locations could never
// classify one.
//
// Home titled every logistics pickup group with the rider's own base name
// before this, because the per-booking source was unreliable and a
// constant was the safer wrong answer. This is what retires that.
'pickup_source_type': s(
pick(['pickup_source_type', 'pickupsourcetype', 'pickupSourceType']),
),
// The counter's own name, as the hub holds it. Distinct from
// `sourcename`, which the backend fills from `providercompany` /
// `providerlocation` and which is a contact person on some rows — that is
// how the biggest type on Home once read `Sudharsan`.
'pickup_source_name': s(
pick(['pickup_source_name', 'pickupsourcename', 'pickupSourceName']),
),
// ── The hub's solved position in the route ──
//
// Verified live 21 Aug 2026: `GET /miler/bookings` carries `step` on

View File

@@ -279,6 +279,65 @@ abstract final class MilerLifecycle {
);
}
/// `POST /miler/consignments/:id/inward-at-hub`.
///
/// Confirmed when the response says the parcel is now the base's —
/// `Inwarded_at_Hub`, or `already_inwarded: true` for a retry whose first
/// attempt landed and whose answer was lost.
///
/// ── Why `already_inwarded` is a success and not an error ──
///
/// A rider hands a parcel over at a loading bay, the reply is dropped, and he
/// presses again. The server has already done the work; refusing the second
/// press would tell him the hand-over failed for a parcel now sitting on the
/// base's counter, and he has no way to prove otherwise from where he is
/// standing. So the second answer confirms the first — which is exactly what
/// the backend's 200-with-a-flag shape is for, and the app must not turn it
/// back into a failure.
static StateTransition inwardAtHub(ApiResult res) {
if (!res.ok) {
return StateTransition(
outcome: TransitionOutcome.refused,
bookingStatus: BookingStatus.unknown,
consignmentState: ConsignmentState.unknown,
code: res.code,
message: res.message,
evidence: 'HTTP ${res.status} ${res.code} ${res.message}',
);
}
final raw = _str(res.data, const [
'consignmentstatus',
'consignment_status',
'status',
]);
final state = consignmentStateFromRaw(raw);
final stamp = _str(res.data, const ['inwardedat', 'inwarded_at']);
final next = _str(res.data, const ['next_action', 'nextaction']).toLowerCase();
// `_str` skips `false`-ish values by design, so the flag is read straight.
final already =
res.data['already_inwarded'] == true ||
res.data['alreadyInwarded'] == true;
final confirmed =
state == ConsignmentState.inwardedAtHub ||
already ||
next == 'handed_to_hub';
return StateTransition(
outcome: confirmed
? TransitionOutcome.confirmed
: TransitionOutcome.unconfirmed,
bookingStatus: BookingStatus.unknown,
consignmentState: state,
nextAction: next,
evidence:
'consignment="$raw" inwardedat="$stamp" next="$next" '
'already=$already',
);
}
/// One line, in the shape a backend ticket wants.
static void report(String verb, StateTransition t) {
switch (t.outcome) {

View File

@@ -719,6 +719,77 @@ class MilerApi {
),
);
// ═══════════════════════════════════════════════════════════════════════
// THE HANDOVER — the rider gives a hub-routed parcel to a base
// ═══════════════════════════════════════════════════════════════════════
/// Hands a `Created` consignment in at a base, ending this rider's part.
///
/// ── The leg that had no route ──
///
/// A parcel bound for another district is collected at a customer's door and
/// carried to a base; the network takes it from there. The rider could do the
/// first half and had no way to record the second, so an intercity parcel sat
/// in his queue until somebody inwarded it in the console — and every one of
/// those jobs reported zero distance and zero value on `/miler/earnings`,
/// because the assignment was never closed against him.
///
/// ── The body is optional, all of it ──
///
/// Sent with nothing, the parcel is handed into the base it was already
/// routed to. [hubId] is worth sending when the app has one — it is what the
/// server reconciles against — and the coordinates are stamped onto the
/// history row as evidence of where the hand-over happened.
///
/// ── Idempotent twice over ──
///
/// The shared `Idempotency-Key` middleware covers a retry after a dropped
/// response, and a parcel already inwarded answers **200 with
/// `already_inwarded: true`** rather than a 4xx — so a rider on bad signal at
/// a loading bay who presses again is confirmed, not refused. Read the result
/// through [MilerLifecycle.inwardAtHub], which treats that reply as the
/// success it is.
///
/// Refusals carry their own reason: `CONSIGNMENT_NOT_FOUND`,
/// `CONSIGNMENT_NOT_ASSIGNED`, `HUB_REQUIRED`, `HUB_NOT_FOUND`,
/// `INVALID_STATE` for a parcel already past this leg.
static Future<ApiResult> inwardAtHub(
Object consignmentId, {
Object? hubId,
double? lat,
double? lon,
}) => _guarded(
'inward-at-hub:$consignmentId',
() => _send(
'POST',
'/miler/consignments/$consignmentId/inward-at-hub',
idempotencyKey: _idempotencyKey('inward-at-hub', consignmentId),
body: {
if (hubId != null) 'hub_id': hubId,
if (lat != null) 'latitude': lat,
if (lon != null) 'longitude': lon,
},
),
);
/// The bases this rider may hand a parcel in at.
///
/// ── Why this is not the tenant locations route ──
///
/// `GET /admin/tenants/:id/locations` is a **different dataset** — a client's
/// own sites, not bases — and `/admin/*` requires roles 1/3/4 while a rider is
/// role 5. The 401 this app has been logging as a gap on that route was by
/// design, not an oversight. This is the rider-readable one.
///
/// Returns `{id, name, address, pincode, latitude, longitude}` per base, plus
/// `distance_km` and nearest-first ordering once the rider has reported a
/// position.
static Future<ApiResult> bases({String status = 'Active'}) => _send(
'GET',
'/miler/bases',
query: {if (status.isNotEmpty) 'status': status},
);
/// The consignment's current state and what may be done to it.
///
/// Returns `status` plus the backend's own derived flags — `collected`,

View File

@@ -1,6 +1,7 @@
import 'package:miler/Models/stop_status.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/data/pickup_locations.dart';
import 'package:miler/data/next_leg.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
@@ -227,6 +228,20 @@ class MilkRun {
);
if (resolved.isNotEmpty) return resolved;
// ── The counter's own name, ahead of the account's ──
//
// `pickup_source_name` ships with request 28 and is what the hub holds for
// the place. `sourcename` is filled from `providercompany` /
// `providerlocation`, and what lands there is whoever is on the account
// rather than the counter — which is how the biggest type on Home once read
// `Sudharsan`, a person, above a distance and an ETA to a place that name
// does not identify.
//
// Below the locations table, which is still the canonical name for a base,
// and above every legacy spelling, which are guesses by comparison.
final named = (stop['pickup_source_name'] ?? '').toString().trim();
if (named.isNotEmpty) return named;
return (stop['sourcename'] ??
stop['SourceName'] ??
stop['kitchenname'] ??
@@ -281,7 +296,35 @@ class MilkRun {
static bool navigatesToCustomer(
Map<String, dynamic> stop, {
Set<String> collectedIds = const {},
/// `next_action` per order, from the pivot. See [NextLegResolver].
Map<String, String> pivotActions = const {},
}) {
// ── The order's own routing outranks the line's shape ──
//
// The two lines below are the whole logistics fix, and they are checked
// first because a per-order fact must not be overruled by a per-line one.
//
// • **hub** is the safety case. A Gandhipuram → Chennai parcel is in this
// rider's hands and its receiver is 500km away; pointing a Navigate
// button at that address sends a Coimbatore rider down a national
// highway. He is going to a hub, and the customer is context, not a
// destination.
// • **customer** is the case the line-level test could never reach.
// `deliversToCustomer` is false on logistics, so a hyperlocal parcel the
// pivot had already released into this rider's own hands could not get a
// customer destination out of this function at all.
final leg = NextLegResolver.resolve(
stop,
pivotAction: pivotActions[idOf(stop)] ?? '',
);
if (leg.isHub) return false;
if (leg.isCustomer) return true;
// No per-order answer — [NextLeg.unknown], or a stop that has not been
// collected yet. Fall back to the line's own shape, which is exactly what
// this function did before and is still right for a milk run, whose rows
// often carry no consignment state at all.
if (!ServiceProfile.active.deliversToCustomer) return false;
if (collectedIds.contains(idOf(stop))) return true;
final status = stopStatusOf(stop);

521
lib/data/next_leg.dart Normal file
View File

@@ -0,0 +1,521 @@
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;
}

View File

@@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/next_leg.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
@@ -49,6 +50,21 @@ abstract final class PickupLocations {
/// [ensureLoaded] has run and the backend has answered.
static Map<String, String> _byId = const {};
/// The full base record by id — name, address and the coordinates a hand-over
/// is navigated to. Empty until [ensureLoaded] has answered.
static Map<String, HandoverHub> _bases = const {};
/// The base for an id, or null when this build has none.
///
/// Read by the hand-over card: a base it cannot resolve is drawn with the
/// name the server put on `next_hub` and no Navigate button, which is honest.
/// Guessing a nearby base would be worse than saying nothing.
static HandoverHub? baseFor(Object? id) {
final key = id?.toString().trim() ?? '';
if (key.isEmpty || key == '0') return null;
return _bases[key];
}
/// In-flight load, so a screen rebuilding mid-fetch joins the request that
/// is already running instead of starting a second one.
static Future<void>? _loading;
@@ -68,6 +84,18 @@ abstract final class PickupLocations {
return _byId[id] ?? '';
}
/// Fills the table directly, for tests and for nothing else.
///
/// The real load goes through `/admin/tenant/:id/locations`, which a test has
/// no business standing up. Marked settled so [ensureLoaded] does not then
/// overwrite the seed with a live answer.
@visibleForTesting
static void seedForTest(Map<String, String> byId) {
_byId = Map<String, String>.from(byId);
_settled = byId.isNotEmpty;
_loading = null;
}
/// True when the table holds anything at all.
static bool get isLoaded => _byId.isNotEmpty;
@@ -96,23 +124,31 @@ abstract final class PickupLocations {
return;
}
final res = await MilerApi.tenantLocations(tenantId);
// ── The rider-readable route, at last ──
//
// This called `GET /admin/tenants/:id/locations` and logged a gap every
// time it came back 401. The backend team settled it: that route is a
// **different dataset** — a client's own sites, not bases — and `/admin/*`
// requires roles 1/3/4 while a rider is role 5. The refusal was by design
// and the gap report was wrong.
//
// `GET /miler/bases` is the one a rider may read, and it carries the
// address and coordinates the old route never did. Naming a base is no
// longer the whole job: a hand-over has to be navigated to.
final res = await MilerApi.bases();
if (!res.ok) {
// The expected outcome if riders are not admitted to `/admin`. Logged
// as a gap rather than an error: it is a question for the backend, and
// the app is already correct without it.
ApiConfig.logGap(
'admin/tenants/:id/locations',
'tenant $tenantId locations came back ${res.status} ${res.message}. '
'Pickup headings will use the booking row\'s own `sourcename`, '
'which is a contact person on some rows. If riders are meant to '
'read this route, it needs to accept a miler token.',
'miler/bases',
'bases came back ${res.status} ${res.message}. Pickup headings will '
'fall back to the booking row\'s own name, and a base hand-over '
'will have no coordinates to navigate to.',
);
_settled = true;
return;
}
final table = <String, String>{};
final bases = <String, HandoverHub>{};
for (final row in res.list) {
if (row is! Map) continue;
final m = row.map((k, v) => MapEntry(k.toString(), v));
@@ -124,6 +160,10 @@ abstract final class PickupLocations {
'location_id',
'id',
]);
// The whole base, kept beside the name — [HandoverHub] is what the
// hand-over card and its Navigate button read.
final base = HandoverHub.from(m);
if (base != null && id.isNotEmpty) bases[id] = base;
final name = _first(m, const [
'locationname',
'locationName',
@@ -142,6 +182,7 @@ abstract final class PickupLocations {
}
_byId = table;
_bases = bases;
_settled = true;
debugPrint('[LOCATIONS] tenant $tenantId → ${table.length} named');
if (table.isEmpty && kDebugMode) {
@@ -172,6 +213,7 @@ abstract final class PickupLocations {
@visibleForTesting
static void reset() {
_byId = const {};
_bases = const {};
_settled = false;
_loading = null;
}
@@ -183,3 +225,59 @@ abstract final class PickupLocations {
_settled = true;
}
}
/// ─────────────────────────────────────────────────────────────────────────
/// THE RIDER'S OWN HUB
///
/// A logistics day starts and ends at one building. The hub assigns the day's
/// bookings — collections, drops and both — hands the rider the route, and he
/// accepts the lot before he leaves. So on Home the whole run belongs to that
/// building, and the card at the head of it is named after it.
///
/// ── Why this is not read off a booking ──
///
/// The obvious source is the stop's own `pickuplocationid`, and it is right for
/// exactly half the run. A **drop** is loaded at the hub, so its pickup
/// location *is* the hub; a **collection** is picked up at the customer's door,
/// so its pickup location is the customer. Naming the head card from the
/// bookings therefore titled it with whichever address happened to sort first —
/// a customer's street on a run that starts at a depot.
///
/// The hub is a fact about the **rider**, not about any one booking. It is his
/// `locationid`, written at login from `applocationid` falling back to `hubid`,
/// and resolved to something he can read off a sign through [PickupLocations].
///
/// ── Everything here degrades to a name, never to a blocked screen ──
///
/// The id may be missing, the locations table may be empty or refused, and the
/// name may simply not be in it. Each of those yields `''`, and the caller
/// falls back to what it drew before. Nothing on this path may stop a rider
/// working — the same rule [PickupLocations] is built on.
abstract final class RiderHub {
static int _id = 0;
/// The hub's location id, or 0 before login has landed.
static int get id => _id;
/// The name a rider can read off the building, or `''` when this build has
/// none. Synchronous: it is read from `build`.
static String get name => PickupLocations.nameFor(_id);
/// Reads the id the login persisted. Cheap, and safe on every queue fetch.
static Future<void> ensureLoaded() async {
if (_id > 0) return;
try {
final prefs = await SharedPreferences.getInstance();
// `locationid` is written as `applocationid ?? hubid` — see
// `auth_provider.dart`. Both name the building this rider works out of.
_id = prefs.getInt('locationid') ?? 0;
} catch (_) {
// A device that cannot read its own prefs is a device with no hub name,
// which is a caption, not a capability.
_id = 0;
}
}
/// For tests, and for a rider who changes hub without reinstalling.
static void resetForTest([int id = 0]) => _id = id;
}

View File

@@ -659,6 +659,38 @@ class TenantController extends GetxController {
///
/// **If tenant 13 is not the milk-run client**, this line is the whole fix:
/// remove the 13 and a rider on it goes back to logistics.
/// ══ THE APP IS PINNED TO THE MILK MAN LINE ══
///
/// **This is a temporary operating decision, not a permanent one.** While it
/// is `true`, [resolveServiceProfile] returns [ServiceProfile.milkMan] before
/// it consults the rider's tenant, the token claim or the build flag — so
/// every signed-in rider works the milk round and the logistics
/// customer-pickup flow is unreachable from the UI.
///
/// ── Why it is a pin and not a deletion ──
///
/// The logistics screens are not removed, and nothing about them is edited.
/// The whole line is gated behind [ServiceProfile.handsOffAtCollection] and
/// its siblings already, so pinning the profile is enough to take it off the
/// screen — and turning it back on is this one word. Deleting the code would
/// have been a one-way door for a decision described as "as of now".
///
/// ── What it fixes today ──
///
/// Tenant resolution is name-first, and the milk-run rider's tenant is
/// spelled `Doormile` — which [logisticsTenantNames] maps, correctly and
/// deliberately, to **logistics**. So a genuine milk-man rider resolved to
/// the parcel line, and every symptom followed from that one answer: accept
/// handed off immediately and jumped him to the work tab, the Bookings page
/// offered **Start Pickup** for orders that belong on Home, and the leg the
/// distance measured to was the kitchen rather than the door.
///
/// ── Turning it off ──
///
/// Set to `false` and the app resolves per rider again, exactly as before.
/// Do that before shipping any build a logistics rider will sign in to.
static const bool pinToMilkManLine = true;
static const Set<int> milkManTenantIds = <int>{13};
/// The old name for [milkManTenantIds].
@@ -800,7 +832,19 @@ class TenantController extends GetxController {
/// seeing a payment prompt he can dismiss; the other direction takes the
/// cash-collection screen away from a live logistics rider at a door.
Future<ServiceProfile> load() async {
final resolved = await resolveServiceProfile();
// ── The pin sits here, not inside the resolver ──
//
// `resolveServiceProfile()` is a pure function over the rider's account and
// it is what the tenant tests exercise directly; short-circuiting *it* made
// 24 of them assert against a function that no longer answered the question
// they were asking. This method is the app's only caller and the single
// place the answer is published, so pinning here takes the whole app onto
// the milk round while leaving the resolution rules intact and testable.
//
// See [pinToMilkManLine] for what this is for and how to lift it.
final resolved = pinToMilkManLine
? ServiceProfile.milkMan
: await resolveServiceProfile();
ServiceProfile.setActive(resolved);
profile.value = resolved;
debugPrint('[TENANT] resolved ${resolved.label}');

View File

@@ -1,5 +1,6 @@
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/next_leg.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
@@ -91,10 +92,10 @@ abstract final class WorkBoundary {
/// True when this order is finished or withdrawn, whatever screen it was on.
///
/// [StopStatus.picked] is terminal on a line that ends at the hub and is the
/// middle of the morning on one that does not — so it is asked of the line,
/// through the same knob everything else here reads. See
/// [ServiceProfile.endsAtHub].
/// [StopStatus.picked] is terminal for some orders and the middle of the
/// morning for others, and **which one is a property of the order, not of the
/// line**. It is asked of [NextLegResolver], which reads the backend's own
/// routing answer. See the note on the picked branch below.
///
/// ── Skipped closes, and why it has to close *here* ──
///
@@ -128,6 +129,9 @@ abstract final class WorkBoundary {
static bool isClosed(
Map<String, dynamic> stop, {
Set<String> closedIds = const <String>{},
/// `next_action` recorded at each order's pivot — see [NextLegResolver].
Map<String, String> pivotActions = const <String, String>{},
}) {
if (closedIds.isNotEmpty && closedIds.contains(MilkRun.idOf(stop))) {
return true;
@@ -138,7 +142,31 @@ abstract final class WorkBoundary {
// rider walked away from and one the office called off are different
// records, and Activity slices them apart.
if (status.isSkipped) return true;
return status.isPicked && ServiceProfile.active.endsAtHub;
// ── After the pickup, ownership is a question about the ORDER ──
//
// This read `status.isPicked && ServiceProfile.active.endsAtHub`, and that
// is a line-level constant deciding a per-order fact. On logistics
// `endsAtHub` is always true, so **every collected parcel was closed at the
// pickup** — including the hyperlocal ones the pivot had just released
// `Out_for_Delivery` into this rider's own hands. He walked away from the
// sender's door carrying a parcel his app had already filed as finished.
//
// The backend decides where a parcel goes next and always has; the app now
// asks. [NextLeg.closed] is the only answer that retires the stop —
// `customer` and `hub` are both journeys the rider still owes, and
// `unknown` is a parcel in his bag whose destination has not been named,
// which must never be silently written off. See [NextLegResolver] for the
// precedence between the pivot, the row and the local record.
//
// [ServiceProfile.endsAtHub] survives as a line-level capability hint — it
// still tells the route card whether the day ends at a depot — but it no
// longer decides whether one order is finished.
if (!status.isPicked && !status.isDeliveryLeg) return false;
return NextLegResolver.resolve(
stop,
pivotAction: pivotActions[MilkRun.idOf(stop)] ?? '',
).isClosed;
}
/// Which screen owns this order.
@@ -154,8 +182,42 @@ abstract final class WorkBoundary {
/// Orders written off today — delivered, cancelled or skipped — read back
/// from the completed and skipped stores. See [isClosed].
Set<String> closedIds = const <String>{},
/// `next_action` recorded at each order's pivot — see [NextLegResolver].
Map<String, String> pivotActions = const <String, String>{},
}) {
if (isClosed(stop, closedIds: closedIds)) return WorkDomain.closed;
if (isClosed(stop, closedIds: closedIds, pivotActions: pivotActions)) {
return WorkDomain.closed;
}
// ── A collected parcel is the work tab's, whatever the line says ──
//
// Below [isClosed] and above the handoff knob, deliberately. Once the
// backend confirms the collection the parcel is physically the rider's, and
// "in my hands, still owed a journey" is the definition of the delivery
// domain on every line. Reading it here rather than from the per-line
// handoff point is what lets a hyperlocal logistics parcel reach Deliveries
// at all — its own line hands off at *acceptance*, which says nothing about
// custody.
//
// ── Gated on the collection having happened ──
//
// [NextLegResolver] answers [NextLeg.unknown] for any row it cannot place,
// and an uncollected booking is one of those: a pending assignment carries
// no consignment state and no pivot record, exactly like a collected parcel
// the server has not routed yet. Asking without this gate handed every
// pending stop to Deliveries — the dual-ownership failure in reverse.
//
// "Is it in his hands?" is [pickupComplete]'s question and it has a real
// answer: the backend's own status, or the collected record written after
// the pivot came back OK. Only then is there a leg to resolve.
if (pickupComplete(stop, collectedIds: collectedIds) &&
NextLegResolver.resolve(
stop,
pivotAction: pivotActions[MilkRun.idOf(stop)] ?? '',
).isInCustody) {
return WorkDomain.delivery;
}
final handedOver = switch (ServiceProfile.active.handoffAt) {
HandoffPoint.collected => pickupComplete(
@@ -184,6 +246,7 @@ abstract final class WorkBoundary {
Set<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
Set<String> closedIds = const <String>{},
Map<String, String> pivotActions = const <String, String>{},
}) => [
for (final stop in day)
if (domainOf(
@@ -191,6 +254,7 @@ abstract final class WorkBoundary {
collectedIds: collectedIds,
acceptedIds: acceptedIds,
closedIds: closedIds,
pivotActions: pivotActions,
) ==
WorkDomain.delivery)
stop,
@@ -202,6 +266,7 @@ abstract final class WorkBoundary {
Set<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
Set<String> closedIds = const <String>{},
Map<String, String> pivotActions = const <String, String>{},
}) => [
for (final stop in day)
if (domainOf(
@@ -209,6 +274,7 @@ abstract final class WorkBoundary {
collectedIds: collectedIds,
acceptedIds: acceptedIds,
closedIds: closedIds,
pivotActions: pivotActions,
) ==
WorkDomain.pickup)
stop,