miler map
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
521
lib/data/next_leg.dart
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user