[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)
- **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).
- **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)
- **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 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]`.

View File

@ -42,7 +42,8 @@ Dynamic class purged. PALETTE array full literal `as const` cycle `index % lengt
## 📅 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)
- **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-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.

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: 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`
### ⚠️ 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)
- 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)
@ -49,9 +52,9 @@ Test theo CODE (single source truth), document mismatch header comment + report.
## 📅 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-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>
{/* 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 */}
<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>

View File

@ -78,6 +78,10 @@ export interface WorkflowAppDetail {
endDate?: string
numDays?: number
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
otDate?: string
startTime?: string

View File

@ -345,6 +345,33 @@ export function WorkflowAppDetailPage() {
</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 */}
<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>

View File

@ -78,6 +78,10 @@ export interface WorkflowAppDetail {
endDate?: string
numDays?: number
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
otDate?: 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.
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);
}

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.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Hrm;
using SolutionErp.Domain.Office;
namespace SolutionErp.Application.Office;
@ -78,7 +79,12 @@ public record LeaveRequestDetailDto(
int? CurrentApprovalLevelOrder,
int? RejectedFromStatus,
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?>;
@ -140,12 +146,36 @@ public class GetLeaveRequestByIdHandler(IApplicationDbContext db)
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
.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(
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName, p.LeaveTypeId,
p.StartDate, p.EndDate, p.NumDays, p.Reason, (int)p.Status,
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
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.");
}
// 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.StartDate = req.StartDate;
p.EndDate = req.EndDate;
@ -319,6 +354,32 @@ public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser c
{
p.Status = WorkflowAppStatus.DaDuyet;
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.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)
{
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
{
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.
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)
{
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);
});
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 =>
{
b.Property<Guid>("Id")
@ -5827,6 +5887,17 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
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 =>
{
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.Office;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Hrm;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Office;
using SolutionErp.Infrastructure.Tests.Common;
@ -76,17 +77,20 @@ public class WorkflowAppApproveV2Tests
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(
Guid requesterId,
Guid? workflowId,
WorkflowAppStatus status,
int? currentLevel)
int? currentLevel,
Guid? leaveTypeId = null)
=> new()
{
Id = Guid.NewGuid(),
RequesterUserId = requesterId,
RequesterFullName = "Người tạo",
LeaveTypeId = Guid.NewGuid(),
LeaveTypeId = leaveTypeId ?? Guid.NewGuid(),
StartDate = FixedNow.Date,
EndDate = FixedNow.Date.AddDays(2),
NumDays = 3,
@ -96,6 +100,23 @@ public class WorkflowAppApproveV2Tests
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 ============
[Fact]
@ -210,10 +231,11 @@ public class WorkflowAppApproveV2Tests
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 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);
// 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);
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 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 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);
await db.SaveChangesAsync(CancellationToken.None);