Files
solution-erp/.claude/workflows/runs/2026-07-12-S114-multi-ncc-per-hangmuc/spec-multi-ncc-per-hangmuc-12-07-2026.md
2026-07-12 21:14:52 +07:00

92 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SPEC — PE multi-NCC per hạng mục (chọn ≥2 NCC-TP/1 hạng mục, mỗi nhà 1 giá riêng)
> Bước 1 kết-tinh (fable-clone invest ensemble `wf_dd49ddc0-f29`, 4-lane investigator-codebase Opus 4.8). Lead verify + synthesize. **Decision D1-D5 CHỜ anh Kiệt chốt trước Bước 2 review + Bước 3 implement.**
## ① Tính-năng / đề-bài
Hiện tại mỗi **hạng mục** (`PurchaseEvaluationDetail`) trong phiếu Duyệt NCC chỉ có winner ở **cấp phiếu** (1 NCC toàn phiếu, hoặc ≥2 NCC liên-danh cả gói Mig 58). Yêu cầu: cho phép **mỗi hạng mục chọn ≥2 NCC-TP trúng thầu riêng**, **mỗi NCC giữ 1 giá riêng**, Bảng so sánh giá hỗ trợ chọn nhiều NCC per hạng mục.
### Ground-truth (4-lane đồng thuận)
- **3 trục winner:** (A) `PurchaseEvaluation.SelectedSupplierId` [PE.cs:28, single legacy] · (B) `PurchaseEvaluationSupplier.IsWinner` [PES.cs:28, Mig 58 = liên-danh whole-supplier] · (C) **`PurchaseEvaluationQuote.IsSelected`** [Quote.cs:15, per (Detail×Supplier), Mig 12 — **DORMANT**].
- **Feature = trục C.** Mig 58 (trục B) KHÔNG giải quyết — đó là "N NCC thắng cả gói", khác "chia winner giữa các hạng mục".
- **`Quote.IsSelected` = hook tự nhiên:** đã persist (`PurchaseEvaluationDetailFeatures.cs:308/332`) + echo DTO (`PurchaseEvaluationDtos.cs:75`) + seed (`DbInitializer.cs:1257`), mỗi Quote có `ThanhTien` = giá riêng. Nhưng **0 consumer tài chính** đọc nó → storage sẵn, chỉ cần wire.
- **Quote unique = (DetailId, SupplierId)** [Config:134] → mỗi NCC tối đa 1 quote/hạng mục = 1 giá/NCC/hạng mục (đúng "mỗi nhà 1 giá riêng").
## ✅ Decision-points — LOCKED (anh Kiệt chốt S114, all recommended)
- **D1 = Cộng giá NCC được chọn** → `winnerQuoteTotal = SUM(Quote.ThanhTien các NCC IsSelected)`. **0 field mới.**
- **D2 = THAY THẾ** → `Quote.IsSelected` = nguồn-sự-thật; `Supplier.IsWinner` = **DERIVED** (NCC có ≥1 IsSelected quote), giữ cho luồng tạo-HĐ + display.
- **D3 = Giá chào gốc** → dùng `Quote.ThanhTien` sẵn có. **0 cột mới.**
- **D4 = 1 HĐ/NCC** → mỗi HĐ gộp hạng mục NCC đó thắng, giaTri = `SUM(IsSelected quotes của NCC)`. Giữ pattern S88.
- **D5 (default em) = submit-guard giữ `winnerQuoteTotal>0` toàn phiếu** (nay tính theo IsSelected), KHÔNG hard-require winner mỗi hạng mục (nới, cảnh-báo-mềm nếu cần).
**Migration KẾT QUẢ: 0 bảng mới, 0 cột mới.** Chỉ wire `Quote.IsSelected` + re-key 6 BE read-site + FE + backfill phiếu cũ. Path ② dưới = ACTIVE.
## ② Cách implement (recommended path — nếu D1=SUM, D2=THAY-THẾ, D3=giá-gốc, D4=1HĐ/NCC)
### Schema / Migration
- **KHÔNG bảng mới.** Tái dùng `Quote.IsSelected` làm winner-truth per (hạng mục × NCC).
- (D3 nếu tách giá) → 1 AddColumn `Quote.WinnerPrice decimal?` (nullable, fallback ThanhTien).
- (D1 nếu chia KL) → 1 AddColumn `Quote.AllocatedKhoiLuong decimal?`.
- **Backfill** (D2 thay-thế): phiếu cũ có `Supplier.IsWinner=true` nhưng `Quote.IsSelected=false` → set `IsSelected=1` cho mọi quote của NCC IsWinner (mirror backfill Mig 58 :31-35), tránh phiếu cũ mất winner → total=0.
### BE write-site (1 chỗ)
- `SelectPurchaseEvaluationWinnerCommand` [`PurchaseEvaluationDetailFeatures.cs:397-431`] — hiện set `Supplier.IsWinner` toàn phiếu. **Đổi/thêm:** command per (DetailId → list SupplierId) set `Quote.IsSelected` per cell. Derive `Supplier.IsWinner = có ≥1 IsSelected quote` (D2 giữ luồng tạo-HĐ).
### 🔴 BE read-sites PHẢI re-key `Supplier.IsWinner` → `Quote.IsSelected` (anti-cardinality-trap — chống sót)
1. **winnerQuoteTotal** `PurchaseEvaluationFeatures.cs:1189-1196``SUM(Quotes.ThanhTien WHERE q.IsSelected)`.
2. **Submit-guard** `PurchaseEvaluationWorkflowService.cs:196-211` → total theo IsSelected.
3. **CCM-threshold-finalize (GATE TÀI CHÍNH)** `PurchaseEvaluationWorkflowService.cs:885-908``< CeoApprovalThreshold` strict → total theo IsSelected (sai = chặn/cho-qua sai quyền CCM).
4. **CreateContractFromEvaluation** `CreateContractFromEvaluationFeatures.cs:56` (winners) + giaTri `:84-93` → winners = NCC có ≥1 IsSelected; giaTri = SUM(IsSelected quotes của NCC đó).
5. **Budget-compare Block-B** `PurchaseEvaluationFeatures.cs:907-927` (currentProposalTotal + prevSelected) → re-key IsSelected.
6. **Projection display** List `:637` · Inbox `:758` · ListApproved `:188-192` (winner-names) + JOIN tên NCC `:560/:874/:1209` → gộp distinct NCC có ≥1 IsSelected; `SelectedSupplierId` mất nghĩa khi split.
### FE 2-app (`PeDetailTabs.tsx` SHA-locked `2dca7cee` — patch byte-identical CẢ 2 app; QuoteDialog/HangMucCard = inline function)
- **DetailCard bảng NCC** `:2638-2792``isWinner = s.isWinner` [:2677] → đổi source per-cell (`q.isSelected`); `toggleWinner` [:2746] → mutation per-detail; styling/ring [:2687-2690] + `canDelete` [:2679] revisit.
- **NccSelectorRow Section 3 picker** `:941-1020``checked={s.isWinner}` [:998] picker phiếu-wide → hạ-cấp read-only summary hoặc rethink per-hạng-mục.
- **Read-sites vỡ nếu quên:** submit-guard `:195/:229` · Section 4 winner-names `:1955-1976` · canCreateContract `:1906` + confirm `:2182` · Section title `:344/:389` · List card `winnerSupplierNames` [types:144]. `PeWorkflowPanel.tsx:111/135`.
- **types drift:** `types/purchaseEvaluation.ts` fe-admin `66ffd707` ≠ fe-user `7de399e1` (cosmetic badge-tint) → **patch delta**, KHÔNG blind-copy nguyên file.
### Test (test-before — financial algorithm, rules §7)
- ~6-7 file khoá cứng semantics whole-supplier sẽ ĐỎ: `PeMultiWinnerTests` · `PeWorkItemBudgetTests(Block-B)` · `CreateContractFromEvaluationMultiWinnerTests` · `PeCcmThresholdFinalizeTests` · `PeSubmitGuardAndBypassTests` · `PeSelectWinnerClearTests` (+ `PeListWinnerNamesProjectionTests`). Update đồng-commit + thêm test per-hạng-mục total.
## ③ Checklist (vai · deliverable · acceptance ĐO ĐƯỢC)
| # | Vai | Deliverable | Acceptance đo được |
|---|---|---|---|
| C1 | database-agent (review) | Xác nhận reuse Quote.IsSelected + backfill strategy + (D1/D3 cột mới nếu cần) | Migration design review PASS, Down reversible |
| C2 | implementer-backend | Command per-detail set IsSelected + derive IsWinner + re-key 6 read-site + Migration (nếu cột mới) + backfill | `dotnet build` 0 error; 6 read-site đọc IsSelected (grep 0 sót `.IsWinner` ở aggregation) |
| C3 | implementer-frontend | FE 2-app: DetailCard per-cell winner toggle + picker + wean read-sites khỏi s.isWinner | `npm build` ×2 PASS; PeDetailTabs SHA-identical 2 app; chọn ≥2 NCC/hạng mục OK |
| C4 | test-specialist | Update 6-7 test + thêm test winnerQuoteTotal per-hạng-mục (split-award SUM đúng) | test xanh; test mới cover ≥2 NCC/hạng mục + backfill + threshold-gate |
| C5 | reviewer | Verify 6 read-site re-key đủ (không sót) + total đúng + kế-thừa-HĐ N-winner | PASS: grep-suite 0 sót IsWinner-aggregation; giaTri HĐ = SUM won-lines |
| C6 | cicd-monitor | Deploy verify bundle rotate + Mig prod + smoke PE | Run PASS; byte-verify feature string |
## 🔴 REVIEW ADJUSTMENTS (Bước 2 — fable-clone review ensemble `wf_1e127f4b-de5` → FINAL spec)
> 2/3 lane PASS_WITH_ADJ (consumer-completeness + checklist-risk); lane correctness-edgecase = **#53 return-garble (null)** → em-main-solo recover (territory phủ bởi 2 lane hội-tụ). Cả 2 lane VERIFY 6 read-site inventory ĐÚNG, 0 sót. 6 adjustment fold vào implement:
### A1 (MUST) — giaTri: BỎ shortcut isSingle→detailsSum
`CreateContractFromEvaluationFeatures.cs:89-93` hiện `isSingle ? detailsSum : SUM(all w.Quotes)`. CẢ 2 nhánh SAI dưới per-hạng-mục. **Fix: giaTri per-winner w = `SUM(w.Quotes WHERE IsSelected)` cho CẢ single lẫn multi — bỏ nhánh detailsSum.**
> ⚠️ **HEADS-UP anh Kiệt:** phiếu single-winner giaTri đổi **budget (detailsSum) → bid (SUM quote)** để đúng D4. Số HĐ có thể khác số UAT cũ (comment :73-75) — đây là hệ-quả D4, đúng hơn (giá HĐ = giá chào thực, không phải ngân sách).
### A2 (MUST) — Derive IsWinner ở MỌI writer + backfill 2 chiều
Invariant BẤT BIẾN: **`supplier.IsWinner == supplier.Quotes.Any(IsSelected)`** — bảo trì bởi TẤT CẢ writer của IsSelected: (a) SelectWinner-per-detail · (b) UpsertQuote `DetailFeatures.cs:308/332` · (c) **seed DbInitializer `:1310-1316`** (hiện KHÔNG set IsWinner → seed DaDuyet vỡ). Backfill **2 chiều**: IsWinner→IsSelected (phiếu cũ) VÀ IsSelected→IsWinner (seed/dormant). Acceptance: fresh Dev DB seeded → winner-names hiện + create-contract OK.
### A3 (MUST) — Re-key SPLIT theo loại (KHÔNG blanket 6-site)
- **(i) Financial-SUM → `WHERE IsSelected`:** winnerQuoteTotal `:1189` · submit-guard `:196` · CCM-threshold `:885` · giaTri `:89` · budget-B `:907`.
- **(ii) Winner-existence/names → GIỮ IsWinner (derived):** projection List:637/Inbox:758/ListApproved:189 · CreateContract winners-list `:56`. Re-key nhóm này = vỡ display.
### A4 (SHOULD) — Endpoint + FE UX = breaking (chốt DTO)
`POST /purchase-evaluations/{id}/select-winner` payload: whole-supplier `{supplierIds}`**per-detail `{detailId, supplierIds[]}`** ("hạng mục detailId, các NCC này thắng" → set Quote.IsSelected per cell + derive IsWinner). FE **redesign UX chọn-theo-ô** trong DetailCard `:2638-2792` (checkbox per cell Detail×NCC), hạ-cấp picker phiếu-wide `:941-1020` thành summary. Mirror CẢ 2 app + patch-delta types (KHÔNG blind-copy).
### A5 (SHOULD) — Test-before + threshold-crossing + acceptance đo được
- Test-before (rules §7): test ĐỎ (over-count repro + threshold-crossing) TRƯỚC/CÙNG impl.
- Threshold-crossing regression: phiếu total-IsWinner ≥ ngưỡng CEO nhưng total-IsSelected < ngưỡng assert đúng nhánh CEO/miễn-CEO.
- Acceptance: C5 = grep `\.IsWinner`/`\.Sum(` phân-loại financial-vs-display, 0 SUM còn key IsWinner. C6 = curl phiếu joint-winner, `winnerQuoteTotal == SUM(cell IsSelected)`.
- FE consumer thêm: PeListPanel.tsx:170-178 + display helper PeDetailTabs :76-79/:344-346/:1578-1579.
### A6 (NIT) — Migration 3-file dù data-only backfill; Down = empty no-op (ghi comment). Stale comment `:920` (:1087→:1189) sửa khi động budget-B.
## Rủi ro then chốt
- **Silent total-lệch:** 6 read-site tài chính sót 1 = số sai runtime, build/test vẫn PASS (S87/S88 chính vụ).
- **R3 CCM-threshold = gate tài chính:** over/under-count chặn/cho-qua sai quyền CCM duyệt-miễn-CEO.
- **Double-count nếu coexist** (D2): supplier-IsWinner SUM mọi quote vs quote-IsSelected 2 nguồn lệch.
- **Backfill phiếu ** (D2 thay-thế): quên phiếu prod mất winner, total=0.
- **DbInitializer seed :1167-1257** đã set IsSelected per-cell bật aggregation IsSelected = tổng seed đổi ngay khi deploy (cần verify).