Skip to main content

Pharmacy & SCM — How it works

Overview

Zhenus Pharmacy & Supply Chain is a separate domain plug-in module (com.zhenus.uhp.api.pharmacy) with its own pharmacy PostgreSQL schema. It mirrors the lab module's boundary: a decoupled Feign plug-in that never imports core or a sibling domain module (imaging, workforce, hl7, lab), and reaches platform engines (tenant, facility, demographic, accesscontrol, clinical, concept, notification, audit, billing) only over Feign. Design: MILESTONE19_PLAN.md.

M19-002 adds the product catalog and formulary: two product kinds (MEDICAL concept + SKU, NON_MEDICAL category + SKU), barcodes, worldwide registration schemes, packs, list price, and a per-tenant MEDICAL formulary. M19-003 adds inventory: batch/lot/expiry per location, reasoned adjustments and stock-take, quarantine/disposal, a near-expiry FEFO list, best-effort alerts through NotificationCommandClient, a hard 409 on expired or non-AVAILABLE outbound, and an append-only controlled-drug register. M19-004 adds procurement: TypeScope suppliers, purchase orders, goods receipt (the purchase stock-increase path), supplier return, and reorder suggestions from per-location stock_reorder thresholds. M19-005 adds dispense. M19-006 adds inter-location transfers (two-sided dispatch / receive with in-transit tracking) and consumption (lab reagent, ward use, encounter, other). M19-007 is the milestone gate: Milestone19PharmacyScmIntegrationTest plus core/MILESTONE19.md.

Data model & ownership

Owned tables live under pharmacy/src/main/resources/db/changelog/pharmacy/. The master changelog is deliberately named pharmacy.db.changelog-master.yaml, not db.changelog-master.yaml — the latter collides with core's on the single-jar classpath.

AreaMigrationPurpose
Schema001CREATE SCHEMA IF NOT EXISTS pharmacy
Catalog / formulary002product_category, product_registration_scheme, product, product_barcode, product_registration, product_pack, formulary_entry
Inventory003stock_batch, stock_movement (append-only), controlled_drug_register (append-only), stock_reorder
Procurement004supplier, purchase_order / purchase_order_line, goods_receipt / goods_receipt_line, supplier_return
DispenseM19-005Patient-record sale (no picker) / till search / anonymous; returns; lot recall; the PRODUCT.md moat
Transfers / consumption006stock_transfer / stock_transfer_line (two-sided status) + stock_consumption

Catalog rows are GLOBAL-CAPABLE config: tenant_id from the session, optional facility_id (null = tenant-wide). Visibility is facility ∪ tenant ∪ country via TypeScopeClient / PharmacyScopeResolver (copied from LabScopeResolver; pharmacy does not import lab). Soft-delete via AuditTrail + @SQLRestriction("voided = false"). Tenant and facility never arrive on input DTOs.

Cross-module references (medication orders, patients, concepts) are logical — resolved over Feign, never a foreign key into another module's tables.

Two product kinds

  • MEDICAL requires conceptId (Long, RxNorm / clinical drug) and a SKU / trade name. Strength lives on the concept, never a free-text column. Many products may share one concept (Panadol vs Emzor). controlledSchedule is the enum UNSCHEDULED | I | II | III | IV | V, not a boolean. A category is forbidden.
  • NON_MEDICAL requires productCategoryId (TypeScope CRUD) and a SKU name. A concept is forbidden — no fake RxNorm for toothpaste. Schedule is null.

list_price is NUMERIC(19,4) for OTC. IDENTIFIED_OTC and ANONYMOUS post that price as ChargeSourceType.SALE lines through BillingCommandClient in the same transaction as the stock decrement (identified → the patient's draft invoice; anonymous → a SALE- counter invoice that names no patient). In-hospital order-linked dispense still prices from the charge master (M13-007) and must not Feign-charge from the till.

Barcodes, registrations, packs

  • product_barcode is a mapping table (not a column on product, not concept_mapping). Unique per tenant among live rows. Optional BarcodeSymbology (EAN_13, UPC_A, GTIN_14, CODE_128, INTERNAL). Optional packFactor must match a pack on the product.
  • product_registration_scheme is rule-1 CRUD + TypeScope. Well-known worldwide codes (NAFDAC, NDC, DIN, MHRA_PL, ARTG, EMA_MA) are upserted for the session tenant on first list when they are not already visible (PharmacyRegistrationSchemeSeeder). Lab does not seed nationally, so this catalog follows the same runtime-seed pattern rather than tenant-less Liquibase rows. A tenant can add a scheme without a code change.
  • product_registration maps (product_id, scheme_id, number). Unique per tenant + scheme + number among live rows.
  • product_pack is name + factor × base units (ProductBaseUnit enum). Stock later stores base units; ProductService#toBaseUnits(productId, packIdOrNull, qty) multiplies exactly (qty is already base units when packId is null).

A barcode or registration-number scan returns exactly one live visible product (404 if none, 409 if ambiguous — uniqueness should make ambiguous impossible inside one tenant).

Formulary

Per-tenant approved MEDICAL products (productId unique per tenant). Optional notes. A product can exist without a formulary row (OTC stock). NON_MEDICAL is refused (400). Formulary is tenant-wide — facility_id stays null.

Inventory (M19-003)

Stock is facility-scoped operational data: tenant_id and facility_id come from the session (fail loudly if either is missing). location_id is the session facility; the display name is resolved over FacilityClient. One live stock_batch row per (product, location, lot) — a null lot is the undated / unlotted bucket.

  • Status is the closed enum AVAILABLE | QUARANTINED | DISPOSED.
  • Movements are append-only (stock_movement). An adjustment that creates a lot writes RECEIPT; a quantity change on an existing lot writes ADJUSTMENT; a physical count writes STOCK_TAKE / COUNTED; quarantine and disposal write QUARANTINE / DISPOSAL. Later tickets reuse DISPENSE and RETURN. M19-006 writes TRANSFER_OUT on dispatch, TRANSFER_IN on receive (and on cancel-after-dispatch restock), and CONSUMPTION on a reasoned decrement.
  • Reason is the closed enum DAMAGED | EXPIRED | RECALLED | COUNTED | CORRECTION | THEFT | OTHER. Notes may elaborate; the category of the row must not be free text.
  • Near-expiry lists AVAILABLE batches whose expiresAt is not null and falls within days (FE sends 30 / 60 / 90), FEFO-ordered. Undated goods skip the tracker.
  • Alerts enqueue through NotificationCommandClient.enqueueEvent (near-expiry within 90 days, or AVAILABLE on-hand below a positive stock_reorder.threshold). A notify failure is logged and never fails the stock write. Threshold 0 (or no row) means do not notify. An optional @Profile("scheduler") job rescans hourly.
  • Controlled-drug register (controlled_drug_register) is a distinct append-only log. A row is written whenever a MEDICAL product whose controlledSchedule is not UNSCHEDULED moves. sku_name and conceptId are denormalized. There is no public write, update or delete API — reads are gated pharmacy.controlled.read.

Clinical identity for MEDICAL stock is the product's conceptId from the concept dictionary, never a pharmacy-local drug name.

Procurement (M19-004)

Shared contracts live in exchange.dto.procurement (and common.procurement) — not exchange.dto.pharmacy. Pharmacy implements clinical SCM under /api/v1/pharmacy/* so the existing till UI does not move; Health ERP will bind the same types under /api/v1/procurement. OpenAPI @Schema(name=…) is unchanged.

  • Supplier is TypeScope CRUD (name, code, contactName, contactPhone, contactEmail, active). A purchase order names a supplierId — never a free-text supplier. Create accepts ?facilityLocal= like the catalog.
  • Purchase order status is the closed enum DRAFT | SUBMITTED | PARTIALLY_RECEIVED | RECEIVED | CANCELLED. Create stores DRAFT. POST …/submit and POST …/cancel are the only transitions besides goods receipt.
  • Goods receipt is the purchase stock-increase path. Each line calls StockService.receive, which creates or increments an AVAILABLE batch with lot / MFD / expiry / unitCost. Barcode or registration-scheme + number resolve the product via the existing catalog. Dated goods (manufacturedAt set) must capture expiry (400 otherwise).
  • Supplier return decrements the named received batch via StockService.decrement (RETURN movement). Expired or non-AVAILABLE outbound is a hard 409 (the M19-003 rule). Reason is the closed enum NEAR_EXPIRY | DAMAGED | RECALLED | WRONG_ITEM | OTHER.
  • Reorder suggestions list products at this facility whose AVAILABLE on-hand is below a positive stock_reorder.threshold. Suggested quantity is threshold − on-hand.

Key rules & invariants

  • No core import. ArchUnit (PharmacyArchitectureBoundaryTest) refuses com.zhenus.uhp.api.core and sibling domain packages. Pharmacy talks to the platform over Feign.
  • No Feign client to another domain module. Domain modules do not call each other.
  • Permission families are declared on PharmacyModuleDescriptor. Catalog controllers gate on pharmacy.catalog.read / pharmacy.catalog.write. Stock gates on pharmacy.stock.read / pharmacy.stock.write. The controlled register gates on pharmacy.controlled.read. Procurement gates on pharmacy.procurement.read / pharmacy.procurement.write. Dispense gates on pharmacy.dispense.read / pharmacy.dispense.write. Transfers and consumption share pharmacy.transfer.read / pharmacy.transfer.write. AuthorizationCatalog exposes route keys pharmacy.stock, pharmacy.controlled, pharmacy.procurement, pharmacy.dispense and pharmacy.transfer so FE-256 / FE-257 / FE-258 / FE-259 screens are not denied.
  • Tenant and facility never arrive on a DTO. Session context supplies them.
  • MEDICAL without conceptId, MEDICAL with a category, NON_MEDICAL with a concept, and NON_MEDICAL without a category are all 400. Unknown concept (Feign) is 400. Duplicate live barcode or registration is 409. Formulary on NON_MEDICAL is 400. Duplicate formulary product per tenant is 409.
  • Outbound (negative adjustment, stock-take decrement) of an expired or non-AVAILABLE batch is a hard 409 (STOCK_EXPIRED / STOCK_NOT_AVAILABLE). A DISPOSED batch refuses any further write (409 STOCK_DISPOSED). Inbound onto an expired AVAILABLE lot is allowed (it does not leave the shelf).
  • Movements and controlled-register rows are append-only. There is no update or delete API for either.

API

REST surface under /api/v1/pharmacy. Catalog (M19-002):

MethodPathPurpose
POST/GET/PUT/DELETE/products (+ /page, /{id})Product CRUD (void on DELETE)
GET/products/by-barcode?value=Scan one live product
GET/products/by-registration?schemeId=&number=Scan one live product
POST/GET/DELETE/products/{id}/barcodesBarcode mapping
POST/GET/DELETE/products/{id}/registrationsRegistration mapping
POST/GET/PUT/DELETE/products/{id}/packsPack conversion
POST/GET/PUT/DELETE/product-categoriesNON_MEDICAL categories
POST/GET/PUT/DELETE/registration-schemesWorldwide schemes (seeded on first GET)
POST/GET/PUT/DELETE/formularyPer-tenant MEDICAL formulary
GET/formulary/by-product/{productId}Live formulary row for a product (M19-008)
GET/formulary/by-product/{productId}/on-formularyWhether the product is on the tenant formulary (M19-008)

Cross-module read contract (M19-008): exchange.client.pharmacy.PharmacyCatalogClient mirrors the catalog GET surface above (getProduct, searchProducts, getFormularyEntry, isFormularyItem, listProductCategories). Read-only — procurement receipt routing classifies lines over Feign without importing pharmacy. URL: ${pharmacy.service.url:${uhp.platform.url}} (default in pharmacy/uhp-module-defaults.yml).

Stock and controlled register (M19-003 / FE-256):

MethodPathPurpose
GET/stockList batches (productId, status, search)
GET/stock/pagePaginated search (search, status, page, size)
GET/stock/{stockBatchId}One batch
GET/stock/near-expiry?days=AVAILABLE batches expiring within days (FEFO)
POST/stock/adjustmentsCreate (receipt-like) or adjust a lot
POST/stock/stock-takeSet on-hand to countedQuantity (reason = COUNTED)
POST/stock/{stockBatchId}/quarantineStatus → QUARANTINED
POST/stock/{stockBatchId}/disposeStatus → DISPOSED
GET/stock/{stockBatchId}/movementsAppend-only movement trail
GET/controlled-registerAppend-only controlled-drug register
GET/controlled-register/pagePaginated register (search, page, size)

Procurement (M19-004 / FE-257):

MethodPathPurpose
POST/GET/PUT/DELETE/suppliers (+ /page, /{id})Supplier TypeScope CRUD (?facilityLocal= on create; void on DELETE)
GET/POST/purchase-orders (+ /page, /{id})Purchase-order list / create DRAFT / get
POST/purchase-orders/{id}/submitDRAFT → SUBMITTED
POST/purchase-orders/{id}/cancelDRAFT or SUBMITTED → CANCELLED
POST/goods-receiptsReceive against a PO; increments stock
GET/purchase-orders/{id}/receiptsReceipts for one PO
POST/supplier-returnsReturn a received batch; decrements stock
GET/reorder-suggestionsBelow-threshold suggestions (page)

Dispense / till (M19-005 / FE-258):

MethodPathPurpose
POST/dispenseSale / fill (PRESCRIPTION, IDENTIFIED_OTC, ANONYMOUS)
GET/dispense/pageTill history
GET/dispense/worklist?patientId=Open MEDICATION orders (empty until clinical Feign)
GET/dispense/{dispenseId}One sale
POST/dispense/{dispenseId}/returnsRestock or quarantine returned lines
GET/dispense/recall?lotNumber=On-hand + historical dispenses for a lot

Anonymous sales refuse controlled-schedule lines (409). Expired outbound is a hard 409. Identified controlled sales require pharmacist + witness sign-off.

Transfers and consumption (M19-006 / FE-259):

MethodPathPurpose
POST/GET/transfersCreate a DRAFT (source = session facility) / list visible transfers
GET/transfers/pagePaginated search (search, status, page, size)
GET/transfers/{transferId}One transfer
POST/transfers/{transferId}/dispatchDRAFT → DISPATCHED; TRANSFER_OUT on source
POST/transfers/{transferId}/receiveDISPATCHED / IN_TRANSIT → RECEIVED; TRANSFER_IN on dest (may create dest batch)
POST/transfers/{transferId}/cancelCancel DRAFT, or restock source after dispatch
POST/GET/consumptionConsume stock / list
GET/consumption/pagePaginated search (search, reason, page, size)

A transfer is one two-sided document: dispatch decrements the source ledger and receive increments the destination. In-transit stock belongs to the transfer, not to a silent qty edit on either side. Consumption reasons are the closed enum LAB_REAGENT | WARD_USE | ENCOUNTER | OTHER. Expired / non-AVAILABLE outbound is a hard 409.

See the generated API Reference (/api-reference) for request/response shapes (exchange.dto.pharmacy). Gate evidence: core/MILESTONE19.md.

Standalone listen port: ${PHARMACY_APP_PORT:${APP_PORT:8088}}.

Configuration & feature flags

SettingDefaultPurpose
PHARMACY_APP_PORT8088Standalone listen port when the module is run on its own
PHARMACY_SERVICE_URL${UHP_PLATFORM_URL}Where other services would reach pharmacy after a split
PHARMACY_DB_*platform DB_*Optional dedicated database when run standalone

Liquibase changelog: classpath:db/changelog/pharmacy.db.changelog-master.yaml. Hibernate default schema: pharmacy.

  • Clinical medication / prescriptions (M11-009) — the orders dispensing will fulfill
  • Charge capture (M13-007) — in-hospital charge on order completion; OTC SALE lines via BillingCommandClient
  • Procurement contracts — exchange.dto.procurement (shared with Health ERP)
  • Concept dictionary — MEDICAL conceptId validated over ConceptValidationClient
  • Lab (LIMS) — a sibling domain module; reagent use is recorded as ConsumptionReason.LAB_REAGENT (a later Feign hook from lab can call the same consumption API)
  • Access control — permission seeding and the pharmacy.catalog / pharmacy.stock / pharmacy.controlled / pharmacy.procurement / pharmacy.dispense / pharmacy.transfer route keys
  • Notification engine — near-expiry and below-reorder alerts via NotificationCommandClient

Patient merge re-point (M37-005)

POST /api/v1/pharmacy/patient-merges/repoint bulk-updates patient_id on dispense. Invoked by core's module orchestrator after M37-004; same retry semantics as lab and imaging when status is PARTIAL.

Why it is this way

MEDICAL products are concept-backed; NON_MEDICAL products are category-backed. Clinical drug identity lives in the concept dictionary (RxNorm via mappings); trade SKUs, barcodes, and NAFDAC/NDC identifiers are product-level mappings, not concept columns. Mixing the two kinds is refused at write.

Dispense and billing share one transaction for OTC. Stock decrement and ChargeSourceType.SALE via BillingCommandClient must commit together — a charge failure must not leave stock decremented. In-hospital prescription dispense stays on M13-007 at order completion, not at pharmacy till.

Procurement contracts live in exchange.dto.procurement. Pharmacy implements clinical SCM today; Health ERP will bind the same DTOs under /api/v1/procurement/* later. Supplier names are never free text — supplierId on every PO line.

Controlled substances never sell anonymously. Anonymous OTC keeps patientId null and refuses controlled lines with 409 CONTROLLED_ANONYMOUS; the controlled register is append-only with no public write API.

Traps

Expired outbound is a hard 409 (STOCK_EXPIRED) — not a warning. Quarantine or dispose the batch; decrementing expired stock breaks audit and regulatory trace.

Transfers are two-sided documents — dispatch decrements source, receive increments dest. Editing on-hand at both locations without the transfer state machine creates invisible in-transit stock.

Accession/catalog tests that mock lockByFacilityId hide missing format seeds — M10B-003C found the same class of bug: code paths that assume configuration exists without a way to create it.

Bean name collisions with ERP — goods receipt and procurement controllers need distinct @RestController names and JPA @Entity(name=…) when both modules load in one jar.

FEFO vs explicit batch — prescription dispense must respect batch selection rules; tests should cover both explicit lot pick and default FEFO, not only mocked batch locks.