Files
solution-erp/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/spec-pe-luyke-tam-tinh-17-07-2026.md
2026-07-17 14:27:19 +07:00

14 KiB
Raw Blame History

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 2bf773ab4a2e) → 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.cspublic 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 :9460,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):
    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):

  1. types purchaseEvaluation.ts ×2 app (⚠️ 2 file KHÁC nhau 2bf773ab4a2e — 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.
  2. 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àuN3: 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.
  3. 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 statusApplicationDbContextModelSnapshot.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. MinMax/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.