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):

  1. authenticates the bext POST via ?token=<BEXT_QUEUE_TOKEN>;
  2. resolves productIds to their default vendors and unions them with explicit vendorIds;
  3. acquires the site's supplier write lock (the same one the crons take — total serialization);
  4. 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.

Note

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 deliveryDays lead 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:

  1. 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).
  2. Demand too late: no remaining slot can arrive on time. Two policies, keyed on the SUPPLIER_SKIP_LATE_DEMAND flag:
    • 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.

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.

Warning

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 createdAt of 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.

Tip

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:

  • stockApplied comes from trustedStock, the stock ledger's trust boundary: untracked stock → 0, pessimistic kill switch → 0, made-to-order vendor → 0, negatives clamped to 0 (unless the STOCK_TRUST_ALLOW_NEGATIVE gate), and capped at STOCK_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 = suggested stays 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.

Warning

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.

Note

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 (via StockLedgerService.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 that confirmDelivery cancelled.

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):

  1. 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.
  2. 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.
  3. 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.

Warning

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.

Note

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 → confirmDeliverySupplierOrderDelivery + 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 (fallback scheduledSendDate) is at least AUTO_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]
Warning

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