Multi-Shipment and Fulfillment
Before EverShop 2.2.1, an order had exactly one shipment. createShipment threw if you called it twice, the shipment status lived on the order row, and there was no item-level association between an order line and the parcel that carried it.
In 2.2.1 the relationship became one-to-many. An order can now have any number of shipments, each with its own status, carrier, tracking number, and item set. The order's shipment_status column survives, but it is no longer something you set — it is a derived rollup recomputed from item quantities after every shipment write.
This page is the contract for extension authors: the data model, the status vocabulary, the rollup math, and the three breaking changes you will hit if you carry 2.1.x code forward.
Breaking changes at a glance
| What changed | 2.1.x | 2.2.1 |
|---|---|---|
order ↔ shipment | 1:1 — second createShipment threw | 1:N — unlimited shipments per order |
order.shipment_status | Writable status code | Derived rollup — never write it directly |
updateShipmentStatus | (orderId, status, connection?) | (shipmentUuid, status, connection?) |
createShipment | (orderUuid, carrier, trackingNumber, connection?) | (orderUuid, payload, connection?) — legacy form throws |
registerShipmentStatus | { name, badge, isDefault?, isCancelable? } | { name, badge, phase } — phase required, the two flags removed |
| Pre-shipped shipment state | pending / processing shipment statuses | Removed — a shipment row exists only because something shipped |
markDelivered | (orderId, connection?) | (shipmentUuid, connection?) |
The data model
shipment
One row per physical parcel (or per carrier hand-off). The columns that matter to extension authors:
| Column | Type | Notes |
|---|---|---|
shipment_id | int | Identity primary key |
uuid | uuid | The key every public service and REST route uses |
shipment_order_id | int | FK to order |
status | varchar | A registered shipment status code. Defaults to shipped |
carrier | varchar | Carrier registry code — no DB FK, so uninstalling a carrier extension is safe |
tracking_number | varchar | Nullable — carriers with no tracking capability leave it null |
shipped_at / delivered_at / canceled_at | timestamptz | First-occurrence timestamps, never cleared once set |
label_url / label_format | varchar | Carrier-hosted label. EverShop never stores the binary |
carrier_shipment_id / carrier_metadata / tracking_url | varchar / jsonb / varchar | Carrier-extension scratch space, passed back verbatim on later carrier calls |
The phase is not a column. It is derived at read time from the status registration, so the config stays the single source of truth and the DB can never drift from it.
shipment_item
The junction table that makes the rollup possible.
CREATE TABLE "shipment_item" (
"shipment_item_id" INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"uuid" UUID NOT NULL DEFAULT gen_random_uuid(),
"shipment_id" INT NOT NULL REFERENCES "shipment" ("shipment_id") ON DELETE CASCADE,
"order_item_id" INT NOT NULL REFERENCES "order_item" ("order_item_id") ON DELETE CASCADE,
"qty" INT NOT NULL CHECK ("qty" > 0),
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT "SHIPMENT_ITEM_UUID_UNIQUE" UNIQUE ("uuid"),
CONSTRAINT "SHIPMENT_ITEM_UNIQUE" UNIQUE ("shipment_id", "order_item_id")
);
UNIQUE (shipment_id, order_item_id) means one row per (shipment, order line) pair — shipping more of the same line inside the same shipment is an update, not a second row. There is no status column: an item's fulfillment state is its shipment's phase.
Phases and statuses
Two vocabularies, deliberately separate.
Phase is hardcoded and cannot be extended:
// modules/oms/types/shipmentPhase.ts
export type ShipmentPhase = 'shipped' | 'delivered' | 'canceled';
There is no pending phase. Stock is deducted at order placement, so a shipment row exists if and only if something was actually shipped — modelling a pre-shipped reservation would be state without meaning. createShipment hardcodes the new row's status to shipped. "Nothing shipped yet" is expressed at the order level by the rollup value pending, not by a shipment row.
Status is the human-visible label and is extensible. The built-in set is three entries:
{
"oms": {
"order": {
"shipmentStatus": {
"shipped": { "name": "Shipped", "badge": "warning", "phase": "shipped" },
"delivered": { "name": "Delivered", "badge": "success", "phase": "delivered" },
"canceled": { "name": "Canceled", "badge": "destructive", "phase": "canceled" }
}
}
}
}
Warning: registerShipmentStatus now requires phase
phase is validated at runtime, before the duplicate-code check. A registration without it throws:
import { registerShipmentStatus } from '@evershop/evershop/oms/services';
export default () => {
// Correct — every status declares the phase the rollup math should bucket it into.
registerShipmentStatus('out_for_delivery', {
name: 'Out for Delivery',
badge: 'warning',
phase: 'shipped'
});
registerShipmentStatus('returned_to_sender', {
name: 'Returned to Sender',
badge: 'destructive',
phase: 'canceled'
});
};
Omitting phase, or passing anything outside shipped | delivered | canceled, throws:
Shipment status "out_for_delivery" must declare phase: 'shipped' | 'delivered' | 'canceled'.
isDefault and isCancelable were removed from the ShipmentStatus type. The initial status is decided by createShipment — hardcoded to shipped — not by a flag; cancelability moved to the order-level shipmentRollupCancelable map described below. Neither flag is read anywhere any more, so leaving them in a hand-written config/default.json block is a silent no-op. What the configuration schema does enforce is that every entry carries name, badge, and phase, so a config-declared status missing phase fails validation at startup.
The configuration JSON schema still lists pending among the accepted phase values for backwards compatibility with old config files, but nothing in the runtime supports it: registerShipmentStatus rejects it, and updateShipmentStatus's phase-transition table has no pending entry. Never declare a pending-phase shipment status.
Registering canonical carrier statuses
Core ships CANONICAL_SHIPMENT_STATUSES — an AfterShip/Shippo/EasyPost-aligned set (in_transit, out_for_delivery, attempt_fail, available_for_pickup, exception, returned, expired) so multiple carrier extensions converge on the same codes instead of inventing their own. Core does not auto-register them; that is the extension's call, and duplicate registration throws:
import {
CANONICAL_SHIPMENT_STATUSES,
getShipmentStatusList,
registerShipmentStatus
} from '@evershop/evershop/oms/services';
export default () => {
for (const [code, detail] of Object.entries(CANONICAL_SHIPMENT_STATUSES)) {
if (!getShipmentStatusList()[code]) {
registerShipmentStatus(code, detail);
}
}
};
The order-level rollup
order.shipment_status holds one of seven derived values:
// modules/oms/types/orderShipmentRollup.ts
export type OrderShipmentRollup =
| 'pending'
| 'partially_shipped'
| 'shipped'
| 'partially_delivered'
| 'delivered'
| 'partially_canceled'
| 'canceled';
The three partially_* values are order-level words only. A single shipment can never be partially_shipped, and none of the three is a registered shipment status — they exist purely as rollup output.
The math is item-based, not shipment-based
The rollup counts quantities, not shipments. For every shippable order line (order_item.no_shipping_required = FALSE), it sums shipment_item.qty grouped by the covering shipment's phase:
SELECT oi.order_item_id,
oi.qty AS qty_ordered,
s.status,
SUM(si.qty) AS qty
FROM order_item oi
LEFT JOIN shipment_item si ON si.order_item_id = oi.order_item_id
LEFT JOIN shipment s ON s.shipment_id = si.shipment_id
WHERE oi.order_item_order_id = $1
AND oi.no_shipping_required = FALSE
GROUP BY oi.order_item_id, oi.qty, s.status;
Application code buckets the statuses into phases (from the registry) and derives the predicates:
| Predicate | Meaning |
|---|---|
allDelivered | Every shippable line has qty_delivered === qty_ordered |
anyDelivered | Some line has qty_delivered > 0 |
allShipped | Every line has qty_shipped + qty_delivered === qty_ordered |
anyShipped | Some line has qty_shipped + qty_delivered > 0 |
allCanceled | Every line has qty_canceled >= qty_ordered |
anyCanceled | Some line has qty_canceled > 0 |
allPending | Every line has qty_shipped === 0 and qty_delivered === 0 |
Canceled shipments contribute zero to qty_shipped and qty_delivered, so canceling a shipment effectively releases its items back into the unshipped pool while still being counted by qty_canceled.
Short-circuits
Two checks run before any item math:
order.status === 'canceled'→ rollup iscanceled. Whole-order cancellation lives onorder.status; the rollup mirrors it.- No shippable items at all (an all-digital order) → rollup is
delivered. "Fully shipped" is vacuously true, which lets the PSO mapping complete digital orders on payment viapaid:delivered → completed.
The rule map and its priority
The predicate-to-output mapping is config, under oms.order.shipmentRollup:
{
"oms": {
"order": {
"shipmentRollup": {
"all:delivered": "delivered",
"any:delivered": "partially_delivered",
"all:shipped": "shipped",
"any:shipped": "partially_shipped",
"all:canceled": "canceled",
"any:canceled": "partially_canceled",
"all:pending": "pending"
}
}
}
}
The evaluation order is fixed in code, not by key order in the object. The resolver walks the predicates in this sequence and returns the first match, falling back to pending:
| # | Predicate key | Why it sits here |
|---|---|---|
| 1 | all:delivered | Most complete state wins |
| 2 | any:delivered | Delivery beats shipping progress |
| 3 | all:shipped | |
| 4 | any:shipped | |
| 5 | all:canceled | After shipping — real shipping progress outranks cancellation |
| 6 | any:canceled | all ⊂ any, so all:canceled must precede it |
| 7 | all:pending | Last — canceled items also satisfy allPending and would mask rules 5 and 6 |
You can rewrite the map from a bootstrap with addProcessor('shipmentRollup', ...), but you cannot reorder the evaluation.
Worked examples, all assuming order.status !== 'canceled':
| Order state (shippable lines only) | Matching rule | Rollup |
|---|---|---|
| 3 lines, all delivered | all:delivered | delivered |
| 3 lines, 2 shipped + 1 delivered | any:delivered | partially_delivered |
| 3 lines, all shipped, none delivered | all:shipped | shipped |
| 3 lines, 1 shipped, 2 untouched | any:shipped | partially_shipped |
| qty 3 on one line, 2 shipped in one parcel | any:shipped | partially_shipped |
| 3 lines, every shipment canceled | all:canceled | canceled |
| 3 lines, one line's shipment canceled, rest untouched | any:canceled | partially_canceled |
| 3 lines, nothing shipped | all:pending | pending |
| 2 physical (delivered) + 1 digital | all:delivered over the 2 shippable lines | delivered |
| All-digital order | short-circuit | delivered |
order_item.no_shipping_required vs order.no_shipping_required
Both columns exist and both are maintained. The invariant, enforced by the cart field resolvers that feed orderCreator, is:
order.no_shipping_required === order.items.every((i) => i.no_shipping_required);
The rollup math reads the item-level flag, because the order-level flag says nothing useful about a mixed cart (it would be false and give you no way to know which lines to skip). The order-level flag remains a denormalized convenience for resolvers that only need a fast "does this order need shipping at all?" answer without joining order_item.
Digital lines are excluded at three layers: the rollup SQL filters them, getUnshippedItems filters them, and createShipment rejects a payload containing one with Item <sku> does not require shipping and cannot be in a shipment.
Cancelability
Cancelability is a policy question about the whole order, so it is keyed on the rollup vocabulary — not on per-shipment statuses:
{
"oms": {
"order": {
"shipmentRollupCancelable": {
"pending": true,
"partially_shipped": true,
"shipped": true,
"partially_delivered": true,
"delivered": false,
"partially_canceled": true,
"canceled": true
}
}
}
}
cancelOrder reads this map alongside the payment side's per-status isCancelable; either side returning false blocks the cancel. Tighten it from a bootstrap when the merchant's carrier policy differs:
import { addProcessor } from '@evershop/evershop/lib/util/registry';
export default () => {
addProcessor('shipmentRollupCancelable', (map) => ({
...map,
// No cancellations once anything is physically in transit.
shipped: false,
partially_delivered: false
}));
};
Display names and badges
Because partially_* are not registered statuses, the admin UI cannot render them through getShipmentStatusList(). It uses a separate display map:
// modules/oms/services/rollupDisplay.ts
export const ROLLUP_DISPLAY: Record<OrderShipmentRollup, { name: string; badge: string }> = {
pending: { name: 'Pending', badge: 'default' },
partially_shipped: { name: 'Partially Shipped', badge: 'warning' },
shipped: { name: 'Shipped', badge: 'warning' },
partially_delivered: { name: 'Partially Delivered', badge: 'warning' },
delivered: { name: 'Delivered', badge: 'success' },
partially_canceled: { name: 'Partially Canceled', badge: 'warning' },
canceled: { name: 'Canceled', badge: 'destructive' }
};
export function getRollupDisplay(): typeof ROLLUP_DISPLAY;
getRollupDisplay() resolves the map through the rollupDisplay processor, so you can relabel or rebadge any rollup value:
import { addProcessor } from '@evershop/evershop/lib/util/registry';
export default () => {
addProcessor('rollupDisplay', (display) => ({
...display,
partially_shipped: { name: 'Part Shipped', badge: 'default' }
}));
};
The GraphQL Order.shipmentStatus resolver goes through this map and returns { code, name, badge }. External GraphQL consumers written against 2.1.x will start seeing codes they have never met — partially_shipped, partially_delivered, partially_canceled — so treat the field as an open enum.
Services
Everything below is exported from @evershop/evershop/oms/services.
Never write order.shipment_status yourself
order.shipment_status is a cached projection. createShipment, updateShipmentStatus, and order creation all call recomputeOrderShipmentStatus — and cancelOrder reaches it too, by cancelling each shipment through updateShipmentStatus — which overwrites the column with the freshly computed rollup. A direct update('order').given({ shipment_status: ... }) is not rejected — it is simply silently overwritten by the next shipment write, and until then it makes order.shipment_status disagree with the item math.
To move fulfillment state, change a shipment: updateShipmentStatus(shipmentUuid, status).
import { recomputeOrderShipmentStatus } from '@evershop/evershop/oms/services';
async function recomputeOrderShipmentStatus(
orderId: number,
connection?: PoolClient | typeof pool,
preloadedOrder?: RollupOrderSummary
): Promise<OrderShipmentRollup>;
It resolves the rollup, writes it, and returns it. The write step is itself the changeShipmentStatus hook target, which is what triggers the OMS bootstrap's hookAfter('changeShipmentStatus') and the PSO-mapping recompute of order.status. Call it directly only if you wrote shipment or shipment_item rows behind the services' back.
createShipment
import { createShipment } from '@evershop/evershop/oms/services';
interface CreateShipmentPayload {
/** Required, non-empty. Digital items are rejected. */
items: Array<{ order_item_id: number; qty: number }>;
/** Required. Must be a registered carrier code. */
carrier: string;
/** Optional. When absent and the carrier implements createLabel, a label is purchased. */
tracking_number?: string;
/** Defaults to true. Rides along on the shipment_created event. */
notifyCustomer?: boolean;
}
createShipment(
orderUuid: string,
payload: CreateShipmentPayload,
connection?: PoolClient
): Promise<CreateShipmentResult>;
const result = await createShipment(order.uuid, {
items: [
{ order_item_id: 42, qty: 2 },
{ order_item_id: 43, qty: 1 }
],
carrier: 'custom',
tracking_number: '1Z999AA10123456784',
notifyCustomer: true
});
// result: { shipment, items, labelCreated }
The legacy positional signature createShipment(orderUuid, carrier, trackingNumber, connection?) throws:
createShipment now requires { items, carrier, tracking_number? }. The legacy
(orderUuid, carrier, trackingNumber) signature is removed in A3. Update callers;
see wiki/multi-shipment-design.md → Services.
There is no silent fallback, because the legacy call produced a shipment with no items — impossible under the CHECK (qty > 0) constraint on shipment_item and meaningless to the rollup. Dispatch happens on the second argument: pass an object containing items and you get the new path.
The service runs in two phases on purpose. Phase 1 validates the payload, rejects digital lines, checks per-item remaining quantity, and — if the carrier implements createLabel and no tracking number was supplied — makes the carrier API call outside any transaction. Phase 2 opens the transaction, takes pg_advisory_xact_lock on the order, re-validates quantities under the lock, inserts the shipment and shipment_item rows with status = 'shipped', recomputes the rollup, and logs the activity. If phase 2 fails after a label was purchased, the service attempts a compensating voidLabel.
updateShipmentStatus
import { updateShipmentStatus } from '@evershop/evershop/oms/services';
updateShipmentStatus(
shipmentUuid: string,
status: string,
connection?: PoolClient
): Promise<void>;
Keyed by shipment uuid, not order id. It loads the shipment, validates the status against the registry, enforces the phase transition, writes the status plus the first-occurrence timestamp for the new phase, recomputes the order rollup, and emits events.
Allowed phase transitions:
| From phase | To phase |
|---|---|
shipped | shipped (relabel), delivered, canceled |
delivered | delivered only — terminal |
canceled | canceled only — terminal |
Same-phase moves are always allowed, which is how a carrier extension advances shipped → in_transit → out_for_delivery without leaving the phase. Anything else throws Cannot transition shipment from phase X to phase Y.
Timestamps are first-occurrence and never cleared, so a shipment that was relabelled several times inside the shipped phase keeps its original shipped_at.
markDelivered
import { markDelivered } from '@evershop/evershop/oms/services';
markDelivered(shipmentUuid: string, connection?: PoolClient): Promise<void>;
A thin wrapper over updateShipmentStatus(shipmentUuid, 'delivered', connection). Also keyed by shipment uuid now.
Read services
import {
getShipmentsForOrder,
getUnshippedItems,
getOrderShipmentRollup
} from '@evershop/evershop/oms/services';
| Service | Signature | Returns |
|---|---|---|
getShipmentsForOrder | (orderIdOrUuid, connection?) | Every shipment on the order with its items embedded, ordered by created_at ASC. Empty array when the order does not exist |
getUnshippedItems | (orderIdOrUuid, connection?) | { order_item_id, uuid, product_sku, product_name, qty_ordered, qty_unshipped }[], shippable lines only. Canceled shipments release their qty back into qty_unshipped |
getOrderShipmentRollup | (orderIdOrUuid, connection?) | The live rollup value — the same answer order.shipment_status should hold. Throws if the order does not exist |
All three accept either a numeric order_id or an order uuid, so callers do not have to normalize first.
// Build a "ship the rest" payload from what is still outstanding.
const remaining = await getUnshippedItems(order.uuid);
const items = remaining
.filter((line) => line.qty_unshipped > 0)
.map((line) => ({ order_item_id: line.order_item_id, qty: line.qty_unshipped }));
if (items.length > 0) {
await createShipment(order.uuid, { items, carrier: 'custom' });
}
Hooks and events
Every service in the fulfillment path is hookable. Register from a bootstrap.ts — the hook system locks once bootstrap finishes.
| Hook key | Wraps |
|---|---|
createShipment | The whole create call |
validateShipmentItems | Payload validation — runs twice, once pre-lock and once under the lock |
insertShipment / insertShipmentItems | The two row writes |
updateShipmentStatus | The whole status change |
validateShipmentStatusBeforeUpdate | The registry check |
changeShipmentStatusForShipment | The per-shipment row write |
resolveShipmentRollup | The pure predicate-to-output resolution |
recomputeOrderShipmentStatus | Compute plus write of the order rollup |
changeShipmentStatus | Just the order.shipment_status write; the OMS PSO recompute already listens here |
markDelivered | The delivered wrapper |
Typed helpers are exported for each, so you rarely need the raw string:
import { hookAfterUpdateShipmentStatus } from '@evershop/evershop/oms/services';
export default () => {
hookAfterUpdateShipmentStatus(async function (shipmentUuid, status) {
// Use a function expression, not an arrow — the hook context is bound to `this`.
if (status === 'delivered') {
await notifyWarehouse(shipmentUuid);
}
});
};
Events emitted along the way:
| Event | Payload | When |
|---|---|---|
shipment_created | { shipmentId, orderId, notifyCustomer } | After the create transaction commits |
shipment_label_created | { shipmentId, orderId, labelUrl, trackingNumber } | Only when a label was purchased in that call |
shipment_status_changed | { shipmentId, orderId, from, to, phase } | Every status change |
shipment_delivered | { shipmentId, orderId } | Additionally, when the new phase is delivered |
Subscribe by dropping a handler at subscribers/shipment_created/notifyWms.ts in your module. notifyCustomer is a hint carried from the admin dialog: the built-in email subscriber returns early when it is false, and your subscriber should respect it too.
GraphQL surface
type Order {
shipmentStatus: ShipmentStatus # the rollup, rendered through ROLLUP_DISPLAY
shipments: [Shipment!]!
shipment: Shipment @deprecated(reason: "Use shipments. Returns the earliest shipment for back-compat.")
}
type Shipment {
shipmentId: Int!
uuid: String!
status: ShipmentStatus!
phase: String!
carrier: String
carrierName: String
trackingNumber: String
trackingUrl: String
labelUrl: String
items: [ShipmentItem!]!
shippedAt: DateTime
deliveredAt: DateTime
canceledAt: DateTime
createdAt: DateTime!
updatedAt: DateTime
}
type ShipmentItem {
uuid: String!
orderItemId: Int!
qty: Int!
productSku: String
productName: String
thumbnail: String
}
Order.shipment (singular) is deprecated and returns the earliest shipment by created_at. unshippedItems lives on the admin schema only (Order.admin.graphql) — referencing it from a storefront query fails at schema build.
Note that Shipment.shippedAt can be null on a delivered shipment if the status jumped straight to delivered. Templates should fall back to deliveredAt or createdAt rather than assuming it is populated.
REST routes
| Method | Path | Purpose |
|---|---|---|
GET | /api/orders/:id/shipments | List an order's shipments |
POST | /api/orders/:id/shipments | Create a shipment |
PATCH | /api/shipments/:shipment_uuid | Update carrier / tracking |
POST | /api/shipments/:shipment_uuid/markDelivered | Mark delivered |
POST | /api/shipments/:shipment_uuid/cancel | Cancel a shipment |
DELETE | /api/shipments/:shipment_uuid/label | Void a purchased label |
All are private access, and every per-shipment route is keyed by shipment_uuid.
Two order-scoped routes survive as back-compat wrappers and should not be used in new code: POST /api/deliveries, which sweeps every non-delivered, non-canceled shipment on the order into delivered, and PATCH /api/orders/:order_id/shipments/:shipment_id, superseded by the uuid-keyed PATCH /api/shipments/:shipment_uuid.
Migration checklist for 2.1.x extensions
- Replace
createShipment(orderUuid, carrier, tracking)with the payload form and supply realitems. - Replace
updateShipmentStatus(orderId, status)andmarkDelivered(orderId)with the shipment-uuid forms — iterategetShipmentsForOrder(orderId)if you only hold an order. - Add
phaseto everyregisterShipmentStatuscall and deleteisDefault/isCancelable. - Delete any direct write to
order.shipment_status; move the intent to a shipment status change or ashipmentRollupprocessor. - If you registered a
pendingorprocessingshipment status, drop it — pre-ship state now lives at the order level as thependingrollup. - Widen any code that switches on
order.shipment_statusto handlepartially_shipped,partially_delivered, andpartially_canceled. - If you moved order status on shipment cancellation, note that
*:cancelednow maps toprocessing, notcanceled— only payment-side cancellation cancels an order.
See Also
- Order Status Management — payment statuses, PSO mapping, and how
order.statusis derived - Registry and Processors —
addProcessorsemantics behindshipmentRollupandrollupDisplay - Events and Subscribers — subscribing to the shipment events
- Data Migration — writing your own
Version-X.Y.Z.tsmigrations - Zero-Total Checkout — the other 2.2.1 order-pipeline change
Support us
EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.