Workforce — How it works
Overview
Zhenus Workforce is a separate domain plug-in module (com.zhenus.uhp.api.workforce) with
its own workforce PostgreSQL schema. It owns staff profiles, credentials, HR lookups, engagements,
shift patterns/rosters, assignments (lead assign + self-pick claim), leave/swap seams, attendance,
and staffing metrics. Platform engines (demographic, facility units, access-control, notification,
workflow) are reached only via Feign. Handoff: core/MILESTONE17.md.
Data model & ownership
Owned tables live under workforce/src/main/resources/db/changelog/workforce/ (not in core).
| Area | Migrations | Purpose |
|---|---|---|
| Staff / credentials | 002, 010–012, 015, 028, 029, 030, 031 | profiles, credentials, types, identifiers, date_hired; 028 drops denormalized credential.credential_type (TERM-008H); 029 drops denormalized shift_pattern_requirement.role_code (TERM-008I); 030 adds skill lookup and drops shift_pattern_requirement.skill_code (TERM-008Q); 031 adds credential_issuer lookup and drops free-text credential.issuer (TERM-008R) |
| Rostering | 013, 029, 030 | shift_pattern (+ requirements), roster, shift; 029 makes job_role_id the sole role discriminator on requirements; 030 makes skill_id the sole skill discriminator (nullable) |
| HR / assign / leave / attendance | 014 | job roles, salary grades/bands, leave, engagements, unit roster policy, assignments, swaps, attendance |
| Pay structure | 032–033 | job level, role/level→grade eligibility, salary band pay ranges, engagement job level NOT NULL |
| HRMS payroll + KYC | 034 | payroll templates (components + formula variables), pay period/run/payslip, identity document types, verification verdicts |
| Categorical seeds | 035, 036, 037 | six categoricals at the Global rung; 036 backfills the version 035 omitted (every row it seeded NPE'd on first edit); 037 seeds a job role per module — Pharmacist, Laboratory Scientist, Radiographer, Finance Officer, Human Resource Officer, Procurement Officer, Scheduling Officer, Programme Officer, Reporting Analyst, Forms Author, Workflow Designer, Interoperability Officer, Terminology Steward, Records Officer, Auditor, Asset Officer |
| Metrics views | 016 | v_shift_coverage, v_staffing_ratio |
| Credential gate | 017 | unit_roster_policy.require_valid_credential |
| Float eligibility | 023 | staff_float_eligibility (unit beyond home engagement) |
| On-call / standby | 024 | shift_type on pattern + shift |
| Bid / fairness | 025 | bid window + weekend/night quotas on unit policy |
| Agency / bank eligibility | 026 | eligibility_scope on pattern/shift; bank_eligible on staff |
| Labour-rule packs | 027 | labour_rule_pack + optional FK on unit roster policy |
Core still owns Unit / UnitLeader / DepartmentLead (facility engine, M17-004A) — workforce
references units by logical unitId.
Key rules & invariants
- Domain module must not import
corepackages (ArchUnit in the workforce module). - Credential expiry may flag roles / enqueue alerts; it never auto-revokes mid-shift and never
auto-releases an assignment. Release is explicit (
releaseAPI). - Assignments and coverage are keyed by
jobRoleId; patterns/shifts carryunitId. - Unit roster policy (
LEAD_ONLY/SELF_PICK/HYBRID, claim ruleINSTANT/ …) governs open rota. - Credential gate (M17-007): when
require_valid_credentialis true on the unit policy, assign and claim require at least one ACTIVE credential whoseexpiresOnis null or not before today. Default is false (opt-in). - Credential issuer (TERM-008R):
credential.credential_issuer_idis the optional issuing-body FK; the legacy free-textissuerVARCHAR was dropped in migration 031. Resolve display names viaGET /api/v1/workforce/credential-issuers/{id}. Migration 031 HALTs when a non-null issuer cannot be linked to a CredentialIssuer row. - Credential type (TERM-008H):
credential.credential_type_idis the sole type discriminator; the legacy denormalizedcredential_typeVARCHAR was dropped in migration 028. New writes requirecredentialTypeId; resolve display names viaGET /api/v1/workforce/credential-types/{id}. Migration 028 HALTs when any live row has nullcredential_type_idor when the duplicate string disagrees withCredentialType.name. - Shift pattern requirements (TERM-008I):
shift_pattern_requirement.job_role_idis the sole role discriminator; the legacy denormalizedrole_codeVARCHAR was dropped in migration 029. New writes requirejobRoleId; resolve display names viaGET /api/v1/workforce/job-roles/{id}. Migration 029 HALTs when any live row has nulljob_role_id(JobRolehasnameonly — no stable code column, sorole_code→name reconciliation is not enforced). - Shift pattern requirements — skill (TERM-008Q):
shift_pattern_requirement.skill_idis the sole skill discriminator (optional); the legacy denormalizedskill_codeVARCHAR was dropped in migration 030. New writes useskillIdwhen a specialty is required; resolve display viaGET /api/v1/workforce/skills/{id}. Migration 030 creates tenant-scopedworkforce.skillrows from distinct legacy codes (tenant viashift_pattern) and HALTs when any non-nullskill_codecannot be linked. Manage skills viaPOST/GET/DELETE /api/v1/workforce/skills(void/unvoid). - Staffing metrics: coverage = assigned/required per shift×jobRole; staffing ratio = ACTIVE engagements / required headcount on in-progress shifts (patient:nurse deferred until census).
- Home org access (M17-014 Policy B): engagement create/promote projects the home unit into core
user_department_accessvia Feign (UserDepartmentAccessClientrotate-home); resign/retire ends home. Cover grants are not written here. Form lists still filter in core — workforce only syncs the access fact. - Engagement at registration (M17-015):
POST /staff-registrationsrequires an initial engagement (departmentId,unitId,jobRoleId,salaryGradeId,salaryBandId,amount). That createsStaffEngagement(with requireddepartmentId, validated against the unit) and rotates home — registration no longer writesregistration.departmentAccess. Job role / salary grade / band are FKs to managed lookups (create · void · unvoid). - Live roster SPI (M17-015):
WorkforceCurrentOrgAssignmentSourceimplements exchangeCurrentOrgAssignmentSourceso form org-visibility can include units the staff is assigned to right now, without core importing workforce. - Float / multi-unit eligibility (M17-008):
StaffFloatEligibilityrows grant roster eligibility on units beyond the singular ACTIVE home engagement. Assign and claim share one gate: home unit + engagement job role, or an open float row for the shift unit/date (optional restrictedjobRoleId; null means engagement job role). Float is not a second engagement and does not write coveruser_department_access— promote/resign leave float rows alone; form cover while on shift remains the live-roster SPI. List API:GET /staff-float-eligibilities?staffProfileId=returns rows for one staff member (optionalincludeVoided);GET ?unitId=returns the unit reverse view for FE-357 — mutually exclusive query params; omitting both or supplying both yields 400. - On-call / standby (M17-009): patterns and generated shifts carry
shiftType(STANDARD|ON_CALL|STANDBY). Per-shift coverage/shortfall (and claim) uses the same required-vs-ASSIGNED math for every type. Unit staffing-ratio required headcount counts STANDARD (on-floor) shifts only — on-call/standby are availability layers, not floor demand. Coverage metric lines exposeshiftTypefor reporting filters. - Bid windows / fairness (M17-010): when
claimRule=BID_WINDOW, claims are allowed only betweenstartsAt − bidWindowOpensMinutesBeforeandstartsAt − bidWindowClosesMinutesBefore. OptionalmaxWeekendClaimsPerPeriod/maxNightClaimsPerPeriodoverfairnessPeriodDays(default 28) reject over-quota self-picks. Weekend = Sat/Sun start; night = start hour in[19,24) ∪ [0,7). Lead assign is not gated by the bid window. - Agency / bank eligibility (M17-011): patterns and generated shifts carry
eligibilityScope(UNIT|FACILITY|BANK). Assign and claim enforce: UNIT — home engagement or float; FACILITY — ACTIVE engagement + stafffacilityIdmatches the shift; BANK — ACTIVE engagement +staff_profile.bank_eligible. Job role must still match the ACTIVE engagement job role. Roster generate copies the pattern scope onto each shift. - Country labour packs (M17-012):
labour_rule_packholds min rest between shifts, optional max hours/day, max hours/week, and optional overtime weekly threshold. Seeded GLOBAL packsUK/US/CA/NG(illustrative defaults). Unit roster policy may setlabourRulePackId(null = no labour gate). Assign/claim then reject insufficient rest or over daily/weekly caps (OT threshold, when set and lower than max week, is the weekly ceiling). - Non-clinical staff portal (M17-013): read-aggregation BFF under
/staff-portal/**, always scoped to the caller'spersonId/staffProfileId(ID-001). Landing target:user_type.provider=true→CLINICIAN_WORKSPACE(M16); otherwiseSTAFF_PORTAL. Surfaces profile, engagements, shifts, open-rota (home-unit shortfall), leave, attendance, credentials (expiry warnings), and identifiers. Paired FE: FE-188.
Job level and pay structure (M17-016)
Before this, salary_band named a range it did not record and staff_engagement.amount was checked
against nothing beyond "the band belongs to the grade". A Junior Cleaner could be engaged on the Chief
Medical Officer's grade at any figure someone typed, and the platform would accept it, store it, and
pay from it. Four tables now answer that:
| Table | Answers |
|---|---|
job_level | Seniority — Junior, Mid, Senior. Carries a rank |
job_role_level_grade | Eligibility: which grade a role at a level may draw |
salary_band_pay_range | Money: what a band pays, where, in what currency, when |
staff_engagement.job_level_id | The level an engagement was made at |
Why eligibility and money are separate tables
The obvious shape is one row carrying role, level, grade, band and min/max. It fails three ways:
- A range attached to a row that also names a job role means "Band 2" pays differently depending on who is looking at it — the band stops being a fact about the pay structure. Oracle HCM (grade rate), Workday (compensation grade profile) and SAP all put the range on the grade or band and vary it by location, currency and effective date, never by job role.
- It restates itself. 20 roles × 5 bands is 100 rows carrying five ranges, which drift the first time four of them are edited.
- It cannot be revised. Ranges are re-based most years; with the range pinned to one row and no effective dating, this year's figures retroactively invalidate last year's engagements.
rank is required
"Junior is below Senior" has to be a comparison the platform can make — promotion checks and seniority rules need an ordinal, and a name cannot be ordered. Two levels sharing a rank cannot be ordered at all, so a partial unique index refuses it and the service reports the collision by name rather than as a bare 500.
Location: divisions, not a state column
A pay range hangs off metadata.division, the platform's variable-depth administrative tree.
There is deliberately no state or province column: ISO 3166-2 defines no fixed rung — Nigeria
has states, Kenya counties, Canada provinces, France regions — which is exactly why the platform
models depth as data (see Metadata).
Resolution is most specific wins, read off the facility's materialized divisionPath
(/1/38/512/):
- A range naming no division is the fallback, applying wherever nothing more specific does.
- A range naming a division applies when that division is on the facility's ancestry, and the deepest such range governs — so Lagos can override the national figure without every band being restated per state.
- A range for somewhere else does not apply at all. Kano's figures must not govern a Lagos facility just because they are the only ones recorded.
- Ids are matched whole, not as substrings: division 3 is not division 38.
⚠ workforce holds division_id as a plain column with no foreign key — it is a domain module
and does not reach into core's schema. The facility's path is read through FacilityClient.
The range is resolved on the engagement's start date
Not on today. A hire backdated to March, validated in August, would otherwise be judged against figures that did not exist when the decision was made — and re-basing salaries would retroactively invalidate every engagement made under the old ones.
Starting a tenant off (M17-016B)
POST /api/v1/workforce/pay-structure/starter creates the levels, grades, bands and pay ranges a
tenant needs before anyone can be engaged, and maps every existing job role onto them.
Why it exists: the model alone does not unblock registration. staff_engagement requires a grade
and a band, and now also refuses a band with no pay range — so a tenant with no rows cannot register
anyone at all. A pay control without seed data is a locked door with a better lock.
- Idempotent. Anything already present is left alone and reported as created-zero, so a second run reads as "nothing to do" rather than tripping the unique rank index halfway through.
- Levels are matched by name, not rank: a tenant that renamed Mid to Intermediate and kept rank 20 would otherwise collide.
- The currency is never invented. It comes from CONF-001's
billing.default_currencyand must name a registered ISO 4217 currency; unset or unregistered is a 400 with what to do. Seeding in a guessed currency would put wrong money on every engagement that follows, invisibly. - Ranges are backdated a year and left unlocated. A range is judged on the engagement's start date, so "effective today" would refuse a hire backdated even a week; unlocated is the only honest default before anyone has said where their facilities are.
- The amounts are provisional and say so in the response's
outstandinglist.
What an engagement is checked against
(jobRoleId, jobLevelId, salaryGradeId)exists injob_role_level_grade.- The band belongs to the grade.
- A range governs that band, at that facility's location, on
startDate. amountfalls inside it, andcurrencyCode— when supplied — matches the range's. Comparing an amount against a range in another currency passes or fails by accident.
A band with no range configured is refused, not waved through. Accepting an unvalidated amount is the exact defect this closes, so an unconfigured band is reported as the setup gap it is.
⚠ staff_engagement.job_level_id is nullable in the column but required by the service.
Engagements recorded before M17-016 have no level, and refusing to load them would take the staff list
down; new ones cannot be created without it.
Staff self-service: my signature (M13-006C)
PUT /api/v1/workforce/staff-portal/me/signature?documentId=… — the first write on the staff
portal, which was ten GETs and nothing else.
The subject is the caller. It comes from CurrentStaffResolver.requireCallerStaffProfileId(), and
there is deliberately no staff id in the path or body: a self-service endpoint that accepted one would
be an edit-anyone API wearing a self-service label — and the thing being edited is the image every
clinician sees on that provider's encounters.
It takes a document id, never a URL. That is the M13-006B fix: the signature used to be free text
handed straight to the encounter screen's <img src>, so anyone able to write the attribute controlled
an image request from every clinician who opened the encounter. What cannot be expressed cannot be
pointed at an attacker's host.
The flow is two ordinary steps rather than a special case:
POST /api/v1/document/documentswithstaffProfileId— the signature is a document like any other: checksum, size limit, scan gate, guarded download.PUT /staff-portal/me/signature?documentId=….
Person is never touched directly. The portal calls a provider-scoped endpoint
(PUT /demographic/providers/{providerId}/signature) and core resolves provider → person itself, so no
module holds a person id to set a signature — and none can reach the rest of the person record by
having one.
Only a CLEAN document may become a signature. Staff with no provider record are refused outright: a
signature is rendered against encounters they signed, so someone with nothing to sign gets a clear
refusal rather than a silent no-op.
API
M17-016 adds /api/v1/workforce/job-levels, /api/v1/workforce/job-role-level-grades
(filterable by jobRoleId + jobLevelId — what the registration form's grade picker calls, so a user
is never offered a grade the save would reject) and /api/v1/workforce/salary-band-pay-ranges, whose
/effective?salaryBandId=&facilityId=&on= answers 204 when no range is configured rather than a
200 with a null the caller might render as zero.
- Pay structure (M17-016): job levels, role/level→grade eligibility, and currencied effective-dated
salary_band_pay_rangerows govern engagement amounts. Starter provisioning viaPOST /pay-structure/starter. - HRMS payroll (D16 / M17-017):
payroll_templatedeclares components and formula variables as declarative strings — never executable code.POST /payroll-runs/{id}/calculategeneratespayslip+payslip_linerows from active engagements overlapping the pay period (proratedENGAGEMENT_AMOUNT/BASE_SALARY), then applies country statutory rules (M17-021). - Statutory deductions (M17-021):
payroll_statutory_rule+ optional PAYE bands per country. Flat-rate pension/NHF and progressive PAYE appendSTAT_*deduction lines at calculate time. Rules are CRUD (/payroll-statutory-rules); kinds and basis are enums — never free text. - Payroll approval (M17-019):
POST /payroll-runs/{id}/approveon a DRAFT run with payslips sets runAPPROVEDand payslipsFINAL. Recalculation remains on DRAFT only. - POSTED lifecycle (M17-020):
POST /payroll-runs/{id}/mark-posted(APPROVED→POSTED) — invoked by ERP afterWORKFORCE_PAYROLLjournals; idempotent if already posted. - Payroll GL seam (M17-018):
GET /payroll-runs/posting-summary?facilityId=&from=&to=returns gross/net totals from the latest approved run for an exact pay-period window. ERP consumes this viaWorkforcePayrollPostingClient; the labelled labour accrual is skipped when payroll exists. - KYC (D5 / KYC-001):
IdentityVerificationProviderandCredentialVerificationProviderSPIs route by country with a simulated fallback (mirrors billing's gateway router). Identity document type is TypeScope CRUD (identity_document_type), not an enum; a country-scoped row may optionally name an issuing authority viacredential_issuer_id(KYC-002 — reuses the credential-issuer registry, nullable for global types such as passport).staff_identity_verificationstores verdict, issuer and timestamp only — never a copy of the ID document.
Endpoint groups under /api/v1/workforce — staff, credentials, registrations, shift-patterns,
rosters, job-roles, salary-, leave-, staff-engagements, staff-float-eligibilities,
unit-roster-policies, labour-rule-packs, staff-portal, shift-assignments, claim/coverage,
shift-swaps, leave-requests, attendance, metrics (/metrics/shift-coverage,
/metrics/staffing-ratio, /metrics/summary), payroll-templates, pay-periods, payroll-runs,
payroll-statutory-rules, payslips, identity-document-types, identity-verifications`.
Feign: exchange.client.workforce.WorkforceMetricsClient (read-only metrics);
exchange.client.facility.FacilityUnitClient for unit/leader reads in core.
See the API Reference when OpenAPI is generated for the workforce deployable.
Configuration & feature flags
| Setting | Purpose |
|---|---|
workforce.credential.expiry-alert-days | Days before expiry to alert (default 30) |
workforce.credential.expiry-scan.enabled | Scheduled expiry scan |
workforce.approval.workflow-definition-id | Optional workflow definition for leave/swap approvals |
| Feign URLs | notification / accesscontrol / demographic / workflow service URLs (fallback uhp.platform.url) |
Related features
- Facility (units & leaders), Demographic (person/provider), Access control (roles), Notification (expiry alerts), Workflow (leave/swap approval seam).
Why it is this way
Staff registration never writes person rows directly. Demographic person/provider creation stays in the demographic engine; workforce links engagements, credentials, and rosters to validated ids over Feign.
Credential types and job roles are TypeScope CRUD — not string columns. role_code duplicates
were dropped (TERM-008I); display resolves through JobRole lookup.
Payroll calculation (M17-017), approval (M17-019), and GL posting (M17-018) combine template
components with country statutory schedules (M17-021). When an approved run exists for the
accounting window, ERP posts gross expense / net liability via WORKFORCE_PAYROLL; otherwise M21-007
still posts the labelled labour accrual from engagement + attendance.
Identity verification stores verdict and metadata only — never a copy of the ID document image; KYC evidence stays in the document store seam.
Traps
⚠ Background Feign calls need service identity (SEC-018) — scheduled expiry scans and roster
jobs run off the request thread; RequestContextHolder is empty unless the service-account filter
propagates context.
⚠ WorkflowApprovalSupport silently skips unknown subject types — new approval routes need a
registered SubjectType (WORKFLOW-SUBJECT-001) or approvals appear to succeed while doing nothing.
⚠ Recalculation wipes payslips — only DRAFT runs accept POST …/calculate; approving locks
the run and finalises payslip status.
⚠ Statutory rules resolve by template country_code (default NGA when unset) and pay-period end
date — wrong country on the template skips or mis-applies deductions.
⚠ Engagement amount is not validated against salary bands today (GAP-005 context) — finance accruals derive from stored engagement figures; garbage in propagates to ERP posting runs.
⚠ Shift coverage metrics are read-only Feign (WorkforceMetricsClient) — do not mutate roster
state through the metrics endpoints; ERP posting adapters consume summaries, not raw attendance rows.