import 'package:miler/Models/stop_status.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.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 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 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. static String sourceKeyOf(Map 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 'none'; } /// 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. static String sourceNameOf(Map stop) => (stop['sourcename'] ?? stop['SourceName'] ?? stop['kitchenname'] ?? stop['KitchenName'] ?? '') .toString() .trim(); /// True when two stops are collected from the same counter. static bool sameSource(Map a, Map 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> sameSourceAs( Map stop, List> stops, ) { final key = sourceKeyOf(stop); return [ for (final s in stops) if (sourceKeyOf(s) == 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 stop, { Set collectedIds = const {}, }) { 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 stop, { Set 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 stop, { Set 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 stop, { Set collectedIds = const {}, }) { double? read(List 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 stop, { Set collectedIds = const {}, }) { if (navigatesToCustomer(stop, collectedIds: collectedIds)) { final name = (stop['pickupcustomer'] ?? stop['PickupCustomer'] ?? '') .toString() .trim(); return name.isEmpty ? 'customer' : name; } final kitchen = sourceNameOf(stop); if (kitchen.isNotEmpty) return kitchen; // ── 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. Its first non-numeric component is the neighbourhood — the // same reading the timeline's area line uses — and "RS Puram" is // something he can ride to in a way "pickup" is not. Only when the // payload has no address either does the label fall back to a word, and // then it is at least a capitalised noun. 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 'Pickup'; } /// 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> outstandingPickups( List> stops, { required Set acceptedIds, required Set collectedIds, Set notLoadedIds = const {}, Set rejectedIds = const {}, }) { final out = >[]; 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 stop, { required Set acceptedIds, required Set collectedIds, Set outForDeliveryIds = const {}, Set deliveredIds = 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; } if (reported == StopStatus.arrived) 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, }; }