[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

@ -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);