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, } extension DeliveryOutcomeX on DeliveryOutcome { String get label => switch (this) { DeliveryOutcome.delivered => 'Delivered', DeliveryOutcome.skipped => 'Skip', DeliveryOutcome.cancelled => 'Cancelled', }; 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, }; Color get colour => switch (this) { DeliveryOutcome.delivered => ColorConstants.acceptGreen, DeliveryOutcome.skipped => ColorConstants.warning, DeliveryOutcome.cancelled => ColorConstants.errorRed, }; /// What Activity will show once the round is over. String get pastTense => switch (this) { DeliveryOutcome.delivered => 'Delivered', DeliveryOutcome.skipped => 'Skipped', DeliveryOutcome.cancelled => 'Cancelled', }; } /// 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. Future releaseForDelivery(Map stop) async { final consignmentId = await resolveConsignmentId(stop); if (consignmentId.isEmpty) { debugPrint('[MILKRUN] no consignment id for ${MilkRun.idOf(stop)}'); return false; } // What the list row already told us, before spending a request on it. var state = consignmentStateFromRaw(stop['consignmentstatus']); if (state == ConsignmentState.unknown) { state = await ConsignmentGate.stateOf(consignmentId); } if (state.isDeliverable || state.isDelivered) return true; if (!state.needsRelease && state != ConsignmentState.unknown) { debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable'); return false; } final res = await MilerApi.startDelivery(consignmentId); 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); return after.isDeliverable || after.isDelivered; } /// 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]. 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.awaitingHub: // The guard that must survive: a logistics consignment sitting at a // hub genuinely is not this rider's to hand over. debugPrint('[DELIVERY] $orderId is with the hub — refusing'); if (context.mounted) { AppFeedback.error( context, 'This parcel is still with the hub. It can be delivered once the ' 'hub releases it.', ); } 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.cancelled && consignmentId.isEmpty) { // Cancel is a *booking* route, so it is the one outcome that still works // without a consignment. // // 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 hub 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. } bool ok = false; try { switch (outcome) { case DeliveryOutcome.delivered: ok = await dc.updateDeliveredStatus( pickupId: pickupIdInt, consignmentId: consignmentId, 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.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. final res = 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}'); 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) { AppFeedback.error( context, 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, }; }