/** * 작업지시 컨트롤러 (work_instruction + work_instruction_detail) */ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; import { numberingRuleService } from "../services/numberingRuleService"; // 자동 마이그레이션: work_instruction_detail에 routing_version_id 컬럼 추가 let _migrationDone = false; async function ensureDetailRoutingColumn() { if (_migrationDone) return; try { const pool = getPool(); await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS routing_version_id VARCHAR(500)"); _migrationDone = true; } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } } // ─── 작업지시 목록 조회 (detail 기준 행 반환) ─── export async function getList(req: AuthenticatedRequest, res: Response) { try { await ensureDetailRoutingColumn(); const companyCode = req.user!.companyCode; const { dateFrom, dateTo, status, progressStatus, keyword, page, pageSize } = req.query; // 페이지네이션 파라미터 파싱 (page 없으면 전체 반환 — 하위호환) const pageNum = page ? Math.max(1, parseInt(page as string, 10) || 1) : null; const sizeNum = pageSize ? Math.max(1, Math.min(1000, parseInt(pageSize as string, 10) || 20)) : null; const paginated = pageNum !== null && sizeNum !== null; const conditions: string[] = []; const params: any[] = []; let idx = 1; if (companyCode !== "*") { conditions.push(`wi.company_code = $${idx}`); params.push(companyCode); idx++; } if (dateFrom) { conditions.push(`wi.start_date >= $${idx}`); params.push(dateFrom); idx++; } if (dateTo) { conditions.push(`wi.end_date <= $${idx}`); params.push(dateTo); idx++; } if (status && status !== "all") { conditions.push(`wi.status = $${idx}`); params.push(status); idx++; } if (progressStatus && progressStatus !== "all") { conditions.push(`wi.progress_status = $${idx}`); params.push(progressStatus); idx++; } // keyword 검색: wi 자체 필드 + detail.item_number 존재 여부로 EXISTS if (keyword) { conditions.push(`( wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR EXISTS ( SELECT 1 FROM work_instruction_detail dd LEFT JOIN item_info ii ON ii.item_number = dd.item_number AND ii.company_code = wi.company_code WHERE dd.work_instruction_id = wi.id AND (dd.item_number ILIKE $${idx} OR COALESCE(ii.item_name,'') ILIKE $${idx}) ) )`); params.push(`%${keyword}%`); idx++; } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const pool = getPool(); // 페이지네이션 모드: WI 단위로 페이지 잘라낸 뒤 detail과 JOIN if (paginated) { // 1) 총 WI 개수 카운트 const countSql = ` SELECT COUNT(*)::int AS cnt FROM work_instruction wi ${whereClause} `; const countRes = await pool.query(countSql, params); const totalCount = countRes.rows[0]?.cnt ?? 0; // 2) 현재 페이지 WI id 목록 const offset = (pageNum! - 1) * sizeNum!; const pageSql = ` SELECT wi.id FROM work_instruction wi ${whereClause} ORDER BY wi.created_date DESC, wi.id DESC LIMIT ${sizeNum} OFFSET ${offset} `; const pageRes = await pool.query(pageSql, params); const wiIds = pageRes.rows.map((r) => r.id); if (wiIds.length === 0) { return res.json({ success: true, data: [], totalCount, page: pageNum, pageSize: sizeNum }); } // 3) 해당 WI들의 detail + 품목/설비/라우팅 JOIN const dataSql = ` SELECT wi.id AS wi_id, wi.work_instruction_no, wi.status, wi.progress_status, wi.qty AS total_qty, wi.completed_qty, wi.start_date, wi.end_date, wi.equipment_id, wi.work_team, wi.worker, wi.remark AS wi_remark, wi.created_date, d.id AS detail_id, d.item_number, d.qty AS detail_qty, d.remark AS detail_remark, d.part_code, d.source_table, d.source_id, d.routing_version_id AS detail_routing_version_id, COALESCE(itm.item_name, '') AS item_name, COALESCE(itm.type, '') AS item_type, COALESCE(itm.size, '') AS item_spec, COALESCE(e.equipment_name, '') AS equipment_name, COALESCE(e.equipment_code, '') AS equipment_code, wi.routing AS routing_version_id, COALESCE(rv.version_name, '') AS routing_name, ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq, COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count FROM work_instruction wi INNER JOIN work_instruction_detail d ON d.work_instruction_id = wi.id LEFT JOIN item_info itm ON itm.item_number = d.item_number AND itm.company_code = wi.company_code LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code WHERE wi.id = ANY($1::varchar[]) ORDER BY wi.created_date DESC, wi.id DESC, d.created_date ASC `; const dataRes = await pool.query(dataSql, [wiIds]); return res.json({ success: true, data: dataRes.rows, totalCount, page: pageNum, pageSize: sizeNum, }); } // 비페이지 모드 (하위호환): 기존 방식 유지, LATERAL만 LEFT JOIN으로 교체 const query = ` SELECT wi.id AS wi_id, wi.work_instruction_no, wi.status, wi.progress_status, wi.qty AS total_qty, wi.completed_qty, wi.start_date, wi.end_date, wi.equipment_id, wi.work_team, wi.worker, wi.remark AS wi_remark, wi.created_date, d.id AS detail_id, d.item_number, d.qty AS detail_qty, d.remark AS detail_remark, d.part_code, d.source_table, d.source_id, d.routing_version_id AS detail_routing_version_id, COALESCE(itm.item_name, '') AS item_name, COALESCE(itm.type, '') AS item_type, COALESCE(itm.size, '') AS item_spec, COALESCE(e.equipment_name, '') AS equipment_name, COALESCE(e.equipment_code, '') AS equipment_code, wi.routing AS routing_version_id, COALESCE(rv.version_name, '') AS routing_name, ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq, COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count FROM work_instruction wi INNER JOIN work_instruction_detail d ON d.work_instruction_id = wi.id LEFT JOIN item_info itm ON itm.item_number = d.item_number AND itm.company_code = wi.company_code LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code ${whereClause} ORDER BY wi.created_date DESC, d.created_date ASC `; const result = await pool.query(query, params); return res.json({ success: true, data: result.rows }); } catch (error: any) { logger.error("작업지시 목록 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 다음 작업지시번호 미리보기 ─── export async function previewNextNo(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; let wiNo: string; try { const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no"); if (rule) { wiNo = await numberingRuleService.previewCode(rule.ruleId, companyCode, {}); } else { throw new Error("채번 규칙 없음"); } } catch { const pool = getPool(); const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); const seqRes = await pool.query( `SELECT COUNT(*) + 1 AS seq FROM work_instruction WHERE company_code = $1 AND work_instruction_no LIKE $2`, [companyCode, `WI-${today}-%`] ); wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`; } return res.json({ success: true, instructionNo: wiNo }); } catch (error: any) { logger.error("작업지시번호 미리보기 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 작업지시 저장 (신규/수정) ─── export async function save(req: AuthenticatedRequest, res: Response) { try { await ensureDetailRoutingColumn(); const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body; if (!items || items.length === 0) { return res.status(400).json({ success: false, message: "품목을 선택해주세요" }); } const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); let wiId: string; let wiNo: string; if (editId) { const check = await client.query(`SELECT id, work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`, [editId, companyCode]); if (check.rowCount === 0) throw new Error("작업지시를 찾을 수 없습니다"); wiId = editId; wiNo = check.rows[0].work_instruction_no; await client.query( `UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, updated_date=NOW(), writer=$11 WHERE id=$12 AND company_code=$13`, [wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId, editId, companyCode] ); await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=$1`, [wiId]); } else { try { const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no"); if (rule) { wiNo = await numberingRuleService.allocateCode(rule.ruleId, companyCode, {}); } else { throw new Error("채번 규칙 없음 - 폴백"); } } catch { const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); const seqRes = await client.query(`SELECT COUNT(*)+1 AS seq FROM work_instruction WHERE company_code=$1 AND work_instruction_no LIKE $2`, [companyCode, `WI-${today}-%`]); wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`; } const insertRes = await client.query( `INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),$13) RETURNING id`, [companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId] ); wiId = insertRes.rows[0].id; } let totalQty = 0; let firstRouting: string | null = null; for (const item of items) { const itemRouting = item.routing || null; if (!firstRouting && itemRouting) firstRouting = itemRouting; totalQty += Number(item.qty || 0); await client.query( `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11)`, [companyCode, wiNo, wiId, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", itemRouting, userId] ); } // 마스터 qty/routing 자동 동기화 (디테일 합계 + 첫 번째 라우팅) const effectiveRouting = routingVersionId || firstRouting; await client.query( `UPDATE work_instruction SET qty = $1, routing = COALESCE(routing, $2) WHERE id = $3`, [String(totalQty), effectiveRouting, wiId] ); await client.query("COMMIT"); return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } }); } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } finally { client.release(); } } catch (error: any) { logger.error("작업지시 저장 실패", { error: error.message, stack: error.stack }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 작업지시 삭제 ─── export async function remove(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { ids } = req.body; if (!ids || ids.length === 0) return res.status(400).json({ success: false, message: "삭제할 항목을 선택해주세요" }); const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); // 디테일 삭제 (id 기반) await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=ANY($1)`, [ids]); const result = await client.query(`DELETE FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]); await client.query("COMMIT"); return res.json({ success: true, deletedCount: result.rowCount }); } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } finally { client.release(); } } catch (error: any) { logger.error("작업지시 삭제 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 품목 소스 (페이징) ─── export async function getItemSource(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { keyword, page: ps, pageSize: pss } = req.query; const page = Math.max(1, parseInt(ps as string) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20)); const offset = (page - 1) * pageSize; const conds = ["company_code = $1"]; const params: any[] = [companyCode]; let idx = 2; if (keyword) { conds.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } const w = conds.join(" AND "); const pool = getPool(); const cnt = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${w}`, params); params.push(pageSize, offset); const rows = await pool.query(`SELECT id, item_number AS item_code, item_name, COALESCE(size,'') AS spec FROM item_info WHERE ${w} ORDER BY item_name LIMIT $${idx} OFFSET $${idx+1}`, params); return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } } // ─── 수주 소스 (페이징) ─── export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { keyword, page: ps, pageSize: pss } = req.query; const page = Math.max(1, parseInt(ps as string) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20)); const offset = (page - 1) * pageSize; const conds = ["d.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2; if (keyword) { conds.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } const fromClause = `FROM sales_order_detail d LEFT JOIN LATERAL (SELECT item_name FROM item_info WHERE item_number = d.part_code AND company_code = d.company_code LIMIT 1) i ON true WHERE ${conds.join(" AND ")}`; const pool = getPool(); const cnt = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params); params.push(pageSize, offset); const rows = await pool.query(`SELECT d.id, d.order_no, d.part_code AS item_code, COALESCE(i.item_name, d.part_name, d.part_code) AS item_name, COALESCE(d.spec,'') AS spec, COALESCE(NULLIF(d.qty,'')::numeric,0) AS qty, d.due_date ${fromClause} ORDER BY d.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params); return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } } // ─── 생산계획 소스 (페이징) ─── export async function getProductionPlanSource(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { keyword, page: ps, pageSize: pss } = req.query; const page = Math.max(1, parseInt(ps as string) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20)); const offset = (page - 1) * pageSize; const conds = ["p.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2; if (keyword) { conds.push(`(p.plan_no ILIKE $${idx} OR p.item_code ILIKE $${idx} OR COALESCE(p.item_name,'') ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } const w = conds.join(" AND "); const pool = getPool(); const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params); params.push(pageSize, offset); const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params); return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } } // ─── 사원 목록 (작업자 Select용) ─── export async function getEmployeeList(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const pool = getPool(); let query: string; let params: any[]; if (companyCode !== "*") { query = `SELECT user_id, user_name, dept_name FROM user_info WHERE company_code = $1 AND company_code != '*' ORDER BY user_name`; params = [companyCode]; } else { query = `SELECT user_id, user_name, dept_name, company_code FROM user_info WHERE company_code != '*' ORDER BY user_name`; params = []; } const result = await pool.query(query, params); return res.json({ success: true, data: result.rows }); } catch (error: any) { logger.error("사원 목록 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 설비 목록 (Select용) ─── export async function getEquipmentList(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const pool = getPool(); const cond = companyCode !== "*" ? "WHERE company_code = $1" : ""; const params = companyCode !== "*" ? [companyCode] : []; const result = await pool.query(`SELECT id, equipment_code, equipment_name FROM equipment_mng ${cond} ORDER BY equipment_name`, params); return res.json({ success: true, data: result.rows }); } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } } // ─── 품목의 라우팅 버전 + 공정 조회 ─── export async function getRoutingVersions(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { itemCode } = req.params; const pool = getPool(); const versionsResult = await pool.query( `SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default FROM item_routing_version WHERE item_code = $1 AND company_code = $2 ORDER BY is_default DESC, created_date DESC`, [itemCode, companyCode] ); const routings = []; for (const version of versionsResult.rows) { const detailsResult = await pool.query( `SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code, rd.is_required, rd.work_type, COALESCE(p.process_name, rd.process_code) AS process_name FROM item_routing_detail rd LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code WHERE rd.routing_version_id = $1 AND rd.company_code = $2 ORDER BY rd.seq_no::integer`, [version.id, companyCode] ); routings.push({ ...version, processes: detailsResult.rows }); } return res.json({ success: true, data: routings }); } catch (error: any) { logger.error("라우팅 버전 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 품목별 라우팅 벌크 조회 (엑셀 업로드용) ─── export async function getRoutingVersionsBulk(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { itemCodes } = req.body as { itemCodes: string[] }; if (!itemCodes || !Array.isArray(itemCodes) || itemCodes.length === 0) { return res.json({ success: true, data: {} }); } const pool = getPool(); const result: Record = {}; // 청크 단위로 분할 (PostgreSQL placeholder 제한 대응) const CHUNK_SIZE = 5000; for (let ci = 0; ci < itemCodes.length; ci += CHUNK_SIZE) { const chunk = itemCodes.slice(ci, ci + CHUNK_SIZE); // 1. 기본 라우팅 버전 조회 const placeholders = chunk.map((_, i) => `$${i + 2}`).join(","); const versionsResult = await pool.query( `SELECT DISTINCT ON (item_code) id, item_code, version_name FROM item_routing_version WHERE company_code = $1 AND item_code IN (${placeholders}) ORDER BY item_code, is_default DESC, created_date DESC`, [companyCode, ...chunk] ); if (versionsResult.rows.length === 0) continue; // 2. 라우팅 디테일 조회 const versionIds = versionsResult.rows.map((v: any) => v.id); const vPlaceholders = versionIds.map((_: any, i: number) => `$${i + 2}`).join(","); const detailsResult = await pool.query( `SELECT rd.routing_version_id, rd.process_code, COALESCE(p.process_name, rd.process_code) AS process_name FROM item_routing_detail rd LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code WHERE rd.company_code = $1 AND rd.routing_version_id IN (${vPlaceholders}) ORDER BY rd.seq_no::integer`, [companyCode, ...versionIds] ); // 3. 매핑 const versionToItem: Record = {}; for (const v of versionsResult.rows) { versionToItem[v.id] = v.item_code; } for (const d of detailsResult.rows) { const itemCode = versionToItem[d.routing_version_id]; if (!itemCode) continue; if (!result[itemCode]) result[itemCode] = []; result[itemCode].push({ code: d.process_code, name: d.process_name }); } } return res.json({ success: true, data: result }); } catch (error: any) { logger.error("벌크 라우팅 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 작업지시 라우팅 변경 ─── export async function updateRouting(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { wiNo } = req.params; const { routingVersionId } = req.body; const pool = getPool(); await pool.query( `UPDATE work_instruction SET routing = $1, updated_date = NOW() WHERE work_instruction_no = $2 AND company_code = $3`, [routingVersionId || null, wiNo, companyCode] ); return res.json({ success: true }); } catch (error: any) { logger.error("라우팅 변경 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 작업지시 전용 공정작업기준 조회 ─── export async function getWorkStandard(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { wiNo } = req.params; const { routingVersionId } = req.query; const pool = getPool(); if (!routingVersionId) { return res.status(400).json({ success: false, message: "routingVersionId 필요" }); } // 라우팅 디테일(공정) 목록 조회 const processesResult = await pool.query( `SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code, COALESCE(p.process_name, rd.process_code) AS process_name FROM item_routing_detail rd LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code WHERE rd.routing_version_id = $1 AND rd.company_code = $2 ORDER BY rd.seq_no::integer`, [routingVersionId, companyCode] ); // 커스텀 작업기준이 있는지 확인 const customCheck = await pool.query( `SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, [wiNo, companyCode] ); const hasCustom = parseInt(customCheck.rows[0].cnt) > 0; const processes = []; for (const proc of processesResult.rows) { let workItems; if (hasCustom) { // 커스텀 버전에서 조회 const wiResult = await pool.query( `SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description, (SELECT COUNT(*) FROM wi_process_work_item_detail d WHERE d.wi_work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count FROM wi_process_work_item wi WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3 ORDER BY wi.work_phase, wi.sort_order`, [wiNo, proc.routing_detail_id, companyCode] ); workItems = wiResult.rows; // 각 work_item의 상세도 로드 for (const wi of workItems) { const detailsResult = await pool.query( `SELECT id, wi_work_item_id AS work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2 ORDER BY sort_order`, [wi.id, companyCode] ); wi.details = detailsResult.rows; } } else { // 원본에서 조회 const origResult = await pool.query( `SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description, (SELECT COUNT(*) FROM process_work_item_detail d WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count FROM process_work_item wi WHERE wi.routing_detail_id = $1 AND wi.company_code = $2 ORDER BY wi.work_phase, wi.sort_order`, [proc.routing_detail_id, companyCode] ); workItems = origResult.rows; for (const wi of workItems) { const detailsResult = await pool.query( `SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2 ORDER BY sort_order`, [wi.id, companyCode] ); wi.details = detailsResult.rows; } } processes.push({ ...proc, workItems, }); } return res.json({ success: true, data: { processes, isCustom: hasCustom } }); } catch (error: any) { logger.error("작업지시 공정작업기준 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 원본 공정작업기준 -> 작업지시 전용 복사 ─── export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { wiNo } = req.params; const { routingVersionId } = req.body; const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); // 기존 커스텀 데이터 삭제 const existingItems = await client.query( `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, [wiNo, companyCode] ); for (const row of existingItems.rows) { await client.query( `DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`, [row.id, companyCode] ); } await client.query( `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, [wiNo, companyCode] ); // 라우팅 디테일 목록 조회 const routingDetails = await client.query( `SELECT id FROM item_routing_detail WHERE routing_version_id = $1 AND company_code = $2`, [routingVersionId, companyCode] ); // 각 공정(routing_detail)별 원본 작업항목 복사 for (const rd of routingDetails.rows) { const origItems = await client.query( `SELECT * FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2`, [rd.id, companyCode] ); for (const origItem of origItems.rows) { const newItemResult = await client.query( `INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, [companyCode, wiNo, rd.id, origItem.work_phase, origItem.title, origItem.is_required, origItem.sort_order, origItem.description, origItem.id, userId] ); const newItemId = newItemResult.rows[0].id; // 상세 복사 const origDetails = await client.query( `SELECT * FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2`, [origItem.id, companyCode] ); for (const origDetail of origDetails.rows) { await client.query( `INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, writer) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, [companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, userId] ); } } } await client.query("COMMIT"); logger.info("공정작업기준 복사 완료", { companyCode, wiNo, routingVersionId }); return res.json({ success: true }); } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } finally { client.release(); } } catch (error: any) { logger.error("공정작업기준 복사 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 작업지시 전용 공정작업기준 저장 (일괄) ─── export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { wiNo } = req.params; const { routingDetailId, workItems } = req.body; const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); // 해당 공정의 기존 커스텀 데이터 삭제 const existing = await client.query( `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`, [wiNo, routingDetailId, companyCode] ); for (const row of existing.rows) { await client.query( `DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`, [row.id, companyCode] ); } await client.query( `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`, [wiNo, routingDetailId, companyCode] ); // 새 데이터 삽입 for (const wi of workItems) { const wiResult = await client.query( `INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, [companyCode, wiNo, routingDetailId, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description || null, wi.source_work_item_id || null, userId] ); const newId = wiResult.rows[0].id; if (wi.details && Array.isArray(wi.details)) { for (const d of wi.details) { await client.query( `INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, writer) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, [companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, userId] ); } } } await client.query("COMMIT"); logger.info("작업지시 공정작업기준 저장 완료", { companyCode, wiNo, routingDetailId }); return res.json({ success: true }); } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } finally { client.release(); } } catch (error: any) { logger.error("작업지시 공정작업기준 저장 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 작업지시 전용 커스텀 데이터 삭제 (원본으로 초기화) ─── export async function resetWorkStandard(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { wiNo } = req.params; const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); const items = await client.query( `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, [wiNo, companyCode] ); for (const row of items.rows) { await client.query( `DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`, [row.id, companyCode] ); } await client.query( `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, [wiNo, companyCode] ); await client.query("COMMIT"); logger.info("작업지시 공정작업기준 초기화", { companyCode, wiNo }); return res.json({ success: true }); } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } finally { client.release(); } } catch (error: any) { logger.error("작업지시 공정작업기준 초기화 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } }