[CLAUDE] FE-User: K5 SP-002 cay pipeline GD2 mo 8 sub-folder nhom (FE-only x2 app, 0-mig)

Gate PASS 0-blocker 6/6 (sub-reviewer-gate-k5.md): 8 ngan nhom tu KHKK_APPROVAL_GROUPS
+ khkkGroupMenuLabel (single-source K4a), phieu vao ngan theo approvalGroup + fallback
"(chua phan nhom)" — bat-bien Σ bao dam bang CAU TRUC (1 mang nguon / 1 phep phan hoach
/ 1 cho dem); HIEN DU 8 ke ca rong; route ngan khop URL K4b /khkk/list?group=n; GD3/GD4
regression chung-duoc-la-khong (union pe.contractId giu, cho-cam lines[].contractId
danh dau — no K7 Line.ContractId ghi-luon). SHA-pair x2 byte-identical (diff rong).
tsc -b x2 = 0 (gate tu chay) + npm x2 PASS.

Treo khong chan (gate MINOR 1-6): chu "SAP" tren ngan rong (UX owner) · a11y button-
trong-summary (khuon moi) · FE 0 ha tang test (console.warn dev-only) · toKhkkLeaf x2
lan/phieu. UAT ngay dau: prod 0 phieu KHKK => 8 ngan "SAP" rong o moi goi = DUNG SPEC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-01 10:44:32 +07:00
parent 80b8cf5113
commit ce35f9066b
4 changed files with 466 additions and 108 deletions

View File

@ -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<number, KhkkListItemDto[]>()
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<string>()
//
// [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<string>()
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}`),