[CLAUDE] Domain+App+Infra+Admin: W1 KHKK — Mig 69 ContractSigningPlans 7 bang + policy KeHoachKyKet + seeder UPGRADE-if-exists + CodeGen KHKK + Designer type-10
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m40s

- Domain/ContractSigningPlans: 7 entity + 3 enum (Phase 1/2/3/98/99; DossierItem Kind/Status start=1); ApplicableType += ContractSigningPlan=10 (slot 10, append-only)
- Mig 69 AddContractSigningPlans: 7 CreateTable / 20 CreateIndex / 0 ALTER; UNIQUE filtered (PlanId,SupplierId) WHERE IsDeleted=0 (#57); FK 6 con Cascade 1-hop + 2 Restrict, 0 multiple-cascade-path; database-agent review PASS 8/8 + DB11
- MenuKeys.All += KeHoachKyKet (policy KeHoachKyKet.* x4 sinh runtime; menu-row prod KHONG doi — 142 giu); STATUS canonical 54->55 / 216->220
- DbInitializer.SeedKeHoachKyKetAccessAsync: UPGRADE-if-exists (F-S1 — khong insert-NO-OP) + vế CanDelete (Drafter/Admin), 13/13 role CanCreate+CanUpdate, chay SAU revoke; revoker khong phu Khkk_* (reviewer Truc-1 PASS)
- ContractSigningPlanCodeGenerator: KHKK/{YYYY}/{Seq:D3} (owner OK @S161) qua WorkflowAppCodeSequences dung chung, SERIALIZABLE
- AWV2AdminFeatures: 3 site count/retained/orphan + nhanh ContractSigningPlanLevelOpinions (chong no FK 500 khi xoa Level); +label VN type-10 (F-7); +ep false 2 co early-finalize server-side cho type-10 (F-8 — containment tang BE, FE da ep o levelFlagsPayload)
- fe-admin Designer: deep-link type-10 + an 2 checkbox early-finalize (anh phan "PHA VO" S155) — containment 2 lop render+payload
- tests: +4 (566 total, 0 fail) — schema-invariant 7 bang + codegen format (fault-inject 2 chieu chung co rang)

Reviewer: PASS_WITH_FLAGS 10 issue -> 8 fixed cung commit, 2 giu trang thai (F-1 acceptance đo-CO cho prod; F-4 ke thua y PE prod)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 20:04:39 +07:00
parent 909134c922
commit a8027c04fe
30 changed files with 8987 additions and 22 deletions

View File

@ -0,0 +1,284 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Infrastructure.Services;
namespace SolutionErp.Infrastructure.Tests.Common;
// [W1 KHKK — S161 2026-07-29] Bẫy bảng cho module "Kế hoạch ký kết HĐ" (Mig 69,
// 7 CreateTable). Test-AFTER (feature mới, `docs/rules.md §7`) + phép đo A6 cho
// format mã phiếu mà owner chốt @S161.
//
// ⚠️ SPEC-DRIFT ĐÃ KHAI (test theo CODE, luật S34 — code là single source of truth):
// Đề bài giao việc mô tả nhóm AuditableEntity là "header/Line/DossierItem/
// LevelOpinion/Attachment ... có IsDeleted" ⇒ ngụ ý CẢ 5 đều phải có query filter.
// ĐĨA nói khác: `ContractSigningPlanLevelOpinion` LÀ AuditableEntity NHƯNG
// **CỐ Ý KHÔNG HasQueryFilter**, lý do viết sẵn 2 chỗ:
// - `Domain/ContractSigningPlans/ContractSigningPlanLevelOpinion.cs:20-23`
// - `Persistence/Configurations/ContractSigningPlanLevelOpinionConfiguration.cs:12-16`
// "site dọn chữ ký MỒ CÔI (ApprovalWorkflowV2AdminFeatures ExecuteDeleteAsync) phải
// THẤY được row đã soft-delete, nếu bị filter che thì row mồ côi sống sót và FK
// Restrict lại nổ 500 lúc admin xoá Cấp."
// ⇒ Test KHÔNG hạ chuẩn: assert LevelOpinion **KHÔNG có filter** như invariant CÓ
// CHỦ Ý. Ai thêm filter về sau ⇒ test ĐỎ ⇒ buộc đọc lại lý do, không lỡ tay.
//
// Vì sao đọc metadata EF model chứ không đọc file config: query filter + cột audit là
// thứ RUNTIME dùng; đọc model = đo đúng cái chạy thật (config quên `AddConfiguration`
// hoặc quên DbSet vẫn "trông đúng" nếu chỉ đọc file).
public class ContractSigningPlanSchemaTests
{
private const string ModuleNamespace = "SolutionErp.Domain.ContractSigningPlans";
// Bộ kỳ vọng ĐO TỪ ĐĨA 2026-07-29 (entity + configuration), KHÔNG suy từ spec.
private sealed record TableSpec(
Type Clr, string Table, bool SoftDelete, bool QueryFilter, string Why);
private static readonly TableSpec[] Expected =
[
new(typeof(ContractSigningPlan), "ContractSigningPlans", true, true,
"header AuditableEntity — HasQueryFilter(!IsDeleted) `ContractSigningPlanConfiguration.cs:47`"),
new(typeof(ContractSigningPlanLine), "ContractSigningPlanLines", true, true,
"dòng giá per-NCC AuditableEntity — filter `ContractSigningPlanLineConfiguration.cs:36`"),
new(typeof(ContractSigningPlanDossierItem), "ContractSigningPlanDossierItems", true, true,
"căn cứ AuditableEntity — filter `ContractSigningPlanDossierItemConfiguration.cs:29`"),
new(typeof(ContractSigningPlanAttachment), "ContractSigningPlanAttachments", true, true,
"file đính kèm AuditableEntity — filter `ContractSigningPlanAttachmentConfiguration.cs:39`"),
new(typeof(ContractSigningPlanLevelOpinion), "ContractSigningPlanLevelOpinions", true, false,
"AuditableEntity NHƯNG CỐ Ý KHÔNG filter — site dọn chữ ký MỒ CÔI phải THẤY row "
+ "soft-deleted, che đi thì FK Restrict nổ 500 lúc admin xoá Cấp "
+ "(ContractSigningPlanLevelOpinion.cs:20-23). Thêm filter = ĐỎ CÓ CHỦ Ý."),
new(typeof(ContractSigningPlanChangelog), "ContractSigningPlanChangelogs", false, false,
"BaseEntity append-only audit — không có IsDeleted nên KHÔNG filter (parity PE/Contract)"),
new(typeof(ContractSigningPlanApproval), "ContractSigningPlanApprovals", false, false,
"BaseEntity append-only lịch sử transition — không có IsDeleted nên KHÔNG filter"),
];
// ===================================================================
// (1) Tên PIN theo spec §③-B: bẫy bảng — audit fields + query filter.
// ===================================================================
[Fact]
public void BayBang_MoiBang_CoAuditFields_VaQueryFilter()
{
using var fix = new SqliteDbFixture();
var mapped = fix.Db.Model.GetEntityTypes()
.Where(t => t.ClrType.Namespace == ModuleNamespace)
.ToDictionary(t => t.ClrType.Name);
// (0) Chốt BỘ bảng: bảng thứ 8 thêm vào module mà không khai ở đây ⇒ ĐỎ.
// Cũng bắt luôn ca "viết Configuration nhưng quên DbSet / quên đăng ký"
// (entity không vào model ⇒ thiếu key ⇒ ĐỎ).
mapped.Keys.Should().BeEquivalentTo(
Expected.Select(e => e.Clr.Name),
"module KHKK (Mig 69) = ĐÚNG 7 bảng; thêm/bớt bảng phải cập nhật invariant này");
foreach (var spec in Expected)
{
var et = mapped[spec.Clr.Name];
et.GetTableName().Should().Be(spec.Table,
"tên bảng vật lý là hợp đồng với migration + SQL tay của ops ({0})", spec.Clr.Name);
// (a) 4 cột audit BaseEntity — MỌI bảng, không ngoại lệ.
foreach (var col in new[] { "CreatedAt", "UpdatedAt", "CreatedBy", "UpdatedBy" })
{
et.FindProperty(col).Should().NotBeNull(
"{0} phải map cột audit {1} (BaseEntity)", spec.Table, col);
}
// (b) bộ 3 soft-delete — CHỈ bảng AuditableEntity, và bảng BaseEntity
// phải KHÔNG có (assert 2 chiều: thiếu-thì-đỏ ∧ thừa-thì-cũng-đỏ).
foreach (var col in new[] { "IsDeleted", "DeletedAt", "DeletedBy" })
{
if (spec.SoftDelete)
{
et.FindProperty(col).Should().NotBeNull(
"{0} là AuditableEntity nên phải map {1}", spec.Table, col);
}
else
{
et.FindProperty(col).Should().BeNull(
"{0} là BaseEntity (append-only) — có {1} là sai thiết kế", spec.Table, col);
}
}
// (c) query filter — đo trên MODEL đang chạy, không đọc file config.
var hasFilter = ((IReadOnlyEntityType)et).GetDeclaredQueryFilters().Any();
hasFilter.Should().Be(spec.QueryFilter, spec.Why);
}
// (d) Neo chống vacuous: bảng CÓ filter thì filter phải THẬT SỰ chặn row
// soft-deleted (metadata "có filter" mà biểu thức sai vẫn đọc là xanh).
var plan = new ContractSigningPlan
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = Guid.NewGuid(),
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
IsDeleted = true,
};
fix.Db.ContractSigningPlans.Add(plan);
fix.Db.SaveChanges();
fix.Db.ContractSigningPlans.AsNoTracking().Count(x => x.Id == plan.Id)
.Should().Be(0, "query filter phải GIẤU phiếu đã xoá mềm");
fix.Db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking().Count(x => x.Id == plan.Id)
.Should().Be(1, "row vẫn nằm trong DB — chỉ bị filter che, KHÔNG bị xoá cứng");
}
// ===================================================================
// (2) Tên PIN theo spec §③-B (phép đo A6 — owner chốt format @S161):
// KHKK/{YYYY}/{Seq:D3}.
// Chạy trên SQLite dù generator mở transaction IsolationLevel.Serializable —
// tiền lệ `SqliteDbFixture.cs:36-42` + `PurchaseEvaluationCodeGeneratorTests.cs:17`
// (provider map enum gracefully). KHÔNG đụng production code.
// ===================================================================
[Fact]
public async Task SigningPlanCodeGenerator_Format_KhkkYearSeqD3()
{
using var fix = new SqliteDbFixture();
var clock = new FixedDateTime(new DateTime(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc));
var gen = new ContractSigningPlanCodeGenerator(fix.Db, clock);
var code1 = await gen.GenerateAsync();
var code2 = await gen.GenerateAsync();
// Hình dạng theo acceptance spec §③-B.
code1.Should().MatchRegex(@"^KHKK/\d{4}/\d{3}$");
code2.Should().MatchRegex(@"^KHKK/\d{4}/\d{3}$");
// Giá trị TUYỆT ĐỐI — regex một mình vẫn xanh nếu seq đứng yên ở 001.
code1.Should().Be("KHKK/2026/001");
code2.Should().Be("KHKK/2026/002");
// Seq lần 2 = lần 1 + 1, đo bằng SỐ (không chỉ so chuỗi).
static int Seq(string code) => int.Parse(code.Split('/')[2]);
Seq(code2).Should().Be(Seq(code1) + 1, "mỗi lần gen phải tiến đúng 1 nấc");
// Sequence sống ở bảng DÙNG CHUNG WorkflowAppCodeSequences (spec §②-9),
// KHÔNG bảng riêng — prefix per-năm là PK.
var row = fix.Db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026");
row.LastSeq.Should().Be(2, "2 lần gen ⇒ LastSeq = 2 trên đúng prefix 'KHKK/2026'");
// {YYYY} phải LIVE theo đồng hồ, không hardcode — `\d{4}` không phân biệt được
// hai thứ đó, nên đẩy đồng hồ sang năm sau và đòi seq reset.
clock.UtcNow = new DateTime(2027, 1, 4, 3, 0, 0, DateTimeKind.Utc);
var code2027 = await gen.GenerateAsync();
code2027.Should().Be("KHKK/2027/001", "sang năm ⇒ prefix mới ⇒ seq reset về 001");
fix.Db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq
.Should().Be(2, "hàng 2026 KHÔNG bị đụng khi sang năm");
}
// ===================================================================
// (3) gotcha #57 (7× tái phát: Mig 43/45/47/64) — UNIQUE (PlanId, SupplierId)
// PHẢI filtered `WHERE IsDeleted = 0`. Không filter ⇒ xoá mềm 1 NCC rồi
// thêm lại chính NCC đó sẽ nổ UNIQUE (SQLite error 19).
// Rủi ro này spec liệt ở §③-C.
// ===================================================================
[Fact]
public async Task Line_UniqueFilteredTheoIsDeleted_ChoXoaMemRoiThemLaiCungNcc()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var plan = new ContractSigningPlan
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = Guid.NewGuid(),
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
};
db.ContractSigningPlans.Add(plan);
var supplierId = Guid.NewGuid();
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
{
Id = Guid.NewGuid(),
ContractSigningPlanId = plan.Id,
SupplierId = supplierId,
PeReferenceAmount = 100_000_000m,
ProposedAmount = 95_000_000m,
});
await db.SaveChangesAsync();
// Xoá MỀM dòng NCC đó (app-layer set cờ — TestApplicationDbContext KHÔNG wire
// AuditingInterceptor nên `Remove()` sẽ là xoá CỨNG, không đo được điều cần đo).
var line1 = await db.ContractSigningPlanLines.SingleAsync(x => x.SupplierId == supplierId);
line1.IsDeleted = true;
line1.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// Thêm LẠI đúng NCC đó vào đúng phiếu đó — đây là ca thật (BCH bấm nhầm, xoá,
// thêm lại). Index không filter ⇒ ném DbUpdateException tại đây.
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
{
Id = Guid.NewGuid(),
ContractSigningPlanId = plan.Id,
SupplierId = supplierId,
PeReferenceAmount = 100_000_000m,
ProposedAmount = 90_000_000m,
});
var act = async () => await db.SaveChangesAsync();
await act.Should().NotThrowAsync(
"UNIQUE (PlanId, SupplierId) phải là filtered `WHERE [IsDeleted] = 0` — gotcha #57");
db.ContractSigningPlanLines.AsNoTracking()
.Count(x => x.ContractSigningPlanId == plan.Id)
.Should().Be(1, "chỉ 1 dòng NCC còn sống sau khi xoá mềm + thêm lại");
db.ContractSigningPlanLines.IgnoreQueryFilters().AsNoTracking()
.Count(x => x.ContractSigningPlanId == plan.Id)
.Should().Be(2, "row cũ vẫn nằm đó (xoá mềm), tổng 2 row vật lý");
// 🔴 VẾ ĐỐI CHỨNG — bắt buộc, nếu không cả test trên là VACUOUS:
// "không ném" cũng đúng trong thế giới mà UNIQUE index KHÔNG HỀ ĐƯỢC TẠO
// (EnsureCreated nuốt filter lạ / index rơi mất). Vế này chứng index CÓ RĂNG:
// 2 dòng CÙNG (PlanId, SupplierId) mà cả 2 còn SỐNG ⇒ PHẢI nổ.
var plan2 = new ContractSigningPlan
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = Guid.NewGuid(),
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
};
db.ContractSigningPlans.Add(plan2);
var dupSupplierId = Guid.NewGuid();
foreach (var _ in new[] { 1, 2 })
{
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
{
Id = Guid.NewGuid(),
ContractSigningPlanId = plan2.Id,
SupplierId = dupSupplierId,
PeReferenceAmount = 1m,
ProposedAmount = 1m,
});
}
var actDup = async () => await db.SaveChangesAsync();
await actDup.Should().ThrowAsync<DbUpdateException>(
"2 dòng SỐNG cùng (PlanId, SupplierId) phải bị UNIQUE chặn — vế này chứng "
+ "index thật sự tồn tại, nên vế 'xoá mềm rồi thêm lại KHÔNG nổ' ở trên "
+ "là do FILTER chứ không phải do thiếu index");
}
// ===================================================================
// (4) Slot enum PIN = 10. `ApplicableType` lưu bằng int (HasConversion<int>,
// `ApprovalWorkflowConfiguration.cs:17`) ⇒ đánh số lại = quy trình đã pin
// trong DB trỏ sang module KHÁC, hỏng im lặng. Đây là hằng số ĐÃ GHI XUỐNG DB.
// ===================================================================
[Fact]
public void ApplicableType_ContractSigningPlan_PinSlot10_KhongTrungSlot()
{
((int)ApprovalWorkflowApplicableType.ContractSigningPlan).Should().Be(10,
"spec W1 §②-3 pin slot 10; số này ĐÃ nằm trong DB (HasConversion<int>) nên "
+ "đổi = phiếu/quy trình cũ trỏ sai module");
Enum.GetValues<ApprovalWorkflowApplicableType>()
.Count(v => (int)v == 10)
.Should().Be(1, "không được có 2 module cùng chiếm slot 10 (append-only enum)");
}
}