Architecture walkthrough · doormile_backend
How a parcel actually moves through Doormile — from a sales rep's GPS-stamped doorstep survey to a consignee reading six digits aloud. And where that pipeline, built for on-demand courier, meets a business run as milk rounds.
Seven stages, two route branches, one convergence. Every arrow below is a real state transition in the Go backend — the labels are the endpoints that cause them and the statuses they write.
pickup-complete is the pivot between them, and the only place the OTP is minted. The teal branch never touches a hub.Bookingsource is the only thing that distinguishes them.A milk round has five properties, and all five are anti-on-demand: a fixed beat, a rider who owns it, standing orders that recur by default, a promised slot, and density economics. Stage 03 above is built on the opposite premise.
backend_jupiter has both. Two lineages, one repo.rider_substitutions — absent rider, substitute rider, per tenant, per date, with a scheduled → active → completed lifecycle. On-demand dispatch has no concept of an absent rider; you simply assign someone else. That table can only exist if a specific person owns a specific round.
| Milk-round property | In Doormile today | Where it lives |
|---|---|---|
| Promised time slot | Present | Preferredpickupfrom / Preferredpickupto on PickupBooking |
| Recurring account shape | Present | CRM captures shipping_frequency, parcel_volume, active_contracts |
| Depot / dairy origin | Present | Tenant → TenantLocation, per-site reporting on Tenantlocationid |
| Dense local drop | Present | 3-digit pincode prefix match skips the hub entirely |
| Day's order book in one drop | Present | POST /admin/expressbooking/bulk |
| Round ownership | Absent | only rider_substitutions, and only in backend_jupiter |
| Beat with ordered stops | Absent | no Route / Beat / Round model — Tripsheet is linehaul only |
| Standing orders | Absent | the only "subscription" in the backend is NATS message subscriptions |
| Waves as delivery rounds | Absent | frontend display filter in utils/batchBucket.js — see below |
Morning / Afternoon / Evening look like rounds. They aren't. They bucket on orderdate — when the order was placed — so a wave describes arrival, not departure. The dispatch folder's own CLAUDE.md records that the promised-delivery field was tried here and deliberately rejected.
There is a second constraint recorded in that file worth carrying forward: the filter field and the bucket field must be the same field. An earlier attempt to admit rows on updatedat while bucketing on createdat put six orders in the Evening batch at 10:41 in the morning on a day with no orders at all.
Four additions, roughly in dependency order. None of them fight the existing pipeline — the consignment, OTP and proof-of-delivery machinery downstream of stage 05 stays exactly as it is.
Beat entity — an ordered stop sequence owned by a tenant and a hub, with a slot window. This is the object the whole model is missing.rider_substitutions in jupiter is already the covering mechanism; it just needs a beat to point at.TenantLocation, so the day's round exists before anyone books it.Dispatch stops choosing a rider per parcel and starts doing two different jobs: sequencing a beat's stops before it goes out, and handling exceptions — the overflow parcel, the absent rider, the address that doesn't fit today's round. That is a smaller, sharper problem than scoring every candidate on every booking, and it preserves the density that makes the model pay.