[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
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:
@ -355,6 +355,23 @@ public class AuthorizePolicyRegressionTests
|
||||
.Select(x => (x.Name, x.Http!.HttpMethods.First().ToUpperInvariant(), x.Authz))
|
||||
.ToArray();
|
||||
|
||||
/// 🔴 [K7 S167] NGOẠI LỆ CÓ TÊN — action tạo ra tài nguyên của MODULE KHÁC phải gate bằng
|
||||
/// key policy của **thứ được tạo**, không phải key của controller đang đứng (gotcha #85:
|
||||
/// "gate quyền dùng đúng key policy của endpoint"; `MenuPermissionHandler.cs:40` so khớp
|
||||
/// CHÍNH XÁC 1 key, không kế-thừa ⇒ gate nhầm key = cấp quyền chéo hoặc khoá oan).
|
||||
///
|
||||
/// Cầu GĐ2→GĐ3 `POST {id}/create-contract` SINH RA một `Contract` ⇒ vế ghi = `Contracts.Create`.
|
||||
/// Vế đọc vẫn còn: class-level `KeHoachKyKet.Read` (`:28`) — ASP.NET Core cộng dồn class +
|
||||
/// action theo **AND**, nên endpoint này là HỘI 2 KHOÁ, KHÔNG phải nới lỏng.
|
||||
///
|
||||
/// Viết dạng BẢN ĐỒ kỳ-vọng-CHÍNH-XÁC (không phải danh sách "bỏ qua"): action trong đây vẫn
|
||||
/// bị đo, chỉ đổi key kỳ vọng. Gate sai thành `KeHoachKyKet.Create` (hoặc bất cứ key nào khác)
|
||||
/// vẫn ĐỎ.
|
||||
private static readonly Dictionary<string, string> KhkkCrossModuleGates = new()
|
||||
{
|
||||
[nameof(ContractSigningPlansController.CreateContract)] = "Contracts.Create",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy()
|
||||
{
|
||||
@ -395,10 +412,23 @@ public class AuthorizePolicyRegressionTests
|
||||
"policy \"{0}\" của {1} phải đúng dạng {{menuKey}}.{{action}}", authz.Policy, name);
|
||||
|
||||
// ---- (b) Tên policy phải là tên CÓ THẬT trong bộ sinh policy của Program.cs.
|
||||
parts[0].Should().Be(MenuKeys.KeHoachKyKet,
|
||||
"action {0} phải gate bằng menu key của CHÍNH module này — Δ W1 chốt prefix " +
|
||||
"`KeHoachKyKet` (spec cũ ghi `ContractSigningPlans`, đã bỏ); gate nhầm key module " +
|
||||
"khác = cấp quyền chéo", name);
|
||||
// Kỳ vọng mặc định = key của CHÍNH module; ngoại lệ CÓ TÊN (K7 cầu → HĐ) đổi kỳ vọng
|
||||
// sang key của tài nguyên ĐƯỢC TẠO — vẫn là so-khớp TUYỆT ĐỐI, không phải bỏ qua.
|
||||
var expectedPolicy = KhkkCrossModuleGates.GetValueOrDefault(name);
|
||||
if (expectedPolicy is not null)
|
||||
{
|
||||
authz.Policy.Should().Be(expectedPolicy,
|
||||
"action {0} tạo ra tài nguyên của module KHÁC ⇒ gate bằng key policy của " +
|
||||
"endpoint đích (gotcha #85). Gate bằng `KeHoachKyKet.*` sẽ cho người chỉ có " +
|
||||
"quyền trên kế hoạch ký kết ĐẺ RA hợp đồng — quyền chéo đúng nghĩa", name);
|
||||
}
|
||||
else
|
||||
{
|
||||
parts[0].Should().Be(MenuKeys.KeHoachKyKet,
|
||||
"action {0} phải gate bằng menu key của CHÍNH module này — Δ W1 chốt prefix " +
|
||||
"`KeHoachKyKet` (spec cũ ghi `ContractSigningPlans`, đã bỏ); gate nhầm key module " +
|
||||
"khác = cấp quyền chéo", name);
|
||||
}
|
||||
MenuKeys.All.Should().Contain(parts[0],
|
||||
"policy \"{0}\" của {1} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không " +
|
||||
"sinh policy đó ⇒ 500 lúc chạy chứ không phải 403", authz.Policy, name);
|
||||
@ -438,5 +468,15 @@ public class AuthorizePolicyRegressionTests
|
||||
"quyền sửa phiếu nhưng không được lập phiếu mới");
|
||||
byName[nameof(ContractSigningPlansController.UploadAttachment)].Should().Be("KeHoachKyKet.Update",
|
||||
"upload đính kèm = ghi vào phiếu đã có ⇒ Update");
|
||||
|
||||
// ---- (e) [K7 S167] Cầu GĐ2→GĐ3 — chốt bằng TÊN, 2 chiều.
|
||||
byName[nameof(ContractSigningPlansController.CreateContract)].Should().Be("Contracts.Create",
|
||||
"cầu KHKK→HĐ sinh ra Contract ⇒ vế GHI phải là khoá của Contract (gotcha #85). " +
|
||||
"Đây là vế 2 của HỘI-2-KHOÁ; vế 1 = class-level `KeHoachKyKet.Read` đã chốt ở (0)");
|
||||
|
||||
// Ngoại lệ phải trỏ action CÓ THẬT: đổi tên/xoá action mà quên dọn bản đồ thì mục đó
|
||||
// thành cấu-hình-chết (im lặng, không ai đo) — vắng-mặt trông giống ổn.
|
||||
KhkkCrossModuleGates.Keys.Should().BeSubsetOf(byName.Keys,
|
||||
"bản đồ ngoại lệ trỏ action không còn tồn tại trên controller");
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,245 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Api.Authorization;
|
||||
using SolutionErp.Api.Controllers;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Api;
|
||||
|
||||
// [K7 — S167 2026-08-01] AUTHZ 2 CHIỀU cho cầu GĐ2→GĐ3 `POST {id}/create-contract`.
|
||||
//
|
||||
// Khác `AuthorizePolicyRegressionTests` (chỉ đọc ATTRIBUTE bằng reflection = "cửa có khoá tên gì"),
|
||||
// file này chạy **máy quyết định thật** `MenuPermissionHandler` = "khoá đó có mở/không mở cho ai".
|
||||
// Hai lớp bổ nhau: pin tên khoá mà không đo quyết định thì vẫn có thể sai ở tầng dữ liệu quyền,
|
||||
// và ngược lại.
|
||||
//
|
||||
// 🔴 ĐIỀU KIỆN LÀM PHÉP ĐO CÓ RĂNG: 2 tên policy KHÔNG hardcode — ĐỌC TỪ CHÍNH CONTROLLER
|
||||
// (class-level + action-level của `CreateContract`). Ai sửa attribute thì test này đo theo cái
|
||||
// MỚI, và vẫn hỏi đúng câu: "hai vế có phải HỘI không". Hardcode chuỗi ở đây là tự tay tách
|
||||
// test khỏi thứ nó bảo vệ.
|
||||
//
|
||||
// Vì sao "hội 2 khoá" quan trọng (gotcha #85 + #82): ASP.NET Core cộng dồn `[Authorize]` class +
|
||||
// action theo **AND**. Nếu ai đó gỡ 1 vế:
|
||||
// - gỡ `Contracts.Create` ⇒ người chỉ được xem/sửa kế hoạch ký kết ĐẺ RA hợp đồng (quyền chéo);
|
||||
// - gỡ `KeHoachKyKet.Read` ⇒ người ngoài module lấy dòng của phiếu người khác đi tạo hợp đồng.
|
||||
public class KhkkBridgeAuthzTests
|
||||
{
|
||||
/// Nạp 2 vế khoá của endpoint cầu TỪ ĐĨA (không hardcode).
|
||||
private static (string ClassPolicy, string ActionPolicy) BridgePolicies()
|
||||
{
|
||||
var t = typeof(ContractSigningPlansController);
|
||||
var classPolicy = t.GetCustomAttributes<AuthorizeAttribute>(inherit: false)
|
||||
.FirstOrDefault()?.Policy;
|
||||
var actionPolicy = t
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.First(m => m.Name == nameof(ContractSigningPlansController.CreateContract))
|
||||
.GetCustomAttributes<AuthorizeAttribute>(inherit: false).FirstOrDefault()?.Policy;
|
||||
|
||||
classPolicy.Should().NotBeNullOrEmpty("vế 1 (class-level) của hội-2-khoá biến mất");
|
||||
actionPolicy.Should().NotBeNullOrEmpty("vế 2 (action-level) của hội-2-khoá biến mất");
|
||||
actionPolicy.Should().NotBe(classPolicy,
|
||||
"hai vế TRÙNG NHAU thì 'hội 2 khoá' chỉ còn là 1 khoá — cửa tự nới ra mà nhìn vẫn như cũ");
|
||||
return (classPolicy!, actionPolicy!);
|
||||
}
|
||||
|
||||
/// Chạy ĐÚNG máy production: `MenuPermissionHandler` + `UserManager`/`RoleManager` thật.
|
||||
/// Trả về quyết định cho MỘT policy "{menuKey}.{action}".
|
||||
private static async Task<bool> AllowsAsync(IdentityFixture fix, User user, string policy)
|
||||
{
|
||||
var parts = policy.Split('.');
|
||||
parts.Should().HaveCount(2, "policy \"{0}\" không đúng dạng {{menuKey}}.{{action}}", policy);
|
||||
|
||||
var handler = new MenuPermissionHandler(
|
||||
fix.Services.GetRequiredService<TestApplicationDbContext>(),
|
||||
fix.Services.GetRequiredService<UserManager<User>>(),
|
||||
fix.Services.GetRequiredService<RoleManager<Role>>());
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())], "test"));
|
||||
var req = new MenuPermissionRequirement(parts[0], parts[1]);
|
||||
var ctx = new AuthorizationHandlerContext([req], principal, resource: null);
|
||||
|
||||
await handler.HandleAsync(ctx);
|
||||
return ctx.HasSucceeded;
|
||||
}
|
||||
|
||||
/// Kết quả THẬT của endpoint = HỘI 2 vế (ASP.NET Core cộng dồn theo AND).
|
||||
private static async Task<bool> BridgeAllowedAsync(IdentityFixture fix, User user)
|
||||
{
|
||||
var (classPolicy, actionPolicy) = BridgePolicies();
|
||||
return await AllowsAsync(fix, user, classPolicy)
|
||||
&& await AllowsAsync(fix, user, actionPolicy);
|
||||
}
|
||||
|
||||
/// Menu row PHẢI có trước Permission: FK `Permission.MenuKey → MenuItem.Key` (Cascade).
|
||||
private static async Task SeedMenuRowsAsync(TestApplicationDbContext db, params string[] keys)
|
||||
{
|
||||
var order = 1;
|
||||
foreach (var k in keys)
|
||||
{
|
||||
if (await db.MenuItems.AnyAsync(m => m.Key == k)) continue;
|
||||
db.MenuItems.Add(new MenuItem { Key = k, Label = k, Order = order++ });
|
||||
}
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// Tạo 1 vai + user của vai đó, cấp đúng bộ cờ được liệt kê.
|
||||
private static async Task<User> UserWithAsync(
|
||||
IdentityFixture fix, TestApplicationDbContext db, string roleName,
|
||||
params (string MenuKey, string Action)[] grants)
|
||||
{
|
||||
var user = await fix.CreateUserAsync(
|
||||
$"{roleName.ToLowerInvariant()}-{Guid.NewGuid():N}@test.local", roleName,
|
||||
departmentId: null, roles: [roleName]);
|
||||
|
||||
var role = await fix.Services.GetRequiredService<RoleManager<Role>>().FindByNameAsync(roleName);
|
||||
role.Should().NotBeNull();
|
||||
|
||||
foreach (var g in grants.GroupBy(x => x.MenuKey))
|
||||
{
|
||||
var actions = g.Select(x => x.Action).ToHashSet();
|
||||
db.Permissions.Add(new Permission
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RoleId = role!.Id,
|
||||
MenuKey = g.Key,
|
||||
CanRead = actions.Contains("Read"),
|
||||
CanCreate = actions.Contains("Create"),
|
||||
CanUpdate = actions.Contains("Update"),
|
||||
CanDelete = actions.Contains("Delete"),
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return user;
|
||||
}
|
||||
|
||||
private static (string MenuKey, string Action) Split(string policy)
|
||||
{
|
||||
var p = policy.Split('.');
|
||||
return (p[0], p[1]);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (1) ĐỦ 2 VẾ → qua. Đây là ĐỐI CHỨNG DƯƠNG: thiếu nó thì 2 test 403 bên dưới
|
||||
// vẫn xanh trong một thế giới mà KHÔNG AI vào được cửa này (403 cho tất cả).
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task CauKhkkSangHd_VaiDuCaHaiVe_ThiQua()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var (classPolicy, actionPolicy) = BridgePolicies();
|
||||
var (khkkKey, khkkAction) = Split(classPolicy);
|
||||
var (contractKey, contractAction) = Split(actionPolicy);
|
||||
|
||||
await SeedMenuRowsAsync(db, khkkKey, contractKey);
|
||||
var user = await UserWithAsync(fix, db, "BridgeFull",
|
||||
(khkkKey, khkkAction), (contractKey, contractAction));
|
||||
|
||||
(await AllowsAsync(fix, user, classPolicy)).Should().BeTrue();
|
||||
(await AllowsAsync(fix, user, actionPolicy)).Should().BeTrue();
|
||||
(await BridgeAllowedAsync(fix, user)).Should().BeTrue(
|
||||
"vai mang ĐỦ `{0}` ∧ `{1}` phải bấm được nút Đưa vào Hợp đồng", classPolicy, actionPolicy);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (2) THIẾU ĐÚNG 1 VẾ (không có `Contracts.Create`) → 403.
|
||||
// Đây chính là hình dạng của LỖ mà lane BE phải vá bằng seeder: trước S167 KHÔNG vai nào
|
||||
// mang `Contracts.Create` (`SeedAllRolesReviewReadPermissionsAsync` đặt `CanCreate = isPe` ⇒ false cho key
|
||||
// `Contracts`) ⇒ mọi người-không-Admin đều rơi vào nhánh này.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task CauKhkkSangHd_VaiChiCoQuyenKeHoach_ThiBiChan()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var (classPolicy, actionPolicy) = BridgePolicies();
|
||||
var (khkkKey, khkkAction) = Split(classPolicy);
|
||||
var (contractKey, _) = Split(actionPolicy);
|
||||
|
||||
await SeedMenuRowsAsync(db, khkkKey, contractKey);
|
||||
// Cấp RỘNG TAY trên module kế hoạch (cả 4 cờ) nhưng KHÔNG có gì trên `Contracts`.
|
||||
var user = await UserWithAsync(fix, db, "KhkkOnly",
|
||||
(khkkKey, "Read"), (khkkKey, "Create"), (khkkKey, "Update"), (khkkKey, "Delete"));
|
||||
|
||||
(await AllowsAsync(fix, user, classPolicy)).Should().BeTrue(
|
||||
"vế 1 PHẢI qua — nếu vế này cũng chặn thì test không đo được vế 2 nào cả");
|
||||
(await AllowsAsync(fix, user, actionPolicy)).Should().BeFalse(
|
||||
"vế 2 `{0}` là khoá của tài nguyên ĐƯỢC TẠO; quyền trên kế hoạch ký kết " +
|
||||
"KHÔNG được đẻ ra hợp đồng (gotcha #85 — không có kế-thừa key)", actionPolicy);
|
||||
(await BridgeAllowedAsync(fix, user)).Should().BeFalse("thiếu 1 vế = 403");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (3) THIẾU ĐÚNG 1 VẾ CÒN LẠI (không có `KeHoachKyKet.Read`) → 403.
|
||||
// Chiều ngược: người được tạo HĐ nhưng ngoài module kế hoạch cũng KHÔNG được lấy dòng
|
||||
// của phiếu người khác đi bắc cầu.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task CauKhkkSangHd_VaiChiCoQuyenTaoHopDong_ThiBiChan()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var (classPolicy, actionPolicy) = BridgePolicies();
|
||||
var (khkkKey, _) = Split(classPolicy);
|
||||
var (contractKey, contractAction) = Split(actionPolicy);
|
||||
|
||||
await SeedMenuRowsAsync(db, khkkKey, contractKey);
|
||||
var user = await UserWithAsync(fix, db, "ContractOnly", (contractKey, contractAction));
|
||||
|
||||
(await AllowsAsync(fix, user, actionPolicy)).Should().BeTrue(
|
||||
"vế 2 PHẢI qua — chứng minh phép đo không xanh nhờ 'chặn tất');");
|
||||
(await AllowsAsync(fix, user, classPolicy)).Should().BeFalse();
|
||||
(await BridgeAllowedAsync(fix, user)).Should().BeFalse("thiếu vế còn lại = vẫn 403");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (4) Admin đi cửa bypass — KHÔNG có 1 dòng `Permissions` nào vẫn qua cả 2 vế.
|
||||
// Khoá hành vi ĐANG CÓ (`MenuPermissionHandler.cs` nhánh `AppRoles.Admin`) để không ai
|
||||
// "siết cho nhất quán" rồi khoá luôn Admin khỏi cầu.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task CauKhkkSangHd_Admin_KhongCanDongQuyenNao_VanQua()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var admin = await fix.CreateUserAsync(
|
||||
$"admin-{Guid.NewGuid():N}@test.local", "Admin", null, [AppRoles.Admin]);
|
||||
|
||||
(await db.Permissions.CountAsync()).Should().Be(0, "cố ý 0 dòng quyền — đo đúng nhánh bypass");
|
||||
(await BridgeAllowedAsync(fix, admin)).Should().BeTrue();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (5) User bị vô hiệu hoá: có ĐỦ quyền nhưng `IsActive = false` → chặn cả 2 vế.
|
||||
// Vế này bắt lỗi "nghỉ việc nhưng token còn hạn vẫn đẻ được hợp đồng".
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task CauKhkkSangHd_UserBiVoHieuHoa_ThiChan_DuCoDuQuyen()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var (classPolicy, actionPolicy) = BridgePolicies();
|
||||
var (khkkKey, khkkAction) = Split(classPolicy);
|
||||
var (contractKey, contractAction) = Split(actionPolicy);
|
||||
|
||||
await SeedMenuRowsAsync(db, khkkKey, contractKey);
|
||||
var user = await UserWithAsync(fix, db, "BridgeDisabled",
|
||||
(khkkKey, khkkAction), (contractKey, contractAction));
|
||||
|
||||
(await BridgeAllowedAsync(fix, user)).Should().BeTrue("chốt trước: lúc còn hoạt động thì QUA");
|
||||
|
||||
var tracked = await db.Users.SingleAsync(u => u.Id == user.Id);
|
||||
tracked.IsActive = false;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
(await BridgeAllowedAsync(fix, user)).Should().BeFalse(
|
||||
"đổi ĐÚNG 1 biến `IsActive` ⇒ cùng bộ quyền, kết quả phải ngược lại");
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user