[CLAUDE] Contract: YC-030 Q3+Q5 — port finalize KHKK vào máy duyệt HĐ (Mig 73 EndedByLevelFinalize + ApproveV2 + body/DTO nở + tắt QT-HD-V2-001, test 697)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-12 21:57:57 +07:00
parent 5facab2f48
commit 698603625b
11 changed files with 7713 additions and 25 deletions

View File

@ -44,6 +44,7 @@ public class ContractWorkflowService(
ApprovalDecision decision,
string? comment,
bool skipToFinal = false,
bool applyLevelFinalize = true,
CancellationToken ct = default)
{
var fromPhase = contract.Phase;
@ -163,7 +164,7 @@ public class ContractWorkflowService(
// V1 legacy giữ behavior cũ — 7 prod contract chạy nhánh này.
if (contract.ApprovalWorkflowId is Guid awId)
{
await ApproveV2Async(contract, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, ct);
await ApproveV2Async(contract, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, applyLevelFinalize, ct);
await db.SaveChangesAsync(ct);
return;
}
@ -374,6 +375,7 @@ public class ContractWorkflowService(
bool isSystem,
string? comment,
bool skipToFinal,
bool applyLevelFinalize,
CancellationToken ct)
{
var aw = await db.ApprovalWorkflows.AsNoTracking()
@ -502,6 +504,36 @@ public class ContractWorkflowService(
}
}
// ---- [WQ3-BE S190 · YC-030 Q3] KẾT THÚC TẠI CẤP (level-finalize) — port khuôn KHKK
// `ContractSigningPlanWorkflowService.cs:286-299`. Cấp bật `AllowApproverFinalize` bấm Duyệt
// ⇒ HĐ PHÁT HÀNH luôn tại đây, không trình tiếp các Bước/Cấp còn lại.
//
// 🔴 OPT-OUT, không phải bắt buộc (khuôn PE S96 → KHKK K3): `applyLevelFinalize=false` =
// người duyệt BỎ tick "Cấp này kết thúc" ⇒ rơi xuống advance thường. Nên điều kiện là
// HAI vế (cờ cấu hình ∧ ý người duyệt), không phải một.
//
// 🔴 ĐỨNG SAU `skipToFinal` CÓ CHỦ ĐÍCH: hai cờ nói hai việc ngược nhau — skip là "nhảy TỚI
// Cấp cuối để cấp đó duyệt thật", finalize là "dừng NGAY, khỏi cần cấp sau". Ca hiếm
// người duyệt bật cả hai: khối skip ở trên đã `return` trước ⇒ skip thắng, HĐ đi tới Cấp
// cuối rồi cấp cuối mới kết thúc — an toàn hơn (còn một người nữa nhìn) so với thứ tự ngược.
// Ngoại lệ: actor ĐANG ở Cấp cuối Bước cuối thì khối skip fall-through xuống đây, và
// finalize ở ca đó ra CÙNG kết quả với terminal thường (cả hai đều `DaPhatHanh` + gen mã),
// chỉ khác dòng changelog — không đẻ trạng thái lạ.
//
// 🔴 TÁI DÙNG `FinalizeToDaPhatHanhAsync` (đường terminal DUY NHẤT) chứ không chép tay:
// gen mã RG-001 + xoá cặp con-trỏ + xoá SLA phải giống hệt nhánh terminal thường, nếu
// không thì HĐ kết thúc sớm ra đời KHÔNG CÓ MÃ (mirror luật choke-point KHKK
// `ContractSigningPlanWorkflowService.cs:342-347`).
if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)
{
contract.EndedByLevelFinalize = true; // cờ RUNTIME (Mig 73) — write-site DUY NHẤT
await FinalizeToDaPhatHanhAsync(
contract, actorUserId,
$"[Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — KẾT THÚC TẠI CẤP (level-finalize), HĐ ĐÃ PHÁT HÀNH] {comment ?? ""}".Trim(),
ct);
return;
}
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
if (currentLevelOrder < maxLevelOrder)
{
@ -516,23 +548,8 @@ public class ContractWorkflowService(
var nextIdx = currentIdx + 1;
if (nextIdx >= steps.Count)
{
// All Steps done — terminal DaPhatHanh. Khác PE: phải gen mã HĐ
// theo RG-001 (mirror V1 line 148-155). FE sau khi nhận DaPhatHanh
// sẽ refresh hiển thị MaHopDong.
if (string.IsNullOrEmpty(contract.MaHopDong))
{
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
?? throw new NotFoundException("Supplier", contract.SupplierId);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
?? throw new NotFoundException("Project", contract.ProjectId);
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
}
contract.Phase = ContractPhase.DaPhatHanh;
contract.CurrentWorkflowStepIndex = null;
contract.CurrentApprovalLevelOrder = null;
contract.SlaDeadline = null;
await LogTransitionAsync(contract, ContractPhase.ChoDuyet, ContractPhase.DaPhatHanh,
actorUserId, ApprovalDecision.Approve, comment, ct);
// All Steps done — terminal DaPhatHanh (thân nằm ở `FinalizeToDaPhatHanhAsync`).
await FinalizeToDaPhatHanhAsync(contract, actorUserId, comment, ct);
}
else
{
@ -544,6 +561,41 @@ public class ContractWorkflowService(
}
}
/// [WQ3-BE S190 · YC-030 Q3] ĐƯỜNG TERMINAL DUY NHẤT của nhánh duyệt V2 — gen mã RG-001 (nếu
/// chưa có) → `DaPhatHanh` → xoá cặp con-trỏ + SLA → log.
///
/// 🔴 LUẬT (khuôn `ApplyApprovedValuesOnFinalize` bên KHKK `ContractSigningPlanWorkflowService.cs:342`,
/// KHÔNG đếm số vì số tự lão-hoá): MỌI nhánh đưa HĐ V2 sang `DaPhatHanh` BẮT BUỘC đi qua đây.
/// Bỏ qua = HĐ "Đã phát hành" mà `MaHopDong` rỗng, hoặc còn con-trỏ Bước/Cấp ⇒ nằm mãi trong
/// hộp thư của một cấp duyệt đã xong việc. Tự kiểm 2 lệnh (phải khớp nhau trên nhánh V2):
/// grep -n "Phase = ContractPhase.DaPhatHanh" src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs
/// grep -n "FinalizeToDaPhatHanhAsync" src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs
/// (Nhánh V1 legacy `:229-244` CỐ Ý đứng ngoài — giữ nguyên từng byte cho 7 HĐ prod đang chạy.)
///
/// `MaHopDong` đã có ⇒ KHÔNG gen lại (HĐ bị trả lại rồi duyệt lại giữ nguyên mã cũ).
private async Task FinalizeToDaPhatHanhAsync(
Contract contract,
Guid? actorUserId,
string? logComment,
CancellationToken ct)
{
// Gen mã HĐ theo RG-001 (mirror V1 `:232-239`). FE sau khi nhận DaPhatHanh refresh MaHopDong.
if (string.IsNullOrEmpty(contract.MaHopDong))
{
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
?? throw new NotFoundException("Supplier", contract.SupplierId);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
?? throw new NotFoundException("Project", contract.ProjectId);
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
}
contract.Phase = ContractPhase.DaPhatHanh;
contract.CurrentWorkflowStepIndex = null;
contract.CurrentApprovalLevelOrder = null;
contract.SlaDeadline = null;
await LogTransitionAsync(contract, ContractPhase.ChoDuyet, ContractPhase.DaPhatHanh,
actorUserId, ApprovalDecision.Approve, logComment, ct);
}
private async Task LogTransitionAsync(
Contract contract,
ContractPhase fromPhase,