/** * BOM 이력 및 버전 관리 서비스 * 설정 패널에서 지정한 테이블명을 동적으로 사용 */ import { query, queryOne, transaction } from "../database/db"; import { logger } from "../utils/logger"; // SQL 인젝션 방지: 테이블명은 알파벳, 숫자, 언더스코어만 허용 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) ───────────────────────────── export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) { const table = safeTableName(tableName || "", "bom_version"); const sql = companyCode === "*" ? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY created_date DESC` : `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY created_date DESC`; const params = companyCode === "*" ? [bomId] : [bomId, companyCode]; return query(sql, params); } export async function createBomVersion( bomId: string, companyCode: string, createdBy: string, versionTableName?: string, detailTableName?: 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]; const detailRows = await client.query( `SELECT * FROM ${dTable} WHERE bom_id = $1 ORDER BY parent_detail_id NULLS FIRST, id`, [bomId], ); const lastVersion = await client.query( `SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`, [bomId], ); let nextVersionNum = 1; if (lastVersion.rows.length > 0) { const parsed = parseFloat(lastVersion.rows[0].version_name); if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1; } const versionName = `${nextVersionNum}.0`; const snapshot = { bom: bomData, details: detailRows.rows, detailTable: dTable, created_at: new Date().toISOString(), }; const insertSql = ` INSERT INTO ${vTable} (bom_id, version_name, revision, status, snapshot_data, created_by, company_code) VALUES ($1, $2, $3, 'developing', $4, $5, $6) RETURNING * `; const result = await client.query(insertSql, [ bomId, versionName, bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0, JSON.stringify(snapshot), createdBy, companyCode, ]); // BOM 헤더의 version 필드도 업데이트 await client.query(`UPDATE bom SET version = $1 WHERE id = $2`, [versionName, bomId]); logger.info("BOM 버전 생성", { bomId, versionName, companyCode, vTable, dTable }); return result.rows[0]; }); } export async function loadBomVersion( bomId: string, versionId: string, companyCode: string, versionTableName?: string, detailTableName?: string, ) { const vTable = safeTableName(versionTableName || "", "bom_version"); const dTable = safeTableName(detailTableName || "", "bom_detail"); 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 snapshot = verRow.rows[0].snapshot_data; if (!snapshot || !snapshot.bom) throw new Error("스냅샷 데이터가 없습니다"); // 스냅샷에 기록된 detailTable을 우선 사용, 없으면 파라미터 사용 const snapshotDetailTable = safeTableName(snapshot.detailTable || "", dTable); await client.query(`DELETE FROM ${snapshotDetailTable} WHERE bom_id = $1`, [bomId]); const b = snapshot.bom; const loadedVersionName = verRow.rows[0].version_name; await client.query( `UPDATE bom SET base_qty = $1, unit = $2, revision = $3, remark = $4 WHERE id = $5`, [b.base_qty || null, b.unit || null, b.revision || null, b.remark || null, bomId], ); const oldToNew: Record = {}; for (const d of snapshot.details || []) { const insertResult = await client.query( `INSERT INTO ${snapshotDetailTable} (bom_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, company_code) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`, [ bomId, 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, companyCode, ], ); oldToNew[d.id] = insertResult.rows[0].id; } logger.info("BOM 버전 불러오기 완료", { bomId, versionId, vTable, snapshotDetailTable }); return { restored: true, versionName: loadedVersionName }; }); } 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 헤더 version도 갱신 const versionName = verRow.rows[0].version_name; await client.query( `UPDATE bom SET version = $1 WHERE id = $2`, [versionName, bomId], ); logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName }); return { activated: true, versionName }; }); } export async function deleteBomVersion(bomId: string, versionId: string, tableName?: string) { const table = safeTableName(tableName || "", "bom_version"); // active 상태 버전은 삭제 불가 const checkSql = `SELECT status FROM ${table} WHERE id = $1 AND bom_id = $2`; const checkResult = await query(checkSql, [versionId, bomId]); if (checkResult.length > 0 && checkResult[0].status === "active") { throw new Error("사용중인 버전은 삭제할 수 없습니다"); } const sql = `DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`; const result = await query(sql, [versionId, bomId]); return result.length > 0; }