diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 8a25e4cb..172fefd6 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -144,7 +144,7 @@ export async function create(req: AuthenticatedRequest, res: Response) { outbound_qty, unit_price, total_amount, lot_number, warehouse_code, location_code, outbound_status, manager_id, memo, - source_type, sales_order_id, shipment_plan_id, item_info_id, + source_table, source_id, destination_code, delivery_destination, delivery_address, created_date, created_by, writer, status ) VALUES ( @@ -154,9 +154,9 @@ export async function create(req: AuthenticatedRequest, res: Response) { $13, $14, $15, $16, $17, $18, $19, $20, $21, - $22, $23, $24, $25, - $26, $27, $28, - NOW(), $29, $29, '출고' + $22, $23, + $24, $25, $26, + NOW(), $27, $27, '출고' ) RETURNING *`, [ companyCode, @@ -180,10 +180,8 @@ export async function create(req: AuthenticatedRequest, res: Response) { item.outbound_status || "대기", manager_id || item.manager_id || null, memo || item.memo || null, - item.source_type || item.source_table || null, - item.sales_order_id || null, - item.shipment_plan_id || null, - item.item_info_id || null, + item.source_table || null, + item.source_id || null, item.destination_code || null, item.delivery_destination || null, item.delivery_address || null, @@ -258,7 +256,7 @@ export async function create(req: AuthenticatedRequest, res: Response) { } // 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영 + master status 자동 전환 - const itemSourceTable = item.source_type || item.source_table; + const itemSourceTable = item.source_table; if ( item.outbound_type === "판매출고" && item.source_id && diff --git a/backend-node/src/controllers/outsourcingOutboundController.ts b/backend-node/src/controllers/outsourcingOutboundController.ts index 75582afd..4980b82c 100644 --- a/backend-node/src/controllers/outsourcingOutboundController.ts +++ b/backend-node/src/controllers/outsourcingOutboundController.ts @@ -107,7 +107,7 @@ export async function getCandidates(req: AuthenticatedRequest, res: Response) { AND NOT EXISTS ( SELECT 1 FROM outbound_mng om WHERE om.outbound_type = '외주출고' - AND om.source_type = 'work_order_process' + AND om.source_table = 'work_order_process' AND om.source_id = wop_done.id ${companyCode !== "*" ? "AND om.company_code = $1" : ""} ) @@ -231,7 +231,7 @@ export async function create(req: AuthenticatedRequest, res: Response) { outbound_qty, unit_price, total_amount, warehouse_code, location_code, outbound_status, manager_id, memo, - source_type, source_id, + source_table, source_id, created_date, created_by, writer, status ) VALUES ( gen_random_uuid()::text, $1, $2, '외주출고', $3, diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 476e0075..0978e0e8 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -3,6 +3,34 @@ import type { Pool, PoolClient } from "pg"; import { getPool } from "../database/db"; import type { AuthenticatedRequest } from "../middleware/authMiddleware"; import logger from "../utils/logger"; +import { + onAcceptCancelled, + onProcessAccept, + onProcessCompleted, + onProcessMove, + onResultSaved, +} from "../services/wipStockService"; + +/** + * user_id → user_name(한글명) 조회 헬퍼 — wip_stock_history.manager_name 기록용. + * 조회 실패 시 user_id 를 fallback 으로 반환. + */ +async function resolveUserName( + exec: { query: (text: string, values?: any[]) => Promise }, + userId: string, + companyCode: string, +): Promise { + try { + const r = await exec.query( + `SELECT COALESCE(NULLIF(user_name, ''), user_id) AS user_name + FROM user_info WHERE user_id = $1 AND company_code = $2 LIMIT 1`, + [userId, companyCode], + ); + return r.rows[0]?.user_name || userId; + } catch { + return userId; + } +} // 불량 상세 항목 타입 interface DefectDetailItem { @@ -978,7 +1006,7 @@ export const controlTimer = async ( updated_date = NOW() WHERE id = $1 AND company_code = $2 AND status != 'completed' - RETURNING id, status, completed_at, completed_by, actual_work_time, good_qty, defect_qty`, + RETURNING id, status, completed_at, completed_by, actual_work_time, good_qty, defect_qty, wop_id, equipment_code`, [ work_order_process_id, companyCode, @@ -999,6 +1027,26 @@ export const controlTimer = async ( }); } + // [WIP 적재 — 트리거 4] 타이머 완료(complete) 시 wip_stock status='completed' 전이 + if (action === "complete" && result.rows[0]?.wop_id) { + try { + const row = result.rows[0]; + const managerName = await resolveUserName(pool, userId, companyCode); + await onProcessCompleted( + pool, + companyCode, + row.wop_id, + work_order_process_id, + userId, + row.equipment_code || null, + managerName, + "타이머 완료(상태 전이)", + ); + } catch (wipErr) { + logger.error("[pop/production] WIP 적재 오류 (타이머 완료는 유지):", wipErr); + } + } + return res.json({ success: true, data: result.rows[0], @@ -1527,6 +1575,36 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { await checkAndCompleteWorkInstruction(client, csWoId, companyCode, userId); } + // [WIP 적재 — 트리거 2] 실적 저장분(양품/불량 증분)을 wip_stock 에 반영 + // SAVEPOINT 로 감싼다: 공유 트랜잭션(client)에서 WIP 쿼리가 throw 하면 + // PG 가 트랜잭션 전체를 aborted 로 만들어 이후 COMMIT 이 ROLLBACK 처리된다. + // SAVEPOINT 까지만 되돌려 트랜잭션을 건강하게 살려 본작업(실적저장)을 보존한다. + if ((currentSeq.rowCount ?? 0) > 0) { + await client.query("SAVEPOINT wip_save_result"); + try { + const cs = currentSeq.rows[0]; + const managerName = await resolveUserName(client, userId, companyCode); + await onResultSaved( + client, + companyCode, + cs.wop_id, + work_order_process_id, + addGood, + addDefect, + userId, + cs.equipment_code || null, + managerName, + ); + await client.query("RELEASE SAVEPOINT wip_save_result"); + } catch (wipErr) { + // WIP 부분쓰기만 되돌리고 본작업 트랜잭션은 살린다 (데이터는 후속 백필로 보정 가능) + await client + .query("ROLLBACK TO SAVEPOINT wip_save_result") + .catch(() => {}); + logger.error("[pop/production] WIP 적재 오류 (실적 저장은 유지):", wipErr); + } + } + const latestData = await client.query( `SELECT id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status, input_qty, @@ -1808,7 +1886,7 @@ export const confirmResult = async ( writer = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 - RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty, wop_id`, + RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty, wop_id, equipment_code`, [work_order_process_id, companyCode, userId], ); @@ -1819,6 +1897,23 @@ export const confirmResult = async ( }); } + // [WIP 적재 — 트리거 3] 실적 확정 시 wip_stock status='completed' 전이 + try { + const managerName = await resolveUserName(pool, userId, companyCode); + await onProcessCompleted( + pool, + companyCode, + result.rows[0].wop_id, + work_order_process_id, + userId, + result.rows[0].equipment_code || null, + managerName, + "실적 확정(상태 전이)", + ); + } catch (wipErr) { + logger.error("[pop/production] WIP 적재 오류 (실적 확정은 유지):", wipErr); + } + // 작업지시 완료 캐스케이드 const wopLookup = await pool.query( `SELECT wo_id FROM work_order_process WHERE id = $1 AND company_code = $2`, @@ -2358,6 +2453,63 @@ export const acceptProcess = async ( { skipAStrategy: true }, ); + // [WIP 적재 — 트리거 1·6] 공정 접수 시 wip_stock UPSERT + 직전 공정 이동 반영. + // 공유 트랜잭션(client) 이므로 WIP 쿼리가 throw 하면 트랜잭션 전체가 aborted 되어 + // 이후 COMMIT 이 ROLLBACK 처리된다 → SAVEPOINT 로 감싸 본작업(접수)을 보존한다. + // 트리거1·6 을 각각 별도 SAVEPOINT 로 감싸 트리거6 실패가 트리거1 적재를 되돌리지 않게 한다. + const wipManagerName = await resolveUserName(client, userId, companyCode); + + // 트리거 1: 이번 공정 접수분 적재 (input_qty 누적, status='in_progress') + await client.query("SAVEPOINT wip_accept"); + try { + await onProcessAccept( + client, + companyCode, + masterId, + inserted.id, + qty, + userId, + req.body.equipment_code || null, + wipManagerName, + ); + await client.query("RELEASE SAVEPOINT wip_accept"); + } catch (wipErr) { + await client.query("ROLLBACK TO SAVEPOINT wip_accept").catch(() => {}); + logger.error("[pop/production] WIP 적재 오류 (공정 접수는 유지):", wipErr); + } + + // 트리거 6: 후속 공정(seq>최소) 접수면 직전 공정 wip_stock 행에 이동 반영 + if (seqNum > 1) { + await client.query("SAVEPOINT wip_move"); + try { + const prevWopRes = await client.query( + `SELECT id FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 + AND NULLIF(regexp_replace(COALESCE(seq_no, ''), '[^0-9-]', '', 'g'), '')::int < $3 + ORDER BY NULLIF(regexp_replace(COALESCE(seq_no, ''), '[^0-9-]', '', 'g'), '')::int DESC + LIMIT 1`, + [row.wo_id, companyCode, seqNum], + ); + if ((prevWopRes.rowCount ?? 0) > 0) { + await onProcessMove( + client, + companyCode, + prevWopRes.rows[0].id, + masterId, + inserted.id, + qty, + userId, + req.body.equipment_code || null, + wipManagerName, + ); + } + await client.query("RELEASE SAVEPOINT wip_move"); + } catch (wipErr) { + await client.query("ROLLBACK TO SAVEPOINT wip_move").catch(() => {}); + logger.error("[pop/production] WIP 이동 적재 오류 (공정 접수는 유지):", wipErr); + } + } + await client.query("COMMIT"); logger.info("[pop/production] accept-process 접수 완료", { @@ -2420,7 +2572,7 @@ export const cancelAccept = async ( const current = await pool.query( `SELECT wr.id, wr.status, wr.input_qty, wr.total_production_qty, wr.result_status, - wr.wop_id, wr.good_qty, wr.concession_qty, + wr.wop_id, wr.good_qty, wr.concession_qty, wr.equipment_code, wop.wo_id, wop.seq_no, wop.process_name, wop.target_warehouse_id, wop.target_location_code FROM work_order_process_result wr @@ -2538,6 +2690,34 @@ export const cancelAccept = async ( } } + // [WIP 적재 — 트리거 5] 접수 취소 시 wip_stock 미소진 접수분 롤백. + // 공유 트랜잭션(client) 이므로 WIP 쿼리 throw 시 트랜잭션 전체가 aborted 되어 + // 이후 COMMIT 이 ROLLBACK 처리된다 → SAVEPOINT 로 감싸 본작업(접수 취소)을 보존한다. + await client.query("SAVEPOINT wip_cancel"); + try { + const managerName = await resolveUserName(client, userId, companyCode); + await onAcceptCancelled( + client, + companyCode, + proc.wop_id, + work_order_process_id, + cancelledQty, + userId, + totalProduced === 0, // 실적 0 → result 행 전체 삭제(fullRemove) + proc.equipment_code || null, + managerName, + ); + await client.query("RELEASE SAVEPOINT wip_cancel"); + } catch (wipErr) { + await client + .query("ROLLBACK TO SAVEPOINT wip_cancel") + .catch(() => {}); + logger.error( + "[pop/production] WIP 적재 오류 (접수 취소는 유지):", + wipErr, + ); + } + await client.query("COMMIT"); } catch (txErr) { await client.query("ROLLBACK"); diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index fb417780..26689e0b 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -38,6 +38,43 @@ async function ensureItemInfoBatchUseColumn() { } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } } +// 자동 마이그레이션: 작업지시 인포(메모) 테이블 (TASK:ERP-node-095) +// 작업지시 1건당 인포 N개 (1:N). POP 상단 표시용 자유 텍스트. +let _wiInfoMigrationDone = false; +async function ensureWorkInstructionInfoTable() { + if (_wiInfoMigrationDone) return; + try { + const pool = getPool(); + await pool.query(` + CREATE TABLE IF NOT EXISTS work_instruction_info ( + id VARCHAR(64) PRIMARY KEY, + company_code VARCHAR(50) NOT NULL, + work_instruction_id VARCHAR(64) NOT NULL, + work_instruction_no VARCHAR(100), + content TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + created_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(100) + ) + `); + await pool.query("CREATE INDEX IF NOT EXISTS idx_wi_info_wid ON work_instruction_info (work_instruction_id)"); + await pool.query("CREATE INDEX IF NOT EXISTS idx_wi_info_wno ON work_instruction_info (company_code, work_instruction_no)"); + _wiInfoMigrationDone = true; + } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } +} + +// 자동 마이그레이션: wi_process_work_item_detail에 material_input_type(자재투입 자동/수동) 컬럼 추가 (TASK:ERP-node-096) +// 'auto'=자동투입(기본, 기존 동작) / 'manual'=수동투입. 공정작업기준 모달 자재투입 항목 전용. +let _matInputTypeMigrationDone = false; +async function ensureMaterialInputTypeColumn() { + if (_matInputTypeMigrationDone) return; + try { + const pool = getPool(); + await pool.query("ALTER TABLE wi_process_work_item_detail ADD COLUMN IF NOT EXISTS material_input_type VARCHAR(10) DEFAULT 'auto'"); + _matInputTypeMigrationDone = true; + } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } +} + // ─── 작업지시 목록 조회 (detail 기준 행 반환) ─── export async function getList(req: AuthenticatedRequest, res: Response) { try { @@ -278,9 +315,10 @@ export async function previewNextNo(req: AuthenticatedRequest, res: Response) { export async function save(req: AuthenticatedRequest, res: Response) { try { await ensureDetailRoutingColumn(); + await ensureWorkInstructionInfoTable(); 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, batchNo, cuttingPlanId, materialOverrides } = req.body; + const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId, materialOverrides, infos } = req.body; if (!items || items.length === 0) { return res.status(400).json({ success: false, message: "품목을 선택해주세요" }); @@ -386,6 +424,26 @@ export async function save(req: AuthenticatedRequest, res: Response) { [String(totalQty), effectiveRouting, wiId] ); + // ── 작업지시 인포(메모) 저장 (TASK:ERP-node-095) ── + // 정책: 신규/편집 공통으로 기존 인포 전체 DELETE 후 재INSERT (work_instruction_detail과 동일 패턴). + // - infos가 없거나 빈 배열이면 INSERT 0건 (편집 시 인포 전체 삭제 효과). + // - 각 항목 trim 후 빈 문자열은 제외, 입력 순서대로 sort_order 0,1,2… 부여. + await client.query(`DELETE FROM work_instruction_info WHERE work_instruction_id = $1`, [wiId]); + if (Array.isArray(infos)) { + const cleanInfos = infos + .map((x: any) => (x == null ? "" : String(x).trim())) + .filter((x: string) => x.length > 0); + let infoSort = 0; + for (const content of cleanInfos) { + await client.query( + `INSERT INTO work_instruction_info (id,company_code,work_instruction_id,work_instruction_no,content,sort_order,created_date,writer) + VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,NOW(),$6)`, + [companyCode, wiId, wiNo, content, infoSort, userId] + ); + infoSort++; + } + } + // ── materialOverrides 적용: wi_process_work_item + wi_process_work_item_detail INSERT ── // 정책: // - 수정(editId) 진입 시 이 작업지시의 기존 wi_* 자재(material_input) 데이터를 모두 삭제 후 재구축. @@ -565,6 +623,8 @@ export async function remove(req: AuthenticatedRequest, res: Response) { await client.query("BEGIN"); // 디테일 삭제 (id 기반) await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=ANY($1)`, [ids]); + // 인포(메모) 동반 삭제 — 고아 데이터 방지 (TASK:ERP-node-095) + await client.query(`DELETE FROM work_instruction_info 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 }); @@ -576,6 +636,27 @@ export async function remove(req: AuthenticatedRequest, res: Response) { } } +// ─── 작업지시 인포(메모) 조회 (편집 모달 복원용, TASK:ERP-node-095) ─── +export async function getInfos(req: AuthenticatedRequest, res: Response) { + try { + await ensureWorkInstructionInfoTable(); + const companyCode = req.user!.companyCode; + const { wiNo } = req.params; + const pool = getPool(); + const rows = await pool.query( + `SELECT id, content, sort_order + FROM work_instruction_info + WHERE work_instruction_no = $1 AND company_code = $2 + ORDER BY sort_order ASC, created_date ASC`, + [wiNo, companyCode] + ); + return res.json({ success: true, data: rows.rows }); + } 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 { @@ -816,6 +897,7 @@ export async function updateRouting(req: AuthenticatedRequest, res: Response) { // ─── 작업지시 전용 공정작업기준 조회 ─── export async function getWorkStandard(req: AuthenticatedRequest, res: Response) { try { + await ensureMaterialInputTypeColumn(); const companyCode = req.user!.companyCode; const { wiNo } = req.params; const { routingVersionId } = req.query; @@ -873,7 +955,8 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response) work_qty_auto_collect, work_qty_plc_data, defect_qty_auto_collect, defect_qty_plc_data, good_qty_auto_collect, good_qty_plc_data, - loss_qty_auto_collect, loss_qty_plc_data + loss_qty_auto_collect, loss_qty_plc_data, + material_input_type FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2 ORDER BY sort_order`, @@ -1088,6 +1171,7 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) // ─── 작업지시 전용 공정작업기준 저장 (일괄) ─── export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) { try { + await ensureMaterialInputTypeColumn(); const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { wiNo } = req.params; @@ -1129,9 +1213,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, bom_item_id, bom_item_name, bom_qty, bom_unit, work_qty_auto_collect, work_qty_plc_data, defect_qty_auto_collect, defect_qty_plc_data, good_qty_auto_collect, good_qty_plc_data, loss_qty_auto_collect, loss_qty_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, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`, - [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, d.work_qty_auto_collect || null, d.work_qty_plc_data || null, d.defect_qty_auto_collect || null, d.defect_qty_plc_data || null, d.good_qty_auto_collect || null, d.good_qty_plc_data || null, d.loss_qty_auto_collect || null, d.loss_qty_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, work_qty_auto_collect, work_qty_plc_data, defect_qty_auto_collect, defect_qty_plc_data, good_qty_auto_collect, good_qty_plc_data, loss_qty_auto_collect, loss_qty_plc_data, material_input_type, 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, $29, $30, $31, $32, $33, $34, $35, $36, $37)`, + [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, d.work_qty_auto_collect || null, d.work_qty_plc_data || null, d.defect_qty_auto_collect || null, d.defect_qty_plc_data || null, d.good_qty_auto_collect || null, d.good_qty_plc_data || null, d.loss_qty_auto_collect || null, d.loss_qty_plc_data || null, d.material_input_type || 'auto', userId] ); } } diff --git a/backend-node/src/routes/workInstructionRoutes.ts b/backend-node/src/routes/workInstructionRoutes.ts index 749126d4..6a4a5ba5 100644 --- a/backend-node/src/routes/workInstructionRoutes.ts +++ b/backend-node/src/routes/workInstructionRoutes.ts @@ -35,4 +35,7 @@ router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard); // 작업지시별 자재투입(BOM 대체) 매핑 조회 — 편집 모달 복원용 router.get("/:wiNo/material-overrides", ctrl.getMaterialOverrides); +// 작업지시별 인포(메모) 조회 — 편집 모달 복원용 (TASK:ERP-node-095) +router.get("/:wiNo/infos", ctrl.getInfos); + export default router; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 3bcf4f3d..3eb0c90a 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import { query, queryOne, transaction, getPool } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; @@ -515,6 +516,38 @@ export class DynamicFormService { console.log("✅ 타입 변환 완료된 데이터:", dataToInsert); + // 🆕 id PK 자동 생성 (엑셀 업로드 경로 대응) + // 개발 DB의 item_info.id 등 일부 테이블은 gen_random_uuid() default가 없어 + // /dynamic-form/save 경로에서 id 미지정 시 NOT NULL 위반 발생. + // 아래 4개 조건을 모두 충족할 때만 백엔드에서 UUID를 채워 넣는다. + // 1) PK가 단일 컬럼이고 그 이름이 "id" + // 2) 테이블에 실제 "id" 컬럼이 존재 + // 3) 클라이언트가 보낸 id 값이 비어 있음 (이중 생성 방지) + // 4) id 컬럼 타입이 문자열/uuid 계열 (integer serial id에 UUID 주입 방지) + const idIsEmpty = + dataToInsert.id === undefined || + dataToInsert.id === null || + String(dataToInsert.id).trim() === ""; + if ( + primaryKeys.length === 1 && + primaryKeys[0] === "id" && + tableColumns.includes("id") && + idIsEmpty + ) { + const idColumn = columnInfo.find((col) => col.column_name === "id"); + const idDataType = idColumn?.data_type?.toLowerCase() || ""; + const isStringTypeId = + idDataType === "character varying" || + idDataType === "text" || + idDataType === "uuid"; + if (isStringTypeId) { + dataToInsert.id = randomUUID(); + console.log( + `🆔 id 자동 생성 (백엔드): ${tableName}.id = ${dataToInsert.id} (data_type=${idDataType})` + ); + } + } + // 동적 SQL을 사용하여 실제 테이블에 UPSERT const columns = Object.keys(dataToInsert); const values: any[] = Object.values(dataToInsert); diff --git a/backend-node/src/services/outsourcePurchaseService.ts b/backend-node/src/services/outsourcePurchaseService.ts index 3d7d2fc3..68180e30 100644 --- a/backend-node/src/services/outsourcePurchaseService.ts +++ b/backend-node/src/services/outsourcePurchaseService.ts @@ -696,7 +696,7 @@ export async function requestRelease( const ob = await client.query( `INSERT INTO outbound_mng ( id, company_code, outbound_number, outbound_type, - outbound_date, reference_number, source_type, + outbound_date, reference_number, source_table, outbound_status, status, writer, created_date, updated_date, created_by, updated_by, memo, warehouse_code, customer_code, customer_name, diff --git a/backend-node/src/services/wipStockService.ts b/backend-node/src/services/wipStockService.ts new file mode 100644 index 00000000..ffe1075c --- /dev/null +++ b/backend-node/src/services/wipStockService.ts @@ -0,0 +1,652 @@ +/** + * wipStockService — 재공품(WIP) 재고 적재 서비스 (TASK:ERP-094) + * + * 마이그레이션 1088(`wip_stock` / `wip_stock_history`)이 만든 구조에 + * POP 공정 트랜잭션 시점마다 재공품 현재고 + 이력을 적재한다. + * + * 핵심 원칙: + * - 본 서비스의 모든 함수는 **호출한 컨트롤러가 보유한 DB 트랜잭션(client/pool)** 을 + * 인자로 받아 같은 트랜잭션 안에서 실행한다. 별도 트랜잭션을 열지 않는다(부분 커밋 방지). + * - `wip_stock` 고유키는 (company_code, work_order_process_id, equipment_code, lot_number). + * equipment_code / lot_number 의 NULL 은 PostgreSQL UNIQUE 에서 서로 다른 값으로 취급되므로 + * 조회/비교 시 COALESCE(...,'') 로 정규화하여 중복 행을 방지한다. + * - work_order_process_id 는 work_order_process.id(공정 인스턴스)이며 + * work_order_process_result.id 가 아니다 → 재접수/리워크는 같은 wip_stock 행에 누적된다. + * - current_qty = good_qty - moved_qty - scrap 으로 일관 계산한다. + * - id 규칙: wip_stock = 'WIP-' + uuid, wip_stock_history = 'WIPH-' + uuid. + */ + +import { randomUUID } from "crypto"; +import logger from "../utils/logger"; + +/** pool / poolClient 양쪽 모두 만족하는 최소 인터페이스 */ +export interface DbExec { + query: (text: string, values?: any[]) => Promise; +} + +/** wip_stock_history.transaction_type 허용값 */ +export type WipTransactionType = + | "start" + | "produce" + | "defect" + | "move_in" + | "move_out" + | "scrap" + | "adjust"; + +/** wip_stock.status 허용값 */ +export type WipStatus = "in_progress" | "completed" | "moved_out" | "scrapped"; + +/** + * wip_stock 행 식별/생성에 필요한 컨텍스트. + * 각 트리거는 work_order_process(공정 인스턴스) 정보를 조회해 이 객체를 채운다. + */ +export interface WipStockContext { + companyCode: string; + /** work_order_process.id (공정 인스턴스) — 고유키 구성요소 */ + workOrderProcessId: string; + /** work_instruction.id */ + workInstructionId?: string | null; + processCode?: string | null; + /** work_order_process.seq_no (정수 변환) */ + processSeq?: number | null; + /** 설비코드 — NULL 가능 (미배정) */ + equipmentCode?: string | null; + /** item_info.item_number */ + itemCode: string; + /** 로트번호 — work_order_process.batch_id, 없으면 wo_id */ + lotNumber?: string | null; + unit?: string | null; + userId: string; +} + +/** history 1행 기록에 필요한 정보 */ +export interface WipHistoryInput { + transactionType: WipTransactionType; + /** 변동량 (양수=증가, 음수=감소) */ + quantity: number; + /** 변동 직후 wip_stock.current_qty 스냅샷 */ + balanceQty: number; + reason?: string | null; + /** 출처 테이블명 — POP 적재는 'work_order_process_result' */ + referenceType?: string | null; + /** 출처 row id — work_order_process_result.id */ + referenceId?: string | null; + referenceNumber?: string | null; + managerName?: string | null; +} + +/** wip_stock 현재 행 스냅샷 */ +interface WipStockRow { + id: string; + input_qty: number; + good_qty: number; + defect_qty: number; + moved_qty: number; + scrap_qty: number; + current_qty: number; + status: string; +} + +/** numeric 컬럼 → number 안전 변환 */ +function toNum(v: unknown): number { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +/** + * wip_stock 행을 고유키(COALESCE 정규화)로 조회한다. 없으면 null. + * scrap 누적은 별도 컬럼이 없으므로 wip_stock 에는 두지 않고, + * current_qty = good_qty - moved_qty - scrap 계산상의 scrap 은 + * history(transaction_type='scrap') 합으로 도출한다. (스키마 1088 준수) + */ +async function findWipStock( + exec: DbExec, + ctx: WipStockContext, +): Promise { + const res = await exec.query( + `SELECT id, input_qty, good_qty, defect_qty, moved_qty, current_qty, status + FROM wip_stock + WHERE company_code = $1 + AND work_order_process_id = $2 + AND COALESCE(equipment_code, '') = COALESCE($3, '') + AND COALESCE(lot_number, '') = COALESCE($4, '') + LIMIT 1`, + [ + ctx.companyCode, + ctx.workOrderProcessId, + ctx.equipmentCode || null, + ctx.lotNumber || null, + ], + ); + if ((res.rowCount ?? 0) === 0) return null; + const r = res.rows[0]; + // scrap 합계: 이 wip_stock 의 history 중 transaction_type='scrap' 절대값 합 + const scrapRes = await exec.query( + `SELECT COALESCE(SUM(ABS(quantity)), 0) AS scrap_sum + FROM wip_stock_history + WHERE wip_stock_id = $1 AND transaction_type = 'scrap'`, + [r.id], + ); + return { + id: r.id, + input_qty: toNum(r.input_qty), + good_qty: toNum(r.good_qty), + defect_qty: toNum(r.defect_qty), + moved_qty: toNum(r.moved_qty), + scrap_qty: toNum(scrapRes.rows[0]?.scrap_sum), + current_qty: toNum(r.current_qty), + status: r.status, + }; +} + +/** + * wip_stock 이력 1행 기록. 변동 1건당 1행. + */ +async function recordWipHistory( + exec: DbExec, + ctx: WipStockContext, + wipStockId: string, + input: WipHistoryInput, +): Promise { + await exec.query( + `INSERT INTO wip_stock_history ( + id, company_code, + wip_stock_id, work_order_process_id, process_code, equipment_code, + item_code, lot_number, + transaction_type, transaction_date, quantity, balance_qty, + reference_type, reference_id, reference_number, + reason, manager_id, manager_name, + created_at, created_by + ) VALUES ( + $1, $2, + $3, $4, $5, $6, + $7, $8, + $9, NOW(), $10, $11, + $12, $13, $14, + $15, $16, $17, + NOW(), $16 + )`, + [ + `WIPH-${randomUUID()}`, + ctx.companyCode, + wipStockId, + ctx.workOrderProcessId, + ctx.processCode || null, + ctx.equipmentCode || null, + ctx.itemCode, + ctx.lotNumber || null, + input.transactionType, + String(input.quantity), + String(input.balanceQty), + input.referenceType || null, + input.referenceId || null, + input.referenceNumber || null, + input.reason || null, + ctx.userId, + input.managerName || null, + ], + ); +} + +/** + * wip_stock UPSERT — delta(증분) 방식. + * 각 수량 컬럼에 delta 를 누적 가산하고 current_qty 를 재계산한다. + * 행이 없으면 INSERT, 있으면 UPDATE. (UNIQUE 제약은 COALESCE 미정규화라 + * ON CONFLICT 대신 SELECT→INSERT/UPDATE 패턴을 쓴다 — inventory_stock 과 동일) + * + * @returns { stockId, currentQty } — history 의 balance_qty 에 쓸 변동 직후 잔량 + */ +async function upsertWipStock( + exec: DbExec, + ctx: WipStockContext, + delta: { + input?: number; + good?: number; + defect?: number; + moved?: number; + scrap?: number; + }, + status?: WipStatus, +): Promise<{ stockId: string; currentQty: number }> { + const dInput = delta.input ?? 0; + const dGood = delta.good ?? 0; + const dDefect = delta.defect ?? 0; + const dMoved = delta.moved ?? 0; + const dScrap = delta.scrap ?? 0; + + const existing = await findWipStock(exec, ctx); + + if (existing) { + const newInput = existing.input_qty + dInput; + const newGood = existing.good_qty + dGood; + const newDefect = existing.defect_qty + dDefect; + const newMoved = existing.moved_qty + dMoved; + const newScrap = existing.scrap_qty + dScrap; + // current_qty = good - moved - scrap (음수 가드) + const newCurrent = Math.max(0, newGood - newMoved - newScrap); + + await exec.query( + `UPDATE wip_stock + SET input_qty = $2, + good_qty = $3, + defect_qty = $4, + moved_qty = $5, + current_qty = $6, + status = COALESCE($7, status), + updated_at = NOW(), + updated_by = $8 + WHERE id = $1`, + [ + existing.id, + String(Math.max(0, newInput)), + String(Math.max(0, newGood)), + String(Math.max(0, newDefect)), + String(Math.max(0, newMoved)), + String(newCurrent), + status || null, + ctx.userId, + ], + ); + return { stockId: existing.id, currentQty: newCurrent }; + } + + // 신규 INSERT + const stockId = `WIP-${randomUUID()}`; + const newGood = Math.max(0, dGood); + const newMoved = Math.max(0, dMoved); + const newScrap = Math.max(0, dScrap); + const newCurrent = Math.max(0, newGood - newMoved - newScrap); + + await exec.query( + `INSERT INTO wip_stock ( + id, company_code, + work_instruction_id, work_order_process_id, process_code, process_seq, + equipment_code, item_code, lot_number, + input_qty, good_qty, defect_qty, moved_qty, current_qty, unit, + status, created_at, updated_at, created_by, updated_by + ) VALUES ( + $1, $2, + $3, $4, $5, $6, + $7, $8, $9, + $10, $11, $12, $13, $14, $15, + $16, NOW(), NOW(), $17, $17 + )`, + [ + stockId, + ctx.companyCode, + ctx.workInstructionId || null, + ctx.workOrderProcessId, + ctx.processCode || null, + ctx.processSeq ?? null, + ctx.equipmentCode || null, + ctx.itemCode, + ctx.lotNumber || null, + String(Math.max(0, dInput)), + String(newGood), + String(Math.max(0, dDefect)), + String(newMoved), + String(newCurrent), + ctx.unit || null, + status || "in_progress", + ctx.userId, + ], + ); + return { stockId, currentQty: newCurrent }; +} + +/** + * 공정 컨텍스트 조회 헬퍼. + * work_order_process.id 로 work_instruction / item_info 를 LEFT JOIN 해 + * WipStockContext 의 item_code / unit / lot_number / process 정보를 채운다. + * + * 품목 식별 정책 (백필 1097 과 동일): + * - item_info 행이 있으면 item_code = item_info.item_number. + * - item_info 행이 삭제된 경우 item_code = wi.item_id(UUID) fallback. + * - work_instruction 자체가 없어 품목 식별자를 전혀 구할 수 없으면 null 반환(스킵). + * + * @param wopId work_order_process.id (공정 인스턴스) + * @param equipmentCode 접수 시 입력된 설비코드 (NULL 가능) + * @returns 품목 식별 완전 불가(work_instruction 없음) 시 null — 호출자는 적재 스킵 + */ +export async function buildWipContext( + exec: DbExec, + companyCode: string, + wopId: string, + userId: string, + equipmentCode?: string | null, +): Promise { + const res = await exec.query( + `SELECT wop.id AS wop_id, + wop.wo_id AS wi_id, + wop.process_code AS process_code, + wop.seq_no AS seq_no, + wop.batch_id AS batch_id, + wi.item_id AS wi_item_id, + ii.item_number AS item_number, + ii.unit AS unit + FROM work_order_process wop + LEFT JOIN work_instruction wi + ON wi.id = wop.wo_id AND wi.company_code = wop.company_code + LEFT JOIN item_info ii + ON ii.id = wi.item_id AND ii.company_code = wi.company_code + WHERE wop.id = $1 AND wop.company_code = $2`, + [wopId, companyCode], + ); + if ((res.rowCount ?? 0) === 0) return null; + const r = res.rows[0]; + // item_code: item_info.item_number 우선, 삭제품목이면 wi.item_id(UUID) fallback + const itemCode = + (r.item_number && String(r.item_number).trim()) || + (r.wi_item_id && String(r.wi_item_id).trim()) || + null; + if (!itemCode) { + // work_instruction 자체가 없음 — 품목 식별 불가, wip_stock.item_code NOT NULL 적재 불가 + logger.warn("[wipStockService] 품목 식별 불가로 적재 스킵", { + companyCode, + wopId, + }); + return null; + } + const seqNum = parseInt(r.seq_no, 10); + return { + companyCode, + workOrderProcessId: r.wop_id, + workInstructionId: r.wi_id, + processCode: r.process_code || null, + processSeq: Number.isFinite(seqNum) ? seqNum : null, + equipmentCode: equipmentCode || null, + itemCode, + // lot_number ← batch_id, 없으면 wo_id 로 대체 (기획서 필드 매핑) + lotNumber: r.batch_id || r.wi_id, + unit: r.unit || null, + userId, + }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// 트리거별 진입점 — popProductionController 의 6개 지점에서 호출 +// ─────────────────────────────────────────────────────────────────────────── + +/** + * [트리거 1] 공정 접수 (acceptProcess). + * input_qty 누적, current_qty 가산 없음(투입은 잔량 아님), status='in_progress'. + * history: transaction_type='start', quantity=accept_qty. + * + * @param wopId work_order_process.id (마스터 공정 인스턴스 id) + * @param resultId 이번 접수로 생성된 work_order_process_result.id (history reference_id) + * @param acceptQty 접수 수량 + */ +export async function onProcessAccept( + exec: DbExec, + companyCode: string, + wopId: string, + resultId: string, + acceptQty: number, + userId: string, + equipmentCode?: string | null, + managerName?: string | null, +): Promise { + const ctx = await buildWipContext(exec, companyCode, wopId, userId, equipmentCode); + if (!ctx) return; + + const { stockId, currentQty } = await upsertWipStock( + exec, + ctx, + { input: acceptQty }, + "in_progress", + ); + await recordWipHistory(exec, ctx, stockId, { + transactionType: "start", + quantity: acceptQty, + balanceQty: currentQty, + reason: "공정 접수", + referenceType: "work_order_process_result", + referenceId: resultId, + managerName: managerName || null, + }); +} + +/** + * [트리거 2] 실적 저장 (saveResult). + * good_qty / defect_qty 증분 갱신, current_qty 재계산. + * history: 양품 증가분은 'produce', 불량 증가분은 'defect' 로 각각 1행. + * + * @param addGood 이번 저장으로 증가한 양품 수량 (>=0) + * @param addDefect 이번 저장으로 증가한 불량 수량 (>=0) + */ +export async function onResultSaved( + exec: DbExec, + companyCode: string, + wopId: string, + resultId: string, + addGood: number, + addDefect: number, + userId: string, + equipmentCode?: string | null, + managerName?: string | null, +): Promise { + if (addGood === 0 && addDefect === 0) return; + const ctx = await buildWipContext(exec, companyCode, wopId, userId, equipmentCode); + if (!ctx) return; + + const { stockId, currentQty } = await upsertWipStock(exec, ctx, { + good: addGood, + defect: addDefect, + }); + + if (addGood !== 0) { + await recordWipHistory(exec, ctx, stockId, { + transactionType: "produce", + quantity: addGood, + balanceQty: currentQty, + reason: "양품 생산 실적", + referenceType: "work_order_process_result", + referenceId: resultId, + managerName: managerName || null, + }); + } + if (addDefect !== 0) { + await recordWipHistory(exec, ctx, stockId, { + transactionType: "defect", + quantity: addDefect, + balanceQty: currentQty, + reason: "불량 발생 실적", + referenceType: "work_order_process_result", + referenceId: resultId, + managerName: managerName || null, + }); + } +} + +/** + * [트리거 3·4] 실적 확정 / 타이머 완료 — 상태 전이 기록. + * wip_stock.status='completed' 로 갱신. 수량 변동 없음. + * history: transaction_type='adjust', quantity=0 (상태전이 기록 행). + */ +export async function onProcessCompleted( + exec: DbExec, + companyCode: string, + wopId: string, + resultId: string, + userId: string, + equipmentCode?: string | null, + managerName?: string | null, + reason: string = "공정 완료(상태 전이)", +): Promise { + const ctx = await buildWipContext(exec, companyCode, wopId, userId, equipmentCode); + if (!ctx) return; + + // 기존 wip_stock 행이 없으면 상태전이만으로 빈 행을 만들지 않는다 (수량 0 행 방지) + const existing = await findWipStock(exec, ctx); + if (!existing) return; + if (existing.status === "completed") return; // 멱등 — 이미 완료면 중복 기록 안 함 + + const { stockId, currentQty } = await upsertWipStock( + exec, + ctx, + {}, + "completed", + ); + await recordWipHistory(exec, ctx, stockId, { + transactionType: "adjust", + quantity: 0, + balanceQty: currentQty, + reason, + referenceType: "work_order_process_result", + referenceId: resultId, + managerName: managerName || null, + }); +} + +/** + * [트리거 5] 접수 취소 (cancelAccept) — 롤백. + * 미소진 접수분(input_qty)을 차감한다. + * - 실적이 전혀 없으면(취소로 result 행 삭제) input_qty 전량 차감, + * 잔량 0 이면 status='scrapped'. + * - 실적이 일부 있으면 미소진분만 차감, 행은 유지(status 유지). + * 잔량/수량 음수 가드는 upsertWipStock 내부에서 처리. + * history: transaction_type='adjust', quantity=-cancelledQty. + * + * @param cancelledQty 취소된 미소진 접수 수량 (>0) + * @param fullRemove 해당 result 행이 완전 삭제됐는지 여부 (true면 행 status='scrapped' 후보) + */ +export async function onAcceptCancelled( + exec: DbExec, + companyCode: string, + wopId: string, + resultId: string, + cancelledQty: number, + userId: string, + fullRemove: boolean, + equipmentCode?: string | null, + managerName?: string | null, +): Promise { + if (cancelledQty <= 0) return; + const ctx = await buildWipContext(exec, companyCode, wopId, userId, equipmentCode); + if (!ctx) return; + + const existing = await findWipStock(exec, ctx); + if (!existing) return; // 적재 안 된 공정이면 롤백 대상 없음 + + // 접수분(input_qty)만 차감 — 잔량 음수 방지 위해 차감량을 input_qty 로 클램프 + const deductInput = Math.min(cancelledQty, existing.input_qty); + const newInput = existing.input_qty - deductInput; + // 실적(good_qty)이 있으면 행 유지, 없고 전량 취소면 scrapped + const nextStatus: WipStatus | undefined = + fullRemove && newInput <= 0 && existing.good_qty <= 0 + ? "scrapped" + : undefined; + + const { stockId, currentQty } = await upsertWipStock( + exec, + ctx, + { input: -deductInput }, + nextStatus, + ); + await recordWipHistory(exec, ctx, stockId, { + transactionType: "adjust", + quantity: -deductInput, + balanceQty: currentQty, + reason: "접수 취소(미소진분 롤백)", + referenceType: "work_order_process_result", + referenceId: resultId, + managerName: managerName || null, + }); +} + +/** + * [트리거 6] 다음 공정 이동. + * 후속 공정 접수 시 직전 공정 wip_stock 행의 moved_qty 를 가산하고 current_qty 차감. + * history: 직전 공정 행에 'move_out'(음수), 후속 공정 행에 'move_in'(양수) 각 1행. + * + * 직전 공정의 wip_stock 행을 lot_number 기준으로 찾을 수 없을 수 있으므로 + * (직전 공정 batch_id 가 다를 수 있음) work_order_process_id 단위로 매칭한다. + * + * @param prevWopId 직전 공정 work_order_process.id + * @param nextWopId 후속(이번 접수) 공정 work_order_process.id + * @param nextResultId 후속 공정 접수 result.id + * @param moveQty 이동 수량 + */ +export async function onProcessMove( + exec: DbExec, + companyCode: string, + prevWopId: string, + nextWopId: string, + nextResultId: string, + moveQty: number, + userId: string, + nextEquipmentCode?: string | null, + managerName?: string | null, +): Promise { + if (moveQty <= 0) return; + + // 직전 공정 wip_stock 행 조회 (work_order_process_id 단위 — 설비/로트 무관 합산 대상 1행) + const prevRes = await exec.query( + `SELECT id, good_qty, moved_qty, equipment_code, lot_number, process_code, + item_code, process_seq, work_instruction_id, unit + FROM wip_stock + WHERE company_code = $1 AND work_order_process_id = $2 + ORDER BY created_at ASC + LIMIT 1`, + [companyCode, prevWopId], + ); + + if ((prevRes.rowCount ?? 0) > 0) { + const p = prevRes.rows[0]; + const prevCtx: WipStockContext = { + companyCode, + workOrderProcessId: prevWopId, + workInstructionId: p.work_instruction_id || null, + processCode: p.process_code || null, + processSeq: p.process_seq ?? null, + equipmentCode: p.equipment_code || null, + itemCode: p.item_code, + lotNumber: p.lot_number || null, + unit: p.unit || null, + userId, + }; + const prevGood = toNum(p.good_qty); + const prevMoved = toNum(p.moved_qty); + // 이동량은 직전 공정 미이동 잔량(good - moved)을 초과하지 않도록 클램프 + const movableQty = Math.max(0, prevGood - prevMoved); + const actualMove = Math.min(moveQty, movableQty); + if (actualMove > 0) { + const { stockId, currentQty } = await upsertWipStock(exec, prevCtx, { + moved: actualMove, + }); + await recordWipHistory(exec, prevCtx, stockId, { + transactionType: "move_out", + quantity: -actualMove, + balanceQty: currentQty, + reason: "후속 공정으로 이동", + referenceType: "work_order_process_result", + referenceId: nextResultId, + managerName: managerName || null, + }); + } + } + + // 후속 공정 wip_stock 행에 move_in 이력 기록 (수량 컬럼 변동 없음 — input 은 start 에서 처리) + const nextCtx = await buildWipContext( + exec, + companyCode, + nextWopId, + userId, + nextEquipmentCode, + ); + if (nextCtx) { + const existing = await findWipStock(exec, nextCtx); + if (existing) { + await recordWipHistory(exec, nextCtx, existing.id, { + transactionType: "move_in", + quantity: moveQty, + balanceQty: existing.current_qty, + reason: "직전 공정에서 이동 수령", + referenceType: "work_order_process_result", + referenceId: nextResultId, + managerName: managerName || null, + }); + } + } +} diff --git a/frontend/app/(main)/COMPANY_10/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/outbound/page.tsx index 114bd060..02c6b617 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/outbound/page.tsx @@ -102,7 +102,7 @@ const GRID_COLUMNS = [ { key: "outbound_type", label: "출고유형" }, { key: "outbound_date", label: "출고일" }, { key: "reference_number", label: "참조번호" }, - { key: "source_type", label: "데이터출처" }, + { key: "source_table", label: "데이터출처" }, { key: "customer_name", label: "거래처" }, { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품목명" }, @@ -217,7 +217,7 @@ interface SelectedSourceItem { outbound_qty: number; unit_price: number; total_amount: number; - source_type: string; + source_table: string; source_id: string; } @@ -348,13 +348,13 @@ export default function OutboundPage() { })(); }, []); - // 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등) + // 플랫 행 생성 (출고유형 코드→라벨 변환, source_table→한글 등) const flatRows = useMemo(() => { return data.map((row) => ({ ...row, _raw_outbound_type: row.outbound_type, outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "", - source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "", + source_table: row.source_table ? (SOURCE_TYPE_LABEL[row.source_table] || row.source_table) : "", item_number: row.item_code || (row as any).item_number || "", spec: row.specification || (row as any).spec || "", })); @@ -554,7 +554,7 @@ export default function OutboundPage() { outbound_qty: Number(g.outbound_qty) || 0, unit_price: Number(g.unit_price) || 0, total_amount: Number(g.total_amount) || 0, - source_type: g.source_type || "", + source_table: g.source_table || "", source_id: (g as any).source_id || "", })) ); @@ -632,7 +632,7 @@ export default function OutboundPage() { outbound_qty: si.remain_qty, unit_price: price, total_amount: si.remain_qty * price, - source_type: "shipment_instruction_detail", + source_table: "shipment_instruction_detail", source_id: String(si.detail_id), }, ]); @@ -658,7 +658,7 @@ export default function OutboundPage() { outbound_qty: po.received_qty, unit_price: po.unit_price, total_amount: po.received_qty * po.unit_price, - source_type: "purchase_order_mng", + source_table: "purchase_order_mng", source_id: po.id, }, ]); @@ -684,7 +684,7 @@ export default function OutboundPage() { outbound_qty: 0, unit_price: item.standard_price, total_amount: 0, - source_type: "item_info", + source_table: "item_info", source_id: item.id, }, ]); @@ -780,7 +780,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -811,7 +811,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -1004,7 +1004,7 @@ export default function OutboundPage() { {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} {row.reference_number || ""} - {row.source_type || ""} + {row.source_table || ""} {row.customer_name || ""} {row.item_number || ""} {row.item_name || ""} diff --git a/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx index ccb0afda..b871a7fb 100644 --- a/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx @@ -128,7 +128,10 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") { + const mLabel = detail.material_input_type === "manual" ? "[수동]" : "[자동]"; + return `${detail.content || "BOM 구성 자재 (자동 연동)"} ${mLabel}`; + } return detail.content || "-"; }; @@ -159,6 +162,8 @@ function DetailFormModalInner({ const [bomMaterials, setBomMaterials] = useState([]); const [bomLoading, setBomLoading] = useState(false); const [bomChecked, setBomChecked] = useState>(new Set()); + // 자재투입 자동/수동 구분 — Map<자재행id, "auto"|"manual"> (TASK:ERP-node-096) + const [bomInputType, setBomInputType] = useState>(new Map()); // 품목검사정보 연동 const [itemInspections, setItemInspections] = useState([]); @@ -215,10 +220,21 @@ function DetailFormModalInner({ } } setBomChecked(restored); + setBomInputType(new Map()); + return; + } + } + // 자재별 개별 detail(현행) 편집 — 해당 자재 1건 체크 + 자동/수동 복원 (TASK:ERP-node-096) + if (mode === "edit" && editData?.detail_type === "material_input" && editData.bom_item_id) { + const mat = bomMaterials.find((m) => m.child_item_id === String(editData.bom_item_id)); + if (mat) { + setBomChecked(new Set([mat.id])); + setBomInputType(new Map([[mat.id, editData.material_input_type === "manual" ? "manual" : "auto"]])); return; } } setBomChecked(new Set()); + setBomInputType(new Map()); }, [open, bomMaterials, mode, editData]); useEffect(() => { @@ -375,6 +391,7 @@ function DetailFormModalInner({ bom_item_name: mat.child_item_name || "", bom_qty: String(mat.quantity || 1), bom_unit: unitLabel, + material_input_type: bomInputType.get(mat.id) || "auto", }); } onClose(); @@ -1072,6 +1089,39 @@ function DetailFormModalInner({ {mat.process_type ? ` | 공정: ${mat.process_type}` : ""} + {/* 자동/수동 투입 토글 — 체크된 자재만 (TASK:ERP-node-096) */} + {bomChecked.has(mat.id) && ( +
+ + +
+ )} ); }) diff --git a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx index e5ec0009..0c65fd25 100644 --- a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx @@ -17,13 +17,14 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList, - getRoutingVersions, RoutingVersionData, + getRoutingVersions, RoutingVersionData, getWIInfos, } from "@/lib/api/workInstruction"; import { WorkStandardEditModal } from "./WorkStandardEditModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { WorkInstructionInfoSection } from "@/components/common/WorkInstructionInfoSection"; const GRID_COLUMNS = [ { key: "work_instruction_no", label: "작업지시번호" }, @@ -180,6 +181,8 @@ export default function WorkInstructionPage() { const [confirmWorkTeam, setConfirmWorkTeam] = useState(""); const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 작업지시 인포(메모) — POP 상단 표시용 (TASK:ERP-node-095) + const [confirmInfos, setConfirmInfos] = useState([]); // 등록 확인 모달 — 인라인 추가 폼 const [confirmAddQty, setConfirmAddQty] = useState(""); @@ -196,6 +199,7 @@ export default function WorkInstructionPage() { const [editWorkTeam, setEditWorkTeam] = useState(""); const [editWorker, setEditWorker] = useState(""); const [editRemark, setEditRemark] = useState(""); + const [editInfos, setEditInfos] = useState([]); const [editSaving, setEditSaving] = useState(false); const [addQty, setAddQty] = useState(""); const [addEquipment, setAddEquipment] = useState(""); @@ -316,7 +320,7 @@ export default function WorkInstructionPage() { setConfirmWiNo("불러오는 중..."); setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]); setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker(""); - setConfirmRouting(""); setConfirmRoutingOptions([]); + setConfirmRouting(""); setConfirmRoutingOptions([]); setConfirmInfos([]); previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); // 품목별 라우팅 옵션 로드 @@ -369,6 +373,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, + infos: confirmInfos.map((s) => s.trim()).filter(Boolean), items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, @@ -395,6 +400,13 @@ export default function WorkInstructionPage() { setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || ""); setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || ""); setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || ""); + // 작업지시 인포(메모) 복원 (TASK:ERP-node-095) + setEditInfos([]); + getWIInfos(wiNo) + .then((res) => { + if (res.success && Array.isArray(res.data)) setEditInfos(res.data.map((d) => d.content)); + }) + .catch(() => {}); const items: SelectedItem[] = relatedDetails.map((d: any) => ({ itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "", qty: Number(d.detail_qty || 0), remark: d.detail_remark || "", @@ -465,6 +477,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, remark: editRemark, + infos: editInfos.map((s) => s.trim()).filter(Boolean), routing: editRouting || null, items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, @@ -789,6 +802,7 @@ export default function WorkInstructionPage() {
+

품목 목록

@@ -907,6 +921,8 @@ export default function WorkInstructionPage() {
+ + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
diff --git a/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx index 114bd060..02c6b617 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx @@ -102,7 +102,7 @@ const GRID_COLUMNS = [ { key: "outbound_type", label: "출고유형" }, { key: "outbound_date", label: "출고일" }, { key: "reference_number", label: "참조번호" }, - { key: "source_type", label: "데이터출처" }, + { key: "source_table", label: "데이터출처" }, { key: "customer_name", label: "거래처" }, { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품목명" }, @@ -217,7 +217,7 @@ interface SelectedSourceItem { outbound_qty: number; unit_price: number; total_amount: number; - source_type: string; + source_table: string; source_id: string; } @@ -348,13 +348,13 @@ export default function OutboundPage() { })(); }, []); - // 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등) + // 플랫 행 생성 (출고유형 코드→라벨 변환, source_table→한글 등) const flatRows = useMemo(() => { return data.map((row) => ({ ...row, _raw_outbound_type: row.outbound_type, outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "", - source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "", + source_table: row.source_table ? (SOURCE_TYPE_LABEL[row.source_table] || row.source_table) : "", item_number: row.item_code || (row as any).item_number || "", spec: row.specification || (row as any).spec || "", })); @@ -554,7 +554,7 @@ export default function OutboundPage() { outbound_qty: Number(g.outbound_qty) || 0, unit_price: Number(g.unit_price) || 0, total_amount: Number(g.total_amount) || 0, - source_type: g.source_type || "", + source_table: g.source_table || "", source_id: (g as any).source_id || "", })) ); @@ -632,7 +632,7 @@ export default function OutboundPage() { outbound_qty: si.remain_qty, unit_price: price, total_amount: si.remain_qty * price, - source_type: "shipment_instruction_detail", + source_table: "shipment_instruction_detail", source_id: String(si.detail_id), }, ]); @@ -658,7 +658,7 @@ export default function OutboundPage() { outbound_qty: po.received_qty, unit_price: po.unit_price, total_amount: po.received_qty * po.unit_price, - source_type: "purchase_order_mng", + source_table: "purchase_order_mng", source_id: po.id, }, ]); @@ -684,7 +684,7 @@ export default function OutboundPage() { outbound_qty: 0, unit_price: item.standard_price, total_amount: 0, - source_type: "item_info", + source_table: "item_info", source_id: item.id, }, ]); @@ -780,7 +780,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -811,7 +811,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -1004,7 +1004,7 @@ export default function OutboundPage() { {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} {row.reference_number || ""} - {row.source_type || ""} + {row.source_table || ""} {row.customer_name || ""} {row.item_number || ""} {row.item_name || ""} diff --git a/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx index ccb0afda..b871a7fb 100644 --- a/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx @@ -128,7 +128,10 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") { + const mLabel = detail.material_input_type === "manual" ? "[수동]" : "[자동]"; + return `${detail.content || "BOM 구성 자재 (자동 연동)"} ${mLabel}`; + } return detail.content || "-"; }; @@ -159,6 +162,8 @@ function DetailFormModalInner({ const [bomMaterials, setBomMaterials] = useState([]); const [bomLoading, setBomLoading] = useState(false); const [bomChecked, setBomChecked] = useState>(new Set()); + // 자재투입 자동/수동 구분 — Map<자재행id, "auto"|"manual"> (TASK:ERP-node-096) + const [bomInputType, setBomInputType] = useState>(new Map()); // 품목검사정보 연동 const [itemInspections, setItemInspections] = useState([]); @@ -215,10 +220,21 @@ function DetailFormModalInner({ } } setBomChecked(restored); + setBomInputType(new Map()); + return; + } + } + // 자재별 개별 detail(현행) 편집 — 해당 자재 1건 체크 + 자동/수동 복원 (TASK:ERP-node-096) + if (mode === "edit" && editData?.detail_type === "material_input" && editData.bom_item_id) { + const mat = bomMaterials.find((m) => m.child_item_id === String(editData.bom_item_id)); + if (mat) { + setBomChecked(new Set([mat.id])); + setBomInputType(new Map([[mat.id, editData.material_input_type === "manual" ? "manual" : "auto"]])); return; } } setBomChecked(new Set()); + setBomInputType(new Map()); }, [open, bomMaterials, mode, editData]); useEffect(() => { @@ -375,6 +391,7 @@ function DetailFormModalInner({ bom_item_name: mat.child_item_name || "", bom_qty: String(mat.quantity || 1), bom_unit: unitLabel, + material_input_type: bomInputType.get(mat.id) || "auto", }); } onClose(); @@ -1072,6 +1089,39 @@ function DetailFormModalInner({ {mat.process_type ? ` | 공정: ${mat.process_type}` : ""}
+ {/* 자동/수동 투입 토글 — 체크된 자재만 (TASK:ERP-node-096) */} + {bomChecked.has(mat.id) && ( +
+ + +
+ )} ); }) diff --git a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx index 39362546..0d6de06d 100644 --- a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx @@ -17,13 +17,14 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList, - getRoutingVersions, RoutingVersionData, + getRoutingVersions, RoutingVersionData, getWIInfos, } from "@/lib/api/workInstruction"; import { WorkStandardEditModal } from "./WorkStandardEditModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { WorkInstructionInfoSection } from "@/components/common/WorkInstructionInfoSection"; const GRID_COLUMNS = [ { key: "work_instruction_no", label: "작업지시번호" }, @@ -180,6 +181,8 @@ export default function WorkInstructionPage() { const [confirmWorkTeam, setConfirmWorkTeam] = useState(""); const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 작업지시 인포(메모) — POP 상단 표시용 (TASK:ERP-node-095) + const [confirmInfos, setConfirmInfos] = useState([]); // 등록 확인 모달 — 인라인 추가 폼 const [confirmAddQty, setConfirmAddQty] = useState(""); @@ -196,6 +199,7 @@ export default function WorkInstructionPage() { const [editWorkTeam, setEditWorkTeam] = useState(""); const [editWorker, setEditWorker] = useState(""); const [editRemark, setEditRemark] = useState(""); + const [editInfos, setEditInfos] = useState([]); const [editSaving, setEditSaving] = useState(false); const [addQty, setAddQty] = useState(""); const [addEquipment, setAddEquipment] = useState(""); @@ -320,7 +324,7 @@ export default function WorkInstructionPage() { setConfirmWiNo("불러오는 중..."); setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]); setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker(""); - setConfirmRouting(""); setConfirmRoutingOptions([]); + setConfirmRouting(""); setConfirmRoutingOptions([]); setConfirmInfos([]); previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); // 품목별 라우팅 옵션 로드 @@ -373,6 +377,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, + infos: confirmInfos.map((s) => s.trim()).filter(Boolean), items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, @@ -399,6 +404,13 @@ export default function WorkInstructionPage() { setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || ""); setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || ""); setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || ""); + // 작업지시 인포(메모) 복원 (TASK:ERP-node-095) + setEditInfos([]); + getWIInfos(wiNo) + .then((res) => { + if (res.success && Array.isArray(res.data)) setEditInfos(res.data.map((d) => d.content)); + }) + .catch(() => {}); const items: SelectedItem[] = relatedDetails.map((d: any) => ({ itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "", qty: Number(d.detail_qty || 0), remark: d.detail_remark || "", @@ -469,6 +481,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, remark: editRemark, + infos: editInfos.map((s) => s.trim()).filter(Boolean), routing: editRouting || null, items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, @@ -793,6 +806,7 @@ export default function WorkInstructionPage() {
+

품목 목록

@@ -911,6 +925,8 @@ export default function WorkInstructionPage() {
+ + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
diff --git a/frontend/app/(main)/COMPANY_28/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_28/logistics/outbound/page.tsx index d6e801a1..415ee98f 100644 --- a/frontend/app/(main)/COMPANY_28/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_28/logistics/outbound/page.tsx @@ -102,7 +102,7 @@ const GRID_COLUMNS = [ { key: "outbound_type", label: "출고유형" }, { key: "outbound_date", label: "출고일" }, { key: "reference_number", label: "참조번호" }, - { key: "source_type", label: "데이터출처" }, + { key: "source_table", label: "데이터출처" }, { key: "customer_name", label: "거래처" }, { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품목명" }, @@ -217,7 +217,7 @@ interface SelectedSourceItem { outbound_qty: number; unit_price: number; total_amount: number; - source_type: string; + source_table: string; source_id: string; } @@ -348,13 +348,13 @@ export default function OutboundPage() { })(); }, []); - // 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등) + // 플랫 행 생성 (출고유형 코드→라벨 변환, source_table→한글 등) const flatRows = useMemo(() => { return data.map((row) => ({ ...row, _raw_outbound_type: row.outbound_type, outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "", - source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "", + source_table: row.source_table ? (SOURCE_TYPE_LABEL[row.source_table] || row.source_table) : "", item_number: row.item_code || (row as any).item_number || "", spec: row.specification || (row as any).spec || "", })); @@ -554,7 +554,7 @@ export default function OutboundPage() { outbound_qty: Number(g.outbound_qty) || 0, unit_price: Number(g.unit_price) || 0, total_amount: Number(g.total_amount) || 0, - source_type: g.source_type || "", + source_table: g.source_table || "", source_id: (g as any).source_id || "", })) ); @@ -631,7 +631,7 @@ export default function OutboundPage() { outbound_qty: si.remain_qty, unit_price: 0, total_amount: 0, - source_type: "shipment_instruction_detail", + source_table: "shipment_instruction_detail", source_id: String(si.detail_id), }, ]); @@ -657,7 +657,7 @@ export default function OutboundPage() { outbound_qty: po.received_qty, unit_price: po.unit_price, total_amount: po.received_qty * po.unit_price, - source_type: "purchase_order_mng", + source_table: "purchase_order_mng", source_id: po.id, }, ]); @@ -683,7 +683,7 @@ export default function OutboundPage() { outbound_qty: 0, unit_price: item.standard_price, total_amount: 0, - source_type: "item_info", + source_table: "item_info", source_id: item.id, }, ]); @@ -779,7 +779,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -810,7 +810,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -1003,7 +1003,7 @@ export default function OutboundPage() { {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} {row.reference_number || ""} - {row.source_type || ""} + {row.source_table || ""} {row.customer_name || ""} {row.item_number || ""} {row.item_name || ""} diff --git a/frontend/app/(main)/COMPANY_28/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_28/production/work-instruction/WorkStandardEditModal.tsx index ccb0afda..b871a7fb 100644 --- a/frontend/app/(main)/COMPANY_28/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_28/production/work-instruction/WorkStandardEditModal.tsx @@ -128,7 +128,10 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") { + const mLabel = detail.material_input_type === "manual" ? "[수동]" : "[자동]"; + return `${detail.content || "BOM 구성 자재 (자동 연동)"} ${mLabel}`; + } return detail.content || "-"; }; @@ -159,6 +162,8 @@ function DetailFormModalInner({ const [bomMaterials, setBomMaterials] = useState([]); const [bomLoading, setBomLoading] = useState(false); const [bomChecked, setBomChecked] = useState>(new Set()); + // 자재투입 자동/수동 구분 — Map<자재행id, "auto"|"manual"> (TASK:ERP-node-096) + const [bomInputType, setBomInputType] = useState>(new Map()); // 품목검사정보 연동 const [itemInspections, setItemInspections] = useState([]); @@ -215,10 +220,21 @@ function DetailFormModalInner({ } } setBomChecked(restored); + setBomInputType(new Map()); + return; + } + } + // 자재별 개별 detail(현행) 편집 — 해당 자재 1건 체크 + 자동/수동 복원 (TASK:ERP-node-096) + if (mode === "edit" && editData?.detail_type === "material_input" && editData.bom_item_id) { + const mat = bomMaterials.find((m) => m.child_item_id === String(editData.bom_item_id)); + if (mat) { + setBomChecked(new Set([mat.id])); + setBomInputType(new Map([[mat.id, editData.material_input_type === "manual" ? "manual" : "auto"]])); return; } } setBomChecked(new Set()); + setBomInputType(new Map()); }, [open, bomMaterials, mode, editData]); useEffect(() => { @@ -375,6 +391,7 @@ function DetailFormModalInner({ bom_item_name: mat.child_item_name || "", bom_qty: String(mat.quantity || 1), bom_unit: unitLabel, + material_input_type: bomInputType.get(mat.id) || "auto", }); } onClose(); @@ -1072,6 +1089,39 @@ function DetailFormModalInner({ {mat.process_type ? ` | 공정: ${mat.process_type}` : ""}
+ {/* 자동/수동 투입 토글 — 체크된 자재만 (TASK:ERP-node-096) */} + {bomChecked.has(mat.id) && ( +
+ + +
+ )} ); }) diff --git a/frontend/app/(main)/COMPANY_28/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_28/production/work-instruction/page.tsx index 110b2b4d..10ddcc03 100644 --- a/frontend/app/(main)/COMPANY_28/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_28/production/work-instruction/page.tsx @@ -17,13 +17,14 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList, - getRoutingVersions, RoutingVersionData, + getRoutingVersions, RoutingVersionData, getWIInfos, } from "@/lib/api/workInstruction"; import { WorkStandardEditModal } from "./WorkStandardEditModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { WorkInstructionInfoSection } from "@/components/common/WorkInstructionInfoSection"; const GRID_COLUMNS = [ { key: "work_instruction_no", label: "작업지시번호" }, @@ -180,6 +181,8 @@ export default function WorkInstructionPage() { const [confirmWorkTeam, setConfirmWorkTeam] = useState(""); const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 작업지시 인포(메모) — POP 상단 표시용 (TASK:ERP-node-095) + const [confirmInfos, setConfirmInfos] = useState([]); // 등록 확인 모달 — 인라인 추가 폼 const [confirmAddQty, setConfirmAddQty] = useState(""); @@ -196,6 +199,7 @@ export default function WorkInstructionPage() { const [editWorkTeam, setEditWorkTeam] = useState(""); const [editWorker, setEditWorker] = useState(""); const [editRemark, setEditRemark] = useState(""); + const [editInfos, setEditInfos] = useState([]); const [editSaving, setEditSaving] = useState(false); const [addQty, setAddQty] = useState(""); const [addEquipment, setAddEquipment] = useState(""); @@ -316,7 +320,7 @@ export default function WorkInstructionPage() { setConfirmWiNo("불러오는 중..."); setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]); setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker(""); - setConfirmRouting(""); setConfirmRoutingOptions([]); + setConfirmRouting(""); setConfirmRoutingOptions([]); setConfirmInfos([]); previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); // 품목별 라우팅 옵션 로드 @@ -369,6 +373,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, + infos: confirmInfos.map((s) => s.trim()).filter(Boolean), items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, @@ -395,6 +400,13 @@ export default function WorkInstructionPage() { setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || ""); setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || ""); setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || ""); + // 작업지시 인포(메모) 복원 (TASK:ERP-node-095) + setEditInfos([]); + getWIInfos(wiNo) + .then((res) => { + if (res.success && Array.isArray(res.data)) setEditInfos(res.data.map((d) => d.content)); + }) + .catch(() => {}); const items: SelectedItem[] = relatedDetails.map((d: any) => ({ itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "", qty: Number(d.detail_qty || 0), remark: d.detail_remark || "", @@ -465,6 +477,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, remark: editRemark, + infos: editInfos.map((s) => s.trim()).filter(Boolean), routing: editRouting || null, items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, @@ -782,6 +795,7 @@ export default function WorkInstructionPage() {
+

품목 목록

@@ -900,6 +914,8 @@ export default function WorkInstructionPage() {
+ + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
diff --git a/frontend/app/(main)/COMPANY_29/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/outbound/page.tsx index 114bd060..02c6b617 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/outbound/page.tsx @@ -102,7 +102,7 @@ const GRID_COLUMNS = [ { key: "outbound_type", label: "출고유형" }, { key: "outbound_date", label: "출고일" }, { key: "reference_number", label: "참조번호" }, - { key: "source_type", label: "데이터출처" }, + { key: "source_table", label: "데이터출처" }, { key: "customer_name", label: "거래처" }, { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품목명" }, @@ -217,7 +217,7 @@ interface SelectedSourceItem { outbound_qty: number; unit_price: number; total_amount: number; - source_type: string; + source_table: string; source_id: string; } @@ -348,13 +348,13 @@ export default function OutboundPage() { })(); }, []); - // 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등) + // 플랫 행 생성 (출고유형 코드→라벨 변환, source_table→한글 등) const flatRows = useMemo(() => { return data.map((row) => ({ ...row, _raw_outbound_type: row.outbound_type, outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "", - source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "", + source_table: row.source_table ? (SOURCE_TYPE_LABEL[row.source_table] || row.source_table) : "", item_number: row.item_code || (row as any).item_number || "", spec: row.specification || (row as any).spec || "", })); @@ -554,7 +554,7 @@ export default function OutboundPage() { outbound_qty: Number(g.outbound_qty) || 0, unit_price: Number(g.unit_price) || 0, total_amount: Number(g.total_amount) || 0, - source_type: g.source_type || "", + source_table: g.source_table || "", source_id: (g as any).source_id || "", })) ); @@ -632,7 +632,7 @@ export default function OutboundPage() { outbound_qty: si.remain_qty, unit_price: price, total_amount: si.remain_qty * price, - source_type: "shipment_instruction_detail", + source_table: "shipment_instruction_detail", source_id: String(si.detail_id), }, ]); @@ -658,7 +658,7 @@ export default function OutboundPage() { outbound_qty: po.received_qty, unit_price: po.unit_price, total_amount: po.received_qty * po.unit_price, - source_type: "purchase_order_mng", + source_table: "purchase_order_mng", source_id: po.id, }, ]); @@ -684,7 +684,7 @@ export default function OutboundPage() { outbound_qty: 0, unit_price: item.standard_price, total_amount: 0, - source_type: "item_info", + source_table: "item_info", source_id: item.id, }, ]); @@ -780,7 +780,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -811,7 +811,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -1004,7 +1004,7 @@ export default function OutboundPage() { {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} {row.reference_number || ""} - {row.source_type || ""} + {row.source_table || ""} {row.customer_name || ""} {row.item_number || ""} {row.item_name || ""} diff --git a/frontend/app/(main)/COMPANY_29/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_29/production/work-instruction/WorkStandardEditModal.tsx index ccb0afda..b871a7fb 100644 --- a/frontend/app/(main)/COMPANY_29/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_29/production/work-instruction/WorkStandardEditModal.tsx @@ -128,7 +128,10 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") { + const mLabel = detail.material_input_type === "manual" ? "[수동]" : "[자동]"; + return `${detail.content || "BOM 구성 자재 (자동 연동)"} ${mLabel}`; + } return detail.content || "-"; }; @@ -159,6 +162,8 @@ function DetailFormModalInner({ const [bomMaterials, setBomMaterials] = useState([]); const [bomLoading, setBomLoading] = useState(false); const [bomChecked, setBomChecked] = useState>(new Set()); + // 자재투입 자동/수동 구분 — Map<자재행id, "auto"|"manual"> (TASK:ERP-node-096) + const [bomInputType, setBomInputType] = useState>(new Map()); // 품목검사정보 연동 const [itemInspections, setItemInspections] = useState([]); @@ -215,10 +220,21 @@ function DetailFormModalInner({ } } setBomChecked(restored); + setBomInputType(new Map()); + return; + } + } + // 자재별 개별 detail(현행) 편집 — 해당 자재 1건 체크 + 자동/수동 복원 (TASK:ERP-node-096) + if (mode === "edit" && editData?.detail_type === "material_input" && editData.bom_item_id) { + const mat = bomMaterials.find((m) => m.child_item_id === String(editData.bom_item_id)); + if (mat) { + setBomChecked(new Set([mat.id])); + setBomInputType(new Map([[mat.id, editData.material_input_type === "manual" ? "manual" : "auto"]])); return; } } setBomChecked(new Set()); + setBomInputType(new Map()); }, [open, bomMaterials, mode, editData]); useEffect(() => { @@ -375,6 +391,7 @@ function DetailFormModalInner({ bom_item_name: mat.child_item_name || "", bom_qty: String(mat.quantity || 1), bom_unit: unitLabel, + material_input_type: bomInputType.get(mat.id) || "auto", }); } onClose(); @@ -1072,6 +1089,39 @@ function DetailFormModalInner({ {mat.process_type ? ` | 공정: ${mat.process_type}` : ""}
+ {/* 자동/수동 투입 토글 — 체크된 자재만 (TASK:ERP-node-096) */} + {bomChecked.has(mat.id) && ( +
+ + +
+ )} ); }) diff --git a/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx index 110b2b4d..10ddcc03 100644 --- a/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx @@ -17,13 +17,14 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList, - getRoutingVersions, RoutingVersionData, + getRoutingVersions, RoutingVersionData, getWIInfos, } from "@/lib/api/workInstruction"; import { WorkStandardEditModal } from "./WorkStandardEditModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { WorkInstructionInfoSection } from "@/components/common/WorkInstructionInfoSection"; const GRID_COLUMNS = [ { key: "work_instruction_no", label: "작업지시번호" }, @@ -180,6 +181,8 @@ export default function WorkInstructionPage() { const [confirmWorkTeam, setConfirmWorkTeam] = useState(""); const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 작업지시 인포(메모) — POP 상단 표시용 (TASK:ERP-node-095) + const [confirmInfos, setConfirmInfos] = useState([]); // 등록 확인 모달 — 인라인 추가 폼 const [confirmAddQty, setConfirmAddQty] = useState(""); @@ -196,6 +199,7 @@ export default function WorkInstructionPage() { const [editWorkTeam, setEditWorkTeam] = useState(""); const [editWorker, setEditWorker] = useState(""); const [editRemark, setEditRemark] = useState(""); + const [editInfos, setEditInfos] = useState([]); const [editSaving, setEditSaving] = useState(false); const [addQty, setAddQty] = useState(""); const [addEquipment, setAddEquipment] = useState(""); @@ -316,7 +320,7 @@ export default function WorkInstructionPage() { setConfirmWiNo("불러오는 중..."); setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]); setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker(""); - setConfirmRouting(""); setConfirmRoutingOptions([]); + setConfirmRouting(""); setConfirmRoutingOptions([]); setConfirmInfos([]); previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); // 품목별 라우팅 옵션 로드 @@ -369,6 +373,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, + infos: confirmInfos.map((s) => s.trim()).filter(Boolean), items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, @@ -395,6 +400,13 @@ export default function WorkInstructionPage() { setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || ""); setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || ""); setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || ""); + // 작업지시 인포(메모) 복원 (TASK:ERP-node-095) + setEditInfos([]); + getWIInfos(wiNo) + .then((res) => { + if (res.success && Array.isArray(res.data)) setEditInfos(res.data.map((d) => d.content)); + }) + .catch(() => {}); const items: SelectedItem[] = relatedDetails.map((d: any) => ({ itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "", qty: Number(d.detail_qty || 0), remark: d.detail_remark || "", @@ -465,6 +477,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, remark: editRemark, + infos: editInfos.map((s) => s.trim()).filter(Boolean), routing: editRouting || null, items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, @@ -782,6 +795,7 @@ export default function WorkInstructionPage() {
+

품목 목록

@@ -900,6 +914,8 @@ export default function WorkInstructionPage() {
+ + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
diff --git a/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx index 1aabe849..a5123af0 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx @@ -133,7 +133,7 @@ const GRID_COLUMNS = [ { key: "outbound_type", label: "출고유형" }, { key: "outbound_date", label: "출고일" }, { key: "reference_number", label: "참조번호" }, - { key: "source_type", label: "데이터출처" }, + { key: "source_table", label: "데이터출처" }, { key: "customer_name", label: "거래처" }, { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품목명" }, @@ -251,7 +251,7 @@ interface SelectedSourceItem { outbound_qty: number; unit_price: number; total_amount: number; - source_type: string; + source_table: string; source_id: string; } @@ -382,13 +382,13 @@ export default function OutboundPage() { })(); }, []); - // 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등) + // 플랫 행 생성 (출고유형 코드→라벨 변환, source_table→한글 등) const flatRows = useMemo(() => { return data.map((row) => ({ ...row, _raw_outbound_type: row.outbound_type, outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "", - source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "", + source_table: row.source_table ? (SOURCE_TYPE_LABEL[row.source_table] || row.source_table) : "", item_number: row.item_code || (row as any).item_number || "", spec: row.specification || (row as any).spec || "", })); @@ -594,7 +594,7 @@ export default function OutboundPage() { outbound_qty: Number(g.outbound_qty) || 0, unit_price: Number(g.unit_price) || 0, total_amount: Number(g.total_amount) || 0, - source_type: g.source_type || "", + source_table: g.source_table || "", source_id: (g as any).source_id || "", })) ); @@ -674,7 +674,7 @@ export default function OutboundPage() { outbound_qty: si.remain_qty, unit_price: 0, total_amount: 0, - source_type: "shipment_instruction_detail", + source_table: "shipment_instruction_detail", source_id: String(si.detail_id), }, ]); @@ -703,7 +703,7 @@ export default function OutboundPage() { outbound_qty: po.received_qty, unit_price: po.unit_price, total_amount: po.received_qty * po.unit_price, - source_type: "purchase_order_mng", + source_table: "purchase_order_mng", source_id: po.id, }, ]); @@ -732,7 +732,7 @@ export default function OutboundPage() { outbound_qty: 0, unit_price: item.standard_price, total_amount: 0, - source_type: "item_info", + source_table: "item_info", source_id: item.id, }, ]); @@ -828,7 +828,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -859,7 +859,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -1055,7 +1055,7 @@ export default function OutboundPage() { {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} {row.reference_number || ""} - {row.source_type || ""} + {row.source_table || ""} {row.customer_name || ""} {row.item_number || ""} {row.item_name || ""} diff --git a/frontend/app/(main)/COMPANY_31/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_31/logistics/outbound/page.tsx index b00ae3d6..e9a80d97 100644 --- a/frontend/app/(main)/COMPANY_31/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_31/logistics/outbound/page.tsx @@ -102,7 +102,7 @@ const GRID_COLUMNS = [ { key: "outbound_type", label: "출고유형" }, { key: "outbound_date", label: "출고일" }, { key: "reference_number", label: "참조번호" }, - { key: "source_type", label: "데이터출처" }, + { key: "source_table", label: "데이터출처" }, { key: "customer_name", label: "거래처" }, { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품목명" }, @@ -217,7 +217,7 @@ interface SelectedSourceItem { outbound_qty: number; unit_price: number; total_amount: number; - source_type: string; + source_table: string; source_id: string; } @@ -348,13 +348,13 @@ export default function OutboundPage() { })(); }, []); - // 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등) + // 플랫 행 생성 (출고유형 코드→라벨 변환, source_table→한글 등) const flatRows = useMemo(() => { return data.map((row) => ({ ...row, _raw_outbound_type: row.outbound_type, outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "", - source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "", + source_table: row.source_table ? (SOURCE_TYPE_LABEL[row.source_table] || row.source_table) : "", item_number: row.item_code || (row as any).item_number || "", spec: row.specification || (row as any).spec || "", })); @@ -554,7 +554,7 @@ export default function OutboundPage() { outbound_qty: Number(g.outbound_qty) || 0, unit_price: Number(g.unit_price) || 0, total_amount: Number(g.total_amount) || 0, - source_type: g.source_type || "", + source_table: g.source_table || "", source_id: (g as any).source_id || "", })) ); @@ -632,7 +632,7 @@ export default function OutboundPage() { outbound_qty: si.remain_qty, unit_price: price, total_amount: si.remain_qty * price, - source_type: "shipment_instruction_detail", + source_table: "shipment_instruction_detail", source_id: String(si.detail_id), }, ]); @@ -658,7 +658,7 @@ export default function OutboundPage() { outbound_qty: po.received_qty, unit_price: po.unit_price, total_amount: po.received_qty * po.unit_price, - source_type: "purchase_order_mng", + source_table: "purchase_order_mng", source_id: po.id, }, ]); @@ -684,7 +684,7 @@ export default function OutboundPage() { outbound_qty: 0, unit_price: item.standard_price, total_amount: 0, - source_type: "item_info", + source_table: "item_info", source_id: item.id, }, ]); @@ -780,7 +780,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -811,7 +811,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -1004,7 +1004,7 @@ export default function OutboundPage() { {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} {row.reference_number || ""} - {row.source_type || ""} + {row.source_table || ""} {row.customer_name || ""} {row.item_number || ""} {row.item_name || ""} diff --git a/frontend/app/(main)/COMPANY_31/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_31/production/work-instruction/WorkStandardEditModal.tsx index 6be1dafd..18513d0a 100644 --- a/frontend/app/(main)/COMPANY_31/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_31/production/work-instruction/WorkStandardEditModal.tsx @@ -132,7 +132,10 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량 / 손실수량"; - if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") { + const mLabel = detail.material_input_type === "manual" ? "[수동]" : "[자동]"; + return `${detail.content || "BOM 구성 자재 (자동 연동)"} ${mLabel}`; + } return detail.content || "-"; }; @@ -163,6 +166,8 @@ function DetailFormModalInner({ const [bomMaterials, setBomMaterials] = useState([]); const [bomLoading, setBomLoading] = useState(false); const [bomChecked, setBomChecked] = useState>(new Set()); + // 자재투입 자동/수동 구분 — Map<자재행id, "auto"|"manual"> (TASK:ERP-node-096) + const [bomInputType, setBomInputType] = useState>(new Map()); // 품목검사정보 연동 const [itemInspections, setItemInspections] = useState([]); @@ -219,10 +224,21 @@ function DetailFormModalInner({ } } setBomChecked(restored); + setBomInputType(new Map()); + return; + } + } + // 자재별 개별 detail(현행) 편집 — 해당 자재 1건 체크 + 자동/수동 복원 (TASK:ERP-node-096) + if (mode === "edit" && editData?.detail_type === "material_input" && editData.bom_item_id) { + const mat = bomMaterials.find((m) => m.child_item_id === String(editData.bom_item_id)); + if (mat) { + setBomChecked(new Set([mat.id])); + setBomInputType(new Map([[mat.id, editData.material_input_type === "manual" ? "manual" : "auto"]])); return; } } setBomChecked(new Set()); + setBomInputType(new Map()); }, [open, bomMaterials, mode, editData]); useEffect(() => { @@ -379,6 +395,7 @@ function DetailFormModalInner({ bom_item_name: mat.child_item_name || "", bom_qty: String(mat.quantity || 1), bom_unit: unitLabel, + material_input_type: bomInputType.get(mat.id) || "auto", }); } onClose(); @@ -1110,6 +1127,39 @@ function DetailFormModalInner({ {mat.process_type ? ` | 공정: ${mat.process_type}` : ""}
+ {/* 자동/수동 투입 토글 — 체크된 자재만 (TASK:ERP-node-096) */} + {bomChecked.has(mat.id) && ( +
+ + +
+ )} ); }) diff --git a/frontend/app/(main)/COMPANY_31/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_31/production/work-instruction/page.tsx index 7a6de390..fd0a77c1 100644 --- a/frontend/app/(main)/COMPANY_31/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_31/production/work-instruction/page.tsx @@ -17,13 +17,14 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, getBomBaseQtyMap, getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList, - getRoutingVersions, RoutingVersionData, + getRoutingVersions, RoutingVersionData, getWIInfos, } from "@/lib/api/workInstruction"; import { WorkStandardEditModal } from "./WorkStandardEditModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { WorkInstructionInfoSection } from "@/components/common/WorkInstructionInfoSection"; const GRID_COLUMNS = [ { key: "work_instruction_no", label: "작업지시번호" }, @@ -205,6 +206,8 @@ export default function WorkInstructionPage() { const [confirmWorkTeam, setConfirmWorkTeam] = useState(""); const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 작업지시 인포(메모) — POP 상단 표시용 (TASK:ERP-node-095) + const [confirmInfos, setConfirmInfos] = useState([]); // 등록 확인 모달 — 인라인 추가 폼 const [confirmAddQty, setConfirmAddQty] = useState(""); @@ -221,6 +224,7 @@ export default function WorkInstructionPage() { const [editWorkTeam, setEditWorkTeam] = useState(""); const [editWorker, setEditWorker] = useState(""); const [editRemark, setEditRemark] = useState(""); + const [editInfos, setEditInfos] = useState([]); const [editSaving, setEditSaving] = useState(false); const [addQty, setAddQty] = useState(""); const [addEquipment, setAddEquipment] = useState(""); @@ -341,7 +345,7 @@ export default function WorkInstructionPage() { setConfirmWiNo("불러오는 중..."); setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]); setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker(""); - setConfirmRouting(""); setConfirmRoutingOptions([]); + setConfirmRouting(""); setConfirmRoutingOptions([]); setConfirmInfos([]); previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); // 품목별 라우팅 옵션 로드 @@ -420,6 +424,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, + infos: confirmInfos.map((s) => s.trim()).filter(Boolean), items: expandedItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i._qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, @@ -446,6 +451,13 @@ export default function WorkInstructionPage() { setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || ""); setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || ""); setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || ""); + // 작업지시 인포(메모) 복원 (TASK:ERP-node-095) + setEditInfos([]); + getWIInfos(wiNo) + .then((res) => { + if (res.success && Array.isArray(res.data)) setEditInfos(res.data.map((d) => d.content)); + }) + .catch(() => {}); const items: SelectedItem[] = relatedDetails.map((d: any) => ({ itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "", qty: Number(d.detail_qty || 0), remark: d.detail_remark || "", @@ -516,6 +528,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, remark: editRemark, + infos: editInfos.map((s) => s.trim()).filter(Boolean), routing: editRouting || null, items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, @@ -833,6 +846,7 @@ export default function WorkInstructionPage() {
+

품목 목록

@@ -977,6 +991,8 @@ export default function WorkInstructionPage() {
+ + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
diff --git a/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx index 114bd060..02c6b617 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx @@ -102,7 +102,7 @@ const GRID_COLUMNS = [ { key: "outbound_type", label: "출고유형" }, { key: "outbound_date", label: "출고일" }, { key: "reference_number", label: "참조번호" }, - { key: "source_type", label: "데이터출처" }, + { key: "source_table", label: "데이터출처" }, { key: "customer_name", label: "거래처" }, { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품목명" }, @@ -217,7 +217,7 @@ interface SelectedSourceItem { outbound_qty: number; unit_price: number; total_amount: number; - source_type: string; + source_table: string; source_id: string; } @@ -348,13 +348,13 @@ export default function OutboundPage() { })(); }, []); - // 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등) + // 플랫 행 생성 (출고유형 코드→라벨 변환, source_table→한글 등) const flatRows = useMemo(() => { return data.map((row) => ({ ...row, _raw_outbound_type: row.outbound_type, outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "", - source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "", + source_table: row.source_table ? (SOURCE_TYPE_LABEL[row.source_table] || row.source_table) : "", item_number: row.item_code || (row as any).item_number || "", spec: row.specification || (row as any).spec || "", })); @@ -554,7 +554,7 @@ export default function OutboundPage() { outbound_qty: Number(g.outbound_qty) || 0, unit_price: Number(g.unit_price) || 0, total_amount: Number(g.total_amount) || 0, - source_type: g.source_type || "", + source_table: g.source_table || "", source_id: (g as any).source_id || "", })) ); @@ -632,7 +632,7 @@ export default function OutboundPage() { outbound_qty: si.remain_qty, unit_price: price, total_amount: si.remain_qty * price, - source_type: "shipment_instruction_detail", + source_table: "shipment_instruction_detail", source_id: String(si.detail_id), }, ]); @@ -658,7 +658,7 @@ export default function OutboundPage() { outbound_qty: po.received_qty, unit_price: po.unit_price, total_amount: po.received_qty * po.unit_price, - source_type: "purchase_order_mng", + source_table: "purchase_order_mng", source_id: po.id, }, ]); @@ -684,7 +684,7 @@ export default function OutboundPage() { outbound_qty: 0, unit_price: item.standard_price, total_amount: 0, - source_type: "item_info", + source_table: "item_info", source_id: item.id, }, ]); @@ -780,7 +780,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -811,7 +811,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -1004,7 +1004,7 @@ export default function OutboundPage() { {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} {row.reference_number || ""} - {row.source_type || ""} + {row.source_table || ""} {row.customer_name || ""} {row.item_number || ""} {row.item_name || ""} diff --git a/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx index 6be1dafd..18513d0a 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx @@ -132,7 +132,10 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량 / 손실수량"; - if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") { + const mLabel = detail.material_input_type === "manual" ? "[수동]" : "[자동]"; + return `${detail.content || "BOM 구성 자재 (자동 연동)"} ${mLabel}`; + } return detail.content || "-"; }; @@ -163,6 +166,8 @@ function DetailFormModalInner({ const [bomMaterials, setBomMaterials] = useState([]); const [bomLoading, setBomLoading] = useState(false); const [bomChecked, setBomChecked] = useState>(new Set()); + // 자재투입 자동/수동 구분 — Map<자재행id, "auto"|"manual"> (TASK:ERP-node-096) + const [bomInputType, setBomInputType] = useState>(new Map()); // 품목검사정보 연동 const [itemInspections, setItemInspections] = useState([]); @@ -219,10 +224,21 @@ function DetailFormModalInner({ } } setBomChecked(restored); + setBomInputType(new Map()); + return; + } + } + // 자재별 개별 detail(현행) 편집 — 해당 자재 1건 체크 + 자동/수동 복원 (TASK:ERP-node-096) + if (mode === "edit" && editData?.detail_type === "material_input" && editData.bom_item_id) { + const mat = bomMaterials.find((m) => m.child_item_id === String(editData.bom_item_id)); + if (mat) { + setBomChecked(new Set([mat.id])); + setBomInputType(new Map([[mat.id, editData.material_input_type === "manual" ? "manual" : "auto"]])); return; } } setBomChecked(new Set()); + setBomInputType(new Map()); }, [open, bomMaterials, mode, editData]); useEffect(() => { @@ -379,6 +395,7 @@ function DetailFormModalInner({ bom_item_name: mat.child_item_name || "", bom_qty: String(mat.quantity || 1), bom_unit: unitLabel, + material_input_type: bomInputType.get(mat.id) || "auto", }); } onClose(); @@ -1110,6 +1127,39 @@ function DetailFormModalInner({ {mat.process_type ? ` | 공정: ${mat.process_type}` : ""}
+ {/* 자동/수동 투입 토글 — 체크된 자재만 (TASK:ERP-node-096) */} + {bomChecked.has(mat.id) && ( +
+ + +
+ )} ); }) diff --git a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx index afd438dd..0bd0a405 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx @@ -56,11 +56,13 @@ import { RoutingVersionData, getWIBomSubstitutes, getWIMaterialOverrides, + getWIInfos, getWIBomTree, WIBomSubstitute, WIBomTreeNode, } from "@/lib/api/workInstruction"; import { SmartSelect } from "@/components/common/SmartSelect"; +import { WorkInstructionInfoSection } from "@/components/common/WorkInstructionInfoSection"; import { WorkStandardEditModal } from "./WorkStandardEditModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; @@ -350,6 +352,8 @@ export default function WorkInstructionPage() { const [confirmWorkTeam, setConfirmWorkTeam] = useState(""); const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 작업지시 인포(메모) — POP 상단 표시용 (TASK:ERP-node-095) + const [confirmInfos, setConfirmInfos] = useState([]); // 등록 확인 모달 — 인라인 추가 폼 const [confirmAddQty, setConfirmAddQty] = useState(""); @@ -366,6 +370,7 @@ export default function WorkInstructionPage() { const [editWorkTeam, setEditWorkTeam] = useState(""); const [editWorker, setEditWorker] = useState(""); const [editRemark, setEditRemark] = useState(""); + const [editInfos, setEditInfos] = useState([]); const [editSaving, setEditSaving] = useState(false); const [addQty, setAddQty] = useState(""); const [addEquipment, setAddEquipment] = useState(""); @@ -605,6 +610,7 @@ export default function WorkInstructionPage() { setConfirmWorker(""); setConfirmRouting(""); setConfirmRoutingOptions([]); + setConfirmInfos([]); // BOM 자재 매핑 state 초기화 (TASK:ERP-node-090) setConfirmMaterialMap({}); setConfirmExpandedItems(new Set()); @@ -1007,6 +1013,7 @@ export default function WorkInstructionPage() { workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, + infos: confirmInfos.map((s) => s.trim()).filter(Boolean), items: expandedItems.map((i) => ({ itemNumber: i.itemCode, itemCode: i.itemCode, @@ -1055,6 +1062,13 @@ export default function WorkInstructionPage() { setEditWorkTeam(order.work_team || ""); setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || ""); + // 작업지시 인포(메모) 복원 (TASK:ERP-node-095) + setEditInfos([]); + getWIInfos(wiNo) + .then((res) => { + if (res.success && Array.isArray(res.data)) setEditInfos(res.data.map((d) => d.content)); + }) + .catch(() => {}); const items: SelectedItem[] = relatedDetails.map((d: any) => ({ itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", @@ -1274,6 +1288,7 @@ export default function WorkInstructionPage() { workTeam: headerWorkTeam, worker: headerWorker, remark: editRemark, + infos: editInfos.map((s) => s.trim()).filter(Boolean), routing: editRouting || null, items: editItems.map((i) => ({ itemNumber: i.itemCode, @@ -2227,6 +2242,7 @@ export default function WorkInstructionPage() { +

품목 목록

@@ -2603,6 +2619,8 @@ export default function WorkInstructionPage() {
+ + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
diff --git a/frontend/app/(main)/COMPANY_8/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_8/logistics/outbound/page.tsx index f5f257b0..de981787 100644 --- a/frontend/app/(main)/COMPANY_8/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_8/logistics/outbound/page.tsx @@ -110,7 +110,7 @@ const GRID_COLUMNS = [ { key: "outbound_type", label: "출고유형" }, { key: "outbound_date", label: "출고일" }, { key: "reference_number", label: "참조번호" }, - { key: "source_type", label: "데이터출처" }, + { key: "source_table", label: "데이터출처" }, { key: "customer_name", label: "거래처" }, { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품목명" }, @@ -225,7 +225,7 @@ interface SelectedSourceItem { outbound_qty: number; unit_price: number; total_amount: number; - source_type: string; + source_table: string; source_id: string; } @@ -368,13 +368,13 @@ export default function OutboundPage() { })(); }, []); - // 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등) + // 플랫 행 생성 (출고유형 코드→라벨 변환, source_table→한글 등) const flatRows = useMemo(() => { return data.map((row) => ({ ...row, _raw_outbound_type: row.outbound_type, outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "", - source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "", + source_table: row.source_table ? (SOURCE_TYPE_LABEL[row.source_table] || row.source_table) : "", item_number: row.item_code || (row as any).item_number || "", spec: row.specification || (row as any).spec || "", })); @@ -853,7 +853,7 @@ export default function OutboundPage() { outbound_qty: Number(g.outbound_qty) || 0, unit_price: Number(g.unit_price) || 0, total_amount: Number(g.total_amount) || 0, - source_type: g.source_type || "", + source_table: g.source_table || "", source_id: (g as any).source_id || "", })) ); @@ -931,7 +931,7 @@ export default function OutboundPage() { outbound_qty: si.remain_qty, unit_price: price, total_amount: si.remain_qty * price, - source_type: "shipment_instruction_detail", + source_table: "shipment_instruction_detail", source_id: String(si.detail_id), }, ]); @@ -957,7 +957,7 @@ export default function OutboundPage() { outbound_qty: po.received_qty, unit_price: po.unit_price, total_amount: po.received_qty * po.unit_price, - source_type: "purchase_order_mng", + source_table: "purchase_order_mng", source_id: po.id, }, ]); @@ -984,7 +984,7 @@ export default function OutboundPage() { outbound_qty: 0, unit_price: item.standard_price, total_amount: 0, - source_type: "item_info", + source_table: "item_info", source_id: item.id, }, ]); @@ -1080,7 +1080,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -1111,7 +1111,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -1334,7 +1334,7 @@ export default function OutboundPage() { {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} {row.reference_number || ""} - {row.source_type || ""} + {row.source_table || ""} {row.customer_name || ""} {row.item_number || ""} {row.item_name || ""} diff --git a/frontend/app/(main)/COMPANY_8/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_8/production/work-instruction/WorkStandardEditModal.tsx index ccb0afda..b871a7fb 100644 --- a/frontend/app/(main)/COMPANY_8/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_8/production/work-instruction/WorkStandardEditModal.tsx @@ -128,7 +128,10 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") { + const mLabel = detail.material_input_type === "manual" ? "[수동]" : "[자동]"; + return `${detail.content || "BOM 구성 자재 (자동 연동)"} ${mLabel}`; + } return detail.content || "-"; }; @@ -159,6 +162,8 @@ function DetailFormModalInner({ const [bomMaterials, setBomMaterials] = useState([]); const [bomLoading, setBomLoading] = useState(false); const [bomChecked, setBomChecked] = useState>(new Set()); + // 자재투입 자동/수동 구분 — Map<자재행id, "auto"|"manual"> (TASK:ERP-node-096) + const [bomInputType, setBomInputType] = useState>(new Map()); // 품목검사정보 연동 const [itemInspections, setItemInspections] = useState([]); @@ -215,10 +220,21 @@ function DetailFormModalInner({ } } setBomChecked(restored); + setBomInputType(new Map()); + return; + } + } + // 자재별 개별 detail(현행) 편집 — 해당 자재 1건 체크 + 자동/수동 복원 (TASK:ERP-node-096) + if (mode === "edit" && editData?.detail_type === "material_input" && editData.bom_item_id) { + const mat = bomMaterials.find((m) => m.child_item_id === String(editData.bom_item_id)); + if (mat) { + setBomChecked(new Set([mat.id])); + setBomInputType(new Map([[mat.id, editData.material_input_type === "manual" ? "manual" : "auto"]])); return; } } setBomChecked(new Set()); + setBomInputType(new Map()); }, [open, bomMaterials, mode, editData]); useEffect(() => { @@ -375,6 +391,7 @@ function DetailFormModalInner({ bom_item_name: mat.child_item_name || "", bom_qty: String(mat.quantity || 1), bom_unit: unitLabel, + material_input_type: bomInputType.get(mat.id) || "auto", }); } onClose(); @@ -1072,6 +1089,39 @@ function DetailFormModalInner({ {mat.process_type ? ` | 공정: ${mat.process_type}` : ""}
+ {/* 자동/수동 투입 토글 — 체크된 자재만 (TASK:ERP-node-096) */} + {bomChecked.has(mat.id) && ( +
+ + +
+ )} ); }) diff --git a/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx index e5ec0009..0c65fd25 100644 --- a/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx @@ -17,13 +17,14 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList, - getRoutingVersions, RoutingVersionData, + getRoutingVersions, RoutingVersionData, getWIInfos, } from "@/lib/api/workInstruction"; import { WorkStandardEditModal } from "./WorkStandardEditModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { WorkInstructionInfoSection } from "@/components/common/WorkInstructionInfoSection"; const GRID_COLUMNS = [ { key: "work_instruction_no", label: "작업지시번호" }, @@ -180,6 +181,8 @@ export default function WorkInstructionPage() { const [confirmWorkTeam, setConfirmWorkTeam] = useState(""); const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 작업지시 인포(메모) — POP 상단 표시용 (TASK:ERP-node-095) + const [confirmInfos, setConfirmInfos] = useState([]); // 등록 확인 모달 — 인라인 추가 폼 const [confirmAddQty, setConfirmAddQty] = useState(""); @@ -196,6 +199,7 @@ export default function WorkInstructionPage() { const [editWorkTeam, setEditWorkTeam] = useState(""); const [editWorker, setEditWorker] = useState(""); const [editRemark, setEditRemark] = useState(""); + const [editInfos, setEditInfos] = useState([]); const [editSaving, setEditSaving] = useState(false); const [addQty, setAddQty] = useState(""); const [addEquipment, setAddEquipment] = useState(""); @@ -316,7 +320,7 @@ export default function WorkInstructionPage() { setConfirmWiNo("불러오는 중..."); setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]); setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker(""); - setConfirmRouting(""); setConfirmRoutingOptions([]); + setConfirmRouting(""); setConfirmRoutingOptions([]); setConfirmInfos([]); previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); // 품목별 라우팅 옵션 로드 @@ -369,6 +373,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, + infos: confirmInfos.map((s) => s.trim()).filter(Boolean), items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, @@ -395,6 +400,13 @@ export default function WorkInstructionPage() { setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || ""); setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || ""); setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || ""); + // 작업지시 인포(메모) 복원 (TASK:ERP-node-095) + setEditInfos([]); + getWIInfos(wiNo) + .then((res) => { + if (res.success && Array.isArray(res.data)) setEditInfos(res.data.map((d) => d.content)); + }) + .catch(() => {}); const items: SelectedItem[] = relatedDetails.map((d: any) => ({ itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "", qty: Number(d.detail_qty || 0), remark: d.detail_remark || "", @@ -465,6 +477,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, remark: editRemark, + infos: editInfos.map((s) => s.trim()).filter(Boolean), routing: editRouting || null, items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, @@ -789,6 +802,7 @@ export default function WorkInstructionPage() {
+

품목 목록

@@ -907,6 +921,8 @@ export default function WorkInstructionPage() {
+ + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
diff --git a/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx index 4934e5e5..258eb2dc 100644 --- a/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx @@ -102,7 +102,7 @@ const GRID_COLUMNS = [ { key: "outbound_type", label: "출고유형" }, { key: "outbound_date", label: "출고일" }, { key: "reference_number", label: "참조번호" }, - { key: "source_type", label: "데이터출처" }, + { key: "source_table", label: "데이터출처" }, { key: "customer_name", label: "거래처" }, { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품목명" }, @@ -220,7 +220,7 @@ interface SelectedSourceItem { outbound_qty: number; unit_price: number; total_amount: number; - source_type: string; + source_table: string; source_id: string; } @@ -351,13 +351,13 @@ export default function OutboundPage() { })(); }, []); - // 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등) + // 플랫 행 생성 (출고유형 코드→라벨 변환, source_table→한글 등) const flatRows = useMemo(() => { return data.map((row) => ({ ...row, _raw_outbound_type: row.outbound_type, outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "", - source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "", + source_table: row.source_table ? (SOURCE_TYPE_LABEL[row.source_table] || row.source_table) : "", item_number: row.item_code || (row as any).item_number || "", spec: row.specification || (row as any).spec || "", })); @@ -557,7 +557,7 @@ export default function OutboundPage() { outbound_qty: Number(g.outbound_qty) || 0, unit_price: Number(g.unit_price) || 0, total_amount: Number(g.total_amount) || 0, - source_type: g.source_type || "", + source_table: g.source_table || "", source_id: (g as any).source_id || "", })) ); @@ -638,7 +638,7 @@ export default function OutboundPage() { outbound_qty: si.remain_qty, unit_price: price, total_amount: si.remain_qty * price, - source_type: "shipment_instruction_detail", + source_table: "shipment_instruction_detail", source_id: String(si.detail_id), }, ]); @@ -667,7 +667,7 @@ export default function OutboundPage() { outbound_qty: po.received_qty, unit_price: po.unit_price, total_amount: po.received_qty * po.unit_price, - source_type: "purchase_order_mng", + source_table: "purchase_order_mng", source_id: po.id, }, ]); @@ -696,7 +696,7 @@ export default function OutboundPage() { outbound_qty: 0, unit_price: item.standard_price, total_amount: 0, - source_type: "item_info", + source_table: "item_info", source_id: item.id, }, ]); @@ -792,7 +792,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -823,7 +823,7 @@ export default function OutboundPage() { outbound_qty: item.outbound_qty, unit_price: item.unit_price, total_amount: item.total_amount, - source_type: item.source_type, + source_table: item.source_table, source_id: item.source_id, outbound_status: "출고완료", })), @@ -1019,7 +1019,7 @@ export default function OutboundPage() { {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} {row.reference_number || ""} - {row.source_type || ""} + {row.source_table || ""} {row.customer_name || ""} {row.item_number || ""} {row.item_name || ""} diff --git a/frontend/app/(main)/COMPANY_9/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_9/production/work-instruction/WorkStandardEditModal.tsx index ccb0afda..b871a7fb 100644 --- a/frontend/app/(main)/COMPANY_9/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_9/production/work-instruction/WorkStandardEditModal.tsx @@ -128,7 +128,10 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") { + const mLabel = detail.material_input_type === "manual" ? "[수동]" : "[자동]"; + return `${detail.content || "BOM 구성 자재 (자동 연동)"} ${mLabel}`; + } return detail.content || "-"; }; @@ -159,6 +162,8 @@ function DetailFormModalInner({ const [bomMaterials, setBomMaterials] = useState([]); const [bomLoading, setBomLoading] = useState(false); const [bomChecked, setBomChecked] = useState>(new Set()); + // 자재투입 자동/수동 구분 — Map<자재행id, "auto"|"manual"> (TASK:ERP-node-096) + const [bomInputType, setBomInputType] = useState>(new Map()); // 품목검사정보 연동 const [itemInspections, setItemInspections] = useState([]); @@ -215,10 +220,21 @@ function DetailFormModalInner({ } } setBomChecked(restored); + setBomInputType(new Map()); + return; + } + } + // 자재별 개별 detail(현행) 편집 — 해당 자재 1건 체크 + 자동/수동 복원 (TASK:ERP-node-096) + if (mode === "edit" && editData?.detail_type === "material_input" && editData.bom_item_id) { + const mat = bomMaterials.find((m) => m.child_item_id === String(editData.bom_item_id)); + if (mat) { + setBomChecked(new Set([mat.id])); + setBomInputType(new Map([[mat.id, editData.material_input_type === "manual" ? "manual" : "auto"]])); return; } } setBomChecked(new Set()); + setBomInputType(new Map()); }, [open, bomMaterials, mode, editData]); useEffect(() => { @@ -375,6 +391,7 @@ function DetailFormModalInner({ bom_item_name: mat.child_item_name || "", bom_qty: String(mat.quantity || 1), bom_unit: unitLabel, + material_input_type: bomInputType.get(mat.id) || "auto", }); } onClose(); @@ -1072,6 +1089,39 @@ function DetailFormModalInner({ {mat.process_type ? ` | 공정: ${mat.process_type}` : ""}
+ {/* 자동/수동 투입 토글 — 체크된 자재만 (TASK:ERP-node-096) */} + {bomChecked.has(mat.id) && ( +
+ + +
+ )} ); }) diff --git a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx index c5a2ecda..7cb6ab2e 100644 --- a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx @@ -17,13 +17,14 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList, - getRoutingVersions, RoutingVersionData, + getRoutingVersions, RoutingVersionData, getWIInfos, } from "@/lib/api/workInstruction"; import { WorkStandardEditModal } from "./WorkStandardEditModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { WorkInstructionInfoSection } from "@/components/common/WorkInstructionInfoSection"; const GRID_COLUMNS = [ { key: "work_instruction_no", label: "작업지시번호" }, @@ -180,6 +181,8 @@ export default function WorkInstructionPage() { const [confirmWorkTeam, setConfirmWorkTeam] = useState(""); const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 작업지시 인포(메모) — POP 상단 표시용 (TASK:ERP-node-095) + const [confirmInfos, setConfirmInfos] = useState([]); // 등록 확인 모달 — 인라인 추가 폼 const [confirmAddQty, setConfirmAddQty] = useState(""); @@ -196,6 +199,7 @@ export default function WorkInstructionPage() { const [editWorkTeam, setEditWorkTeam] = useState(""); const [editWorker, setEditWorker] = useState(""); const [editRemark, setEditRemark] = useState(""); + const [editInfos, setEditInfos] = useState([]); const [editSaving, setEditSaving] = useState(false); const [addQty, setAddQty] = useState(""); const [addEquipment, setAddEquipment] = useState(""); @@ -316,7 +320,7 @@ export default function WorkInstructionPage() { setConfirmWiNo("불러오는 중..."); setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]); setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker(""); - setConfirmRouting(""); setConfirmRoutingOptions([]); + setConfirmRouting(""); setConfirmRoutingOptions([]); setConfirmInfos([]); previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); // 품목별 라우팅 옵션 로드 @@ -369,6 +373,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, + infos: confirmInfos.map((s) => s.trim()).filter(Boolean), items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, @@ -395,6 +400,13 @@ export default function WorkInstructionPage() { setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || ""); setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || ""); setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || ""); + // 작업지시 인포(메모) 복원 (TASK:ERP-node-095) + setEditInfos([]); + getWIInfos(wiNo) + .then((res) => { + if (res.success && Array.isArray(res.data)) setEditInfos(res.data.map((d) => d.content)); + }) + .catch(() => {}); const items: SelectedItem[] = relatedDetails.map((d: any) => ({ itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "", qty: Number(d.detail_qty || 0), remark: d.detail_remark || "", @@ -465,6 +477,7 @@ export default function WorkInstructionPage() { startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, remark: editRemark, + infos: editInfos.map((s) => s.trim()).filter(Boolean), routing: editRouting || null, items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, @@ -783,6 +796,7 @@ export default function WorkInstructionPage() {
+

품목 목록

@@ -901,6 +915,8 @@ export default function WorkInstructionPage() {
+ + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 7ca69854..5bc9234b 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -1267,6 +1267,11 @@ export const ExcelUploadModal: React.FC = ({ await saveMappingTemplateInternal(); onSuccess?.(); + + // 오류 없이 완료되면 모달 자동 닫기 + if (!errors || errors.length === 0) { + onOpenChange(false); + } } else { toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다."); } @@ -1310,6 +1315,11 @@ export const ExcelUploadModal: React.FC = ({ await saveMappingTemplateInternal(); onSuccess?.(); + + // 오류 없이 완료되면 모달 자동 닫기 + if (errors.length === 0) { + onOpenChange(false); + } } else { toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다."); } @@ -1538,6 +1548,11 @@ export const ExcelUploadModal: React.FC = ({ if (successCount > 0 || overwriteCount > 0) { onSuccess?.(); } + + // 실패 없이 완료되면 모달 자동 닫기 (일부 실패 시 결과 확인 위해 유지) + if (failCount === 0) { + onOpenChange(false); + } } else if (failCount > 0) { toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`); } else { diff --git a/frontend/components/common/WorkInstructionInfoSection.tsx b/frontend/components/common/WorkInstructionInfoSection.tsx new file mode 100644 index 00000000..59377aa5 --- /dev/null +++ b/frontend/components/common/WorkInstructionInfoSection.tsx @@ -0,0 +1,82 @@ +"use client"; + +/** + * 작업지시 인포(메모) 입력 섹션 (TASK:ERP-node-095) + * + * 작업지시 1건당 안내사항(인포)을 한 줄씩 여러 건 추가/삭제한다. + * 등록된 인포는 POP(생산현장) 화면 상단에 표시될 예정 — 등록·저장만 본 컴포넌트 담당. + * 작업지시 등록 모달 / 수정 모달 양쪽에서 공용으로 사용한다. + */ + +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Plus, X } from "lucide-react"; + +interface WorkInstructionInfoSectionProps { + /** 인포 내용 배열 (빈 문자열 행 허용 — 저장 시 호출부에서 trim/필터) */ + infos: string[]; + /** 변경 콜백 */ + onChange: (next: string[]) => void; +} + +export function WorkInstructionInfoSection({ infos, onChange }: WorkInstructionInfoSectionProps) { + const updateAt = (idx: number, value: string) => { + onChange(infos.map((v, i) => (i === idx ? value : v))); + }; + const removeAt = (idx: number) => { + onChange(infos.filter((_, i) => i !== idx)); + }; + const add = () => { + onChange([...infos, ""]); + }; + + return ( +
+
+

인포 (POP 상단 표시)

+ +
+

+ 작업지시별 안내사항을 입력하면 POP 화면 상단에 표시됩니다. 여러 건 등록할 수 있습니다. +

+
+ {infos.length === 0 ? ( +

+ 등록된 인포가 없습니다. '인포 추가' 버튼으로 입력하세요. +

+ ) : ( + infos.map((info, idx) => ( +
+ + {idx + 1} + + updateAt(idx, e.target.value)} + className="h-9 flex-1" + placeholder="인포 내용을 입력하세요" + /> + +
+ )) + )} +
+
+ ); +} diff --git a/frontend/lib/api/outbound.ts b/frontend/lib/api/outbound.ts index 49eeb7a3..65804ae3 100644 --- a/frontend/lib/api/outbound.ts +++ b/frontend/lib/api/outbound.ts @@ -26,10 +26,8 @@ export interface OutboundItem { outbound_status: string; manager_id: string | null; memo: string | null; - source_type: string | null; - sales_order_id: string | null; - shipment_plan_id: string | null; - item_info_id: string | null; + source_table: string | null; + source_id: string | null; destination_code: string | null; delivery_destination: string | null; delivery_address: string | null; @@ -118,11 +116,8 @@ export interface CreateOutboundPayload { outbound_status?: string; manager_id?: string; memo?: string; - source_type?: string; + source_table?: string; source_id?: string; - sales_order_id?: string; - shipment_plan_id?: string; - item_info_id?: string; destination_code?: string; delivery_destination?: string; delivery_address?: string; diff --git a/frontend/lib/api/workInstruction.ts b/frontend/lib/api/workInstruction.ts index b0551197..fe6667e2 100644 --- a/frontend/lib/api/workInstruction.ts +++ b/frontend/lib/api/workInstruction.ts @@ -141,6 +141,7 @@ export interface WIWorkItemDetail { bom_item_name?: string; bom_qty?: string; bom_unit?: string; + material_input_type?: string; // "auto"(자동투입, 기본) | "manual"(수동투입) } export interface WIWorkItem { @@ -289,6 +290,17 @@ export async function getWIMaterialOverrides(wiNo: string) { return res.data as { success: boolean; data: WIMaterialOverrideRouting[] }; } +// ─── 작업지시 인포(메모) 조회 — 편집 모달 복원용 (TASK:ERP-node-095) ─── +export interface WIInfo { + id: string; + content: string; + sort_order: number; +} +export async function getWIInfos(wiNo: string) { + const res = await apiClient.get(`/work-instruction/${encodeURIComponent(wiNo)}/infos`); + return res.data as { success: boolean; data: WIInfo[] }; +} + // 품목 검색 (대체품 SmartSelect 옵션 — 마스터에 등록 안 된 임의 자재로 교체할 때 사용) // 작업지시 등록 모달의 source/item 라우트를 재활용 (이미 keyword 검색 지원) // → getWIItemSource(params={ keyword, page, pageSize })