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'; /// ───────────────────────────────────────────────────────────────────────── /// WHICH SCREEN OWNS AN ORDER /// /// One rule, in one place, for the question both work screens have to answer /// about every row of the shared day: **is this Home's or is it Deliveries'?** /// /// ── Why it is here and not on the screens ── /// /// It used to be answered twice. Home decided what to draw from /// [stopStateOf] plus its own local stores; the Deliveries tab decided what to /// list from a `where` clause inside its fetch, with a second `where` for the /// statuses that clause was supposed to re-admit. Two expressions of one fact, /// edited on different days, and they disagreed exactly where it hurts most: /// **an accepted booking could be claimed by both.** It stayed on Home to be /// collected, and it appeared on Deliveries as live work with a strip offering /// to continue it — which opened a map, an I'VE ARRIVED and a confirmation /// sheet for a collection the rider had not made yet. /// /// The fix is not a third filter. It is that the classification is one function /// on the shared data layer, above both screens, so they cannot hold different /// opinions about where an order belongs. /// /// ── The boundary ── /// /// PENDING ─ accept ─▶ ACCEPTED ─ navigate ─▶ ARRIVED ─ pick up ─▶ PICKED /// └──────────────── HOME owns all of this ──────────────┘ │ /// ▼ /// DELIVERIES owns from here on /// /// **Picked up is the boundary**, and only the backend can move it. Accepting /// an assignment is a decision about work still to be done; it is not evidence /// that anything has been collected. Nothing in this file infers a completed /// pickup from an acceptance. /// /// ── The one knob ── /// /// Where the boundary sits is a property of the rider's line, declared once as /// [ServiceProfile.handoffAt], and read here rather than re-derived: /// /// • **A kitchen line** ([HandoffPoint.collected]) hands over at collection. /// The rider makes one trip to one counter for a stack of bags, so every /// rung up to picked-up is Home's, in bulk, and Deliveries holds only what /// is in his hands. /// • **Logistics** ([HandoffPoint.accepted]) hands over at acceptance. There is /// no counter and nothing to batch: he drives to one customer, raises the /// shipment at the door and the collection *is* the job — so an accepted /// booking is already the work tab's, which is where that flow lives. /// /// Same rule, one declared knob. Not a line-name check, and not a per-screen /// conditional. See [ServiceProfile.handoffAt]. /// ───────────────────────────────────────────────────────────────────────── enum WorkDomain { /// Home's. Pending, accepted, on the way, arrived — everything before the /// backend has confirmed the load is aboard. pickup, /// The Deliveries tab's. Past the hand-over point for this line. delivery, /// Neither work screen's. Finished or withdrawn — Activity's record. closed, } /// The hand-over point between [WorkDomain.pickup] and [WorkDomain.delivery]. abstract final class WorkBoundary { /// True once the **backend** says the collection is done. /// /// Two sources, both authoritative, neither of them "the rider accepted it": /// /// • The status on the row — `Picked_Up` / `Converted_To_Consignment`, or /// any of the delivery rungs past them. /// • The collected record, written by Home only after the pickup-complete /// call has come back successful. It exists because the queue is up to one /// poll behind: without it a stop the rider has just handed over jumps back /// to Home for a few seconds, which reads as the confirm having failed. /// /// It deliberately does **not** consult the accepted store. That set says a /// decision was made, not that goods changed hands. static bool pickupComplete( Map stop, { Set collectedIds = const {}, }) { final status = stopStatusOf(stop); if (status.isPicked || status.isDeliveryLeg) return true; // Booking key as well as stop key: a multi-destination pickup is collected // once, before it splits into one row per door. See [MilkRun.wasCollected]. return MilkRun.wasCollected(stop, collectedIds); } /// True when this order is finished or withdrawn, whatever screen it was on. /// /// [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* ── /// /// A skip used to leave the order open, and the Deliveries tab kept it under /// its own heading on the argument that the rider looks for it where he left /// it. The cost of that was **dual ownership**: the same stop sat in the /// active queue *and* in Activity, so the two screens disagreed about whether /// the rider still owed it a visit. One order, one owner — a stop he has /// written off is a record, and records live on Activity. /// /// ── Why the status alone cannot answer it ── /// /// On the delivery leg the backend has no failed-attempt outcome. A /// successful skip leaves the consignment `Out_for_Delivery`, so /// `/miler/bookings` keeps returning the row as live delivery work for the /// rest of the day, and [stopStatusOf] reads exactly what the server sent. /// Asking only the payload therefore re-admits the stop on every poll and on /// every cold start. /// /// [closedIds] is the answer to that, and it is **not** a display flag: it is /// the persisted record of a mutation that already succeeded — day-stamped, /// scoped to this rider, tenant and line (see [WorkScope]) — written by the /// completed and skipped stores at the moment the rider's action came back /// OK. It is the same shape of evidence [pickupComplete] already takes from /// `collectedIds`, and it survives the process that wrote it, which is the /// whole point. /// /// Clearing it is what a resume does: [removeSkippedBookings] drops the id /// and the stop is live work again on the very next read. There is no second /// place holding the same opinion. static bool isClosed( Map stop, { Set closedIds = const {}, /// `next_action` recorded at each order's pivot — see [NextLegResolver]. Map pivotActions = const {}, }) { if (closedIds.isNotEmpty && closedIds.contains(MilkRun.idOf(stop))) { return true; } final status = stopStatusOf(stop); if (status == StopStatus.delivered || status.isCancelled) return true; // Filed under its own word, never flattened into cancelled: a stop the // rider walked away from and one the office called off are different // records, and Activity slices them apart. if (status.isSkipped) return true; // ── 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. /// /// [rejectedIds] and a server-side rejection do **not** close an order here: /// a declined stop stays in the pickup domain because Home is where the rider /// can change his mind about it. What it must never be is delivery work. static WorkDomain domainOf( Map stop, { Set collectedIds = const {}, Set acceptedIds = const {}, /// Orders written off today — delivered, cancelled or skipped — read back /// from the completed and skipped stores. See [isClosed]. Set closedIds = const {}, /// `next_action` recorded at each order's pivot — see [NextLegResolver]. Map pivotActions = const {}, }) { 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( stop, collectedIds: collectedIds, ), // On logistics the work tab takes it at acceptance — from either source, // because the local store leads the queue by a poll. HandoffPoint.accepted => acceptedIds.contains(MilkRun.idOf(stop)) || stopStatusOf(stop) == StopStatus.accepted || stopStatusOf(stop).isActive || stopStatusOf(stop) == StopStatus.arrived, }; return handedOver ? WorkDomain.delivery : WorkDomain.pickup; } /// Everything the Deliveries tab is allowed to hold, out of the shared day. /// /// The tab's list, the count on Home's pill and anything else that asks "how /// much is waiting on the other tab?" all read this, so the two screens /// cannot report different numbers for the same day. static List> deliveryQueue( Iterable> day, { Set collectedIds = const {}, Set acceptedIds = const {}, Set closedIds = const {}, Map pivotActions = const {}, }) => [ for (final stop in day) if (domainOf( stop, collectedIds: collectedIds, acceptedIds: acceptedIds, closedIds: closedIds, pivotActions: pivotActions, ) == WorkDomain.delivery) stop, ]; /// Everything Home is still responsible for, out of the shared day. static List> pickupQueue( Iterable> day, { Set collectedIds = const {}, Set acceptedIds = const {}, Set closedIds = const {}, Map pivotActions = const {}, }) => [ for (final stop in day) if (domainOf( stop, collectedIds: collectedIds, acceptedIds: acceptedIds, closedIds: closedIds, pivotActions: pivotActions, ) == WorkDomain.pickup) stop, ]; }