[CLAUDE] PurchaseEvaluation: freeze ngân sách phiếu DaDuyet — Mig 67 snapshot 11 cột + display-gate budgetFrozen + 2 terminal-guard (UAT anh Kiệt 07-16)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m29s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m29s
- Bug UAT: PeWorkItemBudgets (record NS dùng chung per Dự án × Hạng mục) sửa được sau duyệt → phiếu ĐÃ DUYỆT đổi số retroactive (detail-bundle load pair LIVE :911 + FE fallback ?? bs.*). - Fix: Mig 67 AddPeApprovedBudgetSnapshot — 11 cột ApprovedBudget* trên PurchaseEvaluations; helper ApplyBudgetSnapshotOnFinalizeAsync gọi ở 5 site set DaDuyet (4 finalize + admin-override catch-all — reviewer catch #81-class, assignment qua biến lọt literal-grep); display-gate: DaDuyet+SnapshotAt → serve snapshot + BudgetFrozen=true + CanEdit=false, else LIVE (R2: NS live vẫn là "bản cập nhật gần nhất" cho phiếu chưa duyệt — UpdatePeBudgetPro/Ccm giữ nguyên không phase-guard). - 2 terminal-guard block CẢ Admin: SetPeCcmBudgetPeriod + AdjustBudget → Conflict khi DaDuyet/TuChoi (đường chính thống: un-terminal → duyệt lại → re-snapshot overwrite). - Backfill idempotent phiếu DaDuyet cũ = live hiện tại (số tại-thời-điểm-duyệt không khôi phục được — Changelogs attribution-gap); OUTER APPLY dịch đúng predicate PeBudgetAccumulator (3 bản khớp: SQL ↔ accumulator ↔ code gốc); demo-seeder DbInitializer cũng snapshot (fresh-DB gap — reviewer catch). - PeBudgetAccumulator extract lũy kế behavior-preserving (dùng chung GetDetail live + helper snapshot); FE 2 app: types +budgetFrozen + PeDetailTabs badge khoá NS (SHA-mirror giữ, hash 2 app bằng nhau). - Tests 509→520 (T0 reproduce RED→GREEN + T1-T9 + AdminOverride site-5); build slnx 0/0; npm build ×2 PASS; Mig applied 2 LocalDB (Dev catch-up cả Mig 66 lag). - Pipeline S133: /fable-real invest (wf_348f6155) → spec → HMW Opus MAX (wf_d9639faa / wf_4bef08b8 / wf_75c6bfd3) → reviewer PASS_WITH_FIXES (wf_f9c0b939) → 2 SHOULD fixed. #53 return-fail ×3/5 lane — disk-truth recover 3/3, 0 mất việc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -1347,8 +1347,13 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-300">
|
||||
<div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
|
||||
Tổng hợp ngân sách trình ký
|
||||
<div className="flex items-center justify-between gap-2 bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
|
||||
<span>Tổng hợp ngân sách trình ký</span>
|
||||
{bs.budgetFrozen && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium normal-case tracking-normal text-amber-800">
|
||||
🔒 Ngân sách chốt tại thời điểm duyệt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo.
|
||||
|
||||
@ -328,6 +328,7 @@ export type PeBudgetSummary = {
|
||||
previousSelectedTotal: number // SUM quote ThanhTien NCC trúng (IsWinner) WHERE Phase=DaDuyet
|
||||
previousSelectedCount: number
|
||||
currentProposalTotal: number // SUM ThanhTien quotes NCC trúng (IsWinner) phiếu NÀY (0 khi chưa chọn)
|
||||
budgetFrozen: boolean // [S133] true = phiếu DaDuyet, số NS đã chốt snapshot
|
||||
}
|
||||
|
||||
// Mirror BE PeDepartmentKind enum
|
||||
|
||||
@ -1347,8 +1347,13 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-300">
|
||||
<div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
|
||||
Tổng hợp ngân sách trình ký
|
||||
<div className="flex items-center justify-between gap-2 bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
|
||||
<span>Tổng hợp ngân sách trình ký</span>
|
||||
{bs.budgetFrozen && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium normal-case tracking-normal text-amber-800">
|
||||
🔒 Ngân sách chốt tại thời điểm duyệt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo.
|
||||
|
||||
@ -330,6 +330,7 @@ export type PeBudgetSummary = {
|
||||
previousSelectedTotal: number // SUM quote ThanhTien NCC trúng (IsWinner) WHERE Phase=DaDuyet
|
||||
previousSelectedCount: number
|
||||
currentProposalTotal: number // SUM ThanhTien quotes NCC trúng (IsWinner) phiếu NÀY (0 khi chưa chọn)
|
||||
budgetFrozen: boolean // [S133] true = phiếu DaDuyet, số NS đã chốt snapshot
|
||||
}
|
||||
|
||||
// Mirror BE PeDepartmentKind enum
|
||||
|
||||
@ -346,4 +346,8 @@ public record PeBudgetSummaryDto(
|
||||
decimal CurrentProposalTotal,
|
||||
// [S76] PRO column split — ban hành + hiệu chỉnh riêng cho PRO (mirror CCM Initial/Adjustment).
|
||||
decimal? ProInitialAmount,
|
||||
decimal? ProAdjustmentAmount);
|
||||
decimal? ProAdjustmentAmount,
|
||||
// [S133] true = phiếu DaDuyet serve từ snapshot ngân sách (bất biến, đọc cột ApprovedBudget*
|
||||
// thay record PeWorkItemBudgets live); false = phiếu chưa duyệt đọc LIVE. FE badge "đã chốt" +
|
||||
// ẩn nút sửa. Append CUỐI record (default false) → construction site cũ không gãy.
|
||||
bool BudgetFrozen = false);
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||
|
||||
// [S133 pe-budget-freeze] Lũy kế ngân sách "phiếu TRƯỚC cùng cặp (ProjectId, WorkItemId)"
|
||||
// + "giá trị kỳ này" (own quotes IsSelected). Extract BEHAVIOR-PRESERVING từ
|
||||
// GetPurchaseEvaluationQuery :917-951 để DÙNG CHUNG cho:
|
||||
// (1) live display (GetPurchaseEvaluationQuery nhánh chưa-duyệt — giữ nguyên số),
|
||||
// (2) snapshot khi finalize (ApplyBudgetSnapshotOnFinalizeAsync — chốt 4 số lũy kế).
|
||||
//
|
||||
// Semantics GIỮ NGUYÊN (single source of truth = query handler cũ):
|
||||
// peers = PurchaseEvaluations cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this
|
||||
// (HasQueryFilter !IsDeleted tự loại phiếu xoá mềm).
|
||||
// - PrevSubmitted* : peers Phase ∈ (ChoDuyet, DaDuyet) → Count + SUM(BudgetPeriodAmount ?? 0).
|
||||
// - PrevSelected* : peers Phase = DaDuyet AND có ≥1 Supplier IsWinner (existence);
|
||||
// total = SUM ThanhTien quote IsSelected của các peer đó.
|
||||
// - CurrentProposalTotal : SUM ThanhTien quote IsSelected của CHÍNH phiếu này (own supplier rows).
|
||||
public readonly record struct PeBudgetAccumulation(
|
||||
decimal PrevSubmittedTotal,
|
||||
int PrevSubmittedCount,
|
||||
decimal PrevSelectedTotal,
|
||||
int PrevSelectedCount,
|
||||
decimal CurrentProposalTotal);
|
||||
|
||||
public static class PeBudgetAccumulator
|
||||
{
|
||||
public static async Task<PeBudgetAccumulation> ComputeAsync(
|
||||
IApplicationDbContext db,
|
||||
Guid projectId,
|
||||
Guid workItemId,
|
||||
Guid peId,
|
||||
DateTime peCreatedAt,
|
||||
IReadOnlyList<Guid> currentSupplierRowIds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var peers = db.PurchaseEvaluations.AsNoTracking()
|
||||
.Where(p => p.ProjectId == projectId && p.WorkItemId == workItemId
|
||||
&& p.Id != peId && p.CreatedAt < peCreatedAt);
|
||||
|
||||
var submitted = await peers
|
||||
.Where(p => p.Phase == PurchaseEvaluationPhase.ChoDuyet
|
||||
|| p.Phase == PurchaseEvaluationPhase.DaDuyet)
|
||||
.Select(p => p.BudgetPeriodAmount)
|
||||
.ToListAsync(ct);
|
||||
var prevSubmittedCount = submitted.Count;
|
||||
var prevSubmittedTotal = submitted.Sum(v => v ?? 0m);
|
||||
|
||||
var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet
|
||||
&& p.Suppliers.Any(s => s.IsWinner));
|
||||
var prevSelectedCount = await selectedPeers.CountAsync(ct);
|
||||
var prevSelectedTotal = await (
|
||||
from p in selectedPeers
|
||||
join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
|
||||
on p.Id equals s.PurchaseEvaluationId
|
||||
join q in db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
on s.Id equals q.PurchaseEvaluationSupplierId
|
||||
where q.IsSelected
|
||||
select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
|
||||
|
||||
var currentProposalTotal = currentSupplierRowIds.Count == 0 ? 0m
|
||||
: await db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
.Where(q => currentSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId) && q.IsSelected)
|
||||
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
|
||||
|
||||
return new PeBudgetAccumulation(
|
||||
prevSubmittedTotal, prevSubmittedCount,
|
||||
prevSelectedTotal, prevSelectedCount,
|
||||
currentProposalTotal);
|
||||
}
|
||||
}
|
||||
@ -230,6 +230,12 @@ public class SetPeCcmBudgetPeriodCommandHandler(
|
||||
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||
|
||||
// [S133] Phiếu KẾT THÚC (DaDuyet / TuChoi) = ngân sách BẤT BIẾN — chặn CẢ Admin (ngay sau
|
||||
// load + NotFound, TRƯỚC role-gate). Số NS đã chốt snapshot tại thời điểm duyệt.
|
||||
if (pe.Phase is PurchaseEvaluationPhase.DaDuyet or PurchaseEvaluationPhase.TuChoi)
|
||||
throw new ConflictException(
|
||||
"Phiếu đã kết thúc — ngân sách đã chốt tại thời điểm duyệt, không sửa được nữa.");
|
||||
|
||||
// Fail-closed TRƯỚC mọi side-effect.
|
||||
if (!currentUser.Roles.Contains(AppRoles.Admin)
|
||||
&& !currentUser.Roles.Contains(AppRoles.CostControl))
|
||||
|
||||
@ -360,6 +360,12 @@ public class AdjustPurchaseEvaluationBudgetCommandHandler(
|
||||
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||
|
||||
// [S133] Phiếu KẾT THÚC (DaDuyet / TuChoi) = ngân sách BẤT BIẾN — chặn CẢ Admin (đặt TRƯỚC
|
||||
// toàn khối phân-quyền, áp mọi role). Số NS đã chốt snapshot tại thời điểm duyệt.
|
||||
if (entity.Phase is PurchaseEvaluationPhase.DaDuyet or PurchaseEvaluationPhase.TuChoi)
|
||||
throw new ConflictException(
|
||||
"Phiếu đã kết thúc — ngân sách đã chốt tại thời điểm duyệt, không sửa được nữa.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
|
||||
var isDrafter = currentUser.UserId is Guid uid && entity.DrafterUserId == uid;
|
||||
var actorTag = string.Empty;
|
||||
@ -908,68 +914,75 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
var canEditPro = isAdmin || currentUser.Roles.Contains(AppRoles.Procurement);
|
||||
var canEditCcm = isAdmin || currentUser.Roles.Contains(AppRoles.CostControl);
|
||||
|
||||
// pairRec load NGUYÊN cho MỌI nhánh — nhánh frozen chỉ dùng .Id (FE link không gãy),
|
||||
// KHÔNG dùng số của nó (số đọc từ cột snapshot ApprovedBudget*).
|
||||
var pairRec = await db.PeWorkItemBudgets.AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.ProjectId == e.ProjectId && b.WorkItemId == wiKey, ct);
|
||||
|
||||
// Lũy kế phiếu TRƯỚC cùng cặp. Row 1 = đã trình (ChoDuyet + DaDuyet);
|
||||
// Row 2 = đã chọn thầu (DaDuyet + có đơn vị thắng). TraLai/DangSoanThao
|
||||
// KHÔNG tính (quay về soạn = chưa trình).
|
||||
var peers = db.PurchaseEvaluations.AsNoTracking()
|
||||
.Where(p => p.ProjectId == e.ProjectId && p.WorkItemId == wiKey
|
||||
&& p.Id != e.Id && p.CreatedAt < e.CreatedAt);
|
||||
|
||||
var submitted = await peers
|
||||
.Where(p => p.Phase == PurchaseEvaluationPhase.ChoDuyet
|
||||
|| p.Phase == PurchaseEvaluationPhase.DaDuyet)
|
||||
.Select(p => p.BudgetPeriodAmount)
|
||||
.ToListAsync(ct);
|
||||
var prevSubmittedCount = submitted.Count;
|
||||
var prevSubmittedTotal = submitted.Sum(v => v ?? 0m);
|
||||
|
||||
// "đã chọn thầu" = phiếu DaDuyet có ÍT NHẤT 1 đơn vị IsWinner (existence — IsWinner
|
||||
// là DERIVED == Any quote IsSelected, giữ đúng ngữ nghĩa "có chọn thầu"; A3(ii)).
|
||||
var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet
|
||||
&& p.Suppliers.Any(s => s.IsWinner));
|
||||
var prevSelectedCount = await selectedPeers.CountAsync(ct);
|
||||
// [multi-NCC A3(i)[5]] Tổng "đã chọn thầu" = SUM ThanhTien quote ĐƯỢC CHỌN (Quote.IsSelected
|
||||
// = nguồn-sự-thật per Detail×Supplier) của các phiếu DaDuyet trước — thay whole-supplier IsWinner.
|
||||
var prevSelectedTotal = await (
|
||||
from p in selectedPeers
|
||||
join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
|
||||
on p.Id equals s.PurchaseEvaluationId
|
||||
join q in db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
on s.Id equals q.PurchaseEvaluationSupplierId
|
||||
where q.IsSelected
|
||||
select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
|
||||
|
||||
// Row 4 "Giá trị kỳ này" = SUM báo giá ĐƯỢC CHỌN (Quote.IsSelected) phiếu này
|
||||
// [multi-NCC A3(i)[5]] (khớp winnerQuoteTotal :1189 — nguồn-sự-thật per Detail×Supplier).
|
||||
var curSupplierRowIds = e.Suppliers.Select(s => s.Id).ToList();
|
||||
var currentProposalTotal = curSupplierRowIds.Count == 0 ? 0m
|
||||
: await db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
.Where(q => curSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId) && q.IsSelected)
|
||||
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
|
||||
|
||||
// [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho
|
||||
// Block B công thức = CCM nếu CCM đã nhập, else PRO (FullIsEstimate=true → FE
|
||||
// badge "ngân sách PRO"). PRO full = ProInitial + ProAdjust (migrate từ
|
||||
// ProEstimate cũ qua Mig 56). Cả 2 trống → full 0, không badge.
|
||||
var hasCcm = pairRec?.InitialAmount is not null || pairRec?.AdjustmentAmount is not null;
|
||||
var hasPro = pairRec?.ProInitialAmount is not null || pairRec?.ProAdjustmentAmount is not null;
|
||||
var proFull = (pairRec?.ProInitialAmount ?? 0m) + (pairRec?.ProAdjustmentAmount ?? 0m);
|
||||
var fullAmount = hasCcm
|
||||
? (pairRec!.InitialAmount ?? 0m) + (pairRec.AdjustmentAmount ?? 0m)
|
||||
: proFull;
|
||||
// [S133] Display-gate: phiếu DaDuyet ĐÃ chốt snapshot → serve BẤT BIẾN từ 11 cột
|
||||
// ApprovedBudget* (KHÔNG chạy theo record PeWorkItemBudgets live — record dùng chung
|
||||
// vẫn sửa được cho phiếu chưa duyệt). Fallback: DaDuyet mà SnapshotAt null → rơi xuống
|
||||
// nhánh live như cũ — defense-in-depth; mọi đường tạo DaDuyet đã set SnapshotAt
|
||||
// (4 site finalize + admin-override site-5 + seeder demo + backfill Mig 67 — review S133).
|
||||
var frozen = e.Phase == PurchaseEvaluationPhase.DaDuyet && e.ApprovedBudgetSnapshotAt != null;
|
||||
if (frozen)
|
||||
{
|
||||
// Row 4 "Giá trị kỳ này" — SPEC S133: VẪN tính LIVE từ own quotes (KHÔNG snapshot).
|
||||
var currentProposalTotal = curSupplierRowIds.Count == 0 ? 0m
|
||||
: await db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
.Where(q => curSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId) && q.IsSelected)
|
||||
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
|
||||
|
||||
peBudgetSummary = new PeBudgetSummaryDto(
|
||||
pairRec?.Id, pairRec?.ProEstimateAmount, pairRec?.ProNote,
|
||||
pairRec?.InitialAmount, pairRec?.AdjustmentAmount, pairRec?.CcmNote,
|
||||
fullAmount, !hasCcm && hasPro,
|
||||
canEditPro, canEditCcm,
|
||||
prevSubmittedTotal, prevSubmittedCount,
|
||||
prevSelectedTotal, prevSelectedCount,
|
||||
currentProposalTotal,
|
||||
pairRec?.ProInitialAmount, pairRec?.ProAdjustmentAmount);
|
||||
// Full mỗi cột từ SNAPSHOT (mirror công thức :957-962). Ccm* = cột CCM đã chốt.
|
||||
var sHasCcm = e.ApprovedBudgetCcmInitialAmount is not null || e.ApprovedBudgetCcmAdjustmentAmount is not null;
|
||||
var sHasPro = e.ApprovedBudgetProInitialAmount is not null || e.ApprovedBudgetProAdjustmentAmount is not null;
|
||||
var sProFull = (e.ApprovedBudgetProInitialAmount ?? 0m) + (e.ApprovedBudgetProAdjustmentAmount ?? 0m);
|
||||
var sFullAmount = sHasCcm
|
||||
? (e.ApprovedBudgetCcmInitialAmount ?? 0m) + (e.ApprovedBudgetCcmAdjustmentAmount ?? 0m)
|
||||
: sProFull;
|
||||
|
||||
peBudgetSummary = new PeBudgetSummaryDto(
|
||||
pairRec?.Id, null, e.ApprovedBudgetProNote,
|
||||
e.ApprovedBudgetCcmInitialAmount, e.ApprovedBudgetCcmAdjustmentAmount, e.ApprovedBudgetCcmNote,
|
||||
sFullAmount, !sHasCcm && sHasPro,
|
||||
false, false, // phiếu đã chốt → KHÔNG cho sửa PRO/CCM
|
||||
e.ApprovedBudgetPrevSubmittedTotal ?? 0m, e.ApprovedBudgetPrevSubmittedCount ?? 0,
|
||||
e.ApprovedBudgetPrevSelectedTotal ?? 0m, e.ApprovedBudgetPrevSelectedCount ?? 0,
|
||||
currentProposalTotal,
|
||||
e.ApprovedBudgetProInitialAmount, e.ApprovedBudgetProAdjustmentAmount,
|
||||
BudgetFrozen: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Lũy kế phiếu TRƯỚC cùng cặp + "giá trị kỳ này" (behavior-preserving qua accumulator —
|
||||
// extract nguyên semantics :917-951). Row 1 = đã trình (ChoDuyet + DaDuyet); Row 2 =
|
||||
// đã chọn thầu (DaDuyet + có đơn vị IsWinner). TraLai/DangSoanThao KHÔNG tính.
|
||||
var acc = await PeBudgetAccumulator.ComputeAsync(
|
||||
db, e.ProjectId, wiKey, e.Id, e.CreatedAt, curSupplierRowIds, ct);
|
||||
|
||||
// [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho Block B =
|
||||
// CCM nếu đã nhập, else PRO (FullIsEstimate=true → FE badge "ngân sách PRO"). PRO full =
|
||||
// ProInitial + ProAdjust (migrate từ ProEstimate cũ qua Mig 56). Cả 2 trống → full 0.
|
||||
var hasCcm = pairRec?.InitialAmount is not null || pairRec?.AdjustmentAmount is not null;
|
||||
var hasPro = pairRec?.ProInitialAmount is not null || pairRec?.ProAdjustmentAmount is not null;
|
||||
var proFull = (pairRec?.ProInitialAmount ?? 0m) + (pairRec?.ProAdjustmentAmount ?? 0m);
|
||||
var fullAmount = hasCcm
|
||||
? (pairRec!.InitialAmount ?? 0m) + (pairRec.AdjustmentAmount ?? 0m)
|
||||
: proFull;
|
||||
|
||||
peBudgetSummary = new PeBudgetSummaryDto(
|
||||
pairRec?.Id, pairRec?.ProEstimateAmount, pairRec?.ProNote,
|
||||
pairRec?.InitialAmount, pairRec?.AdjustmentAmount, pairRec?.CcmNote,
|
||||
fullAmount, !hasCcm && hasPro,
|
||||
canEditPro, canEditCcm,
|
||||
acc.PrevSubmittedTotal, acc.PrevSubmittedCount,
|
||||
acc.PrevSelectedTotal, acc.PrevSelectedCount,
|
||||
acc.CurrentProposalTotal,
|
||||
pairRec?.ProInitialAmount, pairRec?.ProAdjustmentAmount,
|
||||
BudgetFrozen: false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load supplier names for PE suppliers + approver names
|
||||
|
||||
@ -111,6 +111,26 @@ public class PurchaseEvaluation : AuditableEntity
|
||||
// cho phiếu DaDuyet để phiếu-đã-lên-CEO KHÔNG còn hiện sai "không qua CEO" (bug anh Kiệt S97).
|
||||
public bool EndedByLevelFinalize { get; set; }
|
||||
|
||||
// [S133 2026-07-17 — anh Kiệt FDC] BẤT BIẾN ngân sách phiếu ĐÃ DUYỆT: snapshot bộ số
|
||||
// NS-gói-thầu (matrix PRO/CCM từ PeWorkItemBudgets + 4 số lũy kế phiếu-trước) vào 11 cột
|
||||
// này TẠI thời điểm chuyển DaDuyet (ApplyBudgetSnapshotOnFinalizeAsync gọi ở MỌI nhánh
|
||||
// finalize). Phiếu DaDuyet serve từ snapshot (display-gate `frozen` — GetPurchaseEvaluationQuery),
|
||||
// phiếu chưa duyệt đọc LIVE record PeWorkItemBudgets. PeWorkItemBudgets + cơ chế nhập PRO/CCM
|
||||
// GIỮ NGUYÊN (record dùng chung mọi phiếu cùng cặp — sửa được tự do khi phiếu chưa duyệt).
|
||||
// SnapshotAt != null (trên phiếu DaDuyet) = cờ "đã chốt". Backfill Mig 67 cho DaDuyet cũ = live.
|
||||
// NAMING: Ccm* = cột CCM (PeWorkItemBudget.InitialAmount/AdjustmentAmount); Pro* = cột PRO.
|
||||
public decimal? ApprovedBudgetProInitialAmount { get; set; }
|
||||
public decimal? ApprovedBudgetProAdjustmentAmount { get; set; }
|
||||
public string? ApprovedBudgetProNote { get; set; }
|
||||
public decimal? ApprovedBudgetCcmInitialAmount { get; set; }
|
||||
public decimal? ApprovedBudgetCcmAdjustmentAmount { get; set; }
|
||||
public string? ApprovedBudgetCcmNote { get; set; }
|
||||
public decimal? ApprovedBudgetPrevSubmittedTotal { get; set; }
|
||||
public int? ApprovedBudgetPrevSubmittedCount { get; set; }
|
||||
public decimal? ApprovedBudgetPrevSelectedTotal { get; set; }
|
||||
public int? ApprovedBudgetPrevSelectedCount { get; set; }
|
||||
public DateTime? ApprovedBudgetSnapshotAt { get; set; }
|
||||
|
||||
public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new();
|
||||
public List<PurchaseEvaluationDetail> Details { get; set; } = new();
|
||||
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();
|
||||
|
||||
@ -42,6 +42,16 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
|
||||
b.Property(x => x.CcmSuggestedPriceNote).HasMaxLength(1000);
|
||||
// [S97 2026-07-01] Runtime finalize-fact — bit NOT NULL default false (mirror IsUrgent* bool).
|
||||
b.Property(x => x.EndedByLevelFinalize).HasDefaultValue(false);
|
||||
// [S133 Mig 67] Snapshot ngân sách phiếu DaDuyet — 6 decimal (18,2) + 2 note (1000)
|
||||
// mirror precision cột ApprovedPrice*/PeWorkItemBudget. Tất cả nullable (backfill Mig 67).
|
||||
b.Property(x => x.ApprovedBudgetProInitialAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ApprovedBudgetProAdjustmentAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ApprovedBudgetCcmInitialAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ApprovedBudgetCcmAdjustmentAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ApprovedBudgetPrevSubmittedTotal).HasPrecision(18, 2);
|
||||
b.Property(x => x.ApprovedBudgetPrevSelectedTotal).HasPrecision(18, 2);
|
||||
b.Property(x => x.ApprovedBudgetProNote).HasMaxLength(1000);
|
||||
b.Property(x => x.ApprovedBudgetCcmNote).HasMaxLength(1000);
|
||||
|
||||
b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL");
|
||||
b.HasIndex(x => new { x.Phase, x.IsDeleted });
|
||||
|
||||
@ -1322,6 +1322,24 @@ public static class DbInitializer
|
||||
}
|
||||
pe.Phase = current;
|
||||
|
||||
// [S133] Demo phiếu DaDuyet cũng phải có snapshot NS: fresh-DB migrate chạy TRƯỚC seed
|
||||
// → backfill Mig 67 no-op trên bảng rỗng; thiếu block này demo DaDuyet hiển thị live-drift
|
||||
// (reviewer S133 catch — mirror helper ApplyBudgetSnapshotOnFinalizeAsync, null-safe).
|
||||
if (current == PurchaseEvaluationPhase.DaDuyet)
|
||||
{
|
||||
var pairDemo = pe.WorkItemId is Guid wiDemo
|
||||
? await db.PeWorkItemBudgets
|
||||
.FirstOrDefaultAsync(b => b.ProjectId == pe.ProjectId && b.WorkItemId == wiDemo)
|
||||
: null;
|
||||
pe.ApprovedBudgetProInitialAmount = pairDemo?.ProInitialAmount;
|
||||
pe.ApprovedBudgetProAdjustmentAmount = pairDemo?.ProAdjustmentAmount;
|
||||
pe.ApprovedBudgetProNote = pairDemo?.ProNote;
|
||||
pe.ApprovedBudgetCcmInitialAmount = pairDemo?.InitialAmount;
|
||||
pe.ApprovedBudgetCcmAdjustmentAmount = pairDemo?.AdjustmentAmount;
|
||||
pe.ApprovedBudgetCcmNote = pairDemo?.CcmNote;
|
||||
pe.ApprovedBudgetSnapshotAt = nowUtc;
|
||||
}
|
||||
|
||||
// Set winner nếu DaDuyet
|
||||
if (current == PurchaseEvaluationPhase.DaDuyet
|
||||
&& winnerSupplierCode is not null
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPeApprovedBudgetSnapshot : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ApprovedBudgetCcmAdjustmentAmount",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ApprovedBudgetCcmInitialAmount",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ApprovedBudgetCcmNote",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "nvarchar(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ApprovedBudgetPrevSelectedCount",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ApprovedBudgetPrevSelectedTotal",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ApprovedBudgetPrevSubmittedCount",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ApprovedBudgetPrevSubmittedTotal",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ApprovedBudgetProAdjustmentAmount",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ApprovedBudgetProInitialAmount",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ApprovedBudgetProNote",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "nvarchar(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ApprovedBudgetSnapshotAt",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
// [S133 2026-07-17 — anh Kiệt FDC] Backfill snapshot ngân sách cho phiếu ĐÃ DUYỆT
|
||||
// hiện hữu = giá trị LIVE hôm nay (số tại-thời-điểm-duyệt KHÔNG khôi phục được —
|
||||
// Changelogs có log budget-edit nhưng gắn phiếu-đang-mở-lúc-sửa, attribution gap).
|
||||
// Idempotent: WHERE ApprovedBudgetSnapshotAt IS NULL → re-run no-op. SQL-Server-only
|
||||
// (tests SQLite EnsureCreated KHÔNG replay migration — mirror Mig 61/65).
|
||||
// Predicate lũy kế = BẢN DỊCH SQL của PeBudgetAccumulator.ComputeAsync (3 bản phải khớp:
|
||||
// SQL này ↔ accumulator ↔ GetPurchaseEvaluationQuery cũ): peers cùng (ProjectId, WorkItemId),
|
||||
// Id <> pe, CreatedAt < pe, IsDeleted = 0; submitted = Phase IN (10 ChoDuyet, 7 DaDuyet);
|
||||
// selected = Phase 7 + EXISTS supplier IsWinner. Suppliers/Quotes = BaseEntity KHÔNG
|
||||
// soft-delete filter → không điều kiện IsDeleted trên s/q. Phiếu WorkItemId NULL →
|
||||
// 6 cột pair NULL (LEFT JOIN không match) + 4 lũy kế NULL (CASE — mirror helper runtime),
|
||||
// SnapshotAt VẪN set (marker "đã chốt").
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE pe SET
|
||||
ApprovedBudgetProInitialAmount = b.ProInitialAmount,
|
||||
ApprovedBudgetProAdjustmentAmount = b.ProAdjustmentAmount,
|
||||
ApprovedBudgetProNote = b.ProNote,
|
||||
ApprovedBudgetCcmInitialAmount = b.InitialAmount,
|
||||
ApprovedBudgetCcmAdjustmentAmount = b.AdjustmentAmount,
|
||||
ApprovedBudgetCcmNote = b.CcmNote,
|
||||
ApprovedBudgetPrevSubmittedTotal = CASE WHEN pe.WorkItemId IS NULL THEN NULL ELSE ISNULL(sub.SubmittedTotal, 0) END,
|
||||
ApprovedBudgetPrevSubmittedCount = CASE WHEN pe.WorkItemId IS NULL THEN NULL ELSE ISNULL(sub.SubmittedCount, 0) END,
|
||||
ApprovedBudgetPrevSelectedTotal = CASE WHEN pe.WorkItemId IS NULL THEN NULL ELSE ISNULL(selt.SelectedTotal, 0) END,
|
||||
ApprovedBudgetPrevSelectedCount = CASE WHEN pe.WorkItemId IS NULL THEN NULL ELSE ISNULL(sel.SelectedCount, 0) END,
|
||||
ApprovedBudgetSnapshotAt = SYSUTCDATETIME()
|
||||
FROM PurchaseEvaluations pe
|
||||
LEFT JOIN PeWorkItemBudgets b
|
||||
ON b.ProjectId = pe.ProjectId AND b.WorkItemId = pe.WorkItemId AND b.IsDeleted = 0
|
||||
OUTER APPLY (
|
||||
SELECT COUNT(*) AS SubmittedCount,
|
||||
SUM(ISNULL(p.BudgetPeriodAmount, 0)) AS SubmittedTotal
|
||||
FROM PurchaseEvaluations p
|
||||
WHERE p.ProjectId = pe.ProjectId AND p.WorkItemId = pe.WorkItemId
|
||||
AND p.Id <> pe.Id AND p.CreatedAt < pe.CreatedAt AND p.IsDeleted = 0
|
||||
AND p.Phase IN (10, 7)
|
||||
) sub
|
||||
OUTER APPLY (
|
||||
SELECT COUNT(*) AS SelectedCount
|
||||
FROM PurchaseEvaluations p
|
||||
WHERE p.ProjectId = pe.ProjectId AND p.WorkItemId = pe.WorkItemId
|
||||
AND p.Id <> pe.Id AND p.CreatedAt < pe.CreatedAt AND p.IsDeleted = 0
|
||||
AND p.Phase = 7
|
||||
AND EXISTS (SELECT 1 FROM PurchaseEvaluationSuppliers s
|
||||
WHERE s.PurchaseEvaluationId = p.Id AND s.IsWinner = 1)
|
||||
) sel
|
||||
OUTER APPLY (
|
||||
SELECT SUM(q.ThanhTien) AS SelectedTotal
|
||||
FROM PurchaseEvaluations p
|
||||
INNER JOIN PurchaseEvaluationSuppliers s ON s.PurchaseEvaluationId = p.Id
|
||||
INNER JOIN PurchaseEvaluationQuotes q ON q.PurchaseEvaluationSupplierId = s.Id
|
||||
WHERE p.ProjectId = pe.ProjectId AND p.WorkItemId = pe.WorkItemId
|
||||
AND p.Id <> pe.Id AND p.CreatedAt < pe.CreatedAt AND p.IsDeleted = 0
|
||||
AND p.Phase = 7 AND q.IsSelected = 1
|
||||
AND EXISTS (SELECT 1 FROM PurchaseEvaluationSuppliers s2
|
||||
WHERE s2.PurchaseEvaluationId = p.Id AND s2.IsWinner = 1)
|
||||
) selt
|
||||
WHERE pe.Phase = 7 AND pe.ApprovedBudgetSnapshotAt IS NULL AND pe.IsDeleted = 0;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedBudgetCcmAdjustmentAmount",
|
||||
table: "PurchaseEvaluations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedBudgetCcmInitialAmount",
|
||||
table: "PurchaseEvaluations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedBudgetCcmNote",
|
||||
table: "PurchaseEvaluations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedBudgetPrevSelectedCount",
|
||||
table: "PurchaseEvaluations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedBudgetPrevSelectedTotal",
|
||||
table: "PurchaseEvaluations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedBudgetPrevSubmittedCount",
|
||||
table: "PurchaseEvaluations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedBudgetPrevSubmittedTotal",
|
||||
table: "PurchaseEvaluations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedBudgetProAdjustmentAmount",
|
||||
table: "PurchaseEvaluations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedBudgetProInitialAmount",
|
||||
table: "PurchaseEvaluations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedBudgetProNote",
|
||||
table: "PurchaseEvaluations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedBudgetSnapshotAt",
|
||||
table: "PurchaseEvaluations");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4682,6 +4682,47 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Property<Guid?>("ApprovalWorkflowId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<decimal?>("ApprovedBudgetCcmAdjustmentAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("ApprovedBudgetCcmInitialAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ApprovedBudgetCcmNote")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<int?>("ApprovedBudgetPrevSelectedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("ApprovedBudgetPrevSelectedTotal")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("ApprovedBudgetPrevSubmittedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("ApprovedBudgetPrevSubmittedTotal")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("ApprovedBudgetProAdjustmentAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("ApprovedBudgetProInitialAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ApprovedBudgetProNote")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<DateTime?>("ApprovedBudgetSnapshotAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("ApprovedPriceAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@ -305,6 +305,10 @@ public class PurchaseEvaluationWorkflowService(
|
||||
// Admin manual override (vd test cứng phase)
|
||||
if (isAdmin)
|
||||
{
|
||||
// [S133 site 5 — reviewer catch] Override thẳng → DaDuyet cũng PHẢI chốt snapshot NS
|
||||
// (RULE helper; assignment qua BIẾN targetPhase nên literal-grep 4-site không thấy — #81).
|
||||
if (targetPhase == PurchaseEvaluationPhase.DaDuyet)
|
||||
await ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct);
|
||||
evaluation.Phase = targetPhase;
|
||||
evaluation.SlaDeadline = targetPhase == PurchaseEvaluationPhase.ChoDuyet
|
||||
? dateTime.UtcNow.AddDays(7) : null;
|
||||
@ -858,6 +862,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
// → EndsBeforeCeo display đọc cờ này thay vì suy từ config (phiếu opt-out lên CEO hiện đúng).
|
||||
evaluation.EndedByLevelFinalize = true;
|
||||
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount);
|
||||
await ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct); // [S133] chốt NS trước DaDuyet
|
||||
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
|
||||
evaluation.CurrentWorkflowStepIndex = null;
|
||||
evaluation.CurrentApprovalLevelOrder = null;
|
||||
@ -896,6 +901,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
$"Giá gói {winnerQuoteTotal:N0}đ ≥ ngưỡng CEO {ceoThreshold:N0}đ — phải trình CEO duyệt, không được duyệt done miễn CEO.");
|
||||
|
||||
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount);
|
||||
await ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct); // [S133] chốt NS trước DaDuyet
|
||||
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
|
||||
evaluation.CurrentWorkflowStepIndex = null;
|
||||
evaluation.CurrentApprovalLevelOrder = null;
|
||||
@ -928,6 +934,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
// All Steps done — terminal DaDuyet. [Mig 54 ①] người duyệt cấp cuối (CEO/NV
|
||||
// cuối) chọn giá chốt khi duyệt — bind trước khi sang DaDuyet.
|
||||
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount);
|
||||
await ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct); // [S133] chốt NS trước DaDuyet
|
||||
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
|
||||
evaluation.CurrentWorkflowStepIndex = null;
|
||||
evaluation.CurrentApprovalLevelOrder = null;
|
||||
@ -983,6 +990,56 @@ public class PurchaseEvaluationWorkflowService(
|
||||
evaluation.ApprovedPriceMaxAmount = source == ProMinMaxSource ? maxAmount : null;
|
||||
}
|
||||
|
||||
// [S133 2026-07-17 — anh Kiệt FDC] Chụp (snapshot) bộ số ngân sách gói thầu vào 11 cột
|
||||
// ApprovedBudget* trên phiếu TẠI thời điểm chuyển DaDuyet → phiếu ĐÃ DUYỆT bất biến số NS
|
||||
// (display-gate `frozen` ở GetPurchaseEvaluationQuery); record PeWorkItemBudgets dùng chung
|
||||
// vẫn sửa tự do cho phiếu CHƯA duyệt. LUÔN overwrite + LUÔN set SnapshotAt (= cờ "đã chốt").
|
||||
// RULE: MỌI nhánh set Phase=DaDuyet PHẢI gọi helper này ngay TRƯỚC assignment (reviewer grep-check 4 site).
|
||||
private async Task ApplyBudgetSnapshotOnFinalizeAsync(PurchaseEvaluation evaluation, CancellationToken ct)
|
||||
{
|
||||
if (evaluation.WorkItemId is Guid wiKey)
|
||||
{
|
||||
// 6 cột pair (matrix PRO/CCM) — pair thiếu → null. NAMING: Ccm* = cột CCM
|
||||
// (PeWorkItemBudget.InitialAmount/AdjustmentAmount); Pro* = cột PRO.
|
||||
var pair = await db.PeWorkItemBudgets.AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.ProjectId == evaluation.ProjectId && b.WorkItemId == wiKey, ct);
|
||||
evaluation.ApprovedBudgetProInitialAmount = pair?.ProInitialAmount;
|
||||
evaluation.ApprovedBudgetProAdjustmentAmount = pair?.ProAdjustmentAmount;
|
||||
evaluation.ApprovedBudgetProNote = pair?.ProNote;
|
||||
evaluation.ApprovedBudgetCcmInitialAmount = pair?.InitialAmount;
|
||||
evaluation.ApprovedBudgetCcmAdjustmentAmount = pair?.AdjustmentAmount;
|
||||
evaluation.ApprovedBudgetCcmNote = pair?.CcmNote;
|
||||
|
||||
// 4 cột lũy kế qua accumulator (BEHAVIOR y GetPurchaseEvaluationQuery live).
|
||||
var supplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
|
||||
.Where(s => s.PurchaseEvaluationId == evaluation.Id)
|
||||
.Select(s => s.Id)
|
||||
.ToListAsync(ct);
|
||||
var acc = await SolutionErp.Application.PurchaseEvaluations.PeBudgetAccumulator.ComputeAsync(
|
||||
db, evaluation.ProjectId, wiKey, evaluation.Id, evaluation.CreatedAt, supplierRowIds, ct);
|
||||
evaluation.ApprovedBudgetPrevSubmittedTotal = acc.PrevSubmittedTotal;
|
||||
evaluation.ApprovedBudgetPrevSubmittedCount = acc.PrevSubmittedCount;
|
||||
evaluation.ApprovedBudgetPrevSelectedTotal = acc.PrevSelectedTotal;
|
||||
evaluation.ApprovedBudgetPrevSelectedCount = acc.PrevSelectedCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
// WorkItemId null → không có cặp gói thầu → 6 cột pair + 4 lũy kế = null.
|
||||
evaluation.ApprovedBudgetProInitialAmount = null;
|
||||
evaluation.ApprovedBudgetProAdjustmentAmount = null;
|
||||
evaluation.ApprovedBudgetProNote = null;
|
||||
evaluation.ApprovedBudgetCcmInitialAmount = null;
|
||||
evaluation.ApprovedBudgetCcmAdjustmentAmount = null;
|
||||
evaluation.ApprovedBudgetCcmNote = null;
|
||||
evaluation.ApprovedBudgetPrevSubmittedTotal = null;
|
||||
evaluation.ApprovedBudgetPrevSubmittedCount = null;
|
||||
evaluation.ApprovedBudgetPrevSelectedTotal = null;
|
||||
evaluation.ApprovedBudgetPrevSelectedCount = null;
|
||||
}
|
||||
// LUÔN set = cờ "đã chốt" cho phiếu DaDuyet (UTC — gotcha #72).
|
||||
evaluation.ApprovedBudgetSnapshotAt = dateTime.UtcNow;
|
||||
}
|
||||
|
||||
// ===== V1 legacy (Mig 21) — iterate PurchaseEvaluationWorkflowSteps =====
|
||||
private async Task ApproveV1LegacyAsync(
|
||||
PurchaseEvaluation evaluation,
|
||||
@ -1050,6 +1107,9 @@ public class PurchaseEvaluationWorkflowService(
|
||||
var nextIdx = currentIdx + 1;
|
||||
if (nextIdx >= steps.Count)
|
||||
{
|
||||
// [S133] V1 legacy: CHỈ chốt budget-snapshot (KHÔNG ApplyApprovedPriceOnFinalize — V1
|
||||
// không có luồng giá-chốt). Ngay TRƯỚC assignment Phase=DaDuyet.
|
||||
await ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct);
|
||||
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
|
||||
evaluation.CurrentWorkflowStepIndex = null;
|
||||
evaluation.SlaDeadline = null;
|
||||
|
||||
@ -0,0 +1,906 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions; // ConflictException (T5/T6 guards)
|
||||
using SolutionErp.Application.PurchaseEvaluations;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.Master.Catalogs;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||
|
||||
// ===== S133 (2026-07-17) T0 REPRODUCE — bug "phiếu DaDuyet đổi số ngân sách theo bảng LIVE" =====
|
||||
// UAT anh Kiệt: "các phiếu đã duyệt thì KHÔNG cho thay đổi gì hết". Hiện tại mọi phiếu cùng cặp
|
||||
// (ProjectId × WorkItemId) đọc CHUNG 1 record PeWorkItemBudgets LIVE:
|
||||
// - GetPurchaseEvaluationQueryHandler (PurchaseEvaluationFeatures.cs:911-912) load pairRec LIVE,
|
||||
// KHÔNG phân biệt Phase → :964-972 build PeBudgetSummaryDto từ pairRec.
|
||||
// ⇒ ai đó nhập lại ngân sách cho gói thầu (record dùng chung) thì phiếu ĐÃ DUYỆT cũng đổi số.
|
||||
//
|
||||
// TEST-BEFORE (bug fix = reproduce → fix, docs/rules.md §7). Test này PHẢI **RED** trên code hiện
|
||||
// tại (đọc live → 999tr/888tr) và sẽ GREEN sau khi fix snapshot ngân sách tại thời điểm chuyển
|
||||
// DaDuyet. KHÔNG touch production (src/Backend, fe-*), KHÔNG sửa test cũ — chỉ WRITE test này.
|
||||
//
|
||||
// ⚠️ Tiền đề T0 CHUẨN (MEMORY-SLICE): đưa phiếu tới DaDuyet QUA service finalize path (mirror
|
||||
// PeApproverFinalizeTests test #5 AtLastSlot: workflow V2 1 Bước 1 Cấp AllowApproverFinalize=true,
|
||||
// approver duyệt + giá chốt → TỰ DaDuyet). TUYỆT ĐỐI KHÔNG seed thẳng Phase=DaDuyet — vì sau fix,
|
||||
// snapshot chỉ được set qua transition; seed thẳng sẽ làm T0 mất nghĩa (không có bản chụp để giữ).
|
||||
public class PeBudgetFreezeTests
|
||||
{
|
||||
// Giá chốt hợp lệ khi finalize (ApplyApprovedPriceOnFinalize đòi source ∈ ValidApprovedPriceSources).
|
||||
private const decimal ValidApprovedPrice = 400_000_000m;
|
||||
private const string ValidApprovedSource = "Ncc";
|
||||
|
||||
// Bộ số ngân sách TẠI thời điểm duyệt (snapshot phải giữ) vs SAU khi duyệt (record live bị đổi).
|
||||
private const decimal ProAtApproval = 100_000_000m; // PRO ban hành lúc duyệt (kỳ vọng giữ)
|
||||
private const decimal CcmAtApproval = 200_000_000m; // CCM ban hành lúc duyệt (kỳ vọng giữ)
|
||||
private const decimal ProAfterApproval = 999_000_000m; // PRO đổi SAU khi duyệt (record live)
|
||||
private const decimal CcmAfterApproval = 888_000_000m; // CCM đổi SAU khi duyệt (record live)
|
||||
|
||||
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
|
||||
TestApplicationDbContext db) CreateService()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var clock = new FixedDateTime(new DateTime(2026, 7, 17, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
|
||||
return (svc, fix, db);
|
||||
}
|
||||
|
||||
// Workflow V2 1 Bước 1 Cấp, ApproverUserId = approver, AllowApproverFinalize=true → duyệt tới
|
||||
// cấp này (slot cuối) = TỰ DaDuyet qua finalize-branch (PurchaseEvaluationWorkflowService.cs:855).
|
||||
private static async Task<ApprovalWorkflow> SeedFinalizeWorkflowAsync(
|
||||
TestApplicationDbContext db, Guid approverUserId, string code = "QT-BF-V2")
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Code = code, // unique per call — UNIQUE(Code,Version) khi 1 test seed nhiều workflow
|
||||
Version = 1,
|
||||
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
Name = "QT test budget-freeze",
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = 1,
|
||||
Name = "Bước 1",
|
||||
};
|
||||
step.Levels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
Name = "Cấp 1",
|
||||
ApproverUserId = approverUserId,
|
||||
AllowApproverFinalize = true, // slot cuối = KẾT THÚC → duyệt là DaDuyet
|
||||
});
|
||||
wf.Steps.Add(step);
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T0 REPRODUCE — phiếu DaDuyet PHẢI giữ ngân sách tại thời điểm duyệt (snapshot),
|
||||
// KHÔNG chạy theo record PeWorkItemBudgets live bị đổi sau khi duyệt.
|
||||
// RED hiện tại: budgetSummary đọc live → 999tr / 888tr.
|
||||
// GREEN sau fix: snapshot → 100tr / 200tr.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task DaDuyet_BudgetSummary_KeepsSnapshotValue_DoesNotFollowLivePairUpdate()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
// (a) Cặp (Dự án × Hạng mục) + record ngân sách gói thầu PRO=100tr / CCM=200tr.
|
||||
var project = new Project { Id = Guid.NewGuid(), Code = "PRJ-BF", Name = "Dự án budget-freeze" };
|
||||
var wi = new WorkItem { Id = Guid.NewGuid(), Code = "WI-BF", Name = "Hạng mục budget-freeze", IsActive = true };
|
||||
db.Projects.Add(project);
|
||||
db.WorkItems.Add(wi);
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
ProInitialAmount = ProAtApproval, // 100tr (PRO ban hành lúc duyệt)
|
||||
InitialAmount = CcmAtApproval, // 200tr (CCM ban hành lúc duyệt)
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// approver = người duyệt cấp finalize; drafter = "user thường" xem phiếu sau khi duyệt.
|
||||
var approver = (await fix.CreateUserAsync("appr@bf.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var drafter = (await fix.CreateUserAsync("drafter@bf.test", "Drafter", null, Array.Empty<string>())).Id;
|
||||
|
||||
var wf = await SeedFinalizeWorkflowAsync(db, approver);
|
||||
|
||||
// (b) Phiếu tại slot duyệt cuối (ChoDuyet) gắn ĐÚNG cặp + workflow finalize.
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = "PE-BF-001",
|
||||
TenGoiThau = "Gói thầu budget-freeze",
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
DrafterUserId = drafter,
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
CurrentWorkflowStepIndex = 0,
|
||||
CurrentApprovalLevelOrder = 1,
|
||||
SlaDeadline = new DateTime(2026, 7, 24, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// (b') Đưa tới DaDuyet QUA service finalize path (KHÔNG seed thẳng Phase=DaDuyet).
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place
|
||||
actorUserId: approver,
|
||||
actorRoles: Array.Empty<string>(),
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: null,
|
||||
approvedPriceAmount: ValidApprovedPrice,
|
||||
approvedPriceSource: ValidApprovedSource,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
|
||||
"tiền đề T0: phiếu PHẢI ở DaDuyet qua finalize path (mirror PeApproverFinalizeTests), không seed thẳng");
|
||||
|
||||
// (c) SAU khi duyệt, đổi record ngân sách LIVE: PRO 100tr→999tr, CCM 200tr→888tr (Admin).
|
||||
// UpdatePeBudgetPro/Ccm KHÔNG có phase-guard → chạy được ở DaDuyet (đây chính là bug:
|
||||
// pair dùng chung bị đổi → phiếu đã duyệt cũng đổi số).
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
|
||||
await new UpdatePeBudgetProCommandHandler(db, admin)
|
||||
.Handle(new UpdatePeBudgetProCommand(pe.Id, ProAfterApproval, null, "đổi sau khi duyệt"),
|
||||
CancellationToken.None);
|
||||
await new UpdatePeBudgetCcmCommandHandler(db, admin)
|
||||
.Handle(new UpdatePeBudgetCcmCommand(pe.Id, CcmAfterApproval, null, "đổi sau khi duyệt"),
|
||||
CancellationToken.None);
|
||||
|
||||
// Sanity: record LIVE thật sự đã đổi (chứng minh update ăn — assert (d) không vacuous-pass).
|
||||
var liveRec = await db.PeWorkItemBudgets.AsNoTracking()
|
||||
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
|
||||
liveRec.ProInitialAmount.Should().Be(ProAfterApproval, "record LIVE đã đổi PRO → 999tr");
|
||||
liveRec.InitialAmount.Should().Be(CcmAfterApproval, "record LIVE đã đổi CCM → 888tr");
|
||||
|
||||
// (d) "user thường" (drafter, isDrafter=true qua authz) xem phiếu DaDuyet → budgetSummary
|
||||
// PHẢI GIỮ số tại thời điểm duyệt (snapshot), KHÔNG chạy theo record live mới đổi.
|
||||
var querier = new TestCurrentUser(drafter); // no roles = user thường
|
||||
var handler = new GetPurchaseEvaluationQueryHandler(
|
||||
db, fix.Services.GetRequiredService<UserManager<User>>(), querier);
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
bundle.BudgetSummary.Should().NotBeNull("phiếu có WorkItemId → budgetSummary được build");
|
||||
var s = bundle.BudgetSummary!;
|
||||
|
||||
// ⭐ RED trên code hiện tại (đọc live → 999tr / 888tr). GREEN sau fix snapshot (100tr / 200tr).
|
||||
s.ProInitialAmount.Should().Be(ProAtApproval,
|
||||
"phiếu DaDuyet phải GIỮ ngân sách PRO tại thời điểm duyệt (100tr), KHÔNG chạy theo record live (999tr)");
|
||||
s.InitialAmount.Should().Be(CcmAtApproval,
|
||||
"phiếu DaDuyet phải GIỮ ngân sách CCM tại thời điểm duyệt (200tr), KHÔNG chạy theo record live (888tr)");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ===== INV-C (S133) T1-T9 — phủ 4 nhánh snapshot + 2 guard + edge =====
|
||||
// BE ĐÃ LAND (11 cột ApprovedBudget* + helper ApplyBudgetSnapshotOnFinalizeAsync 4 site
|
||||
// + display-gate frozen/live + 2 terminal-guard). T1-T9 GREEN ngay (KHÔNG RED phase —
|
||||
// T0 là reproduce duy nhất). Test theo CODE (S34 single-source-of-truth). KHÔNG sửa T0.
|
||||
// =====================================================================
|
||||
|
||||
// Bộ số ngân sách gói thầu TẠI thời điểm duyệt (snapshot phải giữ nguyên bộ này).
|
||||
private const decimal PairProInitial = 100_000_000m; // pair.ProInitialAmount (PRO)
|
||||
private const decimal PairProAdjust = -10_000_000m; // pair.ProAdjustmentAmount (PRO, ÂM hợp lệ)
|
||||
private const string PairProNote = "PRO ban hành Q3 — dự trù đơn giá";
|
||||
private const decimal PairCcmInitial = 200_000_000m; // pair.InitialAmount (CCM)
|
||||
private const decimal PairCcmAdjust = -20_000_000m; // pair.AdjustmentAmount (CCM, ÂM hợp lệ)
|
||||
private const string PairCcmNote = "CCM nguồn dự toán đã duyệt";
|
||||
|
||||
// Clock của CreateService (mirror T0) — SnapshotAt kỳ vọng = giá trị này.
|
||||
private static readonly DateTime ClockNow = new(2026, 7, 17, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ---- shared seed helpers (INV-C) ----
|
||||
|
||||
private static async Task<(Project project, WorkItem wi)> SeedProjectAndWorkItemAsync(
|
||||
TestApplicationDbContext db, string suffix)
|
||||
{
|
||||
var project = new Project { Id = Guid.NewGuid(), Code = "PRJ-BF-" + suffix, Name = "Dự án " + suffix };
|
||||
var wi = new WorkItem { Id = Guid.NewGuid(), Code = "WI-BF-" + suffix, Name = "Hạng mục " + suffix, IsActive = true };
|
||||
db.Projects.Add(project);
|
||||
db.WorkItems.Add(wi);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (project, wi);
|
||||
}
|
||||
|
||||
// 1 record PeWorkItemBudget per cặp (ProjectId × WorkItemId). NAMING: Initial/Adjustment = CCM.
|
||||
private static async Task SeedPairAsync(
|
||||
TestApplicationDbContext db, Guid projectId, Guid wiId,
|
||||
decimal? proInit, decimal? proAdj, string? proNote,
|
||||
decimal? ccmInit, decimal? ccmAdj, string? ccmNote)
|
||||
{
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProjectId = projectId,
|
||||
WorkItemId = wiId,
|
||||
ProInitialAmount = proInit,
|
||||
ProAdjustmentAmount = proAdj,
|
||||
ProNote = proNote,
|
||||
InitialAmount = ccmInit,
|
||||
AdjustmentAmount = ccmAdj,
|
||||
CcmNote = ccmNote,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// PE đứng tại slot duyệt cuối V2 (ChoDuyet) gắn cặp + workflow đã pin.
|
||||
private static PurchaseEvaluation BuildPeAtFinalizeSlot(
|
||||
Guid approvalWorkflowId, Guid projectId, Guid? workItemId, Guid drafterUserId, string code)
|
||||
=> new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Gói thầu " + code,
|
||||
ProjectId = projectId,
|
||||
WorkItemId = workItemId,
|
||||
DrafterUserId = drafterUserId,
|
||||
ApprovalWorkflowId = approvalWorkflowId,
|
||||
CurrentWorkflowStepIndex = 0,
|
||||
CurrentApprovalLevelOrder = 1,
|
||||
SlaDeadline = new DateTime(2026, 7, 24, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
// Approve-in-place + giá chốt hợp lệ (mọi nhánh finalize human cần giá chốt).
|
||||
private static Task ApproveWithPriceAsync(
|
||||
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid approver, string[] roles) =>
|
||||
svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
|
||||
actorUserId: approver,
|
||||
actorRoles: roles,
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: null,
|
||||
approvedPriceAmount: ValidApprovedPrice,
|
||||
approvedPriceSource: ValidApprovedSource,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
// Seed 1 đơn vị IsWinner + 1 detail + 1 quote IsSelected cho phiếu (own quotes → winnerQuoteTotal).
|
||||
private static async Task SeedWinnerQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, decimal thanhTien)
|
||||
{
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = Guid.NewGuid(),
|
||||
Order = 0,
|
||||
IsWinner = true,
|
||||
};
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
GroupCode = "A.I",
|
||||
GroupName = "Bê tông",
|
||||
NoiDung = "Concrete",
|
||||
Order = 0,
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||
db.PurchaseEvaluationDetails.Add(detail);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = thanhTien,
|
||||
IsSelected = true,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Peer PE cùng cặp cho accumulator (lũy kế phiếu TRƯỚC). CreatedAt override thủ công
|
||||
// (TestApplicationDbContext KHÔNG wire audit interceptor) để < CreatedAt phiếu đang xét.
|
||||
private static async Task SeedPeerAsync(
|
||||
TestApplicationDbContext db, Guid projectId, Guid wiId,
|
||||
PurchaseEvaluationPhase phase, decimal? budgetPeriod, DateTime createdAt,
|
||||
decimal? winnerQuote, string code)
|
||||
{
|
||||
var peer = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = phase,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Peer " + code,
|
||||
ProjectId = projectId,
|
||||
WorkItemId = wiId,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
BudgetPeriodAmount = budgetPeriod,
|
||||
};
|
||||
db.PurchaseEvaluations.Add(peer);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
peer.CreatedAt = createdAt; // accumulator dùng CreatedAt < this
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
if (winnerQuote is decimal wq)
|
||||
await SeedWinnerQuoteAsync(db, peer, wq);
|
||||
}
|
||||
|
||||
// Workflow V2 1 Bước 1 Cấp KHÔNG finalize-flag KHÔNG threshold → approve slot cuối =
|
||||
// nhánh all-steps-done (:933). Khác SeedFinalizeWorkflowAsync (AllowApproverFinalize=true, :861).
|
||||
private static async Task<ApprovalWorkflow> SeedPlainWorkflowAsync(
|
||||
TestApplicationDbContext db, Guid approverUserId)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Code = "QT-BF-PLAIN",
|
||||
Version = 1,
|
||||
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
Name = "QT budget-freeze all-steps",
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep { ApprovalWorkflowId = wf.Id, Order = 1, Name = "Bước 1" };
|
||||
step.Levels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
Name = "Cấp 1",
|
||||
ApproverUserId = approverUserId,
|
||||
});
|
||||
wf.Steps.Add(step);
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf;
|
||||
}
|
||||
|
||||
// Workflow V2 1 Bước 1 Cấp CCM + CeoApprovalThreshold → CCM tích uỷ-quyền finalize
|
||||
// (nhánh :900). Cấp KHÔNG AllowApproverFinalize (mặc định) → rơi xuống ccm-delegation.
|
||||
private static async Task<ApprovalWorkflow> SeedCcmThresholdWorkflowAsync(
|
||||
TestApplicationDbContext db, Guid ccmUserId, decimal ceoThreshold)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Code = "QT-BF-CCM",
|
||||
Version = 1,
|
||||
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
Name = "QT budget-freeze CCM threshold",
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
CeoApprovalThreshold = ceoThreshold,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep { ApprovalWorkflowId = wf.Id, Order = 1, Name = "Bước 1" };
|
||||
step.Levels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
Name = "Cấp 1 (CCM)",
|
||||
ApproverUserId = ccmUserId,
|
||||
});
|
||||
wf.Steps.Add(step);
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf;
|
||||
}
|
||||
|
||||
// Seed cặp (full pair 6 cột) + workflow level-finalize + PE ChoDuyet → finalize qua service →
|
||||
// trả PE ĐÃ DaDuyet (snapshot đã chốt). Reuse cho T5/T6/T7(frozen). withWorkItem/seedPair tuỳ chọn.
|
||||
private static async Task<(PurchaseEvaluation pe, Guid drafter, Project project, WorkItem wi)>
|
||||
SeedAndFinalizeAsync(
|
||||
PurchaseEvaluationWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db,
|
||||
string suffix, bool withWorkItem = true, bool seedPair = true)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, suffix);
|
||||
Guid? wiId = withWorkItem ? wi.Id : null;
|
||||
if (seedPair && withWorkItem)
|
||||
await SeedPairAsync(db, project.Id, wi.Id,
|
||||
PairProInitial, PairProAdjust, PairProNote, PairCcmInitial, PairCcmAdjust, PairCcmNote);
|
||||
|
||||
var approver = (await fix.CreateUserAsync($"appr-{suffix}@bf.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var drafter = (await fix.CreateUserAsync($"drafter-{suffix}@bf.test", "Drafter", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedFinalizeWorkflowAsync(db, approver, $"QT-BF-V2-{suffix}");
|
||||
|
||||
var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wiId, drafter, $"PE-BF-{suffix}");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
await ApproveWithPriceAsync(svc, pe, approver, Array.Empty<string>());
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "tiền đề helper: PE phải ở DaDuyet qua finalize path");
|
||||
return (pe, drafter, project, wi);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T1 — LEVEL-FINALIZE (:861) chốt ĐỦ 11 cột snapshot: 6 cột pair (PRO/CCM Initial+
|
||||
// Adjustment+Note) + 4 cột lũy kế (accumulator, theo 2 peer seed) + SnapshotAt.
|
||||
// (T0 chỉ assert 2 số — T1 phủ toàn bộ bộ chụp.)
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task LevelFinalize_SnapshotsAll11Columns_FromPairAndAccumulator()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T1");
|
||||
await SeedPairAsync(db, project.Id, wi.Id,
|
||||
PairProInitial, PairProAdjust, PairProNote, PairCcmInitial, PairCcmAdjust, PairCcmNote);
|
||||
|
||||
// 2 peer cùng cặp (CreatedAt sớm hơn) → lũy kế xác định:
|
||||
// peer DaDuyet: budget 100tr (PrevSubmitted) + winner quote 90tr (PrevSelected).
|
||||
// peer ChoDuyet: budget 50tr (PrevSubmitted, KHÔNG PrevSelected).
|
||||
var baseT = new DateTime(2026, 7, 17, 8, 0, 0, DateTimeKind.Utc);
|
||||
await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet,
|
||||
budgetPeriod: 100_000_000m, createdAt: baseT.AddDays(-3), winnerQuote: 90_000_000m, code: "PE-BF-T1-DD");
|
||||
await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
|
||||
budgetPeriod: 50_000_000m, createdAt: baseT.AddDays(-2), winnerQuote: null, code: "PE-BF-T1-CD");
|
||||
|
||||
var approver = (await fix.CreateUserAsync("appr@t1.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var drafter = (await fix.CreateUserAsync("drafter@t1.test", "Drafter", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedFinalizeWorkflowAsync(db, approver);
|
||||
|
||||
var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, drafter, "PE-BF-T1");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
pe.CreatedAt = baseT; // muộn hơn 2 peer → cả 2 lọt lũy kế
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveWithPriceAsync(svc, pe, approver, Array.Empty<string>());
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
|
||||
|
||||
var s = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
|
||||
// 6 cột pair (NAMING: Pro* = PRO; Ccm* = CCM).
|
||||
s.ApprovedBudgetProInitialAmount.Should().Be(PairProInitial, "chốt PRO ban hành từ pair");
|
||||
s.ApprovedBudgetProAdjustmentAmount.Should().Be(PairProAdjust, "chốt PRO hiệu chỉnh (ÂM) từ pair");
|
||||
s.ApprovedBudgetProNote.Should().Be(PairProNote, "chốt ghi chú PRO từ pair");
|
||||
s.ApprovedBudgetCcmInitialAmount.Should().Be(PairCcmInitial, "chốt CCM ban hành từ pair.InitialAmount");
|
||||
s.ApprovedBudgetCcmAdjustmentAmount.Should().Be(PairCcmAdjust, "chốt CCM hiệu chỉnh (ÂM) từ pair.AdjustmentAmount");
|
||||
s.ApprovedBudgetCcmNote.Should().Be(PairCcmNote, "chốt ghi chú CCM từ pair.CcmNote");
|
||||
// 4 cột lũy kế từ accumulator.
|
||||
s.ApprovedBudgetPrevSubmittedTotal.Should().Be(150_000_000m, "peer DaDuyet 100tr + ChoDuyet 50tr");
|
||||
s.ApprovedBudgetPrevSubmittedCount.Should().Be(2);
|
||||
s.ApprovedBudgetPrevSelectedTotal.Should().Be(90_000_000m, "chỉ peer DaDuyet có winner quote");
|
||||
s.ApprovedBudgetPrevSelectedCount.Should().Be(1);
|
||||
// Cờ đã-chốt.
|
||||
s.ApprovedBudgetSnapshotAt.Should().NotBeNull("SnapshotAt LUÔN set khi finalize = cờ đã chốt");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T2 — CCM-THRESHOLD delegation (:900): CCM tích uỷ-quyền + gói < ngưỡng CEO →
|
||||
// DaDuyet qua nhánh delegation → snapshot ngân sách set (pair cols + SnapshotAt).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task CcmThresholdFinalize_SetsBudgetSnapshot()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T2");
|
||||
await SeedPairAsync(db, project.Id, wi.Id,
|
||||
PairProInitial, null, null, PairCcmInitial, null, null);
|
||||
|
||||
var ccm = (await fix.CreateUserAsync("ccm@t2.test", "CCM", null, new[] { AppRoles.CostControl })).Id;
|
||||
var wf = await SeedCcmThresholdWorkflowAsync(db, ccm, ceoThreshold: 1_000_000_000m);
|
||||
|
||||
var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, Guid.NewGuid(), "PE-BF-T2");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// Gói 400tr < ngưỡng 1 tỷ (winnerQuoteTotal = SUM quote IsSelected của phiếu).
|
||||
await SeedWinnerQuoteAsync(db, pe, ValidApprovedPrice);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
|
||||
actorUserId: ccm,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: null,
|
||||
finalizeByCcmDelegation: true,
|
||||
approvedPriceAmount: ValidApprovedPrice,
|
||||
approvedPriceSource: ValidApprovedSource,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "CCM uỷ-quyền + gói < ngưỡng → DaDuyet (nhánh delegation)");
|
||||
|
||||
var s = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
|
||||
s.ApprovedBudgetSnapshotAt.Should().NotBeNull("nhánh CCM-threshold PHẢI chốt snapshot");
|
||||
s.ApprovedBudgetProInitialAmount.Should().Be(PairProInitial, "chốt PRO từ pair tại nhánh delegation");
|
||||
s.ApprovedBudgetCcmInitialAmount.Should().Be(PairCcmInitial, "chốt CCM từ pair tại nhánh delegation");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T3 — ALL-STEPS-DONE (:933): 1 Bước 1 Cấp KHÔNG finalize-flag → approve slot cuối →
|
||||
// terminal DaDuyet nhánh all-steps → snapshot set.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task AllStepsDoneFinalize_SetsBudgetSnapshot()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T3");
|
||||
await SeedPairAsync(db, project.Id, wi.Id,
|
||||
PairProInitial, PairProAdjust, PairProNote, PairCcmInitial, PairCcmAdjust, PairCcmNote);
|
||||
|
||||
var approver = (await fix.CreateUserAsync("appr@t3.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedPlainWorkflowAsync(db, approver); // AllowApproverFinalize=false
|
||||
|
||||
var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, Guid.NewGuid(), "PE-BF-T3");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveWithPriceAsync(svc, pe, approver, Array.Empty<string>());
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "slot cuối, hết bước → terminal DaDuyet (all-steps)");
|
||||
var s = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
|
||||
s.ApprovedBudgetSnapshotAt.Should().NotBeNull("nhánh all-steps-done PHẢI chốt snapshot");
|
||||
s.ApprovedBudgetProInitialAmount.Should().Be(PairProInitial);
|
||||
s.ApprovedBudgetCcmInitialAmount.Should().Be(PairCcmInitial);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T4 — V1 LEGACY (:1108): phiếu pin WorkflowDefinitionId (V1 flat) đi hết steps →
|
||||
// nhánh V1 → snapshot set NHƯNG ApprovedPriceAmount KHÔNG set (V1 không có luồng giá-chốt).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task V1LegacyFinalize_SetsBudgetSnapshot_WithoutApprovedPrice()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T4");
|
||||
await SeedPairAsync(db, project.Id, wi.Id,
|
||||
PairProInitial, PairProAdjust, PairProNote, PairCcmInitial, PairCcmAdjust, PairCcmNote);
|
||||
|
||||
var approver = (await fix.CreateUserAsync("appr@t4.test", "Approver V1", null, Array.Empty<string>())).Id;
|
||||
|
||||
// V1 workflow definition: 1 step, approver = explicit User (match qua userManager).
|
||||
var def = new PurchaseEvaluationWorkflowDefinition
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "PE-V1-BF",
|
||||
Version = 1,
|
||||
EvaluationType = PurchaseEvaluationType.DuyetNcc,
|
||||
Name = "V1 legacy budget-freeze",
|
||||
IsActive = true,
|
||||
};
|
||||
var step = new PurchaseEvaluationWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationWorkflowDefinitionId = def.Id,
|
||||
Order = 1,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
Name = "Bước 1 (V1)",
|
||||
};
|
||||
step.Approvers.Add(new PurchaseEvaluationWorkflowStepApprover
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationWorkflowStepId = step.Id,
|
||||
Kind = WorkflowApproverKind.User,
|
||||
AssignmentValue = approver.ToString(),
|
||||
});
|
||||
def.Steps.Add(step);
|
||||
db.PurchaseEvaluationWorkflowDefinitions.Add(def);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// PE pin V1 (WorkflowDefinitionId) — ApprovalWorkflowId=null → route ApproveV1LegacyAsync.
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = "PE-BF-T4",
|
||||
TenGoiThau = "Gói thầu V1 legacy",
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
WorkflowDefinitionId = def.Id,
|
||||
ApprovalWorkflowId = null,
|
||||
CurrentWorkflowStepIndex = 0,
|
||||
SlaDeadline = new DateTime(2026, 7, 24, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// V1 KHÔNG có luồng giá-chốt → KHÔNG truyền approvedPrice.
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
|
||||
actorUserId: approver,
|
||||
actorRoles: Array.Empty<string>(),
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: null,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "V1 legacy hết step → DaDuyet (:1108)");
|
||||
var s = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
|
||||
s.ApprovedBudgetSnapshotAt.Should().NotBeNull("V1 legacy PHẢI chốt snapshot (nhánh :1108)");
|
||||
s.ApprovedBudgetProInitialAmount.Should().Be(PairProInitial, "V1 vẫn chốt số ngân sách từ pair");
|
||||
s.ApprovedBudgetCcmInitialAmount.Should().Be(PairCcmInitial);
|
||||
s.ApprovedPriceAmount.Should().BeNull("V1 legacy KHÔNG có luồng giá-chốt → ApprovedPriceAmount null");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T5 — GUARD SetPeCcmBudgetPeriod: phiếu DaDuyet → CCM VÀ Admin đều ConflictException
|
||||
// (guard đặt TRƯỚC role-gate → chặn cả 2). Phiếu ChoDuyet → CCM vẫn set được (guard không lạm).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task SetCcmBudgetPeriodGuard_BlocksDaDuyet_ForCcmAndAdmin_AllowsChoDuyet()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
// Phiếu DaDuyet THẬT qua finalize path.
|
||||
var (peDone, _, _, _) = await SeedAndFinalizeAsync(svc, fix, db, "T5done");
|
||||
|
||||
var ccm = new TestCurrentUser(Guid.NewGuid(), "CCM", null, AppRoles.CostControl);
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
|
||||
|
||||
var byCcm = async () => await new SetPeCcmBudgetPeriodCommandHandler(db, ccm)
|
||||
.Handle(new SetPeCcmBudgetPeriodCommand(peDone.Id, 123_000_000m), CancellationToken.None);
|
||||
await byCcm.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*đã kết thúc*");
|
||||
|
||||
var byAdmin = async () => await new SetPeCcmBudgetPeriodCommandHandler(db, admin)
|
||||
.Handle(new SetPeCcmBudgetPeriodCommand(peDone.Id, 456_000_000m), CancellationToken.None);
|
||||
await byAdmin.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*đã kết thúc*", "guard chặn CẢ Admin (đặt TRƯỚC role-gate)");
|
||||
|
||||
// Phiếu ChoDuyet (chưa duyệt) → CCM set BÌNH THƯỜNG (guard không lạm sang phiếu chưa kết thúc).
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T5open");
|
||||
var peOpen = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = "PE-BF-T5-OPEN",
|
||||
TenGoiThau = "Gói thầu chưa duyệt",
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
};
|
||||
db.PurchaseEvaluations.Add(peOpen);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await new SetPeCcmBudgetPeriodCommandHandler(db, ccm)
|
||||
.Handle(new SetPeCcmBudgetPeriodCommand(peOpen.Id, 77_000_000m), CancellationToken.None);
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == peOpen.Id);
|
||||
reloaded.CcmBudgetPeriodAmount.Should().Be(77_000_000m, "phiếu ChoDuyet → CCM vẫn nhập NS kỳ này được");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T6 — GUARD AdjustBudget: phiếu DaDuyet (qua finalize) + Admin → Conflict; phiếu
|
||||
// TuChoi (seed thẳng — SPEC cho phép vì guard chỉ đọc Phase) + Admin → Conflict.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task AdjustBudgetGuard_BlocksDaDuyetAndTuChoi_ForAdmin()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
|
||||
|
||||
// (a) DaDuyet THẬT qua finalize → Admin adjust → Conflict.
|
||||
var (peDone, _, _, _) = await SeedAndFinalizeAsync(svc, fix, db, "T6done");
|
||||
var adjDone = async () => await new AdjustPurchaseEvaluationBudgetCommandHandler(db, admin)
|
||||
.Handle(new AdjustPurchaseEvaluationBudgetCommand(peDone.Id, 10_000_000m, null), CancellationToken.None);
|
||||
await adjDone.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*đã kết thúc*", "phiếu DaDuyet → chặn CẢ Admin (guard TRƯỚC isAdmin block)");
|
||||
|
||||
// (b) TuChoi seed thẳng (không reachable qua service — S60 gỡ Từ chối). Guard chỉ đọc Phase.
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T6rej");
|
||||
var peReject = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.TuChoi,
|
||||
MaPhieu = "PE-BF-T6-REJ",
|
||||
TenGoiThau = "Gói thầu bị từ chối",
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
};
|
||||
db.PurchaseEvaluations.Add(peReject);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var adjReject = async () => await new AdjustPurchaseEvaluationBudgetCommandHandler(db, admin)
|
||||
.Handle(new AdjustPurchaseEvaluationBudgetCommand(peReject.Id, 5_000_000m, null), CancellationToken.None);
|
||||
await adjReject.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*đã kết thúc*", "phiếu TuChoi cũng = kết thúc → ngân sách bất biến");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T7 — DISPLAY: nhánh else-live (phiếu ChoDuyet) → số MỚI theo pair + BudgetFrozen=false
|
||||
// + CanEdit theo role; nhánh frozen (phiếu DaDuyet) → CanEditPro=CanEditCcm=false (kể cả
|
||||
// Admin) + BudgetFrozen=true.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task BudgetSummary_LiveVsFrozen_FlagsAndEditability()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
|
||||
// ---- Nhánh LIVE: phiếu ChoDuyet + pair, querier = Procurement + đồng thời drafter ----
|
||||
var (projectL, wiL) = await SeedProjectAndWorkItemAsync(db, "T7live");
|
||||
await SeedPairAsync(db, projectL.Id, wiL.Id, null, null, null, PairCcmInitial, null, null);
|
||||
var proUser = await fix.CreateUserAsync("pro@t7.test", "PRO", null, new[] { AppRoles.Procurement });
|
||||
var peLive = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = "PE-BF-T7-LIVE",
|
||||
TenGoiThau = "Gói thầu live",
|
||||
ProjectId = projectL.Id,
|
||||
WorkItemId = wiL.Id,
|
||||
DrafterUserId = proUser.Id, // drafter → authz pass
|
||||
};
|
||||
db.PurchaseEvaluations.Add(peLive);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var proQuerier = new TestCurrentUser(proUser.Id, "PRO", null, AppRoles.Procurement);
|
||||
var liveBundle = await new GetPurchaseEvaluationQueryHandler(db, um, proQuerier)
|
||||
.Handle(new GetPurchaseEvaluationQuery(peLive.Id), CancellationToken.None);
|
||||
var live = liveBundle.BudgetSummary!;
|
||||
live.BudgetFrozen.Should().BeFalse("phiếu ChoDuyet → nhánh live");
|
||||
live.InitialAmount.Should().Be(PairCcmInitial, "nhánh live đọc số MỚI từ record pair");
|
||||
live.CanEditPro.Should().BeTrue("querier role Procurement → được sửa PRO khi chưa duyệt");
|
||||
live.CanEditCcm.Should().BeFalse("querier KHÔNG có role CostControl → không sửa CCM");
|
||||
|
||||
// ---- Nhánh FROZEN: phiếu DaDuyet (finalize) + querier Admin → vẫn KHÓA sửa ----
|
||||
var (peDone, _, _, _) = await SeedAndFinalizeAsync(svc, fix, db, "T7frozen");
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
|
||||
var frozenBundle = await new GetPurchaseEvaluationQueryHandler(db, um, admin)
|
||||
.Handle(new GetPurchaseEvaluationQuery(peDone.Id), CancellationToken.None);
|
||||
var frozen = frozenBundle.BudgetSummary!;
|
||||
frozen.BudgetFrozen.Should().BeTrue("phiếu DaDuyet + SnapshotAt != null → nhánh frozen");
|
||||
frozen.CanEditPro.Should().BeFalse("phiếu đã chốt → KHÔNG cho sửa PRO, kể cả Admin");
|
||||
frozen.CanEditCcm.Should().BeFalse("phiếu đã chốt → KHÔNG cho sửa CCM, kể cả Admin");
|
||||
frozen.InitialAmount.Should().Be(PairCcmInitial, "nhánh frozen serve CCM từ cột snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T8 — NULL-SAFE: (a) phiếu WorkItemId null finalize → không NRE, bundle.BudgetSummary=null
|
||||
// NHƯNG SnapshotAt VẪN set trên entity; (b) phiếu có WorkItemId nhưng KHÔNG có pair record →
|
||||
// finalize OK, frozen bs non-null với 6 cột pair null.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task Finalize_NullSafe_NoWorkItemAndNoPairRecord()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
|
||||
|
||||
// (a) WorkItemId null → helper set SnapshotAt nhưng bundle.BudgetSummary null.
|
||||
var (peNoWi, _, _, _) = await SeedAndFinalizeAsync(svc, fix, db, "T8nowi", withWorkItem: false, seedPair: false);
|
||||
var sNoWi = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == peNoWi.Id);
|
||||
sNoWi.ApprovedBudgetSnapshotAt.Should().NotBeNull("WorkItemId null vẫn set SnapshotAt (không NRE)");
|
||||
sNoWi.ApprovedBudgetProInitialAmount.Should().BeNull("WorkItemId null → không cặp → 10 cột null");
|
||||
var noWiBundle = await new GetPurchaseEvaluationQueryHandler(db, um, admin)
|
||||
.Handle(new GetPurchaseEvaluationQuery(peNoWi.Id), CancellationToken.None);
|
||||
noWiBundle.BudgetSummary.Should().BeNull("phiếu không gắn Hạng mục → BudgetSummary null");
|
||||
|
||||
// (b) WorkItemId set nhưng KHÔNG seed pair → finalize OK, frozen bs non-null, 6 pair cols null.
|
||||
var (peNoPair, _, _, _) = await SeedAndFinalizeAsync(svc, fix, db, "T8nopair", withWorkItem: true, seedPair: false);
|
||||
var sNoPair = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == peNoPair.Id);
|
||||
sNoPair.ApprovedBudgetSnapshotAt.Should().NotBeNull("pair thiếu vẫn set SnapshotAt");
|
||||
sNoPair.ApprovedBudgetCcmInitialAmount.Should().BeNull("pair thiếu → 6 cột pair null");
|
||||
var noPairBundle = await new GetPurchaseEvaluationQueryHandler(db, um, admin)
|
||||
.Handle(new GetPurchaseEvaluationQuery(peNoPair.Id), CancellationToken.None);
|
||||
var bs = noPairBundle.BudgetSummary;
|
||||
bs.Should().NotBeNull("có WorkItemId → frozen bs vẫn build (dù pair rỗng)");
|
||||
bs!.BudgetFrozen.Should().BeTrue();
|
||||
bs.ProInitialAmount.Should().BeNull("pair thiếu → FE hiển thị '—' (cột PRO null)");
|
||||
bs.InitialAmount.Should().BeNull("pair thiếu → cột CCM null");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T9 — RE-FINALIZE OVERWRITE: phiếu ĐÃ có cột snapshot CŨ (seed thẳng — SPEC cho phép để
|
||||
// test semantics 'LUÔN overwrite') đi qua finalize → cột = giá trị pair HIỆN TẠI (mới),
|
||||
// SnapshotAt refresh (không giữ giá trị cũ).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ReFinalize_AlwaysOverwritesStaleSnapshot_RefreshesSnapshotAt()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T9");
|
||||
// Pair MỚI: ProInitial 500tr.
|
||||
await SeedPairAsync(db, project.Id, wi.Id, 500_000_000m, null, null, 600_000_000m, null, null);
|
||||
|
||||
var approver = (await fix.CreateUserAsync("appr@t9.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedFinalizeWorkflowAsync(db, approver);
|
||||
|
||||
var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, Guid.NewGuid(), "PE-BF-T9");
|
||||
// Seed thẳng cột snapshot CŨ (stale) + SnapshotAt cũ để chứng minh helper LUÔN overwrite.
|
||||
var staleAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
pe.ApprovedBudgetProInitialAmount = 111_000_000m; // giá trị cũ SAI
|
||||
pe.ApprovedBudgetCcmInitialAmount = 222_000_000m;
|
||||
pe.ApprovedBudgetSnapshotAt = staleAt;
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveWithPriceAsync(svc, pe, approver, Array.Empty<string>());
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
|
||||
|
||||
// Tracked entity: helper mutate in-place → overwrite bằng pair HIỆN TẠI.
|
||||
pe.ApprovedBudgetProInitialAmount.Should().Be(500_000_000m, "LUÔN overwrite: cột = pair mới (500tr), KHÔNG giữ 111tr cũ");
|
||||
pe.ApprovedBudgetCcmInitialAmount.Should().Be(600_000_000m, "LUÔN overwrite CCM: 600tr mới, KHÔNG giữ 222tr cũ");
|
||||
pe.ApprovedBudgetSnapshotAt.Should().Be(ClockNow, "SnapshotAt refresh về thời điểm duyệt (clock), KHÔNG giữ 2020");
|
||||
pe.ApprovedBudgetSnapshotAt.Should().NotBe(staleAt);
|
||||
|
||||
// Persisted.
|
||||
var s = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
|
||||
s.ApprovedBudgetProInitialAmount.Should().Be(500_000_000m, "overwrite persist xuống DB");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// S133 site-5 (reviewer PASS_WITH_FIXES catch — #81 indirect writer): Admin manual-override
|
||||
// chuyển THẲNG → DaDuyet qua nhánh catch-all (`evaluation.Phase = targetPhase` — assignment
|
||||
// qua BIẾN, không phải token enum → literal-grep 4-site không thấy) cũng PHẢI chốt snapshot.
|
||||
// Thiếu → phiếu DaDuyet SnapshotAt=null → display-gate false → serve LIVE (mất bất biến).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task AdminOverride_DirectToDaDuyet_SetsBudgetSnapshot()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "AO");
|
||||
await SeedPairAsync(db, project.Id, wi.Id, ProAtApproval, null, null, CcmAtApproval, null, null);
|
||||
|
||||
var admin = (await fix.CreateUserAsync("admin@ao.test", "Admin AO", null, new[] { AppRoles.Admin })).Id;
|
||||
|
||||
// Phiếu ở DangSoanThao — KHÔNG rơi approve-step (fromPhase != ChoDuyet) → catch-all
|
||||
// admin-override khi actor là Admin.
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.DangSoanThao,
|
||||
MaPhieu = "PE-BF-AO",
|
||||
TenGoiThau = "Gói thầu admin-override",
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
DrafterUserId = admin,
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.DaDuyet,
|
||||
actorUserId: admin,
|
||||
actorRoles: new[] { AppRoles.Admin },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "admin override thẳng DaDuyet",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "admin catch-all override phải ăn");
|
||||
pe.ApprovedBudgetSnapshotAt.Should().Be(ClockNow,
|
||||
"site-5: override thẳng → DaDuyet cũng phải chốt snapshot (RULE helper — reviewer S133)");
|
||||
pe.ApprovedBudgetProInitialAmount.Should().Be(ProAtApproval, "snapshot PRO từ pair lúc override");
|
||||
pe.ApprovedBudgetCcmInitialAmount.Should().Be(CcmAtApproval, "snapshot CCM từ pair lúc override");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user