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.*,
|
2026-02-27 07:33:54 +09:00
|
|
|
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
|
2026-02-26 13:09:32 +09:00
|
|
|
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-27 07:50:22 +09:00
|
|
|
// ─── 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<BomExcelUploadResult> {
|
|
|
|
|
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<string, { id: string; item_name: string; unit: string }>();
|
|
|
|
|
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<string, number>();
|
|
|
|
|
|
|
|
|
|
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<BomExcelUploadResult> {
|
|
|
|
|
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<string, { id: string; item_name: string; unit: string }>();
|
|
|
|
|
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<string, number>();
|
|
|
|
|
|
|
|
|
|
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<Record<string, any>[]> {
|
|
|
|
|
// BOM 헤더 정보 조회 (최상위 품목)
|
|
|
|
|
const bomHeader = await queryOne<Record<string, any>>(
|
|
|
|
|
`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<string, any>[] = [];
|
|
|
|
|
|
|
|
|
|
// 레벨 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<string, any[]>();
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
}
|