Inventory safety — Tier 1¶
Backend domain: app/graphql/inventories/, app/graphql/stock_reservations/
Migration: add_inventory_safety_tier_1
RBAC Path / Resource: STOCK_RESERVATIONS
Problem / motivation¶
The inventory module has solid plumbing (multi-location, lot/serial, FIFO/LIFO) but no safety rails on the write path. Three correctness gaps stand out and are bundled here because they touch the same hot path in InventoryRepository.update_quantity and InvoiceEventHandler.post_insert / post_update:
- Overselling. Invoices deduct without checking availability. There is no reservation layer, so two concurrent invoices can both draw from the same unit.
- Silent negative stock.
update_quantity()happily drivesinventory.quantitybelow zero.CostingService.consume()then back-fills cost fromItem.last_unit_cost, masking the problem in COGS. - Location/lot opacity in invoicing. Invoices always deduct from the default location regardless of where the goods actually shipped from. Lot-tracked items can't honour lot assignment per line.
Tier 1 also adds the missing inventory_logs(source_id) index so the reversal path on invoice update/void stays O(log n).
In scope¶
- New table
stock_reservationsplus a full resource (model, repo, service, GraphQL). - New column
items.allow_negative_stock(defaultsfalse) plusInsufficientStockErrorraised insideupdate_quantity(). - New columns
invoice_details.location_idandinvoice_details.lot_id(nullable; fall back to default location). - New index
ix_inventory_logs_source_id. - New
Path.STOCK_RESERVATIONS/Resource.STOCK_RESERVATIONSRBAC enums plus a backfill script. - Threading of
location_id/lot_numberfromInvoiceDetailInput→InvoiceFactory.to_event→InvoiceDetailEventDTO→InvoiceEventHandler→InventoryRepository.update_quantity.
Out of scope¶
- Order-time reservations (orders still don't reserve — reservations are a primitive other features will build on).
- Cycle counts, transfers, reorder automation (Tier 2).
- Approval workflow for adjustments (Tier 3).
- Migrating existing
inventory.quantity < 0rows — operators must reconcile manually.
Data model changes¶
| Table | Change | Why |
|---|---|---|
stock_reservations |
new | Soft reservations: inventory_id, source_type, source_id, quantity, status (ACTIVE/CONSUMED/RELEASED/EXPIRED), location_id, expires_at. |
items |
+ allow_negative_stock BOOLEAN NOT NULL DEFAULT false |
Per-item policy switch. Defaults false so existing rows immediately get the safety net. |
invoice_details |
+ location_id UUID NULL FK inventory_locations.id, + lot_id UUID NULL FK inventory_lots.id |
Per-line dimension support. Null means "use default location". |
inventory_logs |
+ ix_inventory_logs_source_id |
Reversal queries (delete_inventory_logs(source_id)) currently scan; this fixes O(n). |
Alembic revision: add_inventory_safety_tier_1, down_revision add_unit_cost_snapshot.
A separate concern bundled into this release (not a migration — a one-off script): the original 20250702_rev1.py declared inventory.reorder_level / reorder_quantity / lead_time / min_stock / max_stock as UUID. The revision is patched in place so fresh tenants get INTEGER; existing tenants run scripts/fix_inventory_reorder_column_types.py to convert. These fields are unused, so the cast is safe.
GraphQL surface¶
New queries¶
stockReservationsByItem(itemId: UUID!, locationId: UUID): [StockReservation!]!
stockReservationsBySource(sourceType: String!, sourceId: UUID!): [StockReservation!]!
availableQuantity(itemId: UUID!, locationId: UUID): Decimal!
availableQuantity returns on_hand − sum(active_reservations) for the resolved location.
New mutations¶
reserveStock(reservation: StockReservationInput!): StockReservation!
releaseStockReservation(reservationId: UUID!): StockReservation!
StockReservationInput: itemId, quantity, sourceType (free-form string the caller owns — e.g. "quote", "order"), sourceId, locationId?, expiresAt?.
Changed inputs¶
InvoiceDetailInput: new optional locationId: UUID, lotId: UUID.
InvoiceDetailResponse: new fields locationId, lotId.
ItemInput / ItemResponse: new field allowNegativeStock: Boolean! (defaults false).
New enum¶
StockReservationStatus: ACTIVE | CONSUMED | RELEASED | EXPIRED.
Behaviour rules¶
- Negative stock check. Every call into
update_quantity()with aquantity < 0computesprojected = inventory.quantity + quantity(in the resolved location). Ifprojected < 0anditem.allow_negative_stock = false, raiseInsufficientStockError(message). Available stock excludes active reservations for negative draws originating outside the source that owns those reservations — invoices created from an order consume that order's reservations first. - Reservation lifecycle.
reserveStockwrites anACTIVErow.releaseStockReservationflips it toRELEASED. Reservations are advisory (not enforced as DB-level locks);availableQuantityand the negative-stock guard read them. - Per-line location/lot. When an invoice line carries
location_id, the deduction targets that location's inventory row. Same forlot_id(resolved tolot_numberfor the repository call).nullkeeps the existing default-location behaviour. - Reversal.
delete_inventory_logs(source_id)already restores quantities; with the new index it stays fast and unchanged.
RBAC¶
- New
Path.STOCK_RESERVATIONSandResource.STOCK_RESERVATIONSenums. - Backfill:
scripts/backfill_stock_reservations_path_permissions.pyaddsPathPermission(role, path=STOCK_RESERVATIONS, can_view=True)for every role of every tenant.
Tests¶
tests/graphql/inventories/test_negative_stock_guard.py— guard raises whenallow_negative_stock=False, allows whenTrue, applies per-location.tests/graphql/stock_reservations/test_stock_reservation_repository.py— create, release, list-by-source, available-quantity arithmetic.
What's being implemented¶
New files:
alembic/versions/20260524_add_inventory_safety_tier_1.pyapp/graphql/stock_reservations/__init__.pyapp/graphql/stock_reservations/models/__init__.pyapp/graphql/stock_reservations/models/stock_reservation.pyapp/graphql/stock_reservations/repositories/__init__.pyapp/graphql/stock_reservations/repositories/stock_reservation_repository.pyapp/graphql/stock_reservations/services/__init__.pyapp/graphql/stock_reservations/services/stock_reservation_service.pyapp/graphql/stock_reservations/strawberry/__init__.pyapp/graphql/stock_reservations/strawberry/stock_reservation_input.pyapp/graphql/stock_reservations/strawberry/stock_reservation_response.pyapp/graphql/stock_reservations/queries/__init__.pyapp/graphql/stock_reservations/queries/stock_reservation_queries.pyapp/graphql/stock_reservations/mutations/__init__.pyapp/graphql/stock_reservations/mutations/stock_reservation_mutations.pyscripts/backfill_stock_reservations_path_permissions.pytests/graphql/stock_reservations/__init__.pytests/graphql/stock_reservations/test_stock_reservation_repository.pytests/graphql/inventories/__init__.pytests/graphql/inventories/test_negative_stock_guard.py
Modified files:
app/core/exceptions.py—InsufficientStockError.app/graphql/items/models/item.py—allow_negative_stockcolumn.app/graphql/items/strawberry/item_input.py/item_response.py— surface the field.app/graphql/inventories/repositories/inventory_repository.py—get_available_quantity(), negative-stock guard inupdate_quantity().app/graphql/invoices/models/invoice_detail.py—location_id,lot_idcolumns.app/graphql/invoices/strawberry/invoice_detail_input.py/invoice_detail_response.py— surface the fields.app/graphql/invoices/factories/invoice_factory.py— thread location/lot into_event.app/events/common/models/detail_event_dto.py—location_id,lot_idfields.app/graphql/invoices/events/invoice_event_handler.py— pass location/lot toupdate_quantity.app/graphql/rbac/models/models.py—Path.STOCK_RESERVATIONS,Resource.STOCK_RESERVATIONS.mkdocs.yml— wire this page.
Frontend contract¶
- Item form — new checkbox "Allow selling below zero stock" (defaults off). Tooltip: "When enabled, this item can be invoiced beyond on-hand quantity."
- Invoice line editor — optional Location picker per line (defaults to invoice-level location or Main) and, for LOT/SERIAL items, a Lot picker fed from
getInventoryLotsByItemId. Both omit on legacy invoices. - Invoice creation error handling — surface
InsufficientStockErroras a per-line validation error referencing the item name and available qty. - Stock reservation API —
reserveStock/releaseStockReservationare exposed but not auto-consumed in this tier; downstream quote/order screens can opt in when ready. - Available qty hint — invoice/quote line editors can call
availableQuantityto show "X available" inline.
Future additions¶
- Auto-consume reservations on invoice creation when the invoice was generated from a reserved order (Tier 2 — needs the order workflow to opt into reservations first).
- Expire stale
ACTIVEreservations via a nightly taskiq job; theexpires_atcolumn is in place. - Stock transfers, cycle counts, reorder automation, approval workflows (Tiers 2–3).