[CLAUDE] Contract: K7 SP-002 bridge KHKK->HD hoi-2-khoa + Line.ContractId + grant seeder (gate PASS-WITH-FLAGS 10, F-8 fixed)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled

BE: POST {id}/create-contract (Contracts.Create AND KeHoachKyKet.Read) + guard 4-ve IsActive/IsUserSelectable
+ Line.ContractId write-site DAU TIEN + list-DTO ContractIds + SeedContractCreateAccessAsync (Drafter/
Procurement/Admin upgrade-only, cau III default cho ratify). FE x2 app SHA-pair 3/3: nut bridge gate 2-khoa
+ dialog khuon-san + cay GD3/GD4 doc TAP contractIds (union pe.contractId legacy). Test +17 (12 bridge T1-T12
+ 5 authz) suite 639/0; ngoai-le CreateContract trong AuthorizePolicyRegressionTests (#85).
Gate F-8 fixed truoc commit: neo so-dong :2516 chet-luc-land -> neo ten ham (3 cho) + 13/13->12/13 theo do
prod + khai chu-dich CanRead + sua tro-nham-ham. No mang theo (0-blocker): MAJOR-race-2-request-dong-thoi
(chong-trung in-memory, phuong an unique/RowVersion = nhip sau) + DraftData-khong-carry + a11y/O(n.m) FE.
UAT-NOI-TRUOC: nut sang sau restart + user RE-LOGIN (menu cache); acceptance seeder log = 0 added + 2 upgraded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-01 13:04:49 +07:00
parent c97f948a7b
commit aaed699228
13 changed files with 2241 additions and 46 deletions

View File

@ -0,0 +1,760 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.ContractSigningPlans;
using SolutionErp.Application.Contracts;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [K7 — S167 2026-08-01] CẦU GĐ2 → GĐ3: `CreateContractFromSigningPlanCommandHandler`
// (`Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs`, file MỚI của
// lane BE cùng đợt). Test-after: BE land trước, đây là lưới khoá hành-vi.
//
// ── Đo cái gì (map bảng T1-T8 `sub-invest-fable-b2-cum3.md:75-82` + 4 vá spec-cụm-3 §②) ──
// T1 happy 1 dòng (trọn field-map + 2 changelog + write-site) T2 gộp 2 dòng CÙNG NCC
// T3 chưa có giá chốt → chặn, KHÔNG đốt mã T4 phiếu chưa duyệt xong
// T5 dòng đã có HĐ (double-bridge) T6 2 dòng KHÁC NCC
// T7 quy trình pin sai — 4 nhánh + ĐỐI CHỨNG DƯƠNG cùng lượt T8 mối nối cầu → đường-ống V2
// T9 dòng-của-phiếu-khác + rào tập LineIds T10 bridge lần 2 dòng khác (`??=` CÓ CHIỀU)
// T11 double-click cùng tập dòng (idempotency)
//
// ── 3 quyết định hạ tầng (điều kiện để phép đo có nghĩa) ──────────────────────────────
//
// (1) `IdentityFixture` chứ không phải fixture KHKK riêng: `ContractWorkflowService` (deps của
// handler cầu, và là máy chạy T8) BẮT BUỘC `UserManager<User>` trong ctor. Khuôn sao y
// bridge PE cũ `CreateContractFromEvaluationMultiWinnerTests.BuildHandler`.
// (2) DÙNG DEPS THẬT (`ContractCodeGenerator` + `ContractWorkflowService` + service KHKK thật ở
// T9-B6), KHÔNG stub: mã HĐ RG-001 và `GetPhaseSla` là hai thứ vá-6 yêu cầu — stub chúng thì
// phép đo tự trả lời chính mình. `BeginTransactionAsync(Serializable)` chạy sạch trên SQLite
// (tiền lệ `SqliteDbFixture.cs:36-42` + codegen tests) — KHÔNG try/skip.
// (3) `ChangeTracker.Clear()` giữa 2 lần bấm (T10/T11): prod là 2 request ⇒ 2 DbContext scoped.
// Giữ nguyên context đã track dòng = một đường đi KHÔNG có thật, và nó CHE mất câu hỏi
// "đọc lại từ DB có thấy `ContractId` đã ghi không".
//
// ── 🔴 LỆCH SPEC ĐÃ ĐO, test theo CODE (single source of truth) ────────────────────────
// Bảng invest T1 (`:75`) ghi kỳ vọng `WorkflowDefinitionId null`. **Vá-6** của spec-cụm-3
// (`spec-cum3-chi-tiet-31-07-2026.md:16`) ĐẢO lại: pin V1 như 2 tiền lệ, và spec `:4` chốt
// "16 vá THẮNG khi xung đột với nền". Mã đang theo vá-6 (`:163-166` + `:201`).
// ⇒ T1 assert **pin ĐÚNG bản active của loại HĐ đó**, và assert luôn vế PHỦ ĐỊNH (không phải
// bản inactive / không phải bản của loại khác) — nếu chỉ assert `!= null` thì `activeWfId` lấy
// nhầm bản nào cũng xanh.
public class CreateContractFromSigningPlanTests
{
private static readonly DateTime Clock = new(2026, 8, 1, 3, 0, 0, DateTimeKind.Utc);
// ===================================================================
// Handler dưới test + deps THẬT (mirror wiring prod ContractSigningPlansController → MediatR)
// ===================================================================
private static CreateContractFromSigningPlanCommandHandler BuildHandler(
TestApplicationDbContext db, IServiceProvider sp, User actor, params string[] roles)
{
var um = sp.GetRequiredService<UserManager<User>>();
var dt = new FixedDateTime(Clock);
var currentUser = new TestCurrentUser(actor.Id, actor.FullName, actor.Email,
roles.Length > 0 ? roles : [AppRoles.Drafter]);
var changelog = new ChangelogService(db, currentUser, um);
var codeGen = new ContractCodeGenerator(db, dt);
var workflowSvc = new ContractWorkflowService(
db, codeGen, dt, new NoOpNotificationServiceApp(), changelog, um);
return new CreateContractFromSigningPlanCommandHandler(db, currentUser, workflowSvc, codeGen);
}
private const string ProjectCode = "FLOCK01";
private const string SupplierCode = "PVL";
private sealed record Seeded(
ContractSigningPlan Plan,
PurchaseEvaluation Pe,
Project Project,
List<Supplier> Suppliers,
Guid ContractWorkflowId, // V2 `ApplicableType = Contract(3)` — hợp lệ
Guid ActiveWorkflowDefId, // V1 active ĐÚNG loại HĐ dùng trong test
User Actor);
/// Mỗi phần tử = 1 dòng của phiếu. `SupplierIndex` trỏ vào `Suppliers` đã seed.
private sealed record LineSeed(int SupplierIndex, decimal PeReference, decimal Proposed, decimal? Approved);
private const ContractType TestType = ContractType.HopDongThauPhu;
/// Seed đủ 1 thế giới nhỏ nhưng THẬT: phiếu KHKK + PE nguồn + Project/Supplier CÓ `Code`
/// (cầu nạp 2 bảng này cho codegen, thiếu = `NotFoundException`) + 1 quy trình V2 loại HĐ
/// hợp lệ + 1 `WorkflowDefinition` V1 active của `TestType`.
///
/// 🔴 MỖI dòng 1 `CatalogEntryId` RIÊNG: UNIQUE `(PlanId, SupplierId, CatalogEntryId)` filtered
/// (`ContractSigningPlanLineConfiguration.cs:39-41`, K2 Mig 71). Chính vì Mig 71 đổi unique từ
/// (Plan, Supplier) sang bộ-3 mà **T2 gộp 2 dòng cùng NCC mới tồn tại được** (vá-5 spec-cụm-3).
private static async Task<Seeded> SeedAsync(
IdentityFixture fix,
TestApplicationDbContext db,
LineSeed[] lines,
int supplierCount = 1,
ContractSigningPlanPhase phase = ContractSigningPlanPhase.DaDuyet,
string maKeHoach = "KHKK/2026/001")
{
// Hậu tố ngẫu nhiên cho MỌI mã: `Projects.Code` / `Suppliers.Code` / `ApprovalWorkflows`
// (Code,Version) / `WorkflowDefinitions` (Code,Version) đều UNIQUE ⇒ T9 gọi `SeedAsync`
// HAI LẦN trên cùng DB sẽ nổ SQLite 19 nếu mã trùng.
var tag = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
var actor = await fix.CreateUserAsync(
$"drafter-k7-{Guid.NewGuid():N}@test.local", "Người soạn K7",
departmentId: null, roles: [AppRoles.Drafter]);
var project = new Project
{
Id = Guid.NewGuid(), Code = $"{ProjectCode}{tag}", Name = "Dự án FLOCK 01",
};
db.Projects.Add(project);
var suppliers = new List<Supplier>();
for (var i = 0; i < supplierCount; i++)
{
var s = new Supplier
{
Id = Guid.NewGuid(),
// NCC[0] mang mã dùng để soi mã HĐ (T1); các NCC sau khác mã để không lẫn.
Code = i == 0 ? $"{SupplierCode}{tag}" : $"NCC{i}{tag}",
Name = $"Nhà cung cấp {i + 1}",
Type = SupplierType.NhaThauPhu,
};
suppliers.Add(s);
db.Suppliers.Add(s);
}
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
MaPhieu = $"PE-2026-K7-{tag}",
Type = PurchaseEvaluationType.DuyetNccPhuongAn,
Phase = PurchaseEvaluationPhase.DaDuyet,
TenGoiThau = "Gói thầu K7",
ProjectId = project.Id,
DrafterUserId = actor.Id,
MoTa = "Mô tả gói thầu K7",
};
db.PurchaseEvaluations.Add(pe);
var plan = new ContractSigningPlan
{
Id = Guid.NewGuid(),
MaKeHoach = maKeHoach,
PurchaseEvaluationId = pe.Id,
ProjectId = project.Id,
DepartmentId = Guid.NewGuid(),
DrafterUserId = actor.Id,
Phase = phase,
ApprovalGroup = 1,
};
foreach (var l in lines)
{
var catalog = new ContractCatalogEntry
{
Id = Guid.NewGuid(),
Code = "K7-" + Guid.NewGuid().ToString("N")[..8],
TenVi = $"Hạng mục K7 #{plan.Lines.Count + 1}",
GroupCode = "B1",
ApprovalGroup = 1,
SignerRole = "TP.PRO",
IsActive = true,
SortOrder = 1,
};
db.ContractCatalogEntries.Add(catalog);
plan.Lines.Add(new ContractSigningPlanLine
{
Id = Guid.NewGuid(),
ContractSigningPlanId = plan.Id,
SupplierId = suppliers[l.SupplierIndex].Id,
CatalogEntryId = catalog.Id,
TenHangMuc = catalog.TenVi,
PeReferenceAmount = l.PeReference,
ProposedAmount = l.Proposed,
ApprovedAmount = l.Approved,
});
}
db.ContractSigningPlans.Add(plan);
var aw = NewContractWorkflow($"QT-HD-V2-K7-{tag}");
db.ApprovalWorkflows.Add(aw);
// V1 active của ĐÚNG `TestType` + 1 bản NHIỄU (loại khác) để vế pin của T1 không thể
// xanh nhờ "chỉ có mỗi một bản trong bảng".
var activeDef = new WorkflowDefinition
{
Id = Guid.NewGuid(), Code = $"QT-TP-{tag}", Version = 1,
ContractType = TestType, Name = "QT Thầu phụ v01", IsActive = true,
};
db.WorkflowDefinitions.Add(activeDef);
db.WorkflowDefinitions.Add(new WorkflowDefinition
{
Id = Guid.NewGuid(), Code = $"QT-MB-{tag}", Version = 1,
ContractType = ContractType.HopDongMuaBan, Name = "QT Mua bán v01", IsActive = true,
});
await db.SaveChangesAsync(CancellationToken.None);
return new Seeded(plan, pe, project, suppliers, aw.Id, activeDef.Id, actor);
}
private static ApprovalWorkflow NewContractWorkflow(
string code, bool isActive = true, bool isUserSelectable = true,
ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.Contract)
=> new()
{
Id = Guid.NewGuid(),
Code = code,
Version = 1,
Name = $"Quy trình {code}",
ApplicableType = type,
IsActive = isActive,
IsUserSelectable = isUserSelectable,
};
private static CreateContractFromSigningPlanCommand Cmd(
Seeded s, IEnumerable<Guid> lineIds, Guid? workflowId = null, string? ten = null)
=> new(s.Plan.Id, lineIds.ToList(), TestType, workflowId ?? s.ContractWorkflowId, ten);
private static Guid LineId(Seeded s, int index) => s.Plan.Lines[index].Id;
// ===================================================================
// T1 — Happy 1 dòng: trọn field-map + write-site + 2 changelog.
// ===================================================================
[Fact]
public async Task T1_MotDong_TaoDungMotHopDong_VaGhiNguocContractId()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, PeReference: 900m, Proposed: 999m, Approved: 750m)]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
var c = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);
// ---- Tiền + trạng thái.
c.GiaTri.Should().Be(750m,
"`GiaTri` = Σ ApprovedAmount của ĐÚNG tập dòng chọn — KHÔNG phải `ProposedAmount` (999) " +
"cũng không phải `PeReferenceAmount` (900); 3 số cố ý khác nhau để chỉ một cái đúng");
c.Phase.Should().Be(ContractPhase.DangSoanThao);
c.Type.Should().Be(TestType);
c.SupplierId.Should().Be(s.Suppliers[0].Id, "NCC của HĐ lấy từ DÒNG, không phải từ phiếu");
c.ProjectId.Should().Be(s.Project.Id);
c.DepartmentId.Should().Be(s.Plan.DepartmentId);
c.DrafterUserId.Should().Be(s.Actor.Id);
// ---- Pin quy trình: V2 (mới) + V1 (vá-6). Xem khối "LỆCH SPEC" đầu file.
c.ApprovalWorkflowId.Should().Be(s.ContractWorkflowId);
c.WorkflowDefinitionId.Should().Be(s.ActiveWorkflowDefId,
"[vá-6] pin bản V1 ĐANG ACTIVE của ĐÚNG loại HĐ — để null thì HĐ rơi về policy hardcode; " +
"pin nhầm bản của loại khác (bảng có sẵn 1 bản nhiễu `HopDongMuaBan`) cũng sai");
// ---- [vá-6] SLA: `GetPhaseSla(DangSoanThao)` trả null ⇒ nhánh dự phòng 7 ngày.
// Thiếu dòng này thì HĐ sinh từ cầu là HĐ DUY NHẤT trong hệ không có hạn phase.
c.SlaDeadline.Should().NotBeNull("[vá-6] cầu phải set hạn phase như 2 tiền lệ");
c.SlaDeadline!.Value.Should().BeCloseTo(DateTime.UtcNow.AddDays(7), TimeSpan.FromMinutes(5));
// ---- [vá-6] Mã HĐ: soi RUỘT chứ không chỉ non-null — mã có `Project.Code` + `Supplier.Code`
// CHÍNH LÀ bằng chứng cầu đã nạp 2 bảng đó (nếu quên nạp thì `GenerateAsync` NRE/mã rỗng).
c.MaHopDong.Should().Be($"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/01",
"RG-001 `{ProjectCode}/HĐTP/SOL&{SupplierCode}/{Seq:D2}` — gen NGAY lúc bắc cầu; " +
"mã CHỨA mã dự án + mã NCC chính là bằng chứng cầu đã nạp 2 bảng đó [vá-6]");
result.MaHopDong.Should().Be(c.MaHopDong, "kết quả trả về phải khớp DB (FE toast đọc số này)");
// ---- NS tham chiếu = Σ `PeReferenceAmount` (số PE), KHÁC `GiaTri` (số đã chốt).
c.BudgetManualAmount.Should().Be(900m);
c.BudgetManualName.Should().Contain(s.Plan.MaKeHoach!);
// ---- [vá-4a] WRITE-SITE: `Line.ContractId` = nguồn THẬT của đường KHKK → HĐ.
var line = await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0));
line.ContractId.Should().Be(c.Id, "[vá-4a] write-site ĐẦU TIÊN toàn codebase của cột này");
// ---- [vá-4a] `pe.ContractId` = fallback hiển thị cho cây 4-folder (ô còn trống ⇒ điền).
var pe = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(p => p.Id == s.Pe.Id);
pe.ContractId.Should().Be(c.Id);
// ---- 2 changelog, mỗi bên 1 hàng, ĐÚNG bảng (không cùng bảng, không thiếu bên nào).
var ctLog = await db.ContractChangelogs.AsNoTracking().Where(x => x.ContractId == c.Id).ToListAsync();
ctLog.Should().HaveCount(1);
ctLog[0].Action.Should().Be(ChangelogAction.Insert);
ctLog[0].EntityType.Should().Be(ChangelogEntityType.Contract);
ctLog[0].UserId.Should().Be(s.Actor.Id);
ctLog[0].Summary.Should().Contain(c.MaHopDong!).And.Contain(s.Plan.MaKeHoach!);
var planLog = await db.ContractSigningPlanChangelogs.AsNoTracking()
.Where(x => x.ContractSigningPlanId == s.Plan.Id).ToListAsync();
planLog.Should().HaveCount(1);
planLog[0].Action.Should().Be(ChangelogAction.Update);
planLog[0].EntityType.Should().Be(ContractSigningPlanEntityType.Line);
planLog[0].ContextNote.Should().Contain(c.Id.ToString());
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1, "1 lần bấm = 1 HĐ");
}
// ===================================================================
// T2 — Gộp 2 dòng CÙNG NCC (máy nhận N, UI hướng 1-1).
// 🔴 Case này CHỈ TỒN TẠI ĐƯỢC nhờ K2 Mig 71 đổi UNIQUE (Plan, Supplier) → bộ-3 có
// `CatalogEntryId` (vá-5): trước đó 2 dòng cùng NCC trên 1 phiếu là bất khả về schema.
// ===================================================================
[Fact]
public async Task T2_HaiDongCungNcc_GopVaoMotHopDong_GiaTriLaTong()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m),
new LineSeed(0, 200m, 500m, Approved: 450m),
]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);
var c = await db.Contracts.AsNoTracking().SingleAsync();
c.Id.Should().Be(result.ContractId);
c.GiaTri.Should().Be(750m, "Σ 300 + 450 của ĐÚNG 2 dòng gửi lên");
c.BudgetManualAmount.Should().Be(300m, "NS tham chiếu = Σ PeReferenceAmount (100 + 200)");
var lines = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).ToListAsync();
lines.Should().HaveCount(2);
lines.Should().OnlyContain(l => l.ContractId == c.Id, "CẢ HAI dòng cùng trỏ 1 HĐ");
}
// ===================================================================
// T3 — 1 dòng chưa có giá chốt ⇒ chặn, message NÊU TÊN hạng mục, KHÔNG đốt mã.
// "Không đốt mã" = phép đo side-effect của sequence: rào (4) phải đứng TRƯỚC codegen
// (`:119-130` vs `:212`). Nếu ai đảo thứ tự, HĐ không sinh ra nhưng số RG-001 đã cháy
// — hỏng câm lặng, chỉ lộ khi soi bảng sequence.
// ===================================================================
[Fact]
public async Task T3_DongChuaCoGiaChot_ThiChan_KhongTaoHd_VaKhongDotMa()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m), // dòng lành
new LineSeed(0, 100m, 700m, Approved: null), // dòng thiếu giá chốt
]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var tenHangMucThieu = s.Plan.Lines[1].TenHangMuc!;
var act = async () => await handler.Handle(
Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>())
.WithMessage($"*{tenHangMucThieu}*",
"message phải NÊU TÊN hạng mục — dialog chỉ hiện tên, không hiện id");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0, "atomic: 0 HĐ dở dang");
(await db.ContractSigningPlanLines.AsNoTracking()
.CountAsync(l => l.ContractId != null)).Should().Be(0, "kể cả dòng LÀNH cũng không bị ghi");
(await db.ContractCodeSequences.AsNoTracking().CountAsync()).Should().Be(0,
"rào phải đứng TRƯỚC codegen — chặn xong mà số RG-001 đã cháy là hỏng câm lặng");
}
// ===================================================================
// T4 — Phiếu chưa duyệt xong (`ChoDuyet`) ⇒ chặn.
// ===================================================================
[Fact]
public async Task T4_PhieuChuaDuyetXong_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
// Dòng CÓ đủ giá chốt: nếu bỏ rào phase, call này sẽ THÀNH CÔNG ⇒ test phân biệt được
// đúng một biến (phase), không phải xanh nhờ vướng rào khác.
var s = await SeedAsync(fix, db,
[new LineSeed(0, 100m, 500m, Approved: 300m)],
phase: ContractSigningPlanPhase.ChoDuyet);
var handler = BuildHandler(db, fix.Services, s.Actor);
var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*Đã duyệt*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
}
// ===================================================================
// T5 — Dòng đã có HĐ ⇒ chặn double-bridge PER-DÒNG (không phải per-phiếu như cầu PE cũ).
// ===================================================================
[Fact]
public async Task T5_DongDaCoHopDong_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
// Đặt sẵn dấu "đã bắc cầu" bằng chính cột write-site.
var seededContractId = Guid.NewGuid();
var line = await db.ContractSigningPlanLines.SingleAsync(l => l.Id == LineId(s, 0));
line.ContractId = seededContractId;
await db.SaveChangesAsync(CancellationToken.None);
db.ChangeTracker.Clear();
var handler = BuildHandler(db, fix.Services, s.Actor);
var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>())
.WithMessage($"*{s.Plan.Lines[0].TenHangMuc}*đã có Hợp đồng*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
(await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0)))
.ContractId.Should().Be(seededContractId, "HĐ cũ KHÔNG bị ghi đè");
}
// ===================================================================
// T6 — 2 dòng KHÁC NCC trong 1 lần bấm ⇒ chặn (1 HĐ = 1 NCC; liên-danh = N lần bấm).
// ===================================================================
[Fact]
public async Task T6_HaiDongKhacNcc_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m),
new LineSeed(1, 100m, 500m, Approved: 300m), // NCC KHÁC
], supplierCount: 2);
var handler = BuildHandler(db, fix.Services, s.Actor);
var act = async () => await handler.Handle(
Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*nhiều NCC khác nhau*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
// ĐỐI CHỨNG DƯƠNG cùng lượt: tách ra bấm từng dòng thì QUA — chứng minh 2 dòng này
// hợp lệ về mọi mặt khác, cái chặn ở trên đúng là vế "khác NCC".
var r1 = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
db.ChangeTracker.Clear();
var r2 = await handler.Handle(Cmd(s, [LineId(s, 1)]), CancellationToken.None);
r1.ContractId.Should().NotBe(r2.ContractId);
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(2, "liên-danh 2 NCC = 2 HĐ");
}
// ===================================================================
// T7 — Rào quy trình pin: 4 nhánh SAI + 1 ĐỐI CHỨNG DƯƠNG, tất cả trên CÙNG một phiếu.
// [vá-3] 2 vế `IsActive`/`IsUserSelectable` là phần VÁ (khuôn gốc `ContractFeatures.cs:79-87`
// chỉ hỏi tồn-tại + đúng-loại ⇒ forge POST pin được quy trình đã nghỉ / chưa ghim).
// Ma-trận cùng-ruột: mọi thứ khác giữ y nguyên, đổi ĐÚNG 1 biến là `ApprovalWorkflowId`.
// Message TÁCH RIÊNG từng nguyên nhân — gộp `||` thì admin không biết bấm nút nào.
// ===================================================================
[Fact]
public async Task T7_QuyTrinhPinSai_Chan4Nhanh_VaQuyTrinhDungThiQua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
var wrongType = NewContractWorkflow("QT-KHKK-V2",
type: ApprovalWorkflowApplicableType.ContractSigningPlan); // loại 10, không phải 3
var retired = NewContractWorkflow("QT-HD-NGHI", isActive: false);
var unpinned = NewContractWorkflow("QT-HD-CHUA-GHIM", isUserSelectable: false);
db.ApprovalWorkflows.AddRange(wrongType, retired, unpinned);
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db, fix.Services, s.Actor);
var lineIds = new[] { LineId(s, 0) };
// (a) không tồn tại → NotFound (khác Conflict: id rác là lỗi tham chiếu, không phải trạng thái).
await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, Guid.NewGuid()), CancellationToken.None)))
.Should().ThrowAsync<NotFoundException>();
// (b) đúng tồn tại nhưng SAI LOẠI (quy trình của chính module KHKK).
(await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, wrongType.Id), CancellationToken.None)))
.Should().ThrowAsync<ConflictException>())
.WithMessage("*không khớp với Hợp đồng*");
// (c) [vá-3] đã ngừng hoạt động.
(await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, retired.Id), CancellationToken.None)))
.Should().ThrowAsync<ConflictException>())
.WithMessage("*ngừng hoạt động*");
// (d) [vá-3] chưa được ghim cho người dùng chọn — message PHẢI khác (c).
(await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, unpinned.Id), CancellationToken.None)))
.Should().ThrowAsync<ConflictException>())
.WithMessage("*chưa được ghim*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0, "4 nhánh sai = 0 HĐ");
(await db.ContractCodeSequences.AsNoTracking().CountAsync()).Should().Be(0,
"rào quy trình cũng phải đứng TRƯỚC codegen");
// ---- ĐỐI CHỨNG DƯƠNG: đổi ĐÚNG 1 biến (quy trình hợp lệ) → QUA.
// Thiếu vế này thì 4 khẳng-định trên vẫn xanh trong một thế giới mà cầu chặn TẤT CẢ.
var ok = await handler.Handle(Cmd(s, lineIds, s.ContractWorkflowId), CancellationToken.None);
ok.ContractId.Should().NotBeEmpty();
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1);
}
// ===================================================================
// T8 (khoá V3) — MỐI NỐI cầu → đường ống V2.
//
// `ContractV2PipelineTests` (W6-S161) đã khoá đường ống, NHƯNG nó **seed thẳng** HĐ ở
// `ChoDuyet` + `ApprovalWorkflowId` + `stepIndex` — tức cho sẵn thứ mà cầu phải tự sinh.
// Test này đo phần KHÔNG ai đo: HĐ do CẦU đẻ có đi vào máy V2 được không.
// Thế giới hỏng phân biệt được: cầu quên pin `ApprovalWorkflowId` ⇒ `TransitionAsync:97`
// để `CurrentApprovalLevelOrder = null` ⇒ `ResolveV2InboxIdsAsync` không khớp ⇒ hộp thư
// của ĐÚNG người phải duyệt rỗng, mà HĐ thì vẫn "đã trình" — treo vô thời hạn, im lặng.
// ===================================================================
[Fact]
public async Task T8_HdSinhTuCau_TrinhDuyet_ThiVaoDungHopThuCapDangCho()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
// 2 Cấp trong 1 Bước: cấp 1 = a, cấp 2 = b (OR-of-N không cần thiết ở đây).
var a = await fix.CreateUserAsync($"a-{Guid.NewGuid():N}@test.local", "Duyệt cấp 1", null, []);
var b = await fix.CreateUserAsync($"b-{Guid.NewGuid():N}@test.local", "Duyệt cấp 2", null, []);
var step = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(), ApprovalWorkflowId = s.ContractWorkflowId,
Order = 1, Name = "Bước 1", DepartmentId = null,
};
db.ApprovalWorkflowSteps.Add(step);
db.ApprovalWorkflowLevels.AddRange(
new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id, Order = 1, Name = "Cấp 1", ApproverUserId = a.Id },
new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id, Order = 2, Name = "Cấp 2", ApproverUserId = b.Id });
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
// ---- Trình duyệt QUA MÁY THẬT (không set Phase bằng tay — seed thẳng là đo nhầm cơ chế).
var dt = new FixedDateTime(Clock);
var drafterUser = new TestCurrentUser(s.Actor.Id, s.Actor.FullName, s.Actor.Email, AppRoles.Drafter);
var svc = new ContractWorkflowService(
db, new ContractCodeGenerator(db, dt), dt, new NoOpNotificationServiceApp(),
new ChangelogService(db, drafterUser, fix.Services.GetRequiredService<UserManager<User>>()),
fix.Services.GetRequiredService<UserManager<User>>());
var contract = await db.Contracts.SingleAsync(x => x.Id == result.ContractId);
await svc.TransitionAsync(contract, ContractPhase.ChoDuyet, s.Actor.Id,
[AppRoles.Drafter], ApprovalDecision.Approve, "trình", ct: CancellationToken.None);
var after = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);
after.Phase.Should().Be(ContractPhase.ChoDuyet);
after.CurrentWorkflowStepIndex.Should().Be(0);
after.CurrentApprovalLevelOrder.Should().Be(1,
"con-trỏ Cấp CHỈ được khởi tạo khi HĐ pin V2 (`ContractWorkflowService.cs:97`) — " +
"null ở đây nghĩa là cầu quên pin `ApprovalWorkflowId`");
// ---- Hộp thư: người của Cấp ĐANG CHỜ thấy; người Cấp SAU chưa thấy.
// Cả 2 user KHÔNG mang role legacy nào ⇒ chỉ có chân V2, đo đúng vế V2.
var inboxA = await new GetMyInboxQueryHandler(db, new TestCurrentUser(a.Id, a.FullName, a.Email))
.Handle(new GetMyInboxQuery(), CancellationToken.None);
inboxA.Select(i => i.Id).Should().BeEquivalentTo(new[] { result.ContractId },
"HĐ sinh từ cầu phải nằm trong hộp thư của Cấp 1 — đây là toàn bộ lý do cầu tồn tại");
var inboxB = await new GetMyInboxQueryHandler(db, new TestCurrentUser(b.Id, b.FullName, b.Email))
.Handle(new GetMyInboxQuery(), CancellationToken.None);
inboxB.Should().BeEmpty("Cấp 2 chưa tới lượt");
}
// ===================================================================
// T9 — Rào tập `LineIds`: id của phiếu KHÁC (hoặc id rác) không được lọt.
// Đây là rào duy nhất chặn "đưa dòng của phiếu người khác vào HĐ của mình".
// ===================================================================
[Fact]
public async Task T9_DongCuaPhieuKhac_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s1 = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
var s2 = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 999m)],
maKeHoach: "KHKK/2026/002");
var handler = BuildHandler(db, fix.Services, s1.Actor);
// Trộn 1 dòng của phiếu 1 + 1 dòng của phiếu 2 → chặn.
var act = async () => await handler.Handle(
Cmd(s1, [LineId(s1, 0), LineId(s2, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*không thuộc kế hoạch này*");
// Id rác cũng rơi vào cùng rào (đếm sau Distinct).
var act2 = async () => await handler.Handle(
Cmd(s1, [LineId(s1, 0), Guid.NewGuid()]), CancellationToken.None);
(await act2.Should().ThrowAsync<ConflictException>()).WithMessage("*không thuộc kế hoạch này*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
(await db.ContractSigningPlanLines.AsNoTracking()
.CountAsync(l => l.ContractId != null)).Should().Be(0, "dòng của phiếu hàng xóm không bị đụng");
}
// ===================================================================
// T10 — Bấm lần 2 trên DÒNG KHÁC của cùng phiếu ⇒ HĐ thứ 2 sinh ra,
// nhưng `pe.ContractId` GIỮ HĐ ĐẦU. Đây là phép đo CHIỀU của `??=` (vá-4a):
// viết `=` thay `??=` thì mọi assert khác vẫn xanh, chỉ vế này ĐỎ.
// Đồng thời là phép đo cardinality của DoD vá-15 (N lần bấm = N ContractId).
// ===================================================================
[Fact]
public async Task T10_BacCauLanHai_TaoHdThuHai_NhungPeGiuHdDau()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m),
new LineSeed(0, 100m, 500m, Approved: 450m),
]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var first = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
db.ChangeTracker.Clear();
var second = await handler.Handle(Cmd(s, [LineId(s, 1)]), CancellationToken.None);
second.ContractId.Should().NotBe(first.ContractId);
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(2);
var lines = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ApprovedAmount).ToListAsync();
lines[0].ContractId.Should().Be(first.ContractId);
lines[1].ContractId.Should().Be(second.ContractId);
var pe = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(p => p.Id == s.Pe.Id);
pe.ContractId.Should().Be(first.ContractId,
"[vá-4a] `??=` CÓ CHIỀU: HĐ đầu giữ ô đơn của PE; HĐ sau KHÔNG cướp ô " +
"(chúng vẫn nhìn thấy được qua `Line.ContractId` — chính là lý do vá-4a tồn tại)");
// Mã HĐ thứ 2 tăng seq trên CÙNG prefix (cùng dự án + cùng NCC) — chứng codegen chạy thật.
var codes = await db.Contracts.AsNoTracking().Select(c => c.MaHopDong).ToListAsync();
codes.Should().BeEquivalentTo(new[]
{
$"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/01",
$"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/02",
});
}
// ===================================================================
// T11 — DOUBLE-CLICK: bấm 2 lần liên tiếp CÙNG tập dòng.
// Hành vi ĐANG CÓ của BE (đọc `:128-129`): lần 2 rơi vào rào per-dòng "đã có Hợp đồng"
// ⇒ `ConflictException`. Test khoá đúng hành vi đó, KHÔNG chế kỳ vọng "no-op im lặng".
// `ChangeTracker.Clear()` mô phỏng request thứ 2 (context mới đọc lại từ DB).
// ===================================================================
[Fact]
public async Task T11_BamHaiLanCungTapDong_ThiLanHaiBiChan_KhongTaoHdThua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var first = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
db.ChangeTracker.Clear();
var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*đã có Hợp đồng*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1, "double-click không đẻ HĐ thứ 2");
(await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0)))
.ContractId.Should().Be(first.ContractId, "dòng vẫn trỏ HĐ của lần bấm đầu");
var seq = await db.ContractCodeSequences.AsNoTracking().SingleAsync();
seq.LastSeq.Should().Be(1, "lần bấm 2 bị chặn TRƯỚC codegen ⇒ số RG-001 không cháy oan");
}
// ===================================================================
// T12 — [vá-7 spec-cụm-3 §②-7] NEO ACCEPTANCE B6, đo END-TO-END trên máy THẬT.
//
// Vì sao phải có dù K3 đã có test finalize: các test kia dừng ở "finalize ghi
// `ApprovedAmount`". Cầu K7 thì ĐỌC cột đó. Mối nối giữa hai vế chưa ai đo — mà chính nó
// là điều kiện sống của toàn bộ happy-path (invest `:154`: "không có K3 thì `ApprovedAmount`
// NULL 100%" ⇒ cầu chặn mọi phiếu). Ở đây phiếu đi TRỌN đường qua
// `ContractSigningPlanWorkflowService` THẬT (trình → duyệt → terminal chốt giá), KHÔNG seed
// thẳng Phase/giá — seed thẳng là tự đưa cho mình đáp án rồi chấm.
//
// Tương phản để phép đo không rỗng:
// dòng A: `ApprovedAmount` = 111 CÓ SẴN (người có thẩm quyền chốt tay), `Proposed` = 999
// dòng B: `ApprovedAmount` = null → finalize `??=` điền = `Proposed` = 250
// ⇒ `GiaTri` phải = 361. Σ `Proposed` = 1249. Nếu cầu (hoặc finalize) lấy nhầm nguồn thì
// ra 1249 / 1109 / 500 — mọi cách sai đều KHÁC 361.
//
// Vế cuối = yêu cầu chữ của vá-7 ("`Line.ContractId` không đổi sau finalize"): sau khi bắc
// cầu, phiếu ĐÃ terminal nên không còn đường finalize lần nữa — chứng bằng cách BẤM THẬT
// (`approve` → Conflict) rồi đọc lại cả `ContractId` lẫn `ApprovedAmount`.
// ===================================================================
[Fact]
public async Task T12_DiTronDuongFinalizeRoiBacCau_GiaTriLaSoDaCHOT_VaKhongDoiVeSau()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, Proposed: 999m, Approved: 111m), // đã chốt tay
new LineSeed(0, 100m, Proposed: 250m, Approved: null), // để finalize điền
], phase: ContractSigningPlanPhase.DangSoanThao);
// Quy trình KHKK (loại 10) 1 Bước × 1 Cấp — đường ngắn nhất tới terminal.
var approver = await fix.CreateUserAsync(
$"tp-{Guid.NewGuid():N}@test.local", "TP duyệt", null, []);
var khkkWf = NewContractWorkflow($"QT-KHKK-{Guid.NewGuid().ToString("N")[..6]}",
type: ApprovalWorkflowApplicableType.ContractSigningPlan);
db.ApprovalWorkflows.Add(khkkWf);
var step = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(), ApprovalWorkflowId = khkkWf.Id,
Order = 1, Name = "Bước 1", DepartmentId = null,
};
db.ApprovalWorkflowSteps.Add(step);
db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
{
Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id,
Order = 1, Name = "Cấp 1", ApproverUserId = approver.Id,
});
var plan = await db.ContractSigningPlans.SingleAsync(p => p.Id == s.Plan.Id);
plan.ApprovalWorkflowId = khkkWf.Id;
await db.SaveChangesAsync(CancellationToken.None);
db.ChangeTracker.Clear();
var dt = new FixedDateTime(Clock);
ContractSigningPlanWorkflowService KhkkSvc(User u, params string[] roles)
=> new(db, new TestCurrentUser(u.Id, u.FullName, u.Email, roles), dt,
new NoOpNotificationServiceApp());
// ---- Trình → duyệt (Cấp cuối của Bước cuối ⇒ terminal + chốt giá).
await KhkkSvc(s.Actor, AppRoles.Drafter)
.TransitionAsync(s.Plan.Id, "submit", null, ct: CancellationToken.None);
var afterApprove = await KhkkSvc(approver)
.TransitionAsync(s.Plan.Id, "approve", "ok", ct: CancellationToken.None);
afterApprove.Phase.Should().Be((int)ContractSigningPlanPhase.DaDuyet,
"B6 neo (a): phiếu phải TỰ đi tới Đã duyệt qua máy, không phải do test gán");
db.ChangeTracker.Clear();
var frozen = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ProposedAmount).ToListAsync();
frozen.Select(l => l.ApprovedAmount).Should().BeEquivalentTo(new decimal?[] { 250m, 111m },
"B6 neo (b): finalize `??=` — dòng chốt tay GIỮ 111 (không bị 999 đè), dòng trống lấy 250");
// ---- Bây giờ mới bắc cầu, trên đúng những con số vừa đông cứng.
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(
Cmd(s, frozen.Select(l => l.Id)), CancellationToken.None);
var c = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);
c.GiaTri.Should().Be(361m,
"= 111 + 250 (số ĐÃ CHỐT). Σ ProposedAmount = 1249 — mọi cách lấy nhầm nguồn đều ra số khác");
// ---- Vế cuối vá-7: sau khi bắc cầu, phiếu terminal ⇒ không còn đường chốt lại giá.
db.ChangeTracker.Clear();
var act = async () => await KhkkSvc(approver)
.TransitionAsync(s.Plan.Id, "approve", "duyệt lại", ct: CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>();
var after = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ProposedAmount).ToListAsync();
after.Should().OnlyContain(l => l.ContractId == result.ContractId,
"`Line.ContractId` KHÔNG đổi sau khi phiếu đã terminal");
after.Select(l => l.ApprovedAmount).Should().BeEquivalentTo(new decimal?[] { 250m, 111m },
"giá đã chốt cũng không đổi — HĐ và phiếu kể cùng một câu chuyện tiền");
}
}