[CLAUDE] Contract: K7 SP-002 bridge KHKK->HD hoi-2-khoa + Line.ContractId + grant seeder (gate PASS-WITH-FLAGS 10, F-8 fixed)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled

BE: POST {id}/create-contract (Contracts.Create AND KeHoachKyKet.Read) + guard 4-ve IsActive/IsUserSelectable
+ Line.ContractId write-site DAU TIEN + list-DTO ContractIds + SeedContractCreateAccessAsync (Drafter/
Procurement/Admin upgrade-only, cau III default cho ratify). FE x2 app SHA-pair 3/3: nut bridge gate 2-khoa
+ dialog khuon-san + cay GD3/GD4 doc TAP contractIds (union pe.contractId legacy). Test +17 (12 bridge T1-T12
+ 5 authz) suite 639/0; ngoai-le CreateContract trong AuthorizePolicyRegressionTests (#85).
Gate F-8 fixed truoc commit: neo so-dong :2516 chet-luc-land -> neo ten ham (3 cho) + 13/13->12/13 theo do
prod + khai chu-dich CanRead + sua tro-nham-ham. No mang theo (0-blocker): MAJOR-race-2-request-dong-thoi
(chong-trung in-memory, phuong an unique/RowVersion = nhip sau) + DraftData-khong-carry + a11y/O(n.m) FE.
UAT-NOI-TRUOC: nut sang sau restart + user RE-LOGIN (menu cache); acceptance seeder log = 0 added + 2 upgraded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-01 13:04:49 +07:00
parent c97f948a7b
commit aaed699228
13 changed files with 2241 additions and 46 deletions

View File

@ -28,7 +28,9 @@ import {
KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
khkkPlanContractIds,
type KhkkListItemDto,
type PagedResult,
} from '@/types/khkk'
@ -56,6 +58,10 @@ const KHKK_MENU_KEYS = ['KeHoachKyKet']
const CONTRACT_MENU_KEYS = ['Contracts', ...CONTRACT_TYPE_CODES.map(c => `Ct_${c}_List`)]
const HARD_COPY_MENU_KEYS = ['HopDongCung', ...CONTRACT_TYPE_CODES.map(c => `Hdc_${c}`)]
// [K7 S167 — vá-4b] Chuông DEV bắn TỐI ĐA 1 LẦN/phiên (buildStages chạy lại theo từng gói
// thầu ⇒ không có cờ này thì console ngập). Xem chỗ dùng ở GĐ3.
let warnedMissingLineContractIds = false
export type PipelineLinkage = {
canPe: boolean
canKhkk: boolean
@ -164,7 +170,8 @@ export type BuildStagesOptions = {
* GĐ1 = chính các phiếu ấy
* 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Đ3 = HĐ trong TẬP id của gói — hợp của `pe.contractId` (legacy) và contract-id trên
* các DÒNG phiếu KHKK (K7 vá-4b; xem thân hàm)
* GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true`
*/
export function usePipelineStages() {
@ -269,26 +276,41 @@ export function usePipelineStages() {
// 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.
//
// [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.
// [K5 S166 — forward-provision B3-A5 · K7 S167 vá-4b ĐÃ CẮM NGUỒN 2] 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`.
//
// 🔴 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:
// 🔴 VÌ SAO CẦN: 1 gói có thể đẻ N hợp đồng (liên danh, và từ K7 là N dòng phiếu
// KHKK × mỗi lần bấm "Đưa vào HĐ"), NHƯNG phía PE chỉ ghi ngược HĐ ĐẦ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.
// ⇒ đọc mỗi `pe.contractId` thì HĐ thứ 2 trở đi VÔ HÌNH trên cây (spec cụm-3 vá-4).
//
// 2 nguồn (dedupe bằng `Set` — chúng CỐ Ý chồng nhau ở HĐ đầu tiên):
// (1) `pe.contractId` — LEGACY, giữ cho phiếu PE cũ đã bắc cầu bằng đường PE→HĐ.
// (2) `khkkPlanContractIds(k)` — HĐ bắc cầu từ TỪNG DÒNG phiếu KHKK của gói. K7 ghi
// `Line.ContractId` là NGUỒN THẬT, `pe.ContractId ??=` chỉ còn là fallback hiển
// thị ⇒ N HĐ / 1 PE hiện ĐỦ, đúng OG-1 "máy rộng, UI hẹp".
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))`.
}
for (const k of khkkItems) {
for (const cid of khkkPlanContractIds(k)) contractIdsOfPackage.add(cid)
}
// Nguồn (2) IM khi BE chưa trả field trên LIST payload — mà "im vì chưa nối" trông y
// hệt "im vì gói chưa có HĐ nào". Nói ra ở DEV thay vì để người sau tưởng cây đã đủ.
// Chỉ báo khi có phiếu ĐÃ DUYỆT (chỉ phiếu duyệt rồi mới bắc cầu được) mà KHÔNG phiếu
// nào mang field — `[]` (BE đã trả, gói chưa có HĐ) thì im, đúng như phải thế.
if (import.meta.env.DEV && !warnedMissingLineContractIds) {
const approved = khkkItems.filter(k => k.phase === KhkkPhase.DaDuyet)
if (approved.length > 0 && approved.every(k => k.contractIds == null)) {
warnedMissingLineContractIds = true
console.warn(
'[pipeline GĐ3] Phiếu KHKK đã duyệt nhưng payload list KHÔNG có `contractIds` ⇒ cây ' +
'chỉ thấy HĐ qua `pe.contractId` (HĐ ĐẦU). API cũ chưa deploy hoặc cache cũ — xem ghi ' +
'chú `types/khkk.ts` (K7 vá-4b, BE `…ListItemDto:75`).',
)
}
}
const stage3Leaves: PipelineStage['leaves'] = []
@ -296,8 +318,13 @@ export function usePipelineStages() {
// `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
// Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200. [K7 vá-4b] tra CẢ
// 2 nguồn: HĐ đến từ dòng phiếu KHKK thì KHÔNG phiếu PE nào trỏ tới nó ⇒ chỉ tra
// `peItems` sẽ rơi về `null` và dòng phụ trống trơn.
const fallbackName =
peItems.find(p => p.contractId === contractId)?.tenGoiThau
?? khkkItems.find(k => khkkPlanContractIds(k).includes(contractId))?.peTenGoiThau
?? null
if (linkage.canContract) {
stage3Leaves.push({
id: contractId,