[CLAUDE] Contract: K3 SP-002 8 workflow nhom KHKK-N1-8 + port level-finalize + designer (0-mig) + 5 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m10s

Seed 8 workflow type-10 theo ROSTER LIVE prod (QT-DN-V2-001, lead verify tay
@SQLEXPRESS: 3 tram PRO->CCM->CEO, 11 nguoi, resolve EMAIL, idempotent per-Code;
co Finalize CHI cap chuong.phan). Port nhanh level-finalize mirror PE :866-876
(EndedByLevelFinalize K2-de-K3-tieu + ApplyApprovedValuesOnFinalize truoc Phase;
opt-out applyLevelFinalize 3-tang interface/service/controller, default true).
Sweep ep-false va-3: GO CREATE ep-false Finalize type-10 (OG-9 supersede PHA-VO
S155); GIU PUT-Conflict (luat versioning CHUNG - spec liet nham, khai lech);
GIU ep-false SkipToFinal type-10 (K3-test O-1 chong config-lie mới) + FE disable
o SkipToFinal cho KHKK (gate-K3 F-2). Rao (v) pin-dung-nhom regex KHKK-N[1-8].
+8 MenuKeys AwV2_KhkkN* (All 56->64, policies 224->256 may-dem) + 8 menu-row +
resolvePath fe-admin phep-am Khkk_ 0-hit. 5 test K3 (T-a..T-e, anti-tautology).
Gate K3 PASS-WITH-FLAGS 8f/0-blocker (sub-reviewer-gate-k3.md): F-1 checkbox
opt-out man duyet = KHAI HANDOFF dau-viec-#1 lan sau (tram CEO tam thoi qua
Designer bo co); F-2 va; F-3 dong truoc do. Suite 614/0 (45D+569I). K3-BE 2 luot
engine-exit -> che nho + lead-solo (bai: task-hep-Write-som song).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-31 18:21:39 +07:00
parent d6cb5469b3
commit 15349e8ee0
15 changed files with 766 additions and 83 deletions

View File

@ -1,6 +1,7 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.ApprovalWorkflowsV2; // [K3 S164] CreateAwDefinitionCommand — test giữ-cờ
using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor
using SolutionErp.Application.ContractSigningPlans; // GetMyContractSigningPlanInboxQuery (W2, đã sống)
using SolutionErp.Application.ContractSigningPlans.Services;
@ -307,7 +308,7 @@ public class ContractSigningPlanApprovalTests
{
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);
return await svc.TransitionAsync(planId, action, comment, ct: CancellationToken.None);
}
private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkWorkflowFixture f, Guid planId)
@ -794,4 +795,347 @@ public class ContractSigningPlanApprovalTests
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á");
}
// ###################################################################
// [K3 S164 — 2026-07-31] KẾT-THÚC-TẠI-CẤP (level-finalize).
//
// Owner @S164 ĐẢO phán quyết "PHÁ VỠ" của S155 ⇒ service port nhánh mới
// (`ContractSigningPlanWorkflowService.cs:276-289`), đứng NGAY SAU `UpsertLevelOpinionAsync`
// và TRƯỚC mọi nhánh advance. Đây là đường thứ HAI đưa phiếu sang `DaDuyet` — tức đường
// thứ hai phải đi qua choke-point chốt giá; bỏ sót nó thì phiếu "Đã duyệt" mà
// `ApprovedAmount` rỗng và GĐ3 sinh hợp đồng 0đ (đúng lớp hỏng mà T3 đã rào cho đường 1).
//
// Ba lưới dưới đây là MỘT MA TRẬN 2 chiều trên CÙNG MỘT bộ seed (kỹ thuật "cùng-ruột,
// khác đúng 1 biến" — S153): chỉ khi cả ba cùng xanh thì mới kết luận được rằng nhánh
// finalize phụ thuộc CẢ HAI vế, chứ không phải một vế nào đó tình cờ đúng.
//
// │ apply=true (mặc định) │ apply=false (bỏ tick)
// ─────────────────────┼───────────────────────┼──────────────────────
// Cấp CÓ cờ │ (T-a) KẾT THÚC │ (T-b) advance thường
// Cấp KHÔNG cờ │ (T-c) advance thường │ — (không có thông tin thêm)
//
// 🔴 Điều kiện để phép đo có nghĩa: Cấp mang cờ là **Bước 2 Cấp 2 của quy trình 3 Bước**,
// tức KHÔNG phải cấp cuối. Nếu đặt cờ ở cấp cuối thì "kết thúc" xảy ra ở cả hai thế giới
// (có cờ / không cờ) và T-a xanh mà chẳng chứng minh gì. Mỗi test vì thế assert lại số
// Bước = 3 trước khi kết luận.
// ###################################################################
/// Bộ seed dùng chung cho T-a/T-b/T-c. Ba phiếu SONG SINH trên CÙNG một quy trình:
/// • `PlanAtFlagLevel` — đứng ở Bước 2 Cấp 2 (Cấp MANG cờ) → đối tượng đo của T-a/T-b
/// • `WitnessAtFlagLevel` — y hệt, KHÔNG ai đụng → bắt lỗi finalize quên vế "chỉ phiếu này"
/// • `PlanAtPlainLevel` — đứng ở Bước 1 Cấp 1 (Cấp KHÔNG cờ) → đối tượng đo của T-c
private sealed record FinalizeScenario(
Guid WorkflowId,
Guid PlanAtFlagLevel,
Guid WitnessAtFlagLevel,
Guid PlanAtPlainLevel,
User FlagApprover,
User PlainApprover,
TestCurrentUser Drafter);
/// Bẫy nguồn số cố ý: `ProposedAmount` ≠ `PeReferenceAmount` ở MỌI dòng (lấy nhầm cột là lộ
/// ngay), và dòng thứ 3 đã có `ApprovedAmount` sẵn để đo đúng ngữ nghĩa `??=` (GIỮ, không đè).
private static readonly LineSeed[] FinalizeLines =
[
new(PeReference: 999m, Proposed: 111m),
new(PeReference: 888m, Proposed: 222m),
new(PeReference: 777m, Proposed: 333m, Approved: 300m),
];
private static async Task<FinalizeScenario> SeedFinalizeScenarioAsync(KhkkWorkflowFixture f, string tag)
{
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, "TP.CCM — Cấp được KẾT THÚC");
var s3c1 = await AddUserAsync(f, "Thư ký CEO");
var s3c2 = await AddUserAsync(f, "CEO");
// Order 5/15/25 (KHÔNG 1/2/3) giữ nguyên chủ ý của T2: `CurrentWorkflowStepIndex` là
// INDEX vào list đã sort, đọc nhầm thành `Step.Order` là lộ ngay chứ không trùng do may.
var wf = await SeedWorkflowAsync(f, $"QT-KHKK-FIN-{tag}", steps:
[
[[s1c1.Id], [s1c2.Id]], // Bước 1
[[s2c1.Id], [s2c2.Id]], // Bước 2 ← Cấp 2 mang cờ
[[s3c1.Id], [s3c2.Id]], // Bước 3 ← còn NGUYÊN phía sau ⇒ kết thúc ở Bước 2 là SỚM
],
stepOrderStart: 5, stepOrderStride: 10);
await SetLevelFinalizeAsync(f, wf, stepIndex: 1, levelOrder: 2, expectedApprover: s2c2.Id);
var flagPlan = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}01",
ContractSigningPlanPhase.ChoDuyet, 1, 2, FinalizeLines);
var witness = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}02",
ContractSigningPlanPhase.ChoDuyet, 1, 2, FinalizeLines);
var plainPlan = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}03",
ContractSigningPlanPhase.ChoDuyet, 0, 1, FinalizeLines);
return new FinalizeScenario(wf, flagPlan, witness, plainPlan, s2c2, s1c1, drafter);
}
/// Bật cờ `AllowApproverFinalize` cho ĐÚNG MỘT Cấp, rồi tự kiểm 2 CHIỀU từ ĐĨA:
/// (i) Cấp được bật đúng là Cấp của người ta nghĩ; (ii) TOÀN quy trình chỉ có đúng nó
/// mang cờ. Thiếu vế (ii) thì một seed lỡ bật thêm Cấp khác vẫn cho T-c xanh ở thế giới sai.
private static async Task SetLevelFinalizeAsync(
KhkkWorkflowFixture f, Guid workflowId, int stepIndex, int levelOrder, Guid expectedApprover)
{
await using (var db = f.NewDb(TestCurrentUser.System()))
{
var stepIds = await db.ApprovalWorkflowSteps
.Where(s => s.ApprovalWorkflowId == workflowId)
.OrderBy(s => s.Order)
.Select(s => s.Id)
.ToListAsync(CancellationToken.None);
var levels = await db.ApprovalWorkflowLevels
.Where(l => l.ApprovalWorkflowStepId == stepIds[stepIndex] && l.Order == levelOrder)
.ToListAsync(CancellationToken.None);
levels.Should().ContainSingle("seed đặt đúng 1 NV ở Cấp mang cờ — nhiều hơn là OR-of-N, phép đo đổi nghĩa");
levels[0].ApproverUserId.Should().Be(expectedApprover,
"bật cờ nhầm Cấp thì mọi assert phía sau nói về một người khác");
levels[0].AllowApproverFinalize = true;
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.NewDb(TestCurrentUser.System());
var flagged = await verify.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.Step!.ApprovalWorkflowId == workflowId && l.AllowApproverFinalize)
.Select(l => l.ApproverUserId)
.ToListAsync(CancellationToken.None);
flagged.Should().BeEquivalentTo(new[] { expectedApprover },
"ĐÚNG MỘT Cấp trong cả quy trình mang cờ — cờ rò sang Cấp khác thì T-c mất răng");
}
/// Số Bước của quy trình — dùng để chốt "còn Bước phía sau" (điều kiện KẾT THÚC SỚM có nghĩa).
private static async Task<int> StepCountAsync(KhkkWorkflowFixture f, Guid workflowId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ApprovalWorkflowSteps.AsNoTracking()
.CountAsync(s => s.ApprovalWorkflowId == workflowId, CancellationToken.None);
}
/// `ActAsync` luôn dùng mặc định (`applyLevelFinalize = true`) — helper NÀY là đường DUY NHẤT
/// gửi `false`, tách riêng để không đụng vào `ActAsync` mà 6 test cũ đang dựa vào.
private static async Task<ContractSigningPlanTransitionResult> ActWithFinalizeFlagAsync(
KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId, string action,
string? comment, bool applyLevelFinalize)
{
await using var db = f.NewDb(actor);
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
return await svc.TransitionAsync(planId, action, comment, applyLevelFinalize, CancellationToken.None);
}
/// Hai dòng khởi điểm `ApprovedAmount = null` phải CÒN null; dòng đã có 300 phải GIỮ 300.
/// Viết thành hàm để ba test nói cùng một câu, không mỗi chỗ một kiểu.
private static void LinesShouldBeUnpriced(ContractSigningPlan plan, string because)
{
plan.Lines.Where(l => l.ProposedAmount != 333m).Should()
.OnlyContain(l => l.ApprovedAmount == null, because);
plan.Lines.Single(l => l.ProposedAmount == 333m).ApprovedAmount.Should().Be(300m,
"dòng vốn đã có giá thì giữ nguyên số cũ — " + because);
}
// ===================================================================
// (T-a) Cấp MANG cờ + không bỏ tick ⇒ KẾT THÚC NGAY tại Cấp đó, kèm chốt giá.
// ===================================================================
[Fact]
public async Task ApproveV2_CapMangCoKetThuc_ChotPhieuNgay_KhongTrinhTiepBuocSau()
{
using var f = new KhkkWorkflowFixture();
var sc = await SeedFinalizeScenarioAsync(f, "A");
// Sanity: đo đúng thế giới mình nghĩ — phiếu đứng ở Cấp mang cờ, chưa từng finalize,
// và quy trình CÒN Bước 3 phía sau (nếu không thì "kết thúc sớm" là vô nghĩa).
var before = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(before, ContractSigningPlanPhase.ChoDuyet, 1, 2, "seed đặt phiếu đúng Bước 2 Cấp 2");
before.EndedByLevelFinalize.Should().BeFalse("cờ runtime phải sạch trước khi đo");
(await StepCountAsync(f, sc.WorkflowId)).Should().Be(3,
"Cấp mang cờ KHÔNG được là cấp cuối — nếu là cấp cuối thì phiếu kết thúc ở CẢ HAI thế giới "
+ "(có cờ / không cờ) và test này xanh mà không chứng minh gì");
var r = await ActAsync(f, As(sc.FlagApprover), sc.PlanAtFlagLevel,
"approve", "TP.CCM chốt — không cần trình CEO");
var after = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(after, ContractSigningPlanPhase.DaDuyet, null, null,
"Cấp mang cờ duyệt = kết thúc quy trình: phase DaDuyet và XOÁ cả 2 con-trỏ; "
+ "để sót con-trỏ thì phiếu đã duyệt vẫn nằm trong hộp thư chờ của Bước 3");
ResultShouldMirror(r, after);
after.EndedByLevelFinalize.Should().BeTrue(
"cờ RUNTIME phân biệt 'kết thúc sớm THẬT' với 'chạy hết quy trình' — FE/GĐ3 đọc cột này; "
+ "suy ngược từ cấu hình quy trình là bài học gotcha #78 (config-lie) đã trả giá ở PE S97");
// Chốt giá: đường finalize là WRITE-PATH THỨ HAI tới DaDuyet ⇒ cũng phải qua choke-point.
after.Lines.Should().NotContain(l => l.ApprovedAmount == null,
"phiếu 'Đã duyệt' mà ApprovedAmount rỗng thì GĐ3 sinh hợp đồng 0đ — nhánh finalize "
+ "PHẢI gọi `ApplyApprovedValuesOnFinalize`, đúng như nhánh duyệt-cấp-cuối");
var byProposed = after.Lines.ToDictionary(l => l.ProposedAmount);
byProposed[111m].ApprovedAmount.Should().Be(111m,
"giá chốt lấy `ProposedAmount` (BCH đề xuất), KHÔNG phải `PeReferenceAmount` 999");
byProposed[222m].ApprovedAmount.Should().Be(222m);
byProposed[333m].ApprovedAmount.Should().Be(300m,
"`??=` GIỮ giá đã chốt sẵn (300), không đè bằng 333");
// Nhánh finalize nằm SAU `UpsertLevelOpinionAsync` ⇒ chữ ký của người kết thúc vẫn phải có.
var op = await ReadOpinionsAsync(f, sc.PlanAtFlagLevel);
op.Should().ContainSingle("kết thúc tại Cấp vẫn là một lượt DUYỆT ⇒ vẫn ghi ý kiến của Cấp đó");
op[0].SignedByUserId.Should().Be(sc.FlagApprover.Id,
"người kết thúc phiếu phải để lại tên — bản in FO-002.01 lấy chữ ký từ đây");
// Chứng nhân: phiếu song sinh (cùng quy trình, cùng con-trỏ) tuyệt đối bất động.
var w = await ReadPlanAsync(f, sc.WitnessAtFlagLevel);
ShouldBeAt(w, ContractSigningPlanPhase.ChoDuyet, 1, 2,
"finalize chỉ được chạm phiếu ĐANG duyệt — lây sang phiếu khác là duyệt hộ cả hệ thống");
w.EndedByLevelFinalize.Should().BeFalse("cờ runtime cũng không được lây");
LinesShouldBeUnpriced(w, "phiếu hàng xóm không được dính giá chốt");
}
// ===================================================================
// (T-b) OPT-OUT — CÙNG bộ seed, đổi ĐÚNG MỘT biến `applyLevelFinalize: false`.
// Người duyệt bỏ tick "Cấp này kết thúc" ⇒ phiếu đi tiếp Bước 3 như thường.
// ===================================================================
[Fact]
public async Task ApproveV2_CapMangCoKetThuc_NhungBoTick_ThiVanTrinhTiepBuocSau()
{
using var f = new KhkkWorkflowFixture();
var sc = await SeedFinalizeScenarioAsync(f, "B");
(await StepCountAsync(f, sc.WorkflowId)).Should().Be(3, "cùng bộ seed với T-a");
var r = await ActWithFinalizeFlagAsync(f, As(sc.FlagApprover), sc.PlanAtFlagLevel,
"approve", "Gói lớn — tôi vẫn trình CEO", applyLevelFinalize: false);
var after = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 2, 1,
"bỏ tick ⇒ rơi xuống advance THƯỜNG: hết Cấp của Bước 2 ⇒ sang Bước 3 (index 2) và "
+ "Cấp RESET về 1. Ra DaDuyet ở đây tức là cờ quy trình một mình quyết định, "
+ "quyền chọn của người duyệt bị bỏ qua");
ResultShouldMirror(r, after);
after.EndedByLevelFinalize.Should().BeFalse(
"phiếu KHÔNG kết thúc sớm ⇒ cờ runtime phải sạch; bật ở đây là badge 'kết thúc sớm' "
+ "hiện sai trên phiếu vẫn còn đang chờ CEO (đúng bệnh #78 mà PE S97 đã sửa)");
LinesShouldBeUnpriced(after,
"chưa tới DaDuyet thì TUYỆT ĐỐI chưa được chốt giá — chốt sớm là ký thay CEO");
// Vòng chạy tiếp là THẬT: đúng người của Bước 3 Cấp 1 duyệt được (không phải con-trỏ
// bị đặt tới một chỗ chết).
var op = await ReadOpinionsAsync(f, sc.PlanAtFlagLevel);
op.Should().ContainSingle("lượt duyệt opt-out vẫn ghi ý kiến của Cấp vừa ký");
}
// ===================================================================
// (T-c) Cờ KHÔNG RÒ — Cấp không mang cờ, duyệt với mặc định (`applyLevelFinalize = true`)
// vẫn phải advance thường. Đây là vế chứng minh `applyLevelFinalize` MỘT MÌNH không đủ
// để kết thúc phiếu — nếu điều kiện viết nhầm thành `applyLevelFinalize` đơn lẻ (hoặc `||`
// thay vì `&&`) thì MỌI lượt duyệt đầu tiên sẽ chốt luôn cả phiếu.
// ===================================================================
[Fact]
public async Task ApproveV2_CapKhongMangCo_ThiKhongBaoGioKetThucSom()
{
using var f = new KhkkWorkflowFixture();
var sc = await SeedFinalizeScenarioAsync(f, "C");
var r = await ActAsync(f, As(sc.PlainApprover), sc.PlanAtPlainLevel,
"approve", "Bước 1 Cấp 1 duyệt");
var after = await ReadPlanAsync(f, sc.PlanAtPlainLevel);
ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 0, 2,
"Cấp KHÔNG có cờ ⇒ chỉ tăng Cấp trong cùng Bước, dù client gửi mặc định "
+ "`applyLevelFinalize = true`; ra DaDuyet ở đây là quy trình 3 Bước bị nuốt còn 1 lượt ký");
ResultShouldMirror(r, after);
after.EndedByLevelFinalize.Should().BeFalse("không có cờ thì không có kết-thúc-sớm để ghi");
LinesShouldBeUnpriced(after, "mới Bước 1 Cấp 1 — chưa có gì để chốt");
// Và phiếu ĐANG đứng ở Cấp mang cờ vẫn nguyên (cờ là thuộc tính của CẤP, không phải
// của quy trình/phiếu — một lượt duyệt ở Cấp khác không được kích hoạt nó).
var atFlag = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(atFlag, ContractSigningPlanPhase.ChoDuyet, 1, 2, "phiếu kia bất động");
atFlag.EndedByLevelFinalize.Should().BeFalse("cờ chỉ bật khi CHÍNH Cấp mang cờ bấm duyệt");
}
// ===================================================================
// (T-e) GIỮ CỜ QUA LỆNH TẠO QUY TRÌNH — `ApprovalWorkflowV2AdminFeatures.cs:376-385`.
//
// Trước @K3, handler tạo version ÉP `AllowApproverFinalize = false` riêng cho type-10
// (containment thời "PHÁ VỠ" S155). Owner đảo phán quyết ⇒ ép-false đã gỡ. Nếu ai đó
// khôi phục nó thì admin vẫn tick được cờ trên Designer, DB vẫn im lặng lưu `false`, và
// T-a phía trên KHÔNG bắt được (T-a set cờ thẳng vào DB, không đi qua lệnh tạo) —
// đúng hình "config-lie" gotcha #78: giao diện nói một đằng, máy chạy một nẻo.
//
// Ba vế để phép đo không rỗng:
// (i) Cấp tick cờ ⇒ DB `true`;
// (ii) Cấp KHÔNG tick ⇒ DB `false` (chứng handler không bật bừa mọi dòng);
// (iii) ĐỐI CHỨNG type khác (DuyetNcc = 1) cũng `true` ⇒ cơ chế là ECHO tham số, không
// phải một nhánh đặc-cách theo loại — vế này là thứ phân biệt "đã gỡ ép-false"
// với "đổi ép-false thành ép-true".
// ===================================================================
[Fact]
public async Task CreateAwDefinition_LoaiKhkk_GiuNguyenCoKetThucTaiCap_KhongConEpFalse()
{
using var f = new KhkkWorkflowFixture();
var plain = await AddUserAsync(f, "NV duyệt Cấp 1");
var finalizer = await AddUserAsync(f, "TP.CCM — Cấp kết thúc");
var khkkId = await CreateAwDefinitionAsync(
f, ApprovalWorkflowApplicableType.ContractSigningPlan, "KHKK-N1", plain.Id, finalizer.Id);
var peId = await CreateAwDefinitionAsync(
f, ApprovalWorkflowApplicableType.DuyetNcc, "QT-PE-DOICHUNG", plain.Id, finalizer.Id);
var khkk = await ReadLevelFlagsAsync(f, khkkId);
khkk.Should().HaveCount(2, "lệnh tạo dựng đúng 2 Cấp");
khkk.Single(l => l.Approver == finalizer.Id).Finalize.Should().BeTrue(
"admin tick 'Cấp này KẾT THÚC' thì DB phải lưu true — ép false ở đây là cờ chết: "
+ "Designer hiện đã tick, service `…WorkflowService.cs:276` đọc ra false, "
+ "và không ai biết cho tới khi phiếu thật treo chờ CEO");
khkk.Single(l => l.Approver == plain.Id).Finalize.Should().BeFalse(
"Cấp KHÔNG tick phải là false — nếu true thì handler đang bật bừa, và vế trên xanh vô nghĩa");
var pe = await ReadLevelFlagsAsync(f, peId);
pe.Single(l => l.Approver == finalizer.Id).Finalize.Should().BeTrue(
"ĐỐI CHỨNG: loại quy trình khác cũng giữ cờ ⇒ cơ chế là echo tham số của lệnh, "
+ "không phải một nhánh đặc-cách theo `ApplicableType`");
}
private sealed record LevelFlag(int Order, Guid Approver, bool Finalize);
/// Tạo 1 quy trình 1 Bước × 2 Cấp qua lệnh THẬT: Cấp 1 không tick, Cấp 2 tick kết-thúc.
private static async Task<Guid> CreateAwDefinitionAsync(
KhkkWorkflowFixture f, ApprovalWorkflowApplicableType type, string code,
Guid plainApprover, Guid finalizeApprover)
{
await using var db = f.NewDb(TestCurrentUser.System());
var handler = new CreateAwDefinitionCommandHandler(db);
return await handler.Handle(
new CreateAwDefinitionCommand(
ApplicableType: (int)type,
Code: code,
Name: $"Quy trình {code}",
Description: null,
Steps:
[
new CreateAwStepInput(1, "Bước 1", null,
[
new CreateAwLevelInput(1, "Cấp 1", plainApprover),
new CreateAwLevelInput(2, "Cấp 2", finalizeApprover, AllowApproverFinalize: true),
]),
]),
CancellationToken.None);
}
private static async Task<List<LevelFlag>> ReadLevelFlagsAsync(KhkkWorkflowFixture f, Guid workflowId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.Step!.ApprovalWorkflowId == workflowId)
.OrderBy(l => l.Order)
.Select(l => new LevelFlag(l.Order, l.ApproverUserId, l.AllowApproverFinalize))
.ToListAsync(CancellationToken.None);
}
}

View File

@ -244,7 +244,7 @@ public class ContractSigningPlanGroupAndCatalogTests
{
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);
return await svc.TransitionAsync(planId, action, comment, ct: CancellationToken.None);
}
private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkGroupFixture f, Guid planId)
@ -821,4 +821,83 @@ public class ContractSigningPlanGroupAndCatalogTests
new CreateContractSigningPlanCommand(peId, workflowId, GhiChu: null, ApprovalGroup: approvalGroup),
CancellationToken.None);
}
// ===================================================================
// [K3 S164 — 2026-07-31] Rào (v) PIN-ĐÚNG-NHÓM — `ContractSigningPlanFeatures.cs:409-420`.
//
// 8 quy trình seed @K3 mang mã họ `KHKK-N{n}`; phiếu nhóm 3 mà pin quy trình nhóm 5 thì
// toàn bộ dây chuyền ký (người ký, thứ tự trạm) là của LOẠI HỢP ĐỒNG KHÁC — sai im lặng,
// chỉ lộ khi hồ sơ tới tay người không có thẩm quyền ký. Mã NGOÀI họ (legacy/chung) cố ý
// không bị ràng buộc.
//
// Ba điều được đo, mỗi cái bịt một cách "xanh giả":
// (1) CÙNG MỘT quy trình `KHKK-N5` cho ra HAI kết quả trái ngược khi chỉ đổi nhóm của
// phiếu (3 → chặn, 5 → cho qua). Chỉ đo một chiều thì một rào chặn-tuốt hoặc một
// rào chưa-bao-giờ-chạy đều xanh.
// (2) Lần bị chặn KHÔNG ĐỐT MÃ: rào (v) đứng TRƯỚC `codeGen.GenerateAsync` (`:428`), mà
// codegen TỰ COMMIT trong transaction riêng ⇒ nếu ai đó đảo thứ tự, số thứ tự bốc hơi
// vĩnh viễn và mã phiếu nhảy cóc. Đo bằng chính mã của phiếu hợp lệ ĐẦU TIÊN (001).
// (3) Mã ngoài họ đi lọt — vế này giữ cho rào không nuốt các quy trình chung.
// ===================================================================
[Fact]
public async Task Create_PinQuyTrinhKhacNhomDuyet_ThiBiChan_VaKhongDotMa()
{
using var f = new KhkkGroupFixture();
var bch = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var pe = await SeedPeForCreateAsync(f);
var wfN5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]);
var wfN3 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]);
var wfLegacy = await SeedWorkflowAsync(f, "QT-KHKK-LEGACY", steps: [[[approver.Id]]]);
// ---- (1a) Phiếu nhóm 3 pin quy trình nhóm 5 ⇒ chặn, và nói ĐÍCH DANH quy trình sai.
var wrongGroup = async () => await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 3);
var ex = await wrongGroup.Should().ThrowAsync<ConflictException>(
"quy trình họ KHKK-N{n} chỉ dùng được cho phiếu đúng nhóm n — sai nhóm là ký sai dây chuyền");
ex.Which.Message.Should().Contain("KHKK-N5").And.Contain("N3",
"thông báo phải chỉ ra CẢ quy trình đang pin lẫn nhóm của phiếu, nếu không người dùng "
+ "chỉ thấy '409' và không biết đổi cái gì");
// Bị chặn thì KHÔNG được để lại dấu vết nào.
(await ReadPlanScopesAsync(f)).Should().BeEmpty("rào ném trước khi `Add` ⇒ không có phiếu nửa vời");
// ---- (2) + (1b) Pin ĐÚNG nhóm ⇒ qua. Mã 001 chứng minh lần 409 không tiêu số thứ tự.
var okSameGroup = await CreateAsync(f, bch, pe.PeId, wfN3, approvalGroup: 3);
okSameGroup.MaKeHoach.Should().Be("KHKK/2026/001",
"rào (v) đứng TRƯỚC codegen ⇒ lần bị chặn KHÔNG đốt mã; ra 002 tức là số thứ tự đã bị "
+ "tiêu cho một phiếu chưa từng tồn tại (codegen tự commit, không rollback theo request)");
// ---- (1c) CHÍNH quy trình vừa bị chặn, nay hợp lệ vì phiếu đúng nhóm 5.
var okOtherGroup = await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 5);
okOtherGroup.MaKeHoach.Should().Be("KHKK/2026/002",
"cùng một quy trình KHKK-N5: nhóm 3 bị chặn, nhóm 5 cho qua — hai kết quả trái ngược "
+ "chỉ khác đúng một biến, đó là bằng chứng rào SO KHỚP chứ không chặn-tuốt");
// ---- (3) Mã ngoài họ `KHKK-N{n}` KHÔNG bị ràng buộc (quy trình chung/legacy).
var okLegacy = await CreateAsync(f, bch, pe.PeId, wfLegacy, approvalGroup: 4);
okLegacy.MaKeHoach.Should().Be("KHKK/2026/003",
"quy trình ngoài họ đi thẳng — rào chỉ áp cho 8 mã nhóm seed @K3");
// ---- Chốt bằng ĐĨA: 3 phiếu, mỗi phiếu pin đúng quy trình của nó.
var scopes = await ReadPlanScopesAsync(f);
scopes.Should().BeEquivalentTo(new[]
{
new PlanScope("KHKK/2026/001", 3, wfN3),
new PlanScope("KHKK/2026/002", 5, wfN5),
new PlanScope("KHKK/2026/003", 4, wfLegacy),
}, "nhóm duyệt + quy trình phải được ghi đúng như lúc tạo — rào cho qua mà lưu sai cặp "
+ "thì phiếu vẫn chạy nhầm dây chuyền, chỉ là muộn hơn một bước");
}
private sealed record PlanScope(string MaKeHoach, int ApprovalGroup, Guid ApprovalWorkflowId);
private static async Task<List<PlanScope>> ReadPlanScopesAsync(KhkkGroupFixture f)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ContractSigningPlans.AsNoTracking()
.OrderBy(p => p.MaKeHoach)
.Select(p => new PlanScope(p.MaKeHoach!, p.ApprovalGroup, p.ApprovalWorkflowId!.Value))
.ToListAsync(CancellationToken.None);
}
}