Supplier ordering pipeline
The platform generates supplier orders (SupplierOrder) from real customer demand, in a
just-in-time model: each supplier send covers a window of customer delivery dates, and
physical stock can legitimately sit at zero between two receptions. Since the #2886
campaign, recalculation is event-driven — every customer-order mutation triggers a
targeted recalc through a bext queue — and supplier emails go through an operator review
queue ("review == sent").
Pipeline overview
flowchart LR
subgraph PROD["Event producers"]
CO["Customer order<br/>create / update / cancel /<br/>status / delete"]
PS["PrestaShop webhooks"]
RC["Supplier-order reception<br/>or cancellation"]
end
CO --> Q[["bext queue<br/>supplier-orders-recalc"]]
PS --> Q
RC --> Q
Q -->|"per-site<br/>write lock"| W["recalculateVendorOrdersUnified"]
C30["Auto-supplier cron<br/>every 30 min"] --> W
C715["Daily reconcile<br/>07:15"] --> W
W --> SO["SupplierOrder DRAFT<br/>+ PENDING projections"]
SO -->|"stage 06:00<br/>(render-only)"| REV["Operator review<br/>emailPreview"]
REV -->|"send"| SENT["SENT → CONFIRMED"]
SENT --> LIV["Reception →<br/>stock ledger"]
Three engines feed the same recalculation, serialized by the per-site supplier write lock (no two of them can ever write concurrently):
| Engine | Cadence | Role |
|---|---|---|
supplier-orders-recalc queue |
real time (event-driven) | carries the nominal load since #2886 |
auto-supplier-orders-service cron |
every 30 min | generation safety net |
reconcile-supplier-orders cron |
daily 07:15 | full reconciliation (adds, updates and removes) |
Event-driven triggers
The bext queue supplier-orders-recalc (gated by BEXT_SUPPLIER_RECALC=true) receives a
message on every event that changes demand or supply:
- Customer order: create, update, cancel, status change, delete
(
packages/services-order/src/order/lifecycle.ts+service.ts); - Supplier order: cancellation, reception
(
packages/services-supplier-order/src/supplier-order/service.ts); - PrestaShop webhooks:
apps/app/src/app/api/webhooks/prestashop/[entity]/route.ts(including updates — see PrestaShop sync); - Manual stock adjustments (
packages/services/src/stock/manual-stock-adjustment.ts).
They all go through enqueueSupplierOrderRecalc (packages/common/src/bext-sdk.ts), with
a { tenantId, siteId, vendorIds?, productIds?, orderIds? } payload.
The worker (apps/app/src/app/api/queue/supplier-orders-recalc/route.ts):
- authenticates the bext POST via
?token=<BEXT_QUEUE_TOKEN>; - resolves
productIdsto their default vendors and unions them with explicitvendorIds; - acquires the site's supplier write lock (the same one the crons take — total serialization);
- runs a scoped regeneration for those vendors, which reconciles the draft items and re-syncs their PENDING "incoming stock" projections.
The response contract follows bext queue semantics: 2xx = ack, 5xx/408/429 =
retry, any other 4xx = dead-letter.
A manual trigger from the back office (recalculateVendorOrdersUnified, the recalc
button) takes the same lock. If the lock is held, the UI surfaces a neutral CONFLICT
("try again") instead of writing concurrently.
The slot model
Every vendor carries sendSchedules — recurring send slots (for example Monday 12:15
- Wednesday 22:15) — and a
deliveryDayslead time (default 1 day). A customer order is assigned to the send slot that still allows on-time delivery:
minSendDate = deliveryDate − (deliveryDays + 1 spare day) // start of day
maxSendDate = deliveryDate − deliveryDays // end of day
Slot assignment (assignClientOrderToEmailScheduleStrict in
packages/services-supplier-order/src/utils/supplier-order-utils-new.ts) looks for a slot
inside that window, then applies two fallbacks:
- On-time fallback: no slot in the ideal window → take an earlier slot that still allows delivery by the required date (early delivery, but on time).
- Demand too late: no remaining slot can arrive on time. Two policies, keyed on the
SUPPLIER_SKIP_LATE_DEMANDflag:- Amis de la Ferme (production,
=true): the demand is excluded from upcoming supplier emails (continue, logs⚠️ [SKIP-LATE-DEMAND]). We never re-order something we can no longer deliver on time — goods arriving after the customer needed them are no help. It's left for the merchant to handle directly. The cadence (two order-days a week, next-day delivery) leaves enough lead that this case stays rare. - Other tenants (
=false) — roll-forward, #2832: the demand rolls to the next future slot. Delivery will be late but the draft stays sendable — the alternative (parking it on a past slot the scheduled sender will never process) amounted to dropping the demand.
- Amis de la Ferme (production,
Example: a Wednesday delivery
Take a vendor with deliveryDays = 2 (the amisferme production value) and two send slots
(Monday 12:15, Wednesday 22:15). A customer orders for delivery on Wednesday:
deliveryDate = Wednesday
minSendDate = Wednesday − (2 + 1) = Sunday // start of day
maxSendDate = Wednesday − 2 = Monday // end of day
The only send slot inside the [Sunday, Monday] window is Monday 12:15 — the demand
lands there. (A Thursday delivery would also fall on Monday; Friday/Saturday would shift to
the Wednesday 22:15 slot.) With two weekly slots, 2 is exactly the value that puts
Wednesday/Thursday deliveries on Monday's email and Friday/Saturday ones on Wednesday's.
deliveryDays is dual-use. It drives the assignment window above and used to double
as the reception estimate (expectedDeliveryDate). Since 2026-07-16 the expected reception
date lives in a separate field — receptionLeadDays (computeExpectedDeliveryDate,
falling back to deliveryDays when empty) — so assumed receptions can be moved closer to the
real arrival without disturbing slot assignment. Never lower deliveryDays from 2 to
1 to fix a reception offset: that would send Thursday's orders on the wrong email and leave
Saturday's with no slot. Tune receptionLeadDays instead — see
The weekly cycle.
The SUPPLIER_NO_PAST_SLOTS gate
In production (SUPPLIER_NO_PAST_SLOTS=true), the automatic path calls
calculateOrderQuantities({ allowPastSlots: false }) with three constraints:
- Time floor: no slot before
now(+ optional grace) — never a backdated draft; - Causality: a supplier send date can never predate the
createdAtof the customer order that created the demand; - Demand too late: keyed on
SUPPLIER_SKIP_LATE_DEMAND, either excluded from upcoming emails (Amis de la Ferme, production — we never order what can't be delivered on time), or rolled to the next future slot (roll-forward #2832, other tenants). See fallback 2 above.
The time-slot validator (time-slot/service.ts) accepts the late placement when the
optimal send has already passed — without it, creation failed and the demand was silently
dropped.
The historical replay (audit tooling) pins allowPastSlots: true to preserve
retrospective semantics: you can replay what the engine would have decided at a past
date without the gate rewriting history.
Quantity computation
For each product and each slot, the engine
(packages/services-supplier-order/src/utils/auto-supplier-order-utils-new.ts) computes:
grossNeed = slot's customer demand (+ optional overage %)
suggested = max(0, grossNeed − stockApplied)
packages = ceil(suggested / packageSize) // packaging unit
displayUnits = packages × packageSize // units actually ordered
Three things matter here:
stockAppliedcomes fromtrustedStock, the stock ledger's trust boundary: untracked stock → 0, pessimistic kill switch → 0, made-to-order vendor → 0, negatives clamped to 0 (unless theSTOCK_TRUST_ALLOW_NEGATIVEgate), and capped atSTOCK_TRUST_MAX_COVER_WEEKS(8 in production) weeks of recent consumption (anti phantom-stock).- Chronological draw-down (#2866): when a product's demand spans several slots,
available stock is consumed slot by slot in order — the first slot absorbs the stock,
later slots order the remainder. The per-slot identity
demand − stock = suggestedstays exact. - Round up to the package: quantity is never lost to rounding. An item is created
even at quantity 0 — it documents the covered customer orders (
sourceOrderIds). A batch that is 100% zero-quantity means "demand already covered by stock" — a healthy outcome, not a cron error.
Candidates: the 30-min cron vs the 07:15 reconcile
| Auto-supplier cron (*/30) | Reconcile (07:15) | |
|---|---|---|
| Mode | force=false |
force=true |
| Candidate products | manageStock=true OR approvisionnement>0 OR generic |
all of the vendor's products |
manageStock=false |
excluded | included — the only engine that fills these orders |
| Operations | add / update | add / update / remove |
| Orphans | — | --removeOrphans: cancels + tags (cancellationReason="reconcile-orphan") |
| Inactive products | included if they have pending demand in the window (#2933) | same |
The reconcile (packages/scripts/src/tasks/reconcile-supplier-orders.ts +
utils/supplier-order-reconciliation.ts) computes the expected supplier-order state
from current customer orders, diffs it against the database, and applies minimal
corrections. The orphan sweep cancels and tags instead of deleting — provenance
(cancellationReason, cancelledAt, cancelledBy) stays auditable.
The reconcile runs --apply --removeOrphans every morning in production. Any change
to applyDiff or the quantity math must be validated with the read-only replay-diff
before merging — see Safety nets.
Grouping: one product → one vendor → one slot
Every product routes to a single purchasing vendor — its defaultVendor. A product
belongs to one default vendor for ordering; vendors are distinct namespaces (the same product
is never ordered from two vendors at once).
The engine creates or updates exactly one SupplierOrder per (vendor, send-slot) pair —
never two drafts for the same vendor at the same slot. Reconciliation keys orders on
vendorId_date_time (orderKey in
packages/services-supplier-order/src/utils/supplier-order-reconciliation.ts), so a recalc
always falls back onto the existing order instead of fragmenting a new one (the live audit
measures 0 fragmentation).
When a product's demand spans several slots, it produces one line per slot on the
vendor's respective orders — each SupplierOrderItem carries the sourceOrderIds of the
customer orders it covers.
Generic products
A generic product (isGeneric=true, #2836) is a virtual purchasing product that
aggregates several shop references (its "children") under a single vendor line. Typical
case: several formats sold to the customer but ordered from the vendor in a common unit (the
kilogram, say).
The child → generic mapping
Children are tied to the parent by ProductGenericLink rows
(packages/db/prisma/schema/product.prisma):
| Field | Role |
|---|---|
genericProductId |
the generic parent product (purchasing side) |
externalProductId |
the child's key — the PrestaShop externalId or, failing that, the internal product.id UUID (#2823) |
conversionFactor (Float, default 1) |
generic_qty = child_qty × conversionFactor |
conversionUnit (default "kg") |
the generic's purchase unit |
⚠️ #2823 subtlety: externalProductId stores the PrestaShop externalId, or the
product.id UUID when the child has no externalId (the UI writes product.externalId || product.id — the "Panier - …" basket components). The guard must match both keys,
otherwise a UUID-mapped child escapes and reappears as a duplicate in the vendor email (a
distinct child line on top of the generic parent).
Children never carry a vendor line
Only the parent carries the purchasing chain. At every SupplierOrderItem creation entry
point (extendExistingOrder, createSupplierOrder, the reconcile's applyDiff),
getMappedShopChildProductIds
(packages/services-supplier-order/src/utils/supplier-order-utils-new.ts) excludes the
mapped children — the defensive guard that blocks the violation even when an upstream filter
failed.
Demand aggregation
resolveOrderItemsViaGenericMapping
(packages/services-supplier-order/src/utils/generic-mapping-utils.ts) resolves each child's
customer demand, multiplies it by its conversionFactor, and sums it onto the parent's
demand. The converted demand is rounded up to the integer, then, like any product, up
to the package — so a fraction of a unit of real demand still orders a whole case:
Bananas: 2 child orders × 1 × 0.05 = 0.10 → ⌈0.10⌉ = 1 → 1 package
This is not a bug: it is the normal round-up to packaging (⌈⌈x⌉/n⌉ = ⌈x/n⌉, the
intermediate rounding is neutral). The manual / "Recalculate" path uses the equivalent
computeGenericConvertedDemandFromSourceOrders (supplier-order-utils-new.ts) so it does not
overwrite the converted quantity the auto-generation laid down (#2836).
Stock netting
A generic is virtual (manageStock=false) — the real stock is carried by its children.
The engine therefore replaces the parent's trustedStock with
genericChildrenStock = Σ(child trustedStock × conversionFactor)
(computeGenericChildrenStock / aggregateGenericChildrenStock, #2871). A generic's demand
thus nets against the real stock of its children, in the same conversion as the demand.
Child stock is evaluated by genericChildTrustedStock, a variant of trustedStock that
skips the vendor's madeToOrder flag: a tracked child's stock is real even if the parent
vendor is just-in-time. Ignoring it would under-forecast the generic and order packages
already covered (the "leeks" false positive). Do not "fix" this behavior.
Other rules
Overage
Overage is off by default (enableOverage=false): exact ordering, grossNeed = demand.
When enabled, the percentage is product.specificOveragePercent (falling back to 5% when
unset) and grossNeed = demand × (1 + overage%) before subtracting stock
(calculateProductQuantities).
Packaging
The packageSize divisor for the round-up to packages follows the precedence
packagingType.orderByQuantity || product.packagingQuantity || 1, and
displayUnits = packages × packageSize — the units actually ordered.
Supplier-order lifecycle
The status (ORDER_STATUS) is deliberately simple; "staged" is not a status but the
presence of a non-null emailPreview on a DRAFT. Delivery is a separate field
(supplierDeliveryStatus, enum SUPPLIER_DELIVERY_STATUS).
stateDiagram-v2
[*] --> DRAFT : generation (queue / cron / manual)
DRAFT --> DRAFT : recalc + 06:00 stage (emailPreview)
DRAFT --> SENT : email send (review == sent)
DRAFT --> CANCELLED : cancellation (merchant / reconcile-orphan / sweep)
SENT --> CONFIRMED : vendor confirmation
SENT --> CANCELLED : cancellation
state "Delivery — supplierDeliveryStatus" as LIV {
[*] --> PENDING
PENDING --> PARTIAL : partial reception
PARTIAL --> COMPLETE : reception completed
PENDING --> COMPLETE : full reception
PENDING --> ISSUE : problem reported
}
SENT --> LIV : confirmDelivery
CONFIRMED --> LIV : confirmDelivery
PENDING projections ("incoming stock")
When a supplier-order item is created, the service creates a PENDING StockAction
with source SUPPLIER_DELIVERY — the incoming-stock projection. These rows never touch
Product.stock (rule 17); they feed the back-office projections.
- A recalc re-syncs an order's PENDING set to its current items (cancels the old set, recreates it from the items).
- Cancelling an order tears down its PENDING rows — no zombie projections.
- Reception (
confirmDelivery) records the physical inflow as COMPLETED (viaStockLedgerService.recordPhysicalMovement, which nets any standing backorder on the signed ledger) and then cancels the order's PENDING rows — the stock has become real. - Undoing a reception (
undoSupplierOrderDelivery) reverses the movement and restores exactly the PENDING rows thatconfirmDeliverycancelled.
Supplier emails: stage → review → send
No supplier email leaves without an operator having seen the quantities. The pipeline
(detailed monorepo-side in docs/supplier-order-staged-outbox.md):
- Stage (06:00 cron, render-only): for each eligible DRAFT without a preview, the
email is rendered and persisted to
SupplierOrder.emailPreview({subject, html, recipients, renderedAt, deferUntil, overrides, itemsFingerprint}). Nothing is sent. - Review (back office): the review queue shows effective values (overrides
merged), lets the operator edit subject/recipients, defer (
deferUntil), re-prepare, or cancel — see Back office. - Send: sending always re-renders from the current items (never ship stale
quantities), but blocks on drift — the staged
itemsFingerprint(SHA-256 of the sorted{productId, productVariationId, quantity, packageSize}items) is recompared to the current items. If they differ:CONFLICT— "re-prepare then resend". This is the review == sent guarantee.
Recipient resolution: test > overrides.recipients > vendor.sendEmail > vendor.email.
CANCELLED guard: a cancelled order is never emailed (early guard + TOCTOU re-check in
the seal and the auto-send). The legacy review-less "send-all" cron is neutralized (it
requires ALLOW_LEGACY_SEND_ALL=1), and auto-sending stale previews is a per-tenant
opt-in (siteSettings.supplierOrders.autoSendAfterHours), off by default.
Previews staged before the fingerprint was introduced carry none: they fail open (send allowed) so the pre-existing queue drains. Only fingerprinted previews can block.
After the send: reception and the auto-validate safety net
After SENT, the order waits for its reception (expectedDeliveryDate ≈ scheduledSendDate + deliveryDays). Two paths seal the physical inflow:
- The merchant's action (primary since #2886): confirming the reception in the UI →
confirmDelivery→SupplierOrderDelivery+ a COMPLETED INCREMENT StockAction (quantity in units = packages ×packageSize). - The auto-validate cron (12:01, safety net): for every SENT/CONFIRMED order without
a delivery whose
expectedDeliveryDate(fallbackscheduledSendDate) is at leastAUTO_VALIDATE_GRACE_HOURS(12 h) in the past, it creates the forgotten delivery. The grace guarantees only deliveries old enough to have actually happened get validated.
The full schedule lives in the daily timeline; the ledger write contract in Write paths.
The life of a customer order
A customer orders on the storefront (PrestaShop or a native order) with a desired delivery date. That date — not the purchase date — is what will drive the supplier send slot.
The order arrives via a PrestaShop webhook (real time) or the 3-hourly sync. A PENDING DECREMENT projection ("virtual outflow") is placed on the ledger — physical stock is not decremented at this point (the decrement-on-sync path was deleted on 2026-06-14).
The producer pushes { tenantId, siteId, orderIds, productIds } onto the bext
supplier-orders-recalc queue. The worker takes the site's supplier write lock — no
cron can write at the same time.
Demand is assigned to the vendor send slot that still allows on-time delivery
(deliveryDate − deliveryDays − 1 spare day). If no remaining slot can arrive on time, the
demand is excluded from upcoming emails (SUPPLIER_SKIP_LATE_DEMAND=true, the Amis de
la Ferme policy — we never order what can no longer be delivered on time), or, for tenants
without the flag, it rolls forward to the next future slot as a late delivery
(SUPPLIER_NO_PAST_SLOTS gate). The slot quantity = demand − trustedStock, rounded up to
the package (packageSize). The DRAFT
item carries the sourceOrderIds and a PENDING INCREMENT projection ("incoming
stock").
The stage cron renders the supplier email into emailPreview — subject, HTML,
recipients, and the items fingerprint (itemsFingerprint). Render-only: nothing
leaves.
The operator reviews the queue, adjusts if needed, and sends. The send re-renders from
current items but blocks if the fingerprint drifted since the review (CONFLICT —
re-prepare, then resend). The order becomes SENT.
The goods arrive. The merchant confirms the reception: SupplierOrderDelivery + a
COMPLETED INCREMENT StockAction (packages × packageSize units) via
recordPhysicalMovement — any standing backorder on the signed ledger is netted, and the
order's PENDING projections are cancelled. Forgotten? The 12:01 auto-validate cron
catches up after the 12-hour grace.
The merchant seals the customer delivery (UI or the bulk "validate today's rounds") → a
COMPLETED DECREMENT StockAction on the signed ledger (stockLedger may go negative =
backorder; Product.stock stays the clamped mirror max(0, ledger)). The loop closes:
the next demand starts from this ledger via trustedStock.
Useful commands
Dry-run both cron engines (no writes):
# Auto supplier orders — verifies the task loads and computes
cd apps/app && bun run cli --task auto-supplier-orders-service \
--tenantId "<tenant>" --siteId "<site>" \
--fromDate "2026-01-01" --toDate "2026-01-02" --verbose
# Reconcile — shows the expected-vs-DB diff without applying it
cd apps/app && bun run cli --task reconcile-supplier-orders \
--tenantId "<tenant>" --siteId "<site>" \
--fromDate "2026-01-01" --toDate "2026-01-15" --verbose
Read-only replay-diff before merging anything that touches the math (rule 17):
bun packages/scripts/src/dev/replay-supplier-orders.ts \
--tenant-id "<tenant>" --site-id "<site>" \
[--vendor-id <uuid>] [--from 2026-05-01] [--to 2026-05-15]
Never trigger a real send to "verify" — that emails a real supplier. On live systems, only verify the stage (render-only), the health probe and the audit.
See also
- Stock & reordering overview
- The stock ledger —
trustedStock, backorders, the clamped mirror - Write paths —
recordPhysicalMovementand the rule-17 contract - Daily timeline — every cron across 24 h
- The weekly cycle — slots,
receptionLeadDays, when to count stock - PrestaShop sync — webhooks and the 3-hourly syncs
- Back office — email review queue, stock pages
- Configuration — the
SUPPLIER_NO_PAST_SLOTS,BEXT_SUPPLIER_RECALC,STOCK_TRUST_*gates - Safety nets — audit, replay-diff, the daily probe