From a532ba6fc3aa8549783f82ce8275dbd2da6eb5b3 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 4 May 2026 12:26:18 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Infra:=20Chunk=20D=20=E2=80=94=20PE?= =?UTF-8?q?=202-stage=20dept=20approval=20(=C4=91=C3=B3ng=20bug=20anh=20Ki?= =?UTF-8?q?=E1=BB=87t=20b=C3=A1o)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ràng buộc 3 (Phase 9) scope tối giản: chỉ PE workflow trước. Đóng bug "NV duyệt được hết phase" anh Kiệt báo trong chat FDC. Logic 2-stage trong PurchaseEvaluationWorkflowService.TransitionAsync, chèn sau policy guard, trước phase transition: 1. Detect approving phase với role thuộc phòng ban: - decision == Approve - target != DangSoanThao && != TuChoi - Không reject + không resume + không admin/system - actorUserId != null + actor.DepartmentId != null 2. Stage detection: - DeptManager (TPB) → Stage=Confirm trực tiếp (TPB tự confirm được) - User.CanBypassReview=true → Stage=Confirm + IsBypassed=true (NV bypass) - Else (NV thường) → Stage=Review only 3. Upsert PurchaseEvaluationDepartmentApproval row: - UNIQUE (PEId, PhaseAtApproval, DepartmentId, Stage) đảm bảo 1 row - UPDATE in-place khi user click Duyệt lần 2 (đổi comment) - ApproverRoleSnapshot: "TPB" / "NV(bypass)" / "NV" denorm cho audit 4. Check Stage=Confirm tồn tại cho (PEId, fromPhase, deptId): - hasConfirm = vừa insert Stage=Confirm OR đã có sẵn - !hasConfirm → BLOCK phase transition: * Insert PEApproval row (FromPhase=ToPhase=fromPhase, Decision=Approve, Comment="[Review NV] ...") để track audit * Insert Changelog "NV X đã review phase Y, chờ TPB confirm" * Return early — Phase KHÔNG đổi - hasConfirm → tiếp tục normal phase transition logic 5. Skip 2-stage hoàn toàn khi: - Decision=Reject (smart reject Chunk C đã handle) - Resume after reject (target đã pinned) - Admin role hoặc System (auto-approve) - actorUserId == null hoặc actor.DepartmentId == null Bug fix verified theo flow anh Kiệt: - User long.chau (NV.PRO, role=Procurement, DepartmentId=PRO) tạo phiếu - long.chau click Duyệt phase ChoPurchasing → ChoCCM: - actor.DepartmentId=PRO → 2-stage logic active - role=Procurement, không có DeptManager → Stage=Review - hasConfirm=false → BLOCK transition - Insert PEDeptApproval(PE, ChoPurchasing, PRO, Review) - Phase giữ nguyên ChoPurchasing - TPB.PRO (tra.bui có role DeptManager + DeptId=PRO) click Duyệt: - role=DeptManager → Stage=Confirm - hasConfirm=true (vừa insert) → ALLOW transition - Phase chuyển ChoPurchasing → ChoCCM - NV CCM lặp pattern tương tự ở phase ChoCCM - Cuối cùng CEO/AuthSigner duyệt ChoCEODuyetNCC → DaDuyet (CEO không thuộc dept cụ thể nên bypass 2-stage) Pending Chunk E: - TODO notify TPB cùng dept khi NV review (best effort, chưa implement) - List endpoint GET /api/purchase-evaluations/{id}/department-approvals cho FE hiển thị progress 2-stage - UserManager API PATCH /api/users/{id}/bypass-review - FE Workflow Panel update + UserManager toggle HĐ + Budget 2-stage scope sẽ làm sau khi PE verify UAT (per default chốt trước đó). Verify: - Build pass (2 warning DocxRenderer cũ) - 77 unit test pass — Domain policy chưa đụng Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PurchaseEvaluationWorkflowService.cs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 3e2df7e..aaac76e 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -4,6 +4,7 @@ using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Notifications; using SolutionErp.Application.PurchaseEvaluations.Services; +using SolutionErp.Domain.Common; using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Identity; using SolutionErp.Domain.Notifications; @@ -85,6 +86,103 @@ public class PurchaseEvaluationWorkflowService( } } + // ===== 2-stage department approval (Phase 9 — Migration 16) ===== + // Bug fix anh Kiệt: NV duyệt được hết phase. Logic mới: + // - User.DepartmentId != null + KHÔNG admin/system + KHÔNG resume: + // - DeptManager (TPB) → Stage=Confirm trực tiếp + // - CanBypassReview=true → Stage=Confirm + IsBypassed=true + // - Else (NV) → Stage=Review only, BLOCK phase transition cho đến khi TPB confirm + // - Skip với reject + resume + admin + system + actor không thuộc dept. + if (decision == ApprovalDecision.Approve + && targetPhase != PurchaseEvaluationPhase.DangSoanThao + && targetPhase != PurchaseEvaluationPhase.TuChoi + && !isResumingAfterReject + && !isAdmin && !isSystem + && actorUserId is Guid actorUid) + { + var actor = await userManager.FindByIdAsync(actorUid.ToString()); + if (actor?.DepartmentId is Guid deptId) + { + var isManager = actorRoles.Contains(AppRoles.DeptManager); + var canBypass = actor.CanBypassReview; + var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review; + var isBypassed = !isManager && canBypass; + var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV"); + + // Upsert: 1 row mỗi (PEId, phase, dept, stage). UNIQUE index enforce. + var existing = await db.PurchaseEvaluationDepartmentApprovals + .FirstOrDefaultAsync(a => + a.PurchaseEvaluationId == evaluation.Id + && a.PhaseAtApproval == (int)fromPhase + && a.DepartmentId == deptId + && a.Stage == stage, ct); + if (existing is null) + { + db.PurchaseEvaluationDepartmentApprovals.Add(new PurchaseEvaluationDepartmentApproval + { + PurchaseEvaluationId = evaluation.Id, + PhaseAtApproval = (int)fromPhase, + DepartmentId = deptId, + Stage = stage, + ApproverUserId = actorUid, + ApproverRoleSnapshot = roleSnapshot, + Comment = comment, + ApprovedAt = dateTime.UtcNow, + IsBypassed = isBypassed, + }); + } + else + { + existing.ApproverUserId = actorUid; + existing.ApproverRoleSnapshot = roleSnapshot; + existing.Comment = comment; + existing.ApprovedAt = dateTime.UtcNow; + existing.IsBypassed = isBypassed; + } + + // Check Stage=Confirm tồn tại cho (PEId, fromPhase, deptId) + var hasConfirm = stage == ApprovalStage.Confirm + || await db.PurchaseEvaluationDepartmentApprovals.AnyAsync(a => + a.PurchaseEvaluationId == evaluation.Id + && a.PhaseAtApproval == (int)fromPhase + && a.DepartmentId == deptId + && a.Stage == ApprovalStage.Confirm, ct); + + if (!hasConfirm) + { + // NV review xong, chưa có TPB confirm → BLOCK phase transition. + // Log Approval + Changelog "đã review" để audit. Phase giữ nguyên. + db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval + { + PurchaseEvaluationId = evaluation.Id, + FromPhase = fromPhase, + ToPhase = fromPhase, // không đổi phase + ApproverUserId = actorUid, + Decision = ApprovalDecision.Approve, + Comment = $"[Review NV] {comment ?? ""}", + ApprovedAt = dateTime.UtcNow, + }); + + string? reviewerName = (actor.FullName ?? actor.Email); + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = evaluation.Id, + EntityType = PurchaseEvaluationEntityType.Workflow, + Action = ChangelogAction.Transition, + PhaseAtChange = fromPhase, + UserId = actorUid, + UserName = reviewerName ?? "Hệ thống", + Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm", + ContextNote = comment, + }); + + // TODO Chunk E: notify TPB cùng dept để confirm. + await db.SaveChangesAsync(ct); + return; + } + } + } + evaluation.SlaWarningSent = false; evaluation.Phase = targetPhase;