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:
parent
290275c27d
commit
bcb07a2201
|
|
@ -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 (시작/일시정지/재시작)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue