Files
ERP-core/docs/12-BACKEND-HRM.md
T
2026-07-23 19:54:56 +05:30

199 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (see `00-CORE.md` §4 and `10-BACKEND-PHASE1.md` Part 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 `User` entity (`Domain/Entities/User.cs`) gains a nullable `Email` column + 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/*` — mirrors `Services/Stock/*` (domain calculation services distinct from CRUD services): `AttendanceComputationService` (analog of `FifoCostingService`), `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 change |
| Leave 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 `TaxSlab` table, not hardcoded |
| RBAC | Still deferred repo-wide (per Phase 1 AR-01), but flagged in `02-SECURITY.md §C.8` as 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`/`RolePermission` mechanism (see `02-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; `EmployeeCode` unique, user-entered; `Status` lifecycle Active→Suspended/Resigned/Terminated/Retired, never hard-deleted.
- FR-HR-MD-03 Maintain one or more `EmployeeBankDetail` rows per employee, exactly one marked `IsPrimary`.
- 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=` and `GET /users/email-lookup?email=` return the matching record (or null) for the *other* side, given an email.
- FR-HR-LINK-02 `Employee.UserId` is nullable; when set, unique (one User backs at most one Employee), enforced by a filtered unique index plus a friendly `409 EMPLOYEE_ALREADY_LINKED`/`USER_ALREADY_LINKED` service check.
- FR-HR-LINK-03 Linking is only ever explicit (via `linkEmployeeId`/`linkUserId` on create, or `POST/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 `HrDocumentType` catalog (deactivate-not-delete; `409 DOCUMENT_TYPE_IN_USE` if referenced).
- FR-HR-DOC-02 Upload an `EmployeeDocument` against 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.ExpiryDate` support 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: columns `Employee Code | Date | Check In | Check Out`. Creates an `AttendanceUploadBatch` in `Draft`.
- 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 derives `AttendanceStatus` (Present/Absent/HalfDay/OnLeave/Holiday/WeekOff) — OnLeave derived from an overlapping Approved `LeaveRequest` (§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`), then `confirm` (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 reaches `UsedInPayroll`.
- FR-HR-ATT-07 A `Confirmed` batch 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 `LeaveType` master (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` (not `Absent`) during Attendance validation.
### B.3.6 Payroll (FR-HR-PAY)
- FR-HR-PAY-01 Maintain `SalaryComponent` master (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_OVERLAP` otherwise).
- FR-HR-PAY-03 Maintain `EmployeeLoan` (Loan or Advance) + `LoanInstallment` schedule/ledger.
- FR-HR-PAY-04 Maintain effective-dated `PayrollStatutorySetting` (EPF/ETF rates) and `TaxSlab` (marginal tax bounds/rate, effective-dated).
- FR-HR-PAY-05 Generate a `PayrollRun` for a period (+ optional branch scope): blocked (`422 ATTENDANCE_NOT_CONFIRMED`) unless every relevant attendance batch for the period is `Confirmed`. Computes one `PayrollLine` + `PayrollLineComponent` set 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 to `Approved`; blocked without authorization.
- FR-HR-PAY-07 `POST /payroll-runs/{id}/generate-payslips` (Locked-only) creates one `Payslip` marker per `PayrollLine`; `GET /payslips/{id}/view` renders 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 (`EmployeeSalaryStructure` revisions over time).
- FR-HR-RPT-05 Leave balance report.
- FR-HR-RPT-06 Document expiry report (`EmployeeDocument.ExpiryDate` within 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 `TaxSlab` mechanism 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.8` for 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 RowVersion` ETag, automatic `AuditLog` capture via `AuditScribe` (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`/`PayrollStatutorySetting` tables 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 `AttendanceRecord`s 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-capped `Category`); 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 (mirrors `Brand`), 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`) — add `Email? (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`.*