---
name: project-pe-multi-ncc-perhangmuc-specreview
description: S113+ spec review — PE winner-truth cardinality-change lần 3 (whole-supplier IsWinner → per-Detail Quote.IsSelected); correctness/edge lane
metadata:
  type: project
---

# PE multi-NCC per hạng mục — spec review (correctness/edge lane), verdict PASS_WITH_ADJ

Cardinality-change lần 3 trên winner-truth (S87 single→multi sót 2 read; S88 bắt). Trục C `Quote.IsSelected` (Mig 12 DORMANT) activate; D2 = IsSelected nguồn-sự-thật, `Supplier.IsWinner` DERIVED+STORED giữ cho create-HĐ + display.

**Exhaustive `IsWinner` grep (non-migration) = spec 6-site list ĐỦ** — 0 financial-sum site sót (S87 check PASS). Sites: winnerQuoteTotal `PurchaseEvaluationFeatures.cs:1190` · submit-guard `PurchaseEvaluationWorkflowService.cs:197` · CCM-threshold `:885` · Block-B `:908/914/922` · CreateContract winners `:56`+giaTri `:89-93`. Display winner-names (List:637/Inbox:758/ListApproved:189/DTO:1226) reads IsWinner — **giữ được trên IsWinner (derived), KHÔNG cần re-key**. `SelectedSupplierId` = display-only (grep: mọi use = JOIN-name; 0 money site).

**MUST (top catch): `CreateContractFromEvaluationFeatures.cs:76-77,89-90` isSingle→detailsSum.** `giaTri = isSingle ? detailsSum : SUM(quotes)`; `detailsSum = Details.Sum(ThanhTienNganSach)` = budget MỌI hạng mục. Per-hạng-mục: 1-winner ≠ thắng-cả-gói (D5 cho 0-winner hạng mục) → over-count budget hạng-mục-không-thắng + phá invariant `Σ giaTri == winnerQuoteTotal`. Spec cite re-key `84-93` **LOẠI** quyết-định isSingle `76-77` = đúng bẫy partial-re-key S87. D4 nói giaTri=SUM(IsSelected) mọi count → phải bỏ ternary. Historical single-winner giaTri đổi budget→quote-sum (escalate anh Kiệt).

**SHOULD: derive-integrity ≥3 write-path IsSelected.** IsWinner STORED derived → mọi nơi ghi IsSelected phải re-derive: (a) winner-cmd [spec có], (b) `UpsertQuoteCommand:308/332` ghi IsSelected KHÔNG derive, (c) seed `DbInitializer:1257` set IsSelected nhưng loop supplier `1203-1214` KHÔNG set IsWinner (seed phiếu IsWinner=false hết — money-site đọc IsSelected hiện số, winner-names đọc IsWinner rỗng → desync). Centralize hoặc derive-tại-mọi-path; "verify tổng seed" phải siết = seed set IsWinner khớp.

**Confirmations:** backfill chain sound (Mig 58:31-35 SelectedSupplierId→IsWinner; new IsWinner→IsSelected all-quotes; whole-package + liên-danh preserve total exact, no drift). CCM-threshold boundary `>=throws` (`:893` strict-<) unchanged, chỉ nguồn total đổi — phải khớp site 1.

**Lesson:** derived-column STORED = burden maintain ở MỌI write-path (không chỉ command trong spec); grep write-path của field-nguồn (IsSelected) KHÔNG chỉ field-đọc (IsWinner). Cite-range của re-key phải BỌC quyết-định branch (isSingle) chứ không chỉ dòng-gán.
