[CLAUDE] Workflow: wire ApproveV2 + LevelOpinions cho 4 WorkflowApps module (Phase 11 P11-A)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m6s

Wire full approval workflow V2 cho Leave/OT/Travel/Vehicle — cookie-cutter
mirror Proposal (Mig 38). Trước đây skeleton Phase 1 (Create+List), giờ
ApproveV2 advance-level + UPSERT LevelOpinion + atomic codegen.

Schema (Mig 41 WireWorkflowAppsApprovalV2 — 84→89 tables, pure additive):
- 4 bảng {Leave,Ot,Travel,Vehicle}LevelOpinions (UNIQUE composite + Cascade
  parent + Restrict Level — mirror ProposalLevelOpinion)
- 1 bảng WorkflowAppCodeSequences (shared atomic MaDonTu, Prefix-keyed)
- 4 cột RejectedFromStatus (smart return tracking)
- enum ApprovalWorkflowApplicableType.TravelRequest = 9

Application (LeaveOt + TravelVehicle ApprovalFeatures.cs — 30 handler):
- GetById detail (Include LevelOpinions + JOIN Step/Level) · UpdateDraft
- Submit (gen MaDonTu + DaGuiDuyet + level=1, verify ApplicableType per module)
- Approve (verify actor==ApproverUserId OR Admin, UPSERT opinion latest-write-wins,
  advance level OR terminal DaDuyet, empty comment → placeholder)
- Reject (→TuChoi) · Return (→TraLai + RejectedFromStatus)

Api: 4 controller +6 route mỗi cái (GET/{id}, PUT/{id}, submit/approve/reject/return)
Infra: DbInitializer seed 4 workflow V2 mẫu (QT-NP/OT/CT/XE-V2-001) → UAT test ngay
FE: WorkflowAppDetailPage.tsx declarative 4-kind (fe-admin+fe-user SHA256 identical)
  — workflow status + opinion timeline + action buttons; gỡ banner skeleton + row nav

Tests: +11 WorkflowAppApproveV2Tests (130→141 PASS) — state machine + UPSERT
  invariant + guards + codegen + forbidden + placeholder (Leave full + Ot smoke)

Verify: build 0 error · 141 test PASS · FE build ×2 · reviewer checklist
  (ApplicableType per-module + cross-module DbSet + [Authorize] — no copy-paste bug)
Known-minor (unreachable): Reject/Return actor-check skip nếu CurrentApprovalLevelOrder
  null — nhưng DaGuiDuyet luôn có set (defer hardening).
ItTicket KHÔNG đụng (kanban, no workflow V2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-30 09:44:00 +07:00
parent ad1dea9349
commit e7b66cd52b
39 changed files with 10604 additions and 22 deletions

View File

@ -0,0 +1,443 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Office;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Office;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// Phase 11 P11-A Wave 4 (S42 2026-05-30) — test-after ApproveV2 wire WorkflowApps.
// Critical-algo state machine + UPSERT invariant cho LeaveOtApprovalFeatures.cs
// (LeaveRequest đầy đủ 8 case + OtRequest smoke 1 case).
//
// Handlers là CQRS MediatR — inject IApplicationDbContext + ICurrentUser + IDateTime
// trực tiếp (không qua service). Khác ContractWorkflowServiceApproveV2Tests (service
// wire 6 dep) — đây nhẹ hơn, instantiate handler + gọi Handle().
//
// Mirror LeaveOtApprovalFeatures.cs:250 ApproveLeaveRequestHandler. Status state:
// Nhap=1, DaGuiDuyet=2, TraLai=3, TuChoi=4, DaDuyet=5.
// LevelOpinion UNIQUE composite (LeaveRequestId, ApprovalWorkflowLevelId).
public class WorkflowAppApproveV2Tests
{
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 Bước × N Cấp LeaveRequest workflow. Trả về list 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-001",
Version = 1,
Name = "Quy trình nghỉ phép test",
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);
}
private static LeaveRequest BuildLeave(
Guid requesterId,
Guid? workflowId,
WorkflowAppStatus status,
int? currentLevel)
=> new()
{
Id = Guid.NewGuid(),
RequesterUserId = requesterId,
RequesterFullName = "Người tạo",
LeaveTypeId = Guid.NewGuid(),
StartDate = FixedNow.Date,
EndDate = FixedNow.Date.AddDays(2),
NumDays = 3,
Reason = "Nghỉ việc riêng",
Status = status,
ApprovalWorkflowId = workflowId,
CurrentApprovalLevelOrder = currentLevel,
};
// ============ Case 1: Submit happy path ============
[Fact]
public async Task Submit_FromNhap_AdvancesToDaGuiDuyet_GeneratesMaDonTu()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-c1@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-c1@test.local", "Approver", null, Array.Empty<string>());
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.Nhap, currentLevel: null);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
var handler = new SubmitLeaveRequestHandler(db, AsUser(requester), clock);
await handler.Handle(new SubmitLeaveRequestCommand(leave.Id), CancellationToken.None);
leave.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet);
leave.CurrentApprovalLevelOrder.Should().Be(1);
leave.RejectedFromStatus.Should().BeNull();
leave.MaDonTu.Should().NotBeNullOrEmpty();
leave.MaDonTu.Should().Be("DT/LR/2026/001", "prefix DT/LR + năm clock + seq D3");
var seq = await db.WorkflowAppCodeSequences.FirstAsync(s => s.Prefix == "DT/LR/2026");
seq.LastSeq.Should().Be(1);
}
}
// ============ Case 2: Submit guards ============
[Fact]
public async Task Submit_WhenStatusNotNhapOrTraLai_ThrowsConflict()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-c2a@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-c2a@test.local", "Approver", null, Array.Empty<string>());
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
var handler = new SubmitLeaveRequestHandler(db, AsUser(requester), clock);
var act = async () => await handler.Handle(new SubmitLeaveRequestCommand(leave.Id), CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Nháp hoặc Trả lại*");
}
}
[Fact]
public async Task Submit_WhenApprovalWorkflowIdNull_ThrowsConflict()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-c2b@test.local", "Requester", null, Array.Empty<string>());
var leave = BuildLeave(requester.Id, workflowId: null, WorkflowAppStatus.Nhap, currentLevel: null);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
var handler = new SubmitLeaveRequestHandler(db, AsUser(requester), clock);
var act = async () => await handler.Handle(new SubmitLeaveRequestCommand(leave.Id), CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Chưa chọn quy trình duyệt*");
}
}
// ============ Case 3: Approve advance (2-level, cấp 1) ============
[Fact]
public async Task Approve_FirstLevel_TwoLevel_AdvancesAndCreatesOneOpinion()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-c3@test.local", "Requester", null, Array.Empty<string>());
var ap1 = await fix.CreateUserAsync("ap1-c3@test.local", "Approver 1", null, Array.Empty<string>());
var ap2 = await fix.CreateUserAsync("ap2-c3@test.local", "Approver 2", null, Array.Empty<string>());
var (wf, levels) = await SeedLeaveWorkflowAsync(db, ap1.Id, ap2.Id);
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
var handler = new ApproveLeaveRequestHandler(db, AsUser(ap1), clock);
await handler.Handle(new ApproveLeaveRequestCommand(leave.Id, "đồng ý"), CancellationToken.None);
leave.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet, "còn Cấp 2 chưa terminal");
leave.CurrentApprovalLevelOrder.Should().Be(2);
var opinions = await db.LeaveRequestLevelOpinions.Where(o => o.LeaveRequestId == leave.Id).ToListAsync();
opinions.Should().HaveCount(1);
opinions[0].ApprovalWorkflowLevelId.Should().Be(levels[0].Id, "slot Cấp 1");
opinions[0].Comment.Should().Be("đồng ý");
opinions[0].SignedByUserId.Should().Be(ap1.Id);
}
}
// ============ Case 4: Approve terminal (cấp cuối) ============
[Fact]
public async Task Approve_LastLevel_TransitionsToDaDuyet_ClearsCurrentLevel()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
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 (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);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
var handler = new ApproveLeaveRequestHandler(db, AsUser(ap2), clock);
await handler.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt cuối"), CancellationToken.None);
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet, "Cấp cuối → terminal");
leave.CurrentApprovalLevelOrder.Should().BeNull();
var opinions = await db.LeaveRequestLevelOpinions.Where(o => o.LeaveRequestId == leave.Id).ToListAsync();
opinions.Should().HaveCount(1, "1 row cho slot Cấp 2");
}
}
// ============ Case 5: Approve UPSERT invariant (re-sign same level) ============
[Fact]
public async Task Approve_SameLevelTwice_AdminReSign_DoesNotDuplicateRow_UpdatesLatestWrite()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-c5@test.local", "Requester", null, Array.Empty<string>());
var ap1 = await fix.CreateUserAsync("ap1-c5@test.local", "Approver 1", null, Array.Empty<string>());
var ap2 = await fix.CreateUserAsync("ap2-c5@test.local", "Approver 2", null, Array.Empty<string>());
var admin = await fix.CreateUserAsync("admin-c5@test.local", "Quản trị", null, new[] { "Admin" });
var (wf, levels) = await SeedLeaveWorkflowAsync(db, ap1.Id, ap2.Id);
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
// 1st approve Cấp 1 bởi ap1 → tạo row, advance lên Cấp 2
await new ApproveLeaveRequestHandler(db, AsUser(ap1), clock)
.Handle(new ApproveLeaveRequestCommand(leave.Id, "ý kiến gốc"), CancellationToken.None);
leave.CurrentApprovalLevelOrder.Should().Be(2);
// Admin override re-sign Cấp 1: pin pointer trở lại Cấp 1 + admin duyệt lại
leave.CurrentApprovalLevelOrder = 1;
await db.SaveChangesAsync(CancellationToken.None);
await new ApproveLeaveRequestHandler(db, AsUser(admin, "Admin"), clock)
.Handle(new ApproveLeaveRequestCommand(leave.Id, "admin ký lại"), CancellationToken.None);
var opinions = await db.LeaveRequestLevelOpinions
.Where(o => o.LeaveRequestId == leave.Id && o.ApprovalWorkflowLevelId == levels[0].Id)
.ToListAsync();
opinions.Should().HaveCount(1, "UNIQUE composite — KHÔNG tạo row thứ 2");
opinions[0].Comment.Should().Be("admin ký lại", "latest-write-wins");
opinions[0].SignedByUserId.Should().Be(admin.Id, "signer cập nhật người ký mới nhất");
}
}
// ============ Case 6: Approve forbidden (outsider non-admin) ============
[Fact]
public async Task Approve_OutsiderNonAdmin_ThrowsForbidden_StateUnchanged()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-c6@test.local", "Requester", null, Array.Empty<string>());
var ap1 = await fix.CreateUserAsync("ap1-c6@test.local", "Approver 1", null, Array.Empty<string>());
var outsider = await fix.CreateUserAsync("out-c6@test.local", "Outsider", null, Array.Empty<string>());
var (wf, _) = await SeedLeaveWorkflowAsync(db, ap1.Id);
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
var handler = new ApproveLeaveRequestHandler(db, AsUser(outsider), clock);
var act = async () => await handler.Handle(new ApproveLeaveRequestCommand(leave.Id, "thử duyệt"), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>().WithMessage("*Không phải người duyệt*");
leave.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet, "guard chặn trước mutate");
leave.CurrentApprovalLevelOrder.Should().Be(1);
(await db.LeaveRequestLevelOpinions.CountAsync(o => o.LeaveRequestId == leave.Id))
.Should().Be(0, "không tạo opinion khi bị chặn");
}
}
// ============ Case 7: Approve empty comment → placeholder ============
[Fact]
public async Task Approve_EmptyComment_StoresPlaceholder()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
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 (wf, levels) = await SeedLeaveWorkflowAsync(db, ap1.Id);
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
await new ApproveLeaveRequestHandler(db, AsUser(ap1), clock)
.Handle(new ApproveLeaveRequestCommand(leave.Id, " "), CancellationToken.None);
var opinion = await db.LeaveRequestLevelOpinions
.FirstAsync(o => o.LeaveRequestId == leave.Id && o.ApprovalWorkflowLevelId == levels[0].Id);
opinion.Comment.Should().Be("(duyệt — không ý kiến)");
}
}
// ============ Case 8a: Reject → TuChoi ============
[Fact]
public async Task Reject_FromDaGuiDuyet_TransitionsToTuChoi_ClearsCurrentLevel()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-c8a@test.local", "Requester", null, Array.Empty<string>());
var ap1 = await fix.CreateUserAsync("ap1-c8a@test.local", "Approver 1", null, Array.Empty<string>());
var (wf, _) = await SeedLeaveWorkflowAsync(db, ap1.Id);
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
await new RejectLeaveRequestHandler(db, AsUser(ap1), clock)
.Handle(new RejectLeaveRequestCommand(leave.Id, "không duyệt"), CancellationToken.None);
leave.Status.Should().Be(WorkflowAppStatus.TuChoi);
leave.CurrentApprovalLevelOrder.Should().BeNull();
}
}
// ============ Case 8b: Return → TraLai + RejectedFromStatus ============
[Fact]
public async Task Return_FromDaGuiDuyet_TransitionsToTraLai_SetsRejectedFromStatus()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-c8b@test.local", "Requester", null, Array.Empty<string>());
var ap1 = await fix.CreateUserAsync("ap1-c8b@test.local", "Approver 1", null, Array.Empty<string>());
var (wf, _) = await SeedLeaveWorkflowAsync(db, ap1.Id);
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
await new ReturnLeaveRequestHandler(db, AsUser(ap1), clock)
.Handle(new ReturnLeaveRequestCommand(leave.Id, "sửa lại"), CancellationToken.None);
leave.Status.Should().Be(WorkflowAppStatus.TraLai);
leave.RejectedFromStatus.Should().Be(WorkflowAppStatus.DaGuiDuyet);
leave.CurrentApprovalLevelOrder.Should().BeNull();
}
}
// ============ Smoke OtRequest: mirror cookie-cutter — terminal happy path ============
[Fact]
public async Task OtRequest_Submit_Then_Approve_SingleLevel_ReachesDaDuyet()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-ot@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-ot@test.local", "Approver", null, Array.Empty<string>());
var wf = new ApprovalWorkflow
{
Id = Guid.NewGuid(),
Code = "QT-OT-001",
Version = 1,
Name = "Quy trình OT test",
ApplicableType = ApprovalWorkflowApplicableType.OtRequest,
IsActive = true,
IsUserSelectable = true,
};
var step = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
Order = 1,
DepartmentId = null,
Name = "Bước 1",
};
var lvl = new ApprovalWorkflowLevel
{
Id = Guid.NewGuid(),
ApprovalWorkflowStepId = step.Id,
Order = 1,
ApproverUserId = approver.Id,
};
db.ApprovalWorkflows.Add(wf);
db.ApprovalWorkflowSteps.Add(step);
db.ApprovalWorkflowLevels.Add(lvl);
var ot = new OtRequest
{
Id = Guid.NewGuid(),
RequesterUserId = requester.Id,
RequesterFullName = "Người tạo",
OtDate = FixedNow.Date,
StartTime = TimeSpan.FromHours(18),
EndTime = TimeSpan.FromHours(21),
Hours = 3,
Reason = "Tăng ca giao hàng",
Status = WorkflowAppStatus.Nhap,
ApprovalWorkflowId = wf.Id,
};
db.OtRequests.Add(ot);
await db.SaveChangesAsync(CancellationToken.None);
await new SubmitOtRequestHandler(db, AsUser(requester), clock)
.Handle(new SubmitOtRequestCommand(ot.Id), CancellationToken.None);
ot.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet);
ot.CurrentApprovalLevelOrder.Should().Be(1);
ot.MaDonTu.Should().Be("DT/OT/2026/001");
await new ApproveOtRequestHandler(db, AsUser(approver), clock)
.Handle(new ApproveOtRequestCommand(ot.Id, null), CancellationToken.None);
ot.Status.Should().Be(WorkflowAppStatus.DaDuyet);
ot.CurrentApprovalLevelOrder.Should().BeNull();
var opinions = await db.OtRequestLevelOpinions.Where(o => o.OtRequestId == ot.Id).ToListAsync();
opinions.Should().HaveCount(1);
opinions[0].Comment.Should().Be("(duyệt — không ý kiến)");
}
}
}