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