23 KiB
12 · BACKEND — Phase 2 Spec (HRM)
Companion to
10-BACKEND-PHASE1.md. Same rules apply unless noted otherwise here — this file adds HRM-specific requirements, decisions, and the ER model; it does not repeat Part A's layering rules verbatim (see00-CORE.md§4 and10-BACKEND-PHASE1.mdPart A, which remain in force for every module).
Part A — HRM-specific architecture notes
A.1 What's new vs. Phase 1
- New cross-cutting fix: the local shadow
Userentity (Domain/Entities/User.cs) gains a nullableEmailcolumn + case-insensitive filtered unique index. This is additive; no existing Phase-1 behavior changes. - New infra layer:
Infra/Storage/IFileStorageService(+LocalFileStorageService) — the first file-upload/attachment mechanism in the codebase. No other module currently has one; HRM introduces the abstraction, other modules may adopt it later. - New service namespace:
Services/Hrm/*— mirrorsServices/Stock/*(domain calculation services distinct from CRUD services):AttendanceComputationService(analog ofFifoCostingService),PayrollCalculationService,EmployeeUserLinkService. - New packages:
ClosedXML(.xlsx read/write),CsvHelper(.csv read/write) — both added for Attendance upload/template generation only. - Decisions carried into this phase (confirmed with the business owner, not re-litigated per module):
Decision Value Document storage Local disk, behind IFileStorageService; cloud swap is a future DI change, not a schema changeLeave Management In scope now (lightweight) — feeds Attendance's OnLeave status and Payroll's No-Pay calc Payslip output Server-rendered HTML/print view; PDF export explicitly deferred Statutory rules Sri Lanka (EPF 8%/12%, ETF 3% employer-only); tax via a configurable TaxSlabtable, not hardcodedRBAC Still deferred repo-wide (per Phase 1 AR-01), but flagged in 02-SECURITY.md §C.8as a decision to make explicitly for HRM, not silently inherited
A.2 Employee.EmployeeCode numbering (deviation from NumberSequence)
Unlike PurchaseOrder/Grn/other Phase-1 documents, Employee.EmployeeCode is user-entered, validated for uniqueness server-side (EMPLOYEE_CODE_DUPLICATE), not generated via NumberSequenceService. Rationale: NumberSequence is year-scoped ({DocType}-{Year}-{00000}), the wrong shape for an identifier that must never look "reset" across years; HR departments also typically already have a legacy numbering scheme to preserve at go-live. All other new HRM transactional documents (AttendanceUploadBatch, LeaveRequest, EmployeeLoan, PayrollRun) do use NumberSequenceService.NextAsync(docType) as-is (ATT-, LV-, LOAN-, PAY-).
A.3 Snapshot-at-ingestion rule (attendance integrity)
AttendanceRecord.WorkShiftId is captured from Employee.WorkShiftId at the moment a record is ingested, not read live from Employee at report time. If an employee's shift assignment changes later, historical Late/OT/Working-Hours figures for already-recorded dates must not silently change. Any service reading AttendanceRecord for computation always joins to the record's own WorkShiftId, never the employee's current one.
A.4 Lock-boundary rule (payroll integrity)
LoanInstallment.PayrollRunId is stamped, and Loan.OutstandingBalance decremented, only when a PayrollRun transitions to Locked — not at Generate/Draft and not at Approve. This is deliberate: a Draft payroll run may be regenerated or discarded; consuming a loan installment before the run is truly final would make regeneration lossy. The same boundary applies to AttendanceUploadBatch.Status flipping Confirmed → UsedInPayroll.
Part B — Software Requirements Specification
B.1 Scope
Phase 2 (HRM) covers: Employee/Staff Management (incl. the Employee↔User login cross-link and staff document attachments), Attendance (Excel/CSV upload → validate → confirm), a lightweight Leave module (feeds Attendance/Payroll), and Payroll (calculate → review → approve → lock → payslips), plus read-only Reports over all of the above. Out of scope for this phase (see §B.7): PDF payslip generation, employee self-service portal, notification/email infrastructure, biometric device integration (schema seam reserved only), tiered late/OT policy, multi-emergency-contact and structured education-history child tables, exit/clearance workflow.
B.2 User classes
- HR Administrator — full CRUD on Employees, masters, Attendance batches, Payroll runs; the only role that can Unlock a payroll run or supersede a confirmed attendance duplicate.
- HR Staff / Payroll Clerk — day-to-day upload/validate/confirm attendance, generate payroll drafts, upload staff documents.
- Approver (Department Head / Finance) — approves Leave Requests, approves/locks Payroll runs.
- System User (non-HR) — no HRM access; sidebar sections hidden via existing
NavItem/RolePermissionmechanism (see02-SECURITY.md §C.8).
Per-endpoint RBAC enforcement is still deferred (mirrors Phase 1 AR-01); the role split above is currently a UI-visibility convention only, not a server-enforced one, except where explicitly noted (Unlock actions).
B.3 Functional Requirements
B.3.1 Employee & Org Masters (FR-HR-MD)
- FR-HR-MD-01 Maintain Branch, Department (unlimited self-nesting, cycle-guarded), Designation, EmploymentType, WorkShift masters — standard deactivate-not-delete CRUD.
- FR-HR-MD-02 Maintain Employee records with the full field set in Part C.2;
EmployeeCodeunique, user-entered;Statuslifecycle Active→Suspended/Resigned/Terminated/Retired, never hard-deleted. - FR-HR-MD-03 Maintain one or more
EmployeeBankDetailrows per employee, exactly one markedIsPrimary. - FR-HR-MD-04 Bidirectional email-based cross-check between Employee and User (see A.5/§B.3.2) — advisory only, human-confirmed link.
B.3.2 Employee ↔ User cross-link (FR-HR-LINK)
- FR-HR-LINK-01
GET /employees/email-lookup?email=andGET /users/email-lookup?email=return the matching record (or null) for the other side, given an email. - FR-HR-LINK-02
Employee.UserIdis nullable; when set, unique (one User backs at most one Employee), enforced by a filtered unique index plus a friendly409 EMPLOYEE_ALREADY_LINKED/USER_ALREADY_LINKEDservice check. - FR-HR-LINK-03 Linking is only ever explicit (via
linkEmployeeId/linkUserIdon create, orPOST/DELETE /employees/{id}/link-user) — never automatic, even on an exact email match.
B.3.3 Staff Documents (FR-HR-DOC)
- FR-HR-DOC-01 Maintain
HrDocumentTypecatalog (deactivate-not-delete;409 DOCUMENT_TYPE_IN_USEif referenced). - FR-HR-DOC-02 Upload an
EmployeeDocumentagainst a document type; server validates extension allowlist + content-type match + max size (422 FILE_TYPE_NOT_ALLOWED/413 FILE_TOO_LARGE). - FR-HR-DOC-03 Download requires authentication; files are never served via a static/guessable URL.
- FR-HR-DOC-04 Archive (not delete) a document row; the on-disk file may be retained or purged per retention policy (not specified further in this phase).
- FR-HR-DOC-05
HrDocumentType.ExpiryTracked+EmployeeDocument.ExpiryDatesupport a document-expiry report (§B.3.7).
B.3.4 Attendance (FR-HR-ATT)
- FR-HR-ATT-01 Upload Excel (
.xlsx) or CSV attendance file: columnsEmployee Code | Date | Check In | Check Out. Creates anAttendanceUploadBatchinDraft. - FR-HR-ATT-02 Downloadable template (
GET /attendance-batches/template.xlsx/?format=csv) generated from the same column-mapping the parser uses. - FR-HR-ATT-03 Validation on upload: employee-code resolution (must exist & be Active), date/time parse + sanity (not future-dated, not before hire date), within-batch duplicate detection, cross-batch-already-confirmed duplicate detection (flagged separately, requires an explicit "supersede" action).
- FR-HR-ATT-04 Preview computes, per record, against the employee's
WorkShift: WorkingMinutes, LateMinutes, EarlyLeaveMinutes, OvertimeMinutes, and derivesAttendanceStatus(Present/Absent/HalfDay/OnLeave/Holiday/WeekOff) — OnLeave derived from an overlapping ApprovedLeaveRequest(§B.3.5). - FR-HR-ATT-05 Confirmation flow: manual record edit, duplicate resolution, then
validate(Draft→Validated, blocks if unresolved errors/duplicates remain —422 ATTENDANCE_DUPLICATE_UNRESOLVED), thenconfirm(Validated→Confirmed, locks records against further edit —409 ATTENDANCE_BATCH_LOCKED). - FR-HR-ATT-06
unlock(Confirmed→Validated) requires a mandatory reason, is authorized-user only, and is itself blocked once the batch reachesUsedInPayroll. - FR-HR-ATT-07 A
Confirmedbatch is the only valid input to Payroll generation for its period (§B.3.7).
B.3.5 Leave (FR-HR-LV)
- FR-HR-LV-01 Maintain
LeaveTypemaster (IsPaid, CountsAsNoPay, AccrualPerYear, CarryForwardAllowed). - FR-HR-LV-02 Submit/approve/reject/cancel a
LeaveRequest(Draft→Submitted→Approved/Rejected, or →Cancelled). - FR-HR-LV-03 Maintain per-employee/type/year
LeaveBalance(Entitled/Taken/CarriedForward/Adjustment); HR can manually adjust. - FR-HR-LV-04 An Approved leave request overlapping an attendance date with no punch classifies that date
OnLeave(notAbsent) during Attendance validation.
B.3.6 Payroll (FR-HR-PAY)
- FR-HR-PAY-01 Maintain
SalaryComponentmaster (Earning/Deduction, IsTaxable, IsEpfEtfApplicable) — for Allowances and ad hoc Other Deductions only. - FR-HR-PAY-02 Maintain effective-dated
EmployeeSalaryStructure(+ lines) per employee; exactly one open-ended (EffectiveTo IS NULL) row at a time (409 SALARY_STRUCTURE_OVERLAPotherwise). - FR-HR-PAY-03 Maintain
EmployeeLoan(Loan or Advance) +LoanInstallmentschedule/ledger. - FR-HR-PAY-04 Maintain effective-dated
PayrollStatutorySetting(EPF/ETF rates) andTaxSlab(marginal tax bounds/rate, effective-dated). - FR-HR-PAY-05 Generate a
PayrollRunfor a period (+ optional branch scope): blocked (422 ATTENDANCE_NOT_CONFIRMED) unless every relevant attendance batch for the period isConfirmed. Computes onePayrollLine+PayrollLineComponentset per employee per the formula in §B.4. - FR-HR-PAY-06 Approval workflow:
Draft(Generate/regenerate freely) →Approved(freeze from recompute) →Locked(immutable; stamps loan installments as Deducted and attendance batches as UsedInPayroll — see A.4).Unlock(mandatory reason) reverses those stamps and reverts toApproved; blocked without authorization. - FR-HR-PAY-07
POST /payroll-runs/{id}/generate-payslips(Locked-only) creates onePayslipmarker perPayrollLine;GET /payslips/{id}/viewrenders an HTML print view.
B.3.7 Reports (FR-HR-RPT)
- FR-HR-RPT-01 Attendance summary (Present/Absent/Leave/OT/Late totals per employee/period).
- FR-HR-RPT-02 Overtime report, Late-arrival report (both derived from
AttendanceRecord). - FR-HR-RPT-03 Payroll register (per
PayrollRun, exportable). - FR-HR-RPT-04 Employee salary history (
EmployeeSalaryStructurerevisions over time). - FR-HR-RPT-05 Leave balance report.
- FR-HR-RPT-06 Document expiry report (
EmployeeDocument.ExpiryDatewithin N days / already expired).
B.4 Payroll calculation (authoritative formula)
GrossSalary = BasicSalary + Σ(SalaryStructureLine WHERE ComponentType=Earning) + OvertimeAmount
NetSalary = GrossSalary
− LateDeductionAmount − NoPayAmount − LoanDeductionAmount
− EpfEmployeeAmount − TaxAmount − OtherDeductionsAmount
EpfEmployerAmount and EtfEmployerAmount are informational/liability lines only — never subtracted from Net (they represent the company's own contribution, not an employee deduction). OvertimeAmount = per-minute rate (derived from Basic ÷ WorkShift.StandardWorkingMinutes, × WorkShift.OtMultiplier or PayrollStatutorySetting.OtMultiplierDefault) × total OT minutes for the period. LateDeductionAmount/NoPayAmount use a flat per-minute/per-day rate in this phase (tiered policies are a future improvement, not built now — see §B.7). Tax is looked up from TaxSlab via standard marginal-slab computation over taxable earnings (per SalaryComponent.IsTaxable).
Open compliance question, not silently assumed: the exact APIT taxable-income base (whether EPF-employee reduces taxable income before slab lookup, whether OT is taxable) requires finance/statutory sign-off before go-live. The
TaxSlabmechanism is built to be configurable; no specific formula is hardcoded beyond "marginal rate over taxable earnings."
B.5 Non-Functional Requirements
- Salary/PII data is more sensitive than Phase-1 inventory data — see
02-SECURITY.md §C.8for the explicit (not inherited) risk decision required before go-live. - File uploads (attendance spreadsheets, staff documents) are untrusted input: size-capped, extension/content-type-validated server-side; true magic-byte sniffing and antivirus scanning are explicitly deferred (new accepted-risk entry, not silently skipped).
- All HRM transactional documents (
AttendanceUploadBatch,LeaveRequest,EmployeeLoan,PayrollRun) follow the same audit/concurrency posture as Phase 1:uint RowVersionETag, automaticAuditLogcapture viaAuditScribe(no per-entity wiring needed).
B.6 Constraints & assumptions
- Single currency (LKR) for Phase 2; multi-currency payroll is out of scope.
- Statutory rules assume Sri Lanka (EPF/ETF/APIT-style tax); the
TaxSlab/PayrollStatutorySettingtables exist specifically so this isn't hardcoded, but no other country's scheme is modeled. - No employee self-service session model exists; all HRM screens are HR/admin-facing only in this phase.
B.7 Future Modules & Integration Seams (Phase 2 → later)
| Seam | Note |
|---|---|
| Biometric device attendance | AttendanceUploadBatch.SourceType reserves a BiometricDevice enum value now; a device feed can write AttendanceRecords directly later with no schema change. |
| PDF payslips | Payslip/PayrollLine/PayrollLineComponent already carry everything a PDF renderer needs; only a rendering step (e.g. QuestPDF) is deferred. |
| Employee self-service portal | Requires a new employee-facing session model, not present in this phase. |
| Notification hooks | Document expiry, payslip release, leave approval — no email/notification infra exists anywhere in this repo yet; a genuine future subsystem. |
| Tiered late/OT policy | MVP uses a flat per-minute/per-day rate; policies like "3 lates = 1 day" are a future enhancement to PayrollCalculationService. |
| Bulk employee import | Mirror the Attendance upload UX (ClosedXML/CsvHelper + preview + validate) for onboarding an existing workforce at go-live. |
| RBAC for HR data | Recommend HRM be the forcing function that finally turns on per-endpoint RBAC (02-SECURITY.md Part D), ahead of inventory adjustments as currently ordered. |
Part C — ER Model
C.1 Org / Masters
- Branch —
BranchId PK, Code, Name, Address?, Status(EntityStatus), CreatedAt, UpdatedAt, RowVersion. - Department —
DepartmentId PK, Code, Name, ParentDepartmentId? (self-FK), HeadEmployeeId? FK→Employee, BranchId? FK→Branch, Status, CreatedAt, UpdatedAt, RowVersion. Unlimited self-nesting (unlike the two-level-cappedCategory); service-level cycle guard on write. - Designation —
DesignationId PK, Code, Name, Status, CreatedAt, UpdatedAt, RowVersion. - EmploymentType —
EmploymentTypeId PK, Code, Name, Status, CreatedAt, UpdatedAt, RowVersion. Master (mirrorsBrand), not an enum. - WorkShift —
WorkShiftId PK, Code, Name, StartTime, EndTime, IsOvernight(bool), GraceMinutes(int, default 15), BreakMinutes(int, default 60), StandardWorkingMinutes(int, default 480), OtMultiplier(decimal, default 1.5), WorkingDaysMask(int), Status, CreatedAt, UpdatedAt, RowVersion.
C.2 Employee core
- Employee —
EmployeeId PK, EmployeeCode(unique, user-entered), FullName, Nic?, DateOfBirth?, Gender?(enum), Nationality?, ProfilePhotoPath?, Email?, PersonalMobile?, AddressLine1?, AddressLine2?, City?, PostalCode?, Country?, EmergencyContactName?, EmergencyContactRelationship?, EmergencyContactPhone?, HireDate, ConfirmationDate?, LastWorkingDate?, DepartmentId FK, DesignationId FK, EmploymentTypeId FK, BranchId? FK, WorkShiftId FK, ReportingManagerId? (self-FK), EpfNumber?, EtfNumber?, TaxIdentificationNumber?, UserId? FK→User (unique, filtered), Status(EmployeeStatus enum: Active/Suspended/Resigned/Terminated/Retired), CreatedBy FK→User, CreatedAt, UpdatedBy? FK→User, UpdatedAt?, RowVersion. - EmployeeBankDetail —
EmployeeBankDetailId PK, EmployeeId FK, BankName, BranchName, AccountNumber, AccountHolderName, SwiftCode?, IsPrimary(bool), Status, CreatedAt, UpdatedAt, RowVersion.
C.3 Documents
- HrDocumentType —
HrDocumentTypeId PK, Code, Name, Category(enum: Identity/Educational/Contract/Certification/Statutory/Other), RequiredAtOnboarding(bool), ExpiryTracked(bool), Status, CreatedAt, UpdatedAt, RowVersion. - EmployeeDocument —
EmployeeDocumentId PK, EmployeeId FK, HrDocumentTypeId FK, OriginalFileName, StoredFileName, RelativePath, ContentType, SizeBytes, IssueDate?, ExpiryDate?, Notes?, UploadedBy FK→User, UploadedAt, VerifiedBy? FK→User, VerifiedAt?, Status(enum: Active, Archived), RowVersion.
C.4 Attendance
- AttendanceUploadBatch —
AttendanceUploadBatchId PK, DocNo(NumberSequence "ATT"), PeriodStart, PeriodEnd, SourceType(enum: Excel, Csv, Manual, BiometricDevice[reserved]), OriginalFileName?, UploadedBy FK→User, UploadedAt, Status(enum: Draft, Validated, Confirmed, UsedInPayroll), ConfirmedBy? FK→User, ConfirmedAt?, RowCountTotal, RowCountDuplicate, RowCountError, RowVersion. - AttendanceRecord —
AttendanceRecordId PK, AttendanceUploadBatchId? FK, EmployeeId FK, AttendanceDate, CheckIn?, CheckOut?, WorkShiftId FK (snapshotted, see A.3), WorkingMinutes(computed), LateMinutes(computed), EarlyLeaveMinutes(computed), OvertimeMinutes(computed), AttendanceStatus(enum: Present/Absent/HalfDay/OnLeave/Holiday/WeekOff), RowValidationStatus(enum: Valid/DuplicateWithinBatch/DuplicateConfirmed/EmployeeNotFound/InvalidDateTime/Error), DuplicateOfAttendanceRecordId? (self-FK), Notes?, IsManualOverride(bool), EditedBy? FK→User, EditedAt?, RowVersion.
C.5 Leave
- LeaveType —
LeaveTypeId PK, Code, Name, IsPaid(bool), CountsAsNoPay(bool), AccrualPerYear(decimal), CarryForwardAllowed(bool), MaxCarryForwardDays?, RequiresApproval(bool, default true), Status, CreatedAt, UpdatedAt, RowVersion. - LeaveRequest —
LeaveRequestId PK, DocNo(NumberSequence "LV"), EmployeeId FK, LeaveTypeId FK, StartDate, EndDate, DaysCount(decimal), Reason?, Status(enum: Draft/Submitted/Approved/Rejected/Cancelled), ApprovedBy? FK→User, ApprovedAt?, RejectionReason?, CreatedBy FK→User, CreatedAt, RowVersion. - LeaveBalance —
LeaveBalanceId PK, EmployeeId FK, LeaveTypeId FK, Year, EntitledDays, TakenDays, CarriedForwardDays, AdjustmentDays, RowVersion, UpdatedAt. Unique(EmployeeId, LeaveTypeId, Year).
C.6 Payroll
- SalaryComponent —
SalaryComponentId PK, Code, Name, ComponentType(enum: Earning, Deduction), IsTaxable(bool), IsEpfEtfApplicable(bool), Status, CreatedAt, UpdatedAt, RowVersion. - EmployeeSalaryStructure —
EmployeeSalaryStructureId PK, EmployeeId FK, EffectiveFrom, EffectiveTo?, BasicSalary(decimal), Currency(default "LKR"), Status(enum: Active, Superseded), ApprovedBy FK→User, ApprovedAt, CreatedBy FK→User, CreatedAt, RowVersion. - EmployeeSalaryStructureLine —
EmployeeSalaryStructureLineId PK, EmployeeSalaryStructureId FK, SalaryComponentId FK, Amount(decimal). - EmployeeLoan —
EmployeeLoanId PK, DocNo(NumberSequence "LOAN"), EmployeeId FK, LoanKind(enum: Loan, Advance), PrincipalAmount, InterestRate(decimal, default 0), InstallmentAmount, NumberOfInstallments, StartYear, StartMonth, OutstandingBalance(decimal, denormalized), Status(enum: Active, Closed, Cancelled), ApprovedBy FK→User, ApprovedAt, CreatedBy FK→User, CreatedAt, RowVersion. - LoanInstallment —
LoanInstallmentId PK, EmployeeLoanId FK, InstallmentNumber, DueYear, DueMonth, ScheduledAmount, PaidAmount?, PayrollRunId? FK (stamped only at Lock), Status(enum: Pending, Deducted, Skipped), RowVersion. - PayrollStatutorySetting —
PayrollStatutorySettingId PK, EpfEmployeeRate(decimal, default 0.08), EpfEmployerRate(decimal, default 0.12), EtfEmployerRate(decimal, default 0.03), OtMultiplierDefault(decimal, default 1.5), EffectiveFrom, EffectiveTo?, CreatedBy FK→User, CreatedAt, RowVersion. - TaxSlab —
TaxSlabId PK, EffectiveFrom, EffectiveTo?, LowerBound(decimal), UpperBound(decimal?, null = "and above"), Rate(decimal), RowVersion, CreatedAt. - PayrollRun —
PayrollRunId PK, DocNo(NumberSequence "PAY"), PeriodYear, PeriodMonth, BranchId? (null = company-wide), Status(enum: Draft, Approved, Locked), GeneratedBy FK→User, GeneratedAt, ApprovedBy? FK→User, ApprovedAt?, LockedBy? FK→User, LockedAt?, UnlockedBy? FK→User, UnlockedAt?, UnlockReason?, RowVersion. - PayrollLine —
PayrollLineId PK, PayrollRunId FK, EmployeeId FK, BasicSalary, TotalAllowances, OvertimeAmount, GrossSalary(computed), LateDeductionAmount, NoPayAmount, LoanDeductionAmount, EpfEmployeeAmount, EpfEmployerAmount(informational), EtfEmployerAmount(informational), TaxAmount, OtherDeductionsAmount, NetSalary(computed), WorkingDays, PresentDays, AbsentDays, LeaveDays, OtMinutesTotal, LateMinutesTotal, RowVersion. - PayrollLineComponent —
PayrollLineComponentId PK, PayrollLineId FK, ComponentCategory(enum: Earning, Deduction, EmployerContribution), SalaryComponentId? FK (null for system-computed lines), Label, Amount, SortOrder. - Payslip —
PayslipId PK, PayrollLineId FK (unique), GeneratedAt, ReleasedAt?, ReleasedBy? FK→User.
C.7 Cross-cutting change
- User (existing entity,
Domain/Entities/User.cs) — addEmail? (nullable string), case-insensitive filtered unique index. No other column changes.
C.10 Entity → implementation mapping
Same convention as Phase 1 (10-BACKEND-PHASE1.md C.10): entities → Domain/Entities/*.cs; enums → Domain/Enums/*.cs; EF configurations (IEntityTypeConfiguration<T>, one per entity) → Infra/Persistence/Configurations/*Configuration.cs; DTOs grouped by module → Dtos/Hrm/*.cs; services → Services/Hrm/*Service.cs (CRUD) + Services/Hrm/{AttendanceComputationService,PayrollCalculationService,EmployeeUserLinkService}.cs (domain calculation, analog of Services/Stock/FifoCostingService.cs); controllers → Controllers/Hrm/*Controller.cs; new file-storage abstraction → Infra/Storage/{IFileStorageService,LocalFileStorageService}.cs.
End of 12-BACKEND-HRM.md. API contract: 13-BACKEND-HRM-API.md. Frontend flows: 21-FRONTEND-HRM.md.