Inventory Item Tracking (QR/Floor-Plan)
Physical tracking at data-driven granularity: GS1 scanning, granularity ladder, floor-plan lens, item passport, RTLS socket.
Status: Design (grounded by a 2-agent audit 2026-06-10); T0 scan core SHIPPED + sandbox-verified 2026-06-10 (see §4) Extends:
inventory-ai-billing-homogenization.md(this is Plane 6 — physical tracking on the same movement spine) Companions:realtime-inventory-supply-chain.md(stream substrate),hospital-movement-architecture.md(custody handoff),patient-qr-self-service-pattern.md(QR token pattern),bed-board-ehr-suite.md(floor plan)Read before any item-QR / scan-driven-stock / inventory-on-floor-plan / RTLS / asset-tracking work.
1. The question this answers
“Where is each item physically, who touched it, and can I scan anything to act on it?” — per-location tracking integrated with the floor plan, QR generation at the right granularity, scan-driven receive/issue/transfer, a full usage history per item, and a socket for BLE/UWB/RFID later. The core insight from the audit: almost every building block already exists — what’s missing is the integration layer. This design adds thin bridges, not new subsystems.
2. What the audit found (2026-06-10)
Already shipped and reusable
| Capability | Where | State |
|---|---|---|
| 3-in-1 scan dialog (camera html5-qrcode + USB HID wedge + manual) | web/packages/medical-kit/src/scanning/ScanDialog.tsx (+ Electron native HID web/electron/native/scanner.ts) |
✅ proven in inpatient flows |
| Scan-receive reference flow (state machine, audit rows, 3 input methods) | blood bank ScanReceivePage + blood_request_pickups audit |
✅ the pattern to clone |
| Per-thing location tracking reference | blood bank: blood_storage_locations / blood_inventory_movements / blood_bag_locations (current-location denorm) |
✅ the unit-tracking shape to generalize |
| Sticker/label printing with QR | specimen-sticker/SpecimenStickerDialog.tsx + print log audit; qrcode.react/jsbarcode installed |
✅ |
| QR token pattern (mint/verify/scope) | patient-qr-self-service-pattern.md |
✅ |
| Movement history substrate | stock_movement_log (actor, WAC, before/after, patient HN, refNo, qrcode field on StockTransaction) + billable_ledger.origin_ref |
✅ live since P0–P2 |
| Location hierarchy | Mongo Store → StoreRoom → StoreShelf, StockItem.handlingStores[].binLocation (string), InventoryLocation (Odoo) |
✅ hierarchy, ❌ no spatial fields |
| Floor-plan spatial substrate | floor_plan_locations (coordinates JSONB {x,y,w,h}, type incl. equipment), entity_locations (generic entity→location hub: patient/equipment/request/staff), bed_floor_plan_mappings (the bed↔plan bridge pattern), floor_plan_drawings |
✅ tables exist, ⚠️ partially consumed |
| Barcode config modules | foundation stockBarcodeDepartment / stockBarcodeOpen (dept routing prefixes, unit barcode catalog) |
✅ dormant |
| Vision AI scan (YOLO label reading) | blood-bag scan + pharmacy verify viewport | ✅ optional enhancer |
| 3D | three + @react-three/fiber + drei installed, zero usage; floor plans today are 2D CSS grid |
⚠️ socket exists |
| RTLS/BLE/RFID | nothing anywhere in repo | ❌ greenfield |
The actual gaps
- No QR generation for stock (items/batches/units) — only consumption-side scanning exists.
- No scan-driven stock transactions (scan → receive/issue/transfer) for general inventory.
- No link from
Store/StoreRoom/StoreShelftofloor_plan_locations(beds have a bridge table; stores don’t). - No serialized unit tracking outside blood bags (implants, pumps, endoscopes).
- No “item passport” UI (history of one thing across movements + charges + alerts).
- No RTLS ingestion path.
3. Design
3.1 Tracking granularity is DATA, not architecture (the “per item or other options” answer)
One ladder, chosen per item via the existing StockItem.trackingType field (+ batchMandatory), never a global mode:
| Level | Granularity | QR identifies | For | Mechanism |
|---|---|---|---|---|
| L0 | item × location | nothing physical (no sticker) | low-value bulk consumables (gauze, syringes) | today’s stock_balance_cache — unchanged |
| L1 | batch/lot (default for trackables) | item + batch (+ GR ref) | drugs, reagents, anything with expiry | sticker printed at GOOD_RECEIVE per lot; scan resolves item+batch instantly — FEFO, recalls, expiry all become scan-verifiable |
| L2 | serialized unit | one physical thing (serial) | implants, devices, pumps, endoscopes, controlled-substance containers | new stock_units registry (§3.4), mirrors blood_bag_locations |
| L3 | container/case | a tote/cart/box holding many | ward supply carts, OR case carts, transport totes | stock_containers + member list; scanning the container moves its contents (later phase) |
3.2 QR payload contract + GS1 acceptance
Generate a versioned URL form (scannable by any phone camera AND our ScanDialog):
https://{host}/scan/v1?t=stock&i=<mongoItemId>&b=<batchId?>&u=<unitSerial?>&c=<containerId?>
- Short, no PHI, no prices. Unauthenticated scan of the URL lands on a public “what is this” page (patient-qr-self-service pattern, read-only item identity) — staff in-app scans skip the URL and parse params.
- Also accept what’s already printed on manufacturer packaging: GS1-128 / DataMatrix with AIs
(01)GTIN(10)lot(17)expiry(21)serial. A small parser (@medical-kit/scanning/gs1.ts) maps GTIN →stock_item_catalog.barcode(column exists, indexed) so receiving often needs no sticker at all — scan the manufacturer’s own barcode, we resolve item+lot+expiry. Our stickers are for things without usable GS1 marks. - Legacy: existing numeric
StockBatch.lotCodeandstockBarcodeDepartmentprefixes remain resolvable (resolver tries: URL → GS1 → barcode column → lotCode → dept-prefix).
3.3 Scan-driven movements — one sheet, one funnel
New StockScanActionSheet (@medical-kit/scanning/ or inventory-kit), built ON ScanDialog:
scan (camera|HID|manual) → resolve(payload) → context card (item, batch, expiry, on-hand here)
→ action chips: [Receive] [Issue to patient] [Transfer] [Count] [Return] [Locate / Passport]
→ confirm → POST existing foundation.stockTransaction.createWithStockAdjustment
(qrcode field stamped with the raw scan; encounterId stamped when patient context known)
Invariant — no parallel write path: every scan action funnels into the SAME stockTransaction REST actions
that power the manual UI; the STOCK_* event spine, balance cache, movement log, and the consume=charge ledger
trigger all fire for free. Scanning is an input method, not a new system.
- Receiving: GOOD_RECEIVE flow gains “scan carton” — GS1 parse prefills item/lot/expiry/qty; prints L1 batch
stickers for the put-away units (SpecimenStickerDialog generalized →
StockLabelDialog, same print-log audit)./inventory/ai-receive(route exists) = this + vision-AI fallback for paper delivery notes. - Issue/transfer: scan item → scan/select destination (location QRs — see 3.5) → qty → done. Two scans replace
the form. Ward “scan to patient wristband” chains the existing
PatientWristbandScanDialog→ stampsencounterId→ the P2 ledger trigger charges it (consume = charge, now scan-verified). - Count mode: walk a shelf scanning; diffs against
stock_balance_cache→ proposes aSTOCK_ADJUSTMENT(human confirms — fills the “no cycle count” gap noted in the P0 audit).
3.4 Serialized units (L2): stock_units — generalize the blood-bag pattern
CREATE TABLE stock_units ( -- Supabase read+registry (writes via foundation svc, P-later: Mongo twin)
id uuid PK,
serial text UNIQUE NOT NULL, -- our serial or manufacturer (21) AI
mongo_item_id text NOT NULL,
mongo_batch_id text,
status text NOT NULL DEFAULT 'in_stock', -- in_stock|reserved|in_transit|implanted|consumed|returned|disposed|missing
current_location_id text, -- Store mongo id (logical)
current_floor_plan_location_id uuid, -- spatial (via bridge, §3.5)
current_holder text, -- userId when checked out
encounter_id text, -- when implanted/consumed for a patient
metadata jsonb DEFAULT '{}', -- {udi?, implantSite?, warrantyUntil?, tag_id?}
created_at/updated_at timestamptz
);
Movements of a unit = normal stock transactions with refNo = serial → the movement log IS the unit history.
status→implanted links to or_case_costing.implant_line_items[] (UDI capture for the implant registry —
the OR adapter in homogenization P6 stamps the serial).
3.5 Floor-plan integration — bridge, lens, location QRs
Reuse the existing spatial hub; add ONE bridge table (mirror of bed_floor_plan_mappings):
CREATE TABLE store_floor_plan_mappings (
id uuid PK,
store_mongo_id text NOT NULL, -- Store / StoreRoom / StoreShelf mongo _id
store_kind text NOT NULL, -- store|store_room|store_shelf|bin
bin_location text, -- matches StockItem.handlingStores[].binLocation
floor_plan_location_id uuid NOT NULL REFERENCES floor_plan_locations(id),
UNIQUE (store_mongo_id, COALESCE(bin_location,''))
);
Three deliverables on top:
- Inventory lens on the existing 2D floor plan (same pattern as the bed board’s order channels):
stock_balance_cache GROUP BY mongo_location_idjoined through the bridge → per-room/shelf overlay chips (item count, active alert count, expiring-soon count, total value) + heatmap coloring by alert severity. Click a shelf → location inventory panel (live, realtime via the already-published cache). - Location QR plaques: every mapped store/shelf/bin gets a printed QR
(
…/scan/v1?t=loc&s=<storeId>&b=<bin>) — the “scan destination” half of two-scan transfers; also instant “what’s on this shelf” for anyone walking the floor. Mapping admin lives in the existing/department-floor-plan-manager(add an “inventory locations” object type —floor_plan_locations.typealready hasequipment; addstorage). - Serialized units on the plan:
entity_locations.typegainsstock_unit; L2 units appear as markers (the table was built for exactly this — patient/equipment/staff today).blood_storage_locationsgets optionalfloor_plan_location_idtoo, so blood fridges join the same lens.
3D: three.js + R3F are installed and unused; floor plans are 2D CSS grid today. Strategy: the lens ships 2D
first (works on every surface incl. tablets), then a 2.5D extrusion view of floor_plan_drawings.objects
(walls/rooms as extruded meshes, inventory/unit markers as glyphs) as a HORUS hospital-map lens — aligns with
the horus-map-lenses WIP branch rather than competing with it. 3D is a rendering of the same bridge data,
never a separate data model.
3.6 Item passport — “scan anything, see its story”
One panel (ItemPassportPanel, inventory-kit), reachable from any scan’s [Locate/Passport] chip and from
catalog/dashboard rows. Tabs over data that already exists:
- Timeline:
stock_movement_logfiltered by item(+batch)(+serial via refNo) — receive → transfers → issue, each with actor/location/qty/WAC (hot 14 days; archive extends this — homogenization Plane 4 item). - Charges:
billable_ledgerrows byorigin_ref(movement ids) — what was billed, to which encounter. - Alerts:
stock_alertshistory for the item/location. - Where now: balances per location (
stock_balance_cache) on a mini floor-plan strip; L2 → unit status + holder. - PHI rule: patient identifiers in the passport render only for users with patient-data roles; the public scan page never shows movement history (item identity only).
3.7 Future BLE / UWB / RFID — an adapter socket, not a bet on a vendor
Technology-agnostic ingestion (the connector pattern from operational-facts-erp-hrm-activation.md):
CREATE TABLE tracking_tags ( -- tag registry: which physical tag is glued to what
tag_id text PK, -- BLE MAC / EPC / UWB id
technology text NOT NULL, -- ble|uwb|rfid_passive|wifi_rtt
entity_type text NOT NULL, -- stock_unit|stock_container|equipment|staff_badge
entity_ref text NOT NULL,
is_active boolean DEFAULT true
);
CREATE TABLE rtls_position_events ( -- append-only, monthly-partitioned
id bigint generated PK,
tag_id text NOT NULL,
zone_ref uuid, -- floor_plan_locations.id (zone-level is enough; x/y optional)
x numeric, y numeric, floor text,
rssi numeric, source_gateway text,
observed_at timestamptz NOT NULL
);
ingest-rtlsedge fn (HMAC-signed, likeingest-operational-facts) accepts any gateway’s webhook (BLE beacon gateways, UWB anchors, RFID portal readers); connector-catalog getsrtls-ble-gateway,rfid-portal,uwb-anchorentries (honestplannedstatus until a real device lands).- A small zone-resolver updates
entity_locations(current zone per tagged thing) → the SAME floor-plan lens renders RTLS positions with zero UI change;stock_units.current_floor_plan_location_idupdates too. - Passive tech proposes, humans confirm: an RFID portal seeing a tagged unit cross a doorway creates a
suggested transfer (
cowork_proposals/ pending transaction), never an auto-posted one — same recommender-first stance as everything else. Exception by explicit rule only (e.g. tote portals in the warehouse). - Geofence rules =
inventory_rulesrows, new kindgeofence: scope {item/category/zone}, predicate {allowed_zones, after_hours?}, action {alert_severity, create_ack} — controlled substances leaving the pharmacy zone fire a critical ack to the pharmacy lead. The engine from P3 evaluates; no new engine.
3.8 Invariants
- One funnel — scans and RTLS create/propose stock transactions through the existing foundation actions; nothing writes the read model directly.
- Granularity is per-item data (
trackingType), never a global mode; L0 stays exactly as today. - Accept the world’s barcodes first (GS1) — our stickers only where packaging has none.
- No PHI in any QR payload or tag; public scan page = item identity only.
- Passive detection proposes, active scan confirms (recommender-first for hardware too).
- Spatial is a projection — floor-plan/3D render bridge + cache data; deleting the bridge loses pixels, never truth.
- Bilingual labels/stickers (TH+EN) per platform rule.
4. Rollout
| Phase | Scope | Effort | Win |
|---|---|---|---|
| T0 — scan core ✅ SHIPPED + sandbox-verified 2026-06-10 | web/packages/inventory-kit/src/scanning/: gs1.ts (AIs 01/10/17/21/11/30, GTIN check digit, symbology prefixes, parenthesized form; 13 jest tests green) + scanResolver.ts (URL→GS1→barcode→code→sku against the live catalog; buildStockQrPayload/buildLocationQrPayload; normalizeItemCode unwraps CodeableConcept JSON blobs in item_code) + StockScanActionSheet (ScanDialog camera/HID/manual → context card w/ live on-hand → Receive/Issue/Transfer/Count → createTransactions one-funnel; patient context stamps patient → ledger charges via HN; dest-location scan for transfers) + StockLabelDialog (50×30 mm QR labels, qrcode.react preview + hidden-iframe print, phone-camera-scannable PHI-free URL) + ItemPassportPanel (where-now/timeline/charges/alerts, fail-soft per section). Entry points: Scan button on /inventory-dashboard header; sandbox ?target=StockScanKit (live-verified: barcode PC1300… resolved → 53 on hand → passport showed live critical alert). NOTE: transaction POST needs the authed app (sandbox has no session); the posting path reuses the production createTransactions. Per-flow GOOD_RECEIVE sticker hook inside the receive tab = follow-up. |
done | Scan-driven warehouse + per-item history, demo-ready, zero schema change |
| T1 — floor-plan lens | store_floor_plan_mappings + mapping UI in department-floor-plan-manager + inventory overlay/heatmap on the 2D floor plan + location QR plaques |
M | “Where is it” answered visually; two-scan transfers |
| T2 — serialized units | stock_units + L2 sticker at receive + status lifecycle + OR implant linkage (UDI) + entity_locations markers |
M | Implant/device traceability (regulatory win) |
| T3 — counts & containers | scan-walk cycle count mode; stock_containers (L3) for carts/totes |
S–M | Closes the no-cycle-count gap |
| T4 — RTLS socket | tracking_tags + rtls_position_events + ingest-rtls + zone resolver + geofence rule kind + connector-catalog entries |
M | Hardware-ready; geofence alerts on day one with a single BLE gateway |
| T5 — 2.5D/3D map | R3F extrusion of floor_plan_drawings as a HORUS map lens; inventory/unit/RTLS markers in 3D | L | The showpiece — but a pure rendering layer over T1/T4 data |
T0 is independently shippable and reuses ~90% existing components. T1–T4 are independent of each other after T0.
5. Open questions
- Sticker hardware — which label printers exist at the pilot site (Zebra ZPL vs browser-print)? SpecimenStickerDialog is browser-print today; ZPL raw printing would need the printing service (Spring Boot, port 8088).
stock_unitswrite home — start Supabase-native (like blood_bag_locations) or Mongo-first with projection (consistent with everything else)? Leaning Mongo-first in foundation for trust, projecting like the rest of the spine.- RTLS pilot tech — BLE gateway (cheapest, zone-level) vs RFID portal (bulk passive, doorway events) — decides which connector pack gets a real implementation first.
- Public scan page domain — same Vercel rewrites pattern as
/patient-menu/:token?