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:
| Field | Meaning |
|---|---|
| movement_type | GOODS_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 / to | warehouse, location and stock status the quantity leaves / arrives at (NULL = outside the company) |
| qty | positive, in the product's base unit (entered qty/unit also stored) |
| batch | batch id (0 = untracked product) |
| reference | document type, id and number (GRN, pick list, dispatch, adjustment…) |
| reason | reason code where applicable |
| user, timestamp | who 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
| Transaction | Source | Destination | Qty | Warehouse on-hand |
|---|---|---|---|---|
| Goods receipt | Supplier | REC-01 (receiving) | +100 | 100 |
| Putaway | REC-01 | A-01-01-01-01 | 100 moved | 100 |
| Pick | A-01-01-01-01 | STA-01 (PICKED) | 20 moved | 100 |
| Dispatch | STA-01 | Customer | −20 | 80 |
This exact scenario is an automated test (tests/integration/InventoryLedgerTest.php).
Stock states
| State | Definition | Allocatable | In on-hand | Valuation |
|---|---|---|---|---|
| On hand | All statuses except IN_TRANSIT | – | yes | yes |
| Available | AVAILABLE status, active pickable STORAGE/PICKING bin, batch ACTIVE and not expired, minus reserved | yes | yes | yes |
| Reserved / allocated | Quantity reserved for order lines (batch-level) | no | yes | yes |
| Awaiting putaway | AVAILABLE in a RECEIVING location | no | yes | yes |
| Picked | Status PICKED in a staging location, awaiting dispatch | no | yes | yes |
| Quarantined | Status QUARANTINE (QC pending, holds, returns awaiting inspection) | no | yes | yes (flagged) |
| Damaged | Status DAMAGED | no | yes | reported as at risk |
| Expired | Batch expiry date < today (any status) | no | yes until written off | reported as loss risk |
| In transit | Status IN_TRANSIT at the destination's TRANSIT location | no | no (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:
- Lock the order and its lines (
FOR UPDATE). - 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).
- 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.
- Lock those rows (
FOR UPDATE) and reserve sequentially until the need is met. - 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:
- every balance row equals the ledger net for its (location, product, batch, status);
- every
qty_reservedequals the sum of open reservations; - no negative or over-reserved balances.
All integration tests assert reconciliation after each scenario.