[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
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:
@ -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+Adjustment−Used 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]`.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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 (Theory→InlineData 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 có 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 (Theory→InlineData 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 có 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 72→83). LeaveRequest 8 case full (Submit happy/guard×2, Approve advance/terminal/UPSERT-invariant/forbidden/empty-comment-placeholder, Reject→TuChoi, Return→TraLai+RejectedFromStatus) + OtRequest smoke (submit→approve single-level→DaDuyet). **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 72→83). LeaveRequest 8 case full (Submit happy/guard×2, Approve advance/terminal/UPSERT-invariant/forbidden/empty-comment-placeholder, Reject→TuChoi, Return→TraLai+RejectedFromStatus) + OtRequest smoke (submit→approve single-level→DaDuyet). **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 86→94). 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 → Remaining−8 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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ố dư phép</h3>
|
||||||
|
<div className="text-sm">
|
||||||
|
Số dư 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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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ố dư phép</h3>
|
||||||
|
<div className="text-sm">
|
||||||
|
Số dư 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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
150
src/Backend/SolutionErp.Application/Hrm/LeaveBalanceFeatures.cs
Normal file
150
src/Backend/SolutionErp.Application/Hrm/LeaveBalanceFeatures.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
27
src/Backend/SolutionErp.Domain/Hrm/LeaveBalance.cs
Normal file
27
src/Backend/SolutionErp.Domain/Hrm/LeaveBalance.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6373
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260530034336_AddLeaveBalances.Designer.cs
generated
Normal file
6373
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260530034336_AddLeaveBalances.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user