/** * BOM 이력 및 버전 관리 서비스 * 행(Row) 기반 버전 관리: bom_detail.version_id로 버전별 데이터 분리 */ import { query, queryOne, transaction } from "../database/db"; import { logger } from "../utils/logger"; function safeTableName(name: string, fallback: string): string { if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback; return name; } // ─── 이력 (History) ───────────────────────────── export async function getBomHistory(bomId: string, companyCode: string, tableName?: string) { const table = safeTableName(tableName || "", "bom_history"); const sql = companyCode === "*" ? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY changed_date DESC` : `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY changed_date DESC`; const params = companyCode === "*" ? [bomId] : [bomId, companyCode]; return query(sql, params); } export async function addBomHistory( bomId: string, companyCode: string, data: { revision?: string; version?: string; change_type: string; change_description?: string; changed_by?: string; }, tableName?: string, ) { const table = safeTableName(tableName || "", "bom_history"); const sql = ` INSERT INTO ${table} (bom_id, revision, version, change_type, change_description, changed_by, company_code) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `; return queryOne(sql, [ bomId, data.revision || null, data.version || null, data.change_type, data.change_description || null, data.changed_by || null, companyCode, ]); } // ─── 버전 (Version) ───────────────────────────── // ─── BOM 헤더 조회 (entity join 포함) ───────────────────────────── export async function getBomHeader(bomId: string, tableName?: string) { const table = safeTableName(tableName || "", "bom"); const sql = ` SELECT b.*, i.item_name, i.item_number, i.division as item_type, COALESCE(b.unit, i.unit) as unit, i.unit as item_unit, i.division, i.size, i.material FROM ${table} b LEFT JOIN item_info i ON b.item_id = i.id WHERE b.id = $1 LIMIT 1 `; return queryOne>(sql, [bomId]); } export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) { const table = safeTableName(tableName || "", "bom_version"); const dTable = "bom_detail"; // 버전 목록 + 각 버전별 디테일 건수 + 현재 활성 버전 ID const sql = companyCode === "*" ? `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count FROM ${table} v WHERE v.bom_id = $1 ORDER BY v.created_date DESC` : `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count FROM ${table} v WHERE v.bom_id = $1 AND v.company_code = $2 ORDER BY v.created_date DESC`; const params = companyCode === "*" ? [bomId] : [bomId, companyCode]; const versions = await query(sql, params); // bom.current_version_id도 함께 반환 const bomRow = await queryOne<{ current_version_id: string }>( `SELECT current_version_id FROM bom WHERE id = $1`, [bomId], ); return { versions, currentVersionId: bomRow?.current_version_id || null, }; } /** * 새 버전 생성: 현재 활성 버전의 bom_detail 행을 복사하여 새 version_id로 INSERT */ export async function createBomVersion( bomId: string, companyCode: string, createdBy: string, versionTableName?: string, detailTableName?: string, inputVersionName?: string, ) { const vTable = safeTableName(versionTableName || "", "bom_version"); const dTable = safeTableName(detailTableName || "", "bom_detail"); return transaction(async (client) => { const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]); if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다"); const bomData = bomRow.rows[0]; // 버전명: 사용자 입력 > 순번 자동 생성 let versionName = inputVersionName?.trim(); if (!versionName) { const countResult = await client.query( `SELECT COUNT(*)::int as cnt FROM ${vTable} WHERE bom_id = $1`, [bomId], ); versionName = `${(countResult.rows[0].cnt || 0) + 1}.0`; } // 중복 체크 const dupCheck = await client.query( `SELECT id FROM ${vTable} WHERE bom_id = $1 AND version_name = $2`, [bomId, versionName], ); if (dupCheck.rows.length > 0) { throw new Error(`이미 존재하는 버전명입니다: ${versionName}`); } // 새 버전 레코드 생성 (snapshot_data 없이) const insertSql = ` INSERT INTO ${vTable} (bom_id, version_name, revision, status, created_by, company_code) VALUES ($1, $2, $3, 'developing', $4, $5) RETURNING * `; const newVersion = await client.query(insertSql, [ bomId, versionName, bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0, createdBy, companyCode, ]); const newVersionId = newVersion.rows[0].id; // 현재 활성 버전의 bom_detail 행을 복사 const sourceVersionId = bomData.current_version_id; if (sourceVersionId) { const sourceDetails = await client.query( `SELECT * FROM ${dTable} WHERE bom_id = $1 AND version_id = $2 ORDER BY parent_detail_id NULLS FIRST, id`, [bomId, sourceVersionId], ); // old ID → new ID 매핑 (parent_detail_id 유지) const oldToNew: Record = {}; for (const d of sourceDetails.rows) { const insertResult = await client.query( `INSERT INTO ${dTable} (bom_id, version_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, seq_no, writer, company_code) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id`, [ bomId, newVersionId, d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null, d.child_item_id, d.quantity, d.unit, d.process_type, d.loss_rate, d.remark, d.level, d.base_qty, d.revision, d.seq_no, d.writer, companyCode, ], ); oldToNew[d.id] = insertResult.rows[0].id; } logger.info("BOM 버전 생성 - 디테일 복사 완료", { bomId, versionName, sourceVersionId, copiedCount: sourceDetails.rows.length, }); } // BOM 헤더의 version과 current_version_id 갱신 await client.query( `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, [versionName, newVersionId, bomId], ); logger.info("BOM 버전 생성 완료", { bomId, versionName, newVersionId, companyCode }); return newVersion.rows[0]; }); } /** * 버전 불러오기: bom_detail 삭제/복원 없이 current_version_id만 전환 */ export async function loadBomVersion( bomId: string, versionId: string, companyCode: string, versionTableName?: string, _detailTableName?: string, ) { const vTable = safeTableName(versionTableName || "", "bom_version"); return transaction(async (client) => { const verRow = await client.query( `SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`, [versionId, bomId], ); if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다"); const versionName = verRow.rows[0].version_name; // BOM 헤더의 version과 current_version_id만 전환 await client.query( `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, [versionName, versionId, bomId], ); logger.info("BOM 버전 불러오기 완료", { bomId, versionId, versionName }); return { restored: true, versionName }; }); } /** * 사용 확정: 선택 버전을 active로 변경 + current_version_id 갱신 */ export async function activateBomVersion(bomId: string, versionId: string, tableName?: string) { const table = safeTableName(tableName || "", "bom_version"); return transaction(async (client) => { const verRow = await client.query( `SELECT version_name FROM ${table} WHERE id = $1 AND bom_id = $2`, [versionId, bomId], ); if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다"); // 기존 active -> inactive await client.query( `UPDATE ${table} SET status = 'inactive' WHERE bom_id = $1 AND status = 'active'`, [bomId], ); // 선택한 버전 -> active await client.query( `UPDATE ${table} SET status = 'active' WHERE id = $1`, [versionId], ); // BOM 헤더 갱신 const versionName = verRow.rows[0].version_name; await client.query( `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, [versionName, versionId, bomId], ); logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName }); return { activated: true, versionName }; }); } /** * 신규 BOM 초기화: 첫 번째 버전 자동 생성 + version_id null인 디테일 보정 * BOM 헤더의 version 필드를 그대로 버전명으로 사용 (사용자 입력값 존중) */ export async function initializeBomVersion( bomId: string, companyCode: string, createdBy: string, ) { return transaction(async (client) => { const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]); if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다"); const bomData = bomRow.rows[0]; if (bomData.current_version_id) { await client.query( `UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`, [bomData.current_version_id, bomId], ); return { versionId: bomData.current_version_id, created: false }; } // 이미 버전 레코드가 존재하는지 확인 (동시 호출 방지) const existingVersion = await client.query( `SELECT id, version_name FROM bom_version WHERE bom_id = $1 ORDER BY created_date ASC LIMIT 1`, [bomId], ); if (existingVersion.rows.length > 0) { const existId = existingVersion.rows[0].id; await client.query( `UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`, [existId, bomId], ); await client.query( `UPDATE bom SET current_version_id = $1 WHERE id = $2 AND current_version_id IS NULL`, [existId, bomId], ); return { versionId: existId, created: false }; } const versionName = bomData.version || "1.0"; const versionResult = await client.query( `INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code) VALUES ($1, $2, 0, 'active', $3, $4) RETURNING id`, [bomId, versionName, createdBy, companyCode], ); const versionId = versionResult.rows[0].id; const updated = await client.query( `UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`, [versionId, bomId], ); await client.query( `UPDATE bom SET current_version_id = $1 WHERE id = $2`, [versionId, bomId], ); logger.info("BOM 초기 버전 생성", { bomId, versionId, versionName, updatedDetails: updated.rowCount }); return { versionId, versionName, created: true }; }); } // ─── BOM 엑셀 업로드 ───────────────────────────── interface BomExcelRow { level: number; item_number: string; item_name?: string; quantity: number; unit?: string; process_type?: string; remark?: string; } interface BomExcelUploadResult { success: boolean; insertedCount: number; skippedCount: number; errors: string[]; unmatchedItems: string[]; createdBomId?: string; } /** * BOM 엑셀 업로드 - 새 BOM 생성 * * 엑셀 레벨 체계: * 레벨 0 = BOM 마스터 (최상위 품목) → bom 테이블에 INSERT * 레벨 1 = 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0) * 레벨 2 = 자품목의 자품목 → bom_detail (parent_detail_id=부모ID, DB level=1) * 레벨 N = ... → bom_detail (DB level=N-1) */ export async function createBomFromExcel( companyCode: string, userId: string, rows: BomExcelRow[], ): Promise { const result: BomExcelUploadResult = { success: false, insertedCount: 0, skippedCount: 0, errors: [], unmatchedItems: [], }; if (!rows || rows.length === 0) { result.errors.push("업로드할 데이터가 없습니다"); return result; } const headerRow = rows.find(r => r.level === 0); const detailRows = rows.filter(r => r.level > 0); if (!headerRow) { result.errors.push("레벨 0(BOM 마스터) 행이 필요합니다"); return result; } if (!headerRow.item_number?.trim()) { result.errors.push("레벨 0(BOM 마스터)의 품번은 필수입니다"); return result; } if (detailRows.length === 0) { result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)"); return result; } // 레벨 유효성 검사 for (let i = 0; i < rows.length; i++) { const row = rows[i]; if (row.level < 0) { result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`); } if (i > 0 && row.level > rows[i - 1].level + 1) { result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다 (현재: ${row.level}, 이전: ${rows[i - 1].level})`); } if (row.level > 0 && !row.item_number?.trim()) { result.errors.push(`${i + 1}행: 품번은 필수입니다`); } } if (result.errors.length > 0) { return result; } return transaction(async (client) => { // 1. 모든 품번 일괄 조회 (헤더 + 디테일) const allItemNumbers = [...new Set(rows.filter(r => r.item_number?.trim()).map(r => r.item_number.trim()))]; const itemLookup = await client.query( `SELECT id, item_number, item_name, unit FROM item_info WHERE company_code = $1 AND item_number = ANY($2::text[])`, [companyCode, allItemNumbers], ); const itemMap = new Map(); for (const item of itemLookup.rows) { itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit }); } for (const num of allItemNumbers) { if (!itemMap.has(num)) { result.unmatchedItems.push(num); } } if (result.unmatchedItems.length > 0) { result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`); return result; } // 2. bom 마스터 생성 (레벨 0) const headerItemInfo = itemMap.get(headerRow.item_number.trim())!; // 동일 품목으로 이미 BOM이 존재하는지 확인 const dupCheck = await client.query( `SELECT id FROM bom WHERE item_id = $1 AND company_code = $2 AND status = 'active'`, [headerItemInfo.id, companyCode], ); if (dupCheck.rows.length > 0) { result.errors.push(`해당 품목(${headerRow.item_number})으로 등록된 BOM이 이미 존재합니다`); return result; } const bomInsert = await client.query( `INSERT INTO bom (item_id, item_code, item_name, base_qty, unit, version, status, remark, writer, company_code) VALUES ($1, $2, $3, $4, $5, '1.0', 'active', $6, $7, $8) RETURNING id`, [ headerItemInfo.id, headerRow.item_number.trim(), headerItemInfo.item_name, String(headerRow.quantity || 1), headerRow.unit || headerItemInfo.unit || null, headerRow.remark || null, userId, companyCode, ], ); const newBomId = bomInsert.rows[0].id; result.createdBomId = newBomId; // 3. bom_version 생성 const versionInsert = await client.query( `INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code) VALUES ($1, '1.0', 0, 'active', $2, $3) RETURNING id`, [newBomId, userId, companyCode], ); const versionId = versionInsert.rows[0].id; await client.query( `UPDATE bom SET current_version_id = $1 WHERE id = $2`, [versionId, newBomId], ); // 4. bom_detail INSERT (레벨 1+ → DB level = 엑셀 level - 1) const levelStack: string[] = []; const seqCounterByParent = new Map(); for (let i = 0; i < detailRows.length; i++) { const row = detailRows[i]; const itemInfo = itemMap.get(row.item_number.trim())!; const dbLevel = row.level - 1; while (levelStack.length > dbLevel) { levelStack.pop(); } const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null; const parentKey = parentDetailId || "__root__"; const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1; seqCounterByParent.set(parentKey, currentSeq); const insertResult = await client.query( `INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12) RETURNING id`, [ newBomId, versionId, parentDetailId, itemInfo.id, String(dbLevel), String(currentSeq), String(row.quantity || 1), row.unit || itemInfo.unit || null, row.process_type || null, row.remark || null, userId, companyCode, ], ); levelStack.push(insertResult.rows[0].id); result.insertedCount++; } // 5. 이력 기록 await client.query( `INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code) VALUES ($1, 'excel_upload', $2, $3, $4)`, [newBomId, `엑셀 업로드로 BOM 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode], ); result.success = true; logger.info("BOM 엑셀 업로드 - 새 BOM 생성 완료", { newBomId, companyCode, insertedCount: result.insertedCount, }); return result; }); } /** * BOM 엑셀 업로드 - 기존 BOM에 새 버전 생성 * * 엑셀에 레벨 0 행이 있으면 건너뛰고 (마스터는 이미 존재) * 레벨 1 이상만 bom_detail로 INSERT, 새 bom_version에 연결 */ export async function createBomVersionFromExcel( bomId: string, companyCode: string, userId: string, rows: BomExcelRow[], versionName?: string, ): Promise { const result: BomExcelUploadResult = { success: false, insertedCount: 0, skippedCount: 0, errors: [], unmatchedItems: [], }; if (!rows || rows.length === 0) { result.errors.push("업로드할 데이터가 없습니다"); return result; } const detailRows = rows.filter(r => r.level > 0); result.skippedCount = rows.length - detailRows.length; if (detailRows.length === 0) { result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)"); return result; } // 레벨 유효성 검사 for (let i = 0; i < rows.length; i++) { const row = rows[i]; if (row.level < 0) { result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`); } if (i > 0 && row.level > rows[i - 1].level + 1) { result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다`); } if (row.level > 0 && !row.item_number?.trim()) { result.errors.push(`${i + 1}행: 품번은 필수입니다`); } } if (result.errors.length > 0) { return result; } return transaction(async (client) => { // 1. BOM 존재 확인 const bomRow = await client.query( `SELECT id, version FROM bom WHERE id = $1 AND company_code = $2`, [bomId, companyCode], ); if (bomRow.rows.length === 0) { result.errors.push("BOM을 찾을 수 없습니다"); return result; } // 2. 품번 → item_info 매핑 const uniqueItemNumbers = [...new Set(detailRows.map(r => r.item_number.trim()))]; const itemLookup = await client.query( `SELECT id, item_number, item_name, unit FROM item_info WHERE company_code = $1 AND item_number = ANY($2::text[])`, [companyCode, uniqueItemNumbers], ); const itemMap = new Map(); for (const item of itemLookup.rows) { itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit }); } for (const num of uniqueItemNumbers) { if (!itemMap.has(num)) { result.unmatchedItems.push(num); } } if (result.unmatchedItems.length > 0) { result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`); return result; } // 3. 버전명 결정 (미입력 시 자동 채번) let finalVersionName = versionName?.trim(); if (!finalVersionName) { const countResult = await client.query( `SELECT COUNT(*)::int as cnt FROM bom_version WHERE bom_id = $1`, [bomId], ); finalVersionName = `${(countResult.rows[0].cnt || 0) + 1}.0`; } // 중복 체크 const dupCheck = await client.query( `SELECT id FROM bom_version WHERE bom_id = $1 AND version_name = $2`, [bomId, finalVersionName], ); if (dupCheck.rows.length > 0) { result.errors.push(`이미 존재하는 버전명입니다: ${finalVersionName}`); return result; } // 4. bom_version 생성 const versionInsert = await client.query( `INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code) VALUES ($1, $2, 0, 'developing', $3, $4) RETURNING id`, [bomId, finalVersionName, userId, companyCode], ); const newVersionId = versionInsert.rows[0].id; // 5. bom_detail INSERT const levelStack: string[] = []; const seqCounterByParent = new Map(); for (let i = 0; i < detailRows.length; i++) { const row = detailRows[i]; const itemInfo = itemMap.get(row.item_number.trim())!; const dbLevel = row.level - 1; while (levelStack.length > dbLevel) { levelStack.pop(); } const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null; const parentKey = parentDetailId || "__root__"; const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1; seqCounterByParent.set(parentKey, currentSeq); const insertResult = await client.query( `INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12) RETURNING id`, [ bomId, newVersionId, parentDetailId, itemInfo.id, String(dbLevel), String(currentSeq), String(row.quantity || 1), row.unit || itemInfo.unit || null, row.process_type || null, row.remark || null, userId, companyCode, ], ); levelStack.push(insertResult.rows[0].id); result.insertedCount++; } // 6. BOM 헤더의 version과 current_version_id 갱신 await client.query( `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, [finalVersionName, newVersionId, bomId], ); // 7. 이력 기록 await client.query( `INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code) VALUES ($1, 'excel_upload', $2, $3, $4)`, [bomId, `엑셀 업로드로 새 버전 ${finalVersionName} 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode], ); result.success = true; result.createdBomId = bomId; logger.info("BOM 엑셀 업로드 - 새 버전 생성 완료", { bomId, companyCode, versionName: finalVersionName, insertedCount: result.insertedCount, }); return result; }); } /** * BOM 엑셀 다운로드용 데이터 조회 * * 화면과 동일한 레벨 체계로 출력: * 레벨 0 = BOM 헤더 (최상위 품목) * 레벨 1 = 직접 자품목 (DB level=0) * 레벨 N = DB level N-1 * * DFS로 순회하여 부모-자식 순서 보장 */ export async function downloadBomExcelData( bomId: string, companyCode: string, ): Promise[]> { // BOM 헤더 정보 조회 (최상위 품목) const bomHeader = await queryOne>( `SELECT b.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit FROM bom b LEFT JOIN item_info ii ON b.item_id = ii.id WHERE b.id = $1 AND b.company_code = $2`, [bomId, companyCode], ); if (!bomHeader) return []; const flatList: Record[] = []; // 레벨 0: BOM 헤더 (최상위 품목) flatList.push({ level: 0, item_number: bomHeader.item_number || "", item_name: bomHeader.item_name || "", quantity: bomHeader.base_qty || "1", unit: bomHeader.item_unit || bomHeader.unit || "", process_type: "", remark: bomHeader.remark || "", _is_header: true, }); // 하위 품목 조회 const versionId = bomHeader.current_version_id; const whereVersion = versionId ? `AND bd.version_id = $3` : `AND bd.version_id IS NULL`; const params = versionId ? [bomId, companyCode, versionId] : [bomId, companyCode]; const details = await query( `SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit, ii.size, ii.material FROM bom_detail bd LEFT JOIN item_info ii ON bd.child_item_id = ii.id WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion} ORDER BY bd.parent_detail_id NULLS FIRST, bd.seq_no::int`, params, ); // 부모 ID별 자식 목록으로 맵 구성 const childrenMap = new Map(); const roots: any[] = []; for (const d of details) { if (!d.parent_detail_id) { roots.push(d); } else { if (!childrenMap.has(d.parent_detail_id)) childrenMap.set(d.parent_detail_id, []); childrenMap.get(d.parent_detail_id)!.push(d); } } // DFS: depth로 정확한 레벨 계산 (DB level 무시, 실제 트리 깊이 사용) const dfs = (nodes: any[], depth: number) => { for (const node of nodes) { flatList.push({ level: depth, item_number: node.item_number || "", item_name: node.item_name || "", quantity: node.quantity || "1", unit: node.unit || node.item_unit || "", process_type: node.process_type || "", remark: node.remark || "", }); const children = childrenMap.get(node.id) || []; if (children.length > 0) { dfs(children, depth + 1); } } }; // 루트 노드들은 레벨 1 (BOM 헤더가 0이므로) dfs(roots, 1); return flatList; } /** * 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제 */ export async function deleteBomVersion( bomId: string, versionId: string, tableName?: string, detailTableName?: string, ) { const table = safeTableName(tableName || "", "bom_version"); const dTable = safeTableName(detailTableName || "", "bom_detail"); return transaction(async (client) => { // active 상태 버전은 삭제 불가 const checkResult = await client.query( `SELECT status FROM ${table} WHERE id = $1 AND bom_id = $2`, [versionId, bomId], ); if (checkResult.rows.length === 0) throw new Error("버전을 찾을 수 없습니다"); if (checkResult.rows[0].status === "active") { throw new Error("사용중인 버전은 삭제할 수 없습니다"); } // 해당 버전의 bom_detail 행 삭제 const deleteDetails = await client.query( `DELETE FROM ${dTable} WHERE bom_id = $1 AND version_id = $2`, [bomId, versionId], ); // 버전 레코드 삭제 const deleteVersion = await client.query( `DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`, [versionId, bomId], ); logger.info("BOM 버전 삭제", { bomId, versionId, deletedDetails: deleteDetails.rowCount, }); return deleteVersion.rows.length > 0; }); }