[CLAUDE] Contract: tách HĐ Thầu phụ thành 01A/01B + thân document-view + pre-fill + khoá bản cứng (YC-032)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 2m41s
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 2m41s
Owner YC-032/033/034/036 — 4 vế:
1. TÁCH LOẠI: +2 ContractType (HopDongThauPhuVatTu=8 / HopDongThauPhuThietBi=9),
họ HD-TP-VT / HD-TP-TB, menu 01A./01B. (giữ 02.-07. nguyên, loại cũ =1 legacy).
Bịt 5 nhánh câm nuốt loại mới: WorkflowPolicy (mất trạm CCM), ContractCodeGenerator
(sai mã RG-001, không hồi tố được), ContractDetailsFeatures x2 (bundle rỗng HTTP 200),
SeedWorkflowDefinitions (Designer V1 rỗng). ContractDetailsHelpers.ThauPhuFamily —
3 loại chung bảng ThauPhuDetails ⇒ 0 migration cho vế này.
2. THÂN DOCUMENT-VIEW: ContractBodyView.tsx + lib/contractBody/ (002.02 = 36 ô/2 bảng/5 điều ·
002.03 = 46 ô/6 bảng/6 điều · PHẦN 2 dùng chung 5 điều read-only gắn CUỐI theo YC-034).
Lưu vào Contract.DraftData lược đồ {schemaVersion,kind,body,paymentTerms}.
Gỡ draftData:null hardcode 4/4 site — trước đây mỗi lần Lưu là xoá trắng thân HĐ.
3. PRE-FILL: cầu KHKK đổ 17 ô (Bên B · chủ đầu tư · gói thầu · số HĐ · giá trị) — 17/17 khớp
slot.key. Địa điểm lấy pe.DiaDiem (0% rỗng) thay Project.Location (93% rỗng). Vá regression
cầu KHKK đánh rơi PaymentTerms + BypassProcurementAndCCM so với cầu PE cũ.
4. KHOÁ BẢN CỨNG MỘT CHIỀU (YC-033 "giấy trắng mực đen"): Mig AddContractSealedAt
(+SealedAt +SealedByUserId), set ??= (upload lần 2 không đè mốc đầu), rào 2 cửa —
xoá đính kèm bản cứng ⇒ 409, sửa HĐ ⇒ chặn. Test-before 2 ca.
+ print CSS in trình ký (ô điền in ra thành đường kẻ, không phải khung input).
Template: 002.02/002.03 convert .doc→.docx vào wwwroot + trỏ đúng 2 loại mới (trước cùng trỏ
loại cũ ⇒ dropdown Mẫu sẽ rỗng) + reconcile IsActive giới hạn 2 FormCode (bản đầu quét toàn bộ
sẽ hồi sinh mẫu admin đã xoá mỗi restart — database-reviewer-style bắt).
Đo: build 0 error · test 697→699 PASS (+2 seal) · has-pending-model-changes = No changes ·
tsc --noEmit exit 0 x2 app · mọi cặp file 2-app sha256 trùng · Menu keys 120→122 · Policies 480→488.
NỢ ĐÃ-BIẾT (owner cố ý hoãn, không phải bỏ sót): VAT (chưa VAT, để sau) · bảng tóm tắt PHẦN 1
trỏ "xem ĐIỀU 8/12" mà 002.04 chỉ có 5 điều ⇒ con-trỏ chết · phân quyền upload bản cứng ·
12 điều PHẦN 2 không có trong tài liệu. Chưa soi: 2 vai style FE/BE (lane không tạo được artifact).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -66,11 +66,13 @@ public class AwV2DesignerMenuKeyTests
|
||||
MenuKeys.ApprovalWorkflowKhkkN7, MenuKeys.ApprovalWorkflowKhkkN8,
|
||||
};
|
||||
|
||||
// 7 mã quy trình HĐ (lane t0 đã ghim cùng bộ ở `UpdateAwDefinitionTests.cs:1574`).
|
||||
// 9 mã quy trình HĐ (lane t0 đã ghim cùng bộ ở `UpdateAwDefinitionTests.cs:1574`).
|
||||
private static readonly string[] ContractWorkflowCodes =
|
||||
{ "HD-TP", "HD-GK", "HD-NCC", "HD-DV", "HD-MB", "HD-NT-NCC", "HD-NT-DV" };
|
||||
{ "HD-TP", "HD-GK", "HD-NCC", "HD-DV", "HD-MB", "HD-NT-NCC", "HD-NT-DV",
|
||||
// 🔄 G1 (YC-032 / S194) — 2 biến-thể Thầu phụ.
|
||||
"HD-TP-VT", "HD-TP-TB" };
|
||||
|
||||
// 7 key kỳ vọng, VIẾT TAY (khuôn `KhkkGroupMenuSeedTests.Expected49`): CỐ Ý **không** gọi
|
||||
// 9 key kỳ vọng, VIẾT TAY (khuôn `KhkkGroupMenuSeedTests.Expected49`): CỐ Ý **không** gọi
|
||||
// `MenuKeys.ContractWorkflowMenuKeys` / `ContractApprovalWorkflowFamily.KeyToken` — gọi lại
|
||||
// bộ sinh là TAUTOLOGY (luật sinh sai chuỗi thì kỳ vọng sai theo, test xanh vô nghĩa).
|
||||
// Chuỗi dưới ĐỌC TỪ MÃ ĐÃ LAND @S187 W3: mã `HD-{X}` → bỏ tiền tố `HD-`, bỏ mọi `-`, GIỮ
|
||||
@ -82,9 +84,13 @@ public class AwV2DesignerMenuKeyTests
|
||||
{
|
||||
"AwV2_HdTP", "AwV2_HdGK", "AwV2_HdNCC", "AwV2_HdDV",
|
||||
"AwV2_HdMB", "AwV2_HdNTNCC", "AwV2_HdNTDV",
|
||||
// 🔄 G1 (YC-032 / S194) — SUY TAY theo đúng luật, KHÔNG gọi bộ sinh (giữ tính chống-tautology
|
||||
// mà khối chú thích trên đặt ra): `HD-TP-VT` → bỏ `HD-` → `TP-VT` → bỏ `-` → `TPVT`
|
||||
// ⇒ `AwV2_HdTPVT`; tương tự `HD-TP-TB` ⇒ `AwV2_HdTPTB`.
|
||||
"AwV2_HdTPVT", "AwV2_HdTPTB",
|
||||
};
|
||||
|
||||
private const string ExpectedCount = "7";
|
||||
private const string ExpectedCount = "9"; // 🔄 G1 YC-032/S194: 7 -> 9 (tách HĐ Thầu phụ thành 2 biến-thể VT/TB)
|
||||
|
||||
// Họ MỚI = mọi leaf `AwV2_*` trong `All` TRỪ 10 leaf cũ.
|
||||
private static string[] ContractDesignerKeysInAll() =>
|
||||
@ -129,7 +135,7 @@ public class AwV2DesignerMenuKeyTests
|
||||
// ── VẾ CHÍNH: họ MỚI phải có đúng 7 thành viên ───────────────────────────────────
|
||||
var family = ContractDesignerKeysInAll();
|
||||
|
||||
family.Should().HaveCount(7,
|
||||
family.Should().HaveCount(9,
|
||||
$"W3 @S187 thêm ĐÚNG {ExpectedCount} leaf Designer cho {ContractWorkflowCodes.Length} loại HĐ " +
|
||||
$"({string.Join(" · ", ContractWorkflowCodes)}) — mỗi loại 1 quy trình V2 riêng (PA-A). " +
|
||||
"Đếm được 0 = lane BE chưa land 7 key vào `MenuKeys.All` (spec-change: test + code CÙNG wave, " +
|
||||
@ -179,7 +185,7 @@ public class AwV2DesignerMenuKeyTests
|
||||
|
||||
// ── TIỀN-ĐỀ (chống vacuous): không có 7 key thì mọi vòng lặp dưới chạy 0 lần ─────
|
||||
var family = ContractDesignerKeysInAll();
|
||||
family.Should().HaveCount(7,
|
||||
family.Should().HaveCount(9,
|
||||
$"tiền-đề: `MenuKeys.All` phải có {ExpectedCount} key `{ContractDesignerPrefix}*` (W3). " +
|
||||
"Đếm 0 = lane BE chưa land — mọi assert menu-row bên dưới sẽ đúng-vô-nghĩa nếu bỏ vế này");
|
||||
|
||||
@ -201,9 +207,9 @@ public class AwV2DesignerMenuKeyTests
|
||||
var allRowKeys = await db.MenuItems.AsNoTracking().Select(m => m.Key).ToListAsync();
|
||||
var awV2RowKeys = allRowKeys.Where(k => k.StartsWith(DesignerLeafPrefix, StringComparison.Ordinal)).ToArray();
|
||||
|
||||
awV2RowKeys.Should().HaveCount(17,
|
||||
$"10 leaf cũ + {ExpectedCount} leaf HĐ mới. Đếm 10 = 7 row mới chưa được seed; " +
|
||||
"đếm >17 = seed dư row (mục lạ trên sidebar admin)");
|
||||
awV2RowKeys.Should().HaveCount(19,
|
||||
$"10 leaf cũ + {ExpectedCount} leaf HĐ mới. Đếm 10 = row mới chưa được seed; " +
|
||||
"đếm >19 = seed dư row (mục lạ trên sidebar admin)");
|
||||
|
||||
awV2RowKeys.Except(MenuKeys.All, StringComparer.Ordinal).Should().BeEmpty(
|
||||
"menu-row họ `AwV2_*` mà key NGOÀI `MenuKeys.All` ⇒ Program.cs không sinh policy cho key đó ⇒ " +
|
||||
@ -212,13 +218,13 @@ public class AwV2DesignerMenuKeyTests
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// T3 — HÌNH SIDEBAR của cả 17 leaf: NHÃN phân biệt + ORDER phân biệt.
|
||||
// T3 — HÌNH SIDEBAR của cả 19 leaf: NHÃN phân biệt + ORDER phân biệt.
|
||||
// Khối 7 dòng mới là copy-paste của khối 8 dòng `AwV2_KhkkN*` (`DbInitializer:1988-1995`)
|
||||
// ⇒ 2 lỗi kinh điển của copy-paste khối: (i) quên sửa 1 nhãn ⇒ 2 mục cùng tên
|
||||
// "Duyệt HĐ — …" trên sidebar admin ⇒ chọn nhầm Designer ⇒ sửa quy trình của LOẠI HĐ
|
||||
// KHÁC; (ii) quên tăng Order ⇒ 2 mục cùng Order ⇒ thứ tự anh em bất định giữa các lần
|
||||
// render (GetMyMenuTree sort theo Order).
|
||||
// Chống vacuous: tiền-đề 17 leaf đứng trước (tập rỗng thì "phân biệt" đúng ở mọi thế giới).
|
||||
// Chống vacuous: tiền-đề 19 leaf đứng trước (tập rỗng thì "phân biệt" đúng ở mọi thế giới).
|
||||
// =====================================================================================
|
||||
[Fact]
|
||||
public async Task SeedMenuTree_AllDesignerLeaves_HaveDistinctLabelsAndDistinctOrders()
|
||||
@ -229,16 +235,16 @@ public class AwV2DesignerMenuKeyTests
|
||||
await SeedMenuAsync(db);
|
||||
var children = await DesignerChildrenAsync(db);
|
||||
|
||||
children.Should().HaveCount(17,
|
||||
$"2 leaf Duyệt NCC + 8 leaf KHKK nhóm + {ExpectedCount} leaf HĐ (W3 @S187) = 17 mục Designer. " +
|
||||
children.Should().HaveCount(19,
|
||||
$"2 leaf Duyệt NCC + 8 leaf KHKK nhóm + {ExpectedCount} leaf HĐ (W3 @S187) = 19 mục Designer. " +
|
||||
"Đếm 10 = lane BE chưa land 7 menu-row `AwV2_Hd*`");
|
||||
|
||||
children.Select(c => c.Label).Should().OnlyHaveUniqueItems(
|
||||
"17 mục Designer = 17 nhãn PHÂN BIỆT. Copy-paste khối 7 dòng mà quên sửa 1 nhãn ⇒ 2 mục cùng " +
|
||||
"19 mục Designer = 19 nhãn PHÂN BIỆT. Copy-paste khối 7 dòng mà quên sửa 1 nhãn ⇒ 2 mục cùng " +
|
||||
"tên trên sidebar admin ⇒ mở nhầm Designer của loại HĐ khác (sửa quy trình sai loại)");
|
||||
|
||||
children.Select(c => c.Order).Should().OnlyHaveUniqueItems(
|
||||
"17 mục = 17 Order PHÂN BIỆT. Trùng Order ⇒ thứ tự anh em bất định (GetMyMenuTree sort theo " +
|
||||
"19 mục = 19 Order PHÂN BIỆT. Trùng Order ⇒ thứ tự anh em bất định (GetMyMenuTree sort theo " +
|
||||
"Order) ⇒ sidebar đổi chỗ giữa các lần render");
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,232 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Contracts;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [G4 — YC-033 @S194 2026-08-13] KHOÁ BẢN CỨNG: một chiều, bất-khả-đảo.
|
||||
//
|
||||
// Owner nguyên văn: *"đc upload có quy trình duyệt riêng → duyệt nhanh thôi nhưng đã chốt
|
||||
// rồi ko sửa đc (bảng cứng đã ký — giấy trắng mực đen upload thì làm sao sửa)"*.
|
||||
//
|
||||
// ── VÌ SAO PHẢI CÓ CỘT `SealedAt`, KHÔNG DÙNG LẠI `HasSealedCopy` ──────────────────────
|
||||
// Cờ `HasSealedCopy` (`ContractDtos.cs:25`) derive THUẦN từ "có ≥1 đính kèm purpose=3"
|
||||
// (`ContractFeatures.cs:448`). Mà xoá đính kèm là HARD delete (`ContractAttachmentFeatures.cs`
|
||||
// `DeleteContractAttachmentCommandHandler` → `Remove()`), nên cờ ấy **2 CHIỀU**: upload xong
|
||||
// bật, xoá file là TẮT. Đúng nghĩa "mở khoá lại" — ngược hẳn ý owner.
|
||||
// ⇒ Mốc thật phải là một cột **chỉ ghi một lần**, và đường xoá phải bị rào.
|
||||
//
|
||||
// ── TIMING = TEST-BEFORE (docs/rules.md §7 — đây là sửa HÀNH VI, không phải feature mới) ──
|
||||
// 2 test dưới viết TRƯỚC khi có mã, chạy phải ĐỎ, rồi mới sửa cho xanh. Bằng chứng ĐỎ ghi ở
|
||||
// `.claude/workflows/runs/2026-08-13-S194-yc032-hd-thauphu-2form/sub-implementer-backend-4.md`.
|
||||
//
|
||||
// ── 3 quyết định hạ tầng (điều kiện để phép đo CÓ NGHĨA) ───────────────────────────────
|
||||
// (1) Gọi HANDLER THẬT (`Upload...CommandHandler` / `Delete...CommandHandler`), không seed
|
||||
// thẳng entity. Mốc `SealedAt` chỉ có nghĩa nếu nó được đặt trên ĐÚNG đường mà API đi;
|
||||
// seed tay `SealedAt = ...` rồi assert = đo chính tay mình.
|
||||
// (2) `IDateTime` bơm bằng `FixedDateTime` ⇒ 2 lần upload có 2 mốc KHÁC NHAU rõ ràng. Nếu
|
||||
// dùng `DateTime.UtcNow` thật, hai mốc cách nhau vài mili-giây và một cài đặt SAI (ghi
|
||||
// đè mốc mỗi lần upload) vẫn có thể xanh do làm tròn/so sánh lỏng.
|
||||
// (3) `TestApplicationDbContext` KHÔNG wire `AuditingInterceptor`; `SqliteDbFixture` đủ vì
|
||||
// 2 ca này không đụng Identity/UserManager.
|
||||
public class ContractSealTests
|
||||
{
|
||||
private static readonly DateTime T1 = new(2026, 8, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime T2 = new(2026, 8, 14, 15, 30, 0, DateTimeKind.Utc);
|
||||
private static readonly Guid ActorA = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid ActorB = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
// ===================================================================
|
||||
// Fakes — đường ghi file + changelog KHÔNG phải thứ đang đo.
|
||||
// ===================================================================
|
||||
|
||||
private sealed class FakeStorage : IFileStorage
|
||||
{
|
||||
public List<string> Deleted { get; } = new();
|
||||
public Task<string> SaveAsync(string relativePath, Stream content, CancellationToken ct = default)
|
||||
=> Task.FromResult(relativePath);
|
||||
public Task<Stream> OpenReadAsync(string relativePath, CancellationToken ct = default)
|
||||
=> Task.FromResult<Stream>(new MemoryStream());
|
||||
public Task DeleteAsync(string relativePath, CancellationToken ct = default)
|
||||
{
|
||||
Deleted.Add(relativePath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public bool Exists(string relativePath) => true;
|
||||
}
|
||||
|
||||
private sealed class FakeChangelog : IChangelogService
|
||||
{
|
||||
public Task LogContractChangeAsync(Guid contractId, ChangelogAction action, string? summary = null,
|
||||
string? fieldChangesJson = null, string? contextNote = null, ContractPhase? phaseAtChange = null,
|
||||
CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task LogDetailChangeAsync(Guid contractId, Guid detailId, ChangelogAction action,
|
||||
string? summary = null, string? fieldChangesJson = null, ContractPhase? phaseAtChange = null,
|
||||
CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task LogWorkflowTransitionAsync(Guid contractId, ContractPhase fromPhase, ContractPhase toPhase,
|
||||
string? comment, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task LogCommentAddedAsync(Guid contractId, string content, ContractPhase phase,
|
||||
CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task LogAttachmentAsync(Guid contractId, Guid attachmentId, ChangelogAction action,
|
||||
string fileName, ContractPhase phase, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Seed + act helpers
|
||||
// ===================================================================
|
||||
|
||||
private static async Task<Contract> SeedContractAsync(TestApplicationDbContext db, ContractPhase phase)
|
||||
{
|
||||
var sup = new Supplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = $"NCC{Guid.NewGuid():N}"[..8],
|
||||
Name = "NCC Bê tông Bình Minh",
|
||||
Type = SupplierType.NhaThauPhu,
|
||||
};
|
||||
var proj = new Project { Id = Guid.NewGuid(), Code = $"P{Guid.NewGuid():N}"[..8], Name = "Dự án FLOCK 01" };
|
||||
db.Suppliers.Add(sup);
|
||||
db.Projects.Add(proj);
|
||||
|
||||
var c = new Contract
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ContractType.HopDongThauPhu,
|
||||
Phase = phase,
|
||||
SupplierId = sup.Id,
|
||||
ProjectId = proj.Id,
|
||||
TenHopDong = "HĐ thầu phụ vật tư",
|
||||
GiaTri = 100_000_000m,
|
||||
};
|
||||
db.Contracts.Add(c);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return c;
|
||||
}
|
||||
|
||||
private static async Task<Guid> UploadAsync(
|
||||
TestApplicationDbContext db, Guid contractId, AttachmentPurpose purpose,
|
||||
DateTime at, Guid actorId, FakeStorage? storage = null)
|
||||
{
|
||||
var handler = new UploadContractAttachmentCommandHandler(
|
||||
db, storage ?? new FakeStorage(), new FakeChangelog(),
|
||||
new TestCurrentUser(actorId, "Người upload"), new FixedDateTime(at));
|
||||
await using var content = new MemoryStream(new byte[] { 1, 2, 3, 4 });
|
||||
var dto = await handler.Handle(new UploadContractAttachmentCommand(
|
||||
contractId, $"ban-cung-{purpose}.pdf", "application/pdf", 4, content, purpose, Note: "2 bộ gốc"),
|
||||
CancellationToken.None);
|
||||
return dto.Id;
|
||||
}
|
||||
|
||||
private static Task DeleteAsync(TestApplicationDbContext db, Guid contractId, Guid attachmentId,
|
||||
FakeStorage? storage = null)
|
||||
=> new DeleteContractAttachmentCommandHandler(db, storage ?? new FakeStorage(), new FakeChangelog())
|
||||
.Handle(new DeleteContractAttachmentCommand(contractId, attachmentId), CancellationToken.None);
|
||||
|
||||
// ===================================================================
|
||||
// (a) Upload bản cứng ⇒ ĐƯỜNG XOÁ ĐÍNH KÈM ĐÓNG LẠI (409).
|
||||
//
|
||||
// Đây là vế "bất-khả-đảo". Trước khi vá, `Delete...Handler` xoá thẳng không hỏi gì
|
||||
// (`Remove()` + `storage.DeleteAsync`) ⇒ `hasSealedCopy` tụt về false = HĐ "mở khoá".
|
||||
//
|
||||
// 3 chứng nhân bao quanh assert chính, để `409` không xanh vì lý do khác:
|
||||
// • CHỨNG NHÂN DƯƠNG trước: HĐ CHƯA có bản cứng thì xoá đính kèm vẫn chạy bình thường
|
||||
// ⇒ rào mới KHÔNG chặn nhầm cả thế giới.
|
||||
// • CONTROL cùng-HĐ-đã-khoá: file phụ (`DraftExport`) vẫn xoá được. Rào bám vào
|
||||
// `Purpose == SealedCopy`, không phải "HĐ này cấm xoá mọi thứ" (owner nói về BẢN CỨNG
|
||||
// đã ký, không nói về file nháp).
|
||||
// • Đếm DB SAU khi ăn 409: dòng đính kèm vẫn còn, và `storage.Deleted` rỗng ⇒ 409 ném
|
||||
// TRƯỚC mọi tác dụng phụ. Ném-sau-khi-xoá cũng cho `Should().Throw` xanh mà file thì
|
||||
// đã bay — đúng cái bẫy phải chặn.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task DeleteAttachment_AfterSealedCopyUpload_Throws409Conflict()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
|
||||
// ---- CHỨNG NHÂN DƯƠNG: HĐ chưa khoá ⇒ xoá đính kèm chạy bình thường.
|
||||
var cOpen = await SeedContractAsync(db, ContractPhase.DangInKy);
|
||||
var openStorage = new FakeStorage();
|
||||
var openAttId = await UploadAsync(db, cOpen.Id, AttachmentPurpose.ScannedSigned, T1, ActorA, openStorage);
|
||||
await DeleteAsync(db, cOpen.Id, openAttId, openStorage);
|
||||
(await db.ContractAttachments.CountAsync(a => a.Id == openAttId)).Should().Be(0,
|
||||
"HĐ chưa có bản cứng thì đường xoá phải y như cũ — nếu ở đây đã 409 thì rào mới "
|
||||
+ "đang chặn nhầm toàn bộ, và assert 409 ở dưới xanh vì lý do sai");
|
||||
|
||||
// ---- Vế chính: upload bản cứng ⇒ đường xoá ĐÓNG.
|
||||
var cSealed = await SeedContractAsync(db, ContractPhase.DaPhatHanh);
|
||||
var sealedStorage = new FakeStorage();
|
||||
var sealedAttId = await UploadAsync(db, cSealed.Id, AttachmentPurpose.SealedCopy, T1, ActorA, sealedStorage);
|
||||
|
||||
var act = () => DeleteAsync(db, cSealed.Id, sealedAttId, sealedStorage);
|
||||
(await act.Should().ThrowAsync<ConflictException>(
|
||||
"bản cứng đã ký là giấy trắng mực đen — gỡ được file scan nghĩa là gỡ được cả cái mốc "
|
||||
+ "'đã chốt' (cờ `hasSealedCopy` derive từ chính đính kèm này), tức khoá 2 CHIỀU"))
|
||||
.And.Message.Should().Contain("bản cứng",
|
||||
"câu lỗi phải nói đúng lý do owner đưa ra, không phải một câu 409 chung chung");
|
||||
|
||||
(await db.ContractAttachments.CountAsync(a => a.Id == sealedAttId)).Should().Be(1,
|
||||
"409 phải ném TRƯỚC khi đụng DB — dòng còn nguyên");
|
||||
sealedStorage.Deleted.Should().BeEmpty(
|
||||
"và trước khi đụng file vật lý; xoá file rồi mới ném thì ngoại lệ vẫn 'đúng' mà bản "
|
||||
+ "cứng thì đã mất");
|
||||
|
||||
// ---- CONTROL cùng HĐ đã khoá: file KHÔNG phải bản cứng vẫn xoá được.
|
||||
var draftAttId = await UploadAsync(db, cSealed.Id, AttachmentPurpose.DraftExport, T2, ActorA, sealedStorage);
|
||||
await DeleteAsync(db, cSealed.Id, draftAttId, sealedStorage);
|
||||
(await db.ContractAttachments.CountAsync(a => a.Id == draftAttId)).Should().Be(0,
|
||||
"rào bám vào `Purpose == SealedCopy`, không phải 'HĐ đã khoá thì cấm xoá mọi file'; "
|
||||
+ "409 ở đây nghĩa là rào rộng hơn ý owner");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (b) Upload bản cứng LẦN 2 ⇒ mốc `SealedAt` GIỮ NGUYÊN lần đầu.
|
||||
//
|
||||
// `??=` chứ không `=`. Lý do: mốc "đã chốt" là sự kiện xảy ra MỘT LẦN. Upload bổ sung
|
||||
// (bộ gốc thứ 2, bản scan rõ hơn) không được đẩy mốc về sau — nếu đẩy, mọi thứ tính theo
|
||||
// mốc (audit, tranh chấp "chốt lúc nào") kể sai chuyện.
|
||||
//
|
||||
// 🔴 `NotBeNull()` sau lần upload ĐẦU là chốt chống-vacuous BẮT BUỘC: nếu mốc không bao giờ
|
||||
// được ghi thì cả hai lần đều `null`, và một phép so `Be(mốc-đầu)` sẽ XANH GIẢ (null == null).
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task UploadSealedCopyTwice_KeepsFirstSealedAtStamp()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
|
||||
var c = await SeedContractAsync(db, ContractPhase.DaPhatHanh);
|
||||
|
||||
// ---- CHỨNG NHÂN: trước khi upload, HĐ chưa mang mốc nào.
|
||||
c.SealedAt.Should().BeNull("HĐ mới seed thì chưa có bản cứng");
|
||||
c.SealedByUserId.Should().BeNull();
|
||||
|
||||
// ---- Lần 1 (T1, ActorA) ⇒ mốc phải XUẤT HIỆN.
|
||||
await UploadAsync(db, c.Id, AttachmentPurpose.SealedCopy, T1, ActorA);
|
||||
var afterFirst = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == c.Id);
|
||||
afterFirst.SealedAt.Should().Be(T1,
|
||||
"upload bản cứng LÀ hành động chốt — không ghi mốc thì phép so 'giữ mốc' ở dưới "
|
||||
+ "chỉ đang so null với null");
|
||||
afterFirst.SealedByUserId.Should().Be(ActorA, "owner chọn nhánh mốc-1-chiều CÓ AUDIT");
|
||||
|
||||
// ---- Lần 2 (T2 muộn hơn 1 ngày, ActorB khác người) ⇒ mốc BẤT ĐỘNG.
|
||||
await UploadAsync(db, c.Id, AttachmentPurpose.SealedCopy, T2, ActorB);
|
||||
var afterSecond = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == c.Id);
|
||||
afterSecond.SealedAt.Should().Be(T1,
|
||||
"`??=` chứ không `=`: mốc chốt là sự kiện MỘT LẦN. Bằng T2 nghĩa là mỗi lần upload "
|
||||
+ "bổ sung lại dời mốc về hiện tại — audit sẽ khai sai thời điểm HĐ được chốt");
|
||||
afterSecond.SealedByUserId.Should().Be(ActorA,
|
||||
"người chốt cũng là người ĐẦU TIÊN; ghi đè sang ActorB là mất dấu người chịu trách nhiệm");
|
||||
|
||||
// ---- Chứng nhân dương cho phép so trên: file thứ 2 CÓ vào DB thật (không phải upload
|
||||
// lần 2 bị nuốt im lặng rồi mốc "giữ nguyên" vì chẳng có gì chạy).
|
||||
(await db.ContractAttachments.CountAsync(a => a.ContractId == c.Id
|
||||
&& a.Purpose == AttachmentPurpose.SealedCopy)).Should().Be(2,
|
||||
"cả 2 lần upload đều phải land — nếu lần 2 bị chặn thì đây đang đo một cơ chế KHÁC "
|
||||
+ "(cấm upload thêm) chứ không phải `??=`");
|
||||
}
|
||||
}
|
||||
@ -132,10 +132,10 @@ public class KhkkGroupMenuSeedTests
|
||||
MenuKeys.All.Should().OnlyHaveUniqueItems(
|
||||
"key đôi trong All ⇒ SeedAdminPermissionsAsync chèn Permission trùng (RoleId, MenuKey) ⇒ nổ UNIQUE");
|
||||
|
||||
MenuKeys.All.Should().HaveCount(120,
|
||||
"ĐÍCH sau W3 @S187 = 113 (số ĐO runtime tại S166 sau K4a) + 7 leaf Designer `AwV2_Hd*` cho " +
|
||||
MenuKeys.All.Should().HaveCount(122,
|
||||
"ĐÍCH sau G1 @S194 = 120 (sau W3 @S187) + 2 leaf Designer cho 2 biến-thể Thầu phụ (HD-TP-VT · HD-TP-TB). Nền: 113 (ĐO runtime S166 sau K4a) + 7 leaf `AwV2_Hd*` cho " +
|
||||
"7 loại HĐ (HD-TP · HD-GK · HD-NCC · HD-DV · HD-MB · HD-NT-NCC · HD-NT-DV). " +
|
||||
"`Policies` = |All| × |Actions| = 120 × 4 = 480 — đổi số ở đây BẮT BUỘC đổi 2 row canonical " +
|
||||
"`Policies` = |All| × |Actions| = 122 × 4 = 488 — đổi số ở đây BẮT BUỘC đổi 2 row canonical " +
|
||||
"`Menu keys` / `Policies` trong docs/STATUS.md CÙNG COMMIT. " +
|
||||
"Đo được 113 = lane BE chưa land 7 key (spec-change: test + code CÙNG wave, rules §7)");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user