[CLAUDE] Contract: K3 SP-002 8 workflow nhom KHKK-N1-8 + port level-finalize + designer (0-mig) + 5 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m10s

Seed 8 workflow type-10 theo ROSTER LIVE prod (QT-DN-V2-001, lead verify tay
@SQLEXPRESS: 3 tram PRO->CCM->CEO, 11 nguoi, resolve EMAIL, idempotent per-Code;
co Finalize CHI cap chuong.phan). Port nhanh level-finalize mirror PE :866-876
(EndedByLevelFinalize K2-de-K3-tieu + ApplyApprovedValuesOnFinalize truoc Phase;
opt-out applyLevelFinalize 3-tang interface/service/controller, default true).
Sweep ep-false va-3: GO CREATE ep-false Finalize type-10 (OG-9 supersede PHA-VO
S155); GIU PUT-Conflict (luat versioning CHUNG - spec liet nham, khai lech);
GIU ep-false SkipToFinal type-10 (K3-test O-1 chong config-lie mới) + FE disable
o SkipToFinal cho KHKK (gate-K3 F-2). Rao (v) pin-dung-nhom regex KHKK-N[1-8].
+8 MenuKeys AwV2_KhkkN* (All 56->64, policies 224->256 may-dem) + 8 menu-row +
resolvePath fe-admin phep-am Khkk_ 0-hit. 5 test K3 (T-a..T-e, anti-tautology).
Gate K3 PASS-WITH-FLAGS 8f/0-blocker (sub-reviewer-gate-k3.md): F-1 checkbox
opt-out man duyet = KHAI HANDOFF dau-viec-#1 lan sau (tram CEO tam thoi qua
Designer bo co); F-2 va; F-3 dong truoc do. Suite 614/0 (45D+569I). K3-BE 2 luot
engine-exit -> che nho + lead-solo (bai: task-hep-Write-som song).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-31 18:21:39 +07:00
parent d6cb5469b3
commit 15349e8ee0
15 changed files with 766 additions and 83 deletions

View File

@ -209,7 +209,8 @@ public class ContractSigningPlansController(
[Authorize(Policy = "KeHoachKyKet.Update")]
public async Task<ActionResult<ContractSigningPlanTransitionResult>> Transition(
Guid id, [FromBody] ContractSigningPlanTransitionBody body, CancellationToken ct)
=> Ok(await workflow.TransitionAsync(id, body.Action, body.Comment, ct));
=> Ok(await workflow.TransitionAsync(id, body.Action, body.Comment,
body.ApplyLevelFinalize ?? true, ct));
// ========================= Body records =========================
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
@ -223,7 +224,10 @@ public class ContractSigningPlansController(
// `Comment` tuỳ chọn. Tên property PascalCase bind đúng JSON "action"/"comment" —
// ASP.NET Core mặc định camelCase + đọc không phân biệt hoa/thường
// (`Api/Program.cs:33-34` chỉ thêm converter DateTime, KHÔNG đổi naming policy).
// [K3 S165] `ApplyLevelFinalize` — OPT-OUT khuôn PE S96: null/true = auto kết-thúc-tại-cấp
// khi Cấp có `AllowApproverFinalize`; false = người duyệt BỎ tick ⇒ trình tiếp CEO.
public record ContractSigningPlanTransitionBody(
string Action,
string? Comment = null);
string? Comment = null,
bool? ApplyLevelFinalize = null);
}

View File

@ -375,10 +375,17 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
AllowApproverEditBudget = l.AllowApproverEditBudget,
// [W1 S161 — reviewer F-8, F-B3 containment TẦNG BE] type=10 KHKK: 2 đường
// early-finalize anh phán "PHÁ VỠ" (S155), W3 không port code path ⇒ ép false
// server-side (FE đã ép ở levelFlagsPayload — đây chặn curl bypass). PUT
// sửa-tại-chỗ đã có echo-so-BẰNG chặn đổi cờ ⇒ chỉ cần guard ở CREATE.
// 🔄 [K3 S165 — OG-9 SUPERSEDE "PHÁ VỠ" @S155, sweep vá-3] GỠ ép-false
// type-10 cho `AllowApproverFinalize`: owner @S164 đảo phán quyết ("a Chương
// quyết cái gì cần CEO") — service KHKK ĐÃ port nhánh level-finalize cùng
// wave ⇒ cờ hết config-lie #78. Đường bật cờ = TẠO VERSION MỚI (đây);
// PUT sửa-tại-chỗ vẫn chặn đổi cờ (luật versioning CHUNG mọi type — spec
// vá-3 liệt nhầm vào "cụm ép-false", lead giữ + khai lệch sub-implbe-k3.md).
// 🔴 `AllowApproverSkipToFinal` GIỮ ép-false CHO RIÊNG type-10 (K3-test O-1):
// service KHKK 0 lần đọc cờ này ("giữ nguyên không port" — 3-ĐIỂM-ADAPT (b))
// ⇒ echo nó cho type-10 là ĐẺ config-lie #78 mới (DB true, máy im).
AllowApproverSkipToFinal = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.AllowApproverSkipToFinal,
AllowApproverFinalize = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.AllowApproverFinalize,
AllowApproverFinalize = l.AllowApproverFinalize,
AllowApproverDelete = l.AllowApproverDelete, // [Mig 68 S155] F6
}).ToList(),
})

View File

@ -406,6 +406,19 @@ public class CreateContractSigningPlanCommandHandler(
// ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`.
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct);
// ---- Rào (v) [K3 S165 — QĐ-3 "K3 chỉ ĐỌC ApprovalGroup"] Workflow họ nhóm
// `KHKK-N{n}` ⇒ n PHẢI khớp nhóm duyệt của phiếu (phiếu nhóm 3 pin quy trình nhóm 5
// = 409). Code NGOÀI họ (type-10 legacy/chung) → cho qua — ràng buộc chỉ áp cho
// 8 workflow nhóm seed @K3.
var wfCode = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == request.ApprovalWorkflowId)
.Select(w => w.Code).FirstOrDefaultAsync(ct);
var groupMatch = System.Text.RegularExpressions.Regex.Match(wfCode ?? string.Empty, "^KHKK-N([1-8])$");
if (groupMatch.Success && int.Parse(groupMatch.Groups[1].Value) != approvalGroup)
throw new ConflictException(
$"Quy trình '{wfCode}' thuộc nhóm duyệt N{groupMatch.Groups[1].Value}, " +
$"không khớp nhóm duyệt N{approvalGroup} của phiếu — chọn đúng quy trình của nhóm.");
// 🔴 GEN MÃ TRƯỚC KHI `Add` — `ContractSigningPlanCodeGenerator` TỰ COMMIT
// (`ContractSigningPlanCodeGenerator.cs:23-43`: transaction SERIALIZABLE +
// `SaveChangesAsync` nội bộ). Nếu phiếu đã nằm trong change-tracker lúc gọi, lượt

View File

@ -26,6 +26,7 @@ public interface IContractSigningPlanWorkflowService
Guid planId,
string action,
string? comment,
bool applyLevelFinalize = true,
CancellationToken ct = default);
}

View File

@ -79,6 +79,23 @@ public static class MenuKeys
public const string ApprovalWorkflowDuyetNccV2 = "AwV2_DuyetNcc"; // leaf cho Duyệt NCC mới
public const string ApprovalWorkflowDuyetNccPhuongAnV2 = "AwV2_DuyetNccPhuongAn"; // leaf cho Duyệt NCC và Giải pháp mới
// [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 quy trình duyệt KHKK theo NHÓM
// DUYỆT N1..N8 (danh mục SOL-CCM-SP-002 Mig 70 chia 86 dòng thành 8 nhóm; phiếu
// KHKK chốt nhóm lúc tạo — Mig 71). Mỗi nhóm 1 workflow `KHKK-N{n}` type-10 do
// `DbInitializer.SeedKhkkGroupWorkflowsAsync` seed.
// 🔴 8 key này VÀO `All` (lệnh lead K3-N1) ⇒ sinh 32 policy AwV2_KhkkN{1..8}.{R,C,U,D}
// ⇒ 2 số canonical `docs/STATUS.md` đổi: Menu keys +8 · Policies +32 (DERIVED |All|×|Actions|).
// 🔴 Vào `All` thì BẮT BUỘC có menu-row tương ứng trong `SeedMenuTreeAsync` —
// thiếu row = Permission FK nổ ngay lần boot đầu (invariant test T6 K1).
public const string ApprovalWorkflowKhkkN1 = "AwV2_KhkkN1";
public const string ApprovalWorkflowKhkkN2 = "AwV2_KhkkN2";
public const string ApprovalWorkflowKhkkN3 = "AwV2_KhkkN3";
public const string ApprovalWorkflowKhkkN4 = "AwV2_KhkkN4";
public const string ApprovalWorkflowKhkkN5 = "AwV2_KhkkN5";
public const string ApprovalWorkflowKhkkN6 = "AwV2_KhkkN6";
public const string ApprovalWorkflowKhkkN7 = "AwV2_KhkkN7";
public const string ApprovalWorkflowKhkkN8 = "AwV2_KhkkN8";
// [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
// (PeWorkItemBudgets per cặp Dự án × Hạng mục). Menu/Permission rows cũ
@ -185,6 +202,9 @@ public static class MenuKeys
KeHoachKyKet, // [W1 Mig 69 — S161] GĐ2 KHKK: const :41 vào All ⇒ sinh policy KeHoachKyKet.{Read,Create,Update,Delete}
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
// [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 nhóm duyệt KHKK (+8 key ⇒ +32 policy).
ApprovalWorkflowKhkkN1, ApprovalWorkflowKhkkN2, ApprovalWorkflowKhkkN3, ApprovalWorkflowKhkkN4,
ApprovalWorkflowKhkkN5, ApprovalWorkflowKhkkN6, ApprovalWorkflowKhkkN7, ApprovalWorkflowKhkkN8,
];
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];

View File

@ -164,6 +164,12 @@ public static class DbInitializer
await SeedSampleTravelRequestWorkflowV2Async(db, userManager, logger);
await SeedSampleVehicleBookingWorkflowV2Async(db, userManager, logger);
// [K3 nhịp-1 — S164/S165] 8 quy trình duyệt KHKK theo nhóm duyệt N1..N8
// (ApplicableType=ContractSigningPlan=10). INFRASTRUCTURE seed NOT gated
// DemoSeed (gotcha #51) — prod cần đủ 8 quy trình ngay deploy đầu, thiếu thì
// Drafter không pick được quy trình lúc tạo phiếu KHKK. Idempotent per-Code.
await SeedKhkkGroupWorkflowsAsync(db, userManager, logger);
await WarnDefaultAdminPasswordAsync(userManager, logger);
}
@ -517,6 +523,169 @@ public static class DbInitializer
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for VehicleBooking: QT-XE-V2-001 v01");
}
// ============================================================================
// [K3 nhịp-1 — S164/S165] 8 QUY TRÌNH DUYỆT KHKK theo NHÓM DUYỆT N1..N8
// ============================================================================
// Danh mục Hợp đồng SOL-CCM-SP-002 (Mig 70) chia 86 dòng vào 8 nhóm duyệt
// (`ContractCatalogEntry.ApprovalGroup` 1..8); phiếu KHKK chốt nhóm lúc TẠO
// (`ContractSigningPlan.ApprovalGroup`, Mig 71) ⇒ mỗi nhóm cần 1 quy trình V2
// riêng để pin. Code `KHKK-N{n}`, ApplicableType=ContractSigningPlan (=10).
//
// ĐỘI HÌNH: 3 trạm PRO → CCM → BGĐ, sao đúng workflow Duyệt NCC prod bản LIVE
// (`QT-DN-V2-001`, ApplicableType=1) theo owner OG-2 — "lấy đúng bộ người duyệt
// đang cấu hình ở workflow Duyệt NCC prod". Cấp 1 mỗi trạm = OR-of-N (N row cùng
// Order ⇒ bất kỳ ai trong cấp duyệt là xong cấp đó), Cấp 2 = trưởng phòng.
//
// INFRASTRUCTURE seed — NOT gated DemoSeed (gotcha #51).
// Idempotent PER-CODE (`KHKK-N{n}`), CỐ Ý KHÔNG dùng khuôn `AnyAsync(w.Applicable
// Type == ...)` của 6 seeder mẫu phía trên: ở đây 8 workflow DÙNG CHUNG 1 type,
// check theo type sẽ chặn 7 nhóm còn lại ngay khi nhóm đầu vừa được tạo.
//
// Resolve người duyệt THEO EMAIL. User vắng mặt (DB dev thiếu người thật) ⇒ BỎ QUA
// đúng slot đó + log warning, KHÔNG throw (seeder chạy lúc boot — throw = API chết).
// 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
// 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(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
// 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.
var roster = new (string StepName, string DeptCode, string[] Level1, string[] Level2, bool Level2Finalize)[]
{
("Phòng Cung Ứng", "PRO",
new[]
{
"phuong.nguyen@solutions.com.vn", // Nguyễn Thị Bích Phượng
"long.chau@solutions.com.vn", // Châu Tạ Kim Long
"duy.nguyen@solutions.com.vn", // Nguyễn Văn Duy
"binh.lethanh@solutions.com.vn", // Bình Lê Thanh (⟂ binh.le bên CCM)
},
new[] { "tra.bui@solutions.com.vn" }, // Bùi Lê Thủy Trà (TP.PRO)
false),
("P. Kiểm soát Chi phí", "CCM",
new[]
{
"luu.tran@solutions.com.vn", // Trần Xuân Lưu
"truong.letrandang@solutions.com.vn", // Lê Trần Đăng Trường (⟂ truong.nguyen bên BGĐ)
"binh.le@solutions.com.vn", // Lê Văn Bình
"nguyen.ho@solutions.com.vn", // Hồ Thị Nữ Nguyên
},
new[] { "chuong.phan@solutions.com.vn" }, // Phan Văn Chương (TP.CCM)
// 🔴 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
// phải config-lie #78.
true),
("Ban Giám đốc", "BOD",
new[] { "truong.nguyen@solutions.com.vn" }, // Nguyễn Văn Trường (CEO)
Array.Empty<string>(),
false),
};
// Resolve email → UserId MỘT lượt (11 email, dùng lại cho cả 8 workflow).
var usersByEmail = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
var missingEmails = new List<string>();
foreach (var email in roster.SelectMany(r => r.Level1.Concat(r.Level2))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
var user = await userManager.FindByEmailAsync(email);
if (user is null) { missingEmails.Add(email); continue; }
usersByEmail[email] = user.Id;
}
if (missingEmails.Count > 0)
logger.LogWarning(
"SeedKhkkGroupWorkflowsAsync: {Count} email người duyệt KHÔNG có user — bỏ qua slot đó: {Emails}",
missingEmails.Count, string.Join(", ", missingEmails));
var deptIdByCode = (await db.Departments.ToListAsync())
.GroupBy(d => d.Code)
.ToDictionary(g => g.Key, g => (Guid?)g.First().Id);
var seeded = 0;
for (var n = 1; n <= 8; n++)
{
var code = $"KHKK-N{n}";
if (await db.ApprovalWorkflows.AnyAsync(w => w.Code == code)) continue;
var wf = new ApprovalWorkflow
{
Code = code,
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
Name = $"Duyệt KHKK — Nhóm {n}",
Description =
$"Quy trình duyệt Kế hoạch ký kết HĐ cho nhóm duyệt N{n} (danh mục SOL-CCM-SP-002). " +
"3 trạm PRO → CCM → BGĐ, đội hình sao từ workflow Duyệt NCC prod. " +
"Admin có thể 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,
};
var stepOrder = 1;
foreach (var r in roster)
{
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, 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)
logger.LogWarning(
"SeedKhkkGroupWorkflowsAsync: {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} KHKK group workflows (K3 SP-002)", seeded);
}
}
// 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).
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)
@ -1808,6 +1977,18 @@ public static class DbInitializer
(MenuKeys.ApprovalWorkflowsV2, "Quy trình duyệt (Mới)", MenuKeys.System, 96, "Workflow"),
(MenuKeys.ApprovalWorkflowDuyetNccV2, "Duyệt NCC (Mới)", MenuKeys.ApprovalWorkflowsV2, 1, "FileCheck"),
(MenuKeys.ApprovalWorkflowDuyetNccPhuongAnV2, "Duyệt NCC và Giải pháp (Mới)", MenuKeys.ApprovalWorkflowsV2, 2, "FileCheck"),
// [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 nhóm duyệt KHKK (workflow
// `KHKK-N{n}` type-10 seed ở SeedKhkkGroupWorkflowsAsync). Key CÓ trong
// MenuKeys.All ⇒ Admin được grant tự động qua loop SeedAdminPermissionsAsync;
// upsert dưới là idempotent per-Key (key đã có → chỉ re-set Order rồi continue).
(MenuKeys.ApprovalWorkflowKhkkN1, "Duyệt KHKK — Nhóm 1", MenuKeys.ApprovalWorkflowsV2, 3, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN2, "Duyệt KHKK — Nhóm 2", MenuKeys.ApprovalWorkflowsV2, 4, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN3, "Duyệt KHKK — Nhóm 3", MenuKeys.ApprovalWorkflowsV2, 5, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN4, "Duyệt KHKK — Nhóm 4", MenuKeys.ApprovalWorkflowsV2, 6, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN5, "Duyệt KHKK — Nhóm 5", MenuKeys.ApprovalWorkflowsV2, 7, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN6, "Duyệt KHKK — Nhóm 6", MenuKeys.ApprovalWorkflowsV2, 8, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN7, "Duyệt KHKK — Nhóm 7", MenuKeys.ApprovalWorkflowsV2, 9, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN8, "Duyệt KHKK — Nhóm 8", MenuKeys.ApprovalWorkflowsV2, 10, "FileSignature"),
// [S61 Mig 50] Menu module Ngân sách cũ (Budgets + 3 leaf Bg_*) đã XÓA —
// rows cũ trên DB cleanup qua SQL trong migration (idempotent DELETE).
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). Root operational HR.

View File

@ -34,16 +34,17 @@ namespace SolutionErp.Infrastructure.Services;
//
// 3 ĐIỂM ADAPT:
// (a) Finalize choke-point: helper `ApplyApprovedValuesOnFinalize` gọi ở MỌI nhánh set
// `DaDuyet` — xem khối LUẬT ngay trên helper. 🔴 KHAI RÕ: service này có **ĐÚNG MỘT**
// nhánh set DaDuyet, KHÔNG có site thứ hai kiểu admin-override của nguồn copy
// (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` qua BIẾN).
// Lý do: hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số
// `targetPhase` ⇒ Admin muốn kết thúc phiếu vẫn phải đi đúng đường `approve`
// (Admin chỉ được miễn vế "có tên trong Cấp", không được miễn choke-point).
// Đó chính là write-path 2 mà test T3 đo.
// (b) 🔴 KHÔNG PORT 2 nhánh kết thúc sớm (`AllowApproverFinalize`, `CeoApprovalThreshold`)
// và cũng KHÔNG port `AllowApproverSkipToFinal` (`:318-352` nguồn copy) — chủ dự án
// phán "PHÁ VỠ" @S155. W3 chỉ đường THƯỜNG: PMH → CCM → CEO ký thật.
// `DaDuyet` — xem khối LUẬT ngay trên helper. 🔄 [K3 S165] service NAY có **HAI** nhánh
// set DaDuyet (đường-thường Cấp-cuối + level-finalize dưới đây), CẢ HAI qua choke-point;
// vẫn KHÔNG có site gán-qua-BIẾN kiểu nguồn copy (`ContractWorkflowService.cs:190-193`):
// hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số `targetPhase`
// ⇒ Admin kết thúc phiếu vẫn đi đúng đường `approve` (miễn vế "có tên trong Cấp",
// KHÔNG min choke-point). Tự-kiểm 2 lệnh grep ở khối LUẬT phải cùng khớp.
// (b) 🔄 [K3 S165 — OG-9 SUPERSEDE "PHÁ VỠ" @S155] Owner @S164 đảo phán quyết (verbatim:
// "chỗ budget chốt chặn là a Chương… bài toán trần budget xem như đóng") ⇒ ĐÃ PORT
// nhánh `AllowApproverFinalize` (level-finalize, OPT-OUT khuôn PE S96 — cờ seed cho
// TP.CCM Phan Văn Chương @K3). VẪN KHÔNG port `CeoApprovalThreshold` (ngưỡng máy —
// NĐUQ = human-gate vĩnh viễn, OG-3) và `AllowApproverSkipToFinal` giữ nguyên không port.
// (c) Trả lại = `TraLai` TOÀN PHẦN (về người soạn, xoá con-trỏ; gửi lại chạy LẠI từ Bước 1
// Cấp 1). 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) CỐ Ý chưa đọc —
// mở thêm mode là đổi hành vi duyệt, thuộc quyền lead.
@ -87,6 +88,7 @@ public class ContractSigningPlanWorkflowService(
Guid planId,
string action,
string? comment,
bool applyLevelFinalize = true,
CancellationToken ct = default)
{
// Rào action đứng TRƯỚC mọi truy vấn: gõ sai tên hành động là lỗi dữ liệu gửi lên,
@ -121,7 +123,7 @@ public class ContractSigningPlanWorkflowService(
break;
case "approve":
await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, ct);
await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, applyLevelFinalize, ct);
break;
case "return":
@ -242,7 +244,8 @@ public class ContractSigningPlanWorkflowService(
// approve — approver Cấp đang chờ duyệt (OR-of-N)
// =====================================================================
private async Task ApproveV2Async(
ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct)
ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment,
bool applyLevelFinalize, CancellationToken ct)
{
if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
throw new ConflictException("Chỉ duyệt được phiếu đang ở trạng thái Đã gửi duyệt.");
@ -264,6 +267,27 @@ public class ContractSigningPlanWorkflowService(
var fromPhase = plan.Phase;
// ---- [K3 S165 — OG-3/OG-9 port level-finalize, mirror PE `:866-876`] Cấp có cờ
// `AllowApproverFinalize` (seed K3: TP.CCM Phan Văn Chương, Bước 2 Cấp 2) được KẾT THÚC
// quy trình TẠI ĐÂY thay vì trình tiếp CEO. OPT-OUT khuôn PE S96: client gửi
// `applyLevelFinalize=false` (người duyệt BỎ tick "Cấp này KẾT THÚC") ⇒ rơi xuống
// advance thường. Supersede "PHÁ VỠ" @S155 theo lời owner @S164 (xem 3-ĐIỂM-ADAPT (b)).
// Cờ RUNTIME `EndedByLevelFinalize` = Mig 71 K2 đẻ — K3 tiêu thụ, HẾT ghost-window.
if (actingLevel.AllowApproverFinalize && applyLevelFinalize)
{
plan.EndedByLevelFinalize = true;
ApplyApprovedValuesOnFinalize(plan); // LUẬT choke-point: TRƯỚC khi gán Phase
plan.Phase = ContractSigningPlanPhase.DaDuyet;
plan.CurrentWorkflowStepIndex = null;
plan.CurrentApprovalLevelOrder = null;
await LogSigningPlanTransitionAsync(
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
actingLevel.Id,
$"Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — KẾT THÚC TẠI CẤP (level-finalize), kế hoạch ĐÃ DUYỆT, chốt giá",
ct);
return;
}
// ---- Advance con-trỏ ĐÔI: còn Cấp trong Bước → Cấp++; hết Cấp → Bước kế (Cấp 1);
// hết Bước → FINALIZE.
if (currentLevelOrder < maxLevelOrder)