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