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 (
+
+ {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.
+
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.
+
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}`),