[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

- 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:
pqhuy1987
2026-07-17 11:14:25 +07:00
parent 38b6d196d6
commit d4fbced02f
16 changed files with 7829 additions and 61 deletions

View File

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