719 lines
30 KiB
Dart
719 lines
30 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
|
||
import 'package:miler/data/api_config.dart';
|
||
import 'package:miler/data/assignment_lookup.dart';
|
||
import 'package:miler/data/lifecycle.dart';
|
||
import 'package:miler/data/consignment_state.dart';
|
||
import 'package:miler/data/miler_api.dart';
|
||
import 'package:miler/data/accepted_store.dart';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// THE PER-STOP WRITE PATH
|
||
///
|
||
/// Everything the rider does at a door goes through here.
|
||
///
|
||
/// ── What changed ──
|
||
///
|
||
/// This file used to carry two complete implementations of every call: a
|
||
/// legacy one that spoke to the retired backend through a hand-built
|
||
/// `IOClient` — one that disabled certificate checking, pinned a hardcoded IP
|
||
/// and did its own TLS upgrade to work around a carrier's broken DNS — and a
|
||
/// v1 one behind `if (ApiConfig.useNewApi)`. The legacy backend is gone, and
|
||
/// with it the reason to ship a client that trusts any certificate presented to
|
||
/// it. Both are deleted.
|
||
///
|
||
/// ── The four calls that were missing ──
|
||
///
|
||
/// The v1 pickup flow is four ordered steps, and only two of them were ever
|
||
/// sent. `parcel` and `payment` were collected from the rider at the door and
|
||
/// then dropped on the floor:
|
||
///
|
||
/// 1. `reached` ✅ was wired
|
||
/// 2. `parcel` ❌ the weight and count he typed went nowhere
|
||
/// 3. `payment` ❌ the cash he took went nowhere
|
||
/// 4. `pickup-complete` ✅ was wired
|
||
///
|
||
/// Step 2 is the one that costs money: `pickup-complete` recomputes chargeable
|
||
/// weight from those dimensions, so a booking completed without them bills on
|
||
/// the customer's estimate rather than on what the rider actually weighed. And
|
||
/// step 3 is the rider's own accountability record for cash he is carrying.
|
||
///
|
||
/// `skip` and `cancel` were worse than missing — they returned a *fabricated
|
||
/// success*, so the app told the rider his skip had registered while nothing
|
||
/// left the phone. Both endpoints exist and are now called.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Per-stop GPS breadcrumbs, tied to a consignment.
|
||
class CreatePickupLogProvider {
|
||
/// Was a no-op returning a synthetic success, on the grounds that "there is
|
||
/// no audit-log endpoint in v1". There is: `POST /miler/consignments/logs`,
|
||
/// which takes a JSON **array** and wants its numbers as strings.
|
||
///
|
||
/// Best-effort by design. Telemetry is Redis-backed and is explicitly not the
|
||
/// system of record — losing a breadcrumb costs a line on a map, and blocking
|
||
/// the rider to retry one would cost him a stop.
|
||
Future<Map<String, dynamic>?> createPickupLog(
|
||
Map<String, dynamic> data,
|
||
) async {
|
||
final consignmentId =
|
||
data['consignmentid'] ?? data['orderheaderid'] ?? data['pickupid'];
|
||
final lat = double.tryParse(
|
||
'${data['latitude'] ?? data['riderslat'] ?? ''}',
|
||
);
|
||
final lon = double.tryParse(
|
||
'${data['longitude'] ?? data['riderslon'] ?? ''}',
|
||
);
|
||
|
||
if (consignmentId == null || lat == null || lon == null) {
|
||
// Not enough to place the breadcrumb. Silently skipping is right — this
|
||
// fires on a timer and a missing fix is normal, not an error.
|
||
return ApiConfig.okEnvelope();
|
||
}
|
||
|
||
final res = await MilerApi.postConsignmentLogs([
|
||
ConsignmentLogEntry(
|
||
consignmentId: consignmentId,
|
||
latitude: lat,
|
||
longitude: lon,
|
||
status: (data['status'] ?? data['orderstatus'])?.toString(),
|
||
speed: double.tryParse('${data['speed'] ?? ''}'),
|
||
heading: double.tryParse('${data['heading'] ?? ''}'),
|
||
battery: int.tryParse('${data['battery'] ?? ''}'),
|
||
remarks: (data['remarks'] ?? '').toString(),
|
||
isBackground: data['is_background'] == true,
|
||
),
|
||
]);
|
||
return res.ok ? ApiConfig.okEnvelope() : {'status': false};
|
||
}
|
||
}
|
||
|
||
class UpdatePickupProvider {
|
||
/// Maps a legacy `updatepickup` payload onto the v1 route its `orderstatus`
|
||
/// means.
|
||
///
|
||
/// The legacy payload shape is kept because ~8 call sites in
|
||
/// `PickupsController` build it, and rewriting those is a separate change to
|
||
/// a file this one has no business touching. What matters is that every
|
||
/// branch now ends in a real request or an honest failure — there are no
|
||
/// invented successes left.
|
||
/// The booking this payload is about, or null when it carries none.
|
||
///
|
||
/// Treats `0`, `''` and an unparseable value as absent — the three shapes a
|
||
/// missing id actually arrives in, none of which is the `null` a `??` chain
|
||
/// is looking for.
|
||
static Object? _bookingId(Map<String, dynamic> data) {
|
||
for (final key in const ['pickupid', 'orderheaderid']) {
|
||
final raw = data[key];
|
||
if (raw == null) continue;
|
||
final s = raw.toString().trim();
|
||
if (s.isEmpty) continue;
|
||
final n = int.tryParse(s);
|
||
if (n != null) {
|
||
if (n > 0) return n;
|
||
continue;
|
||
}
|
||
// A non-numeric reference — a tracking number — is a real id.
|
||
return s;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Future<Map<String, dynamic>?> updatePickup(Map<String, dynamic> data) async {
|
||
final String status = (data['orderstatus'] ?? '').toString();
|
||
final String notes = (data['notes'] ?? '').toString();
|
||
|
||
// ── `??` was catching null, and a missing id is never null here ──
|
||
//
|
||
// This read `data['pickupid'] ?? data['orderheaderid']`. Every caller
|
||
// builds its payload with `int.tryParse('${'$'}{stop['pickupid'] ?? 0}') ?? 0`,
|
||
// so a row without a booking id arrives as **`0`** — which is not null, so
|
||
// the fallback never fired and the app posted to
|
||
// `/miler/bookings/0/reached`.
|
||
//
|
||
// Nothing complained. The rider's rung advanced anyway (arrival is his own
|
||
// report and is deliberately not gated on the write landing), so from the
|
||
// phone it looked identical to a healthy arrival — while the hub was told
|
||
// about booking zero, and the console showed no arrival at all.
|
||
//
|
||
// Zero and empty are *absent* now, and an absent id refuses the call
|
||
// outright rather than aiming it at a booking that does not exist. See
|
||
// [_bookingId].
|
||
final id = _bookingId(data);
|
||
if (id == null) {
|
||
ApiConfig.logGap(
|
||
'update:$status',
|
||
'no usable booking id on the payload — pickupid='
|
||
'${data['pickupid']}, orderheaderid=${data['orderheaderid']}. '
|
||
'Refusing to post; the hub would have been told about booking 0.',
|
||
);
|
||
return {
|
||
'status': false,
|
||
'message': 'This stop is missing its booking reference.',
|
||
};
|
||
}
|
||
|
||
final double? lat = double.tryParse(
|
||
'${data['riderslat'] ?? data['pickuplat'] ?? ''}',
|
||
);
|
||
final double? lon = double.tryParse(
|
||
'${data['riderslon'] ?? data['pickuplong'] ?? ''}',
|
||
);
|
||
|
||
Map<String, dynamic> envelope(ApiResult r) => r.ok
|
||
? ApiConfig.toLegacyEnvelope(r.raw ?? {'success': true})
|
||
: {
|
||
'status': false,
|
||
'code': r.status,
|
||
'message': r.message,
|
||
// The backend's stable failure name, alongside the HTTP status
|
||
// this envelope has always called `code`. Callers that need to
|
||
// branch on *why* a write was refused read this one — message text
|
||
// is prose, and prose gets reworded.
|
||
'errorcode': r.code,
|
||
};
|
||
|
||
switch (status) {
|
||
case 'accepted':
|
||
case 'rejected':
|
||
// Keyed on bookingassignmentid, not bookingid — different sequences.
|
||
// Sending the booking id makes the backend answer 404 while the rider
|
||
// is shown success and the booking is never actually accepted.
|
||
final assignmentId = await AssignmentLookup.idForBooking(id);
|
||
if (assignmentId == null) {
|
||
ApiConfig.logGap(
|
||
'update:$status',
|
||
'No assignment row for booking $id — cannot $status.',
|
||
);
|
||
return {
|
||
'status': false,
|
||
'message': 'This stop is no longer available.',
|
||
};
|
||
}
|
||
AssignmentLookup.invalidate();
|
||
final r = status == 'accepted'
|
||
? await MilerApi.acceptAssignment(assignmentId)
|
||
: await MilerApi.rejectAssignment(
|
||
assignmentId,
|
||
reason: notes.isEmpty ? 'Declined by rider' : notes,
|
||
);
|
||
return envelope(r);
|
||
|
||
case 'arrived':
|
||
// ── A 200 is not proof the hub recorded an arrival ──
|
||
//
|
||
// Verified against production 21 Aug 2026: `reached` returns
|
||
// `{"success":true,"data":{"bookingid":78,"status":"Miler_Assigned"}}`
|
||
// and the booking's status is unchanged afterwards. The endpoint is a
|
||
// no-op that reports success, so `Arrived_At_Pickup` never reaches the
|
||
// console — and the app, which read only `ok`, drew ARRIVED anyway.
|
||
//
|
||
// The transition is now read for what it proves. The envelope carries
|
||
// the verdict so the caller can advance the rider without the app
|
||
// claiming an agreement that does not exist. See [MilerLifecycle].
|
||
final reached = await MilerApi.reached(id, lat: lat, lon: lon);
|
||
final arrival = MilerLifecycle.reached(reached);
|
||
MilerLifecycle.report('reached', arrival);
|
||
if (arrival.isUnconfirmed) {
|
||
ApiConfig.logGap(
|
||
'reached',
|
||
'booking $id: the call succeeded and the booking status did not '
|
||
'become Arrived_At_Pickup — ${arrival.evidence}. The hub '
|
||
'cannot show this rider as arrived. Backend deployment '
|
||
'mismatch; see MILER_API_REQUIREMENTS.md request 15.',
|
||
);
|
||
}
|
||
return {
|
||
...envelope(reached),
|
||
'confirmed': arrival.isConfirmed,
|
||
'serverstatus': arrival.bookingStatus.name,
|
||
'evidence': arrival.evidence,
|
||
};
|
||
|
||
case 'Picked up':
|
||
case 'picked':
|
||
// ── The pivot returns the thing the delivery leg runs on ──
|
||
//
|
||
// `pickup-complete` is what *creates* the consignment, and `deliver`
|
||
// and `skip` are consignment routes. The response was being wrapped and
|
||
// discarded, and the row the rider works from on Deliveries is a
|
||
// snapshot taken before this call — so nothing downstream knew the
|
||
// consignment id, and pressing **Delivered** at the door was refused by
|
||
// the app itself. Recorded here, at the only moment it is guaranteed to
|
||
// be in hand. See [rememberConsignmentId].
|
||
final picked = await MilerApi.pickupComplete(id, lat: lat, lon: lon);
|
||
// ── Which lifecycle the server is running, read from the server ──
|
||
//
|
||
// `Collected_By_Miler` + `next_action: start_delivery` is the new
|
||
// two-step flow; a bare `Out_for_Delivery` is compatibility mode, in
|
||
// which the pivot releases the consignment itself. Both are real, both
|
||
// ship today depending on a server-side flag, and the app must never
|
||
// mirror that flag — it reads which one happened.
|
||
final pivot = MilerLifecycle.pickupComplete(picked);
|
||
MilerLifecycle.report('pickup-complete', pivot);
|
||
// ── The Picked half of the lifecycle trace ──
|
||
//
|
||
// Unconditional, and it prints the response body. `_send` logs only
|
||
// failures, so a pivot that succeeded and wrote the *wrong* state —
|
||
// which is the entire production question — left no record at all, and
|
||
// every investigation of it started by adding this line by hand.
|
||
//
|
||
// Read with the `[TRACE][START-RIDE]` line in `releaseForDelivery`:
|
||
// together they answer "did the app release at Picked?" from one log,
|
||
// with no console access and no packet capture. See MilerLifecycle.
|
||
debugPrint(
|
||
'[TRACE][PICKED] booking=$id '
|
||
'POST /miler/bookings/$id/pickup-complete '
|
||
'-> ${picked.status} booking="${pivot.bookingStatus.name}" '
|
||
'consignment="${pivot.consignmentState.name}" '
|
||
'id="${pivot.consignmentId}" next="${pivot.nextAction}" '
|
||
'compatibilityMode=${pivot.isCompatibilityMode} '
|
||
'startDeliveryCalledHere=false '
|
||
'raw=${picked.raw}',
|
||
);
|
||
if (pivot.isCompatibilityMode) {
|
||
ApiConfig.logGap(
|
||
'pickup-complete',
|
||
'booking $id went straight to Out_for_Delivery — the backend is '
|
||
'running compatibility mode (MILER_COLLECTED_STATE_ENABLED '
|
||
'off). The consignment IS out for delivery, so the console '
|
||
'showing Active is correct and the app must agree with it.',
|
||
);
|
||
}
|
||
if (picked.ok) {
|
||
final newId = _consignmentIdFrom(picked);
|
||
final orderKey = (data['orderid'] ?? id ?? '').toString().trim();
|
||
|
||
// ── What the pivot said about what happens next, RECORDED ──
|
||
//
|
||
// Since 21 Aug 2026 the response carries `status` and `next_action`:
|
||
// a hyperlocal booking lands on `Collected_By_Miler` with
|
||
// `start_delivery`, and a hub-routed one on `Created` with
|
||
// `inward_at_hub`.
|
||
//
|
||
// This used to be logged and dropped, on the reasoning that the
|
||
// states it produces are read back from the consignment itself. That
|
||
// holds for the *release*, and not for the **routing**: the pivot is
|
||
// the only place the server ever names the leg, `GET /miler/bookings`
|
||
// does not carry `next_action`, and `Created` — the state a
|
||
// hub-routed parcel sits on — is also the state a consignment holds
|
||
// while the pivot is still routing it. So on the next poll the two
|
||
// routings are indistinguishable, and the app either strands a
|
||
// hyperlocal parcel or offers a customer delivery for a parcel bound
|
||
// for Chennai.
|
||
//
|
||
// Recorded per order, and read as the **lowest** rung of
|
||
// [NextLegResolver]'s precedence — continuity across a rebuild, never
|
||
// an override of live server state.
|
||
//
|
||
// Written outside the `newId` branch on purpose: the routing answer
|
||
// is worth keeping even on a response that named no consignment,
|
||
// which is a real shape (see the gap logged below).
|
||
await rememberPivotNextAction(orderKey, pivot.nextAction);
|
||
|
||
if (newId.isNotEmpty) {
|
||
await rememberConsignmentId(orderKey, newId);
|
||
debugPrint(
|
||
'[PICKUP] booking $orderKey → consignment $newId '
|
||
'(${_field(picked, 'status')}, next: '
|
||
'${_field(picked, 'next_action')})',
|
||
);
|
||
} else {
|
||
ApiConfig.logGap(
|
||
'pickup-complete',
|
||
'no consignment id in the response for booking $id — the '
|
||
'delivery leg will have to wait for the queue to carry it.',
|
||
);
|
||
// The one thing that makes this diagnosable next time. Debug-only:
|
||
// a response body is not something to write into a release log.
|
||
if (kDebugMode) {
|
||
debugPrint('[PICKUP] pickup-complete body: ${picked.raw}');
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
...envelope(picked),
|
||
'confirmed': pivot.isConfirmed,
|
||
// What the stop actually became, in the consignment's own words.
|
||
// The caller stamps this rather than assuming 'picked' — the whole
|
||
// point of the exercise is that the rider's rung and the office's
|
||
// rung are the same rung.
|
||
'consignmentstatus':
|
||
pivot.consignmentState == ConsignmentState.unknown
|
||
? ''
|
||
: pivot.consignmentState.name,
|
||
'nextaction': pivot.nextAction,
|
||
'compatibilitymode': pivot.isCompatibilityMode,
|
||
'evidence': pivot.evidence,
|
||
};
|
||
|
||
case 'delivered':
|
||
case 'Delivered':
|
||
// ── This route keys on the CONSIGNMENT, not the booking ──
|
||
//
|
||
// Same trap as accept/reject and `bookingassignmentid`: two ids from
|
||
// two sequences, and passing the wrong one gets a 404 while the rider
|
||
// is shown success. This branch was passing `id`, which is the booking
|
||
// — so every delivery it sent was addressed to whatever consignment
|
||
// happened to share that number.
|
||
//
|
||
// The consignment exists only after `pickup-complete` has converted the
|
||
// booking; `MilerGetMyBookings` returns it and the stop adapter carries
|
||
// it through. Without one there is nothing to deliver *yet*, and saying
|
||
// so is better than guessing an id.
|
||
final consignmentId =
|
||
(data['consignmentid'] ?? data['consignmentId'] ?? '')
|
||
.toString()
|
||
.trim();
|
||
if (consignmentId.isEmpty) {
|
||
ApiConfig.logGap(
|
||
'update:delivered',
|
||
'Booking $id has no consignmentid — it has not been collected yet, '
|
||
'so there is nothing to deliver.',
|
||
);
|
||
return {
|
||
'status': false,
|
||
'message': 'This order has not been picked up yet.',
|
||
};
|
||
}
|
||
return envelope(
|
||
await MilerApi.deliver(
|
||
consignmentId,
|
||
deliveredToName: (data['pickupcustomer'] ?? '').toString(),
|
||
// Optional: nothing generates a delivery OTP, so the handler
|
||
// records whether one was presented rather than checking it. A
|
||
// milk-run drop has none and must not invent one.
|
||
otp: (data['otp'] ?? '').toString(),
|
||
photoUrl: (data['dropimage'] ?? '').toString(),
|
||
receiverSignatureUrl: (data['signature'] ?? '').toString(),
|
||
lat: lat,
|
||
lon: lon,
|
||
),
|
||
);
|
||
|
||
case 'skipped':
|
||
// ── The booking skip, not the consignment skip ──
|
||
//
|
||
// It was a fabricated success once — `okEnvelope('skip not supported
|
||
// by backend')` — so the rider watched a stop move to "skipped" while
|
||
// nothing left the phone. The fix that replaced it posted to
|
||
// `POST /miler/consignments/:id/skip` with a **booking** id, because
|
||
// that was the only skip route on the contract. Two different
|
||
// sequences: the call either 404'd or, worse, skipped whichever
|
||
// consignment happened to carry that number.
|
||
//
|
||
// Every caller of this branch is pre-collection — the skip sheet opens
|
||
// from a pickup card — and the pre-pickup skip now has its own route,
|
||
// which keeps the booking assigned and resumable. A skip *after*
|
||
// collection never comes through here: it goes through
|
||
// `closeDelivery`, which resolves the real consignment id first.
|
||
return envelope(
|
||
await MilerApi.skipBooking(
|
||
id,
|
||
reason: notes.isEmpty ? 'Skipped by rider' : notes,
|
||
lat: lat,
|
||
lon: lon,
|
||
),
|
||
);
|
||
|
||
case 'cancelled':
|
||
// Also previously fabricated. Refused server-side once the stop is
|
||
// picked up, which is correct and now surfaces as a real failure.
|
||
return envelope(
|
||
await MilerApi.cancelBooking(
|
||
id,
|
||
reason: notes.isEmpty ? 'Cancelled by rider' : notes,
|
||
),
|
||
);
|
||
|
||
case 'active':
|
||
// `Pickup_Scheduled` is system-set; there is no rider endpoint for it.
|
||
// Nudging availability is the closest true statement the app can make.
|
||
return envelope(await MilerApi.setAvailability('On_Pickup'));
|
||
|
||
default:
|
||
// ── An unmapped status is a failure, not a success ──
|
||
//
|
||
// This returned `okEnvelope()`: nothing left the phone, and the caller
|
||
// was told the write had landed. The rider watched the stop advance,
|
||
// the local stores recorded it, and the hub was never told — which is
|
||
// the single most expensive shape of bug this app can have, because
|
||
// every screen downstream then trusts a transition that does not exist
|
||
// server-side.
|
||
//
|
||
// It also defeats the typed-status handling upstream: a status this
|
||
// build does not recognise must never resolve to a settled state, and
|
||
// silently succeeding here is exactly that, one layer lower.
|
||
ApiConfig.logGap('update:unknown', 'Unmapped orderstatus="$status".');
|
||
return {
|
||
'status': false,
|
||
'message': 'This app cannot record "$status" yet — nothing was sent.',
|
||
};
|
||
}
|
||
}
|
||
|
||
/// The consignment id out of a `pickup-complete` response, whatever shape it
|
||
/// arrives in.
|
||
///
|
||
/// Tolerant on purpose. The contract now names the field — `consignment_id`,
|
||
/// alongside `status` and `next_action` — but this ran for a long time
|
||
/// against a route whose response body was written down nowhere, riders are
|
||
/// on builds that met both, and the cost of a miss is somebody at a door
|
||
/// being told there is nothing to deliver. The search stays.
|
||
/// One shallow field off a mutation response, for logging.
|
||
///
|
||
/// Deliberately not recursive and deliberately not stored: these values are
|
||
/// a description of a moment that has already passed by the time anything
|
||
/// would read them back.
|
||
String _field(ApiResult res, String key) {
|
||
final v = res.data[key];
|
||
if (v == null || v is Map || v is List) return '?';
|
||
final str = v.toString().trim();
|
||
return str.isEmpty ? '?' : str;
|
||
}
|
||
|
||
String _consignmentIdFrom(ApiResult res) {
|
||
// Searched rather than looked up, at every depth. The response body for
|
||
// this route is written down nowhere — not in the API reference, not in the
|
||
// flow doc — so a fixed list of key names is a guess, and the cost of
|
||
// guessing wrong is a rider standing at a door being told there is nothing
|
||
// to deliver. Anything whose key names a consignment and whose value is a
|
||
// plain scalar counts.
|
||
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;
|
||
|
||
// Exact names first, so a nested `id` never wins over a real one.
|
||
for (final key in const [
|
||
'consignmentid',
|
||
'consignmentId',
|
||
'consignment_id',
|
||
'consignmentno',
|
||
]) {
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Then a `consignment` object, whose own id is the one wanted.
|
||
final nested = node['consignment'];
|
||
if (nested is Map) {
|
||
for (final key in const ['consignmentid', 'consignmentId', 'id']) {
|
||
final v = nested[key];
|
||
if (v != null && v is! Map && v is! List) {
|
||
final str = v.toString().trim();
|
||
if (str.isNotEmpty && str != '0') {
|
||
found = str;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
for (final v in node.values) {
|
||
walk(v, depth + 1);
|
||
}
|
||
}
|
||
|
||
walk(res.data, 0);
|
||
if (found.isEmpty) walk(res.raw, 0);
|
||
return found;
|
||
}
|
||
|
||
/// **Step 2** — what the rider actually took, measured at the door.
|
||
///
|
||
/// Reads the verification screen's payload directly rather than taking a
|
||
/// dozen arguments, because that map is already the record of the door and
|
||
/// splitting it would give two places to keep in step.
|
||
///
|
||
/// Dimensions are not collected by the app today — the verify screen asks for
|
||
/// total weight and a count, not L×W×H per parcel — so they go as zero and
|
||
/// the backend bills on weight alone. Collecting them is a UI change waiting
|
||
/// on a decision about how much to ask a rider for at a doorstep.
|
||
Future<Map<String, dynamic>?> submitParcels(
|
||
Object bookingId,
|
||
Map<String, dynamic> verification,
|
||
) async {
|
||
final pickup = verification['pickup'];
|
||
if (pickup is! Map) return ApiConfig.okEnvelope('no pickup leg');
|
||
|
||
final int count = int.tryParse('${pickup['collected'] ?? 0}') ?? 0;
|
||
final double totalWeight =
|
||
double.tryParse('${pickup['weight'] ?? ''}') ?? 0;
|
||
|
||
if (count <= 0 || totalWeight <= 0) {
|
||
ApiConfig.logGap(
|
||
'submitParcels',
|
||
'booking $bookingId: count=$count weight=$totalWeight — nothing to send.',
|
||
);
|
||
return {'status': false, 'message': 'Parcel details are incomplete.'};
|
||
}
|
||
|
||
// The rider weighs the consignment, not each box. Splitting the total
|
||
// evenly is the only honest distribution available from what he was asked,
|
||
// and it keeps the chargeable total correct even though the per-parcel
|
||
// figures are an even split rather than a measurement.
|
||
final double each = totalWeight / count;
|
||
final parcels = [for (var i = 0; i < count; i++) ParcelEntry(weight: each)];
|
||
|
||
final res = await MilerApi.submitParcels(bookingId, parcels);
|
||
debugPrint(
|
||
'[PARCEL] booking $bookingId: $count parcel(s), ${totalWeight}kg '
|
||
'→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}',
|
||
);
|
||
return res.ok
|
||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||
: {'status': false, 'code': res.status, 'message': res.message};
|
||
}
|
||
|
||
/// **Step 1b** — the FROM and TO the rider confirmed with the customer.
|
||
///
|
||
/// Reads the shipment-capture screen's payload, same as [submitParcels] reads
|
||
/// the verification screen's, so there is one record of the door rather than
|
||
/// two argument lists to keep in step.
|
||
///
|
||
/// Sent before the parcel and payment steps because `pickup-complete` builds
|
||
/// the consignment — its routing and its pricing zone — from these values,
|
||
/// and the handler refuses them once that conversion has happened.
|
||
Future<Map<String, dynamic>?> submitAddresses(
|
||
Object bookingId,
|
||
Map<String, dynamic> capture,
|
||
) async {
|
||
final Map? from = capture['from'] is Map ? capture['from'] as Map : null;
|
||
final Map? to = capture['to'] is Map ? capture['to'] as Map : null;
|
||
if (from == null && to == null) {
|
||
return ApiConfig.okEnvelope('no addresses captured');
|
||
}
|
||
|
||
String str(Map? m, String k) => (m?[k] ?? '').toString().trim();
|
||
double? coord(Map? m, String k) => double.tryParse('${m?[k] ?? ''}');
|
||
|
||
final res = await MilerApi.updateBookingAddresses(
|
||
bookingId,
|
||
pickupAddress: str(from, 'address'),
|
||
pickupPincode: str(from, 'pincode'),
|
||
pickupLat: coord(from, 'lat'),
|
||
pickupLon: coord(from, 'lon'),
|
||
deliveryAddress: str(to, 'address'),
|
||
deliveryPincode: str(to, 'pincode'),
|
||
deliveryLat: coord(to, 'lat'),
|
||
deliveryLon: coord(to, 'lon'),
|
||
deliveryCity: str(to, 'city'),
|
||
);
|
||
debugPrint(
|
||
'[ADDRESS] booking $bookingId '
|
||
'→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}',
|
||
);
|
||
return res.ok
|
||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||
: {'status': false, 'code': res.status, 'message': res.message};
|
||
}
|
||
|
||
/// **Step 2b** — what this shipment costs, from the pricing table.
|
||
///
|
||
/// Returns null when the call itself failed, and a [ShipmentQuote] with
|
||
/// `found: false` when the table simply has no rule for this combination.
|
||
/// The two are different things to tell a rider — "try again" versus "call
|
||
/// the hub, this cannot be priced here" — so they stay distinguishable, and
|
||
/// neither one produces a number.
|
||
Future<ShipmentQuote?> fetchQuote({
|
||
required double weight,
|
||
required String serviceType,
|
||
String? pickupPincode,
|
||
String? deliveryPincode,
|
||
String? category,
|
||
}) async {
|
||
final res = await MilerApi.checkPrice(
|
||
weight: weight,
|
||
serviceType: serviceType,
|
||
pickupPincode: pickupPincode,
|
||
deliveryPincode: deliveryPincode,
|
||
category: category,
|
||
);
|
||
if (!res.ok) {
|
||
debugPrint('[PRICE] FAILED ${res.status} ${res.message}');
|
||
return null;
|
||
}
|
||
final quote = ShipmentQuote.fromData(
|
||
res.map,
|
||
preferredCategory: category ?? '',
|
||
);
|
||
debugPrint(
|
||
'[PRICE] ${quote.zone}/${quote.serviceType} ${quote.weight}kg → '
|
||
'${quote.found ? '${quote.currency} ${quote.payable}' : quote.message}',
|
||
);
|
||
return quote;
|
||
}
|
||
|
||
/// **Step 3** — the money, from the collect-payment screen's payload.
|
||
///
|
||
/// Returns a success envelope without calling anything when nothing was
|
||
/// collected: a prepaid stop has no payment to record, and posting a zero
|
||
/// would be rejected anyway (`amount` must be > 0).
|
||
Future<Map<String, dynamic>?> submitPayment(
|
||
Object bookingId,
|
||
Map<String, dynamic> payment,
|
||
) async {
|
||
if (payment['paid'] != true) return ApiConfig.okEnvelope('not collected');
|
||
|
||
final double amount =
|
||
double.tryParse('${payment['amountCollected'] ?? 0}') ?? 0;
|
||
if (amount <= 0) return ApiConfig.okEnvelope('nothing collected');
|
||
|
||
// The screen's own wire names are lowercase; the contract's enum is not.
|
||
final String mode = switch ('${payment['method']}'.toLowerCase()) {
|
||
'upi' => 'UPI',
|
||
'card' => 'Card',
|
||
'wallet' => 'Wallet',
|
||
_ => 'Cash',
|
||
};
|
||
|
||
final res = await MilerApi.submitPayment(
|
||
bookingId,
|
||
amount: amount,
|
||
paymentMode: mode,
|
||
transactionRef: (payment['reference'] ?? '').toString(),
|
||
);
|
||
debugPrint(
|
||
'[PAYMENT] booking $bookingId: $mode ₹$amount '
|
||
'→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}',
|
||
);
|
||
return res.ok
|
||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||
: {'status': false, 'code': res.status, 'message': res.message};
|
||
}
|
||
|
||
/// The rider cannot carry this one — it needs a van.
|
||
///
|
||
/// Wired but unreachable from the UI: there is no control for it anywhere in
|
||
/// the app. Left here because the endpoint exists and the flow it belongs to
|
||
/// is this one; adding the control is a design question about where a rider
|
||
/// says "this doesn't fit on a bike".
|
||
Future<Map<String, dynamic>?> requireVehicle(
|
||
Object bookingId, {
|
||
required String type,
|
||
required String reason,
|
||
}) async {
|
||
final res = await MilerApi.vehicleRequired(
|
||
bookingId,
|
||
type: type,
|
||
reason: reason,
|
||
);
|
||
return res.ok
|
||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||
: {'status': false, 'code': res.status, 'message': res.message};
|
||
}
|
||
}
|