Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 KiB
SPEC — PE lũy kế TẠM TÍNH vs CHÍNH XÁC (17-07-2026, run S134)
Engine:
/fable-real investigator-codebasepropose (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 IDENTICAL5534addb, PeWorkflowPanel IDENTICALa83a58e1, types KHÁC2bf773≠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(quoteIsSelectedThanhTien) (: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 đụngComputeAsync= 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) = peersPhase NOT IN (7, 99)→{Id, MaPhieu, Phase}order CreatedAt.pendingSelectedTotal/Count(delta row 2/6) = peersPhase NOT IN (1, 7, 99)&&Suppliers.Any(IsWinner)→ Count + SUM(quote IsSelected ThanhTien) — mirror shape:50-60.pendingSubmittedTotal/Count(delta row 1/5) = peersPhase 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:
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 orderCreatedAt.
Dtos/PurchaseEvaluationDtos.cs—public record PePendingPriorPeDto(Guid Id, string? MaPhieu, int Phase);(N2 — entityMaPhieunullablePurchaseEvaluation.cs:10, FE fallback "(chưa có mã)") +PeBudgetSummaryDtoappend CUỐI sauBudgetFrozen(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.PurchaseEvaluationFeatures.csnhánh LIVE (:957-984) — gọiComputePendingAsyncsauComputeAsync, fill 5 field. Nhánh FROZEN (:930-955): defaults.- D4 audit-note — TRONG
ApplyBudgetSnapshotOnFinalizeAsync(PurchaseEvaluationWorkflowService.cs:998-1041), 1 chỗ, TRONG nhánhWorkItemId != null: sau khi set snapshot, gọiComputePendingAsync(1 lần); nếuPriorPes.Count > 0→.Add(...)(sync — pattern codebaseLogTransitionAsync:1145; KHÔNGAddAsync) 1 row với SHAPE ĐẦY ĐỦ (M1 — KHÔNG thêm param vào signature helper, giữ A6 0-đổi-call-site):KHÔNG SaveChanges trong helper (caller lo — V2/V1db.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), …", });TransitionAsync:301, admin-override:316). KHÔNG đụng 5 call-site. (Chi phí: ~3 queryComputePendingAsyncmỗi finalize — N4, chấp nhận; tần suất thấp.)
FE — 2 app (fe-user + fe-admin):
- types
purchaseEvaluation.ts×2 app (⚠️ 2 file KHÁC nhau2bf773≠ab4a2e— edit blockPeBudgetSummaryTỪNG app, KHÔNG copy nguyên file): +5 fieldpendingSubmittedTotal: number; pendingSubmittedCount: number; pendingSelectedTotal: number; pendingSelectedCount: number; pendingPriorPes: { id: string; maPhieu: string | null; phase: number }[] | null. PeDetailTabs.tsx(2-app SHA-mirror5534addb— sửa 1 nội dung áp 2 file,git hash-objectphả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ùngpendingPriorPes.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ộtr5_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 guardFeatures.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.
- Gate TOÀN BỘ render tạm-tính:
PeWorkflowPanel.tsx(2-app SHA-mirrora83a58e1— 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ốishouldPickPrice :510là 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).
- Banner amber cạnh V2-banner (:437-vùng) khi
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 diffvùngComputeAsyncthân method = 0 dòng đổi;dotnet build SolutionErp.slnx0 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,pendingPriorPeschứa{maPhieu A, phase:10}. - A4 Frozen → defaults; acceptance: phiếu DaDuyet+SnapshotAt →
pendingPriorPesnull/[], 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
PurchaseEvaluationChangelogsContextNote 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 diffcác vùng:311/:865/:904/:937/:1112chỉ đượ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-object2 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 diffvùng disabled-condition = 0). - B4
npm run buildfe-user + fe-admin đều PASS. test-specialist (TEST-C — pattern seedPeBudgetFreezeTests.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:
ComputeAsyncsố 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.slnxFULL 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)
- WorkItemId null →
budgetSummarynull toàn cục (gate:912) — tạm-tính không tồn tại, banner "gắn hạng mục" giữ. - Multi-winner D4/IsSelected: predicate mirror
:50-60— 0 quy ước mới. - Giá âm: SUM signed nguyên trạng, delta có thể âm — render như mọi số tiền.
- Min–Max/ApprovedPrice: KHÔNG feed lũy kế — orthogonal.
- PRO-split/CcmBudgetPeriodAmount: giữ nguyên quy ước row1 =
BudgetPeriodAmountonly (drift-vector sẵn có S133 — NGOÀI scope). - 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 quaisApproveAction. - Legacy phase 2-6 còn row sống (Dev {3,6}): predicate loại-trừ tự cover.
- N7 (pre-existing, ghi nhận để không ngạc nhiên UAT): phiếu DaDuyet mà SnapshotAt null (
Features.cs:926-928fallback 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.