ReferenceFulfillment depth

Fulfillment depth

The pick/pack/ship loop grew a supply-and-routing spine, split across two modules by ownership: purchase orders, receiving, and physical stock locations land in inventory, and fulfillment nodes, routing modes, dropship vendor costs, and COGS capture land in fulfillment. Neither module owns the other’s schema — each builds a repo instance on its own connection via the owning module’s published createXRepo factory.

Inventory: purchase orders, receiving, locations

The core inventory port (items/lots/FEFO reservations) gained supply-side depth: vendors, purchase orders (with lines), receiving against a PO, and per-node physical locations. Receiving fails closed on a non-receivable PO (po_not_receivable) or an over-receipt (receive_exceeds_expected).

Operator routes — x-operator-token, under /v1/tenants

MethodPathPurpose
POST/v1/tenants/:tenant/locationsCreate a physical stock location; optional node_ref binds it to a fulfillment node.
GET/v1/tenants/:tenant/locationsList locations.
POST/v1/tenants/:tenant/vendorsCreate a supply vendor.
GET/v1/tenants/:tenant/vendorsList vendors.
POST/v1/tenants/:tenant/purchase-ordersCreate a PO + its lines.
GET/v1/tenants/:tenant/purchase-ordersList POs.
GET/v1/tenants/:tenant/purchase-orders/:idFetch a PO + lines.
POST/v1/tenants/:tenant/purchase-orders/:id/receiveReceive stock against a PO (creates lots; fails closed on over-receipt).

Merchant routes — fdk_, scope manage:own_inventory, under /v1/merchant/inventory

MethodPathPurpose
GET/v1/merchant/inventory/vendorsList the caller’s vendors.
POST/v1/merchant/inventory/vendorsCreate a vendor.
GET/v1/merchant/inventory/purchase-ordersList POs.
GET/v1/merchant/inventory/purchase-orders/:idFetch one PO + lines.
POST/v1/merchant/inventory/purchase-ordersCreate a PO.
POST/v1/merchant/inventory/purchase-orders/:id/receiveReceive against a PO.

Fulfillment: nodes, modes, dropship costs, COGS

A fulfillment node is a own_warehouse / threepl / vendor endpoint. Allocation picks a mode:

  • dedicated — draw from own stock, no node pinned.
  • allocated — pin the queue to a specific own_warehouse/threepl node and draw FEFO from that node’s bound inventory location. An unbound node fails closed (node_location_unmapped) rather than silently drawing from elsewhere.
  • dropship — route each line to a vendor node without reserving or decrementing own stock, recording a per-line vendor dispatch (the “purchase/dispatch to vendor”) and a dropship shipment row that stands in for the carrier label.

Mode/node selection happens on the first (queued) pass only; a re-entrant allocate keeps the row’s chosen mode. Mixing own-stock and dropship on one route is refused (wrong_mode) — dropship has its own route.

COGS capture: every shipped line publishes one fulfillment.cogs.captured.v1 at ship time (when the reservations commit / the vendor is dispatched). Dropship COGS resolves each line’s unit cost from the (node, sku) vendor-cost mapping; a line with no cost recorded is captured at zero rather than blocking the ship.

Operator routes — x-operator-token, under /v1/tenants

MethodPathPurpose
GET/v1/tenants/:tenant/fulfillmentList the fulfillment queue.
GET/v1/tenants/:tenant/fulfillment/:idFetch one queue row.
POST/v1/tenants/:tenant/fulfillment/:id/allocateAllocate own stock; a nodeId pins mode allocated, omitting it is dedicated.
POST/v1/tenants/:tenant/fulfillment/:id/dropshipRoute to a vendor node (mode dropship), no own-stock reservation.
POST/v1/tenants/:tenant/nodesCreate a fulfillment node (own_warehouse/threepl/vendor).
GET/v1/tenants/:tenant/nodesList nodes.
POST/v1/tenants/:tenant/vendor-costsMap a per-(node, sku) vendor unit cost (dropship COGS input).
GET/v1/tenants/:tenant/vendor-costsList vendor costs.
POST/v1/tenants/:tenant/fulfillment/:id/labelBuy a shipping label (LabelPort, mock|easypost).
POST/v1/tenants/:tenant/fulfillment/:id/shipShip — commit reservations / dispatch vendor, capture COGS, transition the commerce order.
POST/v1/tenants/:tenant/fulfillment/:id/cancelCancel the queue row.
POST/v1/tenants/:tenant/fulfillment/:id/deliverMark delivered (transitions the commerce order to delivered).

Merchant routes — fdk_, scope manage:own_fulfillment, under /v1/merchant/fulfillment

MethodPathPurpose
GET/v1/merchant/fulfillmentList the queue.
GET/v1/merchant/fulfillment/nodesList nodes.
POST/v1/merchant/fulfillment/nodesCreate a node.
GET/v1/merchant/fulfillment/vendor-costsList vendor costs.
POST/v1/merchant/fulfillment/vendor-costsMap a vendor cost.
GET/v1/merchant/fulfillment/:idFetch one queue row.
POST/v1/merchant/fulfillment/:id/allocateAllocate.
POST/v1/merchant/fulfillment/:id/dropshipDropship-route.
POST/v1/merchant/fulfillment/:id/labelBuy a label.
POST/v1/merchant/fulfillment/:id/shipShip.
POST/v1/merchant/fulfillment/:id/cancelCancel.
POST/v1/merchant/fulfillment/:id/deliverMark delivered.

Events

ModuleDirectionEvent
fulfillmentPublishesfulfillment.cogs.captured.v1 — one per shipped line, at ship time.
fulfillmentConsumescommerce.order.placed.v1 — enqueues a queue row + its lines (real dispatcher consumer).
inventoryPublishesinventory.unit.shipped.v1.

Manifest gates

None. Fulfillment routing, POs, receiving, nodes, and COGS are operator/ merchant back-office surfaces with no storefront manifest toggle.

Seams (honest notes)

  • The SKU-namespace seam (documented, not stubbed): allocation matches a fulfillment queue line’s sku against inventory.canonical_items.sku_root for the same tenant. This port has no shared catalog/SKU-mapping table, so a line whose SKU has no matching inventory item fails allocation with a surfaced 422 inventory_item_not_found, never a silent skip. A future catalog-mapping module is the real fix.
  • One queue row and one shipment per order — the parent’s package-is-first-class model, reships, COA ship-gates, and per-store comms confirmation are out of scope here.
  • Dropship COGS for a line with no vendor cost recorded is captured at zero, not blocked — a documented simplification.