SurgiSync · Inventory Module

Technical Scope &
Specification

Flutter mobile app + Django/DRF backend for inventory intake, on-hand management, kit lifecycle tracking, and post-surgical decrement. Extends the existing SurgiScribe production stack — no new hosting, databases, or deployment pipelines.

Scope of this engagement — Core inventory intake, on-hand management, kit lifecycle, loaner custody, and decrement after use. Features outside this scope — expiration alerting, FIFO logic, confinement agreement tracking, payment processing — are explicitly excluded. The loaner lifecycle is scoped to inventory custody only, with an explicit bridge point to the Ordering module.
Download PDF
Existing Stack — Extended, Not Replaced

Every component below already runs in the SurgiScribe production environment. This engagement adds new Django apps, Flutter screens, and DRF endpoints — no new hosting, databases, or deployment pipelines.

Django Django REST Framework PostgreSQL on AWS RDS Flutter GetX Dio AWS S3 AWS SQS AWS SES Redis Brother Bluetooth SDK mobile_scanner openpyxl httpx sqflite workmanager patrol pytest-django factory_boy GitHub Actions
01 · Mobile Inventory Module

Mobile App Wireframe

SurgiSync — Inventory Module · Interactive Wireframe Live
02 · Inventory Home & Foundation

Inventory Home Screen

Entry point for the inventory module. Search bar, two primary action cards (Receive/Load, Manage On-Hand), quick-action shortcuts, and alert banners that deep-link to filtered views. The schema, API surface, and audit infrastructure here underpin every downstream screen.

Search Bar

Full-text search across kit names, manufacturers, IDs, and locations. Server-side via GET /inventory/?q=, debounced in the Flutter GetX controller at 300ms. Results render inline without screen navigation.

Receive / Load + Manage On-Hand Cards

Primary action cards on the home screen. Tapping Receive/Load navigates to the mode selector via GetX named routes — deep-link compatible via Android intent system and iOS Universal Links. Manage On-Hand pre-expands the filter panel via a serializable GetX controller state.

Active Alerts & Dashboard

Overdue loaners and flagged kits surface as tappable alert banners. Fed by GET /inventory/dashboard/ — returns overdue loaners, late fees owed (from ManufacturerLoanerPolicy), on-hand count by site, and attention items. Tap-throughs call Get.toNamed() with filter args pre-loaded into the on-hand controller.

Schema & API Foundation

Schema via Django migrations following SurgiScribe column conventions. DRF ViewSets with ModelSerializers paginated via LimitOffsetPagination, versioned under /api/v1/inventory/. Every write endpoint accepts an Idempotency-Key header cached in Redis (24-hour TTL) — safe for mobile retry on flaky connections.

App Navigation

Bottom Tab — Six Modules

📋

Cases / Restocks

Surgical case list and restock request flows. Separate from inventory intake — cases trigger decrement, not the other way around.

📦

Inventory

The primary deliverable of this engagement. Receive/Load and Manage On-Hand, accessible from the bottom tab or the home screen cards.

📝

Quotes

Quote builder and history. Exists in SurgiScribe today — Inventory module integrates with it for case-linked pricing.

📊

Reports

Mobile read-only report access. The full Reports engine lives on desktop — see Section 09 for the complete build.

🖨️

Printers

Brother Bluetooth printer management via the Brother iPrint&Label SDK. Print queue in sqflite, drained on reconnect.

🛒

Ordering

The explicit bridge point from the Loaner lifecycle. The LoanerRequest record is designed so the Ordering module reads and writes it directly when that module ships — no migration required.

03 · Receive / Load — Five Intake Modes

Receive / Load

Every intake mode requires the same core fields and produces the same structured record. Required at intake on every mode: Manufacturer, Rep/Assigned To (accountability), Physical Location (where it's stored), inventory Type (Loaned / Consigned / Owned), Status, and a photo. The photo is a non-nullable foreign key — the database refuses the insert without it.

Kit + Manual

Multi-step wizard: Manufacturer → Rep/Assigned To → Physical Location → Kit Name (from SurgiSync catalog) → Kit ID (scan or type) → Type → Status → Photo → Notes → Confirm. State in a GetX controller; draft persisted via shared_preferences so backgrounding the app does not lose in-progress intake. Required fields per Section 4.2 · Slides 5–6 of the BRD.

Kit + Bulk Upload

Download Excel template → fill kit rows → upload. Shared header fields (Manufacturer, Rep/Assigned To, Physical Location) apply to every row. Server-side parsing via openpyxl. Row-level errors use transaction.savepoint() — bad rows fail individually without blocking valid ones. Packing slip photo required per batch. BRD Section 4.2 · Slides 9–10.

SKU + Manual

Loads individual items one at a time. Required: Manufacturer, Rep/Assigned To, Physical Location, Catalog #, Quantity, Type, Photo. Expiration date and UDI toggled on/off per item — hidden from the form when not needed. Quick Scan Mode activates from this flow via the green toggle pill at the top of the screen.

SKU + Bulk Upload

Same file-based flow as Kit + Bulk but at the SKU/item level. Shared session metadata applies to all rows. Commits via POST /inventory/bulk-receive wrapped in transaction.atomic(). Structured skip-reason response payload consumed by Flutter for row-level error display in the UI.

Quick Scan Mode · Section 4.2 · Slides 7–8

Session-locked batch scanning.

Quick Scan Mode — SKU + Manual Only

Activated via the green toggle pill on the SKU + Manual screen. Manufacturer, Rep/Assigned To, Physical Location, Type, Quantity, and Expiration lock for the session. Each scan increments a live counter rendered as a GetX Obx widget. Tap Edit to temporarily unlock fields; re-lock for the next scan. Tap Done to commit the entire session as a single POST /inventory/bulk-receive wrapped in transaction.atomic().

  • Continuous scanning via mobile_scanner — supports GS1 and HIBC barcode formats
  • In-memory Set<String> in the GetX controller prevents duplicate scans within the session
  • Pre-commit server-side bulk_check endpoint validates uniqueness against the database before any insert
  • Only available on SKU + Manual — not available for Kit or Bulk Upload flows
Inventory Type Classification

Every item is one of three types.

Selected at intake — a rep cannot load without choosing the type. The type drives the downstream workflow, policy checks, and reporting. Enforced at the PostgreSQL constraint level, not the UI.

Loaned

Manufacturer-Owned, Time-Bound

Borrowed for a specific period. Governed by the manufacturer's loaner policy. Triggers the loaner lifecycle on intake — expected return date calculated by LoanerPolicyService and stored on the LoanerRequest record.

Consigned

Manufacturer-Owned, Indefinite

Held by the distributor with no time-based policy enforcement. Used and decremented as surgeries occur. Return to manufacturer via UPS/FedEx when appropriate, not on a policy-driven schedule.

Owned

Distributor-Owned Outright

No manufacturer custody rules apply. Full control by the entity that loaded the inventory. No loaner policy lookup, no policy-driven return workflow.

04 · On-Hand Management, Kit Details & Action Modals

On-Hand Management

On-hand list with filter panel. Filters by type, status, manufacturer, rep/custodian, and physical location. Supports single-item and bulk group selection for downstream actions.

Filter Panel · Sections 4.3 · Slides 11–12

Filter chips for type (Loaned / Consigned / Owned), status, manufacturer, rep/custodian, and physical location. Each active filter is a dismissible chip via GetX reactive state. Result count updates live with no reload. Dropdown sources: all reps and facilities within the entity, plus predefined and custom locations.

Kit Cards — Left-Edge Status Stripe

Each kit renders with a left-edge color stripe: green = active, amber = attention (overdue loaner, in-transit), red = expired or blocked. Inline metadata: manufacturer, type badge (Consigned / Loaned / Owned tag rendered via tbdg widget), rep/custodian, physical location, lot, expiration. Stripe color is a derived property on InventoryItem — not a UI flag.

Group Selection & Bulk Actions

Tapping the + button adds a kit to the active group. A floating action bar slides up from the bottom exposing: Transfer, Update Status, Return to Manufacturer. Group state is a GetX selection controller. Expired kits form their own group — only Warehouse transfer or Manufacturer return available. In-transit kits are skipped; skip reason returned as a structured response payload and shown in the UI.

In-Transit Kits · Section 4.4 · Slides 17–18

IN-TRANSIT is a row in the InventorySite table carrying from_site_id and to_site_id foreign keys — not a boolean flag. Handled by TransferService. Supports MAIN→REP, REP→FACILITY, REP→REP, REP→MAIN flows. Auto-confirm triggers when the kit's Physical Location update matches its destination site.

Kit Details · Section 4.4 · Slide 13

Kit Detail View

Metadata Block

Manufacturer, Status, Lot Number, Expiration, Rep/Assigned To, Physical Location, Entity, and inventory Type. Rep/Assigned To and Physical Location are now two separate required fields — replacing the old combined Rep Stock + Location grid. Rendered via a 2-column GridView using the shared widget library.

Audit Log Timeline

Full history of every event on the kit in chronological order: date, action, user. Read from the AuditLog table indexed on (model_name, record_id, timestamp) for sub-100ms queries. "View All" deep-links to the full audit view. Every row is permanent — UPDATE and DELETE revoked at the DB role level.

Loaner Policy Snapshot

For loaned items — a summary card shows the manufacturer's policy: days allowed, expected return date, late fee amount. Pulled from ManufacturerLoanerPolicy via LoanerPolicyService. Read-only summary — reps are not shown a browseable policy tab or admin interface.

Three Action Cards

Update Status, Transfer, and Return to Manufacturer — each tap opens a bottom-sheet modal. All three write to AuditLog and update inventory state inside a single transaction.atomic() block. In-transit state: Transfer and Return to Manufacturer are blocked; Update Status shows auto-confirm behavior when Physical Location matches the transfer destination.

Action Modals

Bottom-Sheet Action Modals

1

Update Status

A 4-option status grid (Complete, Incomplete, Wrapped, Signed Out + additional states). New status, new physical location, optional note. Status row + audit entry + photo reference all commit inside one transaction.atomic(). Available for single kits and bulk groups.

2

Transfer Kit

Transfer From, Transfer To, Transfer Date (all required). Expired kits: destination locked to warehouse only. Creates IN-TRANSIT InventorySite row via TransferService. Available for single and bulk groups; in-transit kits skipped with structured skip-reason response.

3

Pending Transfer (In-Transit Modal)

Opens from the amber banner on the In-Transit Kit Details screen. Shows destination, origin rep, date, carrier, and current transfer status. Cancel Transfer reverses the transfer, returns the kit to its pre-transfer InventorySite state, and writes a cancellation audit entry — all inside transaction.atomic().

4

Return to Manufacturer (Consigned)

UPS REST API via httpx async client. Adapter pattern in shipping/providers/UPSProvider implements abstract ShippingProvider; FedEx drops in without changing call sites. OAuth tokens cached in Redis with 5-minute pre-expiry buffer. Label PDFs stored in S3, emailed via AWS SES. Return condition + photo required before the record commits.

5

Return to Manufacturer (Loaner)

Same UPS/FedEx integration plus loaner cleanup: closes the LoanerRequest record, records return condition and photo, calculates late fees from ManufacturerLoanerPolicy. Late fee calculation is read-only — displayed to the rep for reference; dispute handled directly with the manufacturer. BulkReturnService groups kits by manufacturer_id and fires parallel UPS calls via asyncio.gather() for multi-OEM bulk returns.

09 · Desktop Reports Engine

Desktop Reports Engine

Three report categories — Commission/Sales, Inventory, and Ordering — each with a user-configurable column projection, date range filter, and Excel export. Desktop sidebar also exposes Directory Profiles, Cases/Restocks, Ordering, Quotes, Pricing, and Setup.

SurgiSync Desktop — Reports · Interactive Wireframe Live
Reports Engine — Implementation
Column Groups · All 5 field sets
Case-Level
  • Case Date
  • Rep
  • Surgeon
  • Facility
  • OEM
  • SurgiSync Case ID
  • Case Amount
OEM Lifecycle
  • OEM PO Number
  • PO Entered Date
  • Order Acknowledged
  • OEM Ship Date
  • OEM Delivery Date
  • OEM Invoice #
  • Invoice Recorded
  • Invoice Sent
  • Payment Issued
Facility Lifecycle
  • Facility PO Number
  • PO Received Date
  • PO Acknowledged
  • Facility Ship Date
  • Delivery Date
  • Facility Invoice #
  • Invoice Recorded
  • Invoice Sent
  • Payment Received
Product Details
  • SKU / Catalog #
  • Product Description
  • Lot Number
  • Quantity
  • Unit Price
  • Expiration Date
Commission
  • Commission Amount
  • Commission Paid Date

Customizable Column Selector

Collapsible panel above the data table. Each column group has Select All / None controls. Active column count shown as a badge. Column selection stored client-side in localStorage and sent as query params on GET /reports/ — the server projects only requested fields via QuerySet.values(), keeping payload minimal.

Sortable, Filterable Data Table

Column headers sort via ORDER BY on the Django queryset. Per-column filter dropdowns send filter[column]=value params. Summary row aggregates totals (Case Amount, Commission Amount) using Django ORM aggregate() — not computed client-side. Paginated via LimitOffsetPagination. 48 results shown per page by default.

Export to Excel

GET /reports/export/ accepts the same column selection and filter params as the current view. Server generates the .xlsx file via openpyxl — column headers, data rows, summary row, basic formatting. Streamed as an attachment with no intermediate S3 storage for standard exports.

Three Report Categories

Commission / Sales — case outcomes + commission tracking. Inventory — on-hand status, transfers, returns. Ordering — PO lifecycle, delivery, invoicing. Each maps to a separate DRF ViewSet with its own serializer and column group definitions — one focused endpoint per category, not a shared endpoint trying to do everything.

10 · Role-Based Access & Entity Isolation

Role-Based Access Control

No user can see or touch inventory that belongs to a different entity — by construction, not by convention. A custom OrganizationScopedManager overrides get_queryset() so the entity filter is injected automatically on every read. A permission hole cannot be introduced by forgetting to add a filter — the filter is never written by hand.

Platform Admin

God view

Full access across all entities. Entity creation, global config, system-level audit access. Enforced by IsPlatformAdmin DRF permission class.

Entity Admin

Full access, own entity

Creates and manages managers and reps. Configures manufacturer loaner policies. Enforced by IsEntityAdmin permission class. Desktop entity selector widget switches context without re-auth.

Manager

Rollup view

Sees all inventory across direct reports via a PostgreSQL recursive CTE. Cannot see other managers' reps or other entities. Enforced by IsManager.

Rep

Own inventory only

Sees only their own inventory, cases, and loaner status. Field-first interface. No visibility into other reps' records. Enforced by HasOrganization.

OrganizationScopedManager

Overrides get_queryset() to inject the caller's entity filter on every read. Structural isolation — a developer cannot forget to add the filter.

OrganizationMembership

OrganizationMembership(user, entity, role, valid_from, valid_to) — one user can hold multiple entities with a different role per entity. Historical role state is queryable by date.

RBAC Test Matrix

Every endpoint × every role × cross-entity wrong-user scenarios as parameterized pytest tests before endpoints ship. Every permission hole caught before staging.

Directory Profiles (Desktop)

Entity Admins manage Facilities, OEMs/Manufacturers, Surgeons, and Users from the desktop sidebar — auto-generated via Django ModelAdmin surfaces scoped per role.

11 · Audit Trail, Mobile UI & Offline

Audit Trail, Mobile UI & Offline

Append-Only AuditLog

Single AuditLog table indexed on (model_name, record_id, timestamp). UPDATE and DELETE revoked at the DB role level — even a buggy ORM call cannot violate append-only semantics. Every service method calls log_audit() inside the same transaction.atomic() as the action it records.

Photo-Backed Evidence

Required at every intake, return, and status change. Camera via Flutter's camera package; photos upload to AWS S3 via presigned URLs. The S3 key is a non-nullable FK on the receive record. Three independent timestamps on every photo: server timestamp, S3 LastModified, and embedded EXIF.

Mobile-First Flutter Layout

Every screen designed for one-handed use in a hospital corridor before adapting for desktop. Flutter Web shares the same Dart codebase — no separate frontend. Responsive breakpoints via LayoutBuilder and MediaQuery. Tablet landscape activates two-column layouts and master/detail views.

Offline Support

Photos write to the app documents directory immediately on capture. S3 upload queue via workmanager (Android) and BGTaskScheduler (iOS) with exponential backoff retry. Pending upload count is a GetX observable on the home screen. Brother printer queue in sqflite, drained on reconnect.

12 · Testing & Delivery

Testing & Delivery

Backend — pytest + factory_boy

pytest + pytest-django with fixtures from factory_boy. Unit tests for permission classes, status rules, loaner policy enforcement, decrement logic, and bulk-action skip-reason generation. Full RBAC matrix — every endpoint × every role × cross-entity wrong-user scenarios — as parameterized tests run in CI before every merge.

Flutter E2E — patrol

patrol package on the critical flows on real device simulators: all five receive modes, single + bulk transfer, return to OEM (consigned + loaner), inventory decrement, bulk status update, quick scan session commit. Not widget tests against mocked state — actual end-to-end on simulator hardware.

UPS Integration — pytest-recording

UPS sandbox interactions via pytest-recording cassettes — label generation, OEM routing, error responses, and idempotency replays. The httpx async client's OAuth refresh path (lazy refresh on 401, Redis cache with 5-minute pre-expiry) is tested against recorded sandbox responses, not live UPS calls.

CI/CD & Delivery Cadence

Deployment through existing GitHub Actions CI pipeline to the current AWS environment. Zero-downtime migration patterns. Post-deploy smoke tests verify audit log writes, dashboard endpoint response shape, and decrement behavior. Weekly working demos on a staging branch. Blockers surfaced in writing within 24 hours.

Engineering Principles

Engineering Principles

1

Invariants at the database layer

Permission rules, inventory type requirements, and policy enforcement are enforced at the PostgreSQL and Django model layer — never at the Flutter UI. A bug above the data layer cannot bypass a constraint below it.

2

Every state change is one atomic block

Every write endpoint wraps its action, audit row, and photo reference in a single transaction.atomic(). Partial states — an action with no audit entry, or an audit entry with no action — are impossible by construction.

3

Every integration is an adapter

UPS, Brother, S3 — every external integration sits behind an abstract adapter in shipping/providers/. When FedEx, a different printer, or a new ERP arrives, no code in this engagement needs to be rewritten.