[CLAUDE] Infra+App: Plan B Chunk B — Service ApproveV2Async branch + gen mã HĐ adapt

Mirror PE PurchaseEvaluationWorkflowService.cs:ApproveV2Async (line 446-634).
V1 legacy giữ behavior cũ — 7 prod contract chạy nhánh này. V2 mới pin
ApprovalWorkflowId chạy ApproveV2Async helper.

Changes:
- ContractWorkflowService.cs:
  - TransitionAsync +skipToFinal=false param F2 (Mig 31 Plan K mirror PE)
  - Drafter trình init CurrentApprovalLevelOrder=1 nếu V2 schema pin
  - APPROVE STEP branch V2/V1 dispatch theo ApprovalWorkflowId
  - +ApproveV2Async helper ~150 LOC (mirror PE pattern):
    - Load AW.Steps.Levels OR-of-N
    - Match approver actor.Id ∈ pendingLevelGroup.ApproverUserId
    - Add ContractApproval row + enrich comment skipPrefix
    - skipToFinal F2: AllowApproverSkipToFinal guard + advance pointer last
    - Advance level/step normal
    - Terminal: gen mã HĐ RG-001 + Phase=DaPhatHanh (khác PE just DaDuyet)
- IContractWorkflowService.cs: TransitionAsync +skipToFinal=false param
- ContractFeatures.cs: caller TransitionAsync use named arg ct: ct (skip optional)

TODO Chunk C: UPSERT ContractLevelOpinion (table chưa tồn tại — Mig 33
sẽ scaffold + entity + EF config). Block UPSERT add ở đây sau Chunk C done.

Verify:
- dotnet build SolutionErp.slnx PASS 0 err, 2 pre-existing DocxRenderer warn
- dotnet test 111/111 PASS (58 Domain + 53 Infra) — 0 regression
- V1 legacy path UNCHANGED (7 prod contract giữ behavior)

Plan B chain (6 chunks):
- A1 58898e8 Contract +2 fields (em main, done)
- A2 a85e437 Mig 32 schema + Config + Seed (Implementer Case 2, done)
- B (this) Service ApproveV2Async branch (em main, done)
- C Mig 33 ContractLevelOpinions (Implementer, next)
- D FE Workspace V2 (Implementer, pending)
- E FE Section 5 V2 (Implementer, pending)

Race condition lesson: em main + Implementer parallel touch BE same plan
→ Implementer stash em main WIP for clean build verify. Solution: SEQUENTIAL
chunks A→B→C, NOT parallel B với A2. Pattern add to Implementer MEMORY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-22 12:18:46 +07:00
parent a85e437478
commit 138469db4e
3 changed files with 187 additions and 1 deletions

View File

@ -235,7 +235,7 @@ public class TransitionContractCommandHandler(
currentUser.Roles, currentUser.Roles,
request.Decision, request.Decision,
request.Comment, request.Comment,
ct); ct: ct);
} }
} }

View File

@ -6,6 +6,9 @@ public interface IContractWorkflowService
{ {
// Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ. // Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
// Tự tạo ContractApproval row + update Phase + SlaDeadline + gen mã HĐ nếu cần. // Tự tạo ContractApproval row + update Phase + SlaDeadline + gen mã HĐ nếu cần.
// [Plan B S29 2026-05-22] +skipToFinal param F2 (Mig 31): Approver scope
// ChoDuyet skip thẳng Cấp cuối. V2 only, V1 legacy throw nếu non-admin.
// Default false để KHÔNG break existing caller (ContractsController).
Task TransitionAsync( Task TransitionAsync(
Contract contract, Contract contract,
ContractPhase targetPhase, ContractPhase targetPhase,
@ -13,6 +16,7 @@ public interface IContractWorkflowService
IReadOnlyList<string> actorRoles, IReadOnlyList<string> actorRoles,
ApprovalDecision decision, ApprovalDecision decision,
string? comment, string? comment,
bool skipToFinal = false,
CancellationToken ct = default); CancellationToken ct = default);
// SLA còn bao lâu ở phase hiện tại (seconds). Null nếu không có SLA. // SLA còn bao lâu ở phase hiện tại (seconds). Null nếu không có SLA.

View File

@ -38,6 +38,7 @@ public class ContractWorkflowService(
IReadOnlyList<string> actorRoles, IReadOnlyList<string> actorRoles,
ApprovalDecision decision, ApprovalDecision decision,
string? comment, string? comment,
bool skipToFinal = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var fromPhase = contract.Phase; var fromPhase = contract.Phase;
@ -78,6 +79,9 @@ public class ContractWorkflowService(
} }
contract.Phase = ContractPhase.ChoDuyet; contract.Phase = ContractPhase.ChoDuyet;
contract.CurrentWorkflowStepIndex = 0; contract.CurrentWorkflowStepIndex = 0;
// [Plan B S29 2026-05-22] V2 pointer init — mirror PE line 153.
// Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set).
contract.CurrentApprovalLevelOrder = contract.ApprovalWorkflowId is not null ? 1 : null;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7); contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct); await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@ -87,6 +91,20 @@ public class ContractWorkflowService(
// ===== APPROVE STEP ===== // ===== APPROVE STEP =====
if (fromPhase == ContractPhase.ChoDuyet && decision == ApprovalDecision.Approve) if (fromPhase == ContractPhase.ChoDuyet && decision == ApprovalDecision.Approve)
{ {
// [Plan B S29 2026-05-22] Branch V2 schema mới (ApprovalWorkflowId pin)
// vs V1 legacy (WorkflowDefinitionId pin Mig 21). Mirror PE
// PurchaseEvaluationWorkflowService.cs line 161-180 pattern.
// 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 db.SaveChangesAsync(ct);
return;
}
if (skipToFinal && !isAdmin && !isSystem)
throw new ConflictException(
"skipToFinal chỉ hỗ trợ HĐ V2 (ApprovalWorkflowsV2). HĐ V1 legacy không có per-Approver-slot flag.");
var def = contract.WorkflowDefinitionId is Guid wfId var def = contract.WorkflowDefinitionId is Guid wfId
? await db.WorkflowDefinitions.AsNoTracking() ? await db.WorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order)) .Include(d => d.Steps.OrderBy(s => s.Order))
@ -183,6 +201,170 @@ public class ContractWorkflowService(
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ."); throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
} }
// ===== V2 APPROVE (Mig 32+33 — Plan B S29 2026-05-22) =====
// Mirror PurchaseEvaluationWorkflowService.cs:ApproveV2Async (line 446-634).
// Khác PE: terminal hoàn tất → gen mã HĐ + Phase=DaPhatHanh (PE chỉ
// Phase=DaDuyet, không gen mã). V1 legacy giữ behavior cũ.
//
// skipToFinal F2 (Mig 31 Plan K S23): Approver tick "Duyệt thẳng Cấp cuối"
// + admin opt-in per slot tại matchingLevel.AllowApproverSkipToFinal → bỏ
// qua mọi Bước/Cấp trung gian, advance pointer tới Bước cuối + Cấp cuối.
// Phase giữ ChoDuyet — NV cuối vẫn duyệt thật để tiến DaPhatHanh.
//
// TODO Chunk C: UPSERT ContractLevelOpinion (table chưa tồn tại — Mig 33
// sẽ scaffold + entity + EF config). Sau Chunk C done, em main add block
// UPSERT mirror PE line 512-546.
private async Task ApproveV2Async(
Contract contract,
Guid awId,
Guid? actorUserId,
IReadOnlyList<string> actorRoles,
bool isAdmin,
bool isSystem,
string? comment,
bool skipToFinal,
CancellationToken ct)
{
var aw = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.FirstOrDefaultAsync(w => w.Id == awId, ct)
?? throw new ConflictException($"ApprovalWorkflow {awId} không tồn tại.");
var steps = aw.Steps.OrderBy(s => s.Order).ToList();
if (steps.Count == 0)
throw new ConflictException("Quy trình chưa có bước nào.");
var currentIdx = contract.CurrentWorkflowStepIndex ?? 0;
if (currentIdx < 0 || currentIdx >= steps.Count)
throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");
var currentLevelOrder = contract.CurrentApprovalLevelOrder ?? 1;
var currentStep = steps[currentIdx];
// Group levels by Order = Cấp. Mỗi Cấp có N approvers (OR-of-N).
var levelGroups = currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
if (currentLevelOrder < 1 || currentLevelOrder > maxLevelOrder)
throw new ConflictException($"CurrentApprovalLevelOrder={currentLevelOrder} không hợp lệ (max={maxLevelOrder}).");
var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
?? throw new ConflictException($"Bước {currentIdx + 1} không có cấp {currentLevelOrder}.");
// Match approver: actor.Id ∈ pendingLevelGroup.ApproverUserId. Admin bypass.
if (!isAdmin && !isSystem)
{
if (actorUserId is null)
throw new ForbiddenException("Không xác định được approver.");
var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
if (!allowedUserIds.Contains(actorUserId.Value))
{
var names = string.Join(", ", allowedUserIds);
throw new ForbiddenException(
$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: bạn không có trong danh sách NV duyệt ({names}).");
}
}
// Log approval. Enrich comment với prefix "[Duyệt vượt cấp]" khi
// skipToFinal=true (mirror PE Plan AC S25 Bug 3b).
var skipPrefix = skipToFinal ? "[Duyệt vượt cấp tới Cấp cuối] " : "";
db.ContractApprovals.Add(new ContractApproval
{
ContractId = contract.Id,
FromPhase = contract.Phase,
ToPhase = contract.Phase,
ApproverUserId = actorUserId,
Decision = ApprovalDecision.Approve,
Comment = $"{skipPrefix}[Bước {currentIdx + 1} — Cấp {currentLevelOrder}] {comment ?? ""}".Trim(),
ApprovedAt = dateTime.UtcNow,
});
// TODO Plan B Chunk C: UPSERT ContractLevelOpinion vào row Level chính
// chủ (mirror PE Mig 26 / line 512-546). Bảng + entity chưa có (Chunk C
// Implementer sẽ scaffold Mig 33). Block UPSERT add ở đây sau Chunk C
// done. Hiện tại Section 5 FE sẽ KHÔNG hiển thị opinion comment per
// Level — sẽ wire khi Chunk E (FE Section 5 LevelOpinionsV2).
// skipToFinal F2 (Mig 31 Plan K S23) — Approver scope ChoDuyet skip
// thẳng Cấp cuối. Admin opt-in per slot tại AllowApproverSkipToFinal.
if (skipToFinal)
{
var matchingLevel = pendingLevelGroup.FirstOrDefault(l => actorUserId.HasValue && l.ApproverUserId == actorUserId.Value)
?? pendingLevelGroup.First();
if (!isAdmin && !isSystem && !matchingLevel.AllowApproverSkipToFinal)
{
throw new ConflictException(
$"Cấp Approver hiện tại (Bước {currentIdx + 1} Cấp {currentLevelOrder}) " +
"chưa được phép duyệt thẳng Cấp cuối. Admin phải tick checkbox " +
"'Duyệt thẳng Cấp cuối' trong Workflow Designer cho slot này.");
}
var lastStepIdx = steps.Count - 1;
var lastStep = steps[lastStepIdx];
var lastLevelGroups = lastStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
var lastLevelMaxOrder = lastLevelGroups.Count == 0 ? 1 : lastLevelGroups.Max(g => g.Key);
// Guard: actor đã ở Cấp cuối Bước cuối → fall through normal advance
// (sẽ hit branch nextIdx >= steps.Count → DaPhatHanh đúng).
if (!(currentIdx == lastStepIdx && currentLevelOrder == lastLevelMaxOrder))
{
contract.CurrentWorkflowStepIndex = lastStepIdx;
contract.CurrentApprovalLevelOrder = lastLevelMaxOrder;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(
contract,
ContractPhase.ChoDuyet,
ContractPhase.ChoDuyet,
actorUserId,
ApprovalDecision.Approve,
$"[Approver skip thẳng tới Bước {lastStepIdx + 1} Cấp {lastLevelMaxOrder} (NV cuối) — bỏ qua các Bước/Cấp trung gian] {comment ?? ""}".Trim(),
ct);
return;
}
}
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
if (currentLevelOrder < maxLevelOrder)
{
contract.CurrentApprovalLevelOrder = currentLevelOrder + 1;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(contract, contract.Phase, contract.Phase, actorUserId, ApprovalDecision.Approve,
$"Hoàn tất Cấp {currentLevelOrder}, sang Cấp {currentLevelOrder + 1} cùng Bước {currentIdx + 1}", ct);
return;
}
// Hết cấp trong Step — sang Step kế (Cấp 1)
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);
}
else
{
contract.CurrentWorkflowStepIndex = nextIdx;
contract.CurrentApprovalLevelOrder = 1;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(contract, contract.Phase, contract.Phase, actorUserId, ApprovalDecision.Approve,
$"Hoàn tất Bước {currentIdx + 1}/{steps.Count}, sang Bước {nextIdx + 1} (Cấp 1)", ct);
}
}
private async Task LogTransitionAsync( private async Task LogTransitionAsync(
Contract contract, Contract contract,
ContractPhase fromPhase, ContractPhase fromPhase,