feat: Enhance BOM management with new header retrieval and version handling

- Added a new endpoint to retrieve BOM headers with entity join support, improving data accessibility.
- Updated the BOM service to include logic for fetching current version IDs and handling version-related data more effectively.
- Enhanced the BOM tree component to utilize the new BOM header API for better data management.
- Implemented version ID fallback mechanisms to ensure accurate data representation during BOM operations.
- Improved the overall user experience by integrating new features for version management and data loading.
This commit is contained in:
DDD1542
2026-02-26 13:09:32 +09:00
parent 0f3ec495a5
commit 46ea3612fd
11 changed files with 1011 additions and 215 deletions

View File

@@ -48,6 +48,25 @@ export async function addBomHistory(req: Request, res: Response) {
}
}
// ─── BOM 헤더 조회 (entity join 포함) ─────────────────────────
export async function getBomHeader(req: Request, res: Response) {
try {
const { bomId } = req.params;
const tableName = (req.query.tableName as string) || undefined;
const data = await bomService.getBomHeader(bomId, tableName);
if (!data) {
res.status(404).json({ success: false, message: "BOM을 찾을 수 없습니다" });
return;
}
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 헤더 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ─── 버전 (Version) ─────────────────────────────
export async function getBomVersions(req: Request, res: Response) {
@@ -56,8 +75,12 @@ export async function getBomVersions(req: Request, res: Response) {
const companyCode = (req as any).user?.companyCode || "*";
const tableName = (req.query.tableName as string) || undefined;
const data = await bomService.getBomVersions(bomId, companyCode, tableName);
res.json({ success: true, data });
const result = await bomService.getBomVersions(bomId, companyCode, tableName);
res.json({
success: true,
data: result.versions,
currentVersionId: result.currentVersionId,
});
} catch (error: any) {
logger.error("BOM 버전 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
@@ -110,8 +133,9 @@ export async function deleteBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const tableName = (req.query.tableName as string) || undefined;
const detailTable = (req.query.detailTable as string) || undefined;
const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName);
const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName, detailTable);
if (!deleted) {
res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
return;

View File

@@ -10,6 +10,9 @@ const router = Router();
router.use(authenticateToken);
// BOM 헤더 (entity join 포함)
router.get("/:bomId/header", bomController.getBomHeader);
// 이력
router.get("/:bomId/history", bomController.getBomHistory);
router.post("/:bomId/history", bomController.addBomHistory);

View File

@@ -1,12 +1,11 @@
/**
* BOM 이력 및 버전 관리 서비스
* 설정 패널에서 지정한 테이블명을 동적으로 사용
* 행(Row) 기반 버전 관리: bom_detail.version_id로 버전별 데이터 분리
*/
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;
@@ -54,15 +53,48 @@ export async function addBomHistory(
// ─── 버전 (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);
// ─── 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]);
}
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,
@@ -75,11 +107,7 @@ export async function createBomVersion(
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],
@@ -91,41 +119,80 @@ export async function createBomVersion(
}
const versionName = `${nextVersionNum}.0`;
const snapshot = {
bom: bomData,
details: detailRows.rows,
detailTable: dTable,
created_at: new Date().toISOString(),
};
// 새 버전 레코드 생성 (snapshot_data 없이)
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)
INSERT INTO ${vTable} (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, $2, $3, 'developing', $4, $5)
RETURNING *
`;
const result = await client.query(insertSql, [
const newVersion = await client.query(insertSql, [
bomId,
versionName,
bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0,
JSON.stringify(snapshot),
createdBy,
companyCode,
]);
const newVersionId = newVersion.rows[0].id;
// BOM 헤더의 version 필드도 업데이트
await client.query(`UPDATE bom SET version = $1 WHERE id = $2`, [versionName, bomId]);
// 현재 활성 버전의 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],
);
logger.info("BOM 버전 생성", { bomId, versionName, companyCode, vTable, dTable });
return result.rows[0];
// 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;
}
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,
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(
@@ -134,49 +201,22 @@ export async function loadBomVersion(
);
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
const snapshot = verRow.rows[0].snapshot_data;
if (!snapshot || !snapshot.bom) throw new Error("스냅샷 데이터가 없습니다");
const versionName = verRow.rows[0].version_name;
// 스냅샷에 기록된 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;
// BOM 헤더의 version과 current_version_id만 전환
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],
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[versionName, versionId, bomId],
);
const oldToNew: Record<string, string> = {};
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 };
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");
@@ -197,11 +237,11 @@ export async function activateBomVersion(bomId: string, versionId: string, table
`UPDATE ${table} SET status = 'active' WHERE id = $1`,
[versionId],
);
// BOM 헤더 version도 갱신
// BOM 헤더 갱신
const versionName = verRow.rows[0].version_name;
await client.query(
`UPDATE bom SET version = $1 WHERE id = $2`,
[versionName, bomId],
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[versionName, versionId, bomId],
);
logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName });
@@ -209,15 +249,44 @@ export async function activateBomVersion(bomId: string, versionId: string, table
});
}
export async function deleteBomVersion(bomId: string, versionId: string, tableName?: string) {
/**
* 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제
*/
export async function deleteBomVersion(
bomId: string, versionId: string,
tableName?: string, detailTableName?: 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;
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;
});
}