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 stocks ledger or GL:

  1. 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 dedicated stock_reservations collection. It never moves on-hand — sales stock OUT still happens at invoice POST as before.
  2. 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 fulfillmentStatus state 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) — enum OrderFulfillmentStatus, default NONE.
  • OrderItem.pickedQuantity / packedQuantity / fulfilledQuantity (order/item/item.schema.ts) — additive, default 0; 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 remaining ordered − alreadyReserved (alreadyReserved = active reserved − released for that line). If toReserve > ATP and not fulfillmentAllowOversellBAD_REQUEST "Insufficient available-to-promise…". Sets fulfillmentStatus = RESERVED.
  • pick (confirmPick): per line next = pickedQuantity + qty; if next > netQuantity (ordered)BAD_REQUEST "Cannot pick more than ordered". Status becomes PICKED if every line is fully picked (pickedQuantity ≥ netQuantity), else PICKING.
  • pack (confirmPack): per line next = packedQuantity + qty; if next > pickedQuantityBAD_REQUEST "Cannot pack more than picked". Status → PACKED.
  • ship (confirmShipment): per line next = fulfilledQuantity + qty; if next > packedQuantity and not fulfillmentAllowOversellBAD_REQUEST "Cannot ship more than packed". Status → SHIPPED; deliveryRef recorded on the log.
  • deliver (confirmDelivery): status → DELIVERED (no quantity checks).
  • cancel (cancelFulfillment): every ACTIVE reservation → RELEASED with releasedQuantity = 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 PICKING vs complete PICKED). Every transition writes an order_fulfillment_logs row with quantityByItem.

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 >=. null threshold = 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:

  1. On submit/create — maybeRequireApproval(order) (best-effort): if evaluate().required and a workflowId exists and no active workflow task already targets the order, auto-submit the workflow via workflowEngine.submit({...}). Wrapped in try/catch that intentionally swallows errors — approval automation must never block document creation.
  2. On post — postInvoice(id) (hard gate): re-evaluate(); if required, load the order's non-archived workflow tasks, compute getWorkflowStatus(tasks), and if the status does not include "APPROVED" → throw FORBIDDEN "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)

  1. A Sales Order is created and POSTED (OrderStatusTypes.POSTED). Company has salesFulfillmentEnabled (+ stockReservationEnabled for step 2).
  2. reserveOrderStock(orderId) — per line reserve ordered − alreadyReserved against ATP; create stock_reservations rows; fulfillmentStatus = RESERVED; log NONE→RESERVED.
  3. confirmPick(orderId, items) — bump pickedQuantity (≤ ordered); status PICKING or PICKED.
  4. confirmPack(orderId, items) — bump packedQuantity (≤ picked); status PACKED.
  5. confirmShipment(orderId, deliveryRef, items) — bump fulfilledQuantity (≤ packed); status SHIPPED; deliveryRef logged.
  6. confirmDelivery(orderId) — status DELIVERED.
  7. (Sales stock OUT still posts separately at the corresponding Sales Invoice POST — fulfillment never moves the stocks ledger.)

6.2 Cancel

cancelFulfillment(orderId) releases all ACTIVE reservations (status=RELEASED, releasedQuantity=reserved) and sets fulfillmentStatus = CANCELLED. Idempotent.

6.3 Approval-gated posting

  1. Admin configures a policy: e.g. kind = SalesInvoice, amountThreshold = 50000, workflowId = <approval wf>, enabled = true; company has approvalThresholdsEnabled = true.
  2. A Sales Invoice with totalAmount = 60000 is created → maybeRequireApproval auto-submits the workflow (best-effort).
  3. Operator attempts postInvoiceevaluate returns required → workflow status is not APPROVEDFORBIDDEN. Posting is blocked.
  4. Approver completes the workflow → status includes APPROVEDpostInvoice succeeds (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 evaluate returns required:false).
  • Over-reserve / over-pick / over-pack / over-ship beyond caps → BAD_REQUEST (ship cap waivable via oversell).
  • Posting an over-threshold order without APPROVED workflow → 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 in order.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 evaluate returns required:false, so nothing changes vs. legacy behaviour.
  • Reservation ≠ stock movement. stock_reservations is 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 per kind. 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) vs approvalThresholdsEnabled (company) are independent — both must be true for a band to bite.
  • warehouseId on reservations is declared but unpopulated by the current reserveOrderStock.
  • 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/branchId and use mongoose-delete. See ../../platform/multi-tenancy.md.