From 6664a6136aadae79d7b70aface925694c0740e49 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 17 Jul 2026 14:27:19 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation:=20luy=20ke=20TAM?= =?UTF-8?q?=20TINH=20vs=20CHINH=20XAC=20=E2=80=94=20ComputePendingAsync=20?= =?UTF-8?q?+=205-field=20DTO=20+=20FE=202-app=20sub-dong=20amber/banner=20?= =?UTF-8?q?man=20duyet=20+=20D4=20audit-note=20(UAT=20anh=20Kiet=2015-07,?= =?UTF-8?q?=200=20mig,=20test=20520=E2=86=92528)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .claude/WAL.md | 29 +- .../implementer-backend/MEMORY.md | 2 + .../implementer-frontend/MEMORY.md | 2 + .claude/agent-memory/reviewer/MEMORY.md | 31 +- .../2026-07-17-S134-pe-luyke-tam-tinh/run.md | 32 ++ .../spec-pe-luyke-tam-tinh-17-07-2026.md | 109 ++++ .../sub-implementer-backend-0.md | 35 ++ .../sub-implementer-frontend-1.md | 42 ++ .../sub-investigator-codebase-0.md | 157 ++++++ .../sub-reviewer-0.md | 66 +++ .../sub-test-specialist-0.md | 43 ++ fe-admin/src/components/pe/PeDetailTabs.tsx | 68 ++- .../src/components/pe/PeWorkflowPanel.tsx | 26 + fe-admin/src/types/purchaseEvaluation.ts | 7 + fe-user/src/components/pe/PeDetailTabs.tsx | 68 ++- fe-user/src/components/pe/PeWorkflowPanel.tsx | 26 + fe-user/src/types/purchaseEvaluation.ts | 7 + .../Dtos/PurchaseEvaluationDtos.cs | 15 +- .../PeBudgetAccumulator.cs | 68 +++ .../PurchaseEvaluationFeatures.cs | 20 +- .../PurchaseEvaluationWorkflowService.cs | 30 ++ .../Services/PePendingAccumulationTests.cs | 497 ++++++++++++++++++ 22 files changed, 1324 insertions(+), 56 deletions(-) create mode 100644 .claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/run.md create mode 100644 .claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/spec-pe-luyke-tam-tinh-17-07-2026.md create mode 100644 .claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-implementer-backend-0.md create mode 100644 .claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-implementer-frontend-1.md create mode 100644 .claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-investigator-codebase-0.md create mode 100644 .claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-reviewer-0.md create mode 100644 .claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-test-specialist-0.md create mode 100644 tests/SolutionErp.Infrastructure.Tests/Services/PePendingAccumulationTests.cs diff --git a/.claude/WAL.md b/.claude/WAL.md index 0caffc6..0a1badb 100644 --- a/.claude/WAL.md +++ b/.claude/WAL.md @@ -1,20 +1,21 @@ # WAL — auto-generated, không sửa tay -updated: 2026-07-17 | session: S133 | branch: main -goal: PE budget FREEZE-at-DaDuyet (UAT anh Kiệt 07-16): phiếu DaDuyet bất biến NS; pipeline invest → spec → implement → review → deploy +updated: 2026-07-17 (S134 — mạch MỚI: PE lũy kế tạm tính; invest phóng) | session: S134 | branch: main +goal: PE lũy kế 2 số "TẠM TÍNH (tất cả phiếu, note màu phần chưa-duyệt) vs CHÍNH XÁC (chỉ DaDuyet — giữ S117)" — anh Kiệt chốt "Vậy em xử lý theo hướng này đi"; pipeline anh chỉ định: fable-real invest + review → hmw Opus 4.8 MAX implement. (Mạch S133 freeze-NS ĐÃ ĐÓNG trọn — xem runs/2026-07-17-S133-*/close-synthesis.md; 4 commit local chờ session-end squash+push, nay 5 kể cả docs de64bf2.) chain: -[x] 1. fable-real invest — wf_348f6155-167 (#53 → disk 22.5KB cứu, verify 7/7) -[x] 2. spec + errata — runs/2026-07-17-S133-pe-budget-freeze-daduyet/spec-pe-budget-freeze-daduyet-17-07-2026.md -[x] 3. INV-A — wf_d9639faa-97a: T0 RED thật @:184 · FE 4 file hash 5534addb ×2 · 0-garble -[x] 4. INV-B — wf_4bef08b8-730: lane sửa 6 file + accumulator XONG, #53 chết trước mig → em-main solo Mig 67 (11 AddColumn + backfill OUTER APPLY) + apply 2 DB (Dev catch-up cả Mig 66 lag) + filter 44/44 T0 GREEN -[!] 5. wf: hmw INV-C 1-task test-specialist (T1-T9 vào PeBudgetFreezeTests + full suite ≥510+N 0-fail) — wf_75c6bfd3-754 -[ ] 6. reviewer pre-commit (mig+financial+3-bản-predicate) → em main npm build ×2 + commit push → cicd-monitor verify prod -[ ] 7. docs closeout (STATUS Mig 67 · HANDOFF · session-log) + harvest còn lại +[x] 1. fable-real invest — #53 garble return NHƯNG sub-file 8-phần CỨU TRỌN · evidence: runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-investigator-codebase-0.md (157 dòng, STATUS: COMPLETE) +[x] 2. lead verify 7/7 claim (accumulator :38-60 + frozen-gate :929 + enum + SHA ×3 cặp + DTO :331-353) + 2 errata (path Dtos/ + hash-value) + chốt D1-D5 · evidence: spec-pe-luyke-tam-tinh-17-07-2026.md (3-heading) +[x] 3. fable-real reviewer — #53 garble return, sub-file CỨU (lần 3 liên tiếp) · VERDICT GO_WITH_FIXES 0C/2M/9n · lead áp TRỌN vào spec v2 (M1 shape changelog Action=Update + M2 banner gate phase===10 + N1-N9) · evidence: sub-reviewer-0.md 67 dòng + spec diff +[x] 4. hmw INV-2 wf_58302bd5-b9f [IMPL-A ‖ IMPL-B] — 2/2 done 0-garble · A: 4 file BE +ComputePendingAsync+DTO+D4 (build 0/0, ComputeAsync 0-diff, insertion-only) · B: 6 file FE (hash d28eaa16+bebc0ada ×2 IDENTICAL, npm ×2 PASS, M2 enum-gate) · lead re-verify độc-lập: scope 10 file + hash + grep D4/M2/N8 KHỚP · harvest C4 2 vai APPENDED · evidence: sub-implementer-backend-0.md + sub-implementer-frontend-1.md (hmw index GLOBAL theo taskList — hết collision S133) +[x] 5a. re-verify build 0-err + filter PeBudgetFreeze 11/11 (INV-2 không vỡ freeze) +[x] 5b. hmw INV-3 wf_97d6a582-b7e [TEST-C] — 1/1 done 0-garble · +8 test PePendingAccumulationTests C1-C8 (C6 complementary-no-double-count exact-numbers) · full 528/0 (45D+483I) · NO prod bug · evidence: sub-test-specialist-0.md +[!] 5c. reviewer pre-commit wf_8bb5abee-657 (ghi sub-reviewer-precommit.md) ‖ lead full-suite độc-lập (bg br7adpgua, kỳ vọng 528/0) — cả 2 đang chạy +[ ] 5. test full + npm ×2 + reviewer pre-commit → commit + push + cicd verify +[ ] 6. docs closeout -next: chờ INV-C return → verify full-suite count → reviewer +next: chờ return invest → verify claims (spot-check file:line) → ghi spec (mục 2) verify: -- dotnet test SolutionErp.slnx --nologo (0 fail, tổng = 510 + test mới INV-C) -- git status --porcelain (Migrations 3 file mới + BE 6 M + accumulator + tests + FE 4 M) -- grep -c "ApplyBudgetSnapshotOnFinalizeAsync" src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs = 5 -- sqlcmd Dev: PurchaseEvaluations Phase=7 → chua_snapshot=0 (đã chạy: 1/0 ✓) +- ls .claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/ # run.md + sub-investigator-codebase-0.md (khi invest ghi) +- git log --oneline -1 # de64bf2 (docs S133) hoặc wal: mới hơn +- grep -n "TẠM TÍNH" .claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/run.md # goal đúng mạch diff --git a/.claude/agent-memory/implementer-backend/MEMORY.md b/.claude/agent-memory/implementer-backend/MEMORY.md index 94e439c..ba55372 100644 --- a/.claude/agent-memory/implementer-backend/MEMORY.md +++ b/.claude/agent-memory/implementer-backend/MEMORY.md @@ -1,5 +1,7 @@ # Implementer-Backend Agent — Persistent Memory +- **S134 (07-17) PE lũy kế TẠM TÍNH BE [wf_58302bd5-b9f INV-2.A · return sạch 0-garble] `[engine: opus-worker]`:** +`ComputePendingAsync` + `PePendingAccumulation` (PeBudgetAccumulator.cs pure-add +68/0) + `PePendingPriorPeDto` + `PeBudgetSummaryDto` append-cuối 5 field (2 construction site: `:946` frozen-defaults 0/0/0/0/null · `:985` live-fill) + D4 audit-note TRONG `ApplyBudgetSnapshotOnFinalizeAsync` (Action=**Update** KHÔNG Transition — #49 tránh UI đọc 2 lần chuyển-phase; insertion-only +30/0; signature GIỮ → 5 call-site 0-diff, site cuối line-shift :1112→:1142 content bất động). Build slnx 0/0 · 0 mig · ComputeAsync body 0-diff (A1). Learned: (1) **insertion-only diff (grep `'^-'` = 0 real-removal) = bằng-chứng MẠNH NHẤT "thân method/call-site bất động"** — mạnh hơn đọc-mắt, chống báo-số-sai kiểu #53; (2) derived-audit thêm vào helper KHÔNG cần đổi signature nếu tính nội bộ từ field entity sẵn có (giữ call-site 0-diff); (3) accumulator ns ≠ DTO ns (`...Dtos`) → cần using tường minh dù cùng project. Tag `[s134, pe-luyke-tam-tinh, computepending, insertion-only-proof, d4-changelog-update-not-transition]` + - **S133 (07-17) PE budget-freeze BE [wf_4bef08b8-730 INV-B · #53 return-fail → disk-truth + em-main-solo hoàn tất; entry on-behalf]:** lane sửa XONG 6 file + `PeBudgetAccumulator.cs` mới (Domain 11 props ApprovedBudget* + Config precision/maxlength + DTO append `BudgetFrozen=false` cuối positional-record + display-gate frozen/live 2-nhánh trong GetPurchaseEvaluationQuery + helper `ApplyBudgetSnapshotOnFinalizeAsync` gọi 4-site `:862/:901/:934/:1109` + 2 terminal-guard block-CẢ-Admin) rồi CHẾT TRƯỚC bước sinh migration → em-main solo: scaffold Mig 67 sạch 11 AddColumn (0 RenameColumn #63) + backfill `Sql()` OUTER APPLY ×3 khớp accumulator TỪNG điều kiện (Suppliers/Quotes = BaseEntity KHÔNG soft-delete filter → SQL không thêm IsDeleted trên s/q; WorkItemId-null → 4 lũy kế NULL mirror helper, SnapshotAt vẫn set) + apply 2 DB — **Dev lòi drift Mig 66 committed-chưa-apply-local, catch-up luôn** — + filter test 44/44 (T0 RED→GREEN không sửa test). Phát hiện đáng giữ của lane: `TransitionAsync` KHÔNG SaveChanges nội bộ `:537` → snapshot flush qua SaveChanges của caller (mirror ApprovedPrice pattern); khuôn backfill THẬT Mig 61/65 = plain `Sql()` KHÔNG C# provider-guard (SQLite EnsureCreated không replay = guard kiến trúc). Tag `[s133, pe-budget-freeze, mig67, 53-writelane-disk-recover]` > **Persistent diary cross-session.** Auto-injected first ~200 lines at spawn (L1 HOT). diff --git a/.claude/agent-memory/implementer-frontend/MEMORY.md b/.claude/agent-memory/implementer-frontend/MEMORY.md index 4074639..ca6ba03 100644 --- a/.claude/agent-memory/implementer-frontend/MEMORY.md +++ b/.claude/agent-memory/implementer-frontend/MEMORY.md @@ -1,5 +1,7 @@ # Implementer-Frontend Agent — Persistent Memory +- **S134 (07-17) PE lũy kế TẠM TÍNH FE 2-app [wf_58302bd5-b9f INV-2.B · return sạch 0-garble] `[engine: opus-worker]`:** types ×2 +5 field `pending*` (DIVERGE — edit riêng từng app cùng old_string sau `budgetFrozen`; grep 1/1) · PeDetailTabs `5534addb`→**`d28eaa16`** ×2 IDENTICAL: `BudgetSharedNumCell:1184`+`BudgetColValue:1197` +**`sub?: ReactNode` prop** → sub-dòng amber "Tạm tính" row1/2 (withDetail "+X từ N phiếu chưa duyệt", N8 count-TƯƠNG-ỨNG delta KHÔNG list.length) + row5/6 (gọn) + block liệt kê `pendingPriorPes` dưới matrix (plain+Badge `PurchaseEvaluationPhaseLabel`, N9 KHÔNG link — 403 S89) + gate `showPending = !bs.budgetFrozen && (...)` `?? `-guard data cũ · PeWorkflowPanel `a83a58e1`→**`bebc0ada`** ×2: banner M2-gate `!readOnly && phase === PurchaseEvaluationPhase.ChoDuyet` (enum-constant) + reminder trong Dialog `isApproveAction` TRÊN price-picker; disabled-condition 0-diff (B3 grep). npm ×2 PASS (user `index-BVoJX7QA` 20.9s · admin `index-mcHk0pmo` 9.3s). Learned: (1) **`sub?: ReactNode` slot-prop trên cell-component dùng chung = chèn sub-dòng vào N render-site không nhân bản markup**; (2) hmw đánh index sub-file GLOBAL theo taskList (backend=0, frontend=1 → `sub-implementer-frontend-1.md`) — hết collision S133 khi 2 role KHÁC nhau cùng invocation. Tag `[s134, pe-luyke-tam-tinh, sub-slot-prop, n8-count-per-delta, global-tasklist-index]` + - **S133 (07-17) PE budget-freeze FE types+badge [wf_d9639faa-97a INV-A, return sạch]:** 4 file — types 2-app +`budgetFrozen: boolean` (fe-user `:333` · fe-admin `:331` — 2 file KHÔNG mirror nhưng block PeBudgetSummary nội-dung identical → 1 old_string dùng chung cả 2); PeDetailTabs 2-app byte-identical: badge amber `🔒 Ngân sách chốt tại thời điểm duyệt` @ header `PeBudgetSummaryTable` `:1350-1359` (div `bg-[#1F7DC1]` → +flex justify-between; `normal-case tracking-normal` OVERRIDE `uppercase tracking-wide` của header; non-frozen = 1 span trong flex → appearance giữ nguyên). Gotcha mới đáng nhớ: className header LẶP 2 chỗ (`:1350` budget + `:1931` winners) → Edit anchor PHẢI kèm text visible "Tổng hợp ngân sách trình ký" mới unique. Hash sau sửa `5534addb` ×2 = mirror giữ; npm build ×2 PASS. Tag `[s133, budgetfrozen-badge, anchor-by-visible-text]` > **Persistent diary cross-session.** Auto-injected first ~200 lines at spawn (L1 HOT). diff --git a/.claude/agent-memory/reviewer/MEMORY.md b/.claude/agent-memory/reviewer/MEMORY.md index ec899a9..a4a2dab 100644 --- a/.claude/agent-memory/reviewer/MEMORY.md +++ b/.claude/agent-memory/reviewer/MEMORY.md @@ -1,5 +1,7 @@ # Reviewer Agent — Persistent Memory +- **[→ archive/2026-07.md @S134] S133 (07-17) pre-commit review pe-budget-freeze — PASS_WITH_FIXES 0MUST/2SHOULD:** ⭐ literal-grep 4-site enum PASS hết nhưng grep `.Phase = ` lòi 2 đường GIÁN TIẾP bypass helper (`WorkflowService:308` admin-override `= targetPhase` + `DbInitializer:1323` seeder). **Bài: RULE "mọi nhánh set X" grep CẢ assignment-qua-BIẾN — #81 lớp indirect-assignment.** Lead áp 2 SHOULD trong phiên → suite 520/0. Tag `[s133, indirect-assignment-grep, 81-class]` + > **Persistent diary cross-session.** Auto-injected first ~200 lines at spawn (L1 HOT). > Update BEFORE every stop. Tiered Memory v1: L1 HOT cap ~17KB (hook 24.4KB read-limit) · L2 `archive/` on-demand · L3 RAG `search_memory` just-in-time. Keep entry ≤ 1.5K chars (gotcha #53). > Full verbatim history pre-S40 → git `d2f52ba` + `archive/2026-05-q1..q2.md`; S51→S76 detail → `archive/2026-06.md` (S69/S70/S71/**S80** curate). Archive map: `archive/_INDEX.md` + per-period `.gist.md`. @@ -70,14 +72,13 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod ## 📅 Recent activity (compressed — full verbatim → `archive/2026-06.md` + `archive/2026-07.md` via `archive/_INDEX.md`) -- **S131b (07-17) GATE#2 outward email-hub closure-cadence follow-up (owner-direction narrate, KHÔNG claim đo mới) — PASS_WITH_FIXES (0C/1M/3m):** owner-fidelity vs 2-lượt-chat: "owner tự chỉ ra trade-off" phương-án-A = ĐÚNG (turn-1 owner nói CẢ pro 'giảm nặng start/end' + con 'khó theo dõi hơn' — reviewer NGHI bịa nhưng thật-sự faithful, KHÔNG manufacture) · 4 tiêu-chí trực-tiếp turn-2. 🎯 **M1: "nghi-thức chạy tắt HÀNG LOẠT" inflate source chính-xác "3 closeout liên tiếp"/3-session** (morning e46863c8 GAP-2) — re-introduce đúng overclaim-class mà morning-gate ĐÃ gỡ (2 fencepost/overclaim); follow-up outward tới bounded-source phải GIỮ con-số của source, "hàng loạt" undo kỷ-luật source đã trả-giá. m2: title quote "không **sót**" nhưng owner viết "ko **xót**" (x) — silent spelling-normalize + gloss "im lặng phải khác sạch" commit 1 reading của homophone mập-mờ; interpret probably-correct NHƯNG gate-prompt CŨNG lặp "sót" ⇒ divergence sót upstream = independent-catch. m3: paren-gloss 4 tiêu-chí trộn framework-lead vào section "Owner nhận-định" (hedged caveat-3). Số verify sạch: 250-300K=(ước lượng vận hành)✓ · 6/15 owner-decisions-file+memory-budget:122-123 ⇒ 'số owner đặt' ĐÚNG · eval~0 memory-budget:44 'deterministic NO-API analyzer' ✓. Caveat-block 3-điểm mạnh (chưa-chốt/est-spawn/SE-diễn-giải). Lesson: **verbatim-quote owner = so TỪNG CHỮ kể cả homophone x/s; "đã đo" outward chỉ pass khi trỏ disk-measure thật (morning email có).** -- **S131 (07-17) GATE outward email-hub + adap-report §6 báo 3 op-gap H24×H22×H1/H2 — PASS_WITH_FIXES (0C/2M-shared-root/3m):** → [topic](project_s131_h24_h22_3gap_outward_gate.md). MỌI số atomic reproduce (counter=7·S124=3·S128/129/130 no-tick·tick chỉ session-start §2.1.8 [session-end §L.b(j) chỉ ĐỌC]·12 measured·9+4+1 flag·§5(a) flip đúng). 🎯 **Bắt fencepost khi số atomic ĐÚNG HẾT:** "8 nhãn/+4 (3→7)" ghép lệch cột-mốc (8 nhãn↔+5 baseline post-S123=2; +4↔7 nhãn baseline S124=3) → đọc 8−4=4-miss SAI (thật 3) → đẻ "gấp-đôi" 2× overclaim (đo thật 8:5=1.6×). Lesson: hero-ratio phái-sinh re-derive từ raw, đừng tin phép-nhân tác-giả; fencepost = liệt-kê từng nhãn + đếm tay. -- **S129 (07-16 đêm) REVIEW adap-errata-EOL 2-lane `wf_8a0249f6` — GO-WITH-FIXES ×2:** R0 re-verify **byte-exact** T0/T1 + bắt P1 writer-only = **NỬA-VÁ** (reader `mfe-eval.ps1:193` là read duy-nhất thiếu `-Encoding UTF8`, đang BOM-sniff; blob mang BOM thật `ef bb bf` — S87 consumer-sweep áp cho cả ĐỀ-XUẤT-VÁ, không chỉ code) + 2 presentation-gap (blob-mutation không khai · lợi-ích thổi khi porcelain vốn rỗng — S81 class). R1 adversarial: 4/4 cite exact + 2 quick-test **tự chạy lại = chứng không-bịa rẻ nhất**; tally "7/7 ADOPTED" = rung-flatten 4-gated/3-discipline (S122 memory≠enforcement); story T0 "+1 agent mới" BỊA (delta thật = `skills/README.md` ngoài glob, số scoped=39); on-behalf verbatim 0-miss (22/22+41/41). Cả 2 lane #53 khai-path-không-ghi → em-main scribe từ journal. Tag `[s129, review-adap-errata, half-fix-writer-only, rung-flatten, byte-exact-rerun]` -- **[→ archive/2026-07.md @S128] S124 L2 fidelity-to-source + adap-wave plan — PWF (1 MAJOR):** push-guard "REFINEMENT ahead-of-broadcast" = OVERCLAIM (looser ≠ ahead); spec + adap-report = shipped artifact → reconcile trước hub-send; adap-reports ở docs/governance/ (path-trap false-empty). -- **[→ archive/2026-07.md @S126] S124 adap-wave-reply PLAN L3-lens — PASS_WITH_FIXES (0C/3M/2m):** 🎯 do-token trap bắt WHILE adopting (chunk 26075 mislabeled whole-file 70892, 2.72× under) + M2 meta-count + M3 nấc-verified-on-NEVER-RAN. Lesson: verify do-token = re-Read full + tự đọc truncation-line. -- **[→ archive/2026-07.md @S126] S125 do-token RELABEL (em-main B3):** negative-control 3-probe ⇒ notice-N = **CẬN-TRÊN heuristic, KHÔNG real-token**; dải STATUS [31K byte/4 — 70,9K notice]. Lesson: nhãn "real-N" tự nó = mislabel — negative-control (chuỗi lặp) mới phân-biệt heuristic/tokenizer. -- **[→ archive/2026-07.md @S126] S125 fable-real adap-review — PASS-WITH-SELF-FIXES [engine:fable-real inline-lead]:** 2 near-miss tự bắt: false-TAMPER→RCA **verifier-bệnh** (PS5.1 ANSI-decode; sibling-test 2-CHIỀU: sibling-MATCH⇒file-bệnh · sibling-FAIL⇒verifier-bệnh) · suýt claim-sai nguồn outward→mở ĐÚNG file+dòng trước khi viết; stamp-then-edit = sửa→RE-STAMP `58f5afd8`→re-selftest cùng lượt. Bẫy Edit-tool escape-BOM→build-from-codepoint. Evidence: `runs/2026-07-16-S125-fable-real-adap-review/`. -- **S123 governance 4-change review — PWF (3 must/1 MAJOR/3m):** → [topic](project_s123_governance_4change_review.md). 🔴 **#53 garble tại TAO; đĩa KHÔNG cứu vì chưa ghi diary ⇒ ghi diary TRƯỚC return.** Vá-1-lớp⇒grep MỌI matcher cùng-lớp TRONG diff · đổi-format-cho-trượt-mẫu = Goodhart rời-tập-đo. +- **S134 (07-17) SPEC-review (pre-code) PE lũy-kế tạm-tính [wf S134, ghi-đĩa sub-reviewer-0.md] — GO_WITH_FIXES 0C/2M/9N:** 🎯 M1: D4 audit-note "trong helper, KHÔNG đụng 5 call-site" nhưng helper KHÔNG có actor param & changelog row cần UserId/UserName/EntityType/Action — spec thiếu shape ⇒ hmw tự thêm param = VỠ CHÍNH acceptance A6 của spec. **Bài: spec đặt ràng-buộc "không đổi X" phải tự kiểm mọi field bắt buộc của việc-mới có lấy đủ nguyên-liệu trong X-nguyên-trạng không.** M2: predicate banner không gate readOnly/phase nhưng edge-note claim "tự ẩn khi hết action" — claim-hành-vi FE phải trace về đúng biến trong predicate, không tin câu văn. Positive: SHA 6-file tự đo khớp lead-errata 100% (số investigator stale `47c97df1`→`5534addb` — 2 nguồn đo lệch ⇒ TỰ ĐO là trọng tài); seeder direct-set nên D4 seed-safe (#75-class clear); grep render-site 2-app xác nhận spec liệt đủ 7 site (S89-class không tái diễn). Tag `[s134, spec-review, constraint-self-consistency, banner-gate]` + +- **[→ archive/2026-07.md @S134] S131b GATE#2 outward follow-up — PWF 1M:** follow-up bounded-source phải GIỮ con-số source ("hàng loạt" re-inflate "3 closeout" = re-introduce class morning-gate đã gỡ) · verbatim-quote owner so TỪNG CHỮ kể cả homophone x/s. +- **[→ archive/2026-07.md @S134] S131 GATE outward 3-op-gap — PWF:** → [topic](project_s131_h24_h22_3gap_outward_gate.md). Số atomic đúng HẾT vẫn fencepost (ghép lệch cột-mốc đẻ "gấp-đôi" 2× khi thật 1.6×) — hero-ratio re-derive từ raw + đếm tay từng nhãn. +- **[→ archive/2026-07.md @S134] S129 adap-errata-EOL 2-lane — GO-WITH-FIXES ×2:** writer-only = NỬA-VÁ (consumer-sweep áp cho cả ĐỀ-XUẤT-VÁ) · tally "7/7 ADOPTED" = rung-flatten · quick-test tự chạy lại = chứng không-bịa rẻ nhất · story-delta BỊA bắt bằng glob-scope. +- **[→ archive/2026-07.md @S128/@S126] S124 ×2 + S125 ×2 (digest):** S124-L2: push-guard "ahead-of-broadcast" OVERCLAIM (looser ≠ ahead) · S124-wave: do-token trap bắt WHILE adopting — verify = re-Read full + tự đọc truncation-line · S125-relabel: notice-N = CẬN-TRÊN heuristic, negative-control chuỗi-lặp mới phân-biệt heuristic/tokenizer · S125-fable-real: false-TAMPER → RCA verifier-bệnh (sibling-test 2-CHIỀU: sibling-MATCH⇒file-bệnh · sibling-FAIL⇒verifier-bệnh); stamp-then-edit = re-stamp + re-selftest cùng lượt. +- **[→ archive/2026-07.md @S134] S123 governance 4-change — PWF:** → [topic](project_s123_governance_4change_review.md). 🔴 #53 garble tại TAO ⇒ ghi diary TRƯỚC return · vá-1-lớp ⇒ grep MỌI matcher cùng-lớp TRONG diff. - **Digest S93→S117 (verbatim → `archive/2026-07.md`; 1 mệnh-đề/entry):** S93 code-gate re-derived denom · S97 default-flip byte-mirror SHA ×2 · S98 Windows byte-verify = .NET (MSYS strip báo sai) · S100 HELD stale run-id NOT-stamped · S101 persist-claim cần reader-side · S108 outward-claim "đã sửa X" phải cat X trước gate · S109 gist meta-count ≠ disk · S110 claim-về-code grep lại kể cả reword · S111 nâng-nấc-quên-sync cùng-diff · S115 verify EACH regex-alt by-hand · S116 persist-claim = tracked + 0-drift · S117 2-tier guard compare SUM-EXPRESSION. @@ -86,17 +87,13 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod - **[→ archive/2026-07.md @S128 + topic] S112 close-reviews+FINAL — GO 0-must:** derive guard từ ENDPOINT authz; EDGE-5 gác FROM-state ĐỈNH method; self-flip ≠ external-accept. → [topic](project_s112_supplier_import_review.md). - **[→ archive/2026-07.md @S128] S126 A1-H23-probe + email-H23-gate — gate FAIL bắt 3 lỗi trước stamp:** số AGGREGATE cumulative KHÔNG vào outward artifact (evidence bền = per-lane run-id) · "ba việc" = 2 ASK (mở thư đếm thật) · self-obs R1-open → hedge + quy-thuộc. Stamp `945cf3ad`. Evidence: runs/2026-07-16-S126-adap-spec-execute/. -- **S128 (2026-07-16) D-review 2-lane adap 16-07 [hmw reviewer opus-max ×2 · R1 tự-ghi sub-md, R2 return-only→lead scribe; em-main VERIFY+APPEND] — GO-COMMIT 2/2, 0 BLOCKER, owner-gated 0-diff CONFIRMED:** R1: 5 acceptance re-run PASS paste-thật · hash 7/7 recompute · 0 self-claim "verified" · M1 evidence-paste "tô-điểm" (`contextual_retrieval = False` khi lệnh chỉ in `False`) — soi literal-fidelity CẢ KHI giá-trị đúng. R2: email fact-check 7/7 · counter cumulative AN-TOÀN khi neo file persisted reproducible (≠ S126 self-mutating) · lineage "05-26" → git nói 05-22 `bf93abd` — lệch CONSERVATIVE ⇒ minor không blocker (**direction-of-error quyết mức lỗi outward**) · brute-force canonical-variant với known-good TRƯỚC khi phán tamper (recipe strip-1 vs strip-ALL: 2 biến-thể sống chung theo phía phát-hành — SE-set khớp strip-1, hub-set khớp strip-ALL). Lead fold M1+lineage 3-chỗ-cùng-lớp + re-stamp `c0ac7486447e`. Trace: `runs/2026-07-16-S127-adap-dot-16-07/{sub-reviewer-0,sub-reviewer-1,review-synthesis}.md`. Tag `[s128, d-review, direction-of-error, sha-variant-per-publisher]` -- **S128 fable-real #2 re-review spec-execute adap 16-07 [0-garble ghi-đĩa-tăng-dần; em-main VERIFY+APPEND] — GO-WITH-FIXES 4 SHOULD-FIX/0 BLOCKER (cả 4 = lỗi verify-lệnh/scope-rơi):** F1 count-quên-README · F2 so `_index` bằng Get-FileHash = SAI-LOẠI-HASH (body-sha ≠ whole-file, đo `181a6d19`≠`f29247cf`) · F3 nén draft→spec RƠI item gated cả 2 lane · F4 future-claim S108. **Lesson:** lệnh verify trong spec = CODE chạy thử trước (2/4 sai thật) · bệnh encoding S125 ở CMDLET không ở shell (`Select-String -Path` đúng, `Get-Content` bệnh) · fold = disposition TỪNG DÒNG (S119). Full: `runs/2026-07-16-S127-adap-dot-16-07/sub-reviewer-3-verdict.md`. -- **S127 fable-real #1 gate đầu-vào adap 16-07 [Fable ~62K tok 0-garble; em-main VERIFY 3/3 + APPEND] — GO-ADAP, BÁC claim hub "bản 2 duy nhất":** rag.json:26 override ACTIVE ⇒ bản 5 việc THẬT + bản 3 ≥2 hằng-số sống-bằng-nhãn. **Lesson:** hedge broadcast phải test bằng MỞ config THẬT · "already-aligned" tinh-thần ≠ luật · SE GIỮ counter đúng (điều-kiện gỡ không áp: 3 tick/2 ngày). Full + AC 8/8: `runs/2026-07-16-S127-adap-dot-16-07/sub-reviewer-1-verdict.md`. +- **[→ archive/2026-07.md @S134] S128 D-review 2-lane adap — GO-COMMIT 2/2:** direction-of-error quyết mức lỗi outward (lệch CONSERVATIVE = minor) · sha-variant-per-publisher: brute-force canonical-variant với known-good TRƯỚC khi phán tamper · soi literal-fidelity evidence-paste CẢ KHI giá-trị đúng. Trace: `runs/2026-07-16-S127-adap-dot-16-07/`. +- **[→ archive/2026-07.md @S134] S128 fable-real #2 spec-execute — GO-WITH-FIXES 4SF:** lệnh verify trong spec = CODE chạy thử trước (2/4 sai thật) · Get-FileHash ≠ body-sha (sai-loại-hash) · fold = disposition TỪNG DÒNG. +- **[→ archive/2026-07.md @S134] S127 fable-real #1 gate đầu-vào — GO-ADAP, BÁC claim hub:** hedge broadcast test bằng MỞ config THẬT (rag.json override ACTIVE) · "already-aligned" tinh-thần ≠ luật. Full: `runs/2026-07-16-S127-adap-dot-16-07/sub-reviewer-1-verdict.md`. --- ## 🔄 Curate trigger - Hook-cap **>17.1KB** (24.4KB read-limit — đổi từ ~30KB cũ, S109) → archive recent → L2 `archive/.md` (append additive) + `_INDEX.md` substring pointer. Stale >3mo → remove. -- **S128 curate (em-main, 2026-07-16, hook 20.3KB→16.8KB):** moved 3 verbatim (S124-L2 · S112 · S126) → archive/2026-07.md batch-marker + SEAL-VERIFY substring-containment 3/3 + md5(block)=e5218d2f45b6; merged 12 digest S93→S117 → 1 cụm (verbatim đã ở archive từ trước); vòng-2: gộp Area S89-trio + PE-series-×5 thành pointer-cụm (detail topic-file NGUYÊN trên đĩa) + nén S127/S128/S101 (full ở run-folder verdicts). Seal-verify tay = đúng nghi-thức đề-xuất owner-gated (iii) đang chờ codify. -- **S116 curate (self, 2026-07-13, hook 21.4KB→<17.1KB):** moved S115+S116 verbatim → `archive/2026-07.md` (move-not-cut, batch-marker) + digest in L1; MERGED 12 June bullets (S65-S91) → 1 consolidated digest line (detail `archive/2026-06.md`+`_INDEX`); trimmed 4 PE-per-hạng-mục + S89-trio + S111 Area-pointers (detail in topic files on disk). -- **S113 curate (self, 2026-07-12, hook 21.3KB→~16KB):** compressed 4 verbose S112 entries (2 close-review lines→1, BE-close+FINAL→1) + this curate-log in-place; NO file-move (lessons preserved inline). digest kept L1. **+review-lane addendum:** 4 verbatim ALSO moved → `archive/2026-07.md` + 4 `_INDEX` pointer (move-not-cut) + trimmed 2 digest. -- **S109 curate:** moved 4 verbatim S100/S101 → `archive/2026-07.md` +4 pointer. -- **S102 recovery (em-main):** cut-not-moved ×5 @`f229b07` recovered từ git. **Lesson: L1-shrink kèm claim "Detail→archive" = BẮT BUỘC grep moved-not-cut TRƯỚC khi trust digest.** -- **Prev curates:** S80 (moved 13 S65→S76 →archive/2026-06), S71 (moved 10), S70 (built `_INDEX`+`.gist`), S40. +- **S134 curate (self, 2026-07-17, hook 21.3KB→<17.1KB):** moved 8 verbatim (S131b·S131·S129·S128×2·S127·S133·S123) → `archive/2026-07.md` batch-marker @S134 + seal-verify substring 6/6+2 · nén digest đã-archive (S124×2/S125×2 gộp 1 dòng) + gộp curate-log cũ. +- **Prev curates (digest):** S128 em-main (seal 3/3 md5 e5218d2f45b6) · S116 · S113 · S109 · **S102 recovery: cut-not-moved ×5 @`f229b07` — Lesson: L1-shrink kèm claim "→archive" = BẮT BUỘC grep moved-not-cut trước khi trust digest** · S80 · S71 · S70 · S40. diff --git a/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/run.md b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/run.md new file mode 100644 index 0000000..eadbaa2 --- /dev/null +++ b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/run.md @@ -0,0 +1,32 @@ +# run — 2026-07-17-S134-pe-luyke-tam-tinh + +- **engine:** `/fable-real investigator-codebase` (H21 lệnh-A, alias "invest" anh gõ) → lead verify + ghi spec 3-heading → `/fable-real reviewer` (alias "review" anh gõ — soi "cách làm chi tiết - checklist") → **hmw implement Opus 4.8 MAX** (anh chỉ định "Sau đó hwm cho Opus 4.8 MAX xử lý nhé") → reviewer pre-commit → deploy + cicd. +- **goal:** UAT anh Kiệt FDC (chat 15/07, chốt 16:10 **"Vậy em xử ký theo hướng này đi"**): lũy kế PE hiện chỉ bắt phiếu **DaDuyet** (nguyên tắc Kenny S117 "duyệt rồi mới bắc") → phiếu chưa duyệt KHÔNG góp lũy kế phiếu sau. Vì **cung đặc thù** (phiếu sau trình khi phiếu trước chưa duyệt), anh Kiệt cần: **vẫn cho bắt lên và tính toán, note màu** để biết phiếu trước chưa duyệt, để bước duyệt cuối (CEO/người duyệt thay) biết **cần duyệt phiếu trước phiếu này**. Hướng chốt (em đề xuất, anh Kiệt duyệt ♥): chia 2 số — **"lũy kế TẠM TÍNH" = tất cả phiếu** (kể cả chưa duyệt, đánh dấu màu phần từ phiếu-chưa-duyệt) ⟂ **"lũy kế CHÍNH XÁC" = chỉ phiếu đã duyệt** (semantics hiện tại GIỮ NGUYÊN). Informational — KHÔNG hard-block duyệt (xác nhận ở invest). +- **session:** S134 · lead: Fable 5 (owner-choice) · HMW consent: anh chỉ định hmw trong lệnh. +- **Tương tác S133 (freeze-NS):** phiếu DaDuyet đọc snapshot `ApprovedBudgetPrev*` bất biến — tạm-tính dự kiến CHỈ có nghĩa trên phiếu chưa-terminal; KHÔNG đụng snapshot đã chốt (invest xác nhận). + +## taskList snapshot + +1. `[fable-real:invest-luyke-tam-tinh]` role=`investigator-codebase` · **model=`fable`** (spawn-param THẮNG frontmatter-pin — H23 đo S126) — single deep-pass: + - Predicate lũy kế hiện tại: 3 bản PHẢI KHỚP (accumulator `PeBudgetAccumulator.cs` · live `PurchaseEvaluationFeatures.cs:909/:959` · snapshot `PurchaseEvaluationWorkflowService.cs:1013` · backfill SQL Mig 67 `:99`) — filter Phase nằm đâu, file:line. + - FE matrix "Tổng hợp ngân sách trình ký" rows 5/6 (`PeDetailTabs.tsx:1318-1590` ×2 app) + types `:325/:327` + màn duyệt (PeWorkflowPanel/inbox) — chỗ chèn số tạm-tính + note màu. + - Phase taxonomy đầy đủ → đề xuất tập "tạm tính" (TuChoi? Nháp? TraLai?) + trade-off. + - Danh sách "phiếu trước cùng cặp CHƯA duyệt" (mã + phase) — khả thi trong projection? cần endpoint mới? + - Edge: WorkItemId null · multi-winner D4 · giá âm S116 · Min-Max S117 · PRO-split · CcmBudgetPeriod · budgetFrozen S133. + - Propose spec 3-mục + checklist per-vai acceptance ĐO ĐƯỢC + phân rã BE/FE/test cho hmw. + - 🔴 Ghi-đĩa-TRONG-lúc-làm: `sub-investigator-codebase-0.md` (chống #53 — S133 file 22.5KB cứu trọn). + +## taskList snapshot — implement (append @spec-v2 chốt, sau fable-real review GO_WITH_FIXES) + +- INV-2.A `[impl:be-full]` implementer-backend — spec ② items 1-4 (ComputePendingAsync + DTO + live-branch fill + D4 changelog-in-helper shape M1) + checklist A1-A6. +- INV-2.B `[impl:fe-2app]` implementer-frontend — spec ② items 5-7 (types ×2 KHÔNG-mirror + PeDetailTabs sub-dòng amber SHA-mirror + PeWorkflowPanel banner gate M2 + dialog-block) + checklist B1-B4. +- INV-3.C `[impl:test-full]` test-specialist — checklist C1-C9 (pattern PeBudgetFreezeTests seed :277-332). + +## Stages + +- [x] 1. invest fable-real — #53 garble return, **sub-file 8-phần cứu trọn** (157 dòng COMPLETE); phát hiện lật-khung: row1 ĐÃ gồm ChoDuyet, gap thật = row2 +- [x] 2. lead verify 7/7 claim + 2 errata (path `Dtos/`, hash-value) + chốt D1-D5 → ghi spec 3-heading +- [x] 3. fable-real reviewer — #53 garble return, sub-file cứu (lần 3) · **GO_WITH_FIXES 0C/2M/9n** (M1 changelog-shape đầy-đủ Action=Update né #49 · M2 banner gate phase===ChoDuyet; D4-seeder CLEAR — set trực tiếp không gọi helper) → lead áp TRỌN → spec v2 +- [!] 4. hmw implement Opus 4.8 MAX — INV-2 [A‖B] đang chạy (args.run RUN-TRACE) +- [ ] 5. INV-3 [TEST-C] + test full + npm ×2 + reviewer pre-commit → commit/push + cicd verify +- [ ] 6. docs closeout → /pause (anh dặn) diff --git a/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/spec-pe-luyke-tam-tinh-17-07-2026.md b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/spec-pe-luyke-tam-tinh-17-07-2026.md new file mode 100644 index 0000000..281f2e3 --- /dev/null +++ b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/spec-pe-luyke-tam-tinh-17-07-2026.md @@ -0,0 +1,109 @@ +# 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 PriorPes);` + - `public static async Task 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? 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. diff --git a/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-implementer-backend-0.md b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-implementer-backend-0.md new file mode 100644 index 0000000..c0a091d --- /dev/null +++ b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-implementer-backend-0.md @@ -0,0 +1,35 @@ +# sub-implementer-backend-0 — IMPL-A BE lũy kế TẠM TÍNH (S134) + +> Ghi-đĩa-trong-lúc-làm (chống #53). Append từng bước. 4 file code + build verify. + +## Scope (KHÔNG ĐỤNG) +ComputeAsync thân · snapshot 11 cột ApprovedBudget* · Mig 67/Migrations/ModelSnapshot · frozen số cũ · submit-guard :206 · fe-*/** · tests/** · KHÔNG commit/push/git-add. + +## Baseline (đo trước khi sửa) +- `git status --porcelain` = CLEAN (branch main). +- DTO namespace = `SolutionErp.Application.PurchaseEvaluations.Dtos` (:4) ; accumulator namespace = `SolutionErp.Application.PurchaseEvaluations` → CẦN thêm `using ...Dtos;` vào accumulator. +- `PurchaseEvaluation.MaPhieu` = `string?` (:10) → DTO PePendingPriorPeDto MaPhieu nullable ✓ (khớp reviewer N2). +- Phase enum: DangSoanThao=1 · legacy 2-6 (ChoPurchasing/ChoDuAn/ChoCCM/ChoCEODuyetPA/ChoCEODuyetNCC) · DaDuyet=7 · ChoDuyet=10 · TraLai=98 · TuChoi=99. +- Changelog entity fields: PurchaseEvaluationId/EntityType/EntityId?/Action/PhaseAtChange?/UserId?/UserName?/Summary?/FieldChangesJson?/ContextNote?. EntityType.Workflow=5, ChangelogAction.Update=2 (ContractChangelog.cs:41). LogTransitionAsync (:1128-1155) dùng `.Add(...)` sync + Action=Transition. +- Reviewer GO_WITH_FIXES: M1 (changelog shape) + N1 (.Add not AddAsync) + N2 (MaPhieu nullable) đã incorporate vào task spec item 4 — tôi follow. +- 2 construction site verified: Features.cs:946 (frozen) + :975 (live). Helper 5 call-site :311/:865/:904/:937/:1112 — KHÔNG đổi signature. + +## Tiến độ +- [x] Chunk 1: PeBudgetAccumulator.cs — +using Dtos (:3); +PePendingAccumulation record struct (5-field, sau PeBudgetAccumulation); +ComputePendingAsync (window Y HỆT ComputeAsync; PendingSubmitted loại {1,7,10,99}; PendingSelected loại {1,7,99}+winner mirror join :50-60; PriorPes loại {7,99} order CreatedAt Select PePendingPriorPeDto). CHỈ đọc AsNoTracking, KHÔNG mutate. ComputeAsync thân KHÔNG đổi. +- [x] Chunk 2: PurchaseEvaluationDtos.cs — +PePendingPriorPeDto(Guid Id, string? MaPhieu, int Phase) trước PeBudgetSummaryDto; extend PeBudgetSummaryDto append-CUỐI sau BudgetFrozen: +PendingSubmittedTotal=0/Count=0/SelectedTotal=0/Count=0/PendingPriorPes=null. +- [x] Chunk 3: PurchaseEvaluationFeatures.cs — live-branch insert ComputePendingAsync sau ComputeAsync; live construction (:975) fill 5 field từ pending; frozen construction (:946) thêm 5 default 0m/0/0m/0/null (BudgetFrozen: true GIỮ). ComputeAsync call-site KHÔNG đổi (chỉ THÊM call ComputePendingAsync sau nó). +- [x] Chunk 4: PurchaseEvaluationWorkflowService.cs — D4 trong ApplyBudgetSnapshotOnFinalizeAsync nhánh wiKey SAU set 4 lũy kế; gọi ComputePendingAsync 1 lần; PriorPes.Count>0 → .Add(changelog Action=Update, EntityType=Workflow, PhaseAtChange=DaDuyet, UserId=null, UserName="Hệ thống"); local switch PendingPhaseLabel (1→Nháp,10→Đã gửi duyệt,98→Trả lại,_→Đang duyệt); KHÔNG đổi signature, KHÔNG SaveChanges. +- [x] Build slnx: **Build succeeded. 0 Warning(s) 0 Error(s)** (58.84s). +- [x] git status verify: Migrations/ + ModelSnapshot = **NONE changed (PASS)**. 4 BE file mine đổi. 4 FE file (fe-admin/fe-user PeDetailTabs+types) + sub-implementer-frontend-1.md = của agent implementer-frontend-1 CHẠY SONG SONG (INV-2 A‖B) — KHÔNG phải tôi. + +## Verify acceptance (đo được) +- **A1 (ComputeAsync thân = 0 đổi):** PeBudgetAccumulator.cs diff = `68 insertions(+), 0 deletions`. `grep '^-'` = NO REMOVED LINES → ComputeAsync body byte-nguyên. PASS. +- **A6 (5 call-site helper = 0 đổi):** WorkflowService.cs diff = `30 insertions(+), 0 deletions` (insertion-only). 5 call-site giữ signature `(evaluation, ct)` tại :311/:865/:904/:937/:1142 (site cuối dịch :1112→:1142 = +30 do CHÈN trong THÂN helper, nội-dung call bất động). Signature helper :998 KHÔNG đổi. PASS. +- **A2 (đúng 2 construction site):** `grep 'new PeBudgetSummaryDto' src/Backend/` = ĐÚNG 2 (Features.cs:946 frozen + :985 live; :985 = :975 cũ dịch +10 do frozen+4 & live-insert+6). PASS. +- **Frozen số cũ bất động:** Features.cs diff — nhánh frozen chỉ THÊM 5 default (`0m,0,0m,0,null`) vào construction :946; các dòng `e.ApprovedBudget*` = context KHÔNG đổi. 2 dòng "removed" = `BudgetFrozen: true);`/`false);` chỉ là đóng-ngoặc mở-rộng (giá-trị BudgetFrozen giữ). PASS. +- **submit-guard :206:** KHÔNG trong diff (grep `selectedThanhTien`/`.All(x=>x==0)` trong diff = rỗng). PASS. +- **DTO append-CUỐI:** DTOs.cs diff `14 insertions, 1 deletion` (deletion = mở-rộng `bool BudgetFrozen = false);` → +5 field). Pure-append. PASS. + +## Note (bàn giao FE / risk) +- Contract JSON: `PendingPriorPes` = **null** (nhánh frozen / không-cặp) hoặc list (live) → FE PHẢI null-guard `?? []` (reviewer N2/N9 — plain text mã + fallback "(chưa có mã)", KHÔNG render link). FE = implementer-frontend-1 lo. +- `(int)p.Phase` projection EF-translate: spec-pinned + reviewer trục-4 validated; enum→int underlying = EF standard. Runtime chưa chạy được vì Dev DB 0 multi-pair (reviewer N5) — build compile PASS, logic mirror ComputeAsync đã proven. diff --git a/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-implementer-frontend-1.md b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-implementer-frontend-1.md new file mode 100644 index 0000000..cd84700 --- /dev/null +++ b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-implementer-frontend-1.md @@ -0,0 +1,42 @@ +# SUB implementer-frontend-1 — S134 PE lũy-kế TẠM TÍNH (FE 2 app) + +> Run: 2026-07-17-S134-pe-luyke-tam-tinh · role implementer-frontend · IMPL-B (items 5-7) +> Ghi-đĩa-trong-lúc-làm (chống #53). Append từng bước. +> ⚠️ NAMING: task top-line nói `sub-implementer-frontend-0.md` NHƯNG RUN-TRACE + subMdPath return nói `-1.md` (×4). Resolve = `-1.md` (khớp return contract). Flag cho em-main reconcile. + +## Baseline (git hash-object PRE-edit) — VERIFIED +- PeDetailTabs.tsx ×2 app: `5534addb9a7887c00562a99ab1f52ed4ee77c0ca` IDENTICAL (khớp task) → SHA-mirror protocol: edit fe-user → cp fe-admin → hash verify. +- PeWorkflowPanel.tsx ×2 app: `a83a58e1d8e17ed56660a7061c755d58fa6ad27e` IDENTICAL (khớp task) → same protocol. +- types/purchaseEvaluation.ts: fe-admin `ab4a2ec6` vs fe-user `2bf77367` DIVERGED (KHÔNG mirror, S117 confirmed) → edit RIÊNG từng app, cùng old_string (block PeBudgetSummary content-identical). +- PurchaseEvaluationPhaseLabel exported types:35 · imported PeDetailTabs:29 + PeWorkflowPanel:29 · PurchaseEvaluationPhase.ChoDuyet=10 types:29. +- fmtVnd PeDetailTabs:1002 · cn imported cả 2 file. + +## Plan (3 items) +- Item 5 types ×2: +5 field sau budgetFrozen (pendingSubmittedTotal/Count, pendingSelectedTotal/Count, pendingPriorPes[]|null). +- Item 6 PeDetailTabs: +sub `sub?` prop cho BudgetSharedNumCell(:1184)+BudgetColValue(:1197); showPending gate; sub-dòng amber row1/2/5/6 (N8 count tương ứng); block liệt kê pendingPriorPes dưới matrix (:1656). +- Item 7 PeWorkflowPanel: derived vars sau :132; banner amber sau V2-banner (:437); reminder dialog isApproveAction sau Dialog-open (:524, trên price-picker :696). KHÔNG đụng priceMissing:514/footer:522. + +## Progress +- [x] Read + ground-truth 6 file + anchors (DONE) +- [x] Item 5 types ×2 (fe-user+fe-admin, same old_string budgetFrozen block) — grep pendingPriorPes = 1/1 ✓ +- [x] Item 6 PeDetailTabs — 8 edit fe-user → cp fe-admin → hash `d28eaa16` ×2 IDENTICAL ✓ + - import +type ReactNode; BudgetSharedNumCell+BudgetColValue +sub?:ReactNode prop + - pending vars + showPending gate + pendingSub helper sau row6 + - row1(:1498) row2(:1503) sub withDetail=true; row5(pro/ccm.r5) row6 sub withDetail=false (N8 count tương ứng) + - block liệt kê pendingPriorPes dưới matrix (border-t amber, N9 plain+badge phase-color 10/98/1/else) +- [x] Item 7 PeWorkflowPanel — 3 edit fe-user → cp fe-admin → hash `bebc0ada` ×2 IDENTICAL ✓ + - derived: pendingPriors + showPendingBanner(phase===ChoDuyet M2) + pendingPriorsText (sau :132) + - banner amber sau V2-banner (:437 vùng) + - reminder dialog isApproveAction && showPendingBanner sau Dialog-open (trên price-picker); KHÔNG đụng disabled Button (B3) +- [x] npm build ×2 — BOTH PASS 0 TS err (tsc -b && vite gate): fe-user 1939mod `index-BVoJX7QA.js` 20.94s · fe-admin 1950mod `index-mcHk0pmo.js` 9.26s. Warn pre-existing @import CSS + >500KB + realtime INEFFECTIVE_DYNAMIC_IMPORT. + +## VERIFY FINAL (checklist B1-B4) +- B1 types ×2 (KHÔNG mirror, DIVERGE): grep -c pendingPriorPes = 1/1 ✓. Both build clean. +- B2 PeDetailTabs SHA-mirror: `git hash-object` ×2 = `d28eaa1656e1697f65483e91d6ad8cf381881d65` IDENTICAL ✓ (pre `5534addb`). Frozen phiếu KHÔNG render tạm-tính (showPending = !bs.budgetFrozen && ...) ✓. +- B3 PeWorkflowPanel SHA-mirror: `git hash-object` ×2 = `bebc0ada6afc342f6c22bb2bf12c9f0317d7ade9` IDENTICAL ✓ (pre `a83a58e1`). Disable nút Xác nhận UNTOUCHED — `git diff | grep ^[+-] disabled|priceMissing|shouldPickPrice|transition.isPending` = 0 line ✓. +- B4 diff scope: 6 FE file mine (fe-{admin,user}/{PeDetailTabs,PeWorkflowPanel,types}). 4 Backend file = implementer-backend ∥ (code-disjoint, KHÔNG phải mine). Sub-trace `-1.md` in run-folder. + +## NAMING RESOLVED +Run-folder có `sub-implementer-backend-0.md` (∥ agent) + mine `sub-implementer-frontend-1.md` → em-main index fan-out-GLOBAL (backend=0, frontend=1). `-1.md` ĐÚNG; top-line task `-0.md` = stale artifact. Return subMdPath = `-1.md`. + +## STATUS: COMPLETE — no ambiguity, full precedent (S133 pattern reused). diff --git a/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-investigator-codebase-0.md b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-investigator-codebase-0.md new file mode 100644 index 0000000..6887856 --- /dev/null +++ b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-investigator-codebase-0.md @@ -0,0 +1,157 @@ +# SUB investigator-codebase-0 — S134 PE lũy-kế TẠM TÍNH (ground-truth + propose spec) + +> Run: 2026-07-17-S134-pe-luyke-tam-tinh · engine fable-real single-deep-pass · ghi-đĩa-trong-lúc-làm (chống #53) +> Status: IN-PROGRESS — append từng phần + +## PHẦN 1 — Predicate lũy kế hiện tại (Claim 1) ✅ + +### Kết luận Claim 1: PARTIAL — quan trọng: "chỉ bắt DaDuyet" KHÔNG đúng cho cả 4 số + +**Helper `PeBudgetAccumulator.cs` (extracted S133, behavior-preserving):** +- Peers = cùng `(ProjectId, WorkItemId)`, `Id != this`, `CreatedAt < this` (`PeBudgetAccumulator.cs:38-40`) + HasQueryFilter !IsDeleted tự áp. +- **PrevSubmitted (Total/Count)** = peers `Phase ∈ {ChoDuyet, DaDuyet}` → Count + SUM(`BudgetPeriodAmount ?? 0`) — `:42-48`. **⟵ ĐÃ bao gồm ChoDuyet (phiếu đang trình chưa duyệt)!** +- **PrevSelected (Total/Count)** = peers `Phase == DaDuyet` AND `Suppliers.Any(IsWinner)` (existence) — `:50-52`; total = SUM `ThanhTien` quote `IsSelected` của các peer đó — `:53-60`. **⟵ Đây mới là chỗ DaDuyet-only ("duyệt rồi mới bắc" Kenny S117).** +- **CurrentProposalTotal** = own quotes IsSelected — `:62-65`. + +**Số bản predicate phải khớp — thực tế HIỆN TẠI là 2 bản sống + 1 bản one-time:** +- (a) Helper `PeBudgetAccumulator.ComputeAsync` — bản RUNTIME duy nhất. +- (b) Live-path `PurchaseEvaluationFeatures.cs:962-963` — **GỌI helper, KHÔNG duplicate predicate** (comment :959-961: "Row 1 = đã trình (ChoDuyet + DaDuyet); Row 2 = đã chọn thầu (DaDuyet + IsWinner). TraLai/DangSoanThao KHÔNG tính"). +- (c) Snapshot-path `PurchaseEvaluationWorkflowService.cs:998` (`ApplyBudgetSnapshotOnFinalizeAsync`) — `:1018` **GỌI helper, KHÔNG duplicate**. Helper được gọi từ 5 site: `:311` (submit-bypass?), `:865/:904/:937` (finalize branches), `:1112` (admin-override) — cần verify chi tiết từng site. +- (d) Backfill SQL Mig 67 `20260717032812_AddPeApprovedBudgetSnapshot.cs:99-150` — bản DỊCH SQL one-time (đã chạy, không chạy lại — idempotent `WHERE SnapshotAt IS NULL`). Xác nhận enum số: `Phase IN (10, 7)` = ChoDuyet=10, DaDuyet=7 (`:128`), selected = `Phase = 7 + EXISTS IsWinner` (`:135-137`). + +**TẠM TÍNH có đụng các bản này không?** → KHÔNG bắt buộc đụng semantics cũ. Cách sạch nhất: THÊM computation mới (nhánh/method mới trong helper hoặc method riêng) tính "phần góp từ phiếu chưa-terminal". Snapshot-path (c) + backfill (d) KHÔNG đụng — tạm-tính KHÔNG snapshot (đề bài chốt). Live-path (b) chỉ THÊM field vào DTO. + +### Frozen gate S133 (Claim 3 partial — evidence) +- Gate: `PurchaseEvaluationFeatures.cs:929` — `frozen = Phase == DaDuyet && ApprovedBudgetSnapshotAt != null`. +- Frozen branch `:930-955`: serve 11 cột `ApprovedBudget*` từ PE row; row 4 "Giá trị kỳ này" VẪN live từ own quotes (`:932-936` — SPEC S133); `BudgetFrozen: true` (`:955`); canEditPro/Ccm = false-false (`:950`). +- Live branch `:957-984`: pairRec `PeWorkItemBudgets` + accumulator; `BudgetFrozen: false` (`:984`). +- DTO: `PurchaseEvaluationDtos.cs:353` — `bool BudgetFrozen = false` (cần đọc full record để map field position). +- Entity: `PurchaseEvaluation.cs:116-132` — 11 cột ApprovedBudget* + SnapshotAt. +- Seeder demo cũng set snapshot: `DbInitializer.cs:1327-1340`. + +## PHẦN 2 — Cụm 4 số lũy kế + FE matrix (Claim 2) ✅ + +**BE DTO `PeBudgetSummaryDto` (`PurchaseEvaluationDtos.cs:331-353`, 18 field):** +- `PreviousSubmittedTotal/Count` (:342-343) = SUM `BudgetPeriodAmount` peers Phase ∈ {ChoDuyet, DaDuyet} — "Ngân sách trình duyệt trước" +- `PreviousSelectedTotal/Count` (:344-345) = SUM quote ThanhTien IsSelected của peers DaDuyet+IsWinner — "Kỳ trước đã chọn thầu" +- `CurrentProposalTotal` (:346) = own quotes IsSelected +- `BudgetFrozen` (:353) — append CUỐI default false (pattern append-an-toàn construction site) + +**FE type `PeBudgetSummary` (`fe-user/src/types/purchaseEvaluation.ts:314-334`; block lũy kế :327-333).** ⚠️ types 2-app KHÔNG SHA-mirror (fe-user `173529b` vs fe-admin `5631542` — S117 confirmed lại): sửa type = edit CẢ 2 file riêng. + +**FE matrix `PeDetailTabs.tsx` (2-app SHA IDENTICAL `47c97df1`):** +- Công thức: `row1 = bs.previousSubmittedTotal` (:1315) · `row2 = bs.previousSelectedTotal` (:1316) · `row4 = bs.currentProposalTotal` (:1317) · **`row6 = row2 + row4`** (:1318 — "6. Lũy kế thực hiện = 2 + 4", render :1579-1586) · **`r5 = row1 + r3`** per-cột trong `colCalc` (:1323-1335, :1325 — "5. Lũy kế ngân sách đã sử dụng = 1 + 3", render :1569-1578). r7 = colFull − r5 (:1326), r9 = row4 + r8 (:1328), cmp56 = r5 − row6 (:1332). +- Row 1/2 CHUNG 3 cột (`BudgetSharedNumCell colSpan=3`, :1498/:1503) — hiển thị null khi Count==0. +- Row 3 PER-cột: PRO = `ev.budgetPeriodAmount ?? bs.proInitialAmount` (:1516) / CCM = `ev.ccmBudgetPeriodAmount ?? bs.initialAmount` (:1531 — Mig 59). +- Frozen badge "🔒 Ngân sách chốt tại thời điểm duyệt" (:1352-1356). WorkItemId null → `bs` null → banner nhắc gắn hạng mục (:1297-1304). +- ⚠️ Nuance sẵn có: row1 (accumulator) SUM `BudgetPeriodAmount ?? 0` — peer chưa nhập row3 góp 0, trong khi FE row3 phiếu đó hiển thị fallback proInitialAmount (drift-vector S133 đã ghi nhận, TẠM TÍNH kế thừa nguyên trạng — không sửa trong scope này). + +## PHẦN 3 — Phase taxonomy (Claim 4) + snapshot helper (Claim 3 evidence) + +**Enum `PurchaseEvaluationPhase.cs:15-27`:** +- `DangSoanThao = 1` (Nháp) · `ChoPurchasing=2/ChoDuAn=3/ChoCCM=4/ChoCEODuyetPA=5/ChoCEODuyetNCC=6` [LEGACY deprecated] · **`DaDuyet = 7`** (terminal thành công) · **`ChoDuyet = 10`** (đã gửi duyệt) · **`TraLai = 98`** (trả lại — Drafter sửa gửi lại) · **`TuChoi = 99`** (terminal khoá). + +**Snapshot helper `ApplyBudgetSnapshotOnFinalizeAsync` (`PurchaseEvaluationWorkflowService.cs:998-1041`):** +- WorkItemId có → load pair `PeWorkItemBudgets` 6 cột (:1004-1011) + accumulator 4 cột lũy kế (:1018-1023); WorkItemId null → 10 cột null (:1027-1037); LUÔN set `ApprovedBudgetSnapshotAt` (:1040). +- 5 call-site: `:311` + `:865/:904/:937` + `:1112` (RULE comment :997 "MỌI nhánh set Phase=DaDuyet PHẢI gọi helper NGAY TRƯỚC assignment"). +- Display-gate frozen: `PurchaseEvaluationFeatures.cs:929` (`Phase==DaDuyet && SnapshotAt != null`); frozen branch :930-955 serve snapshot + `BudgetFrozen:true`; row 4 "giá trị kỳ này" VẪN live (:932-936 spec S133). + +## PHẦN 4 — Màn duyệt (Claim 5) — PeWorkflowPanel (2-app SHA IDENTICAL `9d88e671`) + +- Component: `fe-user/src/components/pe/PeWorkflowPanel.tsx` (+ fe-admin mirror same SHA). Panel 3 = timeline + transition buttons + history. +- Final-approver detect ĐÃ CÓ SẴN: `currentIsFinalApprover = lastFlowLevel?.status === 'Current'` (:112-115) · CCM-done eligible `ccmDelegationEligible` (:109-111) · per-level finalize `approverFinalizeEligible` (:119) · `shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || (approverFinalizeEligible && applyLevelFinalize))` (:510) — ĐÂY chính là predicate "đang duyệt CUỐI" tái dụng được cho cảnh báo. +- V2 banner "Đến lượt bạn" :418-437 · action buttons :439-487 · Dialog transition :494-530 (footer Xác nhận :522, `priceMissing` disable :514) · mutation POST `/purchase-evaluations/{id}/transitions` :161-216. +- Chỗ chèn cảnh báo "phiếu trước chưa duyệt" khả thi: (i) TRONG Dialog duyệt khi `isApproveAction` (cạnh :509-514, trước bộ chọn giá) — approver PHẢI nhìn thấy trước Xác nhận; (ii) banner Panel cạnh V2 banner :437; (iii) badge/note trong matrix PeDetailTabs rows 1/5/6. +- Data: bundle DTO (`evaluation`) hiện KHÔNG có danh sách phiếu-trước-chưa-duyệt (chỉ có count gộp ChoDuyet+DaDuyet trong PreviousSubmittedCount — không tách). ⇒ cần field DTO MỚI; endpoint MỚI: KHÔNG cần — bơm vào GetPurchaseEvaluationQuery (đã query peers sẵn). + +## PHẦN 5 — 5 call-site snapshot định danh + DB ground + tests + +**5 site `ApplyBudgetSnapshotOnFinalizeAsync` (đọc context thật):** +- `:311` — Admin manual override (site 5, reviewer-catch S133; assignment qua biến targetPhase) +- `:865` — level-finalize (AllowApproverFinalize + applyLevelFinalize opt-out S96; set `EndedByLevelFinalize=true`) +- `:904` — CCM duyệt-done miễn CEO (threshold guard winnerQuoteTotal < ceoThreshold) +- `:937` — all-steps-done terminal (CEO/cấp cuối V2) +- `:1112` — V1 legacy final (KHÔNG ApplyApprovedPriceOnFinalize) + +**Entity fields:** `PurchaseEvaluation.cs:10` MaPhieu · `:16` WorkItemId loose-Guid · `:40` BudgetPeriodAmount · `:49` CcmBudgetPeriodAmount. Bundle root DTO `BudgetSummary` field `PurchaseEvaluationDtos.cs:260`; construction site duy nhất `PurchaseEvaluationFeatures.cs:1253`. FE bundle type `budgetSummary: PeBudgetSummary | null` (`fe-user/src/types/purchaseEvaluation.ts:470`) ⇒ PeWorkflowPanel đọc được qua `evaluation.budgetSummary` — KHÔNG cần prop/endpoint mới. + +**DB ground:** Dev LocalDB `SolutionErp_Dev`: Phase distribution = {1:1, 3:1, 6:1, 7:1} — **phase LEGACY 3/6 CÒN ROW SỐNG** (seed cũ); multi-pair (cùng ProjectId+WorkItemId ≥2 phiếu) = 0 trong Dev. Prod: UNVERIFIED run này (SSH password extraction fail ×2 — không chặn spec; predicate viết dạng loại-trừ tự cover). + +**Tests `PeBudgetFreezeTests.cs`:** peer-seed helper `:311-332` — CreatedAt override THỦ CÔNG (`peer.CreatedAt = createdAt` :332; TestApplicationDbContext KHÔNG wire audit interceptor); test lũy kế `:435-451` seed 2 peer CreatedAt sớm hơn `baseT`, phiếu xét `pe.CreatedAt = baseT` :451. Own-quote seed helper `:277-306` (IsWinner :287 + IsSelected :306). + +## PHẦN 6 — Phase-set TẠM TÍNH trade-offs (Claim 4) + +| Phase | Vào tạm-tính? | Trade-off | +|---|---|---| +| ChoDuyet=10 | ✅ CORE | "cung đặc thù" — đã trình, sẽ duyệt. Với row2-delta là gap chính; row1 ĐÃ gồm sẵn | +| TraLai=98 | ✅ khuyến nghị | đang sửa để gửi lại — sẽ quay lại luồng duyệt; badge trạng thái để người duyệt tự cân | +| DangSoanThao=1 | ⚠️ FLAG ANH | "tất cả lun" (lời em chat) NHƯNG: (i) nháp = RIÊNG TƯ S89 (`PurchaseEvaluationFeatures.cs:893-898` chặn approver mở nháp) — cộng tiền nháp vào matrix phiếu khác = lộ thông tin nháp; (ii) số nháp chưa chốt → nhiễu. Khuyến nghị: list-only (hiện MÃ + badge "nháp" trong danh sách cảnh báo, KHÔNG cộng delta tiền) | +| TuChoi=99 | ❌ | terminal — không bao giờ thành "chính xác"; nghịch mục đích "số sẽ thành chính xác khi phiếu trước duyệt" | +| DaDuyet=7 | — | đã nằm trong CHÍNH XÁC | +| Legacy 2-6 | ✅ gộp | Dev DB CÓ row (3, 6) — predicate loại-trừ `Phase NOT IN (DaDuyet, TuChoi)` tự cover, an toàn hơn liệt kê dương | + +**Khuyến nghị tập mặc định:** pendingPriorPes list = peers `Phase NOT IN (7, 99)` (mọi non-terminal, kể cả nháp — có badge); **delta-TIỀN row2** = non-terminal TRỪ nháp (`NOT IN (1, 7, 99)`) + IsWinner-existence + SUM IsSelected; **delta-TIỀN row1** = phases chưa nằm trong row1 hiện tại TRỪ nháp (= TraLai + legacy 2-6). Lead quyết + flag anh điểm nháp. + +## PHẦN 7 — Edge cases (Claim 7) — từng edge tạm-tính xử ra sao + +1. **WorkItemId null**: `budgetSummary` null toàn cục (gate `Features.cs:912`) → tạm-tính + warning KHÔNG TỒN TẠI (đúng — không định nghĩa được "cùng cặp"); banner "gắn hạng mục" cũ giữ (`PeDetailTabs.tsx:1297-1304`). +2. **Multi-winner D4/IsSelected (S114)**: delta dùng ĐÚNG predicate hiện hành (IsWinner existence + SUM IsSelected — mirror accumulator :50-60) → nhất quán cardinality, 0 quy ước mới. +3. **Giá âm (S116)**: SUM signed nguyên trạng → delta có thể ÂM; FE render dấu như mọi số tiền; KHÔNG guard thêm. +4. **Min–Max (S117)**: `ApprovedPrice*` KHÔNG feed lũy kế (lũy kế = BudgetPeriodAmount + quote-sums; HĐ GiaTri cũng không đọc giá chốt) → orthogonal, 0 tác động. +5. **PRO-split Mig 56**: `proInitialAmount` chỉ là FALLBACK DISPLAY row3 (`PeDetailTabs:1516`), accumulator dùng `BudgetPeriodAmount ?? 0` — tạm-tính giữ nguyên quy ước (không sửa drift-vector sẵn có trong scope này). +6. **CcmBudgetPeriodAmount Mig 59**: row1 CHỈ SUM `BudgetPeriodAmount` (PRO-side); CCM-kỳ-này per-phiếu KHÔNG vào lũy kế hiện tại → delta CŨNG KHÔNG (thêm = đổi semantics CHÍNH XÁC, ngoài scope). +7. **Mốc CreatedAt**: tạm-tính GIỮ NGUYÊN peer-window `CreatedAt < this.CreatedAt` (khác window = 2 tập peer khác nhau — số chính xác và tạm tính hết so sánh được). Phiếu bị đè CreatedAt (seed/import) nằm ngoài/trong window y hệt semantics hiện tại. Test seed mirror `PeBudgetFreezeTests:311-332/:435-451`. +8. **Own-phase TuChoi/TraLai**: live branch vẫn compute; FE ẩn NUDGE màn duyệt khi phiếu terminal (duyệt không còn); matrix vẫn hiển thị (thông tin). +9. **Audit gap (phiếu sau duyệt trước phiếu trước)**: snapshot CHỈ chốt 4 số CHÍNH XÁC; tạm-tính không snapshot → sau này không tái dựng "lúc duyệt có cảnh báo N phiếu". Optional-recommended: nối `ContextNote` LogTransitionAsync tại finalize "Duyệt khi N phiếu trước chưa duyệt: [mã…]" — 0 cột mới, Changelogs pattern Mig 61 đã có. + +## PHẦN 8 — PROPOSED SPEC 3-mục + +### ① Tính năng / đề bài +UAT anh Kiệt FDC 15/07 (chốt 16:10): vì "cung đặc thù" (phiếu sau trình khi phiếu trước cùng gói thầu CHƯA duyệt), lũy kế PE phải: (a) "bắt lên và tính toán" cả phần góp từ phiếu chưa duyệt = **Lũy kế TẠM TÍNH**, phần góp NHẬN DIỆN ĐƯỢC (note màu); (b) **Lũy kế CHÍNH XÁC** = semantics hiện tại GIỮ NGUYÊN 100%; (c) người duyệt cuối (CEO/duyệt thay) NHÌN THẤY danh sách "phiếu trước chưa duyệt [mã — trạng thái]" lúc duyệt — informational nudge, KHÔNG hard-block. +**Ground-truth thu hẹp đề:** row1 "NS trình duyệt trước" ĐÃ gồm ChoDuyet sẵn (accumulator :43-44). Gap thật = **row2 "Kỳ trước đã chọn thầu" (DaDuyet-only) + row6 dẫn xuất**, và (tuỳ tập chốt) row1 thiếu TraLai/nháp/legacy. + +### ② Cách implement +**BE (0 migration, 0 endpoint mới, 0 đụng snapshot-path):** +1. `PeBudgetAccumulator.cs` — THÊM record `PePendingAccumulation(PendingPrevSubmittedTotal, PendingPrevSubmittedCount, PendingPrevSelectedTotal, PendingPrevSelectedCount, PendingPriorPes)` + method MỚI `ComputePendingAsync(db, projectId, workItemId, peId, peCreatedAt, ct)`. Predicate: peers cùng cặp + `CreatedAt <` + `Phase NOT IN (DaDuyet, TuChoi)`; delta-selected mirror :50-60 (IsWinner-existence + SUM IsSelected); delta-submitted = phases ngoài {ChoDuyet, DaDuyet} theo tập chốt. `PePendingPriorPeDto(Id, MaPhieu, Phase)` cho danh sách. **KHÔNG sửa `ComputeAsync`** (snapshot-path + Mig 67 bất động). +2. `PeBudgetSummaryDto` (`PurchaseEvaluationDtos.cs:331-353`) — append CUỐI sau `BudgetFrozen`: 4 số pending (default 0) + `IReadOnlyList? PendingPriorPes = null` (pattern append-an-toàn như BudgetFrozen S133). +3. `GetPurchaseEvaluationQuery` — nhánh LIVE (`Features.cs:957-984`): gọi `ComputePendingAsync`, fill fields. Nhánh FROZEN (`:930-955`): pending = 0/null/empty — **R1-S133 giữ nguyên: phiếu DaDuyet là bảng snapshot bất biến, KHÔNG thêm số động** (tạm-tính trên phiếu đã duyệt vô nghĩa + vi phạm freeze). +4. *(Optional-recommended, đóng audit-gap)*: tại 4 finalize-site + admin-site, nếu pending priors tồn tại → nối vào comment/ContextNote của `LogTransitionAsync` "Duyệt khi N phiếu trước cùng gói chưa duyệt: [mã…]" — 0 cột, Changelogs pattern sẵn. + +**FE (2 app):** +5. `types/purchaseEvaluation.ts` ×2 app (KHÔNG SHA-mirror — edit block `PeBudgetSummary` TỪNG app): +5 field mới. +6. `PeDetailTabs.tsx` (SHA-mirror 2 app `47c97df1` — sửa 1 copy 2): matrix gate `!bs.budgetFrozen`: + - Row 2 + Row 6: sub-dòng amber khi `pendingPrevSelectedCount > 0`: "Tạm tính: {row2+delta2} (+{delta2} từ {n} phiếu chưa duyệt)"; row6_tạm = row6 + delta2. + - Row 1 + Row 5 (nếu delta1 bật): tương tự với delta1; r5_tạm per-cột = r5 + delta1. + - Danh sách mã pending (MaPhieu + PhaseLabel badge màu per-phase) — render nhỏ dưới matrix hoặc tooltip. +7. `PeWorkflowPanel.tsx` (SHA-mirror `9d88e671`): (a) banner amber cạnh V2-banner (:437) khi `evaluation.budgetSummary?.pendingPriorPes?.length > 0 && !readOnly`: "⚠ 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"; (b) NHẮC LẠI block đó TRONG Dialog duyệt khi `isApproveAction` (vùng :509-514, trên bộ chọn giá) — người duyệt cuối (`shouldPickPrice` :510 = predicate CEO/duyệt-thay sẵn có) chắc chắn thấy trước "Xác nhận". KHÔNG disable nút. + +### ③ Checklist per-vai + acceptance ĐO ĐƯỢC +**implementer-backend:** +- [ ] `ComputePendingAsync` mới; acceptance: `git diff` vùng `ComputeAsync` = 0 dòng; build xanh. +- [ ] DTO append-cuối; acceptance: 2 construction site (:946/:975) set explicit; không site nào khác gãy. +- [ ] Frozen → pending 0/empty; acceptance: GET phiếu DaDuyet+SnapshotAt → `pendingPriorPes=[]`, 4 số 0. +- [ ] Live → pending đúng; acceptance: curl GET phiếu B (peer A ChoDuyet+winner) → `pendingPrevSelectedTotal` = SUM quote IsSelected của A, list chứa `{maPhieu A, phase 10}`. +- [ ] KHÔNG migration: snapshot `ApplicationDbContextModelSnapshot.cs` diff = 0. +- [ ] (opt) Changelog note finalize; acceptance: transition finalize khi có pending → Changelogs row ContextNote chứa mã A. +**implementer-frontend (×2 app):** +- [ ] types 2 app; acceptance: `grep pendingPriorPes fe-{user,admin}/src/types/purchaseEvaluation.ts` = 2 hit. +- [ ] PeDetailTabs sub-rows + list; acceptance: SHA 2 app IDENTICAL sau sửa; phiếu frozen KHÔNG render tạm-tính. +- [ ] PeWorkflowPanel banner + dialog-block; acceptance: SHA 2 app IDENTICAL; nút Xác nhận KHÔNG bị disable bởi pending. +- [ ] UAT flow đo được: A ChoDuyet → mở B: matrix row2 hiện tạm-tính + banner liệt kê A; duyệt A → DaDuyet → refetch B: tạm-tính biến mất, row2 chính-xác tăng ĐÚNG delta cũ. +**test-specialist (pattern `PeBudgetFreezeTests` seed :277-306/:311-332):** +- [ ] ComputePendingAsync: peer ChoDuyet+IsWinner+IsSelected → delta2 = tổng; peer TuChoi → 0; peer DaDuyet → 0 (bên chính xác); TraLai/nháp theo tập chốt. +- [ ] Peer-window: peer CreatedAt > this → loại (mirror :435-451). +- [ ] Quote âm trong delta (signed). +- [ ] Frozen phiếu → pending rỗng. +- [ ] (opt) finalize ghi Changelog note. + +### Phân rã hmw staged (kiểu S133): INV done (file này) → **IMPL-A** backend (items 1-4) → **IMPL-B** frontend 2-app (5-7, sau A merge DTO shape) → **TEST-C** (sau A; FE acceptance UAT tay). A/B song song được nếu chốt DTO-shape trước; C sau A. + +## DECISION-POINTS (lead quyết / flag anh) +1. **DangSoanThao (nháp) vào delta-TIỀN?** A=include-all (đúng verbatim "tất cả lun") / B=list-only mã+badge, KHÔNG cộng tiền (S89 nháp-riêng-tư + số chưa chốt) — **tao khuyến nghị B, flag anh Kiệt**. +2. **Delta row1** (TraLai+legacy BudgetPeriodAmount) làm luôn hay chỉ row2? Khuyến nghị: LÀM (nhất quán "tất cả") — chi phí ~0 (cùng query). +3. **Nudge mức nào:** plain-info (khuyến nghị) / +confirm-checkbox FE "tôi biết N phiếu trước chưa duyệt" (rẻ, không BE) / hard-block BE (KHÔNG khuyến nghị — nghịch "cung đặc thù"; nếu anh muốn thì guard ở 4 finalize-site + admin bypass). +4. **Changelog audit note** lúc finalize (optional-recommended — đóng gap "về sau không chứng minh được lúc duyệt đã cảnh báo"). +5. **TuChoi exclude** — khuyến nghị chắc, nêu để anh confirm. + +## STATUS: COMPLETE — return digest gửi kèm. diff --git a/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-reviewer-0.md b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-reviewer-0.md new file mode 100644 index 0000000..e508afe --- /dev/null +++ b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-reviewer-0.md @@ -0,0 +1,66 @@ +# sub-reviewer-0 — adversarial spec review PE lũy kế tạm-tính (S134) + +> Ghi-đĩa-trong-lúc-làm (chống #53). Append từng issue khi tìm thấy. Return = digest. + +## Trạng thái +- [x] Đọc spec + investigator +- [x] Đọc code BE (accumulator :29-71 / features :880-1009 / workflow service :280-321, :840-949, :998-1041, :1112, :1128-1155 / seeder :1290-1352 / entity changelog / DTO :331-353) +- [x] Đọc code FE (PeDetailTabs :1290-1600 / PeWorkflowPanel :95-134, :400-539 + SHA tự đo 6 file) +- [x] 8 trục verdict — DONE, xem cuối file + +## VERDICT: GO_WITH_FIXES (0 Critical / 2 Major spec-fix / 9 nits — Major đều sửa bằng chữ trong spec trước khi hmw chạy) + +## Issues (append dần) + +### Trục 3 (D4) — kết quả soi từng nghi vấn +- **(a) CLEAR:** seeder `DbInitializer.cs:1328-1341` set snapshot **TRỰC TIẾP** (mirror thủ công, KHÔNG gọi helper) → D4 changelog-in-helper KHÔNG gây changelog rác lúc seed/restart. #75-class không xảy ra. +- **(b) CLEAR:** 5 call-site đều là finalize thật (Phase → DaDuyet): `:311` admin-override chỉ gọi helper khi `targetPhase == DaDuyet`; `:865` level-finalize; `:904` CCM-miễn-CEO; `:937` all-steps-done; `:1112` V1 legacy. Note "Duyệt khi N phiếu trước chưa duyệt" đúng ngữ cảnh cả 5. +- **(c) SaveChanges:** mọi path qua helper đều save — V2/V1 qua `TransitionAsync:301`, admin-override `:316`. OK. +- **[M1] D4 thiếu shape đầy đủ cho changelog row:** helper `ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct)` KHÔNG có `actorUserId`. `PurchaseEvaluationChangelog` (mirror `LogTransitionAsync:1145-1155`) cần `EntityType/Action/PhaseAtChange/UserId/UserName/Summary/ContextNote`. Spec chỉ nói ContextNote. Nếu hmw tự thêm param actor vào helper → ĐỔI 5 call-site → vi phạm chính acceptance A6 "0 đổi 5 call-site". Spec PHẢI chốt: UserId=null + UserName="Hệ thống" (audit actor tái dựng từ row Transition cùng batch) + EntityType/Action/PhaseAtChange value cụ thể — nếu không hmw sẽ tự quyết 2 hướng ngược nhau. +- **[N1] `AddAsync` sai pattern codebase:** `LogTransitionAsync:1145` dùng `db.PurchaseEvaluationChangelogs.Add(...)` (sync). Spec ghi "AddAsync" — hoạt động nhưng lệch pattern; nên ghi `.Add(...)`. +- **(d) Chi phí:** ComputePendingAsync ~3 query (list + delta1 + delta2), spec ghi "+1 query" là under-count — vẫn chấp nhận được (finalize tần suất thấp). Note. + +### Trục 2 — construction site +- Grep `new PeBudgetSummaryDto` toàn repo = ĐÚNG 2 site `Features.cs:946/:975` (khớp spec A2). Session log S74 xác nhận pattern grep này (gotcha #65). Tests KHÔNG construct DTO (chỉ 1 comment `PeBudgetFreezeTests.cs:21`) → append-cuối không gãy compile chỗ nào. +- **[M2] Banner PeWorkflowPanel KHÔNG tự ẩn trên phiếu terminal — mâu thuẫn nội tại spec:** predicate banner spec item 7 = `pendingPriorPes.length > 0 && !budgetFrozen` — KHÔNG có gate `readOnly`/phase. Phiếu TraLai/TuChoi/Nháp đi nhánh live (`Features.cs:957`) → pending fields FILL → banner "cần duyệt phiếu trước trước phiếu này" hiện trên phiếu ĐÃ từ chối/trả lại = sai ngữ cảnh. Edge-6 spec claim "banner duyệt tự ẩn (không còn action duyệt)" — SAI với predicate đã cho (banner không phụ thuộc action; V2-banner :418 có `!readOnly`, action buttons :439 có `next.length > 0` — banner spec không kế thừa gate nào). Fix: thêm `!readOnly && evaluation.phase === ChoDuyet` (hoặc tối thiểu `!readOnly && next.length > 0`) vào predicate banner + sửa câu edge-6 cho khớp. Dialog-block đã tự gate qua `isApproveAction` — không dính. + +### Verify PASS (positive — chống trượt kép) +- **SHA tự đo (git hash-object):** PeDetailTabs 2-app IDENTICAL `5534addb9a78...` ✅ khớp spec; PeWorkflowPanel IDENTICAL `a83a58e1d8e1...` ✅; types KHÁC `2bf7736`≠`ab4a2ec` ✅ (spec errata lead đúng — số investigator cũ `47c97df1`/`9d88e671` stale). Measured-label gate PASS. +- **Trục 1 (chính-xác bất động):** ComputeAsync untouched ⇒ mọi consumer bất động — live `:962`, snapshot `:1018`, backfill Mig 67 one-time, seeder direct-set `:1334-1340`. ComputePendingAsync chỉ ĐỌC (AsNoTracking window y hệt) + fill field MỚI + AddAsync changelog — không mutate field cũ nào. Không có đường lệch số cũ. +- **Soft-delete peer:** `PurchaseEvaluationConfiguration.cs:84` HasQueryFilter !IsDeleted ✅ tự áp cho ComputePendingAsync (cùng DbSet). +- **FE công thức khớp:** `row6 = row2 + row4` (:1318), `r5 = row1 + r3` per-cột (:1325) → row5_tạm = r5+delta1, row6_tạm = row6+delta2 đúng đại số. Render sites lũy kế toàn FE = CHỈ PeDetailTabs (:1315-1316/:1498/:1503 + r5 qua colCalc) — grep 2 app không còn site nào khác (không print/export/list) → spec liệt đủ, S89 miss-2-sites không tái diễn. +- **Trục 4 predicate:** delta1 NOT IN (1,7,10,99) = {98, legacy 2-6} đúng (10/7 đã trong row1 chính-xác — không double-count); delta2 NOT IN (1,7,99) ⊃ {10 có winner} = gap chính đề bài ✓ + {98, legacy} có winner hợp lý; pendingPriorPes NOT IN (7,99) ⊇ cả 2 delta-set + nháp list-only (D1) — 3 tập nhất quán. EF translate: `!array.Contains(p.Phase)` → NOT IN, enum non-null → không bẫy null-semantics. +- **Trục 5 dialog:** block chèn TRÊN bộ chọn giá chỉ RENDER thêm — `priceMissing` (:514) + footer disabled (:522) không đổi → acceptance B3 "diff vùng disabled = 0" đo được. +- **D4 mọi path SaveChanges:** V2/V1 → `TransitionAsync:301`; admin-override → `:316`. Changelog entity + DbSet có sẵn (`IApplicationDbContext.cs:63`). +- **Baseline test 520 khớp** (S133 suite 520/0). A5/A6/B1/B2/B3/B4/C1-C9 đều có lệnh đo được. + +### Trục 6 — acceptance đo được: PASS có 1 gap +- **[N5] A3 live-JSON verify cần DATA chưa tồn tại:** Dev DB multi-pair (cùng ProjectId+WorkItemId ≥2 phiếu) = 0 (invest PHẦN 5). A3/A4 curl cần dựng 2 phiếu cùng cặp trước — spec nên thêm 1 dòng "tạo 2 phiếu cùng Dự án × Hạng mục qua UI/API trước khi curl" để hmw không bịa acceptance đã-pass. +- B2 "phiếu frozen KHÔNG render (gate đầu nhánh)" hơi code-read-feel — chấp nhận được vì C7 (test DTO defaults) + gate biến grep được; có thể thêm "grep điều kiện `budgetFrozen` xuất hiện trong block tạm-tính". + +### Trục 7 — edges THÊM ngoài 7 edge spec +- **Tie CreatedAt bằng nhau:** strict `<` → 2 phiếu cùng timestamp không thấy nhau — pre-existing y hệt ComputeAsync (nhất quán, không mới). Không cần fix. +- **[N2] MaPhieu nullable:** entity `string? MaPhieu` (:10, gen at create `Features.cs:158` nhưng data cổ/nullable schema). DTO spec `PePendingPriorPeDto(Guid Id, string MaPhieu, int Phase)` non-null → nên `string?` + FE fallback "(chưa có mã)". +- **[N9] Danh sách pending KHÔNG render link:** approver click link phiếu Nháp → 403 S89 guard (`Features.cs:893-898`) → toast lỗi khó hiểu. Spec nên chốt "plain text mã + badge, KHÔNG link". +- **Peer có winner nhưng 0 quote IsSelected:** delta2 = 0 nhưng count đếm peer → sub-dòng "+0đ từ 1 phiếu" — mirror y semantics PrevSelectedCount hiện tại (chấp nhận, nhất quán). +- **DaDuyet-no-snapshot fallback [N7]:** `Features.cs:926-928` phiếu DaDuyet mà SnapshotAt null → nhánh live → BudgetFrozen=false → tạm-tính HIỆN trên phiếu DaDuyet dị dạng này. Pre-existing class (nhánh live đã hiện số live cho row1/2 từ S133) — sau backfill Mig 67 + 5 site + seeder về lý thuyết không còn row nào — ghi nhận để không ngạc nhiên UAT, không fix. +- **Performance:** ComputePendingAsync ~3 query (list + delta1 + delta2) mỗi GET detail non-frozen + mỗi finalize — bảng PE nhỏ, peers per cặp ít, chấp nhận. Spec ghi "+1 query" là under-count [N4]. +- **List dài không cap:** thực tế peers/cặp < 10 — không cần cap; nếu muốn phòng thì `.Take(20)` — optional. + +### Trục 8 — mâu thuẫn nội tại / thiếu cho hmw +- M1 (D4 shape) + M2 (banner gate vs edge-6) = 2 mâu thuẫn/thiếu chính — đã ghi trên. +- **[N1]** Spec ghi `AddAsync` — pattern codebase = `db.PurchaseEvaluationChangelogs.Add(...)` (`:1145`). Dùng Add cho khớp. +- **[N3]** Spec "map inline nhỏ nếu chưa có map dùng chung" — map ĐÃ CÓ: `PurchaseEvaluationPhaseLabel` (types cả 2 app, PeWorkflowPanel đang dùng :465) → chỉ thẳng dùng nó, khỏi inline. +- **[N6]** Cite "mirror pattern LogTransition `:1073`-vùng" — thực tế `LogTransitionAsync` ở `:1128-1155`. Số dòng lệch (hmw grep ra được nhưng sửa cho sạch). +- **[N8]** Sub-dòng "{n} phiếu chưa duyệt": chốt n = count TƯƠNG ỨNG delta (row1/5 → pendingSubmittedCount; row2/6 → pendingSelectedCount), KHÔNG dùng `pendingPriorPes.length` (list ⊇ delta-set, dùng nhầm → "từ 3 phiếu" khi delta chỉ từ 1). +- **INV-2 [A‖B] song song:** DTO field names + JSON shape đã pin cứng trong spec → B không chờ A, race DTO-shape KHÔNG thật. OK. + +## CHECKLIST-8-TRỤC (verdict per trục) +1. Chính-xác bất động: **PASS** — ComputeAsync untouched + 4 consumer enumerated + ComputePendingAsync read-only. +2. R1-S133 frozen: **PASS** (2 site construction verified duy nhất; DTO append-cuối an toàn; fallback DaDuyet-no-snapshot = pre-existing note N7). +3. D4-trong-helper: **PASS-WITH-M1** — seeder direct-set (không gọi helper) nên KHÔNG changelog rác; 5 site đều finalize thật + SaveChanges đủ; NHƯNG spec thiếu shape row (actor/EntityType/Action/vị trí trong nhánh wiKey) → M1. +4. Predicate mới: **PASS** — 3 tập nhất quán, không double-count, EF translate OK. +5. FE: **PASS-WITH-M2** — công thức + vị trí + priceMissing không đụng; banner thiếu gate readOnly/phase → M2. +6. Acceptance: **PASS** — đo được trừ A3 cần seed data (N5). +7. Edge sót: 5 edge mới soi — không cái nào block; 3 thành nits (N2/N7/N9). +8. Mâu thuẫn/thiếu: **2 Major + 5 nits** — đều sửa được bằng chữ trong spec, không cần đổi thiết kế. diff --git a/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-test-specialist-0.md b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-test-specialist-0.md new file mode 100644 index 0000000..101b13a --- /dev/null +++ b/.claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/sub-test-specialist-0.md @@ -0,0 +1,43 @@ +# sub-test-specialist-0 — TEST-C: PE lũy kế TẠM TÍNH (S134) + +## Nhiệm vụ +Viết test cho code BE ĐÃ LAND (INV-2): `PeBudgetAccumulator.ComputePendingAsync` + `PeBudgetSummaryDto` +5 field + D4 changelog on-finalize. C1-C9. KHÔNG touch prod. + +## Bước 1 — Đọc code under test (DONE) +- `tests/.../Services/PeBudgetFreezeTests.cs` — seed-helper pattern :277-336 (SeedWinnerQuoteAsync, SeedPeerAsync CreatedAt-override thủ công :332), T0 flow finalize path, T7 frozen query :730, C8-mirror pattern (Changelog discriminator). +- `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeBudgetAccumulator.cs`: + - `ComputePendingAsync` :92-139 — peers window Y HỆT ComputeAsync (:100-102: ProjectId+WorkItemId, Id!=peId, CreatedAt0 → +1 Changelog {EntityType=Workflow, Action=Update, PhaseAtChange=DaDuyet, UserId=null, UserName="Hệ thống", ContextNote chứa "[S134 pending-prior]" + priorList (MaPhieu + label)}. +- Phase enum: DangSoanThao=1, legacy 2-6, DaDuyet=7, ChoDuyet=10, TraLai=98, TuChoi=99. +- BaseEntity.CreatedAt = plain DateTime no-default → phải override thủ công + peCreatedAt muộn hơn. +- Helpers khả dụng cùng ns Services: NoOpNotificationService, FixedDateTime, TestCurrentUser, IdentityFixture. + +## Bước 3 — viết PePendingAccumulationTests.cs (DONE) +File MỚI `tests/SolutionErp.Infrastructure.Tests/Services/PePendingAccumulationTests.cs` — 8 [Fact] C1-C8, mirror seed-helper PeBudgetFreezeTests. Helper copy: CreateService, SeedProjectAndWorkItemAsync, SeedPairAsync (CCM-only), SeedWinnerQuoteAsync, SeedPeerAsync (return peer), SeedFinalizeWorkflowAsync, BuildPeAtFinalizeSlot, ApproveWithPriceAsync, ComputePendingAsync convenience (synthetic curPeId + CurCreatedAt=noon). + +| Test | Method | Kết quả | +|---|---|---| +| C1 | `ChoDuyetWinner_CountsInPendingSelected_AndListedInPriorPes` :158 | PendingSelected=70tr/1, Submitted=0, PriorPes Id+MaPhieu+Phase=10 | +| C2 | `TuChoiAndDaDuyetPeers_ContributeNothingToPending_AndAbsentFromPriorPes` :185 | delta all 0, PriorPes empty | +| C3 | `DraftPeer_ListOnly_TraLaiPeer_CountsInPendingSubmitted` :208 | Submitted=60tr(TraLai)/1 (Nháp 40tr list-only), PriorPes 2 order CreatedAt | +| C4 | `PeerCreatedAfterCurrent_ExcludedFromWindow` :234 | before lọt (25tr/1), after loại (NotContain) | +| C5 | `NegativeSelectedQuote_ProducesSignedPendingSelectedTotal` :257 | PendingSelected=-500k signed | +| C6 | `Compute_And_ComputePending_AreComplementary_NoDoubleCount` :278 | ComputeAsync PrevSelected=90tr/1 PrevSubmitted=130tr/2 bất động; Pending Selected=50tr/1 Submitted=0 | +| C7 | `FrozenQuery_ReturnsZeroPendingAndNullPriorPes_EvenWithPendingPeer` :312 | BudgetFrozen=true, Pending 4=0, PriorPes null (dù có peer ChoDuyet) | +| C8 | `Finalize_WithPendingPriorPeer_WritesAuditChangelog_ContrastNoPeerWritesNone` :355 | +1 D4 Update/Workflow/UserId=null/Hệ thống/ContextNote⊇MaPhieu A; contrast=0 | + +## Bước 4 — chạy suite (DONE) +- Filtered `~PePendingAccumulationTests`: **Passed 8/8, Failed 0** (3s). +- FULL `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1`: + - Domain **45 PASS** 0 fail; Infra **483 PASS** 0 fail → **528 total, 0 fail**. + - Baseline 520 (45D+475I) → **528 (45D+483I) = +8 Infra**. C9 PASS. +- Build 0 error. KHÔNG touch production. KHÔNG git add/commit/push. NO prod bug found. diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 5534add..d28eaa1 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -2,7 +2,7 @@ // NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình. // Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel // → PeApprovalsSection + PeHistorySection). -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useIsFetching, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' @@ -1181,12 +1181,13 @@ function BudgetNoteCell({ editable, value, setValue, savedValue, saving, onSave // [Mig 59] Ô số CHUNG mọi cột (Section B THỰC HIỆN) — 1 giá trị span hết 3 cột Dự án/PRO/CCM // (dòng 1/2/6 không tách cột). value null → "Chưa chọn". -function BudgetSharedNumCell({ value, colSpan }: { value: number | null; colSpan: number }) { +function BudgetSharedNumCell({ value, colSpan, sub }: { value: number | null; colSpan: number; sub?: ReactNode }) { return ( {value == null ? Chưa chọn : {fmtVnd(value)}} + {sub} ) } @@ -1194,8 +1195,8 @@ function BudgetSharedNumCell({ value, colSpan }: { value: number | null; colSpan // [Mig 59] Ô giá trị 1 cột (PRO/CCM) Section B — display read-only. value null → "—". // signed → fmtVndSigned (âm trong ngoặc). pct → dòng % phụ dưới. asCell bọc sẵn // (cho dòng 5/6/7/8 ccm); KHÔNG asCell → chỉ nội dung (nhúng vào editable dòng 3). -function BudgetColValue({ value, pct, signed = false, asCell = false }: { - value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean +function BudgetColValue({ value, pct, signed = false, asCell = false, sub }: { + value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean; sub?: ReactNode }) { const body = value == null ? @@ -1208,8 +1209,8 @@ function BudgetColValue({ value, pct, signed = false, asCell = false }: { ) return asCell - ? {body} - : <>{body} + ? {body}{sub} + : <>{body}{sub} } // [Mig 59] Ô "So sánh" 1 cột (PRO/CCM) Section B — = 0 → text "Bằng ngân sách" (reuse @@ -1317,6 +1318,24 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn) const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4) + // [S134 anh Kiệt FDC] Lũy kế TẠM TÍNH — phần góp từ phiếu cùng gói thầu CHƯA duyệt + // (?? guard data cũ chưa có field). Chỉ hiện khi phiếu CHƯA freeze (số DaDuyet là + // snapshot bất biến S133 — KHÔNG thêm tạm-tính). N8: row1/5 dùng pendingSubmitted*, + // row2/6 dùng pendingSelected*. + const pendingSubmittedTotal = bs.pendingSubmittedTotal ?? 0 + const pendingSubmittedCount = bs.pendingSubmittedCount ?? 0 + const pendingSelectedTotal = bs.pendingSelectedTotal ?? 0 + const pendingSelectedCount = bs.pendingSelectedCount ?? 0 + const pendingPriorPes = bs.pendingPriorPes ?? [] + const showPending = !bs.budgetFrozen && (pendingSubmittedCount > 0 || pendingSelectedCount > 0 || pendingPriorPes.length > 0) + // Sub-dòng amber "Tạm tính" — LUÔN kèm chữ "Tạm tính", KHÔNG bao giờ thay số chính xác. + const pendingSub = (base: number, delta: number, count: number, withDetail: boolean) => ( +
+ Tạm tính: {fmtVnd(base + delta)} + {withDetail && ` (+${fmtVnd(delta)} từ ${count} phiếu chưa duyệt)`} +
+ ) + // [Mig 59] Mỗi cột tính ĐỘC-LẬP theo "NS kỳ này" + "full" của CỘT đó. Helper trả về // bộ row3/5/7/8/9 + 3 so-sánh cho 1 cột. periodAmount = NS kỳ này (live); colFull = // full cột; remaining = giá trị TH dự kiến còn lại (null → default row7). @@ -1495,12 +1514,14 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: {/* 1 — Ngân sách trình duyệt trước (CHUNG) */} 1. Ngân sách trình duyệt trước - + 0 ? pendingSub(row1, pendingSubmittedTotal, pendingSubmittedCount, true) : undefined} /> {/* 2 — Kỳ trước đã chọn thầu (CHUNG) */} 2. Kỳ trước đã chọn thầu - + 0 ? pendingSub(row2, pendingSelectedTotal, pendingSelectedCount, true) : undefined} /> {/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */} @@ -1573,8 +1594,10 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
= 1 + 3
— - - + 0 ? pendingSub(pro.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} /> + 0 ? pendingSub(ccm.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} /> {/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */} @@ -1582,7 +1605,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: 6. Lũy kế thực hiện
= 2 + 4
- + 0 ? pendingSub(row6, pendingSelectedTotal, pendingSelectedCount, false) : undefined} /> @@ -1654,6 +1678,28 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: + {/* [S134] Danh sách phiếu trước cùng gói thầu CHƯA duyệt (informational — N9: + PLAIN TEXT + badge, KHÔNG link vì click phiếu Nháp/chưa-duyệt → 403 S89). */} + {showPending && pendingPriorPes.length > 0 && ( +
+ Phiếu trước cùng gói chưa duyệt: + {pendingPriorPes.map((p, i) => ( + + {i > 0 && ', '} + {p.maPhieu ?? '(chưa có mã)'}{' '} + + {PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`} + + + ))} +
+ )} ) } diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx index a83a58e..bebc0ad 100644 --- a/fe-admin/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx @@ -130,6 +130,17 @@ export function PeWorkflowPanel({ isStepAfterFinalize(stepOrder) || (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order && finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder) + // [S134 anh Kiệt FDC] Phiếu trước cùng gói thầu CHƯA duyệt (informational nudge — KHÔNG + // hard-block). M2: banner "cần duyệt phiếu trước" chỉ có nghĩa khi ĐANG chờ duyệt (phase + // ChoDuyet); phiếu Nháp/TraLai/TuChoi cũng có pending fill nhưng banner vô nghĩa ở đó. + const pendingPriors = evaluation.budgetSummary?.pendingPriorPes ?? [] + const showPendingBanner = pendingPriors.length > 0 + && !evaluation.budgetSummary?.budgetFrozen + && !readOnly + && evaluation.phase === PurchaseEvaluationPhase.ChoDuyet + const pendingPriorsText = pendingPriors + .map(p => `${p.maPhieu ?? '(chưa có mã)'} (${PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`})`) + .join(', ') // Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null). // [S117 anh Kiệt FDC] Khi PRO có CẢ Min & Max → thêm ứng viên "duyệt theo khoảng" // (source=ProMinMax, amount=Min, amountMax=Max). Chọn nó = chốt dải [Min, Max]. @@ -436,6 +447,14 @@ export function PeWorkflowPanel({ )} + {/* [S134] Banner cảnh báo phiếu trước cùng gói thầu CHƯA duyệt (M2: chỉ khi phase + ChoDuyet). Informational — KHÔNG disable nút duyệt. */} + {showPendingBanner && ( +
+ ⚠ {pendingPriors.length} phiếu trước cùng gói thầu CHƯA duyệt: {pendingPriorsText} — cần duyệt phiếu trước trước phiếu này. +
+ )} + {next.length > 0 && !readOnly && (
@@ -522,6 +541,13 @@ export function PeWorkflowPanel({ } > + {/* [S134] NHẮC LẠI phiếu trước chưa duyệt TRONG dialog duyệt (trên bộ chọn giá) + — người duyệt cuối chắc chắn thấy. KHÔNG đụng disable nút Xác nhận (B3). */} + {isApproveAction && showPendingBanner && ( +
+ ⚠ {pendingPriors.length} phiếu trước cùng gói thầu CHƯA duyệt: {pendingPriorsText} — cần duyệt phiếu trước trước phiếu này. +
+ )} {isCancel && (
⚠ Phiếu sẽ bị khoá hoàn toàn (không edit/transition được nữa). Drafter cần tạo phiếu mới nếu muốn làm lại. diff --git a/fe-admin/src/types/purchaseEvaluation.ts b/fe-admin/src/types/purchaseEvaluation.ts index ab4a2ec..49c1b81 100644 --- a/fe-admin/src/types/purchaseEvaluation.ts +++ b/fe-admin/src/types/purchaseEvaluation.ts @@ -329,6 +329,13 @@ export type PeBudgetSummary = { previousSelectedCount: number currentProposalTotal: number // SUM ThanhTien quotes NCC trúng (IsWinner) phiếu NÀY (0 khi chưa chọn) budgetFrozen: boolean // [S133] true = phiếu DaDuyet, số NS đã chốt snapshot + // [S134] Lũy kế TẠM TÍNH — phần góp từ phiếu cùng gói thầu CHƯA duyệt (Phase NOT IN + // {DaDuyet, TuChoi}). Cộng dồn để hiển thị số "tạm tính", KHÔNG thay số CHÍNH XÁC. + pendingSubmittedTotal: number // delta cộng vào previousSubmittedTotal (row 1/5) + pendingSubmittedCount: number + pendingSelectedTotal: number // delta cộng vào previousSelectedTotal (row 2/6) + pendingSelectedCount: number + pendingPriorPes: { id: string; maPhieu: string | null; phase: number }[] | null } // Mirror BE PeDepartmentKind enum diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index 5534add..d28eaa1 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -2,7 +2,7 @@ // NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình. // Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel // → PeApprovalsSection + PeHistorySection). -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useIsFetching, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' @@ -1181,12 +1181,13 @@ function BudgetNoteCell({ editable, value, setValue, savedValue, saving, onSave // [Mig 59] Ô số CHUNG mọi cột (Section B THỰC HIỆN) — 1 giá trị span hết 3 cột Dự án/PRO/CCM // (dòng 1/2/6 không tách cột). value null → "Chưa chọn". -function BudgetSharedNumCell({ value, colSpan }: { value: number | null; colSpan: number }) { +function BudgetSharedNumCell({ value, colSpan, sub }: { value: number | null; colSpan: number; sub?: ReactNode }) { return ( {value == null ? Chưa chọn : {fmtVnd(value)}} + {sub} ) } @@ -1194,8 +1195,8 @@ function BudgetSharedNumCell({ value, colSpan }: { value: number | null; colSpan // [Mig 59] Ô giá trị 1 cột (PRO/CCM) Section B — display read-only. value null → "—". // signed → fmtVndSigned (âm trong ngoặc). pct → dòng % phụ dưới. asCell bọc sẵn // (cho dòng 5/6/7/8 ccm); KHÔNG asCell → chỉ nội dung (nhúng vào editable dòng 3). -function BudgetColValue({ value, pct, signed = false, asCell = false }: { - value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean +function BudgetColValue({ value, pct, signed = false, asCell = false, sub }: { + value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean; sub?: ReactNode }) { const body = value == null ? @@ -1208,8 +1209,8 @@ function BudgetColValue({ value, pct, signed = false, asCell = false }: { ) return asCell - ? {body} - : <>{body} + ? {body}{sub} + : <>{body}{sub} } // [Mig 59] Ô "So sánh" 1 cột (PRO/CCM) Section B — = 0 → text "Bằng ngân sách" (reuse @@ -1317,6 +1318,24 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn) const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4) + // [S134 anh Kiệt FDC] Lũy kế TẠM TÍNH — phần góp từ phiếu cùng gói thầu CHƯA duyệt + // (?? guard data cũ chưa có field). Chỉ hiện khi phiếu CHƯA freeze (số DaDuyet là + // snapshot bất biến S133 — KHÔNG thêm tạm-tính). N8: row1/5 dùng pendingSubmitted*, + // row2/6 dùng pendingSelected*. + const pendingSubmittedTotal = bs.pendingSubmittedTotal ?? 0 + const pendingSubmittedCount = bs.pendingSubmittedCount ?? 0 + const pendingSelectedTotal = bs.pendingSelectedTotal ?? 0 + const pendingSelectedCount = bs.pendingSelectedCount ?? 0 + const pendingPriorPes = bs.pendingPriorPes ?? [] + const showPending = !bs.budgetFrozen && (pendingSubmittedCount > 0 || pendingSelectedCount > 0 || pendingPriorPes.length > 0) + // Sub-dòng amber "Tạm tính" — LUÔN kèm chữ "Tạm tính", KHÔNG bao giờ thay số chính xác. + const pendingSub = (base: number, delta: number, count: number, withDetail: boolean) => ( +
+ Tạm tính: {fmtVnd(base + delta)} + {withDetail && ` (+${fmtVnd(delta)} từ ${count} phiếu chưa duyệt)`} +
+ ) + // [Mig 59] Mỗi cột tính ĐỘC-LẬP theo "NS kỳ này" + "full" của CỘT đó. Helper trả về // bộ row3/5/7/8/9 + 3 so-sánh cho 1 cột. periodAmount = NS kỳ này (live); colFull = // full cột; remaining = giá trị TH dự kiến còn lại (null → default row7). @@ -1495,12 +1514,14 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: {/* 1 — Ngân sách trình duyệt trước (CHUNG) */} 1. Ngân sách trình duyệt trước - + 0 ? pendingSub(row1, pendingSubmittedTotal, pendingSubmittedCount, true) : undefined} /> {/* 2 — Kỳ trước đã chọn thầu (CHUNG) */} 2. Kỳ trước đã chọn thầu - + 0 ? pendingSub(row2, pendingSelectedTotal, pendingSelectedCount, true) : undefined} /> {/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */} @@ -1573,8 +1594,10 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
= 1 + 3
— - - + 0 ? pendingSub(pro.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} /> + 0 ? pendingSub(ccm.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} /> {/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */} @@ -1582,7 +1605,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: 6. Lũy kế thực hiện
= 2 + 4
- + 0 ? pendingSub(row6, pendingSelectedTotal, pendingSelectedCount, false) : undefined} /> @@ -1654,6 +1678,28 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
+ {/* [S134] Danh sách phiếu trước cùng gói thầu CHƯA duyệt (informational — N9: + PLAIN TEXT + badge, KHÔNG link vì click phiếu Nháp/chưa-duyệt → 403 S89). */} + {showPending && pendingPriorPes.length > 0 && ( +
+ Phiếu trước cùng gói chưa duyệt: + {pendingPriorPes.map((p, i) => ( + + {i > 0 && ', '} + {p.maPhieu ?? '(chưa có mã)'}{' '} + + {PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`} + + + ))} +
+ )}
) } diff --git a/fe-user/src/components/pe/PeWorkflowPanel.tsx b/fe-user/src/components/pe/PeWorkflowPanel.tsx index a83a58e..bebc0ad 100644 --- a/fe-user/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-user/src/components/pe/PeWorkflowPanel.tsx @@ -130,6 +130,17 @@ export function PeWorkflowPanel({ isStepAfterFinalize(stepOrder) || (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order && finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder) + // [S134 anh Kiệt FDC] Phiếu trước cùng gói thầu CHƯA duyệt (informational nudge — KHÔNG + // hard-block). M2: banner "cần duyệt phiếu trước" chỉ có nghĩa khi ĐANG chờ duyệt (phase + // ChoDuyet); phiếu Nháp/TraLai/TuChoi cũng có pending fill nhưng banner vô nghĩa ở đó. + const pendingPriors = evaluation.budgetSummary?.pendingPriorPes ?? [] + const showPendingBanner = pendingPriors.length > 0 + && !evaluation.budgetSummary?.budgetFrozen + && !readOnly + && evaluation.phase === PurchaseEvaluationPhase.ChoDuyet + const pendingPriorsText = pendingPriors + .map(p => `${p.maPhieu ?? '(chưa có mã)'} (${PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`})`) + .join(', ') // Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null). // [S117 anh Kiệt FDC] Khi PRO có CẢ Min & Max → thêm ứng viên "duyệt theo khoảng" // (source=ProMinMax, amount=Min, amountMax=Max). Chọn nó = chốt dải [Min, Max]. @@ -436,6 +447,14 @@ export function PeWorkflowPanel({ )} + {/* [S134] Banner cảnh báo phiếu trước cùng gói thầu CHƯA duyệt (M2: chỉ khi phase + ChoDuyet). Informational — KHÔNG disable nút duyệt. */} + {showPendingBanner && ( +
+ ⚠ {pendingPriors.length} phiếu trước cùng gói thầu CHƯA duyệt: {pendingPriorsText} — cần duyệt phiếu trước trước phiếu này. +
+ )} + {next.length > 0 && !readOnly && (
@@ -522,6 +541,13 @@ export function PeWorkflowPanel({ } > + {/* [S134] NHẮC LẠI phiếu trước chưa duyệt TRONG dialog duyệt (trên bộ chọn giá) + — người duyệt cuối chắc chắn thấy. KHÔNG đụng disable nút Xác nhận (B3). */} + {isApproveAction && showPendingBanner && ( +
+ ⚠ {pendingPriors.length} phiếu trước cùng gói thầu CHƯA duyệt: {pendingPriorsText} — cần duyệt phiếu trước trước phiếu này. +
+ )} {isCancel && (
⚠ Phiếu sẽ bị khoá hoàn toàn (không edit/transition được nữa). Drafter cần tạo phiếu mới nếu muốn làm lại. diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts index 2bf7736..536554b 100644 --- a/fe-user/src/types/purchaseEvaluation.ts +++ b/fe-user/src/types/purchaseEvaluation.ts @@ -331,6 +331,13 @@ export type PeBudgetSummary = { previousSelectedCount: number currentProposalTotal: number // SUM ThanhTien quotes NCC trúng (IsWinner) phiếu NÀY (0 khi chưa chọn) budgetFrozen: boolean // [S133] true = phiếu DaDuyet, số NS đã chốt snapshot + // [S134] Lũy kế TẠM TÍNH — phần góp từ phiếu cùng gói thầu CHƯA duyệt (Phase NOT IN + // {DaDuyet, TuChoi}). Cộng dồn để hiển thị số "tạm tính", KHÔNG thay số CHÍNH XÁC. + pendingSubmittedTotal: number // delta cộng vào previousSubmittedTotal (row 1/5) + pendingSubmittedCount: number + pendingSelectedTotal: number // delta cộng vào previousSelectedTotal (row 2/6) + pendingSelectedCount: number + pendingPriorPes: { id: string; maPhieu: string | null; phase: number }[] | null } // Mirror BE PeDepartmentKind enum diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs index 7272075..318e5cd 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs @@ -318,6 +318,11 @@ public record PurchaseEvaluationDetailBundleDto( string? FinalizeLevelName, PurchaseEvaluationWorkflowSummaryDto Workflow); +// [S134 pe-luyke-tam-tinh] Phiếu TRƯỚC cùng cặp (ProjectId, WorkItemId) CHƯA terminal — +// render list "cần lưu ý" ở block ngân sách. MaPhieu nullable (entity string?, data cổ có null). +// Phase = int (PurchaseEvaluationPhase underlying) để FE map label VN qua PurchaseEvaluationPhaseLabel. +public record PePendingPriorPeDto(Guid Id, string? MaPhieu, int Phase); + // [S61 Mig 50] Bảng "TỔNG HỢP NGÂN SÁCH TRÌNH KÝ" theo Excel anh Kiệt — BE compute // kèm PE detail GET. Record PeWorkItemBudgets dùng chung mọi phiếu cùng cặp // (ProjectId, WorkItemId). BudgetId null = phiếu cũ chưa gắn Hạng mục. @@ -350,4 +355,12 @@ public record PeBudgetSummaryDto( // [S133] true = phiếu DaDuyet serve từ snapshot ngân sách (bất biến, đọc cột ApprovedBudget* // thay record PeWorkItemBudgets live); false = phiếu chưa duyệt đọc LIVE. FE badge "đã chốt" + // ẩn nút sửa. Append CUỐI record (default false) → construction site cũ không gãy. - bool BudgetFrozen = false); + bool BudgetFrozen = false, + // [S134 pe-luyke-tam-tinh] Lũy kế TẠM TÍNH — phiếu TRƯỚC cùng cặp đã trình/chọn NHƯNG CHƯA duyệt + // xong (BỔ SUNG số CHÍNH XÁC PreviousSubmitted*/PreviousSelected*, KHÔNG thay). Append CUỐI record + // (default 0/null) → construction site cũ + nhánh frozen (DaDuyet bất biến) không gãy. + decimal PendingSubmittedTotal = 0, + int PendingSubmittedCount = 0, + decimal PendingSelectedTotal = 0, + int PendingSelectedCount = 0, + IReadOnlyList? PendingPriorPes = null); diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeBudgetAccumulator.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeBudgetAccumulator.cs index b78d807..c7b5468 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeBudgetAccumulator.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeBudgetAccumulator.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.PurchaseEvaluations.Dtos; using SolutionErp.Domain.PurchaseEvaluations; namespace SolutionErp.Application.PurchaseEvaluations; @@ -24,6 +25,16 @@ public readonly record struct PeBudgetAccumulation( int PrevSelectedCount, decimal CurrentProposalTotal); +// [S134 pe-luyke-tam-tinh] Lũy kế TẠM TÍNH — phiếu TRƯỚC cùng cặp (ProjectId, WorkItemId) +// đã trình/đã chọn NHƯNG CHƯA duyệt xong. Bổ sung display "tạm tính" — KHÔNG đổi số CHÍNH XÁC +// (PeBudgetAccumulation qua ComputeAsync bất động). +public readonly record struct PePendingAccumulation( + decimal PendingSubmittedTotal, + int PendingSubmittedCount, + decimal PendingSelectedTotal, + int PendingSelectedCount, + IReadOnlyList PriorPes); + public static class PeBudgetAccumulator { public static async Task ComputeAsync( @@ -69,4 +80,61 @@ public static class PeBudgetAccumulator prevSelectedTotal, prevSelectedCount, currentProposalTotal); } + + // [S134] Lũy kế TẠM TÍNH — CHỈ ĐỌC, KHÔNG mutate. peers = window Y HỆT ComputeAsync (:38-40). + // PendingSubmitted* = peers Phase ∉ {DangSoanThao, DaDuyet, ChoDuyet, TuChoi} = {TraLai + legacy 2-6} + // → Count + SUM(BudgetPeriodAmount ?? 0). ChoDuyet/DaDuyet ĐÃ nằm trong + // ComputeAsync PrevSubmitted (chính xác) → loại-trừ tránh double-count. + // PendingSelected* = peers Phase ∉ {DangSoanThao, DaDuyet, TuChoi} AND ≥1 Supplier IsWinner + // (GỒM ChoDuyet-có-winner = gap chính đề bài) → Count + SUM(ThanhTien IsSelected). + // Mirror shape ComputeAsync :50-60 (join Suppliers→Quotes). + // PriorPes = peers CHƯA terminal (Phase ∉ {DaDuyet, TuChoi}) → list render, order CreatedAt. + public static async Task ComputePendingAsync( + IApplicationDbContext db, + Guid projectId, + Guid workItemId, + Guid peId, + DateTime peCreatedAt, + CancellationToken ct) + { + var peers = db.PurchaseEvaluations.AsNoTracking() + .Where(p => p.ProjectId == projectId && p.WorkItemId == workItemId + && p.Id != peId && p.CreatedAt < peCreatedAt); + + var pendingSubmitted = await peers + .Where(p => p.Phase != PurchaseEvaluationPhase.DangSoanThao + && p.Phase != PurchaseEvaluationPhase.DaDuyet + && p.Phase != PurchaseEvaluationPhase.ChoDuyet + && p.Phase != PurchaseEvaluationPhase.TuChoi) + .Select(p => p.BudgetPeriodAmount) + .ToListAsync(ct); + var pendingSubmittedCount = pendingSubmitted.Count; + var pendingSubmittedTotal = pendingSubmitted.Sum(v => v ?? 0m); + + var pendingSelectedPeers = peers.Where(p => p.Phase != PurchaseEvaluationPhase.DangSoanThao + && p.Phase != PurchaseEvaluationPhase.DaDuyet + && p.Phase != PurchaseEvaluationPhase.TuChoi + && p.Suppliers.Any(s => s.IsWinner)); + var pendingSelectedCount = await pendingSelectedPeers.CountAsync(ct); + var pendingSelectedTotal = await ( + from p in pendingSelectedPeers + join s in db.PurchaseEvaluationSuppliers.AsNoTracking() + on p.Id equals s.PurchaseEvaluationId + join q in db.PurchaseEvaluationQuotes.AsNoTracking() + on s.Id equals q.PurchaseEvaluationSupplierId + where q.IsSelected + select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m; + + var priorPes = await peers + .Where(p => p.Phase != PurchaseEvaluationPhase.DaDuyet + && p.Phase != PurchaseEvaluationPhase.TuChoi) + .OrderBy(p => p.CreatedAt) + .Select(p => new PePendingPriorPeDto(p.Id, p.MaPhieu, (int)p.Phase)) + .ToListAsync(ct); + + return new PePendingAccumulation( + pendingSubmittedTotal, pendingSubmittedCount, + pendingSelectedTotal, pendingSelectedCount, + priorPes); + } } diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index 3830f5f..96e6365 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -952,7 +952,11 @@ public class GetPurchaseEvaluationQueryHandler( e.ApprovedBudgetPrevSelectedTotal ?? 0m, e.ApprovedBudgetPrevSelectedCount ?? 0, currentProposalTotal, e.ApprovedBudgetProInitialAmount, e.ApprovedBudgetProAdjustmentAmount, - BudgetFrozen: true); + BudgetFrozen: true, + // [S134] phiếu DaDuyet bất biến — KHÔNG lũy kế tạm-tính (giữ defaults 0/null). + PendingSubmittedTotal: 0m, PendingSubmittedCount: 0, + PendingSelectedTotal: 0m, PendingSelectedCount: 0, + PendingPriorPes: null); } else { @@ -962,6 +966,12 @@ public class GetPurchaseEvaluationQueryHandler( var acc = await PeBudgetAccumulator.ComputeAsync( db, e.ProjectId, wiKey, e.Id, e.CreatedAt, curSupplierRowIds, ct); + // [S134] Lũy kế TẠM TÍNH — phiếu TRƯỚC cùng cặp đã trình/chọn NHƯNG CHƯA duyệt xong + // (row 5/6 "tạm tính" + list phiếu cần lưu ý). CHỈ nhánh live (phiếu chưa chốt); nhánh + // frozen giữ defaults 0/null. KHÔNG đổi số CHÍNH XÁC của acc (ComputeAsync bất động). + var pending = await PeBudgetAccumulator.ComputePendingAsync( + db, e.ProjectId, wiKey, e.Id, e.CreatedAt, ct); + // [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho Block B = // CCM nếu đã nhập, else PRO (FullIsEstimate=true → FE badge "ngân sách PRO"). PRO full = // ProInitial + ProAdjust (migrate từ ProEstimate cũ qua Mig 56). Cả 2 trống → full 0. @@ -981,7 +991,13 @@ public class GetPurchaseEvaluationQueryHandler( acc.PrevSelectedTotal, acc.PrevSelectedCount, acc.CurrentProposalTotal, pairRec?.ProInitialAmount, pairRec?.ProAdjustmentAmount, - BudgetFrozen: false); + BudgetFrozen: false, + // [S134] lũy kế tạm-tính chỉ nhánh live. + PendingSubmittedTotal: pending.PendingSubmittedTotal, + PendingSubmittedCount: pending.PendingSubmittedCount, + PendingSelectedTotal: pending.PendingSelectedTotal, + PendingSelectedCount: pending.PendingSelectedCount, + PendingPriorPes: pending.PriorPes); } } diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 4d4b4fa..59fbe2d 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -1021,6 +1021,36 @@ public class PurchaseEvaluationWorkflowService( evaluation.ApprovedBudgetPrevSubmittedCount = acc.PrevSubmittedCount; evaluation.ApprovedBudgetPrevSelectedTotal = acc.PrevSelectedTotal; evaluation.ApprovedBudgetPrevSelectedCount = acc.PrevSelectedCount; + + // [S134 pending-prior] D4 audit-note — nếu finalize phiếu này mà VẪN còn phiếu TRƯỚC + // cùng gói CHƯA duyệt xong (nháp/đang trình/trả lại) → ghi 1 changelog Action=Update + // (KHÔNG Transition — caller đã ghi row Transition riêng qua LogTransitionAsync; 2 row + // Transition = UI hiểu nhầm 2 lần chuyển phase, gotcha #49). KHÔNG SaveChanges (caller lo). + var pending = await SolutionErp.Application.PurchaseEvaluations.PeBudgetAccumulator.ComputePendingAsync( + db, evaluation.ProjectId, wiKey, evaluation.Id, evaluation.CreatedAt, ct); + if (pending.PriorPes.Count > 0) + { + static string PendingPhaseLabel(int phase) => phase switch + { + (int)PurchaseEvaluationPhase.DangSoanThao => "Nháp", + (int)PurchaseEvaluationPhase.ChoDuyet => "Đã gửi duyệt", + (int)PurchaseEvaluationPhase.TraLai => "Trả lại", + _ => "Đang duyệt", + }; + var priorList = string.Join(", ", pending.PriorPes.Select(pr => + $"{pr.MaPhieu ?? "(chưa có mã)"} ({PendingPhaseLabel(pr.Phase)})")); + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = evaluation.Id, + EntityType = PurchaseEvaluationEntityType.Workflow, + Action = ChangelogAction.Update, + PhaseAtChange = PurchaseEvaluationPhase.DaDuyet, + UserId = null, + UserName = "Hệ thống", + 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 {pending.PriorPes.Count} phiếu trước cùng gói chưa duyệt: {priorList}", + }); + } } else { diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PePendingAccumulationTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PePendingAccumulationTests.cs new file mode 100644 index 0000000..f56b37f --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PePendingAccumulationTests.cs @@ -0,0 +1,497 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.PurchaseEvaluations; // PeBudgetAccumulator, ComputeAsync/ComputePendingAsync, GetPurchaseEvaluationQuery +using SolutionErp.Application.PurchaseEvaluations.Dtos; // PePendingPriorPeDto +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Contracts; // ApprovalDecision, ChangelogAction +using SolutionErp.Domain.Identity; // AppRoles +using SolutionErp.Domain.Master; +using SolutionErp.Domain.Master.Catalogs; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// ===== S134 (2026-07-17) TEST-C — PE lũy kế TẠM TÍNH (PeBudgetAccumulator.ComputePendingAsync) ===== +// Code BE ĐÃ LAND (INV-2): lũy kế "phiếu TRƯỚC cùng cặp (ProjectId, WorkItemId) CHƯA duyệt xong" +// BỔ SUNG (KHÔNG thay) số CHÍNH XÁC ComputeAsync (S133). 3 tầng cần phủ: +// (1) PeBudgetAccumulator.ComputePendingAsync — window Y HỆT ComputeAsync (peers cùng cặp, +// Id != peId, CreatedAt < peCreatedAt). 3 nhóm số: +// PendingSubmitted = Phase ∉ {DangSoanThao=1, DaDuyet=7, ChoDuyet=10, TuChoi=99} (= TraLai=98 +// + legacy 2-6) → Count + SUM(BudgetPeriodAmount ?? 0). ChoDuyet/DaDuyet +// ĐÃ nằm trong ComputeAsync PrevSubmitted → loại tránh double-count. +// PendingSelected = Phase ∉ {DangSoanThao, DaDuyet, TuChoi} && Suppliers.Any(IsWinner) +// (GỒM ChoDuyet-có-winner = gap chính đề bài) → Count + SUM(ThanhTien IsSelected). +// PriorPes = Phase ∉ {DaDuyet, TuChoi} orderby CreatedAt → PePendingPriorPeDto(Id, MaPhieu, (int)Phase). +// (2) GetPurchaseEvaluationQuery: nhánh live fill 5 field / nhánh frozen (DaDuyet + SnapshotAt) defaults 0/null. +// (3) D4 ApplyBudgetSnapshotOnFinalizeAsync: finalize khi còn phiếu trước chưa duyệt → +1 Changelog audit. +// +// Test theo CODE (S34 single source of truth). TEST-AFTER (code đã land, build 0/0). KHÔNG touch +// production (src/Backend, fe-*). Mirror seed-helper PeBudgetFreezeTests :277-336 (CreatedAt override +// thủ công — TestApplicationDbContext KHÔNG wire audit interceptor). +public class PePendingAccumulationTests +{ + // Giá chốt hợp lệ khi finalize (ApplyApprovedPriceOnFinalize đòi source ∈ ValidApprovedPriceSources). + private const decimal ValidApprovedPrice = 400_000_000m; + private const string ValidApprovedSource = "Ncc"; + + // Mốc thời gian: peers seed TRƯỚC (BaseT.AddDays(-n)); "phiếu đang xét" = CurCreatedAt (muộn hơn). + private static readonly DateTime BaseT = new(2026, 7, 17, 8, 0, 0, DateTimeKind.Utc); + private static readonly DateTime CurCreatedAt = new(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); + + private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, + TestApplicationDbContext db) CreateService() + { + var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var clock = new FixedDateTime(new DateTime(2026, 7, 17, 0, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationService(); + var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um); + return (svc, fix, db); + } + + // ---- shared seed helpers (mirror PeBudgetFreezeTests) ---- + + private static async Task<(Project project, WorkItem wi)> SeedProjectAndWorkItemAsync( + TestApplicationDbContext db, string suffix) + { + var project = new Project { Id = Guid.NewGuid(), Code = "PRJ-PA-" + suffix, Name = "Dự án " + suffix }; + var wi = new WorkItem { Id = Guid.NewGuid(), Code = "WI-PA-" + suffix, Name = "Hạng mục " + suffix, IsActive = true }; + db.Projects.Add(project); + db.WorkItems.Add(wi); + await db.SaveChangesAsync(CancellationToken.None); + return (project, wi); + } + + // 1 record PeWorkItemBudget per cặp (ProjectId × WorkItemId). NAMING: Initial/Adjustment = CCM. + private static async Task SeedPairAsync( + TestApplicationDbContext db, Guid projectId, Guid wiId, decimal? ccmInit) + { + db.PeWorkItemBudgets.Add(new PeWorkItemBudget + { + Id = Guid.NewGuid(), + ProjectId = projectId, + WorkItemId = wiId, + InitialAmount = ccmInit, + }); + await db.SaveChangesAsync(CancellationToken.None); + } + + // Seed 1 đơn vị IsWinner + 1 detail + 1 quote IsSelected cho phiếu (own quotes → winnerQuoteTotal). + private static async Task SeedWinnerQuoteAsync( + TestApplicationDbContext db, PurchaseEvaluation pe, decimal thanhTien) + { + var pes = new PurchaseEvaluationSupplier + { + Id = Guid.NewGuid(), + PurchaseEvaluationId = pe.Id, + SupplierId = Guid.NewGuid(), + Order = 0, + IsWinner = true, + }; + var detail = new PurchaseEvaluationDetail + { + Id = Guid.NewGuid(), + PurchaseEvaluationId = pe.Id, + GroupCode = "A.I", + GroupName = "Bê tông", + NoiDung = "Concrete", + Order = 0, + }; + db.PurchaseEvaluationSuppliers.Add(pes); + db.PurchaseEvaluationDetails.Add(detail); + db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote + { + Id = Guid.NewGuid(), + PurchaseEvaluationDetailId = detail.Id, + PurchaseEvaluationSupplierId = pes.Id, + ThanhTien = thanhTien, + IsSelected = true, + }); + await db.SaveChangesAsync(CancellationToken.None); + } + + // Peer PE cùng cặp cho accumulator (lũy kế phiếu TRƯỚC). CreatedAt override thủ công. + private static async Task SeedPeerAsync( + TestApplicationDbContext db, Guid projectId, Guid wiId, + PurchaseEvaluationPhase phase, decimal? budgetPeriod, DateTime createdAt, + decimal? winnerQuote, string code) + { + var peer = new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = phase, + MaPhieu = code, + TenGoiThau = "Peer " + code, + ProjectId = projectId, + WorkItemId = wiId, + DrafterUserId = Guid.NewGuid(), + BudgetPeriodAmount = budgetPeriod, + }; + db.PurchaseEvaluations.Add(peer); + await db.SaveChangesAsync(CancellationToken.None); + peer.CreatedAt = createdAt; // accumulator dùng CreatedAt < peCreatedAt + await db.SaveChangesAsync(CancellationToken.None); + if (winnerQuote is decimal wq) + await SeedWinnerQuoteAsync(db, peer, wq); + return peer; + } + + // Workflow V2 1 Bước 1 Cấp AllowApproverFinalize=true → duyệt slot cuối = DaDuyet (finalize-branch). + private static async Task SeedFinalizeWorkflowAsync( + TestApplicationDbContext db, Guid approverUserId, string code) + { + var wf = new ApprovalWorkflow + { + Code = code, + Version = 1, + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + Name = "QT test pending-accumulation", + IsActive = true, + IsUserSelectable = true, + }; + var step = new ApprovalWorkflowStep { ApprovalWorkflowId = wf.Id, Order = 1, Name = "Bước 1" }; + step.Levels.Add(new ApprovalWorkflowLevel + { + ApprovalWorkflowStepId = step.Id, + Order = 1, + Name = "Cấp 1", + ApproverUserId = approverUserId, + AllowApproverFinalize = true, + }); + wf.Steps.Add(step); + db.ApprovalWorkflows.Add(wf); + await db.SaveChangesAsync(CancellationToken.None); + return wf; + } + + // PE đứng tại slot duyệt cuối V2 (ChoDuyet) gắn cặp + workflow đã pin. + private static PurchaseEvaluation BuildPeAtFinalizeSlot( + Guid approvalWorkflowId, Guid projectId, Guid? workItemId, Guid drafterUserId, string code) + => new() + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.ChoDuyet, + MaPhieu = code, + TenGoiThau = "Gói thầu " + code, + ProjectId = projectId, + WorkItemId = workItemId, + DrafterUserId = drafterUserId, + ApprovalWorkflowId = approvalWorkflowId, + CurrentWorkflowStepIndex = 0, + CurrentApprovalLevelOrder = 1, + SlaDeadline = new DateTime(2026, 7, 24, 0, 0, 0, DateTimeKind.Utc), + }; + + // Approve-in-place + giá chốt hợp lệ → DaDuyet qua finalize path. + private static Task ApproveWithPriceAsync( + PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid approver, string[] roles) => + svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.ChoDuyet, + actorUserId: approver, + actorRoles: roles, + decision: ApprovalDecision.Approve, + comment: null, + approvedPriceAmount: ValidApprovedPrice, + approvedPriceSource: ValidApprovedSource, + ct: CancellationToken.None); + + // Direct call convenience — curPeId synthetic (KHÔNG cần row phiếu đang xét; accumulator chỉ + // filter p.Id != peId && p.CreatedAt < peCreatedAt). Window = CurCreatedAt (muộn hơn mọi peer sớm). + private static Task ComputePendingAsync( + TestApplicationDbContext db, Guid projectId, Guid wiId) => + PeBudgetAccumulator.ComputePendingAsync(db, projectId, wiId, Guid.NewGuid(), CurCreatedAt, CancellationToken.None); + + // ===================================================================== + // C1 — peer ChoDuyet + IsWinner + quote IsSelected → PendingSelected (gap chính đề bài): + // Total = SUM ThanhTien, Count = 1; peer CÓ MẶT trong PriorPes (đúng Id + MaPhieu + Phase=10). + // PendingSubmitted = 0 (ChoDuyet CỐ Ý loại — đã nằm ComputeAsync PrevSubmitted, tránh double-count). + // ===================================================================== + [Fact] + public async Task ChoDuyetWinner_CountsInPendingSelected_AndListedInPriorPes() + { + var (_, fix, db) = CreateService(); + using (fix) + { + var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C1"); + var peer = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet, + budgetPeriod: 30_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: 70_000_000m, code: "PE-C1-CD"); + + var r = await ComputePendingAsync(db, project.Id, wi.Id); + + r.PendingSelectedTotal.Should().Be(70_000_000m, "peer ChoDuyet + winner + quote IsSelected → tạm tính đã chọn"); + r.PendingSelectedCount.Should().Be(1); + r.PendingSubmittedTotal.Should().Be(0m, "ChoDuyet loại khỏi PendingSubmitted (đã nằm ComputeAsync PrevSubmitted)"); + r.PendingSubmittedCount.Should().Be(0); + + r.PriorPes.Should().ContainSingle("ChoDuyet ∉ {DaDuyet, TuChoi} → có trong danh sách cần lưu ý"); + var prior = r.PriorPes.Single(); + prior.Id.Should().Be(peer.Id); + prior.MaPhieu.Should().Be("PE-C1-CD"); + prior.Phase.Should().Be(10, "Phase = (int)ChoDuyet"); + } + } + + // ===================================================================== + // C2 — peer TuChoi → mọi delta 0 + KHÔNG trong PriorPes; peer DaDuyet → delta 0 (nằm bên + // ComputeAsync chính xác, KHÔNG lũy kế tạm tính) + KHÔNG trong PriorPes. + // ===================================================================== + [Fact] + public async Task TuChoiAndDaDuyetPeers_ContributeNothingToPending_AndAbsentFromPriorPes() + { + var (_, fix, db) = CreateService(); + using (fix) + { + var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C2"); + // Cả 2 peer đều có winner + budget để CHỨNG MINH exclusion theo Phase (không phải thiếu data). + await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.TuChoi, + budgetPeriod: 50_000_000m, createdAt: BaseT.AddDays(-3), winnerQuote: 30_000_000m, code: "PE-C2-REJ"); + await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet, + budgetPeriod: 100_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: 90_000_000m, code: "PE-C2-DD"); + + var r = await ComputePendingAsync(db, project.Id, wi.Id); + + r.PendingSubmittedTotal.Should().Be(0m, "TuChoi + DaDuyet đều bị loại khỏi PendingSubmitted"); + r.PendingSubmittedCount.Should().Be(0); + r.PendingSelectedTotal.Should().Be(0m, "TuChoi + DaDuyet đều bị loại khỏi PendingSelected dù có winner"); + r.PendingSelectedCount.Should().Be(0); + r.PriorPes.Should().BeEmpty("cả 2 phase = terminal → không nằm danh sách cần lưu ý"); + } + } + + // ===================================================================== + // C3 — peer DangSoanThao (Nháp) → TRONG PriorPes NHƯNG delta tiền = 0 (D1 list-only); peer TraLai + // có BudgetPeriodAmount → PendingSubmittedTotal đúng (D2). Chứng minh: budget 40tr của Nháp + // KHÔNG cộng, budget 60tr của TraLai CÓ cộng. + // ===================================================================== + [Fact] + public async Task DraftPeer_ListOnly_TraLaiPeer_CountsInPendingSubmitted() + { + var (_, fix, db) = CreateService(); + using (fix) + { + var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C3"); + var draft = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DangSoanThao, + budgetPeriod: 40_000_000m, createdAt: BaseT.AddDays(-3), winnerQuote: null, code: "PE-C3-NHAP"); + var traLai = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.TraLai, + budgetPeriod: 60_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: null, code: "PE-C3-TRALAI"); + + var r = await ComputePendingAsync(db, project.Id, wi.Id); + + r.PendingSubmittedTotal.Should().Be(60_000_000m, "chỉ TraLai cộng (D2); Nháp 40tr là list-only (D1)"); + r.PendingSubmittedCount.Should().Be(1, "chỉ TraLai; DangSoanThao loại khỏi PendingSubmitted"); + r.PendingSelectedTotal.Should().Be(0m, "không peer nào có winner"); + r.PendingSelectedCount.Should().Be(0); + + r.PriorPes.Should().HaveCount(2, "Nháp + TraLai đều ∉ {DaDuyet, TuChoi}"); + r.PriorPes.Select(p => p.Id).Should().ContainInOrder(new[] { draft.Id, traLai.Id }, "orderby CreatedAt"); + r.PriorPes.Should().Contain(p => p.Id == draft.Id && p.Phase == 1, + "peer Nháp CÓ trong danh sách (list-only) dù đóng góp tiền = 0"); + } + } + + // ===================================================================== + // C4 — peer-window: peer CreatedAt > phiếu đang xét → loại HOÀN TOÀN (mirror PeBudgetFreezeTests + // :435-451). Contrast: peer TRƯỚC vẫn lọt để chứng minh filter phân biệt đúng chiều. + // ===================================================================== + [Fact] + public async Task PeerCreatedAfterCurrent_ExcludedFromWindow() + { + var (_, fix, db) = CreateService(); + using (fix) + { + var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C4"); + // (a) peer TRƯỚC CurCreatedAt (noon) → LỌT. + var before = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet, + budgetPeriod: 10_000_000m, createdAt: BaseT.AddDays(-1), winnerQuote: 25_000_000m, code: "PE-C4-BEFORE"); + // (b) peer SAU CurCreatedAt → LOẠI hoàn toàn. + var after = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet, + budgetPeriod: 999_000_000m, createdAt: CurCreatedAt.AddHours(1), winnerQuote: 999_000_000m, code: "PE-C4-AFTER"); + + var r = await ComputePendingAsync(db, project.Id, wi.Id); + + r.PendingSelectedTotal.Should().Be(25_000_000m, "chỉ peer TRƯỚC lọt; peer SAU (999tr) bị loại khỏi window"); + r.PendingSelectedCount.Should().Be(1); + r.PriorPes.Should().ContainSingle().Which.Id.Should().Be(before.Id); + r.PriorPes.Should().NotContain(p => p.Id == after.Id, "peer CreatedAt > current → ngoài window"); + } + } + + // ===================================================================== + // C5 — quote ÂM trong PendingSelected (signed-sum): peer ChoDuyet + winner + quote −500k → + // PendingSelectedTotal = −500k (báo giá âm = phát-sinh-giảm/hoàn NCC, KHÔNG clamp). + // ===================================================================== + [Fact] + public async Task NegativeSelectedQuote_ProducesSignedPendingSelectedTotal() + { + var (_, fix, db) = CreateService(); + using (fix) + { + var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C5"); + await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet, + budgetPeriod: null, createdAt: BaseT.AddDays(-1), winnerQuote: -500_000m, code: "PE-C5-NEG"); + + var r = await ComputePendingAsync(db, project.Id, wi.Id); + + r.PendingSelectedTotal.Should().Be(-500_000m, "signed-sum: quote âm giữ dấu, KHÔNG clamp về 0"); + r.PendingSelectedCount.Should().Be(1); + } + } + + // ===================================================================== + // C6 REGRESSION chính-xác bất động: cùng seed {DaDuyet+winner, ChoDuyet+winner}, gọi CẢ + // ComputeAsync (S133 số CHÍNH XÁC) và ComputePendingAsync (S134 tạm tính) — chứng minh 2 hàm + // BỔ SUNG nhau, KHÔNG chồng lấn: + // ComputeAsync.PrevSelected = CHỈ DaDuyet+winner (thêm peer ChoDuyet KHÔNG đổi). + // ComputeAsync.PrevSubmitted = ChoDuyet + DaDuyet (như cũ). + // ComputePendingAsync.PendingSelected = CHỈ ChoDuyet+winner (DaDuyet loại). + // ComputePendingAsync.PendingSubmitted = 0 (ChoDuyet + DaDuyet đều loại khỏi tạm tính). + // ===================================================================== + [Fact] + public async Task Compute_And_ComputePending_AreComplementary_NoDoubleCount() + { + var (_, fix, db) = CreateService(); + using (fix) + { + var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C6"); + await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet, + budgetPeriod: 100_000_000m, createdAt: BaseT.AddDays(-3), winnerQuote: 90_000_000m, code: "PE-C6-DD"); + await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet, + budgetPeriod: 30_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: 50_000_000m, code: "PE-C6-CD"); + + var curPeId = Guid.NewGuid(); + var exact = await PeBudgetAccumulator.ComputeAsync( + db, project.Id, wi.Id, curPeId, CurCreatedAt, Array.Empty(), CancellationToken.None); + var pending = await PeBudgetAccumulator.ComputePendingAsync( + db, project.Id, wi.Id, curPeId, CurCreatedAt, CancellationToken.None); + + // ComputeAsync (S133) — số CHÍNH XÁC KHÔNG bị peer ChoBudget kéo lệch. + exact.PrevSelectedTotal.Should().Be(90_000_000m, "PrevSelected CHỈ đếm DaDuyet+winner"); + exact.PrevSelectedCount.Should().Be(1, "peer ChoDuyet KHÔNG cộng vào PrevSelected (bất động)"); + exact.PrevSubmittedTotal.Should().Be(130_000_000m, "PrevSubmitted = ChoDuyet 30tr + DaDuyet 100tr (như cũ)"); + exact.PrevSubmittedCount.Should().Be(2); + + // ComputePendingAsync (S134) — bổ sung ChoDuyet winner, KHÔNG chồng DaDuyet. + pending.PendingSelectedTotal.Should().Be(50_000_000m, "PendingSelected CHỈ ChoDuyet+winner (DaDuyet loại)"); + pending.PendingSelectedCount.Should().Be(1); + pending.PendingSubmittedTotal.Should().Be(0m, "ChoDuyet + DaDuyet đều loại khỏi PendingSubmitted (no double-count)"); + pending.PendingSubmittedCount.Should().Be(0); + pending.PriorPes.Should().ContainSingle("chỉ ChoDuyet ∉ {DaDuyet, TuChoi}"); + } + } + + // ===================================================================== + // C7 — DISPLAY frozen: phiếu DaDuyet (finalize) + SnapshotAt qua GetPurchaseEvaluationQuery → + // DTO pending 4 số = 0 + PendingPriorPes null, DÙ tồn tại peer ChoDuyet cùng cặp (nhánh frozen + // hardcode defaults, KHÔNG chạy accumulator). Mirror T7 PeBudgetFreezeTests :730. + // ===================================================================== + [Fact] + public async Task FrozenQuery_ReturnsZeroPendingAndNullPriorPes_EvenWithPendingPeer() + { + var (svc, fix, db) = CreateService(); + using (fix) + { + var um = fix.Services.GetRequiredService>(); + var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C7"); + await SeedPairAsync(db, project.Id, wi.Id, 200_000_000m); + + // Peer ChoDuyet + winner cùng cặp, TRƯỚC phiếu B → nếu B ở nhánh LIVE sẽ có pending != 0. + await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet, + budgetPeriod: 50_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: 80_000_000m, code: "PE-C7-PEER"); + + var approver = (await fix.CreateUserAsync("appr@c7.test", "Approver", null, Array.Empty())).Id; + var wf = await SeedFinalizeWorkflowAsync(db, approver, "QT-PA-C7"); + var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, Guid.NewGuid(), "PE-C7-B"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + pe.CreatedAt = BaseT; // muộn hơn peer → peer lọt window (nếu live) + await db.SaveChangesAsync(CancellationToken.None); + + await ApproveWithPriceAsync(svc, pe, approver, Array.Empty()); + pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet); + + var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin); + var bundle = await new GetPurchaseEvaluationQueryHandler(db, um, admin) + .Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None); + var s = bundle.BudgetSummary!; + + s.BudgetFrozen.Should().BeTrue("phiếu DaDuyet + SnapshotAt → nhánh frozen (test không vacuous)"); + s.PendingSubmittedTotal.Should().Be(0m, "nhánh frozen hardcode 0 dù tồn tại peer ChoDuyet"); + s.PendingSubmittedCount.Should().Be(0); + s.PendingSelectedTotal.Should().Be(0m, "nhánh frozen KHÔNG chạy accumulator tạm tính"); + s.PendingSelectedCount.Should().Be(0); + s.PendingPriorPes.Should().BeNull("nhánh frozen serve PendingPriorPes null"); + } + } + + // ===================================================================== + // C8 — D4 audit: finalize phiếu B tới DaDuyet QUA TransitionAsync (finalize path) khi TỒN TẠI + // peer A ChoDuyet cùng cặp → +1 Changelog Action=Update, EntityType=Workflow, UserId=null, + // UserName="Hệ thống", ContextNote chứa "[S134 pending-prior]" + MaPhieu A (filter discriminator + // ContextNote.Contains — #48 tie-break tránh row Transition). CONTRAST: finalize phiếu KHÔNG có + // pending peer → 0 row loại này. + // ===================================================================== + [Fact] + public async Task Finalize_WithPendingPriorPeer_WritesAuditChangelog_ContrastNoPeerWritesNone() + { + var (svc, fix, db) = CreateService(); + using (fix) + { + // ---- Case chính: có peer A ChoDuyet trước phiếu B ---- + var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C8"); + var peerA = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet, + budgetPeriod: 50_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: null, code: "PE-C8-A"); + + var approver = (await fix.CreateUserAsync("appr@c8.test", "Approver", null, Array.Empty())).Id; + var wf = await SeedFinalizeWorkflowAsync(db, approver, "QT-PA-C8"); + var peB = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, Guid.NewGuid(), "PE-C8-B"); + db.PurchaseEvaluations.Add(peB); + await db.SaveChangesAsync(CancellationToken.None); + peB.CreatedAt = BaseT; // muộn hơn peer A → A lọt window pending + await db.SaveChangesAsync(CancellationToken.None); + + await ApproveWithPriceAsync(svc, peB, approver, Array.Empty()); + peB.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet); + + // Filter discriminator ContextNote.Contains (#48 tie-break) → cô lập row D4 khỏi row Transition. + var d4 = await db.PurchaseEvaluationChangelogs.AsNoTracking() + .Where(c => c.PurchaseEvaluationId == peB.Id + && c.ContextNote != null && c.ContextNote.Contains("[S134 pending-prior]")) + .ToListAsync(CancellationToken.None); + + d4.Should().ContainSingle("finalize khi còn 1 phiếu trước chưa duyệt → đúng 1 row audit D4"); + var row = d4.Single(); + row.Action.Should().Be(ChangelogAction.Update, "D4 = Update, KHÔNG Transition (gotcha #49 tránh 2 row chuyển phase)"); + row.EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow); + row.PhaseAtChange.Should().Be(PurchaseEvaluationPhase.DaDuyet); + row.UserId.Should().BeNull("D4 do hệ thống ghi"); + row.UserName.Should().Be("Hệ thống"); + row.ContextNote.Should().Contain(peerA.MaPhieu!, "audit liệt kê mã phiếu trước còn treo (peer A)"); + + // ---- CONTRAST: phiếu C cặp MỚI không peer → 0 row D4 ---- + var (project2, wi2) = await SeedProjectAndWorkItemAsync(db, "C8none"); + var approver2 = (await fix.CreateUserAsync("appr2@c8.test", "Approver2", null, Array.Empty())).Id; + var wf2 = await SeedFinalizeWorkflowAsync(db, approver2, "QT-PA-C8b"); + var peC = BuildPeAtFinalizeSlot(wf2.Id, project2.Id, wi2.Id, Guid.NewGuid(), "PE-C8-C"); + db.PurchaseEvaluations.Add(peC); + await db.SaveChangesAsync(CancellationToken.None); + peC.CreatedAt = BaseT; + await db.SaveChangesAsync(CancellationToken.None); + + await ApproveWithPriceAsync(svc, peC, approver2, Array.Empty()); + peC.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet); + + var noneCount = await db.PurchaseEvaluationChangelogs.AsNoTracking() + .CountAsync(c => c.PurchaseEvaluationId == peC.Id + && c.ContextNote != null && c.ContextNote.Contains("[S134 pending-prior]"), + CancellationToken.None); + noneCount.Should().Be(0, "finalize không còn phiếu trước treo → KHÔNG ghi row D4"); + } + } +}