Imported from iv-zhang/BMRC-Logistics (
.claude/skills/bmrc-domain/SKILL.md). Install upstream withnpx skills add iv-zhang/BMRC-Logistics --skill bmrc-domain. Copyright stays with the author.
BMRC Domain Model & Invariants
This app tracks EMS medical supplies. Wrong stock math means a crew opens a bag
in the field and the epinephrine isn't there, or is expired. The invariants
below are enforced by an emulator test suite (npm run test:invariants,
INV-1 … INV-12) and by design decisions baked into app/lib/. Never regress
them, and never re-implement the math they protect — import it.
Single sources of truth (import, never re-implement)
| Question | Answer lives in |
|---|---|
| How much stock does this item have? | computeBagStock() — app/lib/item-status.ts |
| What status chip does it get? | getItemStatus() — app/lib/item-status.ts |
| Where is it? (display string) | displayLocation() — app/lib/item-status.ts |
| Is it an asset? | determineIsAsset() — app/lib/inventory.ts |
| Is it audit-verified this cycle? | isAuditedThisMonth() / isStatpackAuditCurrent() — app/lib/item-status.ts |
| Thresholds / expiry windows | getThresholds() — app/lib/org-config-store.ts (see the bmrc-org-config skill) |
If a page shows a count or a status chip computed any other way, that is a bug.
The inventory page, audit page, and dashboard must all agree because they all
import from item-status.ts.
The two stock models
InventoryItem (defined in app/types.ts) supports two mutually exclusive
tracking modes. computeBagStock() picks the mode automatically:
- Bag/lot-tracked — any batch in
item.batches[]hasbagCount > 0oritemsPerBag > 0. Stock lives per batch:bagCount * itemsPerBag + looseItems. Batches carrylotNumber,expirationDate,status('quarantined'for recalls). Batches are the source of truth. - Box-tracked — item-level
unopenedBoxes,itemsPerBox,looseUnits. Stock pools on the item; batches exist only as zero-stock "tombstones" that preserve the lot/expiry paper trail for shipments.
Physical vs. available — the most important distinction in the codebase
computeBagStock() returns both:
totalItems— physical on-hand: everything on the shelf, including expired and quarantined lots. Inventory/audit UIs showing "what is physically here" use this.availableItems— deployable: has stock AND not pastexpirationDateAND notstatus === 'quarantined'. Every readiness, low-stock, out-of-stock, buy-list, or pack-fill decision uses this — nevertotalItems. An item whose only lot is expired must readout, notok(INV-6, INV-7).
Status precedence
getItemStatus(): expired > out > low > expiring > ok.
expiredonly if a batch with stock (batchHasStock()) has a pastexpirationDate. Zero-stock tombstone batches must never flag an item expired forever (this was a real bug — see DATA-7 comment in the file).- Oxygen items (
isOxygen) skip stock statuses (they're PSI-tracked assets). lowwhen0 < availableItems <= item.reorderThreshold.expiringwindow comes fromgetThresholds().expirationWarningDays.
Location model (structured is truth, legacy is mirror)
item.storageLocation: StorageLocationRef ({ zoneId, zoneName, shelfId, shelfName, level, containerId, containerName }, app/types.ts:83) is the
single source of truth for where an item lives. Legacy location / room
and asset currentLocation are denormalized mirrors, synced FROM the
structured ref — never the reverse. Invariants (all enforced in
app/lib/audit-actions.ts and the Storage Management editors):
- Every move goes through
moveItemLocation()/moveItemsBulk(). They resolve the destination zone doc (storage_zones/{zoneId}) and writelocation,room, andcurrentLocationto match — so legacy filters still find a moved item. Never writestorageLocationwith a rawupdateDoc. - A move to a
zoneIdwith no zone doc is rejected with a throw (INV-12). Never silently write a phantom location. - Renaming a zone/shelf/container propagates the new denormalized name to every
referencing item (batched query on
storageLocation.{zoneId|shelfId|containerId}). - Deleting a shelf/container clears dangling refs on affected items first.
StorageZone.level?: 'upper' | 'lower'is the building floor. Zones are edited in/storage, not/settings.
Asset model
determineIsAsset()(app/lib/inventory.ts:1499) treats an item as an asset on any asset signal: serial, status, asset category,assets[],maintenance_logs,isOxygen— not justassetValue ≥ threshold.- Assets are status-tracked, never counted. In pack check-offs, entries
with
serialNumber/assetInstanceIdare excluded from quantity math. InventoryItem.isTrainermarks training gear (trainer AEDs, manikins) — still an asset, but filtered out of every deployable/readiness view.
Two-pool stock model (back reserve / front shelf)
A different axis from bag- vs. box-tracking above: every consumable also splits into two pools.
- Back reserve — the item's batch/box counts (
batches[], orunopenedBoxes/looseUnits). This is whatcomputeBagStock().availableItemsreturns and the only pool that drivesgetItemStatus(ok/low/out/ expired/expiring) and reordering. An item with a full front shelf but an empty back room still correctly readsout— the shelf is never a substitute for reserve in that math, on purpose (see the comment atgetItemStatusinapp/lib/item-status.ts). - Front shelf —
InventoryItem.shelfQuantity, the deployed pool members actually grab from day to day. It is deliberately not event-tracked: general members won't reliably log every unit they take, so instead of instrumenting consumption, roughly weekly someone physically counts the shelf and that count re-anchorsshelfQuantityto reality.lastShelfCheckAt/lastShelfCheckBystamp the check;isShelfCheckCurrent()(app/lib/item-status.ts) tests it againstgetThresholds().shelfCheckIntervalDays(default 7). refillShelf()(app/lib/restock-actions.ts, pool math inapp/lib/stock-pools.ts) is a transfer, never a stock creation. It moves units reserve→shelf viaconsumeReserveUnits(FEFO, loose-before-breaking-bags/boxes, clamped to what reserve actually has) and either incrementsshelfQuantity(plain refill) or, whenobservedShelfQtyis passed, SETS it toobservedShelfQty + consumed(the weekly re-anchor). A check can also run with no transfer at all (qty: 0+observedShelfQty) to record a count without touching reserve. UI:RefillModal/ShelfSweepModalinapp/restock/page.tsx.- Do not conflate this with the deferred class-use vs. field/event stock-pool
gap (D-11, "Known open design gaps" in
CLAUDE.md). That gap is about which reserve a draw comes from; this split is about deployed-but-uncounted vs. counted-and-available stock within a single reserve.
Audit cycles
- Supplies: monthly calendar cycle. An item is verified only if
lastAuditDatefalls in the current calendar month (isAuditedThisMonth). The stickyauditVerifiedboolean is meaningless across months — never trust it alone. - Statpacks: biweekly.
isStatpackAuditCurrent()checks age againstgetThresholds().statpackAuditIntervalDays.
Firestore conventions
Collections: inventory (the central collection — consumables, assets, oxygen
and medications are all inventory docs discriminated by flags; there is
no separate assets collection), inventory_logs, inventory_alerts,
statpacks, statpack_logs, vehicles, vehicle_logs, restock_shelves,
restock_shelf_events, restock_actions, restock_reports, auditEvents
(camelCase — the audit ledger, written by app/lib/audit.ts), issue_reports,
buyList (camelCase — not buy_list), tasks, users, storage_zones,
shelves, containers, box_logs, medication_logs, org_settings,
laf_records, reconciliation_exceptions, events (+ teams[]),
shift_requests, notifications. Shapes are in MODEL.md.
Rules that bite:
- Firestore rejects
undefinedfield values. Wrap every write payload inremoveUndefined()/deepRemoveUndefined()fromapp/lib/audit.ts. - Timestamps hydrate to
Dateon read. Firestore returnsTimestampobjects; the pure helpers (computeBagStock,getItemStatus, …) expectDate. Every read path must call.toDate()(seehydrate()inscripts/emulator/harness.tsfor the canonical deep-conversion). - All reads are real-time
onSnapshotlisteners — there is no REST layer. - Meaningful writes are triple writes: the domain change + a log row
(
inventory_logs/statpack_logs/ …) + anauditEventsledger entry, so usage metrics stay derivable. See the bmrc-audit-workbench skill. - Multi-doc consistency uses Firestore transactions or
writeBatch(500-op limit);logStatpackCheckOffis the reference transaction.
The Tier-1 invariant contract (INV-1 … INV-12)
Tested by scripts/emulator/invariants/inv-XX.test.ts. Any change touching
stock, lots, or locations must keep these green (npm run test:invariants):
- Over-consumption is refused, never underflowed (no negative stock).
- Lot-sum equals total on-hand.
- A second lot does not overwrite the first.
- A dated SKU cannot receive a lot with no expiration.
- FEFO — draws take the earliest-expiring lot first.
- Expired lots are excluded from available quantity.
- A recall quarantines a lot everywhere and flips affected packs.
- Readiness is derived and conservative (never hardcoded green).
- A below-par SKU appears on the buy list only once.
- Class/training draws do not deplete the field pool.
- Controlled-substance receipt is LAF-gated and logged.
- A scan to an unknown location code is rejected.
Before you ship a data-touching change
- Does any new count/status math live outside
item-status.ts? Move it there. - Did you write
storageLocationwithout syncing mirrors? UsemoveItemLocation. - Did you use
totalItemswhere a decision neededavailableItems? - Run
npm run test:invariants(andnpm run test:propertiesif you touchedcomputeBagStock/getItemStatus). See the bmrc-testing skill.