Files
solution-erp/.claude/workflows/runs/2026-07-17-S133-pe-budget-freeze-daduyet/sub-investigator-codebase-0.md
2026-07-17 09:58:40 +07:00

22 KiB
Raw Blame History

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 GetPurchaseEvaluationDetailQueryPeBudgetSummaryDto 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 (ApproveV1LegacyAsyncKHÔ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: GetPurchaseEvaluationDetailQueryfrozen = 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):

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<pe.CreatedAt ...) oa
WHERE pe.Phase=7 AND pe.ApprovedBudgetSnapshotAt IS NULL   -- idempotent

LEFT JOIN → null-safe (WorkItemId null / thiếu pair → marker set, số null → FE hiển thị "—" như hiện tại). Down = DropColumn ×11 (schema reversible); data-restore no-op (mirror Mig 61/65).

G. EDGE

  • TuChoi: terminal khoá (enum :26) + action GỠ S60 (service :101-106 chặn cả admin) → phiếu TuChoi mới KHÔNG phát sinh; phiếu cũ ít. Khuyến nghị: freeze DaDuyet-only (display-gate Phase==DaDuyet); extend TuChoi sau = 1 điều kiện nếu owner muốn.
  • DaDuyet có đường thoát ngược: CÓ — EDGE-5 hard-lock chỉ chặn non-admin (:67-68); Admin + System un-terminal được (comment :62-63). Precedent: ApprovedPrice* KHÔNG BAO GIỜ clear (grep ApprovedPriceAmount = null = 0 hit ngoài default-param) → mirror: KHÔNG clear snapshot khi un-terminal — snapshot nằm im (display-gate theo Phase tự chuyển về live), re-finalize OVERWRITE (helper luôn ghi đè = "re-snapshot lần DaDuyet cuối").
  • TraLai → sửa → duyệt lại: như trên — overwrite tại lần finalize mới. Đúng ngữ nghĩa "ngân sách đúng nhất là bản cập nhật gần nhất" áp cho THỜI ĐIỂM DUYỆT cuối.
  • WorkItemId null / thiếu pair row: helper set SnapshotAt + số null; GetDetail hiện tại đã trả budgetSummary=null khi WorkItemId null (:905-906) → hành vi giữ nguyên (banner "chưa gắn Hạng mục" fe-user :1298-1303).

H. DECISION-time vs DISPLAY-time (GIỮ LIVE — không đụng)

  • Submit-guard WorkflowService.cs:222-228 — lúc trình, LIVE đúng semantics ✓
  • CCM-threshold so winnerQuoteTotal vs aw.CeoApprovalThreshold (:878+) — không đọc budget ✓
  • winnerQuoteTotal = SUM IsSelected (Dtos :269 + Features ~:1219) — quote-derived, tự-frozen ✓
  • CreateContractFromEvaluation KHÔNG đọc PeWorkItemBudgets (grep Budget file = :106-107 carry pe.BudgetPeriodAmount→BudgetManualAmount + :183 projection; GiaTri = SUM báo giá chọn — xác nhận S114/S117) ✓

I. TEST đề xuất (Infrastructure.Tests, SqliteDbFixture — mig Sql() skip qua EnsureCreated ✓ precedent Mig 65)

  1. test-before REPRODUCE (RED hiện tại): phiếu DaDuyet + pair; UpdatePeBudgetPro đổi số; GetDetail → bs phải GIỮ số cũ (nay FAIL vì đọc live) → GREEN sau fix.
  2. Snapshot-set đủ 4 nhánh finalize (level-finalize / ccm-threshold / all-steps / V1-legacy): ApprovedBudget* == pair values tại lúc duyệt + SnapshotAt != null.
  3. Guard: SetPeCcmBudgetPeriod trên DaDuyet → Conflict; AdjustBudget admin trên DaDuyet → Conflict (theo decision #2).
  4. Display-gate else-live: phiếu ChoDuyet sau khi pair đổi → bs = số MỚI + BudgetFrozen=false; frozen DTO có CanEditPro/Ccm=false.
  5. Null-safe: DaDuyet WorkItemId null → budgetSummary null, không NRE; pair thiếu → frozen số null.
  6. Re-finalize overwrite: admin un-terminal → sửa pair → duyệt lại → snapshot = số MỚI. Ước lượng: BE 6 file (PurchaseEvaluation.cs + Configuration + Features + WorkflowService + PeWorkItemBudgetFeatures + Dtos) + mig 3-file + tests ~2 file + FE 4 file (2 app × types+PeDetailTabs) ≈ 15 file / ~400 LOC. Rủi ro: positional-record append (1 construction site :964 — thấp) · mirror SHA PeDetailTabs 2 app (diff-check sau sửa) · OUTER APPLY backfill đúng filter (IsDeleted=0, Phase IN (7,10) cho submitted-row1) · nhánh finalize MỚI tương lai quên gọi helper (mitigate: comment rule + reviewer grep 4/4).

Open decisions (≤3, kèm khuyến nghị)

  1. Snapshot 4 cột lũy kế (row1/2)? REC (conf 0.7) — r5/r7 + so-sánh derive từ row1; không chụp thì phiếu DaDuyet vẫn đổi số khi peer transition (vector khác PeWorkItemBudgets nhưng cùng triệu chứng Kiệt thấy). Bỏ = 7 cột, chấp nhận drift lũy kế.
  2. Terminal-guard SetPeCcmBudgetPeriod+AdjustBudget block cả Admin? REC (conf 0.75) — "không cho thay đổi gì hết"; đường chính thống = admin un-terminal (EDGE-5 exempt) → sửa → duyệt lại (re-snapshot).
  3. Freeze TuChoi? REC KHÔNG (conf 0.8) — action gỡ S60, extend sau 1 dòng.

SPEC-DRAFT (H21 3 heading — lead verify rồi lead ghi file spec)

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

Phiếu PE Phase=DaDuyet bất biến số ngân sách: chụp (snapshot) bộ số ngân-sách-gói-thầu (PeWorkItemBudgets matrix PRO/CCM + lũy kế) vào 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), phiếu chưa duyệt tiếp tục đọc LIVE. Bảng PeWorkItemBudgets GIỮ NGUYÊN là nguồn "bản cập nhật gần nhất" (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. Backfill phiếu DaDuyet hiện hữu = số live hiện tại (số gốc không khôi phục được — changelog attribution gap, honest-note). Nguồn yêu cầu: UAT anh Kiệt FDC 2026-07-16 17:24-17:27 (R1/R2/R3).

② Cách implement (ordered)

  1. Domain src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs: +11 props nullable ApprovedBudget{ProInitial,ProAdjustment,CcmInitial,CcmAdjustment}Amount decimal? · ApprovedBudget{Pro,Ccm}Note string? · ApprovedBudgetPrev{Submitted,Selected}{Total decimal?,Count int?} · ApprovedBudgetSnapshotAt DateTime?.
  2. Config Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs: HasPrecision(18,2) ×6 decimal + HasMaxLength(1000) ×2 note (pattern :115-116).
  3. Mig 67 dotnet ef migrations add AddPeApprovedBudgetSnapshot (3-file rule): AddColumn ×11 + Sql() backfill idempotent WHERE Phase=7 AND ApprovedBudgetSnapshotAt IS NULL (LEFT JOIN pair IsDeleted=0 + OUTER APPLY lũy kế same-predicate Features :921-943; ActiveProvider SqlServer-guard mirror Mig 61/65) + Down DropColumn ×11.
  4. Shared accumulator (chống mirror-drift): extract lũy kế (peers submitted/selected + proposal) từ GetPurchaseEvaluationDetailQuery (:917-951) ra helper static Application/PurchaseEvaluations/PeBudgetAccumulator.cs — dùng chung GetDetail (live path) + service snapshot (runtime path). (Backfill SQL vẫn bản dịch SQL của cùng predicate — reviewer đối chiếu.)
  5. Service Infrastructure/Services/PurchaseEvaluationWorkflowService.cs: +ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct) (load pair AsNoTracking theo ProjectId×WorkItemId, null-safe; gọi accumulator; LUÔN overwrite; comment rule "mọi nhánh set DaDuyet PHẢI gọi") — gọi tại 4 site ngay trước Phase = DaDuyet: :861 · :899 · :931 · :1053.
  6. DTO + display-gate Application/PurchaseEvaluations/{Dtos/PurchaseEvaluationDtos.cs,PurchaseEvaluationFeatures.cs}: PeBudgetSummaryDto +bool BudgetFrozen (append cuối :349); GetDetail :905-973 → nhánh frozen build DTO từ cột snapshot (CanEditPro/Ccm=false, lũy kế + notes từ snapshot, CurrentProposalTotal vẫn từ own quotes), else live như cũ.
  7. Write-guards: PeWorkItemBudgetFeatures.cs SetPeCcmBudgetPeriodHandler +Conflict terminal (:230+); PurchaseEvaluationFeatures.cs AdjustBudget +Conflict terminal trước admin-bypass (:419-422). KHÔNG đụng UpdatePeBudgetPro/Ccm (R2).
  8. FE ×2 app: types +budgetFrozen: boolean (fe-user :314+, fe-admin ~:312+ — file KHÔNG SHA-mirror, sửa từng cái); PeDetailTabs.tsx +badge frozen (SHA-mirror — sửa giống hệt 2 app, diff-check).
  9. Tests (mục I trên — test-before RED trước khi code fix, rule bug-fix=test-before).
  10. Docs closeout: STATUS.md (Mig 67, bảng giữ 89) + schema-diagram migration-table + gotchas nếu phát sinh + session log. slnx không đổi (không file .cs mới ngoài accumulator — VẪN check).

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

implementer-backend

  • Mig 67 3-file: git status Migrations/ = 3 file mới; grep -c AddColumn <mig>.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.