From e657faa74a50d8948eaa09093797d0c5be08ef7e Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 1 Jul 2026 15:39:24 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation:=20=C3=B4=20"C?= =?UTF-8?q?=E1=BA=A5p=20n=C3=A0y=20K=E1=BA=BET=20TH=C3=9AC=20quy=20tr?= =?UTF-8?q?=C3=ACnh"=20=E2=86=92=20opt-out=20b=E1=BB=8F-tick-=C4=91=C6=B0?= =?UTF-8?q?=E1=BB=A3c=20(anh=20Ki=E1=BB=87t=20FDC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit anh Kiệt UAT "cái này option thôi chứ e" — cấp cấu hình AllowApproverFinalize (Mig 58, S89 auto-finalize im lặng) NAY là ô-tích BỎ được: - Default checked = giữ hành vi cũ (duyệt xong KẾT THÚC, không lên CEO) — backward-compat. - BỎ tick (applyLevelFinalize=false) → KHÔNG finalize, trình tiếp CEO/cấp sau; giá chốt ẩn (CEO chọn sau). BE: +param applyLevelFinalize (default true) xuyên interface → service (finalize guard `&& applyLevelFinalize`) → command → handler → controller body. FE: ô xanh static → checkbox 2 app (fe-admin+fe-user SHA-mirror) + hint động + sendPrice/shouldPickPrice gate theo cờ. Test: +test #6 opt-out→advance-to-CEO (spec change; #1-5 backward-compat giữ). Build slnx + 2 FE PASS 0-err; test 434→435. Co-Authored-By: Claude Opus 4.8 --- .../src/components/pe/PeWorkflowPanel.tsx | 36 +++++++++--- fe-user/src/components/pe/PeWorkflowPanel.tsx | 36 +++++++++--- .../PurchaseEvaluationsController.cs | 6 +- .../PurchaseEvaluationFeatures.cs | 5 +- .../IPurchaseEvaluationWorkflowService.cs | 4 ++ .../PurchaseEvaluationWorkflowService.cs | 10 +++- .../Services/PeApproverFinalizeTests.cs | 58 +++++++++++++++++-- 7 files changed, 130 insertions(+), 25 deletions(-) diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx index b88b948..a92c9db 100644 --- a/fe-admin/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx @@ -52,6 +52,9 @@ export function PeWorkflowPanel({ // [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done miễn CEO" + ① người duyệt // cuối chọn 1 giá chốt (Ncc / ProMin / ProMax / Ccm). const [finalizeByCcm, setFinalizeByCcm] = useState(false) + // [S96 2026-07-01 — anh Kiệt FDC] Cấp cấu hình KẾT THÚC (AllowApproverFinalize) NAY là ô-tick + // BỎ được: default checked (giữ hành vi finalize), bỏ tick → trình tiếp CEO thay vì kết thúc. + const [applyLevelFinalize, setApplyLevelFinalize] = useState(true) const [approvedPriceSource, setApprovedPriceSource] = useState(null) const qc = useQueryClient() const { user: currentUser } = useAuth() @@ -165,7 +168,7 @@ export function PeWorkflowPanel({ && evaluation.phase !== PurchaseEvaluationPhase.TraLai // [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done // HOẶC [Mig 58] cấp được cấu hình KẾT THÚC (đều là điểm CHỐT giá). - const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible) + const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || (approverFinalizeEligible && applyLevelFinalize)) // [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi // chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE). // File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side). @@ -192,6 +195,9 @@ export function PeWorkflowPanel({ skipToFinal: !isReject && skipToFinalApprover, // [Mig 54 2026-06-18] ③ CCM duyệt done miễn CEO + ① giá chốt người duyệt chọn. finalizeByCcmDelegation: !isReject && finalizeByCcm, + // [S96 — anh Kiệt FDC] cờ finalize opt-out: chỉ có ý nghĩa khi cấp cấu hình KẾT THÚC. + // Bỏ tick (false) → BE không finalize, trình tiếp CEO; cấp thường gửi true (no-op). + applyLevelFinalize: !isReject && (approverFinalizeEligible ? applyLevelFinalize : true), approvedPriceAmount: sendPrice ? selectedPriceAmount : null, approvedPriceSource: sendPrice ? approvedPriceSource : null, }) @@ -212,6 +218,7 @@ export function PeWorkflowPanel({ setReturnTargetUserId(null) setSkipToFinalApprover(false) setFinalizeByCcm(false) + setApplyLevelFinalize(true) setApprovedPriceSource(null) setApproveFiles([]) if (!wasReject) onApproved?.() @@ -486,7 +493,7 @@ export function PeWorkflowPanel({ : `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}` // [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn. const isApproveAction = !isCancel && !isSendBack - const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible) + const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || (approverFinalizeEligible && applyLevelFinalize)) // [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates // rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh // người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge). @@ -644,14 +651,27 @@ export function PeWorkflowPanel({ )} - {/* [Mig 58 item 4 — anh Kiệt FDC] Cấp được cấu hình KẾT THÚC trong Quy trình duyệt: - BE TỰ finalize khi duyệt tới đây (auto, không ô-tích). Banner báo + bắt chọn giá chốt. */} + {/* [S96 2026-07-01 — anh Kiệt FDC] Cấp cấu hình KẾT THÚC trong Quy trình duyệt NAY là + ô-tích BỎ được (opt-out): default tick = duyệt xong KẾT THÚC (không lên CEO); bỏ tick + = vẫn trình tiếp CEO. Đảo S89 (auto-finalize im lặng → "cái này option thôi"). */} {isApproveAction && approverFinalizeEligible && (
- ✅ Cấp này KẾT THÚC quy trình - - Được cấu hình sẵn trong Quy trình duyệt — bạn duyệt xong là phiếu "Đã duyệt", KHÔNG trình tiếp CEO. Nhớ chọn 1 giá chốt bên dưới trước khi duyệt. - +
)} {/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done. diff --git a/fe-user/src/components/pe/PeWorkflowPanel.tsx b/fe-user/src/components/pe/PeWorkflowPanel.tsx index b88b948..a92c9db 100644 --- a/fe-user/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-user/src/components/pe/PeWorkflowPanel.tsx @@ -52,6 +52,9 @@ export function PeWorkflowPanel({ // [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done miễn CEO" + ① người duyệt // cuối chọn 1 giá chốt (Ncc / ProMin / ProMax / Ccm). const [finalizeByCcm, setFinalizeByCcm] = useState(false) + // [S96 2026-07-01 — anh Kiệt FDC] Cấp cấu hình KẾT THÚC (AllowApproverFinalize) NAY là ô-tick + // BỎ được: default checked (giữ hành vi finalize), bỏ tick → trình tiếp CEO thay vì kết thúc. + const [applyLevelFinalize, setApplyLevelFinalize] = useState(true) const [approvedPriceSource, setApprovedPriceSource] = useState(null) const qc = useQueryClient() const { user: currentUser } = useAuth() @@ -165,7 +168,7 @@ export function PeWorkflowPanel({ && evaluation.phase !== PurchaseEvaluationPhase.TraLai // [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done // HOẶC [Mig 58] cấp được cấu hình KẾT THÚC (đều là điểm CHỐT giá). - const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible) + const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || (approverFinalizeEligible && applyLevelFinalize)) // [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi // chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE). // File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side). @@ -192,6 +195,9 @@ export function PeWorkflowPanel({ skipToFinal: !isReject && skipToFinalApprover, // [Mig 54 2026-06-18] ③ CCM duyệt done miễn CEO + ① giá chốt người duyệt chọn. finalizeByCcmDelegation: !isReject && finalizeByCcm, + // [S96 — anh Kiệt FDC] cờ finalize opt-out: chỉ có ý nghĩa khi cấp cấu hình KẾT THÚC. + // Bỏ tick (false) → BE không finalize, trình tiếp CEO; cấp thường gửi true (no-op). + applyLevelFinalize: !isReject && (approverFinalizeEligible ? applyLevelFinalize : true), approvedPriceAmount: sendPrice ? selectedPriceAmount : null, approvedPriceSource: sendPrice ? approvedPriceSource : null, }) @@ -212,6 +218,7 @@ export function PeWorkflowPanel({ setReturnTargetUserId(null) setSkipToFinalApprover(false) setFinalizeByCcm(false) + setApplyLevelFinalize(true) setApprovedPriceSource(null) setApproveFiles([]) if (!wasReject) onApproved?.() @@ -486,7 +493,7 @@ export function PeWorkflowPanel({ : `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}` // [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn. const isApproveAction = !isCancel && !isSendBack - const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible) + const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || (approverFinalizeEligible && applyLevelFinalize)) // [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates // rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh // người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge). @@ -644,14 +651,27 @@ export function PeWorkflowPanel({ )} - {/* [Mig 58 item 4 — anh Kiệt FDC] Cấp được cấu hình KẾT THÚC trong Quy trình duyệt: - BE TỰ finalize khi duyệt tới đây (auto, không ô-tích). Banner báo + bắt chọn giá chốt. */} + {/* [S96 2026-07-01 — anh Kiệt FDC] Cấp cấu hình KẾT THÚC trong Quy trình duyệt NAY là + ô-tích BỎ được (opt-out): default tick = duyệt xong KẾT THÚC (không lên CEO); bỏ tick + = vẫn trình tiếp CEO. Đảo S89 (auto-finalize im lặng → "cái này option thôi"). */} {isApproveAction && approverFinalizeEligible && (
- ✅ Cấp này KẾT THÚC quy trình - - Được cấu hình sẵn trong Quy trình duyệt — bạn duyệt xong là phiếu "Đã duyệt", KHÔNG trình tiếp CEO. Nhớ chọn 1 giá chốt bên dưới trước khi duyệt. - +
)} {/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done. diff --git a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs index bf82cda..1b5eed8 100644 --- a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs @@ -138,7 +138,7 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase await mediator.Send(new TransitionPurchaseEvaluationCommand( id, body.TargetPhase, body.Decision, body.Comment, body.ReturnMode, body.ReturnTargetUserId, body.SkipToFinal, - body.FinalizeByCcmDelegation, body.ApprovedPriceAmount, body.ApprovedPriceSource), ct); + body.FinalizeByCcmDelegation, body.ApprovedPriceAmount, body.ApprovedPriceSource, body.ApplyLevelFinalize), ct); return NoContent(); } @@ -350,7 +350,9 @@ public record TransitionPeBody( // [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM duyệt done miễn CEO + ① giá chốt. bool FinalizeByCcmDelegation = false, decimal? ApprovedPriceAmount = null, - string? ApprovedPriceSource = null); + string? ApprovedPriceSource = null, + // [S96 2026-07-01 — anh Kiệt FDC] false = BỎ tick "Cấp này KẾT THÚC" → vẫn trình CEO. + bool ApplyLevelFinalize = true); public record AddSupplierBody( Guid SupplierId, diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index 2ef7b77..264c687 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -478,7 +478,9 @@ public record TransitionPurchaseEvaluationCommand( // người duyệt cuối chọn (amount + source ∈ Ncc/ProMin/ProMax/Ccm). bool FinalizeByCcmDelegation = false, decimal? ApprovedPriceAmount = null, - string? ApprovedPriceSource = null) : IRequest; + string? ApprovedPriceSource = null, + // [S96 2026-07-01 — anh Kiệt FDC] false = người duyệt BỎ tick "Cấp này KẾT THÚC" → vẫn trình CEO. + bool ApplyLevelFinalize = true) : IRequest; public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator { @@ -531,6 +533,7 @@ public class TransitionPurchaseEvaluationCommandHandler( request.FinalizeByCcmDelegation, request.ApprovedPriceAmount, request.ApprovedPriceSource, + request.ApplyLevelFinalize, ct); } } diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs index e54ffdc..d083161 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs @@ -32,6 +32,10 @@ public interface IPurchaseEvaluationWorkflowService bool finalizeByCcmDelegation = false, decimal? approvedPriceAmount = null, string? approvedPriceSource = null, + // [S96 2026-07-01 — anh Kiệt FDC] Người duyệt BỎ tick "Cấp này KẾT THÚC quy trình" + // (AllowApproverFinalize) → false = KHÔNG finalize, vẫn trình tiếp CEO/cấp sau. + // Default true = giữ hành vi cũ (cấp cấu hình finalize → auto kết thúc). Đảo S89 auto→opt-out. + bool applyLevelFinalize = true, CancellationToken ct = default); TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase); diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 3dbccf8..c68a5a6 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -47,6 +47,7 @@ public class PurchaseEvaluationWorkflowService( bool finalizeByCcmDelegation = false, decimal? approvedPriceAmount = null, string? approvedPriceSource = null, + bool applyLevelFinalize = true, CancellationToken ct = default) { var fromPhase = evaluation.Phase; @@ -272,7 +273,7 @@ public class PurchaseEvaluationWorkflowService( // V2 path nhận flag, V1 legacy throw nếu non-admin gọi skipToFinal=true. if (evaluation.ApprovalWorkflowId is Guid awId) { - await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, finalizeByCcmDelegation, approvedPriceAmount, approvedPriceSource, ct); + await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, finalizeByCcmDelegation, approvedPriceAmount, approvedPriceSource, applyLevelFinalize, ct); } else { @@ -694,6 +695,7 @@ public class PurchaseEvaluationWorkflowService( bool finalizeByCcmDelegation, decimal? approvedPriceAmount, string? approvedPriceSource, + bool applyLevelFinalize, CancellationToken ct) { var aw = await db.ApprovalWorkflows.AsNoTracking() @@ -858,7 +860,11 @@ public class PurchaseEvaluationWorkflowService( // hay hơn"). KHÔNG threshold/role-gate (admin chỉ-định slot = đủ thẩm quyền). ① giá chốt: // người duyệt phải chọn (Conflict nếu thiếu, isSystem miễn). Đặt SAU skipToFinal, TRƯỚC // ccm-delegation + advance. - if (matchingLevel.AllowApproverFinalize) + // [S96 2026-07-01 — anh Kiệt FDC] Cấp cấu hình KẾT THÚC (AllowApproverFinalize) NAY là + // OPT-OUT: người duyệt BỎ tick "Cấp này KẾT THÚC quy trình" (applyLevelFinalize=false) → + // KHÔNG finalize, rơi xuống advance bình thường → trình tiếp CEO/cấp sau. Đảo S89 (2026-06-24 + // auto-finalize im lặng) theo phản hồi UAT "cái này option thôi". Default true = giữ auto cũ. + if (matchingLevel.AllowApproverFinalize && applyLevelFinalize) { ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource); evaluation.Phase = PurchaseEvaluationPhase.DaDuyet; diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverFinalizeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverFinalizeTests.cs index 8fd660c..6b04dd8 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverFinalizeTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverFinalizeTests.cs @@ -12,8 +12,11 @@ using SolutionErp.Infrastructure.Tests.Common; namespace SolutionErp.Infrastructure.Tests.Services; // ===== item 4 (Mig 58 2026-06-24 — CCM/anh Kiệt FDC) — PER-LEVEL finalize "Duyệt là -// KẾT THÚC, không trình CEO" — AUTO theo cấu hình quy trình (anh Kiệt chốt 2026-06-24 -// "cho bật trong quy trình hay hơn") — test theo CODE (S34 rule, KHÔNG touch production). +// KẾT THÚC, không trình CEO" — test theo CODE (S34 rule, KHÔNG touch production). +// ⚠️ [S96 2026-07-01 SPEC CHANGE — anh Kiệt FDC "cái này option thôi"] Cấp cấu hình +// AllowApproverFinalize NAY là OPT-OUT: mặc-định vẫn auto-finalize (applyLevelFinalize=true, +// backward-compat test #1-5) NHƯNG người duyệt BỎ tick (applyLevelFinalize=false) → KHÔNG +// finalize, trình tiếp CEO (test #6). Đảo "AUTO im lặng 2026-06-24" → opt-out có thể hủy. // // SPEC item 4 (AUTO, KHÔNG ô-tích runtime): ApprovalWorkflowLevel +AllowApproverFinalize // (bool, Mig 58). Trong ApproveV2Async, SAU opinion UPSERT + skipToFinal, TRƯỚC @@ -105,12 +108,14 @@ public class PeApproverFinalizeTests return wf; } - // AUTO model: KHÔNG còn finalizeByApprover param — finalize tự chạy theo cờ cấp. + // [S96 opt-out] Cấp cấu hình KẾT THÚC NAY người duyệt BỎ tick được: applyLevelFinalize=false + // → KHÔNG finalize, trình tiếp CEO. Default true = giữ auto cũ (test #1-5 backward-compat). private static Task ApproveAsync( PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId, string[] roles, decimal? approvedPriceAmount = null, - string? approvedPriceSource = null) => + string? approvedPriceSource = null, + bool applyLevelFinalize = true) => svc.TransitionAsync( evaluation: pe, targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place @@ -120,6 +125,7 @@ public class PeApproverFinalizeTests comment: null, approvedPriceAmount: approvedPriceAmount, approvedPriceSource: approvedPriceSource, + applyLevelFinalize: applyLevelFinalize, ct: CancellationToken.None); // ===================================================================== @@ -316,4 +322,48 @@ public class PeApproverFinalizeTests approvals.Should().HaveCount(1, "1 lần duyệt = 1 Approval row, không double"); } } + + // ===================================================================== + // 6. ⭐ OPT-OUT (S96 2026-07-01 — anh Kiệt FDC "cái này option thôi") — slot + // AllowApproverFinalize=TRUE NHƯNG người duyệt BỎ tick (applyLevelFinalize=FALSE) + // → KHÔNG finalize, advance bình thường lên CEO (Step 2). Đảo AUTO (test #1): cùng + // cấu hình cấp kết-thúc, chỉ khác cờ opt-out → KHÔNG DaDuyet, KHÔNG cần giá chốt. + // ===================================================================== + [Fact] + public async Task ApproveV2_LevelAllowsFinalize_ApproverUnchecks_AdvancesToCeo_NotFinalized() + { + var (svc, fix, db, clock) = CreateService(); + using (fix) + { + // Bước 1 Cấp 1 = approver (AllowApproverFinalize=TRUE) — nhưng người duyệt BỎ tick. + // Bước 2 Cấp 1 = CEO (LẦN NÀY phải tới được vì bỏ tick kết-thúc). + var approver = (await fix.CreateUserAsync("appr6@af.test", "Approver", null, Array.Empty())).Id; + var ceo = (await fix.CreateUserAsync("ceo6@af.test", "CEO", null, new[] { AppRoles.Director })).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { (approver, true) }, // slot CẤU HÌNH kết thúc — nhưng opt-out + new[] { (ceo, false) }, // CEO Step 2 — phải tới được + }); + + var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-006"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + // ⭐ BỎ tick "Cấp này KẾT THÚC" → applyLevelFinalize=false. KHÔNG cần giá chốt (chưa terminal). + await ApproveAsync(svc, pe, approver, Array.Empty(), applyLevelFinalize: false); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, + "bỏ tick KẾT THÚC → KHÔNG finalize, advance bình thường lên CEO"); + pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO) vì opt-out"); + pe.CurrentApprovalLevelOrder.Should().Be(1); + pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7), "SLA reset cho CEO"); + pe.ApprovedPriceAmount.Should().BeNull("opt-out → chưa terminal → chưa bind giá chốt"); + + // approver VẪN ghi opinion (đã duyệt cấp mình), CEO chưa. + var opinions = await db.PurchaseEvaluationLevelOpinions + .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); + opinions.Should().ContainSingle("approver ký cấp mình rồi mới đẩy lên CEO"); + } + } }