Compare commits
2 Commits
d4fbced02f
...
6664a6136a
| Author | SHA1 | Date | |
|---|---|---|---|
| 6664a6136a | |||
| e53a69307f |
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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 = <biến>` 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/<period>.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.
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
# close-synthesis — 2026-07-17-S133-pe-budget-freeze-daduyet (VERIFIED)
|
||||
|
||||
> Lead-verified synthesis toàn run (dạng-1 flat — đóng orphan; ghi @S134 `/tiep` closeout).
|
||||
> Goal: PE budget FREEZE-at-DaDuyet (UAT anh Kiệt 07-16, R1 bất-biến · R2 live-cho-phiếu-mới · R3 no-new-table).
|
||||
|
||||
## Kết quả chốt (evidence-first)
|
||||
|
||||
| Stage | wf | Kết quả VERIFIED | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1. invest fable-real | `wf_348f6155-167` | map read/write-site + 1 choke-point + spec 3-mục (⚠️ #53 return-fail) | `sub-investigator-codebase-0.md` 22.5KB (disk cứu); lead verify 7/7 claim |
|
||||
| 2. spec + errata | — | OD1 CÓ · OD2 CÓ · OD3 KHÔNG; errata query=`GetPurchaseEvaluationQuery` | `spec-pe-budget-freeze-daduyet-17-07-2026.md` |
|
||||
| 3a. INV-A | `wf_d9639faa-97a` | T0 RED thật (found 999tr) + FE badge 4 file mirror `5534addb` ×2 + npm ×2 PASS | `PeBudgetFreezeTests.cs:184` RED log · `sub-test-specialist-0.md` |
|
||||
| 3b. INV-B + solo-mig | `wf_4bef08b8-730` | 6 file BE + `PeBudgetAccumulator` 4-site `:862/:901/:934/:1109` (⚠️ #53 ×2, chết trước mig) → em-main-solo **Mig 67** 11 AddColumn + backfill OUTER APPLY; T0 RED→GREEN | `Persistence/Migrations/20260717032812_AddPeApprovedBudgetSnapshot.cs` · filter 44/44 |
|
||||
| 3c. INV-C | `wf_75c6bfd3-754` | +T1-T9 (⚠️ #53 lần 3 → disk; ⚠️ sub-file INV-A bị overwrite — lesson multi-invocation) | `grep -c "\[Fact\]" PeBudgetFreezeTests.cs` = 11 · full 519/0 |
|
||||
| 4a. reviewer | `wf_f9c0b939-181` | **PASS_WITH_FIXES 0M/2S/2N** — 2 SHOULD = #81-EXT indirect-assignment (`PurchaseEvaluationWorkflowService.cs:308` + `DbInitializer:1323`) → FIXED + test site-5 | `grep -n "S133 site 5"` hit `:308` · filter 11/11 · FULL **520/0** · npm ×2 |
|
||||
| 4b. commit+push | — | `d4fbced` 16 file +7831/−63 → `origin/main` | `git log` |
|
||||
| 4c. cicd | `wf_4eadbcf6-c25` | **VERDICT GO 5/5** — Run #398 gate 520 · Mig 67 prod 11 cột + backfill **18/18 chua=0** + 89 bảng · bundle rotate ×2 `3NCdyODe`/`B--dUHcm` + `CKsoiNCI`/`BuntB63r` (CSS rotate = badge utility-class, benign #69) · byte #77 PRESENT/old-0 · smoke 4×200 + PE 401 | `sub-cicd-monitor-0.md` |
|
||||
| 5. docs closeout | — (@S134 `/tiep`) | **Re-verify độc-lập verdict 4/4 KHỚP** (mig-top ssh sqlcmd + health 200 + bundle-hash curl ×2 + filter 11/11) → STATUS/HANDOFF/gotchas #81-EXT/CLAUDE.md/session-log | `docs/changelog/sessions/2026-07-17-S133-pe-budget-freeze-daduyet.md` |
|
||||
|
||||
## Delta trí-nhớ đáng giữ (đã APPEND các nơi tương ứng)
|
||||
|
||||
1. **#81-EXT indirect-assignment** (gotchas.md): field-đích có hook/snapshot ⇒ grep MỌI assignment-site (override/seeder), không chỉ mutation-API chính-tắc. Reviewer bắt; em-main + 3 invocation implement đều sót.
|
||||
2. **Lesson multi-invocation overwrite:** 2 invocation cùng role+index chung run → `sub-<role>-0.md` đè nhau; đánh index/tên riêng per-invocation (ghi session-log; ứng viên nâng lên hmw-runbook nếu tái-phát).
|
||||
3. **#53 ×3 WRITE-lane** — 0 mất việc nhờ ghi-đĩa-trong-lúc-làm + disk-recover (pattern `feedback_agent_return_garble_recover` đứng vững thêm 1 phiên).
|
||||
@ -20,11 +20,21 @@
|
||||
|
||||
- [x] 1. invest fable-real (wf: `wf_348f6155-167`) — #53 return-fail, disk sub-file 22.5KB cứu; lead verify 7/7 claim PASS
|
||||
- [x] 2. lead verify + ghi `spec-pe-budget-freeze-daduyet-17-07-2026.md` (OD1 CÓ · OD2 CÓ · OD3 KHÔNG)
|
||||
- [ ] 3. hmw implement Opus MAX theo spec — 3 invocation staged:
|
||||
- [x] 3. hmw implement Opus MAX theo spec — 3 invocation staged:
|
||||
- [x] INV-A (2-task ‖, wf_d9639faa-97a, 0-garble): T0 RED thật (`Failed 1/1` found 999tr @PeBudgetFreezeTests.cs:184, prod sạch — evidence sub-test-specialist-0.md) · FE 4 file (badge amber @ "Tổng hợp ngân sách trình ký" :1352, hash mirror `5534addb` ×2, npm build ×2 PASS) · errata spec: query = GetPurchaseEvaluationQuery (bundle prop BudgetSummary)
|
||||
- [x] INV-B (1-task, wf_4bef08b8-730, #53 return-fail ×2): lane XONG 6 file + PeBudgetAccumulator (helper 4-site :862/:901/:934/:1109 · display-gate · 2 guard) — chết trước mig → **em-main-solo Mig 67** (scaffold 11 AddColumn sạch + backfill Sql OUTER APPLY khớp accumulator; apply 2 DB, Dev catch-up Mig 66 lag; build slnx 0/0; filter 44/44 **T0 RED→GREEN**)
|
||||
- [ ] INV-C (1-task): test-specialist T1-T9 + full suite
|
||||
- [ ] 4. reviewer pre-commit → commit/push → cicd verify prod
|
||||
- [x] INV-C (1-task, wf_75c6bfd3-754, #53 lần 3 → disk): +T1-T9 vào PeBudgetFreezeTests (:426/:480/:524/:555/:638/:687/:730/:783/:821) · full suite lead-verified 519/0 · ⚠️ sub-file INV-A bị overwrite (cùng role+index 2 invocation chung run — lesson multi-invocation)
|
||||
- [x] 4a. reviewer pre-commit (wf_f9c0b939-181, return sạch): **PASS_WITH_FIXES 0M/2S/2N** — 2 SHOULD = #81 indirect-assignment (WorkflowService:308 admin-override `= targetPhase` + DbInitializer:1323 seeder `= current`) → lead FIX cả 2 + test AdminOverride site-5 + seeder snapshot + comment → filter 11/11 · FULL **520/0** (45D+475I) · npm ×2 PASS
|
||||
- [x] 4b. commit `d4fbced` (16 file, +7831/−63) + push `9e874ca..d4fbced` origin/main
|
||||
- [x] 4c. cicd-monitor verify d4fbced (wf: `wf_4eadbcf6-c25`, return sạch, verdict về SAU /pause — harvest-liền): **VERDICT GO 5/5** — Run **#398** success gate 520 · Mig 67 prod (11 cột + backfill **18/18** chua=0 + 89 bảng) · bundle rotate ×2 admin js `3NCdyODe` css `B--dUHcm` / eoffice js `CKsoiNCI` css `BuntB63r` (byte-verify PRESENT, old-control 0) · smoke 4×200 + PE 401 · surprise: CSS cũng rotate (badge sinh utility-class mới — benign #69)
|
||||
- [x] 5. docs closeout @S134 `/tiep` — re-verify độc-lập verdict cicd **4/4 KHỚP** (mig-top ssh + health 200 + bundle-hash curl ×2 + filter 11/11) · STATUS (Mig 67/520/S133-block/bundle-hoist) + HANDOFF (segment S133, 9 carry re-stamp, UAT script 3 bước) + gotchas **#81-EXT** (count giữ 83) + CLAUDE.md row-PE + session-log `2026-07-17-S133-pe-budget-freeze-daduyet.md` + `close-synthesis.md` (orphan dạng-1 ĐÓNG)
|
||||
|
||||
## ⏸ PAUSE note (S133 — /pause trong lúc chờ cicd)
|
||||
|
||||
- **Lane cicd `wf_4eadbcf6-c25` đang chạy lúc pause.** CI Gitea = server-side, TỰ chạy tiếp (commit `d4fbced` đã push `9e874ca..d4fbced`). `/tiep`: đọc `sub-cicd-monitor-0.md` nếu lane kịp ghi; lane chết theo phiên → em-main tự verify 5 mục theo SPEC cicd trong run này (lệnh sẵn ở WAL `verify:`); KHÔNG cần re-deploy.
|
||||
- **Docs closeout còn lại (mục 9 WAL):** `STATUS.md` (CURRENT STATE: Mig 66→**67** · Tests 509→**520** [45D+475I, +11 `PeBudgetFreezeTests`] · Recently-Done S133 block · bundle hash MỚI lấy từ verdict cicd) · `HANDOFF.md` segment S133 (re-stamp 8 carry + NEXT-anh UAT) · session-log `2026-07-17-S133-pe-budget-freeze-daduyet.md` · root `CLAUDE.md` row PE +Mig 67 · gotcha mới? (cân nhắc: #81-mở-rộng indirect-assignment — reviewer catch; quyết lúc closeout).
|
||||
- **Carry hook-vs-budget-cap:** hook nén-17.1K bắn **×2 phiên này** (inv-cb 19.7KB · reviewer 20.4KB — đều DƯỚI cap anh 25.6K/30.7K, em không tự nén theo quyết định S122) — gộp báo anh khi closeout.
|
||||
- **UAT script anh Kiệt (đưa vào HANDOFF khi closeout):** (1) mở phiếu **ĐÃ DUYỆT** (incognito — #77) → thấy badge **"🔒 Ngân sách chốt tại thời điểm duyệt"**, bảng TỔNG HỢP NGÂN SÁCH **giữ nguyên số** sau khi PRO/CCM cập nhật NS gói thầu (từ phiếu khác cùng gói); (2) phiếu **CHƯA duyệt** cùng gói → thấy số **MỚI** ("ngân sách cập nhật gần nhất"); (3) thử sửa "NS kỳ này (CCM)" / "Điều chỉnh ngân sách" trên phiếu đã duyệt → lỗi **"Phiếu đã kết thúc — ngân sách đã chốt tại thời điểm duyệt, không sửa được nữa."** (chặn cả Admin).
|
||||
|
||||
## taskList snapshot — implement (append @spec-chốt)
|
||||
|
||||
|
||||
@ -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)
|
||||
@ -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<PePendingPriorPeDto> PriorPes);`
|
||||
- `public static async Task<PePendingAccumulation> ComputePendingAsync(IApplicationDbContext db, Guid projectId, Guid workItemId, Guid peId, DateTime peCreatedAt, CancellationToken ct)` — peers window Y HỆT `:38-40` (`CreatedAt < this` — GIỮ mốc, khác window = 2 tập peer hết so sánh được); 3 predicate theo ①; list order `CreatedAt`.
|
||||
2. **`Dtos/PurchaseEvaluationDtos.cs`** — `public record PePendingPriorPeDto(Guid Id, string? MaPhieu, int Phase);` (N2 — entity `MaPhieu` nullable `PurchaseEvaluation.cs:10`, FE fallback "(chưa có mã)") + `PeBudgetSummaryDto` append CUỐI sau `BudgetFrozen` (pattern S133 an-toàn): `decimal PendingSubmittedTotal = 0, int PendingSubmittedCount = 0, decimal PendingSelectedTotal = 0, int PendingSelectedCount = 0, IReadOnlyList<PePendingPriorPeDto>? PendingPriorPes = null` — 2 construction site set EXPLICIT: frozen `:946` → `0,0,0,0,null` · live `:975` → fill từ `ComputePendingAsync`.
|
||||
3. **`PurchaseEvaluationFeatures.cs` nhánh LIVE (`:957-984`)** — gọi `ComputePendingAsync` sau `ComputeAsync`, fill 5 field. Nhánh FROZEN (`:930-955`): defaults.
|
||||
4. **D4 audit-note — TRONG `ApplyBudgetSnapshotOnFinalizeAsync` (`PurchaseEvaluationWorkflowService.cs:998-1041`), 1 chỗ, TRONG nhánh `WorkItemId != null`:** sau khi set snapshot, gọi `ComputePendingAsync` (1 lần); nếu `PriorPes.Count > 0` → **`.Add(...)`** (sync — pattern codebase `LogTransitionAsync:1145`; KHÔNG `AddAsync`) 1 row với **SHAPE ĐẦY ĐỦ (M1 — KHÔNG thêm param vào signature helper, giữ A6 0-đổi-call-site)**:
|
||||
```csharp
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog {
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
EntityType = PurchaseEvaluationEntityType.Workflow,
|
||||
Action = ChangelogAction.Update, // KHÔNG Transition — caller đã ghi row Transition riêng (LogTransitionAsync:1128-1155); 2 row Transition = UI audit hiểu nhầm 2 lần chuyển phase (#49)
|
||||
PhaseAtChange = PurchaseEvaluationPhase.DaDuyet,
|
||||
UserId = null, UserName = "Hệ thống", // helper không có actor — audit actor tái dựng từ row Transition cùng batch
|
||||
Summary = "Duyệt khi còn phiếu trước cùng gói chưa duyệt",
|
||||
ContextNote = "[S134 pending-prior] Duyệt khi N phiếu trước cùng gói chưa duyệt: A/0xx (Đã gửi duyệt), …",
|
||||
});
|
||||
```
|
||||
KHÔNG SaveChanges trong helper (caller lo — V2/V1 `TransitionAsync:301`, admin-override `:316`). KHÔNG đụng 5 call-site. (Chi phí: ~3 query `ComputePendingAsync` mỗi finalize — N4, chấp nhận; tần suất thấp.)
|
||||
|
||||
**FE — 2 app (fe-user + fe-admin):**
|
||||
|
||||
5. **types `purchaseEvaluation.ts` ×2 app** (⚠️ 2 file KHÁC nhau `2bf773`≠`ab4a2e` — edit block `PeBudgetSummary` TỪNG app, KHÔNG copy nguyên file): +5 field `pendingSubmittedTotal: number; pendingSubmittedCount: number; pendingSelectedTotal: number; pendingSelectedCount: number; pendingPriorPes: { id: string; maPhieu: string | null; phase: number }[] | null`.
|
||||
6. **`PeDetailTabs.tsx`** (2-app SHA-mirror `5534addb` — sửa 1 nội dung áp 2 file, `git hash-object` phải IDENTICAL sau sửa):
|
||||
- Gate TOÀN BỘ render tạm-tính: `!bs.budgetFrozen && (bs.pendingSubmittedCount > 0 || bs.pendingSelectedCount > 0 || (bs.pendingPriorPes?.length ?? 0) > 0)`.
|
||||
- Row 2 (:1503-vùng): sub-dòng **amber** khi `pendingSelectedCount > 0`: `Tạm tính: {fmt(row2 + delta2)} (+{fmt(delta2)} từ {pendingSelectedCount} phiếu chưa duyệt)` — **N8: n = count TƯƠNG ỨNG delta, KHÔNG dùng `pendingPriorPes.length`** (list ⊇ delta-set — dùng nhầm sẽ "từ 3 phiếu" khi delta chỉ từ 1).
|
||||
- Row 6 (:1579-1586): sub-dòng amber `row6_tạm = row6 + delta2` (n = pendingSelectedCount).
|
||||
- Row 1 (:1498-vùng): sub-dòng amber khi `pendingSubmittedCount > 0` (+delta1, n = pendingSubmittedCount); Row 5 (:1569-1578) per-cột `r5_tạm = r5 + delta1`.
|
||||
- Dưới matrix: danh sách pending `{maPhieu ?? "(chưa có mã)"} — badge phase-label màu` — **N3: dùng map sẵn có `PurchaseEvaluationPhaseLabel`** (types cả 2 app; PeWorkflowPanel đang dùng `:465`), màu ChoDuyet=amber · TraLai=orange · Nháp=gray · legacy=amber. **N9: plain-text + badge, KHÔNG render link** (approver click link phiếu Nháp → 403 S89 guard `Features.cs:893-898` → toast lỗi khó hiểu). Số tạm-tính LUÔN kèm nhãn "Tạm tính" — không bao giờ thay số chính xác.
|
||||
7. **`PeWorkflowPanel.tsx`** (2-app SHA-mirror `a83a58e1` — mirror y vậy):
|
||||
- **Banner amber** cạnh V2-banner (:437-vùng) khi `(evaluation.budgetSummary?.pendingPriorPes?.length ?? 0) > 0 && !evaluation.budgetSummary?.budgetFrozen && !readOnly && evaluation.phase === 10 /* ChoDuyet — M2: phiếu Nháp/TraLai/TuChoi đi nhánh live cũng có pending fill, banner "cần duyệt phiếu trước" chỉ có nghĩa khi phiếu ĐANG chờ duyệt */`: `⚠ {n} phiếu trước cùng gói thầu CHƯA duyệt: A/012 (Đã gửi duyệt), … — cần duyệt phiếu trước trước phiếu này` (n = `pendingPriorPes.length` — đây là LIST, đúng nghĩa banner).
|
||||
- **Block NHẮC LẠI trong Dialog duyệt** khi `isApproveAction` (vùng :509-514, TRÊN bộ chọn giá — mọi cấp duyệt thấy; bước cuối `shouldPickPrice :510` là tập con nên chắc chắn thấy trước "Xác nhận"). **KHÔNG disable nút Xác nhận** (D3).
|
||||
|
||||
**KHÔNG đụng:** `ComputeAsync` · snapshot 11 cột · Mig 67 backfill · frozen branch số cũ · submit-guard · HĐ GiaTri · multi-winner derive (#81) · endsBeforeCeo.
|
||||
|
||||
## ③ Checklist per-vai + acceptance ĐO ĐƯỢC
|
||||
|
||||
**implementer-backend (IMPL-A):**
|
||||
- [ ] A1 `ComputePendingAsync` + 2 record mới; acceptance: `git diff` vùng `ComputeAsync` thân method = **0 dòng đổi**; `dotnet build SolutionErp.slnx` 0 error/0 warning mới.
|
||||
- [ ] A2 DTO append-cuối + 2 construction site explicit; acceptance: grep `PendingPriorPes` đúng 2 site set + build xanh.
|
||||
- [ ] A3 Live-branch fill; acceptance: **dựng data trước — Dev DB hiện 0 cặp multi-phiếu (N5): tạo 2 phiếu cùng Dự án × Hạng mục (qua API/UI hoặc seed test), phiếu A ChoDuyet + winner + quote IsSelected** → GET phiếu B: JSON `pendingSelectedTotal` = SUM IsSelected của A, `pendingPriorPes` chứa `{maPhieu A, phase:10}`.
|
||||
- [ ] A4 Frozen → defaults; acceptance: phiếu DaDuyet+SnapshotAt → `pendingPriorPes` null/[], 4 số = 0, JSON số chính-xác Y NGUYÊN trước-sau (R1-S133).
|
||||
- [ ] A5 KHÔNG migration; acceptance: `git status` — `ApplicationDbContextModelSnapshot.cs` + `Migrations/` **0 file đổi**.
|
||||
- [ ] A6 D4 changelog-note 1-chỗ-trong-helper; acceptance: finalize phiếu có pending → +1 row `PurchaseEvaluationChangelogs` ContextNote chứa `[S134 pending-prior]` + mã phiếu; finalize KHÔNG pending → 0 row mới loại này; **0 đổi 5 call-site** (`git diff` các vùng `:311/:865/:904/:937/:1112` chỉ được phép 0-dòng).
|
||||
**implementer-frontend (IMPL-B, ×2 app):**
|
||||
- [ ] B1 types ×2; acceptance: `grep -c pendingPriorPes fe-user/src/types/purchaseEvaluation.ts fe-admin/src/types/purchaseEvaluation.ts` = 1 mỗi file.
|
||||
- [ ] B2 PeDetailTabs sub-rows + list + gate frozen; acceptance: `git hash-object` 2 file IDENTICAL; string "Tạm tính" xuất hiện trong source; phiếu frozen KHÔNG render (gate đầu nhánh).
|
||||
- [ ] B3 PeWorkflowPanel banner + dialog-block; acceptance: hash 2 file IDENTICAL; nút Xác nhận KHÔNG thêm điều kiện disable (`git diff` vùng disabled-condition = 0).
|
||||
- [ ] B4 `npm run build` fe-user + fe-admin đều PASS.
|
||||
**test-specialist (TEST-C — pattern seed `PeBudgetFreezeTests.cs:277-306/:311-332`, CreatedAt override thủ công `:332`):**
|
||||
- [ ] C1 peer ChoDuyet+IsWinner+IsSelected → delta2 = tổng ThanhTien; có mặt trong PriorPes.
|
||||
- [ ] C2 peer TuChoi → delta 0 + KHÔNG trong list; peer DaDuyet → delta 0 (bên chính xác) + KHÔNG trong list.
|
||||
- [ ] C3 peer Nháp → TRONG list, delta tiền = 0 (D1); peer TraLai có BudgetPeriodAmount → delta1 đúng (D2).
|
||||
- [ ] C4 peer-window: peer `CreatedAt > this` → loại hoàn toàn (mirror `:435-451`).
|
||||
- [ ] C5 quote ÂM trong delta2 (signed-sum S116).
|
||||
- [ ] C6 REGRESSION: `ComputeAsync` số cũ Y NGUYÊN khi thêm peer non-terminal (chính-xác không đổi).
|
||||
- [ ] C7 frozen phiếu → DTO pending 0/empty.
|
||||
- [ ] C8 D4: finalize có pending → changelog row đúng format; không pending → không row.
|
||||
- [ ] C9 `dotnet test SolutionErp.slnx` FULL PASS (520 + N mới, 0 fail).
|
||||
|
||||
**Phân rã hmw staged:** INV xong (sub-file) → **INV-2 = [IMPL-A ‖ IMPL-B]** (song song — DTO shape + JSON field names chốt sẵn trong spec) → **INV-3 = [TEST-C]** (sau A land). Lead verify giữa các invocation + reviewer pre-commit cuối.
|
||||
|
||||
## Edge cases (phủ trong implement + test)
|
||||
1. WorkItemId null → `budgetSummary` null toàn cục (gate `:912`) — tạm-tính không tồn tại, banner "gắn hạng mục" giữ.
|
||||
2. Multi-winner D4/IsSelected: predicate mirror `:50-60` — 0 quy ước mới.
|
||||
3. Giá âm: SUM signed nguyên trạng, delta có thể âm — render như mọi số tiền.
|
||||
4. Min–Max/ApprovedPrice: KHÔNG feed lũy kế — orthogonal.
|
||||
5. PRO-split/CcmBudgetPeriodAmount: giữ nguyên quy ước row1 = `BudgetPeriodAmount` only (drift-vector sẵn có S133 — NGOÀI scope).
|
||||
6. Own-phase terminal/nháp (TuChoi/TraLai/DangSoanThao): matrix VẪN hiển thị tạm-tính (thông tin); **banner PeWorkflowPanel ẨN bằng gate tường minh `evaluation.phase === 10` (M2 — KHÔNG tự ẩn; phiếu non-ChoDuyet vẫn có pending fill từ nhánh live)**; dialog-block tự gate qua `isApproveAction`.
|
||||
7. Legacy phase 2-6 còn row sống (Dev {3,6}): predicate loại-trừ tự cover.
|
||||
8. **N7 (pre-existing, ghi nhận để không ngạc nhiên UAT):** phiếu DaDuyet mà SnapshotAt null (`Features.cs:926-928` fallback defense-in-depth) → đi nhánh live → tạm-tính HIỆN trên phiếu DaDuyet dị dạng — sau backfill Mig 67 + 5 site + seeder, lý thuyết 0 row loại này; KHÔNG fix trong scope.
|
||||
@ -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.
|
||||
@ -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).
|
||||
@ -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<PePendingPriorPeDto>? 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.
|
||||
@ -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ế.
|
||||
@ -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, CreatedAt<peCreatedAt).
|
||||
- PendingSubmitted :104-112 = Phase NOT IN {DangSoanThao, DaDuyet, ChoDuyet, TuChoi} → Count + SUM(BudgetPeriodAmount ?? 0).
|
||||
- PendingSelected :114-126 = Phase NOT IN {DangSoanThao, DaDuyet, TuChoi} AND Suppliers.Any(IsWinner) → Count + SUM(ThanhTien WHERE IsSelected).
|
||||
- PriorPes :128-133 = Phase NOT IN {DaDuyet, TuChoi} orderby CreatedAt → PePendingPriorPeDto(Id, MaPhieu, (int)Phase).
|
||||
- `PePendingAccumulation` record struct :31-36.
|
||||
|
||||
## Bước 2 — DTO + query handler + D4 (DONE)
|
||||
- `PePendingPriorPeDto(Guid Id, string? MaPhieu, int Phase)` @ `Dtos/PurchaseEvaluationDtos.cs:324` (ns `SolutionErp.Application.PurchaseEvaluations.Dtos`).
|
||||
- `PeBudgetSummaryDto` +5 field cuối :362-366 (PendingSubmittedTotal/Count, PendingSelectedTotal/Count, PendingPriorPes default null).
|
||||
- Query handler `PurchaseEvaluationFeatures.cs`: nhánh frozen :930-959 (Pending 4 số = 0 + PendingPriorPes null); nhánh live :961-1000 (fill từ ComputePendingAsync :972).
|
||||
- D4 `ApplyBudgetSnapshotOnFinalizeAsync` @ `PurchaseEvaluationWorkflowService.cs:998` — :1025-1053 nếu ComputePendingAsync.PriorPes.Count>0 → +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.
|
||||
@ -52,14 +52,14 @@ Kiến trúc: **.NET 10 Clean Architecture + 2 React FE (admin + user) + SQL Ser
|
||||
- Audit fields: `CreatedAt`, `UpdatedAt`, `CreatedBy`, `UpdatedBy` (`BaseEntity`)
|
||||
- Soft delete: `IsDeleted`, `DeletedAt`, `DeletedBy` (`AuditableEntity`)
|
||||
- Migrations: `dotnet ef migrations add <Name> --project src/Backend/SolutionErp.Infrastructure --startup-project src/Backend/SolutionErp.Api`
|
||||
- **Số migration · bảng hiện tại → [`docs/STATUS.md`](docs/STATUS.md) (canonical — KHÔNG copy số ở đây để tránh drift; Harness-11 B1).** Lịch sử mig gần đây: **+S97 Mig 60 `AddPeEndedByLevelFinalize` + Mig 61 `BackfillEndedByLevelFinalizeFromChangelog`** (PE cờ RUNTIME finalize-sớm — display đọc runtime thay suy-config; backfill Mig 60 soi nhầm bảng Approvals → Mig 61 đúng Changelogs; gotcha #78/#79) · +S74 Mig 55 `AddCcmNoteToPeWorkItemBudget` (PE CcmNote) · +S73 Mig 54 `AddPeSuggestedAndApprovedPrice` (PE giá đề xuất PRO/CCM). +S69: Mig 53 `AddPeUrgentAndCeoApprovalThreshold` — PE +IsUrgentByPro/Ccm cờ gấp PRO/CCM + ApprovalWorkflow +CeoApprovalThreshold ngưỡng CCM duyệt-final, 3 AddColumn no new table. +S65: Mig 51 `AddDepartmentParentId` Department.ParentId loose-Guid no-FK org-tree phân cấp + Mig 52 `AddHoSoLinkToPurchaseEvaluation` PE HoSoLink nvarchar(1000) hyperlink NAS — cả 2 AddColumn no new table, tables giữ 88. Phase 10 COMPLETE + Phase 11 P11-A→F done — Mig 34-42 HRM/Office/WorkflowApps/Attendance + Contract V2 (32-33) + WireWorkflowApps V2 (41) + LeaveBalance (42) + Holiday filtered-unique (43, S45) + Vehicle/Driver catalog (44, S51) + HRM-catalog filtered-unique 3× (45, S51) + ItTicket SLA (46, S52) + Master filtered-unique 3× (47, S53 gotcha #57 EXT) + Project master fields Year/Investor/Location/Package (48, S55 — AddColumn no new table, kèm nạp 62 dự án + 71 hạng mục + 3 NCC real data từ Excel qua `SeedRealMasterDataAsync` ungated idempotent) + PE gắn Hạng mục công việc WorkItemId loose-Guid KHÔNG FK vật lý (49, S57bis — AddColumn+CreateIndex, no new table) + **Mig 50 `ReplaceBudgetModuleWithPeWorkItemBudgets` (S61, 2026-06-13) — XÓA module Budget cũ, thay bằng `PeWorkItemBudgets` ngân sách per-gói-thầu (1 record/cặp Dự án × Hạng mục, nhập theo role PRO/CCM, vượt ngân sách = cảnh báo mềm cho lưu S62); backfill `BudgetManualAmount→BudgetPeriodAmount` TRƯỚC DropColumn (phiếu UAT giữ số); net bảng 93→88; gotcha #63/#64**. V2 schema history S29-era bên dưới giữ nguyên — Mig 32+33 Plan B Contract V2 cookie-cutter mirror PE Mig 22-26 (S29). Mig 26 `AddPeLevelOpinionsForV2`: bảng mới `PurchaseEvaluationLevelOpinions` UNIQUE composite (PEId, LevelId), FK Cascade Pe + Restrict Level. Section 5 "Ý kiến cấp duyệt" V2 dynamic theo workflow đã pin: forEach Step (Phòng) → forEach Level (Cấp) → forEach NV → 1 OpinionBox. Service `ApproveV2Async` UPSERT auto khi NV duyệt — Q1=1B (sync gắn với Duyệt, KHÔNG form input rời). SignedByUserId track signer thật, FE banner "Admin duyệt thay" khi !== ApproverUserId. Comment empty → "(duyệt — không ý kiến)" placeholder. Phiếu V1 legacy fallback Mig 15 4 box readOnly (data history). Mig 25 `AddIsUserSelectableToApprovalWorkflows`: ALTER `ApprovalWorkflows` +`IsUserSelectable bit` (admin pin/unpin workflow nào cho user pick lúc create phiếu, multi-select độc lập IsActive). Backfill `WHERE IsActive=1 SET 1` giữ behavior cũ. Designer +badge "Cho user chọn" + button Ghim/Bỏ ghim. Workspace filter dropdown chỉ workflows `IsUserSelectable=true`. Mig 22-24 V2 schema (Session 17): `ApprovalWorkflows`/Steps/Levels — Quy trình > Bước (Phòng) > Cấp (N NV cụ thể qua ApproverUserId, OR-of-N cùng cấp). PE.ApprovalWorkflowId pin V2. PE.CurrentApprovalLevelOrder track. State machine 5 trạng thái: Nháp / Đã gửi duyệt / Trả lại (Phase riêng TraLai=98) / Từ chối / Đã duyệt. PE Service V2 wire match `actor.Id == ApproverUserId`. Contract V2 ĐÃ WIRE (Mig 32+33 Plan B S29 — cookie-cutter mirror PE V2: `ApproveV2Async` + `ContractLevelOpinions` UPSERT + Workspace V2 Select dropdown). Mig 21 V1 flat workflow vẫn live cho phiếu cũ.)
|
||||
- **Số migration · bảng hiện tại → [`docs/STATUS.md`](docs/STATUS.md) (canonical — KHÔNG copy số ở đây để tránh drift; Harness-11 B1).** Lịch sử mig gần đây: **+S133 Mig 67 `AddPeApprovedBudgetSnapshot`** (PE freeze ngân sách phiếu DaDuyet — snapshot 11 cột AddColumn chốt tại finalize choke-point + site-5 admin-override + backfill `Sql()` OUTER APPLY 18/18 prod, no new table; display-gate `budgetFrozen` phiếu DaDuyet đọc snapshot/phiếu mới đọc live; gotcha #81-EXT) · **+S97 Mig 60 `AddPeEndedByLevelFinalize` + Mig 61 `BackfillEndedByLevelFinalizeFromChangelog`** (PE cờ RUNTIME finalize-sớm — display đọc runtime thay suy-config; backfill Mig 60 soi nhầm bảng Approvals → Mig 61 đúng Changelogs; gotcha #78/#79) · +S74 Mig 55 `AddCcmNoteToPeWorkItemBudget` (PE CcmNote) · +S73 Mig 54 `AddPeSuggestedAndApprovedPrice` (PE giá đề xuất PRO/CCM). +S69: Mig 53 `AddPeUrgentAndCeoApprovalThreshold` — PE +IsUrgentByPro/Ccm cờ gấp PRO/CCM + ApprovalWorkflow +CeoApprovalThreshold ngưỡng CCM duyệt-final, 3 AddColumn no new table. +S65: Mig 51 `AddDepartmentParentId` Department.ParentId loose-Guid no-FK org-tree phân cấp + Mig 52 `AddHoSoLinkToPurchaseEvaluation` PE HoSoLink nvarchar(1000) hyperlink NAS — cả 2 AddColumn no new table, tables giữ 88. Phase 10 COMPLETE + Phase 11 P11-A→F done — Mig 34-42 HRM/Office/WorkflowApps/Attendance + Contract V2 (32-33) + WireWorkflowApps V2 (41) + LeaveBalance (42) + Holiday filtered-unique (43, S45) + Vehicle/Driver catalog (44, S51) + HRM-catalog filtered-unique 3× (45, S51) + ItTicket SLA (46, S52) + Master filtered-unique 3× (47, S53 gotcha #57 EXT) + Project master fields Year/Investor/Location/Package (48, S55 — AddColumn no new table, kèm nạp 62 dự án + 71 hạng mục + 3 NCC real data từ Excel qua `SeedRealMasterDataAsync` ungated idempotent) + PE gắn Hạng mục công việc WorkItemId loose-Guid KHÔNG FK vật lý (49, S57bis — AddColumn+CreateIndex, no new table) + **Mig 50 `ReplaceBudgetModuleWithPeWorkItemBudgets` (S61, 2026-06-13) — XÓA module Budget cũ, thay bằng `PeWorkItemBudgets` ngân sách per-gói-thầu (1 record/cặp Dự án × Hạng mục, nhập theo role PRO/CCM, vượt ngân sách = cảnh báo mềm cho lưu S62); backfill `BudgetManualAmount→BudgetPeriodAmount` TRƯỚC DropColumn (phiếu UAT giữ số); net bảng 93→88; gotcha #63/#64**. V2 schema history S29-era bên dưới giữ nguyên — Mig 32+33 Plan B Contract V2 cookie-cutter mirror PE Mig 22-26 (S29). Mig 26 `AddPeLevelOpinionsForV2`: bảng mới `PurchaseEvaluationLevelOpinions` UNIQUE composite (PEId, LevelId), FK Cascade Pe + Restrict Level. Section 5 "Ý kiến cấp duyệt" V2 dynamic theo workflow đã pin: forEach Step (Phòng) → forEach Level (Cấp) → forEach NV → 1 OpinionBox. Service `ApproveV2Async` UPSERT auto khi NV duyệt — Q1=1B (sync gắn với Duyệt, KHÔNG form input rời). SignedByUserId track signer thật, FE banner "Admin duyệt thay" khi !== ApproverUserId. Comment empty → "(duyệt — không ý kiến)" placeholder. Phiếu V1 legacy fallback Mig 15 4 box readOnly (data history). Mig 25 `AddIsUserSelectableToApprovalWorkflows`: ALTER `ApprovalWorkflows` +`IsUserSelectable bit` (admin pin/unpin workflow nào cho user pick lúc create phiếu, multi-select độc lập IsActive). Backfill `WHERE IsActive=1 SET 1` giữ behavior cũ. Designer +badge "Cho user chọn" + button Ghim/Bỏ ghim. Workspace filter dropdown chỉ workflows `IsUserSelectable=true`. Mig 22-24 V2 schema (Session 17): `ApprovalWorkflows`/Steps/Levels — Quy trình > Bước (Phòng) > Cấp (N NV cụ thể qua ApproverUserId, OR-of-N cùng cấp). PE.ApprovalWorkflowId pin V2. PE.CurrentApprovalLevelOrder track. State machine 5 trạng thái: Nháp / Đã gửi duyệt / Trả lại (Phase riêng TraLai=98) / Từ chối / Đã duyệt. PE Service V2 wire match `actor.Id == ApproverUserId`. Contract V2 ĐÃ WIRE (Mig 32+33 Plan B S29 — cookie-cutter mirror PE V2: `ApproveV2Async` + `ContractLevelOpinions` UPSERT + Workspace V2 Select dropdown). Mig 21 V1 flat workflow vẫn live cho phiếu cũ.)
|
||||
|
||||
### Modules
|
||||
|
||||
| Module | Namespace | Migration | Trạng thái |
|
||||
|---|---|---|---|
|
||||
| Contract (HĐ) | `Domain/Contracts/` | 1-11 | Feature-complete (7 ContractType × 9 phase) |
|
||||
| PurchaseEvaluation (Duyệt NCC tiền-HĐ) | `Domain/PurchaseEvaluations/` | 12,13,15,49,50,52-61,65,66 | Feature-complete — +Hạng mục (Mig 49) +ngân sách per-gói-thầu role PRO/CCM (Mig 50) +Link hồ sơ NAS (Mig 52) +cờ gấp PRO/CCM + CCM duyệt-final theo ngưỡng (Mig 53) +giá đề xuất PRO/CCM + CCM-note (Mig 54/55) +PRO-split + ghi-chú-giá (Mig 56/57) +multi-winner liên-danh + level-finalize + NS-kỳ-CCM (Mig 58/59) +**`EndedByLevelFinalize` runtime-flag + backfill-fix (Mig 60/61, S97 — gotcha #78/#79)** +multi-NCC per-hạng-mục (Mig 65, S114 — gotcha #81) +duyệt-khoảng giá Min–Max (Mig 66, S117). Export PDF pending |
|
||||
| PurchaseEvaluation (Duyệt NCC tiền-HĐ) | `Domain/PurchaseEvaluations/` | 12,13,15,49,50,52-61,65-67 | Feature-complete — +Hạng mục (Mig 49) +ngân sách per-gói-thầu role PRO/CCM (Mig 50) +Link hồ sơ NAS (Mig 52) +cờ gấp PRO/CCM + CCM duyệt-final theo ngưỡng (Mig 53) +giá đề xuất PRO/CCM + CCM-note (Mig 54/55) +PRO-split + ghi-chú-giá (Mig 56/57) +multi-winner liên-danh + level-finalize + NS-kỳ-CCM (Mig 58/59) +**`EndedByLevelFinalize` runtime-flag + backfill-fix (Mig 60/61, S97 — gotcha #78/#79)** +multi-NCC per-hạng-mục (Mig 65, S114 — gotcha #81) +duyệt-khoảng giá Min–Max (Mig 66, S117) +**freeze NS phiếu DaDuyet snapshot 11-cột + display-gate `budgetFrozen` (Mig 67, S133 — gotcha #81-EXT indirect-assignment)**. Export PDF pending |
|
||||
| ~~Budget (Ngân sách dự án)~~ | — | 14 → **Mig 50 DROP** | ⚠️ **REMOVED S61** — module Budget cũ XÓA, thay bằng PE-budget-per-gói-thầu (`PeWorkItemBudgets`). FE pages/types/menu `Bg_*` gỡ hết |
|
||||
| Master (Supplier/Project/Department) | `Domain/Master/` | 2, 10, 48, 51, 62-64 | Feature-complete — +Project fields Year/Investor/Location/Package (Mig 48) +Department.ParentId org-tree (Mig 51) +Supplier expand 30-field/import/publish-state (Mig 62-64) |
|
||||
| Identity (User/Role/Permission/MenuItem) | `Domain/Identity/` | 1, 3, 11 | Feature-complete (30 demo user — 16 sample + 14 Solutions thật) |
|
||||
|
||||
@ -2,7 +2,11 @@
|
||||
|
||||
> **Tiering rule (S40):** giữ **2-3 session gần nhất**. Cũ hơn → `docs/changelog/sessions/`. Full brief history pre-S40 → `docs/_archive/HANDOFF-preS40-fullhistory.md`.
|
||||
|
||||
**Last updated:** 2026-07-17 (S131+S132 — **session-end S131 = phiên ĐẦU mở đúng nghi-thức §L.b sau 3 closeout S128-S130 chạy tắt (→ E-011 + AS-15 NEW error-ledger); S131 chết session-limit giữa chừng (2 monitor die), S132 `/tiep` nối verify-5/5 → đóng TRỌN.** S131: 2 email hub stamped selftest-MATCH (**3-gap vận-hành** `e46863c8`: GAP-1 counter H24 không tick trên mạch pause→`/tiep` — dưới-đếm ~37%/8-nhãn · GAP-2 3-closeout không spawn H1/H2 dù "KHÔNG skip" · GAP-3 diary 2 vai H24 không tồn-tại sau first-run S124, per-RUN gate mù per-ROLE; + **direction `closure-cadence`** `641bf8ca` ĐANG BÀN chưa chốt) + adap-report H24 §6 UPDATE + tick 6→7 ancestor-REACHABLE + reviewer-gate ×2 outward PASS_WITH_FIXES. S132: spawn DỒN H1+H2 phủ S127→S131 — **H2 GAPS→CLOSED-in-session** (0-orphan/31 run.md · seed 2 diary H24 retro-harvest từ `sub-lead-{view,omission}.md` 8379B/9967B · rmdir stray S119 · S130 solo-0-spawn = không nợ) · **H1 DRIFT-FOUND 1 thật FIXED cùng phiên** (`rag-onboarding-guide:118/:134` `contextual_retrieval: true` + "+15% recall" vô-căn-cứ sau owner-final `f5ce2778` → false + reword; roster 14/14 khớp 4-nguồn; plugin 0-delta) · (h) marks n-a · (i) spot-check 3/3 (sha 2 email re-compute MATCH + tick ancestor) · **(j) counter=7 · light 4/6 · deep 4/15 · OVERDUE none**.)
|
||||
**Last updated:** 2026-07-17 (S133 + closeout @S134 `/tiep` — **PE freeze ngân sách phiếu DaDuyet SHIP PROD `d4fbced` (16 file +7831/−63), CI Run #398 VERDICT GO 5/5, bundle rotate ×2 admin `3NCdyODe`/user `CKsoiNCI`.** UAT anh Kiệt FDC 07-16: cập nhật NS gói thầu làm phiếu ĐÃ DUYỆT đổi số theo → R1 DaDuyet bất biến [Mig 67 snapshot 11 cột chốt tại finalize + site-5 admin-override + backfill 18/18 prod] · R2 phiếu chưa duyệt đọc live "bản cập nhật gần nhất" [display-gate `budgetFrozen` + badge 🔒 FE 2-app SHA-mirror `5534addb`] · R3 no new table [89 giữ]. Reviewer PASS_WITH_FIXES 2 SHOULD = **#81-EXT indirect-assignment** (2 site gán Phase thẳng bypass choke-point → FIXED + test site-5). Test **509→520** (+11 `PeBudgetFreezeTests` T0 RED→GREEN + T1-T9 + site-5), FULL 520/0. ⚠️ #53 ×3 + lesson **sub-file overwrite khi 2 invocation cùng role+index chung run** (INV-A bị INV-C đè — đánh tên riêng per-invocation). S133 `/pause` giữa chờ cicd → S134 `/tiep` nối: verdict GO harvest-liền được **re-verify độc-lập 4/4 KHỚP** [mig prod top `20260717032812` + health 200 + bundle-hash ×2 + filter 11/11] → docs closeout + synthesis đóng orphan run-folder.)
|
||||
**Carry MỞ re-stamp (9):** (1) carry-age design-question `[carry:carry-age-vacuous]` (2) **hook-cap 17.1K — S133 bắn ×2 MỚI: inv-cb 19.7KB + reviewer 20.4KB, đều DƯỚI cap anh 25.6K/30.7K ⇒ em KHÔNG tự nén (S122), chờ anh chốt hướng** `[carry:hook-vs-budget-cap]` (3) UAT S117 incognito `[carry:uat-s117]` (4) PE phân-quyền số-phiếu+user `[carry:pe-phanquyen]` (5) BVAAU `[carry:bvaau]` (6) quyết-định Bình/Lê `[carry:binh-le-decision]` (7) Trà/Bùi re-login `[carry:tra-bui-relogin]` (8) account trùng `[carry:account-trung]` (9) 🆕 **UAT freeze-NS S133** `[carry:uat-s133-budget-freeze]`.
|
||||
**🔴 NEXT anh:** (1) 🆕 **UAT script freeze-NS cho anh Kiệt (3 bước, incognito #77):** (a) mở phiếu **ĐÃ DUYỆT** → thấy badge **🔒 "Ngân sách chốt tại thời điểm duyệt"**, bảng TỔNG HỢP NGÂN SÁCH **giữ nguyên số** sau khi PRO/CCM cập nhật NS gói thầu (từ phiếu khác cùng gói); (b) phiếu **CHƯA duyệt** cùng gói → thấy số **MỚI** ("ngân sách cập nhật gần nhất"); (c) thử sửa "NS kỳ này (CCM)" / "Điều chỉnh ngân sách" trên phiếu đã duyệt → lỗi **"Phiếu đã kết thúc — ngân sách đã chốt tại thời điểm duyệt, không sửa được nữa."** (chặn cả Admin) `[carry:uat-s133-budget-freeze]`; (2) 🟡 **hook-cap bắn ×2 phiên S133** (inv-cb 19.7K · reviewer 20.4K — dưới cap, em không tự nén) — anh chốt hướng `[carry:hook-vs-budget-cap]`; (3) 2 đề-xuất S131 CHỜ GẬT (wire tick H24 vào `/tiep` + light-run owner-triggered); (4) CHỜ HUB reply-batch (i)/seal-verify/do-token/closure-cadence — khi land em `/check-email` verify hash + áp canonical.
|
||||
**NEXT em (phiên kế):** watch `broadcasts/inbox`; spawn-probe reviewer Measured-label gate `reviewer.md:46` runtime-active (post-restart, H1 #3); monthly combined audit 2026-08-01; session-end khi anh gõ (squash `wal:` local trước push — 4 commit local đang chờ).
|
||||
**Prev S131+S132:** 2026-07-17 (S131+S132 — **session-end S131 = phiên ĐẦU mở đúng nghi-thức §L.b sau 3 closeout S128-S130 chạy tắt (→ E-011 + AS-15 NEW error-ledger); S131 chết session-limit giữa chừng (2 monitor die), S132 `/tiep` nối verify-5/5 → đóng TRỌN.** S131: 2 email hub stamped selftest-MATCH (**3-gap vận-hành** `e46863c8`: GAP-1 counter H24 không tick trên mạch pause→`/tiep` — dưới-đếm ~37%/8-nhãn · GAP-2 3-closeout không spawn H1/H2 dù "KHÔNG skip" · GAP-3 diary 2 vai H24 không tồn-tại sau first-run S124, per-RUN gate mù per-ROLE; + **direction `closure-cadence`** `641bf8ca` ĐANG BÀN chưa chốt) + adap-report H24 §6 UPDATE + tick 6→7 ancestor-REACHABLE + reviewer-gate ×2 outward PASS_WITH_FIXES. S132: spawn DỒN H1+H2 phủ S127→S131 — **H2 GAPS→CLOSED-in-session** (0-orphan/31 run.md · seed 2 diary H24 retro-harvest từ `sub-lead-{view,omission}.md` 8379B/9967B · rmdir stray S119 · S130 solo-0-spawn = không nợ) · **H1 DRIFT-FOUND 1 thật FIXED cùng phiên** (`rag-onboarding-guide:118/:134` `contextual_retrieval: true` + "+15% recall" vô-căn-cứ sau owner-final `f5ce2778` → false + reword; roster 14/14 khớp 4-nguồn; plugin 0-delta) · (h) marks n-a · (i) spot-check 3/3 (sha 2 email re-compute MATCH + tick ancestor) · **(j) counter=7 · light 4/6 · deep 4/15 · OVERDUE none**.)
|
||||
**Carry MỞ re-stamp (8):** (1) carry-age design-question còn treo (vacuous-GỐC đã hết @S130 nhờ tách dòng — anh xem đóng?) `[carry:carry-age-vacuous]` (2) hook-cap 17.1K — reviewer ĐÃ curate 20.3→16.8KB @S128; cụm D6 còn inv-cb/frontend-designer/cicd/test-spec trên-target `[carry:hook-vs-budget-cap]` (3) UAT S117 incognito `[carry:uat-s117]` (4) PE phân-quyền số-phiếu+user `[carry:pe-phanquyen]` (5) BVAAU `[carry:bvaau]` (6) quyết-định Bình/Lê `[carry:binh-le-decision]` (7) Trà/Bùi re-login `[carry:tra-bui-relogin]` (8) account trùng `[carry:account-trung]`.
|
||||
**🔴 NEXT anh:** (1) 2 đề-xuất S131 CHỜ GẬT: wire tick H24 vào `/tiep` (+tick-at-close idempotent) + H24 light-run owner-triggered ngay (thay vì chờ counter 9); (2) CHỜ HUB: reply-batch mục (i) `_owner_ratified` + (iii) seal-verify + do-token errata + phản-hồi closure-cadence/3-gap — khi land, em `/check-email` verify hash + áp canonical + re-sync F10 `session-start.md:206`; (3) carry (1) carry-age — bằng-chứng S130 (detector 7/52) đủ đóng chưa?
|
||||
**NEXT em (phiên kế):** watch `broadcasts/inbox`; spawn-probe reviewer xác-nhận Measured-label gate `reviewer.md:46` runtime-active (post-restart, H1 đề-xuất #3); monthly combined audit 2026-08-01; đọc verdict cicd nếu CI chạy cho commit chốt S131-S132.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,46 @@
|
||||
# S133 — PE freeze ngân sách phiếu DaDuyet (2026-07-17, closeout @S134 `/tiep`)
|
||||
|
||||
**Commit:** `d4fbced` (16 file, +7831/−63) PUSHED `9e874ca..d4fbced` · **CI:** Run **#398** VERDICT **GO 5/5** · **Test:** 509→**520** (45D+475I, 0 fail) · **Mig:** 66→**67** · **Bundle:** admin `3NCdyODe`/css `B--dUHcm` · user `CKsoiNCI`/css `BuntB63r` · **Lead:** Fable 5 (owner-choice) · HMW-ON · run-folder `.claude/workflows/runs/2026-07-17-S133-pe-budget-freeze-daduyet/`
|
||||
|
||||
## Đề bài (UAT anh Kiệt FDC — chat 2026-07-16 17:24–17:27)
|
||||
|
||||
Cập nhật ngân sách (`PeWorkItemBudgets` live, per Dự án × Hạng mục) làm **phiếu ĐÃ DUYỆT đổi số theo** (retro-display). Yêu cầu chốt:
|
||||
- **R1** — phiếu DaDuyet **bất biến**, không đổi gì hết.
|
||||
- **R2** — ngân sách đúng nhất cho phiếu mới = bản cập nhật **gần nhất** (live giữ nguyên semantics).
|
||||
- **R3** — "tức lại database tí là oki": DB delta nhỏ, **không sinh bảng mới** (snapshot AddColumn).
|
||||
|
||||
## Giải pháp ship
|
||||
|
||||
- **Mig 67 `AddPeApprovedBudgetSnapshot`** — 11 AddColumn snapshot budget-summary lên PE (`ApprovedBudgetSnapshotAt` + `ApprovedBudgetPro{InitialAmount,AdjustmentAmount,Note}` + `ApprovedBudgetCcm{InitialAmount,AdjustmentAmount,Note}` + `ApprovedBudgetPrev{SubmittedTotal,SubmittedCount,SelectedTotal,SelectedCount}`), reversible Down DropColumn ×11, **no new table (89 giữ)**.
|
||||
- **Chốt tại choke-point:** helper `PeBudgetAccumulator` 4-site (`PurchaseEvaluationFeatures/Service :862/:901/:934/:1109`) — mọi nhánh vào DaDuyet (CEO-final · level-finalize · CCM-threshold) đi qua cùng snapshot logic.
|
||||
- **Backfill** `Sql()` OUTER APPLY khớp accumulator cho phiếu DaDuyet cũ — prod **18/18, chua=0** (không phiếu nào sót).
|
||||
- **Display-gate `budgetFrozen`:** phiếu DaDuyet đọc snapshot + badge **🔒 "Ngân sách chốt tại thời điểm duyệt"** tại "Tổng hợp ngân sách trình ký" (FE 2-app SHA-mirror `5534addb`, `PeDetailTabs :1352`); phiếu chưa duyệt đọc live (R2).
|
||||
- **+2 terminal-guard:** chặn sửa "NS kỳ này (CCM)" / "Điều chỉnh ngân sách" trên phiếu đã kết thúc — lỗi rõ nghĩa, **chặn cả Admin**.
|
||||
|
||||
## Pipeline (6 stage — engine-đắt H21 + hmw staged)
|
||||
|
||||
1. **`/fable-real invest`** `wf_348f6155-167` — ⚠️ #53 return-fail, **disk sub-file 22.5KB cứu trọn** (`sub-investigator-codebase-0.md`); lead verify 7/7 claim PASS.
|
||||
2. **Spec + errata** — `spec-pe-budget-freeze-daduyet-17-07-2026.md` (errata: query = `GetPurchaseEvaluationQuery` bundle prop `BudgetSummary`).
|
||||
3. **hmw implement Opus MAX ×3 invocation staged:**
|
||||
- **INV-A** (`wf_d9639faa-97a`, 0-garble): **T0 RED thật** (`Failed 1/1` — found 999tr @`PeBudgetFreezeTests.cs:184`, prod sạch) + FE 4 file badge amber, npm build ×2 PASS.
|
||||
- **INV-B** (`wf_4bef08b8-730`, #53 ×2): lane xong 6 file + `PeBudgetAccumulator` — **chết trước mig** → **em-main-solo Mig 67** (scaffold + backfill; apply 2 DB, Dev catch-up Mig 66 lag; build 0/0; filter 44/44 **T0 RED→GREEN**).
|
||||
- **INV-C** (`wf_75c6bfd3-754`, #53 lần 3 → disk): +T1-T9 vào `PeBudgetFreezeTests` (9 vị trí `:426`→`:821`); full suite 519/0.
|
||||
4. **Reviewer pre-commit** (`wf_f9c0b939-181`, return sạch): **PASS_WITH_FIXES 0M/2S/2N** — 2 SHOULD = **#81-EXT indirect-assignment**: admin-override `PurchaseEvaluationWorkflowService.cs:308` (`= targetPhase`) + seeder `DbInitializer:1323` (`= current`) gán Phase thẳng **bypass choke-point** → phiếu vào DaDuyet không snapshot. Lead FIX cả 2 + test `AdminOverride` site-5 + comment `[S133 site 5]` → filter **11/11**, FULL **520/0**, npm ×2 PASS.
|
||||
5. **Commit + push** `d4fbced`.
|
||||
6. **cicd-monitor** (`wf_4eadbcf6-c25`, return sạch — verdict về SAU `/pause`, lead harvest-liền): **VERDICT GO 5/5** — Run #398 success gate 520 · Mig 67 prod (history-top + 11 cột + backfill 18/18 chua=0 + 89 bảng) · bundle rotate ×2 (CSS cũng rotate — badge sinh utility-class mới, benign #69) · byte-verify #77 `budgetFrozen`=1 + VN-string 🔒=1, old-control 0 · smoke 4×200 + PE 401.
|
||||
|
||||
## Closeout @S134 `/tiep` (phiên kế — S133 `/pause` giữa chờ cicd)
|
||||
|
||||
- **Sàn-3:** flag ①(orphan run-folder — chưa synthesis) + ④ (wal-commit chạm `runs/`) — cả 2 trỏ đúng mạch S133 đang dở, nhất quán WAL (không phải sổ-trống-giả-sạch).
|
||||
- **Re-verify độc lập verdict GO 4/4 KHỚP** (mục 8 WAL dặn spot-check vì harvest-liền chưa re-verify): mig prod top = `20260717032812_AddPeApprovedBudgetSnapshot` (ssh sqlcmd) · health/live 200 · bundle-hash ×2 app khớp byte (curl index.html) · `dotnet test --filter PeBudgetFreeze` **11/11**.
|
||||
- **Docs closeout:** STATUS (Mig 67 · 520 · Recently-Done S133 · bundle hoist S117→S133) + HANDOFF (segment S133, re-stamp 9 carry, UAT script) + gotchas **#81-EXT** (giữ count 83) + root CLAUDE.md (row PE + lịch sử mig) + session-log này + `close-synthesis.md` đóng orphan.
|
||||
|
||||
## ⚠️ #53 events (×3 + 1 overwrite-lesson) — 0 mất việc
|
||||
|
||||
- fable-real invest + INV-B ×2 + INV-C: đều **recover từ disk sub-file** (ghi-đĩa-trong-lúc-làm).
|
||||
- **Lesson MỚI:** 2 invocation cùng role+index chung run → `sub-<role>-0.md` bị **OVERWRITE** (file INV-A bị INV-C đè). Multi-invocation chung run-folder phải đánh index/tên riêng per-invocation.
|
||||
|
||||
## Carry
|
||||
|
||||
- 🆕 **UAT freeze-NS** `[carry:uat-s133-budget-freeze]` — script 3 bước trong HANDOFF (badge 🔒 / phiếu mới số MỚI / guard sửa-NS lỗi rõ nghĩa).
|
||||
- **hook-cap bắn ×2 phiên này** (inv-cb 19.7KB · reviewer 20.4KB — đều dưới cap anh 25.6K/30.7K ⇒ em không tự nén theo S122) `[carry:hook-vs-budget-cap]`.
|
||||
@ -1348,6 +1348,8 @@ for h in resp.points: # ← .points không phải iterable trực tiếp
|
||||
|
||||
**Bài học:** derived-invariant phải re-establish ở **MỌI mutation đổi tập-nguồn — kể cả DELETE + cascade-delete**, không chỉ write/select. **Enumeration writer trong spec KHÔNG đáng tin** — grep độc-lập MỌI `.Remove(` chạm entity chứa field-nguồn (S87/S88 cardinality class mở rộng lên tầng mutation-path: writer-miss ≡ read-site-miss). Chỉ **verify adversarial-refute** bắt (invest/review/implement đều sót vì cùng theo enumeration). Liên-quan #72/#73 (cardinality) + `feedback_cardinality_change_grep_consumers`.
|
||||
|
||||
**EXT S133 (instance 2 — indirect-assignment):** snapshot ngân-sách-at-DaDuyet (Mig 67) đặt hook ở finalize choke-point (`ApplyApprovedPriceOnFinalize` + helper `PeBudgetAccumulator` 4-site) NHƯNG 2 site gán `Phase` TRỰC TIẾP bypass hook: admin-override `PurchaseEvaluationWorkflowService.cs:308` (`= targetPhase`) + seeder `DbInitializer:1323` (`= current`) → phiếu vào DaDuyet qua 2 đường này KHÔNG snapshot (frozen=0, display rơi về live — đúng bug đang vá, chỉ ở nhánh hiếm hơn). Reviewer adversarial bắt pre-commit; em-main + 3 invocation implement đều sót — cùng bài enumeration. Fix: site-5 chốt snapshot trong admin-override (comment `[S133 site 5]`) + seeder + test `AdminOverride`. **Meta-class chung với instance 1:** field-đích có hook/derive/snapshot ⇒ grep MỌI assignment-site của field đó (`= targetPhase`, `.Phase =`, seeder, override) chứ không chỉ mutation-API chính-tắc.
|
||||
|
||||
### 82. Menu-flag permission grant ≠ quyền API thật — controller gate GET bằng `[Authorize]` trần (Session 118)
|
||||
|
||||
**Triệu chứng:** S118 phân quyền role Procurement — grant menu `Suppliers` (R+C+U) tưởng "menu hiện = làm được mọi thứ". THỰC TẾ 2 chiều lệch: (1) `PUT/DELETE /suppliers` gate `[Authorize(Roles="Admin,CatalogManager")]` → PRO vẫn **403 khi Sửa/Xóa NCC** dù CanUpdate=1 (chỉ Publish/Import theo policy `Suppliers.Update` là ăn); nút Sửa hiện nhưng bấm lỗi. (2) Ngược lại `ReportsController` chỉ `[Authorize]` **trần (KHÔNG policy)** → menu bị S92 ẩn nhưng **MỌI user đăng nhập** vẫn gọi `GET /reports/dashboard` lấy tổng giá trị HĐ + top NCC/dự án theo giá trị (gõ URL/API trực tiếp, bỏ qua menu).
|
||||
|
||||
@ -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 (
|
||||
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums text-slate-900" colSpan={colSpan}>
|
||||
{value == null
|
||||
? <span className="font-sans text-slate-400">Chưa chọn</span>
|
||||
: <span className={cn(value < 0 && 'font-semibold text-red-600')}>{fmtVnd(value)}</span>}
|
||||
{sub}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
@ -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 <td> sẵn
|
||||
// (cho dòng 5/6/7/8 ccm); KHÔNG asCell → chỉ nội dung (nhúng vào <td> 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
|
||||
? <span className="text-slate-300">—</span>
|
||||
@ -1208,8 +1209,8 @@ function BudgetColValue({ value, pct, signed = false, asCell = false }: {
|
||||
</>
|
||||
)
|
||||
return asCell
|
||||
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}</td>
|
||||
: <>{body}</>
|
||||
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}{sub}</td>
|
||||
: <>{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) => (
|
||||
<div className="mt-0.5 text-[10px] font-sans italic text-amber-700">
|
||||
Tạm tính: {fmtVnd(base + delta)}
|
||||
{withDetail && ` (+${fmtVnd(delta)} từ ${count} phiếu chưa duyệt)`}
|
||||
</div>
|
||||
)
|
||||
|
||||
// [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) */}
|
||||
<tr>
|
||||
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">1. Ngân sách trình duyệt trước</td>
|
||||
<BudgetSharedNumCell colSpan={3} value={bs.previousSubmittedCount === 0 ? null : row1} />
|
||||
<BudgetSharedNumCell colSpan={3} value={bs.previousSubmittedCount === 0 ? null : row1}
|
||||
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(row1, pendingSubmittedTotal, pendingSubmittedCount, true) : undefined} />
|
||||
</tr>
|
||||
{/* 2 — Kỳ trước đã chọn thầu (CHUNG) */}
|
||||
<tr>
|
||||
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">2. Kỳ trước đã chọn thầu</td>
|
||||
<BudgetSharedNumCell colSpan={3} value={bs.previousSelectedCount === 0 ? null : row2} />
|
||||
<BudgetSharedNumCell colSpan={3} value={bs.previousSelectedCount === 0 ? null : row2}
|
||||
sub={showPending && pendingSelectedCount > 0 ? pendingSub(row2, pendingSelectedTotal, pendingSelectedCount, true) : undefined} />
|
||||
</tr>
|
||||
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
|
||||
<tr>
|
||||
@ -1573,8 +1594,10 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
<div className="text-[10px] text-slate-400">= 1 + 3</div>
|
||||
</td>
|
||||
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300">—</td>
|
||||
<BudgetColValue asCell value={pro.r5} />
|
||||
<BudgetColValue asCell value={ccm.r5} />
|
||||
<BudgetColValue asCell value={pro.r5}
|
||||
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(pro.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} />
|
||||
<BudgetColValue asCell value={ccm.r5}
|
||||
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(ccm.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} />
|
||||
</tr>
|
||||
{/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */}
|
||||
<tr>
|
||||
@ -1582,7 +1605,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
6. Lũy kế thực hiện
|
||||
<div className="text-[10px] text-slate-400">= 2 + 4</div>
|
||||
</td>
|
||||
<BudgetSharedNumCell colSpan={3} value={row6} />
|
||||
<BudgetSharedNumCell colSpan={3} value={row6}
|
||||
sub={showPending && pendingSelectedCount > 0 ? pendingSub(row6, pendingSelectedTotal, pendingSelectedCount, false) : undefined} />
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
|
||||
@ -1654,6 +1678,28 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* [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 && (
|
||||
<div className="border-t border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-6 text-amber-800">
|
||||
<span className="font-medium">Phiếu trước cùng gói chưa duyệt: </span>
|
||||
{pendingPriorPes.map((p, i) => (
|
||||
<span key={p.id}>
|
||||
{i > 0 && ', '}
|
||||
<span className="font-mono">{p.maPhieu ?? '(chưa có mã)'}</span>{' '}
|
||||
<span className={cn(
|
||||
'rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||
p.phase === 10 ? 'bg-amber-100 text-amber-800'
|
||||
: p.phase === 98 ? 'bg-orange-100 text-orange-800'
|
||||
: p.phase === 1 ? 'bg-slate-200 text-slate-700'
|
||||
: 'bg-amber-100 text-amber-800',
|
||||
)}>
|
||||
{PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* [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 && (
|
||||
<div className="rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||
⚠ {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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{next.length > 0 && !readOnly && (
|
||||
<div>
|
||||
<Label className="text-xs">Hành động:</Label>
|
||||
@ -522,6 +541,13 @@ export function PeWorkflowPanel({
|
||||
<Button onClick={() => transition.mutate()} disabled={transition.isPending || priceMissing}>Xác nhận</Button>
|
||||
</>}
|
||||
>
|
||||
{/* [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 && (
|
||||
<div className="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||
⚠ {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.
|
||||
</div>
|
||||
)}
|
||||
{isCancel && (
|
||||
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||
⚠ 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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums text-slate-900" colSpan={colSpan}>
|
||||
{value == null
|
||||
? <span className="font-sans text-slate-400">Chưa chọn</span>
|
||||
: <span className={cn(value < 0 && 'font-semibold text-red-600')}>{fmtVnd(value)}</span>}
|
||||
{sub}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
@ -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 <td> sẵn
|
||||
// (cho dòng 5/6/7/8 ccm); KHÔNG asCell → chỉ nội dung (nhúng vào <td> 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
|
||||
? <span className="text-slate-300">—</span>
|
||||
@ -1208,8 +1209,8 @@ function BudgetColValue({ value, pct, signed = false, asCell = false }: {
|
||||
</>
|
||||
)
|
||||
return asCell
|
||||
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}</td>
|
||||
: <>{body}</>
|
||||
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}{sub}</td>
|
||||
: <>{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) => (
|
||||
<div className="mt-0.5 text-[10px] font-sans italic text-amber-700">
|
||||
Tạm tính: {fmtVnd(base + delta)}
|
||||
{withDetail && ` (+${fmtVnd(delta)} từ ${count} phiếu chưa duyệt)`}
|
||||
</div>
|
||||
)
|
||||
|
||||
// [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) */}
|
||||
<tr>
|
||||
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">1. Ngân sách trình duyệt trước</td>
|
||||
<BudgetSharedNumCell colSpan={3} value={bs.previousSubmittedCount === 0 ? null : row1} />
|
||||
<BudgetSharedNumCell colSpan={3} value={bs.previousSubmittedCount === 0 ? null : row1}
|
||||
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(row1, pendingSubmittedTotal, pendingSubmittedCount, true) : undefined} />
|
||||
</tr>
|
||||
{/* 2 — Kỳ trước đã chọn thầu (CHUNG) */}
|
||||
<tr>
|
||||
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">2. Kỳ trước đã chọn thầu</td>
|
||||
<BudgetSharedNumCell colSpan={3} value={bs.previousSelectedCount === 0 ? null : row2} />
|
||||
<BudgetSharedNumCell colSpan={3} value={bs.previousSelectedCount === 0 ? null : row2}
|
||||
sub={showPending && pendingSelectedCount > 0 ? pendingSub(row2, pendingSelectedTotal, pendingSelectedCount, true) : undefined} />
|
||||
</tr>
|
||||
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
|
||||
<tr>
|
||||
@ -1573,8 +1594,10 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
<div className="text-[10px] text-slate-400">= 1 + 3</div>
|
||||
</td>
|
||||
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300">—</td>
|
||||
<BudgetColValue asCell value={pro.r5} />
|
||||
<BudgetColValue asCell value={ccm.r5} />
|
||||
<BudgetColValue asCell value={pro.r5}
|
||||
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(pro.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} />
|
||||
<BudgetColValue asCell value={ccm.r5}
|
||||
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(ccm.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} />
|
||||
</tr>
|
||||
{/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */}
|
||||
<tr>
|
||||
@ -1582,7 +1605,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
6. Lũy kế thực hiện
|
||||
<div className="text-[10px] text-slate-400">= 2 + 4</div>
|
||||
</td>
|
||||
<BudgetSharedNumCell colSpan={3} value={row6} />
|
||||
<BudgetSharedNumCell colSpan={3} value={row6}
|
||||
sub={showPending && pendingSelectedCount > 0 ? pendingSub(row6, pendingSelectedTotal, pendingSelectedCount, false) : undefined} />
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
|
||||
@ -1654,6 +1678,28 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* [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 && (
|
||||
<div className="border-t border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-6 text-amber-800">
|
||||
<span className="font-medium">Phiếu trước cùng gói chưa duyệt: </span>
|
||||
{pendingPriorPes.map((p, i) => (
|
||||
<span key={p.id}>
|
||||
{i > 0 && ', '}
|
||||
<span className="font-mono">{p.maPhieu ?? '(chưa có mã)'}</span>{' '}
|
||||
<span className={cn(
|
||||
'rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||
p.phase === 10 ? 'bg-amber-100 text-amber-800'
|
||||
: p.phase === 98 ? 'bg-orange-100 text-orange-800'
|
||||
: p.phase === 1 ? 'bg-slate-200 text-slate-700'
|
||||
: 'bg-amber-100 text-amber-800',
|
||||
)}>
|
||||
{PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* [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 && (
|
||||
<div className="rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||
⚠ {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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{next.length > 0 && !readOnly && (
|
||||
<div>
|
||||
<Label className="text-xs">Hành động:</Label>
|
||||
@ -522,6 +541,13 @@ export function PeWorkflowPanel({
|
||||
<Button onClick={() => transition.mutate()} disabled={transition.isPending || priceMissing}>Xác nhận</Button>
|
||||
</>}
|
||||
>
|
||||
{/* [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 && (
|
||||
<div className="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||
⚠ {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.
|
||||
</div>
|
||||
)}
|
||||
{isCancel && (
|
||||
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||
⚠ 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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<PePendingPriorPeDto>? PendingPriorPes = null);
|
||||
|
||||
@ -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<PePendingPriorPeDto> PriorPes);
|
||||
|
||||
public static class PeBudgetAccumulator
|
||||
{
|
||||
public static async Task<PeBudgetAccumulation> 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<PePendingAccumulation> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
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<PurchaseEvaluation> 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<ApprovalWorkflow> 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<PePendingAccumulation> 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<Guid>(), 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<UserManager<User>>();
|
||||
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<string>())).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<string>());
|
||||
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<string>())).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<string>());
|
||||
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<string>())).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<string>());
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user