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.
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.
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.
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.
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.
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 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.
Surgical case list and restock request flows. Separate from inventory intake — cases trigger decrement, not the other way around.
The primary deliverable of this engagement. Receive/Load and Manage On-Hand, accessible from the bottom tab or the home screen cards.
Quote builder and history. Exists in SurgiScribe today — Inventory module integrates with it for case-linked pricing.
Mobile read-only report access. The full Reports engine lives on desktop — see Section 09 for the complete build.
Brother Bluetooth printer management via the Brother iPrint&Label SDK. Print queue in sqflite, drained on reconnect.
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.
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.
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.
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.
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.
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.
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().
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.
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.
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.
No manufacturer custody rules apply. Full control by the entity that loaded the inventory. No loaner policy lookup, no policy-driven return workflow.
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 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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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().
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.
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.
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.
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.
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.
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.
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.
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.
Full access across all entities. Entity creation, global config, system-level audit access. Enforced by IsPlatformAdmin DRF permission class.
Creates and manages managers and reps. Configures manufacturer loaner policies. Enforced by IsEntityAdmin permission class. Desktop entity selector widget switches context without re-auth.
Sees all inventory across direct reports via a PostgreSQL recursive CTE. Cannot see other managers' reps or other entities. Enforced by IsManager.
Sees only their own inventory, cases, and loaner status. Field-first interface. No visibility into other reps' records. Enforced by HasOrganization.
Overrides get_queryset() to inject the caller's entity filter on every read. Structural isolation — a developer cannot forget to add the filter.
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.
Every endpoint × every role × cross-entity wrong-user scenarios as parameterized pytest tests before endpoints ship. Every permission hole caught before staging.
Entity Admins manage Facilities, OEMs/Manufacturers, Surgeons, and Users from the desktop sidebar — auto-generated via Django ModelAdmin surfaces scoped per role.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.