part of 'pickups.dart'; /// ───────────────────────────────────────────────────────────────────────── /// CLOSING A DELIVERY /// /// The three ways a drop can end, and the one place that writes them. /// /// ── Why this is not the confirmation sheet ── /// /// The delivery leg used to run through the pickup flow's screens: the map /// asked the rider to press **I've arrived**, which opened a sheet offering /// *Picked up · Skipped · Not picked*. Both halves were wrong on a round he is /// already carrying. Arrival at a door is not a state anything records — there /// is no endpoint for it (see [MilkRun.deliveryArrivalIsLocalOnly]) — so /// pressing it wrote nothing and existed only to reveal the next screen. And /// the screen it revealed asked him to confirm a *pickup* for a bag in his box. /// /// So the delivery leg has no arrival step and no sheet. The stop is simply /// **active** once he sets off, and the only question left is how it ended. /// /// ── Why the writes live here and not in the control that calls them ── /// /// The confirmation sheet already knew how to close a stop, and putting a /// second copy behind a new button would be two implementations of one /// business action — the thing that lets a delivery recorded from one screen /// differ from the same delivery recorded from another. The control is a /// control; this is what it does. /// ───────────────────────────────────────────────────────────────────────── enum DeliveryOutcome { /// Handed over. `POST /miler/consignments/:id/deliver`. delivered, /// Not this time — a return visit. `POST /miler/consignments/:id/skip` /// increments `attemptcount` rather than failing the parcel. skipped, /// It is not going to happen. `POST /miler/bookings/:id/cancel`. cancelled, /// Handed in at a base, ending this rider's custody. /// `POST /miler/consignments/:id/inward-at-hub` — see [handOverAtHub]. /// /// Not a kind of [delivered]. A base handover has no receiver, no proof /// photo, no OTP and no COD, and it must never reach `deliver` — that route /// moves the consignment to `Out_for_Delivery` against a receiver in another /// district, which is a state the handset cannot undo. handedOver, } extension DeliveryOutcomeX on DeliveryOutcome { String get label => switch (this) { DeliveryOutcome.delivered => 'Delivered', DeliveryOutcome.skipped => 'Skip', DeliveryOutcome.cancelled => 'Cancelled', DeliveryOutcome.handedOver => 'Handed over', }; IconData get icon => switch (this) { DeliveryOutcome.delivered => LucideIcons.circleCheck, // A clock, not ⏭. `skip_next` is a media-player transport glyph — "jump // to the next track" — and on a stop it implied the order is passed // over. A skip here is a RETURN VISIT ("keep it on board and try again // later"), and the clock is the app's own later-glyph, the same one the // skip-reason sheet's "Delivery paused" row already wears. DeliveryOutcome.skipped => LucideIcons.clock, DeliveryOutcome.cancelled => LucideIcons.circleX, // A building, not a tick: the parcel is not finished, it has changed hands. DeliveryOutcome.handedOver => LucideIcons.building2, }; Color get colour => switch (this) { DeliveryOutcome.delivered => ColorConstants.acceptGreen, DeliveryOutcome.skipped => ColorConstants.warning, DeliveryOutcome.cancelled => ColorConstants.errorRed, DeliveryOutcome.handedOver => ColorConstants.acceptGreen, }; /// What Activity will show once the round is over. String get pastTense => switch (this) { DeliveryOutcome.delivered => 'Delivered', DeliveryOutcome.skipped => 'Skipped', DeliveryOutcome.cancelled => 'Cancelled', DeliveryOutcome.handedOver => 'Handed over', }; } /// Last resort: ask the backend what this booking became. /// /// Returns '' when it has become nothing, which is a real answer — the booking /// was never converted — and not a failure to look. Future _consignmentIdFromBackend(Map stop) async { final bookingId = (stop['bookingid'] ?? stop['orderheaderid'] ?? '') .toString() .trim(); if (bookingId.isEmpty) return ''; try { final assignmentId = await AssignmentLookup.idForBooking(bookingId); if (assignmentId == null) return ''; final res = await MilerApi.assignment(assignmentId); if (!res.ok) return ''; String found = ''; void walk(dynamic node, int depth) { if (found.isNotEmpty || depth > 4 || node == null) return; if (node is List) { for (final item in node) { walk(item, depth + 1); } return; } if (node is! Map) return; for (final key in const [ 'consignmentid', 'consignmentId', 'consignment_id', ]) { final v = node[key]; if (v != null && v is! Map && v is! List) { final str = v.toString().trim(); if (str.isNotEmpty && str != '0' && str != 'null') { found = str; return; } } } for (final v in node.values) { walk(v, depth + 1); } } walk(res.data, 0); if (found.isEmpty) walk(res.raw, 0); if (found.isNotEmpty) { debugPrint( '[DELIVERY] booking $bookingId → consignment $found (assignment)', ); } return found; } catch (e) { debugPrint('[DELIVERY] consignment lookup failed for $bookingId: $e'); return ''; } } /// The consignment this stop delivers against, from four sources in order of /// freshness — or '' when the booking genuinely was never converted. /// /// ── Why this is a function and not a lookup ── /// /// `deliver` and `skip` key on the CONSIGNMENT, which only exists after /// `pickup-complete` — and **`GET /miler/bookings` does not return it at /// all** (verified against the live API on 2026-08-20: 22 rows, zero /// `consignment*` keys). So the row a rider works from *never* carries the id /// by itself, and any path that reads `stop['consignmentid']` raw refuses /// every delivery on a fresh session. That was exactly the Update Status /// sheet's bug: `closeDelivery` had this chain inline while the sheet read /// the raw key, so the same door could be closed from one screen and /// "Could not record delivered" from the other. One resolver, every caller. /// /// 1. the row itself, once a previous resolution has written it back; /// 2. the id recorded at the pivot on this device, under any of the three /// keys the pivot has historically filed it under — order id first, that /// is the key the current write uses; /// 3. the assignment row (`GET /miler/assignments/:id`), the one record /// that spans both halves of the job — it knows the booking and, once /// converted, what the booking became. One extra call at the door beats /// refusing the rider; /// 4. nothing, which is a real state — the pickup never converted — and gets /// a real message rather than a network error. /// /// A found id is written back onto the row and into the local record, so the /// rest of the session — and the skip path — get it for free. Future resolveConsignmentId(Map stop) async { final String orderId = (stop['orderid'] ?? '').toString().trim(); String consignmentId = (stop['consignmentid'] ?? '').toString().trim(); if (consignmentId.isEmpty) { final ids = await getConsignmentIds(); for (final key in [ orderId, (stop['pickupid'] ?? '').toString().trim(), (stop['bookingid'] ?? '').toString().trim(), ]) { if (key.isEmpty) continue; final found = (ids[key] ?? '').trim(); if (found.isNotEmpty) { consignmentId = found; break; } } } if (consignmentId.isEmpty) { consignmentId = await _consignmentIdFromBackend(stop); } if (consignmentId.isNotEmpty) { stop['consignmentid'] = consignmentId; if (orderId.isNotEmpty) { await rememberConsignmentId(orderId, consignmentId); } } return consignmentId; } /// Asks the server to release one consignment. True when it is on the road. /// /// ── Two servers, one button ── /// /// `Collected_By_Miler` and `POST /consignments/:id/start-delivery` are built /// but **gated off** on the backend (`MILER_COLLECTED_STATE_ENABLED`, default /// off) until this app ships the press. So both worlds are live at once and /// this has to be right in each: /// /// flag OFF `pickup-complete` releases hyperlocal work itself, exactly as /// before. The consignment is already `Out_for_Delivery` when it /// reaches this tab, and there is nothing to call. /// flag ON the pivot stops at `Collected_By_Miler` and this press is what /// puts the load on the road. /// /// The row itself now says which — `consignmentstatus` ships on every /// `GET /miler/bookings` row since 21 Aug 2026. So the common path costs no /// round trip at all, and with the flag off the app never fires a /// `start-delivery` that can only be refused: a stream of 4xx per rider press /// is not a harmless no-op, it is the backend's error log. /// /// Falls back to asking the consignment when the row does not say, which is /// also what answers the two refusals a real round produces — `INVALID_STATE` /// (it left `Collected_By_Miler` some other way) and a lingering /// `IDEMPOTENCY_IN_PROGRESS` after [MilerApi] has waited the write out. /// Neither is answerable from the error itself. /// Why the last [releaseForDelivery] said no, in the rider's words. /// /// ── One message was covering four different problems ── /// /// Every failure below returned a bare `false`, and the caller printed *"This /// parcel isn't ready to go out yet. Check your connection and try again."* for /// all of them. Three of the four have nothing to do with a connection, and /// two of them cannot be fixed by trying again at all: /// /// • **No consignment.** The pickup never converted, or its id was lost. The /// rider can retry this until his battery dies. /// • **At a hub.** A cross-city parcel is `Inwarded_at_Hub` and will be /// delivered by somebody else. There is nothing here for him to start. /// • **Refused.** The server gave a reason and it was thrown away. It is /// read now — and *translated*, because the reason arrives as a state /// machine talking to itself (`consignment is Inwarded_At_Hub, not /// Collected_By_Miler`) and that is not a sentence for a rider. See /// [_refusalSentence]. /// • **Unreachable.** The only one where "check your connection" is true. /// /// Telling a rider to check a working connection, about a parcel that is not /// his to deliver, is how a screen teaches him to ignore what it says. Null /// after a success. String? lastReleaseFailure; /// Whether a "somebody else has this" hold is even possible on this rider's /// line. /// /// ── The state that cannot happen, refusing the rider anyway ── /// /// `Inwarded_at_Hub`, `Tripsheet_Loaded` and `In_Transit` are the logistics /// network's own rungs: a cross-city parcel is dropped at a collection point /// and delivered by a different rider on a different day. That is a real hold /// and it must keep blocking on the logistics line. /// /// On a meal run **it cannot happen at all.** Every DailyGrubs order is /// hyperlocal — collected at the kitchen and carried straight to the door, one /// rider, one leg, no network in between. So one of those states coming back /// for a meal stop is not news about the food; it is a bad read, and the two /// ways it happens are both known: a consignment reference that resolved to /// the wrong record, and a row whose state belongs to a different booking. /// /// The rider was shown the hold anyway, mid-round, on a slider whose only job /// is to set him off — and there was nothing he could do about it, because the /// thing it described was not true of his stop. /// /// So a hold is only believed on a line that has somewhere to hold things. On /// every other line the state is treated as unreadable, which sends the /// question to the server: it is the judge of its own consignment, and one /// refusal carrying its own reason beats a client-side refusal inventing one. bool get _handoffHoldIsPossible => ServiceProfile.active.endsAtHub; Future releaseForDelivery(Map stop) async { lastReleaseFailure = null; // ── A hub-routed parcel has no customer round to start ── // // This is the gate that keeps a Gandhipuram → Chennai consignment out of the // delivery lifecycle. `start-delivery` moves it to `Out_for_Delivery` against // a receiver 500km away, the hub then sees a rider "actively delivering" a // parcel that is going onto a line-haul truck, and there is no way back from // that state on the handset. // // Asked of the order, never of the line — see [NextLegResolver]. The pivot's // own instruction is read from the store because that is the only place the // routing answer survives a rebuild; `GET /miler/bookings` does not carry it. final leg = NextLegResolver.resolve( stop, pivotAction: (await getPivotNextActions())[MilkRun.idOf(stop)] ?? '', ); if (leg.isHub) { debugPrint('[NEXTLEG] release refused for ${MilkRun.idOf(stop)} — $leg'); lastReleaseFailure = 'This parcel goes back to base, not to the customer. Hand it over at ' 'base — there is no delivery to start here.'; return false; } // ── `unknown` is NOT refused here, deliberately ── // // It would be the cautious-looking thing to do and it is the wrong thing. // A row that says nothing is exactly the case the consignment re-read below // exists for: `Created` and a blank status both arrive on a poll that is // behind the rider, and the server is the judge of its own consignment. // Refusing here short-circuits that read and strands a rider on a stop the // server would have released — the failure `_handoffHoldIsPossible` already // documents, arriving by a new route. // // Only [NextLeg.hub] is refused, because that is the one answer the server // has actually given. final consignmentId = await resolveConsignmentId(stop); if (consignmentId.isEmpty) { debugPrint('[MILKRUN] no consignment id for ${MilkRun.idOf(stop)}'); lastReleaseFailure = "This stop has no delivery reference yet, so it can't be started. " 'Ask your office to check it — sliding again will not help.'; return false; } // What the list row already told us, before spending a request on it. var state = consignmentStateFromRaw(stop['consignmentstatus']); // ── A row saying `Created` is the one row worth re-reading ── // // `Created` is the state a consignment holds for as long as it takes the // pivot to route it, and `GET /miler/bookings` is a poll behind the rider by // design. So a stop collected seconds ago can arrive on this tab carrying // the state it had *before* `pickup-complete` finished with it, and every // other state on the row is durable enough not to have that problem. // // Acting on it cost the rider the round: the guard below read it as hub // custody and refused, and no amount of sliding cleared a row that only the // next poll was going to correct. It is asked directly instead — one request, // on the one state where the row is not evidence. if (state == ConsignmentState.unknown || state == ConsignmentState.created) { state = await ConsignmentGate.stateOf(consignmentId); } if (state.isDeliverable || state.isDelivered) { // ── Released without a request, and the trace has to say so ── // // In compatibility mode the pivot has already released the consignment, so // Start ride spends nothing here and the console was *already* Active // before the rider slid. A silent `true` made that indistinguishable from // a release this press actually made, which is the exact ambiguity the // production trace has to resolve. debugPrint( '[TRACE][START-RIDE] consignment=$consignmentId ' 'state="${state.name}" startDeliveryCalled=false ' 'reason=already-released-by-pickup-complete (compatibility mode)', ); return true; } // ── What may be *attempted*, as against what may be *claimed* ── // // This refused everything that was not `Collected_By_Miler` or unreadable, // and `Created` fell into it wearing the hub's wording. Both halves were // wrong for a parcel on the rider's own back. // // `Created` now goes to the server like `unknown` does, for the reason // `unknown` does: the app is not the judge of a state it cannot fully // account for, and one 400 carrying the backend's own sentence is worth more // than a client-side refusal that invents one. A hyperlocal consignment the // pivot has not finished releasing is let through; a hub-routed one is // refused by the server, and the rider is told what the server *meant* — // translated out of the state machine's vocabulary, see [_refusalSentence]. // A hold that cannot exist on this line is a bad read, not a fact about the // stop. See [_handoffHoldIsPossible]. if (state.awaitsHub && !_handoffHoldIsPossible) { debugPrint( '[MILKRUN] $consignmentId read as ${state.name} on a line with no ' 'handoff — treating as unreadable and asking the server', ); } if (!state.needsRelease && !state.awaitsHubInward && !(state.awaitsHub && !_handoffHoldIsPossible) && state != ConsignmentState.unknown) { debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable'); // One sentence per state, written once — see // [ConsignmentStateX.startRefusalSentence]. This branch and the refusal // below are the same answer arrived at from two directions (a state the // app read, and a state the server named), and they used to word it // differently: a cross-city parcel blocked here said "it's not yours to // deliver" while the identical parcel blocked one request later read out // a database enum. lastReleaseFailure = state.startRefusalSentence ?? 'This parcel cannot be started yet. Ask your office to check it.'; return false; } debugPrint( '[TRACE][START-RIDE] consignment=$consignmentId state="${state.name}" ' 'POST /miler/consignments/$consignmentId/start-delivery — calling', ); final res = await MilerApi.startDelivery(consignmentId); debugPrint( '[TRACE][START-RIDE] consignment=$consignmentId startDeliveryCalled=true ' '-> ${res.status} ${res.code} ok=${res.ok} raw=${res.raw}', ); if (res.ok) return true; debugPrint( '[MILKRUN] start-delivery $consignmentId -> ' '${res.status} ${res.code} ${res.message}', ); // Already on the road, or already handed over — either way this press is // not what stands between the rider and the door. final after = await ConsignmentGate.stateOf(consignmentId); if (after.isDeliverable || after.isDelivered) return true; // `status: 0` is the request never leaving, and it is the one failure that // is really about the connection. lastReleaseFailure = res.status == 0 ? 'Could not reach your office. Check your connection and slide again.' : _refusalSentence(res, after); return false; } /// The rider's version of a refused **Start ride**. /// /// ── What the rider was actually reading ── /// /// The server's own words were passed straight to the screen, on the argument /// that one refusal carrying the backend's reason beats a client-side guess /// that invents one. That argument holds for *whose* answer it is. It does not /// hold for what the answer turned out to be: `start-delivery` refuses with an /// assertion about its own model — **"consignment is Inwarded_At_Hub, not /// Collected_By_Miler"** — and that is what a rider saw on the slider, in /// database vocabulary, with no way to tell it from a crash. /// /// The reason is worth keeping; the wording was never the server's to write. /// So the state is what travels, and [ConsignmentStateX.startRefusalSentence] /// says it once, in one place, in words that answer the only two questions he /// has: is this mine, and will sliding again help. /// /// ── Three sources for that state, best first ── /// /// 1. **A fresh read** — [after], asked immediately after the refusal, which /// is the newest thing anyone knows about the consignment. /// 2. **The refusal itself.** The read can fail — the row need not carry a /// status and the detail route 404s on older deployments — and when it /// does the refusal is the only thing left that knows. See /// [consignmentStateNamedInRefusal] for why reading it is safe. /// 3. **The failure code.** `INVALID_STATE` alone still says the one useful /// thing: the consignment has moved on from the rung this press acts at, /// so sliding again is not the answer. /// /// Prose from the server is still preferred over a generic sentence — some /// routes really do answer in words meant for a person. What is filtered out /// is the wire vocabulary. See [namesWireState]. String _refusalSentence(ApiResult res, ConsignmentState after) { final state = after != ConsignmentState.unknown ? after : consignmentStateNamedInRefusal(res.message); final fromState = state.startRefusalSentence; if (fromState != null) return fromState; final said = res.message.trim(); if (said.isNotEmpty && !namesWireState(said)) return said; return res.isInvalidState ? 'Your office has already moved this parcel on, so it cannot be ' 'started from here. Ask them to check it.' : 'Your office would not start this delivery.'; } /// Brings this device into line with a delivery the server already has. /// /// The same local bookkeeping a successful `deliver` performs — the order /// leaves the rider's hands, its consignment mapping is forgotten, and it is /// filed as finished — **without** posting anything. Nothing here invents a /// state: the state was read from the consignment's own history first. Future _reconcileAlreadyDone( Map stop, _MyPickupsState? parentState, ) async { final orderId = (stop['orderid'] ?? '').toString(); try { if (orderId.isNotEmpty) { await removeCollectedOrderIds([orderId]); await forgetConsignmentIds([orderId]); } // The same path a real completion takes — one recorder, so a reconciled // stop and a delivered one produce the same Activity record. parentState?.markPickupFinished(stop); await addCompletedBookings([stop], terminalStatus: 'delivered'); } catch (e) { debugPrint('[DELIVERY] reconcile failed for $orderId: $e'); } } /// Writes [outcome] against [stop], then clears the local bookkeeping so the /// order leaves Deliveries and lands in Activity. /// /// Returns the outcome payload the caller should pop with, or null when the /// write failed — in which case the rider keeps the stop and the screen, which /// is the only honest thing to do with work the hub has not been told about. /// One press, one write. /// /// The delivery path had no shared guard — only a widget's `_isNavigating` /// flag, which is per-widget and dies with a rebuild, and the outcome is /// reachable from a sheet that can be re-opened. Two Delivered taps meant two /// `deliver` posts against a backend that is not idempotent about them. Keyed /// on the resource and the verb, so every route to the same stop shares it. Future?> closeDelivery( BuildContext context, { required Map stop, required _MyPickupsState? parentState, required DeliveryOutcome outcome, String notes = '', String proofPath = '', }) { final key = '${outcome.name}:${(stop['orderid'] ?? '').toString()}'; return MutationGuard.run?>( key, () => _closeDelivery( context, stop: stop, parentState: parentState, outcome: outcome, notes: notes, proofPath: proofPath, ), ); } Future?> _closeDelivery( BuildContext context, { required Map stop, required _MyPickupsState? parentState, required DeliveryOutcome outcome, String notes = '', /// Where the doorstep photo was saved, `''` when the rider completed /// without one. Carried onto the finished record so the Activity detail /// page can show it — see [ProofStore] for why it is not sent as /// `photourl`. String proofPath = '', }) async { final dc = Get.put(PickupsController(), permanent: true); final String orderId = (stop['orderid'] ?? '').toString(); final int pickupIdInt = int.tryParse('${stop['pickupid'] ?? 0}') ?? 0; final int orderHeaderId = int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0; double parseD(dynamic v) { if (v == null) return 0.0; if (v is num) return v.toDouble(); return double.tryParse(v.toString()) ?? 0.0; } // The customer's door. On a milk run the stop *is* the customer's address, so // the pickup coordinates stand in when there is no separate drop point — // same fallback the sheet uses, for the same reason. final double dropLat = parseD(stop['droplat'] ?? stop['DropLat'] ?? 0); final double dropLng = parseD(stop['droplon'] ?? stop['DropLon'] ?? 0); final double stopLat = parseD(stop['pickuplat'] ?? stop['PickupLat'] ?? 0); final double stopLng = parseD(stop['pickuplon'] ?? stop['PickupLon'] ?? 0); final String consignmentId = await resolveConsignmentId(stop); // ── Ask the consignment what it is, before writing to it ── // // ROOT CAUSE, verified against the live API 2026-08-21. `deliver` answers // `400 consignment is not out for delivery` for TWO opposite situations — // "not released yet" and "already delivered" — and the app was treating // both as an error to show the rider. Consignment 34's own history // (`GET /miler/consignments/logs/34`) reads: // // Out_for_Delivery "Package collected by miler and converted…" // Delivered "Delivered to SEQTEST Ukkadam at (…)" // // …so `pickup-complete` DOES release hyperlocal work (no // `/miler/deliveries/start` is involved, and none exists), and that stop had // been delivered an hour earlier. It stayed on the Deliveries tab because // the only thing the app reads is the BOOKING status, which is terminal at // `Converted_To_Consignment` and never learns about the delivery half — and // the local "I delivered this" memory had gone with a reinstall. // // So the state is read from the one route that reports it, and the answer // decides. See [ConsignmentGate]. // // Skip asks the same question and gets a different answer to one of the // rungs: the backend widened `skip` on 21 Aug 2026 so a failed attempt is // reportable from `Collected_By_Miler` as well as `Out_for_Delivery` — a // customer who is not home is not home whether or not the rider remembered // to press Start round. See [ConsignmentStateX.canSkip]. // ── A handover is deliberately NOT gated here ── // // [DeliverGate] answers the question "may this be *delivered*", and a // base-routed parcel is `Created` — which it reads as `awaitingInward` and // refuses with "this parcel hasn't been released for delivery yet". Correct // for a delivery, exactly wrong for the rung that performs the inward. // `inward-at-hub` is the server's own judge of its preconditions and answers // `INVALID_STATE` when they are not met. final gated = outcome == DeliveryOutcome.delivered || outcome == DeliveryOutcome.skipped; if (gated && consignmentId.isNotEmpty) { final state = await ConsignmentGate.stateOf(consignmentId); if (outcome == DeliveryOutcome.skipped && state.canSkip) { // Nothing to correct and nothing to release — post it. } else { switch (ConsignmentGate.gateFor(state)) { case DeliverGate.alreadyDelivered: // The work is done server-side; this device is what is behind. File // it and drop it from the tab — showing an error for a delivery the // rider genuinely completed is the bug, not the fix. debugPrint('[DELIVERY] $orderId already delivered — reconciling'); await _reconcileAlreadyDone(stop, parentState); if (context.mounted) { AppFeedback.success(context, 'Already delivered — record updated'); } return {'outcome': 'completed', 'reconciled': true}; case DeliverGate.awaitingInward: // Converted but never released and never inwarded. It is in his box, // so the hub-hold sentence below would be false — but `deliver` will // refuse it, so saying nothing and posting anyway would put a 400 on // screen with no explanation attached. debugPrint('[DELIVERY] $orderId is Created — not on the road yet'); if (context.mounted) { AppFeedback.error( context, "This parcel hasn't been released for delivery yet. Slide to " 'start the ride first, or ask your office to check it.', ); } return null; case DeliverGate.awaitingHub: // The guard that must survive: a logistics consignment inside the // network genuinely is not this rider's to hand over. // // But only on a line that HAS a network. The same bad read that hit // the Start-ride slider reaches this branch too, and refusing here // is worse — the rider is at the door with the food in his hand. // See [_handoffHoldIsPossible]. if (!_handoffHoldIsPossible) { debugPrint( '[DELIVERY] $orderId read as held on a line with no handoff — ' 'ignoring the read and letting the server judge', ); break; } debugPrint('[DELIVERY] $orderId is held elsewhere — refusing'); if (context.mounted) { AppFeedback.error( context, "This one has already been handed on, so it can't be delivered " 'from here.', ); } return null; case DeliverGate.needsRelease: // Collected, but nobody pressed **Start round** — a rider who left // straight from the kitchen, or a press that failed on a dead signal. // // He is standing at the door. The release is his own to make and the // route exists, so it is made here rather than sending him back two // screens to press a button whose only purpose is to make this one // work. Nothing is assumed: the server performs the transition and // the state is read again before the delivery is posted. debugPrint('[DELIVERY] $orderId not started — releasing now'); if (!await releaseForDelivery(stop) || !(await ConsignmentGate.stateOf(consignmentId)).isDeliverable) { if (context.mounted) { AppFeedback.error( context, 'This delivery has not been started yet. Open Deliveries and ' 'press Start round, then try again.', ); } return null; } break; case DeliverGate.closed: debugPrint('[DELIVERY] $orderId is closed — refusing'); if (context.mounted) { AppFeedback.error( context, 'This order was closed by the office and cannot be delivered.', ); } return null; case DeliverGate.deliverable: case DeliverGate.unknown: // `unknown` falls through on purpose: a failed read is not evidence, // and the server is still the judge one call later. break; } } } if (outcome == DeliveryOutcome.delivered && consignmentId.isEmpty) { // ── Only `deliver` needs a consignment ── // // This guard used to catch **skip** as well, and skip is the one thing a // rider does when a delivery goes wrong — so the outcome he reaches for // precisely when something is already wrong was the one refused with "this // order's delivery reference is missing". Nothing he could do cleared it. // // Cancel was already exempt: it is a *booking* route. Skip now has one too // (`POST /miler/bookings/:id/skip`, shipped 24 Aug), so both work without a // consignment and only the hand-over — which genuinely keys on one — is // held here. // // Reaching here means four sources came back empty, which is no longer a // client-side gap: it means the pickup never converted this booking. The // message says the actionable thing rather than naming an internal id the // rider has no way to obtain. // ── Two different failures wore one sentence ── // // This always said "never picked up on the system — mark it picked up // from Home first", which is true for exactly one of the two ways to get // here, and actively misleading for the other: // // • The booking really has NOT been collected. The advice is right. // • The booking IS `Converted_To_Consignment` — collected, converted, // sitting on the Deliveries tab — and the app simply cannot obtain the // consignment id. Telling that rider to "pick it up from Home" sends // him to look for a stop that is not on Home, to redo work he has // already done, and it cannot possibly clear the error. // // Verified against production 2026-08-21, booking 59 // (`DM-BK-501CB551-45130`, status `Converted_To_Consignment`): every route // to its consignment id is a dead end — `GET /miler/bookings` returns no // consignment key on any of its 29 rows, and `GET /miler/assignments` // returns only 12 rows covering 12 of those bookings, so // `AssignmentLookup` has nothing to resolve. `?status=`/`?bookingid=` // filters are ignored by that endpoint. There is no fifth route. // // So the rider is told what is actually true and who can fix it, and the // gap is logged with the evidence rather than blamed on him. The real fix // is backend — see MILER_API_REQUIREMENTS.md request 3. final collected = stopStatusOf(stop).isPicked || stopStatusOf(stop).isDeliveryLeg; debugPrint('[DELIVERY] $orderId has no consignment id — refusing'); ApiConfig.logGap( 'deliver', collected ? 'booking $orderId is collected but its consignment id is ' 'unreachable: the row carries none, the local pivot record is ' 'absent, and GET /miler/assignments does not list this ' 'booking. Backend must return consignmentid on /miler/bookings.' : 'booking $orderId reached the delivery leg with no consignment: ' 'its pickup-complete either never ran or did not convert it.', ); if (context.mounted) { AppFeedback.error( context, collected ? "This order's delivery reference is missing, so it cannot be " 'completed from the app. Ask your office to check it — ' 'collecting it again will not help.' : 'This order was never picked up on the system — mark it picked ' 'up from Home first, then deliver.', ); } return null; } // One fix for whichever branch runs, taken before the write so a slow GPS // cannot delay the call the rider is waiting on. Null is fine: every route // here treats coordinates as optional telemetry, not as a condition. Position? riderFix; try { riderFix = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, timeLimit: Duration(seconds: 5), ), ); } catch (_) { // No fix, and the outcome still has to be recordable. See above. } // ── The photograph leaves the phone ── // // It never could: `deliver` takes `photourl`, which wants a URL, and nothing // on the contract accepted an upload — so proof of delivery lived in the // app's own directory and died with the next reinstall, on the one record // somebody asks about weeks later. // // `POST /miler/uploads/sign` (24 Aug) closes it: sign, PUT the bytes, send // the public URL back on the delivery. // // **A failed upload never blocks a hand-over.** The parcel is in the // customer's hands whatever the network did, and `deliver` accepts an empty // `photourl`. The local copy is kept either way, so a failure costs the hub // its copy and costs the rider nothing. String proofUrl = ''; if (outcome == DeliveryOutcome.delivered && proofPath.isNotEmpty) { final uploaded = await MilerApi.uploadProof( File(proofPath), purpose: MilerApi.proofDelivery, consignmentId: consignmentId.isEmpty ? null : consignmentId, ); if (uploaded != null) { proofUrl = uploaded; } else { ApiConfig.logGap( 'deliver', 'the proof photo for $orderId could not be uploaded; the delivery is ' 'being recorded without one and the copy stays on the device.', ); } } bool ok = false; try { switch (outcome) { case DeliveryOutcome.delivered: ok = await dc.updateDeliveredStatus( pickupId: pickupIdInt, consignmentId: consignmentId, proofImage: proofUrl, deliveredToName: (stop['dropcustomer'] ?? stop['pickupcustomer'] ?? stop['PickupCustomer'] ?? 'Customer') .toString(), dropLat: (dropLat != 0 ? dropLat : stopLat).toStringAsFixed(6), dropLng: (dropLng != 0 ? dropLng : stopLng).toStringAsFixed(6), notes: notes, ); case DeliveryOutcome.handedOver: // The fence, the consignment lookup and the `inward-at-hub` write all // live in [handOverAtHub] — this is the one close path, so the record // that gets filed is the same shape whichever rung produced it. ok = await handOverAtHub(stop); if (!ok && lastHandoverFailure != null && context.mounted) { AppFeedback.error(context, lastHandoverFailure!); } case DeliveryOutcome.skipped: // ── Straight to the consignment route ── // // Not through `PickupsController.updateSkippedStatus`, which takes an // `int pickupId` and posts the *pickup* skip. A consignment id is not // guaranteed to be numeric — it can be a tracking reference — so // squeezing it through an int parameter would either lose it or, worse, // fall back to the booking id and skip the wrong thing. // // The pickup-leg skip keeps that method; this is the delivery leg's. // // ── Two routes, chosen by what the stop actually has ── // // With a consignment the skip belongs to it — that is the attempt // counter the hub reads. Without one, the booking route is not a // fallback but the *correct* call: a stop with no consignment has not // been converted, so there is nothing on the delivery side to bump. // Posting an empty id to `/consignments//skip` — which is what this // did whenever the id was unreachable — 404s and tells the rider his // own stop is broken. final res = consignmentId.isEmpty ? await MilerApi.skipBooking( stop['bookingid'] ?? stop['orderheaderid'] ?? orderId, reason: notes.isEmpty ? 'Skipped by rider' : notes, lat: riderFix?.latitude, lon: riderFix?.longitude, ) : await MilerApi.skipConsignment( consignmentId, reason: notes.isEmpty ? 'Skipped by rider' : notes, lat: riderFix?.latitude, lon: riderFix?.longitude, ); ok = res.ok; if (!ok) debugPrint('[DELIVERY][skip] ${res.status} ${res.message}'); // ── The attempt counter is the hub's, and it has a ceiling ── // // `skip` returns `attemptcount` and it is the source of truth: the // consignment stays `Out_for_Delivery` and the parcel stays in the // rider's hands, so nothing here may close the stop. At **3** the // backend raises an Undeliverable exception for the hub, and there is // no automated return or reassignment behind it — a human picks it up // from there. Saying so is the difference between a rider trying a // fourth time and a rider ringing the hub. if (ok) { final body = res.data; final attempts = body is Map ? int.tryParse('${body['attemptcount'] ?? ''}') ?? 0 : 0; if (attempts >= 3 && context.mounted) { AppFeedback.info( context, 'Third attempt on this stop — your office has been told and will ' 'take it from here.', ); } } case DeliveryOutcome.cancelled: ok = await dc.updateCancelledStatus( pickupId: pickupIdInt, orderHeaderId: orderHeaderId, pickupLat: (dropLat != 0 ? dropLat : stopLat).toStringAsFixed(6), pickupLng: (dropLng != 0 ? dropLng : stopLng).toStringAsFixed(6), notes: notes.isEmpty ? 'Cancelled by rider' : notes, ); } } catch (e) { debugPrint('[DELIVERY][${outcome.name}] $orderId failed: $e'); ok = false; } if (!ok) { // ── Refused is not failed, and neither one may close a stop ── // // Completing locally after a write that never landed is what made the app // show a finished screen while the office still had the order live. The // rider keeps the stop and gets told why. if (context.mounted) { // ── Cancel is a booking route, and a collected parcel is past it ── // // `POST /miler/bookings/:id/cancel` is refused once the stop is picked // up — by design: cancelling releases the booking for reassignment, and // a parcel already in a rider's box cannot be handed to somebody else. // The generic "could not record — try again" invited exactly the retry // that can never work, so the one outcome with a *permanent* reason says // it. final collectedAlready = outcome == DeliveryOutcome.cancelled && (stopStatusOf(stop).isPicked || stopStatusOf(stop).isDeliveryLeg); AppFeedback.error( context, collectedAlready ? 'You are already carrying this parcel, so it cannot be ' 'cancelled from the app. Skip it to try again later, or ask ' 'your office to cancel it.' : dc.lastBlockedReason ?? 'Could not record ${outcome.pastTense.toLowerCase()} — try again', ); } return null; } // ── Did the skip actually close anything? Ask, do not assume ── // // A delivery closes its consignment: the server says `Delivered` and the row // stops being delivery work on its own. **A skip is a failed attempt**, and // what the server does with one is the server's business — it may move the // consignment to a failure state, or it may quite correctly leave it // `Out_for_Delivery` for a second attempt or an RTO decision made elsewhere. // // Those two need opposite handling, and the app cannot tell them apart from // the skip's own 200. So it reads the consignment afterwards: // // server closed it → a terminal record, filed in Activity, off this tab. // server kept it open → **parked**, not closed. The stop stays visible // under SKIPPED with its reason, the parcel stays in // the rider's hands, and its consignment mapping is // kept because it is still actionable. // // Writing a terminal local record over a consignment the hub still calls // open is the failure this avoids: two systems disagreeing about whether a // parcel is somebody's problem, with the rider's screen the only one saying // it is not. See MILER_API_REQUIREMENTS.md request 14 — there is no route // that reports a *failed attempt* as an outcome, which is why this has to be // inferred at all. var stillOpen = false; if (outcome == DeliveryOutcome.skipped) { final after = consignmentId.isEmpty ? ConsignmentState.unknown : await ConsignmentGate.stateOf(consignmentId); // The rule, and why unknown counts as open, is on the state itself. stillOpen = after.isOpenAfterSkip; debugPrint( '[DELIVERY][skip] $orderId -> consignment is ${after.name}, ' '${stillOpen ? 'still open — parking' : 'closed — filing'}', ); if (stillOpen) { ApiConfig.logGap( 'skip', 'consignment $consignmentId is still ${after.name} after a successful ' 'skip: the backend exposes no failed-attempt outcome, so the app ' 'parks the stop locally rather than inventing a terminal state.', ); } } // Local bookkeeping, so the order leaves this tab immediately rather than at // the next poll: the write has landed, and a row that lingers reads as the // button having done nothing. try { if (stillOpen) { // Parked, not finished. The parcel is still in his hands and the // consignment is still writable, so neither record is cleared — and the // completed store, which is what holds a stop off this tab for good, is // deliberately not written. await addSkippedBooking({ ...stop, if (proofPath.isNotEmpty) 'proofphotopath': proofPath, }, reason: notes); parentState?.markOrderAsSkipped(stop, notes); unawaited(WorkRepository.instance.invalidate()); return { 'outcome': outcome.name, 'isDelivery': true, 'deliveryOutcome': outcome.name, 'parked': true, 'notes': notes, }; } if (orderId.isNotEmpty) { // It is no longer in his hands, whichever way it ended. await removeCollectedOrderIds([orderId]); // And nothing else will be posted against its consignment. await forgetConsignmentIds([orderId]); } // Records it for Activity and drops it from this tab in one call — the same // path every other finished stop takes. `cancelled` covers both non-delivery // outcomes, which is what Activity renders as "not completed". parentState?.markPickupFinished( { ...stop, 'orderstatus': outcome == DeliveryOutcome.delivered ? 'delivered' : outcome.name, // Why he walked away, carried onto the record — Activity has nowhere // else to get it, and "Skipped" with no reason is half a record. if (outcome == DeliveryOutcome.skipped && notes.isNotEmpty) 'skipreason': notes, // The doorstep photo rides onto the record, which is the only place it // is ever read from — Activity's detail page draws it from here. Absent // when there is none, so a record without proof has no key rather than // an empty one that reads as a broken image. if (proofPath.isNotEmpty) 'proofphotopath': proofPath, }, cancelled: outcome != DeliveryOutcome.delivered, // ── A skip is filed as a skip, and it does not come back ── // // It used to be flattened into `cancelled`, which put a stop the rider // walked away from into the same slice as one the office called off. // Filed under its own word it lands in Activity's *Active* slice, which // is exactly what it is: work that still owes a return visit. // // Reached only when the server agrees the consignment is closed. The // completed store is what [_MyPickupsState._restoreClosedToday] reads, // so this stop leaves the tab and stays off it — which is only honest // because the hub says the same thing. terminalStatus: outcome == DeliveryOutcome.skipped ? 'skipped' : null, ); unawaited(ProofStore.prune()); } catch (e) { debugPrint('[DELIVERY][${outcome.name}] cleanup: $e'); } // One copy of the day, and it is now stale — Home, Deliveries and Activity // must all re-read rather than each deciding for itself. See [WorkRepository]. unawaited(WorkRepository.instance.invalidate()); return { 'outcome': outcome == DeliveryOutcome.delivered ? 'completed' : outcome.name, 'isDelivery': true, 'deliveryOutcome': outcome.name, 'notes': notes, }; } // ═══════════════════════════════════════════════════════════════════════════ // THE BASE HANDOVER — the leg the rider could not finish // ═══════════════════════════════════════════════════════════════════════════ /// Why the last [handOverAtHub] refused, in the rider's words. Null on success. String? lastHandoverFailure; /// The base this stop must be handed in at, or null when it is not a base leg. /// /// Two sources, in order. `next_hub` rides on the row itself (request 25) and /// is the authoritative one. [PickupLocations.baseFor] is the fallback for a /// row that names a hub id without expanding it — a shape older payloads use. HandoverHub? handoverBaseFor(Map stop) { final fromRow = HandoverHub.from(stop['next_hub'] ?? stop['nexthub']); if (fromRow != null) return fromRow; return PickupLocations.baseFor( stop['next_hub_id'] ?? stop['nexthubid'] ?? stop['hubid'], ); } /// Hands a base-routed consignment in, ending this rider's custody of it. /// /// ── The leg that existed everywhere except the app ── /// /// `MilerApi.inwardAtHub` and `MilerLifecycle.inwardAtHub` were both written, /// documented and covered by `hub_handover_contract_test.dart`. Neither had a /// single call site. So with `MILER_HUB_HANDOVER_ENABLED` on server-side, a /// Gandhipuram → Chennai parcel arrived on Deliveries as [NextLeg.hub], /// [releaseForDelivery] correctly refused to start a customer round for it — /// telling the rider "hand it over at base" — and there was no control in the /// app that could record him doing so. The parcel sat in his queue until hub /// staff inwarded it from the console, and the assignment closed against /// nobody, so the job reported zero distance and zero value on his earnings. /// /// ── Correct in both positions of the server flag ── /// /// This never asks which way the flag is set, because the app must not mirror /// it. With the flag **off**, `pickup-complete` inwards the parcel itself and /// the row comes back `Inwarded_at_Hub` / `handed_to_hub` — [NextLeg.closed] — /// so this function is never reached and no control is drawn. With it **on**, /// the row is `Created` / `inward_at_hub` — [NextLeg.hub] — and this is the /// rung that finishes it. The leg is read from the row, every time. /// /// ── Fenced at the base, like every other presence claim ── /// /// 100 m, against the base's own coordinates. A base with no coordinates is /// refused rather than waved through, for the same reason a customer stop with /// no pin is: nobody can say afterwards where the rider was standing. /// /// Idempotent twice over — the shared `Idempotency-Key` covers a retry after a /// dropped response, and a parcel already inwarded answers 200 with /// `already_inwarded: true`, which [MilerLifecycle.inwardAtHub] reads as the /// success it is. A rider pressing again on bad signal at a loading bay is /// confirmed, not refused. Future handOverAtHub(Map stop) async { lastHandoverFailure = null; final orderId = MilkRun.idOf(stop); final leg = NextLegResolver.resolve( stop, pivotAction: (await getPivotNextActions())[orderId] ?? '', ); if (!leg.isHub) { // Not a refusal the rider caused — the row says this parcel is not going to // a base. Saying so beats posting a handover the server will reject. lastHandoverFailure = 'This parcel is not going to a base. Check the stop and try again.'; debugPrint('[HANDOVER] $orderId is not a base leg — $leg'); return false; } final base = handoverBaseFor(stop); // ── The fence, before anything else is spent ── // // A base with no coordinates lands on [GeofenceOutcome.noTarget] and is // refused with a sentence pointing at the office, which is the only party who // can add the missing pin. final decision = await Geofence.check( targetLat: base?.latitude, targetLng: base?.longitude, action: 'Handed over', ); if (!decision.allowed) { lastHandoverFailure = decision.reason; debugPrint('[HANDOVER] $orderId refused by the fence — $decision'); return false; } final consignmentId = await resolveConsignmentId(stop); if (consignmentId.isEmpty) { lastHandoverFailure = 'This stop has no shipment reference yet, so it cannot be handed over. ' 'Ask your office to check it — pressing again will not help.'; debugPrint('[HANDOVER] no consignment id for $orderId'); return false; } debugPrint( '[TRACE][HANDOVER] consignment=$consignmentId base=${base?.id} ' 'POST /miler/consignments/$consignmentId/inward-at-hub — calling', ); final res = await MilerApi.inwardAtHub( consignmentId, hubId: (base?.id.isNotEmpty ?? false) ? base!.id : null, // The position the fence just judged, not a fresh one: two fixes seconds // apart are two different answers and the hub's history row should carry // the one the app actually allowed the handover on. lat: decision.riderLat, lon: decision.riderLng, ); final t = MilerLifecycle.inwardAtHub(res); MilerLifecycle.report('inward-at-hub', t); debugPrint( '[TRACE][HANDOVER] consignment=$consignmentId -> ${res.status} ' '${res.code} confirmed=${t.isConfirmed} raw=${res.raw}', ); if (t.isConfirmed) return true; // ── A 200 that names no state is not proof ── // // The rule request 15 exists for, and the one this app has been bitten by on // `reached`: a bare success is not a transition. The rider is not shown a // handover the hub may not have recorded. if (t.isUnconfirmed) { lastHandoverFailure = 'Your office did not confirm the handover. Check with the base before ' 'you leave the parcel.'; return false; } final serverMsg = (res.message).trim(); lastHandoverFailure = serverMsg.isNotEmpty && serverMsg.length < 140 ? 'The base would not accept this parcel: $serverMsg' : 'The base would not accept this parcel. Ask your office to check it.'; return false; }