Skip to content

Stock and money today

This page is the living summary of the BRV-01 baseline (evidence: improvement-plans/BRV-01-Domain-Baseline.md). It describes the code as it is, not as it should be. Each Stage 2 ticket that changes a row here updates this page in the same pull request; when the reservation and movement model lands, this page becomes the description of that model.

Every change to stock is an inventory_movements row written by InventoryLedger (ADR-011): batch, flower, kind (receipt, receipt_correction, receipt_reversal, opening_balance, reservation, release, consumption, write_off, supplier_return, recovery), signed physical and reserved deltas, unit cost, order and allocation references, actor, reason and a unique action key. The projections are updated in the same transaction:

Projection Meaning
supply_items.quantity_remaining On hand: physical stems in the batch
supply_items.quantity_reserved Stems promised to confirmed orders, not yet consumed
flowers.stock_quantity Physical total across the flower’s batches

Free stems are on hand minus reserved. supply_items.status is active, review (unknown expiry or legacy origin; kept on hand, never allocated automatically) or quarantined.

Entry point Ledger action Notes
Create supply receive per batch Receipt of quantity; idempotent per batch
Edit supply, quantity changed correctReceipt Refused below what is reserved; a batch cannot change flower
Edit supply, expiry or status changed none (metadata) Refused when it would strand reservations, naming the orders (BRV-09)
Write off / return stems writeOff / supplierReturn Per batch, with reason; refused below reserved; Idempotency-Key honoured (BRV-09)
Remove batch or delete supply reverseReceipt + soft delete Refused once stems are reserved or consumed
Create, edit, delete a pending order none Rule B01
Confirm order (pending → confirmed) reserve per allocation OrderReservations::reserve(): lock order, flowers by id, eligible batches FEFO; recheck; all-or-nothing; order_allocations rows in state reserved
Start assembly (confirmed → in_progress) consume per allocation (physical −qty and reserved −qty in one movement) OrderConsumption: rechecks every reserved batch first; allocations move to consumed with the batch unit cost
Cancel a confirmed order release per allocation Allocations move to released; consumed allocations of an assembled order stay consumed
Amend a confirmed order release all, then reserve for the new revision, one transaction OrderReservations::reallocate(); a shortage rolls back both, leaving the previous reservations
Flower form none Stock is read-only on the form
Flower::consumeStock() consume per batch, FEFO Kept for BRV-07, which replaces it with allocation-based consumption
php artisan orders:reserve {id} same as confirm Operator command; also the independent connection the race suite uses

order_allocations now holds reservations (state reserved; consumed and released follow BRV-07/08) with the batch unit cost at reservation. idempotency_keys records confirmations keyed by workspace, scope and key with a fingerprint of order id and version. order_transitions records every status change (from, to, actor, reason, order version, action key) since BRV-07.

Every stock writer takes row locks in the same order: the order row, then flower rows ascending by id, then batch rows in FEFO order; balances are rechecked after locking; the transaction retries up to three times on deadlock. The race suite in tests/Race/ runs its contenders as separate processes with their own connections on committed fixtures. A lock holder (tests/Race/lock-holder.php) first takes the contested flower rows FOR UPDATE, keeps them until it sees both contenders queued on locks in pg_stat_activity (watched from a second connection, because that view is a per-transaction snapshot), then commits, so every case proves the contenders really competed. Each test asserts that observation (at least two waiters), and the holder fails the test if it times out without seeing them. The suite asserts exactly one winner when stock cannot satisfy both, both winners when it can (opposite line order), one application when both carry the same idempotency key, a single outcome for edit or cancellation against assembly, and no oversell when a write-off races a confirmation. Until 4 October 2026 the contenders only shared a start time, which made contention likely but never proved it (S2-F06).

Terminal window
php artisan inventory:reconcile [--workspace=slug] # read-only; exit 1 on any disagreement

Compares movement sums with each batch’s on-hand and reserved figures, active allocations with reservations, flower totals with their batches, and each order’s reserved or consumed quantities with its current revision’s requirement. Findings are either corruption (the run fails) or operational exceptions (reported): reserved stems on an expired, quarantined or removed batch or on one that expires before the order’s delivery date, review and unknown-cost batches, legacy confirmed orders awaiting their first reservation (order_unreserved), orders needing reallocation. A confirmed order with no allocations that is not legacy is corruption (order_reservations_missing). The same checks feed the manager-only Attention page (/control/inventory/attention), which lists the affected orders and batches with a “Reserve again from eligible batches” action on each order. --json gives the structured report. There is no automatic repair: a wrong figure is corrected with a compensating movement, never by overwriting a projection.

order_revisions holds one row per agreed version of an order: number (equals orders.version), actor, reason, provenance (recorded by the application, or migrated when reconstructed from present data at introduction), status at the time, currency, amounts, delivery date and slot, and a JSON snapshot of the agreed header and lines. order_revision_items snapshots each line’s product name, SKU, quantity, unit and total price and packaging (copied from the order line’s packaging snapshot since Stage 3A); order_revision_components expands every line into flowers with quantity per item, total required quantity and the flower’s estimated unit cost at that moment. OrderRevision::requiredFlowers() aggregates the requirement per flower across the whole order, which is what allocation (BRV-06) consumes. Revisions are never updated. php artisan orders:backfill-revisions is idempotent.

Path Behaviour
Order item unit_price is the current catalogue price; total_price = quantity × unit_price. No manual line price through the UI
Order totals Since BRV-03: OrderTotals gives subtotal = Σ (unit + packaging) × qty (packaging since Stage 3A), total = subtotal + delivery fee, tax and discount zero, exact minor-unit arithmetic. Recomputed on create and on every edit of an amendable order; frozen orders keep agreed amounts. Legacy totals are listed by php artisan orders:report-totals
Custom bouquet Created on the fly with its composition; price is the exact Money sum of components (BRV-03); costed from components like any bouquet, unknown when a component lacks cost (BRV-10)
Cart Since BRV-03: integer minor units in the workspace currency with the currency’s exponent; the payload carries currency. Orders snapshot currency at creation
Client input Totals and line prices are never accepted from the browser. Correct principle, wrong arithmetic
Display The browser formats with Intl.NumberFormat (the storefront SDK’s formatMoney, the control panel’s useCurrency). Anything rendered on the server for people, the checkout emails, uses Money::format(): ICU in the notification’s locale, the narrow symbol from Currency::symbol(), no decimals when the amount is whole. Display text is never parsed back

OrderCosting::summary() gives the order page, for managers and above, the estimated figure (from the current revision’s components and their cost at revision time) and, once stems were consumed, the actual figure (from consumed allocations at the batch cost recorded then). CostFigure carries the known part, whether it is complete and which components lack a cost; an incomplete figure is shown as unknown. MaterialMetrics turns merchandise revenue (the subtotal; the delivery fee is not merchandise revenue) and the cost into material profit, material margin and markup with exact minor-unit arithmetic and one rounding at presentation; undefined cases are null. Cancelled assembled orders show the consumed cost as a loss. All figures exclude labour and delivery and are labelled so. OrderCosting::figures() gives the same figure for many orders at once; Finance sums it over a period (ADR-018).

Any status can be set to any other by a user with orders.edit. Staff may only move forward through pending → confirmed → in_progress → ready → delivered. No transition has a business effect except cancellation. The target model is on Order lifecycle.

Inherited defects and where they are fixed

Section titled “Inherited defects and where they are fixed”

Reproductions live in tests/Feature/Baseline/InheritedDefectsTest.php, marked todo until the owning ticket lands; that ticket removes the marker so the reproduction becomes the regression test.

ID Defect Ticket
D-01 Delivery fee missing from the total fixed, BRV-03
D-02 Editing items neither releases nor reserves stock fixed, BRV-05 (pending orders touch no stock)
D-03 Deleting a cancelled order releases stock twice fixed, BRV-05
D-04 Orders never touch batches fixed, BRV-06/07
D-05 Flower form sets stock directly fixed, BRV-05
D-06 Supply edit can under-run consumption or move a batch between flowers fixed, BRV-05 (corrections refused below reservations; flower change refused; expiry and cost edits still free until BRV-09)
D-07 Custom bouquet cost is 0 fixed, BRV-10
D-08 Cart currency hard-coded; orders have no currency fixed, BRV-03
D-09 Status changes carry no business effect fixed, BRV-07

The local database (seeder fixtures plus hand-made test data, no real shop) shows the consequences: 13 of 17 flowers have a total that differs from their batches, mostly flowers created through the form with no batch at all, and 5 of 6 orders have a total short by their delivery fee. No production data exists yet, so the Stage 2 cutover rehearses on a copy of this database.