medOS ultra

Inventory Item Tracking (QR/Floor-Plan)

Physical tracking at data-driven granularity: GS1 scanning, granularity ladder, floor-plan lens, item passport, RTLS socket.

12 min read diagramsUpdated 2026-06-10docs/architecture/inventory-item-tracking-qr-floorplan.md

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

  1. No QR generation for stock (items/batches/units) — only consumption-side scanning exists.
  2. No scan-driven stock transactions (scan → receive/issue/transfer) for general inventory.
  3. No link from Store/StoreRoom/StoreShelf to floor_plan_locations (beds have a bridge table; stores don’t).
  4. No serialized unit tracking outside blood bags (implants, pumps, endoscopes).
  5. No “item passport” UI (history of one thing across movements + charges + alerts).
  6. 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.lotCode and stockBarcodeDepartment prefixes 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 → stamps encounterId → the P2 ledger trigger charges it (consume = charge, now scan-verified).
  • Count mode: walk a shelf scanning; diffs against stock_balance_cache → proposes a STOCK_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:

  1. Inventory lens on the existing 2D floor plan (same pattern as the bed board’s order channels): stock_balance_cache GROUP BY mongo_location_id joined 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).
  2. 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.type already has equipment; add storage).
  3. Serialized units on the plan: entity_locations.type gains stock_unit; L2 units appear as markers (the table was built for exactly this — patient/equipment/staff today). blood_storage_locations gets optional floor_plan_location_id too, 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_log filtered 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_ledger rows by origin_ref (movement ids) — what was billed, to which encounter.
  • Alerts: stock_alerts history 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-rtls edge fn (HMAC-signed, like ingest-operational-facts) accepts any gateway’s webhook (BLE beacon gateways, UWB anchors, RFID portal readers); connector-catalog gets rtls-ble-gateway, rfid-portal, uwb-anchor entries (honest planned status 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_id updates 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_rules rows, new kind geofence: 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

  1. One funnel — scans and RTLS create/propose stock transactions through the existing foundation actions; nothing writes the read model directly.
  2. Granularity is per-item data (trackingType), never a global mode; L0 stays exactly as today.
  3. Accept the world’s barcodes first (GS1) — our stickers only where packaging has none.
  4. No PHI in any QR payload or tag; public scan page = item identity only.
  5. Passive detection proposes, active scan confirms (recommender-first for hardware too).
  6. Spatial is a projection — floor-plan/3D render bridge + cache data; deleting the bridge loses pixels, never truth.
  7. Bilingual labels/stickers (TH+EN) per platform rule.

4. Rollout

Phase Scope Effort Win
T0 — scan coreSHIPPED + 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

  1. 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).
  2. stock_units write 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.
  3. RTLS pilot tech — BLE gateway (cheapest, zone-level) vs RFID portal (bulk passive, doorway events) — decides which connector pack gets a real implementation first.
  4. Public scan page domain — same Vercel rewrites pattern as /patient-menu/:token?
Ask Anything