Files
doormile_milderapp/lib/data/work_domain.dart
2026-09-09 12:55:23 +05:30

283 lines
13 KiB
Dart

import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/next_leg.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHICH SCREEN OWNS AN ORDER
///
/// One rule, in one place, for the question both work screens have to answer
/// about every row of the shared day: **is this Home's or is it Deliveries'?**
///
/// ── Why it is here and not on the screens ──
///
/// It used to be answered twice. Home decided what to draw from
/// [stopStateOf] plus its own local stores; the Deliveries tab decided what to
/// list from a `where` clause inside its fetch, with a second `where` for the
/// statuses that clause was supposed to re-admit. Two expressions of one fact,
/// edited on different days, and they disagreed exactly where it hurts most:
/// **an accepted booking could be claimed by both.** It stayed on Home to be
/// collected, and it appeared on Deliveries as live work with a strip offering
/// to continue it — which opened a map, an I'VE ARRIVED and a confirmation
/// sheet for a collection the rider had not made yet.
///
/// The fix is not a third filter. It is that the classification is one function
/// on the shared data layer, above both screens, so they cannot hold different
/// opinions about where an order belongs.
///
/// ── The boundary ──
///
/// PENDING ─ accept ─▶ ACCEPTED ─ navigate ─▶ ARRIVED ─ pick up ─▶ PICKED
/// └──────────────── HOME owns all of this ──────────────┘ │
/// ▼
/// DELIVERIES owns from here on
///
/// **Picked up is the boundary**, and only the backend can move it. Accepting
/// an assignment is a decision about work still to be done; it is not evidence
/// that anything has been collected. Nothing in this file infers a completed
/// pickup from an acceptance.
///
/// ── The one knob ──
///
/// Where the boundary sits is a property of the rider's line, declared once as
/// [ServiceProfile.handoffAt], and read here rather than re-derived:
///
/// • **A kitchen line** ([HandoffPoint.collected]) hands over at collection.
/// The rider makes one trip to one counter for a stack of bags, so every
/// rung up to picked-up is Home's, in bulk, and Deliveries holds only what
/// is in his hands.
/// • **Logistics** ([HandoffPoint.accepted]) hands over at acceptance. There is
/// no counter and nothing to batch: he drives to one customer, raises the
/// shipment at the door and the collection *is* the job — so an accepted
/// booking is already the work tab's, which is where that flow lives.
///
/// Same rule, one declared knob. Not a line-name check, and not a per-screen
/// conditional. See [ServiceProfile.handoffAt].
/// ─────────────────────────────────────────────────────────────────────────
enum WorkDomain {
/// Home's. Pending, accepted, on the way, arrived — everything before the
/// backend has confirmed the load is aboard.
pickup,
/// The Deliveries tab's. Past the hand-over point for this line.
delivery,
/// Neither work screen's. Finished or withdrawn — Activity's record.
closed,
}
/// The hand-over point between [WorkDomain.pickup] and [WorkDomain.delivery].
abstract final class WorkBoundary {
/// True once the **backend** says the collection is done.
///
/// Two sources, both authoritative, neither of them "the rider accepted it":
///
/// • The status on the row — `Picked_Up` / `Converted_To_Consignment`, or
/// any of the delivery rungs past them.
/// • The collected record, written by Home only after the pickup-complete
/// call has come back successful. It exists because the queue is up to one
/// poll behind: without it a stop the rider has just handed over jumps back
/// to Home for a few seconds, which reads as the confirm having failed.
///
/// It deliberately does **not** consult the accepted store. That set says a
/// decision was made, not that goods changed hands.
static bool pickupComplete(
Map<String, dynamic> stop, {
Set<String> collectedIds = const <String>{},
}) {
final status = stopStatusOf(stop);
if (status.isPicked || status.isDeliveryLeg) return true;
return collectedIds.contains(MilkRun.idOf(stop));
}
/// True when this order is finished or withdrawn, whatever screen it was on.
///
/// [StopStatus.picked] is terminal for some orders and the middle of the
/// morning for others, and **which one is a property of the order, not of the
/// line**. It is asked of [NextLegResolver], which reads the backend's own
/// routing answer. See the note on the picked branch below.
///
/// ── Skipped closes, and why it has to close *here* ──
///
/// A skip used to leave the order open, and the Deliveries tab kept it under
/// its own heading on the argument that the rider looks for it where he left
/// it. The cost of that was **dual ownership**: the same stop sat in the
/// active queue *and* in Activity, so the two screens disagreed about whether
/// the rider still owed it a visit. One order, one owner — a stop he has
/// written off is a record, and records live on Activity.
///
/// ── Why the status alone cannot answer it ──
///
/// On the delivery leg the backend has no failed-attempt outcome. A
/// successful skip leaves the consignment `Out_for_Delivery`, so
/// `/miler/bookings` keeps returning the row as live delivery work for the
/// rest of the day, and [stopStatusOf] reads exactly what the server sent.
/// Asking only the payload therefore re-admits the stop on every poll and on
/// every cold start.
///
/// [closedIds] is the answer to that, and it is **not** a display flag: it is
/// the persisted record of a mutation that already succeeded — day-stamped,
/// scoped to this rider, tenant and line (see [WorkScope]) — written by the
/// completed and skipped stores at the moment the rider's action came back
/// OK. It is the same shape of evidence [pickupComplete] already takes from
/// `collectedIds`, and it survives the process that wrote it, which is the
/// whole point.
///
/// Clearing it is what a resume does: [removeSkippedBookings] drops the id
/// and the stop is live work again on the very next read. There is no second
/// place holding the same opinion.
static bool isClosed(
Map<String, dynamic> stop, {
Set<String> closedIds = const <String>{},
/// `next_action` recorded at each order's pivot — see [NextLegResolver].
Map<String, String> pivotActions = const <String, String>{},
}) {
if (closedIds.isNotEmpty && closedIds.contains(MilkRun.idOf(stop))) {
return true;
}
final status = stopStatusOf(stop);
if (status == StopStatus.delivered || status.isCancelled) return true;
// Filed under its own word, never flattened into cancelled: a stop the
// rider walked away from and one the office called off are different
// records, and Activity slices them apart.
if (status.isSkipped) return true;
// ── After the pickup, ownership is a question about the ORDER ──
//
// This read `status.isPicked && ServiceProfile.active.endsAtHub`, and that
// is a line-level constant deciding a per-order fact. On logistics
// `endsAtHub` is always true, so **every collected parcel was closed at the
// pickup** — including the hyperlocal ones the pivot had just released
// `Out_for_Delivery` into this rider's own hands. He walked away from the
// sender's door carrying a parcel his app had already filed as finished.
//
// The backend decides where a parcel goes next and always has; the app now
// asks. [NextLeg.closed] is the only answer that retires the stop —
// `customer` and `hub` are both journeys the rider still owes, and
// `unknown` is a parcel in his bag whose destination has not been named,
// which must never be silently written off. See [NextLegResolver] for the
// precedence between the pivot, the row and the local record.
//
// [ServiceProfile.endsAtHub] survives as a line-level capability hint — it
// still tells the route card whether the day ends at a depot — but it no
// longer decides whether one order is finished.
if (!status.isPicked && !status.isDeliveryLeg) return false;
return NextLegResolver.resolve(
stop,
pivotAction: pivotActions[MilkRun.idOf(stop)] ?? '',
).isClosed;
}
/// Which screen owns this order.
///
/// [rejectedIds] and a server-side rejection do **not** close an order here:
/// a declined stop stays in the pickup domain because Home is where the rider
/// can change his mind about it. What it must never be is delivery work.
static WorkDomain domainOf(
Map<String, dynamic> stop, {
Set<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
/// Orders written off today — delivered, cancelled or skipped — read back
/// from the completed and skipped stores. See [isClosed].
Set<String> closedIds = const <String>{},
/// `next_action` recorded at each order's pivot — see [NextLegResolver].
Map<String, String> pivotActions = const <String, String>{},
}) {
if (isClosed(stop, closedIds: closedIds, pivotActions: pivotActions)) {
return WorkDomain.closed;
}
// ── A collected parcel is the work tab's, whatever the line says ──
//
// Below [isClosed] and above the handoff knob, deliberately. Once the
// backend confirms the collection the parcel is physically the rider's, and
// "in my hands, still owed a journey" is the definition of the delivery
// domain on every line. Reading it here rather than from the per-line
// handoff point is what lets a hyperlocal logistics parcel reach Deliveries
// at all — its own line hands off at *acceptance*, which says nothing about
// custody.
//
// ── Gated on the collection having happened ──
//
// [NextLegResolver] answers [NextLeg.unknown] for any row it cannot place,
// and an uncollected booking is one of those: a pending assignment carries
// no consignment state and no pivot record, exactly like a collected parcel
// the server has not routed yet. Asking without this gate handed every
// pending stop to Deliveries — the dual-ownership failure in reverse.
//
// "Is it in his hands?" is [pickupComplete]'s question and it has a real
// answer: the backend's own status, or the collected record written after
// the pivot came back OK. Only then is there a leg to resolve.
if (pickupComplete(stop, collectedIds: collectedIds) &&
NextLegResolver.resolve(
stop,
pivotAction: pivotActions[MilkRun.idOf(stop)] ?? '',
).isInCustody) {
return WorkDomain.delivery;
}
final handedOver = switch (ServiceProfile.active.handoffAt) {
HandoffPoint.collected => pickupComplete(
stop,
collectedIds: collectedIds,
),
// On logistics the work tab takes it at acceptance — from either source,
// because the local store leads the queue by a poll.
HandoffPoint.accepted =>
acceptedIds.contains(MilkRun.idOf(stop)) ||
stopStatusOf(stop) == StopStatus.accepted ||
stopStatusOf(stop).isActive ||
stopStatusOf(stop) == StopStatus.arrived,
};
return handedOver ? WorkDomain.delivery : WorkDomain.pickup;
}
/// Everything the Deliveries tab is allowed to hold, out of the shared day.
///
/// The tab's list, the count on Home's pill and anything else that asks "how
/// much is waiting on the other tab?" all read this, so the two screens
/// cannot report different numbers for the same day.
static List<Map<String, dynamic>> deliveryQueue(
Iterable<Map<String, dynamic>> day, {
Set<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
Set<String> closedIds = const <String>{},
Map<String, String> pivotActions = const <String, String>{},
}) => [
for (final stop in day)
if (domainOf(
stop,
collectedIds: collectedIds,
acceptedIds: acceptedIds,
closedIds: closedIds,
pivotActions: pivotActions,
) ==
WorkDomain.delivery)
stop,
];
/// Everything Home is still responsible for, out of the shared day.
static List<Map<String, dynamic>> pickupQueue(
Iterable<Map<String, dynamic>> day, {
Set<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
Set<String> closedIds = const <String>{},
Map<String, String> pivotActions = const <String, String>{},
}) => [
for (final stop in day)
if (domainOf(
stop,
collectedIds: collectedIds,
acceptedIds: acceptedIds,
closedIds: closedIds,
pivotActions: pivotActions,
) ==
WorkDomain.pickup)
stop,
];
}