[CLAUDE] PurchaseEvaluation: luy ke TAM TINH vs CHINH XAC — ComputePendingAsync + 5-field DTO + FE 2-app sub-dong amber/banner man duyet + D4 audit-note (UAT anh Kiet 15-07, 0 mig, test 520→528)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m27s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m27s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -1,20 +1,21 @@
|
|||||||
# WAL — auto-generated, không sửa tay
|
# WAL — auto-generated, không sửa tay
|
||||||
updated: 2026-07-17 | session: S133 | branch: main
|
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 budget FREEZE-at-DaDuyet (UAT anh Kiệt 07-16): phiếu DaDuyet bất biến NS; pipeline invest → spec → implement → review → deploy
|
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:
|
chain:
|
||||||
[x] 1. fable-real invest — wf_348f6155-167 (#53 → disk 22.5KB cứu, verify 7/7)
|
[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. spec + errata — runs/2026-07-17-S133-pe-budget-freeze-daduyet/spec-pe-budget-freeze-daduyet-17-07-2026.md
|
[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. INV-A — wf_d9639faa-97a: T0 RED thật @:184 · FE 4 file hash 5534addb ×2 · 0-garble
|
[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. 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
|
[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)
|
||||||
[!] 5. wf: hmw INV-C 1-task test-specialist (T1-T9 vào PeBudgetFreezeTests + full suite ≥510+N 0-fail) — wf_75c6bfd3-754
|
[x] 5a. re-verify build 0-err + filter PeBudgetFreeze 11/11 (INV-2 không vỡ freeze)
|
||||||
[ ] 6. reviewer pre-commit (mig+financial+3-bản-predicate) → em main npm build ×2 + commit push → cicd-monitor verify prod
|
[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
|
||||||
[ ] 7. docs closeout (STATUS Mig 67 · HANDOFF · session-log) + harvest còn lại
|
[!] 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:
|
verify:
|
||||||
- dotnet test SolutionErp.slnx --nologo (0 fail, tổng = 510 + test mới INV-C)
|
- ls .claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/ # run.md + sub-investigator-codebase-0.md (khi invest ghi)
|
||||||
- git status --porcelain (Migrations 3 file mới + BE 6 M + accumulator + tests + FE 4 M)
|
- git log --oneline -1 # de64bf2 (docs S133) hoặc wal: mới hơn
|
||||||
- grep -c "ApplyBudgetSnapshotOnFinalizeAsync" src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs = 5
|
- grep -n "TẠM TÍNH" .claude/workflows/runs/2026-07-17-S134-pe-luyke-tam-tinh/run.md # goal đúng mạch
|
||||||
- sqlcmd Dev: PurchaseEvaluations Phase=7 → chua_snapshot=0 (đã chạy: 1/0 ✓)
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# Implementer-Backend Agent — Persistent Memory
|
# 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]`
|
- **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).
|
> **Persistent diary cross-session.** Auto-injected first ~200 lines at spawn (L1 HOT).
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# Implementer-Frontend Agent — Persistent Memory
|
# 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]`
|
- **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).
|
> **Persistent diary cross-session.** Auto-injected first ~200 lines at spawn (L1 HOT).
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# Reviewer Agent — Persistent Memory
|
# 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).
|
> **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).
|
> 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`.
|
> 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`)
|
## 📅 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ó).**
|
- **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]`
|
||||||
- **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 @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 @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 @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 @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 @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 @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 @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 @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/`.
|
- **[→ 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.
|
||||||
- **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.
|
|
||||||
- **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
|
- **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.
|
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 + 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/.
|
- **[→ 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]`
|
- **[→ 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/`.
|
||||||
- **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`.
|
- **[→ 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.
|
||||||
- **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] 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
|
## 🔄 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.
|
- 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.
|
- **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ũ.
|
||||||
- **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).
|
- **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.
|
||||||
- **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.
|
|
||||||
|
|||||||
@ -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.
|
||||||
@ -2,7 +2,7 @@
|
|||||||
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
|
// 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
|
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
||||||
// → PeApprovalsSection + PeHistorySection).
|
// → 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 { useIsFetching, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
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
|
// [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".
|
// (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 (
|
return (
|
||||||
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums text-slate-900" colSpan={colSpan}>
|
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums text-slate-900" colSpan={colSpan}>
|
||||||
{value == null
|
{value == null
|
||||||
? <span className="font-sans text-slate-400">Chưa chọn</span>
|
? <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>}
|
: <span className={cn(value < 0 && 'font-semibold text-red-600')}>{fmtVnd(value)}</span>}
|
||||||
|
{sub}
|
||||||
</td>
|
</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 → "—".
|
// [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
|
// 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).
|
// (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 }: {
|
function BudgetColValue({ value, pct, signed = false, asCell = false, sub }: {
|
||||||
value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean
|
value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean; sub?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const body = value == null
|
const body = value == null
|
||||||
? <span className="text-slate-300">—</span>
|
? <span className="text-slate-300">—</span>
|
||||||
@ -1208,8 +1209,8 @@ function BudgetColValue({ value, pct, signed = false, asCell = false }: {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
return asCell
|
return asCell
|
||||||
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}</td>
|
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}{sub}</td>
|
||||||
: <>{body}</>
|
: <>{body}{sub}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Mig 59] Ô "So sánh" 1 cột (PRO/CCM) Section B — = 0 → text "Bằng ngân sách" (reuse
|
// [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 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)
|
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ề
|
// [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 =
|
// 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).
|
// full cột; remaining = giá trị TH dự kiến còn lại (null → default row7).
|
||||||
@ -1495,12 +1514,14 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
{/* 1 — Ngân sách trình duyệt trước (CHUNG) */}
|
{/* 1 — Ngân sách trình duyệt trước (CHUNG) */}
|
||||||
<tr>
|
<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>
|
<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>
|
</tr>
|
||||||
{/* 2 — Kỳ trước đã chọn thầu (CHUNG) */}
|
{/* 2 — Kỳ trước đã chọn thầu (CHUNG) */}
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">2. Kỳ trước đã chọn thầu</td>
|
<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>
|
</tr>
|
||||||
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
|
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
|
||||||
<tr>
|
<tr>
|
||||||
@ -1573,8 +1594,10 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
<div className="text-[10px] text-slate-400">= 1 + 3</div>
|
<div className="text-[10px] text-slate-400">= 1 + 3</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300">—</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={pro.r5}
|
||||||
<BudgetColValue asCell value={ccm.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>
|
</tr>
|
||||||
{/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */}
|
{/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */}
|
||||||
<tr>
|
<tr>
|
||||||
@ -1582,7 +1605,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
6. Lũy kế thực hiện
|
6. Lũy kế thực hiện
|
||||||
<div className="text-[10px] text-slate-400">= 2 + 4</div>
|
<div className="text-[10px] text-slate-400">= 2 + 4</div>
|
||||||
</td>
|
</td>
|
||||||
<BudgetSharedNumCell colSpan={3} value={row6} />
|
<BudgetSharedNumCell colSpan={3} value={row6}
|
||||||
|
sub={showPending && pendingSelectedCount > 0 ? pendingSub(row6, pendingSelectedTotal, pendingSelectedCount, false) : undefined} />
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -130,6 +130,17 @@ export function PeWorkflowPanel({
|
|||||||
isStepAfterFinalize(stepOrder)
|
isStepAfterFinalize(stepOrder)
|
||||||
|| (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order
|
|| (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order
|
||||||
&& finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder)
|
&& 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).
|
// Ứ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"
|
// [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].
|
// (source=ProMinMax, amount=Min, amountMax=Max). Chọn nó = chốt dải [Min, Max].
|
||||||
@ -436,6 +447,14 @@ export function PeWorkflowPanel({
|
|||||||
</div>
|
</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 && (
|
{next.length > 0 && !readOnly && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Hành động:</Label>
|
<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>
|
<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 && (
|
{isCancel && (
|
||||||
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
<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.
|
⚠ 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
|
previousSelectedCount: number
|
||||||
currentProposalTotal: number // SUM ThanhTien quotes NCC trúng (IsWinner) phiếu NÀY (0 khi chưa chọn)
|
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
|
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
|
// Mirror BE PeDepartmentKind enum
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
|
// 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
|
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
||||||
// → PeApprovalsSection + PeHistorySection).
|
// → 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 { useIsFetching, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
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
|
// [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".
|
// (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 (
|
return (
|
||||||
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums text-slate-900" colSpan={colSpan}>
|
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums text-slate-900" colSpan={colSpan}>
|
||||||
{value == null
|
{value == null
|
||||||
? <span className="font-sans text-slate-400">Chưa chọn</span>
|
? <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>}
|
: <span className={cn(value < 0 && 'font-semibold text-red-600')}>{fmtVnd(value)}</span>}
|
||||||
|
{sub}
|
||||||
</td>
|
</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 → "—".
|
// [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
|
// 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).
|
// (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 }: {
|
function BudgetColValue({ value, pct, signed = false, asCell = false, sub }: {
|
||||||
value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean
|
value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean; sub?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const body = value == null
|
const body = value == null
|
||||||
? <span className="text-slate-300">—</span>
|
? <span className="text-slate-300">—</span>
|
||||||
@ -1208,8 +1209,8 @@ function BudgetColValue({ value, pct, signed = false, asCell = false }: {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
return asCell
|
return asCell
|
||||||
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}</td>
|
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}{sub}</td>
|
||||||
: <>{body}</>
|
: <>{body}{sub}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Mig 59] Ô "So sánh" 1 cột (PRO/CCM) Section B — = 0 → text "Bằng ngân sách" (reuse
|
// [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 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)
|
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ề
|
// [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 =
|
// 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).
|
// full cột; remaining = giá trị TH dự kiến còn lại (null → default row7).
|
||||||
@ -1495,12 +1514,14 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
{/* 1 — Ngân sách trình duyệt trước (CHUNG) */}
|
{/* 1 — Ngân sách trình duyệt trước (CHUNG) */}
|
||||||
<tr>
|
<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>
|
<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>
|
</tr>
|
||||||
{/* 2 — Kỳ trước đã chọn thầu (CHUNG) */}
|
{/* 2 — Kỳ trước đã chọn thầu (CHUNG) */}
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">2. Kỳ trước đã chọn thầu</td>
|
<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>
|
</tr>
|
||||||
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
|
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
|
||||||
<tr>
|
<tr>
|
||||||
@ -1573,8 +1594,10 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
<div className="text-[10px] text-slate-400">= 1 + 3</div>
|
<div className="text-[10px] text-slate-400">= 1 + 3</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300">—</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={pro.r5}
|
||||||
<BudgetColValue asCell value={ccm.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>
|
</tr>
|
||||||
{/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */}
|
{/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */}
|
||||||
<tr>
|
<tr>
|
||||||
@ -1582,7 +1605,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
6. Lũy kế thực hiện
|
6. Lũy kế thực hiện
|
||||||
<div className="text-[10px] text-slate-400">= 2 + 4</div>
|
<div className="text-[10px] text-slate-400">= 2 + 4</div>
|
||||||
</td>
|
</td>
|
||||||
<BudgetSharedNumCell colSpan={3} value={row6} />
|
<BudgetSharedNumCell colSpan={3} value={row6}
|
||||||
|
sub={showPending && pendingSelectedCount > 0 ? pendingSub(row6, pendingSelectedTotal, pendingSelectedCount, false) : undefined} />
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -130,6 +130,17 @@ export function PeWorkflowPanel({
|
|||||||
isStepAfterFinalize(stepOrder)
|
isStepAfterFinalize(stepOrder)
|
||||||
|| (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order
|
|| (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order
|
||||||
&& finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder)
|
&& 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).
|
// Ứ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"
|
// [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].
|
// (source=ProMinMax, amount=Min, amountMax=Max). Chọn nó = chốt dải [Min, Max].
|
||||||
@ -436,6 +447,14 @@ export function PeWorkflowPanel({
|
|||||||
</div>
|
</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 && (
|
{next.length > 0 && !readOnly && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Hành động:</Label>
|
<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>
|
<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 && (
|
{isCancel && (
|
||||||
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
<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.
|
⚠ 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
|
previousSelectedCount: number
|
||||||
currentProposalTotal: number // SUM ThanhTien quotes NCC trúng (IsWinner) phiếu NÀY (0 khi chưa chọn)
|
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
|
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
|
// Mirror BE PeDepartmentKind enum
|
||||||
|
|||||||
@ -318,6 +318,11 @@ public record PurchaseEvaluationDetailBundleDto(
|
|||||||
string? FinalizeLevelName,
|
string? FinalizeLevelName,
|
||||||
PurchaseEvaluationWorkflowSummaryDto Workflow);
|
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
|
// [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
|
// 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.
|
// (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*
|
// [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" +
|
// 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.
|
// ẩ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 Microsoft.EntityFrameworkCore;
|
||||||
using SolutionErp.Application.Common.Interfaces;
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations.Dtos;
|
||||||
using SolutionErp.Domain.PurchaseEvaluations;
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
namespace SolutionErp.Application.PurchaseEvaluations;
|
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||||
@ -24,6 +25,16 @@ public readonly record struct PeBudgetAccumulation(
|
|||||||
int PrevSelectedCount,
|
int PrevSelectedCount,
|
||||||
decimal CurrentProposalTotal);
|
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 class PeBudgetAccumulator
|
||||||
{
|
{
|
||||||
public static async Task<PeBudgetAccumulation> ComputeAsync(
|
public static async Task<PeBudgetAccumulation> ComputeAsync(
|
||||||
@ -69,4 +80,61 @@ public static class PeBudgetAccumulator
|
|||||||
prevSelectedTotal, prevSelectedCount,
|
prevSelectedTotal, prevSelectedCount,
|
||||||
currentProposalTotal);
|
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,
|
e.ApprovedBudgetPrevSelectedTotal ?? 0m, e.ApprovedBudgetPrevSelectedCount ?? 0,
|
||||||
currentProposalTotal,
|
currentProposalTotal,
|
||||||
e.ApprovedBudgetProInitialAmount, e.ApprovedBudgetProAdjustmentAmount,
|
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
|
else
|
||||||
{
|
{
|
||||||
@ -962,6 +966,12 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
var acc = await PeBudgetAccumulator.ComputeAsync(
|
var acc = await PeBudgetAccumulator.ComputeAsync(
|
||||||
db, e.ProjectId, wiKey, e.Id, e.CreatedAt, curSupplierRowIds, ct);
|
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 =
|
// [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 =
|
// 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.
|
// 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.PrevSelectedTotal, acc.PrevSelectedCount,
|
||||||
acc.CurrentProposalTotal,
|
acc.CurrentProposalTotal,
|
||||||
pairRec?.ProInitialAmount, pairRec?.ProAdjustmentAmount,
|
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.ApprovedBudgetPrevSubmittedCount = acc.PrevSubmittedCount;
|
||||||
evaluation.ApprovedBudgetPrevSelectedTotal = acc.PrevSelectedTotal;
|
evaluation.ApprovedBudgetPrevSelectedTotal = acc.PrevSelectedTotal;
|
||||||
evaluation.ApprovedBudgetPrevSelectedCount = acc.PrevSelectedCount;
|
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
|
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