Inventory ledger, stock states and FEFO

The ledger is the source of truth

Q-Putaway never overwrites a product's stock quantity. Every change is a row in stock_movements:

FieldMeaning
movement_typeGOODS_RECEIPT, PUTAWAY, BIN_TRANSFER, TRANSFER_OUT/IN/LOSS, RESERVE, RELEASE, PICK, UNPICK, DISPATCH, QUALITY_RELEASE/REJECT/HOLD, DAMAGE, RETURN_RESTOCK, RETURN_DAMAGED, CUSTOMER_RETURN, SUPPLIER_RETURN, COUNT_GAIN/LOSS, ADJUSTMENT_IN/OUT, EXPIRY_WRITEOFF_IN/OUT, DAMAGE_WRITEOFF_IN/OUT, OPENING_STOCK, REPLENISH
from / towarehouse, location and stock status the quantity leaves / arrives at (NULL = outside the company)
qtypositive, in the product's base unit (entered qty/unit also stored)
batchbatch id (0 = untracked product)
referencedocument type, id and number (GRN, pick list, dispatch, adjustment…)
reasonreason code where applicable
user, timestampwho and when

Balances (stock_balances) are updated in the same InnoDB transaction, with the source row locked. Posted movements are never edited or deleted. Corrections use adjustments, status changes, returns or (for transfers) explicit loss movements.

Worked example

TransactionSourceDestinationQtyWarehouse on-hand
Goods receiptSupplierREC-01 (receiving)+100100
PutawayREC-01A-01-01-01-01100 moved100
PickA-01-01-01-01STA-01 (PICKED)20 moved100
DispatchSTA-01Customer−2080

This exact scenario is an automated test (tests/integration/InventoryLedgerTest.php).

Stock states

StateDefinitionAllocatableIn on-handValuation
On handAll statuses except IN_TRANSIT–yesyes
AvailableAVAILABLE status, active pickable STORAGE/PICKING bin, batch ACTIVE and not expired, minus reservedyesyesyes
Reserved / allocatedQuantity reserved for order lines (batch-level)noyesyes
Awaiting putawayAVAILABLE in a RECEIVING locationnoyesyes
PickedStatus PICKED in a staging location, awaiting dispatchnoyesyes
QuarantinedStatus QUARANTINE (QC pending, holds, returns awaiting inspection)noyesyes (flagged)
DamagedStatus DAMAGEDnoyesreported as at risk
ExpiredBatch expiry date < today (any status)noyes until written offreported as loss risk
In transitStatus IN_TRANSIT at the destination's TRANSIT locationnono (reported separately)yes

"Reserved" and "allocated" are the same thing in Q-Putaway: allocation creates batch-level reservations.

FEFO allocation algorithm

For each order line still needing quantity:

  1. Lock the order and its lines (FOR UPDATE).
  2. Select balance rows for the product in the order's warehouse where: status = AVAILABLE; free quantity (on hand − reserved) > 0; location active, pickable and of type STORAGE/PICKING; batch not blocked; expiry NULL or ≥ today + required shelf days, where required = max(product.min_dispatch_shelf_days, customer.min_shelf_days).
  3. Order them: expiry ASC (undated last), manufacturing date ASC, batch creation (first receipt) ASC, location pick sequence ASC, balance id ASC. This order is deterministic.
  4. Lock those rows (FOR UPDATE) and reserve sequentially until the need is met.
  5. Write a reservation row and a RESERVE ledger marker for each batch, and an audit entry with the detail.

FIFO (per product or as the default) orders by batch first-receipt time, then manufacturing date.

Enforcement downstream: pick confirmation requires the allocated batch (setting inventory.pick_verify_batch). Dispatch re-checks block status, expiry and remaining shelf life. Expired stock can never be restocked from returns.

Tests: tests/integration/FefoAllocationTest.php (prompt example B001/B002/B003, exclusions, shelf life, tie-break, FIFO, no over-reservation, de-allocation) and tests/concurrency/race.php (8 simultaneous allocations).

Expiry management

  • Alert windows: inventory.expiry_alert_days (default 7,15,30,60,90). Notifications are deduplicated per batch per window, and generated daily by cron.
  • Near-expiry dashboard window: inventory.near_expiry_days (default 30).
  • Expiry on a posted receipt is never silently changed. Authorised corrections are audited (old/new values and reason).
  • Expiry losses are reported from EXPIRY_WRITEOFF and DAMAGE movements valued at standard cost.

Reconciliation

Inventory → Movement Ledger → Reconcile (and InventoryService::reconcile()) checks:

  1. every balance row equals the ledger net for its (location, product, batch, status);
  2. every qty_reserved equals the sum of open reservations;
  3. no negative or over-reserved balances.

All integration tests assert reconciliation after each scenario.