From 970a8f708a298d4c110736a9bb393fc7f2da417e Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 6 May 2026 18:09:23 +0900 Subject: [PATCH] Implement process materials auto-fill functionality for outsource purchase orders - Added a new endpoint to retrieve process materials based on routing details and work order ID. - Introduced the `getProcessMaterials` function in the `outsourcePurchaseController` to handle the logic for fetching materials. - Updated the `outsourcePurchaseRoutes` to include the new route for process materials. - Enhanced the `RegistrationModal` component to toggle material needs and automatically fill materials when required. (TASK:ERP-019) --- .../outsourcePurchaseController.ts | 26 ++ .../processWorkStandardController.ts | 32 ++- .../controllers/workInstructionController.ts | 28 +- .../src/routes/outsourcePurchaseRoutes.ts | 3 + .../src/services/outsourcePurchaseService.ts | 154 +++++++++-- .../purchase-order/DetailModal.tsx | 3 +- .../purchase-order/RegistrationModal.tsx | 195 +++++++------- .../purchase-order/ReleaseRequestModal.tsx | 242 +++++++++--------- .../outsourcing/purchase-order/page.tsx | 3 + frontend/lib/api/outsourcePurchase.ts | 24 +- 10 files changed, 467 insertions(+), 243 deletions(-) diff --git a/backend-node/src/controllers/outsourcePurchaseController.ts b/backend-node/src/controllers/outsourcePurchaseController.ts index d86f5420..a43d5310 100644 --- a/backend-node/src/controllers/outsourcePurchaseController.ts +++ b/backend-node/src/controllers/outsourcePurchaseController.ts @@ -182,6 +182,32 @@ export async function autoProcesses( } } +// ───────────────────────────────────────────────────────────────────────────── +// 공정 자재투입(material_input) 자동 채움 +// — 사급자재 체크 시 해당 공정의 자재 목록을 외주발주 자재 형식으로 반환 +// ───────────────────────────────────────────────────────────────────────────── +export async function getProcessMaterials( + req: AuthenticatedRequest, + res: Response, +) { + try { + const companyCode = req.user!.companyCode; + const workOrderId = req.query.work_order_id as string | undefined; + const routingDetailId = req.query.routing_detail_id as string | undefined; + if (!routingDetailId) { + return fail(res, 400, "routing_detail_id는 필수입니다"); + } + const data = await svc.getProcessMaterialInputs( + companyCode, + workOrderId, + routingDetailId, + ); + return ok(res, data); + } catch (e: any) { + return fail(res, 500, e?.message || "공정 자재투입 조회 실패", e); + } +} + // ───────────────────────────────────────────────────────────────────────────── // 외주발주 가능 작업지시 목록 // (TASK:ERP-019 재구현 — 좌측 리스트 필터) diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts index 19152d4d..07e1a011 100644 --- a/backend-node/src/controllers/processWorkStandardController.ts +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -466,6 +466,7 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons selected_bom_items, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, + bom_item_id, bom_item_name, bom_qty, bom_unit, created_date FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2 @@ -499,6 +500,8 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo // 설비조건(equip_condition) 전용 5개 필드 — TASK:ERP-015 condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, + // 자재투입(material_input) 전용 4필드 — 외주발주 사급자재 자동 채움 연동 + bom_item_id, bom_item_name, bom_qty, bom_unit, } = req.body; if (!work_item_id || !content) { @@ -524,9 +527,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo duration_minutes, input_type, lookup_target, display_fields, selected_bom_items, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, - condition_auto_collect, condition_plc_data) + condition_auto_collect, condition_plc_data, + bom_item_id, bom_item_name, bom_qty, bom_unit) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, - $21, $22, $23, $24, $25) + $21, $22, $23, $24, $25, $26, $27, $28, $29) RETURNING * `; @@ -559,6 +563,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo condition_tolerance || null, condition_auto_collect || null, condition_plc_data || null, + bom_item_id || null, + bom_item_name || null, + bom_qty != null && bom_qty !== "" ? String(bom_qty) : null, + bom_unit || null, ]); logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id }); @@ -588,6 +596,8 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo // 설비조건(equip_condition) 전용 5개 필드 — TASK:ERP-015 condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, + // 자재투입(material_input) 전용 4필드 — 외주발주 사급자재 자동 채움 연동 + bom_item_id, bom_item_name, bom_qty, bom_unit, } = req.body; const bomItemsJson = Array.isArray(selected_bom_items) ? JSON.stringify(selected_bom_items) : selected_bom_items ?? null; @@ -616,6 +626,10 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo condition_tolerance = $22, condition_auto_collect = $23, condition_plc_data = $24, + bom_item_id = $25, + bom_item_name = $26, + bom_qty = $27, + bom_unit = $28, updated_date = NOW() WHERE id = $6 AND company_code = $7 RETURNING * @@ -646,6 +660,10 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo condition_tolerance ?? null, condition_auto_collect ?? null, condition_plc_data ?? null, + bom_item_id ?? null, + bom_item_name ?? null, + bom_qty != null && bom_qty !== "" ? String(bom_qty) : null, + bom_unit ?? null, ]); if (result.rowCount === 0) { @@ -762,9 +780,10 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, condition_unit, condition_base_value, condition_tolerance, - condition_auto_collect, condition_plc_data) + condition_auto_collect, condition_plc_data, + bom_item_id, bom_item_name, bom_qty, bom_unit) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, - $18, $19, $20, $21, $22)`, + $18, $19, $20, $21, $22, $23, $24, $25, $26)`, [ companyCode, workItemId, @@ -789,6 +808,11 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { detail.condition_tolerance || null, detail.condition_auto_collect || null, detail.condition_plc_data || null, + // 자재투입(material_input) 전용 4필드 — 외주발주 사급자재 자동 채움 연동 + detail.bom_item_id || null, + detail.bom_item_name || null, + detail.bom_qty != null && detail.bom_qty !== "" ? String(detail.bom_qty) : null, + detail.bom_unit || null, ] ); } diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index b60c6811..f5c7ee9a 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -668,7 +668,8 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response) duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, - condition_auto_collect, condition_plc_data + condition_auto_collect, condition_plc_data, + bom_item_id, bom_item_name, bom_qty, bom_unit FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2 ORDER BY sort_order`, @@ -695,7 +696,8 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response) duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, - condition_auto_collect, condition_plc_data + condition_auto_collect, condition_plc_data, + bom_item_id, bom_item_name, bom_qty, bom_unit FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2 ORDER BY sort_order`, @@ -751,7 +753,7 @@ async function syncMasterChecklistFromWi( // 3. 접수 건수 확인 const acceptCount = await client.query( `SELECT COUNT(*)::int AS cnt FROM work_order_process_result wopr - JOIN work_order_process wop ON wop.id = wopr.work_order_process_id + JOIN work_order_process wop ON wop.id = wopr.wop_id WHERE wop.wo_id = $1 AND wop.company_code = $2 AND wopr.company_code = $2`, [wiId, companyCode], ); @@ -850,9 +852,9 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) for (const origDetail of origDetails.rows) { await client.query( - `INSERT INTO wi_process_work_item_detail (id, 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, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, writer) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`, - [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, origDetail.condition_unit || null, origDetail.condition_base_value || null, origDetail.condition_tolerance || null, origDetail.condition_auto_collect || null, origDetail.condition_plc_data || null, userId] + `INSERT INTO wi_process_work_item_detail (id, 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, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, bom_item_id, bom_item_name, bom_qty, bom_unit, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)`, + [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, origDetail.condition_unit || null, origDetail.condition_base_value || null, origDetail.condition_tolerance || null, origDetail.condition_auto_collect || null, origDetail.condition_plc_data || null, origDetail.bom_item_id || null, origDetail.bom_item_name || null, origDetail.bom_qty || null, origDetail.bom_unit || null, userId] ); } } @@ -919,9 +921,9 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) if (wi.details && Array.isArray(wi.details)) { for (const d of wi.details) { await client.query( - `INSERT INTO wi_process_work_item_detail (id, 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, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, writer) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`, - [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, d.condition_unit || null, d.condition_base_value || null, d.condition_tolerance || null, d.condition_auto_collect || null, d.condition_plc_data || null, userId] + `INSERT INTO wi_process_work_item_detail (id, 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, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, bom_item_id, bom_item_name, bom_qty, bom_unit, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)`, + [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, d.condition_unit || null, d.condition_base_value || null, d.condition_tolerance || null, d.condition_auto_collect || null, d.condition_plc_data || null, d.bom_item_id || null, d.bom_item_name || null, d.bom_qty != null && d.bom_qty !== "" ? String(d.bom_qty) : null, d.bom_unit || null, userId] ); } } @@ -939,7 +941,13 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) client.release(); } } catch (error: any) { - logger.error("작업지시 공정작업기준 저장 실패", { error: error.message }); + logger.error("작업지시 공정작업기준 저장 실패", { + message: error?.message, + code: error?.code, + detail: error?.detail, + where: error?.where, + stack: error?.stack, + }); return res.status(500).json({ success: false, message: error.message }); } } diff --git a/backend-node/src/routes/outsourcePurchaseRoutes.ts b/backend-node/src/routes/outsourcePurchaseRoutes.ts index 0d3999f1..72486d00 100644 --- a/backend-node/src/routes/outsourcePurchaseRoutes.ts +++ b/backend-node/src/routes/outsourcePurchaseRoutes.ts @@ -14,6 +14,9 @@ router.use(authenticateToken); // 헬퍼 (정적 경로 — :id 라우트보다 위에) router.get("/auto-processes", ctrl.autoProcesses); +// 공정 자재투입 자동 채움 (사급자재 체크 시) +router.get("/process-materials", ctrl.getProcessMaterials); + // 외주발주 가능 작업지시 목록 (좌측 리스트 필터, TASK:ERP-019 재구현) router.get("/outsourceable-work-orders", ctrl.listOutsourceableWorkOrders); diff --git a/backend-node/src/services/outsourcePurchaseService.ts b/backend-node/src/services/outsourcePurchaseService.ts index 5539c8f9..b331601b 100644 --- a/backend-node/src/services/outsourcePurchaseService.ts +++ b/backend-node/src/services/outsourcePurchaseService.ts @@ -30,7 +30,8 @@ export interface OPOProcessInput { seq: number; process_code?: string; process_name?: string; - vendor_code?: string; + vendor_id?: string; // subcontractor_mng.id (그룹핑 키) + vendor_code?: string; // 표시·하위호환용 vendor_name?: string; material_needed?: boolean; remark?: string; @@ -271,8 +272,8 @@ export async function createOrder( const pr = await client.query( `INSERT INTO outsource_purchase_order_process ( opo_id, company_code, seq, process_code, process_name, - vendor_code, vendor_name, material_needed, remark - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + vendor_id, vendor_code, vendor_name, material_needed, remark + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, [ master.id, @@ -280,6 +281,7 @@ export async function createOrder( proc.seq, proc.process_code || null, proc.process_name || null, + proc.vendor_id || null, proc.vendor_code || null, proc.vendor_name || null, proc.material_needed === true, @@ -452,8 +454,8 @@ export async function updateOrder( const pr = await client.query( `INSERT INTO outsource_purchase_order_process ( opo_id, company_code, seq, process_code, process_name, - vendor_code, vendor_name, material_needed, remark - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + vendor_id, vendor_code, vendor_name, material_needed, remark + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, [ id, @@ -461,6 +463,7 @@ export async function updateOrder( proc.seq, proc.process_code || null, proc.process_name || null, + proc.vendor_id || null, proc.vendor_code || null, proc.vendor_name || null, proc.material_needed === true, @@ -562,7 +565,7 @@ export async function requestRelease( `SELECT mm.id, mm.opo_process_id, mm.item_code, mm.item_name, mm.qty, mm.unit, mm.release_status, - pp.opo_id, pp.process_name, pp.vendor_code, pp.vendor_name, + pp.opo_id, pp.process_name, pp.vendor_id, pp.vendor_code, pp.vendor_name, oo.order_no, oo.spec, oo.material AS opo_material FROM outsource_purchase_order_material mm JOIN outsource_purchase_order_process pp ON pp.id = mm.opo_process_id @@ -578,22 +581,30 @@ export async function requestRelease( throw new Error("선택한 자재 중 출고요청 가능한 항목이 없습니다 (이미 요청됐거나 권한 없음)"); } - // 2) OPO별로 그룹핑하여 outbound_mng 1건씩 INSERT - const byOpo = new Map(); + // 2) (OPO, 외주사) 단위로 그룹핑하여 outbound_mng 1건씩 INSERT + // └─ 외주사 = 자재가 가는 곳. 외주사 다르면 출고전표·납품처가 분리되어야 함. + // └─ 그룹핑 키는 외주사 마스터 PK(vendor_id) — 코드(vendor_code)는 사람이 바꿀 수 있어 정합성 위험. + // └─ 외주사 미지정(vendor_id 비어있음)은 별도 그룹으로 묶어 출고요청. POP 화면에서 외주사 선택하여 출고 처리. + const byOpoVendor = new Map(); for (const m of mats.rows) { - if (!byOpo.has(m.opo_id)) byOpo.set(m.opo_id, []); - byOpo.get(m.opo_id)!.push(m); + const vid = m.vendor_id || "__unassigned__"; + const k = `${m.opo_id}::${vid}`; + if (!byOpoVendor.has(k)) byOpoVendor.set(k, []); + byOpoVendor.get(k)!.push(m); } const outboundIds: string[] = []; const updatedMaterialIds: string[] = []; - for (const [opoId, group] of byOpo.entries()) { + for (const [k, group] of byOpoVendor.entries()) { + const opoId = k.split("::")[0]; const head = group[0]; const orderNo = head.order_no; + const vendorAssigned = !!head.vendor_id; + const vendorLabel = vendorAssigned ? (head.vendor_name || "외주사") : "외주사 미지정"; const today = new Date().toISOString().slice(0, 10); - // outbound_mng INSERT — 사급출고 + // outbound_mng INSERT — 사급출고 (외주사별 1건, 미지정은 별도 1건) const ob = await client.query( `INSERT INTO outbound_mng ( id, company_code, outbound_number, outbound_type, @@ -613,7 +624,7 @@ export async function requestRelease( today, orderNo, userId, - payload.memo || `외주발주 ${orderNo} 사급자재 출고요청`, + payload.memo || `외주발주 ${orderNo} 사급자재 출고요청 (${vendorLabel})`, payload.warehouse_code || null, ] ); @@ -784,7 +795,7 @@ async function fetchWorkOrderProcessesWithStatus( ) wopr ON true LEFT JOIN LATERAL ( SELECT jsonb_agg( - jsonb_build_object('code', sm.subcontractor_code, 'name', sm.subcontractor_name) + jsonb_build_object('id', sm.id, 'code', sm.subcontractor_code, 'name', sm.subcontractor_name) ORDER BY irs.seq_order ) AS vendor_options FROM item_routing_subcontractor irs @@ -890,7 +901,7 @@ export async function autoSelectProcesses( let routing: any[] = []; try { const r = await pool.query( - `SELECT rd.id, rd.seq_no, rd.process_code, rd.work_type, rd.execution_type, + `SELECT rd.id, rd.id AS routing_detail_id, rd.seq_no, rd.process_code, rd.work_type, rd.execution_type, rd.outsource_supplier, rd.standard_time, pm.process_name, COALESCE(vend.vendor_options, '[]'::jsonb) AS vendor_options @@ -1007,3 +1018,116 @@ export async function listOutsourceableWorkOrders( const sliced = matched.slice(offset, offset + size); return { rows: sliced, total, page, size }; } + +// ───────────────────────────────────────────────────────────────────────────── +// 공정 자재투입(material_input) 목록 조회 — 외주발주 사급자재 자동 채움 +// +// 우선순위: +// 1. 작업지시 커스텀 (wi_process_work_item / wi_process_work_item_detail) +// — work_instruction_no + routing_detail_id 키 +// 2. 마스터 (process_work_item / process_work_item_detail) +// — routing_detail_id 키 +// +// 응답: bom_* 4컬럼 → 외주발주 자재 입력 형식으로 매핑 +// { item_code, item_name, qty, unit } +// item_code 우선순위: item_info.item_number(또는 item_code) > bom_item_id > 빈값 +// ───────────────────────────────────────────────────────────────────────────── +export async function getProcessMaterialInputs( + companyCode: string, + workOrderId: string | undefined, + routingDetailId: string, +) { + const pool = getPool(); + if (!routingDetailId) return []; + + // 1) 작업지시 커스텀 우선 — work_order_id가 있으면 work_instruction_no 조회 + let workInstructionNo: string | null = null; + if (workOrderId) { + const wi = await pool.query( + `SELECT work_instruction_no FROM work_instruction + WHERE id = $1 AND company_code = $2 LIMIT 1`, + [workOrderId, companyCode], + ); + workInstructionNo = wi.rows[0]?.work_instruction_no || null; + } + + // wi_* 우선 — "커스텀 작업기준이 존재하기만 하면" 그 결과를 따른다 (자재투입 0건이어도 마스터로 폴백 금지) + if (workInstructionNo) { + // 1-A. 커스텀 작업기준 존재 여부 (work_item 자체) + const wiExists = await pool.query( + `SELECT 1 FROM wi_process_work_item + WHERE company_code = $1 AND work_instruction_no = $2 AND routing_detail_id = $3 + LIMIT 1`, + [companyCode, workInstructionNo, routingDetailId], + ); + if (wiExists.rowCount && wiExists.rowCount > 0) { + // 1-B. 자재투입 detail 조회 (0건이면 빈 배열 반환 → 사용자가 의도적으로 뺀 케이스 보호) + const wiRes = await pool.query( + `SELECT wid.bom_item_id, wid.bom_item_name, wid.bom_qty, wid.bom_unit, wid.content + FROM wi_process_work_item wpwi + JOIN wi_process_work_item_detail wid + ON wid.wi_work_item_id = wpwi.id AND wid.company_code = wpwi.company_code + WHERE wpwi.company_code = $1 + AND wpwi.work_instruction_no = $2 + AND wpwi.routing_detail_id = $3 + AND wid.detail_type = 'material_input' + ORDER BY wpwi.sort_order, wid.sort_order`, + [companyCode, workInstructionNo, routingDetailId], + ); + return await mapToOpoMaterials(pool, companyCode, wiRes.rows); + } + } + + // 2) 마스터 폴백 — 커스텀 작업기준 자체가 없는 경우에만 + const masterRes = await pool.query( + `SELECT pwd.bom_item_id, pwd.bom_item_name, pwd.bom_qty, pwd.bom_unit, pwd.content + FROM process_work_item pwi + JOIN process_work_item_detail pwd + ON pwd.work_item_id = pwi.id AND pwd.company_code = pwi.company_code + WHERE pwi.company_code = $1 + AND pwi.routing_detail_id = $2 + AND pwd.detail_type = 'material_input' + ORDER BY pwi.sort_order, pwd.sort_order`, + [companyCode, routingDetailId], + ); + return await mapToOpoMaterials(pool, companyCode, masterRes.rows); +} + +/** bom_* 4필드 → 외주발주 자재 입력 형식으로 매핑. + * bom_item_id로 item_info 조회하여 item_code/item_name 보강. */ +async function mapToOpoMaterials( + pool: any, + companyCode: string, + rows: any[], +): Promise> { + if (rows.length === 0) return []; + + // bom_item_id가 있는 row의 item_info 일괄 조회 (id 또는 item_code/item_number 매칭) + const ids = rows.map((r) => r.bom_item_id).filter((v) => v != null && v !== ""); + let infoMap = new Map(); + if (ids.length > 0) { + const ph = ids.map((_, i) => `$${i + 2}`).join(","); + const r = await pool.query( + `SELECT id, item_number AS item_code, item_name + FROM item_info + WHERE company_code = $1 + AND id IN (${ph})`, + [companyCode, ...ids], + ); + for (const row of r.rows) { + infoMap.set(row.id, { item_code: row.item_code || "", item_name: row.item_name || "" }); + } + } + + return rows + .filter((r) => r.bom_item_name || r.bom_item_id) // 안내문 row 스킵 + .map((r) => { + const info = r.bom_item_id ? infoMap.get(r.bom_item_id) : undefined; + return { + item_code: info?.item_code || r.bom_item_id || "", + item_name: info?.item_name || r.bom_item_name || "", + qty: r.bom_qty != null && r.bom_qty !== "" ? String(r.bom_qty) : "", + unit: r.bom_unit || "", + }; + }); +} diff --git a/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/DetailModal.tsx b/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/DetailModal.tsx index 47f68a1e..9240b679 100644 --- a/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/DetailModal.tsx +++ b/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/DetailModal.tsx @@ -12,6 +12,7 @@ import { Loader2, Inbox } from "lucide-react"; import { toast } from "sonner"; import { getOutsourcePurchaseOrder, type OPODetail } from "@/lib/api/outsourcePurchase"; +import { toLocalDateTime } from "@/lib/utils/localDate"; interface DetailModalProps { open: boolean; @@ -109,7 +110,7 @@ export function DetailModal({ open, onOpenChange, opoId }: DetailModalProps) { - + {detail.memo && (
diff --git a/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/RegistrationModal.tsx b/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/RegistrationModal.tsx index dcbc99f9..ccb78e62 100644 --- a/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/RegistrationModal.tsx +++ b/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/RegistrationModal.tsx @@ -31,7 +31,6 @@ import { Search as SearchIcon, Trash2, X, - Wand2, Package, } from "lucide-react"; import { toast } from "sonner"; @@ -46,6 +45,7 @@ import { updateOutsourcePurchaseOrder, getOutsourcePurchaseOrder, getAutoProcesses, + getProcessMaterials, listSubcontractors, listOutsourceableWorkOrders, type OPOInputPayload, @@ -99,7 +99,8 @@ interface SelectedItemEntry { } interface SubcontractorOption { - code: string; + id: string; // subcontractor_mng.id (그룹핑·저장 키) + code: string; // 표시용 name: string; } @@ -208,10 +209,11 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis if (!alive) return; const opts: SubcontractorOption[] = rows .map((r: any) => ({ - code: r.subcontractor_code || r.code || r.id, - name: r.subcontractor_name || r.name || r.subcontractor_code, + id: r.id || "", + code: r.subcontractor_code || r.code || r.id || "", + name: r.subcontractor_name || r.name || r.subcontractor_code || "", })) - .filter((o: SubcontractorOption) => o.code && o.name); + .filter((o: SubcontractorOption) => o.id && o.name); setVendors(opts); } catch { // 외주사 마스터 미존재 시 무시 @@ -744,20 +746,24 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis return; } const processes: OPOProcess[] = candidates.map((c, i) => { - const opts: { code: string; name: string }[] = Array.isArray((c as any).vendor_options) - ? (c as any).vendor_options.filter((o: any) => o?.code && o?.name) + const opts: { id: string; code: string; name: string }[] = Array.isArray((c as any).vendor_options) + ? (c as any).vendor_options.filter((o: any) => o?.id && o?.name) : []; // 외주사 후보가 단 1곳이면 자동 선택, 여러 곳이면 비워두고 사용자 선택 유도 const auto = opts.length === 1 ? opts[0] : null; + // routing_detail_id: work_order 모드는 wop.routing_detail_id, item_routing 폴백은 rd.id + const rdid = (c as any).routing_detail_id || (c as any).id || ""; return { seq: i + 1, process_code: c.process_code, process_name: c.process_name || c.process_code, + vendor_id: auto?.id || "", vendor_code: auto?.code || "", vendor_name: auto?.name || "", material_needed: false, materials: [], vendor_options: opts, + routing_detail_id: rdid, }; }); setSelected((prev) => @@ -773,50 +779,6 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis [], ); - const autoFillProcesses = async (key: string) => { - const entry = selected.find((s) => s.key === key); - if (!entry) return; - if (!entry.work_order_id && !entry.item_code) { - toast.error("작업지시 또는 품목 코드가 없어 자동표기를 진행할 수 없어요"); - return; - } - await runAutoFillForKey(key, { - workOrderId: entry.work_order_id, - itemCode: entry.item_code, - silent: false, - }); - }; - - const clearProcesses = (key: string) => { - setSelected((prev) => - prev.map((s) => (s.key === key ? { ...s, processes: [] } : s)), - ); - }; - - const addManualProcess = (key: string) => { - setSelected((prev) => - prev.map((s) => { - if (s.key !== key) return s; - const nextSeq = s.processes.length + 1; - return { - ...s, - processes: [ - ...s.processes, - { - seq: nextSeq, - process_code: "", - process_name: `공정 ${nextSeq}`, - vendor_code: "", - vendor_name: "", - material_needed: false, - materials: [], - }, - ], - }; - }), - ); - }; - const removeProcess = (key: string, seq: number) => { setSelected((prev) => prev.map((s) => @@ -845,9 +807,65 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis ); }; - const updateProcessVendor = (key: string, seq: number, vendorCode: string) => { - const v = vendors.find((vv) => vv.code === vendorCode); - updateProcess(key, seq, { vendor_code: vendorCode, vendor_name: v?.name || "" }); + const updateProcessVendor = (key: string, seq: number, vendorId: string) => { + // 우선순위: 공정의 vendor_options(라우팅 매핑) → 전체 vendors 마스터 + const entry = selected.find((s) => s.key === key); + const proc = entry?.processes.find((p) => p.seq === seq); + const fromOptions = proc?.vendor_options?.find((o) => o.id === vendorId); + const fromMaster = vendors.find((vv) => vv.id === vendorId); + const v = fromOptions || fromMaster; + updateProcess(key, seq, { + vendor_id: vendorId, + vendor_code: v?.code || "", + vendor_name: v?.name || "", + }); + }; + + // 사급자재 체크박스 토글 — ON이면 공정작업기준 material_input을 자재 목록에 자동 채움 + const toggleMaterialNeeded = async (key: string, seq: number, checked: boolean) => { + if (!checked) { + updateProcess(key, seq, { material_needed: false }); + return; + } + const entry = selected.find((s) => s.key === key); + const proc = entry?.processes.find((p) => p.seq === seq); + if (!proc) return; + + // 이미 자재가 있으면 덮어쓰지 않고 체크만 ON + if ((proc.materials || []).length > 0) { + updateProcess(key, seq, { material_needed: true }); + return; + } + if (!proc.routing_detail_id) { + updateProcess(key, seq, { material_needed: true }); + toast.warning("라우팅 정보가 없어 자재 자동 채움 불가. [+ 자재] 버튼으로 직접 추가해주세요."); + return; + } + try { + const mats = await getProcessMaterials({ + workOrderId: entry?.work_order_id, + routingDetailId: proc.routing_detail_id, + }); + if (mats.length === 0) { + updateProcess(key, seq, { material_needed: true }); + toast.info("이 공정에 등록된 자재투입 항목이 없어요. 직접 추가해주세요."); + return; + } + updateProcess(key, seq, { + material_needed: true, + materials: mats.map((m) => ({ + item_code: m.item_code, + item_name: m.item_name, + qty: m.qty, + unit: m.unit, + release_status: "대기", + })), + }); + toast.success(`자재 ${mats.length}건 자동 채움 완료`); + } catch (e: any) { + updateProcess(key, seq, { material_needed: true }); + toast.error(e?.message || "자재 자동 채움 실패"); + } }; // ───────────────────────────────────────────────────────────────────────── @@ -1281,12 +1299,10 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis setExpanded((p) => ({ ...p, [entry.key]: !p[entry.key] })) } vendors={vendors} - onAutoFill={() => autoFillProcesses(entry.key)} - onClearProcesses={() => clearProcesses(entry.key)} - onAddProcess={() => addManualProcess(entry.key)} onRemoveProcess={(seq) => removeProcess(entry.key, seq)} onUpdateProcess={(seq, patch) => updateProcess(entry.key, seq, patch)} onUpdateVendor={(seq, code) => updateProcessVendor(entry.key, seq, code)} + onToggleMaterialNeeded={(seq, checked) => toggleMaterialNeeded(entry.key, seq, checked)} onAddMaterial={(seq) => addMaterialToProcess(entry.key, seq)} onUpdateMaterial={(seq, idx, patch) => updateMaterial(entry.key, seq, idx, patch)} onRemoveMaterial={(seq, idx) => removeMaterial(entry.key, seq, idx)} @@ -1453,12 +1469,10 @@ function ItemAccordion({ expanded, onToggle, vendors, - onAutoFill, - onClearProcesses, - onAddProcess, onRemoveProcess, onUpdateProcess, onUpdateVendor, + onToggleMaterialNeeded, onAddMaterial, onUpdateMaterial, onRemoveMaterial, @@ -1468,12 +1482,10 @@ function ItemAccordion({ expanded: boolean; onToggle: () => void; vendors: SubcontractorOption[]; - onAutoFill: () => void; - onClearProcesses: () => void; - onAddProcess: () => void; onRemoveProcess: (seq: number) => void; onUpdateProcess: (seq: number, patch: Partial) => void; onUpdateVendor: (seq: number, vendorCode: string) => void; + onToggleMaterialNeeded: (seq: number, checked: boolean) => void; onAddMaterial: (seq: number) => void; onUpdateMaterial: (seq: number, idx: number, patch: any) => void; onRemoveMaterial: (seq: number, idx: number) => void; @@ -1499,15 +1511,6 @@ function ItemAccordion({
- - -
- 선택된 외주발주의 사급자재 중 출고가 필요한 항목만 표시됩니다. 출고요청 시 해당 발주의 상태가 - 출고요청 - 으로 변경되고 출고관리 시스템에 [사급출고] 유형으로 등록됩니다. + 선택한 외주발주의 사급자재(대기 상태)를 외주사별로 자동 그룹핑하여 출고요청합니다. 외주사 1곳당 출고전표 1건이 + 사급출고 + 유형으로 출고관리에 등록됩니다. -
- - - 외주사 미지정 자재({stats.missing}건)는 기본 체크 해제 상태입니다. 외주사 지정 후 요청을 권장합니다. - -
- {loading ? (
@@ -178,79 +176,95 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }: 출고요청 가능한 사급자재가 없어요
) : ( -
- - - - - - - - - - - - - - {rows.map((r) => { - const isChecked = checked.has(r.material_id); - return ( - - - - - - - - - - ); - })} - -
- - 외주발주품목공정외주사사급자재필요수량
- toggleOne(r.material_id)} /> - {r.order_no} -
{r.item_code}
-
{r.item_name}
-
{r.process_name} - {r.vendor_missing ? ( - - 미지정 - - ) : ( - {r.vendor_name} - )} - -
{r.material_item_code}
-
{r.material_item_name}
-
- {r.qty.toLocaleString()} {r.unit} -
+
+ {vendorGroups.map((g) => { + const isUnassigned = g.vendor_id === UNASSIGNED_KEY; + return ( +
+
+
+ {isUnassigned ? ( + + ) : ( + + )} + + {g.vendor_name} + + + 자재 {g.rows.length}건 + +
+ + {isUnassigned ? "POP 출고 시 외주사 선택 필요" : "출고전표 1건 생성 예정"} + +
+
+ {g.rows.map((r) => ( +
+ +
{r.order_no}
+
{r.process_name}
+
+ {r.material_item_code}{" "} + {r.material_item_name} +
+
+ {r.qty.toLocaleString()} {r.unit} +
+
+ ))} +
+
+ ); + })}
)}
- 총 {stats.total}건 중 {stats.sel}건 선택 + {stats.materials > 0 ? ( + + 출고전표 {stats.vendors}건 · 자재{" "} + {stats.materials}건 + + ) : null}
-
diff --git a/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/page.tsx b/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/page.tsx index 8b24fe99..ba7d44ef 100644 --- a/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/page.tsx @@ -24,6 +24,7 @@ import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { toLocalDateTime } from "@/lib/utils/localDate"; import { listOutsourcePurchaseOrders, @@ -158,6 +159,8 @@ export default function OutsourcePurchaseOrderPage() { ? `${processVendor} 외 ${(r.process_count || 0) - 1}건` : processVendor, material_status_label: matLabel, + // 생성일 — UTC ISO를 브라우저 로컬 시각(KST 사용자 기준)으로 표시 + created_date: r.created_date ? toLocalDateTime(new Date(r.created_date)) : "", }; }); }, [rows]); diff --git a/frontend/lib/api/outsourcePurchase.ts b/frontend/lib/api/outsourcePurchase.ts index ad735da9..ed5d96fe 100644 --- a/frontend/lib/api/outsourcePurchase.ts +++ b/frontend/lib/api/outsourcePurchase.ts @@ -38,13 +38,18 @@ export interface OPOProcess { seq: number; process_code?: string; process_name?: string; + /** subcontractor_mng.id — 그룹핑·출고요청 키 */ + vendor_id?: string; + /** 표시·하위호환용. 그룹핑 키로 사용하지 말 것 (사람이 바꿀 수 있음) */ vendor_code?: string; vendor_name?: string; material_needed?: boolean; remark?: string; materials?: OPOMaterial[]; /** 라우팅에 매핑된 외주사 후보 (드롭다운을 이 목록으로 제한) */ - vendor_options?: { code: string; name: string }[]; + vendor_options?: { id: string; code: string; name: string }[]; + /** item_routing_detail.id (사급자재 자동 채움 호출에 사용) */ + routing_detail_id?: string; } export interface OPOMaster { @@ -244,6 +249,23 @@ export async function getAutoProcesses( return res.data?.data || { source: "none", routing: [], candidates: [] }; } +/** + * 공정 자재투입(material_input) 자동 채움 — 사급자재 체크 시 호출 + * 응답: 외주발주 자재 입력 형식 배열 [{item_code, item_name, qty, unit}] + */ +export async function getProcessMaterials( + args: { workOrderId?: string; routingDetailId: string }, +): Promise> { + if (!args.routingDetailId) return []; + const params: Record = { routing_detail_id: args.routingDetailId }; + if (args.workOrderId) params.work_order_id = args.workOrderId; + const res = await apiClient.get>>( + "/outsource-purchase/process-materials", + { params }, + ); + return res.data?.data || []; +} + /** * 외주발주 가능 작업지시 목록 (TASK:ERP-019 재구현) * "이전 공정 완료 + 다음 공정 외주/선택가능" 작업지시만 반환