diff --git a/fe-admin/src/components/pipeline/PipelineStageFolders.tsx b/fe-admin/src/components/pipeline/PipelineStageFolders.tsx index 2ed9466..7db2447 100644 --- a/fe-admin/src/components/pipeline/PipelineStageFolders.tsx +++ b/fe-admin/src/components/pipeline/PipelineStageFolders.tsx @@ -55,9 +55,30 @@ export type PipelineLeaf = { onOpen?: () => void } +/** + * [K5 S166] Ngăn con TRONG một folder giai đoạn (GĐ2 = 8 nhóm duyệt KHKK). + * 🔴 `leaves` ở đây là LEAVES THẬT (chính các phiếu con), KHÔNG phải một đường + * `content` riêng — nhờ vậy tổng số phiếu của các ngăn luôn bằng số phiếu của + * folder cha theo CẤU TRÚC, không phải nhờ ai đó nhớ cộng cho khớp (vá-9). + */ +export type PipelineGroup = { + key: string + label: string + leaves: PipelineLeaf[] + /** Bấm vào TÊN ngăn ⇒ mở trang danh sách đã lọc sẵn ngăn đó. */ + onOpenAll?: () => void +} + export type PipelineStage = { n: PipelineStageNo + /** Danh sách PHẲNG, là nguồn duy nhất để đếm. `groups` chỉ chia lại chính nó. */ leaves: PipelineLeaf[] + /** + * Có mặt ⇒ thân folder vẽ theo NGĂN thay vì danh sách phẳng. Ngăn RỖNG vẫn + * hiện (mirror tiền lệ folder giai đoạn chưa có gì: chip "Sắp") — người dùng + * phải thấy đủ khung 8 nhóm để biết cái nào trống, chứ không phải đoán. + */ + groups?: PipelineGroup[] /** Không có quyền xem module GĐ này ⇒ folder khoá + "Sắp", KHÔNG gọi API. */ locked?: boolean /** Query GĐ này lỗi ⇒ đếm "—", cây KHÔNG vỡ. */ @@ -85,6 +106,106 @@ function SoonChip() { ) } +type StageStyle = (typeof STAGE_STYLE)[PipelineStageNo] + +/** Danh sách leaf phẳng — dùng chung cho thân folder LẪN thân từng ngăn (K5). */ +function LeafList({ leaves, style }: { leaves: PipelineLeaf[]; style: StageStyle }) { + return ( + + ) +} + +/** + * [K5 S166] Một ngăn trong folder giai đoạn (GĐ2 = 1 nhóm duyệt). + * 🔴 Hai thao tác TÁCH BẠCH trên cùng một dòng: bấm vùng dòng = mở/đóng ngăn + * (hành vi mặc định của ), bấm ĐÚNG TÊN ngăn = sang trang danh sách đã + * lọc sẵn nhóm đó. Nút tên phải chặn nổi bọt, nếu không mỗi lần đi trang lại + * kèm một lần đóng/mở ngăn nhấp nháy phía sau. + * Mặc định ĐÓNG: 8 ngăn mở sẵn là một bức tường chữ, mà cây này nằm ở cột trái. + */ +function GroupFolder({ group, style }: { group: PipelineGroup; style: StageStyle }) { + const count = group.leaves.length + const empty = count === 0 + return ( +
+ + + {group.onOpenAll ? ( + + ) : ( + + {group.label} + + )} + {count > 0 ? ( + {count} + ) : ( + + )} + +
+ {empty ? ( +
Chưa có phiếu ở nhóm này.
+ ) : ( + + )} +
+
+ ) +} + function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentStage: PipelineStageNo }) { // Folder của TRANG hiện tại mở sẵn; 3 folder kia đóng. Vẫn collapse/expand được. const [open, setOpen] = useState(stage.n === currentStage) @@ -163,43 +284,19 @@ function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentSta ) : stage.content !== undefined ? ( stage.content + ) : stage.groups !== undefined ? ( + // [K5 S166] Có ngăn ⇒ vẽ theo ngăn. Ngăn rỗng KHÔNG bị lọc bỏ ở đây: + // việc "hiện đủ 8 nhóm hay không" do bên dựng quyết (xem builder), chỗ + // này chỉ vẽ đúng thứ nhận được. +
+ {stage.groups.map(g => ( + + ))} +
) : stage.leaves.length === 0 ? (
Chưa có dữ liệu ở giai đoạn này.
) : ( - + )} {stage.truncated && !stage.failed && !stage.loading && (
diff --git a/fe-admin/src/hooks/usePipelineStages.ts b/fe-admin/src/hooks/usePipelineStages.ts index cad8aae..1a30a12 100644 --- a/fe-admin/src/hooks/usePipelineStages.ts +++ b/fe-admin/src/hooks/usePipelineStages.ts @@ -24,7 +24,14 @@ import { api } from '@/lib/api' import { usePermission } from '@/hooks/usePermission' import type { PipelineStage } from '@/components/pipeline/PipelineStageFolders' import { ContractPhaseColor, ContractPhaseLabel, type ContractListItem } from '@/types/contracts' -import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, type KhkkListItemDto, type PagedResult } from '@/types/khkk' +import { + KHKK_APPROVAL_GROUPS, + KHKK_PHASE_BADGE, + KHKK_PHASE_LABELS, + khkkGroupMenuLabel, + type KhkkListItemDto, + type PagedResult, +} from '@/types/khkk' import type { Paged } from '@/types/master' import { PeDisplayStatusColor, @@ -155,8 +162,9 @@ export type BuildStagesOptions = { * Trả `buildStages(peItems)` — dựng 4 folder cho MỘT gói thầu (1 cặp Dự án × * Hạng mục) từ danh sách phiếu PE của gói đó. * GĐ1 = chính các phiếu ấy - * GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu - * GĐ3 = HĐ trỏ bởi `pe.contractId` + * GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu, chia thành 8 NGĂN theo + * nhóm duyệt (+ ngăn "(chưa phân nhóm)" khi có phiếu lạc) — K5 + * GĐ3 = HĐ trong TẬP id của gói (xem forward-provision B3-A5 ở thân hàm) * GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true` */ export function usePipelineStages() { @@ -185,20 +193,72 @@ export function usePipelineStages() { }), } - // ── GĐ2 — Kế hoạch Hợp đồng ──────────────────────────────────────────── - const khkkLeaves = linkage.canKhkk - ? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []).map(k => ({ - id: k.id, - primary: k.maKeHoach ?? '(chưa cấp mã)', - secondary: k.peTenGoiThau, - badgeLabel: KHKK_PHASE_LABELS[k.phase], - badgeClass: KHKK_PHASE_BADGE[k.phase], - onOpen: () => navigate(`/khkk/${k.id}`), - })) - : [] + // ── GĐ2 — Kế hoạch Hợp đồng (8 ngăn theo NHÓM DUYỆT) ─────────────────── + const khkkItems = linkage.canKhkk ? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []) : [] + const toKhkkLeaf = (k: KhkkListItemDto) => ({ + id: k.id, + primary: k.maKeHoach ?? '(chưa cấp mã)', + secondary: k.peTenGoiThau, + badgeLabel: KHKK_PHASE_LABELS[k.phase], + badgeClass: KHKK_PHASE_BADGE[k.phase], + onOpen: () => navigate(`/khkk/${k.id}`), + }) + const khkkLeaves = khkkItems.map(toKhkkLeaf) + + // [K5 S166 — vá-9] Chia phiếu về ngăn theo `approvalGroup` CỦA CHÍNH PHIẾU. + // Phiếu thiếu nhóm / nhóm ngoài miền 1..8 KHÔNG được rơi ra ngoài im lặng — + // gom vào ngăn "(chưa phân nhóm)" để nhìn thấy được mà đi sửa dữ liệu. + // (`approvalGroup` khai `number` nhưng payload cũ còn trong cache React Query + // có thể chưa có field — K2 cũng để `!= null` guard ở 2 trang.) + const khkkByGroup = new Map() + const khkkUngrouped: KhkkListItemDto[] = [] + for (const k of khkkItems) { + const g = k.approvalGroup + if (typeof g === 'number' && (KHKK_APPROVAL_GROUPS as readonly number[]).includes(g)) { + const bucket = khkkByGroup.get(g) + if (bucket) bucket.push(k) + else khkkByGroup.set(g, [k]) + } else { + khkkUngrouped.push(k) + } + } + // 🔴 HIỆN ĐỦ 8 NGĂN KỂ CẢ RỖNG — mirror tiền lệ folder giai đoạn chưa có gì + // (chip "Sắp"): khung nhóm là thứ người dùng cần thấy để biết chỗ nào trống + // và bấm vào lập phiếu, ẩn đi thì họ phải đoán hệ thống có mấy nhóm. + const khkkGroups = KHKK_APPROVAL_GROUPS.map(n => ({ + key: `khkk-g${n}`, + label: khkkGroupMenuLabel(n), + leaves: (khkkByGroup.get(n) ?? []).map(toKhkkLeaf), + // Khớp ĐÚNG URL leaf sidebar nhóm n (K4b) ⇒ bấm ngăn hay bấm menu ra cùng + // một trang, và trang đó tự sáng đúng mục bên trái. + onOpenAll: () => navigate(`/khkk/list?group=${n}`), + })) + // Ngăn "chưa phân nhóm" CHỈ hiện khi thật sự có phiếu rơi vào — khác 8 ngăn + // trên (8 nhóm là khung cố định của nghiệp vụ, còn đây là chỗ chứa bất thường). + if (khkkUngrouped.length > 0) { + khkkGroups.push({ + key: 'khkk-g0', + label: '(chưa phân nhóm)', + leaves: khkkUngrouped.map(toKhkkLeaf), + onOpenAll: () => navigate('/khkk/list'), + }) + } + + // Bất-biến Σ: mỗi phiếu vào ĐÚNG một ngăn ⇒ tổng các ngăn == tổng folder. + // Bảo đảm bằng cấu trúc (vòng lặp trên phân hoạch `khkkItems`), dòng dưới chỉ + // là chuông báo khi ai đó sửa vòng lặp thành lọc-trùng/lọc-sót. Dev-only: + // người dùng cuối không cần thấy, nhưng người sửa code thì cần. + const khkkGrouped = khkkGroups.reduce((s, g) => s + g.leaves.length, 0) + if (import.meta.env.DEV && khkkGrouped !== khkkLeaves.length) { + console.warn( + `[pipeline GĐ2] Σ ngăn (${khkkGrouped}) ≠ Σ folder (${khkkLeaves.length}) — có phiếu bị nhân đôi hoặc rơi mất khi chia nhóm.`, + ) + } + const stage2: PipelineStage = { n: 2, leaves: khkkLeaves, + groups: khkkGroups, locked: !linkage.canKhkk, failed: linkage.khkkFailed, loading: linkage.khkkLoading, @@ -208,21 +268,43 @@ export function usePipelineStages() { // ── GĐ3 / GĐ4 — HĐ trỏ bởi phiếu (GĐ4 = tập con đã có bản cứng) ───────── // HĐ nằm ngoài index (list kẹp 200) vẫn được nêu bằng leaf tối giản thay vì // biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3. - const seen = new Set() + // + // [K5 S166 — forward-provision B3-A5] Lấy HĐ của gói bằng một TẬP id gộp từ + // NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`. Hôm nay tập ấy chỉ có + // một nguồn, nhưng hình dạng đã đúng nên thêm nguồn sau là thêm một dòng. + // + // 🔴 VÌ SAO CẦN: 1 phiếu có thể đẻ N hợp đồng (liên danh — endpoint + // `create-contract` trả `contractIds: string[]`, xem `PeDetailTabs.tsx:2186`), + // NHƯNG BE chỉ ghi ngược HĐ ĐẦU vào phiếu: + // `CreateContractFromEvaluationFeatures.cs:143-145` + // "Idempotency: pe.ContractId = HĐ ĐẦU … N HĐ liên-danh truy qua changelog" + // ⇒ HĐ thứ 2 trở đi hiện KHÔNG có đường nào để cây nhìn thấy: `PeListItem` + // không có `lines[]`, `ContractListItem` không có `purchaseEvaluationId`, + // `Contract.cs` cũng không có khoá ngược. Đây là giới hạn DỮ LIỆU, không + // phải chỗ này thiếu logic — đừng "sửa" bằng cách bịa field không tồn tại. + // ⇒ KHI BE mở đường (một trong hai: list DTO trả `contractIds[]`, hoặc dòng + // phiếu có `contractId` riêng), nối vào ngay dưới đây và xoá ghi chú này. + const contractIdsOfPackage = new Set() + for (const p of peItems) { + if (p.contractId) contractIdsOfPackage.add(p.contractId) + // ← chỗ cắm nguồn thứ hai (HĐ 2..N). Ví dụ khi có: `p.contractIds?.forEach(...)` + // hoặc `p.lines?.forEach(l => l.contractId && add(l.contractId))`. + } + const stage3Leaves: PipelineStage['leaves'] = [] const stage4Leaves: PipelineStage['leaves'] = [] - for (const p of peItems) { - if (!p.contractId || seen.has(p.contractId)) continue - seen.add(p.contractId) - const contractId = p.contractId + // `Set` đã khử trùng ⇒ không cần biến `seen` riêng như trước. + for (const contractId of contractIdsOfPackage) { const c = linkage.contractById.get(contractId) + // Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200. + const fallbackName = peItems.find(p => p.contractId === contractId)?.tenGoiThau ?? null if (linkage.canContract) { stage3Leaves.push({ id: contractId, // [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn // "(chưa cấp mã)" (trộn 2 nghĩa khác nhau). primary: c ? (c.maHopDong ?? '(chưa cấp mã)') : '(ngoài chỉ mục 200 — mở để xem)', - secondary: c?.tenHopDong ?? p.tenGoiThau, + secondary: c?.tenHopDong ?? fallbackName, badgeLabel: c ? ContractPhaseLabel[c.phase] : null, badgeClass: c ? ContractPhaseColor[c.phase] : null, onOpen: () => navigate(`/contracts/${contractId}`), diff --git a/fe-user/src/components/pipeline/PipelineStageFolders.tsx b/fe-user/src/components/pipeline/PipelineStageFolders.tsx index 2ed9466..7db2447 100644 --- a/fe-user/src/components/pipeline/PipelineStageFolders.tsx +++ b/fe-user/src/components/pipeline/PipelineStageFolders.tsx @@ -55,9 +55,30 @@ export type PipelineLeaf = { onOpen?: () => void } +/** + * [K5 S166] Ngăn con TRONG một folder giai đoạn (GĐ2 = 8 nhóm duyệt KHKK). + * 🔴 `leaves` ở đây là LEAVES THẬT (chính các phiếu con), KHÔNG phải một đường + * `content` riêng — nhờ vậy tổng số phiếu của các ngăn luôn bằng số phiếu của + * folder cha theo CẤU TRÚC, không phải nhờ ai đó nhớ cộng cho khớp (vá-9). + */ +export type PipelineGroup = { + key: string + label: string + leaves: PipelineLeaf[] + /** Bấm vào TÊN ngăn ⇒ mở trang danh sách đã lọc sẵn ngăn đó. */ + onOpenAll?: () => void +} + export type PipelineStage = { n: PipelineStageNo + /** Danh sách PHẲNG, là nguồn duy nhất để đếm. `groups` chỉ chia lại chính nó. */ leaves: PipelineLeaf[] + /** + * Có mặt ⇒ thân folder vẽ theo NGĂN thay vì danh sách phẳng. Ngăn RỖNG vẫn + * hiện (mirror tiền lệ folder giai đoạn chưa có gì: chip "Sắp") — người dùng + * phải thấy đủ khung 8 nhóm để biết cái nào trống, chứ không phải đoán. + */ + groups?: PipelineGroup[] /** Không có quyền xem module GĐ này ⇒ folder khoá + "Sắp", KHÔNG gọi API. */ locked?: boolean /** Query GĐ này lỗi ⇒ đếm "—", cây KHÔNG vỡ. */ @@ -85,6 +106,106 @@ function SoonChip() { ) } +type StageStyle = (typeof STAGE_STYLE)[PipelineStageNo] + +/** Danh sách leaf phẳng — dùng chung cho thân folder LẪN thân từng ngăn (K5). */ +function LeafList({ leaves, style }: { leaves: PipelineLeaf[]; style: StageStyle }) { + return ( +
    + {leaves.map(leaf => ( +
  • + +
  • + ))} +
+ ) +} + +/** + * [K5 S166] Một ngăn trong folder giai đoạn (GĐ2 = 1 nhóm duyệt). + * 🔴 Hai thao tác TÁCH BẠCH trên cùng một dòng: bấm vùng dòng = mở/đóng ngăn + * (hành vi mặc định của ), bấm ĐÚNG TÊN ngăn = sang trang danh sách đã + * lọc sẵn nhóm đó. Nút tên phải chặn nổi bọt, nếu không mỗi lần đi trang lại + * kèm một lần đóng/mở ngăn nhấp nháy phía sau. + * Mặc định ĐÓNG: 8 ngăn mở sẵn là một bức tường chữ, mà cây này nằm ở cột trái. + */ +function GroupFolder({ group, style }: { group: PipelineGroup; style: StageStyle }) { + const count = group.leaves.length + const empty = count === 0 + return ( +
+ + + {group.onOpenAll ? ( + + ) : ( + + {group.label} + + )} + {count > 0 ? ( + {count} + ) : ( + + )} + +
+ {empty ? ( +
Chưa có phiếu ở nhóm này.
+ ) : ( + + )} +
+
+ ) +} + function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentStage: PipelineStageNo }) { // Folder của TRANG hiện tại mở sẵn; 3 folder kia đóng. Vẫn collapse/expand được. const [open, setOpen] = useState(stage.n === currentStage) @@ -163,43 +284,19 @@ function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentSta
) : stage.content !== undefined ? ( stage.content + ) : stage.groups !== undefined ? ( + // [K5 S166] Có ngăn ⇒ vẽ theo ngăn. Ngăn rỗng KHÔNG bị lọc bỏ ở đây: + // việc "hiện đủ 8 nhóm hay không" do bên dựng quyết (xem builder), chỗ + // này chỉ vẽ đúng thứ nhận được. +
+ {stage.groups.map(g => ( + + ))} +
) : stage.leaves.length === 0 ? (
Chưa có dữ liệu ở giai đoạn này.
) : ( - + )} {stage.truncated && !stage.failed && !stage.loading && (
diff --git a/fe-user/src/hooks/usePipelineStages.ts b/fe-user/src/hooks/usePipelineStages.ts index cad8aae..1a30a12 100644 --- a/fe-user/src/hooks/usePipelineStages.ts +++ b/fe-user/src/hooks/usePipelineStages.ts @@ -24,7 +24,14 @@ import { api } from '@/lib/api' import { usePermission } from '@/hooks/usePermission' import type { PipelineStage } from '@/components/pipeline/PipelineStageFolders' import { ContractPhaseColor, ContractPhaseLabel, type ContractListItem } from '@/types/contracts' -import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, type KhkkListItemDto, type PagedResult } from '@/types/khkk' +import { + KHKK_APPROVAL_GROUPS, + KHKK_PHASE_BADGE, + KHKK_PHASE_LABELS, + khkkGroupMenuLabel, + type KhkkListItemDto, + type PagedResult, +} from '@/types/khkk' import type { Paged } from '@/types/master' import { PeDisplayStatusColor, @@ -155,8 +162,9 @@ export type BuildStagesOptions = { * Trả `buildStages(peItems)` — dựng 4 folder cho MỘT gói thầu (1 cặp Dự án × * Hạng mục) từ danh sách phiếu PE của gói đó. * GĐ1 = chính các phiếu ấy - * GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu - * GĐ3 = HĐ trỏ bởi `pe.contractId` + * GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu, chia thành 8 NGĂN theo + * nhóm duyệt (+ ngăn "(chưa phân nhóm)" khi có phiếu lạc) — K5 + * GĐ3 = HĐ trong TẬP id của gói (xem forward-provision B3-A5 ở thân hàm) * GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true` */ export function usePipelineStages() { @@ -185,20 +193,72 @@ export function usePipelineStages() { }), } - // ── GĐ2 — Kế hoạch Hợp đồng ──────────────────────────────────────────── - const khkkLeaves = linkage.canKhkk - ? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []).map(k => ({ - id: k.id, - primary: k.maKeHoach ?? '(chưa cấp mã)', - secondary: k.peTenGoiThau, - badgeLabel: KHKK_PHASE_LABELS[k.phase], - badgeClass: KHKK_PHASE_BADGE[k.phase], - onOpen: () => navigate(`/khkk/${k.id}`), - })) - : [] + // ── GĐ2 — Kế hoạch Hợp đồng (8 ngăn theo NHÓM DUYỆT) ─────────────────── + const khkkItems = linkage.canKhkk ? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []) : [] + const toKhkkLeaf = (k: KhkkListItemDto) => ({ + id: k.id, + primary: k.maKeHoach ?? '(chưa cấp mã)', + secondary: k.peTenGoiThau, + badgeLabel: KHKK_PHASE_LABELS[k.phase], + badgeClass: KHKK_PHASE_BADGE[k.phase], + onOpen: () => navigate(`/khkk/${k.id}`), + }) + const khkkLeaves = khkkItems.map(toKhkkLeaf) + + // [K5 S166 — vá-9] Chia phiếu về ngăn theo `approvalGroup` CỦA CHÍNH PHIẾU. + // Phiếu thiếu nhóm / nhóm ngoài miền 1..8 KHÔNG được rơi ra ngoài im lặng — + // gom vào ngăn "(chưa phân nhóm)" để nhìn thấy được mà đi sửa dữ liệu. + // (`approvalGroup` khai `number` nhưng payload cũ còn trong cache React Query + // có thể chưa có field — K2 cũng để `!= null` guard ở 2 trang.) + const khkkByGroup = new Map() + const khkkUngrouped: KhkkListItemDto[] = [] + for (const k of khkkItems) { + const g = k.approvalGroup + if (typeof g === 'number' && (KHKK_APPROVAL_GROUPS as readonly number[]).includes(g)) { + const bucket = khkkByGroup.get(g) + if (bucket) bucket.push(k) + else khkkByGroup.set(g, [k]) + } else { + khkkUngrouped.push(k) + } + } + // 🔴 HIỆN ĐỦ 8 NGĂN KỂ CẢ RỖNG — mirror tiền lệ folder giai đoạn chưa có gì + // (chip "Sắp"): khung nhóm là thứ người dùng cần thấy để biết chỗ nào trống + // và bấm vào lập phiếu, ẩn đi thì họ phải đoán hệ thống có mấy nhóm. + const khkkGroups = KHKK_APPROVAL_GROUPS.map(n => ({ + key: `khkk-g${n}`, + label: khkkGroupMenuLabel(n), + leaves: (khkkByGroup.get(n) ?? []).map(toKhkkLeaf), + // Khớp ĐÚNG URL leaf sidebar nhóm n (K4b) ⇒ bấm ngăn hay bấm menu ra cùng + // một trang, và trang đó tự sáng đúng mục bên trái. + onOpenAll: () => navigate(`/khkk/list?group=${n}`), + })) + // Ngăn "chưa phân nhóm" CHỈ hiện khi thật sự có phiếu rơi vào — khác 8 ngăn + // trên (8 nhóm là khung cố định của nghiệp vụ, còn đây là chỗ chứa bất thường). + if (khkkUngrouped.length > 0) { + khkkGroups.push({ + key: 'khkk-g0', + label: '(chưa phân nhóm)', + leaves: khkkUngrouped.map(toKhkkLeaf), + onOpenAll: () => navigate('/khkk/list'), + }) + } + + // Bất-biến Σ: mỗi phiếu vào ĐÚNG một ngăn ⇒ tổng các ngăn == tổng folder. + // Bảo đảm bằng cấu trúc (vòng lặp trên phân hoạch `khkkItems`), dòng dưới chỉ + // là chuông báo khi ai đó sửa vòng lặp thành lọc-trùng/lọc-sót. Dev-only: + // người dùng cuối không cần thấy, nhưng người sửa code thì cần. + const khkkGrouped = khkkGroups.reduce((s, g) => s + g.leaves.length, 0) + if (import.meta.env.DEV && khkkGrouped !== khkkLeaves.length) { + console.warn( + `[pipeline GĐ2] Σ ngăn (${khkkGrouped}) ≠ Σ folder (${khkkLeaves.length}) — có phiếu bị nhân đôi hoặc rơi mất khi chia nhóm.`, + ) + } + const stage2: PipelineStage = { n: 2, leaves: khkkLeaves, + groups: khkkGroups, locked: !linkage.canKhkk, failed: linkage.khkkFailed, loading: linkage.khkkLoading, @@ -208,21 +268,43 @@ export function usePipelineStages() { // ── GĐ3 / GĐ4 — HĐ trỏ bởi phiếu (GĐ4 = tập con đã có bản cứng) ───────── // HĐ nằm ngoài index (list kẹp 200) vẫn được nêu bằng leaf tối giản thay vì // biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3. - const seen = new Set() + // + // [K5 S166 — forward-provision B3-A5] Lấy HĐ của gói bằng một TẬP id gộp từ + // NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`. Hôm nay tập ấy chỉ có + // một nguồn, nhưng hình dạng đã đúng nên thêm nguồn sau là thêm một dòng. + // + // 🔴 VÌ SAO CẦN: 1 phiếu có thể đẻ N hợp đồng (liên danh — endpoint + // `create-contract` trả `contractIds: string[]`, xem `PeDetailTabs.tsx:2186`), + // NHƯNG BE chỉ ghi ngược HĐ ĐẦU vào phiếu: + // `CreateContractFromEvaluationFeatures.cs:143-145` + // "Idempotency: pe.ContractId = HĐ ĐẦU … N HĐ liên-danh truy qua changelog" + // ⇒ HĐ thứ 2 trở đi hiện KHÔNG có đường nào để cây nhìn thấy: `PeListItem` + // không có `lines[]`, `ContractListItem` không có `purchaseEvaluationId`, + // `Contract.cs` cũng không có khoá ngược. Đây là giới hạn DỮ LIỆU, không + // phải chỗ này thiếu logic — đừng "sửa" bằng cách bịa field không tồn tại. + // ⇒ KHI BE mở đường (một trong hai: list DTO trả `contractIds[]`, hoặc dòng + // phiếu có `contractId` riêng), nối vào ngay dưới đây và xoá ghi chú này. + const contractIdsOfPackage = new Set() + for (const p of peItems) { + if (p.contractId) contractIdsOfPackage.add(p.contractId) + // ← chỗ cắm nguồn thứ hai (HĐ 2..N). Ví dụ khi có: `p.contractIds?.forEach(...)` + // hoặc `p.lines?.forEach(l => l.contractId && add(l.contractId))`. + } + const stage3Leaves: PipelineStage['leaves'] = [] const stage4Leaves: PipelineStage['leaves'] = [] - for (const p of peItems) { - if (!p.contractId || seen.has(p.contractId)) continue - seen.add(p.contractId) - const contractId = p.contractId + // `Set` đã khử trùng ⇒ không cần biến `seen` riêng như trước. + for (const contractId of contractIdsOfPackage) { const c = linkage.contractById.get(contractId) + // Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200. + const fallbackName = peItems.find(p => p.contractId === contractId)?.tenGoiThau ?? null if (linkage.canContract) { stage3Leaves.push({ id: contractId, // [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn // "(chưa cấp mã)" (trộn 2 nghĩa khác nhau). primary: c ? (c.maHopDong ?? '(chưa cấp mã)') : '(ngoài chỉ mục 200 — mở để xem)', - secondary: c?.tenHopDong ?? p.tenGoiThau, + secondary: c?.tenHopDong ?? fallbackName, badgeLabel: c ? ContractPhaseLabel[c.phase] : null, badgeClass: c ? ContractPhaseColor[c.phase] : null, onOpen: () => navigate(`/contracts/${contractId}`),