[CLAUDE] Workflow: W3 seed 7 quy trình HD-* sao khuôn KHKK (roster builder chung, finalize=false có khai) + 7 key AwV2_Hd* by-construction + menu-row T6 + 2 row canonical STATUS; test T2/T6 khoá drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@ -0,0 +1,63 @@
|
|||||||
|
namespace SolutionErp.Domain.Contracts;
|
||||||
|
|
||||||
|
// [S187 W3 — YC-023/YC-024] HỌ MÃ QUY TRÌNH DUYỆT HỢP ĐỒNG (ApprovalWorkflow V2, ApplicableType=3).
|
||||||
|
//
|
||||||
|
// Owner chốt @S188: (80) trục = **7 LOẠI HĐ** (không phải 8 nhóm duyệt SP-002) · (81) **PA-A** =
|
||||||
|
// mỗi loại MỘT mã quy trình riêng, 0 cột mới trong bảng quy trình (khuôn KHKK `KHKK-N{n}` đã qua UAT).
|
||||||
|
//
|
||||||
|
// 🔴 ĐÂY LÀ NHÀ DUY NHẤT của 7 mã này ở production. Trước W3 chúng chỉ tồn tại trong test
|
||||||
|
// (`UpdateAwDefinitionTests.cs:1573` `ContractFamilyCodes`) — test giữ bản riêng CÓ CHỦ ĐÍCH
|
||||||
|
// (test không được lấy hằng của code làm kỳ vọng, không thì nó đo chính nó). Mọi chỗ KHÁC cần
|
||||||
|
// "loại HĐ này đi quy trình mã nào" thì gọi `CodeFor`, ĐỪNG gõ lại chuỗi:
|
||||||
|
// • `DbInitializer.SeedContractTypeWorkflowsAsync` — seed 7 quy trình;
|
||||||
|
// • `MenuKeys.ContractWorkflowMenuKeys` — sinh 7 key Designer `AwV2_Hd*` (by-construction);
|
||||||
|
// • W4 (nhịp sau) — rào auto-pin/re-pin so `Contract.Type` ↔ mã đã pin.
|
||||||
|
//
|
||||||
|
// ⚠️ ĐẶT Ở DOMAIN, không phải Application, vì `MenuKeys` (Domain/Identity) phải sinh key TỪ đây —
|
||||||
|
// Domain không thấy được Application. Đây cũng là lý do KHÔNG nhét vào nhà KHKK
|
||||||
|
// (`ContractSigningPlanWorkflowGuards`, tầng Application): 2 họ khác trục, khác tầng, khác vòng đời.
|
||||||
|
//
|
||||||
|
// 🔴 MÃ ASCII HOA, KHÔNG DẤU (FLAG-6): admin tạo version mới qua Designer phải lọt validator
|
||||||
|
// `^[A-Za-z0-9._-]+$` (`ApprovalWorkflowV2AdminFeatures.cs:274-276`) — seeder không đi qua
|
||||||
|
// validator nhưng vòng đời "clone tạo v02" thì CÓ ⇒ mã mang "Đ"/dấu là kẹt ở lần sửa đầu tiên.
|
||||||
|
// Đường ghi còn chuẩn hoá `Trim().ToUpperInvariant()` (`:348`) ⇒ mã ở đây phải sẵn dạng HOA,
|
||||||
|
// nếu không thì bản seed và bản admin-tạo là HAI chuỗi khác nhau trên SQLite (so BINARY).
|
||||||
|
public static class ContractApprovalWorkflowFamily
|
||||||
|
{
|
||||||
|
/// Tiền tố chung của cả họ. Tách hằng để `KeyToken` cắt đúng độ dài, không gõ số 3.
|
||||||
|
public const string CodePrefix = "HD-";
|
||||||
|
|
||||||
|
/// 7 thành viên — thứ tự = thứ tự `ContractType` (1..7), cũng là thứ tự hiện trên sidebar.
|
||||||
|
/// `ShortName` dùng cho CẢ tên quy trình ("Duyệt Hợp đồng — {ShortName}") LẪN nhãn menu
|
||||||
|
/// Designer ⇒ 2 chỗ không thể lệch chữ.
|
||||||
|
public static readonly (ContractType Type, string Code, string ShortName)[] Members =
|
||||||
|
[
|
||||||
|
(ContractType.HopDongThauPhu, "HD-TP", "Thầu phụ"),
|
||||||
|
(ContractType.HopDongGiaoKhoan, "HD-GK", "Giao khoán"),
|
||||||
|
(ContractType.HopDongNhaCungCap, "HD-NCC", "Nhà cung cấp"),
|
||||||
|
(ContractType.HopDongDichVu, "HD-DV", "Dịch vụ"),
|
||||||
|
(ContractType.HopDongMuaBan, "HD-MB", "Mua bán"),
|
||||||
|
(ContractType.HopDongNguyenTacNCC, "HD-NT-NCC", "Nguyên tắc NCC"),
|
||||||
|
(ContractType.HopDongNguyenTacDichVu, "HD-NT-DV", "Nguyên tắc Dịch vụ"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 7 mã, đúng thứ tự `Members`.
|
||||||
|
public static readonly string[] Codes = Members.Select(m => m.Code).ToArray();
|
||||||
|
|
||||||
|
/// Mã quy trình của một loại HĐ, `null` nếu loại nằm ngoài họ (enum nở thêm về sau).
|
||||||
|
public static string? CodeFor(ContractType type)
|
||||||
|
{
|
||||||
|
foreach (var m in Members)
|
||||||
|
if (m.Type == type) return m.Code;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// QUY TẮC ĐẶT KEY MENU (khai một lần, dùng mọi nơi): bỏ tiền tố `HD-`, bỏ mọi dấu `-`,
|
||||||
|
/// GIỮ NGUYÊN hoa/thường của phần còn lại ⇒ `HD-TP`→`TP`, `HD-NT-NCC`→`NTNCC`.
|
||||||
|
/// `MenuKeys` ghép thành `AwV2_Hd{token}` (⇒ `AwV2_HdTP`, `AwV2_HdNTNCC`).
|
||||||
|
/// Suy TỪ mã chứ không khai thêm cột: mã đổi thì key đổi theo, không có đường lệch câm.
|
||||||
|
public static string KeyToken(string code) =>
|
||||||
|
code.StartsWith(CodePrefix, StringComparison.Ordinal)
|
||||||
|
? code[CodePrefix.Length..].Replace("-", string.Empty)
|
||||||
|
: code.Replace("-", string.Empty);
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
|
||||||
namespace SolutionErp.Domain.Identity;
|
namespace SolutionErp.Domain.Identity;
|
||||||
|
|
||||||
// Nguồn duy nhất (single source of truth) cho menu key — dùng ở cả BE (policy) và seed.
|
// Nguồn duy nhất (single source of truth) cho menu key — dùng ở cả BE (policy) và seed.
|
||||||
@ -133,6 +135,27 @@ public static class MenuKeys
|
|||||||
public const string ApprovalWorkflowKhkkN7 = "AwV2_KhkkN7";
|
public const string ApprovalWorkflowKhkkN7 = "AwV2_KhkkN7";
|
||||||
public const string ApprovalWorkflowKhkkN8 = "AwV2_KhkkN8";
|
public const string ApprovalWorkflowKhkkN8 = "AwV2_KhkkN8";
|
||||||
|
|
||||||
|
// [S187 W3 — YC-023/YC-024] 7 leaf Designer cho 7 QUY TRÌNH DUYỆT HỢP ĐỒNG theo LOẠI HĐ
|
||||||
|
// (owner @S188: (80) trục 7-loại · (81) PA-A mỗi loại 1 mã). Workflow `HD-{TOKEN}` type-3
|
||||||
|
// do `DbInitializer.SeedContractTypeWorkflowsAsync` seed; mã + tên loại ở NHÀ DUY NHẤT
|
||||||
|
// `ContractApprovalWorkflowFamily` (Domain/Contracts).
|
||||||
|
// 🔴 7 key này VÀO `All` (T3 — quyết mức-lead, tiền lệ K3-N1 `:123`) ⇒ sinh 28 policy
|
||||||
|
// AwV2_Hd*.{Read,Create,Update,Delete} ⇒ 2 số canonical `docs/STATUS.md` đổi:
|
||||||
|
// Menu keys +7 · Policies +28 (DERIVED |All|×|Actions| — khai DELTA, không neo mốc tuyệt đối).
|
||||||
|
// Như 8 key KHKK: registered-unused ở tầng endpoint (controller AwV2 vẫn gác bằng
|
||||||
|
// `ApprovalWorkflowsV2.*`) — đừng OR key con vào gate, gotcha #85.
|
||||||
|
// 🔴 Vào `All` thì BẮT BUỘC có menu-row trong `SeedMenuTreeAsync` — thiếu row = Permission
|
||||||
|
// FK nổ ngay lần boot đầu (invariant T6; test `ContractCatalogTests` `:445` bắt đúng ca này).
|
||||||
|
// Mảng dưới là NGUỒN CHUNG cho cả `All` LẪN vòng seed menu-row ⇒ 2 phía khớp
|
||||||
|
// by-construction, KHÔNG mirror tay (khuôn K4a `KhkkGroupMenuKeys`).
|
||||||
|
public static string ApprovalWorkflowContractLeaf(string keyToken) => $"AwV2_Hd{keyToken}";
|
||||||
|
|
||||||
|
// Khai báo TRƯỚC `All` — static field initializer chạy theo THỨ TỰ VĂN BẢN
|
||||||
|
// (đặt sau `All` thì `All` nhận mảng null, hỏng câm lúc boot).
|
||||||
|
public static readonly string[] ContractWorkflowMenuKeys = ContractApprovalWorkflowFamily.Members
|
||||||
|
.Select(m => ApprovalWorkflowContractLeaf(ContractApprovalWorkflowFamily.KeyToken(m.Code)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
// [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_List/Bg_Create/Bg_Pending)
|
// [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_List/Bg_Create/Bg_Pending)
|
||||||
// đã XÓA — thay bằng "Ngân sách gói thầu" nhúng trong phiếu PE
|
// đã XÓA — thay bằng "Ngân sách gói thầu" nhúng trong phiếu PE
|
||||||
// (PeWorkItemBudgets per cặp Dự án × Hạng mục). Menu/Permission rows cũ
|
// (PeWorkItemBudgets per cặp Dự án × Hạng mục). Menu/Permission rows cũ
|
||||||
@ -247,6 +270,9 @@ public static class MenuKeys
|
|||||||
// [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 nhóm duyệt KHKK (+8 key ⇒ +32 policy).
|
// [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 nhóm duyệt KHKK (+8 key ⇒ +32 policy).
|
||||||
ApprovalWorkflowKhkkN1, ApprovalWorkflowKhkkN2, ApprovalWorkflowKhkkN3, ApprovalWorkflowKhkkN4,
|
ApprovalWorkflowKhkkN1, ApprovalWorkflowKhkkN2, ApprovalWorkflowKhkkN3, ApprovalWorkflowKhkkN4,
|
||||||
ApprovalWorkflowKhkkN5, ApprovalWorkflowKhkkN6, ApprovalWorkflowKhkkN7, ApprovalWorkflowKhkkN8,
|
ApprovalWorkflowKhkkN5, ApprovalWorkflowKhkkN6, ApprovalWorkflowKhkkN7, ApprovalWorkflowKhkkN8,
|
||||||
|
// [S187 W3] 7 leaf Designer cho 7 quy trình duyệt HĐ theo LOẠI (+7 key ⇒ +28 policy).
|
||||||
|
// Bộ-sinh từ `ContractApprovalWorkflowFamily` — dùng CHUNG với vòng seed menu-row.
|
||||||
|
.. ContractWorkflowMenuKeys,
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];
|
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];
|
||||||
|
|||||||
@ -170,6 +170,13 @@ public static class DbInitializer
|
|||||||
// Drafter không pick được quy trình lúc tạo phiếu KHKK. Idempotent per-Code.
|
// Drafter không pick được quy trình lúc tạo phiếu KHKK. Idempotent per-Code.
|
||||||
await SeedKhkkGroupWorkflowsAsync(db, userManager, logger);
|
await SeedKhkkGroupWorkflowsAsync(db, userManager, logger);
|
||||||
|
|
||||||
|
// [S187 W3 — YC-023/YC-024] 7 quy trình duyệt HĐ theo LOẠI (`HD-*`,
|
||||||
|
// ApplicableType=Contract=3). Cùng lý do ungated + idempotent per-Code như KHKK
|
||||||
|
// ngay trên (gotcha #51). Đặt SAU KHKK vì dùng chung `ProCcmBodApprovalRoster`
|
||||||
|
// và cùng khuôn 3 trạm — đọc 2 seeder cạnh nhau thấy ngay chỗ chúng LỆCH
|
||||||
|
// (cờ finalize: KHKK true / HĐ false).
|
||||||
|
await SeedContractTypeWorkflowsAsync(db, userManager, logger);
|
||||||
|
|
||||||
await WarnDefaultAdminPasswordAsync(userManager, logger);
|
await WarnDefaultAdminPasswordAsync(userManager, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -546,13 +553,25 @@ public static class DbInitializer
|
|||||||
// Cấp không còn ai ⇒ bỏ cấp (Order được đánh LẠI liên tục 1,2,… để không vi phạm
|
// Cấp không còn ai ⇒ bỏ cấp (Order được đánh LẠI liên tục 1,2,… để không vi phạm
|
||||||
// ràng buộc sequential của Designer); Bước không còn cấp nào ⇒ bỏ bước; workflow
|
// ràng buộc sequential của Designer); Bước không còn cấp nào ⇒ bỏ bước; workflow
|
||||||
// 0 bước vẫn TẠO (giữ mã cho phiếu pin + admin bổ sung người qua Designer) + log.
|
// 0 bước vẫn TẠO (giữ mã cho phiếu pin + admin bổ sung người qua Designer) + log.
|
||||||
private static async Task SeedKhkkGroupWorkflowsAsync(
|
// ĐỘI HÌNH 3 TRẠM DÙNG CHUNG — nguồn DUY NHẤT của "ai duyệt, trạm nào, thứ tự nào".
|
||||||
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
|
//
|
||||||
{
|
// Roster VERIFY LIVE trên prod 2026-07-31 (bản QT-DN-V2-001 IsActive=1) —
|
||||||
// Roster VERIFY LIVE trên prod 2026-07-31 (bản QT-DN-V2-001 IsActive=1) —
|
// bước × phòng × [Cấp 1 OR-of-N] × [Cấp 2] × cờ finalize của Cấp 2.
|
||||||
// bước × phòng × [Cấp 1 OR-of-N] × [Cấp 2] × cờ finalize của Cấp 2.
|
//
|
||||||
var roster = new (string StepName, string DeptCode, string[] Level1, string[] Level2, bool Level2Finalize)[]
|
// 🔴 [S187 W3] NÂNG từ biến cục bộ của seeder KHKK lên field dùng chung, vì họ quy trình
|
||||||
{
|
// HĐ (`SeedContractTypeWorkflowsAsync`, type-3) phải "SAO KHUÔN KHKK" theo owner (83).
|
||||||
|
// Chép mảng lần thứ hai = 2 bản 11 email đi lệch nhau theo thời gian (đúng bug-class mà
|
||||||
|
// `ContractSigningPlanWorkflowGuards:28-32` đang chống) ⇒ dùng CHUNG để 2 họ khớp
|
||||||
|
// by-construction. Đo 2026-08-10 trên Dev: 8/8 workflow `KHKK-N*` có chữ ký cấu trúc
|
||||||
|
// GIỐNG HỆT nhau (MD5 `7EB6CD21…` trên tập bước/phòng/cấp/email/cờ) ⇒ "khuôn chung" là
|
||||||
|
// mô tả ĐO ĐƯỢC, không phải giả định.
|
||||||
|
// ⚠️ Muốn 2 họ KHÁC người nhau thì TÁCH thành 2 mảng đặt cạnh nhau — ĐỪNG sửa tại chỗ,
|
||||||
|
// sửa ở đây là đổi luôn họ kia mà không ai thấy. (Chỉnh người cho MỘT quy trình đang chạy
|
||||||
|
// là việc của admin trên Designer, không phải của seeder: seeder idempotent per-Code nên
|
||||||
|
// sau lần seed đầu nó không đụng vào DB nữa.)
|
||||||
|
private static readonly (string StepName, string DeptCode, string[] Level1, string[] Level2, bool Level2Finalize)[]
|
||||||
|
ProCcmBodApprovalRoster =
|
||||||
|
[
|
||||||
("Phòng Cung Ứng", "PRO",
|
("Phòng Cung Ứng", "PRO",
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
@ -576,15 +595,27 @@ public static class DbInitializer
|
|||||||
// 🔴 AllowApproverFinalize=true (owner OG-3/OG-9: a Chương quyết lên CEO hay
|
// 🔴 AllowApproverFinalize=true (owner OG-3/OG-9: a Chương quyết lên CEO hay
|
||||||
// kết thúc). Nhánh service port ở nhịp sau CÙNG WAVE trước push nên không
|
// kết thúc). Nhánh service port ở nhịp sau CÙNG WAVE trước push nên không
|
||||||
// phải config-lie #78.
|
// phải config-lie #78.
|
||||||
|
// 🔴 [S187 W3] Cờ này CHỈ ăn cho họ KHKK (type-10). Họ HĐ (type-3) truyền
|
||||||
|
// `applyLevel2Finalize: false` — xem chú thích tại `SeedContractTypeWorkflowsAsync`:
|
||||||
|
// máy duyệt type-3 (`ContractWorkflowService.ApproveV2Async`) KHÔNG có nhánh
|
||||||
|
// finalize (đo: 0 hit `AllowApproverFinalize`; control dương PE `:870` + KHKK
|
||||||
|
// `ContractSigningPlanWorkflowService.cs:286` đều CÓ) ⇒ seed `true` cho HĐ sẽ
|
||||||
|
// khoe trên Designer một quyền mà engine không thi hành = đúng #78.
|
||||||
true),
|
true),
|
||||||
|
|
||||||
("Ban Giám đốc", "BOD",
|
("Ban Giám đốc", "BOD",
|
||||||
new[] { "truong.nguyen@solutions.com.vn" }, // Nguyễn Văn Trường (CEO)
|
new[] { "truong.nguyen@solutions.com.vn" }, // Nguyễn Văn Trường (CEO)
|
||||||
Array.Empty<string>(),
|
Array.Empty<string>(),
|
||||||
false),
|
false),
|
||||||
};
|
];
|
||||||
|
|
||||||
// Resolve email → UserId MỘT lượt (11 email, dùng lại cho cả 8 workflow).
|
// Resolve email → UserId MỘT lượt cho cả roster (dùng lại cho MỌI workflow của họ).
|
||||||
|
// Thiếu user ⇒ ghi warning + bỏ đúng slot đó, KHÔNG throw (seeder chạy lúc boot).
|
||||||
|
private static async Task<Dictionary<string, Guid>> ResolveRosterApproversAsync(
|
||||||
|
UserManager<User> userManager,
|
||||||
|
(string StepName, string DeptCode, string[] Level1, string[] Level2, bool Level2Finalize)[] roster,
|
||||||
|
string seeder, ILogger logger)
|
||||||
|
{
|
||||||
var usersByEmail = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
var usersByEmail = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||||
var missingEmails = new List<string>();
|
var missingEmails = new List<string>();
|
||||||
foreach (var email in roster.SelectMany(r => r.Level1.Concat(r.Level2))
|
foreach (var email in roster.SelectMany(r => r.Level1.Concat(r.Level2))
|
||||||
@ -596,13 +627,83 @@ public static class DbInitializer
|
|||||||
}
|
}
|
||||||
if (missingEmails.Count > 0)
|
if (missingEmails.Count > 0)
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"SeedKhkkGroupWorkflowsAsync: {Count} email người duyệt KHÔNG có user — bỏ qua slot đó: {Emails}",
|
"{Seeder}: {Count} email người duyệt KHÔNG có user — bỏ qua slot đó: {Emails}",
|
||||||
missingEmails.Count, string.Join(", ", missingEmails));
|
seeder, missingEmails.Count, string.Join(", ", missingEmails));
|
||||||
|
return usersByEmail;
|
||||||
|
}
|
||||||
|
|
||||||
var deptIdByCode = (await db.Departments.ToListAsync())
|
private static async Task<Dictionary<string, Guid?>> LoadDepartmentIdsByCodeAsync(ApplicationDbContext db) =>
|
||||||
|
(await db.Departments.ToListAsync())
|
||||||
.GroupBy(d => d.Code)
|
.GroupBy(d => d.Code)
|
||||||
.ToDictionary(g => g.Key, g => (Guid?)g.First().Id);
|
.ToDictionary(g => g.Key, g => (Guid?)g.First().Id);
|
||||||
|
|
||||||
|
// Dựng 3 trạm PRO → CCM → BGĐ theo `ProCcmBodApprovalRoster` vào MỘT workflow.
|
||||||
|
// Cấp không còn ai ⇒ bỏ cấp (Order đánh LẠI liên tục 1,2,… để không vi phạm ràng buộc
|
||||||
|
// sequential của Designer); Bước không còn cấp nào ⇒ bỏ bước + log.
|
||||||
|
// `applyLevel2Finalize=false` ⇒ ép cờ finalize của Cấp 2 về false cho MỌI trạm (họ nào mà
|
||||||
|
// engine chưa có nhánh finalize thì truyền false — xem chú thích cờ trong roster).
|
||||||
|
private static void AddProCcmBodStepsToWorkflow(
|
||||||
|
ApprovalWorkflow wf,
|
||||||
|
IReadOnlyDictionary<string, Guid> usersByEmail,
|
||||||
|
IReadOnlyDictionary<string, Guid?> deptIdByCode,
|
||||||
|
bool applyLevel2Finalize,
|
||||||
|
string seeder, string code, ILogger logger)
|
||||||
|
{
|
||||||
|
var stepOrder = 1;
|
||||||
|
foreach (var r in ProCcmBodApprovalRoster)
|
||||||
|
{
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
ApprovalWorkflow = wf,
|
||||||
|
Order = stepOrder,
|
||||||
|
Name = r.StepName,
|
||||||
|
DepartmentId = deptIdByCode.GetValueOrDefault(r.DeptCode),
|
||||||
|
};
|
||||||
|
|
||||||
|
var levelOrder = 1;
|
||||||
|
foreach (var (emails, finalize) in new[]
|
||||||
|
{ (r.Level1, false), (r.Level2, applyLevel2Finalize && r.Level2Finalize) })
|
||||||
|
{
|
||||||
|
var approverIds = emails
|
||||||
|
.Where(usersByEmail.ContainsKey)
|
||||||
|
.Select(e => usersByEmail[e])
|
||||||
|
.ToArray();
|
||||||
|
if (approverIds.Length == 0) continue; // cấp rỗng → bỏ, KHÔNG chừa lỗ Order
|
||||||
|
|
||||||
|
foreach (var approverId in approverIds)
|
||||||
|
{
|
||||||
|
step.Levels.Add(new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Step = step,
|
||||||
|
Order = levelOrder, // N row cùng Order = OR-of-N trong 1 Cấp
|
||||||
|
Name = $"Cấp {levelOrder}",
|
||||||
|
ApproverUserId = approverId,
|
||||||
|
AllowApproverFinalize = finalize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
levelOrder++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.Levels.Count == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"{Seeder}: {Code} bỏ Bước \"{Step}\" — 0 người duyệt resolve được",
|
||||||
|
seeder, code, r.StepName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
wf.Steps.Add(step);
|
||||||
|
stepOrder++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedKhkkGroupWorkflowsAsync(
|
||||||
|
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
|
||||||
|
{
|
||||||
|
var usersByEmail = await ResolveRosterApproversAsync(
|
||||||
|
userManager, ProCcmBodApprovalRoster, nameof(SeedKhkkGroupWorkflowsAsync), logger);
|
||||||
|
var deptIdByCode = await LoadDepartmentIdsByCodeAsync(db);
|
||||||
|
|
||||||
var seeded = 0;
|
var seeded = 0;
|
||||||
for (var n = 1; n <= 8; n++)
|
for (var n = 1; n <= 8; n++)
|
||||||
{
|
{
|
||||||
@ -624,51 +725,12 @@ public static class DbInitializer
|
|||||||
ActivatedAt = DateTime.UtcNow,
|
ActivatedAt = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
var stepOrder = 1;
|
// [S187 W3] Vòng dựng trạm CŨ (inline) đã nâng nguyên xi thành `AddProCcmBodStepsToWorkflow`.
|
||||||
foreach (var r in roster)
|
// `applyLevel2Finalize: true` ⇒ biểu thức trong builder là `true && r.Level2Finalize`
|
||||||
{
|
// = ĐÚNG bằng `r.Level2Finalize` của dòng cũ ⇒ hành vi KHKK không đổi một li.
|
||||||
var step = new ApprovalWorkflowStep
|
AddProCcmBodStepsToWorkflow(
|
||||||
{
|
wf, usersByEmail, deptIdByCode, applyLevel2Finalize: true,
|
||||||
ApprovalWorkflow = wf,
|
nameof(SeedKhkkGroupWorkflowsAsync), code, logger);
|
||||||
Order = stepOrder,
|
|
||||||
Name = r.StepName,
|
|
||||||
DepartmentId = deptIdByCode.GetValueOrDefault(r.DeptCode),
|
|
||||||
};
|
|
||||||
|
|
||||||
var levelOrder = 1;
|
|
||||||
foreach (var (emails, finalize) in new[] { (r.Level1, false), (r.Level2, r.Level2Finalize) })
|
|
||||||
{
|
|
||||||
var approverIds = emails
|
|
||||||
.Where(usersByEmail.ContainsKey)
|
|
||||||
.Select(e => usersByEmail[e])
|
|
||||||
.ToArray();
|
|
||||||
if (approverIds.Length == 0) continue; // cấp rỗng → bỏ, KHÔNG chừa lỗ Order
|
|
||||||
|
|
||||||
foreach (var approverId in approverIds)
|
|
||||||
{
|
|
||||||
step.Levels.Add(new ApprovalWorkflowLevel
|
|
||||||
{
|
|
||||||
Step = step,
|
|
||||||
Order = levelOrder, // N row cùng Order = OR-of-N trong 1 Cấp
|
|
||||||
Name = $"Cấp {levelOrder}",
|
|
||||||
ApproverUserId = approverId,
|
|
||||||
AllowApproverFinalize = finalize,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
levelOrder++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step.Levels.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogWarning(
|
|
||||||
"SeedKhkkGroupWorkflowsAsync: {Code} bỏ Bước \"{Step}\" — 0 người duyệt resolve được",
|
|
||||||
code, r.StepName);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
wf.Steps.Add(step);
|
|
||||||
stepOrder++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wf.Steps.Count == 0)
|
if (wf.Steps.Count == 0)
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
@ -686,6 +748,76 @@ public static class DbInitializer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// [S187 W3 — YC-023/YC-024] 7 QUY TRÌNH DUYỆT HỢP ĐỒNG theo LOẠI HĐ
|
||||||
|
// ============================================================================
|
||||||
|
// Owner @S188: (80) trục = 7 LOẠI HĐ · (81) PA-A mỗi loại MỘT mã riêng, 0 cột mới ·
|
||||||
|
// (83) đội hình SAO KHUÔN KHKK, anh tự chỉnh lại trong Admin Designer sau.
|
||||||
|
// Mã + tên loại: `ContractApprovalWorkflowFamily` (Domain/Contracts) — NHÀ DUY NHẤT.
|
||||||
|
// `ApplicableType = Contract` (=3) ⇒ Drafter pick được ở màn tạo HĐ (dropdown lọc
|
||||||
|
// `IsUserSelectable && IsActive` theo type).
|
||||||
|
//
|
||||||
|
// INFRASTRUCTURE seed — NOT gated DemoSeed (gotcha #51): prod cần đủ 7 quy trình ngay
|
||||||
|
// deploy đầu, thiếu thì HĐ loại đó không pin được quy trình nào.
|
||||||
|
// Idempotent PER-CODE (`HD-*`) — CÙNG lý do như KHKK: 7 workflow DÙNG CHUNG 1 type nên
|
||||||
|
// check theo `ApplicableType` sẽ chặn 6 loại còn lại ngay khi loại đầu vừa tạo (và ở đây
|
||||||
|
// còn tệ hơn: type-3 ĐÃ có sẵn `QT-HD-V2-001` sample từ S29 ⇒ check-by-type = seed 0 cái).
|
||||||
|
//
|
||||||
|
// 🔴 CỜ `AllowApproverFinalize` CỐ Ý KHÔNG SAO NGUYÊN XI (`applyLevel2Finalize: false`):
|
||||||
|
// roster KHKK bật finalize ở Cấp 2 trạm CCM, nhưng máy duyệt type-3
|
||||||
|
// (`ContractWorkflowService`) đo ngày 2026-08-10 có **0 hit** `AllowApproverFinalize`
|
||||||
|
// (control dương: PE `PurchaseEvaluationWorkflowService.cs:870` CÓ · KHKK
|
||||||
|
// `ContractSigningPlanWorkflowService.cs:286` CÓ ⇒ thước sống, 0-hit là vắng THẬT).
|
||||||
|
// Seed `true` khi engine không thi hành = Designer khoe một quyền không có thật —
|
||||||
|
// đúng gotcha #78 (config-lie). Owner bật lại bằng 1 click trong Designer khi máy
|
||||||
|
// type-3 có nhánh finalize. TRẠM/NGƯỜI/THỨ TỰ/OR-of-N giữ NGUYÊN theo (83).
|
||||||
|
private static async Task SeedContractTypeWorkflowsAsync(
|
||||||
|
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
|
||||||
|
{
|
||||||
|
var usersByEmail = await ResolveRosterApproversAsync(
|
||||||
|
userManager, ProCcmBodApprovalRoster, nameof(SeedContractTypeWorkflowsAsync), logger);
|
||||||
|
var deptIdByCode = await LoadDepartmentIdsByCodeAsync(db);
|
||||||
|
|
||||||
|
var seeded = 0;
|
||||||
|
foreach (var (_, code, shortName) in ContractApprovalWorkflowFamily.Members)
|
||||||
|
{
|
||||||
|
if (await db.ApprovalWorkflows.AnyAsync(w => w.Code == code)) continue;
|
||||||
|
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Version = 1,
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||||
|
Name = $"Duyệt Hợp đồng — {shortName}",
|
||||||
|
Description =
|
||||||
|
$"Quy trình duyệt Hợp đồng loại \"{shortName}\". Đội hình sao khuôn quy trình " +
|
||||||
|
"duyệt KHKK (3 trạm PRO → CCM → BGĐ). Admin chỉnh người/quyền tại chỗ hoặc tạo " +
|
||||||
|
"version mới qua Designer.",
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
ActivatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
AddProCcmBodStepsToWorkflow(
|
||||||
|
wf, usersByEmail, deptIdByCode, applyLevel2Finalize: false,
|
||||||
|
nameof(SeedContractTypeWorkflowsAsync), code, logger);
|
||||||
|
|
||||||
|
if (wf.Steps.Count == 0)
|
||||||
|
logger.LogWarning(
|
||||||
|
"SeedContractTypeWorkflowsAsync: {Code} tạo RỖNG (0 Bước có người) — admin phải thêm người qua Designer",
|
||||||
|
code);
|
||||||
|
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
seeded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seeded > 0)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Seeded {Count} contract-type approval workflows (W3 YC-023)", seeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
|
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
|
||||||
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
|
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
|
||||||
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)
|
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)
|
||||||
@ -2156,6 +2288,27 @@ public static class DbInitializer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [S187 W3 — YC-023/YC-024] 7 leaf Designer cho 7 quy trình duyệt HĐ theo LOẠI
|
||||||
|
// (workflow `HD-*` type-3 seed ở `SeedContractTypeWorkflowsAsync`). Cùng cha
|
||||||
|
// `ApprovalWorkflowsV2` với 8 leaf KHKK ngay trên, Order tiếp ngay sau (KHKK 3..10
|
||||||
|
// ⇒ HĐ 11..17). Key CÓ trong `MenuKeys.All` ⇒ Admin được grant tự động qua loop
|
||||||
|
// `SeedAdminPermissionsAsync`; upsert dưới idempotent per-Key.
|
||||||
|
// 🔴 Key sinh bằng CHÍNH `MenuKeys.ApprovalWorkflowContractLeaf` trên CHÍNH
|
||||||
|
// `ContractApprovalWorkflowFamily.Members` — tức CÙNG bộ sinh với
|
||||||
|
// `MenuKeys.ContractWorkflowMenuKeys` đang nằm trong `All` ⇒ mọi key của họ đều
|
||||||
|
// có menu-row BY-CONSTRUCTION (invariant T6: key vào `All` mà thiếu menu-row =
|
||||||
|
// Permission FK nổ ngay lần boot đầu). Thêm loại HĐ thứ 8 chỉ phải sửa 1 nơi.
|
||||||
|
// ⚠️ Nhãn ở đây chỉ ăn cho DB TRỐNG / key MỚI. Đổi nhãn sau khi đã seed BẮT BUỘC
|
||||||
|
// thêm entry vào `labelBackfill` bên dưới — upsert chỉ re-set `Order` (gotcha #11).
|
||||||
|
var awV2ContractOrder = 11;
|
||||||
|
foreach (var (_, familyCode, shortName) in ContractApprovalWorkflowFamily.Members)
|
||||||
|
{
|
||||||
|
var leafKey = MenuKeys.ApprovalWorkflowContractLeaf(
|
||||||
|
ContractApprovalWorkflowFamily.KeyToken(familyCode));
|
||||||
|
tree.Add((leafKey, $"QT Duyệt HĐ — {shortName}",
|
||||||
|
MenuKeys.ApprovalWorkflowsV2, awV2ContractOrder++, "FileSignature"));
|
||||||
|
}
|
||||||
|
|
||||||
var existingItems = await db.MenuItems.ToDictionaryAsync(m => m.Key);
|
var existingItems = await db.MenuItems.ToDictionaryAsync(m => m.Key);
|
||||||
var added = 0;
|
var added = 0;
|
||||||
var reordered = 0;
|
var reordered = 0;
|
||||||
|
|||||||
@ -0,0 +1,244 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Infrastructure.Persistence;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||||
|
|
||||||
|
// [W3 — S187 2026-08-10] Khoá DRIFT menu-key/policy cho họ leaf Designer `AwV2_*`
|
||||||
|
// (dưới root "Quy trình duyệt (Mới)" = `ApprovalWorkflowsV2`).
|
||||||
|
//
|
||||||
|
// ĐÍCH của wave W3 (spec `spec-duyet-hd-10-08-2026.md:10` + `sub-plan-review-fable.md §5`):
|
||||||
|
// thêm **7 key `AwV2_Hd*`** vào `MenuKeys.All` + 7 menu-row trong `SeedMenuTreeAsync`, mirror
|
||||||
|
// TRỌN khuôn 8 key `AwV2_KhkkN{1..8}` (K3 @S164/S165), cho 7 mã quy trình duyệt HĐ
|
||||||
|
// `HD-TP · HD-GK · HD-NCC · HD-DV · HD-MB · HD-NT-NCC · HD-NT-DV`.
|
||||||
|
// Hệ quả SỐ (2 row canonical `docs/STATUS.md`): Menu keys 113 → **120** · Policies 452 → **480**
|
||||||
|
// (Policies = |All| × |Actions|, DERIVED — khoá ở `KhkkGroupMenuSeedTests` T2, xem §"chồng lấn").
|
||||||
|
//
|
||||||
|
// ── VÌ SAO FILE NÀY KHÔNG VIẾT TÊN KEY RA CHỮ ────────────────────────────────────────────
|
||||||
|
// Spec chốt "CÓ sinh 7 key `AwV2_Hd*`" nhưng KHÔNG chốt chuỗi ký tự cụ thể (`AwV2_HdTp`?
|
||||||
|
// `AwV2_HdThauPhu`? theo `ContractTypeCodes` hay theo token mã `HD-TP`?). Viết thẳng
|
||||||
|
// `MenuKeys.ApprovalWorkflowHdTp` mà lane BE đặt tên khác ⇒ LỖI BIÊN DỊCH ⇒ sập cả test-project
|
||||||
|
// ⇒ đỏ vì "test sai", đúng thứ phải tránh. Nên: **0 tham chiếu tới symbol C# chưa tồn tại** —
|
||||||
|
// họ key mới được PHÁT HIỆN bằng phép trừ trên `MenuKeys.All` (10 key cũ tham chiếu qua const
|
||||||
|
// CÓ THẬT). Tập rỗng ⇒ đỏ tại vế tiền-đề với thông điệp "chưa có key nào" (= key chưa có).
|
||||||
|
// 🔄 Cập nhật giữa lượt: lane BE ĐÃ land `MenuKeys.cs` + `ContractApprovalWorkflowFamily.cs`
|
||||||
|
// (2026-08-10) ⇒ 7 chuỗi key nay ĐỌC ĐƯỢC TỪ MÃ và được ghim VIẾT TAY ở `Expected7` (khoá drift
|
||||||
|
// tên — xem chú thích tại đó). Vẫn giữ luật "0 symbol mới": file này KHÔNG gọi
|
||||||
|
// `MenuKeys.ContractWorkflowMenuKeys` / `ContractApprovalWorkflowFamily.KeyToken` — gọi bộ sinh
|
||||||
|
// làm kỳ vọng là tautology, và tên field của lane khác đổi thì test phải ĐỎ có thông điệp, chứ
|
||||||
|
// không phải sập biên dịch cả test-project.
|
||||||
|
//
|
||||||
|
// ── CHỒNG LẤN đã tra TRƯỚC khi viết (bài S166) ───────────────────────────────────────────
|
||||||
|
// • `ContractCatalogTests.cs:445` (T6 lane K1) — `All.Except(menuKeysInDb).BeEmpty()` phủ
|
||||||
|
// chiều "key ∈ All ⇒ có menu-row" cho TOÀN BỘ `All` ⇒ file này KHÔNG nhân bản, chỉ thêm
|
||||||
|
// bản gia-cố per-key (thông điệp gọi đúng tên key thiếu) + **chiều NGƯỢC chưa ai đo**.
|
||||||
|
// • `KhkkGroupMenuSeedTests.cs:131` (T2 lane K4a) — số neo `|All|` DUY NHẤT trong `tests/`;
|
||||||
|
// update 113 → 120 ở CHÍNH nó (không mở số neo thứ hai ở đây — 1 sự thật 1 chỗ).
|
||||||
|
// • `grep "AwV2\|KhkkN" tests/` = **0 hit** trước lượt này ⇒ họ 10 leaf Designer đang sống
|
||||||
|
// CHƯA có test nào. File này khoá cả họ cũ (control dương, XANH hôm nay) lẫn họ mới.
|
||||||
|
// • Admin grant 4 cờ cho key mới: CỐ Ý KHÔNG viết — by-construction từ loop `MenuKeys.All`
|
||||||
|
// trong `SeedAdminPermissionsAsync :2299`, và đã có chứng nhân độc lập ở ContractCatalog T6.
|
||||||
|
// • `RevokeTemporarilyHiddenModulesAsync`: bộ lọc `:2568-2571` = `StartsWith("Hrm"|"Off") ||
|
||||||
|
// == Personal`; site-2 `:2413` CỐ Ý loại `AwV2_*` (admin-only) ⇒ không có ca thật, không
|
||||||
|
// viết test trang trí.
|
||||||
|
public class AwV2DesignerMenuKeyTests
|
||||||
|
{
|
||||||
|
// Tiền tố họ leaf Designer. Root `ApprovalWorkflowsV2` = "ApprovalWorkflowsV2" (KHÔNG mang
|
||||||
|
// tiền tố này) nên phép lọc dưới chỉ bắt LEAF.
|
||||||
|
private const string DesignerLeafPrefix = "AwV2_";
|
||||||
|
|
||||||
|
// Khuôn tên bắt buộc của họ mới (spec §5: "+7 key `AwV2_Hd*`").
|
||||||
|
private const string ContractDesignerPrefix = "AwV2_Hd";
|
||||||
|
|
||||||
|
// 10 leaf Designer ĐANG SỐNG — tham chiếu qua const CÓ THẬT (đổi tên const ⇒ lỗi biên dịch
|
||||||
|
// ngay, đúng chỗ). Vừa là tập trừ để phát hiện họ mới, vừa là CONTROL DƯƠNG.
|
||||||
|
private static readonly string[] LegacyDesignerLeafKeys =
|
||||||
|
{
|
||||||
|
MenuKeys.ApprovalWorkflowDuyetNccV2,
|
||||||
|
MenuKeys.ApprovalWorkflowDuyetNccPhuongAnV2,
|
||||||
|
MenuKeys.ApprovalWorkflowKhkkN1, MenuKeys.ApprovalWorkflowKhkkN2,
|
||||||
|
MenuKeys.ApprovalWorkflowKhkkN3, MenuKeys.ApprovalWorkflowKhkkN4,
|
||||||
|
MenuKeys.ApprovalWorkflowKhkkN5, MenuKeys.ApprovalWorkflowKhkkN6,
|
||||||
|
MenuKeys.ApprovalWorkflowKhkkN7, MenuKeys.ApprovalWorkflowKhkkN8,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 7 mã quy trình HĐ (lane t0 đã ghim cùng bộ ở `UpdateAwDefinitionTests.cs:1574`).
|
||||||
|
private static readonly string[] ContractWorkflowCodes =
|
||||||
|
{ "HD-TP", "HD-GK", "HD-NCC", "HD-DV", "HD-MB", "HD-NT-NCC", "HD-NT-DV" };
|
||||||
|
|
||||||
|
// 7 key kỳ vọng, VIẾT TAY (khuôn `KhkkGroupMenuSeedTests.Expected49`): CỐ Ý **không** gọi
|
||||||
|
// `MenuKeys.ContractWorkflowMenuKeys` / `ContractApprovalWorkflowFamily.KeyToken` — gọi lại
|
||||||
|
// bộ sinh là TAUTOLOGY (luật sinh sai chuỗi thì kỳ vọng sai theo, test xanh vô nghĩa).
|
||||||
|
// Chuỗi dưới ĐỌC TỪ MÃ ĐÃ LAND @S187 W3: mã `HD-{X}` → bỏ tiền tố `HD-`, bỏ mọi `-`, GIỮ
|
||||||
|
// hoa/thường → ghép `AwV2_Hd{token}` (`ContractApprovalWorkflowFamily.KeyToken`).
|
||||||
|
// 🔴 Đây là chỗ khoá DRIFT TÊN: đổi luật đặt key (vd "normalize" về `AwV2_Hdtp`) mà không
|
||||||
|
// đổi dòng này = ĐỎ — đúng ý, vì đổi key sau khi đã seed là mồ côi permission-row đã cấp
|
||||||
|
// (bài S155) + lệch `fe-*/src/lib/menuKeys.ts` đồng bộ tay.
|
||||||
|
private static readonly string[] Expected7 =
|
||||||
|
{
|
||||||
|
"AwV2_HdTP", "AwV2_HdGK", "AwV2_HdNCC", "AwV2_HdDV",
|
||||||
|
"AwV2_HdMB", "AwV2_HdNTNCC", "AwV2_HdNTDV",
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string ExpectedCount = "7";
|
||||||
|
|
||||||
|
// Họ MỚI = mọi leaf `AwV2_*` trong `All` TRỪ 10 leaf cũ.
|
||||||
|
private static string[] ContractDesignerKeysInAll() =>
|
||||||
|
MenuKeys.All
|
||||||
|
.Where(k => k.StartsWith(DesignerLeafPrefix, StringComparison.Ordinal))
|
||||||
|
.Except(LegacyDesignerLeafKeys, StringComparer.Ordinal)
|
||||||
|
.OrderBy(k => k, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Seeder `private static` → REFLECTION (KHÔNG sửa prod đổi visibility) — mirror
|
||||||
|
// `KhkkGroupMenuSeedTests.InvokePrivateSeedAsync`.
|
||||||
|
private static async Task SeedMenuAsync(TestApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var mi = typeof(DbInitializer).GetMethod("SeedMenuTreeAsync", BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
mi.Should().NotBeNull("DbInitializer.SeedMenuTreeAsync phải tồn tại (private static) — đổi signature thì cập nhật test");
|
||||||
|
await (Task)mi!.Invoke(null, new object[] { db, NullLogger.Instance })!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<MenuItem>> DesignerChildrenAsync(TestApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var rows = await db.MenuItems.AsNoTracking()
|
||||||
|
.Where(m => m.ParentKey == MenuKeys.ApprovalWorkflowsV2).ToListAsync();
|
||||||
|
return rows.OrderBy(r => r.Key, StringComparer.Ordinal).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================================
|
||||||
|
// T1 — `MenuKeys.All`: đúng 7 key Designer HĐ, đúng khuôn tên `AwV2_Hd*`, 10 key cũ NGUYÊN.
|
||||||
|
// Vế "10 key cũ nguyên" đứng TRƯỚC = CONTROL DƯƠNG cho chính phép trừ: nếu ai đổi tên
|
||||||
|
// họ `AwV2_KhkkN*` thì tập-trừ sẽ nhặt nhầm chúng vào "họ mới" và mọi kết luận dưới
|
||||||
|
// thành rác — vế này bắt đúng ca đó trước khi kịp kết luận sai.
|
||||||
|
// =====================================================================================
|
||||||
|
[Fact]
|
||||||
|
public void MenuKeys_All_HasSevenContractDesignerKeys_AndKeepsTheTenLegacyOnes()
|
||||||
|
{
|
||||||
|
// ── CONTROL DƯƠNG: cơ sở của phép trừ còn nguyên ─────────────────────────────────
|
||||||
|
MenuKeys.All.Should().Contain(LegacyDesignerLeafKeys,
|
||||||
|
"10 leaf Designer đang sống (2 Duyệt NCC + 8 KHKK nhóm) phải còn trong `All` — mất/đổi tên " +
|
||||||
|
"là (a) mồ côi permission-row đã cấp (bài S155) và (b) phép trừ tìm 'họ mới' bên dưới hỏng thước");
|
||||||
|
|
||||||
|
LegacyDesignerLeafKeys.Should().OnlyHaveUniqueItems("10 hằng phải trỏ 10 chuỗi khác nhau");
|
||||||
|
|
||||||
|
// ── VẾ CHÍNH: họ MỚI phải có đúng 7 thành viên ───────────────────────────────────
|
||||||
|
var family = ContractDesignerKeysInAll();
|
||||||
|
|
||||||
|
family.Should().HaveCount(7,
|
||||||
|
$"W3 @S187 thêm ĐÚNG {ExpectedCount} leaf Designer cho {ContractWorkflowCodes.Length} loại HĐ " +
|
||||||
|
$"({string.Join(" · ", ContractWorkflowCodes)}) — mỗi loại 1 quy trình V2 riêng (PA-A). " +
|
||||||
|
"Đếm được 0 = lane BE chưa land 7 key vào `MenuKeys.All` (spec-change: test + code CÙNG wave, " +
|
||||||
|
"rules §7). Đếm 6/8 = lệch danh sách. Số này quyết định 2 row canonical `Menu keys` (113→120) " +
|
||||||
|
"và `Policies` (452→480 = |All|×4) ở docs/STATUS.md");
|
||||||
|
|
||||||
|
family.Should().OnlyHaveUniqueItems(
|
||||||
|
"key đôi trong `All` ⇒ SeedAdminPermissionsAsync chèn Permission trùng (RoleId, MenuKey) ⇒ nổ UNIQUE");
|
||||||
|
|
||||||
|
// ── ĐỦ 7 TÊN, đúng chuỗi ký tự (khoá drift tên — xem chú thích `Expected7`) ──────
|
||||||
|
family.Should().BeEquivalentTo(Expected7,
|
||||||
|
$"7 key phải đúng khuôn `{ContractDesignerPrefix}{{token}}` với token = mã bỏ `HD-` và bỏ `-` " +
|
||||||
|
$"({string.Join(" → ", ContractWorkflowCodes.Select((c, i) => c + "/" + Expected7[i]))}). " +
|
||||||
|
"Sai 1 ký tự = menu-row lệch key với `MenuKeys.All` ⇒ Permission FK nổ lúc boot; đổi key SAU khi " +
|
||||||
|
"đã seed = mồ côi permission-row đã cấp (bài S155) + lệch `fe-*/src/lib/menuKeys.ts` (đồng bộ tay)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================================
|
||||||
|
// T2 — MENU-ROW cho 7 key mới: có row thật, treo đúng cha `ApprovalWorkflowsV2`, và HAI
|
||||||
|
// CHIỀU khớp nhau với `All`:
|
||||||
|
// (A) key ∈ All ⇒ có menu-row — thiếu row = Permission FK nổ NGAY lần boot đầu
|
||||||
|
// (`MenuKeys.cs:125-126`; chiều này `ContractCatalogTests:445` đã phủ toàn cục,
|
||||||
|
// ở đây gia cố per-key để thông điệp gọi đúng tên key thiếu).
|
||||||
|
// (B) menu-row `AwV2_*` ⇒ key ∈ All — CHƯA AI ĐO. Row có mà key ngoài `All` thì
|
||||||
|
// `Program.cs` KHÔNG sinh policy ⇒ mục hiện trên sidebar admin nhưng bấm vào là
|
||||||
|
// 403/500 (cùng lớp gotcha #85: menu rộng hơn API).
|
||||||
|
// Thứ tự: seed → CONTROL DƯƠNG trên 10 leaf cũ → tiền-đề 7 → vế mới. Control đứng
|
||||||
|
// trước để lúc đỏ còn phân biệt được "seeder không chạy" với "7 key chưa có".
|
||||||
|
// =====================================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMenuTree_ContractDesignerLeaves_HaveRowsUnderApprovalWorkflowsV2_AndMatchAllBothWays()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
|
||||||
|
await SeedMenuAsync(db);
|
||||||
|
|
||||||
|
// ── CONTROL DƯƠNG: seeder CÓ chạy và khuôn assert dưới là thoả được ──────────────
|
||||||
|
var root = await db.MenuItems.AsNoTracking().FirstOrDefaultAsync(m => m.Key == MenuKeys.ApprovalWorkflowsV2);
|
||||||
|
root.Should().NotBeNull("root 'Quy trình duyệt (Mới)' phải có row — vế này đỏ = seeder không chạy, đừng đọc các vế sau");
|
||||||
|
root!.ParentKey.Should().Be(MenuKeys.System, "root Designer treo dưới Hệ thống");
|
||||||
|
|
||||||
|
var children = await DesignerChildrenAsync(db);
|
||||||
|
children.Select(c => c.Key).Should().Contain(LegacyDesignerLeafKeys,
|
||||||
|
"10 leaf Designer cũ phải có row dưới đúng root — vế này đỏ = hỏng THƯỚC (parent/seeder), " +
|
||||||
|
"không phải chuyện của 7 key mới");
|
||||||
|
|
||||||
|
// ── TIỀN-ĐỀ (chống vacuous): không có 7 key thì mọi vòng lặp dưới chạy 0 lần ─────
|
||||||
|
var family = ContractDesignerKeysInAll();
|
||||||
|
family.Should().HaveCount(7,
|
||||||
|
$"tiền-đề: `MenuKeys.All` phải có {ExpectedCount} key `{ContractDesignerPrefix}*` (W3). " +
|
||||||
|
"Đếm 0 = lane BE chưa land — mọi assert menu-row bên dưới sẽ đúng-vô-nghĩa nếu bỏ vế này");
|
||||||
|
|
||||||
|
// ── (A) key ∈ All ⇒ menu-row, đúng cha, có nhãn + icon ──────────────────────────
|
||||||
|
foreach (var key in family)
|
||||||
|
{
|
||||||
|
var row = children.FirstOrDefault(c => c.Key == key);
|
||||||
|
row.Should().NotBeNull(
|
||||||
|
$"{key} ∈ MenuKeys.All ⇒ BẮT BUỘC có menu-row trong SeedMenuTreeAsync với ParentKey=" +
|
||||||
|
$"'{MenuKeys.ApprovalWorkflowsV2}'. Thiếu row = SeedAdminPermissionsAsync chèn Permission " +
|
||||||
|
"→ FK MenuKey→MenuItem.Key nổ NGAY lần boot đầu (MenuKeys.cs:125-126)");
|
||||||
|
row!.Label.Should().NotBeNullOrWhiteSpace($"{key} phải có nhãn tiếng Việt hiện trên sidebar admin");
|
||||||
|
row.Icon.Should().NotBeNullOrWhiteSpace($"{key} phải có icon như 10 leaf anh em (sidebar lệch hình nếu trống)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (B) CHIỀU NGƯỢC: mọi menu-row họ `AwV2_*` phải nằm trong `All` ──────────────
|
||||||
|
// Lọc TRONG BỘ NHỚ bằng Ordinal (LIKE của SQLite case-insensitive ASCII — không muốn
|
||||||
|
// phép đo phụ thuộc collation provider; bài S166).
|
||||||
|
var allRowKeys = await db.MenuItems.AsNoTracking().Select(m => m.Key).ToListAsync();
|
||||||
|
var awV2RowKeys = allRowKeys.Where(k => k.StartsWith(DesignerLeafPrefix, StringComparison.Ordinal)).ToArray();
|
||||||
|
|
||||||
|
awV2RowKeys.Should().HaveCount(17,
|
||||||
|
$"10 leaf cũ + {ExpectedCount} leaf HĐ mới. Đếm 10 = 7 row mới chưa được seed; " +
|
||||||
|
"đếm >17 = seed dư row (mục lạ trên sidebar admin)");
|
||||||
|
|
||||||
|
awV2RowKeys.Except(MenuKeys.All, StringComparer.Ordinal).Should().BeEmpty(
|
||||||
|
"menu-row họ `AwV2_*` mà key NGOÀI `MenuKeys.All` ⇒ Program.cs không sinh policy cho key đó ⇒ " +
|
||||||
|
"mục hiện trên sidebar nhưng endpoint/gate tương ứng 403/500 (cùng lớp gotcha #85). " +
|
||||||
|
"Vào `All` và seed row là MỘT cặp, không được lệch nửa");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================================
|
||||||
|
// T3 — HÌNH SIDEBAR của cả 17 leaf: NHÃN phân biệt + ORDER phân biệt.
|
||||||
|
// Khối 7 dòng mới là copy-paste của khối 8 dòng `AwV2_KhkkN*` (`DbInitializer:1988-1995`)
|
||||||
|
// ⇒ 2 lỗi kinh điển của copy-paste khối: (i) quên sửa 1 nhãn ⇒ 2 mục cùng tên
|
||||||
|
// "Duyệt HĐ — …" trên sidebar admin ⇒ chọn nhầm Designer ⇒ sửa quy trình của LOẠI HĐ
|
||||||
|
// KHÁC; (ii) quên tăng Order ⇒ 2 mục cùng Order ⇒ thứ tự anh em bất định giữa các lần
|
||||||
|
// render (GetMyMenuTree sort theo Order).
|
||||||
|
// Chống vacuous: tiền-đề 17 leaf đứng trước (tập rỗng thì "phân biệt" đúng ở mọi thế giới).
|
||||||
|
// =====================================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMenuTree_AllDesignerLeaves_HaveDistinctLabelsAndDistinctOrders()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
|
||||||
|
await SeedMenuAsync(db);
|
||||||
|
var children = await DesignerChildrenAsync(db);
|
||||||
|
|
||||||
|
children.Should().HaveCount(17,
|
||||||
|
$"2 leaf Duyệt NCC + 8 leaf KHKK nhóm + {ExpectedCount} leaf HĐ (W3 @S187) = 17 mục Designer. " +
|
||||||
|
"Đếm 10 = lane BE chưa land 7 menu-row `AwV2_Hd*`");
|
||||||
|
|
||||||
|
children.Select(c => c.Label).Should().OnlyHaveUniqueItems(
|
||||||
|
"17 mục Designer = 17 nhãn PHÂN BIỆT. Copy-paste khối 7 dòng mà quên sửa 1 nhãn ⇒ 2 mục cùng " +
|
||||||
|
"tên trên sidebar admin ⇒ mở nhầm Designer của loại HĐ khác (sửa quy trình sai loại)");
|
||||||
|
|
||||||
|
children.Select(c => c.Order).Should().OnlyHaveUniqueItems(
|
||||||
|
"17 mục = 17 Order PHÂN BIỆT. Trùng Order ⇒ thứ tự anh em bất định (GetMyMenuTree sort theo " +
|
||||||
|
"Order) ⇒ sidebar đổi chỗ giữa các lần render");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -118,6 +118,10 @@ public class KhkkGroupMenuSeedTests
|
|||||||
// ⭐ Vế cuối = guard thật: nếu ai thêm số 1 vào `KhkkGroupNumbers` thì `Khkk_G1` vào
|
// ⭐ Vế cuối = guard thật: nếu ai thêm số 1 vào `KhkkGroupNumbers` thì `Khkk_G1` vào
|
||||||
// `All` VÀ vòng seed thêm `tree.Add("Khkk_G1")` lần thứ hai (row cứng đã có ở
|
// `All` VÀ vòng seed thêm `tree.Add("Khkk_G1")` lần thứ hai (row cứng đã có ở
|
||||||
// initializer) ⇒ 2 entry cùng PK trong 1 lần SaveChanges = nổ lúc boot DB trống.
|
// initializer) ⇒ 2 entry cùng PK trong 1 lần SaveChanges = nổ lúc boot DB trống.
|
||||||
|
// 🔄 [W3 — S187 2026-08-10] SỐ NEO update 113 → 120: wave W3 thêm 7 leaf Designer
|
||||||
|
// `AwV2_Hd*` (7 loại HĐ — spec `spec-duyet-hd-10-08-2026.md:10` §5). Đây là số neo
|
||||||
|
// |All| DUY NHẤT trong `tests/` (1 sự thật 1 chỗ) — hình dạng họ key mới khoá riêng ở
|
||||||
|
// `AwV2DesignerMenuKeyTests`, KHÔNG mở số neo thứ hai ở đó.
|
||||||
// =====================================================================================
|
// =====================================================================================
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MenuKeys_All_ContainsThe49_NoDuplicate_AndKeepsGroup1Outside()
|
public void MenuKeys_All_ContainsThe49_NoDuplicate_AndKeepsGroup1Outside()
|
||||||
@ -128,9 +132,12 @@ public class KhkkGroupMenuSeedTests
|
|||||||
MenuKeys.All.Should().OnlyHaveUniqueItems(
|
MenuKeys.All.Should().OnlyHaveUniqueItems(
|
||||||
"key đôi trong All ⇒ SeedAdminPermissionsAsync chèn Permission trùng (RoleId, MenuKey) ⇒ nổ UNIQUE");
|
"key đôi trong All ⇒ SeedAdminPermissionsAsync chèn Permission trùng (RoleId, MenuKey) ⇒ nổ UNIQUE");
|
||||||
|
|
||||||
MenuKeys.All.Should().HaveCount(113,
|
MenuKeys.All.Should().HaveCount(120,
|
||||||
"số ĐO runtime tại S166 sau K4a (+49). `Policies` = |All| × |Actions| = 113 × 4 = 452 — " +
|
"ĐÍCH sau W3 @S187 = 113 (số ĐO runtime tại S166 sau K4a) + 7 leaf Designer `AwV2_Hd*` cho " +
|
||||||
"đổi số ở đây BẮT BUỘC đổi 2 row canonical `Menu keys` / `Policies` trong docs/STATUS.md");
|
"7 loại HĐ (HD-TP · HD-GK · HD-NCC · HD-DV · HD-MB · HD-NT-NCC · HD-NT-DV). " +
|
||||||
|
"`Policies` = |All| × |Actions| = 120 × 4 = 480 — đổi số ở đây BẮT BUỘC đổi 2 row canonical " +
|
||||||
|
"`Menu keys` / `Policies` trong docs/STATUS.md CÙNG COMMIT. " +
|
||||||
|
"Đo được 113 = lane BE chưa land 7 key (spec-change: test + code CÙNG wave, rules §7)");
|
||||||
|
|
||||||
MenuKeys.KhkkGroupNumbers.Should().Equal(new[] { 2, 3, 4, 5, 6, 7, 8 },
|
MenuKeys.KhkkGroupNumbers.Should().Equal(new[] { 2, 3, 4, 5, 6, 7, 8 },
|
||||||
"nhóm 1 CỐ Ý ngoài bộ sinh — 7 key nhóm 1 không-infix đã có row cứng trong SeedMenuTreeAsync");
|
"nhóm 1 CỐ Ý ngoài bộ sinh — 7 key nhóm 1 không-infix đã có row cứng trong SeedMenuTreeAsync");
|
||||||
|
|||||||
Reference in New Issue
Block a user