import 'package:flutter/foundation.dart'; import 'package:miler/data/next_leg.dart'; import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── /// WHAT **THIS ONE JOB** REQUIRES — asked of the job, never of the rider /// /// There is one kind of Miler. The same rider can be carrying ten meal parcels /// out of a kitchen, five customer pickups booked in the Doormile app, and a /// hub handover, all inside one shift. So nothing about a rider can decide how /// a stop behaves: a rider is not "a milk man" or "a logistics rider", he is a /// person with a queue, and every item in that queue brings its own workflow. /// /// This is the replacement for reading [ServiceProfile.active]. That static /// answers "who is this rider", which is the wrong question and gives the same /// answer for every row on the screen — so a customer pickup sitting next to a /// kitchen collection got the kitchen's rules, or vanished. /// /// ── Read, never inferred ── /// /// Every field below is derived from what the **server said about this row**: /// `pickup_source_type` and `next_action`, both already on `GET /miler/bookings` /// and both already parsed ([PickupSource], [NextLeg]). Nothing here looks at a /// tenant, a business name, an address, a pincode or a missing id. Where the /// server has not said, the answer is [WorkKind.unknown] and the screens draw /// the plain, safe shape — the same doctrine [PickupSource] already states: /// the server's word, or an honest "not known", never a guess. /// ───────────────────────────────────────────────────────────────────────── enum WorkKind { /// Collect in bulk from a place of business — a kitchen, a base, a shop. /// The meal round's morning load and an ordinary hub collection are the same /// shape of work: one address, a manifest, many bags. sourcePickup, /// Collect from a person's front door, against a booking that customer made /// in the Doormile app. Needs the verify-at-the-door flow: count what is /// actually there, weigh it, capture the destination(s), take payment if the /// booking asks for it. customerPickup, /// Already in the rider's hands, going to a receiver. customerDelivery, /// Already in the rider's hands, going to a base. The network carries it from /// there — this rider does not ride it to another city. hubHandover, /// Nothing left for this rider on this item. done, /// In custody, and the server has not said where it goes. Deliberately not /// folded into [customerDelivery] or [done]: see [NextLeg.unknown], which /// makes the same call for the same reason. unknown, } /// The capabilities one work item needs, resolved from that item alone. @immutable class TaskProfile { const TaskProfile._({ required this.kind, required this.groupsBySource, required this.leavesHomeOnAccept, required this.deliversToCustomer, required this.endsAtHub, required this.capturesShipmentDetails, required this.mayCollectCash, }); /// What kind of work this row is. final WorkKind kind; /// This stop is collected in bulk from a named place of business, so Home can /// head it with that place — a kitchen load is one visit, not fourteen rows. /// /// ── NOT a drop-in for `ServiceProfile.active.sourceIsKitchen` ── /// /// Read this before migrating a grouping call site. `sourceIsKitchen` looks /// like "does this group?" and is not: it **selects which key to group by**. /// /// • On the milk round, stops group under the **source they were collected /// from** — the kitchen. /// • On logistics, stops group under the **base that assigned the work**, /// drops and collections alike, *including a collection made at a /// customer's own door*. `hub_pickup_group_test.dart` states the rule: /// "one building assigned them, so one node holds them". /// /// Substituting this flag for that one splits a single hub run into two /// groups — a customer collection stops keying to the assigning base and /// wanders off under its own name. That was tried, and those tests caught it. /// /// ── And DO NOT migrate a grouping site to this flag ── /// /// This was attempted twice and reverted twice. The second attempt is the /// instructive one: with the safe default below, `hub_pickup_group_test.dart` /// went green and the **milk round lost every kitchen heading** — /// `service_flow_test.dart` reports `Found 0 widgets with text "Kitchen 2"`. /// /// The reason is the thing to understand before trying again: **the rows do /// not carry the discriminator**. Neither the meal fixtures nor the logistics /// fixtures set `pickup_source_type`, so per-stop classification cannot tell /// a kitchen load from a customer door — both land on [WorkKind.unknown]. /// `ServiceProfile.active.sourceIsKitchen` was not merely *deciding* the /// grouping; it was *supplying information the payload lacks*. Removing it /// removes the fact, and no default can be right for both lines at once: /// grouping by default breaks logistics, not grouping breaks the milk round. /// /// What a grouping site actually needs is the stop's own **source identity**, /// which the rows DO carry — `sourcename`, `pickuplocationid`, `sourceid`. /// `MilkRun.sourceKeyOf` already reads exactly those and returns `id:…` or /// `name:…` for a stop with a real named counter, falling back to `at:…` or /// `none` for one without. That distinction is the grouping key, and it works /// on today's payloads. Build on it, not on this flag. /// /// This flag stays honest about its own narrow claim: "the server said this /// is collected from a business". It is correct for rows that carry /// `pickup_source_type`, and it is silent — not wrong — for rows that do not. final bool groupsBySource; /// Whether accepting moves it off Home immediately. /// /// A customer pickup does: it becomes an appointment the rider owes someone, /// and it belongs on Bookings until he has collected it. A source pickup does /// not: accepting a kitchen load does not put anything in his hands, so it /// stays on Home until he is actually carrying it. /// /// Replaces `ServiceProfile.active.handoffAt`. final bool leavesHomeOnAccept; /// Finishes at a receiver's door. final bool deliversToCustomer; /// Finishes by handing over at a base. final bool endsAtHub; /// The door flow has to capture what is being shipped and where it is going — /// package count, weights, destinations, recipients. Only a customer pickup /// does this; a kitchen manifest is already known before the rider arrives. final bool capturesShipmentDetails; /// Money may change hands on this item. Whether it actually does is the /// row's own COD figure — this only says the flow must be able to. final bool mayCollectCash; /// The safe shape: a plain stop, no grouping, no door capture, no promises /// about where it ends. static const TaskProfile unknownTask = TaskProfile._( kind: WorkKind.unknown, groupsBySource: false, leavesHomeOnAccept: false, deliversToCustomer: false, endsAtHub: false, capturesShipmentDetails: false, mayCollectCash: false, ); static const TaskProfile sourcePickup = TaskProfile._( kind: WorkKind.sourcePickup, groupsBySource: true, leavesHomeOnAccept: false, deliversToCustomer: false, endsAtHub: false, capturesShipmentDetails: false, mayCollectCash: false, ); static const TaskProfile customerPickup = TaskProfile._( kind: WorkKind.customerPickup, groupsBySource: false, leavesHomeOnAccept: true, deliversToCustomer: false, endsAtHub: false, capturesShipmentDetails: true, mayCollectCash: true, ); static const TaskProfile customerDelivery = TaskProfile._( kind: WorkKind.customerDelivery, groupsBySource: false, leavesHomeOnAccept: true, deliversToCustomer: true, endsAtHub: false, capturesShipmentDetails: false, mayCollectCash: true, ); static const TaskProfile hubHandover = TaskProfile._( kind: WorkKind.hubHandover, groupsBySource: false, leavesHomeOnAccept: true, deliversToCustomer: false, endsAtHub: true, capturesShipmentDetails: false, mayCollectCash: false, ); static const TaskProfile done = TaskProfile._( kind: WorkKind.done, groupsBySource: false, leavesHomeOnAccept: true, deliversToCustomer: false, endsAtHub: false, capturesShipmentDetails: false, mayCollectCash: false, ); /// Which surface this item belongs on. The whole point of the type. /// /// • **Home** — assigned, not yet in the rider's hands, and not yet an /// appointment he has taken on. /// • **Bookings** — a customer visit he has accepted and still owes. /// • **Deliveries** — anything already in his custody. /// • **Activity** — finished. WorkSurface surfaceWhen({required bool accepted}) => switch (kind) { WorkKind.done => WorkSurface.activity, WorkKind.customerPickup => accepted ? WorkSurface.bookings : WorkSurface.home, WorkKind.sourcePickup => WorkSurface.home, WorkKind.customerDelivery || WorkKind.hubHandover || WorkKind.unknown => WorkSurface.deliveries, }; /// Resolve a queue row. /// /// Order matters: `next_action` is asked first because it is the server's /// statement about **where this parcel is in its life**, and that outranks /// where it was collected from. A parcel picked up at a customer's door is a /// delivery once it is in the bag; it stops being a pickup the moment it is /// collected, and only the leg knows that. static TaskProfile of(Map row) { final leg = NextLegResolver.rowNextAction(row); switch (leg) { case NextActionWord.deliver: case NextActionWord.startDelivery: return customerDelivery; case NextActionWord.inwardAtHub: return hubHandover; case NextActionWord.handedToHub: case NextActionWord.none: return done; } // Still to be collected — `next_action: pickup`, or a row too old to carry // one. `stoptype` is the second witness: the server derives it from the // booking status, so it answers even when next_action is absent. final stopType = (row['stoptype'] ?? '').toString().trim().toLowerCase(); if (leg.isEmpty && stopType == 'delivery') { // Collected, and nothing said where it goes. Custody without a // destination — NextLeg.unknown's case exactly. return unknownTask; } return switch (PickupSource.of(row)) { PickupSource.customer => customerPickup, PickupSource.hub || PickupSource.merchant || PickupSource.store => sourcePickup, // ── No word from the server: take the safe shape, not a kind ── // // This said [sourcePickup] once, and that was the bug. `sourcePickup` // carries `groupsBySource: true`, so every row too old to carry a // `pickup_source_type` — which is every fixture in the existing suite, // and every booking written before these fields shipped — started // grouping under whatever name it could scrape together. A logistics day // that is meant to bucket under one assigning base split into a group per // customer. `hub_pickup_group_test.dart` caught it. // // An absent field is not evidence of a kitchen. It is evidence of // nothing, and [unknownTask] is what nothing looks like: no grouping, no // door capture, no promise about where it ends. Same doctrine // [PickupSource] already states — the server's word, or an honest "not // known", never a guess. PickupSource.unknown => unknownTask, }; } } /// The four places work can live in the rider app. enum WorkSurface { home, bookings, deliveries, activity } /// What a whole trip looks like, derived from the stops actually in it. /// /// ── Why a trip needs its own answer ── /// /// A few things on Home describe the **day**, not one stop: whether it starts /// at a kitchen, whether it ends by returning to a base. Those used to read the /// rider's line, which made them constant for the whole app — true enough when /// a rider only ever did one kind of work. /// /// Under mixed work they are no longer constant, and they are no longer even /// exclusive: one trip can hold a kitchen load *and* five customer pickups, and /// can finish by handing some parcels to a base while the last meal goes to /// somebody's door. So the question changes from "which line is this rider on" /// to "what is actually in this trip", and the honest answer is a fold over the /// stops. /// /// [endsAtHub] is `any`, not `every`, deliberately: if even one parcel has to /// be handed in at a base, the rider's day ends at that base. Telling him /// "END · HOME" while he is still carrying something the network needs is the /// failure worth avoiding. abstract final class TripShape { /// The trip collects in bulk somewhere — so Home can head that group with the /// place it came from. static bool groupsBySource(Iterable> stops) => stops.any((s) => TaskProfile.of(s).groupsBySource); /// Something in the trip has to be handed over at a base before the rider is /// finished. static bool endsAtHub(Iterable> stops) => stops.any((s) => TaskProfile.of(s).endsAtHub); /// The trip contains at least one customer visit the rider owes — the work /// that belongs on Bookings rather than Home once accepted. static bool hasCustomerPickup(Iterable> stops) => stops.any((s) => TaskProfile.of(s).kind == WorkKind.customerPickup); } /// ───────────────────────────────────────────────────────────────────────── /// THE BRIDGE — per item where the row knows, per line where it does not /// /// [TaskProfile] is the right answer and it could not simply replace /// [ServiceProfile.active] in one pass, for a reason worth stating plainly /// rather than discovering twice: **most rows do not carry the discriminator /// yet.** `pickup_source_type` and `next_action` shipped recently; a booking /// written before them, and every fixture in the existing suite, carries /// neither. [TaskProfile.of] correctly answers [WorkKind.unknown] for those, /// and a screen that took `unknown` at face value would lose the milk round its /// kitchen headings and the logistics day its door capture — which is what /// happened both times a grouping site was migrated wholesale. /// /// So this is the migration path, not a second source of truth: /// /// the row said something → the row decides, per item /// the row said nothing → the rider's line decides, exactly as before /// /// Every method below is that shape. The consequence is the one that matters /// for mixed work: a CX customer pickup sitting next to a kitchen collection /// now gets **its own** rules on the strength of its own `pickup_source_type`, /// instead of inheriting whatever the rider's tenant happens to say. The /// fallback keeps every existing row behaving precisely as it does today, which /// is what makes this safe to ship in the same release as the fence. /// /// As the backend fills these fields in on every row (see handoff BE-6), the /// fallbacks stop being reachable and can be deleted a call site at a time. /// ───────────────────────────────────────────────────────────────────────── abstract final class WorkPolicy { /// Does the rider have to establish what is being shipped at this stop — /// count the parcels, weigh them, capture destination and recipient, take /// payment? /// /// This is the question that most needed taking off the rider. It had exactly /// one call site, `ServiceProfile.active.needsVerification`, and that static /// gives the *same answer for every row on the screen*: on a meal tenant a CX /// customer pickup was walked straight past the door flow — no parcel count, /// no destination, no weight — and the booking pivoted on whatever the /// customer had typed into the app days earlier. On a parcel tenant the /// reverse: a kitchen collection of fourteen known bags asked the rider to /// itemise a manifest that was already on his screen. /// /// A customer pickup needs it. A collection from a counter does not — the /// manifest is known before he arrives. static bool capturesShipmentDetails(Map row) { final profile = TaskProfile.of(row); if (profile.kind == WorkKind.unknown) { return ServiceProfile.active.needsVerification; } return profile.capturesShipmentDetails; } /// Does this stop establish the shipment's addresses and price — the /// logistics desk, as against a proof-of-collection photo? /// /// Same shape and the same reason. Falls back to /// `ServiceProfile.active.capturesShipmentAddresses`. static bool capturesShipmentAddresses(Map row) { final profile = TaskProfile.of(row); if (profile.kind == WorkKind.unknown) { return ServiceProfile.active.capturesShipmentAddresses; } return profile.capturesShipmentDetails; } /// Does collecting here bring a shipment into existence? static bool initiatesShipment(Map row) { final profile = TaskProfile.of(row); if (profile.kind == WorkKind.unknown) { return ServiceProfile.active.initiatesShipment; } return profile.kind == WorkKind.customerPickup; } /// Does an accepted stop stay on Home until the rider is actually carrying /// it, rather than moving to Bookings the moment he accepts? /// /// The per-item replacement for `ServiceProfile.active.handsOffAtCollection` /// (`handoffAt == HandoffPoint.collected`). A **source pickup** stays: taking /// on a kitchen load puts nothing in his hands and he still has to go and /// collect it. A **customer pickup** does not: accepting it is an appointment /// he now owes somebody, and it belongs on Bookings until he has been. /// /// Asking the rider's line for this gave one answer for every row on the /// screen — so on a meal tenant an accepted CX pickup sat on Home beside ten /// kitchen bags with nothing to distinguish it, and on a parcel tenant a /// kitchen load vanished off Home the instant it was accepted. static bool staysOnHomeUntilCollected(Map row) { final profile = TaskProfile.of(row); if (profile.kind == WorkKind.unknown) { return ServiceProfile.active.handsOffAtCollection; } return !profile.leavesHomeOnAccept; } /// Which surface this item belongs on. /// /// The whole point of [TaskProfile], and the answer the four-tab shell cannot /// yet act on in full — see the release report. Correct per item today, so /// the queues can be split without re-deriving the rule. static WorkSurface surfaceOf( Map row, { required bool accepted, }) => TaskProfile.of(row).surfaceWhen(accepted: accepted); /// True when this one item ends by handing over at a base. /// /// Read from the row's `next_action`. Unlike the flags above there is no /// fallback and none is wanted: [TaskProfile.hubHandover] is reached only /// when the server has said `inward_at_hub`, and guessing a base leg from the /// rider's line is how a hyperlocal parcel gets routed to a counter. static bool endsAtHub(Map row) => TaskProfile.of(row).kind == WorkKind.hubHandover; /// True when this one item ends at a receiver's door. /// /// Falls back to the line, because a row with no `next_action` genuinely does /// not say — and on a meal run every stop does end at a door. static bool deliversToCustomer(Map row) { final profile = TaskProfile.of(row); if (profile.kind == WorkKind.unknown) { return ServiceProfile.active.deliversToCustomer; } return profile.deliversToCustomer; } }