298 lines
11 KiB
Dart
298 lines
11 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:miler/data/api_status.dart';
|
|
import 'package:miler/data/consignment_state.dart';
|
|
import 'package:miler/data/miler_api.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// WHAT THE SERVER ACTUALLY CONFIRMED
|
|
///
|
|
/// Every rung this app draws — Accepted, Arrived, Picked, Active, Delivered —
|
|
/// is a claim about what the **hub** believes. The app had no way to check
|
|
/// that claim: a mutation returned `ok`, the screen advanced, and whether the
|
|
/// office could see the same thing was never asked.
|
|
///
|
|
/// ── The bug that made this a file ──
|
|
///
|
|
/// Verified against production on 21 Aug 2026, booking 78:
|
|
///
|
|
/// ```
|
|
/// BEFORE GET /miler/bookings status = Miler_Assigned
|
|
/// CALL POST /miler/bookings/78/reached
|
|
/// → 200 {"success":true,"data":{"bookingid":78,
|
|
/// "status":"Miler_Assigned"}}
|
|
/// AFTER GET /miler/bookings status = Miler_Assigned
|
|
/// ```
|
|
///
|
|
/// `reached` **returns success and writes nothing.** It echoes the booking's
|
|
/// current status. So the rider pressed *I've arrived*, the app got `ok: true`,
|
|
/// drew ARRIVED, and the hub never heard about it — not because the app failed
|
|
/// to call, but because a 200 was taken as proof of a transition that never
|
|
/// happened.
|
|
///
|
|
/// ── The rule ──
|
|
///
|
|
/// A 200 is proof the call was accepted. It is **not** proof of a state
|
|
/// change. The only proof of a state change is the state coming back in the
|
|
/// response. So every lifecycle mutation is read through this file, which
|
|
/// answers three separate questions the caller used to conflate:
|
|
///
|
|
/// • did the call succeed? → [ApiResult.ok]
|
|
/// • did the state actually move? → [TransitionOutcome.confirmed]
|
|
/// • what is the state now? → [bookingStatus] / [consignmentState]
|
|
///
|
|
/// Nothing here blocks a rider. A backend that does not record his arrival is
|
|
/// the backend's fault, and stranding him at a kitchen over it would turn one
|
|
/// broken endpoint into a stopped operation. What it does is stop the app
|
|
/// *claiming* the hub agrees when it demonstrably does not — the claim is
|
|
/// downgraded, logged with the evidence, and surfaced.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
enum TransitionOutcome {
|
|
/// The response carries the state the call was supposed to produce. The rung
|
|
/// the rider sees is the rung the office sees.
|
|
confirmed,
|
|
|
|
/// The call succeeded and the state did **not** move — or moved somewhere
|
|
/// the response does not name. The rider may carry on; the app must not
|
|
/// pretend the hub knows. See [MilerLifecycle.reached].
|
|
unconfirmed,
|
|
|
|
/// The server refused. A real failure with a reason.
|
|
refused,
|
|
}
|
|
|
|
/// One lifecycle mutation, read for what it proves.
|
|
@immutable
|
|
class StateTransition {
|
|
const StateTransition({
|
|
required this.outcome,
|
|
required this.bookingStatus,
|
|
required this.consignmentState,
|
|
this.consignmentId = '',
|
|
this.nextAction = '',
|
|
this.code = '',
|
|
this.message = '',
|
|
this.evidence = '',
|
|
});
|
|
|
|
final TransitionOutcome outcome;
|
|
|
|
/// The booking status the response reported, parsed. [BookingStatus.unknown]
|
|
/// when the response named none.
|
|
final BookingStatus bookingStatus;
|
|
|
|
/// The consignment state the response reported, parsed.
|
|
final ConsignmentState consignmentState;
|
|
|
|
/// The id minted at the pivot, when this transition was one.
|
|
final String consignmentId;
|
|
|
|
/// The server's own instruction for what happens next — `start_delivery`,
|
|
/// `inward_at_hub`. Read, never assumed.
|
|
final String nextAction;
|
|
|
|
/// The stable failure code, for callers that must branch on *why*.
|
|
final String code;
|
|
|
|
final String message;
|
|
|
|
/// What the response actually said, for a log line that can be pasted into a
|
|
/// backend ticket without re-running anything.
|
|
final String evidence;
|
|
|
|
bool get isConfirmed => outcome == TransitionOutcome.confirmed;
|
|
bool get isUnconfirmed => outcome == TransitionOutcome.unconfirmed;
|
|
bool get isRefused => outcome == TransitionOutcome.refused;
|
|
|
|
/// True when the pivot left the consignment in the rider's hands awaiting a
|
|
/// **Start delivery** press — the post-flag lifecycle.
|
|
bool get awaitsStartDelivery =>
|
|
consignmentState.needsRelease || nextAction == 'start_delivery';
|
|
|
|
/// True when the pivot released the consignment itself, which is the
|
|
/// pre-flag lifecycle the backend calls compatibility mode.
|
|
///
|
|
/// **This is the reason Admin shows Active for a stop the rider just
|
|
/// picked.** Not a client bug and not something the app may paper over: the
|
|
/// consignment really is out for delivery, and a rider told otherwise would
|
|
/// be looking at a different truth from his office.
|
|
bool get isCompatibilityMode =>
|
|
consignmentState.isDeliverable && nextAction != 'start_delivery';
|
|
}
|
|
|
|
/// Reads lifecycle mutations. Pure — no I/O of its own.
|
|
abstract final class MilerLifecycle {
|
|
/// First non-empty value among [keys], searched shallow then one level in.
|
|
static String _str(Map<String, dynamic> data, List<String> keys) {
|
|
for (final k in keys) {
|
|
final v = data[k];
|
|
if (v == null || v is Map || v is List) continue;
|
|
final s = v.toString().trim();
|
|
if (s.isNotEmpty && s != 'null' && s != '0') return s;
|
|
}
|
|
for (final v in data.values) {
|
|
if (v is Map) {
|
|
final nested = v.map((k, x) => MapEntry(k.toString(), x));
|
|
final found = _str(nested, keys);
|
|
if (found.isNotEmpty) return found;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/// `POST /miler/bookings/:id/reached`.
|
|
///
|
|
/// Confirmed **only** when the response reports
|
|
/// [BookingStatus.arrivedAtPickup]. Anything else — including the 200 that
|
|
/// echoes the unchanged status, which is what production returns today — is
|
|
/// [TransitionOutcome.unconfirmed], and the evidence says exactly what came
|
|
/// back so the report writes itself.
|
|
static StateTransition reached(ApiResult res) {
|
|
if (!res.ok) {
|
|
return StateTransition(
|
|
outcome: TransitionOutcome.refused,
|
|
bookingStatus: BookingStatus.unknown,
|
|
consignmentState: ConsignmentState.unknown,
|
|
code: res.code,
|
|
message: res.message,
|
|
evidence: 'HTTP ${res.status} ${res.code} ${res.message}',
|
|
);
|
|
}
|
|
|
|
final raw = _str(res.data, const [
|
|
'status',
|
|
'bookingstatus',
|
|
'booking_status',
|
|
]);
|
|
final parsed = BookingStatus.parse(raw);
|
|
|
|
// ── Arrival is a timestamp, not a status ──
|
|
//
|
|
// This asked whether the booking's `status` had become
|
|
// `Arrived_At_Pickup`, and answered *unconfirmed* forever — because the
|
|
// backend team has since confirmed there is **no Arrived rung in the
|
|
// booking-status lifecycle at all**. It goes
|
|
// `pickup_scheduled → converted_to_consignment → …`, and arrival is
|
|
// recorded beside it as `reachedat`.
|
|
//
|
|
// So the app was demanding evidence of a transition the backend never
|
|
// claimed to make, and logging a gap every time it did not get it. The
|
|
// proof of arrival is the arrival stamp coming back; the status echoing
|
|
// `Miler_Assigned` or `pickup_scheduled` is correct and expected.
|
|
final stamp = _str(res.data, const [
|
|
'reachedat',
|
|
'reached_at',
|
|
'arrivedat',
|
|
]);
|
|
final confirmed = stamp.isNotEmpty;
|
|
|
|
return StateTransition(
|
|
outcome: confirmed
|
|
? TransitionOutcome.confirmed
|
|
: TransitionOutcome.unconfirmed,
|
|
bookingStatus: parsed,
|
|
consignmentState: ConsignmentState.unknown,
|
|
evidence: confirmed
|
|
? 'the response stamped the arrival at "$stamp"'
|
|
: raw.isEmpty
|
|
? 'the response carried no arrival stamp and named no status'
|
|
: 'the response carried no arrival stamp; status="$raw"',
|
|
);
|
|
}
|
|
|
|
/// `POST /miler/bookings/:id/pickup-complete`.
|
|
///
|
|
/// The pivot has two legitimate outcomes and the app must not choose between
|
|
/// them from a build-time assumption:
|
|
///
|
|
/// `Collected_By_Miler` + `next_action: start_delivery`
|
|
/// the rider holds it; **Start delivery** releases it.
|
|
/// `Out_for_Delivery`
|
|
/// compatibility mode — the pivot released it in the same call.
|
|
///
|
|
/// Both are confirmed transitions. Which one happened is [isCompatibilityMode],
|
|
/// read from the response and never from a flag mirrored into this app.
|
|
static StateTransition pickupComplete(ApiResult res) {
|
|
if (!res.ok) {
|
|
return StateTransition(
|
|
outcome: TransitionOutcome.refused,
|
|
bookingStatus: BookingStatus.unknown,
|
|
consignmentState: ConsignmentState.unknown,
|
|
code: res.code,
|
|
message: res.message,
|
|
evidence: 'HTTP ${res.status} ${res.code} ${res.message}',
|
|
);
|
|
}
|
|
|
|
final bookingRaw = _str(res.data, const [
|
|
'booking_status',
|
|
'bookingstatus',
|
|
'status',
|
|
]);
|
|
final consignmentRaw = _str(res.data, const [
|
|
'consignmentstatus',
|
|
'consignment_status',
|
|
'consignmentstate',
|
|
]);
|
|
final id = _str(res.data, const [
|
|
'consignment_id',
|
|
'consignmentid',
|
|
'consignmentId',
|
|
'consignmentno',
|
|
]);
|
|
final next = _str(res.data, const [
|
|
'next_action',
|
|
'nextaction',
|
|
]).toLowerCase();
|
|
|
|
final booking = BookingStatus.parse(bookingRaw);
|
|
var consignment = consignmentStateFromRaw(consignmentRaw);
|
|
|
|
// A response that names only the booking still answers the question when
|
|
// the booking word is one of the two that carries the delivery half.
|
|
if (consignment == ConsignmentState.unknown) {
|
|
if (booking == BookingStatus.outForDelivery) {
|
|
consignment = ConsignmentState.outForDelivery;
|
|
} else if (next == 'start_delivery') {
|
|
consignment = ConsignmentState.collectedByMiler;
|
|
}
|
|
}
|
|
|
|
// The pivot is confirmed by the id it minted. Without one there is nothing
|
|
// to deliver against, whatever the words say.
|
|
final confirmed =
|
|
id.isNotEmpty ||
|
|
booking == BookingStatus.convertedToConsignment ||
|
|
consignment != ConsignmentState.unknown;
|
|
|
|
return StateTransition(
|
|
outcome: confirmed
|
|
? TransitionOutcome.confirmed
|
|
: TransitionOutcome.unconfirmed,
|
|
bookingStatus: booking,
|
|
consignmentState: consignment,
|
|
consignmentId: id,
|
|
nextAction: next,
|
|
evidence:
|
|
'booking="$bookingRaw" consignment="$consignmentRaw" '
|
|
'id="$id" next="$next"',
|
|
);
|
|
}
|
|
|
|
/// One line, in the shape a backend ticket wants.
|
|
static void report(String verb, StateTransition t) {
|
|
switch (t.outcome) {
|
|
case TransitionOutcome.confirmed:
|
|
debugPrint('[LIFECYCLE][$verb] confirmed — ${t.evidence}');
|
|
case TransitionOutcome.unconfirmed:
|
|
debugPrint(
|
|
'[LIFECYCLE][$verb] NOT CONFIRMED BY THE SERVER — ${t.evidence}. '
|
|
'The call succeeded and the state did not move. This is a backend '
|
|
'deployment mismatch, not a client failure.',
|
|
);
|
|
case TransitionOutcome.refused:
|
|
debugPrint('[LIFECYCLE][$verb] refused — ${t.evidence}');
|
|
}
|
|
}
|
|
}
|