feat: Implement BOM Excel upload and download functionality

- Added endpoints for uploading BOM data from Excel and downloading BOM data in Excel format.
- Developed the `createBomFromExcel` function to handle Excel uploads, including validation and error handling.
- Implemented the `downloadBomExcelData` function to retrieve BOM data for Excel downloads.
- Created a new `BomExcelUploadModal` component for the frontend to facilitate Excel file uploads.
- Updated BOM routes to include new Excel upload and download routes, enhancing BOM management capabilities.
This commit is contained in:
kjs
2026-02-27 07:50:22 +09:00
parent d50f705c44
commit 929b68299a
10 changed files with 1299 additions and 20 deletions

View File

@@ -143,6 +143,70 @@ export async function initializeBomVersion(req: Request, res: Response) {
}
}
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
export async function createBomFromExcel(req: Request, res: Response) {
try {
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomFromExcel(companyCode, userId, rows);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomVersionFromExcel(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows, versionName } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function downloadBomExcelData(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const data = await bomService.downloadBomExcelData(bomId, companyCode);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;

View File

@@ -17,6 +17,11 @@ router.get("/:bomId/header", bomController.getBomHeader);
router.get("/:bomId/history", bomController.getBomHistory);
router.post("/:bomId/history", bomController.addBomHistory);
// 엑셀 업로드/다운로드
router.post("/excel-upload", bomController.createBomFromExcel);
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
// 버전
router.get("/:bomId/versions", bomController.getBomVersions);
router.post("/:bomId/versions", bomController.createBomVersion);

View File

@@ -319,6 +319,485 @@ export async function initializeBomVersion(
});
}
// ─── 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;
}
/**
* 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제
*/