Design Rationale — Inventory
Non-obvious design choices for the inventory module — the WHY behind each decision.
Inventory (locked 2026-06-09)
Vertical-neutral item: item_type discriminator + JSONB attributes
Decision: One item table with item_type (plant / hard_good / service, extensible via config) and a JSONB attributes column for type-specific data. No vertical-specific columns on item.
Why: Generic-first — plants, tools, hardscape, services are all inventory items that differ in attributes, not schema structure. A single query path handles all item types.
Rejected: Per-vertical extension tables across schemas (e.g. nursery.plant_detail joined to inventory.item) — multi-schema join complexity for no v1.0 benefit; the generic-core/extension model was explicitly retired.
Guard: Plant-specific data lives in item.attributes JSONB keyed by item_type = 'plant' — not in a plant_* column on item. Graduation from JSONB to a real column happens only when query performance demands it, not speculatively.
The sellable/stocked unit is item_variant, not item
Decision: Stock, pricing, cost, and barcode attach to item_variant. item is the concept (the product); item_variant is the sellable unit (the SKU).
Why: A single product has multiple sellable variants (a plant in 1-gal, 3-gal, 5-gal containers; a tool in two colors). Stocking and pricing at the item level cannot represent per-variant inventory or per-variant pricing.
Rejected: Item-level stock — can't represent variants with different stock levels without adding a variant layer anyway.
Immutable stock_movement + maintained balance + idempotency_key
Decision: Every stock change creates an immutable stock_movement header + stock_movement_line rows. stock.on_hand_qty is a maintained balance. stock_movement.idempotency_key is a dedup key carried from the calling module (POS or Purchasing).
Why: Full audit trail + fast balance reads without re-summing history. The idempotency key ensures that a retried POS sync or Purchasing receipt does not double-move stock — POS and Purchasing each carry the same key across retries, which InventoryService checks before inserting.
Guard: This is the maintained-cache + immutable-ledger pattern (see Cross-Cutting Patterns). Keep both the balance and the movement log. The idempotency_key on stock_movement is the seam that makes offline POS safe — do not remove it.
Weighted-average cost on variant; lot detail separate
Decision: item_variant.cost_cents is a maintained weighted-average cost. Lot-level cost detail lives in stock_lot / stock_movement_line. These are separate concerns.
Why: Costing needs a fast current-cost number for margin calculation at POS and purchasing. Lot traceability is a separate workflow (expiry, recall, source tracking). Bundling them would make the common case (current cost) pay the cost of the uncommon case (full lot drill-down).