# SPEC — PE lũy kế TẠM TÍNH vs CHÍNH XÁC (17-07-2026, run S134)

> Engine: `/fable-real investigator-codebase` propose (sub-investigator-codebase-0.md, 8 phần) → **lead verified 7/7 claim + 2 errata** (path DTO = `PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs`; hash-value dùng số lead đo: PeDetailTabs 2-app IDENTICAL `5534addb`, PeWorkflowPanel IDENTICAL `a83a58e1`, types KHÁC `2bf773`≠`ab4a2e`) → lead ghi. Decisions D1-D5 lead chốt (flag anh ở báo cáo cuối).

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

UAT anh Kiệt FDC (chat 15/07, chốt 16:10 "Vậy em xử ký theo hướng này đi"): vì **"cung đặc thù"** — phiếu sau trình khi phiếu trước cùng gói thầu (ProjectId × WorkItemId) CHƯA duyệt — cần:

- **Lũy kế TẠM TÍNH**: "bắt lên và tính toán" cả phần góp từ phiếu chưa duyệt; phần này **nhận diện được** (màu/badge riêng — anh Kiệt: "note nó màu gì đó để biết rằng phiếu trước đó chưa duyệt").
- **Lũy kế CHÍNH XÁC**: semantics HIỆN TẠI — **GIỮ NGUYÊN 100%, không đổi 1 số nào** (nguyên tắc Kenny S117 "duyệt rồi mới bắc").
- **Người duyệt cuối (CEO/người duyệt thay) NHÌN THẤY** danh sách "phiếu trước chưa duyệt [mã — trạng thái]" ngay lúc duyệt — **informational nudge, KHÔNG hard-block** (D3).

**Ground-truth thu hẹp đề (invest verified):** row 1 "NS trình duyệt trước" = `Phase ∈ {ChoDuyet, DaDuyet}` — **ĐÃ gồm phiếu đang trình**. Gap thật: **row 2 "Kỳ trước đã chọn thầu"** (`DaDuyet + IsWinner` only → phiếu chưa duyệt đã chọn NCC không góp) + row 6 dẫn xuất; row 1 thiếu TraLai/legacy/nháp.

**Predicate hiện tại (bản RUNTIME duy nhất — `PeBudgetAccumulator.ComputeAsync`, lead re-đọc):**
- peers = cùng `(ProjectId, WorkItemId)`, `Id != this`, `CreatedAt < this` (`PeBudgetAccumulator.cs:38-40`)
- PrevSubmitted = peers `Phase ∈ {ChoDuyet=10, DaDuyet=7}` → Count + SUM(`BudgetPeriodAmount ?? 0`) (`:42-48`)
- PrevSelected = peers `DaDuyet && Suppliers.Any(IsWinner)` → Count + SUM(quote `IsSelected` `ThanhTien`) (`:50-60`)
- Live-path gọi helper (`PurchaseEvaluationFeatures.cs:962`) · snapshot-path gọi helper (`PurchaseEvaluationWorkflowService.cs:1018`, 5 call-site) · backfill Mig 67 = SQL one-time đã chạy. **⇒ KHÔNG đụng `ComputeAsync` = snapshot/backfill/chính-xác bất động.**

**Phase enum:** DangSoanThao=1 · DaDuyet=7 · ChoDuyet=10 · TraLai=98 · TuChoi=99 (+legacy 2-6 deprecated, Dev DB còn row sống phase 3/6 → predicate viết dạng LOẠI-TRỪ).

**Decisions CHỐT (D1-D5):**
- **D1** Nháp (DangSoanThao=1): vào **LIST cảnh báo** (mã + badge "Nháp") nhưng **KHÔNG cộng delta tiền** (S89 nháp riêng-tư `Features.cs:893-898` + số chưa chốt = nhiễu). ⚑ flag anh.
- **D2** Delta row 1 LÀM (TraLai + legacy 2-6 có BudgetPeriodAmount) — cùng query, nhất quán "tất cả".
- **D3** Plain-info: KHÔNG disable nút, KHÔNG hard-block BE.
- **D4** Audit-note Changelog lúc finalize khi còn pending — **1 chỗ duy nhất trong helper snapshot** (KHÔNG đụng 5 call-site).
- **D5** TuChoi=99 exclude tuyệt đối (không bao giờ thành "chính xác").

**Tập predicate MỚI (chốt):**
- `pendingPriorPes` (list cảnh báo) = peers `Phase NOT IN (7, 99)` → `{Id, MaPhieu, Phase}` order CreatedAt.
- `pendingSelectedTotal/Count` (delta row 2/6) = peers `Phase NOT IN (1, 7, 99)` && `Suppliers.Any(IsWinner)` → Count + SUM(quote IsSelected ThanhTien) — mirror shape `:50-60`.
- `pendingSubmittedTotal/Count` (delta row 1/5) = peers `Phase NOT IN (1, 7, 10, 99)` → Count + SUM(`BudgetPeriodAmount ?? 0`) (10/7 đã nằm trong row 1 chính xác).
- Phiếu FROZEN (`budgetFrozen=true`): pending = 0/empty — **R1-S133 phiếu DaDuyet bất biến, KHÔNG số động mới**.

## ② Cách implement

**BE — 0 migration, 0 endpoint mới, 0 đụng `ComputeAsync`/snapshot-path/backfill:**

1. **`PeBudgetAccumulator.cs`** — THÊM (không sửa cũ):
   - `public readonly record struct PePendingAccumulation(decimal PendingSubmittedTotal, int PendingSubmittedCount, decimal PendingSelectedTotal, int PendingSelectedCount, IReadOnlyList<PePendingPriorPeDto> PriorPes);`
   - `public static async Task<PePendingAccumulation> ComputePendingAsync(IApplicationDbContext db, Guid projectId, Guid workItemId, Guid peId, DateTime peCreatedAt, CancellationToken ct)` — peers window Y HỆT `:38-40` (`CreatedAt < this` — GIỮ mốc, khác window = 2 tập peer hết so sánh được); 3 predicate theo ①; list order `CreatedAt`.
2. **`Dtos/PurchaseEvaluationDtos.cs`** — `public record PePendingPriorPeDto(Guid Id, string? MaPhieu, int Phase);` (N2 — entity `MaPhieu` nullable `PurchaseEvaluation.cs:10`, FE fallback "(chưa có mã)") + `PeBudgetSummaryDto` append CUỐI sau `BudgetFrozen` (pattern S133 an-toàn): `decimal PendingSubmittedTotal = 0, int PendingSubmittedCount = 0, decimal PendingSelectedTotal = 0, int PendingSelectedCount = 0, IReadOnlyList<PePendingPriorPeDto>? PendingPriorPes = null` — 2 construction site set EXPLICIT: frozen `:946` → `0,0,0,0,null` · live `:975` → fill từ `ComputePendingAsync`.
3. **`PurchaseEvaluationFeatures.cs` nhánh LIVE (`:957-984`)** — gọi `ComputePendingAsync` sau `ComputeAsync`, fill 5 field. Nhánh FROZEN (`:930-955`): defaults.
4. **D4 audit-note — TRONG `ApplyBudgetSnapshotOnFinalizeAsync` (`PurchaseEvaluationWorkflowService.cs:998-1041`), 1 chỗ, TRONG nhánh `WorkItemId != null`:** sau khi set snapshot, gọi `ComputePendingAsync` (1 lần); nếu `PriorPes.Count > 0` → **`.Add(...)`** (sync — pattern codebase `LogTransitionAsync:1145`; KHÔNG `AddAsync`) 1 row với **SHAPE ĐẦY ĐỦ (M1 — KHÔNG thêm param vào signature helper, giữ A6 0-đổi-call-site)**:
   ```csharp
   db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog {
       PurchaseEvaluationId = evaluation.Id,
       EntityType = PurchaseEvaluationEntityType.Workflow,
       Action = ChangelogAction.Update,   // KHÔNG Transition — caller đã ghi row Transition riêng (LogTransitionAsync:1128-1155); 2 row Transition = UI audit hiểu nhầm 2 lần chuyển phase (#49)
       PhaseAtChange = PurchaseEvaluationPhase.DaDuyet,
       UserId = null, UserName = "Hệ thống",   // helper không có actor — audit actor tái dựng từ row Transition cùng batch
       Summary = "Duyệt khi còn phiếu trước cùng gói chưa duyệt",
       ContextNote = "[S134 pending-prior] Duyệt khi N phiếu trước cùng gói chưa duyệt: A/0xx (Đã gửi duyệt), …",
   });
   ```
   KHÔNG SaveChanges trong helper (caller lo — V2/V1 `TransitionAsync:301`, admin-override `:316`). KHÔNG đụng 5 call-site. (Chi phí: ~3 query `ComputePendingAsync` mỗi finalize — N4, chấp nhận; tần suất thấp.)

**FE — 2 app (fe-user + fe-admin):**

5. **types `purchaseEvaluation.ts` ×2 app** (⚠️ 2 file KHÁC nhau `2bf773`≠`ab4a2e` — edit block `PeBudgetSummary` TỪNG app, KHÔNG copy nguyên file): +5 field `pendingSubmittedTotal: number; pendingSubmittedCount: number; pendingSelectedTotal: number; pendingSelectedCount: number; pendingPriorPes: { id: string; maPhieu: string | null; phase: number }[] | null`.
6. **`PeDetailTabs.tsx`** (2-app SHA-mirror `5534addb` — sửa 1 nội dung áp 2 file, `git hash-object` phải IDENTICAL sau sửa):
   - Gate TOÀN BỘ render tạm-tính: `!bs.budgetFrozen && (bs.pendingSubmittedCount > 0 || bs.pendingSelectedCount > 0 || (bs.pendingPriorPes?.length ?? 0) > 0)`.
   - Row 2 (:1503-vùng): sub-dòng **amber** khi `pendingSelectedCount > 0`: `Tạm tính: {fmt(row2 + delta2)} (+{fmt(delta2)} từ {pendingSelectedCount} phiếu chưa duyệt)` — **N8: n = count TƯƠNG ỨNG delta, KHÔNG dùng `pendingPriorPes.length`** (list ⊇ delta-set — dùng nhầm sẽ "từ 3 phiếu" khi delta chỉ từ 1).
   - Row 6 (:1579-1586): sub-dòng amber `row6_tạm = row6 + delta2` (n = pendingSelectedCount).
   - Row 1 (:1498-vùng): sub-dòng amber khi `pendingSubmittedCount > 0` (+delta1, n = pendingSubmittedCount); Row 5 (:1569-1578) per-cột `r5_tạm = r5 + delta1`.
   - Dưới matrix: danh sách pending `{maPhieu ?? "(chưa có mã)"} — badge phase-label màu` — **N3: dùng map sẵn có `PurchaseEvaluationPhaseLabel`** (types cả 2 app; PeWorkflowPanel đang dùng `:465`), màu ChoDuyet=amber · TraLai=orange · Nháp=gray · legacy=amber. **N9: plain-text + badge, KHÔNG render link** (approver click link phiếu Nháp → 403 S89 guard `Features.cs:893-898` → toast lỗi khó hiểu). Số tạm-tính LUÔN kèm nhãn "Tạm tính" — không bao giờ thay số chính xác.
7. **`PeWorkflowPanel.tsx`** (2-app SHA-mirror `a83a58e1` — mirror y vậy):
   - **Banner amber** cạnh V2-banner (:437-vùng) khi `(evaluation.budgetSummary?.pendingPriorPes?.length ?? 0) > 0 && !evaluation.budgetSummary?.budgetFrozen && !readOnly && evaluation.phase === 10 /* ChoDuyet — M2: phiếu Nháp/TraLai/TuChoi đi nhánh live cũng có pending fill, banner "cần duyệt phiếu trước" chỉ có nghĩa khi phiếu ĐANG chờ duyệt */`: `⚠ {n} phiếu trước cùng gói thầu CHƯA duyệt: A/012 (Đã gửi duyệt), … — cần duyệt phiếu trước trước phiếu này` (n = `pendingPriorPes.length` — đây là LIST, đúng nghĩa banner).
   - **Block NHẮC LẠI trong Dialog duyệt** khi `isApproveAction` (vùng :509-514, TRÊN bộ chọn giá — mọi cấp duyệt thấy; bước cuối `shouldPickPrice :510` là tập con nên chắc chắn thấy trước "Xác nhận"). **KHÔNG disable nút Xác nhận** (D3).

**KHÔNG đụng:** `ComputeAsync` · snapshot 11 cột · Mig 67 backfill · frozen branch số cũ · submit-guard · HĐ GiaTri · multi-winner derive (#81) · endsBeforeCeo.

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

**implementer-backend (IMPL-A):**
- [ ] A1 `ComputePendingAsync` + 2 record mới; acceptance: `git diff` vùng `ComputeAsync` thân method = **0 dòng đổi**; `dotnet build SolutionErp.slnx` 0 error/0 warning mới.
- [ ] A2 DTO append-cuối + 2 construction site explicit; acceptance: grep `PendingPriorPes` đúng 2 site set + build xanh.
- [ ] A3 Live-branch fill; acceptance: **dựng data trước — Dev DB hiện 0 cặp multi-phiếu (N5): tạo 2 phiếu cùng Dự án × Hạng mục (qua API/UI hoặc seed test), phiếu A ChoDuyet + winner + quote IsSelected** → GET phiếu B: JSON `pendingSelectedTotal` = SUM IsSelected của A, `pendingPriorPes` chứa `{maPhieu A, phase:10}`.
- [ ] A4 Frozen → defaults; acceptance: phiếu DaDuyet+SnapshotAt → `pendingPriorPes` null/[], 4 số = 0, JSON số chính-xác Y NGUYÊN trước-sau (R1-S133).
- [ ] A5 KHÔNG migration; acceptance: `git status` — `ApplicationDbContextModelSnapshot.cs` + `Migrations/` **0 file đổi**.
- [ ] A6 D4 changelog-note 1-chỗ-trong-helper; acceptance: finalize phiếu có pending → +1 row `PurchaseEvaluationChangelogs` ContextNote chứa `[S134 pending-prior]` + mã phiếu; finalize KHÔNG pending → 0 row mới loại này; **0 đổi 5 call-site** (`git diff` các vùng `:311/:865/:904/:937/:1112` chỉ được phép 0-dòng).
**implementer-frontend (IMPL-B, ×2 app):**
- [ ] B1 types ×2; acceptance: `grep -c pendingPriorPes fe-user/src/types/purchaseEvaluation.ts fe-admin/src/types/purchaseEvaluation.ts` = 1 mỗi file.
- [ ] B2 PeDetailTabs sub-rows + list + gate frozen; acceptance: `git hash-object` 2 file IDENTICAL; string "Tạm tính" xuất hiện trong source; phiếu frozen KHÔNG render (gate đầu nhánh).
- [ ] B3 PeWorkflowPanel banner + dialog-block; acceptance: hash 2 file IDENTICAL; nút Xác nhận KHÔNG thêm điều kiện disable (`git diff` vùng disabled-condition = 0).
- [ ] B4 `npm run build` fe-user + fe-admin đều PASS.
**test-specialist (TEST-C — pattern seed `PeBudgetFreezeTests.cs:277-306/:311-332`, CreatedAt override thủ công `:332`):**
- [ ] C1 peer ChoDuyet+IsWinner+IsSelected → delta2 = tổng ThanhTien; có mặt trong PriorPes.
- [ ] C2 peer TuChoi → delta 0 + KHÔNG trong list; peer DaDuyet → delta 0 (bên chính xác) + KHÔNG trong list.
- [ ] C3 peer Nháp → TRONG list, delta tiền = 0 (D1); peer TraLai có BudgetPeriodAmount → delta1 đúng (D2).
- [ ] C4 peer-window: peer `CreatedAt > this` → loại hoàn toàn (mirror `:435-451`).
- [ ] C5 quote ÂM trong delta2 (signed-sum S116).
- [ ] C6 REGRESSION: `ComputeAsync` số cũ Y NGUYÊN khi thêm peer non-terminal (chính-xác không đổi).
- [ ] C7 frozen phiếu → DTO pending 0/empty.
- [ ] C8 D4: finalize có pending → changelog row đúng format; không pending → không row.
- [ ] C9 `dotnet test SolutionErp.slnx` FULL PASS (520 + N mới, 0 fail).

**Phân rã hmw staged:** INV xong (sub-file) → **INV-2 = [IMPL-A ‖ IMPL-B]** (song song — DTO shape + JSON field names chốt sẵn trong spec) → **INV-3 = [TEST-C]** (sau A land). Lead verify giữa các invocation + reviewer pre-commit cuối.

## Edge cases (phủ trong implement + test)
1. WorkItemId null → `budgetSummary` null toàn cục (gate `:912`) — tạm-tính không tồn tại, banner "gắn hạng mục" giữ.
2. Multi-winner D4/IsSelected: predicate mirror `:50-60` — 0 quy ước mới.
3. Giá âm: SUM signed nguyên trạng, delta có thể âm — render như mọi số tiền.
4. Min–Max/ApprovedPrice: KHÔNG feed lũy kế — orthogonal.
5. PRO-split/CcmBudgetPeriodAmount: giữ nguyên quy ước row1 = `BudgetPeriodAmount` only (drift-vector sẵn có S133 — NGOÀI scope).
6. Own-phase terminal/nháp (TuChoi/TraLai/DangSoanThao): matrix VẪN hiển thị tạm-tính (thông tin); **banner PeWorkflowPanel ẨN bằng gate tường minh `evaluation.phase === 10` (M2 — KHÔNG tự ẩn; phiếu non-ChoDuyet vẫn có pending fill từ nhánh live)**; dialog-block tự gate qua `isApproveAction`.
7. Legacy phase 2-6 còn row sống (Dev {3,6}): predicate loại-trừ tự cover.
8. **N7 (pre-existing, ghi nhận để không ngạc nhiên UAT):** phiếu DaDuyet mà SnapshotAt null (`Features.cs:926-928` fallback defense-in-depth) → đi nhánh live → tạm-tính HIỆN trên phiếu DaDuyet dị dạng — sau backfill Mig 67 + 5 site + seeder, lý thuyết 0 row loại này; KHÔNG fix trong scope.
