[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:
@ -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.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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user