diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 172fefd6..8ef006da 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_table, source_id, + source_table, source_id, sales_order_id, shipment_plan_id, item_info_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, - NOW(), $27, $27, '출고' + $22, $23, $24, $25, $26, + $27, $28, $29, + NOW(), $30, $30, '출고' ) RETURNING *`, [ companyCode, @@ -180,8 +180,11 @@ export async function create(req: AuthenticatedRequest, res: Response) { item.outbound_status || "대기", manager_id || item.manager_id || null, memo || item.memo || null, - item.source_table || null, + item.source_type || item.source_table || null, item.source_id || null, + item.sales_order_id || null, + item.shipment_plan_id || null, + item.item_info_id || null, item.destination_code || null, item.delivery_destination || null, item.delivery_address || null, @@ -853,3 +856,149 @@ export async function getLocations(req: AuthenticatedRequest, res: Response) { return res.status(500).json({ success: false, message: error.message }); } } +export async function getProductionResults( + req: AuthenticatedRequest, + res: Response, +) { + try { + const companyCode = req.user!.companyCode; + const { processCode, keyword, pageSize } = req.query; + + const limit = Math.min(500, Math.max(1, Number(pageSize) || 100)); + const params: any[] = [companyCode]; + let paramIdx = 2; + + let processCondition = ""; + if (processCode) { + processCondition = `AND wop.process_code = $${paramIdx}`; + params.push(processCode); + paramIdx++; + } + + let keywordCondition = ""; + if (keyword) { + keywordCondition = `AND (wi.work_instruction_no ILIKE $${paramIdx} OR COALESCE(ii.item_name, '') ILIKE $${paramIdx} OR COALESCE(ii.item_number, '') ILIKE $${paramIdx})`; + params.push(`%${keyword}%`); + paramIdx++; + } + + const pool = getPool(); + + const dataResult = await pool.query( + `SELECT + wr.id, + wop.id AS wop_id, + wop.wo_id, + wi.work_instruction_no, + wi.start_date AS order_date, + COALESCE(CAST(NULLIF(wi.qty, '') AS numeric), 0) AS instruction_qty, + wop.process_code, + wop.process_name, + wop.seq_no, + COALESCE(ii.item_number, wi.item_id) AS item_code, + COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name, + COALESCE(ii.size, '') AS spec, + COALESCE(ii.material, '') AS material, + NULLIF(ii.unit, '') AS unit, + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) AS good_qty, + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS concession_qty, + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS order_qty, + COALESCE(ship.shipped_qty, 0) AS shipped_qty, + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) + - COALESCE(ship.shipped_qty, 0) AS remain_qty, + 'work_order_process_result' AS source_table, + wr.result_status, + COALESCE(ii.image, NULL) AS image, + CASE WHEN EXISTS ( + SELECT 1 FROM item_inspection_info iii + WHERE iii.company_code = wop.company_code + AND COALESCE(iii.is_active, 'Y') IN ('Y', '사용') + AND iii.item_code = COALESCE(ii.item_number, wi.item_id) + ) THEN 'self' ELSE NULL END AS inspection_type, + tp_agg.packages, + tl.loading_code, + tl.loading_name + FROM work_order_process_result wr + JOIN work_order_process wop + ON wop.id = wr.wop_id + AND wop.company_code = wr.company_code + JOIN work_instruction wi + ON wi.id = wop.wo_id + AND wi.company_code = wop.company_code + LEFT JOIN ( + SELECT DISTINCT ON (id, company_code) + id, item_number, item_name, size, material, unit, image, company_code + FROM item_info + ORDER BY id, company_code, created_date DESC + ) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code + LEFT JOIN ( + SELECT source_id, company_code, + SUM(COALESCE(CAST(NULLIF(outbound_qty::text, '') AS numeric), 0)) AS shipped_qty + FROM outbound_mng + WHERE source_table = 'work_order_process_result' + AND company_code = $1 + AND source_id IS NOT NULL + GROUP BY source_id, company_code + ) ship ON ship.source_id = wr.id AND ship.company_code = wr.company_code + LEFT JOIN ( + SELECT packed.source_id, packed.company_code, + JSON_AGG(JSON_BUILD_OBJECT( + 'pkg_code', packed.pkg_code, + 'pkg_name', COALESCE(pu.pkg_name, packed.pkg_code), + 'count', packed.cnt, + 'qty_per_unit', packed.qty_per_unit + )) AS packages + FROM ( + SELECT source_id, company_code, pkg_code, + CAST(quantity AS numeric) AS qty_per_unit, + COUNT(*)::int AS cnt + FROM transaction_packaging + WHERE company_code = $1 + AND source_type = 'work_order_process_result' + GROUP BY source_id, company_code, pkg_code, quantity + ) packed + LEFT JOIN pkg_unit pu + ON pu.pkg_code = packed.pkg_code + AND pu.company_code = packed.company_code + GROUP BY packed.source_id, packed.company_code + ) tp_agg ON tp_agg.source_id = wr.id AND tp_agg.company_code = wr.company_code + LEFT JOIN ( + SELECT DISTINCT ON (source_doc_id, company_code) + source_doc_id, company_code, + loading_code, loading_name + FROM transaction_loading + WHERE company_code = $1 + AND source_type = 'work_order_process_result' + ORDER BY source_doc_id, company_code, COALESCE(loading_seq, 1) ASC + ) tl ON tl.source_doc_id = wr.id AND tl.company_code = wr.company_code + WHERE wr.company_code = $1 + AND CAST(wop.seq_no AS int) = ( + SELECT MAX(CAST(wop2.seq_no AS int)) + FROM work_order_process wop2 + WHERE wop2.wo_id = wop.wo_id + AND wop2.company_code = wop.company_code + ) + AND ( + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) + ) > 0 + AND ( + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) + - COALESCE(ship.shipped_qty, 0) + ) > 0 + ${processCondition} + ${keywordCondition} + ORDER BY wi.work_instruction_no, wr.created_date NULLS LAST + LIMIT ${limit}`, + params, + ); + + return res.json({ success: true, data: dataResult.rows }); + } catch (error: any) { + logger.error("생산출고 소스 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 0978e0e8..b38f30d1 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -10,6 +10,10 @@ import { onProcessMove, onResultSaved, } from "../services/wipStockService"; +import { + ensureLoadingInstance, + insertPackagingRows, +} from "../services/transactionPackagingService"; /** * user_id → user_name(한글명) 조회 헬퍼 — wip_stock_history.manager_name 기록용. @@ -1304,6 +1308,7 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { defect_qty, defect_detail, result_note, + material_inputs, } = req.body; // validation: BEGIN 이전에 처리 @@ -1575,6 +1580,102 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { await checkAndCompleteWorkInstruction(client, csWoId, companyCode, userId); } + // 자재 자동 투입 + 재고 차감 (이번 차수 생산수량 × 1개당 소요량) + // material_inputs 가 있으면 process_work_result INSERT + inventory_stock 차감. + // 재고 부족 시 ROLLBACK + 400 응답. + if (Array.isArray(material_inputs) && material_inputs.length > 0) { + for (const mi of material_inputs) { + const childItemId = mi.child_item_id; + const childItemCode = mi.child_item_code; + const childItemName = mi.child_item_name || ""; + const inputQty = parseFloat(String(mi.input_qty)); + const unit = mi.unit || ""; + const bomDetailId = mi.bom_detail_id || null; + const requiredQty = mi.required_qty != null ? String(mi.required_qty) : null; + + if (!childItemId || !childItemCode || isNaN(inputQty) || inputQty <= 0) { + continue; + } + + // 창고/로케이션 자동 선택 (기존 saveMaterialInput 패턴: 가장 최근 입고 우선) + const autoStock = await client.query( + `SELECT warehouse_code, location_code, + COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS current_qty + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) > 0 + ORDER BY last_in_date DESC NULLS LAST LIMIT 1`, + [companyCode, childItemCode], + ); + + if (autoStock.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ + success: false, + message: `자재 재고 부족: ${childItemName || childItemCode} (현재고 0)`, + }); + } + + const stockRow = autoStock.rows[0]; + const stockQty = parseFloat(String(stockRow.current_qty)) || 0; + if (stockQty < inputQty) { + await client.query("ROLLBACK"); + return res.status(400).json({ + success: false, + message: `자재 재고 부족: ${childItemName || childItemCode} (현재고 ${stockQty}, 필요 ${inputQty})`, + }); + } + + const effectiveWh = stockRow.warehouse_code; + const effectiveLoc = stockRow.location_code || effectiveWh; + + await client.query( + `INSERT INTO process_work_result ( + id, company_code, work_order_process_id, + detail_type, detail_content, item_title, + result_value, unit, is_passed, status, + bom_detail_id, required_qty, warehouse_code, location_code, + recorded_by, recorded_at, writer + ) VALUES ( + gen_random_uuid()::text, $1, $2, + 'material_input', $3, $4, + $5, $6, 'Y', 'completed', + $7, $8, $9, $10, + $11, NOW()::text, $11 + )`, + [ + companyCode, + work_order_process_id, + childItemCode, + childItemName, + String(inputQty), + unit, + bomDetailId, + requiredQty, + effectiveWh, + effectiveLoc, + userId, + ], + ); + + await client.query( + `UPDATE inventory_stock + SET current_qty = (COALESCE(current_qty::numeric, 0) - $4::numeric)::text, + updated_date = NOW(), writer = $5 + WHERE company_code = $1 AND item_code = $2 + AND warehouse_code = $3 AND location_code = $6`, + [ + companyCode, + childItemCode, + effectiveWh, + String(inputQty), + userId, + effectiveLoc, + ], + ); + } + } + // [WIP 적재 — 트리거 2] 실적 저장분(양품/불량 증분)을 wip_stock 에 반영 // SAVEPOINT 로 감싼다: 공유 트랜잭션(client)에서 WIP 쿼리가 throw 하면 // PG 가 트랜잭션 전체를 aborted 로 만들어 이후 COMMIT 이 ROLLBACK 처리된다. @@ -3939,3 +4040,289 @@ export const getProcessResult = async ( return res.status(500).json({ success: false, message: error.message }); } }; +export const autoCompleteProcess = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id } = req.body; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } + + const wopr = await pool.query( + `SELECT wr.id, wr.input_qty, wr.status, wr.result_status, wr.wop_id + FROM work_order_process_result wr + WHERE wr.id = $1 AND wr.company_code = $2`, + [work_order_process_id, companyCode], + ); + + if (wopr.rowCount === 0) { + return res + .status(404) + .json({ success: false, message: "접수 카드를 찾을 수 없습니다." }); + } + + const prev = wopr.rows[0]; + if (prev.result_status === "confirmed" || prev.status === "completed") { + return res + .status(403) + .json({ success: false, message: "이미 확정된 실적입니다." }); + } + + const prCheck = await pool.query( + `SELECT COUNT(*)::int AS cnt FROM process_work_result + WHERE work_order_process_id = $1 AND company_code = $2 + AND detail_type = 'production_result'`, + [work_order_process_id, companyCode], + ); + if ((prCheck.rows[0]?.cnt ?? 0) > 0) { + return res.status(400).json({ + success: false, + message: "실적등록 항목이 존재하는 공정은 자동 완료할 수 없습니다.", + }); + } + + const acceptedQty = parseInt(prev.input_qty, 10) || 0; + if (acceptedQty <= 0) { + return res + .status(400) + .json({ success: false, message: "접수 수량이 없습니다." }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const result = await client.query( + `UPDATE work_order_process_result + SET status = 'completed', + result_status = 'confirmed', + total_production_qty = $3, + good_qty = $3, + defect_qty = '0', + concession_qty = '0', + completed_at = NOW()::text, + completed_by = $4, + writer = $4, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty, wop_id`, + [work_order_process_id, companyCode, String(acceptedQty), userId], + ); + + const wopLookup = await client.query( + `SELECT wo_id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [result.rows[0].wop_id, companyCode], + ); + + await client.query("COMMIT"); + + if ((wopLookup.rowCount ?? 0) > 0) { + await checkAndCompleteWorkInstruction( + pool, + wopLookup.rows[0].wo_id, + companyCode, + userId, + ); + } + + return res.json({ success: true, data: result.rows[0] }); + } catch (txErr) { + await client.query("ROLLBACK"); + throw txErr; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("[pop/production] auto-complete 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "자동 완료 처리 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 실적 이력 조회 — work_order_process_result 기준 (wop_result.id) + */ +export const savePackaging = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + work_order_process_result_id, + packages, + loading_code, + loading_name, + } = req.body; + + if (!work_order_process_result_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_result_id는 필수입니다.", + }); + } + + const procInfo = await pool.query( + `SELECT wr.id, wr.wop_id, wop.wo_id, + wi.work_instruction_no + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + JOIN work_instruction wi ON wi.id = wop.wo_id AND wi.company_code = wop.company_code + WHERE wr.id = $1 AND wr.company_code = $2`, + [work_order_process_result_id, companyCode], + ); + + if (procInfo.rowCount === 0) { + return res + .status(404) + .json({ success: false, message: "실적을 찾을 수 없습니다." }); + } + + const proc = procInfo.rows[0]; + const labelPrefix: string = + proc.work_instruction_no || work_order_process_result_id; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + await client.query( + `DELETE FROM transaction_packaging + WHERE company_code = $1 + AND source_type = 'work_order_process_result' + AND source_id = $2`, + [companyCode, work_order_process_result_id], + ); + + const loadingId = await ensureLoadingInstance(client, { + companyCode, + sourceType: "work_order_process_result", + sourceDocId: work_order_process_result_id, + loadingCode: loading_code || null, + loadingName: loading_name || null, + loadingSeq: 1, + writer: userId, + }); + + const rawPkgs: any[] = Array.isArray(packages) ? packages : []; + const packagesInput = rawPkgs + .map((p) => ({ + pkg_code: String(p?.pkg_code ?? p?.unit?.value ?? ""), + count: Number(p?.count ?? 0), + qty_per_unit: Number(p?.qty_per_unit ?? p?.qtyPerUnit ?? 0), + })) + .filter((p) => p.pkg_code && p.count > 0 && p.qty_per_unit > 0); + + let labels: string[] = []; + if (packagesInput.length > 0) { + labels = await insertPackagingRows(client, { + companyCode, + sourceType: "work_order_process_result", + sourceId: work_order_process_result_id, + inboundNumber: labelPrefix, + packages: packagesInput, + loadingId, + warehouseCode: null, + locationCode: null, + writer: userId, + }); + } + + await client.query("COMMIT"); + + return res.json({ + success: true, + message: "포장/적재함이 저장되었습니다.", + data: { + loading_id: loadingId, + loading_code: loading_code || null, + loading_name: loading_name || null, + packages: packagesInput, + labels, + }, + }); + } catch (txErr: any) { + await client.query("ROLLBACK").catch(() => {}); + throw txErr; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("[pop/production] save-packaging 오류:", error); + return res + .status(500) + .json({ success: false, message: error.message || "포장/적재함 저장 오류" }); + } +}; + +/** + * 공정 실적에 저장된 포장단위/적재함 조회 + */ +export const getProcessPackaging = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { wopResultId } = req.params; + + if (!wopResultId) { + return res.status(400).json({ + success: false, + message: "wopResultId는 필수입니다.", + }); + } + + const pkgResult = await pool.query( + `SELECT id, package_label, pkg_code, quantity, loading_id, seq_no, status + FROM transaction_packaging + WHERE company_code = $1 + AND source_type = 'work_order_process_result' + AND source_id = $2 + ORDER BY seq_no ASC`, + [companyCode, wopResultId], + ); + + const loadingResult = await pool.query( + `SELECT tl.id, tl.loading_code, tl.loading_name, tl.loading_seq, + lu.loading_type, lu.max_load_kg, lu.max_stack + FROM transaction_loading tl + LEFT JOIN loading_unit lu ON lu.loading_code = tl.loading_code AND lu.company_code = tl.company_code + WHERE tl.company_code = $1 + AND tl.source_type = 'work_order_process_result' + AND tl.source_doc_id = $2 + ORDER BY tl.loading_seq ASC + LIMIT 1`, + [companyCode, wopResultId], + ); + + return res.json({ + success: true, + data: { + loading: loadingResult.rows[0] || null, + packages: pkgResult.rows, + }, + }); + } catch (error: any) { + logger.error("[pop/production] get-packaging 오류:", error); + return res + .status(500) + .json({ success: false, message: error.message || "포장 조회 오류" }); + } +}; diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 26689e0b..9d6a4d68 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -1503,3 +1503,56 @@ export async function getMaterialOverrides(req: AuthenticatedRequest, res: Respo return res.status(500).json({ success: false, message: error.message }); } } + +// 작업지시 공정별 실적 (생산실적 우측 패널용) +// work_order_process LEFT JOIN work_order_process_result, 카드(result) 단위로 flat 반환. +// 공정에 카드가 없으면 wr.* 컬럼들이 모두 NULL 인 한 행을 반환. +export async function getProcessResults(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { wiId } = req.params; + if (!wiId) return res.status(400).json({ success: false, message: "wiId는 필수입니다." }); + + const pool = getPool(); + const result = await pool.query( + `SELECT + wop.id AS wop_id, + wop.wo_id, + wop.seq_no AS process_seq_no, + wop.process_code, + wop.process_name, + wr.id AS result_id, + wr.seq AS result_seq, + wr.equipment_code, + wr.input_qty, + wr.good_qty, + wr.defect_qty, + wr.concession_qty, + wr.total_production_qty, + wr.started_at, + wr.completed_at, + wr.status, + wr.result_status, + wr.result_note, + wr.defect_detail, + wr.is_rework, + wr.rework_source_id + FROM work_order_process wop + LEFT JOIN work_order_process_result wr + ON wr.wop_id = wop.id + AND wr.company_code = wop.company_code + WHERE wop.wo_id = $1 AND wop.company_code = $2 + ORDER BY + CASE WHEN wop.seq_no::text ~ '^[0-9]+$' THEN wop.seq_no::text::int ELSE NULL END NULLS LAST, + wop.seq_no::text, + CASE WHEN wr.seq::text ~ '^[0-9]+$' THEN wr.seq::text::int ELSE NULL END NULLS LAST, + wr.seq::text`, + [wiId, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("작업지시 공정 실적 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/workInstructionRoutes.ts b/backend-node/src/routes/workInstructionRoutes.ts index 6a4a5ba5..b057af3d 100644 --- a/backend-node/src/routes/workInstructionRoutes.ts +++ b/backend-node/src/routes/workInstructionRoutes.ts @@ -24,6 +24,9 @@ router.post("/bom-base-qty", ctrl.getBomBaseQtyMap); // BOM 트리 조회 (작업지시 등록/수정 모달의 자재 트리 섹션용 — TASK:ERP-node-090 트리화) router.get("/bom-tree/:itemCode", ctrl.getBomTree); +// 작업지시별 공정 실적 집계 (생산실적 화면 우측 패널) +router.get("/:wiId/process-results", ctrl.getProcessResults); + // 라우팅 & 공정작업기준 router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions); router.put("/:wiNo/routing", ctrl.updateRouting); 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 0c65fd25..8e474428 100644 --- a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx @@ -66,6 +66,31 @@ interface SelectedItem { equipmentIds?: string[]; workTeams?: string[]; workers?: string[]; + // 기준수(BOM 0레벨 base_qty) / 배치수(자동) / 배분(균등|순차) + baseQty?: number | null; + splitMode?: "even" | "sequential"; +} + +// 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1 +function calcBatchCount(qty: number, baseQty: number | null | undefined): number { + const b = Number(baseQty || 0); + if (!Number.isFinite(b) || b <= 0) return 1; + if (!Number.isFinite(qty) || qty <= 0) return 1; + return qty > b ? Math.ceil(qty / b) : 1; +} + +// 분할 산출: batchCount<=1이면 [qty], 아니면 mode 따라 분할 +function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" | "sequential"): number[] { + if (batchCount <= 1) return [qty]; + if (mode === "sequential") { + const head = Array(batchCount - 1).fill(baseQty); + const tail = qty - baseQty * (batchCount - 1); + return [...head, tail]; + } + // even: 앞은 floor, 마지막이 잔여 흡수 + const base = Math.floor(qty / batchCount); + const remainder = qty - base * (batchCount - 1); + return [...Array(batchCount - 1).fill(base), remainder]; } // 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) @@ -368,14 +393,27 @@ export default function WorkInstructionPage() { const headerEquipment = first?.equipmentIds?.[0] || ""; const headerWorkTeam = first?.workTeams?.[0] || ""; const headerWorker = first?.workers?.[0] || ""; + // 배치수≥2인 품목은 splitMode에 따라 N건으로 펼친다. + const expandedItems: Array = []; + for (const i of confirmItems) { + const qty = Number(i.qty || 0); + const baseQty = Number(i.baseQty || 0); + const batchCount = calcBatchCount(qty, i.baseQty); + if (batchCount > 1 && baseQty > 0) { + const parts = splitQty(qty, baseQty, batchCount, i.splitMode || "even"); + for (const p of parts) expandedItems.push({ ...i, _qty: p }); + } else { + expandedItems.push({ ...i, _qty: qty }); + } + } const payload = { status: confirmStatus, 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, + 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, routing: i.routing || null, // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) 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 0d6de06d..1f922fe6 100644 --- a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx @@ -66,6 +66,31 @@ interface SelectedItem { equipmentIds?: string[]; workTeams?: string[]; workers?: string[]; + // 기준수(BOM 0레벨 base_qty) / 배치수(자동) / 배분(균등|순차) + baseQty?: number | null; + splitMode?: "even" | "sequential"; +} + +// 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1 +function calcBatchCount(qty: number, baseQty: number | null | undefined): number { + const b = Number(baseQty || 0); + if (!Number.isFinite(b) || b <= 0) return 1; + if (!Number.isFinite(qty) || qty <= 0) return 1; + return qty > b ? Math.ceil(qty / b) : 1; +} + +// 분할 산출: batchCount<=1이면 [qty], 아니면 mode 따라 분할 +function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" | "sequential"): number[] { + if (batchCount <= 1) return [qty]; + if (mode === "sequential") { + const head = Array(batchCount - 1).fill(baseQty); + const tail = qty - baseQty * (batchCount - 1); + return [...head, tail]; + } + // even: 앞은 floor, 마지막이 잔여 흡수 + const base = Math.floor(qty / batchCount); + const remainder = qty - base * (batchCount - 1); + return [...Array(batchCount - 1).fill(base), remainder]; } // 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) @@ -372,14 +397,27 @@ export default function WorkInstructionPage() { const headerEquipment = first?.equipmentIds?.[0] || ""; const headerWorkTeam = first?.workTeams?.[0] || ""; const headerWorker = first?.workers?.[0] || ""; + // 배치수≥2인 품목은 splitMode에 따라 N건으로 펼친다. + const expandedItems: Array = []; + for (const i of confirmItems) { + const qty = Number(i.qty || 0); + const baseQty = Number(i.baseQty || 0); + const batchCount = calcBatchCount(qty, i.baseQty); + if (batchCount > 1 && baseQty > 0) { + const parts = splitQty(qty, baseQty, batchCount, i.splitMode || "even"); + for (const p of parts) expandedItems.push({ ...i, _qty: p }); + } else { + expandedItems.push({ ...i, _qty: qty }); + } + } const payload = { status: confirmStatus, 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, + 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, routing: i.routing || null, // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) 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 10ddcc03..102a6356 100644 --- a/frontend/app/(main)/COMPANY_28/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_28/production/work-instruction/page.tsx @@ -66,6 +66,31 @@ interface SelectedItem { equipmentIds?: string[]; workTeams?: string[]; workers?: string[]; + // 기준수(BOM 0레벨 base_qty) / 배치수(자동) / 배분(균등|순차) + baseQty?: number | null; + splitMode?: "even" | "sequential"; +} + +// 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1 +function calcBatchCount(qty: number, baseQty: number | null | undefined): number { + const b = Number(baseQty || 0); + if (!Number.isFinite(b) || b <= 0) return 1; + if (!Number.isFinite(qty) || qty <= 0) return 1; + return qty > b ? Math.ceil(qty / b) : 1; +} + +// 분할 산출: batchCount<=1이면 [qty], 아니면 mode 따라 분할 +function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" | "sequential"): number[] { + if (batchCount <= 1) return [qty]; + if (mode === "sequential") { + const head = Array(batchCount - 1).fill(baseQty); + const tail = qty - baseQty * (batchCount - 1); + return [...head, tail]; + } + // even: 앞은 floor, 마지막이 잔여 흡수 + const base = Math.floor(qty / batchCount); + const remainder = qty - base * (batchCount - 1); + return [...Array(batchCount - 1).fill(base), remainder]; } // 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) @@ -368,14 +393,27 @@ export default function WorkInstructionPage() { const headerEquipment = first?.equipmentIds?.[0] || ""; const headerWorkTeam = first?.workTeams?.[0] || ""; const headerWorker = first?.workers?.[0] || ""; + // 배치수≥2인 품목은 splitMode에 따라 N건으로 펼친다. + const expandedItems: Array = []; + for (const i of confirmItems) { + const qty = Number(i.qty || 0); + const baseQty = Number(i.baseQty || 0); + const batchCount = calcBatchCount(qty, i.baseQty); + if (batchCount > 1 && baseQty > 0) { + const parts = splitQty(qty, baseQty, batchCount, i.splitMode || "even"); + for (const p of parts) expandedItems.push({ ...i, _qty: p }); + } else { + expandedItems.push({ ...i, _qty: qty }); + } + } const payload = { status: confirmStatus, 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, + 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, routing: i.routing || null, // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) 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 10ddcc03..102a6356 100644 --- a/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx @@ -66,6 +66,31 @@ interface SelectedItem { equipmentIds?: string[]; workTeams?: string[]; workers?: string[]; + // 기준수(BOM 0레벨 base_qty) / 배치수(자동) / 배분(균등|순차) + baseQty?: number | null; + splitMode?: "even" | "sequential"; +} + +// 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1 +function calcBatchCount(qty: number, baseQty: number | null | undefined): number { + const b = Number(baseQty || 0); + if (!Number.isFinite(b) || b <= 0) return 1; + if (!Number.isFinite(qty) || qty <= 0) return 1; + return qty > b ? Math.ceil(qty / b) : 1; +} + +// 분할 산출: batchCount<=1이면 [qty], 아니면 mode 따라 분할 +function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" | "sequential"): number[] { + if (batchCount <= 1) return [qty]; + if (mode === "sequential") { + const head = Array(batchCount - 1).fill(baseQty); + const tail = qty - baseQty * (batchCount - 1); + return [...head, tail]; + } + // even: 앞은 floor, 마지막이 잔여 흡수 + const base = Math.floor(qty / batchCount); + const remainder = qty - base * (batchCount - 1); + return [...Array(batchCount - 1).fill(base), remainder]; } // 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) @@ -368,14 +393,27 @@ export default function WorkInstructionPage() { const headerEquipment = first?.equipmentIds?.[0] || ""; const headerWorkTeam = first?.workTeams?.[0] || ""; const headerWorker = first?.workers?.[0] || ""; + // 배치수≥2인 품목은 splitMode에 따라 N건으로 펼친다. + const expandedItems: Array = []; + for (const i of confirmItems) { + const qty = Number(i.qty || 0); + const baseQty = Number(i.baseQty || 0); + const batchCount = calcBatchCount(qty, i.baseQty); + if (batchCount > 1 && baseQty > 0) { + const parts = splitQty(qty, baseQty, batchCount, i.splitMode || "even"); + for (const p of parts) expandedItems.push({ ...i, _qty: p }); + } else { + expandedItems.push({ ...i, _qty: qty }); + } + } const payload = { status: confirmStatus, 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, + 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, routing: i.routing || null, // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) 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 0bd0a405..ea7a7daa 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx @@ -36,6 +36,7 @@ import { ClipboardCheck, Inbox, Settings2, + Lock, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -131,6 +132,9 @@ interface SelectedItem { batchUse?: "Y" | "N"; // 미사용 품목의 사용자 수동 배치수 (기본 1 = 분할 없음) manualBatch?: number; + // 수정 모드: 기존 detail row 식별 + 잠금 상태 + detailId?: string; + locked?: boolean; } // ── BOM 자재 매핑 행 (TASK:ERP-node-090, 트리화) ── @@ -1291,6 +1295,8 @@ export default function WorkInstructionPage() { infos: editInfos.map((s) => s.trim()).filter(Boolean), routing: editRouting || null, items: editItems.map((i) => ({ + // detailId 있으면 백엔드가 UPDATE, 없으면 INSERT 분류 + detailId: i.detailId || undefined, itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), @@ -2689,23 +2695,27 @@ export default function WorkInstructionPage() { editItems.map((item, idx) => { const editItemKey = makeItemKey(item.itemCode, item.sourceTable, item.sourceId); const editMatExpanded = editExpandedItems.has(editItemKey); + const rowBg = item.locked ? "bg-amber-50/60" : "bg-background"; return ( - - - {idx + 1} + + +
+ {idx + 1} + {item.locked && } +
- + {item.itemCode} {item.itemName || "-"} {item.spec || "-"} @@ -2741,6 +2751,7 @@ export default function WorkInstructionPage() { type="number" className="ml-auto h-9 w-full min-w-[100px] text-sm" value={item.qty} + disabled={item.locked} onChange={(e) => setEditItems((prev) => prev.map((it, i) => (i === idx ? { ...it, qty: Number(e.target.value) } : it)), @@ -2751,6 +2762,7 @@ export default function WorkInstructionPage() {