[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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user