11 KiB
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.ThanhTiensẵ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>0toà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.IsSelectedlà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=truenhưngQuote.IsSelected=false→ setIsSelected=1cho 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 setSupplier.IsWinnertoàn phiếu. Đổi/thêm: command per (DetailId → list SupplierId) setQuote.IsSelectedper cell. DeriveSupplier.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)
- winnerQuoteTotal
PurchaseEvaluationFeatures.cs:1189-1196→SUM(Quotes.ThanhTien WHERE q.IsSelected). - Submit-guard
PurchaseEvaluationWorkflowService.cs:196-211→ total theo IsSelected. - CCM-threshold-finalize (GATE TÀI CHÍNH)
PurchaseEvaluationWorkflowService.cs:885-908—< CeoApprovalThresholdstrict → total theo IsSelected (sai = chặn/cho-qua sai quyền CCM). - CreateContractFromEvaluation
CreateContractFromEvaluationFeatures.cs:56(winners) + giaTri:84-93→ winners = NCC có ≥1 IsSelected; giaTri = SUM(IsSelected quotes của NCC đó). - Budget-compare Block-B
PurchaseEvaluationFeatures.cs:907-927(currentProposalTotal + prevSelected) → re-key IsSelected. - 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;SelectedSupplierIdmấ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 cardwinnerSupplierNames[types:144].PeWorkflowPanel.tsx:111/135. - types drift:
types/purchaseEvaluation.tsfe-admin66ffd707≠ fe-user7de399e1(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).