[CLAUDE] App+Infra+Api+FE-User+FE-Admin+Tests: W3 KHKK — duyet 3 tram OR-of-N + finalize chot gia (test-before 6 RED->GREEN)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m54s

- ContractSigningPlanWorkflowService: copy khuon ApproveV2Async (con-tro doi StepIndex+LevelOrder, GroupBy Order, OR-of-N, LevelOpinions UPSERT "(duyet — khong y kien)") — CAM Proposal-flatten; KHONG port 2 duong ket-thuc-som (owner phan PHA VO S155)
- Choke-point finalize ApplyApprovedValuesOnFinalize (Line.ApprovedAmount ??= Proposed) 1-SITE-BY-DESIGN: hop-dong transitions khong co targetPhase => admin cung xuyen choke-point (triet lop #81 tu goc); RULE-comment khuon :356-360
- Transitions endpoint: submit/approve/return/reject (hop-dong LEAD-dinh-nghia literal — reviewer Truc-4 KHOP 5/5, truc W2 tung dut 6 diem); guard trinh CreatedBy|Drafter|DeptManager-cung-phong|Admin; changelog/approvals bang KHKK rieng (CAM IChangelogService — FK-547 rao bang vang-mat-dep); notify Cap-ke dich danh + exclusion actor/drafter (S86)
- Save-diem: 1 SaveChanges cuoi = 1 transition 1 unit-of-work (service la diem vao, khac nguon :537)
- FE KhkkWorkflowPanel x2 app SHA-pair: so-do Buoc/Cap + banner den-luot + Gui/Duyet/Tra/Tu-choi + y kien; panel GATE nut theo actor (khong de an 403)
- Test-before: 6 test san PIN RED-evidence 6/6 (5 NotImplemented + 1 assert) -> GREEN 6/6; sua-test dung 1 seed FAULT-INJECT sot (0 assert doi); suite 574 -> 580/0
- Reviewer 7/7 truc DAT 0 chan (4 truc vai + 3 truc lead on-behalf; Q1-Q6 duyet)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 22:33:11 +07:00
parent d7bdb45312
commit df52fa0cd0
11 changed files with 2370 additions and 6 deletions

View File

@ -0,0 +1,729 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor
using SolutionErp.Application.ContractSigningPlans; // GetMyContractSigningPlanInboxQuery (W2, đã sống)
using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [W3 KHKK — S161 2026-07-29] Duyệt 3 trạm phiếu "Kế hoạch ký kết HĐ" (GĐ2) — **TEST-BEFORE**.
//
// 🔴 ĐỢT NÀY 5/6 TEST PHẢI ĐỎ. Service mới chỉ là skeleton: mọi nhánh ném
// `NotImplementedException("W3 đợt-3: <action>")` (`ContractSigningPlanWorkflowService.cs:108/113/117/121`).
// Đỏ-vì-CHƯA-LÀM (không phải đỏ-vì-không-biên-dịch-được) chính là bằng chứng duy nhất
// rằng 6 lưới này có răng — đợt-3 điền logic xong chúng phải TỰ xanh, không được sửa test
// cho vừa. Test số 4 (Inbox) đi qua query handler W2 đã sống ⇒ **xanh ngay hôm nay**, cố ý
// không ép đỏ; nó đồng thời là phép thử "seed có hợp lệ không" cho 5 test còn lại (cùng
// bộ helper: nếu FK/So-sánh seed sai thì test 4 đỏ trước tiên).
//
// Tên 6 test PIN theo spec W3 §③-B (`spec-wave-w3-duyet-3-tram-29-07-2026.md:35`) — thiếu 1
// tên là TRƯỢT acceptance, KHÔNG đổi chữ.
//
// ── Đo cái gì (map 1:1 spec §③-B) ──────────────────────────────────────────────
// T1 OR-of-N trọn 4 vế (a)(b)(c)(d) T2 con-trỏ ĐÔI + reset Cấp
// T3 finalize `AND PlanId=@id`, 2 write-path T4 Inbox đúng người đúng Cấp
// T5 UPSERT ý kiến 1 row T6 Trả lại → gửi lại chạy LẠI từ Bước 1 Cấp 1
//
// ── 3 quyết định hạ tầng (đều là ĐIỀU KIỆN để phép đo có nghĩa) ────────────────
//
// (1) Fixture RIÊNG có `AuditingInterceptor` (mirror `ContractSigningPlanCrudTests.cs:49-82`).
// Guard trình đợt-3 = `CreatedBy == actor DeptManager Admin`
// (`ContractSigningPlanWorkflowService.cs:58-59`). `TestApplicationDbContext` trần KHÔNG
// wire interceptor ⇒ `CreatedBy` của MỌI phiếu seed sẽ là null ⇒ vế đầu của guard không
// bao giờ đúng và `submit` đỏ vì lý do SAI. Seed phiếu bằng context của chính người soạn
// ⇒ `CreatedBy` == `DrafterUserId` (đợt-3 đọc cột nào cũng ra cùng một người).
//
// (2) MỖI transition chạy trên MỘT DbContext MỚI (mô phỏng scoped-per-request của prod).
// Dùng lại 1 context cho cả seed lẫn lệnh sẽ để EF track sẵn Lines/LevelOpinions —
// một đường đi KHÔNG có thật ở prod (ở đó context mới chỉ nạp thứ service tự Include).
//
// (3) User seed THẲNG `db.Users.Add(...)`, KHÔNG kéo `IdentityFixture`/`UserManager`:
// service nhận role qua `ICurrentUser.Roles` (lane BE QUYẾT-2 cố ý không inject
// UserManager), còn `ApprovalWorkflowLevel.ApproverUserId` chỉ cần **row tồn tại**
// (FK Restrict — `ApprovalWorkflowConfiguration.cs:72-75`), không cần đăng nhập được.
public class ContractSigningPlanApprovalTests
{
private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);
// ===================================================================
// Fixture: SQLite in-memory + AuditingInterceptor, 1 connection dùng chung.
// ===================================================================
private sealed class KhkkWorkflowFixture : IDisposable
{
private readonly SqliteConnection _conn;
public FixedDateTime Clock { get; } = new(Clock2026);
public KhkkWorkflowFixture()
{
_conn = new SqliteConnection("DataSource=:memory:");
_conn.Open();
using var boot = NewDb(TestCurrentUser.System());
boot.Database.EnsureCreated();
}
/// Context MỚI cho mỗi "request". `actor` phải là ĐÚNG người mà lệnh chạy dưới danh
/// nghĩa — prod resolve CÙNG một `ICurrentUser` scoped cho cả interceptor lẫn service,
/// tách hai ra thì `CreatedBy`/`UpdatedBy` ghi ra người sai *theo kiểu trông như đúng*.
public TestApplicationDbContext NewDb(ICurrentUser actor)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(_conn)
.EnableSensitiveDataLogging()
.AddInterceptors(new AuditingInterceptor(actor, Clock))
.Options;
return new TestApplicationDbContext(options);
}
public void Dispose() => _conn.Dispose();
}
// ===================================================================
// Seed helpers
// ===================================================================
private static TestCurrentUser As(User u, params string[] roles)
=> new(u.Id, u.FullName, u.Email, roles);
/// Row `AspNetUsers` tối thiểu nhưng ĐỦ THẬT: FK `Level.ApproverUserId` Restrict cần
/// row tồn tại, còn `SignedByFullName` (snapshot người ký) cần `FullName` khác rỗng.
private static async Task<User> AddUserAsync(KhkkWorkflowFixture f, string fullName)
{
await using var db = f.NewDb(TestCurrentUser.System());
var email = $"u{Guid.NewGuid():N}@test.local";
var user = new User
{
Id = Guid.NewGuid(),
FullName = fullName,
UserName = email,
NormalizedUserName = email.ToUpperInvariant(),
Email = email,
NormalizedEmail = email.ToUpperInvariant(),
EmailConfirmed = true,
SecurityStamp = Guid.NewGuid().ToString("N"),
ConcurrencyStamp = Guid.NewGuid().ToString("N"),
IsActive = true,
CreatedAt = Clock2026,
};
db.Users.Add(user);
await db.SaveChangesAsync(CancellationToken.None);
return user;
}
// `steps[i][j]` = danh sách NV của **Cấp j+1** trong **Bước i+1**. Nhiều NV trong cùng một
// Cấp = OR-of-N (`ApprovalWorkflow.cs:85-88`).
//
// `stepOrderStart` / `stepOrderStride`: CỐ Ý cho phép `Step.Order` KHÔNG bắt đầu từ 1 và
// không liền nhau. `CurrentWorkflowStepIndex` là **INDEX vào list đã sort theo Order**, KHÔNG
// phải giá trị `Order` — mã W2 đã chốt semantic đó (`ContractSigningPlanFeatures.cs:256-260`
// `steps[idx]`). T2 dùng Order 5/15/25 để hai đại lượng này không thể trùng nhau do may mắn.
private static async Task<Guid> SeedWorkflowAsync(
KhkkWorkflowFixture f,
string code,
Guid[][][] steps,
int stepOrderStart = 1,
int stepOrderStride = 1)
{
await using var db = f.NewDb(TestCurrentUser.System());
var wf = new ApprovalWorkflow
{
Id = Guid.NewGuid(),
Code = code,
Version = 1,
Name = "Quy trình duyệt Kế hoạch ký kết",
// Loại phải là ContractSigningPlan(10) — quy trình module khác bị rào Create
// của W2 chặn (`ContractSigningPlanFeatures.cs:218-219`).
ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
IsActive = true,
IsUserSelectable = true,
};
db.ApprovalWorkflows.Add(wf);
for (var s = 0; s < steps.Length; s++)
{
var step = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
Order = stepOrderStart + (s * stepOrderStride),
Name = $"Bước {s + 1}",
DepartmentId = null, // null = bỏ qua FK phòng ban (khuôn Pattern 11)
};
db.ApprovalWorkflowSteps.Add(step);
for (var l = 0; l < steps[s].Length; l++)
{
foreach (var approverId in steps[s][l])
{
db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
{
Id = Guid.NewGuid(),
ApprovalWorkflowStepId = step.Id,
Order = l + 1, // Cấp 1-based
Name = $"Cấp {l + 1}",
ApproverUserId = approverId,
// 5 cờ Allow* GIỮ MẶC ĐỊNH: W3 default dry-run = đường THƯỜNG duy nhất,
// KHÔNG bật kết-thúc-sớm (spec §②-3b — chủ dự án phán "PHÁ VỠ" @S155).
});
}
}
}
await db.SaveChangesAsync(CancellationToken.None);
return wf.Id;
}
private sealed record LineSeed(decimal PeReference, decimal Proposed, decimal? Approved = null);
private static async Task<Guid> SeedPlanAsync(
KhkkWorkflowFixture f,
TestCurrentUser drafter,
Guid workflowId,
string maKeHoach,
ContractSigningPlanPhase phase,
int? stepIndex,
int? levelOrder,
LineSeed[] lines)
{
// Seed dưới danh nghĩa NGƯỜI SOẠN ⇒ interceptor ghi `CreatedBy = drafter` (xem ghi chú (1)).
await using var db = f.NewDb(drafter);
var plan = new ContractSigningPlan
{
Id = Guid.NewGuid(),
MaKeHoach = maKeHoach,
// 4 cột dưới là loose-Guid KHÔNG FK vật lý (`ContractSigningPlanConfiguration.cs:10-12`)
// ⇒ W3 không phải seed PE/Project/Department như W2.
PurchaseEvaluationId = Guid.NewGuid(),
ProjectId = Guid.NewGuid(),
DepartmentId = Guid.NewGuid(),
DrafterUserId = drafter.UserId!.Value,
Phase = phase,
ApprovalWorkflowId = workflowId,
CurrentWorkflowStepIndex = stepIndex,
CurrentApprovalLevelOrder = levelOrder,
};
foreach (var l in lines)
{
plan.Lines.Add(new ContractSigningPlanLine
{
Id = Guid.NewGuid(),
ContractSigningPlanId = plan.Id,
SupplierId = Guid.NewGuid(), // loose-Guid (`ContractSigningPlanLine.cs:15`)
PeReferenceAmount = l.PeReference,
ProposedAmount = l.Proposed,
ApprovedAmount = l.Approved,
});
}
db.ContractSigningPlans.Add(plan);
await db.SaveChangesAsync(CancellationToken.None);
return plan.Id;
}
// ===================================================================
// Act / Read helpers — gọi service THẬT, KHÔNG mock.
// ===================================================================
private static async Task<ContractSigningPlanTransitionResult> ActAsync(
KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId, string action, string? comment = null)
{
await using var db = f.NewDb(actor);
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
return await svc.TransitionAsync(planId, action, comment, CancellationToken.None);
}
private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkWorkflowFixture f, Guid planId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ContractSigningPlans.AsNoTracking()
.Include(p => p.Lines)
.SingleAsync(p => p.Id == planId, CancellationToken.None);
}
private static async Task<List<ContractSigningPlanLevelOpinion>> ReadOpinionsAsync(
KhkkWorkflowFixture f, Guid planId)
{
await using var db = f.NewDb(TestCurrentUser.System());
// Lọc `WHERE PlanId = @id` ĐÚNG như acceptance §③-B yêu cầu cho Lines: nếu đợt-3
// quên vế này ở bất kỳ đâu thì phiếu hàng xóm sẽ dính, và các assert "phiếu chứng
// nhân bất động" bên dưới bắt được.
return await db.ContractSigningPlanLevelOpinions.AsNoTracking()
.Where(o => o.ContractSigningPlanId == planId)
.ToListAsync(CancellationToken.None);
}
private static async Task<Guid> LevelIdOfAsync(KhkkWorkflowFixture f, Guid approverUserId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.ApproverUserId == approverUserId)
.Select(l => l.Id)
.SingleAsync(CancellationToken.None);
}
private static async Task<List<ContractSigningPlanListItemDto>> InboxAsync(
KhkkWorkflowFixture f, TestCurrentUser actor)
{
await using var db = f.NewDb(actor);
var handler = new GetMyContractSigningPlanInboxQueryHandler(db, actor);
return await handler.Handle(new GetMyContractSigningPlanInboxQuery(), CancellationToken.None);
}
/// Con-trỏ ĐÔI + phase là MỘT bộ ba — kiểm cả ba cùng lúc để không có chuyện
/// "phase đúng mà con-trỏ trôi" lọt lưới.
private static void ShouldBeAt(
ContractSigningPlan plan, ContractSigningPlanPhase phase, int? stepIndex, int? levelOrder, string because)
{
plan.Phase.Should().Be(phase, because);
plan.CurrentWorkflowStepIndex.Should().Be(stepIndex, because);
plan.CurrentApprovalLevelOrder.Should().Be(levelOrder, because);
}
/// 3 field response (FE đọc để vẽ lại panel ngay, không refetch) phải KHỚP DB.
/// Lệch = FE hiển thị một trạng thái không tồn tại cho tới lần F5 kế.
private static void ResultShouldMirror(ContractSigningPlanTransitionResult r, ContractSigningPlan plan)
{
r.Phase.Should().Be((int)plan.Phase, "response `phase` là int (hợp-đồng literal) và phải bằng phase đã ghi DB");
r.CurrentWorkflowStepIndex.Should().Be(plan.CurrentWorkflowStepIndex, "response phải soi đúng con-trỏ Bước trong DB");
r.CurrentApprovalLevelOrder.Should().Be(plan.CurrentApprovalLevelOrder, "response phải soi đúng con-trỏ Cấp trong DB");
}
// ===================================================================
// (T1) OR-of-N — acceptance #1 TRỌN 4 VẾ (a)(b)(c)(d), spec §③-B `:28`.
//
// Đây là lưới DUY NHẤT bắt được lỗi chết người của wave này: copy nhầm khuôn
// `ProposalFeatures` (flatten `SelectMany` các Level) làm OR-of-N biến thành AND **ÂM
// THẦM** — build sạch, 574 test cũ vẫn xanh, chỉ vỡ khi phiếu thật treo giữa chừng vì
// chờ đủ chữ ký. Vì thế:
// (a) 2 phiếu SONG SINH cùng quy trình: phiếu-1 do A duyệt, phiếu-2 do B duyệt;
// (d) mỗi phiếu tiến với ĐÚNG MỘT chữ ký — nếu đòi cả hai thì con-trỏ đứng im ở Cấp 1;
// (c) mỗi phiếu ghi ĐÚNG 1 dòng ý kiến, và dòng đó trỏ Level của CHÍNH người ký;
// (b) ca ÂM 2 lớp: người sai Cấp (C, Cấp 2 chưa tới lượt) và người NGOÀI quy trình (D).
// ===================================================================
[Fact]
public async Task ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var a = await AddUserAsync(f, "NV Cung ứng A");
var b = await AddUserAsync(f, "NV Cung ứng B");
var c = await AddUserAsync(f, "Trưởng phòng C");
var outsider = await AddUserAsync(f, "Kế toán ngoài quy trình D");
// 1 Bước — Cấp 1 = {A, B} (OR-of-N), Cấp 2 = {C}.
var wf = await SeedWorkflowAsync(f, "QT-KHKK-ORN", steps:
[
[[a.Id, b.Id], [c.Id]],
]);
LineSeed[] oneLine = [new(PeReference: 999m, Proposed: 111m)];
var p1 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/001", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
var p2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/002", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
var p3 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/003", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
// Sanity TRƯỚC khi gọi service: seed đặt đúng lượt Cấp 1 Bước 1. Thiếu chốt này thì
// một seed hỏng (con-trỏ null) vẫn cho ra assert xanh ở thế giới sai.
ShouldBeAt(await ReadPlanAsync(f, p1), ContractSigningPlanPhase.ChoDuyet, 0, 1,
"seed phải đặt phiếu ĐÚNG lượt Cấp 1 Bước 1 — sai seed thì test đo chính nó");
// ---- (a) + (d): A duyệt phiếu-1, B duyệt phiếu-2. Cả hai đều TIẾN.
await ActAsync(f, As(a), p1, "approve", "A đồng ý đơn giá");
var after1 = await ReadPlanAsync(f, p1);
ShouldBeAt(after1, ContractSigningPlanPhase.ChoDuyet, 0, 2,
"MỘT người trong Cấp 1 duyệt là Cấp 1 xong ⇒ con-trỏ sang Cấp 2 CÙNG Bước; "
+ "nếu quy trình đòi đủ cả A lẫn B (AND) thì con-trỏ còn đứng ở Cấp 1");
await ActAsync(f, As(b), p2, "approve", "B đồng ý đơn giá");
ShouldBeAt(await ReadPlanAsync(f, p2), ContractSigningPlanPhase.ChoDuyet, 0, 2,
"phiếu song sinh: người CÒN LẠI của cùng Cấp duyệt cũng phải tiến y hệt — "
+ "chỉ 1 trong 2 người tiến được là dấu hiệu match cứng theo NV đầu danh sách");
// Chưa phải cấp cuối ⇒ TUYỆT ĐỐI chưa được chốt giá.
after1.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
"giá chốt CHỈ sinh tại cấp cuối cùng — chốt sớm là ký thay người chưa duyệt");
// ---- (c) ĐÚNG 1 dòng ý kiến / phiếu, trỏ Level của CHÍNH người ký.
var op1 = await ReadOpinionsAsync(f, p1);
op1.Should().ContainSingle("mỗi lượt duyệt ghi đúng 1 dòng ý kiến cho Cấp đó (UNIQUE PlanId × LevelId)");
op1[0].ApprovalWorkflowLevelId.Should().Be(await LevelIdOfAsync(f, a.Id),
"ý kiến phải gắn vào row Level CỦA NGƯỜI KÝ; gắn nhầm row của B thì màn Ý kiến cấp duyệt "
+ "hiện chữ ký treo dưới tên người chưa hề bấm nút");
op1[0].SignedByUserId.Should().Be(a.Id);
var op2 = await ReadOpinionsAsync(f, p2);
op2.Should().ContainSingle();
op2[0].ApprovalWorkflowLevelId.Should().Be(await LevelIdOfAsync(f, b.Id),
"hai phiếu song sinh ký bởi hai người khác nhau ⇒ hai LevelId khác nhau");
op2[0].SignedByUserId.Should().Be(b.Id);
// ---- (b) ca ÂM lớp 1: C có chân trong quy trình nhưng Ở CẤP KHÁC, chưa tới lượt.
Func<Task> actWrongLevel = async () => await ActAsync(f, As(c), p3, "approve", "duyệt vượt lượt");
await actWrongLevel.Should().ThrowAsync<ForbiddenException>(
"NV Cấp 2 KHÔNG được ký thay Cấp 1 — đây là chỗ mất trắng vòng kiểm soát nếu lọt");
// ---- (b) ca ÂM lớp 2: người ngoài quy trình.
Func<Task> actOutsider = async () => await ActAsync(f, As(outsider), p3, "approve", "tôi ký hộ");
await actOutsider.Should().ThrowAsync<ForbiddenException>(
"người không có trong Cấp nào của quy trình thì không phải approver");
// Ném rồi mà vẫn ghi còn tệ hơn không rào: phiếu p3 phải NGUYÊN VẸN.
ShouldBeAt(await ReadPlanAsync(f, p3), ContractSigningPlanPhase.ChoDuyet, 0, 1,
"hai lần bị chặn ⇒ con-trỏ p3 bất động");
(await ReadOpinionsAsync(f, p3)).Should().BeEmpty("bị chặn thì KHÔNG được để lại chữ ký");
}
// ===================================================================
// (T2) Con-trỏ ĐÔI — acceptance #2 (spec §③-B `:29`).
//
// 3 Bước × 2 Cấp, chạy trọn 6 lượt duyệt. Hai điểm dễ hỏng nhất:
// • hết Cấp trong Bước ⇒ sang Bước kế **và Cấp phải RESET về 1** (quên reset thì Bước 2
// bắt đầu từ Cấp 2, Cấp 1 của Bước 2 bị bỏ qua vĩnh viễn — mất một chữ ký thật);
// • `CurrentWorkflowStepIndex` là **INDEX** vào list đã sort, KHÔNG phải `Step.Order`.
// Seed cố ý đặt Order = 5/15/25 để hai đại lượng này không thể trùng nhau do may mắn;
// mã W2 đã đọc theo INDEX (`ContractSigningPlanFeatures.cs:256-260`) nên lệch semantic
// ở đây = inbox mù ngay lập tức.
// ===================================================================
[Fact]
public async Task ApproveV2_NewStep_ResetsLevelOrderToOne()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var s1c1 = await AddUserAsync(f, "PMH cấp 1");
var s1c2 = await AddUserAsync(f, "PMH cấp 2");
var s2c1 = await AddUserAsync(f, "CCM cấp 1");
var s2c2 = await AddUserAsync(f, "CCM cấp 2");
var s3c1 = await AddUserAsync(f, "Thư ký CEO");
var s3c2 = await AddUserAsync(f, "CEO");
var wf = await SeedWorkflowAsync(f, "QT-KHKK-3TRAM", steps:
[
[[s1c1.Id], [s1c2.Id]], // Bước 1 (PMH)
[[s2c1.Id], [s2c2.Id]], // Bước 2 (CCM)
[[s3c1.Id], [s3c2.Id]], // Bước 3 (CEO)
],
stepOrderStart: 5, stepOrderStride: 10); // Order 5/15/25 ≠ index 0/1/2
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/010",
ContractSigningPlanPhase.ChoDuyet, 0, 1, [new(PeReference: 900m, Proposed: 800m)]);
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1, "sanity seed");
// 1) Trong cùng Bước 1: Cấp 1 → Cấp 2.
var r1 = await ActAsync(f, As(s1c1), planId, "approve", "Bước 1 Cấp 1");
var p1 = await ReadPlanAsync(f, planId);
ShouldBeAt(p1, ContractSigningPlanPhase.ChoDuyet, 0, 2, "còn Cấp trong Bước ⇒ chỉ tăng Cấp, Bước giữ nguyên");
ResultShouldMirror(r1, p1);
// 2) Hết Cấp Bước 1 ⇒ Bước 2 (index 1) + Cấp RESET về 1.
var r2 = await ActAsync(f, As(s1c2), planId, "approve", "Bước 1 Cấp 2");
var p2 = await ReadPlanAsync(f, planId);
ShouldBeAt(p2, ContractSigningPlanPhase.ChoDuyet, 1, 1,
"sang Bước MỚI thì Cấp phải reset về 1; giữ nguyên 2 là Cấp 1 của Bước 2 không bao giờ được ký");
ResultShouldMirror(r2, p2);
// 3) Bước 2: Cấp 1 → Cấp 2.
await ActAsync(f, As(s2c1), planId, "approve", "Bước 2 Cấp 1");
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 1, 2, "trong Bước 2");
// 4) Reset LẦN HAI — một lần đúng có thể do hardcode, hai lần thì không.
await ActAsync(f, As(s2c2), planId, "approve", "Bước 2 Cấp 2");
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 2, 1,
"reset Cấp lần 2 (Bước 3) — chứng đây là quy tắc, không phải trùng hợp ở Bước 2");
// 5) Bước 3 Cấp 1 → Cấp 2: chưa xong, TUYỆT ĐỐI chưa DaDuyet.
await ActAsync(f, As(s3c1), planId, "approve", "Bước 3 Cấp 1");
var p5 = await ReadPlanAsync(f, planId);
ShouldBeAt(p5, ContractSigningPlanPhase.ChoDuyet, 2, 2,
"còn Cấp cuối chưa ký ⇒ chưa được kết thúc (W3 đường THƯỜNG: CEO ký thật, không kết-thúc-sớm)");
// 6) Cấp cuối Bước cuối ⇒ terminal, HAI con-trỏ về null.
var r6 = await ActAsync(f, As(s3c2), planId, "approve", "CEO duyệt");
var p6 = await ReadPlanAsync(f, planId);
ShouldBeAt(p6, ContractSigningPlanPhase.DaDuyet, null, null,
"hết Bước cuối ⇒ DaDuyet và cả 2 con-trỏ = null (`ContractSigningPlan.cs:34` 'Null khi terminal'); "
+ "để sót con-trỏ thì phiếu đã duyệt vẫn nằm trong hộp thư chờ của ai đó");
ResultShouldMirror(r6, p6);
}
// ===================================================================
// (T3) Finalize chốt giá — acceptance #3 (spec §③-B `:30`).
//
// Hai thứ được đo cùng lúc:
// • `AND PlanId = @id`: phiếu CHỨNG NHÂN (planB, cùng quy trình, cùng đang chờ) phải
// BẤT ĐỘNG. Bug thật của lớp này là `Where(l => l.ApprovedAmount == null)` quên vế
// PlanId — nó chốt giá cho mọi phiếu đang mở trong hệ thống, im lặng và không hoàn lại được.
// • **2 WRITE-PATH** (spec F-B1): duyệt-cấp-cuối THƯỜNG *và* Admin duyệt-thay (bypass
// danh sách approver — khuôn nguồn copy `ContractWorkflowService.cs:255` `if (!isAdmin…)`).
// Helper `ApplyApprovedValuesOnFinalize` phải được gọi ở CẢ HAI; gọi một chỗ thì
// phiếu do Admin chốt sẽ ở DaDuyet mà không có giá — GĐ3 sinh HĐ 0đ.
//
// Bẫy nguồn số: `ProposedAmount` ≠ `PeReferenceAmount` ở mọi dòng ⇒ lấy nhầm cột là lộ
// ngay (`ApprovedAmount ??= ProposedAmount` — spec §②-3a). Dòng thứ 3 đã có giá sẵn ⇒
// đo đúng ngữ nghĩa `??=` (giữ, KHÔNG đè).
// ===================================================================
[Fact]
public async Task Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "CEO ký duyệt");
var adminUser = await AddUserAsync(f, "Quản trị hệ thống");
var admin = As(adminUser, AppRoles.Admin); // KHÔNG có chân trong quy trình
// 1 Bước × 1 Cấp ⇒ một lượt duyệt là terminal.
var wf = await SeedWorkflowAsync(f, "QT-KHKK-1CAP", steps: [[[approver.Id]]]);
var planA = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/020",
ContractSigningPlanPhase.ChoDuyet, 0, 1,
[
new(PeReference: 999m, Proposed: 111m),
new(PeReference: 888m, Proposed: 222m),
new(PeReference: 777m, Proposed: 333m, Approved: 300m), // đã có giá chốt sẵn
]);
var planB = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/021",
ContractSigningPlanPhase.ChoDuyet, 0, 1,
[new(PeReference: 555m, Proposed: 55m), new(PeReference: 444m, Proposed: 44m)]);
var planC = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/022",
ContractSigningPlanPhase.ChoDuyet, 0, 1,
[new(PeReference: 222m, Proposed: 22m), new(PeReference: 111m, Proposed: 11m)]);
// ---- WRITE-PATH 1: approver Cấp cuối duyệt.
await ActAsync(f, As(approver), planA, "approve", "Chốt giá theo đề xuất BCH");
var a = await ReadPlanAsync(f, planA);
a.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet, "Cấp cuối Bước cuối ⇒ terminal");
a.Lines.Should().NotContain(l => l.ApprovedAmount == null,
"acceptance: `Lines WHERE PlanId=@id AND ApprovedAmount IS NULL` = 0");
var byProposed = a.Lines.ToDictionary(l => l.ProposedAmount);
byProposed[111m].ApprovedAmount.Should().Be(111m,
"giá chốt lấy từ `ProposedAmount` (BCH đề xuất), KHÔNG phải `PeReferenceAmount` 999 — "
+ "lấy nhầm cột thì phiếu chốt bằng số tham chiếu cũ của phiếu Duyệt NCC");
byProposed[222m].ApprovedAmount.Should().Be(222m);
byProposed[333m].ApprovedAmount.Should().Be(300m,
"`??=` GIỮ giá đã chốt sẵn (300), không đè bằng 333 — đè là xoá số người có thẩm quyền đã sửa");
// ---- Chứng nhân: phiếu hàng xóm BẤT ĐỘNG (vế `AND PlanId=@id`).
var bAfter1 = await ReadPlanAsync(f, planB);
bAfter1.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
"phiếu KHÁC tuyệt đối không được dính giá chốt — thiếu vế PlanId là chốt giá cả hệ thống");
bAfter1.Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet, "và cũng không được nhảy phase");
// ---- WRITE-PATH 2: Admin duyệt thay (không nằm trong Cấp nào) — spec F-B1.
admin.Roles.Should().Contain(AppRoles.Admin, "actor của write-path 2 phải THỰC SỰ mang role Admin");
await ActAsync(f, admin, planC, "approve", "Admin duyệt thay CEO");
var c = await ReadPlanAsync(f, planC);
c.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet, "Admin bypass danh sách approver (khuôn `ContractWorkflowService.cs:255`)");
c.Lines.Should().NotContain(l => l.ApprovedAmount == null,
"đường Admin cũng PHẢI đi qua choke-point chốt giá — đây là write-path thứ 2 mà "
+ "grep literal không nhìn thấy (spec F-B1)");
c.Lines.Single(l => l.ProposedAmount == 22m).ApprovedAmount.Should().Be(22m);
// Chứng nhân lần 2 — sau CẢ HAI write-path vẫn phải sạch.
(await ReadPlanAsync(f, planB)).Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
"qua 2 đường finalize, phiếu chứng nhân vẫn không được đụng tới");
}
// ===================================================================
// (T4) Inbox — acceptance #4 (spec §③-B `:31`).
//
// 🔴 Test DUY NHẤT trong 6 cái KHÔNG chạm service ⇒ **XANH ngay đợt này** (query handler
// W2 đã wire: `ContractSigningPlanFeatures.cs:725-752`). Cố ý không ép đỏ. Nó gánh thêm
// vai trò lưới an toàn cho 5 test kia: dùng CHUNG bộ seed helper, nên nếu seed sai FK
// hay sai con-trỏ thì test này đỏ TRƯỚC và ta biết ngay là lỗi test chứ không phải lỗi sản phẩm.
//
// Ba lớp phủ: đúng-Cấp (A, B thấy — OR-of-N) · khác-Cấp (C KHÔNG thấy phiếu Cấp 1) ·
// ngoài-quy-trình + người-soạn (rỗng). Cộng phiếu nháp: KHÔNG vào hộp thư của ai.
// ===================================================================
[Fact]
public async Task Inbox_OnlyPendingLevelApprover_SeesPlan()
{
using var f = new KhkkWorkflowFixture();
var drafterUser = await AddUserAsync(f, "BCH lập kế hoạch");
var drafter = As(drafterUser);
var a = await AddUserAsync(f, "NV Cung ứng A");
var b = await AddUserAsync(f, "NV Cung ứng B");
var c = await AddUserAsync(f, "Trưởng phòng C");
var outsider = await AddUserAsync(f, "Kế toán ngoài quy trình D");
var wf = await SeedWorkflowAsync(f, "QT-KHKK-INBOX", steps:
[
[[a.Id, b.Id], [c.Id]],
]);
LineSeed[] oneLine = [new(PeReference: 999m, Proposed: 111m)];
// Tên biến = Cấp mà phiếu ĐANG CHỜ: `pendingLevel1` đứng ở Cấp 1 (việc của A/B),
// `pendingLevel2` ở Cấp 2 (việc của C) — có ĐỦ CẢ HAI thì ca ÂM "cấp khác không thấy"
// mới đo được cả hai chiều.
// 🔧 [W3 đợt-3 sửa SEED] Dòng đầu trước đó là `0, 2` kèm chú "FAULT-INJECT tạm" — vết
// của lượt tự-thử-lưới còn sót, làm CẢ HAI phiếu cùng đứng Cấp 2 ⇒ hộp thư A/B rỗng và
// C thấy 2 phiếu. Đỏ vì SEED, KHÔNG phải vì sản phẩm (query handler W2 không đổi một
// dòng nào ở đợt này). Mọi assert giữ NGUYÊN CHỮ.
var pendingLevel1 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/030",
ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
var pendingLevel2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/031",
ContractSigningPlanPhase.ChoDuyet, 0, 2, oneLine);
var draft = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/032",
ContractSigningPlanPhase.DangSoanThao, null, null, oneLine);
// A và B cùng Cấp 1 (OR-of-N) ⇒ CẢ HAI cùng thấy đúng một phiếu.
(await InboxAsync(f, As(a))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel1 },
"approver của Cấp ĐANG CHỜ thấy phiếu; phiếu đang ở Cấp 2 thì không phải việc của A");
(await InboxAsync(f, As(b))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel1 },
"OR-of-N: người thứ hai cùng Cấp cũng phải thấy, nếu không thì cả Cấp phụ thuộc 1 người");
// C ở Cấp 2 — thấy ĐÚNG phiếu đang chờ Cấp 2, KHÔNG thấy phiếu đang ở Cấp 1 (ca ÂM).
(await InboxAsync(f, As(c))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel2 },
"cấp khác KHÔNG được thấy phiếu chưa tới lượt mình — thấy = duyệt vượt lượt ngay từ hộp thư");
(await InboxAsync(f, As(outsider))).Should().BeEmpty("người ngoài quy trình không có việc gì ở đây");
(await InboxAsync(f, drafter)).Should().BeEmpty(
"người SOẠN không phải người DUYỆT — hộp thư là 'chờ tôi duyệt', không phải 'phiếu của tôi'");
// Phiếu nháp không xuất hiện ở bất kỳ hộp thư nào (con-trỏ null).
var allInboxIds = (await InboxAsync(f, As(a))).Concat(await InboxAsync(f, As(b)))
.Concat(await InboxAsync(f, As(c))).Select(i => i.Id).ToList();
allInboxIds.Should().NotContain(draft, "phiếu Nháp chưa trình thì chưa chờ ai duyệt");
}
// ===================================================================
// (T5) UPSERT ý kiến — acceptance #5 (spec §③-B `:32`).
//
// "Duyệt 2 lần CÙNG MỘT CẤP" chỉ xảy ra theo đúng một đường ở W3 đường-THƯỜNG: ký →
// bị trả lại → gửi lại → ký lại. Dùng CHÍNH một người cho cả hai lượt để `LevelId` không
// đổi, nhờ vậy phép đo "1 row" là rõ nghĩa dưới mọi cách đọc.
//
// Nếu đợt-3 viết `Add(...)` thẳng thay vì UPSERT: hoặc nổ UNIQUE (PlanId, LevelId), hoặc
// đẻ dòng thứ hai — cả hai đều bị bắt ở đây. Nếu ghi đúng 1 row nhưng quên cập nhật
// `SignedByUserId`/`SignedByFullName`/`Comment` thì đó là chữ ký cũ đứng tên cho lần duyệt mới.
// ===================================================================
[Fact]
public async Task ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var a = await AddUserAsync(f, "NV Cung ứng A");
var b = await AddUserAsync(f, "NV Cung ứng B");
var c = await AddUserAsync(f, "Trưởng phòng C");
var wf = await SeedWorkflowAsync(f, "QT-KHKK-UPSERT", steps:
[
[[a.Id, b.Id], [c.Id]],
]);
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/040",
ContractSigningPlanPhase.ChoDuyet, 0, 1, [new(PeReference: 500m, Proposed: 480m)]);
var levelOfA = await LevelIdOfAsync(f, a.Id);
// ---- Lượt 1: A ký, KHÔNG nhập ý kiến.
await ActAsync(f, As(a), planId, "approve", comment: null);
var first = await ReadOpinionsAsync(f, planId);
first.Should().ContainSingle("lượt ký đầu tiên tạo 1 dòng");
first[0].ApprovalWorkflowLevelId.Should().Be(levelOfA);
first[0].SignedAt.Should().Be(Clock2026,
"thời điểm ký lấy từ `IDateTime` được bơm vào service — dùng `DateTime.UtcNow` trực tiếp "
+ "là không test được và lệch giờ máy chủ");
first[0].Comment.Should().NotBeNullOrWhiteSpace(
"ý kiến rỗng phải thành chữ thay thế (`ContractSigningPlanLevelOpinion.cs:26` 'hoặc placeholder', "
+ "khuôn nguồn copy `ContractWorkflowService.cs:295-297`) — để trống thì màn Ý kiến hiện ô câm, "
+ "không phân biệt được 'đã ký không ý kiến' với 'chưa ký'");
// ---- Trả lại rồi gửi lại ⇒ con-trỏ quay về Cấp 1, tới lượt A LẦN NỮA.
await ActAsync(f, As(c), planId, "return", "Thiếu bảng chiết tính — trả lại BCH");
(await ReadPlanAsync(f, planId)).Phase.Should().Be(ContractSigningPlanPhase.TraLai);
await ActAsync(f, drafter, planId, "submit", "Đã bổ sung chiết tính");
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1,
"gửi lại thì chạy LẠI từ Bước 1 Cấp 1 ⇒ đúng lượt A");
// ---- Lượt 2: CHÍNH A ký lại CÙNG Cấp đó, lần này có ý kiến.
await ActAsync(f, As(a), planId, "approve", "Đã rà lại đơn giá — thống nhất ký");
var second = await ReadOpinionsAsync(f, planId);
second.Should().ContainSingle(
"duyệt lần 2 cùng Cấp là CẬP NHẬT dòng cũ (UNIQUE PlanId × LevelId), KHÔNG đẻ dòng thứ 2");
second[0].ApprovalWorkflowLevelId.Should().Be(levelOfA, "vẫn là ý kiến của đúng Cấp đó");
second[0].Comment.Should().Be("Đã rà lại đơn giá — thống nhất ký",
"nội dung phải là ý kiến MỚI NHẤT (latest-write-wins) — giữ chữ cũ là hồ sơ nói sai sự thật");
second[0].SignedByUserId.Should().Be(a.Id, "người ký THẬT của lượt sau");
second[0].SignedByFullName.Should().Be(a.FullName,
"tên là snapshot chụp lúc ký (`ContractSigningPlanLevelOpinion.cs:29`) — bỏ trống thì bản in "
+ "tờ FO-002.01 ra ô tên rỗng");
}
// ===================================================================
// (T6) Trả lại → gửi lại — acceptance #6 (spec §③-B `:33`).
//
// Mấu chốt: phiếu bị trả lại khi ĐÃ ĐI QUA HẾT Bước 1 và đang đứng ở Bước 2. Nếu đợt-3
// hiểu "gửi lại" là *nối tiếp chỗ đang dở* thì con-trỏ sẽ về (Bước 2, Cấp 1) — trông rất
// hợp lý, và hậu quả là bản sửa của BCH KHÔNG được Bước 1 xem lại lần nào. Vì thế phải
// trả lại đầu quy trình: (Bước 1, Cấp 1).
//
// Lượt duyệt số 6 (A ký lại được) là vế chứng minh vòng chạy lại là THẬT, không phải chỉ
// đặt lại hai con số rồi khoá.
// ===================================================================
[Fact]
public async Task SendBack_Resubmit_RestartsFromStepOneLevelOne()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var a = await AddUserAsync(f, "PMH cấp 1");
var b = await AddUserAsync(f, "PMH cấp 2");
var c = await AddUserAsync(f, "CCM Bước 2");
var wf = await SeedWorkflowAsync(f, "QT-KHKK-TRALAI", steps:
[
[[a.Id], [b.Id]], // Bước 1: 2 Cấp
[[c.Id]], // Bước 2: 1 Cấp
]);
// Phiếu bắt đầu ở NHÁP, con-trỏ trống — đúng trạng thái sau khi W2 lập phiếu.
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/050",
ContractSigningPlanPhase.DangSoanThao, null, null, [new(PeReference: 700m, Proposed: 650m)]);
// 1) Trình lần đầu: đặt con-trỏ về (Bước 1, Cấp 1).
var rSubmit = await ActAsync(f, drafter, planId, "submit", "Trình duyệt kế hoạch ký kết");
var afterSubmit = await ReadPlanAsync(f, planId);
ShouldBeAt(afterSubmit, ContractSigningPlanPhase.ChoDuyet, 0, 1,
"trình = mở quy trình từ Bước 1 Cấp 1");
ResultShouldMirror(rSubmit, afterSubmit);
// 2-3) Đi hết Bước 1 để phiếu ĐANG ĐỨNG Ở BƯỚC 2 lúc bị trả lại.
await ActAsync(f, As(a), planId, "approve", "Bước 1 Cấp 1 duyệt");
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2, "sang Cấp 2");
await ActAsync(f, As(b), planId, "approve", "Bước 1 Cấp 2 duyệt");
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 1, 1,
"hết Bước 1 ⇒ đang chờ Bước 2 Cấp 1 (đây là mốc để câu hỏi 'gửi lại về đâu' có nghĩa)");
// 4) Bước 2 trả lại.
var rReturn = await ActAsync(f, As(c), planId, "return", "Sai khối lượng hạng mục 3 — trả lại");
var afterReturn = await ReadPlanAsync(f, planId);
ShouldBeAt(afterReturn, ContractSigningPlanPhase.TraLai, null, null,
"Trả lại toàn phần: phase TraLai + XOÁ con-trỏ (`ContractSigningPlanWorkflowService.cs:116`) — "
+ "để sót con-trỏ thì phiếu vừa nằm ở tay người soạn vừa nằm trong hộp thư người duyệt");
ResultShouldMirror(rReturn, afterReturn);
afterReturn.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
"bị trả lại thì không có giá nào được chốt");
// 5) 🔴 Gửi lại: PHẢI về đầu quy trình, KHÔNG nối tiếp Bước 2.
await ActAsync(f, drafter, planId, "submit", "Đã sửa khối lượng, trình lại");
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1,
"gửi lại chạy LẠI TỪ ĐẦU (Bước 1 Cấp 1); về (1,1) tức Bước 1 không hề xem bản sửa");
// 6) Vòng mới là thật: A có lượt lại và duyệt được.
await ActAsync(f, As(a), planId, "approve", "Xem lại bản sửa — đồng ý");
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2,
"A ký được lần nữa ⇒ vòng duyệt chạy lại thật sự, không phải con-trỏ bị đặt lại rồi khoá");
}
}