# 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 cũ 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 cũ** (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).
