Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
20 KiB
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 == DaDuyetANDSuppliers.Any(IsWinner)(existence) —:50-52; total = SUMThanhTienquoteIsSelectedcủ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) —:1018GỌ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 — idempotentWHERE 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ộtApprovedBudget*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: pairRecPeWorkItemBudgets+ 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) = SUMBudgetPeriodAmountpeers 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 IsSelectedBudgetFrozen(: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 + r3per-cột trongcolCalc(: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 →
bsnull → 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
PeWorkItemBudgets6 cột (:1004-1011) + accumulator 4 cột lũy kế (:1018-1023); WorkItemId null → 10 cột null (:1027-1037); LUÔN setApprovedBudgetSnapshotAt(: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 eligibleccmDelegationEligible(:109-111) · per-level finalizeapproverFinalizeEligible(: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,
priceMissingdisable :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; setEndedByLevelFinalize=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
- WorkItemId null:
budgetSummarynull toàn cục (gateFeatures.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). - 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.
- 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.
- 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. - PRO-split Mig 56:
proInitialAmountchỉ là FALLBACK DISPLAY row3 (PeDetailTabs:1516), accumulator dùngBudgetPeriodAmount ?? 0— tạm-tính giữ nguyên quy ước (không sửa drift-vector sẵn có trong scope này). - 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). - 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 mirrorPeBudgetFreezeTests:311-332/:435-451. - 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).
- 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
ContextNoteLogTransitionAsync 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):
PeBudgetAccumulator.cs— THÊM recordPePendingAccumulation(PendingPrevSubmittedTotal, PendingPrevSubmittedCount, PendingPrevSelectedTotal, PendingPrevSelectedCount, PendingPriorPes)+ method MỚIComputePendingAsync(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ửaComputeAsync(snapshot-path + Mig 67 bất động).PeBudgetSummaryDto(PurchaseEvaluationDtos.cs:331-353) — append CUỐI sauBudgetFrozen: 4 số pending (default 0) +IReadOnlyList<PePendingPriorPeDto>? PendingPriorPes = null(pattern append-an-toàn như BudgetFrozen S133).GetPurchaseEvaluationQuery— nhánh LIVE (Features.cs:957-984): gọiComputePendingAsync, 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).- (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.
PeWorkflowPanel.tsx(SHA-mirror9d88e671): (a) banner amber cạnh V2-banner (:437) khievaluation.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 khiisApproveAction(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:
ComputePendingAsyncmới; acceptance:git diffvùngComputeAsync= 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.csdiff = 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
PeBudgetFreezeTestsseed :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)
- 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.
- 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).
- 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).
- 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").
- TuChoi exclude — khuyến nghị chắc, nêu để anh confirm.