[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:
@ -235,7 +235,7 @@ public class TransitionContractCommandHandler(
|
|||||||
currentUser.Roles,
|
currentUser.Roles,
|
||||||
request.Decision,
|
request.Decision,
|
||||||
request.Comment,
|
request.Comment,
|
||||||
ct);
|
ct: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user