Add BOM Copy Functionality to BOM Management
- Introduced a new API endpoint to copy a BOM tree to multiple target items, allowing for efficient duplication of BOM structures. - Implemented payload validation to ensure correct data format and integrity during the copy process. - Added a modal in the frontend for managing the BOM copy operation, including options for conflict resolution and progress tracking. - Enhanced the BOM service with necessary logic for handling BOM copies, including versioning and error handling. (TASK: ERP-028)
This commit is contained in:
@@ -143,6 +143,73 @@ export async function initializeBomVersion(req: Request, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BOM 복사 (TASK:ERP-028) ─────────────────────────
|
||||
|
||||
/**
|
||||
* POST /bom/:bomId/copy-to-items
|
||||
* 기준 BOM의 트리(편집본)를 대상 품목 N개에 복제
|
||||
* - conflictStrategy = "skip": 대상 품목에 BOM 있으면 skipped[]
|
||||
* - conflictStrategy = "new_version": 대상 품목 BOM에 새 draft 버전 추가 (없으면 새 BOM 생성)
|
||||
*/
|
||||
export async function copyBomToItems(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 { targetItemIds, conflictStrategy, editedTree } = req.body || {};
|
||||
|
||||
// ─── 페이로드 검증 ─────────────────────
|
||||
if (!Array.isArray(targetItemIds) || targetItemIds.length === 0) {
|
||||
res.status(400).json({ success: false, message: "targetItemIds는 1개 이상의 배열이어야 합니다" });
|
||||
return;
|
||||
}
|
||||
if (conflictStrategy !== "skip" && conflictStrategy !== "new_version") {
|
||||
res.status(400).json({ success: false, message: "conflictStrategy는 'skip' 또는 'new_version'이어야 합니다" });
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(editedTree) || editedTree.length === 0) {
|
||||
res.status(400).json({ success: false, message: "editedTree는 1개 이상의 노드 배열이어야 합니다" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 트리 노드 필수 필드 검증
|
||||
for (const n of editedTree) {
|
||||
if (!n || typeof n !== "object") {
|
||||
res.status(400).json({ success: false, message: "editedTree 노드는 객체여야 합니다" });
|
||||
return;
|
||||
}
|
||||
if (!n.tempId) {
|
||||
res.status(400).json({ success: false, message: "editedTree 각 노드에는 tempId가 필요합니다" });
|
||||
return;
|
||||
}
|
||||
if (!n.childItemId) {
|
||||
res.status(400).json({ success: false, message: `노드 ${n.tempId}: childItemId가 필요합니다` });
|
||||
return;
|
||||
}
|
||||
const qty = Number(n.quantity);
|
||||
if (!Number.isFinite(qty) || qty <= 0) {
|
||||
res.status(400).json({ success: false, message: `노드 ${n.tempId}: quantity는 0보다 커야 합니다` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = await bomService.copyBomToItems({
|
||||
sourceBomId: bomId,
|
||||
companyCode,
|
||||
userId,
|
||||
targetItemIds,
|
||||
conflictStrategy,
|
||||
editedTree,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 복사 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
|
||||
|
||||
export async function createBomFromExcel(req: Request, res: Response) {
|
||||
|
||||
@@ -22,6 +22,9 @@ router.post("/excel-upload", bomController.createBomFromExcel);
|
||||
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
|
||||
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
|
||||
|
||||
// BOM 복사 — 다른 품목으로 전체 트리 복제 (TASK:ERP-028)
|
||||
router.post("/:bomId/copy-to-items", bomController.copyBomToItems);
|
||||
|
||||
// 버전
|
||||
router.get("/:bomId/versions", bomController.getBomVersions);
|
||||
router.post("/:bomId/versions", bomController.createBomVersion);
|
||||
|
||||
@@ -959,3 +959,408 @@ export async function deleteBomDetailSubstitute(id: string) {
|
||||
const sql = `DELETE FROM bom_detail_substitute WHERE id = $1 RETURNING id`;
|
||||
return queryOne(sql, [id]);
|
||||
}
|
||||
|
||||
// ─── BOM 복사 (TASK:ERP-028) ─────────────────────────────
|
||||
|
||||
export interface BomTreeNodeInput {
|
||||
tempId: string;
|
||||
parentTempId: string | null;
|
||||
childItemId: string;
|
||||
quantity: number | string;
|
||||
unit?: string | null;
|
||||
processType?: string | null;
|
||||
lossRate?: number | string | null;
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
export interface CopyBomToItemsParams {
|
||||
sourceBomId: string;
|
||||
companyCode: string;
|
||||
userId: string;
|
||||
targetItemIds: string[]; // item_info.id (UUID)
|
||||
conflictStrategy: "skip" | "new_version";
|
||||
editedTree: BomTreeNodeInput[];
|
||||
}
|
||||
|
||||
export interface CopyBomToItemsResult {
|
||||
success_new: string[];
|
||||
success_versioned: { itemId: string; newVersionName: string }[];
|
||||
skipped: { itemId: string; reason: string }[];
|
||||
failed: { itemId: string; error: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 위상 정렬 + 무결성 검증
|
||||
* - parentTempId가 가리키는 tempId가 트리 안에 존재해야 함
|
||||
* - 사이클 차단
|
||||
* - 루트 노드(parentTempId=null)는 1개 이상 존재해야 함
|
||||
* 반환: 부모→자식 순서로 정렬된 노드 배열
|
||||
*/
|
||||
export function topoSortAndValidateTree(nodes: BomTreeNodeInput[]): BomTreeNodeInput[] {
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) {
|
||||
throw new Error("트리 노드가 없습니다");
|
||||
}
|
||||
const tempIdSet = new Set<string>();
|
||||
for (const n of nodes) {
|
||||
if (!n.tempId) throw new Error("tempId가 비어있는 노드가 있습니다");
|
||||
if (tempIdSet.has(n.tempId)) throw new Error(`중복 tempId: ${n.tempId}`);
|
||||
tempIdSet.add(n.tempId);
|
||||
}
|
||||
// parentTempId 참조 무결성
|
||||
for (const n of nodes) {
|
||||
if (n.parentTempId && !tempIdSet.has(n.parentTempId)) {
|
||||
throw new Error(`노드 ${n.tempId}의 parentTempId(${n.parentTempId})가 트리에 없습니다`);
|
||||
}
|
||||
}
|
||||
|
||||
// 위상 정렬 (Kahn's algorithm)
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
const indegree = new Map<string, number>();
|
||||
for (const n of nodes) {
|
||||
indegree.set(n.tempId, 0);
|
||||
}
|
||||
for (const n of nodes) {
|
||||
if (n.parentTempId) {
|
||||
indegree.set(n.tempId, (indegree.get(n.tempId) || 0) + 1);
|
||||
const arr = childrenOf.get(n.parentTempId) || [];
|
||||
arr.push(n.tempId);
|
||||
childrenOf.set(n.parentTempId, arr);
|
||||
}
|
||||
}
|
||||
const queue: string[] = [];
|
||||
for (const [id, deg] of indegree.entries()) {
|
||||
if (deg === 0) queue.push(id);
|
||||
}
|
||||
if (queue.length === 0) throw new Error("루트 노드가 없습니다 (사이클 의심)");
|
||||
|
||||
const byId = new Map<string, BomTreeNodeInput>();
|
||||
for (const n of nodes) byId.set(n.tempId, n);
|
||||
|
||||
const sorted: BomTreeNodeInput[] = [];
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
sorted.push(byId.get(cur)!);
|
||||
const children = childrenOf.get(cur) || [];
|
||||
for (const c of children) {
|
||||
indegree.set(c, (indegree.get(c) || 0) - 1);
|
||||
if (indegree.get(c) === 0) queue.push(c);
|
||||
}
|
||||
}
|
||||
if (sorted.length !== nodes.length) {
|
||||
throw new Error("트리에 사이클이 있습니다");
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 bom_id의 다음 version_name 채번 (충돌 시 +0.1)
|
||||
* - 기존 version_name이 숫자 형식이면 max+0.1
|
||||
* - 아니면 "1.0" 부터 시도
|
||||
*/
|
||||
export async function nextVersionName(client: any, bomId: string): Promise<string> {
|
||||
const res = await client.query(
|
||||
`SELECT version_name FROM bom_version WHERE bom_id = $1`,
|
||||
[bomId],
|
||||
);
|
||||
const names: string[] = res.rows.map((r: any) => String(r.version_name || ""));
|
||||
if (names.length === 0) return "1.0";
|
||||
|
||||
let maxVal = 0;
|
||||
let hasNumeric = false;
|
||||
for (const n of names) {
|
||||
const v = parseFloat(n);
|
||||
if (!Number.isNaN(v)) {
|
||||
hasNumeric = true;
|
||||
if (v > maxVal) maxVal = v;
|
||||
}
|
||||
}
|
||||
if (!hasNumeric) {
|
||||
// 숫자 버전이 하나도 없으면 "1.0" 시도 (existing string 버전들과 충돌 시 별도 처리)
|
||||
if (!names.includes("1.0")) return "1.0";
|
||||
// 1.0도 있으면 1.1, 1.2...
|
||||
let candidate = 1.0;
|
||||
while (names.includes(candidate.toFixed(1))) candidate += 0.1;
|
||||
return candidate.toFixed(1);
|
||||
}
|
||||
const next = (maxVal + 0.1).toFixed(1);
|
||||
// 안전망: next가 이미 있으면 +0.1 반복
|
||||
let nextNum = parseFloat(next);
|
||||
let candidate = nextNum.toFixed(1);
|
||||
while (names.includes(candidate)) {
|
||||
nextNum += 0.1;
|
||||
candidate = nextNum.toFixed(1);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 버전 레코드 + bom_detail 트리 INSERT
|
||||
* - sortedTree는 topoSortAndValidateTree()가 부모→자식 순서로 보장
|
||||
*/
|
||||
export async function insertVersionAndDetails(
|
||||
client: any,
|
||||
params: {
|
||||
bomId: string;
|
||||
versionName: string;
|
||||
revision: number;
|
||||
status: string; // "draft" 등
|
||||
createdBy: string;
|
||||
companyCode: string;
|
||||
sortedTree: BomTreeNodeInput[];
|
||||
},
|
||||
): Promise<{ versionId: string; insertedCount: number }> {
|
||||
const versionRes = await client.query(
|
||||
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[
|
||||
params.bomId,
|
||||
params.versionName,
|
||||
params.revision,
|
||||
params.status,
|
||||
params.createdBy,
|
||||
params.companyCode,
|
||||
],
|
||||
);
|
||||
const newVersionId = versionRes.rows[0].id;
|
||||
|
||||
// tempId → 새 detail id 매핑 (parent 재배선용)
|
||||
const tempToNewId: Record<string, string> = {};
|
||||
let seq = 1;
|
||||
for (const node of params.sortedTree) {
|
||||
const parentDetailId = node.parentTempId ? (tempToNewId[node.parentTempId] || null) : null;
|
||||
// level 계산: parent가 없으면 1, 있으면 parent level +1
|
||||
// (간단 처리: 정렬 순서대로 매기는 BFS이므로 트리 깊이별 누적이 들어맞음. 정확한 level은 클라이언트가 안 보낸 경우 1로 기본)
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO bom_detail
|
||||
(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`,
|
||||
[
|
||||
params.bomId,
|
||||
newVersionId,
|
||||
parentDetailId,
|
||||
node.childItemId,
|
||||
node.quantity != null ? String(node.quantity) : null,
|
||||
node.unit || null,
|
||||
node.processType || null,
|
||||
node.lossRate != null ? String(node.lossRate) : null,
|
||||
node.remark || null,
|
||||
null,
|
||||
null,
|
||||
String(params.revision),
|
||||
String(seq++),
|
||||
params.createdBy,
|
||||
params.companyCode,
|
||||
],
|
||||
);
|
||||
tempToNewId[node.tempId] = insertRes.rows[0].id;
|
||||
}
|
||||
|
||||
return { versionId: newVersionId, insertedCount: params.sortedTree.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 복사 메인 진입
|
||||
* - 품목 1개당 1트랜잭션 (일부 실패해도 다음 진행)
|
||||
* - 자기 자신(기준 BOM의 item) 차단은 failed[]에 누적
|
||||
*/
|
||||
export async function copyBomToItems(params: CopyBomToItemsParams): Promise<CopyBomToItemsResult> {
|
||||
const { sourceBomId, companyCode, userId, targetItemIds, conflictStrategy, editedTree } = params;
|
||||
|
||||
// 1) 기준 BOM 헤더 조회
|
||||
const sourceBom = await queryOne<Record<string, any>>(
|
||||
`SELECT id, item_id, item_code, item_name, item_type, base_qty, unit, remark, company_code
|
||||
FROM bom WHERE id = $1`,
|
||||
[sourceBomId],
|
||||
);
|
||||
if (!sourceBom) {
|
||||
throw new Error("기준 BOM을 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
// 2) 트리 검증 + 위상정렬 (요청 전체에 1회만 수행 — 동일 트리를 N개 품목에 적용)
|
||||
const sortedTree = topoSortAndValidateTree(editedTree);
|
||||
|
||||
const result: CopyBomToItemsResult = {
|
||||
success_new: [],
|
||||
success_versioned: [],
|
||||
skipped: [],
|
||||
failed: [],
|
||||
};
|
||||
|
||||
const sourceItemId = sourceBom.item_id;
|
||||
const todayStr = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
// 3) 대상 품목별 루프 — 품목 1개당 1트랜잭션
|
||||
for (const targetItemId of targetItemIds) {
|
||||
// 자기 자신 차단
|
||||
if (targetItemId === sourceItemId) {
|
||||
result.failed.push({
|
||||
itemId: targetItemId,
|
||||
error: "기준 BOM의 품목 자체로는 복사할 수 없습니다",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 대상 품목 조회 (item_code/item_name 갱신용)
|
||||
const targetItem = await queryOne<Record<string, any>>(
|
||||
`SELECT id, item_number, item_name, unit FROM item_info WHERE id = $1`,
|
||||
[targetItemId],
|
||||
);
|
||||
if (!targetItem) {
|
||||
result.failed.push({ itemId: targetItemId, error: "대상 품목을 찾을 수 없습니다" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 충돌 처리: 대상 품목에 이미 BOM이 있는지 확인
|
||||
const existingBom = await queryOne<{ id: string }>(
|
||||
`SELECT id FROM bom WHERE item_id = $1 ${
|
||||
companyCode !== "*" ? "AND company_code = $2" : ""
|
||||
} LIMIT 1`,
|
||||
companyCode !== "*" ? [targetItemId, companyCode] : [targetItemId],
|
||||
);
|
||||
|
||||
if (conflictStrategy === "skip") {
|
||||
if (existingBom) {
|
||||
result.skipped.push({ itemId: targetItemId, reason: "기존 BOM이 존재하여 스킵" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 신규 BOM 생성 (스킵 모드 — 없을 때만 생성)
|
||||
await transaction(async (client) => {
|
||||
await createNewBomWithTree(client, {
|
||||
companyCode,
|
||||
createdBy: userId,
|
||||
targetItemId,
|
||||
targetItemCode: targetItem.item_number || "",
|
||||
targetItemName: targetItem.item_name || "",
|
||||
sourceBom,
|
||||
sortedTree,
|
||||
todayStr,
|
||||
});
|
||||
});
|
||||
result.success_new.push(targetItemId);
|
||||
} else {
|
||||
// new_version 모드
|
||||
if (existingBom) {
|
||||
// 기존 bom_id에 새 draft 버전 append (race condition 대비 1회 재시도)
|
||||
let attempted = false;
|
||||
let newVersionName = "";
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
await transaction(async (client) => {
|
||||
newVersionName = await nextVersionName(client, existingBom.id);
|
||||
await insertVersionAndDetails(client, {
|
||||
bomId: existingBom.id,
|
||||
versionName: newVersionName,
|
||||
revision: 0,
|
||||
status: "draft",
|
||||
createdBy: userId,
|
||||
companyCode,
|
||||
sortedTree,
|
||||
});
|
||||
// bom.current_version_id는 변경하지 않음 (draft만 추가)
|
||||
});
|
||||
break;
|
||||
} catch (e: any) {
|
||||
if (!attempted && /duplicate|unique|version_name/i.test(e.message || "")) {
|
||||
attempted = true;
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
result.success_versioned.push({ itemId: targetItemId, newVersionName });
|
||||
} else {
|
||||
// 기존 BOM 없음 → 신규 생성
|
||||
await transaction(async (client) => {
|
||||
await createNewBomWithTree(client, {
|
||||
companyCode,
|
||||
createdBy: userId,
|
||||
targetItemId,
|
||||
targetItemCode: targetItem.item_number || "",
|
||||
targetItemName: targetItem.item_name || "",
|
||||
sourceBom,
|
||||
sortedTree,
|
||||
todayStr,
|
||||
});
|
||||
});
|
||||
result.success_new.push(targetItemId);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error("BOM 복사 실패 (품목)", {
|
||||
targetItemId,
|
||||
error: err.message,
|
||||
});
|
||||
result.failed.push({ itemId: targetItemId, error: err.message || "알 수 없는 오류" });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 BOM 마스터 + 초기 버전 + 트리 INSERT (한 트랜잭션 내부에서 호출)
|
||||
*/
|
||||
async function createNewBomWithTree(
|
||||
client: any,
|
||||
args: {
|
||||
companyCode: string;
|
||||
createdBy: string;
|
||||
targetItemId: string;
|
||||
targetItemCode: string;
|
||||
targetItemName: string;
|
||||
sourceBom: Record<string, any>;
|
||||
sortedTree: BomTreeNodeInput[];
|
||||
todayStr: string;
|
||||
},
|
||||
) {
|
||||
// bom_number 자동 채번: BOM-{YYYYMMDD}-{랜덤4}
|
||||
const rand = Math.floor(1000 + Math.random() * 9000);
|
||||
const bomNumber = `BOM-${args.todayStr.replace(/-/g, "")}-${rand}`;
|
||||
|
||||
const newBomRes = await client.query(
|
||||
`INSERT INTO bom
|
||||
(id, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, writer, created_date, updated_date)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL, $13, $14, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[
|
||||
args.companyCode,
|
||||
bomNumber,
|
||||
args.targetItemId,
|
||||
args.targetItemCode,
|
||||
args.targetItemName,
|
||||
args.sourceBom.item_type || null,
|
||||
args.sourceBom.base_qty || null,
|
||||
args.sourceBom.unit || null,
|
||||
"1.0",
|
||||
"0",
|
||||
"draft",
|
||||
args.todayStr,
|
||||
args.sourceBom.remark || null,
|
||||
args.createdBy,
|
||||
],
|
||||
);
|
||||
const newBomId = newBomRes.rows[0].id;
|
||||
|
||||
// 초기 버전 + 트리 INSERT (version_name="1.0" — 신규 BOM이므로 충돌 없음)
|
||||
const { versionId } = await insertVersionAndDetails(client, {
|
||||
bomId: newBomId,
|
||||
versionName: "1.0",
|
||||
revision: 0,
|
||||
status: "draft",
|
||||
createdBy: args.createdBy,
|
||||
companyCode: args.companyCode,
|
||||
sortedTree: args.sortedTree,
|
||||
});
|
||||
|
||||
// 신규 BOM은 초기 버전이 곧 current_version
|
||||
await client.query(
|
||||
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
|
||||
[versionId, newBomId],
|
||||
);
|
||||
|
||||
return { newBomId, versionId };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user