Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
1223 lines
55 KiB
Dart
1223 lines
55 KiB
Dart
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<String> _consignmentIdFromBackend(Map<String, dynamic> 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<String> resolveConsignmentId(Map<String, dynamic> 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 <String>[
|
|
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<bool> releaseForDelivery(Map<String, dynamic> 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<void> _reconcileAlreadyDone(
|
|
Map<String, dynamic> 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<Map<String, dynamic>?> closeDelivery(
|
|
BuildContext context, {
|
|
required Map<String, dynamic> stop,
|
|
required _MyPickupsState? parentState,
|
|
required DeliveryOutcome outcome,
|
|
String notes = '',
|
|
String proofPath = '',
|
|
}) {
|
|
final key = '${outcome.name}:${(stop['orderid'] ?? '').toString()}';
|
|
return MutationGuard.run<Map<String, dynamic>?>(
|
|
key,
|
|
() => _closeDelivery(
|
|
context,
|
|
stop: stop,
|
|
parentState: parentState,
|
|
outcome: outcome,
|
|
notes: notes,
|
|
proofPath: proofPath,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> _closeDelivery(
|
|
BuildContext context, {
|
|
required Map<String, dynamic> 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<String, dynamic> 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<bool> handOverAtHub(Map<String, dynamic> 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;
|
|
}
|