Health ERP — How it works
Overview
The erp module is the platform's health-native back-office: chart of accounts, general ledger,
expenses and accounts payable, fixed assets, document routing, general procurement, and financial
posting integration with billing, workforce and pharmacy. It has no direct patient interface —
every coordination path is Feign to platform engines or shared contracts in exchange.
It is a domain module (com.zhenus.uhp.api.erp), so it imports only common and exchange and
reaches platform engines over Feign — the same call whether it is bundled in the single jar or split
into its own service later. Design: MILESTONE21_PLAN.md.
workforce is the structural reference. M21-001 is the skeleton; M21-002 adds the chart of accounts and general ledger.
| Ticket | Delivers | Status |
|---|---|---|
| M21-001 | Maven module, /api/v1/erp namespace, duty-split module descriptor, Feign context propagation, boundary tests, expense-approve segregation proof | done |
| M21-002 | Chart of accounts, accounting periods, double-entry journal posting | done |
| M21-003 | Expenses + accounts payable (workflow-approved) | done |
| M21-004 | Asset register — item / item_attribute / item_attribute_type, custody, depreciation | done |
| M21-005 | Document routing & hash-bound e-sign (M13-006 metadata; internal attestation — not qualified eIDAS) | done |
| M21-006 | General/non-clinical procurement (requisition → PO → receipt → three-way match) | done |
| M21-007 | Financial posting integration (billing, workforce, pharmacy → GL) | done |
| M21-008 | Milestone gate — MILESTONE21.md, Milestone21ErpIntegrationTest | done |
Frontend counterpart: FE-336 (module scaffold, nav entry, permission gating, finance role separation), then FE-337….
Procurement contracts live in exchange.dto.procurement (shared with pharmacy M19). The ERP procurement
engine will eventually bind ApiV1Paths.PROCUREMENT; pharmacy continues to serve clinical SCM under
/api/v1/pharmacy/*.
Data model & ownership
Owned tables will live under erp/src/main/resources/db/changelog/erp/. The master changelog is
deliberately named erp.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 erp |
| Chart of accounts | 002 | account — global-capable definition (tenant_id, optional facility_id) |
| Accounting periods | 003 | accounting_period — facility-owned open/close gate |
| General ledger | 004 | journal_entry + journal_line — immutable double-entry facts |
| Expense claims | 005 | expense_claim + expense_claim_line — facility-owned, workflow-approved |
| Vendors | 006 | vendor — global-capable definition shared with procurement |
| Vendor bills | 007 | vendor_bill + vendor_bill_line — accounts-payable facts |
| Item registry | 008 | item, item_attribute_type, item_attribute — Person-pattern asset register |
| Custody | 009 | asset_custody_event — append-only custody history |
| Depreciation | 010 | depreciation_schedule — straight-line GL posting via PostingService |
| Document routing | 011 | document_routing_config, document_routing — workflow-bound signatory routing |
| E-sign | 012 | signature — immutable hash-bound internal attestation |
| Signed-doc register | 013 | signed_document_register — append-only; supersession adds rows |
| Purchase requisition | 014 | purchase_requisition — M34-approved form registration |
| Purchase order | 015 | purchase_order + purchase_order_line — non-clinical PO facts |
| Goods receipt | 016 | goods_receipt + goods_receipt_line; optional links on vendor_bill |
Key rules & invariants
- No
coreimport, and no sibling domain-module import.ErpArchitectureBoundaryTestrefusescom.zhenus.uhp.api.coreand each ofworkforce,pharmacy, and other domain modules. Coordination is erp → platform only. - Permissions split by duty, not by table. The skeleton declares separate codes for submit vs
approve vs post (
erp.expense.submit≠erp.expense.approve≠erp.journal.post). A single coarseerp.writewould collapse segregation of duties — the first control any auditor tests. - Double-entry ledger (M21-002): relational
NUMERIC(19,4)money; unbalanced journal entries are rejected at write time; posted entries are immutable (corrections are reversals); closed accounting periods refuse posting; mixed-currency entries are refused; every entry carriessourceModule+sourceReference. - Posting on approval only (M21-003): expense claims and vendor bills post to the GL through
PostingServiceonly after approval; rejection posts nothing. Claimant self-approval is refused at the service layer. Duplicate vendor bill numbers per vendor are refused. Workflow routing usesErpWorkflowApprovalSupportwith optionalerp.approval.workflow-definition-id(local fall-through when unset). - Clinical vocabulary: expense categories, asset types, and similar health-facing categoricals use
conceptIdvalidated via Feign — not module-local name tables or free text. - Asset register (M21-004): thin
itemidentity; administrableitem_attribute_typerows with optionalItemAttributeSemantichooks (ASSET_CLASS,CUSTODIAN,LOCATION, …); attribute values in JSONB. Custodian/location changes go through append-onlyasset_custody_event, not direct attribute edits. Depreciation posts balanced journal entries throughPostingServiceand stops at salvage value. - Document e-sign (M21-005): routing config binds document type concept → workflow definition;
signature binds to SHA-256 of document bytes from M13-006 metadata (never document id alone).
signatureandsigned_document_registerare append-only. Only the routed signatory may sign. Verification fails when bytes change after signing. Internal attestation only — not qualified eIDAS/ESIGN. Form-shaped approvals use M34 DnD forms, not this ticket. - General procurement (M21-006): requisition capture/approval uses M34 DnD forms; ERP registers
approved submissions as
purchase_requisition. PO, goods receipt, and three-way match are ERP-owned.ErpClinicalProductGuardrefuses MEDICAL and formulary catalog products (route to M19 pharmacy). Over-receipt beyonderp.procurement.over-receipt-tolerance-percent(default 0) is refused. Vendor bill approval requires a successful three-way match whenpurchaseOrderIdandgoodsReceiptIdare set.
API (M21-001 … M21-006)
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/v1/erp/health | (none — module status) | Liveness / module identity |
| POST | /api/v1/erp/expense-claims | erp.expense.submit | Create draft expense claim |
| POST | /api/v1/erp/expense-claims/{id}/submit | erp.expense.submit | Submit for approval (starts workflow when configured) |
| POST | /api/v1/erp/expense-claims/{id}/approve | erp.expense.approve | Approve and post balanced journal entry |
| POST | /api/v1/erp/expense-claims/{id}/reject | erp.expense.approve | Reject without posting |
| POST | /api/v1/erp/expense-claims/{id}/cancel | erp.expense.submit | Cancel draft/pending claim |
| GET | /api/v1/erp/expense-claims | erp.expense.submit | List claims (?status= optional) |
| GET | /api/v1/erp/expense-claims/{id} | erp.expense.submit | Get one claim |
| POST | /api/v1/erp/vendors | erp.account.write | Create vendor (?facilityLocal=true optional) |
| PUT | /api/v1/erp/vendors/{id} | erp.account.write | Update vendor |
| GET | /api/v1/erp/vendors | erp.account.read | List vendors |
| GET | /api/v1/erp/vendors/{id} | erp.account.read | Get vendor |
| POST | /api/v1/erp/vendor-bills | erp.expense.submit | Create draft vendor bill |
| POST | /api/v1/erp/vendor-bills/{id}/submit | erp.expense.submit | Submit bill for approval |
| POST | /api/v1/erp/vendor-bills/{id}/approve | erp.expense.approve | Approve and post to payables/GL |
| POST | /api/v1/erp/vendor-bills/{id}/reject | erp.expense.approve | Reject without posting |
| POST | /api/v1/erp/vendor-bills/{id}/cancel | erp.expense.submit | Cancel draft/pending bill |
| GET | /api/v1/erp/vendor-bills | erp.account.read | List bills / ageing (?vendorId=, ?status=, ?ageingBucket=) |
| GET | /api/v1/erp/vendor-bills/{id} | erp.account.read | Get one bill |
| POST | /api/v1/erp/accounts | erp.account.write | Create chart-of-accounts entry (?facilityLocal=true for facility-local) |
| GET | /api/v1/erp/accounts | erp.account.read | List visible accounts (tenant + GLOBAL) |
| GET | /api/v1/erp/accounts/{accountId} | erp.account.read | Get one account |
| PUT | /api/v1/erp/accounts/{accountId} | erp.account.write | Update owned account |
| DELETE | /api/v1/erp/accounts/{accountId} | erp.account.write | Retire (void) account |
| GET | /api/v1/erp/accounts/{accountId}/balance | erp.account.read | Net balance for session facility |
| POST | /api/v1/erp/accounting-periods | erp.account.write | Open accounting period for session facility |
| GET | /api/v1/erp/accounting-periods | erp.account.read | List periods for session facility |
| GET | /api/v1/erp/accounting-periods/{id} | erp.account.read | Get one period |
| POST | /api/v1/erp/accounting-periods/{id}/close | erp.period.close | Close period — posting refused afterward |
| POST | /api/v1/erp/accounting-periods/{id}/reopen | erp.period.close | Reopen closed period |
| POST | /api/v1/erp/journal-entries | erp.journal.post | Post balanced journal entry |
| GET | /api/v1/erp/journal-entries | erp.account.read | List entries (?accountingPeriodId= optional) |
| GET | /api/v1/erp/journal-entries/{id} | erp.account.read | Get entry with lines |
| POST | /api/v1/erp/journal-entries/{id}/reverse | erp.journal.post | Post linked reversal |
| POST | /api/v1/erp/item-attribute-types | erp.asset.write | Create item attribute type |
| GET | /api/v1/erp/item-attribute-types | erp.asset.read | List attribute types in scope |
| DELETE | /api/v1/erp/item-attribute-types/{id} | erp.asset.write | Retire attribute type |
| POST | /api/v1/erp/items | erp.asset.write | Register asset item |
| GET | /api/v1/erp/items | erp.asset.read | List facility items |
| GET | /api/v1/erp/items/{id} | erp.asset.read | Get item with attributes |
| PUT | /api/v1/erp/items/{id} | erp.asset.write | Update item identity |
| POST | /api/v1/erp/items/{id}/attributes | erp.asset.write | Add non-custody attribute |
| POST | /api/v1/erp/items/{id}/custody-transfer | erp.asset.write | Transfer custody/location |
| GET | /api/v1/erp/items/{id}/custody-events | erp.asset.read | Custody history |
| POST | /api/v1/erp/items/{id}/dispose | erp.asset.write | Dispose item (stops depreciation) |
| POST | /api/v1/erp/items/{id}/depreciation-schedule | erp.asset.write | Create depreciation schedule |
| GET | /api/v1/erp/items/{id}/depreciation-schedule | erp.asset.read | Get item schedule |
| POST | /api/v1/erp/depreciation-schedules/{id}/run | erp.asset.write | Post one depreciation period |
| POST | /api/v1/erp/document-routing-configs | erp.document.route | Create document-type → workflow routing config |
| GET | /api/v1/erp/document-routing-configs | erp.document.route | List routing configs |
| PUT | /api/v1/erp/document-routing-configs/{id} | erp.document.route | Update routing config |
| DELETE | /api/v1/erp/document-routing-configs/{id} | erp.document.route | Void routing config |
| POST | /api/v1/erp/document-routings | erp.document.route | Route document for signature |
| GET | /api/v1/erp/document-routings | erp.document.route | List routings (?documentId=) |
| POST | /api/v1/erp/document-routings/{id}/sign | erp.document.sign | Capture hash-bound signature |
| POST | /api/v1/erp/document-routings/{id}/cancel | erp.document.route | Cancel pending routing |
| GET | /api/v1/erp/signatures | erp.document.route | List signatures (?documentId=) |
| POST | /api/v1/erp/signatures/{id}/verify | erp.document.route | Verify signature vs current bytes |
| GET | /api/v1/erp/signed-document-register | erp.document.route | List register entries (?documentId=) |
| POST | /api/v1/erp/signed-document-register/supersede | erp.document.route | Append supersession register row |
| POST | /api/v1/erp/purchase-requisitions | erp.procurement.requisition | Register approved M34 requisition |
| GET | /api/v1/erp/purchase-requisitions | erp.procurement.requisition | List requisitions (?status= optional) |
| GET | /api/v1/erp/purchase-requisitions/{id} | erp.procurement.requisition | Get one requisition |
| POST | /api/v1/erp/purchase-orders | erp.procurement.approve | Create draft purchase order |
| POST | /api/v1/erp/purchase-orders/from-requisition/{id} | erp.procurement.approve | Create PO from requisition |
| POST | /api/v1/erp/purchase-orders/{id}/submit | erp.procurement.approve | Submit purchase order |
| GET | /api/v1/erp/purchase-orders | erp.procurement.approve | List purchase orders |
| GET | /api/v1/erp/purchase-orders/{id} | erp.procurement.approve | Get one purchase order |
| POST | /api/v1/erp/goods-receipts | erp.procurement.receipt | Receive goods against a PO |
| GET | /api/v1/erp/goods-receipts | erp.procurement.receipt | List receipts (?purchaseOrderId=) |
| GET | /api/v1/erp/goods-receipts/{id} | erp.procurement.receipt | Get one receipt |
| GET | /api/v1/erp/procurement/three-way-match | erp.procurement.approve | Match PO + receipt + vendor bill totals |
Expense-claim capture form (FORM-110)
Claims are captured through the drag-and-drop Expense claim form, whose definition declares
the expense_claim submission handler (ExpenseClaimFormSubmissionHandler) instead of a backing
table. The filler provides claimant / claim date / description / amount / receipt; the handler
resolves the open accounting period for the claim date, the payable and expense accounts by
code (erp.expense-claim-form.*), and the currency from the tenant's
billing.default_currency global property, then raises a DRAFT expense_claim — submitting
into the approval flow stays the claimant's explicit act. A missing period, account or currency
fails the submission with a message naming the gap; the raw payload is retained for retry.
⚠ Expense-claim capture needs a chart of accounts and an open accounting period. Account codes
seed at the country ministry tenant (SEED-111) and resolve by code through the scoped account resolver;
the current calendar-year period opens automatically when a facility is created via
FacilityProvisioningHook / ErpAccountingPeriodProvisioner.
Module configuration (CONF-002)
Standalone and bundled runs load erp/src/main/resources/uhp-module-defaults.yml:
| Property | Env var | Purpose |
|---|---|---|
erp.service.url | ERP_SERVICE_URL | Loopback base URL for Feign clients targeting this module when extracted |
erp.approval.workflow-definition-id | ERP_APPROVAL_WORKFLOW_DEFINITION_ID | Optional workflow definition for expense/vendor-bill approvals; unset = local-only |
erp.procurement.over-receipt-tolerance-percent | ERP_PROCUREMENT_OVER_RECEIPT_TOLERANCE_PERCENT | Max over-receipt above ordered qty (default 0) |
erp.expense-claim-form.payable-account-code | ERP_EXPENSE_CLAIM_PAYABLE_ACCOUNT_CODE | Account code the expense-claim form handler accrues against (default 2100) |
erp.expense-claim-form.expense-account-code | ERP_EXPENSE_CLAIM_EXPENSE_ACCOUNT_CODE | Account code the form-captured claim line posts to (default 6100) |
erp.expense-claim-form.currency-property | ERP_EXPENSE_CLAIM_CURRENCY_PROPERTY | Global property the claim currency resolves from (default billing.default_currency) |
server.port (standalone) | ERP_APP_PORT | Default 8092 in standalone application.yml |
Database settings follow the same pattern as other domain modules (ERP_DB_* overrides when run
standalone; defaults to platform DB_* in the single jar).
Tests
| Test | Proves |
|---|---|
ErpApplicationTest | Spring context loads |
ErpArchitectureBoundaryTest | No illegal imports (core, sibling modules) |
ErpModuleDescriptorTest | Duty-split permissions declared; paths match ApiV1Paths.ERP |
ExpenseApprovalAccessTest | Approve endpoint requires erp.expense.approve; submit-only caller gets 403 |
ExpenseClaimServiceImplTest / VendorBillServiceImplTest | Approve posts via mocked PostingService; self-approve blocked; duplicate bill refused |
ErpPayablesContractsTest | Exchange DTO/enums for payables |
AccountServiceImplTest / PostingServiceImplTest | Chart-of-accounts scope + GL balance/period/currency invariants |
AccountControllerTest / AccountingPeriodControllerTest / JournalEntryControllerTest | REST delegation for M21-002 ledger surface |
ErpAssetContractsTest | Exchange DTO/enums for asset register |
ItemServiceImplTest / DepreciationServiceImplTest | Custody-direct-write refusal; depreciation stops at salvage |
ErpDocumentContractsTest | Exchange DTO/enums for document routing & e-sign |
SignatureServiceImplTest / DocumentRoutingServiceImplTest | Hash-bound sign; wrong signatory refused; verify fails on content change |
ErpProcurementContractsTest | Exchange DTO/enums for ERP procurement |
PurchaseRequisitionServiceImplTest / PurchaseOrderServiceImplTest / GoodsReceiptServiceImplTest / ThreeWayMatchServiceImplTest | Requisition registration; PO submit; over-receipt refusal; three-way match |
ErpClinicalProductGuardTest | MEDICAL/formulary products refused with M19 pointer |
ProcurementControllerTest | REST delegation for M21-006 procurement surface |
Related
MILESTONE21_PLAN.md— milestone design and ticket breakdowncore/MILESTONE21.md— gate invariants and integration test summarydocs/site/docs/engines/procurement.md— shared procurement contractserp/README.md— module-local quick reference
Why it is this way
Double-entry balance is enforced in one service, not by convention. PostingService rejects
unbalanced journals in the same transaction; corrections are reversals, not edits. Finance officers
need a single writer they can audit.
Posting runs pull from source modules over Feign rather than importing them. ERP is a domain
plug-in like pharmacy or lab — it owns the GL and reaches billing, workforce, and pharmacy only
through exchange.client.*. That keeps ArchUnit green and lets each module deploy independently later.
Workforce labour cost prefers calculated payroll when present (M17-018 / M17-021). Template-based
payroll runs expose GET /payroll-runs/posting-summary; ERP posts via WORKFORCE_PAYROLL. Statutory
deductions (PAYE, pension, NHF) are applied at calculate time from country-scoped rules — not a
separate ERP step. When no calculated run exists, M21-007 derives a labelled accrual from
engagement amount and attendance — explicitly not statutory payroll.
Clinical procurement stays in M19. ERP procurement refuses formulary/clinical product lines with a pointer to pharmacy SCM — the first time someone cannot buy a reagent quickly through pharmacy, the ERP PO looks like the answer, but the boundary must hold as a rule.
E-sign is hash-bound internal attestation. Signatures bind to document content hash via M13-006 metadata; verification fails when bytes change. This is attributable and auditable, not eIDAS-qualified.
Traps
⚠ Posting-run idempotency keys are global per period, not per run — PostingRunSource unique index
uq_posting_run_source_idempotency on (source_module, source_reference, accounting_period_id, facility_id). A rerun must not insert a second row; look up any prior outcome and return an in-memory
SKIPPED result instead.
⚠ Unconfigured adapters still write a SKIPPED row on first run — PostingRunServiceImpl. Pharmacy
and workforce adapters with erp.posting.*-enabled=false persist SKIPPED on the first attempt; the
second run must hit the idempotency lookup before the isConfigured() branch or you get a duplicate-key
failure.
⚠ Posting account resolution is facility-scoped — PostingAccountResolver. Chart accounts created
tenant-global (facilityLocal=false) are invisible to posting runs, which resolve
(tenantId, facilityId, code). Gate tests and finance setup must create facility-local accounts for
configured codes (1100, 4000, etc.).
⚠ Closed periods refuse automated runs identically to manual posting — PostingRunServiceImpl and
PostingServiceImpl. An unattended run that silently skips a closed period is worse than a loud refusal;
operators re-run posting after failures, so the period gate must apply to both paths.
⚠ Segregation of duties is permission-split, not role-named — expense submit vs approve vs journal post are separate grants. A super_admin walkthrough does not prove a submitter never sees an approve button; test as a non-admin submitter too.