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

11 KiB
Raw Blame History

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").
  • D1 = Cộng giá NCC được chọnwinnerQuoteTotal = 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.

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.IsWinnerQuote.IsSelected (anti-cardinality-trap — chống sót)

  1. winnerQuoteTotal PurchaseEvaluationFeatures.cs:1189-1196SUM(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-2792isWinner = 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-1020checked={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).