[CLAUDE] PurchaseEvaluation: ô "Cấp này KẾT THÚC quy trình" → opt-out bỏ-tick-được (anh Kiệt FDC)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m12s

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 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-01 15:39:24 +07:00
parent ba4b8e0339
commit e657faa74a
7 changed files with 130 additions and 25 deletions

View File

@ -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<string | null>(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({
</label>
</div>
)}
{/* [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 && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<span className="font-medium"> Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
Đưc cấu hình sẵn trong Quy trình duyệt bạn duyệt xong 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.
</span>
<label className="flex cursor-pointer items-start gap-2">
<input
type="checkbox"
className="mt-0.5"
checked={applyLevelFinalize}
onChange={e => setApplyLevelFinalize(e.target.checked)}
/>
<span>
<span className="font-medium">Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
{applyLevelFinalize
? 'Được cấu hình sẵn trong Quy trình duyệt — 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. BỎ tick nếu muốn vẫn trình CEO.'
: 'Đã BỎ tick — phiếu sẽ TRÌNH TIẾP lên CEO/cấp sau thay vì kết thúc tại đây (không cần chọn giá chốt).'}
</span>
</span>
</label>
</div>
)}
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.

View File

@ -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<string | null>(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({
</label>
</div>
)}
{/* [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 && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<span className="font-medium"> Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
Đưc cấu hình sẵn trong Quy trình duyệt bạn duyệt xong 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.
</span>
<label className="flex cursor-pointer items-start gap-2">
<input
type="checkbox"
className="mt-0.5"
checked={applyLevelFinalize}
onChange={e => setApplyLevelFinalize(e.target.checked)}
/>
<span>
<span className="font-medium">Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
{applyLevelFinalize
? 'Được cấu hình sẵn trong Quy trình duyệt — 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. BỎ tick nếu muốn vẫn trình CEO.'
: 'Đã BỎ tick — phiếu sẽ TRÌNH TIẾP lên CEO/cấp sau thay vì kết thúc tại đây (không cần chọn giá chốt).'}
</span>
</span>
</label>
</div>
)}
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.

View File

@ -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,

View File

@ -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<TransitionPurchaseEvaluationCommand>
{
@ -531,6 +533,7 @@ public class TransitionPurchaseEvaluationCommandHandler(
request.FinalizeByCcmDelegation,
request.ApprovedPriceAmount,
request.ApprovedPriceSource,
request.ApplyLevelFinalize,
ct);
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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<string>())).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<string>(), 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");
}
}
}