# sub-investigator-codebase-0 — PE budget FREEZE-at-DaDuyet invest > RUN: 2026-07-17-S133-pe-budget-freeze-daduyet · engine /fable-real single deep-pass > STATUS: DONE — mọi claim grep/đọc từ đĩa lúc chạy (2026-07-17) ## Đề bài R1 phiếu DaDuyet bất-biến số NS · R2 PeWorkItemBudgets = live "bản cập nhật gần nhất" cho phiếu chưa duyệt · R3 DB delta nhỏ (AddColumn bảng sẵn có, no new table, reversible Down, backfill idempotent). --- ## A. READ-MAP — mọi site BE load/JOIN `PeWorkItemBudgets` (grep toàn `src/Backend` = 4 file ngoài mig/config) | # | Site | file:line | Serve phiếu DaDuyet? | |---|---|---|---| | 1 | CreatePE auto-create pair record (race-safe) | `PurchaseEvaluationFeatures.cs:105-134` | KHÔNG serve số — write-path | | 2 | **DETAIL BUNDLE** `GetPurchaseEvaluationDetailQuery` → `PeBudgetSummaryDto` | `PurchaseEvaluationFeatures.cs:911-912` (load pairRec LIVE) → :964-972 (DTO) | **CÓ — display-after-decision. ĐÂY là site retro-change duy nhất serve số pair ra phiếu DaDuyet** | | 3 | Submit-guard `effectiveBudget = BudgetPeriodAmount ?? pair.ProInitialAmount` | `PurchaseEvaluationWorkflowService.cs:222-228` | KHÔNG — decision-time lúc TRÌNH (phiếu còn DangSoanThao/TraLai) → GIỮ LIVE | | 4 | `PeWorkItemBudgetEnsurer.EnsureTrackedAsync` (load-or-create) | `PeWorkItemBudgetFeatures.cs:39-57` | KHÔNG — write-path helper | List/inbox projection KHÔNG join pair (grep `BudgetPeriodAmount` Features chỉ :151/:218/:295/:428 — cột per-slip). Dashboard/Contract KHÔNG đọc PeWorkItemBudgets (grep 0 hit ngoài 4 file trên). Workflow Designer không đụng budget. **Số trong PeBudgetSummaryDto (Dtos.cs:331-349, 17 field) chia 3 nhóm:** - **Từ pairRec LIVE (đổi retro khi PRO/CCM sửa)**: ProEstimateAmount(legacy, FE không dùng — fe-user types :316), ProNote, InitialAmount(CCM), AdjustmentAmount(CCM), CcmNote, ProInitialAmount, ProAdjustmentAmount, FullAmount+FullIsEstimate (derived :957-962), CanEditPro/CanEditCcm (role-derived :908-909). - **Từ peers (drift theo transition phiếu KHÁC, không theo pair)**: PreviousSubmittedTotal/Count (:921-927 — peers CreatedAt< this, Phase ∈ {ChoDuyet,DaDuyet}, SUM BudgetPeriodAmount), PreviousSelectedTotal/Count (:931-943 — peers DaDuyet + quote IsSelected). - **Từ chính phiếu (tự-frozen sau DaDuyet)**: CurrentProposalTotal (:947-951 — own IsSelected quotes; edit-guard `PurchaseEvaluationDetailFeatures.cs:28-31` + :49-98 chặn mọi sửa Section-2/chọn-thầu ngoài DangSoanThao/TraLai/ChoDuyet-có-quyền → DaDuyet = Conflict :97-98). **FE render (mirror 2 app `components/pe/PeDetailTabs.tsx`)**: row3 PRO `draftRow3 = ev.budgetPeriodAmount ?? bs.proInitialAmount` (fe-user :1288-1290) · row3 CCM `draftCcmRow3 = ev.ccmBudgetPeriodAmount ?? bs.initialAmount` (:1294-1295) — **fallback đọc pairRec LIVE = đường retro-change hiển thị thứ 2**; proFull/ccmFull (:1309-1310); r5/r7/r8/r9 + 3 so-sánh (colCalc :1323-1335); cờ vượt NS (:1344-1346). effBudget mirror submit-guard (:220/:244 cả 2 app). ## B. WRITE-MAP `PeWorkItemBudgets` - `UpdatePeBudgetProCommandHandler` `PeWorkItemBudgetFeatures.cs:80-130` — role PRO|Admin, absolute-set :105-107, **KHÔNG ràng Phase** (comment :20-22 "tài liệu sống per cặp"). Endpoint `PUT /purchase-evaluations/{id}/budget/pro` (controller :72). - `UpdatePeBudgetCcmCommandHandler` :151-200 — role CCM|Admin, absolute-set :175-177, KHÔNG ràng Phase. `PUT .../budget/ccm` (controller :80). - Key shared `(ProjectId × WorkItemId)` (`EnsureTrackedAsync` :36-58; UNIQUE filtered index Mig 50) → **mọi phiếu cùng gói đọc chung 1 record = cơ chế retro-change xác nhận (AC2 = read-site :911-912 + fallback FE :1288/:1294)**. **Vector retro-change per-slip (ngoài pair):** - `SetPeCcmBudgetPeriodCommandHandler` :224-257 ghi `PE.CcmBudgetPeriodAmount` **KHÔNG phase-guard** (:228-242) → CCM sửa được row3-CCM trên chính phiếu DaDuyet. `PATCH .../budget/ccm-period` (controller :122). - `AdjustBudgetCommand` (`PurchaseEvaluationFeatures.cs:319-429`) ghi `PE.BudgetPeriodAmount/ExpectedRemainingAmount` :425-429 — non-admin bị chặn phase khác (:412-417) nhưng **Admin bypass mọi phase** (:419-422) → admin sửa được row3-PRO phiếu DaDuyet. ## C. PER-SLIP fields sẵn có (semantics thật — soi #73) - `PE.BudgetPeriodAmount` (Mig 50, "NS kỳ này" PRO row3): set Create :151 · UpdateDraft :294-295 (null-safe GIỮ, chỉ DangSoanThao/TraLai per :326-327) · AdjustBudget :428 (absolute-set, scope Drafter-nháp/Approver-đang-duyệt-có-AllowApproverEditBudget/Admin). KHÔNG bị sync theo live. **Tái dụng làm snapshot? KHÔNG** — semantics khác (số drafter/approver NHẬP per-slip, không phải bản chụp pair matrix; matrix vẫn hiển thị pair riêng). - `PE.CcmBudgetPeriodAmount` (Mig 59, row3 CCM): SetPeCcmBudgetPeriod :242 absolute-set — cần thêm terminal-guard (xem G). - `PurchaseEvaluationDetails.DonGiaNganSach/ThanhTienNganSach` (`PurchaseEvaluationDetail.cs:17-18`): per-line item, ghi qua Add/Update detail (controller :195-206), lock edit-guard sau trình → tự-frozen. KHÔNG liên quan pair matrix. - **Kết luận C: KHÔNG có chỗ chứa sẵn cho bản chụp pair matrix → thêm cột snapshot mới sạch hơn** (trade-off: +11 cột nullable vs conflate semantics 2 field cũ — chọn cột mới, theo precedent `ApprovedPrice*` Mig 54 "amount = snapshot lúc duyệt" comment WorkflowService :951). ## D. FINALIZE-MAP (grep `Phase\s*=\s*PurchaseEvaluationPhase\.DaDuyet` toàn `src` = **đúng 4 hit, ALL trong PurchaseEvaluationWorkflowService.cs** — không seed/initializer/controller nào set) 1. `:861` V2 **level-finalize** (AllowApproverFinalize opt-in, `:859` EndedByLevelFinalize=true, `:860` ApplyApprovedPriceOnFinalize) 2. `:899` V2 **CCM-threshold** (CeoApprovalThreshold `:878`, `:898` ApplyApprovedPriceOnFinalize) 3. `:931` V2 **all-steps-done** (`:930` ApplyApprovedPriceOnFinalize) 4. `:1053` **V1 legacy** last-step (`ApproveV1LegacyAsync` — **KHÔNG gọi** ApplyApprovedPriceOnFinalize) **Choke-point đề xuất:** KHÔNG dùng `ApplyApprovedPriceOnFinalize` (miss nhánh V1 :1053; static/sync không db-access :955-956). → Helper MỚI `ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct)` trong cùng service, gọi ngay TRƯỚC mỗi `Phase = DaDuyet` tại **4 site**. Rủi ro nhánh sót tương lai: guard = unit test 4 nhánh + reviewer grep-check "mỗi assignment DaDuyet có snapshot call kề" (hiện 4/4; nhánh mới thêm sau này phải kèm — ghi rule vào comment helper). ## E. THIẾT KẾ (phương án NHỎ NHẤT thỏa R1-R3) **(i) Bộ số phiếu DaDuyet đang hiển thị hôm nay** (soi FE matrix + BE DTO): - Block A pair: ProInitial, ProAdjust, ProNote, CcmInitial(=pair.InitialAmount), CcmAdjust(=pair.AdjustmentAmount), CcmNote → proFull/ccmFull FE tự cộng (:1309-1310). - Row1/2 lũy kế: PrevSubmittedTotal/Count, PrevSelectedTotal/Count (peer-derived — drift riêng). - Row3/8: per-slip sẵn (BudgetPeriodAmount/CcmBudgetPeriodAmount/ExpectedRemainingAmount) — frozen bằng WRITE-GUARD, không cần cột. - Row4/r5/r7/r9 + so-sánh + cờ vượt: FE derive từ trên. - ProEstimateAmount legacy: FE không dùng (fe-user types :316 "[LEGACY ≤S75]") → KHÔNG snapshot. **(ii) Bảng đặt cột: `PurchaseEvaluations`** — 1 PE : 1 WorkItemId (`PE.WorkItemId Guid?` Mig 49) : 1 pair record → cardinality 1-1 per phiếu, đúng đơn vị bị freeze. KHÔNG bảng mới (R3). **(iii) Cột mới (Mig 67 `AddPeApprovedBudgetSnapshot`) — 11 cột nullable:** ``` ApprovedBudgetProInitialAmount decimal(18,2)? ApprovedBudgetCcmInitialAmount decimal(18,2)? ApprovedBudgetProAdjustmentAmount decimal(18,2)? ApprovedBudgetCcmAdjustmentAmount decimal(18,2)? ApprovedBudgetProNote nvarchar(1000)? ApprovedBudgetCcmNote nvarchar(1000)? ApprovedBudgetPrevSubmittedTotal decimal(18,2)? ApprovedBudgetPrevSubmittedCount int? ApprovedBudgetPrevSelectedTotal decimal(18,2)? ApprovedBudgetPrevSelectedCount int? ApprovedBudgetSnapshotAt datetime2? ← marker frozen + audit thời điểm chụp ``` Naming theo precedent `ApprovedPrice*` (Mig 54/66 — snapshot-lúc-duyệt trên PE). Core tối thiểu = 7 (bỏ 4 lũy kế) nếu owner chọn open-decision #1 = KHÔNG. **(iv) Display-gate:** `GetPurchaseEvaluationDetailQuery` — `frozen = e.Phase==DaDuyet && e.ApprovedBudgetSnapshotAt!=null` → build `PeBudgetSummaryDto` từ cột snapshot, **CanEditPro=CanEditCcm=false**, +field mới `bool BudgetFrozen` (append cuối record :349 — chỉ 1 construction site :964 phải sửa + 1 site frozen mới); else nhánh live y nguyên + BudgetFrozen=false. Fallback an toàn: DaDuyet mà SnapshotAt null (không thể sau backfill) → live như cũ. **FE 2 app touch TỐI THIỂU** (input đã gate sẵn qua `bs.canEditPro/canEditCcm` — fe-admin :1403-1459/:1524, fe-user tương tự; row3/row8 đã gate `drafterEditable` :1236 = isEditablePhase loại DaDuyet): - `fe-user/src/types/purchaseEvaluation.ts:314-349` + `fe-admin/src/types/purchaseEvaluation.ts` (~:312, KHÔNG SHA-mirror — S117): +`budgetFrozen: boolean`. - `fe-{user,admin}/src/components/pe/PeDetailTabs.tsx` (SHA-mirror — sửa giống hệt): badge "Ngân sách chốt tại thời điểm duyệt" khi `bs.budgetFrozen` (cạnh header matrix ~:1306); KHÔNG đổi logic render. **Write-guards kèm (R1 "không cho thay đổi gì hết"):** - `SetPeCcmBudgetPeriodCommandHandler` (PeWorkItemBudgetFeatures.cs:228+): +Conflict khi `pe.Phase ∈ {DaDuyet, TuChoi}`. - `AdjustBudgetCommand` (PurchaseEvaluationFeatures.cs:419-422): +Conflict terminal-phase TRƯỚC nhánh admin (block cả Admin — open-decision #2). - `UpdatePeBudgetPro/Ccm` pair: **GIỮ NGUYÊN không phase-guard** (R2 — bảng live vẫn là "bản cập nhật gần nhất"; phiếu frozen không bị ảnh hưởng nhờ display-gate). ## F. BACKFILL phiếu DaDuyet hiện hữu `PurchaseEvaluationChangelogs` CÓ log giá trị budget-edit (Summary "Ngân sách gói thầu (PRO): ban hành PRO Xđ → Yđ" — PeWorkItemBudgetFeatures.cs:109-126/:179-196) **NHƯNG KHÔNG tái dựng được số tại-thời-điểm-duyệt**: (a) log gắn `PurchaseEvaluationId` = phiếu ĐANG MỞ lúc sửa — sửa từ trang phiếu B không xuất hiện trong changelog phiếu A cùng cặp (attribution gap); (b) giá trị nằm trong string N0-format phải parse; (c) dev DB hiện 0 budgetLogs / 0 pairRows (sqlcmd 2026-07-17; prod [UNVERIFIED] chưa scan). → **Backfill = copy LIVE hiện tại + honest-note "không khôi phục được số gốc tại thời điểm duyệt"** (mirror pattern Mig 61 #79 — đúng bảng, đúng kỳ vọng). SQL trong Mig 67 Up (ActiveProvider==SqlServer guard như Mig 61/65): ```sql UPDATE pe SET ApprovedBudget... = b...., ApprovedBudgetPrevSubmittedTotal = oa.st, ..., 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 ... peers CreatedAt.cs` = 11; Down có 11 DropColumn. - [ ] `grep -n "ApplyBudgetSnapshotOnFinalizeAsync" PurchaseEvaluationWorkflowService.cs` = 5 hit (1 def + 4 call :861/:899/:931/:1053-vùng). - [ ] `grep -n "Phase = PurchaseEvaluationPhase.DaDuyet" src/ -r` = 4 hit, mỗi hit có snapshot-call trong ≤5 dòng trước. - [ ] Backfill idempotent: `dotnet ef database update` ×2 không lỗi; sqlcmd Dev `SELECT COUNT(*) FROM PurchaseEvaluations WHERE Phase=7 AND ApprovedBudgetSnapshotAt IS NULL` = 0. - [ ] Guard: curl PATCH `/budget/ccm-period` + `/budget-adjust` trên phiếu DaDuyet → 409 (cả token admin nếu decision #2 = CÓ). - [ ] `dotnet build` 0 error/0 warning mới. **implementer-frontend** - [ ] `grep -rn budgetFrozen fe-user/src fe-admin/src` ≥ 4 hit (types + PeDetailTabs mỗi app). - [ ] PeDetailTabs 2 app SHA-identical sau sửa: `git hash-object fe-user/.../PeDetailTabs.tsx fe-admin/.../PeDetailTabs.tsx` bằng nhau (nếu baseline đang identical — verify trước). - [ ] 2 app `npm run build` pass. **test-specialist** - [ ] Test REPRODUCE commit TRƯỚC fix, RED (bs đổi theo pair trên phiếu DaDuyet); sau fix GREEN. - [ ] 4 test snapshot per-nhánh finalize + 2 guard + null-safe + else-live + re-finalize-overwrite (≥8 test mới). - [ ] `dotnet test SolutionErp.slnx` full pass (baseline STATUS.md + mới). **reviewer** - [ ] Adversarial: xác nhận KHÔNG thêm phase-guard vào UpdatePeBudgetPro/Ccm (R2); xác nhận display-gate điều kiện `Phase==DaDuyet && SnapshotAt!=null`; đối chiếu predicate backfill SQL vs accumulator vs Features :921-943 (3 bản khớp); soi EDGE-5 interplay (un-terminal → dormant → overwrite). - [ ] Grep `canEditPro|canEditCcm` FE — frozen path không còn đường edit hiện. **cicd-monitor** - [ ] Run xanh; prod sqlcmd `__EFMigrationsHistory` top = `AddPeApprovedBudgetSnapshot`; `SELECT COUNT(*) ... Phase=7 AND ApprovedBudgetSnapshotAt IS NULL` = 0; bundle FE rotate + curl-grep `budgetFrozen` trong bundle (verify-deployed-artifact byte-level). - [ ] UAT script: mở phiếu DaDuyet → ghi số matrix → sửa NS PRO từ phiếu nháp cùng cặp → reload phiếu DaDuyet → số KHÔNG đổi + badge frozen; phiếu nháp thấy số MỚI. **Migration đề xuất:** Mig 67 `AddPeApprovedBudgetSnapshot`. **File đụng (15):** Domain/PurchaseEvaluations/PurchaseEvaluation.cs · Infrastructure/.../PurchaseEvaluationConfiguration.cs · Migrations ×3 · Application/.../PurchaseEvaluationFeatures.cs · .../PeWorkItemBudgetFeatures.cs · .../Dtos/PurchaseEvaluationDtos.cs · +Application/.../PeBudgetAccumulator.cs (mới → **update slnx? KHÔNG cần** — file trong csproj glob; vẫn verify) · Infrastructure/Services/PurchaseEvaluationWorkflowService.cs · tests ×~2 · fe-user types+PeDetailTabs · fe-admin types+PeDetailTabs.