Implement Work Standard Copy Functionality for Items

- Added new API endpoints to retrieve the work standard tree by item and to copy work standards from a source item to target items, supporting conflict strategies.
- Enhanced the backend logic to handle the retrieval and copying of work standards, including validation for required parameters and error handling.
- Introduced a new modal component in the frontend for managing the copy operation, allowing users to select target items and define conflict resolution strategies.

(TASK: ERP-029)
This commit is contained in:
kjs
2026-05-11 18:02:39 +09:00
parent 12fc9818cf
commit 1003273709
7 changed files with 1829 additions and 155 deletions

View File

@@ -1104,6 +1104,669 @@ export async function registerItemsBatch(req: AuthenticatedRequest, res: Respons
}
}
// ============================================================
// 품목 단위 작업기준 복사 (TASK:ERP-029)
// ============================================================
/**
* 소스 품목의 작업기준 트리 전체 로드 (복사 모달용)
* process_code별 첫 routing_detail의 work_items + details 한 번에 반환
*/
export async function getWorkStandardTreeByItem(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { itemCode } = req.params;
if (!itemCode) {
return res.status(400).json({ success: false, message: "itemCode 필수" });
}
const pool = getPool();
const routingResult = await pool.query(
`SELECT
rd.id AS routing_detail_id,
rd.process_code,
rd.seq_no,
rd.is_required,
rd.is_fixed_order,
rd.work_type,
rd.standard_time,
rd.outsource_supplier,
rv.id AS routing_version_id,
COALESCE(rv.is_default, false) AS is_default,
p.process_name
FROM item_routing_detail rd
JOIN item_routing_version rv ON rv.id = rd.routing_version_id
AND rv.company_code = rd.company_code
LEFT JOIN process_mng p ON p.process_code = rd.process_code
AND p.company_code = rd.company_code
WHERE rv.item_code = $1 AND rd.company_code = $2
ORDER BY rv.is_default DESC NULLS LAST, rv.created_date DESC, rd.seq_no::integer`,
[itemCode, companyCode]
);
// process_code별 첫 routing_detail만 사용 (기본 버전 우선)
const firstByProcess: Record<string, any> = {};
for (const row of routingResult.rows) {
if (!firstByProcess[row.process_code]) {
firstByProcess[row.process_code] = row;
}
}
const processes: any[] = [];
for (const processCode of Object.keys(firstByProcess)) {
const meta = firstByProcess[processCode];
const wiResult = await pool.query(
`SELECT * FROM process_work_item
WHERE routing_detail_id = $1 AND company_code = $2
ORDER BY work_phase, sort_order, created_date`,
[meta.routing_detail_id, companyCode]
);
const workItems: any[] = [];
for (const wi of wiResult.rows) {
const dr = await pool.query(
`SELECT * FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order, created_date`,
[wi.id, companyCode]
);
workItems.push({ ...wi, details: dr.rows });
}
processes.push({
processCode,
processName: meta.process_name || processCode,
workItems,
// 라우팅 메타 — 자동 라우팅 복제 시 그대로 보존
routingMeta: {
seq_no: meta.seq_no,
is_required: meta.is_required,
is_fixed_order: meta.is_fixed_order,
work_type: meta.work_type,
standard_time: meta.standard_time,
outsource_supplier: meta.outsource_supplier,
},
});
}
return res.json({ success: true, data: { processes } });
} catch (error: any) {
logger.error("작업기준 트리 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 품목 단위 작업기준 복사 (편집된 트리 지원)
* 소스 품목의 모든 라우팅 작업기준 → 타겟 품목들에 복제
*
* 매칭 키: process_code 단일 키 (소스/타겟 모두 process_code별 첫 routing_detail 사용,
* 기본 버전 is_default=true 우선)
* 충돌 전략: skip(이미 있으면 건너뜀) | overwrite(기존 삭제 후 INSERT)
* 트랜잭션: 타겟 품목당 1트랜잭션 (부분 실패 허용)
*
* Body:
* sourceItemCode, targetItemCodes, conflictStrategy
* editedTree?: [{ processCode, processName, workItems: [{ work_phase, title, ..., details: [{...}] }] }]
* - 지정 시 소스 DB 대신 이 트리를 사용 (사용자 모달 편집 결과 반영)
* - 미지정 시 소스 DB 조회 결과 그대로 복제
*/
export async function copyByItem(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
try {
const companyCode = req.user?.companyCode;
const writer = req.user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { sourceItemCode, targetItemCodes, conflictStrategy, editedTree, screenCode } = req.body;
if (!sourceItemCode || !Array.isArray(targetItemCodes) || targetItemCodes.length === 0) {
return res.status(400).json({
success: false,
message: "sourceItemCode, targetItemCodes 필수",
});
}
if (!["skip", "overwrite"].includes(conflictStrategy)) {
return res.status(400).json({
success: false,
message: "conflictStrategy는 skip 또는 overwrite",
});
}
// 1) 소스 process_code 목록 + 작업기준 트리 준비
// editedTree 있으면 그것을 사용, 없으면 DB에서 로드
// sourceProcessMap에는 라우팅 자동 복제 시 사용할 메타(seq_no/is_required/is_fixed_order/work_type/standard_time/outsource_supplier)도 함께 보관
type RoutingMeta = {
process_name: string;
seq_no: string;
is_required: string | null;
is_fixed_order: string | null;
work_type: string | null;
standard_time: string | null;
outsource_supplier: string | null;
};
const sourceProcessMap: Record<string, RoutingMeta> = {};
const sourceTreeByProcessCode: Record<
string,
Array<{ work_phase: string; title: string; is_required?: string; sort_order?: number; description?: string; details: any[] }>
> = {};
if (Array.isArray(editedTree) && editedTree.length > 0) {
let editedSeq = 0;
for (const proc of editedTree) {
if (!proc?.processCode) continue;
editedSeq++;
const meta = proc.routingMeta || {};
sourceProcessMap[proc.processCode] = {
process_name: proc.processName || proc.processCode,
seq_no: meta.seq_no ? String(meta.seq_no) : String(editedSeq),
is_required: meta.is_required ?? null,
is_fixed_order: meta.is_fixed_order ?? null,
work_type: meta.work_type ?? null,
standard_time: meta.standard_time ?? null,
outsource_supplier: meta.outsource_supplier ?? null,
};
sourceTreeByProcessCode[proc.processCode] = Array.isArray(proc.workItems)
? proc.workItems.map((wi: any) => ({
work_phase: wi.work_phase,
title: wi.title,
is_required: wi.is_required || "N",
sort_order: wi.sort_order || 0,
description: wi.description || null,
details: Array.isArray(wi.details) ? wi.details : [],
}))
: [];
}
} else {
// 소스 품목 routing_detail + 작업기준 사전 로드 (기존 경로)
const sourceRoutingResult = await pool.query(
`SELECT
rd.id AS routing_detail_id,
rd.process_code,
rd.seq_no,
rd.is_required,
rd.is_fixed_order,
rd.work_type,
rd.standard_time,
rd.outsource_supplier,
rv.id AS routing_version_id,
rv.version_name,
COALESCE(rv.is_default, false) AS is_default,
p.process_name
FROM item_routing_detail rd
JOIN item_routing_version rv ON rv.id = rd.routing_version_id
AND rv.company_code = rd.company_code
LEFT JOIN process_mng p ON p.process_code = rd.process_code
AND p.company_code = rd.company_code
WHERE rv.item_code = $1 AND rd.company_code = $2
ORDER BY rv.is_default DESC NULLS LAST, rv.created_date DESC, rd.seq_no::integer`,
[sourceItemCode, companyCode]
);
if (sourceRoutingResult.rowCount === 0) {
return res.status(400).json({
success: false,
message: "소스 품목에 라우팅이 없습니다",
});
}
const firstRoutingDetailByProcess: Record<string, any> = {};
for (const row of sourceRoutingResult.rows) {
if (!firstRoutingDetailByProcess[row.process_code]) {
firstRoutingDetailByProcess[row.process_code] = row;
sourceProcessMap[row.process_code] = {
process_name: row.process_name || row.process_code,
seq_no: row.seq_no ? String(row.seq_no) : "1",
is_required: row.is_required ?? null,
is_fixed_order: row.is_fixed_order ?? null,
work_type: row.work_type ?? null,
standard_time: row.standard_time ?? null,
outsource_supplier: row.outsource_supplier ?? null,
};
}
}
for (const processCode of Object.keys(firstRoutingDetailByProcess)) {
const sourceDetailId = firstRoutingDetailByProcess[processCode].routing_detail_id;
const wiResult = await pool.query(
`SELECT * FROM process_work_item
WHERE routing_detail_id = $1 AND company_code = $2
ORDER BY work_phase, sort_order, created_date`,
[sourceDetailId, companyCode]
);
const workItems: any[] = [];
for (const wi of wiResult.rows) {
const dr = await pool.query(
`SELECT * FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order, created_date`,
[wi.id, companyCode]
);
workItems.push({
work_phase: wi.work_phase,
title: wi.title,
is_required: wi.is_required,
sort_order: wi.sort_order,
description: wi.description,
details: dr.rows,
});
}
sourceTreeByProcessCode[processCode] = workItems;
}
}
if (Object.keys(sourceProcessMap).length === 0) {
return res.status(400).json({
success: false,
message: "복사할 공정/작업기준이 없습니다",
});
}
// 2) 타겟 품목별 처리
const results: any[] = [];
const summary = {
totalItems: 0,
totalProcesses: 0,
copied: 0,
skipped: 0,
notMatched: 0,
failed: 0,
};
for (const targetItemCode of targetItemCodes) {
summary.totalItems++;
const itemResult: any = {
itemCode: targetItemCode,
itemName: targetItemCode,
processes: [],
};
// 타겟 품목명 조회
const itemNameResult = await pool.query(
`SELECT item_name FROM item_info WHERE item_number = $1 AND company_code = $2 LIMIT 1`,
[targetItemCode, companyCode]
);
if (itemNameResult.rowCount && itemNameResult.rows[0]?.item_name) {
itemResult.itemName = itemNameResult.rows[0].item_name;
}
// 타겟 품목의 routing_detail 매핑 (기본 버전 우선)
const targetRoutingResult = await pool.query(
`SELECT
rd.id AS routing_detail_id,
rd.process_code,
rd.seq_no,
rv.id AS routing_version_id,
COALESCE(rv.is_default, false) AS is_default
FROM item_routing_detail rd
JOIN item_routing_version rv ON rv.id = rd.routing_version_id
AND rv.company_code = rd.company_code
WHERE rv.item_code = $1 AND rd.company_code = $2
ORDER BY rv.is_default DESC NULLS LAST, rv.created_date DESC, rd.seq_no::integer`,
[targetItemCode, companyCode]
);
const targetProcessMap: Record<string, any> = {};
for (const row of targetRoutingResult.rows) {
if (!targetProcessMap[row.process_code]) {
targetProcessMap[row.process_code] = row;
}
}
// 자동 등록 결과 추적 — 같은 트랜잭션 안에서 처리되어야 함
const autoRoutedProcessCodes = new Set<string>();
// 트랜잭션 (성공 시에만 itemResult/summary에 반영)
const client = await pool.connect();
const tempProcesses: any[] = [];
const tempCounts = { copied: 0, skipped: 0, notMatched: 0, totalProcesses: 0 };
try {
await client.query("BEGIN");
// 타겟 품목의 라우팅이 전혀 없으면 → 소스 routing_detail 전체를 순서/속성 보존해서 자동 복제
// (매칭 안 된 일부 공정만 추가하는 케이스는 process loop 안에서 처리됨)
if (Object.keys(targetProcessMap).length === 0 && Object.keys(sourceProcessMap).length > 0) {
// 기본 routing_version 가져오기 or 생성
const defaultVerResult = await client.query(
`SELECT id FROM item_routing_version
WHERE item_code = $1 AND company_code = $2 AND COALESCE(is_default, false) = true
ORDER BY created_date DESC
LIMIT 1`,
[targetItemCode, companyCode]
);
let defaultVersionId: string;
if (defaultVerResult.rowCount && defaultVerResult.rows[0]?.id) {
defaultVersionId = defaultVerResult.rows[0].id;
} else {
const newVerResult = await client.query(
`INSERT INTO item_routing_version
(id, company_code, item_code, version_name, description, is_default, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, true, $5)
RETURNING id`,
[companyCode, targetItemCode, "기본", "복사 시 자동 생성", writer]
);
defaultVersionId = newVerResult.rows[0].id;
}
// 소스 routing_detail 전체 복제 — 메타 모두 보존
for (const pc of Object.keys(sourceProcessMap)) {
const meta = sourceProcessMap[pc];
const newDetailResult = await client.query(
`INSERT INTO item_routing_detail
(id, company_code, routing_version_id, seq_no, process_code,
is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
[
companyCode,
defaultVersionId,
meta.seq_no || "1",
pc,
meta.is_required ?? null,
meta.is_fixed_order ?? null,
meta.work_type ?? null,
meta.standard_time ?? null,
meta.outsource_supplier ?? null,
writer,
]
);
targetProcessMap[pc] = {
routing_detail_id: newDetailResult.rows[0].id,
process_code: pc,
routing_version_id: defaultVersionId,
seq_no: meta.seq_no,
is_default: true,
};
autoRoutedProcessCodes.add(pc);
}
}
for (const processCode of Object.keys(sourceProcessMap)) {
tempCounts.totalProcesses++;
const sourceProcess = sourceProcessMap[processCode];
const targetProcess = targetProcessMap[processCode];
const processInfo: any = {
processCode,
processName: sourceProcess.process_name || processCode,
};
const sourceWorkItemsForProc = sourceTreeByProcessCode[processCode] || [];
if (sourceWorkItemsForProc.length === 0) {
processInfo.status = "skipped_exists";
processInfo.reason = "복사할 작업기준 없음";
tempProcesses.push(processInfo);
tempCounts.skipped++;
continue;
}
// 매칭 안 됨 → 라우팅 자동 생성/공정 자동 추가
let effectiveTargetProcess = targetProcess;
if (!effectiveTargetProcess) {
// 기본 라우팅 버전 확인 또는 생성 (트랜잭션 안)
const defaultVerResult = await client.query(
`SELECT id FROM item_routing_version
WHERE item_code = $1 AND company_code = $2 AND COALESCE(is_default, false) = true
ORDER BY created_date DESC
LIMIT 1`,
[targetItemCode, companyCode]
);
let defaultVersionId: string;
if (defaultVerResult.rowCount && defaultVerResult.rows[0]?.id) {
defaultVersionId = defaultVerResult.rows[0].id;
} else {
// 라우팅 버전이 전혀 없음 → 새로 생성
const newVerResult = await client.query(
`INSERT INTO item_routing_version
(id, company_code, item_code, version_name, description, is_default, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, true, $5)
RETURNING id`,
[companyCode, targetItemCode, "기본", "복사 시 자동 생성", writer]
);
defaultVersionId = newVerResult.rows[0].id;
}
// seq_no는 해당 버전의 max+1
const maxSeqResult = await client.query(
`SELECT COALESCE(MAX(seq_no::integer), 0) + 1 AS next_seq
FROM item_routing_detail
WHERE routing_version_id = $1 AND company_code = $2`,
[defaultVersionId, companyCode]
);
const nextSeq = maxSeqResult.rows[0]?.next_seq || 1;
// routing_detail INSERT — 매칭 안 된 누락 공정 1개를 max+1로 추가. 메타도 보존.
const metaForProc = sourceProcessMap[processCode];
const newDetailResult = await client.query(
`INSERT INTO item_routing_detail
(id, company_code, routing_version_id, seq_no, process_code,
is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
[
companyCode,
defaultVersionId,
String(nextSeq),
processCode,
metaForProc?.is_required ?? null,
metaForProc?.is_fixed_order ?? null,
metaForProc?.work_type ?? null,
metaForProc?.standard_time ?? null,
metaForProc?.outsource_supplier ?? null,
writer,
]
);
effectiveTargetProcess = {
routing_detail_id: newDetailResult.rows[0].id,
process_code: processCode,
routing_version_id: defaultVersionId,
seq_no: String(nextSeq),
is_default: true,
};
targetProcessMap[processCode] = effectiveTargetProcess;
autoRoutedProcessCodes.add(processCode);
}
// 기존 work_item 존재 여부
const existingResult = await client.query(
`SELECT COUNT(*)::int AS cnt FROM process_work_item
WHERE routing_detail_id = $1 AND company_code = $2`,
[effectiveTargetProcess.routing_detail_id, companyCode]
);
const hasExisting = (existingResult.rows[0]?.cnt || 0) > 0;
if (hasExisting && conflictStrategy === "skip") {
processInfo.status = "skipped_exists";
processInfo.reason = "기존 작업기준 존재";
tempProcesses.push(processInfo);
tempCounts.skipped++;
continue;
}
if (hasExisting && conflictStrategy === "overwrite") {
await client.query(
`DELETE FROM process_work_item_detail
WHERE work_item_id IN (
SELECT id FROM process_work_item
WHERE routing_detail_id = $1 AND company_code = $2
)`,
[effectiveTargetProcess.routing_detail_id, companyCode]
);
await client.query(
`DELETE FROM process_work_item
WHERE routing_detail_id = $1 AND company_code = $2`,
[effectiveTargetProcess.routing_detail_id, companyCode]
);
}
let copiedItemCount = 0;
let copiedDetailCount = 0;
for (const sourceWi of sourceWorkItemsForProc) {
const wiInsert = await client.query(
`INSERT INTO process_work_item
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
[
companyCode,
effectiveTargetProcess.routing_detail_id,
sourceWi.work_phase,
sourceWi.title,
sourceWi.is_required,
sourceWi.sort_order,
sourceWi.description,
writer,
]
);
const newWorkItemId = wiInsert.rows[0].id;
copiedItemCount++;
const sourceDetails = (sourceWi as any).details || [];
for (const sd of sourceDetails) {
// selected_bom_items: 배열로 들어오면 JSON 문자열로 정규화
const selectedBomItemsValue = Array.isArray(sd.selected_bom_items)
? JSON.stringify(sd.selected_bom_items)
: sd.selected_bom_items ?? null;
await client.query(
`INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields, selected_bom_items,
process_inspection_apply, equip_inspection_apply,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data,
bom_item_id, bom_item_name, bom_qty, bom_unit,
work_qty_auto_collect, work_qty_plc_data,
defect_qty_auto_collect, defect_qty_plc_data,
good_qty_auto_collect, good_qty_plc_data,
loss_qty_auto_collect, loss_qty_plc_data,
inspection_count_apply, inspection_count,
material_equipment_code, material_equipment_name,
material_auto_collect, material_plc_data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
$19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29,
$30, $31, $32, $33, $34, $35, $36, $37,
$38, $39, $40, $41, $42, $43)`,
[
companyCode,
newWorkItemId,
sd.detail_type,
sd.content,
sd.is_required,
sd.sort_order,
sd.remark,
writer,
sd.inspection_code,
sd.inspection_method,
sd.unit,
sd.lower_limit,
sd.upper_limit,
sd.duration_minutes,
sd.input_type,
sd.lookup_target,
sd.display_fields,
selectedBomItemsValue,
sd.process_inspection_apply,
sd.equip_inspection_apply,
sd.condition_unit,
sd.condition_base_value,
sd.condition_tolerance,
sd.condition_auto_collect,
sd.condition_plc_data,
sd.bom_item_id,
sd.bom_item_name,
sd.bom_qty,
sd.bom_unit,
sd.work_qty_auto_collect,
sd.work_qty_plc_data,
sd.defect_qty_auto_collect,
sd.defect_qty_plc_data,
sd.good_qty_auto_collect,
sd.good_qty_plc_data,
sd.loss_qty_auto_collect,
sd.loss_qty_plc_data,
sd.inspection_count_apply,
sd.inspection_count,
sd.material_equipment_code,
sd.material_equipment_name,
sd.material_auto_collect,
sd.material_plc_data,
]
);
copiedDetailCount++;
}
}
processInfo.status = "copied";
if (autoRoutedProcessCodes.has(processCode)) {
processInfo.reason = "라우팅 자동 생성";
}
processInfo.copiedItemCount = copiedItemCount;
processInfo.copiedDetailCount = copiedDetailCount;
tempProcesses.push(processInfo);
tempCounts.copied++;
}
// 자동 라우팅이 1건이라도 생성됐고 등록 모드(screenCode)면, 좌측 트리에 노출되도록 item_routing_registered 등록
if (screenCode && autoRoutedProcessCodes.size > 0) {
await client.query(
`INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
SELECT $1::text, id, $2::text, company_code, $3::text
FROM item_info
WHERE item_number = $2::text AND company_code = $4::text
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING`,
[screenCode, targetItemCode, writer, companyCode]
);
}
await client.query("COMMIT");
// 트랜잭션 성공 — 실제 결과에 반영
itemResult.processes.push(...tempProcesses);
summary.totalProcesses += tempCounts.totalProcesses;
summary.copied += tempCounts.copied;
summary.skipped += tempCounts.skipped;
summary.notMatched += tempCounts.notMatched;
} catch (err: any) {
try {
await client.query("ROLLBACK");
} catch { /* ignore */ }
// 트랜잭션 실패 — 모든 공정을 failed로 표기 (적용된 것 없음)
for (const pc of Object.keys(sourceProcessMap)) {
itemResult.processes.push({
processCode: pc,
processName: sourceProcessMap[pc].process_name || pc,
status: "failed",
reason: err.message || "트랜잭션 실패",
});
summary.totalProcesses++;
summary.failed++;
}
} finally {
client.release();
}
results.push(itemResult);
}
logger.info("작업기준 품목단위 복사", {
companyCode,
sourceItemCode,
targetCount: targetItemCodes.length,
...summary,
});
return res.json({ success: true, results, summary });
} catch (error: any) {
logger.error("작업기준 복사 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 등록 품목 제거
*/

View File

@@ -33,6 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
// 전체 저장 (일괄)
router.put("/save-all", ctrl.saveAll);
// 품목 단위 작업기준 복사 (TASK:ERP-029)
router.get("/items/:itemCode/work-standard-tree", ctrl.getWorkStandardTreeByItem);
router.post("/copy-by-item", ctrl.copyByItem);
// 등록 품목 관리 (화면별 품목 목록)
router.get("/registered-items/:screenCode", ctrl.getRegisteredItems);
router.post("/registered-items", ctrl.registerItem);