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.
| Area | Migration | Purpose |
|---|---|---|
| Schema | 001 | CREATE SCHEMA IF NOT EXISTS pharmacy |
| Catalog / formulary | 002 | product_category, product_registration_scheme, product, product_barcode, product_registration, product_pack, formulary_entry |
| Inventory | 003 | stock_batch, stock_movement (append-only), controlled_drug_register (append-only), stock_reorder |
| Procurement | 004 | supplier, purchase_order / purchase_order_line, goods_receipt / goods_receipt_line, supplier_return |
| Dispense | M19-005 | Patient-record sale (no picker) / till search / anonymous; returns; lot recall; the PRODUCT.md moat |
| Transfers / consumption | 006 | stock_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).controlledScheduleis the enumUNSCHEDULED | 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_barcodeis a mapping table (not a column onproduct, notconcept_mapping). Unique per tenant among live rows. OptionalBarcodeSymbology(EAN_13,UPC_A,GTIN_14,CODE_128,INTERNAL). OptionalpackFactormust match a pack on the product.product_registration_schemeis 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_registrationmaps(product_id, scheme_id, number). Unique per tenant + scheme + number among live rows.product_packis name +factor× base units (ProductBaseUnitenum). Stock later stores base units;ProductService#toBaseUnits(productId, packIdOrNull, qty)multiplies exactly (qtyis already base units whenpackIdis 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 writesRECEIPT; a quantity change on an existing lot writesADJUSTMENT; a physical count writesSTOCK_TAKE/COUNTED; quarantine and disposal writeQUARANTINE/DISPOSAL. Later tickets reuseDISPENSEandRETURN. M19-006 writesTRANSFER_OUTon dispatch,TRANSFER_INon receive (and on cancel-after-dispatch restock), andCONSUMPTIONon 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
expiresAtis not null and falls withindays(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 positivestock_reorder.threshold). A notify failure is logged and never fails the stock write. Threshold0(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 whosecontrolledScheduleis notUNSCHEDULEDmoves.sku_nameandconceptIdare denormalized. There is no public write, update or delete API — reads are gatedpharmacy.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 asupplierId— 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 storesDRAFT.POST …/submitandPOST …/cancelare 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 (manufacturedAtset) must capture expiry (400 otherwise). - Supplier return decrements the named received batch via
StockService.decrement(RETURNmovement). Expired or non-AVAILABLE outbound is a hard 409 (the M19-003 rule). Reason is the closed enumNEAR_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 isthreshold − on-hand.
Key rules & invariants
- No
coreimport. ArchUnit (PharmacyArchitectureBoundaryTest) refusescom.zhenus.uhp.api.coreand 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 onpharmacy.catalog.read/pharmacy.catalog.write. Stock gates onpharmacy.stock.read/pharmacy.stock.write. The controlled register gates onpharmacy.controlled.read. Procurement gates onpharmacy.procurement.read/pharmacy.procurement.write. Dispense gates onpharmacy.dispense.read/pharmacy.dispense.write. Transfers and consumption sharepharmacy.transfer.read/pharmacy.transfer.write.AuthorizationCatalogexposes route keyspharmacy.stock,pharmacy.controlled,pharmacy.procurement,pharmacy.dispenseandpharmacy.transferso 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 (409STOCK_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):
| Method | Path | Purpose |
|---|---|---|
| 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}/barcodes | Barcode mapping |
| POST/GET/DELETE | /products/{id}/registrations | Registration mapping |
| POST/GET/PUT/DELETE | /products/{id}/packs | Pack conversion |
| POST/GET/PUT/DELETE | /product-categories | NON_MEDICAL categories |
| POST/GET/PUT/DELETE | /registration-schemes | Worldwide schemes (seeded on first GET) |
| POST/GET/PUT/DELETE | /formulary | Per-tenant MEDICAL formulary |
| GET | /formulary/by-product/{productId} | Live formulary row for a product (M19-008) |
| GET | /formulary/by-product/{productId}/on-formulary | Whether 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):
| Method | Path | Purpose |
|---|---|---|
| GET | /stock | List batches (productId, status, search) |
| GET | /stock/page | Paginated search (search, status, page, size) |
| GET | /stock/{stockBatchId} | One batch |
| GET | /stock/near-expiry?days= | AVAILABLE batches expiring within days (FEFO) |
| POST | /stock/adjustments | Create (receipt-like) or adjust a lot |
| POST | /stock/stock-take | Set on-hand to countedQuantity (reason = COUNTED) |
| POST | /stock/{stockBatchId}/quarantine | Status → QUARANTINED |
| POST | /stock/{stockBatchId}/dispose | Status → DISPOSED |
| GET | /stock/{stockBatchId}/movements | Append-only movement trail |
| GET | /controlled-register | Append-only controlled-drug register |
| GET | /controlled-register/page | Paginated register (search, page, size) |
Procurement (M19-004 / FE-257):
| Method | Path | Purpose |
|---|---|---|
| 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}/submit | DRAFT → SUBMITTED |
| POST | /purchase-orders/{id}/cancel | DRAFT or SUBMITTED → CANCELLED |
| POST | /goods-receipts | Receive against a PO; increments stock |
| GET | /purchase-orders/{id}/receipts | Receipts for one PO |
| POST | /supplier-returns | Return a received batch; decrements stock |
| GET | /reorder-suggestions | Below-threshold suggestions (page) |
Dispense / till (M19-005 / FE-258):
| Method | Path | Purpose |
|---|---|---|
| POST | /dispense | Sale / fill (PRESCRIPTION, IDENTIFIED_OTC, ANONYMOUS) |
| GET | /dispense/page | Till history |
| GET | /dispense/worklist?patientId= | Open MEDICATION orders (empty until clinical Feign) |
| GET | /dispense/{dispenseId} | One sale |
| POST | /dispense/{dispenseId}/returns | Restock 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):
| Method | Path | Purpose |
|---|---|---|
| POST/GET | /transfers | Create a DRAFT (source = session facility) / list visible transfers |
| GET | /transfers/page | Paginated search (search, status, page, size) |
| GET | /transfers/{transferId} | One transfer |
| POST | /transfers/{transferId}/dispatch | DRAFT → DISPATCHED; TRANSFER_OUT on source |
| POST | /transfers/{transferId}/receive | DISPATCHED / IN_TRANSIT → RECEIVED; TRANSFER_IN on dest (may create dest batch) |
| POST | /transfers/{transferId}/cancel | Cancel DRAFT, or restock source after dispatch |
| POST/GET | /consumption | Consume stock / list |
| GET | /consumption/page | Paginated 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
| Setting | Default | Purpose |
|---|---|---|
PHARMACY_APP_PORT | 8088 | Standalone 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.
Related features
- Clinical medication / prescriptions (M11-009) — the orders dispensing will fulfill
- Charge capture (M13-007) — in-hospital charge on order completion; OTC
SALElines viaBillingCommandClient - Procurement contracts —
exchange.dto.procurement(shared with Health ERP) - Concept dictionary — MEDICAL
conceptIdvalidated overConceptValidationClient - 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.transferroute 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.