625 lines
29 KiB
Dart
625 lines
29 KiB
Dart
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';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// THE MILK RUN — accept per order, collect per kitchen, deliver per order.
|
||
///
|
||
/// ```
|
||
/// HOME DELIVERIES ACTIVITY
|
||
/// ──── ────────── ────────
|
||
/// 6 orders ─ accept ─┬─ Vidhya ×3 ─ arrived ─ picked ─▶ deliver ─▶ done
|
||
/// ├─ Priyanka×2 ─ arrived ─ picked ─▶ deliver ─▶ done
|
||
/// └─ ABC ×1 ─ arrived ─ picked ─▶ deliver ─▶ done
|
||
/// ```
|
||
///
|
||
/// ── The three units, and why they differ ──
|
||
///
|
||
/// **Acceptance is per order.** The rider takes on the morning's work in one
|
||
/// press, whichever counters it comes from.
|
||
///
|
||
/// **Pickup is per kitchen.** Three orders from Vidhya Kitchen are one ride,
|
||
/// one counter and one handover. He arrives once and is then given bags one at
|
||
/// a time, so arrival is grouped and collection is per bag — and a selection
|
||
/// must never span two kitchens, or he posts "arrived" for a shop he is
|
||
/// nowhere near. See [sameSourceAs].
|
||
///
|
||
/// **Delivery is per order.** Each bag goes to its own door.
|
||
///
|
||
/// Accepted orders therefore stay individually visible on Home; the kitchen is
|
||
/// what bounds a pickup operation, not what replaces the cards.
|
||
///
|
||
/// ── Collected work goes out immediately ──
|
||
///
|
||
/// There is no "start delivery" gate. A kitchen's orders become live
|
||
/// deliveries the moment they are in the rider's hands, so he can drop the
|
||
/// first three while a second kitchen is still cooking. Holding them until
|
||
/// every counter was done made the first customers wait for food already on
|
||
/// the bike.
|
||
///
|
||
/// ── How client stages map to the real backend ──
|
||
///
|
||
/// The UI has more stages than the API has statuses, which is fine as long as
|
||
/// every one either writes something real or is honestly local. None invents a
|
||
/// server state:
|
||
///
|
||
/// | Rider does | UI stage | Server call |
|
||
/// |-------------------|--------------------|--------------------------------------|
|
||
/// | Accepts work | `accepted` | `POST /assignments/:aid/accept` |
|
||
/// | Reaches a kitchen | `arrived` | `POST /bookings/:id/reached` |
|
||
/// | Takes a bag | `picked` | `POST /bookings/:id/pickup-complete` |
|
||
/// | (rides on) | `outForDelivery` | `POST /deliveries/start` ¹ |
|
||
/// | Reaches a door | `deliveryArrived` | *(local — no route exists)* ² |
|
||
/// | Hands it over | `delivered` | `POST /consignments/:cid/deliver` |
|
||
///
|
||
/// ¹ **not deployed.** Fired best-effort as part of pickup; until it ships the
|
||
/// consignments stay `Inwarded_at_Hub` and `deliver` refuses them.
|
||
/// ² **no endpoint exists.** See [deliveryArrivalIsLocalOnly].
|
||
///
|
||
/// Everything here is a pure function over the stop maps the rest of the app
|
||
/// already passes around, so it can be tested without a widget tree, a GetX
|
||
/// container or a network. Same reasoning as `AssignmentLookup.buildIndex`.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
class MilkRun {
|
||
MilkRun._();
|
||
|
||
/// **BACKEND DEPENDENCY.** There is no route that records a rider arriving at
|
||
/// a *delivery* address — `/bookings/:id/reached` keys on a booking in its
|
||
/// pickup phase, and the consignment routes have no arrival event. So the
|
||
/// `deliveryArrived` rung is held on the device and the hub sees the round
|
||
/// jump from out-for-delivery straight to delivered.
|
||
///
|
||
/// Nothing is faked to cover this: no call is made and no success is
|
||
/// invented. The rung exists because the *rider* needs the distinction — it
|
||
/// is what turns his Deliver button on at the right door — and the flag is
|
||
/// here so the gap is greppable rather than folklore.
|
||
static const bool deliveryArrivalIsLocalOnly = true;
|
||
|
||
/// Order id of a stop, however the payload spells it.
|
||
static String idOf(Map<String, dynamic> stop) =>
|
||
(stop['orderid'] ?? stop['orderId'] ?? '').toString();
|
||
|
||
/// The consignment this stop became once it was collected, or '' before that.
|
||
///
|
||
/// The delivery route keys on this and not on the booking id — they come from
|
||
/// different sequences, the same trap [AssignmentLookup] exists for on the
|
||
/// accept side. A stop without one cannot be delivered yet.
|
||
static String consignmentIdOf(Map<String, dynamic> stop) =>
|
||
(stop['consignmentid'] ?? stop['consignmentId'] ?? '').toString().trim();
|
||
|
||
// ── Which counter a stop is collected from ──
|
||
//
|
||
// Pickup is grouped by kitchen even though acceptance and delivery are per
|
||
// order. Three orders from Vidhya Kitchen are one ride, one counter and one
|
||
// handover; the rider works them together and must not be able to sweep an
|
||
// order from a different kitchen into that operation.
|
||
//
|
||
// The key is the **stable id** where the payload carries one, because two
|
||
// kitchens can share a display name across areas and the id is what the hub
|
||
// controls. A name-only payload still groups rather than collapsing into one
|
||
// nameless pile, and a stop with neither gets its own bucket rather than a
|
||
// fabricated kitchen.
|
||
|
||
/// The stable key a stop groups under for pickup.
|
||
///
|
||
/// ── The address is the last witness, and it was not being asked ──
|
||
///
|
||
/// Id, then name, then `'none'` — and `'none'` is its own bucket per the note
|
||
/// above, on the sound principle that the app must not fabricate a kitchen.
|
||
///
|
||
/// What that missed is that a stop with no source id and no source name is
|
||
/// not a stop with no *place*. It still says where it is collected from: the
|
||
/// pickup address, which is on every row, and which two orders off the same
|
||
/// counter share exactly because it is the same counter.
|
||
///
|
||
/// The cost of not asking was visible on a real morning. Eight orders came
|
||
/// from one kitchen, seven carrying its id and one not, and the eighth could
|
||
/// not be grouped with its siblings — so it fell out of the dropdown into a
|
||
/// flat header of its own, titled with the customer's name, reading as a
|
||
/// second place the rider had to ride to. Seven bags in a list and one
|
||
/// stranded above it, off the same shelf.
|
||
///
|
||
/// Grouping by address is not a fabricated kitchen: it is the weakest true
|
||
/// statement available — *these are collected from the same address* — and it
|
||
/// is only reached when the two stronger ones are absent. A row with no
|
||
/// address at all still gets `'none'`, and still stands alone.
|
||
/// [within] is the rest of the day, when the caller has it. A stop that
|
||
/// names no counter **adopts the one its neighbours name at the same
|
||
/// address** — which is the half of this that the reported case actually
|
||
/// needed: seven of the eight carried the kitchen's id and the eighth carried
|
||
/// nothing, so keying the odd one out by its address alone still left it in a
|
||
/// bucket of its own, correctly grouped with nobody.
|
||
///
|
||
/// Only ever *adopts*, never overrides: a stop with its own identity is
|
||
/// keyed by it and does not consult the day at all. So two counters that
|
||
/// share a street cannot merge, and a row cannot be pulled away from the id
|
||
/// the hub gave it.
|
||
static String sourceKeyOf(
|
||
Map<String, dynamic> stop, {
|
||
List<Map<String, dynamic>> within = const [],
|
||
}) {
|
||
final own = _identityKeyOf(stop);
|
||
if (own.isNotEmpty) return own;
|
||
|
||
final place = _addressKey(stop);
|
||
if (place.isEmpty) return 'none';
|
||
|
||
for (final other in within) {
|
||
if (_addressKey(other) != place) continue;
|
||
final identified = _identityKeyOf(other);
|
||
if (identified.isNotEmpty) return identified;
|
||
}
|
||
return 'at:$place';
|
||
}
|
||
|
||
/// The counter this stop names for itself — `id:…`, `name:…`, or `''` when
|
||
/// the payload names none.
|
||
static String _identityKeyOf(Map<String, dynamic> stop) {
|
||
final id =
|
||
(stop['sourceid'] ??
|
||
stop['kitchenid'] ??
|
||
stop['pickuplocationid'] ??
|
||
'')
|
||
.toString()
|
||
.trim();
|
||
if (id.isNotEmpty && id != '0') return 'id:$id';
|
||
final name = sourceNameOf(stop).toLowerCase();
|
||
if (name.isNotEmpty) return 'name:$name';
|
||
return '';
|
||
}
|
||
|
||
/// A pickup address reduced to something two rows can be compared on.
|
||
///
|
||
/// Case, spacing and punctuation all vary between rows the same hub wrote —
|
||
/// `12, SNS Colony` and `12 SNS COLONY ` are one place — so everything but
|
||
/// the letters and digits is dropped before comparing. Deliberately not
|
||
/// `compactAddress`: that is written to be *read*, and a key should not
|
||
/// change because a display rule was tuned.
|
||
static String _addressKey(Map<String, dynamic> stop) {
|
||
final raw =
|
||
(stop['pickupaddress'] ??
|
||
stop['PickupAddress'] ??
|
||
stop['pickup_address'] ??
|
||
'')
|
||
.toString()
|
||
.toLowerCase();
|
||
return raw.replaceAll(RegExp(r'[^a-z0-9]'), '');
|
||
}
|
||
|
||
/// The kitchen's name as the rider reads it, or '' when the payload has none.
|
||
/// **The** reader for a place's name. `stopSourceName` delegates here.
|
||
///
|
||
/// ── Two readers, one question, different answers ──
|
||
///
|
||
/// This read `sourcename` / `kitchenname`. `stopSourceName` read those *and*
|
||
/// the CamelCase `KitchenName` / `SourceName` the payload sometimes carries.
|
||
/// A booking with only the capitalised key therefore had a source according
|
||
/// to one function and none according to the other, and the route card used
|
||
/// both in the same expression:
|
||
///
|
||
/// • `stopSourceName` said "there is a counter", so the group was **not**
|
||
/// flat — the header became a foldable place with its orders under it.
|
||
/// • `sourceNameOf` said "there is no counter", so `navigationLabel` fell
|
||
/// through to its leg description and the header was titled **"pickup"**.
|
||
///
|
||
/// A group headed by the word *pickup* that folds open onto one order named
|
||
/// after the same stop — which is the shape the `flat` flag exists to
|
||
/// prevent, produced by the two halves of the decision disagreeing.
|
||
///
|
||
/// One key list, one answer.
|
||
///
|
||
/// ── The tenant's own location list wins ──
|
||
///
|
||
/// The booking row's `sourcename` is filled by the backend from
|
||
/// `providercompany` / `providerlocation`, and what lands there is often
|
||
/// whoever is on the account rather than the counter — the route card's
|
||
/// heading, the biggest type on Home, read `Sudharsan`.
|
||
///
|
||
/// The booking does carry the location's **id**, and the tenant's locations
|
||
/// are a list with proper names on them, so the name is joined rather than
|
||
/// read off the row. See [PickupLocations]: when the table is empty — not
|
||
/// loaded yet, or the rider's token is not admitted to the admin route —
|
||
/// this falls through to exactly what it returned before.
|
||
static String sourceNameOf(Map<String, dynamic> stop) {
|
||
final resolved = PickupLocations.nameFor(
|
||
stop['sourceid'] ?? stop['kitchenid'] ?? stop['pickuplocationid'],
|
||
);
|
||
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'] ??
|
||
stop['KitchenName'] ??
|
||
'')
|
||
.toString()
|
||
.trim();
|
||
}
|
||
|
||
/// True when two stops are collected from the same counter.
|
||
static bool sameSource(Map<String, dynamic> a, Map<String, dynamic> b) =>
|
||
sourceKeyOf(a) == sourceKeyOf(b);
|
||
|
||
/// Every stop collected from the same counter as [stop], out of [stops].
|
||
///
|
||
/// What the rider ticks at a kitchen, and the bound on what one pickup
|
||
/// operation may touch.
|
||
static List<Map<String, dynamic>> sameSourceAs(
|
||
Map<String, dynamic> stop,
|
||
List<Map<String, dynamic>> stops,
|
||
) {
|
||
// The day is passed on both sides so a row that names no counter is
|
||
// gathered with the ones that do — see [sourceKeyOf]. Without it the bulk
|
||
// collect would leave behind exactly the order the route card had just
|
||
// learned to group in, which is the two-readers failure in a new place.
|
||
final key = sourceKeyOf(stop, within: stops);
|
||
return [
|
||
for (final s in stops)
|
||
if (sourceKeyOf(s, within: stops) == key) s,
|
||
];
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// WHERE "NAVIGATE" GOES
|
||
//
|
||
// The same button on the same order means two different places depending on
|
||
// where the order is in its day, and getting it wrong is expensive in both
|
||
// directions: sending a rider to a customer for an order still sitting in a
|
||
// kitchen wastes the trip *and* the customer's slot, and sending him back to
|
||
// a kitchen for a bag already in his box is a ride to collect nothing.
|
||
//
|
||
// So the destination follows the stop's stage, in one place, rather than
|
||
// being decided by whichever screen happens to own the button.
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
|
||
/// Whether this stop's next journey is to the customer rather than the
|
||
/// kitchen.
|
||
///
|
||
/// True once it is collected. On a logistics booking this is always false:
|
||
/// that line's collected parcel goes to a hub and is delivered by somebody
|
||
/// else, so the rider never drives to its customer.
|
||
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);
|
||
return status.isPicked || status.isDeliveryLeg;
|
||
}
|
||
|
||
/// The kind of work this stop is **for the leg the rider is on**.
|
||
///
|
||
/// ── Why [stopKindOf] is not enough on its own ──
|
||
///
|
||
/// `stopKindOf` answers "what kind of stop is this?" from the payload, and the
|
||
/// payload is wrong about it on the one line where it matters most: the
|
||
/// booking adapter stamps `type: pickup` on every row it builds, because a v1
|
||
/// booking *is* a first-mile pickup and the backend has no per-stop type to
|
||
/// send. See `ApiConfig.pickupFromBooking`.
|
||
///
|
||
/// So a milk-run order the rider has already collected — a bag in his box, on
|
||
/// its way to a subscriber's door — still described itself as a pickup. The
|
||
/// card offered **Start Pickup**, the confirmation sheet asked him to
|
||
/// **Confirm pickup**, and the write path behind that button took the pickup
|
||
/// branch. He was being asked to collect something he was carrying.
|
||
///
|
||
/// The leg is the honest question, and this app already knows how to answer
|
||
/// it: [navigatesToCustomer] is true exactly when the load is in his hands.
|
||
/// Everything the rider reads and everything the button posts should follow
|
||
/// that, not the stamped type.
|
||
///
|
||
/// Returns [stopKindOf]'s answer unchanged on every other line and every
|
||
/// other stage, so a parcel booking is untouched.
|
||
static StopKind workingKind(
|
||
Map<String, dynamic> stop, {
|
||
Set<String> collectedIds = const {},
|
||
}) => navigatesToCustomer(stop, collectedIds: collectedIds)
|
||
? StopKind.delivery
|
||
: stopKindOf(stop);
|
||
|
||
/// Whether this stop's next rung is worked on **its own screen** — the map,
|
||
/// then I'VE ARRIVED, then the confirmation sheet — or in bulk on Home.
|
||
///
|
||
/// ── The rule ──
|
||
///
|
||
/// On **logistics**, every stop is worked on its own screen. The rider drives
|
||
/// to one customer, weighs one parcel, raises one shipment and takes one
|
||
/// payment; there is nothing to batch.
|
||
///
|
||
/// On a **kitchen line** the pickup half is not a per-stop journey at all. He
|
||
/// makes one trip to one counter and is handed a stack of bags, so the rungs
|
||
/// up to *picked up* are a bulk gesture on Home — select the orders, slide
|
||
/// once — and the single-stop map/arrive/confirm screen is only ever the
|
||
/// **delivery** leg, one subscriber's door at a time.
|
||
///
|
||
/// ── What it prevents ──
|
||
///
|
||
/// A stop still to be collected could be opened on that screen from the
|
||
/// Deliveries tab's live strip. It took the rider through a map, an I'VE
|
||
/// ARRIVED and a confirmation sheet for a collection he was supposed to make
|
||
/// at the kitchen with everything else — a second, contradictory way to work
|
||
/// the same rung, on the tab that is supposed to be his load. The screens ask
|
||
/// this before they offer that route in.
|
||
static bool worksOnOwnScreen(
|
||
Map<String, dynamic> stop, {
|
||
Set<String> collectedIds = const {},
|
||
}) =>
|
||
!ServiceProfile.active.handsOffAtCollection ||
|
||
navigatesToCustomer(stop, collectedIds: collectedIds);
|
||
|
||
/// The coordinates Navigate should open, or null when the stop carries none
|
||
/// for the leg it is on.
|
||
///
|
||
/// Returns null rather than falling back to the other end: a Navigate button
|
||
/// that quietly opens the wrong destination is worse than one that is
|
||
/// disabled, because the rider only finds out when he arrives.
|
||
static ({double lat, double lng})? navigationTarget(
|
||
Map<String, dynamic> stop, {
|
||
Set<String> collectedIds = const {},
|
||
}) {
|
||
double? read(List<String> keys) {
|
||
for (final k in keys) {
|
||
final v = double.tryParse('${stop[k] ?? ''}');
|
||
if (v != null && v != 0) return v;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
if (navigatesToCustomer(stop, collectedIds: collectedIds)) {
|
||
final lat = read(['droplat', 'DropLat', 'deliverylatitude']);
|
||
final lng = read(['droplon', 'DropLon', 'deliverylongitude']);
|
||
if (lat == null || lng == null) return null;
|
||
return (lat: lat, lng: lng);
|
||
}
|
||
|
||
final lat = read(['pickuplat', 'PickupLat', 'pickuplatitude']);
|
||
final lng = read([
|
||
'pickuplon',
|
||
'pickuplong',
|
||
'PickupLon',
|
||
'pickuplongitude',
|
||
]);
|
||
if (lat == null || lng == null) return null;
|
||
return (lat: lat, lng: lng);
|
||
}
|
||
|
||
/// What that destination is called, for the button and the sheet.
|
||
static String navigationLabel(
|
||
Map<String, dynamic> stop, {
|
||
Set<String> collectedIds = const {},
|
||
}) {
|
||
if (navigatesToCustomer(stop, collectedIds: collectedIds)) {
|
||
final name = (stop['pickupcustomer'] ?? stop['PickupCustomer'] ?? '')
|
||
.toString()
|
||
.trim();
|
||
return name.isEmpty ? 'customer' : name;
|
||
}
|
||
final place = pickupPlaceOf(stop);
|
||
if (place.isNotEmpty) return place;
|
||
|
||
// ── The word "pickup" is never a place ──
|
||
//
|
||
// This returned the literal `'pickup'`, and on a payload with no source
|
||
// name — which is what the live backend sends today — that word became
|
||
// the 24sp headline of the rider's first group. The project's own rule
|
||
// ("never display 'pickup' when a real name exists") was being met to the
|
||
// letter and lost in spirit: no name existed, so a leg description was
|
||
// promoted to a title.
|
||
//
|
||
// A rider thinks in PLACES, and the payload still knows one: the pickup
|
||
// address. Only when it has no address either does the label fall back to
|
||
// a word, and then it is at least a capitalised noun. See [pickupPlaceOf].
|
||
return 'Pickup';
|
||
}
|
||
|
||
/// **Where the rider is collecting from**, or `''` when the payload says.
|
||
///
|
||
/// ── Why this is not the customer's name ──
|
||
///
|
||
/// The route card's heading is the biggest type on Home, and on a group of
|
||
/// one order it was the *customer* — so a rider planning his next collection
|
||
/// read `Sudharsan`, which is a person, not somewhere he can ride to. The
|
||
/// place was on the card the whole time, one line down, in grey.
|
||
///
|
||
/// The order of preference is the order of usefulness at a kerb:
|
||
///
|
||
/// 1. the counter's own name — what is written on the sign
|
||
/// 2. the neighbourhood off the pickup address — what he steers by
|
||
///
|
||
/// Split out of [navigationLabel] so a caller can tell "no place in this
|
||
/// payload" from the word *Pickup*, which reads as a name and is not one.
|
||
static String pickupPlaceOf(Map<String, dynamic> stop) {
|
||
final kitchen = sourceNameOf(stop);
|
||
if (kitchen.isNotEmpty) return kitchen;
|
||
|
||
for (final key in const ['pickupaddress', 'PickupAddress']) {
|
||
final raw = (stop[key] ?? '').toString().trim();
|
||
if (raw.isEmpty) continue;
|
||
for (final part in raw.split(',')) {
|
||
final p = part.trim();
|
||
if (p.isEmpty) continue;
|
||
// Skip a leading door/plot number — the rider navigates by the
|
||
// neighbourhood, not by "12/4".
|
||
if (RegExp(r'^[0-9/\-]+$').hasMatch(p)) continue;
|
||
// And when the component carries its own house number — "124
|
||
// Gandhipuram Main Road" — the number is still not the place. Strip
|
||
// the leading digit token and title the street.
|
||
return p.replaceFirst(RegExp(r'^[0-9][0-9/\-]*\s+'), '');
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/// Stops still owing the rider a collection at a given counter.
|
||
///
|
||
/// A stop counts as outstanding when it has been accepted and is not yet in
|
||
/// his hands. Anything he has been told he will not get — not loaded by the
|
||
/// source, cancelled, rejected — is not outstanding: it is settled, and
|
||
/// holding the round open for it would strand him at a counter waiting for a
|
||
/// bag that is not coming.
|
||
static List<Map<String, dynamic>> outstandingPickups(
|
||
List<Map<String, dynamic>> stops, {
|
||
required Set<String> acceptedIds,
|
||
required Set<String> collectedIds,
|
||
Set<String> notLoadedIds = const {},
|
||
Set<String> rejectedIds = const {},
|
||
}) {
|
||
final out = <Map<String, dynamic>>[];
|
||
for (final stop in stops) {
|
||
final id = idOf(stop);
|
||
if (id.isEmpty) continue;
|
||
if (collectedIds.contains(id)) continue;
|
||
if (notLoadedIds.contains(id)) continue;
|
||
if (rejectedIds.contains(id)) continue;
|
||
|
||
final status = stopStatusOf(stop);
|
||
if (status.isCancelled || status.isRejected) continue;
|
||
// Already down the delivery leg — collected on a previous session whose
|
||
// local set was cleared. Not outstanding.
|
||
if (status.isDeliveryLeg || status.isPicked) continue;
|
||
|
||
// Only work he has taken on holds the round open. An un-accepted stop is
|
||
// an offer, and a rider must not be blocked from starting his round by
|
||
// work he never agreed to.
|
||
if (!acceptedIds.contains(id) && !status.isFinishedPickup) {
|
||
if (status.isPending) continue;
|
||
}
|
||
out.add(stop);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/// Where a stop is on the milk-run ladder, from the app's own records.
|
||
///
|
||
/// The local sets lead the server here, deliberately: the queue endpoints are
|
||
/// a poll or more behind the rider and a card that ignores what he just did
|
||
/// reads as a button that did nothing. The backend status is the tie-breaker
|
||
/// underneath, not the first word.
|
||
static StopStatus stageOf(
|
||
Map<String, dynamic> stop, {
|
||
required Set<String> acceptedIds,
|
||
required Set<String> collectedIds,
|
||
Set<String> outForDeliveryIds = const {},
|
||
Set<String> deliveredIds = const {},
|
||
|
||
/// Stops the rider has reported arriving at.
|
||
///
|
||
/// ── Why arrival is a set and not a status ──
|
||
///
|
||
/// The backend team confirmed there is no Arrived rung in the booking
|
||
/// lifecycle: `/reached` records the arrival as a **timestamp**
|
||
/// (`reachedat`) beside a status that stays `pickup_scheduled`. And
|
||
/// `GET /miler/bookings` does not return that stamp — see
|
||
/// `getArrivedOrderIds` — so there is no server field to reconstruct
|
||
/// arrival from after a refresh.
|
||
///
|
||
/// It therefore arrives the same way the other two rider-owned facts do,
|
||
/// as a set of ids, and it is subject to the same precedence: it speaks
|
||
/// only where nothing further along has happened. Delete this parameter,
|
||
/// not the rung, on the day the stamp appears on a booking row.
|
||
Set<String> arrivedIds = const {},
|
||
}) {
|
||
final id = idOf(stop);
|
||
final reported = stopStatusOf(stop);
|
||
|
||
if (deliveredIds.contains(id) || reported.isDelivered) {
|
||
return StopStatus.delivered;
|
||
}
|
||
if (reported == StopStatus.deliveryArrived) {
|
||
return StopStatus.deliveryArrived;
|
||
}
|
||
// ── `Out_for_Delivery` is the consignment's state, not the rider's ──
|
||
//
|
||
// `pickup-complete` releases hyperlocal work itself: pickup and delivery
|
||
// pincodes sharing a 3-digit prefix means the parcel never sees a hub, so
|
||
// the consignment is stamped `Out_for_Delivery` in the same call that
|
||
// records the collection. Every DailyGrubs order is hyperlocal, so that is
|
||
// *always* what the next poll reports after a successful **Picked**.
|
||
//
|
||
// Reading it as this rung skipped `picked` entirely: the rider slid to
|
||
// confirm a hand-over at the counter and the ladder jumped straight to the
|
||
// delivery-active rung — before he had left the kitchen, let alone started
|
||
// the round. He never saw the state he had just created.
|
||
//
|
||
// So the released consignment resolves to [StopStatus.picked], and the
|
||
// delivery-active rung is the rider's own: [outForDeliveryIds] is written
|
||
// when he opens the stop and sets off. Nothing is faked and no status is
|
||
// invented — the two facts were simply being read as one. `deliveryArrived`
|
||
// and `delivered` are checked above and still win, so a stop the backend
|
||
// genuinely reports further along is never dragged back to picked.
|
||
if (outForDeliveryIds.contains(id)) return StopStatus.outForDelivery;
|
||
if (collectedIds.contains(id) ||
|
||
reported.isPicked ||
|
||
reported == StopStatus.outForDelivery) {
|
||
return StopStatus.picked;
|
||
}
|
||
// ── Arrived, from either source ──
|
||
//
|
||
// Below everything above it, deliberately: the pickup milestone outranks
|
||
// the arrival, and the delivery rungs outrank both. A rider's own arrival
|
||
// record can never walk a stop the hub has moved on backwards — the same
|
||
// rule the local store obeys everywhere else in the app.
|
||
if (reported == StopStatus.arrived || arrivedIds.contains(id)) {
|
||
return StopStatus.arrived;
|
||
}
|
||
if (acceptedIds.contains(id) || reported == StopStatus.accepted) {
|
||
return StopStatus.accepted;
|
||
}
|
||
return reported;
|
||
}
|
||
|
||
/// The button a stop's current stage offers, in the rider's words, or null
|
||
/// when the stop is waiting on something other than him.
|
||
static String? nextActionLabel(StopStatus stage) => switch (stage) {
|
||
StopStatus.accepted => 'Arrived',
|
||
StopStatus.arrived => 'Picked up',
|
||
StopStatus.outForDelivery => 'Arrived',
|
||
StopStatus.deliveryArrived => 'Delivered',
|
||
_ => null,
|
||
};
|
||
}
|