14 KiB
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-codebasedeep-passwf_348f6155-167(#53 return-fail → disk-truthsub-investigator-codebase-0.md22.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 IDENTICAL931025f2· 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).
① 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)
- Domain
src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs— +11 props nullable:ApprovedBudgetProInitialAmountApprovedBudgetProAdjustmentAmountApprovedBudgetCcmInitialAmountApprovedBudgetCcmAdjustmentAmountApprovedBudgetPrevSubmittedTotalApprovedBudgetPrevSelectedTotal(decimal?) ·ApprovedBudgetProNoteApprovedBudgetCcmNote(string?) ·ApprovedBudgetPrevSubmittedCountApprovedBudgetPrevSelectedCount(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ênInitialAmount/AdjustmentAmountcho CCM.) - 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ó). - 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 (guardActiveProviderSqlServer-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ớpPurchaseEvaluationFeatures.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 updatecảSolutionErp_Dev(--connection override) +SolutionErp_Design(per skill ef-core-migration + feedback_designtime_runtime_db).
- Up: AddColumn ×11 +
- Shared accumulator (chống drift 3-bản): extract khối lũy kế + pair-load semantics từ
GetPurchaseEvaluationDetailQuery(PurchaseEvaluationFeatures.cs:917-951) ra helper static mớiApplication/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ó). - Service snapshot helper
Infrastructure/Services/PurchaseEvaluationWorkflowService.cs— +private async Task ApplyBudgetSnapshotOnFinalizeAsync(PurchaseEvaluation evaluation, CancellationToken ct):- Load pair
AsNoTrackingtheo (ProjectId, WorkItemId) — null-safe (WorkItemId null / pair thiếu → các cột số = null, VẪN setApprovedBudgetSnapshotAt= 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).
- Load pair
- DTO + display-gate
Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs+PurchaseEvaluationFeatures.cs:PeBudgetSummaryDto(positional record, hiện 17 field, 1 construction site:964) — append cuốibool BudgetFrozen = false.GetPurchaseEvaluationDetailQuery(:905-973):var frozen = e.Phase == PurchaseEvaluationPhase.DaDuyet && e.ApprovedBudgetSnapshotAt != null;→ nhánh frozen: build DTO từ cộtApprovedBudget*(fullAmount/FullIsEstimate dùng CÙNG công thức:957-962nhưng input = snapshot cols;CanEditPro=CanEditCcm=false;CurrentProposalTotalvẫ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ũ.
- Write-guards (OD2 — block CẢ Admin trên terminal):
PeWorkItemBudgetFeatures.csSetPeCcmBudgetPeriodCommandHandler(:228+): +ConflictExceptionkhipe.Phase ∈ {DaDuyet, TuChoi}(đặt TRƯỚC role-check side-effects, sau NotFound).PurchaseEvaluationFeatures.csAdjustBudgetCommand(:419-422): +Conflict terminal-phase TRƯỚC nhánhelse { actorTag = "[Admin]" }.- KHÔNG đụng
UpdatePeBudgetPro/CcmCommandHandler(pair live = R2, giữ không phase-guard).
- 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) +:312) — 2 file KHÔNG mirror, sửa riêng: +fe-admin/src/types/purchaseEvaluation.ts(budgetFrozen: booleanvào PeBudgetSummary type.fe-user+fe-adminsrc/components/pe/PeDetailTabs.tsx(SHA-mirror — sửa GIỐNG HỆT, verifygit hash-object2 file bằng nhau sau sửa; baseline hiện931025f2identical): +badge "🔒 Ngân sách chốt tại thời điểm duyệt" khibs.budgetFrozen(cạnh header bảng TỔNG HỢP NGÂN SÁCH). KHÔNG đổi logic render khác (input đã tự khoá quacanEditPro/canEditCcm=falsetừ BE).npm run build×2 app PASS.
- 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:
SetPeCcmBudgetPeriodtrên DaDuyet → Conflict (cả admin) ·AdjustBudgetadmin 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).
- T0 REPRODUCE (test-before, viết + chạy TRƯỚC khi BE fix land): phiếu DaDuyet + pair;
- 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
PeBudgetFreezeTestscó T0; chạydotnet test --filter PeBudgetFreezetrê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 (baseline931025f2đang identical).npm run buildPASS cả 2 app (log exit 0).
implementer-backend (stage B — sau stage A):
- Mig 67 3-file:
git statusMigrations/ = 3 file (mới ×2 + ModelSnapshot);grep -c "AddColumn" <Mig67>.cs= 11; Down có 11 DropColumn; backfillSql()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.slnx0 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.slnxFULL 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|canEditCcmFE: nhánh frozen không còn đường edit; 2 terminal-guard có test.
cicd-monitor (post-deploy):
- Run Gitea xanh; prod sqlcmd:
__EFMigrationsHistorytop =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.