production
This commit is contained in:
@@ -214,25 +214,142 @@ Future<String> resolveConsignmentId(Map<String, dynamic> stop) async {
|
||||
/// (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.
|
||||
/// • **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;
|
||||
|
||||
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']);
|
||||
if (state == ConsignmentState.unknown) {
|
||||
// ── 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) return true;
|
||||
if (!state.needsRelease && state != ConsignmentState.unknown) {
|
||||
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 said.
|
||||
// A hold that cannot exist on this line is a bad read, not a fact about the
|
||||
// stop. See [_handoffHoldIsPossible].
|
||||
final heldElsewhere = state.awaitsHub && _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');
|
||||
lastReleaseFailure = heldElsewhere
|
||||
// The honest answer for a cross-city parcel: it is not his round, and
|
||||
// saying so without naming a building he has never been to.
|
||||
? "This one has already been handed on — it's not yours to deliver."
|
||||
: '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(
|
||||
@@ -242,7 +359,16 @@ Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
|
||||
// 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;
|
||||
if (after.isDeliverable || after.isDelivered) return true;
|
||||
|
||||
// `status: 0` is the request never leaving; anything else is the server
|
||||
// answering, and its own words beat a guess about the network.
|
||||
lastReleaseFailure = res.status == 0
|
||||
? 'Could not reach your office. Check your connection and slide again.'
|
||||
: (res.message.trim().isNotEmpty
|
||||
? res.message.trim()
|
||||
: 'Your office would not start this delivery.');
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Brings this device into line with a delivery the server already has.
|
||||
@@ -386,15 +512,42 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
}
|
||||
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');
|
||||
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 is still with the hub. It can be delivered once the '
|
||||
'hub releases it.',
|
||||
"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;
|
||||
@@ -441,9 +594,18 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
}
|
||||
}
|
||||
|
||||
if (outcome != DeliveryOutcome.cancelled && consignmentId.isEmpty) {
|
||||
// Cancel is a *booking* route, so it is the one outcome that still works
|
||||
// without a consignment.
|
||||
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
|
||||
@@ -492,7 +654,7 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
context,
|
||||
collected
|
||||
? "This order's delivery reference is missing, so it cannot be "
|
||||
'completed from the app. Ask your hub to check it — '
|
||||
'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.',
|
||||
@@ -516,6 +678,38 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
// 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) {
|
||||
@@ -523,6 +717,7 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
ok = await dc.updateDeliveredStatus(
|
||||
pickupId: pickupIdInt,
|
||||
consignmentId: consignmentId,
|
||||
proofImage: proofUrl,
|
||||
deliveredToName:
|
||||
(stop['dropcustomer'] ??
|
||||
stop['pickupcustomer'] ??
|
||||
@@ -544,15 +739,55 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
// 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,
|
||||
);
|
||||
//
|
||||
// ── 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,
|
||||
@@ -574,10 +809,26 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
// 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,
|
||||
dc.lastBlockedReason ??
|
||||
'Could not record ${outcome.pastTense.toLowerCase()} — try again',
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user