300 lines
12 KiB
Dart
300 lines
12 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/miler_api.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 `queue.workolik.com` 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.
|
||
Future<Map<String, dynamic>?> updatePickup(Map<String, dynamic> data) async {
|
||
final String status = (data['orderstatus'] ?? '').toString();
|
||
final id = data['pickupid'] ?? data['orderheaderid'];
|
||
final String notes = (data['notes'] ?? '').toString();
|
||
|
||
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};
|
||
|
||
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':
|
||
return envelope(await MilerApi.reached(id, lat: lat, lon: lon));
|
||
|
||
case 'Picked up':
|
||
case 'picked':
|
||
return envelope(await MilerApi.pickupComplete(id, lat: lat, lon: lon));
|
||
|
||
case 'delivered':
|
||
case 'Delivered':
|
||
return envelope(
|
||
await MilerApi.deliver(
|
||
id,
|
||
deliveredToName: (data['pickupcustomer'] ?? '').toString(),
|
||
// Only sent when there is one. The tenant flag decides whether it
|
||
// is required, and an empty string would fail a tenant that has it
|
||
// on while being pointless for one that does not.
|
||
otp: (data['otp'] ?? '').toString(),
|
||
photoUrl: (data['dropimage'] ?? '').toString(),
|
||
receiverSignatureUrl: (data['signature'] ?? '').toString(),
|
||
lat: lat,
|
||
lon: lon,
|
||
),
|
||
);
|
||
|
||
case 'skipped':
|
||
// Was a fabricated success — `okEnvelope('skip not supported by
|
||
// backend')` — so the rider watched a stop move to "skipped" while
|
||
// nothing left the phone and the hub never learned.
|
||
return envelope(
|
||
await MilerApi.skipConsignment(
|
||
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:
|
||
ApiConfig.logGap('update:unknown', 'Unmapped orderstatus="$status".');
|
||
return ApiConfig.okEnvelope();
|
||
}
|
||
}
|
||
|
||
/// **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 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};
|
||
}
|
||
}
|