/** * 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, i.unit 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 }; }); } /** * 버전 삭제: 해당 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; }); }