Delivery & Shipment Logistics
A design reference for outbound goods movement, cross-border parcel data, and route scheduling in a single-codebase ERP system
1. Requirements
1.1 Functional requirements
- Record the physical movement of goods out of a warehouse against a sales order, a stock-moving sales invoice, or a hand-picked fulfillment list, producing a Dispatch Record that is the system of record for “what left the building.”
- Support returns: a Dispatch Record can be flagged as a return, carrying negative quantities, rolling back delivered/returned quantities on whatever it was originally issued against.
- Track billing progress against a Dispatch Record independently of its stock effect, always knowing what fraction of its value has been billed and what fraction has come back as a return.
- Optionally capture cross-border / parcel-courier data — parcel dimensions and weight, customs description, an Incoterm, a named carrier and service level, an airway-bill/tracking number, and a pickup/delivery window — as a separate Cross-Border Consignment record.
- Let a warehouse pre-define reusable Parcel Templates (fixed length/width/height/weight) so staff don’t re-enter box dimensions each time.
- Let a Cross-Border Consignment reference the Dispatch Record(s) whose value it carries, as an informational link.
- Group multiple Dispatch Records into a Delivery Run: one driver, one vehicle, one departure time, an ordered list of Route Stops, each pointing at a customer address and optionally a Dispatch Record.
- Estimate per-stop arrival times and total distance for a Delivery Run via an external route-calculation service, optionally re-ordering stops while honoring stops the dispatcher has manually locked.
- Notify each stop’s contact by email once a run is scheduled, optionally attaching a printable copy of the Dispatch Record.
- Allow closing a Dispatch Record early and reopening it later.
1.2 Non-functional requirements
- Consistency of derived percentages: percent-billed and percent-returned must always reflect the current state of every submitted referencing document — recomputed, not incrementally patched.
- Idempotent status derivation: status is a pure function of percentages, cancel/submit state, and a manual close flag, never a value that can drift out of sync.
- Graceful degradation of route estimation: if the external route service is unreachable or its credential is missing, the estimate must fail loudly rather than leave stale or wrong distances.
- Low coupling between subsystems: creating a Consignment or a Run from a Dispatch Record must not block, delay, or alter the Dispatch Record’s own stock and billing lifecycle.
- Auditable manual overrides: closing/reopening a record and marking a stop “visited” must be visible operations that don’t fight the automatic status recomputation.
1.3 Constraints
- A Dispatch Record is a submittable, stock-moving transaction — its quantity and value effects flow through the same posting funnel used by every other stock movement, and it participates in the same Cost Layer Queue / Moving Average valuation branching as any other outbound movement. This document does not re-derive that mechanism, only what the dispatch lifecycle feeds into it.
- The three subsystems here — Dispatch Record, Cross-Border Consignment, Delivery Run — live in three separate, only loosely-connected areas of the codebase. A warehouse can use any one, two, or all three independently.
- Route calculation depends on a third-party mapping/directions service reachable only with a per-instance API credential; there is no offline or self-hosted fallback in this codebase.
2. High-Level Design
2.1 Component diagram
2.2 How the three subsystems actually relate
This is the load-bearing finding of this document: the Dispatch Record, the Cross-Border Consignment, and the Delivery Run are not one integrated pipeline. Each is a separate document type with its own lifecycle, connected only by thin, mostly one-directional convenience links, not a shared state machine.
- Dispatch Record to Consignment: a one-shot button copies the grand total, pickup company/address, and customer/contact into a brand-new Consignment, plus one link row (name + value) on its line table. After that the two are independent — changing the Consignment’s tracking status, carrier, or AWB has no effect back on the Dispatch Record, which has no field that even names its Consignment.
- Dispatch Record to Delivery Run: also a one-shot document copy (“Create > Delivery Trip”), creating one Route Stop per Dispatch Record. Unlike the Consignment case, this is two-way at the field level: the Dispatch Record carries a direct reference to its Delivery Run, and the Run’s save/submit/cancel logic writes the driver, vehicle registration, a received-by-reference number (the Run’s own name), and a reference date back onto every referenced Dispatch Record — cleared again on cancel/delete. Real, live, but narrowly scoped: it never touches quantities, valuation, billing percentages, or the dispatch’s own status.
- Consignment to Delivery Run: no code path connects these two at all.
In short: two pairings are single-shot field copies; the third adds a narrow, reversible field back-write. None shares a status enum, a percentage, or a stock/value effect.
3. Deep Dive
3.1 Data model
Dispatch Record (the delivery-note transaction)
Header: customer, company, shared selling-document currency fields, an optional Delivery Run link, transporter/driver/vehicle/received-by-reference/reference-date fields (manual or Run write-back), is_return, and return_against on return records. Lines: item, quantity, stock quantity, warehouse, rate/amount, links back to the originating sales-order/sales-invoice line and to a pick-list line if fulfillment was planned first, an installed-quantity counter, a billed-amount counter, and — on return lines only — a link to the original line being reversed.
Cross-Border Consignment
Unrelated in schema to the Dispatch Record beyond the link rows above. Header: pickup/delivery party (company, customer, or supplier) with address/contact for each, a shipment type (Goods or Documents), a pallet flag, an Incoterm, a free-text customs description, a value-of-goods figure, a pickup date/time window, and a “shipment information” group — service-provider name, external shipment ID, shipment amount, tracking URL, carrier name, carrier service level, airway-bill number, and tracking status (blank / In Progress / Delivered / Returned / Lost). All carrier/tracking fields are plain free-text or select fields with no call-out code here populating or validating them against a real carrier — shaped for an external courier integration that does not exist in the read scope. It computes only two things itself: total weight (each parcel’s weight times count, summed) and value of goods (sum of every linked Dispatch Record’s grand_total, or a manual figure if none are linked).
Parcel Template / Parcel line A Parcel Template is a tiny reusable named record: length, width, height (cm), weight (kg). A Consignment’s parcel line holds the same four measurements plus a count; a template pre-fills a new line but is not a live link.
Delivery Run
Header: driver, vehicle, departure time, an ordering employee, a total-estimated-distance figure with its unit, and a table of Route Stops. Status is a small closed enum (Draft / Scheduled / In Transit / Completed / Cancelled) computed from docstatus plus whether all, some, or none of its stops are visited — a hand-written equivalent of the status-map pattern used elsewhere.
Route Stop A child row: customer, address and a resolved display copy of it, a lock flag (the optimizer may not reorder past it), a visited flag (settable post-submission, driving the parent’s status), an optional Dispatch Record reference and its grand total, estimated latitude/longitude, distance from the previous stop, an estimated arrival timestamp, and an email-sent-to marker.
3.2 Status lifecycle — Dispatch Record
Status is never stored as an independent decision; it is recomputed on every save from percent-billed, percent-returned, is_return, docstatus, and a manual Closed flag, using a rule list where later-listed rules outrank earlier ones when more than one matches — Cancelled and Closed both outrank any percentage-driven state:
Two percentages drive this. Percent billed is recomputed across every line whenever a referencing invoice line (or the dispatch’s own billed-amount column) is submitted or cancelled — billed amount over line amount, clamped and summed. A dispatch billed directly against its sales order (rather than against itself) has that amount distributed across dispatches first-submitted-first-billed. Percent returned only exists on a non-return record, populated once a return record referencing it is submitted: returned quantity per line sums the absolute value of every submitted return line pointing at it, and the header percentage is that sum over the original total stock quantity.
A Closed dispatch is a manual override, not a percentage threshold, reversible via Reopen, which recomputes status fresh from current percentages.
3.3 Delivery Run: routing and estimated arrival
- A dispatcher creates a Delivery Run — driver, vehicle, departure time — and adds one Route Stop per delivery, by hand or via the one-shot “Create > Delivery Trip” copy from a submitted Dispatch Record. A stop missing a resolved address gets one filled in from the linked address record.
- Submitting the Run requires a driver, and requires none of its referenced Dispatch Records still be in Draft.
- On submit/save/cancel, the Run’s status is recomputed from
docstatusand stop visited-flags, and every referenced Dispatch Record has its driver, vehicle, run reference, and reference date pushed onto it (or cleared, on cancel/delete). - Optionally, a dispatcher triggers arrival-time estimation: stops are split into address sequences at each locked stop (the optimizer cannot reorder across a lock), each sequence is sent to an external route-calculation service from the driver’s home address, and the response’s leg distances/durations are written back per stop — distance in the configured unit, latitude/longitude, and an estimated arrival by walking the departure time forward through each leg plus a configurable inter-stop delay. An optimize request also returns a reordered sequence, applied before arrival times are computed.
- Once a stop’s contact has an email address, a dispatch-notification email can be sent per stop, optionally attaching a printed Dispatch Record; the run and each stop record that it went out.
External route-calculation service. The call above goes through a general-purpose mapping/directions client library, authenticated with an API-key credential read from a global settings singleton (systemwide, not scoped per company or run). If the credential is absent, the call is refused up front; its value is never logged or displayed. No offline, cached, or self-hosted routing fallback exists — an unreachable service simply fails the estimate.
3.4 Error handling
- Warehouse validation: a Dispatch Record line for a stock-tracked item without a resolved warehouse is rejected before submission; if the source sales-order line has a stock reservation, the warehouse must match one of the reserved warehouses, or submission is rejected naming the mismatch.
- Submitting a Run ahead of its deliveries: rejected, listing which referenced Dispatch Records are still Draft.
- Reordering past a lock, or no driver-home address: each locked Route Stop is a hard boundary the optimizer never reorders across; without a driver home address, arrival-time estimation is rejected explicitly rather than guessing an origin.
- Consignment submission guards: cannot submit with no parcel lines, a zero/negative parcel weight, a pickup-to time earlier than pickup-from, or zero value-of-goods.
- Route-service failures: any exception from the directions call aborts the whole save/estimate action; stops already updated by an earlier successful leg are not rolled back, so a retry after a mid-route failure should treat updated stops as provisional.
4. Scale and Reliability
- Load pattern: Dispatch Record volume tracks order volume and is the busy path — one record, one posting-funnel call, per outbound shipment. Consignments and Runs are comparatively rare, dispatcher-driven documents.
- Percentage recomputation cost: percent-billed/returned are recomputed by re-scanning every child row of every affected parent on each referencing submit/cancel, not maintained incrementally — cheap per dispatch, but a sales order billed across many small dispatches triggers a full rescan of each one per invoice submission.
- Route-service calls are synchronous and blocking: one external HTTP call per address sequence in-line with the dispatcher’s action, no retry/backoff, no background queue.
- Loose coupling avoids cross-document coordination: the Consignment and Run are only thinly linked to the Dispatch Record, so none needs distributed locking; but the Run’s write-back is per-dispatch, not batched — scheduling a run with many stops issues one separate load-and-save per referenced Dispatch Record rather than one bulk update.
5. Trade-off Analysis
| Decision | Trade-off |
|---|---|
| Three separate document types instead of one integrated logistics object | Each subsystem is adopted independently and evolves on its own schedule, at the cost of no single place to see “where is this shipment, physically, right now” — assembled by hand across three documents. |
| Consignment links to the Dispatch Record only via a one-shot copy + a value-only reference row | Simple, no ongoing sync burden, survives a later dispatch close/reopen — but a value change on the dispatch after the copy (discount, return) never reflects back, so the two can silently drift apart. |
| Delivery Run write-back limited to a handful of dispatch fields | Narrow and fully reversible on cancel/delete, never risks corrupting the dispatch’s own status or valuation — but billing/return state still lives only on the dispatch, not the run. |
| Percent-billed/returned recomputed by full rescan, not incremental counters | Immune to drift from a missed incremental update, at the cost of rescanning every child row on every referencing submit/cancel rather than maintaining a running total. |
| Status derived from a fixed, reverse-priority rule list rather than an explicit state machine | Compact and reused across many document types — but “last matching rule wins” is easy to get wrong when adding a new status, since ordering rather than explicit precedence decides ties. |
| Route calculation delegated to one external mapping service with no fallback, and Consignment carrier/tracking fields left as free text with no in-tree integration code | Both avoid building or maintaining bespoke logistics infrastructure in-house — but a missing route-service credential means no arrival estimates at all, and free-text tracking fields are only ever as current as whoever last updated them by hand, with no live carrier feed behind either one in this codebase. |
6. What to Revisit as the System Grows
- A real cross-subsystem view: answering “what is the physical status of shipment X” as a single query needs either a genuine Dispatch-to-Consignment link (today only discoverable from the Consignment’s side) or a reporting layer joining all three documents — neither exists today.
- Batching the Delivery Run write-back and making percentages incremental: replace the per-dispatch load-and-save loop with one bulk update once run sizes grow, and revisit full-rescan percent-billed/returned recompute once a sales order routinely spans enough dispatches/invoices for it to be measurable.
- Route-service resilience: add retry/backoff and mark a mid-route failure’s already-updated stops as explicitly provisional, since today some stops end up updated and others not, with no marker distinguishing the two.
- Actual courier integration: the Consignment’s carrier/tracking fields are shaped for one but have no code behind them here; if added elsewhere, this document’s account of “free text, no live call-out” will need updating.
- This document sits toward the lower end of its calibration band by design: the honest finding is three thin, independently-evolving document types rather than one deep pipeline, and padding the write-up to look more integrated than the code is would misrepresent it.