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

14 KiB
Raw Blame History

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 snapshot 4 cột lũy kế · OD2 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)

  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ừ GetPurchaseEvaluationDetailQuery (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.
    • GetPurchaseEvaluationDetailQuery (: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()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.