A shipment is not an order. Generic order management software assumes one record moves from created to fulfilled to delivered, and that every quantity on the record stays with the record. Freight does not behave that way. A pallet gets short-shipped at the dock, half of it ships tomorrow, the other half rides a different truck through a different terminal, and the customer still expects one invoice and one tracking link.
That single mismatch is why logistics teams end up with three systems and a spreadsheet. The software holds the order. The carrier portal holds the actual movement. Someone in ops holds the reconciliation in their head, then emails a corrected invoice at the end of the month. We have seen the same shape in retail and construction: the record the system stores stops matching the thing in the world, and humans absorb the difference.
The fix is not more fields on the shipment table. It is modeling the domain the way the domain actually works: quantities that split and merge, custody that transfers between parties, charges that accrue after delivery, and time windows that are constraints rather than timestamps. Here is what each of those does to your schema and your code.
Shipments split and merge, so quantity cannot live on the shipment
The first thing generic software gets wrong is putting quantity and status on the shipment header. Then the dock short-ships, and someone has to choose between editing the original record (destroying history) or creating a second shipment with no link back to the first (destroying the customer's single view).
The model that survives is a tree. An order line commits quantity. A shipment unit is a physical thing that can be split into children or grouped into a parent. Movements attach to units, not to orders.
-- units form a tree: split creates children, consolidation creates a parent
create table shipment_unit (
id uuid primary key,
order_line_id uuid not null references order_line(id),
parent_id uuid references shipment_unit(id),
qty numeric(12,3) not null check (qty > 0),
uom text not null,
status text not null,
created_at timestamptz not null default now()
);
-- invariant enforced in code and re-checked nightly:
-- sum(children.qty) = parent.qty, and
-- sum(leaf units per order_line) <= order_line.qty_committed
Two rules follow. Only leaf units move — a parent is a reporting convenience, never a thing you scan. And the sum invariant gets checked by a scheduled job, not just at write time, because integrations will write through paths your validation does not cover. Every carrier EDI feed we have integrated eventually sent something that should have been impossible.
The tradeoff is real: this is more expensive to query and to render than a flat shipment table. If you move full truckloads of a single commodity between two fixed points and nothing ever splits, do not build this. A flat table with a status column is correct for that business, and you should not pay tree costs for a case that never happens.
Status is a lie; custody events are the truth
Generic products give you a status enum and a updated_at. Logistics needs to answer questions the enum cannot: who had the freight at 4:12pm, what condition was it in, who signed, and which party is liable for the damage found at the consignee. A single mutable status field cannot answer any of those.
Model movement as an append-only event log where each event names the party taking custody, the location, the timestamp, and the source that reported it. Current status becomes a projection over the log.
- Event type — tendered, picked up, arrived terminal, departed, out for delivery, delivered, refused, returned, damaged.
- Actor — the party ID that had custody after this event, not the user who typed it.
- Reported by — driver app, EDI 214, carrier webhook, CSR manual entry. Store this. You will need it when two sources disagree.
- Occurred at and recorded at — two separate columns. Offline scans arrive hours late and must sort by occurrence, not arrival.
- Geo and evidence — coordinates, photo keys, signature key, temperature reading if you carry anything that cares.
The reported-by field is the one everyone skips and everyone regrets. When a carrier webhook says delivered at 3pm and the driver app says refused at 3:05pm, you need a documented precedence rule — ours is usually that first-party evidence with a photo beats third-party status codes — and you cannot write that rule if you did not record the source.
If your system cannot tell you who was holding the freight at a given minute, it cannot settle a damage claim, and settling claims is most of what logistics software is for.
Billing accrues after the freight is gone
Generic order software invoices what you sold. Freight invoices what happened. Detention because the dock kept the driver ninety minutes. Reweigh because the shipper's declared weight was wrong. Liftgate, residential delivery, redelivery attempt, fuel surcharge indexed to a weekly published rate. Most of these arrive days after delivery, and some arrive as a carrier invoice you have to reconcile against your own estimate.
That means the invoice cannot be a snapshot of the order. It has to be a rollup of charge lines that stay open for a defined window, with a rule for what happens when a charge lands after the invoice was sent.
- Quote time: write estimated charge lines with the rate source and the inputs used (weight, class, distance, accessorial flags).
- Movement time: write accrued lines from events — a detention line is derived from arrival and departure events plus the free-time rule for that account.
- Settlement: match the carrier invoice line by line against accrued lines. Store the variance, do not overwrite the estimate.
- Invoice: bill accrued lines only. Estimated-but-never-accrued lines get closed with a reason code.
- Post-invoice: late charges become a supplemental invoice referencing the original. Never mutate a sent invoice.
Step 5 is the one that separates working systems from ones that generate credit memos forever. We built the same discipline into Retailer OS for returns and into AIA progress billing for BuildAEC, where a sent application for payment is immutable and corrections flow through the next period. Immutable documents plus explicit supplements is boring and it holds up under audit.
Time windows are constraints, not timestamps
A delivery appointment is not a datetime. It is a window with a timezone, a set of receiving-hour rules, a dock capacity limit, and an escalation path when the window is missed. Generic scheduling stores a single UTC timestamp and lets the UI format it, which produces two failures on day one.
First, a shipment crossing time zones needs local-time semantics. A receiver open 7am–3pm local means something different in Phoenix in July than in Dallas. Store the window as a start and end instant plus the IANA timezone that generated it, and recompute rather than assuming DST.
Second, capacity is per dock door per interval. If your appointment model has no capacity dimension, ops will double-book, and drivers will wait. Model a resource (door, bay, crew) with slot inventory, and treat appointment booking as a reservation against that inventory with the same rigor you would use for seats.
The rule we apply
If a business promises a time to a third party who is physically present, that time needs a window, a timezone, a capacity check, and a miss policy. If it is an internal target nobody stands around waiting for, a single timestamp is fine. Applying the full model everywhere makes the UI miserable for no benefit.
The scanner is offline more often than the roadmap admits
Docks are steel boxes. Warehouses have dead aisles. Rural delivery routes drop signal for twenty minutes at a time. Any logistics app with a mobile component is an offline-first app whether you planned for it or not, and retrofitting that is far harder than building it in.
The concrete requirements: scans queue locally with a client-generated ID for idempotency, occurred-at is stamped on device, photos and signatures upload separately from the event so a 4MB image does not block a status change, and the server accepts events out of order and re-derives the projection. We covered the mechanics in more depth in our post on field service apps with no signal, and we shipped the same pattern in an offline-capable POS that reconciles sales when connectivity returns.
One logistics-specific wrinkle: device clocks are wrong. Drivers change time zones and phones drift. Send both the device timestamp and a monotonic counter since app start, and have the server compute a clock skew estimate from the upload time. When a detention charge depends on a fifteen-minute difference, you need to know which timestamp you trust.
Hardware is the other reason generic web apps stall out here. Bluetooth label printers, ring scanners, temperature probes and NFC seals are not reachable from a browser tab. We ship those surfaces as native mobile through Capacitor so the same codebase covers the office and the dock.
Where generic tools are the right call
Not every logistics operation needs custom software. If you tender to two carriers, ship under a hundred loads a month, and never handle the freight yourself, a rate-shopping tool plus a spreadsheet is genuinely cheaper and faster than anything we would build for you. The domain rules above cost money to implement, and they only pay back when volume or liability makes the manual reconciliation expensive.
The signal that you have crossed the line is usually the spreadsheet, not the volume. When ops maintains a parallel sheet because the system cannot express splits, or accounting keeps a claims tab the software knows nothing about, the domain has outgrown the tool. We wrote about the same tell in construction — why contractors keep a spreadsheet next to the software — and the diagnosis transfers cleanly.
A short audit you can run this week
- Take a shipment that split last month. Can the system show both halves under one customer-facing reference, with the original commitment intact? If not, quantity is on the wrong table.
- Pick a delivery and ask who had custody at a specific minute. If the answer requires opening a carrier portal, your event log is incomplete.
- Find last month's most-disputed accessorial. Can you show the events that generated it and the rule that priced it? If it was typed in by hand, it will be disputed again.
- Open a delivery appointment record. Does it carry a timezone and a capacity reservation? A bare UTC timestamp means double-bookings are coming.
- Turn airplane mode on and complete a pickup scan with a photo. If anything is lost or duplicated, the offline path is not real.
Every one of those questions maps to a schema decision, not a feature request. Get the model right and the features are ordinary CRUD on top of it. Get it wrong and you spend the next two years writing reconciliation scripts. If you are mid-build and unsure which side you are on, tell us what breaks and we will tell you which of the five it is.
Frequently asked
Why can't we just use a generic order management system for freight?
Order systems assume one record moves in a straight line with fixed quantity. Freight splits, merges, changes custody between parties, and accrues charges days after delivery. Those four behaviors need a unit tree, an append-only custody event log, open charge lines and immutable invoices. Bolting them onto an order table produces constant manual reconciliation, which is why ops teams keep a parallel spreadsheet.
Should shipment status be a column or derived from events?
Derive it. Store an append-only event log with actor, occurred-at, recorded-at and reported-by, then project current status from it. Keep a denormalized status column for query speed if you need it, but treat it as a cache you can rebuild. Without the log you cannot settle damage claims, price detention, or resolve conflicts between a carrier feed and your driver app.
How do you handle accessorial charges that arrive after invoicing?
Never mutate a sent invoice. Charge lines stay open through a defined settlement window, and anything landing later becomes a supplemental invoice that references the original with a reason code. Store the estimated line, the accrued line and the carrier-invoiced line separately so variance is visible rather than overwritten. It is the same discipline as progress billing in construction.
Does a logistics app really need offline support?
If it runs on a phone or a scanner in a warehouse, dock or truck, yes. Signal drops in steel buildings and rural routes. The requirements are modest: client-generated IDs for idempotency, device-stamped occurred-at, out-of-order event acceptance on the server, and photo uploads decoupled from the status change. Retrofitting this after launch is significantly harder than building it in.
When is custom logistics software not worth it?
When you tender to a couple of carriers, ship low volume, and never take physical custody of the freight. A rate-shopping tool and a spreadsheet cost less and move faster. The break point is usually visible in the workarounds: a parallel sheet for splits, or a claims tab the software knows nothing about, means the domain has outgrown the tool.