2026-02-25 14:50:51 +09:00
|
|
|
/**
|
|
|
|
|
* BOM 이력 및 버전 관리 서비스
|
2026-02-26 13:09:32 +09:00
|
|
|
* 행(Row) 기반 버전 관리: bom_detail.version_id로 버전별 데이터 분리
|
2026-02-25 14:50:51 +09:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
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) ─────────────────────────────
|
|
|
|
|
|
2026-02-26 13:09:32 +09:00
|
|
|
// ─── 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<Record<string, any>>(sql, [bomId]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 14:50:51 +09:00
|
|
|
export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) {
|
|
|
|
|
const table = safeTableName(tableName || "", "bom_version");
|
2026-02-26 13:09:32 +09:00
|
|
|
const dTable = "bom_detail";
|
|
|
|
|
|
|
|
|
|
// 버전 목록 + 각 버전별 디테일 건수 + 현재 활성 버전 ID
|
2026-02-25 14:50:51 +09:00
|
|
|
const sql = companyCode === "*"
|
2026-02-26 13:09:32 +09:00
|
|
|
? `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`;
|
2026-02-25 14:50:51 +09:00
|
|
|
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
|
2026-02-26 13:09:32 +09:00
|
|
|
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,
|
|
|
|
|
};
|
2026-02-25 14:50:51 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:09:32 +09:00
|
|
|
/**
|
|
|
|
|
* 새 버전 생성: 현재 활성 버전의 bom_detail 행을 복사하여 새 version_id로 INSERT
|
|
|
|
|
*/
|
2026-02-25 14:50:51 +09:00
|
|
|
export async function createBomVersion(
|
|
|
|
|
bomId: string, companyCode: string, createdBy: string,
|
|
|
|
|
versionTableName?: string, detailTableName?: string,
|
2026-02-26 20:48:56 +09:00
|
|
|
inputVersionName?: string,
|
2026-02-25 14:50:51 +09:00
|
|
|
) {
|
|
|
|
|
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];
|
|
|
|
|
|
2026-02-26 20:48:56 +09:00
|
|
|
// 버전명: 사용자 입력 > 순번 자동 생성
|
|
|
|
|
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],
|
2026-02-25 14:50:51 +09:00
|
|
|
);
|
2026-02-26 20:48:56 +09:00
|
|
|
if (dupCheck.rows.length > 0) {
|
|
|
|
|
throw new Error(`이미 존재하는 버전명입니다: ${versionName}`);
|
2026-02-25 14:50:51 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:09:32 +09:00
|
|
|
// 새 버전 레코드 생성 (snapshot_data 없이)
|
2026-02-25 14:50:51 +09:00
|
|
|
const insertSql = `
|
2026-02-26 13:09:32 +09:00
|
|
|
INSERT INTO ${vTable} (bom_id, version_name, revision, status, created_by, company_code)
|
|
|
|
|
VALUES ($1, $2, $3, 'developing', $4, $5)
|
2026-02-25 14:50:51 +09:00
|
|
|
RETURNING *
|
|
|
|
|
`;
|
2026-02-26 13:09:32 +09:00
|
|
|
const newVersion = await client.query(insertSql, [
|
2026-02-25 14:50:51 +09:00
|
|
|
bomId,
|
|
|
|
|
versionName,
|
|
|
|
|
bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0,
|
|
|
|
|
createdBy,
|
|
|
|
|
companyCode,
|
|
|
|
|
]);
|
2026-02-26 13:09:32 +09:00
|
|
|
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],
|
|
|
|
|
);
|
2026-02-25 14:50:51 +09:00
|
|
|
|
2026-02-26 13:09:32 +09:00
|
|
|
// old ID → new ID 매핑 (parent_detail_id 유지)
|
|
|
|
|
const oldToNew: Record<string, string> = {};
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-25 16:18:46 +09:00
|
|
|
|
2026-02-26 13:09:32 +09:00
|
|
|
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];
|
2026-02-25 14:50:51 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:09:32 +09:00
|
|
|
/**
|
|
|
|
|
* 버전 불러오기: bom_detail 삭제/복원 없이 current_version_id만 전환
|
|
|
|
|
*/
|
2026-02-25 14:50:51 +09:00
|
|
|
export async function loadBomVersion(
|
|
|
|
|
bomId: string, versionId: string, companyCode: string,
|
2026-02-26 13:09:32 +09:00
|
|
|
versionTableName?: string, _detailTableName?: string,
|
2026-02-25 14:50:51 +09:00
|
|
|
) {
|
|
|
|
|
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("버전을 찾을 수 없습니다");
|
|
|
|
|
|
2026-02-26 13:09:32 +09:00
|
|
|
const versionName = verRow.rows[0].version_name;
|
2026-02-25 14:50:51 +09:00
|
|
|
|
2026-02-26 13:09:32 +09:00
|
|
|
// BOM 헤더의 version과 current_version_id만 전환
|
2026-02-25 14:50:51 +09:00
|
|
|
await client.query(
|
2026-02-26 13:09:32 +09:00
|
|
|
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
|
|
|
|
|
[versionName, versionId, bomId],
|
2026-02-25 14:50:51 +09:00
|
|
|
);
|
|
|
|
|
|
2026-02-26 13:09:32 +09:00
|
|
|
logger.info("BOM 버전 불러오기 완료", { bomId, versionId, versionName });
|
|
|
|
|
return { restored: true, versionName };
|
2026-02-25 16:18:46 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:09:32 +09:00
|
|
|
/**
|
|
|
|
|
* 사용 확정: 선택 버전을 active로 변경 + current_version_id 갱신
|
|
|
|
|
*/
|
2026-02-25 16:18:46 +09:00
|
|
|
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],
|
|
|
|
|
);
|
2026-02-26 13:09:32 +09:00
|
|
|
// BOM 헤더 갱신
|
2026-02-25 16:18:46 +09:00
|
|
|
const versionName = verRow.rows[0].version_name;
|
|
|
|
|
await client.query(
|
2026-02-26 13:09:32 +09:00
|
|
|
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
|
|
|
|
|
[versionName, versionId, bomId],
|
2026-02-25 16:18:46 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName });
|
|
|
|
|
return { activated: true, versionName };
|
2026-02-25 14:50:51 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 20:48:56 +09:00
|
|
|
/**
|
|
|
|
|
* 신규 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 };
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:09:32 +09:00
|
|
|
/**
|
|
|
|
|
* 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteBomVersion(
|
|
|
|
|
bomId: string, versionId: string,
|
|
|
|
|
tableName?: string, detailTableName?: string,
|
|
|
|
|
) {
|
2026-02-25 14:50:51 +09:00
|
|
|
const table = safeTableName(tableName || "", "bom_version");
|
2026-02-26 13:09:32 +09:00
|
|
|
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;
|
|
|
|
|
});
|
2026-02-25 14:50:51 +09:00
|
|
|
}
|