diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 6ce685fb..c070c243 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -11,6 +11,54 @@ interface DefectDetailItem { disposition: string; } +/** + * inventory_stock UPSERT 공통 함수 + * PC의 receivingController와 동일한 SELECT→INSERT/UPDATE 패턴 사용. + * (inventory_stock에 UNIQUE 제약조건이 없으므로 ON CONFLICT 사용 불가) + */ +async function upsertInventoryStock( + client: { query: (text: string, values?: any[]) => Promise }, + companyCode: string, + itemCode: string, + warehouseCode: string, + locationCode: string | null, + qty: number, + userId: string +): Promise { + const whCode = warehouseCode || null; + const locCode = locationCode || null; + + const existing = await client.query( + `SELECT id FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code, '') = COALESCE($3, '') + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, itemCode, whCode || '', locCode || ''] + ); + + if (existing.rows.length > 0) { + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text), + last_in_date = NOW(), + updated_date = NOW(), + writer = $2 + WHERE id = $3`, + [qty, userId, existing.rows[0].id] + ); + } else { + await client.query( + `INSERT INTO inventory_stock ( + id, company_code, item_code, warehouse_code, location_code, + current_qty, safety_qty, last_in_date, + created_date, updated_date, writer + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`, + [companyCode, itemCode, whCode, locCode, String(qty), userId] + ); + } +} + /** * 체크리스트 복사 공통 함수 * 분할 행/재작업 카드 생성 시 마스터의 체크리스트를 새 행에 복사한다. @@ -53,13 +101,15 @@ async function copyChecklistToSplit( ORDER BY pwi.sort_order, pwd.sort_order`, [newProcessId, userId, routingDetailId, companyCode] ); - return result.rowCount ?? 0; + const countA = result.rowCount ?? 0; + if (countA > 0) return countA; + // A 전략에서 0건이면 B 전략(마스터 행의 기존 결과 복사)으로 fallthrough } - // B. routing_detail_id가 없으면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화) + // B. routing_detail_id가 없거나 A 전략에서 0건이면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화) const result = await client.query( `INSERT INTO process_work_result ( - id, company_code, work_order_process_id, + company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, @@ -68,7 +118,7 @@ async function copyChecklistToSplit( status, writer ) SELECT - gen_random_uuid()::text, company_code, $1, + company_code, $1, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, @@ -84,6 +134,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 }, + 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 ( + id, 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 (gen_random_uuid()::text, $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 || rd.is_fixed_order === "Y" ? "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 +266,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 ( - id, 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 (gen_random_uuid()::text, $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 +283,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 +307,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 (시작/일시정지/재시작) */ @@ -752,6 +944,33 @@ export const saveResult = async ( }); } + // === BUG-2 FIX: SPLIT 실적 저장 후 master 행에 합산 === + if (prev.parent_process_id) { + await pool.query( + `UPDATE work_order_process + SET good_qty = sub.sum_good, + defect_qty = sub.sum_defect, + total_production_qty = sub.sum_total, + concession_qty = sub.sum_concession, + updated_date = NOW() + FROM ( + SELECT + COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0)::text AS sum_good, + COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0)::text AS sum_defect, + COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0)::text AS sum_total, + COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0)::text AS sum_concession + FROM work_order_process + WHERE parent_process_id = $1 AND company_code = $2 + ) sub + WHERE id = $1 AND company_code = $2`, + [prev.parent_process_id, companyCode] + ); + logger.info("[pop/production] master 합산 업데이트", { + masterId: prev.parent_process_id, + splitId: work_order_process_id, + }); + } + // 현재 분할 행의 공정 정보 조회 const currentSeq = await pool.query( `SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty, @@ -813,111 +1032,109 @@ export const saveResult = async ( } } - // 다음 공정 활성화: 양품 발생 시 다음 seq_no의 원본(마스터) 행을 acceptable로 - // waiting -> acceptable (최초 활성화) - // in_progress -> acceptable (앞공정 추가양품으로 접수가능량 복원) - if (addGood > 0 && currentSeq.rowCount > 0) { - const { seq_no, wo_id } = currentSeq.rows[0]; - const nextSeq = String(parseInt(seq_no, 10) + 1); - const nextUpdate = await pool.query( - `UPDATE work_order_process - SET status = CASE WHEN status IN ('waiting', 'in_progress') THEN 'acceptable' ELSE status END, - updated_date = NOW() - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NULL - AND status != 'completed' - RETURNING id, process_name, status`, - [wo_id, nextSeq, companyCode] - ); - if (nextUpdate.rowCount > 0) { - logger.info("[pop/production] 다음 공정 상태 전환", { - nextProcess: nextUpdate.rows[0], - }); - } - } - - // 개별 분할 행 자동완료: 이 분할 행의 접수분 전량 생산 시 completed + // 개별 분할 행 자동완료 (다음 공정 활성화보다 먼저 실행) if (currentSeq.rowCount > 0) { - const { seq_no, wo_id, current_input_qty, instruction_qty } = currentSeq.rows[0]; - const myInputQty = parseInt(current_input_qty, 10) || 0; + const { seq_no: csSeq, wo_id: csWoId, current_input_qty: csInputQty, instruction_qty: csInstrQty, parent_process_id: csParentId } = currentSeq.rows[0]; + const csMyInput = parseInt(csInputQty, 10) || 0; - if (newTotal >= myInputQty && myInputQty > 0) { + if (newTotal >= csMyInput && csMyInput > 0) { await pool.query( - `UPDATE work_order_process - SET status = 'completed', - result_status = 'confirmed', - completed_at = NOW()::text, - completed_by = $3, - updated_date = NOW() - WHERE id = $1 AND company_code = $2 - AND status != 'completed'`, + `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', + completed_at = NOW()::text, completed_by = $3, updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND status != 'completed'`, [work_order_process_id, companyCode, userId] ); - logger.info("[pop/production] 분할 행 자동 완료", { - work_order_process_id, newTotal, myInputQty, - }); - // 같은 공정의 모든 분할 행이 completed인지 체크 -> 원본도 completed로 - const seqNum = parseInt(seq_no, 10); - const instrQty = parseInt(instruction_qty, 10) || 0; - - // 앞공정 양품 합산 (접수가능 잔여 계산용) - let prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); - const prevProcess = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, - [wo_id, prevSeq, companyCode] + // 같은 seq의 모든 분할 행 완료 체크 → 마스터도 completed + const csSeqNum = parseInt(csSeq, 10); + let csPrevGood = parseInt(csInstrQty, 10) || 0; + if (csSeqNum > 1) { + const prev = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as tg + FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [csWoId, String(csSeqNum - 1), companyCode] ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; - } + if (prev.rowCount > 0) csPrevGood = parseInt(prev.rows[0].tg, 10) || 0; } - - // 같은 seq_no의 모든 분할 행 접수량 합산 + 미완료 행 카운트 - const siblingCheck = await pool.query( - `SELECT - COALESCE(SUM(input_qty::int), 0) as total_input, - COUNT(*) FILTER (WHERE status != 'completed') as incomplete_count - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [wo_id, seq_no, companyCode] + const sibCheck = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as ti, COUNT(*) FILTER (WHERE status != 'completed') as ic + FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, + [csWoId, csSeq, companyCode] ); - - const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0; - const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0; - const remainingAcceptable = prevGoodQty - totalInput; - - // 모든 분할 행 완료 + 잔여 접수가능 0 -> 원본(마스터)도 completed - if (incompleteCount === 0 && remainingAcceptable <= 0) { - const masterId = currentSeq.rows[0].parent_process_id; - if (masterId) { - await pool.query( - `UPDATE work_order_process - SET status = 'completed', - result_status = 'confirmed', - completed_at = NOW()::text, - completed_by = $3, - updated_date = NOW() - WHERE id = $1 AND company_code = $2 - AND status != 'completed'`, - [masterId, companyCode, userId] - ); - logger.info("[pop/production] 원본(마스터) 공정 자동 완료", { - masterId, totalInput, prevGoodQty, - }); - } + const csTotalInput = parseInt(sibCheck.rows[0].ti, 10) || 0; + const csIncomplete = parseInt(sibCheck.rows[0].ic, 10) || 0; + if (csIncomplete === 0 && csPrevGood - csTotalInput <= 0 && csParentId) { + await pool.query( + `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', + completed_at = NOW()::text, completed_by = $3, updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND status != 'completed'`, + [csParentId, companyCode, userId] + ); } } - // 작업지시 전체 완료 판정 - const { wo_id: woIdForWi } = currentSeq.rows[0]; - await checkAndCompleteWorkInstruction(pool, woIdForWi, companyCode, userId); + await checkAndCompleteWorkInstruction(pool, csWoId, companyCode, userId); } + // 다음 공정 활성화 (다중공정 대응) + // is_fixed_order='Y' 그룹이면 그룹 전체 완료 후 다음 활성화 + if (addGood > 0 && currentSeq.rowCount > 0) { + const { seq_no, wo_id, is_fixed_order } = currentSeq.rows[0]; + const seqNum = parseInt(seq_no, 10); + + let shouldActivateNext = true; + + if (is_fixed_order === "Y") { + // 같은 seq_no에서 is_fixed_order='Y'인 병렬 공정이 모두 완료되었는지 확인 + // (병렬 그룹 = 같은 seq_no를 공유하는 공정들) + const groupCheck = await pool.query( + `SELECT id, seq_no, status, + COALESCE(good_qty::int, 0) + COALESCE(concession_qty::int, 0) as total_good + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 + AND parent_process_id IS NULL + AND seq_no = $3 + ORDER BY CAST(seq_no AS int)`, + [wo_id, companyCode, seq_no] + ); + + // 같은 seq의 미완료 공정 확인 (병렬 그룹 내) + const incomplete = groupCheck.rows.filter((r: Record) => + String(r.status) !== "completed" && parseInt(String(r.total_good), 10) <= 0 + ); + shouldActivateNext = incomplete.length === 0; + + if (!shouldActivateNext) { + logger.info("[pop/production] 병렬 그룹 미완료 — 다음 공정 대기", { + groupSize: groupCheck.rows.length, + incomplete: incomplete.length, + }); + } + } + + if (shouldActivateNext) { + // 다음 seq 중 is_fixed_order='N'인 첫 공정 OR 다음 Y 그룹 시작점 + const nextSeq = String(seqNum + 1); + const nextUpdate = await pool.query( + `UPDATE work_order_process + SET status = CASE WHEN status IN ('waiting', 'in_progress') THEN 'acceptable' ELSE status END, + updated_date = NOW() + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NULL + AND status != 'completed' + RETURNING id, process_name, status`, + [wo_id, nextSeq, companyCode] + ); + if (nextUpdate.rowCount > 0) { + logger.info("[pop/production] 다음 공정 상태 전환", { + nextProcess: nextUpdate.rows[0], + }); + } + } + } + + // (분할행 완료 + 마스터 캐스케이드는 위에서 이미 처리됨) + logger.info("[pop/production] save-result 완료 (누적)", { companyCode, work_order_process_id, @@ -985,7 +1202,7 @@ const checkAndCompleteWorkInstruction = async ( const completedQty = totalGoodResult.rows[0].total_good; - await pool.query( + const updateResult = await pool.query( `UPDATE work_instruction SET status = 'completed', progress_status = 'completed', @@ -993,13 +1210,60 @@ const checkAndCompleteWorkInstruction = async ( writer = $4, updated_date = NOW() WHERE id = $1 AND company_code = $2 - AND status != 'completed'`, + AND status != 'completed' + RETURNING id, item_id`, [woId, companyCode, String(completedQty), userId] ); logger.info("[pop/production] 작업지시 전체 완료", { woId, completedQty, companyCode, }); + + // 생산완료→재고 입고: 마지막 공정의 target_warehouse_id가 설정된 경우 inventory_stock UPSERT + if (updateResult.rowCount > 0 && completedQty > 0) { + try { + const itemId = updateResult.rows[0].item_id; + + // item_info에서 item_number 조회 + const itemResult = await pool.query( + `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, + [itemId, companyCode] + ); + if (itemResult.rowCount === 0) { + logger.warn("[pop/production] 재고입고 건너뜀: item_info 없음", { itemId, companyCode }); + return; + } + const itemCode = itemResult.rows[0].item_number; + + // 마지막 공정의 창고 설정 조회 (마스터 행에서) + const warehouseResult = await pool.query( + `SELECT target_warehouse_id, target_location_code + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NULL + LIMIT 1`, + [woId, maxSeq, companyCode] + ); + + if (warehouseResult.rowCount === 0 || !warehouseResult.rows[0].target_warehouse_id) { + logger.info("[pop/production] 재고입고 건너뜀: 목표창고 미설정", { woId }); + return; + } + + const warehouseCode = warehouseResult.rows[0].target_warehouse_id; + const locationCode = warehouseResult.rows[0].target_location_code || warehouseCode; + + // inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) + await upsertInventoryStock(pool, companyCode, itemCode, warehouseCode, locationCode, completedQty, userId); + + logger.info("[pop/production] 생산완료→재고 입고 완료", { + woId, itemCode, warehouseCode, locationCode, qty: completedQty, companyCode, + }); + } catch (inventoryError: any) { + // 재고 입고 실패해도 공정 완료는 유지 (재고는 보조 기능) + logger.error("[pop/production] 재고입고 오류 (공정 완료는 유지):", inventoryError); + } + } }; /** @@ -1099,6 +1363,29 @@ export const confirmResult = async ( ); } + // === BUG-2 FIX: confirmResult에서도 master 합산 === + if (parent_process_id) { + await pool.query( + `UPDATE work_order_process + SET good_qty = sub.sum_good, + defect_qty = sub.sum_defect, + total_production_qty = sub.sum_total, + concession_qty = sub.sum_concession, + updated_date = NOW() + FROM ( + SELECT + COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0)::text AS sum_good, + COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0)::text AS sum_defect, + COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0)::text AS sum_total, + COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0)::text AS sum_concession + FROM work_order_process + WHERE parent_process_id = $1 AND company_code = $2 + ) sub + WHERE id = $1 AND company_code = $2`, + [parent_process_id, companyCode] + ); + } + // 마스터 자동완료 캐스케이드 (분할 행인 경우) if (parent_process_id) { let prevGoodQty = instrQty; @@ -1361,12 +1648,14 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) */ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => { const pool = getPool(); + const client = await pool.connect(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, accept_qty } = req.body; if (!work_order_process_id || !accept_qty) { + client.release(); return res.status(400).json({ success: false, message: "work_order_process_id와 accept_qty가 필요합니다.", @@ -1375,54 +1664,63 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => const qty = parseInt(accept_qty, 10); if (qty <= 0) { + client.release(); return res.status(400).json({ success: false, message: "접수 수량은 1 이상이어야 합니다." }); } - // 원본(마스터) 행 조회 - parent_process_id가 NULL인 행 또는 직접 지정된 행 - const current = await pool.query( + await client.query("BEGIN"); + + // 원본(마스터) 행 조회 + FOR UPDATE (동시 접수 방지) + const current = await client.query( `SELECT wop.id, wop.seq_no, wop.wo_id, wop.status, wop.parent_process_id, wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, wop.standard_time, wop.equipment_code, wop.routing_detail_id, wi.qty as instruction_qty FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code - WHERE wop.id = $1 AND wop.company_code = $2`, + WHERE wop.id = $1 AND wop.company_code = $2 + FOR UPDATE OF wop`, [work_order_process_id, companyCode] ); if (current.rowCount === 0) { + await client.query("ROLLBACK"); + client.release(); return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); } const row = current.rows[0]; - // 접수 대상은 원본(마스터) 행이어야 함 const masterId = row.parent_process_id || row.id; if (row.status === "completed") { + await client.query("ROLLBACK"); + client.release(); return res.status(400).json({ success: false, message: "이미 완료된 공정입니다." }); } if (row.status !== "acceptable") { - return res.status(400).json({ success: false, message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다. 접수가능 상태의 카드에서 접수해주세요.` }); + await client.query("ROLLBACK"); + client.release(); + return res.status(400).json({ success: false, message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다.` }); } const instrQty = parseInt(row.instruction_qty, 10) || 0; const seqNum = parseInt(row.seq_no, 10); - // 같은 공정(wo_id + seq_no)의 모든 분할 행 접수량 합산 - const totalAccepted = await pool.query( + // 같은 공정의 모든 분할 행 접수량 합산 (트랜잭션 내부 — 정확한 값) + const totalAccepted = await client.query( `SELECT COALESCE(SUM(input_qty::int), 0) as total_input FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, [row.wo_id, row.seq_no, companyCode] ); - const currentTotalInput = totalAccepted.rows[0].total_input; + const currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0; // 앞공정 양품+특채 합산 let prevGoodQty = instrQty; if (seqNum > 1) { const prevSeq = String(seqNum - 1); - const prevProcess = await pool.query( + const prevProcess = await client.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, @@ -1435,6 +1733,8 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => const availableQty = prevGoodQty - currentTotalInput; if (qty > availableQty) { + await client.query("ROLLBACK"); + client.release(); return res.status(400).json({ success: false, message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`, @@ -1442,7 +1742,7 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => } // 분할 행 INSERT (원본 행에서 공정 정보 복사) - const result = await pool.query( + const result = await client.query( `INSERT INTO work_order_process ( id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, equipment_code, routing_detail_id, @@ -1463,14 +1763,21 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => ] ); - // 분할 행에 체크리스트 복사 (마스터의 routing_detail_id 또는 마스터의 기존 체크리스트에서) + // 분할 행에 체크리스트 복사 const splitId = result.rows[0].id; const checklistCount = await copyChecklistToSplit( - pool, masterId, splitId, row.routing_detail_id, companyCode, userId + client, masterId, splitId, row.routing_detail_id, companyCode, userId ); - // 마스터는 항상 acceptable 유지 (completed 전환은 saveResult에서 처리) + // 마스터 행의 input_qty를 분할 합계로 갱신 const newTotalInput = currentTotalInput + qty; + await client.query( + `UPDATE work_order_process SET input_qty = $3, updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [masterId, companyCode, String(newTotalInput)] + ); + + await client.query("COMMIT"); logger.info("[pop/production] accept-process 분할 접수 완료", { companyCode, userId, masterId, @@ -1487,11 +1794,14 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => message: `${qty}개 접수 완료 (총 접수합계: ${newTotalInput})`, }); } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); logger.error("[pop/production] accept-process 오류:", error); return res.status(500).json({ success: false, message: error.message || "접수 중 오류가 발생했습니다.", }); + } finally { + client.release(); } }; @@ -1519,7 +1829,8 @@ export const cancelAccept = async ( const current = await pool.query( `SELECT id, status, input_qty, total_production_qty, result_status, - parent_process_id, wo_id, seq_no, process_name + parent_process_id, wo_id, seq_no, process_name, + target_warehouse_id, target_location_code, good_qty, concession_qty FROM work_order_process WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] @@ -1558,32 +1869,87 @@ export const cancelAccept = async ( } let cancelledQty = unproducedQty; + const client = await pool.connect(); - if (totalProduced === 0) { - // 실적이 없으면 분할 행 완전 삭제 - await pool.query( - `DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] + try { + await client.query("BEGIN"); + + if (totalProduced === 0) { + // 실적이 없으면 체크리스트 먼저 삭제 → 분할 행 삭제 + await client.query( + `DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + await client.query( + `DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + } else { + // 실적이 있으면 input_qty를 실적 수량으로 축소 + completed + await client.query( + `UPDATE work_order_process + SET input_qty = $3, status = 'completed', result_status = 'confirmed', + completed_at = NOW()::text, completed_by = $4, + updated_date = NOW(), writer = $4 + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode, String(totalProduced), userId] + ); + } + + // 재고 원복: 분할 행에 target_warehouse_id가 있으면 입고된 수량을 차감 + if (proc.target_warehouse_id) { + const inboundQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); + if (inboundQty > 0) { + // work_instruction에서 item_id 조회 + const wiResult = await client.query( + `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, + [proc.wo_id, companyCode] + ); + if (wiResult.rowCount > 0) { + const itemResult = await client.query( + `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, + [wiResult.rows[0].item_id, companyCode] + ); + if (itemResult.rowCount > 0) { + const itemCode = itemResult.rows[0].item_number; + const locCode = proc.target_location_code || proc.target_warehouse_id; + await client.query( + `UPDATE inventory_stock + SET current_qty = GREATEST((COALESCE(current_qty::numeric, 0) - $4::numeric), 0)::text, + updated_date = NOW(), writer = $5 + WHERE company_code = $1 AND item_code = $2 + AND warehouse_code = $3 AND location_code = $6`, + [companyCode, itemCode, proc.target_warehouse_id, String(inboundQty), userId, locCode] + ); + } + } + } + } + + // 마스터 행의 input_qty를 분할 합계로 재계산 + const remainingSplits = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input + FROM work_order_process + WHERE parent_process_id = $1 AND company_code = $2`, + [proc.parent_process_id, companyCode] ); - } else { - // 실적이 있으면 input_qty를 실적 수량으로 축소 + 접수분 전량 생산이므로 completed - await pool.query( + const newMasterInput = parseInt(remainingSplits.rows[0].total_input, 10) || 0; + + // 원본(마스터) 행: input_qty 복원 + acceptable 상태 유지 + await client.query( `UPDATE work_order_process - SET input_qty = $3, status = 'completed', result_status = 'confirmed', - completed_at = NOW()::text, completed_by = $4, - updated_date = NOW(), writer = $4 - WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode, String(totalProduced), userId] + SET status = 'acceptable', input_qty = $3, updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`, + [proc.parent_process_id, companyCode, String(newMasterInput)] ); - } - // 원본(마스터) 행을 다시 acceptable로 복원 (잔여 접수 가능하도록) - await pool.query( - `UPDATE work_order_process - SET status = 'acceptable', updated_date = NOW() - WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`, - [proc.parent_process_id, companyCode] - ); + await client.query("COMMIT"); + } catch (txErr) { + await client.query("ROLLBACK"); + throw txErr; + } finally { + client.release(); + } logger.info("[pop/production] cancel-accept 완료 (분할 행)", { companyCode, userId, work_order_process_id, @@ -1608,354 +1974,744 @@ export const cancelAccept = async ( } }; -// ======================================== -// POP 전용 함수 (PC 코드와 분리) -// ======================================== +/** + * 창고 목록 조회 (POP 생산용) + */ +export const getWarehouses = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const result = await pool.query( + `SELECT id, warehouse_code, warehouse_name, warehouse_type + FROM warehouse_info + WHERE company_code = $1 AND COALESCE(status, '') != '삭제' + ORDER BY warehouse_name`, + [companyCode] + ); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] 창고 목록 조회 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; /** - * 내부 헬퍼: 단일 작업지시에 대해 work_order_process + process_work_result 일괄 생성 - * syncWorkInstructions에서 사용한다. + * 특정 창고의 위치(로케이션) 목록 조회 + * warehouseId는 warehouse_info.id → warehouse_code를 조회해서 warehouse_location과 매칭 */ -async function generateWorkProcessesForInstruction( - client: { query: (text: string, values?: any[]) => Promise }, - 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; - } +export const getWarehouseLocations = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { warehouseId } = req.params; + if (!warehouseId) { + return res.status(400).json({ success: false, message: "warehouseId는 필수입니다." }); + } - 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) { - const wopResult = await client.query( - `INSERT INTO work_order_process ( - id, 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 (gen_random_uuid()::text, $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, - ] + // warehouse_info.id → warehouse_code 변환 + const whInfo = await pool.query( + `SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`, + [warehouseId, companyCode] ); - const wopId = wopResult.rows[0].id; + if (whInfo.rowCount === 0) { + return res.json({ success: true, data: [] }); + } + const warehouseCode = whInfo.rows[0].warehouse_code; - const checklistCount = await copyChecklistToSplit( - client, wopId, wopId, rd.id, companyCode, userId + const result = await pool.query( + `SELECT id, location_code, location_name + FROM warehouse_location + WHERE warehouse_code = $1 AND company_code = $2 + ORDER BY location_name`, + [warehouseCode, companyCode] ); - totalChecklists += checklistCount; + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] 창고 위치 조회 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; - processes.push({ - id: wopId, seq_no: rd.seq_no, process_name: rd.process_name, checklist_count: checklistCount, +/** + * 마지막 공정 여부 확인 + * 같은 wo_id에서 현재 seq_no보다 큰 공정(마스터 행)이 없으면 마지막 + */ +export const isLastProcess = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res.json({ success: true, data: { isLast: false } }); + } + + // 현재 공정의 wo_id와 seq_no 조회 (분할 행이면 parent의 seq_no 기준) + const process = await pool.query( + `SELECT wo_id, seq_no, parent_process_id + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [processId, companyCode] + ); + if (process.rowCount === 0) { + return res.json({ success: true, data: { isLast: false } }); + } + + const { wo_id, seq_no, parent_process_id } = process.rows[0]; + + // 분할 행이면 마스터의 seq_no 기준으로 판단 + let effectiveSeqNo = seq_no; + if (parent_process_id) { + const master = await pool.query( + `SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`, + [parent_process_id, companyCode] + ); + if (master.rowCount > 0) { + effectiveSeqNo = master.rows[0].seq_no; + } + } + + const next = await pool.query( + `SELECT id FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 + AND CAST(seq_no AS int) > CAST($3 AS int) + AND parent_process_id IS NULL + LIMIT 1`, + [wo_id, companyCode, effectiveSeqNo] + ); + + // 현재 공정의 기존 창고 설정도 반환 (기본값 세팅용) + const warehouseInfo = await pool.query( + `SELECT target_warehouse_id, target_location_code + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [processId, companyCode] + ); + + return res.json({ + success: true, + data: { + isLast: next.rowCount === 0, + woId: wo_id, + seqNo: effectiveSeqNo, + targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null, + targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null, + }, }); + } catch (error: any) { + logger.error("[pop/production] 마지막 공정 확인 오류:", error); + return res.status(500).json({ success: false, message: error.message }); } - - return { processes, total_checklists: totalChecklists }; -} +}; /** - * POP: 미동기화 작업지시 일괄 동기화 + * 공정의 목표 창고/위치 업데이트 + * 마지막 공정 완료 전 또는 완료 후 창고를 지정한다. + * 마스터 행에 저장하여 checkAndCompleteWorkInstruction이 참조할 수 있도록 한다. */ -export const syncWorkInstructions = async ( +export const updateTargetWarehouse = async ( req: AuthenticatedRequest, res: Response ) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - 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, skipped = 0, 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, - }); - } 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 || "알 수 없는 오류", - }); - } finally { - client.release(); - } - } - 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 || "동기화 오류" }); - } -}; - -export const getWarehouses = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const result = await pool.query( - `SELECT id, warehouse_code, warehouse_name, warehouse_type - FROM warehouse_info WHERE company_code = $1 AND COALESCE(status, '') != '삭제' ORDER BY warehouse_name`, - [companyCode] - ); - return res.json({ success: true, data: result.rows }); - } catch (error: any) { - return res.status(500).json({ success: false, message: error.message }); - } -}; - -export const getWarehouseLocations = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const { warehouseId } = req.params; - if (!warehouseId) return res.status(400).json({ success: false, message: "warehouseId 필수" }); - const whInfo = await pool.query( - `SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`, - [warehouseId, companyCode] - ); - if (whInfo.rowCount === 0) return res.json({ success: true, data: [] }); - const result = await pool.query( - `SELECT id, location_code, location_name FROM warehouse_location - WHERE warehouse_code = $1 AND company_code = $2 ORDER BY location_name`, - [whInfo.rows[0].warehouse_code, companyCode] - ); - return res.json({ success: true, data: result.rows }); - } catch (error: any) { - return res.status(500).json({ success: false, message: error.message }); - } -}; - -export const isLastProcess = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const { processId } = req.params; - if (!processId) return res.json({ success: true, data: { isLast: false } }); - const process = await pool.query( - `SELECT wo_id, seq_no, parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, - [processId, companyCode] - ); - if (process.rowCount === 0) return res.json({ success: true, data: { isLast: false } }); - const { wo_id, seq_no, parent_process_id } = process.rows[0]; - let effectiveSeqNo = seq_no; - if (parent_process_id) { - const master = await pool.query( - `SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`, - [parent_process_id, companyCode] - ); - if (master.rowCount > 0) effectiveSeqNo = master.rows[0].seq_no; - } - const next = await pool.query( - `SELECT id FROM work_order_process - WHERE wo_id = $1 AND company_code = $2 AND CAST(seq_no AS int) > CAST($3 AS int) AND parent_process_id IS NULL LIMIT 1`, - [wo_id, companyCode, effectiveSeqNo] - ); - const warehouseInfo = await pool.query( - `SELECT target_warehouse_id, target_location_code FROM work_order_process WHERE id = $1 AND company_code = $2`, - [processId, companyCode] - ); - return res.json({ - success: true, - data: { - isLast: next.rowCount === 0, woId: wo_id, seqNo: effectiveSeqNo, - targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null, - targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null, - }, - }); - } catch (error: any) { - return res.status(500).json({ success: false, message: error.message }); - } -}; - -export const updateTargetWarehouse = async (req: AuthenticatedRequest, res: Response) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, target_warehouse_id, target_location_code } = req.body; - if (!work_order_process_id || !target_warehouse_id) - return res.status(400).json({ success: false, message: "work_order_process_id와 target_warehouse_id 필수" }); + + if (!work_order_process_id || !target_warehouse_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 target_warehouse_id는 필수입니다.", + }); + } + + // 분할 행이면 마스터 행도 함께 업데이트 const procInfo = await pool.query( `SELECT parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] ); + const idsToUpdate = [work_order_process_id]; - if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) idsToUpdate.push(procInfo.rows[0].parent_process_id); + if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) { + idsToUpdate.push(procInfo.rows[0].parent_process_id); + } + for (const id of idsToUpdate) { await pool.query( - `UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW() + `UPDATE work_order_process + SET target_warehouse_id = $3, + target_location_code = $4, + writer = $5, + updated_date = NOW() WHERE id = $1 AND company_code = $2`, [id, companyCode, target_warehouse_id, target_location_code || null, userId] ); } + + logger.info("[pop/production] 목표 창고 업데이트", { + companyCode, userId, work_order_process_id, + target_warehouse_id, target_location_code, + updatedIds: idsToUpdate, + }); + return res.json({ success: true, data: { target_warehouse_id, target_location_code } }); } catch (error: any) { + logger.error("[pop/production] 목표 창고 업데이트 오류:", error); return res.status(500).json({ success: false, message: error.message }); } }; -export const inventoryInbound = async (req: AuthenticatedRequest, res: Response) => { +/** + * 독립 재고 입고 API + * 창고 저장 + inventory_stock UPSERT를 한 번에 수행한다. + * 실적(save-result) 완료 후 나중에 창고를 선택해도 재고가 들어가도록 분리. + * 이중 입고 방지: target_warehouse_id가 이미 설정된 경우 "이미 입고됨" 반환. + */ +export const inventoryInbound = async ( + req: AuthenticatedRequest, + res: Response +) => { const pool = getPool(); const client = await pool.connect(); + try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, warehouse_code, location_code } = req.body; - if (!work_order_process_id || !warehouse_code) - return res.status(400).json({ success: false, message: "work_order_process_id와 warehouse_code 필수" }); + + if (!work_order_process_id || !warehouse_code) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 warehouse_code는 필수입니다.", + }); + } + await client.query("BEGIN"); + + // 1. work_order_process에서 wo_id, good_qty, parent_process_id, 기존 target_warehouse_id 조회 const procResult = await client.query( - `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id FROM work_order_process WHERE id = $1 AND company_code = $2`, + `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no + FROM work_order_process + WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] ); - if (procResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "공정 없음" }); } + + if (procResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "해당 공정을 찾을 수 없습니다.", + }); + } + const proc = procResult.rows[0]; - if (proc.target_warehouse_id) { await client.query("ROLLBACK"); return res.status(409).json({ success: false, message: "이미 입고 완료" }); } + + // 이중 입고 방지: 이미 target_warehouse_id가 설정되어 있으면 거부 + if (proc.target_warehouse_id) { + await client.query("ROLLBACK"); + return res.status(409).json({ + success: false, + message: "이미 재고 입고가 완료된 공정입니다.", + data: { existing_warehouse: proc.target_warehouse_id }, + }); + } + const goodQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); - if (goodQty <= 0) { await client.query("ROLLBACK"); return res.status(400).json({ success: false, message: "양품 0" }); } - const wiResult = await client.query(`SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, [proc.wo_id, companyCode]); - if (wiResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "작업지시 없음" }); } - const itemResult = await client.query(`SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, [wiResult.rows[0].item_id, companyCode]); - if (itemResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "품목 없음" }); } - const itemCode = itemResult.rows[0].item_number; - const locCode = location_code || warehouse_code; - await client.query( - `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6) - ON CONFLICT (company_code, item_code, warehouse_code, location_code) - DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text, - last_in_date = NOW(), updated_date = NOW(), writer = $6`, - [companyCode, itemCode, warehouse_code, locCode, String(goodQty), userId] + + if (goodQty <= 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ + success: false, + message: "양품 수량이 0이므로 재고 입고할 수 없습니다.", + }); + } + + // 2. work_instruction에서 item_id 조회 + const wiResult = await client.query( + `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, + [proc.wo_id, companyCode] ); + + if (wiResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "작업지시를 찾을 수 없습니다.", + }); + } + + const itemId = wiResult.rows[0].item_id; + + // 3. item_info에서 item_number 조회 + const itemResult = await client.query( + `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, + [itemId, companyCode] + ); + + if (itemResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "품목 정보를 찾을 수 없습니다.", + }); + } + + const itemCode = itemResult.rows[0].item_number; + const effectiveLocationCode = location_code || null; + + // 4. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) + await upsertInventoryStock(client, companyCode, itemCode, warehouse_code, effectiveLocationCode, goodQty, userId); + + // 5. work_order_process에 target_warehouse_id 저장 (현재 행 + 마스터 행) const idsToUpdate = [work_order_process_id]; - if (proc.parent_process_id) idsToUpdate.push(proc.parent_process_id); + if (proc.parent_process_id) { + idsToUpdate.push(proc.parent_process_id); + } + for (const id of idsToUpdate) { await client.query( - `UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW() WHERE id = $1 AND company_code = $2`, + `UPDATE work_order_process + SET target_warehouse_id = $3, + target_location_code = $4, + writer = $5, + updated_date = NOW() + WHERE id = $1 AND company_code = $2`, [id, companyCode, warehouse_code, location_code || null, userId] ); } + await client.query("COMMIT"); - return res.json({ success: true, message: "재고 입고 완료", data: { item_code: itemCode, warehouse_code, location_code: locCode, qty: goodQty } }); + + logger.info("[pop/production] 독립 재고 입고 완료", { + companyCode, userId, work_order_process_id, + itemCode, warehouse_code, location_code: effectiveLocationCode, + qty: goodQty, + }); + + return res.json({ + success: true, + message: "재고 입고가 완료되었습니다.", + data: { + item_code: itemCode, + warehouse_code, + location_code: effectiveLocationCode, + qty: goodQty, + }, + }); } catch (error: any) { await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/production] 독립 재고 입고 오류:", error); return res.status(500).json({ success: false, message: error.message }); - } finally { client.release(); } + } finally { + client.release(); + } }; -export const quickInventoryInbound = async (req: AuthenticatedRequest, res: Response) => { +/** + * 간이 재고 입고 (공정 접수 없이 바로 입고) + * 품목 + 수량 + 창고만으로 inventory_stock UPSERT + inbound_mng 이력 기록 + */ +export const quickInventoryInbound = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { item_id, qty, warehouse_code, location_code, remark } = req.body; + + // 필수 파라미터 검증 + if (!item_id || !qty || !warehouse_code) { + return res.status(400).json({ + success: false, + message: "item_id, qty, warehouse_code는 필수입니다.", + }); + } + + const parsedQty = parseInt(String(qty), 10); + if (isNaN(parsedQty) || parsedQty <= 0) { + return res.status(400).json({ + success: false, + message: "수량은 1 이상의 정수여야 합니다.", + }); + } + + await client.query("BEGIN"); + + // 1. item_info에서 item_number, item_name 조회 + const itemResult = await client.query( + `SELECT item_number, item_name, size, material, unit + FROM item_info WHERE id = $1 AND company_code = $2`, + [item_id, companyCode] + ); + + if (itemResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "품목 정보를 찾을 수 없습니다.", + }); + } + + const item = itemResult.rows[0]; + const itemCode = item.item_number; + const effectiveLocationCode = location_code || null; + + // 2. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) + await upsertInventoryStock(client, companyCode, itemCode, warehouse_code, effectiveLocationCode, parsedQty, userId); + + // 3. inbound_mng에 간이입고 이력 기록 + const seqResult = await client.query( + `SELECT COALESCE(MAX( + CASE WHEN inbound_number ~ '^QIB-[0-9]{4}-[0-9]+$' + THEN CAST(SUBSTRING(inbound_number FROM '[0-9]+$') AS INTEGER) + ELSE 0 END + ), 0) + 1 AS next_seq + FROM inbound_mng WHERE company_code = $1`, + [companyCode] + ); + const nextSeq = seqResult.rows[0].next_seq; + const year = new Date().getFullYear(); + const inboundNumber = `QIB-${year}-${String(nextSeq).padStart(4, "0")}`; + + await client.query( + `INSERT INTO inbound_mng ( + id, company_code, inbound_number, inbound_type, inbound_date, + item_number, item_name, spec, material, unit, + inbound_qty, warehouse_code, location_code, + inbound_status, memo, remark, + created_date, updated_date, writer, created_by, updated_by + ) VALUES ( + gen_random_uuid()::text, $1, $2, '간이입고', CURRENT_DATE, + $3, $4, $5, $6, $7, + $8, $9, $10, + '완료', $11, $12, + NOW(), NOW(), $13, $13, $13 + )`, + [ + companyCode, inboundNumber, + item.item_number, item.item_name, item.size, item.material, item.unit, + parsedQty, warehouse_code, effectiveLocationCode, + remark || "POP 간이입고", remark || null, + userId, + ] + ); + + await client.query("COMMIT"); + + logger.info("[pop/production] 간이 재고 입고 완료", { + companyCode, userId, item_id, + itemCode, warehouse_code, location_code: effectiveLocationCode, + qty: parsedQty, inboundNumber, + }); + + return res.json({ + success: true, + message: "간이 재고 입고가 완료되었습니다.", + data: { + inbound_number: inboundNumber, + item_code: itemCode, + item_name: item.item_name, + warehouse_code, + location_code: effectiveLocationCode, + qty: parsedQty, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/production] 간이 재고 입고 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; + +/** + * 재작업 이력 조회 + * 작업지시(wo_id) 기준으로 모든 재작업 체인을 반환한다. + * 원본 → 재작업1 → 재작업2 → ... 순서로 체인 추적. + */ +export const getReworkHistory = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const woId = req.query.wo_id as string || req.params.woId; + if (!woId) { + return res.status(400).json({ success: false, message: "wo_id는 필수입니다." }); + } + + const result = await pool.query( + `SELECT id, seq_no, process_code, process_name, status, + input_qty, good_qty, defect_qty, concession_qty, + is_rework, rework_source_id, parent_process_id, + accepted_by, accepted_at, started_at, completed_at, + created_date + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 + AND (is_rework = 'Y' OR is_rework = '1' OR defect_qty::int > 0 OR parent_process_id IS NOT NULL) + ORDER BY created_date ASC`, + [woId, companyCode] + ); + + // 체인 구성: rework_source_id를 따라 트리 구조 + const rows = result.rows; + const byId: Record = {}; + for (const r of rows) byId[r.id] = r; + + const chains: Array<{ + source: typeof rows[0]; + reworks: typeof rows; + totalReworkCount: number; + }> = []; + + // 원본 행(불량 발생한 것) 찾기 + const reworkSourceIds = new Set(rows.filter(r => r.rework_source_id).map(r => r.rework_source_id)); + const sources = rows.filter(r => reworkSourceIds.has(r.id) || (parseInt(r.defect_qty || "0", 10) > 0 && r.is_rework !== "Y")); + + for (const src of sources) { + const chain: typeof rows = []; + const visited = new Set(); + // 이 소스에서 시작하는 재작업 체인 추적 + const queue = rows.filter(r => r.rework_source_id === src.id); + while (queue.length > 0) { + const item = queue.shift()!; + if (visited.has(item.id)) continue; + visited.add(item.id); + chain.push(item); + // 이 재작업에서 또 재작업이 나온 것 찾기 + const next = rows.filter(r => r.rework_source_id === item.id); + queue.push(...next); + } + chains.push({ + source: src, + reworks: chain, + totalReworkCount: chain.length, + }); + } + + return res.json({ + success: true, + data: { + wo_id: woId, + total_rework_count: rows.filter(r => r.is_rework === "Y" || r.is_rework === "1").length, + chains, + all_records: rows, + }, + }); + } catch (error: any) { + logger.error("[pop/production] rework-history 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 공정별 BOM 자재 목록 + 소요량 계산 + * work_order_process_id → item_code → bom + bom_detail 조회 + */ +export const getBomMaterials = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res.status(400).json({ success: false, message: "processId 필수" }); + } + + // 1. work_order_process → work_instruction → item_code, plan_qty + const procResult = await pool.query( + `SELECT wop.wo_id, wop.process_code, wop.input_qty, wop.plan_qty, + wi.item_id, wi.qty as instruction_qty + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + WHERE wop.id = $1 AND wop.company_code = $2`, + [processId, companyCode] + ); + if (procResult.rowCount === 0) { + return res.json({ success: true, data: { materials: [], processQty: 0 } }); + } + const proc = procResult.rows[0]; + const processQty = parseInt(proc.input_qty || proc.plan_qty || proc.instruction_qty || "0", 10); + + // 2. item_info → item_code (item_number) + const itemResult = await pool.query( + `SELECT item_number, item_name FROM item_info WHERE id = $1 AND company_code = $2`, + [proc.item_id, companyCode] + ); + if (itemResult.rowCount === 0) { + return res.json({ success: true, data: { materials: [], processQty } }); + } + const itemCode = itemResult.rows[0].item_number; + + // 3. BOM 조회 + const bomResult = await pool.query( + `SELECT bd.id, bd.child_item_id, bd.quantity, bd.unit, bd.process_type, bd.loss_rate, + i.item_name as child_item_name, i.item_number as child_item_code, i.unit as item_unit + FROM bom b + JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code + LEFT JOIN item_info i ON bd.child_item_id = i.id AND i.company_code = b.company_code + WHERE (b.item_code = $1 OR b.item_id = $2) AND b.company_code = $3 + ORDER BY bd.seq_no ASC`, + [itemCode, proc.item_id, companyCode] + ); + + // 4. 소요량 계산 + const bomBase = await pool.query( + `SELECT base_qty FROM bom WHERE (item_code = $1 OR item_id = $2) AND company_code = $3 LIMIT 1`, + [itemCode, proc.item_id, companyCode] + ); + const baseQty = parseFloat(bomBase.rows[0]?.base_qty || "1") || 1; + + const materials = bomResult.rows.map((bd: Record) => { + const bomQty = parseFloat(String(bd.quantity || "0")) || 0; + const lossRate = parseFloat(String(bd.loss_rate || "0")) || 0; + const requiredQty = Math.ceil((processQty / baseQty) * bomQty * (1 + lossRate / 100)); + return { + id: bd.id, + child_item_id: bd.child_item_id, + child_item_code: bd.child_item_code || "", + child_item_name: bd.child_item_name || "", + bom_qty: bomQty, + unit: bd.unit || bd.item_unit || "", + process_type: bd.process_type || "", + loss_rate: lossRate, + required_qty: requiredQty, + input_qty: 0, + }; + }); + + return res.json({ + success: true, + data: { materials, processQty, baseQty, itemCode, itemName: itemResult.rows[0].item_name }, + }); + } catch (error: any) { + logger.error("[pop/production] bom-materials 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 자재 투입 기록 저장 + * BOM 기준과 다른 수량도 허용 (유동 투입) + */ +export const saveMaterialInput = async (req: AuthenticatedRequest, res: Response) => { const pool = getPool(); const client = await pool.connect(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; - const { item_id, qty, warehouse_code, location_code, remark } = req.body; - if (!item_id || !qty || !warehouse_code) - return res.status(400).json({ success: false, message: "item_id, qty, warehouse_code 필수" }); - const parsedQty = parseInt(String(qty), 10); - if (isNaN(parsedQty) || parsedQty <= 0) - return res.status(400).json({ success: false, message: "수량은 1 이상" }); + const { work_order_process_id, inputs } = req.body; + + if (!work_order_process_id || !inputs || !Array.isArray(inputs)) { + return res.status(400).json({ success: false, message: "work_order_process_id, inputs[] 필수" }); + } + await client.query("BEGIN"); - const itemResult = await client.query( - `SELECT item_number, item_name, size, material, unit FROM item_info WHERE id = $1 AND company_code = $2`, [item_id, companyCode] - ); - if (itemResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "품목 없음" }); } - const item = itemResult.rows[0]; - const locCode = location_code || warehouse_code; - await client.query( - `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6) - ON CONFLICT (company_code, item_code, warehouse_code, location_code) - DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text, - last_in_date = NOW(), updated_date = NOW(), writer = $6`, - [companyCode, item.item_number, warehouse_code, locCode, String(parsedQty), userId] - ); - const seqResult = await client.query( - `SELECT COALESCE(MAX(CASE WHEN inbound_number ~ '^QIB-[0-9]{4}-[0-9]+$' - THEN CAST(SUBSTRING(inbound_number FROM '[0-9]+$') AS INTEGER) ELSE 0 END), 0) + 1 AS next_seq - FROM inbound_mng WHERE company_code = $1`, [companyCode] - ); - const inboundNumber = `QIB-${new Date().getFullYear()}-${String(seqResult.rows[0].next_seq).padStart(4, "0")}`; - await client.query( - `INSERT INTO inbound_mng (id, company_code, inbound_number, inbound_type, inbound_date, - item_number, item_name, spec, material, unit, inbound_qty, warehouse_code, location_code, - inbound_status, memo, remark, created_date, updated_date, writer, created_by, updated_by - ) VALUES (gen_random_uuid()::text, $1, $2, '간이입고', CURRENT_DATE, - $3, $4, $5, $6, $7, $8, $9, $10, '완료', $11, $12, NOW(), NOW(), $13, $13, $13)`, - [companyCode, inboundNumber, item.item_number, item.item_name, item.size, item.material, item.unit, - parsedQty, warehouse_code, locCode, remark || "POP 간이입고", remark || null, userId] - ); + + const results = []; + for (const input of inputs) { + const { child_item_id, child_item_code, child_item_name, input_qty, unit, bom_detail_id, required_qty, warehouse_code, location_code } = input; + // item_code/qty 등 대안 필드명도 허용 + const effectiveItemId = child_item_id || input.item_id || input.item_code || child_item_code; + const effectiveItemCode = child_item_code || input.item_code || child_item_id; + const effectiveItemName = child_item_name || input.item_name || ""; + const effectiveQty = input_qty || input.qty || input.quantity; + + if (!effectiveItemId || !effectiveQty) continue; + + const parsedQty = parseFloat(String(effectiveQty)); + if (isNaN(parsedQty) || parsedQty <= 0) continue; + + // 투입 기록 INSERT (process_work_result에 material_input 타입으로) + const insertResult = await client.query( + `INSERT INTO process_work_result ( + id, company_code, work_order_process_id, + detail_type, detail_content, item_title, + result_value, unit, is_passed, status, + remark, recorded_by, recorded_at, writer + ) VALUES ( + gen_random_uuid()::text, $1, $2, + 'material_input', $3, $4, + $5, $6, 'Y', 'completed', + $7, $8, NOW()::text, $8 + ) RETURNING id`, + [ + companyCode, work_order_process_id, + effectiveItemCode || effectiveItemId, effectiveItemName, + String(parsedQty), unit || "", + JSON.stringify({ bom_detail_id, required_qty: required_qty || 0, warehouse_code, location_code }), + userId, + ] + ); + + // 재고 차감 (warehouse_code가 있을 때만) + if (warehouse_code) { + const locCode = location_code || warehouse_code; + await client.query( + `UPDATE inventory_stock + SET current_qty = (COALESCE(current_qty::numeric, 0) - $4::numeric)::text, + updated_date = NOW(), writer = $5 + WHERE company_code = $1 AND item_code = $2 + AND warehouse_code = $3 AND location_code = $6`, + [companyCode, effectiveItemCode, warehouse_code, String(parsedQty), userId, locCode] + ); + } + + results.push({ id: insertResult.rows[0].id, child_item_code: effectiveItemCode, input_qty: parsedQty }); + } + await client.query("COMMIT"); - return res.json({ success: true, message: "간이 입고 완료", - data: { inbound_number: inboundNumber, item_code: item.item_number, item_name: item.item_name, warehouse_code, location_code: locCode, qty: parsedQty } }); + + return res.json({ + success: true, + message: `${results.length}건 자재 투입 완료`, + data: results, + }); } catch (error: any) { await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/production] material-input 오류:", error); return res.status(500).json({ success: false, message: error.message }); - } finally { client.release(); } + } finally { + client.release(); + } +}; + +/** + * 자재 투입 현황 조회 + */ +export const getMaterialInputs = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res.status(400).json({ success: false, message: "processId 필수" }); + } + + const result = await pool.query( + `SELECT id, detail_content as item_code, item_title as item_name, + result_value as input_qty, unit, remark, recorded_by, recorded_at + FROM process_work_result + WHERE work_order_process_id = $1 AND company_code = $2 + AND detail_type = 'material_input' + ORDER BY recorded_at ASC`, + [processId, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] material-inputs 조회 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } }; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index 921ddf92..ff383f23 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -18,6 +18,10 @@ import { updateTargetWarehouse, inventoryInbound, quickInventoryInbound, + getReworkHistory, + getBomMaterials, + saveMaterialInput, + getMaterialInputs, } from "../controllers/popProductionController"; const router = Router(); @@ -41,5 +45,9 @@ router.get("/is-last-process/:processId", isLastProcess); router.post("/update-target-warehouse", updateTargetWarehouse); router.post("/inventory-inbound", inventoryInbound); router.post("/quick-inventory-inbound", quickInventoryInbound); +router.get("/rework-history/:woId", getReworkHistory); +router.get("/bom-materials/:processId", getBomMaterials); +router.post("/material-input", saveMaterialInput); +router.get("/material-inputs/:processId", getMaterialInputs); export default router; diff --git a/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx b/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx new file mode 100644 index 00000000..7fd1a67f --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx @@ -0,0 +1,1434 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { apiClient } from "@/lib/api/client"; +import type { PopSettings } from "@/hooks/pop/usePopSettings"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Save, + RotateCcw, + X, + Plus, + Trash2, + Settings2, + Loader2, + Monitor, + MousePointerClick, +} from "lucide-react"; + +// ============================================================ +// Default Settings (mirrors usePopSettings.ts DEFAULT_SETTINGS) +// ============================================================ +const DEFAULT_SETTINGS: PopSettings = { + version: "hardcoded-1.0", + screens: { + processExecution: { + materialInput: true, + photoUpload: true, + plcEnabled: false, + bomFlexible: true, + packagingOptions: ["낱개", "박스", "파렛트"], + defectTypes: ["스크래치", "치수불량", "변색", "크랙", "기포"], + reworkTargetSelection: true, + groupPhotoEnabled: false, + dateFilter: false, + lastProcessInventory: "manual", + defaultWarehouse: false, + inspectionAutoJudge: "off", + standardTimeDisplay: false, + progressDisplay: false, + }, + inbound: { + inspectionRequired: false, + photoUpload: false, + barcodeEnabled: true, + packagingRecord: false, + defectSeparation: false, + }, + outbound: { + photoUpload: false, + barcodeEnabled: true, + }, + home: { + kpiCarousel: true, + recentActivity: true, + bannerEnabled: false, + bannerText: "", + iconThemeColor: "#2563eb", + iconCustomImages: false, + dashboardLayout: "default", + }, + plc: { + connectionType: "db", + refreshInterval: 5, + tagMappings: [], + alarmThresholds: [], + }, + }, +}; + +// ============================================================ +// Setting Zone Interface & Zone Map +// ============================================================ +interface SettingZone { + id: string; + label: string; + description: string; + settingPath: string; + type: "toggle" | "tags" | "select" | "text" | "number" | "array-object"; + top: string; + left: string; + width: string; + height: string; + selectOptions?: { value: string; label: string }[]; + arrayObjectFields?: { key: string; label: string; type: "string" | "number" | "select"; selectOptions?: { value: string; label: string }[] }[]; +} + +const ZONE_MAP: Record = { + "/pop/home": [ + { + id: "kpiCarousel", + label: "KPI 캐러셀", + description: "오늘의 현황 캐러셀을 표시합니다", + settingPath: "screens.home.kpiCarousel", + type: "toggle", + top: "12%", + left: "3%", + width: "94%", + height: "32%", + }, + { + id: "recentActivity", + label: "최근 활동", + description: "최근 입출고 활동을 표시합니다", + settingPath: "screens.home.recentActivity", + type: "toggle", + top: "72%", + left: "3%", + width: "94%", + height: "25%", + }, + { + id: "home_banner", + label: "공지 배너", + description: "상단에 공지 배너를 표시합니다", + settingPath: "screens.home.bannerEnabled", + type: "toggle", + top: "2%", + left: "3%", + width: "94%", + height: "8%", + }, + { + id: "home_bannerText", + label: "배너 텍스트", + description: "공지 배너에 표시할 텍스트를 입력합니다", + settingPath: "screens.home.bannerText", + type: "text", + top: "2%", + left: "3%", + width: "47%", + height: "8%", + }, + { + id: "home_iconThemeColor", + label: "아이콘 테마색", + description: "메뉴 아이콘의 테마 색상을 지정합니다 (hex)", + settingPath: "screens.home.iconThemeColor", + type: "text", + top: "46%", + left: "3%", + width: "47%", + height: "8%", + }, + { + id: "home_iconCustomImages", + label: "아이콘 이미지 커스텀", + description: "메뉴 아이콘에 커스텀 이미지를 사용합니다", + settingPath: "screens.home.iconCustomImages", + type: "toggle", + top: "46%", + left: "52%", + width: "46%", + height: "8%", + }, + { + id: "home_dashboardLayout", + label: "대시보드 구성", + description: "대시보드 레이아웃 모드를 선택합니다", + settingPath: "screens.home.dashboardLayout", + type: "select", + top: "56%", + left: "3%", + width: "94%", + height: "8%", + selectOptions: [ + { value: "default", label: "기본" }, + { value: "compact", label: "간결" }, + { value: "detailed", label: "상세" }, + ], + }, + ], + "/pop/inbound": [ + { + id: "inbound_barcode", + label: "바코드 스캔", + description: "거래처/품목 바코드 스캔 기능을 사용합니다", + settingPath: "screens.inbound.barcodeEnabled", + type: "toggle", + top: "8%", + left: "80%", + width: "17%", + height: "15%", + }, + { + id: "inbound_inspection", + label: "검사 필수", + description: "입고 시 검사 항목을 필수로 표시합니다", + settingPath: "screens.inbound.inspectionRequired", + type: "toggle", + top: "28%", + left: "65%", + width: "30%", + height: "8%", + }, + { + id: "inbound_photo", + label: "사진 첨부", + description: "입고 확정 시 사진 첨부를 허용합니다", + settingPath: "screens.inbound.photoUpload", + type: "toggle", + top: "85%", + left: "3%", + width: "94%", + height: "10%", + }, + { + id: "inbound_packagingRecord", + label: "포장/적재 기록", + description: "입고 시 포장 및 적재 기록을 입력합니다", + settingPath: "screens.inbound.packagingRecord", + type: "toggle", + top: "38%", + left: "3%", + width: "60%", + height: "8%", + }, + { + id: "inbound_defectSeparation", + label: "불량 분리 입력", + description: "입고 시 불량 분리 입력을 표시합니다", + settingPath: "screens.inbound.defectSeparation", + type: "toggle", + top: "48%", + left: "3%", + width: "60%", + height: "8%", + }, + ], + "/pop/outbound": [ + { + id: "outbound_barcode", + label: "바코드 스캔", + description: "고객사/품목 바코드 스캔 기능을 사용합니다", + settingPath: "screens.outbound.barcodeEnabled", + type: "toggle", + top: "8%", + left: "80%", + width: "17%", + height: "15%", + }, + { + id: "outbound_photo", + label: "사진 첨부", + description: "출고 시 사진 첨부를 허용합니다", + settingPath: "screens.outbound.photoUpload", + type: "toggle", + top: "85%", + left: "3%", + width: "94%", + height: "10%", + }, + ], + "/pop/production": [ + { + id: "pe_material", + label: "자재 투입", + description: "BOM 기반 자재 투입 탭을 표시합니다", + settingPath: "screens.processExecution.materialInput", + type: "toggle", + top: "65%", + left: "52%", + width: "46%", + height: "12%", + }, + { + id: "pe_bomFlexible", + label: "BOM 유동 투입", + description: "기준과 다른 수량 투입을 허용합니다", + settingPath: "screens.processExecution.bomFlexible", + type: "toggle", + top: "65%", + left: "52%", + width: "23%", + height: "6%", + }, + { + id: "pe_photo", + label: "사진 첨부", + description: "실적 입력 시 사진 첨부를 허용합니다", + settingPath: "screens.processExecution.photoUpload", + type: "toggle", + top: "78%", + left: "52%", + width: "46%", + height: "8%", + }, + { + id: "pe_groupPhoto", + label: "그룹별 사진", + description: "체크리스트 그룹마다 사진을 첨부합니다", + settingPath: "screens.processExecution.groupPhotoEnabled", + type: "toggle", + top: "40%", + left: "52%", + width: "46%", + height: "8%", + }, + { + id: "pe_plc", + label: "PLC 연동", + description: "설비 PLC 데이터를 자동 연동합니다", + settingPath: "screens.processExecution.plcEnabled", + type: "toggle", + top: "50%", + left: "52%", + width: "46%", + height: "8%", + }, + { + id: "pe_rework", + label: "재작업 공정 지정", + description: "불량 처리 시 특정 공정을 선택할 수 있습니다", + settingPath: "screens.processExecution.reworkTargetSelection", + type: "toggle", + top: "88%", + left: "52%", + width: "46%", + height: "8%", + }, + { + id: "pe_packaging", + label: "포장 옵션", + description: "포장 단위 선택지를 관리합니다", + settingPath: "screens.processExecution.packagingOptions", + type: "tags", + top: "58%", + left: "52%", + width: "23%", + height: "6%", + }, + { + id: "pe_defects", + label: "불량 유형", + description: "불량 유형 선택지를 관리합니다", + settingPath: "screens.processExecution.defectTypes", + type: "tags", + top: "88%", + left: "52%", + width: "23%", + height: "6%", + }, + { + id: "pe_dateFilter", + label: "날짜 필터", + description: "작업지시 날짜 필터를 표시합니다", + settingPath: "screens.processExecution.dateFilter", + type: "toggle", + top: "8%", + left: "3%", + width: "46%", + height: "8%", + }, + { + id: "pe_lastProcessInventory", + label: "마지막 공정 재고 입고", + description: "마지막 공정에서 재고 입고 방식을 선택합니다", + settingPath: "screens.processExecution.lastProcessInventory", + type: "select", + top: "18%", + left: "3%", + width: "46%", + height: "8%", + selectOptions: [ + { value: "auto", label: "자동 입고" }, + { value: "manual", label: "수동 입력" }, + { value: "button", label: "버튼 클릭" }, + ], + }, + { + id: "pe_defaultWarehouse", + label: "기본 창고 기억", + description: "마지막 선택 창고를 기억하여 자동 적용합니다", + settingPath: "screens.processExecution.defaultWarehouse", + type: "toggle", + top: "28%", + left: "3%", + width: "46%", + height: "8%", + }, + { + id: "pe_inspectionAutoJudge", + label: "검사 자동 판정", + description: "검사 결과를 자동으로 판정하는 방식을 선택합니다", + settingPath: "screens.processExecution.inspectionAutoJudge", + type: "select", + top: "38%", + left: "3%", + width: "46%", + height: "8%", + selectOptions: [ + { value: "off", label: "사용 안함" }, + { value: "warn", label: "경고만" }, + { value: "fail", label: "불합격 처리" }, + ], + }, + { + id: "pe_standardTimeDisplay", + label: "표준시간 비교", + description: "표준시간 대비 실제 시간을 비교 표시합니다", + settingPath: "screens.processExecution.standardTimeDisplay", + type: "toggle", + top: "18%", + left: "52%", + width: "46%", + height: "8%", + }, + { + id: "pe_progressDisplay", + label: "진행률 표시", + description: "작업지시 전체 진행률을 표시합니다", + settingPath: "screens.processExecution.progressDisplay", + type: "toggle", + top: "28%", + left: "52%", + width: "46%", + height: "8%", + }, + ], + "/pop/plc": [ + { + id: "plc_connectionType", + label: "PLC 연결 방식", + description: "PLC와의 연결 방식을 선택합니다", + settingPath: "screens.plc.connectionType", + type: "select", + top: "8%", + left: "3%", + width: "94%", + height: "10%", + selectOptions: [ + { value: "db", label: "DB 연동" }, + { value: "opcua", label: "OPC-UA" }, + { value: "rest", label: "REST API" }, + ], + }, + { + id: "plc_refreshInterval", + label: "값 갱신 주기", + description: "PLC 값을 갱신하는 주기(초)를 설정합니다", + settingPath: "screens.plc.refreshInterval", + type: "number", + top: "20%", + left: "3%", + width: "94%", + height: "10%", + }, + { + id: "plc_tagMappings", + label: "PLC 태그 매핑", + description: "PLC 태그와 공정/체크리스트 항목을 매핑합니다", + settingPath: "screens.plc.tagMappings", + type: "array-object", + top: "32%", + left: "3%", + width: "94%", + height: "30%", + arrayObjectFields: [ + { key: "tagName", label: "태그명", type: "string" }, + { key: "processCode", label: "공정코드", type: "string" }, + { key: "checklistItemId", label: "체크리스트 항목 ID", type: "string" }, + { key: "unit", label: "단위", type: "string" }, + ], + }, + { + id: "plc_alarmThresholds", + label: "임계값 경고", + description: "PLC 태그 값의 임계값과 경고 동작을 설정합니다", + settingPath: "screens.plc.alarmThresholds", + type: "array-object", + top: "64%", + left: "3%", + width: "94%", + height: "30%", + arrayObjectFields: [ + { key: "tagName", label: "태그명", type: "string" }, + { key: "lowerLimit", label: "하한", type: "number" }, + { key: "upperLimit", label: "상한", type: "number" }, + { key: "action", label: "동작", type: "select", selectOptions: [{ value: "warn", label: "경고" }, { value: "stop", label: "정지" }] }, + ], + }, + ], +}; + +// Screen path to display name mapping +const SCREEN_LABELS: Record = { + "/pop/home": "홈", + "/pop/inbound": "구매입고", + "/pop/outbound": "판매출고", + "/pop/production": "공정실행", + "/pop/plc": "PLC 설정", +}; + +// ============================================================ +// getSettingValue / setSettingValue utilities +// ============================================================ +function getSettingValue(settings: PopSettings, path: string): unknown { + return path + .split(".") + .reduce( + (obj: Record | unknown, key: string) => + (obj as Record)?.[key], + settings as unknown + ); +} + +function setSettingValue( + settings: PopSettings, + path: string, + value: unknown +): PopSettings { + const keys = path.split("."); + const newSettings = JSON.parse(JSON.stringify(settings)) as Record< + string, + unknown + >; + let obj: Record = newSettings; + for (let i = 0; i < keys.length - 1; i++) { + obj = obj[keys[i]] as Record; + } + obj[keys[keys.length - 1]] = value; + return newSettings as unknown as PopSettings; +} + +// ============================================================ +// Tag Editor Component +// ============================================================ +function TagEditor({ + tags, + onChange, +}: { + tags: string[]; + onChange: (tags: string[]) => void; +}) { + const [input, setInput] = useState(""); + + const handleAdd = () => { + const value = input.trim(); + if (value && !tags.includes(value)) { + onChange([...tags, value]); + setInput(""); + } + }; + + return ( +
+
+ {tags.map((tag) => ( + + {tag} + + + ))} + {tags.length === 0 && ( + + 항목이 없습니다 + + )} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(); + } + }} + placeholder="입력 후 Enter 또는 추가 버튼" + className="h-8 text-sm" + /> + +
+
+ ); +} + +// ============================================================ +// Array Object Editor Component +// ============================================================ +function ArrayObjectEditor({ + items, + fields, + onChange, +}: { + items: Record[]; + fields: NonNullable; + onChange: (items: Record[]) => void; +}) { + const handleAdd = () => { + const newItem: Record = {}; + fields.forEach((f) => { + if (f.type === "number") newItem[f.key] = 0; + else if (f.type === "select" && f.selectOptions?.length) + newItem[f.key] = f.selectOptions[0].value; + else newItem[f.key] = ""; + }); + onChange([...items, newItem]); + }; + + const handleRemove = (index: number) => { + onChange(items.filter((_, i) => i !== index)); + }; + + const handleChange = ( + index: number, + key: string, + value: string | number + ) => { + const updated = items.map((item, i) => + i === index ? { ...item, [key]: value } : item + ); + onChange(updated); + }; + + return ( +
+ {items.length === 0 && ( + 항목이 없습니다 + )} + {items.map((item, idx) => ( +
+
+ + #{idx + 1} + + +
+
+ {fields.map((field) => ( +
+ + {field.type === "select" && field.selectOptions ? ( + + ) : field.type === "number" ? ( + + handleChange( + idx, + field.key, + parseFloat(e.target.value) || 0 + ) + } + className="h-7 text-xs" + /> + ) : ( + + handleChange(idx, field.key, e.target.value) + } + className="h-7 text-xs" + /> + )} +
+ ))} +
+
+ ))} + +
+ ); +} + +// ============================================================ +// Main Page +// ============================================================ +export default function PopSettingsMngPage() { + const { user } = useAuth(); + const iframeRef = useRef(null); + + // Settings state + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [originalSettings, setOriginalSettings] = + useState(DEFAULT_SETTINGS); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [existingId, setExistingId] = useState(null); + const [hasChanges, setHasChanges] = useState(false); + + // Overlay state + const [currentPath, setCurrentPath] = useState("/pop/home"); + const [selectedZone, setSelectedZone] = useState(null); + const [hoveredZone, setHoveredZone] = useState(null); + + // Current zones based on detected path + const currentZones = ZONE_MAP[currentPath] || []; + + // ---- Fetch settings ---- + const fetchSettings = useCallback(async () => { + setLoading(true); + try { + const res = await apiClient.get("/data/pop_settings?pageSize=1"); + const rows = res.data?.data?.data || res.data?.data || []; + if (rows.length > 0 && rows[0].settings_data) { + const parsed = + typeof rows[0].settings_data === "string" + ? JSON.parse(rows[0].settings_data) + : rows[0].settings_data; + const merged: PopSettings = { + ...DEFAULT_SETTINGS, + ...parsed, + screens: { + ...DEFAULT_SETTINGS.screens, + ...parsed.screens, + processExecution: { + ...DEFAULT_SETTINGS.screens.processExecution, + ...parsed.screens?.processExecution, + }, + inbound: { + ...DEFAULT_SETTINGS.screens.inbound, + ...parsed.screens?.inbound, + }, + outbound: { + ...DEFAULT_SETTINGS.screens.outbound, + ...parsed.screens?.outbound, + }, + home: { + ...DEFAULT_SETTINGS.screens.home, + ...parsed.screens?.home, + }, + plc: { + ...DEFAULT_SETTINGS.screens.plc, + ...parsed.screens?.plc, + }, + }, + }; + setSettings(merged); + setOriginalSettings(merged); + if (rows[0].id) { + setExistingId(rows[0].id); + } + } + } catch { + const local = localStorage.getItem("pop_settings"); + if (local) { + try { + const parsed = JSON.parse(local); + const merged = { ...DEFAULT_SETTINGS, ...parsed }; + setSettings(merged); + setOriginalSettings(merged); + } catch { + /* use default */ + } + } + } + setLoading(false); + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + // Track changes + useEffect(() => { + setHasChanges( + JSON.stringify(settings) !== JSON.stringify(originalSettings) + ); + }, [settings, originalSettings]); + + // ---- iframe URL change detection ---- + useEffect(() => { + const timer = setInterval(() => { + try { + const path = + iframeRef.current?.contentWindow?.location.pathname || ""; + if (path && path !== currentPath) { + setCurrentPath(path); + setSelectedZone(null); + } + } catch { + /* cross-origin: ignore */ + } + }, 500); + return () => clearInterval(timer); + }, [currentPath]); + + // ---- Zone selection ---- + const selectZone = (zone: SettingZone) => { + setSelectedZone(zone); + }; + + // ---- Save ---- + const handleSave = async () => { + setSaving(true); + try { + await apiClient.post("/pop/execute-action", { + taskType: "data-save", + targetTable: "pop_settings", + columnMapping: { + id: existingId || crypto.randomUUID(), + company_code: user?.companyCode || user?.company_code || "COMPANY_7", + settings_data: JSON.stringify(settings), + updated_by: user?.userId, + }, + }); + setOriginalSettings(settings); + setExistingId(existingId || null); + // Reload iframe so POP picks up new settings + try { + iframeRef.current?.contentWindow?.location.reload(); + } catch { + /* cross-origin fallback */ + if (iframeRef.current) { + iframeRef.current.src = iframeRef.current.src; + } + } + alert("POP 설정이 저장되었습니다."); + } catch { + localStorage.setItem("pop_settings", JSON.stringify(settings)); + alert( + "설정이 로컬에 저장되었습니다. (DB 테이블 생성 후 자동 동기화됩니다)" + ); + } + setSaving(false); + }; + + // ---- Reset to default ---- + const handleReset = () => { + if ( + confirm( + "모든 설정을 기본값으로 초기화하시겠습니까?\n저장 버튼을 눌러야 DB에 반영됩니다." + ) + ) { + setSettings(DEFAULT_SETTINGS); + setSelectedZone(null); + } + }; + + // ---- Setting value update from panel ---- + const handleSettingChange = (zone: SettingZone, value: unknown) => { + setSettings((prev) => setSettingValue(prev, zone.settingPath, value)); + }; + + // ---- Loading state ---- + if (loading) { + return ( +
+
+ + 설정을 불러오는 중... +
+
+ ); + } + + // Collect all zones for current screen as summary list + const summaryZones = currentZones; + + return ( +
+
+ {/* ---- Page Header ---- */} +
+
+
+ +

+ POP 화면 설정 +

+
+

+ 왼쪽 POP 화면에서 설정할 영역을 클릭하면 오른쪽에 설정 패널이 열립니다. + {user?.companyCode && ( + + {user.companyCode} + + )} +

+
+
+ + +
+
+ + {/* ---- Main Content: iframe + Settings Panel ---- */} +
+ {/* Left: iframe + overlay (65%) */} +
+ {/* Current screen indicator */} +
+ + + 현재 화면: + + + {SCREEN_LABELS[currentPath] || currentPath} + + {currentZones.length > 0 && ( + + ({currentZones.length}개 설정 영역) + + )} +
+ + {/* Tablet frame */} +
+ {/* iframe */} +