[CLAUDE] Workflow: LeaveBalance business logic — trừ phép khi duyệt + số dư (Phase 11 P11-B)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m8s

Số dư phép theo (User × LeaveType × Year) + trừ tự động khi đơn nghỉ duyệt cuối.
Policy: cho phép vượt số dư (âm) + cảnh báo (anh main chốt), tích hợp vào trang đơn nghỉ.

Schema (Mig 42 AddLeaveBalances — pure additive, 1 bảng):
- LeaveBalance: UserId + LeaveTypeId + Year + EntitledDays + UsedDays + AdjustmentDays.
  UNIQUE (UserId,LeaveTypeId,Year), FK LeaveType Restrict, decimal(5,2).
  Remaining = Entitled + Adjustment − Used (computed, không store).

Deduction hook (ApproveLeaveRequestHandler nhánh terminal DaDuyet — exactly-once):
- Upsert LeaveBalance(RequesterUserId, LeaveTypeId, StartDate.Year), auto-create từ
  LeaveType.DaysPerYear, UsedDays += NumDays. Guard Status!=DaGuiDuyet chặn re-approve.

FK invariant guard (em main thêm sau test reveal FK risk):
- Create + UpdateDraft validate LeaveTypeId tồn tại (AnyAsync) → ConflictException.
  Đóng cửa vào — bogus type không thể tới deduction FK insert (tránh 500 kẹt đơn).

CQRS LeaveBalanceFeatures.cs: GetMy (self, lazy merge active LeaveType) + GetUser (admin)
  + AdjustLeaveBalance (admin upsert carry-over). Controller [Authorize] + admin Roles=Admin.
Embed: GetLeaveRequestByIdHandler trả balance NGƯỜI TẠO (approver xem thấy đúng).
FE: WorkflowAppDetailPage ×2 — block "Số dư phép" + cảnh báo vượt khi kind=leave (SHA256 identical).

Tests (+11, 130→154 PASS): deduction single/multi-level/accumulate/negative-allowed/
  reject-return-no-deduct + lazy-merge + adjust upsert + Create guard bogus→Conflict.
  Cũng repair 2 test S42 terminal FK-fail (template BuildLeave +seed LeaveType).

Verify: build 0 error · 154 test · FE ×2 · reviewer Max PASS (deduction exactly-once +
  FK invariant fully closed, 2 minor concurrency/comment defer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-30 11:10:44 +07:00
parent 0db5e1fdc9
commit 82d7fcff4d
21 changed files with 7356 additions and 10 deletions

View File

@ -66,7 +66,7 @@ UI `disabled={!canX}` + BE helper `EnsureCanXAsync(id, userId)` throw 403 (NOT i
## 🧠 SOLUTION_ERP BE conventions (S40) ## 🧠 SOLUTION_ERP BE conventions (S40)
- **BE .NET 10:** PascalCase entities + DTO records + command names. CQRS+MediatR+FluentValidation+AutoMapper. Repository qua `IApplicationDbContext`. `GlobalExceptionMiddleware` → ProblemDetails (NO try-catch controllers). - **BE .NET 10:** PascalCase entities + DTO records + command names. CQRS+MediatR+FluentValidation+AutoMapper. Repository qua `IApplicationDbContext`. `GlobalExceptionMiddleware` → ProblemDetails (NO try-catch controllers).
- **State S41:** 41 mig (last `WireWorkflowAppsApprovalV2`) · 89 SQL tables · ~211 endpoints · 130 test baseline (test-specialist owns). Phase 9 UAT skip per chunk (`feedback_uat_skip_verify`). - **State S43:** 42 mig (last `AddLeaveBalances`) · 90 SQL tables · ~214 endpoints · 130 test baseline (test-specialist owns). Phase 9 UAT skip per chunk (`feedback_uat_skip_verify`).
- **Build:** `dotnet build SolutionErp.slnx` clean 0 err. Commit `[CLAUDE] <scope>: <msg>` + Co-Authored-By Claude Opus 4.8 (1M context). - **Build:** `dotnet build SolutionErp.slnx` clean 0 err. Commit `[CLAUDE] <scope>: <msg>` + Co-Authored-By Claude Opus 4.8 (1M context).
- **Pin (KHÔNG `*`/latest):** MediatR `12.4.1` (14 fail DI) · Swashbuckle `6.9.0` · Node CI `20.x` · LibreOffice `25.8.6` · @microsoft/signalr `8.0.7`. - **Pin (KHÔNG `*`/latest):** MediatR `12.4.1` (14 fail DI) · Swashbuckle `6.9.0` · Node CI `20.x` · LibreOffice `25.8.6` · @microsoft/signalr `8.0.7`.
@ -74,6 +74,7 @@ UI `disabled={!canX}` + BE helper `EnsureCanXAsync(id, userId)` throw 403 (NOT i
## 📅 Recent activity (FIFO — older → archive/git) ## 📅 Recent activity (FIFO — older → archive/git)
- **S43 P11-B Wave 1 — LeaveBalance business logic (Mig 42 `AddLeaveBalances`, 7 file: 1 entity + 1 config + 2 DbSet edit + Mig 3-file + 1 hook edit + 1 Features + 1 Controller):** Case 1/3 deterministic ~98% em main spec. Pattern 12-ter-adjacent single-entity: entity `LeaveBalance:AuditableEntity` (UserId/LeaveTypeId/Year + EntitledDays/UsedDays/AdjustmentDays decimal(5,2), nav LeaveType). Config FK LeaveType WithMany() **Restrict** (catalog no cascade) + UNIQUE composite (UserId,LeaveTypeId,Year) + IX UserId. Mig diff CLEAN: 1 CreateTable + 3 IX, no drift. Applied BOTH DB (Dev `SolutionErp_Dev` + Design default). **Deduction hook:** insert in `ApproveLeaveRequestHandler` terminal else (DaDuyet branch) ONLY — UPSERT LeaveBalance, `bal.UsedDays += p.NumDays`, exactly-once guaranteed by early guard `Status != DaGuiDuyet throw`. OtRequest/Travel/Vehicle UNTOUCHED (only Leave has balance). CQRS `Application.Hrm`: DTO RemainingDays=Entitled+AdjustmentUsed COMPUTED (not stored) + GetMy/GetUser lazy-merge (load active LeaveTypes + balances → in-memory merge, synth default when no row — KHÔNG EF LEFT JOIN translate) + AdjustLeaveBalanceCommand admin upsert (HasValue-gated). **Policy resolved:** HRM admin convention = `[Authorize(Roles="Admin")]` NOT menu policy (verified HrmConfigsController write endpoints) — used on GET-by-user + PUT /adjust; /my = `[Authorize]`. Controller injects IDateTime for year default `clock.Now.Year` (thin, no DateTime.Now hardcode). HRM no HasQueryFilter → `.Where(!IsDeleted)` manual everywhere. KHÔNG touch FE/test/commit. Build 0 err (2 pre-existing DocxRenderer warn). Tag `[s43, p11-b-w1, mig42, leave-balance, single-entity]`.
- **S42 P11-A SEED — 4 sample ApprovalWorkflow V2 for WorkflowApps (DbInitializer.cs only, +4 method ~210 LOC + 4 call):** Case 1 mechanical mirror `SeedSampleProposalWorkflowV2Async` EXACT × 4 (Leave5/Ot6/Travel9/Vehicle7). Each: idempotent `AnyAsync(ApplicableType==X)` guard → resolve approver `binh.le@solutions.com.vn` (SAME user as Proposal/Contract seed, null→LogWarning+return) → 1 ApprovalWorkflow (Version=1, IsActive+IsUserSelectable=true, ActivatedAt=UtcNow) + 1 Step (Order=1, Name="Cấp duyệt", DepartmentId=CCM?.Id) + 1 Level (Order=1, ApproverUserId). Codes QT-NP/OT/CT/XE-V2-001. Wired 4 calls after SeedSampleProposalWorkflowV2Async (NOT gated DemoSeed, gotcha #51 infra seed). Enum verified Grep. Build 0 err 0 warn. Bash tool = bash NOT PowerShell despite env hint (use `cd && cmd | grep`). Spec deterministic 100% → ACCEPT Case 1. NOT touched App/Controller/FE/test/Mig. Tag `[s42, p11-a, seed, mirror-proposal-exact]`. - **S42 P11-A SEED — 4 sample ApprovalWorkflow V2 for WorkflowApps (DbInitializer.cs only, +4 method ~210 LOC + 4 call):** Case 1 mechanical mirror `SeedSampleProposalWorkflowV2Async` EXACT × 4 (Leave5/Ot6/Travel9/Vehicle7). Each: idempotent `AnyAsync(ApplicableType==X)` guard → resolve approver `binh.le@solutions.com.vn` (SAME user as Proposal/Contract seed, null→LogWarning+return) → 1 ApprovalWorkflow (Version=1, IsActive+IsUserSelectable=true, ActivatedAt=UtcNow) + 1 Step (Order=1, Name="Cấp duyệt", DepartmentId=CCM?.Id) + 1 Level (Order=1, ApproverUserId). Codes QT-NP/OT/CT/XE-V2-001. Wired 4 calls after SeedSampleProposalWorkflowV2Async (NOT gated DemoSeed, gotcha #51 infra seed). Enum verified Grep. Build 0 err 0 warn. Bash tool = bash NOT PowerShell despite env hint (use `cd && cmd | grep`). Spec deterministic 100% → ACCEPT Case 1. NOT touched App/Controller/FE/test/Mig. Tag `[s42, p11-a, seed, mirror-proposal-exact]`.
- **S42 P11-A Wave 2b APP — wire ApproveV2 CQRS Travel+Vehicle (`TravelVehicleApprovalFeatures.cs` ~830 LOC + 2 controller edit):** Cookie-cutter mirror Wave 2a / ProposalFeatures Region 2. 1 new file ns `Application.Office`: 2 module × (DetailDto + LevelOpinionDto + GetById JOIN Step/Level metadata + UpdateDraft + Submit + Approve UPSERT+advance + Reject TuChoi + Return TraLai+RejectedFromStatus) + 1 shared `internal static TravelVehicleCodeGen.GenerateMaDonTuAsync` (Serializable tx, `WorkflowAppCodeSequences` Prefix-keyed, prefix `DT/CT/{year}` Travel & `DX/XE/{year}` Vehicle, format `{prefix}/{seq:D3}` — D3 no year segment per spec). KEY gotcha: WorkflowAppStatus enum DIFFERS from ProposalStatus int values (DaGuiDuyet=2 not 1, TraLai=3) → mirror by SEMANTIC enum member not literal. Owner = `RequesterUserId` (not DrafterUserId). Submit verify wf.ApplicableType==Travel9/Vehicle7 else Conflict. 2 controller +6 route each (GET{id}/PUT/submit/approve/reject/return) nested body records, CreatedAtAction. KHÔNG sửa WorkflowAppsFeatures.cs/Leave/Ot/FE/test/seed. Build 0 err. Spec deterministic ~98% em main → ACCEPT Case 1/2. Tag `[s42, p11-a, wave-2b, mirror-proposal-region2]`. - **S42 P11-A Wave 2b APP — wire ApproveV2 CQRS Travel+Vehicle (`TravelVehicleApprovalFeatures.cs` ~830 LOC + 2 controller edit):** Cookie-cutter mirror Wave 2a / ProposalFeatures Region 2. 1 new file ns `Application.Office`: 2 module × (DetailDto + LevelOpinionDto + GetById JOIN Step/Level metadata + UpdateDraft + Submit + Approve UPSERT+advance + Reject TuChoi + Return TraLai+RejectedFromStatus) + 1 shared `internal static TravelVehicleCodeGen.GenerateMaDonTuAsync` (Serializable tx, `WorkflowAppCodeSequences` Prefix-keyed, prefix `DT/CT/{year}` Travel & `DX/XE/{year}` Vehicle, format `{prefix}/{seq:D3}` — D3 no year segment per spec). KEY gotcha: WorkflowAppStatus enum DIFFERS from ProposalStatus int values (DaGuiDuyet=2 not 1, TraLai=3) → mirror by SEMANTIC enum member not literal. Owner = `RequesterUserId` (not DrafterUserId). Submit verify wf.ApplicableType==Travel9/Vehicle7 else Conflict. 2 controller +6 route each (GET{id}/PUT/submit/approve/reject/return) nested body records, CreatedAtAction. KHÔNG sửa WorkflowAppsFeatures.cs/Leave/Ot/FE/test/seed. Build 0 err. Spec deterministic ~98% em main → ACCEPT Case 1/2. Tag `[s42, p11-a, wave-2b, mirror-proposal-region2]`.
- **S42 P11-A Wave 2a APP — wire ApproveV2 CQRS Leave+Ot (`LeaveOtApprovalFeatures.cs` ~770 LOC + 2 controller edit):** Pattern 4 (UPSERT in Approve, 0 opinion endpoint) + cookie-cutter mirror ProposalFeatures Region 2. 1 new file ns `Application.Office`: 2 module × (DetailDto + LevelOpinionDto + GetById JOIN Step/Level metadata + UpdateDraft + Submit + Approve UPSERT+advance + Reject TuChoi + Return TraLai+RejectedFromStatus) + 1 shared `internal static WorkflowAppCodeGen.GenerateMaDonTuAsync` (Serializable tx + `WorkflowAppCodeSequences` Prefix-keyed, prefix DT/LR & DT/OT, format `{prefix}/{year}/{seq:D3}`). Approve: flatten allLevels OrderBy Step→Level, currentSlot=allLevels[order-1], actor==ApproverUserId OR Admin, comment empty→placeholder, advance OR terminal DaDuyet. Submit verify wf.ApplicableType==Leave5/Ot6 else Conflict + gen MaDonTu nếu null. 2 controller +6 route each (GET{id}/PUT/submit/approve/reject/return) mirror ProposalsController nested body records (`WorkflowActionBody`). KHÔNG sửa WorkflowAppsFeatures.cs (Region 1 Create/List ở đó). Build 0 err (2 warn DocxRenderer pre-existing). Spec deterministic 100% em main → ACCEPT Case 1. Travel/Vehicle (Wave 2b) + test (Wave 4) deferred. Tag `[s42, p11-a, wave-2a, mirror-proposal-region2]`. - **S42 P11-A Wave 2a APP — wire ApproveV2 CQRS Leave+Ot (`LeaveOtApprovalFeatures.cs` ~770 LOC + 2 controller edit):** Pattern 4 (UPSERT in Approve, 0 opinion endpoint) + cookie-cutter mirror ProposalFeatures Region 2. 1 new file ns `Application.Office`: 2 module × (DetailDto + LevelOpinionDto + GetById JOIN Step/Level metadata + UpdateDraft + Submit + Approve UPSERT+advance + Reject TuChoi + Return TraLai+RejectedFromStatus) + 1 shared `internal static WorkflowAppCodeGen.GenerateMaDonTuAsync` (Serializable tx + `WorkflowAppCodeSequences` Prefix-keyed, prefix DT/LR & DT/OT, format `{prefix}/{year}/{seq:D3}`). Approve: flatten allLevels OrderBy Step→Level, currentSlot=allLevels[order-1], actor==ApproverUserId OR Admin, comment empty→placeholder, advance OR terminal DaDuyet. Submit verify wf.ApplicableType==Leave5/Ot6 else Conflict + gen MaDonTu nếu null. 2 controller +6 route each (GET{id}/PUT/submit/approve/reject/return) mirror ProposalsController nested body records (`WorkflowActionBody`). KHÔNG sửa WorkflowAppsFeatures.cs (Region 1 Create/List ở đó). Build 0 err (2 warn DocxRenderer pre-existing). Spec deterministic 100% em main → ACCEPT Case 1. Travel/Vehicle (Wave 2b) + test (Wave 4) deferred. Tag `[s42, p11-a, wave-2a, mirror-proposal-region2]`.

View File

@ -42,7 +42,8 @@ Dynamic class purged. PALETTE array full literal `as const` cycle `index % lengt
## 📅 Recent activity (last 10 FIFO) ## 📅 Recent activity (last 10 FIFO)
- **2026-05-29 (S39 agent split setup):** NEW agent từ split implementer. Seeded FE patterns (16-bis 9× + SHA256 mirror + KIND_CONFIG + Tailwind palette + PageHeader S37). Prior FE work absorbed: S33 EmployeesListPage + S34 Directory + S35 HrmConfigs declarative + S36 MeetingCalendar + S37 Proposal + S38 WorkflowApps generic. First dedicated spawn pending em main S39+ FE task. - **2026-05-30 (S42 P11-B Wave 2 — leave balance display):** WorkflowAppDetailPage.tsx + workflowApps.ts (2 app SHA256 identical). +3 optional `leaveBalance{Entitled,Used,Remaining}?: number|null` trong `// leave` block (BE `decimal?` → camelCase). Block "Số dư phép" sau Section 1 IIFE `kind==='leave' && d.leaveBalanceRemaining != null`: year từ StartDate, banner amber/red khi `remaining<0 || (status!==DaDuyet && remaining<numDays)`. Case 1, KHÔNG 4-place (enrich existing page). cp fe-admin→fe-user. Build PASS ×2 (page 8ef83e4b, type 1c4f167a). Lesson reuse: IIFE inline `(() => {...})()` cho conditional block có derived vars — sạch hơn tách helper.
- **2026-05-29 (S39 agent split setup):** NEW agent từ split implementer. Seeded FE patterns (16-bis 9× + SHA256 mirror + KIND_CONFIG + Tailwind palette + PageHeader S37). Prior FE work absorbed: S33 EmployeesListPage + S34 Directory + S35 HrmConfigs declarative + S36 MeetingCalendar + S37 Proposal + S38 WorkflowApps generic.
--- ---

View File

@ -57,6 +57,7 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
## 📅 Recent activity (FIFO — older → archive/git) ## 📅 Recent activity (FIFO — older → archive/git)
- **2026-05-30 (S43 P11-B LeaveBalance pre-commit — PASS, Max no-truncate):** 14 file (LeaveBalance entity+config+Mig42 + Features + Controller + deduction hook + Create/Update LeaveType guard + embed balance + FE×4 + tests). 154 PASS (130→154). **Deduction exactly-once VERIFIED** (terminal else only, guard Status!=DaGuiDuyet chặn re-approve; advance/reject/return no-deduct). **FK invariant fully closed** — grep 2 write site LeaveTypeId (Create + UpdateDraft) cả 2 guard AnyAsync→Conflict, bogus type không thể tới terminal FK insert. Embed balance = RequesterUserId (approver thấy đúng người tạo). admin `[Authorize(Roles=Admin)]`. **2 MINOR defer:** concurrency lost-update UsedDays (no RowVersion — human-sequential accept) · stale line-num comment. Verdict PASS. Tag `[s43, p11b-leavebalance, max-clean]`.
- **2026-05-28 (S35 G-H2 BE CRUD 16 endpoint pre-commit — PASS, Smart Friend 8× CLEAN):** 2 NEW file `HrmConfigFeatures.cs` 439 + Controller 137. build clean, 130/130 PASS. Cat1: 0 mock, 8 ConflictException (Holiday Update composite `(Year,Date)` BOTH fields). Cat3: class `[Authorize]` + 12 per-action `[Authorize(Roles="Admin")]`. Cat5: 8 Validator MaxLength MATCH EF source (Code=50 not spec 20). **2 MINOR defer:** ListHolidays no IsActive filter (inconsistent sibling) · OtPolicy "1 active unique" NOT enforced handler (G-P1 ambiguous nếu 2+ active). Verdict PASS. Tag `[s35, smart-friend-8x-clean]`. - **2026-05-28 (S35 G-H2 BE CRUD 16 endpoint pre-commit — PASS, Smart Friend 8× CLEAN):** 2 NEW file `HrmConfigFeatures.cs` 439 + Controller 137. build clean, 130/130 PASS. Cat1: 0 mock, 8 ConflictException (Holiday Update composite `(Year,Date)` BOTH fields). Cat3: class `[Authorize]` + 12 per-action `[Authorize(Roles="Admin")]`. Cat5: 8 Validator MaxLength MATCH EF source (Code=50 not spec 20). **2 MINOR defer:** ListHolidays no IsActive filter (inconsistent sibling) · OtPolicy "1 active unique" NOT enforced handler (G-P1 ambiguous nếu 2+ active). Verdict PASS. Tag `[s35, smart-friend-8x-clean]`.
- **2026-05-26 (S33 Plan B G-H1 Phase 2 pre-commit — PASS, Smart Friend 6× CLEAN):** 17 file (3 BE + 6 FE new + 6 mod + 2). SHA256 mirror 3 file IDENTICAL admin==user. 5 endpoint real mediator.Send 0 mock. Mig 34 `AddEmployeeProfiles` 7 table UNIQUE indexes + FK Cascade. SeedDemoEmployeeProfiles NOT gated DemoSeed (gotcha #51 ✓). gotcha #50 Layout staticMap mirror ✓. **3 MINOR defer:** EmployeeCode race SERIALIZABLE low-risk · Update 3 bool not nullable (partial reset) · Delete DateTime.UtcNow direct. Verdict PASS. Tag `[s33, hrm-mig34, smart-friend-6x]`. - **2026-05-26 (S33 Plan B G-H1 Phase 2 pre-commit — PASS, Smart Friend 6× CLEAN):** 17 file (3 BE + 6 FE new + 6 mod + 2). SHA256 mirror 3 file IDENTICAL admin==user. 5 endpoint real mediator.Send 0 mock. Mig 34 `AddEmployeeProfiles` 7 table UNIQUE indexes + FK Cascade. SeedDemoEmployeeProfiles NOT gated DemoSeed (gotcha #51 ✓). gotcha #50 Layout staticMap mirror ✓. **3 MINOR defer:** EmployeeCode race SERIALIZABLE low-risk · Update 3 bool not nullable (partial reset) · Delete DateTime.UtcNow direct. Verdict PASS. Tag `[s33, hrm-mig34, smart-friend-6x]`.
- **Smart Friend cumulative 8× CLEAN:** (1) S22 #44 silent-403 · (2) S25 #48 SQLite tie-break · (3) S29 password ≥12 · (4) S29 ApplicableType cross-module · (5) S33 BW test · (6) S33 Plan B Phase 2 · (7) S35 FE forms · (8) S35 G-H2. Plus 9× G-O2 (S36, em không track ở đây). 2 MAJOR catches total (S29 password + S29 ApplicableType); rest clean với MINOR defer. - **Smart Friend cumulative 8× CLEAN:** (1) S22 #44 silent-403 · (2) S25 #48 SQLite tie-break · (3) S29 password ≥12 · (4) S29 ApplicableType cross-module · (5) S33 BW test · (6) S33 Plan B Phase 2 · (7) S35 FE forms · (8) S35 G-H2. Plus 9× G-O2 (S36, em không track ở đây). 2 MAJOR catches total (S29 password + S29 ApplicableType); rest clean với MINOR defer.

View File

@ -15,9 +15,12 @@ WRITE specialist độc quyền `tests/**`. xUnit + FluentAssertions 7.2 + EF SQ
- ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix - ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix
- ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority - ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority
## 📊 Baseline 141 PASS (58 Domain + 83 Infra) ← S42 +11 WorkflowApp ApproveV2 ## 📊 Baseline 152 PASS (58 Domain + 94 Infra) ← S43 +8 LeaveBalance + repaired 2 template terminal FK-fail
Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal` Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal`
### ⚠️ Pattern: deduction hook FK → seed LeaveType cho terminal test (S43)
LeaveBalance → LeaveType `Restrict` FK. ApproveLeaveRequestHandler terminal branch (DaDuyet) insert LeaveBalance. Test đi tới DaDuyet PHẢI seed 1 LeaveType row + LeaveRequest.LeaveTypeId = type.Id (KHÔNG random Guid → FK fail SQLite Error 19). Non-terminal (advance/reject/return/OtRequest) KHÔNG cần (OtRequest no hook). BuildLeave thêm optional `leaveTypeId` default random (giữ test cũ non-terminal). Year = StartDate.Year. Negative allowed (no quota guard → Remaining<0 OK). Query lazy synth Entitled=DaysPerYear khi 0 row.
## ⏱️ Timing rules (docs/rules.md §7) ## ⏱️ Timing rules (docs/rules.md §7)
- Feature mới = test-after (UAT ổn viết, Phase 9 skip per `feedback_uat_skip_verify`) - Feature mới = test-after (UAT ổn viết, Phase 9 skip per `feedback_uat_skip_verify`)
- Bug fix = test-before BẮT BUỘC (reproduce fix) - Bug fix = test-before BẮT BUỘC (reproduce fix)
@ -49,9 +52,9 @@ Test theo CODE (single source truth), document mismatch header comment + report.
## 📅 Recent activity (last 10 FIFO) ## 📅 Recent activity (last 10 FIFO)
- **2026-05-29 (S39 agent split setup):** NEW dedicated agent. Seeded test patterns (10 reflection authz + 11 infra helper + 12 InternalsVisibleTo + #48 SQLite tie-break + spec drift S34). Inherited coverage gap backlog 4 priority items từ S36 Reviewer audit (130 PASS baseline). First spawn pending em main S39+ test bundle task (recommend Gap 1 Holiday composite UNIQUE first).
- **2026-05-29 (S40 baseline audit smoke):** CONFIRMED 130 PASS (Domain 58 + Infra 72), 0 fail/skip, ~15s. Runner count authoritative; raw `[Fact]/[Theory]` attr = 48+70 (TheoryInlineData expand). Infra spread 15 files. Gap re-verified vs prod: EmployeesController+HrmConfigsController EXIST, authz regression chỉ ApprovalWorkflowsV2Controller (gotcha #44 gap real). Proposal = Domain entity + EF config only, CHƯA ApproveV2Async service (S37 skeleton, defer đúng). Agent load OK. AUDIT-only, no write. - **2026-05-29 (S40 baseline audit smoke):** CONFIRMED 130 PASS (Domain 58 + Infra 72), 0 fail/skip, ~15s. Runner count authoritative; raw `[Fact]/[Theory]` attr = 48+70 (TheoryInlineData expand). Infra spread 15 files. Gap re-verified vs prod: EmployeesController+HrmConfigsController EXIST, authz regression chỉ ApprovalWorkflowsV2Controller (gotcha #44 gap real). Proposal = Domain entity + EF config only, CHƯA ApproveV2Async service (S37 skeleton, defer đúng). Agent load OK. AUDIT-only, no write.
- **2026-05-30 (S42 P11-A Wave4):** +11 test `tests/.../Application/WorkflowAppApproveV2Tests.cs` **141 PASS** (Infra 7283). LeaveRequest 8 case full (Submit happy/guard×2, Approve advance/terminal/UPSERT-invariant/forbidden/empty-comment-placeholder, RejectTuChoi, ReturnTraLai+RejectedFromStatus) + OtRequest smoke (submitapprove single-levelDaDuyet). **No prod bug** LeaveOt ApproveV2 wire correct, all PASS first run. **NEW Pattern:** WorkflowApps handlers = CQRS MediatR (KHÔNG service) instantiate handler trực tiếp `new ApproveLeaveRequestHandler(db, AsUser(u), clock).Handle(cmd,ct)`, chỉ 3 dep (IApplicationDbContext + TestCurrentUser + FixedDateTime) nhẹ hơn 6-dep Contract service. MaDonTu format "DT/LR/2026/001". Gap #4 (Workflow Apps) PARTIAL done Travel/Vehicle mirror pending. Lesson: CWD drift (fe-user) ghi MEMORY nhầm path, em main relocate. Verify CWD root trước Write memory. - **2026-05-30 (S42 P11-A Wave4):** +11 test `tests/.../Application/WorkflowAppApproveV2Tests.cs` **141 PASS** (Infra 7283). LeaveRequest 8 case full (Submit happy/guard×2, Approve advance/terminal/UPSERT-invariant/forbidden/empty-comment-placeholder, RejectTuChoi, ReturnTraLai+RejectedFromStatus) + OtRequest smoke (submitapprove single-levelDaDuyet). **No prod bug** LeaveOt ApproveV2 wire correct, all PASS first run. **NEW Pattern:** WorkflowApps handlers = CQRS MediatR (KHÔNG service) instantiate handler trực tiếp `new ApproveLeaveRequestHandler(db, AsUser(u), clock).Handle(cmd,ct)`, chỉ 3 dep (IApplicationDbContext + TestCurrentUser + FixedDateTime) nhẹ hơn 6-dep Contract service. MaDonTu format "DT/LR/2026/001". Gap #4 (Workflow Apps) PARTIAL done Travel/Vehicle mirror pending. Lesson: CWD drift (fe-user) ghi MEMORY nhầm path, em main relocate. Verify CWD root trước Write memory.
- **2026-05-30 (S43 P11-B Wave3 LeaveBalance):** +8 test `tests/.../Application/LeaveBalanceTests.cs` **152 PASS** (Infra 8694). Deduction hook (ApproveLeaveRequestHandler terminal) full: deduct single-level (create row from DaysPerYear), only-at-terminal multi-level (advance no-deduct + 1× terminal), accumulate UPSERT (5+2=7 no new row), negative allowed (Used20>Entitled12 → Remaining8 no throw), Reject+Return no-deduct (split 5a/5b), GetMyLeaveBalances lazy synth (2 active type filter inactive), AdjustLeaveBalance upsert. **⚠️ FOUND + FIXED 2 pre-existing RED** in S42 template (`Approve_LastLevel_TransitionsToDaDuyet` + `Approve_EmptyComment_StoresPlaceholder`): Wave 1 deduction hook (uncommitted, prod) làm terminal insert LeaveBalance FK→LeaveTypes Restrict FAIL vì BuildLeave dùng `LeaveTypeId=Guid.NewGuid()`. **NOT prod bug** (prod đơn luôn pin LeaveType thật) — fix tại test: BuildLeave +optional leaveTypeId, seed LeaveType ở 2 test đó. Baseline thật trước S43 = 142-pass/2-RED (KHÔNG phải 144-green). REPORTED em main.
--- ---

View File

@ -345,6 +345,33 @@ export function WorkflowAppDetailPage() {
</div> </div>
</div> </div>
{/* Số dư phép (chỉ kind=leave) — Wave 2 hiển thị balance đã embed trong detail */}
{kind === 'leave' && d.leaveBalanceRemaining != null && (() => {
const remaining = d.leaveBalanceRemaining
const numDays = d.numDays ?? 0
const year = d.startDate ? new Date(d.startDate).getFullYear() : new Date().getFullYear()
const isApproved = d.status === WorkflowAppStatus.DaDuyet
const overBudget = remaining < 0 || (!isApproved && remaining < numDays)
return (
<div className="rounded-lg border bg-card p-6 space-y-3">
<h3 className="font-semibold text-base">Số phép</h3>
<div className="text-sm">
Số phép năm <span className="font-semibold">{year}</span>:{' '}
Đưc hưởng <span className="font-medium">{d.leaveBalanceEntitled ?? '—'}</span> ·{' '}
Đã dùng <span className="font-medium">{d.leaveBalanceUsed ?? '—'}</span> ·{' '}
<span className="font-semibold">Còn {remaining}</span> ngày
</div>
{overBudget && (
<div className="rounded-lg border border-red-300 bg-amber-50/50 p-3 text-sm font-medium text-amber-900">
{remaining < 0
? '⚠️ Đã âm số dư phép'
: `⚠️ Đơn ${numDays} ngày vượt số dư còn lại (${remaining} ngày)`}
</div>
)}
</div>
)
})()}
{/* Section 2: Quy trình duyệt */} {/* Section 2: Quy trình duyệt */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <div className="rounded-lg border bg-card p-6 space-y-3">
<h3 className="font-semibold text-base">2. Quy trình duyệt</h3> <h3 className="font-semibold text-base">2. Quy trình duyệt</h3>

View File

@ -78,6 +78,10 @@ export interface WorkflowAppDetail {
endDate?: string endDate?: string
numDays?: number numDays?: number
reason?: string reason?: string
// leave balance (P11-B Wave 1: embed số dư phép NGƯỜI TẠO cho loại phép + năm đơn; null nếu loại phép không tồn tại)
leaveBalanceEntitled?: number | null
leaveBalanceUsed?: number | null
leaveBalanceRemaining?: number | null
// ot // ot
otDate?: string otDate?: string
startTime?: string startTime?: string

View File

@ -345,6 +345,33 @@ export function WorkflowAppDetailPage() {
</div> </div>
</div> </div>
{/* Số dư phép (chỉ kind=leave) — Wave 2 hiển thị balance đã embed trong detail */}
{kind === 'leave' && d.leaveBalanceRemaining != null && (() => {
const remaining = d.leaveBalanceRemaining
const numDays = d.numDays ?? 0
const year = d.startDate ? new Date(d.startDate).getFullYear() : new Date().getFullYear()
const isApproved = d.status === WorkflowAppStatus.DaDuyet
const overBudget = remaining < 0 || (!isApproved && remaining < numDays)
return (
<div className="rounded-lg border bg-card p-6 space-y-3">
<h3 className="font-semibold text-base">Số phép</h3>
<div className="text-sm">
Số phép năm <span className="font-semibold">{year}</span>:{' '}
Đưc hưởng <span className="font-medium">{d.leaveBalanceEntitled ?? '—'}</span> ·{' '}
Đã dùng <span className="font-medium">{d.leaveBalanceUsed ?? '—'}</span> ·{' '}
<span className="font-semibold">Còn {remaining}</span> ngày
</div>
{overBudget && (
<div className="rounded-lg border border-red-300 bg-amber-50/50 p-3 text-sm font-medium text-amber-900">
{remaining < 0
? '⚠️ Đã âm số dư phép'
: `⚠️ Đơn ${numDays} ngày vượt số dư còn lại (${remaining} ngày)`}
</div>
)}
</div>
)
})()}
{/* Section 2: Quy trình duyệt */} {/* Section 2: Quy trình duyệt */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <div className="rounded-lg border bg-card p-6 space-y-3">
<h3 className="font-semibold text-base">2. Quy trình duyệt</h3> <h3 className="font-semibold text-base">2. Quy trình duyệt</h3>

View File

@ -78,6 +78,10 @@ export interface WorkflowAppDetail {
endDate?: string endDate?: string
numDays?: number numDays?: number
reason?: string reason?: string
// leave balance (P11-B Wave 1: embed số dư phép NGƯỜI TẠO cho loại phép + năm đơn; null nếu loại phép không tồn tại)
leaveBalanceEntitled?: number | null
leaveBalanceUsed?: number | null
leaveBalanceRemaining?: number | null
// ot // ot
otDate?: string otDate?: string
startTime?: string startTime?: string

View File

@ -0,0 +1,42 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Hrm;
namespace SolutionErp.Api.Controllers;
// Phase 11 P11-B Wave 1 (Mig 42 — S43) — Số dư phép theo năm.
// /my = mọi user đăng nhập (xem phép của chính mình). Admin endpoint (xem user khác +
// điều chỉnh) dùng [Authorize(Roles = "Admin")] — mirror HrmConfigsController convention
// (HRM write/admin = Roles "Admin", KHÔNG menu policy).
[ApiController]
[Route("api/leave-balances")]
[Authorize]
public class LeaveBalancesController(IMediator mediator, IDateTime clock) : ControllerBase
{
[HttpGet("my")]
public async Task<IActionResult> GetMy([FromQuery] int? year)
=> Ok(await mediator.Send(new GetMyLeaveBalancesQuery(year ?? clock.Now.Year)));
[HttpGet]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> GetForUser([FromQuery] Guid userId, [FromQuery] int? year)
=> Ok(await mediator.Send(new GetUserLeaveBalancesQuery(userId, year ?? clock.Now.Year)));
[HttpPut("adjust")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Adjust([FromBody] AdjustLeaveBalanceBody body)
{
await mediator.Send(new AdjustLeaveBalanceCommand(
body.UserId, body.LeaveTypeId, body.Year, body.EntitledDays, body.AdjustmentDays));
return NoContent();
}
public record AdjustLeaveBalanceBody(
Guid UserId,
Guid LeaveTypeId,
int Year,
decimal? EntitledDays,
decimal? AdjustmentDays);
}

View File

@ -139,5 +139,8 @@ public interface IApplicationDbContext
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS. // Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
DbSet<Attendance> Attendances { get; } DbSet<Attendance> Attendances { get; }
// Phase 11 P11-B Wave 1 (Mig 42) — Số dư phép theo năm (NV × LoạiPhép × Năm).
DbSet<LeaveBalance> LeaveBalances { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
} }

View File

@ -0,0 +1,150 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Hrm;
namespace SolutionErp.Application.Hrm;
// Phase 11 P11-B Wave 1 (Mig 42 — S43 2026-05-30) — Số dư phép theo năm.
// Query lazy: list mọi LeaveType IsActive LEFT JOIN LeaveBalance — chưa có row thì
// synthesize default (Entitled=DaysPerYear, Used=0, Adjustment=0). Admin upsert qua
// AdjustLeaveBalanceCommand. Trừ phép tự động ở ApproveLeaveRequestHandler (terminal).
//
// RemainingDays = EntitledDays + AdjustmentDays UsedDays (COMPUTED, KHÔNG store).
// HRM entities KHÔNG có global HasQueryFilter → query phải Where(!IsDeleted) thủ công.
// ===== DTO =====
public record LeaveBalanceDto(
Guid LeaveTypeId,
string Code,
string Name,
int Year,
decimal EntitledDays,
decimal UsedDays,
decimal AdjustmentDays,
decimal RemainingDays,
decimal DaysPerYear,
bool IsPaid);
// ===== Shared builder: merge active LeaveTypes + balances cho 1 user/năm =====
internal static class LeaveBalanceProjection
{
public static async Task<List<LeaveBalanceDto>> BuildAsync(
IApplicationDbContext db, Guid userId, int year, CancellationToken ct)
{
var types = await db.LeaveTypes.AsNoTracking()
.Where(t => !t.IsDeleted && t.IsActive)
.OrderBy(t => t.Code)
.Select(t => new { t.Id, t.Code, t.Name, t.DaysPerYear, t.IsPaid })
.ToListAsync(ct);
var balances = await db.LeaveBalances.AsNoTracking()
.Where(b => !b.IsDeleted && b.UserId == userId && b.Year == year)
.Select(b => new { b.LeaveTypeId, b.EntitledDays, b.UsedDays, b.AdjustmentDays })
.ToListAsync(ct);
var byType = balances.ToDictionary(b => b.LeaveTypeId);
return types.Select(t =>
{
byType.TryGetValue(t.Id, out var b);
var entitled = b?.EntitledDays ?? t.DaysPerYear;
var used = b?.UsedDays ?? 0m;
var adjustment = b?.AdjustmentDays ?? 0m;
return new LeaveBalanceDto(
t.Id, t.Code, t.Name, year,
entitled, used, adjustment,
entitled + adjustment - used,
t.DaysPerYear, t.IsPaid);
}).ToList();
}
}
// ===== Query: số dư phép của chính mình =====
public record GetMyLeaveBalancesQuery(int Year) : IRequest<List<LeaveBalanceDto>>;
public class GetMyLeaveBalancesHandler(IApplicationDbContext db, ICurrentUser cu)
: IRequestHandler<GetMyLeaveBalancesQuery, List<LeaveBalanceDto>>
{
public async Task<List<LeaveBalanceDto>> Handle(GetMyLeaveBalancesQuery req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
return await LeaveBalanceProjection.BuildAsync(db, cu.UserId.Value, req.Year, ct);
}
}
// ===== Query: số dư phép của 1 user (admin) =====
public record GetUserLeaveBalancesQuery(Guid UserId, int Year) : IRequest<List<LeaveBalanceDto>>;
public class GetUserLeaveBalancesHandler(IApplicationDbContext db)
: IRequestHandler<GetUserLeaveBalancesQuery, List<LeaveBalanceDto>>
{
public async Task<List<LeaveBalanceDto>> Handle(GetUserLeaveBalancesQuery req, CancellationToken ct)
=> await LeaveBalanceProjection.BuildAsync(db, req.UserId, req.Year, ct);
}
// ===== Command: admin upsert (carry-over / điều chỉnh) =====
public record AdjustLeaveBalanceCommand(
Guid UserId,
Guid LeaveTypeId,
int Year,
decimal? EntitledDays,
decimal? AdjustmentDays) : IRequest;
public class AdjustLeaveBalanceValidator : AbstractValidator<AdjustLeaveBalanceCommand>
{
public AdjustLeaveBalanceValidator()
{
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.LeaveTypeId).NotEmpty();
RuleFor(x => x.Year).InclusiveBetween(2000, 2100);
RuleFor(x => x.EntitledDays).GreaterThanOrEqualTo(0).When(x => x.EntitledDays.HasValue);
}
}
public class AdjustLeaveBalanceHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<AdjustLeaveBalanceCommand>
{
public async Task Handle(AdjustLeaveBalanceCommand req, CancellationToken ct)
{
var typeExists = await db.LeaveTypes.AsNoTracking()
.AnyAsync(t => t.Id == req.LeaveTypeId && !t.IsDeleted, ct);
if (!typeExists) throw new NotFoundException("LeaveType", req.LeaveTypeId);
var bal = await db.LeaveBalances
.FirstOrDefaultAsync(b => b.UserId == req.UserId && b.LeaveTypeId == req.LeaveTypeId
&& b.Year == req.Year && !b.IsDeleted, ct);
if (bal is null)
{
var daysPerYear = await db.LeaveTypes.AsNoTracking()
.Where(t => t.Id == req.LeaveTypeId).Select(t => t.DaysPerYear).FirstOrDefaultAsync(ct);
bal = new LeaveBalance
{
UserId = req.UserId,
LeaveTypeId = req.LeaveTypeId,
Year = req.Year,
EntitledDays = req.EntitledDays ?? daysPerYear,
UsedDays = 0,
AdjustmentDays = req.AdjustmentDays ?? 0,
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
};
db.LeaveBalances.Add(bal);
}
else
{
if (req.EntitledDays.HasValue) bal.EntitledDays = req.EntitledDays.Value;
if (req.AdjustmentDays.HasValue) bal.AdjustmentDays = req.AdjustmentDays.Value;
bal.UpdatedAt = clock.UtcNow;
bal.UpdatedBy = cu.UserId;
}
await db.SaveChangesAsync(ct);
}
}

View File

@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Hrm;
using SolutionErp.Domain.Office; using SolutionErp.Domain.Office;
namespace SolutionErp.Application.Office; namespace SolutionErp.Application.Office;
@ -78,7 +79,12 @@ public record LeaveRequestDetailDto(
int? CurrentApprovalLevelOrder, int? CurrentApprovalLevelOrder,
int? RejectedFromStatus, int? RejectedFromStatus,
DateTime CreatedAt, DateTime CreatedAt,
List<LeaveRequestLevelOpinionDto> LevelOpinions); List<LeaveRequestLevelOpinionDto> LevelOpinions,
// P11-B: số dư phép của NGƯỜI TẠO cho (LeaveType, năm của đơn) — embed để approver
// cũng thấy (khác /my = balance người xem). null nếu loại phép không tồn tại.
decimal? LeaveBalanceEntitled,
decimal? LeaveBalanceUsed,
decimal? LeaveBalanceRemaining);
public record GetLeaveRequestByIdQuery(Guid Id) : IRequest<LeaveRequestDetailDto?>; public record GetLeaveRequestByIdQuery(Guid Id) : IRequest<LeaveRequestDetailDto?>;
@ -140,12 +146,36 @@ public class GetLeaveRequestByIdHandler(IApplicationDbContext db)
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder) .OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
.ToList(); .ToList();
// P11-B: số dư phép người tạo cho (LeaveType, năm của StartDate) — hiển thị + cảnh báo vượt.
// Lazy: chưa có row → từ LeaveType.DaysPerYear (Used=0). Remaining = Entitled + Adjustment Used.
var balYear = p.StartDate.Year;
var balRow = await db.LeaveBalances.AsNoTracking()
.Where(b => b.UserId == p.RequesterUserId && b.LeaveTypeId == p.LeaveTypeId
&& b.Year == balYear && !b.IsDeleted)
.Select(b => new { b.EntitledDays, b.UsedDays, b.AdjustmentDays })
.FirstOrDefaultAsync(ct);
decimal? balEntitled, balUsed, balRemaining;
if (balRow is not null)
{
balEntitled = balRow.EntitledDays + balRow.AdjustmentDays;
balUsed = balRow.UsedDays;
balRemaining = balRow.EntitledDays + balRow.AdjustmentDays - balRow.UsedDays;
}
else
{
var dpy = await db.LeaveTypes.AsNoTracking()
.Where(t => t.Id == p.LeaveTypeId).Select(t => (decimal?)t.DaysPerYear).FirstOrDefaultAsync(ct);
balEntitled = dpy;
balUsed = dpy.HasValue ? 0m : (decimal?)null;
balRemaining = dpy;
}
return new LeaveRequestDetailDto( return new LeaveRequestDetailDto(
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName, p.LeaveTypeId, p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName, p.LeaveTypeId,
p.StartDate, p.EndDate, p.NumDays, p.Reason, (int)p.Status, p.StartDate, p.EndDate, p.NumDays, p.Reason, (int)p.Status,
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder, p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null, p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
p.CreatedAt, opinions); p.CreatedAt, opinions, balEntitled, balUsed, balRemaining);
} }
} }
@ -195,6 +225,11 @@ public class UpdateLeaveRequestDraftHandler(IApplicationDbContext db, ICurrentUs
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn nghỉ phép."); throw new ConflictException("Quy trình duyệt không thuộc loại Đơn nghỉ phép.");
} }
// P11-B: enforce LeaveTypeId tồn tại nếu đổi (deduction FK→LeaveTypes Restrict).
if (req.LeaveTypeId != p.LeaveTypeId
&& !await db.LeaveTypes.AsNoTracking().AnyAsync(t => t.Id == req.LeaveTypeId, ct))
throw new ConflictException("Loại phép không tồn tại.");
p.LeaveTypeId = req.LeaveTypeId; p.LeaveTypeId = req.LeaveTypeId;
p.StartDate = req.StartDate; p.StartDate = req.StartDate;
p.EndDate = req.EndDate; p.EndDate = req.EndDate;
@ -319,6 +354,32 @@ public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser c
{ {
p.Status = WorkflowAppStatus.DaDuyet; p.Status = WorkflowAppStatus.DaDuyet;
p.CurrentApprovalLevelOrder = null; p.CurrentApprovalLevelOrder = null;
// P11-B: trừ phép khi duyệt cuối — chạy đúng 1 lần (DaDuyet không approve lại,
// early guard Status != DaGuiDuyet chặn re-approve). UPSERT LeaveBalance theo năm.
var year = p.StartDate.Year;
var bal = await db.LeaveBalances.FirstOrDefaultAsync(
b => b.UserId == p.RequesterUserId && b.LeaveTypeId == p.LeaveTypeId && b.Year == year, ct);
if (bal is null)
{
var daysPerYear = await db.LeaveTypes.AsNoTracking()
.Where(t => t.Id == p.LeaveTypeId).Select(t => t.DaysPerYear).FirstOrDefaultAsync(ct);
bal = new LeaveBalance
{
UserId = p.RequesterUserId,
LeaveTypeId = p.LeaveTypeId,
Year = year,
EntitledDays = daysPerYear,
UsedDays = 0,
AdjustmentDays = 0,
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
};
db.LeaveBalances.Add(bal);
}
bal.UsedDays += p.NumDays;
bal.UpdatedAt = clock.UtcNow;
bal.UpdatedBy = cu.UserId;
} }
p.UpdatedAt = clock.UtcNow; p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId; p.UpdatedBy = cu.UserId;

View File

@ -41,6 +41,10 @@ public class CreateLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu
public async Task<Guid> Handle(CreateLeaveRequestCommand req, CancellationToken ct) public async Task<Guid> Handle(CreateLeaveRequestCommand req, CancellationToken ct)
{ {
if (cu.UserId is null) throw new UnauthorizedException(); if (cu.UserId is null) throw new UnauthorizedException();
// P11-B: enforce LeaveTypeId tồn tại (deduction lúc duyệt cuối insert LeaveBalance
// FK→LeaveTypes Restrict — bogus type → 500 kẹt đơn). Guard tại cửa Create.
if (!await db.LeaveTypes.AsNoTracking().AnyAsync(t => t.Id == req.LeaveTypeId, ct))
throw new ConflictException("Loại phép không tồn tại.");
var e = new LeaveRequest var e = new LeaveRequest
{ {
RequesterUserId = cu.UserId.Value, RequesterUserId = cu.UserId.Value,

View File

@ -0,0 +1,27 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Hrm;
// Phase 11 P11-B Wave 1 (Mig 42 — S43 2026-05-30) — Số dư phép theo năm.
// Track quota phép từng NV × LoạiPhép × Năm. Trừ phép tự động khi đơn nghỉ
// phép duyệt cuối (ApproveLeaveRequestHandler terminal branch UPSERT UsedDays).
//
// Remaining = EntitledDays + AdjustmentDays UsedDays (COMPUTED ở DTO, KHÔNG store).
// UNIQUE (UserId, LeaveTypeId, Year) — 1 row mỗi NV mỗi loại mỗi năm.
public class LeaveBalance : AuditableEntity
{
public Guid UserId { get; set; }
public Guid LeaveTypeId { get; set; }
public int Year { get; set; }
// Phân bổ năm — mặc định lấy từ LeaveType.DaysPerYear lúc tạo row.
public decimal EntitledDays { get; set; }
// Đã dùng — cộng dồn NumDays mỗi đơn nghỉ phép duyệt cuối.
public decimal UsedDays { get; set; }
// Admin carry-over / điều chỉnh (dồn phép năm trước, thưởng phép...). Default 0.
public decimal AdjustmentDays { get; set; }
public LeaveType? LeaveType { get; set; }
}

View File

@ -125,6 +125,9 @@ public class ApplicationDbContext
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS. // Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
public DbSet<Attendance> Attendances => Set<Attendance>(); public DbSet<Attendance> Attendances => Set<Attendance>();
// Phase 11 P11-B Wave 1 (Mig 42) — Số dư phép theo năm.
public DbSet<LeaveBalance> LeaveBalances => Set<LeaveBalance>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
base.OnModelCreating(builder); base.OnModelCreating(builder);

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Hrm;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 42 P11-B Wave 1 (Phase 11) — Số dư phép theo năm.
// FK LeaveType WithMany() Restrict (catalog không cascade). UNIQUE composite
// (UserId, LeaveTypeId, Year). HRM no global HasQueryFilter — list query
// MUST .Where(!IsDeleted) thủ công ở handler.
public class LeaveBalanceConfiguration : IEntityTypeConfiguration<LeaveBalance>
{
public void Configure(EntityTypeBuilder<LeaveBalance> e)
{
e.ToTable("LeaveBalances");
e.Property(x => x.EntitledDays).HasPrecision(5, 2);
e.Property(x => x.UsedDays).HasPrecision(5, 2);
e.Property(x => x.AdjustmentDays).HasPrecision(5, 2);
e.HasOne(x => x.LeaveType)
.WithMany()
.HasForeignKey(x => x.LeaveTypeId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.UserId, x.LeaveTypeId, x.Year }).IsUnique();
e.HasIndex(x => x.UserId);
}
}

View File

@ -0,0 +1,68 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddLeaveBalances : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LeaveBalances",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LeaveTypeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Year = table.Column<int>(type: "int", nullable: false),
EntitledDays = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
UsedDays = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
AdjustmentDays = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LeaveBalances", x => x.Id);
table.ForeignKey(
name: "FK_LeaveBalances_LeaveTypes_LeaveTypeId",
column: x => x.LeaveTypeId,
principalTable: "LeaveTypes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_LeaveBalances_LeaveTypeId",
table: "LeaveBalances",
column: "LeaveTypeId");
migrationBuilder.CreateIndex(
name: "IX_LeaveBalances_UserId",
table: "LeaveBalances",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_LeaveBalances_UserId_LeaveTypeId_Year",
table: "LeaveBalances",
columns: new[] { "UserId", "LeaveTypeId", "Year" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LeaveBalances");
}
}
}

View File

@ -2553,6 +2553,66 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("Holidays", (string)null); b.ToTable("Holidays", (string)null);
}); });
modelBuilder.Entity("SolutionErp.Domain.Hrm.LeaveBalance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<decimal>("AdjustmentDays")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<decimal>("EntitledDays")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<Guid>("LeaveTypeId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<decimal>("UsedDays")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Year")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("LeaveTypeId");
b.HasIndex("UserId");
b.HasIndex("UserId", "LeaveTypeId", "Year")
.IsUnique();
b.ToTable("LeaveBalances", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Hrm.LeaveType", b => modelBuilder.Entity("SolutionErp.Domain.Hrm.LeaveType", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -5827,6 +5887,17 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("EmployeeProfile"); b.Navigation("EmployeeProfile");
}); });
modelBuilder.Entity("SolutionErp.Domain.Hrm.LeaveBalance", b =>
{
b.HasOne("SolutionErp.Domain.Hrm.LeaveType", "LeaveType")
.WithMany()
.HasForeignKey("LeaveTypeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("LeaveType");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{ {
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent") b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")

View File

@ -0,0 +1,424 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Hrm;
using SolutionErp.Application.Office;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Hrm;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Office;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// Phase 11 P11-B Wave 3 (S43 2026-05-30) — test-after, critical-algo (financial-ish trừ phép).
// Cover deduction hook trong ApproveLeaveRequestHandler terminal branch (LeaveOtApprovalFeatures.cs:344)
// + LeaveBalanceFeatures.cs query lazy-merge + AdjustLeaveBalance upsert.
//
// CHỐT theo CODE (single source of truth):
// - Deduct CHỈ ở nhánh terminal DaDuyet (CurrentApprovalLevelOrder == allLevels.Count).
// Advance level KHÔNG trừ. Reject/Return KHÔNG trừ.
// - UPSERT theo UNIQUE (UserId, LeaveTypeId, Year). Auto-create EntitledDays=LeaveType.DaysPerYear,
// UsedDays=0, AdjustmentDays=0 nếu chưa có row. UsedDays += NumDays.
// - Year = StartDate.Year (KHÔNG phải EndDate / clock năm).
// - KHÔNG validate âm → Used > Entitled cho phép (Remaining < 0, không throw).
// - Query Remaining = EntitledDays + AdjustmentDays UsedDays (COMPUTED ở DTO).
// Lazy: chưa có row → synthesize Entitled=DaysPerYear, Used=0, Adjustment=0.
//
// ⚠️ Pre-existing failure REPORT (S42 template WorkflowAppApproveV2Tests.cs): hook Wave 1 mới
// làm 2 test terminal cũ FK-fail (BuildLeave LeaveTypeId=Guid.NewGuid() → LeaveBalance insert
// FK→LeaveTypes fail). Fix tại file đó: seed LeaveType + truyền Id. KHÔNG phải prod bug —
// prod đơn nghỉ luôn pin LeaveType thật.
//
// FK note: LeaveBalance → LeaveType Restrict (LeaveBalanceConfiguration). MỌI đơn nghỉ test
// terminal PHẢI seed 1 LeaveType row + LeaveRequest.LeaveTypeId = type.Id đó.
public class LeaveBalanceTests
{
private static readonly DateTime FixedNow = new(2026, 5, 30, 8, 0, 0, DateTimeKind.Utc);
private static (IdentityFixture fix, TestApplicationDbContext db, FixedDateTime clock) NewCtx()
{
var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var clock = new FixedDateTime(FixedNow);
return (fix, db, clock);
}
private static TestCurrentUser AsUser(User u, params string[] roles)
=> new() { UserId = u.Id, FullName = u.FullName, Roles = roles ?? Array.Empty<string>() };
// Seed 1 LeaveType row (Restrict FK target). Trả entity để dùng Id cho LeaveRequest.LeaveTypeId.
private static async Task<LeaveType> SeedLeaveTypeAsync(
TestApplicationDbContext db, string code, decimal daysPerYear, bool isActive = true, bool isPaid = true)
{
var type = new LeaveType
{
Id = Guid.NewGuid(),
Code = code,
Name = $"Loại {code}",
DaysPerYear = daysPerYear,
IsPaid = isPaid,
IsActive = isActive,
};
db.LeaveTypes.Add(type);
await db.SaveChangesAsync(CancellationToken.None);
return type;
}
// Seed 1 Bước × N Cấp LeaveRequest workflow (mirror WorkflowAppApproveV2Tests). Levels theo Order asc.
private static async Task<(ApprovalWorkflow wf, List<ApprovalWorkflowLevel> levels)> SeedLeaveWorkflowAsync(
TestApplicationDbContext db, params Guid[] approverUserIds)
{
var wf = new ApprovalWorkflow
{
Id = Guid.NewGuid(),
Code = "QT-LR-BAL",
Version = 1,
Name = "Quy trình nghỉ phép test balance",
ApplicableType = ApprovalWorkflowApplicableType.LeaveRequest,
IsActive = true,
IsUserSelectable = true,
};
var step = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
Order = 1,
DepartmentId = null,
Name = "Bước 1",
};
var levels = new List<ApprovalWorkflowLevel>();
for (var i = 0; i < approverUserIds.Length; i++)
{
levels.Add(new ApprovalWorkflowLevel
{
Id = Guid.NewGuid(),
ApprovalWorkflowStepId = step.Id,
Order = i + 1,
ApproverUserId = approverUserIds[i],
});
}
db.ApprovalWorkflows.Add(wf);
db.ApprovalWorkflowSteps.Add(step);
db.ApprovalWorkflowLevels.AddRange(levels);
await db.SaveChangesAsync(CancellationToken.None);
return (wf, levels);
}
// BuildLeave với LeaveTypeId explicit (KHÁC template — bắt buộc seeded type cho FK trừ phép).
private static LeaveRequest BuildLeave(
Guid requesterId,
Guid leaveTypeId,
Guid? workflowId,
WorkflowAppStatus status,
int? currentLevel,
decimal numDays = 3,
DateTime? startDate = null)
{
var start = startDate ?? FixedNow.Date;
return new LeaveRequest
{
Id = Guid.NewGuid(),
RequesterUserId = requesterId,
RequesterFullName = "Người tạo",
LeaveTypeId = leaveTypeId,
StartDate = start,
EndDate = start.AddDays((double)numDays - 1),
NumDays = numDays,
Reason = "Nghỉ việc riêng",
Status = status,
ApprovalWorkflowId = workflowId,
CurrentApprovalLevelOrder = currentLevel,
};
}
// ============ Case 1: Deduct single-level — tạo balance row mới ============
[Fact]
public async Task Approve_LastLevel_DeductsLeave_CreatesNewBalanceRow_FromDaysPerYear()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-b1@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-b1@test.local", "Approver", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
// single-level, pin tại cấp cuối (=1), NumDays=3
var leave = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 3);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt"), CancellationToken.None);
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
leave.CurrentApprovalLevelOrder.Should().BeNull();
var bal = await db.LeaveBalances
.SingleAsync(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id);
bal.Year.Should().Be(2026, "Year = StartDate.Year");
bal.EntitledDays.Should().Be(12m, "auto-create từ LeaveType.DaysPerYear");
bal.UsedDays.Should().Be(3m, "UsedDays += NumDays");
bal.AdjustmentDays.Should().Be(0m);
(bal.EntitledDays + bal.AdjustmentDays - bal.UsedDays).Should().Be(9m, "Remaining = 12 + 0 3");
}
}
// ============ Case 2: Deduct only at terminal (multi-level) — chỉ trừ 1 lần ============
[Fact]
public async Task Approve_MultiLevel_NoDeductAtIntermediate_DeductsOnceAtTerminal()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-b2@test.local", "Requester", null, Array.Empty<string>());
var ap1 = await fix.CreateUserAsync("ap1-b2@test.local", "Approver 1", null, Array.Empty<string>());
var ap2 = await fix.CreateUserAsync("ap2-b2@test.local", "Approver 2", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
var (wf, _) = await SeedLeaveWorkflowAsync(db, ap1.Id, ap2.Id);
var leave = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 4);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
// Cấp 1 duyệt → advance, CHƯA terminal → KHÔNG có balance row
await new ApproveLeaveRequestHandler(db, AsUser(ap1), clock)
.Handle(new ApproveLeaveRequestCommand(leave.Id, "ok cấp 1"), CancellationToken.None);
leave.CurrentApprovalLevelOrder.Should().Be(2);
(await db.LeaveBalances.CountAsync(b => b.UserId == requester.Id))
.Should().Be(0, "advance level KHÔNG trừ phép");
// Cấp 2 (cuối) duyệt → terminal → trừ 1 lần
await new ApproveLeaveRequestHandler(db, AsUser(ap2), clock)
.Handle(new ApproveLeaveRequestCommand(leave.Id, "ok cấp cuối"), CancellationToken.None);
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
var balances = await db.LeaveBalances.Where(b => b.UserId == requester.Id).ToListAsync();
balances.Should().HaveCount(1, "chỉ 1 row, trừ đúng 1 lần ở terminal");
balances[0].UsedDays.Should().Be(4m);
}
}
// ============ Case 3: Accumulate existing balance — KHÔNG tạo row mới ============
[Fact]
public async Task Approve_LastLevel_AccumulatesExistingBalance_SameRow()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-b3@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-b3@test.local", "Approver", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
// Pre-seed balance đã dùng 5 ngày cùng (User, Type, Year=2026)
db.LeaveBalances.Add(new LeaveBalance
{
Id = Guid.NewGuid(),
UserId = requester.Id,
LeaveTypeId = type.Id,
Year = 2026,
EntitledDays = 12m,
UsedDays = 5m,
AdjustmentDays = 0m,
CreatedAt = FixedNow,
});
var leave = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 2);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt"), CancellationToken.None);
var balances = await db.LeaveBalances
.Where(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id && b.Year == 2026).ToListAsync();
balances.Should().HaveCount(1, "UNIQUE (User,Type,Year) — accumulate KHÔNG tạo row mới");
balances[0].UsedDays.Should().Be(7m, "5 + 2 cộng dồn");
}
}
// ============ Case 4: Negative allowed (allow+warn policy) — KHÔNG throw ============
[Fact]
public async Task Approve_LastLevel_OverEntitled_AllowsNegativeRemaining_NoThrow()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-b4@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-b4@test.local", "Approver", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
// NumDays=20 > Entitled 12 → Remaining âm, KHÔNG được throw
var leave = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 20);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt vượt"), CancellationToken.None);
await act.Should().NotThrowAsync("policy allow+warn — KHÔNG chặn vượt quota");
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
var bal = await db.LeaveBalances.SingleAsync(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id);
bal.UsedDays.Should().Be(20m);
(bal.EntitledDays + bal.AdjustmentDays - bal.UsedDays).Should().Be(-8m, "Remaining = 12 20 = 8");
}
}
// ============ Case 5a: Reject KHÔNG trừ ============
[Fact]
public async Task Reject_DoesNotDeductLeave()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-b5a@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-b5a@test.local", "Approver", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
var leave = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 3);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
await new RejectLeaveRequestHandler(db, AsUser(approver), clock)
.Handle(new RejectLeaveRequestCommand(leave.Id, "từ chối"), CancellationToken.None);
leave.Status.Should().Be(WorkflowAppStatus.TuChoi);
(await db.LeaveBalances.CountAsync(b => b.UserId == requester.Id))
.Should().Be(0, "chỉ terminal DaDuyet mới trừ — TuChoi KHÔNG");
}
}
// ============ Case 5b: Return KHÔNG trừ ============
[Fact]
public async Task Return_DoesNotDeductLeave()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-b5b@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-b5b@test.local", "Approver", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
var leave = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 3);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
await new ReturnLeaveRequestHandler(db, AsUser(approver), clock)
.Handle(new ReturnLeaveRequestCommand(leave.Id, "trả lại sửa"), CancellationToken.None);
leave.Status.Should().Be(WorkflowAppStatus.TraLai);
(await db.LeaveBalances.CountAsync(b => b.UserId == requester.Id))
.Should().Be(0, "TraLai KHÔNG trừ phép");
}
}
// ============ Case 6: GetMyLeaveBalances lazy — synthesize default cho mọi active type ============
[Fact]
public async Task GetMyLeaveBalances_NoBalanceRows_SynthesizesDefaultsForActiveTypes()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var user = await fix.CreateUserAsync("req-b6@test.local", "User", null, Array.Empty<string>());
await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
await SeedLeaveTypeAsync(db, "SICK", daysPerYear: 30m);
// 1 type inactive — KHÔNG xuất hiện trong kết quả
await SeedLeaveTypeAsync(db, "RETIRED", daysPerYear: 5m, isActive: false);
var result = await new GetMyLeaveBalancesHandler(db, AsUser(user))
.Handle(new GetMyLeaveBalancesQuery(2026), CancellationToken.None);
result.Should().HaveCount(2, "chỉ 2 type active, inactive bị loại");
result.Should().OnlyContain(d => d.UsedDays == 0m, "chưa có row → Used=0");
// ordered by Code: ANNUAL trước SICK
var annual = result.Single(d => d.Code == "ANNUAL");
annual.EntitledDays.Should().Be(12m, "synthesize từ DaysPerYear");
annual.RemainingDays.Should().Be(12m, "Remaining = 12 + 0 0");
var sick = result.Single(d => d.Code == "SICK");
sick.EntitledDays.Should().Be(30m);
sick.RemainingDays.Should().Be(30m);
}
}
// ============ Case 7: AdjustLeaveBalance upsert — tạo row khi chưa có + query phản ánh ============
[Fact]
public async Task AdjustLeaveBalance_NoRow_CreatesRow_QueryReflectsRemaining()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var admin = await fix.CreateUserAsync("admin-b7@test.local", "Quản trị", null, new[] { "Admin" });
var target = await fix.CreateUserAsync("tgt-b7@test.local", "Nhân viên", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
// Admin upsert: EntitledDays=15 (override DaysPerYear), AdjustmentDays=2 (carry-over)
await new AdjustLeaveBalanceHandler(db, AsUser(admin, "Admin"), clock)
.Handle(new AdjustLeaveBalanceCommand(target.Id, type.Id, 2026, EntitledDays: 15m, AdjustmentDays: 2m),
CancellationToken.None);
var bal = await db.LeaveBalances
.SingleAsync(b => b.UserId == target.Id && b.LeaveTypeId == type.Id && b.Year == 2026);
bal.EntitledDays.Should().Be(15m, "override DaysPerYear bằng giá trị admin nhập");
bal.AdjustmentDays.Should().Be(2m);
bal.UsedDays.Should().Be(0m);
// Query lại — Remaining phản ánh: 15 + 2 0 = 17
var result = await new GetUserLeaveBalancesHandler(db)
.Handle(new GetUserLeaveBalancesQuery(target.Id, 2026), CancellationToken.None);
var annual = result.Single(d => d.Code == "ANNUAL");
annual.EntitledDays.Should().Be(15m);
annual.AdjustmentDays.Should().Be(2m);
annual.RemainingDays.Should().Be(17m, "Remaining = Entitled 15 + Adjustment 2 Used 0");
}
}
// ============ Guard P11-B: LeaveTypeId phải tồn tại (chặn 500 FK-fail lúc duyệt cuối) ============
[Fact]
public async Task CreateLeaveRequest_BogusLeaveTypeId_ThrowsConflict_NoRowAdded()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-guard@test.local", "Requester", null, Array.Empty<string>());
// KHÔNG seed LeaveType → LeaveTypeId random là bogus.
var act = async () => await new CreateLeaveRequestHandler(db, AsUser(requester), clock)
.Handle(new CreateLeaveRequestCommand(Guid.NewGuid(), FixedNow.Date, FixedNow.Date.AddDays(1),
1m, "nghỉ", ApprovalWorkflowId: null), CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Loại phép không tồn tại*");
(await db.LeaveRequests.CountAsync()).Should().Be(0, "guard chặn trước khi Add");
}
}
[Fact]
public async Task CreateLeaveRequest_ValidLeaveType_Succeeds()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-guard2@test.local", "Requester", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
var id = await new CreateLeaveRequestHandler(db, AsUser(requester), clock)
.Handle(new CreateLeaveRequestCommand(type.Id, FixedNow.Date, FixedNow.Date.AddDays(1),
1m, "nghỉ", ApprovalWorkflowId: null), CancellationToken.None);
id.Should().NotBeEmpty();
(await db.LeaveRequests.CountAsync(x => x.Id == id)).Should().Be(1);
}
}
}

View File

@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Office; using SolutionErp.Application.Office;
using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Hrm;
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Office; using SolutionErp.Domain.Office;
using SolutionErp.Infrastructure.Tests.Common; using SolutionErp.Infrastructure.Tests.Common;
@ -76,17 +77,20 @@ public class WorkflowAppApproveV2Tests
return (wf, levels); return (wf, levels);
} }
// leaveTypeId: chỉ cần seeded type thật khi đơn đi tới TERMINAL (DaDuyet) — nhánh đó
// trừ phép insert LeaveBalance FK→LeaveTypes (Restrict). Test non-terminal để null (random).
private static LeaveRequest BuildLeave( private static LeaveRequest BuildLeave(
Guid requesterId, Guid requesterId,
Guid? workflowId, Guid? workflowId,
WorkflowAppStatus status, WorkflowAppStatus status,
int? currentLevel) int? currentLevel,
Guid? leaveTypeId = null)
=> new() => new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
RequesterUserId = requesterId, RequesterUserId = requesterId,
RequesterFullName = "Người tạo", RequesterFullName = "Người tạo",
LeaveTypeId = Guid.NewGuid(), LeaveTypeId = leaveTypeId ?? Guid.NewGuid(),
StartDate = FixedNow.Date, StartDate = FixedNow.Date,
EndDate = FixedNow.Date.AddDays(2), EndDate = FixedNow.Date.AddDays(2),
NumDays = 3, NumDays = 3,
@ -96,6 +100,23 @@ public class WorkflowAppApproveV2Tests
CurrentApprovalLevelOrder = currentLevel, CurrentApprovalLevelOrder = currentLevel,
}; };
// Seed 1 LeaveType (FK target cho trừ phép terminal). Trả entity dùng Id.
private static async Task<LeaveType> SeedLeaveTypeAsync(TestApplicationDbContext db, string code, decimal daysPerYear)
{
var type = new LeaveType
{
Id = Guid.NewGuid(),
Code = code,
Name = $"Loại {code}",
DaysPerYear = daysPerYear,
IsPaid = true,
IsActive = true,
};
db.LeaveTypes.Add(type);
await db.SaveChangesAsync(CancellationToken.None);
return type;
}
// ============ Case 1: Submit happy path ============ // ============ Case 1: Submit happy path ============
[Fact] [Fact]
@ -210,10 +231,11 @@ public class WorkflowAppApproveV2Tests
var requester = await fix.CreateUserAsync("req-c4@test.local", "Requester", null, Array.Empty<string>()); var requester = await fix.CreateUserAsync("req-c4@test.local", "Requester", null, Array.Empty<string>());
var ap1 = await fix.CreateUserAsync("ap1-c4@test.local", "Approver 1", null, Array.Empty<string>()); var ap1 = await fix.CreateUserAsync("ap1-c4@test.local", "Approver 1", null, Array.Empty<string>());
var ap2 = await fix.CreateUserAsync("ap2-c4@test.local", "Approver 2", null, Array.Empty<string>()); var ap2 = await fix.CreateUserAsync("ap2-c4@test.local", "Approver 2", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", 12m); // terminal trừ phép → cần LeaveType thật
var (wf, _) = await SeedLeaveWorkflowAsync(db, ap1.Id, ap2.Id); var (wf, _) = await SeedLeaveWorkflowAsync(db, ap1.Id, ap2.Id);
// pin tại Cấp cuối (2) // pin tại Cấp cuối (2)
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 2); var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 2, leaveTypeId: type.Id);
db.LeaveRequests.Add(leave); db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
@ -304,9 +326,10 @@ public class WorkflowAppApproveV2Tests
{ {
var requester = await fix.CreateUserAsync("req-c7@test.local", "Requester", null, Array.Empty<string>()); var requester = await fix.CreateUserAsync("req-c7@test.local", "Requester", null, Array.Empty<string>());
var ap1 = await fix.CreateUserAsync("ap1-c7@test.local", "Approver 1", null, Array.Empty<string>()); var ap1 = await fix.CreateUserAsync("ap1-c7@test.local", "Approver 1", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", 12m); // single-level → terminal → trừ phép cần LeaveType
var (wf, levels) = await SeedLeaveWorkflowAsync(db, ap1.Id); var (wf, levels) = await SeedLeaveWorkflowAsync(db, ap1.Id);
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1); var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, leaveTypeId: type.Id);
db.LeaveRequests.Add(leave); db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);