From d436af2971fbc25b962929ae40f0348df447bccb Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Sun, 12 Jul 2026 22:22:09 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation:=20multi-NCC=20pe?= =?UTF-8?q?r=20h=E1=BA=A1ng=20m=E1=BB=A5c=20=E2=80=94=20ch=E1=BB=8Dn=20?= =?UTF-8?q?=E2=89=A52=20NCC-TP=20tr=C3=BAng=20th=E1=BA=A7u/1=20h=E1=BA=A1n?= =?UTF-8?q?g=20m=E1=BB=A5c=20(m=E1=BB=97i=20nh=C3=A0=201=20gi=C3=A1=20ri?= =?UTF-8?q?=C3=AAng)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Đổi winner-truth từ whole-supplier Supplier.IsWinner (Mig 58 liên-danh) sang per-hạng-mục Quote.IsSelected (Mig 12 dormant hook → nay nguồn-sự-thật); Supplier.IsWinner = DERIVED (== Any IsSelected). Anh Kiệt LOCKED D1=SUM(IsSelected) · D2=THAY-THẾ · D3=giá-chào-gốc · D4=1HĐ/NCC. 0 bảng/cột mới. BE: - SelectWinnerCommand + POST /select-winner: body {supplierIds} → {detailId, supplierIds} (per hạng mục). - Re-key 5 financial-SUM → WHERE IsSelected: winnerQuoteTotal, submit-guard, CCM-threshold, giaTri, budget-B. - giaTri bỏ shortcut isSingle→detailsSum (single đổi budget→bid theo D4). - Derive IsWinner invariant ở MỌI writer: SelectWinner + UpsertQuote + seed + DeleteQuote/DeleteDetail (helper PeWinnerInvariant, fix Bước 4). - GIỮ IsWinner-derived cho winner-names/existence + CreateContract winners-list. - Migration BackfillPeQuoteIsSelectedAndWinnerInvariant (data-only 2-chiều IsWinner↔IsSelected, Down no-op). FE 2-app (PeDetailTabs SHA-identical): HangMucCard per-cell winner grid Detail×NCC + toggle per-detail; NccSelectorRow hạ-cấp read-only summary; giữ derived display. Test: 7 file update + 8 test mới (per-hạng-mục total, giaTri single-partial, threshold-crossing, derive-invariant, delete-path). 486 PASS. Flow: fable-clone invest ensemble → anh Kiệt 4-decision → fable-clone review ensemble (6 adjustment) → hmw implement (disk-recover #53) → fable-clone verify (bắt MUST bug delete-path → fix test-before). Co-Authored-By: Claude Opus 4.8 --- fe-admin/src/components/pe/PeDetailTabs.tsx | 169 +- fe-user/src/components/pe/PeDetailTabs.tsx | 169 +- .../PurchaseEvaluationsController.cs | 5 +- .../CreateContractFromEvaluationFeatures.cs | 21 +- .../Dtos/PurchaseEvaluationDtos.cs | 14 +- .../PurchaseEvaluationDetailFeatures.cs | 134 +- .../PurchaseEvaluationFeatures.cs | 33 +- .../Persistence/DbInitializer.cs | 15 + ...teIsSelectedAndWinnerInvariant.Designer.cs | 6360 +++++++++++++++++ ...fillPeQuoteIsSelectedAndWinnerInvariant.cs | 49 + .../PurchaseEvaluationWorkflowService.cs | 44 +- ...eContractFromEvaluationMultiWinnerTests.cs | 64 +- .../PeListWinnerNamesProjectionTests.cs | 6 + .../Application/PeMultiWinnerTests.cs | 40 +- .../Application/PeSelectWinnerClearTests.cs | 454 +- .../Application/PeWorkItemBudgetTests.cs | 13 +- .../Services/PeCcmThresholdFinalizeTests.cs | 94 +- .../Services/PeSubmitGuardAndBypassTests.cs | 13 +- 18 files changed, 7272 insertions(+), 425 deletions(-) create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260712142652_BackfillPeQuoteIsSelectedAndWinnerInvariant.Designer.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260712142652_BackfillPeQuoteIsSelectedAndWinnerInvariant.cs diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 2dca7ce..ae3788e 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -933,95 +933,46 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo ) } -// ===== a. NCC / TP được chọn — MULTI-WINNER picker [Mig 58 anh Kiệt FDC] ===== -// Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng -// thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner). -// Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`; -// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner). -function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { - const canEdit = !readOnly && isEditablePhase(ev.phase) - const qc = useQueryClient() - // gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker. - const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0 - const setWinners = useMutation({ - mutationFn: async (supplierIds: string[]) => - api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }), - onSuccess: () => { - toast.success('Đã cập nhật đơn vị trúng thầu.') - qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) - qc.invalidateQueries({ queryKey: ['pe-list'] }) - }, - onError: e => toast.error(getErrorMessage(e)), - }) - const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId) - const toggle = (supplierId: string, on: boolean) => - setWinners.mutate(on - ? [...winnerIds, supplierId] - : winnerIds.filter(id => id !== supplierId)) - const busy = setWinners.isPending || peFetching - - if (!canEdit) { - const winners = ev.suppliers.filter(s => s.isWinner) - return ( - 0 - ? ✓ {winners.map(s => s.supplierName).join(', ')} - : — (chưa chọn)} - /> - ) - } - +// ===== a. NCC / TP được chọn — TÓM TẮT read-only [multi-NCC per hạng mục] ===== +// Winner giờ CHỌN THEO TỪNG HẠNG MỤC ở bảng NCC (Mục 2) — nguồn sự thật = quote.isSelected +// mỗi cặp (hạng mục × NCC). Picker phiếu-wide cũ MẤT NGHĨA → hạ-cấp thành ô TÓM TẮT: mỗi +// đơn vị trúng + các hạng mục nó thắng (derive ev.details[].quotes IsSelected). Tổng = +// ev.winnerQuoteTotal (BE = SUM mọi quote IsSelected). +function NccSelectorRow({ ev }: { ev: PeDetailBundle }) { + const winners = ev.suppliers.filter(s => s.isWinner) + // Các hạng mục mà supplier-row `rowId` thắng (có ≥1 quote IsSelected trong hạng mục đó). + const wonItems = (rowId: string) => + ev.details + .filter(d => d.quotes.some(q => q.purchaseEvaluationSupplierId === rowId && q.isSelected)) + .map(d => d.groupCode || d.noiDung) return ( -
- a. NCC / TP được chọn -
- {ev.suppliers.length === 0 ? ( -

- Thêm NCC ở Section 3 trước rồi mới chọn trúng thầu. -

- ) : ( -
- {ev.suppliers.map(s => ( -