# SPEC — PE budget FREEZE-at-DaDuyet (S133, 2026-07-17)

> **Provenance:** UAT anh Kiệt FDC chat 2026-07-16 17:24-17:27 (screenshot anh gửi) → `/fable-real investigator-codebase` deep-pass `wf_348f6155-167` (#53 return-fail → disk-truth `sub-investigator-codebase-0.md` 22.5KB) → **lead verify từ đĩa 2026-07-17** (finalize-map 4/4 hit khớp grep · pair-load :911-912 khớp · guard-vectors :228-242/:419-422 khớp · PeDetailTabs 2-app hash IDENTICAL `931025f2` · types 2-app hash KHÁC) → lead ghi spec này.
> **Owner-resolve 3 open-decision (theo khuyến nghị invest, veto được):** OD1 **CÓ** snapshot 4 cột lũy kế · OD2 **CÓ** terminal-guard block cả Admin · OD3 **KHÔNG** freeze TuChoi (DaDuyet-only).
> **Errata @INV-A (2026-07-17):** tên query detail-bundle ĐÚNG theo code = `GetPurchaseEvaluationQuery` (trả `PurchaseEvaluationDetailBundleDto.BudgetSummary`) — bản đầu spec ghi nhầm `GetPurchaseEvaluationDetailQuery` (test-specialist bắt khi viết T0; đã sửa toàn spec). Con số dòng `:905-973/:964` không đổi.

## ① Tính-năng / đề-bài

Phiếu PE **Phase=DaDuyet bất biến số ngân sách** (R1 — anh Kiệt: "các phiếu đã duyệt thì không cho thay đổi gì hết"): chụp **snapshot** bộ số ngân-sách-gói-thầu (matrix PRO/CCM từ `PeWorkItemBudgets` + 4 số lũy kế peer-derived) vào **11 cột mới trên `PurchaseEvaluations`** tại thời điểm phiếu chuyển DaDuyet. Phiếu đã duyệt hiển thị/serve từ snapshot (display-gate BE + flag `budgetFrozen` trong DTO); phiếu chưa duyệt tiếp tục đọc **LIVE** (R2 — "ngân sách đúng nhất là ngân sách cập nhật gần nhất": bảng `PeWorkItemBudgets` giữ nguyên semantics + cơ chế nhập PRO/CCM KHÔNG đổi). Kèm **2 write-guard** chặn sửa field ngân-sách per-slip trên phiếu terminal (block cả Admin — OD2; đường chính thống = un-terminal EDGE-5 → sửa → duyệt lại → re-snapshot overwrite). **Backfill** phiếu DaDuyet hiện hữu = số live hiện tại (số-tại-thời-điểm-duyệt KHÔNG khôi phục được — changelog có log value nhưng attribution-gap: log gắn phiếu-đang-mở-lúc-sửa, honest-note). DB delta = AddColumn-only, **không bảng mới** (R3 — "tức lại database tí là oki").

**Cơ chế bug (đã verify):** mọi phiếu cùng cặp (ProjectId × WorkItemId) đọc CHUNG 1 record `PeWorkItemBudgets` LIVE — BE `PurchaseEvaluationFeatures.cs:911-912` (detail-bundle → `PeBudgetSummaryDto` :964, 1 construction site duy nhất) + FE fallback `ev.budgetPeriodAmount ?? bs.proInitialAmount` / `ev.ccmBudgetPeriodAmount ?? bs.initialAmount` (`PeDetailTabs.tsx:1511/:1526`) ⇒ PRO/CCM sửa NS → phiếu DaDuyet đổi số retroactive. Lũy kế row1/2 (`:917-943` peers) = vector drift thứ 2 (đổi khi phiếu KHÁC transition) → OD1 chụp luôn.

## ② Cách implement (ordered — worker làm ĐÚNG thứ tự trong vai mình)

1. **Domain** `src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs` — +11 props nullable:
   `ApprovedBudgetProInitialAmount` `ApprovedBudgetProAdjustmentAmount` `ApprovedBudgetCcmInitialAmount` `ApprovedBudgetCcmAdjustmentAmount` `ApprovedBudgetPrevSubmittedTotal` `ApprovedBudgetPrevSelectedTotal` (decimal?) · `ApprovedBudgetProNote` `ApprovedBudgetCcmNote` (string?) · `ApprovedBudgetPrevSubmittedCount` `ApprovedBudgetPrevSelectedCount` (int?) · `ApprovedBudgetSnapshotAt` (DateTime? — marker frozen + audit).
   *(Mapping nguồn: ProInitial/ProAdjust/ProNote ← pair.ProInitialAmount/ProAdjustmentAmount/ProNote · CcmInitial/CcmAdjust/CcmNote ← pair.InitialAmount/AdjustmentAmount/CcmNote — chú ý pair dùng tên `InitialAmount`/`AdjustmentAmount` cho CCM.)*
2. **EF Config** `Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs` — HasPrecision(18,2) ×6 decimal + HasMaxLength(1000) ×2 note (mirror pattern cột ApprovedPrice*/Note hiện có).
3. **Mig 67** `AddPeApprovedBudgetSnapshot` (`dotnet ef migrations add ... --project src/Backend/SolutionErp.Infrastructure --startup-project src/Backend/SolutionErp.Api` — 3-file rule: Migration + Designer + ModelSnapshot commit đủ):
   - Up: AddColumn ×11 + `Sql()` backfill **idempotent** (guard `ActiveProvider` SqlServer-only mirror Mig 61/65):
     `UPDATE pe SET ApprovedBudget* = b.*, ApprovedBudgetPrevSubmittedTotal/Count + PrevSelectedTotal/Count = (OUTER APPLY peers: cùng ProjectId+WorkItemId, Id<>pe.Id, CreatedAt<pe.CreatedAt; submitted = Phase IN (10,7) SUM(BudgetPeriodAmount)+COUNT; selected = Phase=7 AND EXISTS supplier IsWinner=1 → COUNT + SUM(q.ThanhTien WHERE q.IsSelected qua join Suppliers→Quotes)), ApprovedBudgetSnapshotAt = SYSUTCDATETIME() FROM PurchaseEvaluations pe LEFT JOIN PeWorkItemBudgets b ON b.ProjectId=pe.ProjectId AND b.WorkItemId=pe.WorkItemId AND b.IsDeleted=0 WHERE pe.Phase=7 AND pe.ApprovedBudgetSnapshotAt IS NULL AND pe.IsDeleted=0`
     *(predicate PHẢI khớp `PurchaseEvaluationFeatures.cs:917-943` — reviewer đối chiếu 3 bản: SQL ↔ accumulator ↔ Features cũ; enum: ChoDuyet=10, DaDuyet=7)*
   - Down: DropColumn ×11 (data-restore no-op mirror Mig 61/65).
   - Apply local: `dotnet ef database update` cả `SolutionErp_Dev` (--connection override) + `SolutionErp_Design` (per skill ef-core-migration + feedback_designtime_runtime_db).
4. **Shared accumulator (chống drift 3-bản):** extract khối lũy kế + pair-load semantics từ `GetPurchaseEvaluationQuery` (`PurchaseEvaluationFeatures.cs:917-951`) ra helper static mới `Application/PurchaseEvaluations/PeBudgetAccumulator.cs` (input: db, projectId, workItemId, peId, peCreatedAt → output record {PrevSubmittedTotal/Count, PrevSelectedTotal/Count}) — dùng chung: GetDetail nhánh live + service snapshot. GetDetail refactor gọi accumulator (0 đổi hành vi — so output trước/sau bằng test hiện có).
5. **Service snapshot helper** `Infrastructure/Services/PurchaseEvaluationWorkflowService.cs` — +`private async Task ApplyBudgetSnapshotOnFinalizeAsync(PurchaseEvaluation evaluation, CancellationToken ct)`:
   - Load pair `AsNoTracking` theo (ProjectId, WorkItemId) — null-safe (WorkItemId null / pair thiếu → các cột số = null, VẪN set `ApprovedBudgetSnapshotAt` = UtcNow làm marker).
   - Gọi accumulator cho 4 số lũy kế. **LUÔN overwrite** (re-finalize sau un-terminal/TraLai = re-snapshot lần DaDuyet cuối — khớp "bản cập nhật gần nhất" áp cho thời điểm duyệt).
   - Comment rule ngay trên helper: `// RULE: MỌI nhánh set Phase=DaDuyet PHẢI gọi helper này ngay trước assignment — reviewer grep-check.`
   - **Gọi tại ĐÚNG 4 site, ngay trước `evaluation.Phase = PurchaseEvaluationPhase.DaDuyet`:** `:861` (level-finalize) · `:899` (CCM-threshold) · `:931` (all-steps-done) · `:1053` (V1 legacy — nhánh này hiện KHÔNG gọi ApplyApprovedPriceOnFinalize, đừng copy nhầm behavior giá-chốt vào V1; CHỈ thêm budget-snapshot).
6. **DTO + display-gate** `Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs` + `PurchaseEvaluationFeatures.cs`:
   - `PeBudgetSummaryDto` (positional record, hiện 17 field, 1 construction site `:964`) — **append cuối** `bool BudgetFrozen = false`.
   - `GetPurchaseEvaluationQuery` (`:905-973`): `var frozen = e.Phase == PurchaseEvaluationPhase.DaDuyet && e.ApprovedBudgetSnapshotAt != null;` → nhánh frozen: build DTO từ cột `ApprovedBudget*` (fullAmount/FullIsEstimate dùng CÙNG công thức `:957-962` nhưng input = snapshot cols; `CanEditPro=CanEditCcm=false`; `CurrentProposalTotal` vẫn từ own quotes — tự-frozen bởi edit-guard; `BudgetFrozen=true`); nhánh else: live y nguyên + `BudgetFrozen=false`. Fallback an toàn: DaDuyet mà SnapshotAt null (không xảy ra sau backfill) → live như cũ.
7. **Write-guards (OD2 — block CẢ Admin trên terminal):**
   - `PeWorkItemBudgetFeatures.cs` `SetPeCcmBudgetPeriodCommandHandler` (`:228+`): +`ConflictException` khi `pe.Phase ∈ {DaDuyet, TuChoi}` (đặt TRƯỚC role-check side-effects, sau NotFound).
   - `PurchaseEvaluationFeatures.cs` `AdjustBudgetCommand` (`:419-422`): +Conflict terminal-phase TRƯỚC nhánh `else { actorTag = "[Admin]" }`.
   - **KHÔNG đụng** `UpdatePeBudgetPro/CcmCommandHandler` (pair live = R2, giữ không phase-guard).
8. **FE ×2 app (sau khi BE DTO chốt — nhưng shape đã cố định ở spec, làm song song được):**
   - `fe-user/src/types/purchaseEvaluation.ts` (~:314-349) + `fe-admin/src/types/purchaseEvaluation.ts` (~:312) — 2 file KHÔNG mirror, sửa riêng: +`budgetFrozen: boolean` vào PeBudgetSummary type.
   - `fe-user` + `fe-admin` `src/components/pe/PeDetailTabs.tsx` (**SHA-mirror — sửa GIỐNG HỆT, verify `git hash-object` 2 file bằng nhau sau sửa**; baseline hiện `931025f2` identical): +badge "🔒 Ngân sách chốt tại thời điểm duyệt" khi `bs.budgetFrozen` (cạnh header bảng TỔNG HỢP NGÂN SÁCH). KHÔNG đổi logic render khác (input đã tự khoá qua `canEditPro/canEditCcm=false` từ BE).
   - `npm run build` ×2 app PASS.
9. **Tests** (Infrastructure.Tests, SQLite fixture — mig `Sql()` không replay qua EnsureCreated = safe, precedent Mig 65):
   - **T0 REPRODUCE (test-before, viết + chạy TRƯỚC khi BE fix land):** phiếu DaDuyet + pair; `UpdatePeBudgetPro` đổi số; GetDetail → bs **phải giữ số cũ** → hiện RED (đọc live) → GREEN sau fix. *(Test này compile trên code HIỆN TẠI — không cần cột mới.)*
   - T1-T4: snapshot-set đủ **4 nhánh finalize** (level-finalize / CCM-threshold / all-steps / V1-legacy): `ApprovedBudget* == pair values` + `SnapshotAt != null`.
   - T5-T6 guards: `SetPeCcmBudgetPeriod` trên DaDuyet → Conflict (cả admin) · `AdjustBudget` admin trên DaDuyet → Conflict.
   - T7: display-gate else-live (phiếu ChoDuyet sau pair-đổi → số MỚI + `BudgetFrozen=false`; frozen DTO có CanEdit*=false).
   - T8: null-safe (DaDuyet WorkItemId null → budgetSummary null không NRE; pair thiếu → frozen số null + SnapshotAt set).
   - T9: re-finalize overwrite (un-terminal → sửa pair → duyệt lại → snapshot = số MỚI).
10. **Docs closeout (em main):** STATUS (Mig 67, tables giữ 89) + HANDOFF + session-log + gotchas nếu phát sinh; slnx KHÔNG đổi (accumulator nằm trong csproj glob — vẫn verify `git status`).

## ③ Checklist per-vai (acceptance ĐO ĐƯỢC)

**test-specialist (stage A — REPRODUCE trước):**
- [ ] File test mới `PeBudgetFreezeTests` có T0; chạy `dotnet test --filter PeBudgetFreeze` trên code HIỆN TẠI → **RED** (log output làm evidence), commit-ready.
- [ ] KHÔNG đụng src/ production.

**implementer-frontend (stage A — song song, disjoint):**
- [ ] `grep -rn budgetFrozen fe-user/src fe-admin/src` ≥ 4 hit (2 types + 2 PeDetailTabs).
- [ ] `git hash-object fe-user/src/components/pe/PeDetailTabs.tsx fe-admin/src/components/pe/PeDetailTabs.tsx` → 2 hash **BẰNG NHAU** (baseline `931025f2` đang identical).
- [ ] `npm run build` PASS cả 2 app (log exit 0).

**implementer-backend (stage B — sau stage A):**
- [ ] Mig 67 3-file: `git status` Migrations/ = 3 file (mới ×2 + ModelSnapshot); `grep -c "AddColumn" <Mig67>.cs` = 11; Down có 11 DropColumn; backfill `Sql()` có `WHERE pe.Phase=7 AND pe.ApprovedBudgetSnapshotAt IS NULL`.
- [ ] `grep -n "ApplyBudgetSnapshotOnFinalizeAsync" PurchaseEvaluationWorkflowService.cs` = **5 hit** (1 def + 4 call); `grep -n "Phase = PurchaseEvaluationPhase.DaDuyet" src/ -r` = 4 hit, MỖI hit có snapshot-call trong ≤6 dòng trước.
- [ ] `dotnet build SolutionErp.slnx` 0 error (build FULL slnx — gotcha #65, DTO record đổi chữ ký đụng tests compile).
- [ ] `dotnet ef database update` ×2 DB local không lỗi; sqlcmd Dev: `SELECT COUNT(*) FROM PurchaseEvaluations WHERE Phase=7 AND ApprovedBudgetSnapshotAt IS NULL` = **0**.
- [ ] `dotnet test --filter PeBudgetFreeze` → T0 **GREEN**.

**test-specialist (stage C — sau BE):**
- [ ] T1-T9 đủ (≥9 test mới tổng cả T0); `dotnet test SolutionErp.slnx` FULL PASS (baseline 509 + mới, 0 fail/0 skip).
- [ ] Đối chiếu accumulator: GetDetail output lũy kế TRƯỚC/SAU refactor bằng nhau (test hiện có không đổi kết quả).

**reviewer (pre-commit — schema mig + financial BẮT BUỘC):**
- [ ] Xác nhận KHÔNG có phase-guard mới trên `UpdatePeBudgetPro/Ccm` (R2 giữ live).
- [ ] Display-gate đúng điều kiện `Phase==DaDuyet && SnapshotAt!=null`; nhánh frozen KHÔNG rò field live nào (đối chiếu 17+1 field từng cái).
- [ ] Đối chiếu **3 bản predicate lũy kế** (SQL backfill ↔ PeBudgetAccumulator ↔ Features cũ :917-943) khớp từng điều kiện (Phase set, CreatedAt<, Id<>, IsDeleted, IsSelected join-path).
- [ ] EDGE-5 interplay: un-terminal → snapshot dormant (không clear) → re-finalize overwrite; grep xác nhận KHÔNG chỗ nào set `ApprovedBudget* = null`.
- [ ] Grep `canEditPro|canEditCcm` FE: nhánh frozen không còn đường edit; 2 terminal-guard có test.

**cicd-monitor (post-deploy):**
- [ ] Run Gitea xanh; prod sqlcmd: `__EFMigrationsHistory` top = `AddPeApprovedBudgetSnapshot`; `SELECT COUNT(*) FROM PurchaseEvaluations WHERE Phase=7 AND ApprovedBudgetSnapshotAt IS NULL` = 0; sys.tables = **89** (no new table).
- [ ] Bundle FE rotate ×2 app + curl-grep byte-level `budgetFrozen` + "Ngân sách chốt" PRESENT trong bundle mới (#77).
- [ ] Smoke 200×3 + PE endpoint 401-secured.
- [ ] UAT script cho anh Kiệt: mở phiếu DaDuyet → ghi số matrix → sửa NS PRO/CCM từ phiếu nháp cùng cặp (hoặc màn NS) → reload phiếu DaDuyet **incognito** → số KHÔNG đổi + badge 🔒; phiếu nháp cùng cặp thấy số MỚI.

**Migration:** Mig 67 `AddPeApprovedBudgetSnapshot`. **File đụng (~15):** Domain PurchaseEvaluation.cs · PurchaseEvaluationConfiguration.cs · Migrations ×3 · PurchaseEvaluationFeatures.cs · PeWorkItemBudgetFeatures.cs · PurchaseEvaluationDtos.cs · +PeBudgetAccumulator.cs (mới) · PurchaseEvaluationWorkflowService.cs · tests ~2 · fe-user types+PeDetailTabs · fe-admin types+PeDetailTabs.
