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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록 품목 제거
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user