Fulfillment & Approval Thresholds — sales pick/pack/ship + value-band approval gates
Two related, flag-gated, additive engines that bolt onto the existing order flow without touching the
stocksledger or GL:
- Fulfillment reduces to: progress a posted Sales Order through
RESERVED → PICKING → PICKED → PACKED → SHIPPED → DELIVERED, tracking per-line picked/packed/fulfilled quantities and reserving available-to-promise (ATP) in a dedicatedstock_reservationscollection. It never moves on-hand — sales stock OUT still happens at invoice POST as before.- Approval thresholds reduce to: for each order
kind, if the order's total amount (or discount %) exceeds a configured band, require a workflow approval before the document can be posted. The bands are configured per company; enforcement lives in the order service, not here.
Source: BE src/modules/inventory/fulfillment + src/modules/inventory/approval-threshold · Admin: none (both are headless — no admin module ships today).
Both are off by default behind company feature flags; with the flags off every public method is a no-op or throws FORBIDDEN, so legacy order/stock/GL behaviour is unchanged. Enums live in inventory/inventory.constant.ts. See ./stock.md for the ledger and the order doc for the base order flow.
1. Purpose & scope
Fulfillment
Operational tracking layer for Sales Orders (only OrderKindTypes.SalesOrder, and only when POSTED):
- Reserve ATP per line into
stock_reservations(ATP = on-hand − active reservations). - Record pick / pack / ship progress per line on the existing
OrderItem(pickedQuantity,packedQuantity,fulfilledQuantity). - Drive the order's
fulfillmentStatusstate machine and write an immutable transition log (order_fulfillment_logs). - Expose a per-order reservation/progress summary and ATP query.
Explicitly does NOT: write the stocks collection, post GL, or affect the actual sales stock OUT (that remains at invoice POST). Reservation affects ATP only. There is no admin UI.
Approval thresholds
A per-company policy table (approval_thresholds) mapping an order kind + value band → a workflow:
- When enabled and an order exceeds a band, an approval workflow is auto-submitted on creation (best-effort) and posting is blocked until that workflow is APPROVED.
Explicitly does NOT: define who approves (that is the workflow engine — this module only stores workflowId and the numeric bands). There are no named approver roles or value tiers beyond a single amount threshold + a single discount-% threshold per kind. No admin UI ships.
2. Data model
2.1 stock_reservations — available-to-promise reservation (fulfillment/stock-reservation.schema.ts)
Dedicated collection so it never touches stocks / reports / GL. Soft-deleted.
| field | type | required | description |
|---|---|---|---|
orderId |
ObjectId | no | The Sales Order. |
orderItemId |
ObjectId | no | The order line being reserved. |
itemId |
ObjectId | no | The product. |
warehouseId |
ObjectId | no | (Declared; not populated by the current reserveOrderStock.) |
quantity |
number | 0 | Reserved amount at creation. |
reservedQuantity |
number | 0 | Active reserved qty. |
releasedQuantity |
number | 0 | Released portion (on cancel). |
status |
StockReservationStatus |
ACTIVE |
ACTIVE / RELEASED / CONSUMED / CANCELLED. |
actorId |
ObjectId | no | The user who reserved. |
export enum StockReservationStatus { ACTIVE = "ACTIVE", RELEASED = "RELEASED", CONSUMED = "CONSUMED", CANCELLED = "CANCELLED" }2.2 order_fulfillment_logs — immutable transition audit (fulfillment/fulfillment-log.schema.ts)
| field | type | description |
|---|---|---|
orderId |
ObjectId | The order. |
fromStatus / toStatus |
string | Fulfillment status before/after the transition. |
quantityByItem |
[{ itemId, quantity }] |
Quantities moved in this transition. |
actorId |
ObjectId | Who performed it (contextSvc.userId). |
note / deliveryRef |
string | Optional note; shipment delivery reference. |
2.3 Fulfillment fields on existing schemas (not in this module)
Order.fulfillmentStatus(order/order.schema.ts) — enumOrderFulfillmentStatus, defaultNONE.OrderItem.pickedQuantity/packedQuantity/fulfilledQuantity(order/item/item.schema.ts) — additive, default0; operational only, no stock/GL effect.
// inventory.constant.ts
export enum OrderFulfillmentStatus {
NONE = "NONE", RESERVED = "RESERVED", PICKING = "PICKING", PICKED = "PICKED",
PACKED = "PACKED", SHIPPED = "SHIPPED", DELIVERED = "DELIVERED", CANCELLED = "CANCELLED"
}2.4 approval_thresholds — value-band policy (approval-threshold/approval-threshold.schema.ts)
Soft-deleted. One row per kind (the active policy is findOne({ kind, enabled: true })).
| field | type | required | description |
|---|---|---|---|
kind |
ApprovalThresholdKind |
yes | Which order kind this band applies to. |
workflowId |
ObjectId | no | Workflow to auto-run / require when a band is exceeded. |
amountThreshold |
number | no | Require approval when order.totalAmount > amountThreshold (null = ignore). |
discountPercentageThreshold |
number | no | Require approval when discount type is PERCENTAGE and discountValue > threshold (null = ignore). |
enabled |
boolean | true |
Policy on/off (separate from the company-level feature flag). |
export enum ApprovalThresholdKind {
SalesQuotation = "SalesQuotation", SalesOrder = "SalesOrder", SalesInvoice = "SalesInvoice",
PurchaseRequisition = "PurchaseRequisition", PurchaseOrder = "PurchaseOrder", PurchaseInvoice = "PurchaseInvoice"
}2.5 Company feature flags (company/company.schema.ts) — all default false
| flag | gates |
|---|---|
salesFulfillmentEnabled |
all fulfillment mutations (assertEnabled()). |
stockReservationEnabled |
reserveOrderStock only (assertEnabled(true)). |
fulfillmentAllowOversell |
waive the ATP cap on reserve and the packed cap on ship. |
approvalThresholdsEnabled |
the entire approval-threshold evaluation (isEnabledForCompany()). |
3. API surface
Fulfillment — GraphQL (fulfillment/fulfillment.resolver.ts, class-gated @ApGqlAuthorize)
| Operation | Type | Input | Returns | Notes |
|---|---|---|---|---|
reserveOrderStock |
mutation | orderId |
Order |
reserve ATP per line; audit UPDATE |
confirmPick |
mutation | orderId, items: [FulfillmentItemInput] |
Order |
cap: pick ≤ ordered |
confirmPack |
mutation | orderId, items |
Order |
cap: pack ≤ picked |
confirmShipment |
mutation | orderId, deliveryRef?, items |
Order |
cap: ship ≤ packed (unless oversell) |
confirmDelivery |
mutation | orderId |
Order |
→ DELIVERED |
cancelFulfillment |
mutation | orderId |
Order |
release active reservations → CANCELLED |
orderFulfillmentLogs |
query | orderId |
[OrderFulfillmentLog] |
transition history |
orderReservationSummary |
query | orderId |
OrderReservationSummary |
per-line ordered/reserved/picked/packed/fulfilled/ATP |
stockReservationsPage |
query | page: StockReservationPageInput |
StockReservationPageResult |
paged reservations |
FulfillmentItemInput { itemId: String, quantity: Float }. All fulfillment mutations return the refreshed Order and write an order_fulfillment_logs row.
Approval threshold — GraphQL (approval-threshold/approval-threshold.resolver.ts, class-gated @ApGqlAuthorize)
| Operation | Type | Input | Returns | Permission |
|---|---|---|---|---|
createApprovalThreshold |
mutation | policy: CreateApprovalThresholdInput |
ApprovalThreshold |
audit CREATE |
updateApprovalThreshold |
mutation | _id, policy: UpdateApprovalThresholdInput |
ApprovalThreshold |
audit UPDATE |
deleteApprovalThreshold |
mutation | _id |
Boolean |
audit DELETE |
findOneApprovalThreshold |
query | query: ApprovalThresholdQueryInput |
ApprovalThreshold (nullable) |
— |
approvalThresholdPage |
query | page: ApprovalThresholdPageInput |
ApprovalThresholdPageResult |
— |
evaluate(order)is not a GraphQL op — it is an internal service method called by the order service (see §4.3). The resolver only does policy CRUD.
4. Business rules & calculations
4.1 ATP (available-to-promise) — FulfillmentService.availableToPromise()
ATP(item, branch) = stockSvc.balance({ itemId, branchId }) // on-hand (Σ IN − Σ OUT)
− Σ (reservedQuantity − releasedQuantity) // over ACTIVE reservations for item
Read-only; never mutates the stocks ledger.
4.2 Fulfillment state machine + cumulative caps
reserveOrderStock confirmPick (all) confirmPack confirmShipment confirmDelivery
NONE ───────────────────────▶ RESERVED ──┬─▶ PICKING ──(all)──▶ PICKED ─────▶ PACKED ─────▶ SHIPPED ─────▶ DELIVERED
│ (partial → PICKING)
any active state ──cancelFulfillment──▶ CANCELLED (releases ACTIVE reservations)
- reserve (
reserveOrderStock): idempotent — for each line reserves only the remainingordered − alreadyReserved(alreadyReserved= active reserved − released for that line). IftoReserve > ATPand notfulfillmentAllowOversell→BAD_REQUEST "Insufficient available-to-promise…". SetsfulfillmentStatus = RESERVED. - pick (
confirmPick): per linenext = pickedQuantity + qty; ifnext > netQuantity (ordered)→BAD_REQUEST "Cannot pick more than ordered". Status becomesPICKEDif every line is fully picked (pickedQuantity ≥ netQuantity), elsePICKING. - pack (
confirmPack): per linenext = packedQuantity + qty; ifnext > pickedQuantity→BAD_REQUEST "Cannot pack more than picked". Status →PACKED. - ship (
confirmShipment): per linenext = fulfilledQuantity + qty; ifnext > packedQuantityand notfulfillmentAllowOversell→BAD_REQUEST "Cannot ship more than packed". Status →SHIPPED;deliveryRefrecorded on the log. - deliver (
confirmDelivery): status →DELIVERED(no quantity checks). - cancel (
cancelFulfillment): everyACTIVEreservation →RELEASEDwithreleasedQuantity = reservedQuantity; status →CANCELLED. Safe to call repeatedly.
The cumulative caps form the invariant picked ≤ ordered ≤, packed ≤ picked, fulfilled(shipped) ≤ packed. Pack/ship always set the terminal status for that stage (only pick distinguishes partial
PICKINGvs completePICKED). Every transition writes anorder_fulfillment_logsrow withquantityByItem.
Every mutation first runs assertEnabled() (and assertEnabled(true) for reserve), then loadEligibleOrder which requires kind === SalesOrder and status === POSTED (else BAD_REQUEST / NOT_FOUND).
4.3 Approval threshold evaluation — ApprovalThresholdService.evaluate()
// returns { required, workflowId?, reason? }
if (!order?.kind) return { required: false };
if (!isEnabledForCompany()) return { required: false }; // company.approvalThresholdsEnabled
const policy = findOne({ kind: order.kind, enabled: true });
if (!policy) return { required: false };
const exceedsAmount = policy.amountThreshold != null && (order.totalAmount || 0) > policy.amountThreshold;
const exceedsDiscount = policy.discountPercentageThreshold != null
&& order.discountValueType === "PERCENTAGE"
&& (order.discountValue || 0) > policy.discountPercentageThreshold;
return (exceedsAmount || exceedsDiscount)
? { required: true, workflowId: policy.workflowId, reason: exceedsAmount ? "amount-threshold" : "discount-threshold" }
: { required: false };- Strictly greater-than (
>), not>=.nullthreshold = ignore that dimension. - Returns
{ required: false }whenever the company flag is off or no enabled policy matches — making every consumer a no-op by default.
4.4 Where approval is enforced — the order service (not this module)
inventory/order/order.service.ts wires evaluate() at two points:
- On submit/create —
maybeRequireApproval(order)(best-effort): ifevaluate().requiredand aworkflowIdexists and no active workflow task already targets the order, auto-submit the workflow viaworkflowEngine.submit({...}). Wrapped in try/catch that intentionally swallows errors — approval automation must never block document creation. - On post —
postInvoice(id)(hard gate): re-evaluate(); ifrequired, load the order's non-archived workflow tasks, computegetWorkflowStatus(tasks), and if the status does not include"APPROVED"→ throwFORBIDDEN "This order requires approval before it can be posted". Only then does posting (line validation, GL POSTED,status = POSTED) proceed. On order delete,workflowTaskSvc.deleteByRefId(id)cleans up the tasks.
So the band defines when approval is needed and which workflow; the workflow engine decides who approves and produces the APPROVED status the post-gate checks. See ../../platform/workflow-approval-engine.md.
4.5 Transactionality
Fulfillment mutations are sequential repo writes (order item updates + reservation/log creates); they are not wrapped in a single Mongo transaction in this service (unlike transfer/adjustment). Approval CRUD uses the base service's transaction manager.
5. Permissions
Both resolvers are class-gated by @ApGqlAuthorize({}) (JWT + access-group RBAC — see ../../platform/permissions-access.md). There are no dedicated inventory action keys for fulfillment or approval-threshold (unlike transfer/adjustment). Gating is the generic authorize guard plus the company feature flags (§2.5), which act as the real on/off switch. Mutations carry @AuditMeta (module: "fulfillment" / "approval-threshold") → audit trail.
6. Flows
6.1 Reserve → pick → pack → ship → deliver (happy path)
- A Sales Order is created and POSTED (
OrderStatusTypes.POSTED). Company hassalesFulfillmentEnabled(+stockReservationEnabledfor step 2). reserveOrderStock(orderId)— per line reserveordered − alreadyReservedagainst ATP; createstock_reservationsrows;fulfillmentStatus = RESERVED; log NONE→RESERVED.confirmPick(orderId, items)— bumppickedQuantity(≤ ordered); status PICKING or PICKED.confirmPack(orderId, items)— bumppackedQuantity(≤ picked); status PACKED.confirmShipment(orderId, deliveryRef, items)— bumpfulfilledQuantity(≤ packed); status SHIPPED;deliveryReflogged.confirmDelivery(orderId)— status DELIVERED.- (Sales stock OUT still posts separately at the corresponding Sales Invoice POST — fulfillment never moves the
stocksledger.)
6.2 Cancel
cancelFulfillment(orderId) releases all ACTIVE reservations (status=RELEASED, releasedQuantity=reserved) and sets fulfillmentStatus = CANCELLED. Idempotent.
6.3 Approval-gated posting
- Admin configures a policy: e.g.
kind = SalesInvoice, amountThreshold = 50000, workflowId = <approval wf>, enabled = true; company hasapprovalThresholdsEnabled = true. - A Sales Invoice with
totalAmount = 60000is created →maybeRequireApprovalauto-submits the workflow (best-effort). - Operator attempts
postInvoice→evaluatereturnsrequired→ workflow status is notAPPROVED→FORBIDDEN. Posting is blocked. - Approver completes the workflow → status includes
APPROVED→postInvoicesucceeds (GL POSTED, order POSTED).
6.4 Unhappy paths
- Fulfillment on a non-SalesOrder or non-POSTED order → BAD_REQUEST.
- Feature flag off → FORBIDDEN (fulfillment) / silent no-op (approval
evaluatereturnsrequired:false). - Over-reserve / over-pick / over-pack / over-ship beyond caps → BAD_REQUEST (ship cap waivable via oversell).
- Posting an over-threshold order without
APPROVEDworkflow → FORBIDDEN.
7. Admin UI
None. Neither fulfillment nor approval-threshold ships an admin module (zerp-admin has no fulfillment / approval-threshold / reservation folder, and no admin code references reserveOrderStock, confirmPick, orderReservationSummary, or approvalThreshold*). These are headless APIs intended to be driven by the workflow engine and future operational screens. The order admin surfaces fulfillmentStatus on the Order type but does not yet expose the transition mutations.
8. Dependencies & integrations
company(CompanyService) — reads the four feature flags +defaultWorkflows.order/order/item(OrderRepository,OrderItemService) — eligibility checks, status writes, and per-line picked/packed/fulfilled updates.stock(StockService.balance) — on-hand for the ATP calculation (read-only).- workflow engine (
WorkflowEngine,WorkflowTaskService) — consumes approval-threshold output: auto-submit on create, status check on post (wired inorder.service.ts). - No cron, no external services, no events emitted.
9. Gotchas & project-specific rules
- Everything is flag-gated and additive. Off by default; with flags off, fulfillment throws FORBIDDEN and approval
evaluatereturnsrequired:false, so nothing changes vs. legacy behaviour. - Reservation ≠ stock movement.
stock_reservationsis a separate collection; it only affects ATP. On-hand and GL are untouched until the normal invoice POST. - Fulfillment is Sales-Order-only. Sales Invoices and all purchase kinds are rejected by
loadEligibleOrder. - Approval bands use strict
>and a single amount + single discount-% threshold perkind. There are no graduated value tiers or named approver levels here — that nuance lives entirely in the linked workflow. - Two gates, different strictness: auto-submit on create is best-effort (errors swallowed); the post gate is a hard FORBIDDEN. A missing/failed auto-submit will surface as a blocked post later.
enabled(policy) vsapprovalThresholdsEnabled(company) are independent — both must be true for a band to bite.warehouseIdon reservations is declared but unpopulated by the currentreserveOrderStock.- No transaction wrapper around the multi-write fulfillment mutations (unlike transfer/adjustment) — a mid-sequence failure can leave partial line updates + a missing log; callers should treat them as best-effort operational writes.
- Multi-tenant / soft-delete — all four collections carry
companyId/branchIdand usemongoose-delete. See../../platform/multi-tenancy.md.