Skip to main content

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.

TicketDeliversStatus
M21-001Maven module, /api/v1/erp namespace, duty-split module descriptor, Feign context propagation, boundary tests, expense-approve segregation proofdone
M21-002Chart of accounts, accounting periods, double-entry journal postingdone
M21-003Expenses + accounts payable (workflow-approved)done
M21-004Asset register — item / item_attribute / item_attribute_type, custody, depreciationdone
M21-005Document routing & hash-bound e-sign (M13-006 metadata; internal attestation — not qualified eIDAS)done
M21-006General/non-clinical procurement (requisition → PO → receipt → three-way match)done
M21-007Financial posting integration (billing, workforce, pharmacy → GL)done
M21-008Milestone gate — MILESTONE21.md, Milestone21ErpIntegrationTestdone

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.

AreaMigrationPurpose
Schema001CREATE SCHEMA IF NOT EXISTS erp
Chart of accounts002account — global-capable definition (tenant_id, optional facility_id)
Accounting periods003accounting_period — facility-owned open/close gate
General ledger004journal_entry + journal_line — immutable double-entry facts
Expense claims005expense_claim + expense_claim_line — facility-owned, workflow-approved
Vendors006vendor — global-capable definition shared with procurement
Vendor bills007vendor_bill + vendor_bill_line — accounts-payable facts
Item registry008item, item_attribute_type, item_attribute — Person-pattern asset register
Custody009asset_custody_event — append-only custody history
Depreciation010depreciation_schedule — straight-line GL posting via PostingService
Document routing011document_routing_config, document_routing — workflow-bound signatory routing
E-sign012signature — immutable hash-bound internal attestation
Signed-doc register013signed_document_register — append-only; supersession adds rows
Purchase requisition014purchase_requisition — M34-approved form registration
Purchase order015purchase_order + purchase_order_line — non-clinical PO facts
Goods receipt016goods_receipt + goods_receipt_line; optional links on vendor_bill

Key rules & invariants

  • No core import, and no sibling domain-module import. ErpArchitectureBoundaryTest refuses com.zhenus.uhp.api.core and each of workforce, 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.submiterp.expense.approveerp.journal.post). A single coarse erp.write would 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 carries sourceModule + sourceReference.
  • Posting on approval only (M21-003): expense claims and vendor bills post to the GL through PostingService only after approval; rejection posts nothing. Claimant self-approval is refused at the service layer. Duplicate vendor bill numbers per vendor are refused. Workflow routing uses ErpWorkflowApprovalSupport with optional erp.approval.workflow-definition-id (local fall-through when unset).
  • Clinical vocabulary: expense categories, asset types, and similar health-facing categoricals use conceptId validated via Feign — not module-local name tables or free text.
  • Asset register (M21-004): thin item identity; administrable item_attribute_type rows with optional ItemAttributeSemantic hooks (ASSET_CLASS, CUSTODIAN, LOCATION, …); attribute values in JSONB. Custodian/location changes go through append-only asset_custody_event, not direct attribute edits. Depreciation posts balanced journal entries through PostingService and 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). signature and signed_document_register are 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. ErpClinicalProductGuard refuses MEDICAL and formulary catalog products (route to M19 pharmacy). Over-receipt beyond erp.procurement.over-receipt-tolerance-percent (default 0) is refused. Vendor bill approval requires a successful three-way match when purchaseOrderId and goodsReceiptId are set.

API (M21-001 … M21-006)

MethodPathPermissionPurpose
GET/api/v1/erp/health(none — module status)Liveness / module identity
POST/api/v1/erp/expense-claimserp.expense.submitCreate draft expense claim
POST/api/v1/erp/expense-claims/{id}/submiterp.expense.submitSubmit for approval (starts workflow when configured)
POST/api/v1/erp/expense-claims/{id}/approveerp.expense.approveApprove and post balanced journal entry
POST/api/v1/erp/expense-claims/{id}/rejecterp.expense.approveReject without posting
POST/api/v1/erp/expense-claims/{id}/cancelerp.expense.submitCancel draft/pending claim
GET/api/v1/erp/expense-claimserp.expense.submitList claims (?status= optional)
GET/api/v1/erp/expense-claims/{id}erp.expense.submitGet one claim
POST/api/v1/erp/vendorserp.account.writeCreate vendor (?facilityLocal=true optional)
PUT/api/v1/erp/vendors/{id}erp.account.writeUpdate vendor
GET/api/v1/erp/vendorserp.account.readList vendors
GET/api/v1/erp/vendors/{id}erp.account.readGet vendor
POST/api/v1/erp/vendor-billserp.expense.submitCreate draft vendor bill
POST/api/v1/erp/vendor-bills/{id}/submiterp.expense.submitSubmit bill for approval
POST/api/v1/erp/vendor-bills/{id}/approveerp.expense.approveApprove and post to payables/GL
POST/api/v1/erp/vendor-bills/{id}/rejecterp.expense.approveReject without posting
POST/api/v1/erp/vendor-bills/{id}/cancelerp.expense.submitCancel draft/pending bill
GET/api/v1/erp/vendor-billserp.account.readList bills / ageing (?vendorId=, ?status=, ?ageingBucket=)
GET/api/v1/erp/vendor-bills/{id}erp.account.readGet one bill
POST/api/v1/erp/accountserp.account.writeCreate chart-of-accounts entry (?facilityLocal=true for facility-local)
GET/api/v1/erp/accountserp.account.readList visible accounts (tenant + GLOBAL)
GET/api/v1/erp/accounts/{accountId}erp.account.readGet one account
PUT/api/v1/erp/accounts/{accountId}erp.account.writeUpdate owned account
DELETE/api/v1/erp/accounts/{accountId}erp.account.writeRetire (void) account
GET/api/v1/erp/accounts/{accountId}/balanceerp.account.readNet balance for session facility
POST/api/v1/erp/accounting-periodserp.account.writeOpen accounting period for session facility
GET/api/v1/erp/accounting-periodserp.account.readList periods for session facility
GET/api/v1/erp/accounting-periods/{id}erp.account.readGet one period
POST/api/v1/erp/accounting-periods/{id}/closeerp.period.closeClose period — posting refused afterward
POST/api/v1/erp/accounting-periods/{id}/reopenerp.period.closeReopen closed period
POST/api/v1/erp/journal-entrieserp.journal.postPost balanced journal entry
GET/api/v1/erp/journal-entrieserp.account.readList entries (?accountingPeriodId= optional)
GET/api/v1/erp/journal-entries/{id}erp.account.readGet entry with lines
POST/api/v1/erp/journal-entries/{id}/reverseerp.journal.postPost linked reversal
POST/api/v1/erp/item-attribute-typeserp.asset.writeCreate item attribute type
GET/api/v1/erp/item-attribute-typeserp.asset.readList attribute types in scope
DELETE/api/v1/erp/item-attribute-types/{id}erp.asset.writeRetire attribute type
POST/api/v1/erp/itemserp.asset.writeRegister asset item
GET/api/v1/erp/itemserp.asset.readList facility items
GET/api/v1/erp/items/{id}erp.asset.readGet item with attributes
PUT/api/v1/erp/items/{id}erp.asset.writeUpdate item identity
POST/api/v1/erp/items/{id}/attributeserp.asset.writeAdd non-custody attribute
POST/api/v1/erp/items/{id}/custody-transfererp.asset.writeTransfer custody/location
GET/api/v1/erp/items/{id}/custody-eventserp.asset.readCustody history
POST/api/v1/erp/items/{id}/disposeerp.asset.writeDispose item (stops depreciation)
POST/api/v1/erp/items/{id}/depreciation-scheduleerp.asset.writeCreate depreciation schedule
GET/api/v1/erp/items/{id}/depreciation-scheduleerp.asset.readGet item schedule
POST/api/v1/erp/depreciation-schedules/{id}/runerp.asset.writePost one depreciation period
POST/api/v1/erp/document-routing-configserp.document.routeCreate document-type → workflow routing config
GET/api/v1/erp/document-routing-configserp.document.routeList routing configs
PUT/api/v1/erp/document-routing-configs/{id}erp.document.routeUpdate routing config
DELETE/api/v1/erp/document-routing-configs/{id}erp.document.routeVoid routing config
POST/api/v1/erp/document-routingserp.document.routeRoute document for signature
GET/api/v1/erp/document-routingserp.document.routeList routings (?documentId=)
POST/api/v1/erp/document-routings/{id}/signerp.document.signCapture hash-bound signature
POST/api/v1/erp/document-routings/{id}/cancelerp.document.routeCancel pending routing
GET/api/v1/erp/signatureserp.document.routeList signatures (?documentId=)
POST/api/v1/erp/signatures/{id}/verifyerp.document.routeVerify signature vs current bytes
GET/api/v1/erp/signed-document-registererp.document.routeList register entries (?documentId=)
POST/api/v1/erp/signed-document-register/supersedeerp.document.routeAppend supersession register row
POST/api/v1/erp/purchase-requisitionserp.procurement.requisitionRegister approved M34 requisition
GET/api/v1/erp/purchase-requisitionserp.procurement.requisitionList requisitions (?status= optional)
GET/api/v1/erp/purchase-requisitions/{id}erp.procurement.requisitionGet one requisition
POST/api/v1/erp/purchase-orderserp.procurement.approveCreate draft purchase order
POST/api/v1/erp/purchase-orders/from-requisition/{id}erp.procurement.approveCreate PO from requisition
POST/api/v1/erp/purchase-orders/{id}/submiterp.procurement.approveSubmit purchase order
GET/api/v1/erp/purchase-orderserp.procurement.approveList purchase orders
GET/api/v1/erp/purchase-orders/{id}erp.procurement.approveGet one purchase order
POST/api/v1/erp/goods-receiptserp.procurement.receiptReceive goods against a PO
GET/api/v1/erp/goods-receiptserp.procurement.receiptList receipts (?purchaseOrderId=)
GET/api/v1/erp/goods-receipts/{id}erp.procurement.receiptGet one receipt
GET/api/v1/erp/procurement/three-way-matcherp.procurement.approveMatch 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:

PropertyEnv varPurpose
erp.service.urlERP_SERVICE_URLLoopback base URL for Feign clients targeting this module when extracted
erp.approval.workflow-definition-idERP_APPROVAL_WORKFLOW_DEFINITION_IDOptional workflow definition for expense/vendor-bill approvals; unset = local-only
erp.procurement.over-receipt-tolerance-percentERP_PROCUREMENT_OVER_RECEIPT_TOLERANCE_PERCENTMax over-receipt above ordered qty (default 0)
erp.expense-claim-form.payable-account-codeERP_EXPENSE_CLAIM_PAYABLE_ACCOUNT_CODEAccount code the expense-claim form handler accrues against (default 2100)
erp.expense-claim-form.expense-account-codeERP_EXPENSE_CLAIM_EXPENSE_ACCOUNT_CODEAccount code the form-captured claim line posts to (default 6100)
erp.expense-claim-form.currency-propertyERP_EXPENSE_CLAIM_CURRENCY_PROPERTYGlobal property the claim currency resolves from (default billing.default_currency)
server.port (standalone)ERP_APP_PORTDefault 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

TestProves
ErpApplicationTestSpring context loads
ErpArchitectureBoundaryTestNo illegal imports (core, sibling modules)
ErpModuleDescriptorTestDuty-split permissions declared; paths match ApiV1Paths.ERP
ExpenseApprovalAccessTestApprove endpoint requires erp.expense.approve; submit-only caller gets 403
ExpenseClaimServiceImplTest / VendorBillServiceImplTestApprove posts via mocked PostingService; self-approve blocked; duplicate bill refused
ErpPayablesContractsTestExchange DTO/enums for payables
AccountServiceImplTest / PostingServiceImplTestChart-of-accounts scope + GL balance/period/currency invariants
AccountControllerTest / AccountingPeriodControllerTest / JournalEntryControllerTestREST delegation for M21-002 ledger surface
ErpAssetContractsTestExchange DTO/enums for asset register
ItemServiceImplTest / DepreciationServiceImplTestCustody-direct-write refusal; depreciation stops at salvage
ErpDocumentContractsTestExchange DTO/enums for document routing & e-sign
SignatureServiceImplTest / DocumentRoutingServiceImplTestHash-bound sign; wrong signatory refused; verify fails on content change
ErpProcurementContractsTestExchange DTO/enums for ERP procurement
PurchaseRequisitionServiceImplTest / PurchaseOrderServiceImplTest / GoodsReceiptServiceImplTest / ThreeWayMatchServiceImplTestRequisition registration; PO submit; over-receipt refusal; three-way match
ErpClinicalProductGuardTestMEDICAL/formulary products refused with M19 pointer
ProcurementControllerTestREST delegation for M21-006 procurement surface
  • MILESTONE21_PLAN.md — milestone design and ticket breakdown
  • core/MILESTONE21.md — gate invariants and integration test summary
  • docs/site/docs/engines/procurement.md — shared procurement contracts
  • erp/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 runPostingRunSource 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 runPostingRunServiceImpl. 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-scopedPostingAccountResolver. 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 postingPostingRunServiceImpl 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.