Skip to main content

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).

AreaMigrationsPurpose
Staff / credentials002, 010–012, 015, 028, 029, 030, 031profiles, 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)
Rostering013, 029, 030shift_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 / attendance014job roles, salary grades/bands, leave, engagements, unit roster policy, assignments, swaps, attendance
Pay structure032–033job level, role/level→grade eligibility, salary band pay ranges, engagement job level NOT NULL
HRMS payroll + KYC034payroll templates (components + formula variables), pay period/run/payslip, identity document types, verification verdicts
Categorical seeds035, 036, 037six 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 views016v_shift_coverage, v_staffing_ratio
Credential gate017unit_roster_policy.require_valid_credential
Float eligibility023staff_float_eligibility (unit beyond home engagement)
On-call / standby024shift_type on pattern + shift
Bid / fairness025bid window + weekend/night quotas on unit policy
Agency / bank eligibility026eligibility_scope on pattern/shift; bank_eligible on staff
Labour-rule packs027labour_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 core packages (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 (release API).
  • Assignments and coverage are keyed by jobRoleId; patterns/shifts carry unitId.
  • Unit roster policy (LEAD_ONLY / SELF_PICK / HYBRID, claim rule INSTANT / …) governs open rota.
  • Credential gate (M17-007): when require_valid_credential is true on the unit policy, assign and claim require at least one ACTIVE credential whose expiresOn is null or not before today. Default is false (opt-in).
  • Credential issuer (TERM-008R): credential.credential_issuer_id is the optional issuing-body FK; the legacy free-text issuer VARCHAR was dropped in migration 031. Resolve display names via GET /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_id is the sole type discriminator; the legacy denormalized credential_type VARCHAR was dropped in migration 028. New writes require credentialTypeId; resolve display names via GET /api/v1/workforce/credential-types/{id}. Migration 028 HALTs when any live row has null credential_type_id or when the duplicate string disagrees with CredentialType.name.
  • Shift pattern requirements (TERM-008I): shift_pattern_requirement.job_role_id is the sole role discriminator; the legacy denormalized role_code VARCHAR was dropped in migration 029. New writes require jobRoleId; resolve display names via GET /api/v1/workforce/job-roles/{id}. Migration 029 HALTs when any live row has null job_role_id (JobRole has name only — no stable code column, so role_code→name reconciliation is not enforced).
  • Shift pattern requirements — skill (TERM-008Q): shift_pattern_requirement.skill_id is the sole skill discriminator (optional); the legacy denormalized skill_code VARCHAR was dropped in migration 030. New writes use skillId when a specialty is required; resolve display via GET /api/v1/workforce/skills/{id}. Migration 030 creates tenant-scoped workforce.skill rows from distinct legacy codes (tenant via shift_pattern) and HALTs when any non-null skill_code cannot be linked. Manage skills via POST/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_access via Feign (UserDepartmentAccessClient rotate-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-registrations requires an initial engagement (departmentId, unitId, jobRoleId, salaryGradeId, salaryBandId, amount). That creates StaffEngagement (with required departmentId, validated against the unit) and rotates home — registration no longer writes registration.departmentAccess. Job role / salary grade / band are FKs to managed lookups (create · void · unvoid).
  • Live roster SPI (M17-015): WorkforceCurrentOrgAssignmentSource implements exchange CurrentOrgAssignmentSource so form org-visibility can include units the staff is assigned to right now, without core importing workforce.
  • Float / multi-unit eligibility (M17-008): StaffFloatEligibility rows 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 restricted jobRoleId; null means engagement job role). Float is not a second engagement and does not write cover user_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 (optional includeVoided); 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 expose shiftType for reporting filters.
  • Bid windows / fairness (M17-010): when claimRule=BID_WINDOW, claims are allowed only between startsAt − bidWindowOpensMinutesBefore and startsAt − bidWindowClosesMinutesBefore. Optional maxWeekendClaimsPerPeriod / maxNightClaimsPerPeriod over fairnessPeriodDays (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 + staff facilityId matches 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_pack holds min rest between shifts, optional max hours/day, max hours/week, and optional overtime weekly threshold. Seeded GLOBAL packs UK / US / CA / NG (illustrative defaults). Unit roster policy may set labourRulePackId (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's personId/staffProfileId (ID-001). Landing target: user_type.provider=trueCLINICIAN_WORKSPACE (M16); otherwise STAFF_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:

TableAnswers
job_levelSeniority — Junior, Mid, Senior. Carries a rank
job_role_level_gradeEligibility: which grade a role at a level may draw
salary_band_pay_rangeMoney: what a band pays, where, in what currency, when
staff_engagement.job_level_idThe 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_currency and 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 outstanding list.

What an engagement is checked against

  1. (jobRoleId, jobLevelId, salaryGradeId) exists in job_role_level_grade.
  2. The band belongs to the grade.
  3. A range governs that band, at that facility's location, on startDate.
  4. amount falls inside it, and currencyCode — 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:

  1. POST /api/v1/document/documents with staffProfileId — the signature is a document like any other: checksum, size limit, scan gate, guarded download.
  2. 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_range rows govern engagement amounts. Starter provisioning via POST /pay-structure/starter.
  • HRMS payroll (D16 / M17-017): payroll_template declares components and formula variables as declarative strings — never executable code. POST /payroll-runs/{id}/calculate generates payslip + payslip_line rows from active engagements overlapping the pay period (prorated ENGAGEMENT_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 append STAT_* 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}/approve on a DRAFT run with payslips sets run APPROVED and payslips FINAL. Recalculation remains on DRAFT only.
  • POSTED lifecycle (M17-020): POST /payroll-runs/{id}/mark-posted (APPROVEDPOSTED) — invoked by ERP after WORKFORCE_PAYROLL journals; 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 via WorkforcePayrollPostingClient; the labelled labour accrual is skipped when payroll exists.
  • KYC (D5 / KYC-001): IdentityVerificationProvider and CredentialVerificationProvider SPIs 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 via credential_issuer_id (KYC-002 — reuses the credential-issuer registry, nullable for global types such as passport). staff_identity_verification stores 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

SettingPurpose
workforce.credential.expiry-alert-daysDays before expiry to alert (default 30)
workforce.credential.expiry-scan.enabledScheduled expiry scan
workforce.approval.workflow-definition-idOptional workflow definition for leave/swap approvals
Feign URLsnotification / accesscontrol / demographic / workflow service URLs (fallback uhp.platform.url)

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.