From 138469db4ee90af82193bc1b3bf1e4255bb8b904 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 22 May 2026 12:18:46 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Infra+App:=20Plan=20B=20Chunk=20B=20?= =?UTF-8?q?=E2=80=94=20Service=20ApproveV2Async=20branch=20+=20gen=20m?= =?UTF-8?q?=C3=A3=20H=C4=90=20adapt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Contracts/ContractFeatures.cs | 2 +- .../Services/IContractWorkflowService.cs | 4 + .../Services/ContractWorkflowService.cs | 182 ++++++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs index f7273e0..714d2ce 100644 --- a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs @@ -235,7 +235,7 @@ public class TransitionContractCommandHandler( currentUser.Roles, request.Decision, request.Comment, - ct); + ct: ct); } } diff --git a/src/Backend/SolutionErp.Application/Contracts/Services/IContractWorkflowService.cs b/src/Backend/SolutionErp.Application/Contracts/Services/IContractWorkflowService.cs index 75413e1..af8ce8c 100644 --- a/src/Backend/SolutionErp.Application/Contracts/Services/IContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Application/Contracts/Services/IContractWorkflowService.cs @@ -6,6 +6,9 @@ public interface IContractWorkflowService { // 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. + // [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( Contract contract, ContractPhase targetPhase, @@ -13,6 +16,7 @@ public interface IContractWorkflowService IReadOnlyList actorRoles, ApprovalDecision decision, string? comment, + bool skipToFinal = false, CancellationToken ct = default); // SLA còn bao lâu ở phase hiện tại (seconds). Null nếu không có SLA. diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index be04890..a4cce4f 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -38,6 +38,7 @@ public class ContractWorkflowService( IReadOnlyList actorRoles, ApprovalDecision decision, string? comment, + bool skipToFinal = false, CancellationToken ct = default) { var fromPhase = contract.Phase; @@ -78,6 +79,9 @@ public class ContractWorkflowService( } contract.Phase = ContractPhase.ChoDuyet; 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); await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct); await db.SaveChangesAsync(ct); @@ -87,6 +91,20 @@ public class ContractWorkflowService( // ===== APPROVE STEP ===== 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 ? await db.WorkflowDefinitions.AsNoTracking() .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ợ."); } + // ===== 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 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( Contract contract, ContractPhase fromPhase,