inventory — overview
Stock list with cost tracking, movement ledger, audit trail and consumable auto-deduction (roadmap #220, core upgrade #226).
Per-item minimum quantities, atomic SELECT … FOR UPDATE row-locked stock changes, an append-only stock_movements ledger (the audit trail), unit_cost with a valuation endpoint, and automatic deduction of linked consumables when a treatment is performed via subscription inversion (#226).
What it is
Clinic-scoped CRUD over the InventoryItem list plus several special endpoints:
POST /{item_id}/adjustapplies a relative stock change (+deltarestock /-deltaconsumption) through_apply_movement(the single write path): aSELECT … FOR UPDATErow lock, a Python-arithmetic floor check, and an append-onlystock_movementsrow in the same transaction.PATCH /{item_id}with an absolutestock_quantityis a correction — the delta lands in the ledger too.GET /valuationtotals on-hand value over active items with a knownunit_cost.GET /{item_id}/movementsreturns the full audit trail with resolved actor names.
Concurrency (the PR #153 post-mortem): quantity changes go through SELECT … FOR UPDATE row locking — the DB arbitrates, never app code. Two concurrent adjustments serialise at the row level; neither can drive stock negative (CHECK constraint) nor lose an increment (race-safe delta arithmetic).
Auto-deduction (subscription inversion #226)
inventory exposes apply_consumption as a clean public primitive that accepts pre-resolved (item_id, quantity) links. The treatment_consumables module owns the links table, resolves links with its own ORM model, and calls apply_consumption — no raw SQL, no inspector guard, no fail-soft branch. Duplicate deductions for the same treatment are silently ignored via a partial unique index (uq_stock_movements_consumption_ref) and ON CONFLICT DO NOTHING.
Audit trail
Every quantity change — opening stock, manual adjustments, absolute-set corrections, auto-deductions — is recorded in the append-only stock_movements ledger with reason, note, business reference and actor. The ledger sums exactly to on-hand stock. Items with ledger history can no longer be hard-deleted (409); they are deactivated instead (is_active).
Low-stock model
Each item carries a min_quantity threshold. An item is low when stock_quantity <= min_quantity (computed property on the model; also filterable server-side via ?low_stock=true). The inventory.low_stock event fires once per not-low → low crossing.
Data model
inventory_items—id,clinic_id,name,category(consumables|equipment|office|other, closed Literal set stored asString(50)so adding categories later is code-only),unit,stock_quantitynumeric(12,2) with CHECK >= 0,min_quantitynumeric(12,2),unit_costnumeric(12,2) nullable,is_activeboolean (default true),notes(nullable),created_by(nullable FKusers.id), timestamps.stock_movements— append-only ledger:id,clinic_id,inventory_item_idFK,deltanumeric(12,2),reason(initial/restock/consumption/adjustment/correction),note,reference_type/reference_id(loose business reference),created_byFK,created_at. Partial unique indexuq_stock_movements_consumption_refon(reference_type, reference_id, inventory_item_id) WHERE reason = 'consumption'for idempotent auto-deduction.
Dependencies
manifest.depends = [] — fully standalone. FKs point only at core tables (clinics, users). treatment_consumables FKs into this module; the reverse subscription is handled by treatment_consumables via subscription inversion.
Tenancy
Every query, mutation and agent tool filters by the caller's clinic_id; cross-clinic access surfaces as 404.
Lifecycle
installable=True, auto_install=False (activated from the module admin UI), removable=True. Own Alembic branch (inventory) rooted on core "0001". Uninstall round-trip covered by test_uninstall_roundtrip.py.