feat: POP 온디맨드 Pull — 작업지시 → 공정 자동 동기화

백엔드:
- createWorkProcesses 핵심 로직을 generateWorkProcessesForInstruction 내부 함수로 추출
- POST /api/pop/production/sync-work-instructions API 신규 추가
- routing 있지만 work_order_process 없는 작업지시를 자동 감지+생성
- 건별 개별 try-catch로 하나 실패해도 나머지 진행
프론트엔드:
- pop-card-list-v2(MES 공정흐름) 컴포넌트에서 마운트 시 sync 자동 호출
- timelineSource 감지 기반 — screen_id 하드코딩 없음
- sync 실패 시 조용히 무시 (화면 렌더링 영향 없음)
- sync 성공 시 데이터 자동 재조회
This commit is contained in:
SeongHyun Kim 2026-03-31 15:58:56 +09:00
parent 290275c27d
commit bcb07a2201
3 changed files with 251 additions and 87 deletions

View File

@ -84,6 +84,101 @@ async function copyChecklistToSplit(
return result.rowCount ?? 0;
}
/**
* 헬퍼: 단일 work_order_process + process_work_result
* createWorkProcesses와 syncWorkInstructions .
*
* @returns + . null .
*/
async function generateWorkProcessesForInstruction(
client: { query: (text: string, values?: any[]) => Promise<any> },
workInstructionId: string,
routingVersionId: string,
planQty: string | null,
companyCode: string,
userId: string
): Promise<{
processes: Array<{ id: string; seq_no: string; process_name: string; checklist_count: number }>;
total_checklists: number;
} | null> {
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[workInstructionId, companyCode]
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
return null; // 이미 존재
}
// 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명)
const routingDetails = await client.query(
`SELECT rd.id, rd.seq_no, rd.process_code,
COALESCE(pm.process_name, rd.process_code) as process_name,
rd.is_required, rd.is_fixed_order, rd.standard_time
FROM item_routing_detail rd
LEFT JOIN process_mng pm ON pm.process_code = rd.process_code
AND pm.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY CAST(rd.seq_no AS int) NULLS LAST`,
[routingVersionId, companyCode]
);
if (routingDetails.rows.length === 0) {
return null; // 공정 없음
}
const processes: Array<{
id: string;
seq_no: string;
process_name: string;
checklist_count: number;
}> = [];
let totalChecklists = 0;
for (const rd of routingDetails.rows) {
// 2. work_order_process INSERT
const wopResult = await client.query(
`INSERT INTO work_order_process (
company_code, wo_id, seq_no, process_code, process_name,
is_required, is_fixed_order, standard_time, plan_qty,
status, routing_detail_id, writer
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[
companyCode,
workInstructionId,
rd.seq_no,
rd.process_code,
rd.process_name,
rd.is_required,
rd.is_fixed_order,
rd.standard_time,
planQty || null,
parseInt(rd.seq_no, 10) === 1 ? "acceptable" : "waiting",
rd.id,
userId,
]
);
const wopId = wopResult.rows[0].id;
// 3. process_work_result INSERT (공통 함수로 체크리스트 복사)
const checklistCount = await copyChecklistToSplit(
client, wopId, wopId, rd.id, companyCode, userId
);
totalChecklists += checklistCount;
processes.push({
id: wopId,
seq_no: rd.seq_no,
process_name: rd.process_name,
checklist_count: checklistCount,
});
}
return { processes, total_checklists: totalChecklists };
}
/**
* D-BE1: 작업지시
* PC에서 . 1 work_order_process + process_work_result .
@ -121,92 +216,15 @@ export const createWorkProcesses = async (
await client.query("BEGIN");
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[work_instruction_id, companyCode]
const result = await generateWorkProcessesForInstruction(
client, work_instruction_id, routing_version_id, plan_qty, companyCode, userId
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
if (!result) {
await client.query("ROLLBACK");
return res.status(409).json({
success: false,
message: "이미 공정이 생성된 작업지시입니다.",
});
}
// 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명)
const routingDetails = await client.query(
`SELECT rd.id, rd.seq_no, rd.process_code,
COALESCE(pm.process_name, rd.process_code) as process_name,
rd.is_required, rd.is_fixed_order, rd.standard_time
FROM item_routing_detail rd
LEFT JOIN process_mng pm ON pm.process_code = rd.process_code
AND pm.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY CAST(rd.seq_no AS int) NULLS LAST`,
[routing_version_id, companyCode]
);
if (routingDetails.rows.length === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "라우팅 버전에 등록된 공정이 없습니다.",
});
}
const processes: Array<{
id: string;
seq_no: string;
process_name: string;
checklist_count: number;
}> = [];
let totalChecklists = 0;
for (const rd of routingDetails.rows) {
// 2. work_order_process INSERT
const wopResult = await client.query(
`INSERT INTO work_order_process (
company_code, wo_id, seq_no, process_code, process_name,
is_required, is_fixed_order, standard_time, plan_qty,
status, routing_detail_id, writer
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[
companyCode,
work_instruction_id,
rd.seq_no,
rd.process_code,
rd.process_name,
rd.is_required,
rd.is_fixed_order,
rd.standard_time,
plan_qty || null,
parseInt(rd.seq_no, 10) === 1 ? "acceptable" : "waiting",
rd.id,
userId,
]
);
const wopId = wopResult.rows[0].id;
// 3. process_work_result INSERT (공통 함수로 체크리스트 복사)
const checklistCount = await copyChecklistToSplit(
client, wopId, wopId, rd.id, companyCode, userId
);
totalChecklists += checklistCount;
processes.push({
id: wopId,
seq_no: rd.seq_no,
process_name: rd.process_name,
checklist_count: checklistCount,
});
logger.info("[pop/production] 공정 생성 완료", {
wopId,
processName: rd.process_name,
checklistCount,
message: "이미 공정이 생성된 작업지시이거나 라우팅에 공정이 없습니다.",
});
}
@ -215,16 +233,16 @@ export const createWorkProcesses = async (
logger.info("[pop/production] create-work-processes 완료", {
companyCode,
work_instruction_id,
total_processes: processes.length,
total_checklists: totalChecklists,
total_processes: result.processes.length,
total_checklists: result.total_checklists,
});
return res.json({
success: true,
data: {
processes,
total_processes: processes.length,
total_checklists: totalChecklists,
processes: result.processes,
total_processes: result.processes.length,
total_checklists: result.total_checklists,
},
});
} catch (error: any) {
@ -239,6 +257,130 @@ export const createWorkProcesses = async (
}
};
/**
* POP Pull: 미동기화 sync
* routing이 work_order_process가 .
* try-catch로 .
*/
export const syncWorkInstructions = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
logger.info("[pop/production] sync-work-instructions 요청", {
companyCode,
userId,
});
// 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목
const unsyncedResult = await pool.query(
`SELECT wi.id, wi.work_instruction_no, wi.routing, wi.qty
FROM work_instruction wi
WHERE wi.company_code = $1
AND wi.routing IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM work_order_process wop
WHERE wop.wo_id = wi.id AND wop.company_code = $1
)`,
[companyCode]
);
const unsynced = unsyncedResult.rows;
if (unsynced.length === 0) {
return res.json({
success: true,
data: { synced: 0, skipped: 0, errors: 0, details: [] },
});
}
let synced = 0;
let skipped = 0;
let errors = 0;
const details: Array<{
work_instruction_id: string;
work_instruction_no: string;
status: "synced" | "skipped" | "error";
process_count?: number;
error?: string;
}> = [];
for (const wi of unsynced) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await generateWorkProcessesForInstruction(
client, wi.id, wi.routing, wi.qty || null, companyCode, userId
);
if (!result) {
await client.query("ROLLBACK");
skipped++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "skipped",
});
continue;
}
await client.query("COMMIT");
synced++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "synced",
process_count: result.processes.length,
});
logger.info("[pop/production] sync: 공정 생성 완료", {
work_instruction_no: wi.work_instruction_no,
process_count: result.processes.length,
});
} catch (err: any) {
await client.query("ROLLBACK");
errors++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "error",
error: err.message || "알 수 없는 오류",
});
logger.error("[pop/production] sync: 개별 오류", {
work_instruction_no: wi.work_instruction_no,
error: err.message,
});
} finally {
client.release();
}
}
logger.info("[pop/production] sync-work-instructions 완료", {
companyCode,
synced,
skipped,
errors,
});
return res.json({
success: true,
data: { synced, skipped, errors, details },
});
} catch (error: any) {
logger.error("[pop/production] sync-work-instructions 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "작업지시 동기화 중 오류가 발생했습니다.",
});
}
};
/**
* D-BE2: 타이머 API (//)
*/

View File

@ -2,6 +2,7 @@ import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
createWorkProcesses,
syncWorkInstructions,
controlTimer,
controlGroupTimer,
getDefectTypes,
@ -24,6 +25,7 @@ const router = Router();
router.use(authenticateToken);
router.post("/create-work-processes", createWorkProcesses);
router.post("/sync-work-instructions", syncWorkInstructions);
router.post("/timer", controlTimer);
router.post("/group-timer", controlGroupTimer);
router.get("/defect-types", getDefectTypes);

View File

@ -1081,6 +1081,26 @@ export function PopCardListV2Component({
fetchData();
}, [dataSourceKey, fetchData]); // eslint-disable-line react-hooks/exhaustive-deps
// MES 공정흐름 컴포넌트: 마운트 시 미동기화 작업지시 자동 sync
// timelineSource가 있으면 이 컴포넌트가 공정 흐름을 표시하는 것이므로, sync 후 데이터 재조회
const syncCalledRef = useRef(false);
useEffect(() => {
if (!timelineSource || syncCalledRef.current) return;
syncCalledRef.current = true;
apiClient
.post("/api/pop/production/sync-work-instructions")
.then((resp) => {
const synced = resp.data?.data?.synced ?? 0;
if (synced > 0) {
fetchDataRef.current();
}
})
.catch(() => {
// sync 실패해도 화면 렌더링에 영향 없음 — 조용히 무시
});
}, [timelineSource]);
// 데이터 수집
useEffect(() => {
if (!componentId) return;