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?> createPickupLog( Map 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 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?> updatePickup(Map 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 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?> submitParcels( Object bookingId, Map 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?> submitAddresses( Object bookingId, Map 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 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?> submitPayment( Object bookingId, Map 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?> 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}; } }