[CLAUDE] Contract: K2 SP-002 phieu KHKK mang hang-muc + nhom duyet N1-8 (Mig 71) + FE x2 app + 11 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m12s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m12s
Mig 71 AddKhkkApprovalGroupCatalogAndFinalizeRuntime: +ApprovalGroup (backfill=1)
+EndedByLevelFinalize (K2 de, K3 tieu - ghost-window khai) + Line +CatalogEntryId/
TenHangMuc + unique (Plan,Supplier,CatalogEntry) filtered IsDeleted=0. Rao
1-PE-1-phieu doi (PeId,ApprovalGroup) 2 site + picker OG-7 ExistingGroups + nut
'Tao them cho nhom khac'. Submit-guard va-5 (CHAN null, thang nen cho-qua-legacy;
ghost-window K2->K4b khai) + dem-so-khop (gate F-1: bit duong hang-muc xoa-mem).
List ?group= end-to-end + Tong-Approved list (va-8). FE: select nhom default 1 +
badge N{g} + cot Hang-muc + cot Tong-duyet, SHA-pair 5/5. Gate K2 PASS-WITH-FLAGS
10f: F-1 guard fix + 2 test bit (xoa-mem chan + reflection ?group=); F-2/F-5/F-7/
F-8/F-9 comment-honest; F-3/F-4 docs-delta. Suite 609/0 (45D+564I). Wave co #53 x5,
artifact + relaunch-cat-got cuu 100% (0 mat cong).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -7,6 +7,7 @@ using SolutionErp.Application.ContractSigningPlans.Services;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.ContractSigningPlans;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master.Catalogs; // [K2 S164] fixture gán hạng mục cho line trước trình
|
||||
using SolutionErp.Infrastructure.Persistence;
|
||||
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
@ -196,6 +197,22 @@ public class ContractSigningPlanApprovalTests
|
||||
// Seed dưới danh nghĩa NGƯỜI SOẠN ⇒ interceptor ghi `CreatedBy = drafter` (xem ghi chú (1)).
|
||||
await using var db = f.NewDb(drafter);
|
||||
|
||||
// [K2 S164 — vá-5] SubmitAsync nay CHẶN dòng chưa gán hạng mục + hạng mục khác nhóm.
|
||||
// Fixture gán MỌI line vào 1 hạng mục CÙNG NHÓM với phiếu (entity default ApprovalGroup=1)
|
||||
// — mirror phiếu "đã gán đủ" trước trình; Code random né unique-filtered giữa các plan.
|
||||
var catalog = new ContractCatalogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "T-" + Guid.NewGuid().ToString("N")[..8],
|
||||
TenVi = "Hạng mục test W3",
|
||||
GroupCode = "B1",
|
||||
ApprovalGroup = 1,
|
||||
SignerRole = "TP.PRO",
|
||||
IsActive = true,
|
||||
SortOrder = 1,
|
||||
};
|
||||
db.ContractCatalogEntries.Add(catalog);
|
||||
|
||||
var plan = new ContractSigningPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@ -219,6 +236,8 @@ public class ContractSigningPlanApprovalTests
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = Guid.NewGuid(), // loose-Guid (`ContractSigningPlanLine.cs:15`)
|
||||
CatalogEntryId = catalog.Id, // [K2] đã gán hạng mục ⇒ qua submit-guard
|
||||
TenHangMuc = catalog.TenVi,
|
||||
PeReferenceAmount = l.PeReference,
|
||||
ProposedAmount = l.Proposed,
|
||||
ApprovedAmount = l.Approved,
|
||||
@ -230,6 +249,55 @@ public class ContractSigningPlanApprovalTests
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// [gate-K2 F-1] Submit-guard phải hỏi "MỌI hạng mục resolve CÒN SỐNG + đúng nhóm".
|
||||
// Hạng mục xoá mềm biến khỏi query (filter !IsDeleted) — guard kiểu Any(sai-nhóm)
|
||||
// đọc vắng-mặt thành sạch và cho TRÌNH QUA. Test khoá: đếm-so-khớp phải CHẶN.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Submit_HangMucDaXoaMem_ThiBiChan()
|
||||
{
|
||||
using var f = new KhkkWorkflowFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var a = await AddUserAsync(f, "PMH cấp 1");
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-F1", steps: [[[a.Id]]]);
|
||||
var planId = await SeedPlanAsync(f, drafter, wf, "KH-F1", ContractSigningPlanPhase.DangSoanThao,
|
||||
null, null, [new LineSeed(100m, 90m)]);
|
||||
|
||||
// Xoá mềm hạng mục line đang trỏ (app-layer set cờ — mirror khuôn SchemaTests:
|
||||
// TestApplicationDbContext không wire AuditingInterceptor cho Remove()).
|
||||
await using (var db = f.NewDb(TestCurrentUser.System()))
|
||||
{
|
||||
var catIds = await db.ContractSigningPlanLines
|
||||
.Where(l => l.ContractSigningPlanId == planId)
|
||||
.Select(l => l.CatalogEntryId!.Value).Distinct().ToListAsync(CancellationToken.None);
|
||||
var cats = await db.ContractCatalogEntries.Where(c => catIds.Contains(c.Id))
|
||||
.ToListAsync(CancellationToken.None);
|
||||
foreach (var c in cats) { c.IsDeleted = true; c.DeletedAt = DateTime.UtcNow; }
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var act = async () => await ActAsync(f, drafter, planId, "submit");
|
||||
await act.Should().ThrowAsync<ConflictException>(
|
||||
"hạng mục đã xoá mềm không resolve được — guard đếm-so-khớp phải chặn trình");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// [gate-K2 F-6] Khoá TÊN THAM SỐ HTTP của bộ lọc nhóm: controller bind `?group=`
|
||||
// (một chỗ nền viết `?approvalGroup=` — gõ theo đó là lọc câm trả tất, HTTP 200).
|
||||
// Reflection giữ tên ở tầng API để drift kêu trong CI thay vì sai im lặng trên prod.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public void List_ThamSoLocNhom_BindDungTenGroup()
|
||||
{
|
||||
var p = typeof(SolutionErp.Api.Controllers.ContractSigningPlansController)
|
||||
.GetMethod("List")!.GetParameters().Single(x => x.Name == "group");
|
||||
var attr = (Microsoft.AspNetCore.Mvc.FromQueryAttribute)p
|
||||
.GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.FromQueryAttribute), false).Single();
|
||||
attr.Name.Should().Be("group",
|
||||
"controller expose ?group= (spec-tổng K2) — đổi tên là vỡ FE K4b lẫn acceptance K8");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Act / Read helpers — gọi service THẬT, KHÔNG mock.
|
||||
// ===================================================================
|
||||
|
||||
@ -0,0 +1,824 @@
|
||||
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.Common.Models; // PagedResult / PagedRequest
|
||||
using SolutionErp.Application.ContractSigningPlans;
|
||||
using SolutionErp.Application.ContractSigningPlans.Services; // ContractSigningPlanTransitionResult
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.ContractSigningPlans;
|
||||
using SolutionErp.Domain.Identity; // AppRoles + User
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.Master.Catalogs; // ContractCatalogEntry (K1 — Mig 70)
|
||||
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;
|
||||
|
||||
// [K2 KHKK — S164 2026-07-31] Phiếu Kế hoạch ký kết mang HẠNG MỤC (danh mục SP-002) + NHÓM
|
||||
// DUYỆT N1..N8 (Mig 71) — test-AFTER (`docs/rules.md §7`: BE đã land, spec ĐÓNG BĂNG).
|
||||
//
|
||||
// Code under test (đo lại trên đĩa, KHÔNG chép số từ sổ):
|
||||
// submit-guard hạng mục `ContractSigningPlanWorkflowService.cs:174-182` (vá-5 spec-cụm1)
|
||||
// rào (PeId, ApprovalGroup) `ContractSigningPlanFeatures.cs:393-402`
|
||||
// list `?group=` `ContractSigningPlanFeatures.cs:737-738`
|
||||
// Σ đã-chốt (vá-8) `ContractSigningPlanFeatures.cs:803-804`
|
||||
// gate ghi nháp `ContractSigningPlanFeatures.cs:229-237`
|
||||
// site gán-Phase-qua-BIẾN `ContractSigningPlanWorkflowService.cs:327-359`
|
||||
//
|
||||
// ── 3 quyết định hạ tầng (mỗi cái là ĐIỀU KIỆN để phép đo có nghĩa) ─────────────
|
||||
//
|
||||
// (1) File RIÊNG + fixture RIÊNG (mirror `ContractSigningPlanCrudTests.cs:49-82`) thay vì
|
||||
// nối vào 2 file KHKK sẵn có: cả hai file đó vừa được sửa theo spec K2 ở lane khác —
|
||||
// chèn thêm vào chúng là hai tay cùng ghi một tệp. Fixture wire `AuditingInterceptor`
|
||||
// vì guard trình đọc `plan.CreatedBy` (`…Service.cs:222`); `TestApplicationDbContext`
|
||||
// trần KHÔNG wire ⇒ `CreatedBy` null ⇒ submit có thể đỏ/xanh vì LÝ DO SAI (bài S155).
|
||||
//
|
||||
// (2) MỖI lệnh một `DbContext` mới trên CÙNG một connection — mô phỏng scoped-per-request.
|
||||
// Dùng lại context của seed thì Lines/Changelogs đã nằm sẵn trong change-tracker, một
|
||||
// đường đi không có thật ở prod (ở đó service chỉ có thứ nó tự `Include`).
|
||||
//
|
||||
// (3) Seed User bằng `db.Users.Add` thẳng, KHÔNG kéo `IdentityFixture`: các handler dưới
|
||||
// test nhận vai qua `ICurrentUser.Roles`; `ApprovalWorkflowLevel.ApproverUserId` chỉ
|
||||
// cần ROW tồn tại (FK Restrict).
|
||||
//
|
||||
// ⚠️ QUAN SÁT (KHÔNG phải bug-claim, không pin thành test): submit-guard dùng
|
||||
// `plan.Lines.Any(l => l.CatalogEntryId == null)` ⇒ phiếu KHÔNG có dòng nào sẽ đi lọt cả
|
||||
// hai vế guard. Đường tạo phiếu của K2 luôn sinh ≥1 dòng (mỗi winner 1 dòng, và create
|
||||
// ném khi 0 winner) nên hiện KHÔNG với tới được; nếu K4b mở đường xoá dòng thì khe này
|
||||
// thành thật. Vì thế các test dưới đây đo trên phiếu CÓ dòng thật (DoD vá-5: chống
|
||||
// PASS-bằng-tập-rỗng) và assert số dòng trước khi trình.
|
||||
public class ContractSigningPlanGroupAndCatalogTests
|
||||
{
|
||||
private static readonly DateTime Clock2026 = new(2026, 7, 31, 3, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ===================================================================
|
||||
// Fixture: SQLite in-memory + AuditingInterceptor.
|
||||
// ===================================================================
|
||||
private sealed class KhkkGroupFixture : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _conn;
|
||||
public FixedDateTime Clock { get; } = new(Clock2026);
|
||||
|
||||
public KhkkGroupFixture()
|
||||
{
|
||||
_conn = new SqliteConnection("DataSource=:memory:");
|
||||
_conn.Open();
|
||||
using var boot = NewDb(TestCurrentUser.System());
|
||||
boot.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
/// `actor` phải là ĐÚNG người mà lệnh chạy dưới danh nghĩa — prod resolve CÙNG một
|
||||
/// `ICurrentUser` scoped cho cả interceptor lẫn handler.
|
||||
public TestApplicationDbContext NewDb(ICurrentUser actor)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(_conn)
|
||||
.EnableSensitiveDataLogging()
|
||||
.AddInterceptors(new AuditingInterceptor(actor, Clock))
|
||||
.Options;
|
||||
return new TestApplicationDbContext(options);
|
||||
}
|
||||
|
||||
public void Dispose() => _conn.Dispose();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Seed helpers
|
||||
// ===================================================================
|
||||
|
||||
private static TestCurrentUser As(User u, params string[] roles)
|
||||
=> new(u.Id, u.FullName, u.Email, roles);
|
||||
|
||||
private static async Task<User> AddUserAsync(KhkkGroupFixture f, string fullName)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
var email = $"u{Guid.NewGuid():N}@test.local";
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FullName = fullName,
|
||||
UserName = email,
|
||||
NormalizedUserName = email.ToUpperInvariant(),
|
||||
Email = email,
|
||||
NormalizedEmail = email.ToUpperInvariant(),
|
||||
EmailConfirmed = true,
|
||||
SecurityStamp = Guid.NewGuid().ToString("N"),
|
||||
ConcurrencyStamp = Guid.NewGuid().ToString("N"),
|
||||
IsActive = true,
|
||||
CreatedAt = Clock2026,
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return user;
|
||||
}
|
||||
|
||||
/// 1 dòng danh mục SP-002 thuộc `approvalGroup`. `Code` random né UNIQUE filtered
|
||||
/// (`ContractCatalogEntryConfiguration` — K1 Mig 70).
|
||||
private static async Task<Guid> AddCatalogAsync(KhkkGroupFixture f, int approvalGroup, string tenVi)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
var entry = new ContractCatalogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "K2-" + Guid.NewGuid().ToString("N")[..8],
|
||||
TenVi = tenVi,
|
||||
GroupCode = "B1",
|
||||
ApprovalGroup = approvalGroup,
|
||||
SignerRole = "TP.PRO",
|
||||
IsActive = true,
|
||||
SortOrder = 1,
|
||||
};
|
||||
db.ContractCatalogEntries.Add(entry);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return entry.Id;
|
||||
}
|
||||
|
||||
/// `steps[i][j]` = NV của Cấp j+1 trong Bước i+1 (OR-of-N nếu nhiều NV cùng Cấp).
|
||||
private static async Task<Guid> SeedWorkflowAsync(
|
||||
KhkkGroupFixture f,
|
||||
string code,
|
||||
Guid[][][] steps,
|
||||
ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.ContractSigningPlan)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = code,
|
||||
Version = 1,
|
||||
Name = "Quy trình duyệt Kế hoạch ký kết",
|
||||
ApplicableType = type,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
|
||||
for (var s = 0; s < steps.Length; s++)
|
||||
{
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = s + 1,
|
||||
Name = $"Bước {s + 1}",
|
||||
DepartmentId = null, // null = né FK phòng ban (Pattern 11)
|
||||
};
|
||||
db.ApprovalWorkflowSteps.Add(step);
|
||||
|
||||
for (var l = 0; l < steps[s].Length; l++)
|
||||
foreach (var approverId in steps[s][l])
|
||||
db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = l + 1,
|
||||
Name = $"Cấp {l + 1}",
|
||||
ApproverUserId = approverId,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf.Id;
|
||||
}
|
||||
|
||||
/// Dòng phiếu: `CatalogId = null` = dòng CHƯA gán hạng mục (đúng trạng thái auto-gen
|
||||
/// `ContractSigningPlanFeatures.cs:454-455`).
|
||||
private sealed record LineSeed(
|
||||
decimal PeReference, decimal Proposed, Guid? CatalogId, decimal? Approved = null);
|
||||
|
||||
private static async Task<Guid> SeedPlanAsync(
|
||||
KhkkGroupFixture f,
|
||||
TestCurrentUser drafter,
|
||||
Guid? workflowId,
|
||||
string maKeHoach,
|
||||
ContractSigningPlanPhase phase,
|
||||
int approvalGroup,
|
||||
LineSeed[] lines,
|
||||
int? stepIndex = null,
|
||||
int? levelOrder = null,
|
||||
Guid? peId = null)
|
||||
{
|
||||
await using var db = f.NewDb(drafter); // interceptor ghi CreatedBy = người soạn
|
||||
|
||||
var plan = new ContractSigningPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MaKeHoach = maKeHoach,
|
||||
PurchaseEvaluationId = peId ?? Guid.NewGuid(), // loose-Guid, không FK vật lý
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DepartmentId = Guid.NewGuid(),
|
||||
DrafterUserId = drafter.UserId!.Value,
|
||||
Phase = phase,
|
||||
ApprovalGroup = approvalGroup,
|
||||
ApprovalWorkflowId = workflowId,
|
||||
CurrentWorkflowStepIndex = stepIndex,
|
||||
CurrentApprovalLevelOrder = levelOrder,
|
||||
};
|
||||
|
||||
foreach (var l in lines)
|
||||
plan.Lines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = Guid.NewGuid(),
|
||||
CatalogEntryId = l.CatalogId,
|
||||
TenHangMuc = l.CatalogId is null ? null : "Hạng mục seed",
|
||||
PeReferenceAmount = l.PeReference,
|
||||
ProposedAmount = l.Proposed,
|
||||
ApprovedAmount = l.Approved,
|
||||
});
|
||||
|
||||
db.ContractSigningPlans.Add(plan);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
// ---- Act / Read helpers — service + handler THẬT, không mock ----
|
||||
|
||||
private static async Task<ContractSigningPlanTransitionResult> ActAsync(
|
||||
KhkkGroupFixture f, TestCurrentUser actor, Guid planId, string action, string? comment = null)
|
||||
{
|
||||
await using var db = f.NewDb(actor);
|
||||
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
|
||||
return await svc.TransitionAsync(planId, action, comment, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkGroupFixture f, Guid planId)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
return await db.ContractSigningPlans.AsNoTracking()
|
||||
.Include(p => p.Lines)
|
||||
.SingleAsync(p => p.Id == planId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<PagedResult<ContractSigningPlanListItemDto>> ListAsync(
|
||||
KhkkGroupFixture f, TestCurrentUser actor, int? group)
|
||||
{
|
||||
await using var db = f.NewDb(actor);
|
||||
var handler = new ListContractSigningPlansQueryHandler(db, actor);
|
||||
return await handler.Handle(
|
||||
new ListContractSigningPlansQuery(ApprovalGroup: group) { Page = 1, PageSize = 50 },
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-a) SUBMIT-GUARD null — vá-5 spec-cụm1 (`…Service.cs:174-176`), THẮNG nền `:126`.
|
||||
//
|
||||
// Đo trên phiếu CÓ DÒNG THẬT (DoD chống PASS-bằng-tập-rỗng) và bằng CẶP CÙNG-RUỘT khác
|
||||
// đúng 1 biến: hai phiếu y hệt nhau (cùng workflow, cùng nhóm, cùng 2 dòng, cùng người
|
||||
// soạn), chỉ khác việc dòng thứ hai đã gán hạng mục hay chưa. Thiếu vế dương thì test âm
|
||||
// vẫn xanh trong cái thế giới mà submit hỏng vì lý do khác (workflow rỗng, guard quyền…).
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Submit_ConDongChuaGanHangMuc_ThiBiChan_GanDuThiTrinhQua()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var approver = await AddUserAsync(f, "NV Cung ứng duyệt Cấp 1");
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-N1", steps: [[[approver.Id]]]);
|
||||
var catalog = await AddCatalogAsync(f, approvalGroup: 1, tenVi: "Thi công phần thô");
|
||||
|
||||
// ---- Ca ÂM: dòng 2 còn NULL.
|
||||
var pBad = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/001",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
|
||||
lines: [new(100m, 100m, catalog), new(200m, 200m, CatalogId: null)]);
|
||||
|
||||
var beforeBad = await ReadPlanAsync(f, pBad);
|
||||
beforeBad.Lines.Should().HaveCount(2,
|
||||
"guard phải được đo trên phiếu CÓ dòng thật — phiếu rỗng đi lọt `Any(...)` nên "
|
||||
+ "một test seed 0 dòng sẽ xanh mà chẳng chứng minh điều gì (DoD vá-5)");
|
||||
|
||||
Func<Task> actBad = async () => await ActAsync(f, drafter, pBad, "submit");
|
||||
(await actBad.Should().ThrowAsync<ConflictException>(
|
||||
"còn dòng chưa gán hạng mục SP-002 thì chưa đủ căn cứ để trình duyệt"))
|
||||
.WithMessage("*chưa gán hạng mục*");
|
||||
|
||||
var afterBad = await ReadPlanAsync(f, pBad);
|
||||
afterBad.Phase.Should().Be(ContractSigningPlanPhase.DangSoanThao,
|
||||
"bị chặn thì phiếu phải NGUYÊN Ở NHÁP — ném xong mà vẫn đổi phase còn tệ hơn không rào");
|
||||
afterBad.CurrentWorkflowStepIndex.Should().BeNull("chưa trình thì chưa có con-trỏ Bước");
|
||||
afterBad.CurrentApprovalLevelOrder.Should().BeNull("chưa trình thì chưa có con-trỏ Cấp");
|
||||
|
||||
// ---- Ca DƯƠNG (đối chứng, đổi ĐÚNG 1 biến): dòng 2 đã gán.
|
||||
var pOk = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/002",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
|
||||
lines: [new(100m, 100m, catalog), new(200m, 200m, catalog)]);
|
||||
|
||||
var res = await ActAsync(f, drafter, pOk, "submit");
|
||||
res.Phase.Should().Be((int)ContractSigningPlanPhase.ChoDuyet);
|
||||
|
||||
var afterOk = await ReadPlanAsync(f, pOk);
|
||||
afterOk.Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet,
|
||||
"cùng bộ seed đó, gán đủ hạng mục thì phải trình được — nếu vế này cũng đỏ thì "
|
||||
+ "ca âm ở trên đang bắt một lỗi khác chứ không phải guard hạng mục");
|
||||
afterOk.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
afterOk.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-b) SUBMIT-GUARD khác nhóm — `…Service.cs:177-182` (code mẫu nền K2.d).
|
||||
//
|
||||
// 3 vế, mỗi vế bắt một lỗi khác nhau:
|
||||
// • phiếu nhóm 1 + hạng mục nhóm 2 → 409 (vế chính)
|
||||
// • phiếu nhóm 1 + [hạng mục nhóm 1, hạng mục nhóm 2] → 409 (chứng vị ngữ là ANY,
|
||||
// không phải "mọi dòng đều sai"; cài đặt bằng `All` sẽ cho lọt phiếu pha trộn)
|
||||
// • phiếu nhóm 2 + hạng mục nhóm 2 → QUA (chứng guard so theo nhóm
|
||||
// CỦA PHIẾU, không phải hằng số 1)
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Submit_HangMucKhacNhomDuyetCuaPhieu_ThiBiChan_CungNhomThiQua()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var approver = await AddUserAsync(f, "NV duyệt");
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-MIX", steps: [[[approver.Id]]]);
|
||||
|
||||
var catN1 = await AddCatalogAsync(f, approvalGroup: 1, tenVi: "Hạng mục nhóm 1");
|
||||
var catN2 = await AddCatalogAsync(f, approvalGroup: 2, tenVi: "Hạng mục nhóm 2");
|
||||
|
||||
// ---- Vế 1: phiếu nhóm 1, dòng duy nhất thuộc nhóm 2.
|
||||
var pWrong = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/010",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
|
||||
lines: [new(100m, 100m, catN2)]);
|
||||
|
||||
Func<Task> actWrong = async () => await ActAsync(f, drafter, pWrong, "submit");
|
||||
(await actWrong.Should().ThrowAsync<ConflictException>(
|
||||
"hạng mục quyết định workflow nào duyệt phiếu — lệch nhóm là trình sai hội đồng"))
|
||||
.WithMessage("*N1*");
|
||||
|
||||
(await ReadPlanAsync(f, pWrong)).Phase.Should().Be(ContractSigningPlanPhase.DangSoanThao,
|
||||
"bị chặn ⇒ phiếu bất động");
|
||||
|
||||
// ---- Vế 2: phiếu PHA TRỘN — 1 dòng đúng nhóm + 1 dòng sai nhóm.
|
||||
var pMixed = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/011",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
|
||||
lines: [new(100m, 100m, catN1), new(200m, 200m, catN2)]);
|
||||
|
||||
Func<Task> actMixed = async () => await ActAsync(f, drafter, pMixed, "submit");
|
||||
await actMixed.Should().ThrowAsync<ConflictException>(
|
||||
"chỉ cần MỘT dòng lệch nhóm là phiếu đã sai hội đồng duyệt — cài bằng `All(...)` "
|
||||
+ "thì phiếu pha trộn lọt qua mà vẫn trông như có rào");
|
||||
|
||||
// ---- Vế 3 (đối chứng): phiếu nhóm 2 + hạng mục nhóm 2 ⇒ QUA.
|
||||
var pN2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/012",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 2,
|
||||
lines: [new(100m, 100m, catN2)]);
|
||||
|
||||
await ActAsync(f, drafter, pN2, "submit");
|
||||
(await ReadPlanAsync(f, pN2)).Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet,
|
||||
"hạng mục nhóm 2 trên phiếu nhóm 2 là hợp lệ — nếu vế này cũng chặn thì guard đang "
|
||||
+ "so với hằng số 1 chứ không phải với nhóm của phiếu");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-c) Rào (PeId, ApprovalGroup) qua CreateHandler THẬT — `Features.cs:393-402`.
|
||||
//
|
||||
// Trước K2 rào là "1 PE = 1 phiếu" tuyệt đối; K2 nới thành 1 phiếu SỐNG MỖI NHÓM.
|
||||
// Đo 4 nước liên tiếp trên CÙNG một PE: nhóm 1 → nhóm 2 → nhóm 2 lần nữa (409) →
|
||||
// nhóm 1 lần nữa (409). Kèm phép đo phụ "KHÔNG ĐỐT MÃ": rào đứng TRƯỚC codegen
|
||||
// (`:395` vs `:413`) ⇒ 2 lần 409 không được làm nhảy số thứ tự.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Create_CungPeCungNhom_ThiConflict_KhacNhom_ThiTaoDuoc_VaKhongDotMa()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var seeded = await SeedPeForCreateAsync(f);
|
||||
|
||||
var g1 = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 1);
|
||||
g1.LineCount.Should().Be(1, "PE có đúng 1 NCC trúng thầu ⇒ 1 dòng");
|
||||
|
||||
var g2 = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 2);
|
||||
g2.Id.Should().NotBe(g1.Id, "cùng PE khác NHÓM = phiếu khác, không phải cập nhật phiếu cũ");
|
||||
g2.MaKeHoach.Should().NotBe(g1.MaKeHoach, "mỗi phiếu một mã");
|
||||
|
||||
// ---- 409 nhóm 2 (đã có phiếu sống nhóm 2).
|
||||
Func<Task> dupG2 = async () =>
|
||||
await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 2);
|
||||
(await dupG2.Should().ThrowAsync<ConflictException>(
|
||||
"1 PE chỉ được 1 kế hoạch SỐNG mỗi nhóm duyệt"))
|
||||
.WithMessage("*N2*");
|
||||
|
||||
// ---- 409 nhóm 1 (chứng rào không phải "chỉ chặn nhóm vừa tạo").
|
||||
Func<Task> dupG1 = async () =>
|
||||
await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 1);
|
||||
(await dupG1.Should().ThrowAsync<ConflictException>()).WithMessage("*N1*");
|
||||
|
||||
await using var db = f.NewDb(drafter);
|
||||
db.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId)
|
||||
.Should().Be(2, "hai lần 409 KHÔNG được để lại phiếu nửa vời");
|
||||
db.ContractSigningPlans.Where(p => p.PurchaseEvaluationId == seeded.PeId)
|
||||
.Select(p => p.ApprovalGroup).OrderBy(x => x).ToList()
|
||||
.Should().Equal([1, 2], "hai phiếu sống thuộc hai nhóm khác nhau");
|
||||
|
||||
// Rào đứng TRƯỚC codegen ⇒ số thứ tự chỉ nhảy đúng 2 lần (2 phiếu tạo được).
|
||||
db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq
|
||||
.Should().Be(2, "409 xảy ra trước khi cấp mã ⇒ không đốt số; đổi thứ tự hai bước "
|
||||
+ "này thì dải mã KHKK thủng lỗ mà chẳng phiếu nào mang");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-c bis) Phiếu bị TỪ CHỐI không chiếm chỗ — vế `Phase != TuChoi` của rào.
|
||||
//
|
||||
// Chính vế này là lý do BE CỐ Ý không dựng unique (PeId, ApprovalGroup) dưới DB
|
||||
// (`ContractSigningPlanConfiguration.cs:33-37`): unique không diễn tả được "trừ phiếu
|
||||
// đã từ chối". Test khoá cả hai chiều — TuChoi nhường chỗ, ChoDuyet thì KHÔNG.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Create_PhieuNhomDaTuChoi_KhongChiemCho_PhieuDangChoDuyet_ThiChiemCho()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var seeded = await SeedPeForCreateAsync(f);
|
||||
|
||||
var first = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 3);
|
||||
|
||||
// Phiếu bị từ chối (kết quả của nhánh reject — ở đây set thẳng phase để cô lập rào create).
|
||||
await using (var db = f.NewDb(drafter))
|
||||
{
|
||||
var plan = await db.ContractSigningPlans.SingleAsync(p => p.Id == first.Id);
|
||||
plan.Phase = ContractSigningPlanPhase.TuChoi;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var again = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 3);
|
||||
again.Id.Should().NotBe(first.Id,
|
||||
"phiếu bị từ chối là ngõ cụt ⇒ phải lập lại được cho CÙNG nhóm, bằng một phiếu MỚI");
|
||||
|
||||
// ---- Vế đối chứng: phiếu vừa tạo đang SỐNG (Nháp) ⇒ nhóm 3 lại bị khoá.
|
||||
Func<Task> third = async () =>
|
||||
await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 3);
|
||||
(await third.Should().ThrowAsync<ConflictException>(
|
||||
"phiếu sống mới lập lại chiếm chỗ nhóm 3 — nếu vế này cũng qua thì rào đã chết hẳn "
|
||||
+ "chứ không phải 'TuChoi được miễn'"))
|
||||
.WithMessage("*N3*");
|
||||
|
||||
await using var check = f.NewDb(drafter);
|
||||
check.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId
|
||||
&& p.Phase != ContractSigningPlanPhase.TuChoi)
|
||||
.Should().Be(1, "đúng MỘT phiếu sống cho nhóm 3");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-d) List lọc theo nhóm — `Features.cs:737-738`.
|
||||
//
|
||||
// Hai điều dễ hỏng, test khoá cả hai:
|
||||
// • lọc ở CLIENT (lấy hết rồi `.Where` trên list): `Items` trông y hệt nhưng `Total`
|
||||
// vẫn là cả thế giới ⇒ phân trang sai. Vì thế assert CẢ `Total`, không chỉ `Items`.
|
||||
// • lọc đặt NHẦM vào trong khối IDOR (`:720-729`) ⇒ chỉ đúng cho một vai. Vì thế đo
|
||||
// bằng CẢ non-admin (chủ phiếu) LẪN admin.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task List_LocTheoNhomDuyet_ChiTraDungNhom_VaTotalKhopFilter()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var admin = As(await AddUserAsync(f, "Quản trị"), AppRoles.Admin);
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-LIST", steps: [[[drafter.UserId!.Value]]]);
|
||||
var cat = await AddCatalogAsync(f, 1, "Hạng mục list");
|
||||
|
||||
LineSeed[] one = [new(10m, 10m, cat)];
|
||||
await SeedPlanAsync(f, drafter, wf, "KHKK/2026/020", ContractSigningPlanPhase.DangSoanThao, 1, one);
|
||||
await SeedPlanAsync(f, drafter, wf, "KHKK/2026/021", ContractSigningPlanPhase.DangSoanThao, 1, one);
|
||||
await SeedPlanAsync(f, drafter, wf, "KHKK/2026/022", ContractSigningPlanPhase.DangSoanThao, 2, one);
|
||||
|
||||
foreach (var (actor, vai) in new[] { (drafter, "người soạn"), (admin, "admin") })
|
||||
{
|
||||
var all = await ListAsync(f, actor, group: null);
|
||||
all.Total.Should().Be(3, $"{vai}: không lọc ⇒ thấy cả 3 phiếu (giữ nguyên hành vi trước K2)");
|
||||
all.Items.Should().HaveCount(3);
|
||||
|
||||
var g2 = await ListAsync(f, actor, group: 2);
|
||||
g2.Items.Should().ContainSingle($"{vai}: chỉ 1 phiếu thuộc nhóm 2")
|
||||
.Which.ApprovalGroup.Should().Be(2, "DTO phải mang nhóm để FE gắn badge/cây folder");
|
||||
g2.Total.Should().Be(1,
|
||||
$"{vai}: `Total` là dấu vân tay của việc lọc CHẠY DƯỚI DB — lọc ở client thì "
|
||||
+ "Items đúng mà Total vẫn là 3");
|
||||
|
||||
var g1 = await ListAsync(f, actor, group: 1);
|
||||
g1.Total.Should().Be(2, $"{vai}: 2 phiếu nhóm 1");
|
||||
g1.Items.Should().OnlyContain(i => i.ApprovalGroup == 1);
|
||||
|
||||
var g8 = await ListAsync(f, actor, group: 8);
|
||||
g8.Total.Should().Be(0, $"{vai}: nhóm chưa có phiếu ⇒ rỗng, KHÔNG rơi về 'trả hết'");
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-e) Σ ĐÃ CHỐT trên list — vá-8 (`Features.cs:803-804`).
|
||||
//
|
||||
// Thiếu Σ này thì màn danh sách khoe số ĐỀ XUẤT còn màn chi tiết khoá theo số ĐÃ CHỐT =
|
||||
// hai màn nói hai con số về cùng một phiếu. Seed cố ý cho Proposed ≠ Approved để một cài
|
||||
// đặt "Σ Approved = Σ Proposed" (copy nhầm dòng trên) lộ ra ngay.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task List_TongDaChot_CongTheoApprovedAmount_PhieuChuaDuyetThiBangKhong()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-SUM", steps: [[[drafter.UserId!.Value]]]);
|
||||
var cat = await AddCatalogAsync(f, 1, "Hạng mục Σ");
|
||||
|
||||
// Phiếu ĐÃ DUYỆT: đề xuất 70+50=120 nhưng chốt 30+20=50 (hai số KHÁC nhau).
|
||||
var approved = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/030",
|
||||
ContractSigningPlanPhase.DaDuyet, 1,
|
||||
[new(80m, 70m, cat, Approved: 30m), new(60m, 50m, cat, Approved: 20m)]);
|
||||
|
||||
// Phiếu NHÁP: có tiền đề xuất nhưng chưa chốt đồng nào.
|
||||
var draft = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/031",
|
||||
ContractSigningPlanPhase.DangSoanThao, 1,
|
||||
[new(111m, 111m, cat)]);
|
||||
|
||||
// Phiếu CHỐT DỞ (1 dòng có, 1 dòng chưa) — khoá ngữ nghĩa "Σ bỏ qua dòng null".
|
||||
var partial = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/032",
|
||||
ContractSigningPlanPhase.ChoDuyet, 1,
|
||||
[new(40m, 40m, cat, Approved: 10m), new(40m, 40m, cat)]);
|
||||
|
||||
var page = await ListAsync(f, drafter, group: null);
|
||||
|
||||
var a = page.Items.Single(i => i.Id == approved);
|
||||
a.TotalApprovedAmount.Should().Be(50m, "Σ số ĐÃ CHỐT = 30 + 20");
|
||||
a.TotalProposedAmount.Should().Be(120m,
|
||||
"Σ đề xuất phải VẪN là 120 — hai Σ song song, không được cái này đè cái kia");
|
||||
a.TotalPeReferenceAmount.Should().Be(140m, "Σ tham chiếu PE bất biến (bài K2.e)");
|
||||
|
||||
var d = page.Items.Single(i => i.Id == draft);
|
||||
d.TotalApprovedAmount.Should().Be(0m, "phiếu chưa duyệt ⇒ mọi dòng null ⇒ 0");
|
||||
d.TotalProposedAmount.Should().Be(111m,
|
||||
"và số 0 ở trên phải là do null-coalesce chứ không phải do phiếu rỗng tiền");
|
||||
|
||||
var p = page.Items.Single(i => i.Id == partial);
|
||||
p.TotalApprovedAmount.Should().Be(10m,
|
||||
"Σ cộng đúng phần đã chốt, bỏ qua dòng chưa chốt — nếu ra 0 thì cài đặt đang null-hoá "
|
||||
+ "cả tổng khi gặp một dòng null");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-f) Gate ghi nháp — `ContractSigningPlanScope.EnsureDraftEditable` (`Features.cs:229-237`).
|
||||
//
|
||||
// BE lane chốt (`sub-implbe-k2.md §e`): K2 KHÔNG dựng guard mồ-côi cho line-edit; ràng buộc
|
||||
// là "mọi đường ghi (kể cả Lines-editor K4b) PHẢI đi qua gate này". Test đo 2 tầng:
|
||||
// (1) BẢN THÂN gate: ma trận 5 phase + vế quyền;
|
||||
// (2) gate ĐÃ ĐƯỢC NỐI vào một đường ghi THẬT (`UpdateContractSigningPlanDraftCommandHandler`
|
||||
// `:518`) và chặn được thật — chống ghost-wire (guard tồn tại nhưng không ai gọi).
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task EnsureDraftEditable_ChanMoiPhaseNgoaiNhapVaTraLai_VaDaNoiVaoDuongGhiThat()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var stranger = As(await AddUserAsync(f, "Người ngoài"));
|
||||
var admin = As(await AddUserAsync(f, "Quản trị"), AppRoles.Admin);
|
||||
|
||||
// ---- (1) Ma trận phase, gọi thẳng gate (InternalsVisibleTo).
|
||||
ContractSigningPlan PlanAt(ContractSigningPlanPhase phase) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DrafterUserId = drafter.UserId!.Value,
|
||||
Phase = phase,
|
||||
};
|
||||
|
||||
foreach (var open in new[] { ContractSigningPlanPhase.DangSoanThao, ContractSigningPlanPhase.TraLai })
|
||||
{
|
||||
var act = () => ContractSigningPlanScope.EnsureDraftEditable(PlanAt(open), drafter);
|
||||
act.Should().NotThrow($"{open} là trạng thái người soạn đang cầm phiếu — phải sửa được");
|
||||
}
|
||||
|
||||
foreach (var closed in new[]
|
||||
{
|
||||
ContractSigningPlanPhase.ChoDuyet,
|
||||
ContractSigningPlanPhase.DaDuyet,
|
||||
ContractSigningPlanPhase.TuChoi,
|
||||
})
|
||||
{
|
||||
var act = () => ContractSigningPlanScope.EnsureDraftEditable(PlanAt(closed), drafter);
|
||||
act.Should().Throw<ConflictException>(
|
||||
$"{closed}: phiếu đã rời tay người soạn — sửa/xoá dòng lúc này làm Σ của phiếu "
|
||||
+ "đã duyệt trôi ÂM THẦM (khe rò gián tiếp duy nhất, spec vá-6)")
|
||||
.WithMessage("*Nháp hoặc Trả lại*");
|
||||
}
|
||||
|
||||
// Admin vẫn bị chặn theo PHASE (vế quyền và vế phase là hai rào độc lập).
|
||||
var adminOnApproved = () =>
|
||||
ContractSigningPlanScope.EnsureDraftEditable(PlanAt(ContractSigningPlanPhase.DaDuyet), admin);
|
||||
adminOnApproved.Should().Throw<ConflictException>(
|
||||
"Admin bỏ qua vế QUYỀN chứ không bỏ qua vế PHASE — nếu Admin sửa được phiếu đã duyệt "
|
||||
+ "thì số đã chốt không còn là số chốt");
|
||||
|
||||
// Người ngoài bị chặn ngay ở vế quyền, kể cả phiếu đang mở.
|
||||
var strangerOnDraft = () =>
|
||||
ContractSigningPlanScope.EnsureDraftEditable(PlanAt(ContractSigningPlanPhase.DangSoanThao), stranger);
|
||||
strangerOnDraft.Should().Throw<ForbiddenException>("phiếu nháp là của người soạn (S89)");
|
||||
|
||||
// ---- (2) Gate đã NỐI vào đường ghi thật.
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-GATE", steps: [[[drafter.UserId!.Value]]]);
|
||||
var cat = await AddCatalogAsync(f, 1, "Hạng mục gate");
|
||||
var approvedPlan = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/040",
|
||||
ContractSigningPlanPhase.DaDuyet, 1, [new(10m, 10m, cat)]);
|
||||
|
||||
async Task UpdateAsync()
|
||||
{
|
||||
await using var db = f.NewDb(drafter);
|
||||
var handler = new UpdateContractSigningPlanDraftCommandHandler(db, drafter);
|
||||
await handler.Handle(
|
||||
new UpdateContractSigningPlanDraftCommand(approvedPlan, GhiChu: "sửa sau khi duyệt"),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
Func<Task> actUpdate = UpdateAsync;
|
||||
await actUpdate.Should().ThrowAsync<ConflictException>(
|
||||
"gate phải chặn TẠI handler thật, không chỉ tồn tại như một hàm không ai gọi");
|
||||
|
||||
await using var db2 = f.NewDb(drafter);
|
||||
(await db2.ContractSigningPlans.AsNoTracking().SingleAsync(p => p.Id == approvedPlan))
|
||||
.GhiChu.Should().BeNull("ném rồi thì KHÔNG được ghi gì xuống phiếu");
|
||||
db2.ContractSigningPlanChangelogs.Count(c => c.ContractSigningPlanId == approvedPlan)
|
||||
.Should().Be(0, "cũng không được để lại dòng nhật ký cho một lần sửa đã bị chặn");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-g) Mặc định nhóm 1 — `ContractSigningPlan.cs:39` + backfill Mig 71 (`:61`).
|
||||
//
|
||||
// Ngữ nghĩa backfill: MỌI phiếu sinh ra từ đường code cũ (không set nhóm) phải rơi vào
|
||||
// nhóm 1, khớp `UPDATE … SET ApprovalGroup = 1`. Kèm vế đối chứng "set 5 thì ra 5" —
|
||||
// thiếu vế đó, một cài đặt ép cứng 1 (default DB đè giá trị) vẫn xanh.
|
||||
// Đo luôn `EndedByLevelFinalize` (cột K2 ĐẺ, K3 tiêu thụ — ghost-window khai ở
|
||||
// `sub-implbe-k2.md §a vá-7`): tới khi K3 land, nó phải luôn false.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Entity_MacDinhNhomMot_VaGiuNguyenKhiGanTay_ConCoKetThucSomMacDinhTat()
|
||||
{
|
||||
new ContractSigningPlan().ApprovalGroup.Should().Be(1,
|
||||
"phiếu không khai nhóm = nhóm 1, đúng ngữ nghĩa backfill Mig 71 cho dữ liệu cũ");
|
||||
new ContractSigningPlan().EndedByLevelFinalize.Should().BeFalse(
|
||||
"cột runtime của K3: từ nay tới khi K3 land KHÔNG site nào ghi ⇒ luôn false");
|
||||
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
|
||||
// Ghi phiếu KHÔNG set ApprovalGroup — đúng hình dạng code-path cũ.
|
||||
var legacyId = Guid.NewGuid();
|
||||
await using (var db = f.NewDb(drafter))
|
||||
{
|
||||
db.ContractSigningPlans.Add(new ContractSigningPlan
|
||||
{
|
||||
Id = legacyId,
|
||||
MaKeHoach = "KHKK/2026/050",
|
||||
PurchaseEvaluationId = Guid.NewGuid(),
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = drafter.UserId!.Value,
|
||||
Phase = ContractSigningPlanPhase.DangSoanThao,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var legacy = await ReadPlanAsync(f, legacyId);
|
||||
legacy.ApprovalGroup.Should().Be(1, "round-trip qua DB vẫn là nhóm 1");
|
||||
legacy.EndedByLevelFinalize.Should().BeFalse();
|
||||
|
||||
// Đối chứng: gán tay nhóm 5 ⇒ đọc lại phải là 5 (default không được đè).
|
||||
var explicitId = await SeedPlanAsync(f, drafter, workflowId: null, "KHKK/2026/051",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 5, lines: []);
|
||||
(await ReadPlanAsync(f, explicitId)).ApprovalGroup.Should().Be(5,
|
||||
"giá trị người dùng chọn phải thắng giá trị mặc định — nếu cũng ra 1 thì cột đang bị "
|
||||
+ "ép cứng và mọi phiếu nhóm 2..8 sẽ âm thầm nằm sai folder");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-h) MỌI đường tới `DaDuyet` đều đi qua chốt giá — vá-2 spec-cụm1 phương án (ii).
|
||||
//
|
||||
// `ReturnOrRejectAsync` (`…Service.cs:327-359`) gán `plan.Phase = targetPhase` QUA BIẾN —
|
||||
// đúng lớp bẫy #81-EXT (PE Mig 67 site-5). Spec chốt KHÔNG sửa site đó mà KHOÁ bằng test:
|
||||
// biến ấy chỉ nhận `TraLai`/`TuChoi`, nên không đường nào tới `DaDuyet` mà bỏ qua
|
||||
// `ApplyApprovedValuesOnFinalize`. Đo bằng CẢ BA hành động trên ba phiếu song sinh.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Transition_ChiNhanhDuyetCuoiMoiToiDaDuyet_VaLuonKemChotGia()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var approver = await AddUserAsync(f, "NV duyệt Cấp cuối");
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-END", steps: [[[approver.Id]]]);
|
||||
var cat = await AddCatalogAsync(f, 1, "Hạng mục kết thúc");
|
||||
|
||||
async Task<Guid> PendingPlanAsync(string ma) => await SeedPlanAsync(
|
||||
f, drafter, wf, ma, ContractSigningPlanPhase.ChoDuyet, 1,
|
||||
[new(500m, 400m, cat)], stepIndex: 0, levelOrder: 1);
|
||||
|
||||
var pReturn = await PendingPlanAsync("KHKK/2026/060");
|
||||
var pReject = await PendingPlanAsync("KHKK/2026/061");
|
||||
var pApprove = await PendingPlanAsync("KHKK/2026/062");
|
||||
|
||||
await ActAsync(f, As(approver), pReturn, "return", "thiếu hồ sơ");
|
||||
var afterReturn = await ReadPlanAsync(f, pReturn);
|
||||
afterReturn.Phase.Should().Be(ContractSigningPlanPhase.TraLai,
|
||||
"biến `targetPhase` ở nhánh trả lại chỉ nhận TraLai — ra DaDuyet ở đây là phiếu tự duyệt");
|
||||
afterReturn.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
|
||||
"trả lại KHÔNG chốt giá");
|
||||
|
||||
await ActAsync(f, As(approver), pReject, "reject", "không ký loại HĐ này");
|
||||
var afterReject = await ReadPlanAsync(f, pReject);
|
||||
afterReject.Phase.Should().Be(ContractSigningPlanPhase.TuChoi);
|
||||
afterReject.Lines.Should().OnlyContain(l => l.ApprovedAmount == null, "từ chối KHÔNG chốt giá");
|
||||
|
||||
// Đường DUY NHẤT tới DaDuyet — và nó phải kèm chốt giá.
|
||||
await ActAsync(f, As(approver), pApprove, "approve", "đồng ý");
|
||||
var afterApprove = await ReadPlanAsync(f, pApprove);
|
||||
afterApprove.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet);
|
||||
afterApprove.Lines.Should().OnlyContain(l => l.ApprovedAmount == 400m,
|
||||
"tới DaDuyet là phải đi qua `ApplyApprovedValuesOnFinalize` ⇒ mọi dòng có số chốt "
|
||||
+ "(mặc định = số đề xuất); phiếu 'Đã duyệt' mà ApprovedAmount rỗng thì GĐ3 sinh HĐ 0đ");
|
||||
|
||||
// Hai phiếu kia PHẢI bất động ở đúng chỗ của chúng (không lây tác dụng phụ).
|
||||
(await ReadPlanAsync(f, pReturn)).Phase.Should().Be(ContractSigningPlanPhase.TraLai);
|
||||
(await ReadPlanAsync(f, pReject)).Phase.Should().Be(ContractSigningPlanPhase.TuChoi);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Seed PE + create helper (dùng cho T-c / T-c bis)
|
||||
// ===================================================================
|
||||
|
||||
private sealed record SeededPe(Guid PeId, Guid WorkflowId);
|
||||
|
||||
/// PE `DaDuyet`, 1 NCC trúng thầu, 1 báo giá được chọn — bộ tối thiểu để CreateHandler
|
||||
/// đi hết 4 rào (`Features.cs:377-405`) mà không vướng cái nào.
|
||||
private static async Task<SeededPe> SeedPeForCreateAsync(KhkkGroupFixture f)
|
||||
{
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-CREATE", steps: []);
|
||||
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
var peId = Guid.NewGuid();
|
||||
|
||||
var master = new Supplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "NCC-K2",
|
||||
Name = "Nhà cung cấp K2",
|
||||
Type = SupplierType.NhaThauPhu,
|
||||
};
|
||||
db.Suppliers.Add(master);
|
||||
|
||||
var row = new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(), // ≠ master.Id — bẫy `w.Id` vs `w.SupplierId`
|
||||
PurchaseEvaluationId = peId,
|
||||
SupplierId = master.Id,
|
||||
Order = 0,
|
||||
IsWinner = true,
|
||||
};
|
||||
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = peId,
|
||||
MaPhieu = "PE-K2-001",
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.DaDuyet,
|
||||
TenGoiThau = "Gói thầu K2",
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DepartmentId = Guid.NewGuid(),
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf,
|
||||
};
|
||||
pe.Suppliers.Add(row);
|
||||
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = peId,
|
||||
GroupCode = "A.I",
|
||||
GroupName = "Nhóm A",
|
||||
NoiDung = "Hạng mục 1",
|
||||
Order = 0,
|
||||
ThanhTienNganSach = 1_000_000_000m,
|
||||
};
|
||||
detail.Quotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = row.Id,
|
||||
ThanhTien = 250m,
|
||||
IsSelected = true,
|
||||
});
|
||||
pe.Details.Add(detail);
|
||||
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
return new SeededPe(peId, wf);
|
||||
}
|
||||
|
||||
private static async Task<CreateContractSigningPlanResult> CreateAsync(
|
||||
KhkkGroupFixture f, TestCurrentUser actor, Guid peId, Guid workflowId, int? approvalGroup)
|
||||
{
|
||||
await using var db = f.NewDb(actor);
|
||||
var handler = new CreateContractSigningPlanCommandHandler(
|
||||
db, actor, new ContractSigningPlanCodeGenerator(db, f.Clock));
|
||||
return await handler.Handle(
|
||||
new CreateContractSigningPlanCommand(peId, workflowId, GhiChu: null, ApprovalGroup: approvalGroup),
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@ -193,11 +193,17 @@ public class ContractSigningPlanSchemaTests
|
||||
db.ContractSigningPlans.Add(plan);
|
||||
|
||||
var supplierId = Guid.NewGuid();
|
||||
// [K2 Mig 71] UNIQUE nay 3 cột (PlanId, SupplierId, CatalogEntryId). CẢ HAI vế của test
|
||||
// dùng CatalogEntryId NON-NULL GIỐNG NHAU để phép đo độc lập provider: SQLite coi
|
||||
// NULL ≠ NULL trong unique (cặp NULL không nổ) còn SQL Server coi bằng nhau — cặp NULL
|
||||
// do SUBMIT-GUARD (WorkflowService) chặn ở tầng nghiệp vụ, không đo ở đây.
|
||||
var catalogId = Guid.NewGuid(); // loose-Guid — index test không cần row danh mục thật
|
||||
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = supplierId,
|
||||
CatalogEntryId = catalogId,
|
||||
PeReferenceAmount = 100_000_000m,
|
||||
ProposedAmount = 95_000_000m,
|
||||
});
|
||||
@ -217,13 +223,14 @@ public class ContractSigningPlanSchemaTests
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = supplierId,
|
||||
CatalogEntryId = catalogId, // trùng cả 3 cột với dòng đã XOÁ MỀM
|
||||
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");
|
||||
"UNIQUE (PlanId, SupplierId, CatalogEntryId) phải là filtered `WHERE [IsDeleted] = 0` — gotcha #57");
|
||||
|
||||
db.ContractSigningPlanLines.AsNoTracking()
|
||||
.Count(x => x.ContractSigningPlanId == plan.Id)
|
||||
@ -246,23 +253,48 @@ public class ContractSigningPlanSchemaTests
|
||||
};
|
||||
db.ContractSigningPlans.Add(plan2);
|
||||
var dupSupplierId = Guid.NewGuid();
|
||||
foreach (var _ in new[] { 1, 2 })
|
||||
var dupCatalogId = Guid.NewGuid();
|
||||
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan2.Id,
|
||||
SupplierId = dupSupplierId,
|
||||
PeReferenceAmount = 1m,
|
||||
ProposedAmount = 1m,
|
||||
});
|
||||
}
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan2.Id,
|
||||
SupplierId = dupSupplierId,
|
||||
CatalogEntryId = dupCatalogId,
|
||||
PeReferenceAmount = 1m,
|
||||
ProposedAmount = 1m,
|
||||
});
|
||||
await db.SaveChangesAsync(); // dòng 1 sống — mốc đối chứng
|
||||
|
||||
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan2.Id,
|
||||
SupplierId = dupSupplierId,
|
||||
CatalogEntryId = dupCatalogId, // TRÙNG cả 3 cột, cả 2 còn SỐNG
|
||||
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 "
|
||||
"2 dòng SỐNG cùng (PlanId, SupplierId, CatalogEntryId) 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");
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
// [K2 Mig 71] Vế MỞ — chính tính năng K2: CÙNG (Plan, NCC) nhưng KHÁC hạng mục = 2 dòng
|
||||
// hợp lệ (tách 1 NCC theo nhiều hạng mục). Unique 2 cột cũ CẤM ca này — nay phải QUA.
|
||||
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan2.Id,
|
||||
SupplierId = dupSupplierId,
|
||||
CatalogEntryId = Guid.NewGuid(), // hạng mục KHÁC ⇒ được phép
|
||||
PeReferenceAmount = 1m,
|
||||
ProposedAmount = 1m,
|
||||
});
|
||||
var actSplit = async () => await db.SaveChangesAsync();
|
||||
await actSplit.Should().NotThrowAsync(
|
||||
"unique 3 cột phải CHO PHÉP cùng (Plan, NCC) khác CatalogEntryId — mục đích của Mig 71");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
Reference in New Issue
Block a user