[CLAUDE] App+Api+FE-User+FE-Admin+Tests: W2 KHKK — CRUD phieu nhap + can cu b.8-9 + FE 3 page x2 app + 12 fix reviewer
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s

- BE mega-file ContractSigningPlanFeatures (Create 4-rao + auto-Lines snapshot per-winner + MaKeHoach @CREATE + UpdateDraft null-safe + Get/List/inbox-precompute-V2/deleted + Dossier + Attachments + Delete allow-list M6 + picker) + Controller 15 action policy per-action 15/15 (KeHoachKyKet.*)
- FE pages/khkk 3 page + types x2 app SHA-pair + 4-place (6 leaf Khkk_* het coming-soon; WfView: user=matrix?type=10 [whitelist +10] / admin=Designer deep-link); root KeHoachKyKet -> /khkk/list
- Reviewer adversarial: 12 finding (3 CRITICAL dut-2-bo FE-BE: route picker + peId body + dossier planId; 2 MAJOR security: detail+download 0 rao -> EnsureCanViewAsync mirror PE S89 nhap-rieng-tu; PUT-vs-POST de-ban-sao; peTenGoiThau; winnerSupplierNames; Content-Disposition RFC6266; LEFT-join Projects) -> 11 FIXED + 1 GIU (F-10 ke thua khuon PE, bit dong bo sau)
- tests: +8 (574 total 0 fail) — authz reflection dong + Create/Delete/guard/khong-dot-ma-khi-409 + fixture-co-interceptor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 21:19:41 +07:00
parent 914c5a40f2
commit 6cbc6ad937
20 changed files with 5020 additions and 23 deletions

View File

@ -1,5 +1,6 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Routing; // HttpMethodAttribute — đọc verb thật của action (W2 KHKK)
using SolutionErp.Api.Controllers;
using SolutionErp.Domain.Identity; // MenuKeys — chốt policy name có thật (F-1b S155)
@ -321,4 +322,121 @@ public class AuthorizePolicyRegressionTests
"GET my-dashboard KHÔNG được gate Policy — mỗi user chỉ thấy số của chính mình. " +
"Thêm policy sẽ khóa dashboard cá nhân của role không có Reports.Read (S118 giữ mở có chủ đích).");
}
// ===================================================================
// [W2 KHKK — S161 2026-07-29] ContractSigningPlansController (GĐ2 "Kế hoạch ký kết HĐ").
//
// Shape KHÁC 3 controller ở trên — đừng ép về cùng một kiểu:
// class-level CÓ Policy `KeHoachKyKet.Read` (mọi endpoint tối thiểu Read) + per-action
// override GHI ĐỦ 15/15 action, KỂ CẢ action chỉ-đọc (viết lại Read cho tường minh).
// Lý do ghi thành lời ở `ContractSigningPlansController.cs:12-19`.
//
// Test này quét TOÀN BỘ action (không liệt tên tay) vì rủi ro thật của module mới là
// action thứ 16 thêm sau này QUÊN attribute — liệt tay thì action mới không bao giờ bị soi.
// 3 lớp răng, mỗi lớp bắt một hỏng khác nhau:
// (a) THIẾU attribute → endpoint tụt xuống class-level Read ⇒ ai đọc được cũng GHI được;
// (b) SAI tên policy → `Program.cs` chỉ sinh policy cho key ∈ `MenuKeys.All`; tên lạ
// = 500 lúc chạy (KHÔNG phải 403) — attribute vẫn "trông đúng";
// (c) verb ghi mà policy Read → 403 không bao giờ xảy ra: user chỉ-xem GHI được vào phiếu.
// ===================================================================
/// Bộ action của controller = method public khai TRỰC TIẾP trên class + có `[HttpXxx]`.
/// (Lọc theo HttpMethodAttribute để bỏ property/ctor/record lồng bên trong.)
private static (string Name, string Verb, AuthorizeAttribute? Authz)[] GetActionsWithVerb(Type controllerType)
=> controllerType
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Select(m => new
{
m.Name,
Http = m.GetCustomAttributes<HttpMethodAttribute>(inherit: false).FirstOrDefault(),
Authz = m.GetCustomAttributes<AuthorizeAttribute>(inherit: false).FirstOrDefault(),
})
.Where(x => x.Http is not null)
.Select(x => (x.Name, x.Http!.HttpMethods.First().ToUpperInvariant(), x.Authz))
.ToArray();
[Fact]
public void ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy()
{
var type = typeof(ContractSigningPlansController);
// ---- (0) Class-level: 2 tầng — tầng 1 CÓ Policy Read (cố ý, khác ApprovalWorkflowsV2).
var classAttr = GetClassLevelAuthorize(type);
classAttr.Should().NotBeNull("controller phải có [Authorize] class-level chặn anonymous");
classAttr!.Policy.Should().Be("KeHoachKyKet.Read",
"tầng 1 của authz 2 tầng: MỌI endpoint KHKK tối thiểu phải có quyền đọc menu này. " +
"Đây KHÔNG phải bẫy gotcha #44 (silent 403) vì module có menu key riêng — user được " +
"cấp KeHoachKyKet mới thấy màn hình này, không phải mọi Drafter như ApprovalWorkflowsV2.");
var actions = GetActionsWithVerb(type);
// ---- Neo chống VACUOUS: foreach trên mảng rỗng luôn xanh. 15 action đo từ đĩa
// 2026-07-29 (5 GET đọc + POST/PUT/DELETE phiếu + 3 dossier-item + 4 attachment).
actions.Should().HaveCountGreaterThanOrEqualTo(15,
"quét được {0} action — dưới 15 nghĩa là phép quét hỏng (đổi BindingFlags/attribute) " +
"chứ không phải controller teo lại; lúc đó mọi assert dưới đây thành vô nghĩa",
actions.Length);
// Verb GHI = mọi thứ không phải GET/HEAD. Không hardcode danh sách endpoint.
var writeVerbs = new[] { "POST", "PUT", "PATCH", "DELETE" };
foreach (var (name, verb, authz) in actions)
{
// ---- (a) MỌI action phải có attribute riêng (kể cả GET — luật của module này).
authz.Should().NotBeNull(
"action {0} ({1}) thiếu [Authorize(Policy = ...)] — nó sẽ tụt xuống class-level " +
"`KeHoachKyKet.Read`, nghĩa là ai ĐỌC được cũng GHI được (gotcha #82)", name, verb);
authz!.Policy.Should().NotBeNullOrEmpty(
"action {0} có [Authorize] TRẦN — `[Authorize]` không Policy = mọi user đăng nhập " +
"lọt qua tầng 2 (bài F-1b S155)", name);
var parts = authz.Policy!.Split('.');
parts.Should().HaveCount(2,
"policy \"{0}\" của {1} phải đúng dạng {{menuKey}}.{{action}}", authz.Policy, name);
// ---- (b) Tên policy phải là tên CÓ THẬT trong bộ sinh policy của Program.cs.
parts[0].Should().Be(MenuKeys.KeHoachKyKet,
"action {0} phải gate bằng menu key của CHÍNH module này — Δ W1 chốt prefix " +
"`KeHoachKyKet` (spec cũ ghi `ContractSigningPlans`, đã bỏ); gate nhầm key module " +
"khác = cấp quyền chéo", name);
MenuKeys.All.Should().Contain(parts[0],
"policy \"{0}\" của {1} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không " +
"sinh policy đó ⇒ 500 lúc chạy chứ không phải 403", authz.Policy, name);
MenuKeys.Actions.Should().Contain(parts[1],
"policy \"{0}\" của {1} dùng action không có trong MenuKeys.Actions", authz.Policy, name);
// ---- (c) Verb GHI KHÔNG được gate bằng Read (403 sẽ không bao giờ xảy ra).
if (writeVerbs.Contains(verb))
{
parts[1].Should().NotBe("Read",
"{0} là {1} (ghi) mà chỉ gate `Read` ⇒ user chỉ-xem VẪN ghi được vào phiếu", name, verb);
parts[1].Should().BeOneOf(["Create", "Update", "Delete"],
"verb ghi {0} phải gate Create/Update/Delete", name);
}
else
{
parts[1].Should().Be("Read",
"{0} là {1} (đọc) — gate mạnh hơn Read sẽ khóa nhầm người chỉ có quyền xem " +
"(403 silent, gotcha #44)", name, verb);
}
}
// ---- (d) Chốt 3 endpoint mấu chốt bằng TÊN — nếu ai đổi POST phiếu sang `Update`
// thì luật (c) ở trên vẫn xanh (Update cũng là verb ghi hợp lệ).
var byName = actions.ToDictionary(a => a.Name, a => a.Authz?.Policy);
byName[nameof(ContractSigningPlansController.Create)].Should().Be("KeHoachKyKet.Create",
"POST tạo phiếu = quyền Create");
byName[nameof(ContractSigningPlansController.UpdateDraft)].Should().Be("KeHoachKyKet.Update",
"PUT sửa nháp = quyền Update");
byName[nameof(ContractSigningPlansController.Delete)].Should().Be("KeHoachKyKet.Delete",
"DELETE phiếu = quyền Delete RIÊNG (không gộp Update) — xoá là hành vi khác sửa");
// Ghi vào phiếu (căn cứ b.8-9 / đính kèm) = Update chứ KHÔNG Create: "tạo" ở module
// này nghĩa là tạo PHIẾU. Chốt lại để không ai "sửa cho nhất quán" nhầm hướng.
byName[nameof(ContractSigningPlansController.CreateDossierItem)].Should().Be("KeHoachKyKet.Update",
"thêm căn cứ = ghi vào phiếu đã có ⇒ Update; gate Create sẽ chặn oan người được giao " +
"quyền sửa phiếu nhưng không được lập phiếu mới");
byName[nameof(ContractSigningPlansController.UploadAttachment)].Should().Be("KeHoachKyKet.Update",
"upload đính kèm = ghi vào phiếu đã có ⇒ Update");
}
}

View File

@ -0,0 +1,563 @@
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;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts; // ChangelogAction (enum dùng chung 3 module)
using SolutionErp.Domain.Identity; // AppRoles
using SolutionErp.Domain.Master;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [W2 KHKK — S161 2026-07-29] CRUD phiếu "Kế hoạch ký kết HĐ" (GĐ2) — test-AFTER
// (feature mới, `docs/rules.md §7`), chốt acceptance spec W2 §③-B.
//
// Code under test: `Application/ContractSigningPlans/ContractSigningPlanFeatures.cs`
// Create :292-400 (4 rào theo thứ tự + codegen + auto-sinh Lines)
// Delete :1098-1142 (owner ∥ Admin + allow-list phase + thứ tự Remove/Add)
//
// ── 2 quyết định hạ tầng, cả hai đều là ĐIỀU KIỆN để phép đo có nghĩa ──────────
//
// (1) Fixture RIÊNG có `AuditingInterceptor` (KHÔNG dùng SqliteDbFixture/IdentityFixture).
// `TestApplicationDbContext` mặc định KHÔNG wire interceptor ⇒ `Remove()` là xoá
// **CỨNG** (bài S155). Đo "xoá xong lập lại được" trên xoá-cứng là đo NHẦM cơ chế:
// rào (iii) của Create dựa vào GLOBAL QUERY FILTER che row `IsDeleted=1`
// (`:323-325`), chứ không phải row biến mất khỏi bảng. Không có interceptor thì
// test vẫn XANH kể cả khi filter bị gỡ — đúng kiểu vacuous.
// Không cần Identity stack: 2 handler dưới test chỉ nhận `db + currentUser (+ codeGen)`.
//
// (2) MỖI lệnh chạy trên MỘT DbContext MỚI (`NewDb()`), dùng chung 1 connection —
// mô phỏng scoped-per-request của prod. Nếu tái dùng 1 context cho cả seed lẫn
// lệnh, các con (Lines/Changelogs) đang được TRACK sẽ bị EF cascade client-side
// ngay tại `Remove()` — một đường đi KHÔNG hề xảy ra trong prod (ở đó context mới
// chỉ nạp mỗi header). Sai lệch này chính là thứ đẻ ra bug T26 S155, nên nó phải
// được mô phỏng cho ĐÚNG chứ không được làm cho tiện.
public class ContractSigningPlanCrudTests
{
private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);
// ===================================================================
// Fixture: SQLite in-memory + AuditingInterceptor (xoá mềm THẬT).
// ===================================================================
private sealed class KhkkFixture : IDisposable
{
private readonly SqliteConnection _conn;
public FixedDateTime Clock { get; } = new(Clock2026);
/// Người đang thao tác — đổi được giữa chừng để đóng vai user khác.
public TestCurrentUser Actor { get; } = new(Guid.NewGuid(), "BCH lập kế hoạch", "bch@test.local");
public KhkkFixture()
{
_conn = new SqliteConnection("DataSource=:memory:");
_conn.Open();
using var boot = NewDb();
boot.Database.EnsureCreated();
}
/// Context MỚI cho mỗi "request" (xem ghi chú (2) đầu file).
/// 🔴 `actor` phải là ĐÚNG cái `ICurrentUser` mà handler nhận: trong prod cả
/// interceptor lẫn handler đều resolve CÙNG một `ICurrentUser` scoped. Nếu để
/// interceptor dính cứng một người còn handler chạy bằng người khác thì
/// `DeletedBy`/`UpdatedBy` đo ra người sai — và tệ hơn, nó sai theo hướng
/// TRÔNG NHƯ ĐÚNG (vẫn có giá trị, vẫn là một Guid user thật).
public TestApplicationDbContext NewDb(ICurrentUser? actor = null)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(_conn)
.EnableSensitiveDataLogging()
.AddInterceptors(new AuditingInterceptor(actor ?? Actor, Clock))
.Options;
return new TestApplicationDbContext(options);
}
public void Dispose() => _conn.Dispose();
}
// ===================================================================
// Seed: 1 PE + N nhà cung cấp + M hạng mục báo giá + 1 quy trình duyệt.
// ===================================================================
private sealed record SeededPe(
Guid PeId,
Guid ProjectId,
Guid DepartmentId,
Guid PeDrafterId,
Guid WorkflowId,
List<Guid> MasterSupplierIds, // Id ở bảng Suppliers (master)
List<Guid> PeSupplierRowIds); // Id ở bảng PurchaseEvaluationSuppliers (ROW của phiếu)
/// <param name="quotes">quotes[hạng mục][NCC] = ThanhTien.</param>
/// <param name="selected">selected[hạng mục][NCC] = IsSelected; null ⇒ mặc định "quote của winner được chọn".</param>
private static async Task<SeededPe> SeedPeAsync(
KhkkFixture f,
int supplierCount,
int[] winnerIndexes,
decimal[][] quotes,
bool[][]? selected = null,
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DaDuyet,
ApprovalWorkflowApplicableType wfType = ApprovalWorkflowApplicableType.ContractSigningPlan)
{
await using var db = f.NewDb();
// Quy trình duyệt V2 — BẮT BUỘC là row thật: `ContractSigningPlan.ApprovalWorkflowId`
// là FK Restrict (`ContractSigningPlanConfiguration.cs:39-42`), Guid rác = SQLite lỗi 19.
// KHÔNG cần Step/Level (Level.ApproverUserId mới là FK sang Users) — W2 chưa duyệt.
var wf = new ApprovalWorkflow
{
Code = "QT-KHKK-V2",
Version = 1,
ApplicableType = wfType,
Name = "Quy trình duyệt kế hoạch ký kết",
IsActive = true,
IsUserSelectable = true,
};
db.ApprovalWorkflows.Add(wf);
var peId = Guid.NewGuid();
var masterIds = new List<Guid>();
var rowIds = new List<Guid>();
var peSuppliers = new List<PurchaseEvaluationSupplier>();
for (var i = 0; i < supplierCount; i++)
{
var master = new Supplier
{
Id = Guid.NewGuid(),
Code = $"NCC{i + 1}",
Name = $"Nhà cung cấp {i + 1}",
Type = SupplierType.NhaThauPhu,
};
db.Suppliers.Add(master);
var row = new PurchaseEvaluationSupplier
{
Id = Guid.NewGuid(), // ≠ master.Id — chính là cái bẫy w.Id vs w.SupplierId
PurchaseEvaluationId = peId,
SupplierId = master.Id,
Order = i,
IsWinner = winnerIndexes.Contains(i),
};
masterIds.Add(master.Id);
rowIds.Add(row.Id);
peSuppliers.Add(row);
}
// Người soạn PE (QS) CỐ Ý khác người lập kế hoạch (BCH) — dùng để chứng D2.
var peDrafterId = Guid.NewGuid();
var projectId = Guid.NewGuid();
var departmentId = Guid.NewGuid();
var pe = new PurchaseEvaluation
{
Id = peId,
MaPhieu = "PE-KHKK-001",
Type = PurchaseEvaluationType.DuyetNcc,
Phase = phase,
TenGoiThau = "Cung cấp và lắp dựng cốp pha",
ProjectId = projectId,
DepartmentId = departmentId,
DrafterUserId = peDrafterId,
ApprovalWorkflowId = wf.Id,
};
pe.Suppliers.AddRange(peSuppliers);
for (var d = 0; d < quotes.Length; d++)
{
var detail = new PurchaseEvaluationDetail
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = peId,
GroupCode = "A.I",
GroupName = "Cốp pha",
NoiDung = $"Hạng mục {d + 1}",
Order = d,
ThanhTienNganSach = 1_000_000_000m, // ngân sách CỐ Ý lệch hẳn báo giá
};
for (var s = 0; s < supplierCount; s++)
{
detail.Quotes.Add(new PurchaseEvaluationQuote
{
Id = Guid.NewGuid(),
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = peSuppliers[s].Id,
ThanhTien = quotes[d][s],
IsSelected = selected is not null ? selected[d][s] : winnerIndexes.Contains(s),
});
}
pe.Details.Add(detail);
}
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return new SeededPe(peId, projectId, departmentId, peDrafterId, wf.Id, masterIds, rowIds);
}
// ---- Chạy lệnh qua handler THẬT, mỗi lệnh 1 context mới ----
private static async Task<CreateContractSigningPlanResult> CreateAsync(
KhkkFixture f, Guid peId, Guid workflowId, string? ghiChu = null)
{
await using var db = f.NewDb();
var handler = new CreateContractSigningPlanCommandHandler(
db, f.Actor, new ContractSigningPlanCodeGenerator(db, f.Clock));
return await handler.Handle(
new CreateContractSigningPlanCommand(peId, workflowId, ghiChu), CancellationToken.None);
}
private static async Task DeleteAsync(KhkkFixture f, Guid planId, string? reason, TestCurrentUser actor)
{
await using var db = f.NewDb(actor); // interceptor + handler CÙNG một actor (như prod)
var handler = new DeleteContractSigningPlanCommandHandler(db, actor);
await handler.Handle(new DeleteContractSigningPlanCommand(planId, reason), CancellationToken.None);
}
// ===================================================================
// (1) Tên PIN theo spec §③-B — PE chưa duyệt xong thì KHÔNG lập được kế hoạch.
//
// Rào này là rào ĐẦU TIÊN (`:312-313`) nên nó cũng gác luôn 3 rào sau: sai ở đây
// là phiếu GĐ2 chốt giá trên một phiếu Duyệt NCC còn đang tranh luận.
// ===================================================================
[Fact]
public async Task CreateContractSigningPlan_FromNonApprovedPe_Throws()
{
using var f = new KhkkFixture();
// PE ChoDuyet — MỌI thứ khác đã hợp lệ (có winner, có báo giá được chọn, workflow
// đúng loại) ⇒ nếu ném thì chỉ có thể vì phase.
var seeded = await SeedPeAsync(f,
supplierCount: 2,
winnerIndexes: [0],
quotes: [[100m, 7m]],
phase: PurchaseEvaluationPhase.ChoDuyet);
var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
(await act.Should().ThrowAsync<ConflictException>(
"phiếu Duyệt NCC chưa ở trạng thái Đã duyệt thì chưa có giá chốt để lập kế hoạch"))
.WithMessage("*Đã duyệt*");
await using (var db = f.NewDb())
{
db.ContractSigningPlans.IgnoreQueryFilters().Count()
.Should().Be(0, "ném xong KHÔNG được để lại phiếu nửa vời");
// Rào phase đứng TRƯỚC codegen (`:312` vs `:339`) ⇒ không đốt số thứ tự.
// Nếu ai đổi thứ tự, mã KHKK sẽ có lỗ mà chẳng phiếu nào mang.
db.WorkflowAppCodeSequences.Any(s => s.Prefix == "KHKK/2026")
.Should().BeFalse("lỗi ở rào đầu thì chưa được cấp phát mã nào");
}
// ---- VẾ ĐỐI CHỨNG (bắt buộc): cùng bộ seed, ĐỔI ĐÚNG 1 BIẾN = phase → phải CHẠY.
// Thiếu vế này, test trên vẫn xanh trong cái thế giới mà seed hỏng sẵn (FK/required
// thiếu) — lúc đó nó đo lỗi của chính mình chứ không đo rào.
using var ok = new KhkkFixture();
var okSeed = await SeedPeAsync(ok,
supplierCount: 2,
winnerIndexes: [0],
quotes: [[100m, 7m]],
phase: PurchaseEvaluationPhase.DaDuyet);
var result = await CreateAsync(ok, okSeed.PeId, okSeed.WorkflowId);
result.LineCount.Should().Be(1, "cùng seed đó với PE Đã duyệt thì lập được kế hoạch bình thường");
}
// ===================================================================
// (2) Happy path — acceptance §③-B: N Lines = số winner, `PeReferenceAmount` =
// SUM báo giá ĐƯỢC CHỌN của ĐÚNG NCC đó, mã khớp `^KHKK/\d{4}/\d{3}$`.
//
// Seed cài sẵn 3 cái bẫy, mỗi cái bắt một lỗi khác nhau:
// • NCC thua (idx 2) có báo giá `IsSelected = true` và số TO → lọt = quên lọc winner;
// • winner 1 có 1 hạng mục `IsSelected = false` → cộng = quên lọc IsSelected;
// • Id ROW ≠ Id master supplier → dùng nhầm = sai NCC hoàn toàn
// (bẫy `w.Id` vs `w.SupplierId` mà lane BE tự khai ở sub-md §1).
// ===================================================================
[Fact]
public async Task CreateContractSigningPlan_HappyPath_LinesMatchWinners_AndPeReferenceAmountIsSelectedQuoteSum()
{
using var f = new KhkkFixture();
// 3 NCC: 0 + 1 trúng, 2 thua. 2 hạng mục.
// winner0: 100 + 40 = 140 (cả 2 được chọn)
// winner1: 200 + [60] = 200 (hạng mục 2 KHÔNG được chọn)
// loser2 : 999 + 888 (được chọn hết — mồi nhử)
var seeded = await SeedPeAsync(f,
supplierCount: 3,
winnerIndexes: [0, 1],
quotes:
[
[100m, 200m, 999m],
[40m, 60m, 888m],
],
selected:
[
[true, true, true],
[true, false, true],
]);
var result = await CreateAsync(f, seeded.PeId, seeded.WorkflowId, "Kế hoạch ký kết đợt 1");
// ---- Kết quả trả về (đúng thứ FE đọc để điều hướng sau khi POST).
result.LineCount.Should().Be(2, "2 NCC trúng thầu ⇒ 2 dòng giá");
result.MaKeHoach.Should().MatchRegex(@"^KHKK/\d{4}/\d{3}$", "acceptance §③-B");
result.MaKeHoach.Should().Be("KHKK/2026/001",
"regex một mình vẫn xanh khi năm bị hardcode hoặc seq đứng yên — chốt luôn giá trị tuyệt đối");
await using var db = f.NewDb();
var plan = await db.ContractSigningPlans.AsNoTracking()
.Include(p => p.Lines)
.SingleAsync(p => p.Id == result.Id);
plan.Phase.Should().Be(ContractSigningPlanPhase.DangSoanThao, "lập xong là NHÁP, chưa trình");
plan.MaKeHoach.Should().Be(result.MaKeHoach, "mã trong DB phải khớp mã trả về");
plan.ApprovalWorkflowId.Should().Be(seeded.WorkflowId, "quy trình pin ngay lúc tạo");
plan.GhiChu.Should().Be("Kế hoạch ký kết đợt 1");
// ---- Denorm từ PE.
plan.PurchaseEvaluationId.Should().Be(seeded.PeId);
plan.ProjectId.Should().Be(seeded.ProjectId, "dự án lấy từ phiếu nguồn");
plan.DepartmentId.Should().Be(seeded.DepartmentId, "phòng lấy từ phiếu nguồn");
// ---- D2: người soạn kế hoạch = NGƯỜI ĐANG TẠO, không phải người soạn PE.
// 2 vế: bằng người tạo ∧ KHÁC người soạn PE. Chỉ có vế đầu thì trong thế giới
// "copy pe.DrafterUserId" test vẫn có thể xanh nếu hai người trùng nhau.
plan.DrafterUserId.Should().Be(f.Actor.UserId!.Value, "BCH lập kế hoạch là chủ phiếu GĐ2");
plan.DrafterUserId.Should().NotBe(seeded.PeDrafterId,
"người soạn phiếu Duyệt NCC (QS) là vai KHÁC — copy sang là trao nhầm quyền sửa/xoá");
// ---- Lines: đúng 2 NCC trúng, trỏ Id MASTER (không phải Id row của phiếu PE).
var lines = plan.Lines.OrderBy(l => l.PeReferenceAmount).ToList();
lines.Select(l => l.SupplierId).Should().BeEquivalentTo(
new[] { seeded.MasterSupplierIds[0], seeded.MasterSupplierIds[1] },
"dòng giá phải trỏ NCC ở danh mục master");
lines.Select(l => l.SupplierId).Should().NotContain(seeded.PeSupplierRowIds[0],
"SupplierId là `w.SupplierId` (master) chứ KHÔNG phải `w.Id` (row của phiếu PE) — "
+ "nhầm 2 cái này thì dòng giá trỏ vào một NCC không tồn tại, tên hiện rỗng ở FE");
lines.Should().NotContain(l => l.SupplierId == seeded.MasterSupplierIds[2],
"NCC thua thầu KHÔNG được có dòng giá dù báo giá của nó vẫn IsSelected");
// ---- Số tiền chốt: từng đồng, không dùng biểu thức lặp lại logic sản xuất.
var bySupplier = plan.Lines.ToDictionary(l => l.SupplierId);
bySupplier[seeded.MasterSupplierIds[0]].PeReferenceAmount.Should().Be(140m,
"winner0 = 100 + 40 (cả 2 hạng mục được chọn)");
bySupplier[seeded.MasterSupplierIds[1]].PeReferenceAmount.Should().Be(200m,
"winner1 = 200; hạng mục 60 KHÔNG được chọn nên KHÔNG cộng — nếu ra 260 là quên lọc IsSelected");
// Ngân sách hạng mục là 1 tỷ/dòng: nếu ai lỡ lấy ThanhTienNganSach thì con số sẽ
// nhảy sang hàng tỷ, không thể trùng 140/200 do ngẫu nhiên.
plan.Lines.Sum(l => l.PeReferenceAmount).Should().Be(340m);
// ---- D4: mồi ProposedAmount = số tham chiếu (W2 chưa có đường sửa dòng giá).
plan.Lines.Should().OnlyContain(l => l.ProposedAmount == l.PeReferenceAmount,
"đề xuất mồi bằng giá đã duyệt ở PE — để 0 thì cả phiếu là 0 mà W2 không ai sửa được");
plan.Lines.Should().OnlyContain(l => l.ApprovedAmount == null, "giá chốt chỉ có ở W3");
// ---- Vết audit: 1 dòng nhật ký Insert, có trỏ ngược về phiếu nguồn.
var logs = await db.ContractSigningPlanChangelogs.AsNoTracking()
.Where(c => c.ContractSigningPlanId == plan.Id).ToListAsync();
logs.Should().ContainSingle();
logs[0].Action.Should().Be(ChangelogAction.Insert);
logs[0].EntityType.Should().Be(ContractSigningPlanEntityType.Header);
logs[0].PhaseAtChange.Should().Be(ContractSigningPlanPhase.DangSoanThao);
logs[0].UserId.Should().Be(f.Actor.UserId);
logs[0].ContextNote.Should().Contain(seeded.PeId.ToString(),
"nhật ký phải truy ngược được về phiếu Duyệt NCC nguồn");
}
// ===================================================================
// (3) 1 phiếu PE = 1 kế hoạch SỐNG (`:323-328`) — acceptance "tạo lần 2 → 409".
// ===================================================================
[Fact]
public async Task CreateContractSigningPlan_SecondTimeForSamePe_Throws_AndDoesNotBurnCode()
{
using var f = new KhkkFixture();
var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
var first = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
first.MaKeHoach.Should().Be("KHKK/2026/001");
var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
await act.Should().ThrowAsync<ConflictException>(
"1 phiếu Duyệt NCC chỉ được có 1 kế hoạch ký kết đang sống");
await using var db = f.NewDb();
db.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId)
.Should().Be(1, "lần 2 không được đẻ thêm phiếu");
// Rào (iii) đứng TRƯỚC codegen ⇒ lần thất bại không nuốt mất số 002.
db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq
.Should().Be(1, "lần tạo hỏng KHÔNG được tiêu một số thứ tự (mã sẽ thủng lỗ chỗ)");
}
// ===================================================================
// (4) Rào workflow-type — TRỌN 2 nhánh (`:197-200`).
//
// Đây là chỗ dễ "copy nửa guard" nhất: bản gốc Proposal có 2 nhánh, chép thiếu nhánh
// NotFound thì một Guid quy trình rác đi thẳng vào phiếu, và chỉ vỡ ở W3 lúc duyệt.
// ===================================================================
[Fact]
public async Task CreateContractSigningPlan_WorkflowGuard_WrongType_Conflict_And_Unknown_NotFound()
{
// (a) Quy trình CÓ THẬT nhưng thuộc module khác (Duyệt NCC) → Conflict.
using var wrong = new KhkkFixture();
var wrongSeed = await SeedPeAsync(wrong,
supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]],
wfType: ApprovalWorkflowApplicableType.DuyetNcc);
var actWrong = async () => await CreateAsync(wrong, wrongSeed.PeId, wrongSeed.WorkflowId);
(await actWrong.Should().ThrowAsync<ConflictException>(
"quy trình của module khác không được pin vào phiếu KHKK"))
.WithMessage("*Kế hoạch ký kết*");
// (b) Quy trình KHÔNG tồn tại → NotFound (KHÔNG được im lặng cho qua).
using var missing = new KhkkFixture();
var okSeed = await SeedPeAsync(missing, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
var actMissing = async () => await CreateAsync(missing, okSeed.PeId, Guid.NewGuid());
await actMissing.Should().ThrowAsync<NotFoundException>(
"Guid quy trình rác phải chặn NGAY — lọt vào phiếu thì tới W3 mới vỡ, lúc đó phiếu đã có mã");
await using var db = missing.NewDb();
db.ContractSigningPlans.IgnoreQueryFilters().Count().Should().Be(0);
}
// ===================================================================
// (5) Chưa chọn NCC trúng thầu (`:317-318`) — phiếu 0 dòng giá là phiếu vô nghĩa.
// ===================================================================
[Fact]
public async Task CreateContractSigningPlan_NoWinner_Throws()
{
using var f = new KhkkFixture();
var seeded = await SeedPeAsync(f,
supplierCount: 3,
winnerIndexes: [], // PE đã duyệt nhưng chưa tick NCC nào trúng
quotes: [[100m, 200m, 300m]],
selected: [[true, true, true]]); // có báo giá được chọn — vẫn KHÔNG đủ
var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
(await act.Should().ThrowAsync<ConflictException>(
"không có NCC trúng thầu thì không có dòng giá nào để chốt"))
.WithMessage("*trúng thầu*");
await using var db = f.NewDb();
db.ContractSigningPlans.IgnoreQueryFilters().Count()
.Should().Be(0, "KHÔNG được tạo phiếu rỗng 0 dòng");
}
// ===================================================================
// (6) Acceptance §③-B: "DELETE ở Nháp → 204; tạo lại cho CÙNG phiếu PE → 201".
//
// Đo 3 điều CÙNG LÚC, vì chỉ đo "tạo lại được" là đo yếu nhất trong ba:
// (a) xoá là xoá MỀM (row còn, `IsDeleted=1`) — nếu là xoá cứng thì màn "Đã xoá" rỗng;
// (b) rào "1 PE = 1 kế hoạch sống" thoát được nhờ GLOBAL QUERY FILTER, không phải
// nhờ row biến mất ⇒ đây mới là cửa thoát khoá 409 vĩnh viễn (M6);
// (c) 🔴 dòng nhật ký "xoá" PHẢI tồn tại — bug T26 (S155) là: `Add(changelog)` đặt
// TRƯỚC `Remove(cha)` thì EF cascade client-side tại `Remove()` DETACH luôn
// dòng đang `Added` ⇒ SaveChanges ghi 0 row, IM LẶNG, không lỗi. Bản W2 đã đảo
// đúng thứ tự (`:1126-1128`); vế (c) khoá lại để không ai "dọn cho gọn" ngược về.
// ===================================================================
[Fact]
public async Task DeleteContractSigningPlan_SoftDeletes_KeepsChangelog_ThenCreateAgainSucceeds()
{
using var f = new KhkkFixture();
var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
var first = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
await DeleteAsync(f, first.Id, "Lập nhầm phiếu nguồn", f.Actor);
await using (var db = f.NewDb())
{
// (a) xoá MỀM: khuất khỏi mọi query thường, nhưng row vẫn nằm đó.
db.ContractSigningPlans.Count(p => p.Id == first.Id)
.Should().Be(0, "phiếu đã xoá không được hiện ở danh sách");
var raw = db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
.Single(p => p.Id == first.Id);
raw.IsDeleted.Should().BeTrue("`Remove()` + AuditingInterceptor = xoá MỀM, không xoá cứng");
raw.DeletedAt.Should().NotBeNull();
raw.DeletedBy.Should().Be(f.Actor.UserId);
raw.MaKeHoach.Should().Be("KHKK/2026/001", "mã cũ vẫn giữ trong row đã xoá");
// (c) vết xoá phải SỐNG SÓT qua lượt cascade — regression bug T26 S155.
var logs = db.ContractSigningPlanChangelogs.AsNoTracking()
.Where(c => c.ContractSigningPlanId == first.Id).ToList();
logs.Should().HaveCount(2, "1 dòng lúc lập + 1 dòng lúc xoá");
var del = logs.Single(l => l.Action == ChangelogAction.Delete);
del.ContextNote.Should().Be("Lập nhầm phiếu nguồn", "lý do xoá nằm ở nhật ký");
del.PhaseAtChange.Should().Be(ContractSigningPlanPhase.DangSoanThao);
del.UserId.Should().Be(f.Actor.UserId);
}
// (b) Cửa thoát: lập LẠI cho CHÍNH phiếu PE đó.
var second = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
second.Id.Should().NotBe(first.Id);
second.MaKeHoach.Should().Be("KHKK/2026/002", "phiếu mới lấy số tiếp theo, không tái dùng mã cũ");
second.LineCount.Should().Be(1);
await using var check = f.NewDb();
check.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId)
.Should().Be(1, "chỉ 1 kế hoạch SỐNG cho phiếu PE này");
check.ContractSigningPlans.IgnoreQueryFilters().Count(p => p.PurchaseEvaluationId == seeded.PeId)
.Should().Be(2, "cộng cả phiếu đã xoá mềm là 2 row vật lý");
}
// ===================================================================
// (7) Rào xoá: chủ phiếu ∥ Admin, và CHỈ ở {Nháp, Từ chối} (`:1107-1118`).
// Phiếu đang duyệt phải Trả lại/Từ chối trước — xoá giữa chừng là mất lượt
// của người đang chờ duyệt (đường xoá-khi-đang-duyệt là việc của W3).
// ===================================================================
[Fact]
public async Task DeleteContractSigningPlan_GuardedByOwnerAndPhase()
{
using var f = new KhkkFixture();
var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
var created = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
// (a) người lạ (không phải chủ phiếu, không Admin) → Forbidden.
var stranger = new TestCurrentUser(Guid.NewGuid(), "Người lạ", "la@test.local");
var actStranger = async () => await DeleteAsync(f, created.Id, null, stranger);
await actStranger.Should().ThrowAsync<ForbiddenException>(
"chỉ người soạn kế hoạch hoặc Admin được xoá");
await using (var db = f.NewDb())
{
db.ContractSigningPlans.Count(p => p.Id == created.Id)
.Should().Be(1, "chặn xong phiếu phải còn NGUYÊN (ném rồi mà vẫn ghi = tệ hơn không rào)");
// (b) đẩy phiếu sang Đã gửi duyệt (W2 chưa có transition ⇒ set thẳng DB).
var plan = db.ContractSigningPlans.Single(p => p.Id == created.Id);
plan.Phase = ContractSigningPlanPhase.ChoDuyet;
db.SaveChanges();
}
var actPhase = async () => await DeleteAsync(f, created.Id, null, f.Actor);
(await actPhase.Should().ThrowAsync<ConflictException>(
"phiếu đang chờ duyệt thì chủ phiếu cũng không được rút êm"))
.WithMessage("*Nháp*");
// (c) VẾ THUẬN: Admin không-phải-chủ-phiếu + đưa phiếu về Từ chối (nằm trong
// allow-list) → xoá ĐƯỢC. Không có vế này thì 2 vế trên vẫn xanh trong thế
// giới mà handler ném với MỌI đầu vào.
await using (var db = f.NewDb())
{
var plan = db.ContractSigningPlans.Single(p => p.Id == created.Id);
plan.Phase = ContractSigningPlanPhase.TuChoi;
db.SaveChanges();
}
var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", "admin@test.local", AppRoles.Admin);
await DeleteAsync(f, created.Id, "Dọn phiếu bị từ chối", admin);
await using var final = f.NewDb();
final.ContractSigningPlans.Count(p => p.Id == created.Id).Should().Be(0);
final.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
.Single(p => p.Id == created.Id).DeletedBy
.Should().Be(admin.UserId, "người xoá thật được ghi lại là Admin, không phải chủ phiếu");
}
}