diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index c2d8ed00..896b25b1 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -7,70 +7,70 @@ * - 기타출고 → item_info (품목) */ -import { Response } from "express"; -import { AuthenticatedRequest } from "../types/auth"; +import type { Response } from "express"; import { getPool } from "../database/db"; +import type { AuthenticatedRequest } from "../types/auth"; import { logger } from "../utils/logger"; // 출고 목록 조회 export async function getList(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const { - outbound_type, - outbound_status, - search_keyword, - date_from, - date_to, - } = req.query; + try { + const companyCode = req.user!.companyCode; + const { + outbound_type, + outbound_status, + search_keyword, + date_from, + date_to, + } = req.query; - const conditions: string[] = []; - const params: any[] = []; - let paramIdx = 1; + const conditions: string[] = []; + const params: any[] = []; + let paramIdx = 1; - if (companyCode === "*") { - // 최고 관리자: 전체 조회 - } else { - conditions.push(`om.company_code = $${paramIdx}`); - params.push(companyCode); - paramIdx++; - } + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`om.company_code = $${paramIdx}`); + params.push(companyCode); + paramIdx++; + } - if (outbound_type && outbound_type !== "all") { - conditions.push(`om.outbound_type = $${paramIdx}`); - params.push(outbound_type); - paramIdx++; - } + if (outbound_type && outbound_type !== "all") { + conditions.push(`om.outbound_type = $${paramIdx}`); + params.push(outbound_type); + paramIdx++; + } - if (outbound_status && outbound_status !== "all") { - conditions.push(`om.outbound_status = $${paramIdx}`); - params.push(outbound_status); - paramIdx++; - } + if (outbound_status && outbound_status !== "all") { + conditions.push(`om.outbound_status = $${paramIdx}`); + params.push(outbound_status); + paramIdx++; + } - if (search_keyword) { - conditions.push( - `(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})` - ); - params.push(`%${search_keyword}%`); - paramIdx++; - } + if (search_keyword) { + conditions.push( + `(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})`, + ); + params.push(`%${search_keyword}%`); + paramIdx++; + } - if (date_from) { - conditions.push(`om.outbound_date >= $${paramIdx}`); - params.push(date_from); - paramIdx++; - } - if (date_to) { - conditions.push(`om.outbound_date <= $${paramIdx}`); - params.push(date_to); - paramIdx++; - } + if (date_from) { + conditions.push(`om.outbound_date >= $${paramIdx}`); + params.push(date_from); + paramIdx++; + } + if (date_to) { + conditions.push(`om.outbound_date <= $${paramIdx}`); + params.push(date_to); + paramIdx++; + } - const whereClause = - conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - const query = ` + const query = ` SELECT om.*, wh.warehouse_name @@ -82,42 +82,52 @@ export async function getList(req: AuthenticatedRequest, res: Response) { ORDER BY om.created_date DESC `; - const pool = getPool(); - const result = await pool.query(query, params); + const pool = getPool(); + const result = await pool.query(query, params); - logger.info("출고 목록 조회", { - companyCode, - rowCount: result.rowCount, - }); + logger.info("출고 목록 조회", { + companyCode, + rowCount: result.rowCount, + }); - 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 }); - } + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("출고 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 출고 등록 (다건) export async function create(req: AuthenticatedRequest, res: Response) { - const pool = getPool(); - const client = await pool.connect(); + const pool = getPool(); + const client = await pool.connect(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { items, outbound_number, outbound_date, warehouse_code, location_code, manager_id, memo } = req.body; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + items, + outbound_number, + outbound_date, + warehouse_code, + location_code, + manager_id, + memo, + } = req.body; - if (!items || !Array.isArray(items) || items.length === 0) { - return res.status(400).json({ success: false, message: "출고 품목이 없습니다." }); - } + if (!items || !Array.isArray(items) || items.length === 0) { + return res + .status(400) + .json({ success: false, message: "출고 품목이 없습니다." }); + } - await client.query("BEGIN"); + await client.query("BEGIN"); - const insertedRows: any[] = []; + const insertedRows: any[] = []; - for (const item of items) { - const result = await client.query( - `INSERT INTO outbound_mng ( + for (const item of items) { + const result = await client.query( + `INSERT INTO outbound_mng ( id, company_code, outbound_number, outbound_type, outbound_date, reference_number, customer_code, customer_name, item_code, item_name, specification, material, unit, @@ -138,165 +148,202 @@ export async function create(req: AuthenticatedRequest, res: Response) { $26, $27, $28, NOW(), $29, $29, '출고' ) RETURNING *`, - [ - companyCode, - outbound_number || item.outbound_number, - item.outbound_type, - outbound_date || item.outbound_date, - item.reference_number || null, - item.customer_code || null, - item.customer_name || null, - item.item_code || item.item_number || null, - item.item_name || null, - item.spec || item.specification || null, - item.material || null, - item.unit || "EA", - item.outbound_qty || 0, - item.unit_price || 0, - item.total_amount || 0, - item.lot_number || null, - warehouse_code || item.warehouse_code || null, - location_code || item.location_code || null, - item.outbound_status || "대기", - manager_id || item.manager_id || null, - memo || item.memo || null, - item.source_type || 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, - userId, - ] - ); + [ + companyCode, + outbound_number || item.outbound_number, + item.outbound_type, + outbound_date || item.outbound_date, + item.reference_number || null, + item.customer_code || null, + item.customer_name || null, + item.item_code || item.item_number || null, + item.item_name || null, + item.spec || item.specification || null, + item.material || null, + item.unit || "EA", + item.outbound_qty || 0, + item.unit_price || 0, + item.total_amount || 0, + item.lot_number || null, + warehouse_code || item.warehouse_code || null, + location_code || item.location_code || null, + item.outbound_status || "대기", + manager_id || item.manager_id || null, + memo || item.memo || null, + item.source_type || 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, + userId, + ], + ); - insertedRows.push(result.rows[0]); + insertedRows.push(result.rows[0]); - // 재고 업데이트 (inventory_stock): 출고 수량 차감 - const itemCode = item.item_code || item.item_number || null; - const whCode = warehouse_code || item.warehouse_code || null; - const locCode = location_code || item.location_code || null; - const outQty = Number(item.outbound_qty) || 0; - if (itemCode && outQty > 0) { - const existingStock = await client.query( - `SELECT id FROM inventory_stock + // 재고 업데이트 (inventory_stock): 출고 수량 차감 + const itemCode = item.item_code || item.item_number || null; + const whCode = warehouse_code || item.warehouse_code || null; + const locCode = location_code || item.location_code || null; + const outQty = Number(item.outbound_qty) || 0; + if (itemCode && outQty > 0) { + // 재고 사전 검증: 부족 시 즉시 에러 (트랜잭션 ROLLBACK) + const stockCheck = await client.query( + `SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) as cur + FROM inventory_stock WHERE company_code = $1 AND item_code = $2 AND COALESCE(warehouse_code, '') = COALESCE($3, '') AND COALESCE(location_code, '') = COALESCE($4, '') LIMIT 1`, - [companyCode, itemCode, whCode || '', locCode || ''] - ); + [companyCode, itemCode, whCode || "", locCode || ""], + ); + const currentStock = parseFloat(stockCheck.rows[0]?.cur || "0"); + if (currentStock < outQty) { + throw new Error( + `재고 부족: ${item.item_name || itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${currentStock}, 요청 출고 ${outQty}`, + ); + } - if (existingStock.rows.length > 0) { - await client.query( - `UPDATE inventory_stock + const existingStock = await client.query( + `SELECT id FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code, '') = COALESCE($3, '') + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, itemCode, whCode || "", locCode || ""], + ); + + if (existingStock.rows.length > 0) { + await client.query( + `UPDATE inventory_stock SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text), last_out_date = NOW(), updated_date = NOW() WHERE id = $2`, - [outQty, existingStock.rows[0].id] - ); - } else { - // 재고 레코드가 없으면 0으로 생성 (마이너스 방지) - await client.query( - `INSERT INTO inventory_stock ( + [outQty, existingStock.rows[0].id], + ); + } else { + // 재고 레코드가 없으면 0으로 생성 (마이너스 방지) + await client.query( + `INSERT INTO inventory_stock ( id, company_code, item_code, warehouse_code, location_code, current_qty, safety_qty, last_out_date, created_date, updated_date, writer ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`, - [companyCode, itemCode, whCode, locCode, userId] - ); - } + [companyCode, itemCode, whCode, locCode, userId], + ); + } - // 재고 이력 기록 (inventory_history) - const afterStockRes = await client.query( - `SELECT current_qty FROM inventory_stock + // 재고 이력 기록 (inventory_history) + const afterStockRes = await client.query( + `SELECT current_qty FROM inventory_stock WHERE company_code = $1 AND item_code = $2 AND COALESCE(warehouse_code, '') = COALESCE($3, '') AND COALESCE(location_code, '') = COALESCE($4, '') LIMIT 1`, - [companyCode, itemCode, whCode || '', locCode || ''] - ); - const afterQty = afterStockRes.rows[0]?.current_qty || '0'; - await client.query( - `INSERT INTO inventory_history ( + [companyCode, itemCode, whCode || "", locCode || ""], + ); + const afterQty = afterStockRes.rows[0]?.current_qty || "0"; + await client.query( + `INSERT INTO inventory_history ( id, company_code, item_code, warehouse_code, location_code, transaction_type, transaction_date, quantity, balance_qty, remark, writer, created_date ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '출고', NOW(), $5, $6, $7, $8, NOW())`, - [companyCode, itemCode, whCode, locCode, String(-outQty), afterQty, item.outbound_type || '출고', userId] - ); - } + [ + companyCode, + itemCode, + whCode, + locCode, + String(-outQty), + afterQty, + item.outbound_type || "출고", + userId, + ], + ); + } - // 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영 - if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") { - const outQtyNum = Number(item.outbound_qty) || 0; - await client.query( - `UPDATE shipment_instruction_detail + // 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영 + if ( + item.outbound_type === "판매출고" && + item.source_id && + item.source_type === "shipment_instruction_detail" + ) { + const outQtyNum = Number(item.outbound_qty) || 0; + await client.query( + `UPDATE shipment_instruction_detail SET ship_qty = COALESCE(ship_qty, 0) + $1, updated_date = NOW() WHERE id = $2 AND company_code = $3`, - [outQtyNum, item.source_id, companyCode] - ); + [outQtyNum, item.source_id, companyCode], + ); - // 출하지시 상세의 detail_id로 수주상세(sales_order_detail) ship_qty도 업데이트 - const sidRes = await client.query( - `SELECT detail_id FROM shipment_instruction_detail WHERE id = $1 AND company_code = $2`, - [item.source_id, companyCode] - ); - const detailId = sidRes.rows[0]?.detail_id; - if (detailId) { - await client.query( - `UPDATE sales_order_detail + // 출하지시 상세의 detail_id로 수주상세(sales_order_detail) ship_qty도 업데이트 + const sidRes = await client.query( + `SELECT detail_id FROM shipment_instruction_detail WHERE id = $1 AND company_code = $2`, + [item.source_id, companyCode], + ); + const detailId = sidRes.rows[0]?.detail_id; + if (detailId) { + await client.query( + `UPDATE sales_order_detail SET ship_qty = (COALESCE(NULLIF(ship_qty,'')::numeric, 0) + $1)::text, balance_qty = (COALESCE(NULLIF(qty,'')::numeric, 0) - COALESCE(NULLIF(ship_qty,'')::numeric, 0) - $1)::text, updated_date = NOW() WHERE id = $2 AND company_code = $3`, - [outQtyNum, detailId, companyCode] - ); - } - } - } + [outQtyNum, detailId, companyCode], + ); + } + } + } - await client.query("COMMIT"); + await client.query("COMMIT"); - logger.info("출고 등록 완료", { - companyCode, - userId, - count: insertedRows.length, - outbound_number, - }); + logger.info("출고 등록 완료", { + companyCode, + userId, + count: insertedRows.length, + outbound_number, + }); - return res.json({ - success: true, - data: insertedRows, - message: `${insertedRows.length}건 출고 등록 완료`, - }); - } catch (error: any) { - await client.query("ROLLBACK"); - logger.error("출고 등록 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } finally { - client.release(); - } + return res.json({ + success: true, + data: insertedRows, + message: `${insertedRows.length}건 출고 등록 완료`, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("출고 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } } // 출고 수정 export async function update(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { id } = req.params; - const { - outbound_date, outbound_qty, unit_price, total_amount, - lot_number, warehouse_code, location_code, - outbound_status, manager_id: mgr, memo, - } = req.body; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { + outbound_date, + outbound_qty, + unit_price, + total_amount, + lot_number, + warehouse_code, + location_code, + outbound_status, + manager_id: mgr, + memo, + } = req.body; - const pool = getPool(); - const result = await pool.query( - `UPDATE outbound_mng SET + const pool = getPool(); + const result = await pool.query( + `UPDATE outbound_mng SET outbound_date = COALESCE($1, outbound_date), outbound_qty = COALESCE($2, outbound_qty), unit_price = COALESCE($3, unit_price), @@ -311,73 +358,89 @@ export async function update(req: AuthenticatedRequest, res: Response) { updated_by = $11 WHERE id = $12 AND company_code = $13 RETURNING *`, - [ - outbound_date, outbound_qty, unit_price, total_amount, - lot_number, warehouse_code, location_code, - outbound_status, mgr, memo, - userId, id, companyCode, - ] - ); + [ + outbound_date, + outbound_qty, + unit_price, + total_amount, + lot_number, + warehouse_code, + location_code, + outbound_status, + mgr, + memo, + userId, + id, + companyCode, + ], + ); - if (result.rowCount === 0) { - return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." }); - } + if (result.rowCount === 0) { + return res + .status(404) + .json({ success: false, message: "출고 데이터를 찾을 수 없습니다." }); + } - logger.info("출고 수정", { companyCode, userId, id }); + logger.info("출고 수정", { companyCode, userId, id }); - return res.json({ success: true, data: result.rows[0] }); - } catch (error: any) { - logger.error("출고 수정 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("출고 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 출고 삭제 export async function deleteOutbound(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); - const result = await pool.query( - `DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`, - [id, companyCode] - ); + const result = await pool.query( + `DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode], + ); - if (result.rowCount === 0) { - return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); - } + if (result.rowCount === 0) { + return res + .status(404) + .json({ success: false, message: "데이터를 찾을 수 없습니다." }); + } - logger.info("출고 삭제", { companyCode, id }); + logger.info("출고 삭제", { companyCode, id }); - return res.json({ success: true, message: "삭제 완료" }); - } catch (error: any) { - logger.error("출고 삭제 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("출고 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 판매출고용: 출하지시 데이터 조회 -export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const { keyword } = req.query; +export async function getShipmentInstructions( + req: AuthenticatedRequest, + res: Response, +) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; - const conditions: string[] = ["si.company_code = $1"]; - const params: any[] = [companyCode]; - let paramIdx = 2; + const conditions: string[] = ["si.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; - if (keyword) { - conditions.push( - `(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})` - ); - params.push(`%${keyword}%`); - paramIdx++; - } + if (keyword) { + conditions.push( + `(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`, + ); + params.push(`%${keyword}%`); + paramIdx++; + } - const pool = getPool(); - const result = await pool.query( - `SELECT + const pool = getPool(); + const result = await pool.query( + `SELECT sid.id AS detail_id, si.id AS instruction_id, si.instruction_no, @@ -400,42 +463,45 @@ export async function getShipmentInstructions(req: AuthenticatedRequest, res: Re WHERE ${conditions.join(" AND ")} AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0) ORDER BY si.instruction_date DESC, si.instruction_no`, - params - ); + params, + ); - return res.json({ success: true, data: result.rows }); - } catch (error: any) { - logger.error("출하지시 데이터 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("출하지시 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 반품출고용: 발주(입고) 데이터 조회 -export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const { keyword } = req.query; +export async function getPurchaseOrders( + req: AuthenticatedRequest, + res: Response, +) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; - const conditions: string[] = ["company_code = $1"]; - const params: any[] = [companyCode]; - let paramIdx = 2; + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; - // 입고된 것만 (반품 대상) - conditions.push( - `COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0` - ); + // 입고된 것만 (반품 대상) + conditions.push( + `COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`, + ); - if (keyword) { - conditions.push( - `(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})` - ); - params.push(`%${keyword}%`); - paramIdx++; - } + if (keyword) { + conditions.push( + `(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`, + ); + params.push(`%${keyword}%`); + paramIdx++; + } - const pool = getPool(); - const result = await pool.query( - `SELECT + const pool = getPool(); + const result = await pool.query( + `SELECT id, purchase_no, order_date, supplier_code, supplier_name, item_code, item_name, spec, material, COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty, @@ -445,137 +511,146 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response FROM purchase_order_mng WHERE ${conditions.join(" AND ")} ORDER BY order_date DESC, purchase_no`, - params - ); + params, + ); - return res.json({ success: true, data: result.rows }); - } catch (error: any) { - logger.error("발주 데이터 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("발주 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 기타출고용: 품목 데이터 조회 export async function getItems(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const { keyword } = req.query; + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; - const conditions: string[] = ["company_code = $1"]; - const params: any[] = [companyCode]; - let paramIdx = 2; + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; - if (keyword) { - conditions.push( - `(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})` - ); - params.push(`%${keyword}%`); - paramIdx++; - } + if (keyword) { + conditions.push( + `(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`, + ); + params.push(`%${keyword}%`); + paramIdx++; + } - const pool = getPool(); - const result = await pool.query( - `SELECT + const pool = getPool(); + const result = await pool.query( + `SELECT id, item_number, item_name, size AS spec, material, unit, COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price FROM item_info WHERE ${conditions.join(" AND ")} ORDER BY item_name`, - params - ); + params, + ); - return res.json({ success: true, data: result.rows }); - } catch (error: any) { - logger.error("품목 데이터 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("품목 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 출고번호 자동생성 export async function generateNumber(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const ruleId = (req.query.ruleId as string) || (req.query.rule_id as string); + try { + const companyCode = req.user!.companyCode; + const ruleId = + (req.query.ruleId as string) || (req.query.rule_id as string); - // 1순위: POP 화면설정에서 선택한 채번규칙 사용 - if (ruleId && ruleId !== "__none__") { - try { - const { numberingRuleService } = await import("../services/numberingRuleService"); - const newNumber = await numberingRuleService.allocateCode(ruleId, companyCode); - return res.json({ success: true, data: newNumber }); - } catch (e: any) { - logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", { ruleId, error: e.message }); - } - } + // 1순위: POP 화면설정에서 선택한 채번규칙 사용 + if (ruleId && ruleId !== "__none__") { + try { + const { numberingRuleService } = await import( + "../services/numberingRuleService" + ); + const newNumber = await numberingRuleService.allocateCode( + ruleId, + companyCode, + ); + return res.json({ success: true, data: newNumber }); + } catch (e: any) { + logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", { + ruleId, + error: e.message, + }); + } + } - // 2순위: 기본 하드코딩 채번 (OUT-YYYY-XXXX) - const pool = getPool(); - const today = new Date(); - const yyyy = today.getFullYear(); - const prefix = `OUT-${yyyy}-`; + // 2순위: 기본 하드코딩 채번 (OUT-YYYY-XXXX) + const pool = getPool(); + const today = new Date(); + const yyyy = today.getFullYear(); + const prefix = `OUT-${yyyy}-`; - const result = await pool.query( - `SELECT outbound_number FROM outbound_mng + const result = await pool.query( + `SELECT outbound_number FROM outbound_mng WHERE company_code = $1 AND outbound_number LIKE $2 ORDER BY outbound_number DESC LIMIT 1`, - [companyCode, `${prefix}%`] - ); + [companyCode, `${prefix}%`], + ); - let seq = 1; - if (result.rows.length > 0) { - const lastNo = result.rows[0].outbound_number; - const lastSeq = parseInt(lastNo.replace(prefix, ""), 10); - if (!isNaN(lastSeq)) seq = lastSeq + 1; - } + let seq = 1; + if (result.rows.length > 0) { + const lastNo = result.rows[0].outbound_number; + const lastSeq = parseInt(lastNo.replace(prefix, ""), 10); + if (!isNaN(lastSeq)) seq = lastSeq + 1; + } - const newNumber = `${prefix}${String(seq).padStart(4, "0")}`; + const newNumber = `${prefix}${String(seq).padStart(4, "0")}`; - return res.json({ success: true, data: newNumber }); - } catch (error: any) { - logger.error("출고번호 생성 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ success: true, data: newNumber }); + } catch (error: any) { + logger.error("출고번호 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 창고 목록 조회 export async function getWarehouses(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); - const result = await pool.query( - `SELECT warehouse_code, warehouse_name, warehouse_type + const result = await pool.query( + `SELECT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info WHERE company_code = $1 AND COALESCE(status, '') != '삭제' ORDER BY warehouse_name`, - [companyCode] - ); + [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 }); - } + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("창고 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 창고별 위치 목록 조회 export async function getLocations(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const warehouseCode = req.query.warehouse_code as string; - const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const warehouseCode = req.query.warehouse_code as string; + const pool = getPool(); - const result = await pool.query( - `SELECT location_code, location_name, warehouse_code + const result = await pool.query( + `SELECT location_code, location_name, warehouse_code FROM warehouse_location WHERE company_code = $1 ${warehouseCode ? "AND warehouse_code = $2" : ""} ORDER BY location_code`, - warehouseCode ? [companyCode, warehouseCode] : [companyCode] - ); + warehouseCode ? [companyCode, warehouseCode] : [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 }); - } + 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/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 8d0af7c5..92cba89d 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -1,25 +1,29 @@ -import { Response } from "express"; +import type { Response } from "express"; import { getPool } from "../database/db"; +import type { AuthenticatedRequest } from "../middleware/authMiddleware"; import logger from "../utils/logger"; -import { AuthenticatedRequest } from "../middleware/authMiddleware"; // 불량 상세 항목 타입 interface DefectDetailItem { - defect_code: string; - defect_name: string; - qty: string; - disposition: string; + defect_code: string; + defect_name: string; + qty: string; + disposition: string; } // 자동 마이그레이션: batch_id 컬럼 추가 (배치/로트 추적용) let _batchMigrationDone = false; async function ensureBatchIdColumn() { - if (_batchMigrationDone) return; - try { - const pool = getPool(); - await pool.query("ALTER TABLE work_order_process ADD COLUMN IF NOT EXISTS batch_id VARCHAR(100)"); - _batchMigrationDone = true; - } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } + if (_batchMigrationDone) return; + try { + const pool = getPool(); + await pool.query( + "ALTER TABLE work_order_process ADD COLUMN IF NOT EXISTS batch_id VARCHAR(100)", + ); + _batchMigrationDone = true; + } catch { + /* 이미 존재하거나 권한 문제 시 무시 */ + } } /** @@ -28,46 +32,46 @@ async function ensureBatchIdColumn() { * (inventory_stock에 UNIQUE 제약조건이 없으므로 ON CONFLICT 사용 불가) */ async function upsertInventoryStock( - client: { query: (text: string, values?: any[]) => Promise }, - companyCode: string, - itemCode: string, - warehouseCode: string, - locationCode: string | null, - qty: number, - userId: string + client: { query: (text: string, values?: any[]) => Promise }, + companyCode: string, + itemCode: string, + warehouseCode: string, + locationCode: string | null, + qty: number, + userId: string, ): Promise { - const whCode = warehouseCode || null; - const locCode = locationCode || null; + const whCode = warehouseCode || null; + const locCode = locationCode || null; - const existing = await client.query( - `SELECT id FROM inventory_stock + const existing = await client.query( + `SELECT id FROM inventory_stock WHERE company_code = $1 AND item_code = $2 AND COALESCE(warehouse_code, '') = COALESCE($3, '') AND COALESCE(location_code, '') = COALESCE($4, '') LIMIT 1`, - [companyCode, itemCode, whCode || '', locCode || ''] - ); + [companyCode, itemCode, whCode || "", locCode || ""], + ); - if (existing.rows.length > 0) { - await client.query( - `UPDATE inventory_stock + if (existing.rows.length > 0) { + await client.query( + `UPDATE inventory_stock SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text), last_in_date = NOW(), updated_date = NOW(), writer = $2 WHERE id = $3`, - [qty, userId, existing.rows[0].id] - ); - } else { - await client.query( - `INSERT INTO inventory_stock ( + [qty, userId, existing.rows[0].id], + ); + } else { + await client.query( + `INSERT INTO inventory_stock ( id, company_code, item_code, warehouse_code, location_code, current_qty, safety_qty, last_in_date, created_date, updated_date, writer ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`, - [companyCode, itemCode, whCode, locCode, String(qty), userId] - ); - } + [companyCode, itemCode, whCode, locCode, String(qty), userId], + ); + } } /** @@ -77,17 +81,17 @@ async function upsertInventoryStock( * 전략: routingDetailId가 있으면 원본 템플릿에서, 없으면 마스터의 기존 결과에서 복사 */ async function copyChecklistToSplit( - client: { query: (text: string, values?: any[]) => Promise }, - masterProcessId: string, - newProcessId: string, - routingDetailId: string | null, - companyCode: string, - userId: string + client: { query: (text: string, values?: any[]) => Promise }, + masterProcessId: string, + newProcessId: string, + routingDetailId: string | null, + companyCode: string, + userId: string, ): Promise { - // A. routing_detail_id가 있으면 원본 템플릿(process_work_item + detail)에서 복사 - if (routingDetailId) { - const result = await client.query( - `INSERT INTO process_work_result ( + // A. routing_detail_id가 있으면 원본 템플릿(process_work_item + detail)에서 복사 + if (routingDetailId) { + const result = await client.query( + `INSERT INTO process_work_result ( id, company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, @@ -110,16 +114,16 @@ async function copyChecklistToSplit( WHERE pwi.routing_detail_id = $3 AND pwi.company_code = $4 ORDER BY pwi.sort_order, pwd.sort_order`, - [newProcessId, userId, routingDetailId, companyCode] - ); - const countA = result.rowCount ?? 0; - if (countA > 0) return countA; - // A 전략에서 0건이면 B 전략(마스터 행의 기존 결과 복사)으로 fallthrough - } + [newProcessId, userId, routingDetailId, companyCode], + ); + const countA = result.rowCount ?? 0; + if (countA > 0) return countA; + // A 전략에서 0건이면 B 전략(마스터 행의 기존 결과 복사)으로 fallthrough + } - // B. routing_detail_id가 없거나 A 전략에서 0건이면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화) - const result = await client.query( - `INSERT INTO process_work_result ( + // B. routing_detail_id가 없거나 A 전략에서 0건이면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화) + const result = await client.query( + `INSERT INTO process_work_result ( company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, @@ -140,9 +144,9 @@ async function copyChecklistToSplit( WHERE work_order_process_id = $3 AND company_code = $4 ORDER BY item_sort_order, detail_sort_order`, - [newProcessId, userId, masterProcessId, companyCode] - ); - return result.rowCount ?? 0; + [newProcessId, userId, masterProcessId, companyCode], + ); + return result.rowCount ?? 0; } /** @@ -152,29 +156,34 @@ async function copyChecklistToSplit( * @returns 생성된 공정 목록 + 체크리스트 총 수. 이미 존재하면 null 반환. */ async function generateWorkProcessesForInstruction( - client: { query: (text: string, values?: any[]) => Promise }, - workInstructionId: string, - routingVersionId: string, - planQty: string | null, - companyCode: string, - userId: string + client: { query: (text: string, values?: any[]) => Promise }, + workInstructionId: string, + routingVersionId: string, + planQty: string | null, + companyCode: string, + userId: string, ): Promise<{ - processes: Array<{ id: string; seq_no: string; process_name: string; checklist_count: number }>; - total_checklists: number; + processes: Array<{ + id: string; + seq_no: string; + process_name: string; + checklist_count: number; + }>; + total_checklists: number; } | null> { - // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 - const existCheck = await client.query( - `SELECT COUNT(*) as cnt FROM work_order_process + // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 + const existCheck = await client.query( + `SELECT COUNT(*) as cnt FROM work_order_process WHERE wo_id = $1 AND company_code = $2`, - [workInstructionId, companyCode] - ); - if (parseInt(existCheck.rows[0].cnt, 10) > 0) { - return null; // 이미 존재 - } + [workInstructionId, companyCode], + ); + if (parseInt(existCheck.rows[0].cnt, 10) > 0) { + return null; // 이미 존재 + } - // 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명) - const routingDetails = await client.query( - `SELECT rd.id, rd.seq_no, rd.process_code, + // 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명) + const routingDetails = await client.query( + `SELECT rd.id, rd.seq_no, rd.process_code, COALESCE(pm.process_name, rd.process_code) as process_name, rd.is_required, rd.is_fixed_order, rd.standard_time FROM item_routing_detail rd @@ -182,62 +191,69 @@ async function generateWorkProcessesForInstruction( AND pm.company_code = rd.company_code WHERE rd.routing_version_id = $1 AND rd.company_code = $2 ORDER BY CAST(rd.seq_no AS int) NULLS LAST`, - [routingVersionId, companyCode] - ); + [routingVersionId, companyCode], + ); - if (routingDetails.rows.length === 0) { - return null; // 공정 없음 - } + if (routingDetails.rows.length === 0) { + return null; // 공정 없음 + } - const processes: Array<{ - id: string; - seq_no: string; - process_name: string; - checklist_count: number; - }> = []; - let totalChecklists = 0; + const processes: Array<{ + id: string; + seq_no: string; + process_name: string; + checklist_count: number; + }> = []; + let totalChecklists = 0; - for (const rd of routingDetails.rows) { - // 2. work_order_process INSERT - const wopResult = await client.query( - `INSERT INTO work_order_process ( + for (const rd of routingDetails.rows) { + // 2. work_order_process INSERT + const wopResult = await client.query( + `INSERT INTO work_order_process ( id, company_code, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, plan_qty, status, routing_detail_id, writer ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`, - [ - companyCode, - workInstructionId, - rd.seq_no, - rd.process_code, - rd.process_name, - rd.is_required, - rd.is_fixed_order, - rd.standard_time, - planQty || null, - parseInt(rd.seq_no, 10) === 1 || rd.is_fixed_order === "Y" ? "acceptable" : "waiting", - rd.id, - userId, - ] - ); - const wopId = wopResult.rows[0].id; + [ + companyCode, + workInstructionId, + rd.seq_no, + rd.process_code, + rd.process_name, + rd.is_required, + rd.is_fixed_order, + rd.standard_time, + planQty || null, + parseInt(rd.seq_no, 10) === 1 || rd.is_fixed_order === "Y" + ? "acceptable" + : "waiting", + rd.id, + userId, + ], + ); + const wopId = wopResult.rows[0].id; - // 3. process_work_result INSERT (공통 함수로 체크리스트 복사) - const checklistCount = await copyChecklistToSplit( - client, wopId, wopId, rd.id, companyCode, userId - ); - totalChecklists += checklistCount; + // 3. process_work_result INSERT (공통 함수로 체크리스트 복사) + const checklistCount = await copyChecklistToSplit( + client, + wopId, + wopId, + rd.id, + companyCode, + userId, + ); + totalChecklists += checklistCount; - processes.push({ - id: wopId, - seq_no: rd.seq_no, - process_name: rd.process_name, - checklist_count: checklistCount, - }); - } + processes.push({ + id: wopId, + seq_no: rd.seq_no, + process_name: rd.process_name, + checklist_count: checklistCount, + }); + } - return { processes, total_checklists: totalChecklists }; + return { processes, total_checklists: totalChecklists }; } /** @@ -245,77 +261,81 @@ async function generateWorkProcessesForInstruction( * PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성. */ export const createWorkProcesses = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); - const client = await pool.connect(); + const pool = getPool(); + const client = await pool.connect(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; - const { work_instruction_id, item_code, routing_version_id, plan_qty } = - req.body; + const { work_instruction_id, item_code, routing_version_id, plan_qty } = + req.body; - if (!work_instruction_id || !routing_version_id) { - return res.status(400).json({ - success: false, - message: - "work_instruction_id와 routing_version_id는 필수입니다.", - }); - } + if (!work_instruction_id || !routing_version_id) { + return res.status(400).json({ + success: false, + message: "work_instruction_id와 routing_version_id는 필수입니다.", + }); + } - logger.info("[pop/production] create-work-processes 요청", { - companyCode, - userId, - work_instruction_id, - item_code, - routing_version_id, - plan_qty, - }); + logger.info("[pop/production] create-work-processes 요청", { + companyCode, + userId, + work_instruction_id, + item_code, + routing_version_id, + plan_qty, + }); - await client.query("BEGIN"); + await client.query("BEGIN"); - const result = await generateWorkProcessesForInstruction( - client, work_instruction_id, routing_version_id, plan_qty, companyCode, userId - ); + const result = await generateWorkProcessesForInstruction( + client, + work_instruction_id, + routing_version_id, + plan_qty, + companyCode, + userId, + ); - if (!result) { - await client.query("ROLLBACK"); - return res.status(409).json({ - success: false, - message: "이미 공정이 생성된 작업지시이거나 라우팅에 공정이 없습니다.", - }); - } + if (!result) { + await client.query("ROLLBACK"); + return res.status(409).json({ + success: false, + message: "이미 공정이 생성된 작업지시이거나 라우팅에 공정이 없습니다.", + }); + } - await client.query("COMMIT"); + await client.query("COMMIT"); - logger.info("[pop/production] create-work-processes 완료", { - companyCode, - work_instruction_id, - total_processes: result.processes.length, - total_checklists: result.total_checklists, - }); + logger.info("[pop/production] create-work-processes 완료", { + companyCode, + work_instruction_id, + total_processes: result.processes.length, + total_checklists: result.total_checklists, + }); - return res.json({ - success: true, - data: { - processes: result.processes, - total_processes: result.processes.length, - total_checklists: result.total_checklists, - }, - }); - } catch (error: any) { - await client.query("ROLLBACK"); - logger.error("[pop/production] create-work-processes 오류:", error); - return res.status(500).json({ - success: false, - message: error.message || "공정 생성 중 오류가 발생했습니다.", - }); - } finally { - client.release(); - } + return res.json({ + success: true, + data: { + processes: result.processes, + total_processes: result.processes.length, + total_checklists: result.total_checklists, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("[pop/production] create-work-processes 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "공정 생성 중 오류가 발생했습니다.", + }); + } finally { + client.release(); + } }; /** @@ -324,23 +344,23 @@ export const createWorkProcesses = async ( * 각 건별 개별 try-catch로 하나 실패해도 나머지 진행. */ export const syncWorkInstructions = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); + const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; - logger.info("[pop/production] sync-work-instructions 요청", { - companyCode, - userId, - }); + logger.info("[pop/production] sync-work-instructions 요청", { + companyCode, + userId, + }); - // 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목 - const unsyncedResult = await pool.query( - `SELECT wi.id, wi.work_instruction_no, wi.routing, wi.qty + // 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목 + const unsyncedResult = await pool.query( + `SELECT wi.id, wi.work_instruction_no, wi.routing, wi.qty FROM work_instruction wi WHERE wi.company_code = $1 AND wi.routing IS NOT NULL @@ -348,168 +368,172 @@ export const syncWorkInstructions = async ( SELECT 1 FROM work_order_process wop WHERE wop.wo_id = wi.id AND wop.company_code = $1 )`, - [companyCode] - ); + [companyCode], + ); - const unsynced = unsyncedResult.rows; + const unsynced = unsyncedResult.rows; - if (unsynced.length === 0) { - return res.json({ - success: true, - data: { synced: 0, skipped: 0, errors: 0, details: [] }, - }); - } + if (unsynced.length === 0) { + return res.json({ + success: true, + data: { synced: 0, skipped: 0, errors: 0, details: [] }, + }); + } - let synced = 0; - let skipped = 0; - let errors = 0; - const details: Array<{ - work_instruction_id: string; - work_instruction_no: string; - status: "synced" | "skipped" | "error"; - process_count?: number; - error?: string; - }> = []; + let synced = 0; + let skipped = 0; + let errors = 0; + const details: Array<{ + work_instruction_id: string; + work_instruction_no: string; + status: "synced" | "skipped" | "error"; + process_count?: number; + error?: string; + }> = []; - for (const wi of unsynced) { - const client = await pool.connect(); - try { - await client.query("BEGIN"); + for (const wi of unsynced) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); - const result = await generateWorkProcessesForInstruction( - client, wi.id, wi.routing, wi.qty || null, companyCode, userId - ); + const result = await generateWorkProcessesForInstruction( + client, + wi.id, + wi.routing, + wi.qty || null, + companyCode, + userId, + ); - if (!result) { - await client.query("ROLLBACK"); - skipped++; - details.push({ - work_instruction_id: wi.id, - work_instruction_no: wi.work_instruction_no, - status: "skipped", - }); - continue; - } + if (!result) { + await client.query("ROLLBACK"); + skipped++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + status: "skipped", + }); + continue; + } - await client.query("COMMIT"); - synced++; - details.push({ - work_instruction_id: wi.id, - work_instruction_no: wi.work_instruction_no, - status: "synced", - process_count: result.processes.length, - }); + await client.query("COMMIT"); + synced++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + status: "synced", + process_count: result.processes.length, + }); - logger.info("[pop/production] sync: 공정 생성 완료", { - work_instruction_no: wi.work_instruction_no, - process_count: result.processes.length, - }); - } catch (err: any) { - await client.query("ROLLBACK"); - errors++; - details.push({ - work_instruction_id: wi.id, - work_instruction_no: wi.work_instruction_no, - status: "error", - error: err.message || "알 수 없는 오류", - }); - logger.error("[pop/production] sync: 개별 오류", { - work_instruction_no: wi.work_instruction_no, - error: err.message, - }); - } finally { - client.release(); - } - } + logger.info("[pop/production] sync: 공정 생성 완료", { + work_instruction_no: wi.work_instruction_no, + process_count: result.processes.length, + }); + } catch (err: any) { + await client.query("ROLLBACK"); + errors++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + status: "error", + error: err.message || "알 수 없는 오류", + }); + logger.error("[pop/production] sync: 개별 오류", { + work_instruction_no: wi.work_instruction_no, + error: err.message, + }); + } finally { + client.release(); + } + } - logger.info("[pop/production] sync-work-instructions 완료", { - companyCode, - synced, - skipped, - errors, - }); + logger.info("[pop/production] sync-work-instructions 완료", { + companyCode, + synced, + skipped, + errors, + }); - return res.json({ - success: true, - data: { synced, skipped, errors, details }, - }); - } catch (error: any) { - logger.error("[pop/production] sync-work-instructions 오류:", error); - return res.status(500).json({ - success: false, - message: error.message || "작업지시 동기화 중 오류가 발생했습니다.", - }); - } + return res.json({ + success: true, + data: { synced, skipped, errors, details }, + }); + } catch (error: any) { + logger.error("[pop/production] sync-work-instructions 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "작업지시 동기화 중 오류가 발생했습니다.", + }); + } }; /** * D-BE2: 타이머 API (시작/일시정지/재시작) */ export const controlTimer = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); + const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; - const { work_order_process_id, action } = req.body; + const { work_order_process_id, action } = req.body; - if (!work_order_process_id || !action) { - return res.status(400).json({ - success: false, - message: "work_order_process_id와 action은 필수입니다.", - }); - } + if (!work_order_process_id || !action) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 action은 필수입니다.", + }); + } - if (!["start", "pause", "resume", "complete"].includes(action)) { - return res.status(400).json({ - success: false, - message: - "action은 start, pause, resume, complete 중 하나여야 합니다.", - }); - } + if (!["start", "pause", "resume", "complete"].includes(action)) { + return res.status(400).json({ + success: false, + message: "action은 start, pause, resume, complete 중 하나여야 합니다.", + }); + } - logger.info("[pop/production] timer 요청", { - companyCode, - userId, - work_order_process_id, - action, - }); + logger.info("[pop/production] timer 요청", { + companyCode, + userId, + work_order_process_id, + action, + }); - let result; + let result; - switch (action) { - case "start": - // 최초 1회만 설정, 이미 있으면 무시 - result = await pool.query( - `UPDATE work_order_process + switch (action) { + case "start": + // 최초 1회만 설정, 이미 있으면 무시 + result = await pool.query( + `UPDATE work_order_process SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END, status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END, updated_date = NOW() WHERE id = $1 AND company_code = $2 RETURNING id, started_at, status`, - [work_order_process_id, companyCode] - ); - break; + [work_order_process_id, companyCode], + ); + break; - case "pause": - result = await pool.query( - `UPDATE work_order_process + case "pause": + result = await pool.query( + `UPDATE work_order_process SET paused_at = NOW()::text, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND paused_at IS NULL RETURNING id, paused_at`, - [work_order_process_id, companyCode] - ); - break; + [work_order_process_id, companyCode], + ); + break; - case "resume": - // 일시정지 시간 누적 후 paused_at 초기화 - result = await pool.query( - `UPDATE work_order_process + case "resume": + // 일시정지 시간 누적 후 paused_at 초기화 + result = await pool.query( + `UPDATE work_order_process SET total_paused_time = ( COALESCE(total_paused_time::int, 0) + EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int @@ -518,15 +542,15 @@ export const controlTimer = async ( updated_date = NOW() WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL RETURNING id, total_paused_time`, - [work_order_process_id, companyCode] - ); - break; + [work_order_process_id, companyCode], + ); + break; - case "complete": { - const { good_qty, defect_qty } = req.body; + case "complete": { + const { good_qty, defect_qty } = req.body; - const groupSumResult = await pool.query( - `SELECT COALESCE(SUM( + const groupSumResult = await pool.query( + `SELECT COALESCE(SUM( CASE WHEN group_started_at IS NOT NULL AND group_completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM group_completed_at::timestamp - group_started_at::timestamp)::int - COALESCE(group_total_paused_time::int, 0) @@ -534,12 +558,13 @@ export const controlTimer = async ( ), 0)::text AS total_work_seconds FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); - const calculatedWorkTime = groupSumResult.rows[0]?.total_work_seconds || "0"; + [work_order_process_id, companyCode], + ); + const calculatedWorkTime = + groupSumResult.rows[0]?.total_work_seconds || "0"; - result = await pool.query( - `UPDATE work_order_process + result = await pool.query( + `UPDATE work_order_process SET status = 'completed', completed_at = NOW()::text, completed_by = $3, @@ -551,43 +576,43 @@ export const controlTimer = async ( WHERE id = $1 AND company_code = $2 AND status != 'completed' RETURNING id, status, completed_at, completed_by, actual_work_time, good_qty, defect_qty`, - [ - work_order_process_id, - companyCode, - userId, - calculatedWorkTime, - good_qty || null, - defect_qty || null, - ] - ); - break; - } - } + [ + work_order_process_id, + companyCode, + userId, + calculatedWorkTime, + good_qty || null, + defect_qty || null, + ], + ); + break; + } + } - if (!result || result.rowCount === 0) { - return res.status(404).json({ - success: false, - message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", - }); - } + if (!result || result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", + }); + } - logger.info("[pop/production] timer 완료", { - action, - work_order_process_id, - result: result.rows[0], - }); + logger.info("[pop/production] timer 완료", { + action, + work_order_process_id, + result: result.rows[0], + }); - return res.json({ - success: true, - data: result.rows[0], - }); - } catch (error: any) { - logger.error("[pop/production] timer 오류:", error); - return res.status(500).json({ - success: false, - message: error.message || "타이머 처리 중 오류가 발생했습니다.", - }); - } + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("[pop/production] timer 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "타이머 처리 중 오류가 발생했습니다.", + }); + } }; /** @@ -595,75 +620,78 @@ export const controlTimer = async ( * 좌측 사이드바의 각 작업 그룹마다 독립적인 시작/정지/재개/완료 타이머 */ export const controlGroupTimer = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); + const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const { work_order_process_id, source_work_item_id, action } = req.body; + try { + const companyCode = req.user!.companyCode; + const { work_order_process_id, source_work_item_id, action } = req.body; - if (!work_order_process_id || !source_work_item_id || !action) { - return res.status(400).json({ - success: false, - message: - "work_order_process_id, source_work_item_id, action은 필수입니다.", - }); - } + if (!work_order_process_id || !source_work_item_id || !action) { + return res.status(400).json({ + success: false, + message: + "work_order_process_id, source_work_item_id, action은 필수입니다.", + }); + } - if (!["start", "pause", "resume", "complete"].includes(action)) { - return res.status(400).json({ - success: false, - message: - "action은 start, pause, resume, complete 중 하나여야 합니다.", - }); - } + if (!["start", "pause", "resume", "complete"].includes(action)) { + return res.status(400).json({ + success: false, + message: "action은 start, pause, resume, complete 중 하나여야 합니다.", + }); + } - logger.info("[pop/production] group-timer 요청", { - companyCode, - work_order_process_id, - source_work_item_id, - action, - }); + logger.info("[pop/production] group-timer 요청", { + companyCode, + work_order_process_id, + source_work_item_id, + action, + }); - const whereClause = `work_order_process_id = $1 AND source_work_item_id = $2 AND company_code = $3`; - const baseParams = [work_order_process_id, source_work_item_id, companyCode]; + const whereClause = `work_order_process_id = $1 AND source_work_item_id = $2 AND company_code = $3`; + const baseParams = [ + work_order_process_id, + source_work_item_id, + companyCode, + ]; - let result; + let result; - switch (action) { - case "start": - result = await pool.query( - `UPDATE process_work_result + switch (action) { + case "start": + result = await pool.query( + `UPDATE process_work_result SET group_started_at = CASE WHEN group_started_at IS NULL THEN NOW()::text ELSE group_started_at END, updated_date = NOW() WHERE ${whereClause} RETURNING id, group_started_at`, - baseParams - ); - await pool.query( - `UPDATE work_order_process + baseParams, + ); + await pool.query( + `UPDATE work_order_process SET started_at = NOW()::text, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND started_at IS NULL`, - [work_order_process_id, companyCode] - ); - break; + [work_order_process_id, companyCode], + ); + break; - case "pause": - result = await pool.query( - `UPDATE process_work_result + case "pause": + result = await pool.query( + `UPDATE process_work_result SET group_paused_at = NOW()::text, updated_date = NOW() WHERE ${whereClause} AND group_paused_at IS NULL RETURNING id, group_paused_at`, - baseParams - ); - break; + baseParams, + ); + break; - case "resume": - result = await pool.query( - `UPDATE process_work_result + case "resume": + result = await pool.query( + `UPDATE process_work_result SET group_total_paused_time = ( COALESCE(group_total_paused_time::int, 0) + EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int @@ -672,13 +700,13 @@ export const controlGroupTimer = async ( updated_date = NOW() WHERE ${whereClause} AND group_paused_at IS NOT NULL RETURNING id, group_total_paused_time`, - baseParams - ); - break; + baseParams, + ); + break; - case "complete": { - result = await pool.query( - `UPDATE process_work_result + case "complete": { + result = await pool.query( + `UPDATE process_work_result SET group_completed_at = NOW()::text, group_total_paused_time = CASE WHEN group_paused_at IS NOT NULL THEN ( @@ -691,88 +719,88 @@ export const controlGroupTimer = async ( updated_date = NOW() WHERE ${whereClause} RETURNING id, group_started_at, group_completed_at, group_total_paused_time`, - baseParams - ); - break; - } - } + baseParams, + ); + break; + } + } - if (!result || result.rowCount === 0) { - return res.status(404).json({ - success: false, - message: "대상 그룹을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", - }); - } + if (!result || result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "대상 그룹을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", + }); + } - logger.info("[pop/production] group-timer 완료", { - action, - source_work_item_id, - affectedRows: result.rowCount, - }); + logger.info("[pop/production] group-timer 완료", { + action, + source_work_item_id, + affectedRows: result.rowCount, + }); - return res.json({ - success: true, - data: result.rows[0], - affectedRows: result.rowCount, - }); - } catch (error: any) { - logger.error("[pop/production] group-timer 오류:", error); - return res.status(500).json({ - success: false, - message: error.message || "그룹 타이머 처리 중 오류가 발생했습니다.", - }); - } + return res.json({ + success: true, + data: result.rows[0], + affectedRows: result.rowCount, + }); + } catch (error: any) { + logger.error("[pop/production] group-timer 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "그룹 타이머 처리 중 오류가 발생했습니다.", + }); + } }; /** * 불량 유형 목록 조회 (defect_standard_mng) */ export const getDefectTypes = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); + const pool = getPool(); - try { - const companyCode = req.user!.companyCode; + try { + const companyCode = req.user!.companyCode; - let query: string; - let params: unknown[]; + let query: string; + let params: unknown[]; - if (companyCode === "*") { - query = ` + if (companyCode === "*") { + query = ` SELECT id, defect_code, defect_name, defect_type, severity, company_code FROM defect_standard_mng WHERE is_active = 'Y' ORDER BY defect_code`; - params = []; - } else { - query = ` + params = []; + } else { + query = ` SELECT id, defect_code, defect_name, defect_type, severity, company_code FROM defect_standard_mng WHERE is_active = 'Y' AND company_code = $1 ORDER BY defect_code`; - params = [companyCode]; - } + params = [companyCode]; + } - const result = await pool.query(query, params); + const result = await pool.query(query, params); - logger.info("[pop/production] defect-types 조회", { - companyCode, - count: result.rowCount, - }); + logger.info("[pop/production] defect-types 조회", { + companyCode, + count: result.rowCount, + }); - return res.json({ - success: true, - data: result.rows, - }); - } catch (error: any) { - logger.error("[pop/production] defect-types 오류:", error); - return res.status(500).json({ - success: false, - message: error.message || "불량 유형 조회 중 오류가 발생했습니다.", - }); - } + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("[pop/production] defect-types 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "불량 유형 조회 중 오류가 발생했습니다.", + }); + } }; /** @@ -780,149 +808,157 @@ export const getDefectTypes = async ( * 이번 차수 생산수량을 기존 누적치에 더한다. * result_status는 'draft' 유지 (확정 전까지 계속 추가 등록 가능) */ -export const saveResult = async ( - req: AuthenticatedRequest, - res: Response -) => { - const pool = getPool(); +export const saveResult = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; - const { - work_order_process_id, - production_qty, - good_qty, - defect_qty, - defect_detail, - result_note, - } = req.body; + const { + work_order_process_id, + production_qty, + good_qty, + defect_qty, + defect_detail, + result_note, + } = req.body; - if (!work_order_process_id) { - return res.status(400).json({ - success: false, - message: "work_order_process_id는 필수입니다.", - }); - } + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } - if (!production_qty || parseInt(production_qty, 10) <= 0) { - return res.status(400).json({ - success: false, - message: "생산수량을 입력해주세요.", - }); - } + if (!production_qty || parseInt(production_qty, 10) <= 0) { + return res.status(400).json({ + success: false, + message: "생산수량을 입력해주세요.", + }); + } - const statusCheck = await pool.query( - `SELECT wop.status, wop.result_status, wop.total_production_qty, wop.good_qty, + const statusCheck = await pool.query( + `SELECT wop.status, wop.result_status, wop.total_production_qty, wop.good_qty, wop.defect_qty, wop.concession_qty, wop.defect_detail, wop.input_qty, wop.parent_process_id, wop.wo_id, wop.seq_no FROM work_order_process wop WHERE wop.id = $1 AND wop.company_code = $2`, - [work_order_process_id, companyCode] - ); + [work_order_process_id, companyCode], + ); - if (statusCheck.rowCount === 0) { - return res.status(404).json({ - success: false, - message: "공정을 찾을 수 없습니다.", - }); - } + if (statusCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없습니다.", + }); + } - const prev = statusCheck.rows[0]; + const prev = statusCheck.rows[0]; - // 마스터 행에 직접 실적 등록 방지 (분할 행이 존재하는 경우) - if (!prev.parent_process_id) { - const splitCheck = await pool.query( - `SELECT COUNT(*) as cnt FROM work_order_process + // 마스터 행에 직접 실적 등록 방지 (분할 행이 존재하는 경우) + if (!prev.parent_process_id) { + const splitCheck = await pool.query( + `SELECT COUNT(*) as cnt FROM work_order_process WHERE parent_process_id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); - if (parseInt(splitCheck.rows[0].cnt, 10) > 0) { - return res.status(400).json({ - success: false, - message: "원본 공정에는 직접 실적을 등록할 수 없습니다. 분할된 접수 카드에서 등록해주세요.", - }); - } - } + [work_order_process_id, companyCode], + ); + if (parseInt(splitCheck.rows[0].cnt, 10) > 0) { + return res.status(400).json({ + success: false, + message: + "원본 공정에는 직접 실적을 등록할 수 없습니다. 분할된 접수 카드에서 등록해주세요.", + }); + } + } - if (prev.result_status === "confirmed") { - return res.status(403).json({ - success: false, - message: "이미 확정된 실적입니다. 추가 등록이 불가능합니다.", - }); - } + if (prev.result_status === "confirmed") { + return res.status(403).json({ + success: false, + message: "이미 확정된 실적입니다. 추가 등록이 불가능합니다.", + }); + } - // 초과 생산 경고 (차단하지 않음 - 현장 유연성) - const prevTotal = parseInt(prev.total_production_qty, 10) || 0; - const acceptedQty = parseInt(prev.input_qty, 10) || 0; - const requestedQty = parseInt(production_qty, 10) || 0; - if (acceptedQty > 0 && (prevTotal + requestedQty) > acceptedQty) { - logger.warn("[pop/production] 초과 생산 감지", { - work_order_process_id, - prevTotal, requestedQty, acceptedQty, - overAmount: (prevTotal + requestedQty) - acceptedQty, - }); - } + // 초과 생산 경고 (차단하지 않음 - 현장 유연성) + const prevTotal = parseInt(prev.total_production_qty, 10) || 0; + const acceptedQty = parseInt(prev.input_qty, 10) || 0; + const requestedQty = parseInt(production_qty, 10) || 0; + if (acceptedQty > 0 && prevTotal + requestedQty > acceptedQty) { + logger.warn("[pop/production] 초과 생산 감지", { + work_order_process_id, + prevTotal, + requestedQty, + acceptedQty, + overAmount: prevTotal + requestedQty - acceptedQty, + }); + } - // 서버 측 양품/불량/특채 계산 (클라이언트 good_qty는 참고만) - const addProduction = parseInt(production_qty, 10) || 0; - let addDefect = 0; - let addConcession = 0; + // 서버 측 양품/불량/특채 계산 (클라이언트 good_qty는 참고만) + const addProduction = parseInt(production_qty, 10) || 0; + let addDefect = 0; + let addConcession = 0; - let defectDetailStr: string | null = null; - if (defect_detail && Array.isArray(defect_detail)) { - const validated = defect_detail.map((item: DefectDetailItem) => ({ - defect_code: item.defect_code || "", - defect_name: item.defect_name || "", - qty: item.qty || "0", - disposition: item.disposition || "scrap", - })); - defectDetailStr = JSON.stringify(validated); + let defectDetailStr: string | null = null; + if (defect_detail && Array.isArray(defect_detail)) { + const validated = defect_detail.map((item: DefectDetailItem) => ({ + defect_code: item.defect_code || "", + defect_name: item.defect_name || "", + qty: item.qty || "0", + disposition: item.disposition || "scrap", + })); + defectDetailStr = JSON.stringify(validated); - for (const item of validated) { - const itemQty = parseInt(item.qty, 10) || 0; - addDefect += itemQty; - if (item.disposition === "accept") { - addConcession += itemQty; - } - } - } else { - addDefect = parseInt(defect_qty, 10) || 0; - } - const addGood = addProduction - addDefect; + for (const item of validated) { + const itemQty = parseInt(item.qty, 10) || 0; + addDefect += itemQty; + if (item.disposition === "accept") { + addConcession += itemQty; + } + } + } else { + addDefect = parseInt(defect_qty, 10) || 0; + } + const addGood = addProduction - addDefect; - const newTotal = (parseInt(prev.total_production_qty, 10) || 0) + addProduction; - const newGood = (parseInt(prev.good_qty, 10) || 0) + addGood; - const newDefect = (parseInt(prev.defect_qty, 10) || 0) + addDefect; - const newConcession = (parseInt(prev.concession_qty, 10) || 0) + addConcession; + const newTotal = + (parseInt(prev.total_production_qty, 10) || 0) + addProduction; + const newGood = (parseInt(prev.good_qty, 10) || 0) + addGood; + const newDefect = (parseInt(prev.defect_qty, 10) || 0) + addDefect; + const newConcession = + (parseInt(prev.concession_qty, 10) || 0) + addConcession; - // 기존 defect_detail에 이번 차수 상세를 병합 - let mergedDefectDetail: string | null = null; - if (defectDetailStr) { - let existingEntries: DefectDetailItem[] = []; - try { - existingEntries = prev.defect_detail ? JSON.parse(prev.defect_detail) : []; - } catch { /* 파싱 실패 시 빈 배열 */ } - const newEntries: DefectDetailItem[] = JSON.parse(defectDetailStr); - const merged = [...existingEntries]; - for (const ne of newEntries) { - const existing = merged.find( - (e) => e.defect_code === ne.defect_code && e.disposition === ne.disposition - ); - if (existing) { - existing.qty = String( - (parseInt(existing.qty, 10) || 0) + (parseInt(ne.qty, 10) || 0) - ); - } else { - merged.push(ne); - } - } - mergedDefectDetail = JSON.stringify(merged); - } + // 기존 defect_detail에 이번 차수 상세를 병합 + let mergedDefectDetail: string | null = null; + if (defectDetailStr) { + let existingEntries: DefectDetailItem[] = []; + try { + existingEntries = prev.defect_detail + ? JSON.parse(prev.defect_detail) + : []; + } catch { + /* 파싱 실패 시 빈 배열 */ + } + const newEntries: DefectDetailItem[] = JSON.parse(defectDetailStr); + const merged = [...existingEntries]; + for (const ne of newEntries) { + const existing = merged.find( + (e) => + e.defect_code === ne.defect_code && + e.disposition === ne.disposition, + ); + if (existing) { + existing.qty = String( + (parseInt(existing.qty, 10) || 0) + (parseInt(ne.qty, 10) || 0), + ); + } else { + merged.push(ne); + } + } + mergedDefectDetail = JSON.stringify(merged); + } - const result = await pool.query( - `UPDATE work_order_process + const result = await pool.query( + `UPDATE work_order_process SET total_production_qty = $3, good_qty = $4, defect_qty = $5, @@ -935,30 +971,30 @@ export const saveResult = async ( updated_date = NOW() WHERE id = $1 AND company_code = $2 RETURNING id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status`, - [ - work_order_process_id, - companyCode, - String(newTotal), - String(newGood), - String(newDefect), - mergedDefectDetail, - result_note || null, - userId, - String(newConcession), - ] - ); + [ + work_order_process_id, + companyCode, + String(newTotal), + String(newGood), + String(newDefect), + mergedDefectDetail, + result_note || null, + userId, + String(newConcession), + ], + ); - if (result.rowCount === 0) { - return res.status(404).json({ - success: false, - message: "공정을 찾을 수 없거나 권한이 없습니다.", - }); - } + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없거나 권한이 없습니다.", + }); + } - // === BUG-2 FIX: SPLIT 실적 저장 후 master 행에 합산 === - if (prev.parent_process_id) { - await pool.query( - `UPDATE work_order_process + // === BUG-2 FIX: SPLIT 실적 저장 후 master 행에 합산 === + if (prev.parent_process_id) { + await pool.query( + `UPDATE work_order_process SET good_qty = sub.sum_good, defect_qty = sub.sum_defect, total_production_qty = sub.sum_total, @@ -974,17 +1010,17 @@ export const saveResult = async ( WHERE parent_process_id = $1 AND company_code = $2 ) sub WHERE id = $1 AND company_code = $2`, - [prev.parent_process_id, companyCode] - ); - logger.info("[pop/production] master 합산 업데이트", { - masterId: prev.parent_process_id, - splitId: work_order_process_id, - }); - } + [prev.parent_process_id, companyCode], + ); + logger.info("[pop/production] master 합산 업데이트", { + masterId: prev.parent_process_id, + splitId: work_order_process_id, + }); + } - // 현재 분할 행의 공정 정보 조회 - const currentSeq = await pool.query( - `SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty, + // 현재 분할 행의 공정 정보 조회 + const currentSeq = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty, wop.parent_process_id, wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, wop.standard_time, wop.equipment_code, wop.routing_detail_id, @@ -992,52 +1028,57 @@ export const saveResult = async ( FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code WHERE wop.id = $1 AND wop.company_code = $2`, - [work_order_process_id, companyCode] - ); + [work_order_process_id, companyCode], + ); - // 재작업 카드 자동 생성 (disposition = 'rework' 항목이 있을 때) - if (currentSeq.rowCount > 0 && defect_detail && Array.isArray(defect_detail)) { - let totalReworkQty = 0; - let targetProcessCode: string | null = null; - for (const item of defect_detail) { - if (item.disposition === "rework") { - totalReworkQty += parseInt(item.qty, 10) || 0; - if (item.target_process_code) targetProcessCode = item.target_process_code; - } - } - if (totalReworkQty > 0) { - const proc = currentSeq.rows[0]; - const masterId = proc.parent_process_id || work_order_process_id; + // 재작업 카드 자동 생성 (disposition = 'rework' 항목이 있을 때) + if ( + currentSeq.rowCount > 0 && + defect_detail && + Array.isArray(defect_detail) + ) { + let totalReworkQty = 0; + let targetProcessCode: string | null = null; + for (const item of defect_detail) { + if (item.disposition === "rework") { + totalReworkQty += parseInt(item.qty, 10) || 0; + if (item.target_process_code) + targetProcessCode = item.target_process_code; + } + } + if (totalReworkQty > 0) { + const proc = currentSeq.rows[0]; + const masterId = proc.parent_process_id || work_order_process_id; - // 재작업 대상 공정 결정 - let reworkSeqNo = proc.seq_no; - let reworkProcessCode = proc.process_code; - let reworkProcessName = proc.process_name; - let reworkRoutingDetailId = proc.routing_detail_id; - let reworkMasterId = masterId; + // 재작업 대상 공정 결정 + let reworkSeqNo = proc.seq_no; + let reworkProcessCode = proc.process_code; + let reworkProcessName = proc.process_name; + let reworkRoutingDetailId = proc.routing_detail_id; + let reworkMasterId = masterId; - // target_process_code가 지정되면 해당 공정 정보를 조회 - if (targetProcessCode) { - const targetProc = await pool.query( - `SELECT id, seq_no, process_code, process_name, routing_detail_id + // target_process_code가 지정되면 해당 공정 정보를 조회 + if (targetProcessCode) { + const targetProc = await pool.query( + `SELECT id, seq_no, process_code, process_name, routing_detail_id FROM work_order_process WHERE wo_id = $1 AND process_code = $2 AND company_code = $3 AND parent_process_id IS NULL LIMIT 1`, - [proc.wo_id, targetProcessCode, companyCode] - ); - if (targetProc.rowCount > 0) { - const tp = targetProc.rows[0]; - reworkSeqNo = tp.seq_no; - reworkProcessCode = tp.process_code; - reworkProcessName = tp.process_name; - reworkRoutingDetailId = tp.routing_detail_id; - reworkMasterId = tp.id; // 지정 공정의 마스터 ID - } - } + [proc.wo_id, targetProcessCode, companyCode], + ); + if (targetProc.rowCount > 0) { + const tp = targetProc.rows[0]; + reworkSeqNo = tp.seq_no; + reworkProcessCode = tp.process_code; + reworkProcessName = tp.process_name; + reworkRoutingDetailId = tp.routing_detail_id; + reworkMasterId = tp.id; // 지정 공정의 마스터 ID + } + } - const reworkInsert = await pool.query( - `INSERT INTO work_order_process ( + const reworkInsert = await pool.query( + `INSERT INTO work_order_process ( id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, equipment_code, routing_detail_id, status, input_qty, good_qty, defect_qty, concession_qty, total_production_qty, @@ -1049,173 +1090,213 @@ export const saveResult = async ( 'draft', 'Y', $11, $12, $13, $14 ) RETURNING id`, - [ - proc.wo_id, reworkSeqNo, reworkProcessCode, reworkProcessName, - proc.is_required, proc.is_fixed_order, proc.standard_time, - proc.equipment_code, reworkRoutingDetailId, - String(totalReworkQty), work_order_process_id, - reworkMasterId, companyCode, userId, - ] - ); - // 재작업 카드에 체크리스트 복사 - const reworkId = reworkInsert.rows[0]?.id; - if (reworkId) { - const reworkChecklistCount = await copyChecklistToSplit( - pool, reworkMasterId, reworkId, reworkRoutingDetailId, companyCode, userId - ); - logger.info("[pop/production] 재작업 카드 자동 생성", { - reworkId, - sourceId: work_order_process_id, - reworkQty: totalReworkQty, - targetProcess: targetProcessCode || "(같은 공정)", - reworkSeqNo, - checklistCount: reworkChecklistCount, - }); - } - } - } + [ + proc.wo_id, + reworkSeqNo, + reworkProcessCode, + reworkProcessName, + proc.is_required, + proc.is_fixed_order, + proc.standard_time, + proc.equipment_code, + reworkRoutingDetailId, + String(totalReworkQty), + work_order_process_id, + reworkMasterId, + companyCode, + userId, + ], + ); + // 재작업 카드에 체크리스트 복사 + const reworkId = reworkInsert.rows[0]?.id; + if (reworkId) { + const reworkChecklistCount = await copyChecklistToSplit( + pool, + reworkMasterId, + reworkId, + reworkRoutingDetailId, + companyCode, + userId, + ); + logger.info("[pop/production] 재작업 카드 자동 생성", { + reworkId, + sourceId: work_order_process_id, + reworkQty: totalReworkQty, + targetProcess: targetProcessCode || "(같은 공정)", + reworkSeqNo, + checklistCount: reworkChecklistCount, + }); + } + } + } - // 개별 분할 행 자동완료 (다음 공정 활성화보다 먼저 실행) - if (currentSeq.rowCount > 0) { - const { seq_no: csSeq, wo_id: csWoId, current_input_qty: csInputQty, instruction_qty: csInstrQty, parent_process_id: csParentId } = currentSeq.rows[0]; - const csMyInput = parseInt(csInputQty, 10) || 0; + // 개별 분할 행 자동완료 (다음 공정 활성화보다 먼저 실행) + if (currentSeq.rowCount > 0) { + const { + seq_no: csSeq, + wo_id: csWoId, + current_input_qty: csInputQty, + instruction_qty: csInstrQty, + parent_process_id: csParentId, + } = currentSeq.rows[0]; + const csMyInput = parseInt(csInputQty, 10) || 0; - if (newTotal >= csMyInput && csMyInput > 0) { - await pool.query( - `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', + if (newTotal >= csMyInput && csMyInput > 0) { + await pool.query( + `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', completed_at = NOW()::text, completed_by = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND status != 'completed'`, - [work_order_process_id, companyCode, userId] - ); + [work_order_process_id, companyCode, userId], + ); - // 같은 seq의 모든 분할 행 완료 체크 → 마스터도 completed - const csSeqNum = parseInt(csSeq, 10); - let csPrevGood = parseInt(csInstrQty, 10) || 0; - if (csSeqNum > 1) { - const prev = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as tg + // 같은 seq의 모든 분할 행 완료 체크 → 마스터도 completed + const csSeqNum = parseInt(csSeq, 10); + let csPrevGood = parseInt(csInstrQty, 10) || 0; + if (csSeqNum > 1) { + const prev = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as tg FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, - [csWoId, String(csSeqNum - 1), companyCode] - ); - if (prev.rowCount > 0) csPrevGood = parseInt(prev.rows[0].tg, 10) || 0; - } - const sibCheck = await pool.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as ti, COUNT(*) FILTER (WHERE status != 'completed') as ic + [csWoId, String(csSeqNum - 1), companyCode], + ); + if (prev.rowCount > 0) + csPrevGood = parseInt(prev.rows[0].tg, 10) || 0; + } + const sibCheck = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as ti, COUNT(*) FILTER (WHERE status != 'completed') as ic FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, - [csWoId, csSeq, companyCode] - ); - const csTotalInput = parseInt(sibCheck.rows[0].ti, 10) || 0; - const csIncomplete = parseInt(sibCheck.rows[0].ic, 10) || 0; - if (csIncomplete === 0 && csPrevGood - csTotalInput <= 0 && csParentId) { - await pool.query( - `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', + [csWoId, csSeq, companyCode], + ); + const csTotalInput = parseInt(sibCheck.rows[0].ti, 10) || 0; + const csIncomplete = parseInt(sibCheck.rows[0].ic, 10) || 0; + if ( + csIncomplete === 0 && + csPrevGood - csTotalInput <= 0 && + csParentId + ) { + await pool.query( + `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', completed_at = NOW()::text, completed_by = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND status != 'completed'`, - [csParentId, companyCode, userId] - ); - } - } + [csParentId, companyCode, userId], + ); + } + } - await checkAndCompleteWorkInstruction(pool, csWoId, companyCode, userId); - } + await checkAndCompleteWorkInstruction(pool, csWoId, companyCode, userId); + } - // 다음 공정 활성화 (다중공정 대응) - // is_fixed_order='Y' 그룹이면 그룹 전체 완료 후 다음 활성화 - if (addGood > 0 && currentSeq.rowCount > 0) { - const { seq_no, wo_id, is_fixed_order } = currentSeq.rows[0]; - const seqNum = parseInt(seq_no, 10); + // 다음 공정 활성화 (다중공정 대응) + // is_fixed_order='Y' 그룹이면 그룹 전체 완료 후 다음 활성화 + if (addGood > 0 && currentSeq.rowCount > 0) { + const { seq_no, wo_id, is_fixed_order } = currentSeq.rows[0]; + const seqNum = parseInt(seq_no, 10); - let shouldActivateNext = true; + let shouldActivateNext = true; - if (is_fixed_order === "Y") { - // 같은 seq_no에서 is_fixed_order='Y'인 병렬 공정이 모두 완료되었는지 확인 - // (병렬 그룹 = 같은 seq_no를 공유하는 공정들) - const groupCheck = await pool.query( - `SELECT id, seq_no, status, + if (is_fixed_order === "Y") { + // 같은 seq_no에서 is_fixed_order='Y'인 병렬 공정이 모두 완료되었는지 확인 + // (병렬 그룹 = 같은 seq_no를 공유하는 공정들) + const groupCheck = await pool.query( + `SELECT id, seq_no, status, COALESCE(good_qty::int, 0) + COALESCE(concession_qty::int, 0) as total_good FROM work_order_process WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL AND seq_no = $3 ORDER BY CAST(seq_no AS int)`, - [wo_id, companyCode, seq_no] - ); + [wo_id, companyCode, seq_no], + ); - // 같은 seq의 미완료 공정 확인 (병렬 그룹 내) - const incomplete = groupCheck.rows.filter((r: Record) => - String(r.status) !== "completed" && parseInt(String(r.total_good), 10) <= 0 - ); - shouldActivateNext = incomplete.length === 0; + // 같은 seq의 미완료 공정 확인 (병렬 그룹 내) + const incomplete = groupCheck.rows.filter( + (r: Record) => + String(r.status) !== "completed" && + parseInt(String(r.total_good), 10) <= 0, + ); + shouldActivateNext = incomplete.length === 0; - if (!shouldActivateNext) { - logger.info("[pop/production] 병렬 그룹 미완료 — 다음 공정 대기", { - groupSize: groupCheck.rows.length, - incomplete: incomplete.length, - }); - } - } + if (!shouldActivateNext) { + logger.info("[pop/production] 병렬 그룹 미완료 — 다음 공정 대기", { + groupSize: groupCheck.rows.length, + incomplete: incomplete.length, + }); + } + } - if (shouldActivateNext) { - // 다음 seq 활성화 (completed도 재활성화 — 새 양품이 들어오면 추가 접수 가능) - const nextSeq = String(seqNum + 1); - const nextUpdate = await pool.query( - `UPDATE work_order_process - SET status = 'acceptable', - updated_date = NOW() - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NULL - RETURNING id, process_name, status`, - [wo_id, nextSeq, companyCode] - ); - if (nextUpdate.rowCount > 0) { - logger.info("[pop/production] 다음 공정 상태 전환", { - nextProcess: nextUpdate.rows[0], - }); - } - } - } + if (shouldActivateNext) { + // 다음 seq 활성화 (seq_no 비순차 대응: seqNum+1이 아니라 "현재보다 큰 가장 작은 seq_no") + const nextSeqQuery = await pool.query( + `SELECT MIN(CAST(seq_no AS int)) as next_seq + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL + AND CAST(seq_no AS int) > $3`, + [wo_id, companyCode, seqNum], + ); + const actualNextSeq = nextSeqQuery.rows[0]?.next_seq; + if (actualNextSeq != null) { + const nextUpdate = await pool.query( + `UPDATE work_order_process + SET status = 'acceptable', + updated_date = NOW() + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NULL + RETURNING id, process_name, status`, + [wo_id, String(actualNextSeq), companyCode], + ); + if (nextUpdate.rowCount > 0) { + logger.info("[pop/production] 다음 공정 상태 전환", { + nextProcess: nextUpdate.rows[0], + }); + } + } + } + } - // (분할행 완료 + 마스터 캐스케이드는 위에서 이미 처리됨) + // (분할행 완료 + 마스터 캐스케이드는 위에서 이미 처리됨) - logger.info("[pop/production] save-result 완료 (누적)", { - companyCode, - work_order_process_id, - added: { production_qty: addProduction, good_qty: addGood, defect_qty: addDefect }, - accumulated: { total: newTotal, good: newGood, defect: newDefect }, - }); + logger.info("[pop/production] save-result 완료 (누적)", { + companyCode, + work_order_process_id, + added: { + production_qty: addProduction, + good_qty: addGood, + defect_qty: addDefect, + }, + accumulated: { total: newTotal, good: newGood, defect: newDefect }, + }); - // 자동 완료 후 최신 데이터 반환 (status가 변경되었을 수 있음) - const latestData = await pool.query( - `SELECT id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status, input_qty + // 자동 완료 후 최신 데이터 반환 (status가 변경되었을 수 있음) + const latestData = await pool.query( + `SELECT id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status, input_qty FROM work_order_process WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); + [work_order_process_id, companyCode], + ); - // 리워크 정보도 응답에 포함 (프론트에서 다음 공정 접수 시 전달 가능) - const responseData = latestData.rows[0] || result.rows[0]; - if (responseData) { - const reworkInfo = await pool.query( - `SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`, - [work_order_process_id] - ); - if (reworkInfo.rows[0]?.rework_source_id) { - responseData.rework_source_id = reworkInfo.rows[0].rework_source_id; - responseData.is_rework = reworkInfo.rows[0].is_rework; - } - } + // 리워크 정보도 응답에 포함 (프론트에서 다음 공정 접수 시 전달 가능) + const responseData = latestData.rows[0] || result.rows[0]; + if (responseData) { + const reworkInfo = await pool.query( + `SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`, + [work_order_process_id], + ); + if (reworkInfo.rows[0]?.rework_source_id) { + responseData.rework_source_id = reworkInfo.rows[0].rework_source_id; + responseData.is_rework = reworkInfo.rows[0].is_rework; + } + } - return res.json({ - success: true, - data: responseData, - }); - } catch (error: any) { - logger.error("[pop/production] save-result 오류:", error); - return res.status(500).json({ - success: false, - message: error.message || "실적 저장 중 오류가 발생했습니다.", - }); - } + return res.json({ + success: true, + data: responseData, + }); + } catch (error: any) { + logger.error("[pop/production] save-result 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "실적 저장 중 오류가 발생했습니다.", + }); + } }; /** @@ -1223,44 +1304,44 @@ export const saveResult = async ( * 마지막 공정의 모든 행이 completed이면 작업지시도 완료 처리 */ const checkAndCompleteWorkInstruction = async ( - pool: any, - woId: string, - companyCode: string, - userId: string + pool: any, + woId: string, + companyCode: string, + userId: string, ) => { - const maxSeqResult = await pool.query( - `SELECT MAX(seq_no::int) as max_seq + const maxSeqResult = await pool.query( + `SELECT MAX(seq_no::int) as max_seq FROM work_order_process WHERE wo_id = $1 AND company_code = $2`, - [woId, companyCode] - ); + [woId, companyCode], + ); - if (maxSeqResult.rowCount === 0 || !maxSeqResult.rows[0].max_seq) return; + if (maxSeqResult.rowCount === 0 || !maxSeqResult.rows[0].max_seq) return; - const maxSeq = String(maxSeqResult.rows[0].max_seq); + const maxSeq = String(maxSeqResult.rows[0].max_seq); - const incompleteCheck = await pool.query( - `SELECT COUNT(*) as cnt + const incompleteCheck = await pool.query( + `SELECT COUNT(*) as cnt FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND status != 'completed'`, - [woId, maxSeq, companyCode] - ); + [woId, maxSeq, companyCode], + ); - if (parseInt(incompleteCheck.rows[0].cnt, 10) > 0) return; + if (parseInt(incompleteCheck.rows[0].cnt, 10) > 0) return; - const totalGoodResult = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + const totalGoodResult = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, - [woId, maxSeq, companyCode] - ); + [woId, maxSeq, companyCode], + ); - const completedQty = totalGoodResult.rows[0].total_good; + const completedQty = totalGoodResult.rows[0].total_good; - const updateResult = await pool.query( - `UPDATE work_instruction + const updateResult = await pool.query( + `UPDATE work_instruction SET status = 'completed', progress_status = 'completed', completed_qty = $3, @@ -1269,58 +1350,85 @@ const checkAndCompleteWorkInstruction = async ( WHERE id = $1 AND company_code = $2 AND status != 'completed' RETURNING id, item_id`, - [woId, companyCode, String(completedQty), userId] - ); + [woId, companyCode, String(completedQty), userId], + ); - logger.info("[pop/production] 작업지시 전체 완료", { - woId, completedQty, companyCode, - }); + logger.info("[pop/production] 작업지시 전체 완료", { + woId, + completedQty, + companyCode, + }); - // 생산완료→재고 입고: 마지막 공정의 target_warehouse_id가 설정된 경우 inventory_stock UPSERT - if (updateResult.rowCount > 0 && completedQty > 0) { - try { - const itemId = updateResult.rows[0].item_id; + // 생산완료→재고 입고: 마지막 공정의 target_warehouse_id가 설정된 경우 inventory_stock UPSERT + if (updateResult.rowCount > 0 && completedQty > 0) { + try { + const itemId = updateResult.rows[0].item_id; - // item_info에서 item_number 조회 - const itemResult = await pool.query( - `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, - [itemId, companyCode] - ); - if (itemResult.rowCount === 0) { - logger.warn("[pop/production] 재고입고 건너뜀: item_info 없음", { itemId, companyCode }); - return; - } - const itemCode = itemResult.rows[0].item_number; + // item_info에서 item_number 조회 + const itemResult = await pool.query( + `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, + [itemId, companyCode], + ); + if (itemResult.rowCount === 0) { + logger.warn("[pop/production] 재고입고 건너뜀: item_info 없음", { + itemId, + companyCode, + }); + return; + } + const itemCode = itemResult.rows[0].item_number; - // 마지막 공정의 창고 설정 조회 (마스터 행에서) - const warehouseResult = await pool.query( - `SELECT target_warehouse_id, target_location_code + // 마지막 공정의 창고 설정 조회 (마스터 행에서) + const warehouseResult = await pool.query( + `SELECT target_warehouse_id, target_location_code FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NULL LIMIT 1`, - [woId, maxSeq, companyCode] - ); + [woId, maxSeq, companyCode], + ); - if (warehouseResult.rowCount === 0 || !warehouseResult.rows[0].target_warehouse_id) { - logger.info("[pop/production] 재고입고 건너뜀: 목표창고 미설정", { woId }); - return; - } + if ( + warehouseResult.rowCount === 0 || + !warehouseResult.rows[0].target_warehouse_id + ) { + logger.info("[pop/production] 재고입고 건너뜀: 목표창고 미설정", { + woId, + }); + return; + } - const warehouseCode = warehouseResult.rows[0].target_warehouse_id; - const locationCode = warehouseResult.rows[0].target_location_code || warehouseCode; + const warehouseCode = warehouseResult.rows[0].target_warehouse_id; + const locationCode = + warehouseResult.rows[0].target_location_code || warehouseCode; - // inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) - await upsertInventoryStock(pool, companyCode, itemCode, warehouseCode, locationCode, completedQty, userId); + // inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) + await upsertInventoryStock( + pool, + companyCode, + itemCode, + warehouseCode, + locationCode, + completedQty, + userId, + ); - logger.info("[pop/production] 생산완료→재고 입고 완료", { - woId, itemCode, warehouseCode, locationCode, qty: completedQty, companyCode, - }); - } catch (inventoryError: any) { - // 재고 입고 실패해도 공정 완료는 유지 (재고는 보조 기능) - logger.error("[pop/production] 재고입고 오류 (공정 완료는 유지):", inventoryError); - } - } + logger.info("[pop/production] 생산완료→재고 입고 완료", { + woId, + itemCode, + warehouseCode, + locationCode, + qty: completedQty, + companyCode, + }); + } catch (inventoryError: any) { + // 재고 입고 실패해도 공정 완료는 유지 (재고는 보조 기능) + logger.error( + "[pop/production] 재고입고 오류 (공정 완료는 유지):", + inventoryError, + ); + } + } }; /** @@ -1328,50 +1436,52 @@ const checkAndCompleteWorkInstruction = async ( * 마지막 등록 확인 용도로 유지. 실적은 save-result에서 차수별로 쌓임. */ export const confirmResult = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); + const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; - const { work_order_process_id } = req.body; + const { work_order_process_id } = req.body; - if (!work_order_process_id) { - return res.status(400).json({ - success: false, - message: "work_order_process_id는 필수입니다.", - }); - } + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } - const statusCheck = await pool.query( - `SELECT status, result_status, total_production_qty FROM work_order_process + const statusCheck = await pool.query( + `SELECT status, result_status, total_production_qty FROM work_order_process WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); + [work_order_process_id, companyCode], + ); - if (statusCheck.rowCount === 0) { - return res.status(404).json({ - success: false, - message: "공정을 찾을 수 없습니다.", - }); - } + if (statusCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없습니다.", + }); + } - const currentProcess = statusCheck.rows[0]; + const currentProcess = statusCheck.rows[0]; - if (!currentProcess.total_production_qty || - parseInt(currentProcess.total_production_qty, 10) <= 0) { - return res.status(400).json({ - success: false, - message: "등록된 실적이 없습니다. 실적을 먼저 등록해주세요.", - }); - } + if ( + !currentProcess.total_production_qty || + parseInt(currentProcess.total_production_qty, 10) <= 0 + ) { + return res.status(400).json({ + success: false, + message: "등록된 실적이 없습니다. 실적을 먼저 등록해주세요.", + }); + } - // 수동 확정: 무조건 completed 처리 (수동 완료 용도) - const result = await pool.query( - `UPDATE work_order_process + // 수동 확정: 무조건 completed 처리 (수동 완료 용도) + const result = await pool.query( + `UPDATE work_order_process SET result_status = 'confirmed', status = 'completed', completed_at = NOW()::text, @@ -1380,49 +1490,50 @@ export const confirmResult = async ( updated_date = NOW() WHERE id = $1 AND company_code = $2 RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty`, - [work_order_process_id, companyCode, userId] - ); + [work_order_process_id, companyCode, userId], + ); - if (result.rowCount === 0) { - return res.status(404).json({ - success: false, - message: "공정을 찾을 수 없습니다.", - }); - } + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없습니다.", + }); + } - // 공정 정보 조회 (다음 공정 활성화 + 마스터 캐스케이드용) - const seqCheck = await pool.query( - `SELECT wop.seq_no, wop.wo_id, wop.parent_process_id, + // 공정 정보 조회 (다음 공정 활성화 + 마스터 캐스케이드용) + const seqCheck = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.parent_process_id, wi.qty as instruction_qty FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code WHERE wop.id = $1 AND wop.company_code = $2`, - [work_order_process_id, companyCode] - ); + [work_order_process_id, companyCode], + ); - if (seqCheck.rowCount > 0) { - const { seq_no, wo_id, parent_process_id, instruction_qty } = seqCheck.rows[0]; - const seqNum = parseInt(seq_no, 10); - const instrQty = parseInt(instruction_qty, 10) || 0; + if (seqCheck.rowCount > 0) { + const { seq_no, wo_id, parent_process_id, instruction_qty } = + seqCheck.rows[0]; + const seqNum = parseInt(seq_no, 10); + const instrQty = parseInt(instruction_qty, 10) || 0; - // 다음 공정 활성화 (양품이 있으면) - const goodQty = parseInt(result.rows[0].good_qty, 10) || 0; - if (goodQty > 0) { - const nextSeq = String(seqNum + 1); - await pool.query( - `UPDATE work_order_process + // 다음 공정 활성화 (양품이 있으면) + const goodQty = parseInt(result.rows[0].good_qty, 10) || 0; + if (goodQty > 0) { + const nextSeq = String(seqNum + 1); + await pool.query( + `UPDATE work_order_process SET status = 'acceptable', updated_date = NOW() WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NULL`, - [wo_id, nextSeq, companyCode] - ); - } + [wo_id, nextSeq, companyCode], + ); + } - // === BUG-2 FIX: confirmResult에서도 master 합산 === - if (parent_process_id) { - await pool.query( - `UPDATE work_order_process + // === BUG-2 FIX: confirmResult에서도 master 합산 === + if (parent_process_id) { + await pool.query( + `UPDATE work_order_process SET good_qty = sub.sum_good, defect_qty = sub.sum_defect, total_production_qty = sub.sum_total, @@ -1438,44 +1549,45 @@ export const confirmResult = async ( WHERE parent_process_id = $1 AND company_code = $2 ) sub WHERE id = $1 AND company_code = $2`, - [parent_process_id, companyCode] - ); - } + [parent_process_id, companyCode], + ); + } - // 마스터 자동완료 캐스케이드 (분할 행인 경우) - if (parent_process_id) { - let prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); - const prevProcess = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + // 마스터 자동완료 캐스케이드 (분할 행인 경우) + if (parent_process_id) { + let prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, - [wo_id, prevSeq, companyCode] - ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; - } - } + [wo_id, prevSeq, companyCode], + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } + } - const siblingCheck = await pool.query( - `SELECT + const siblingCheck = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input, COUNT(*) FILTER (WHERE status != 'completed') as incomplete_count FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, - [wo_id, seq_no, companyCode] - ); + [wo_id, seq_no, companyCode], + ); - const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0; - const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0; - const remainingAcceptable = prevGoodQty - totalInput; + const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0; + const incompleteCount = + parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0; + const remainingAcceptable = prevGoodQty - totalInput; - if (incompleteCount === 0 && remainingAcceptable <= 0) { - await pool.query( - `UPDATE work_order_process + if (incompleteCount === 0 && remainingAcceptable <= 0) { + await pool.query( + `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', completed_at = NOW()::text, @@ -1483,36 +1595,38 @@ export const confirmResult = async ( updated_date = NOW() WHERE id = $1 AND company_code = $2 AND status != 'completed'`, - [parent_process_id, companyCode, userId] - ); - logger.info("[pop/production] confirmResult: 마스터 자동 완료", { - masterId: parent_process_id, totalInput, prevGoodQty, - }); - } - } + [parent_process_id, companyCode, userId], + ); + logger.info("[pop/production] confirmResult: 마스터 자동 완료", { + masterId: parent_process_id, + totalInput, + prevGoodQty, + }); + } + } - // 작업지시 전체 완료 판정 - await checkAndCompleteWorkInstruction(pool, wo_id, companyCode, userId); - } + // 작업지시 전체 완료 판정 + await checkAndCompleteWorkInstruction(pool, wo_id, companyCode, userId); + } - logger.info("[pop/production] confirm-result 완료", { - companyCode, - work_order_process_id, - userId, - finalStatus: result.rows[0].status, - }); + logger.info("[pop/production] confirm-result 완료", { + companyCode, + work_order_process_id, + userId, + finalStatus: result.rows[0].status, + }); - return res.json({ - success: true, - data: result.rows[0], - }); - } catch (error: any) { - logger.error("[pop/production] confirm-result 오류:", error); - return res.status(500).json({ - success: false, - message: error.message || "실적 확정 중 오류가 발생했습니다.", - }); - } + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("[pop/production] confirm-result 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "실적 확정 중 오류가 발생했습니다.", + }); + } }; /** @@ -1520,36 +1634,40 @@ export const confirmResult = async ( * total_production_qty 변경 이력 = 각 차수의 등록 기록 */ export const getResultHistory = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); + const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const rawWopId = req.query.work_order_process_id; - const work_order_process_id = Array.isArray(rawWopId) ? rawWopId[0] : rawWopId; + try { + const companyCode = req.user!.companyCode; + const rawWopId = req.query.work_order_process_id; + const work_order_process_id = Array.isArray(rawWopId) + ? rawWopId[0] + : rawWopId; - if (!work_order_process_id) { - return res.status(400).json({ - success: false, - message: "work_order_process_id는 필수입니다.", - }); - } + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } - // 소유권 확인 - const ownerCheck = await pool.query( - `SELECT id FROM work_order_process WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); - if (ownerCheck.rowCount === 0) { - return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); - } + // 소유권 확인 + const ownerCheck = await pool.query( + `SELECT id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode], + ); + if (ownerCheck.rowCount === 0) { + return res + .status(404) + .json({ success: false, message: "공정을 찾을 수 없습니다." }); + } - // 같은 changed_at 기준으로 그룹핑하여 차수별 이력 추출 - // total_production_qty가 증가한(new_value > old_value) 로그만 = 실적 등록 시점 - const historyResult = await pool.query( - `WITH grouped AS ( + // 같은 changed_at 기준으로 그룹핑하여 차수별 이력 추출 + // total_production_qty가 증가한(new_value > old_value) 로그만 = 실적 등록 시점 + const historyResult = await pool.query( + `WITH grouped AS ( SELECT changed_at, MAX(changed_by) as changed_by, @@ -1569,41 +1687,45 @@ export const getResultHistory = async ( WHERE total_new IS NOT NULL AND (COALESCE(total_new::int, 0) - COALESCE(total_old::int, 0)) > 0 ORDER BY changed_at ASC`, - [work_order_process_id] - ); + [work_order_process_id], + ); - const batches = historyResult.rows.map((row: any, idx: number) => { - const batchQty = (parseInt(row.total_new, 10) || 0) - (parseInt(row.total_old, 10) || 0); - const batchGood = (parseInt(row.good_new, 10) || 0) - (parseInt(row.good_old, 10) || 0); - const batchDefect = (parseInt(row.defect_new, 10) || 0) - (parseInt(row.defect_old, 10) || 0); + const batches = historyResult.rows.map((row: any, idx: number) => { + const batchQty = + (parseInt(row.total_new, 10) || 0) - (parseInt(row.total_old, 10) || 0); + const batchGood = + (parseInt(row.good_new, 10) || 0) - (parseInt(row.good_old, 10) || 0); + const batchDefect = + (parseInt(row.defect_new, 10) || 0) - + (parseInt(row.defect_old, 10) || 0); - return { - seq: idx + 1, - batch_qty: batchQty, - batch_good: batchGood, - batch_defect: batchDefect, - accumulated_total: parseInt(row.total_new, 10) || 0, - changed_at: row.changed_at, - changed_by: row.changed_by, - }; - }); + return { + seq: idx + 1, + batch_qty: batchQty, + batch_good: batchGood, + batch_defect: batchDefect, + accumulated_total: parseInt(row.total_new, 10) || 0, + changed_at: row.changed_at, + changed_by: row.changed_by, + }; + }); - logger.info("[pop/production] result-history 조회", { - work_order_process_id, - batchCount: batches.length, - }); + logger.info("[pop/production] result-history 조회", { + work_order_process_id, + batchCount: batches.length, + }); - return res.json({ - success: true, - data: batches, - }); - } catch (error: any) { - logger.error("[pop/production] result-history 오류:", error); - return res.status(500).json({ - success: false, - message: error.message || "이력 조회 중 오류가 발생했습니다.", - }); - } + return res.json({ + success: true, + data: batches, + }); + } catch (error: any) { + logger.error("[pop/production] result-history 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "이력 조회 중 오류가 발생했습니다.", + }); + } }; /** @@ -1611,99 +1733,126 @@ export const getResultHistory = async ( * GET /api/pop/production/available-qty?work_order_process_id=xxx * 반환: { prevGoodQty, myInputQty, availableQty, instructionQty } */ -export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const rawWopId = req.query.work_order_process_id; - const work_order_process_id = Array.isArray(rawWopId) ? rawWopId[0] : rawWopId; +export const getAvailableQty = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const rawWopId = req.query.work_order_process_id; + const work_order_process_id = Array.isArray(rawWopId) + ? rawWopId[0] + : rawWopId; - if (!work_order_process_id) { - return res.status(400).json({ - success: false, - message: "work_order_process_id가 필요합니다.", - }); - } + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id가 필요합니다.", + }); + } - const current = await pool.query( - `SELECT wop.seq_no, wop.wo_id, wop.parent_process_id, + const current = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.parent_process_id, wi.qty as instruction_qty FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code WHERE wop.id = $1 AND wop.company_code = $2`, - [work_order_process_id, companyCode] - ); + [work_order_process_id, companyCode], + ); - if (current.rowCount === 0) { - return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); - } + if (current.rowCount === 0) { + return res + .status(404) + .json({ success: false, message: "공정을 찾을 수 없습니다." }); + } - const { seq_no, wo_id, instruction_qty } = current.rows[0]; - const instrQty = parseInt(instruction_qty, 10) || 0; - const seqNum = parseInt(seq_no, 10); + const { seq_no, wo_id, instruction_qty } = current.rows[0]; + const instrQty = parseInt(instruction_qty, 10) || 0; + const seqNum = parseInt(seq_no, 10); - // 재작업 카드 여부 확인 - const reworkCheck = await pool.query( - `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, - [work_order_process_id] - ); - const isRework = reworkCheck.rows[0]?.is_rework === "Y"; + // 재작업 카드 여부 확인 + const reworkCheck = await pool.query( + `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, + [work_order_process_id], + ); + const isRework = reworkCheck.rows[0]?.is_rework === "Y"; - let myInputQty: number; - let prevGoodQty: number; - let availableQty: number; + let myInputQty: number; + let prevGoodQty: number; + let availableQty: number; - if (isRework) { - // 재작업 카드: 자체 input_qty가 접수 가능 수량 - const reworkInput = parseInt(reworkCheck.rows[0]?.input_qty, 10) || 0; - myInputQty = 0; - prevGoodQty = reworkInput; - availableQty = reworkInput; - } else { - // 일반 카드: 앞공정 양품 - 기접수합계 (재작업 카드 제외) - const totalAccepted = await pool.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total_input + if (isRework) { + // 재작업 카드: 자체 input_qty가 접수 가능 수량 + const reworkInput = parseInt(reworkCheck.rows[0]?.input_qty, 10) || 0; + myInputQty = 0; + prevGoodQty = reworkInput; + availableQty = reworkInput; + } else { + // 일반 카드: 앞공정 양품 - 기접수합계 (재작업 카드 제외) + const totalAccepted = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL AND (is_rework IS NULL OR is_rework != 'Y')`, - [wo_id, seq_no, companyCode] - ); - myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; + [wo_id, seq_no, companyCode], + ); + myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; - prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); - const prevProcess = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + prevGoodQty = instrQty; + // 첫 공정 여부를 seq_no==1이 아니라 "이 공정보다 작은 seq_no가 있는지"로 판단 + // (라우팅 seq_no가 1, 2, 3이 아니라 10, 20, 30 같은 비순차여도 정상 동작) + const minSeqCheck = await pool.query( + `SELECT MIN(CAST(seq_no AS int)) as min_seq + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL`, + [wo_id, companyCode], + ); + const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum; + const isFirstProcess = seqNum <= minSeq; + if (!isFirstProcess) { + // 이전 공정 찾기 (seq_no가 더 작은 가장 가까운 공정) + const prevProcessSeq = await pool.query( + `SELECT MAX(CAST(seq_no AS int)) as prev_seq FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [wo_id, prevSeq, companyCode] - ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; - } - } - availableQty = Math.max(0, prevGoodQty - myInputQty); - } + WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL + AND CAST(seq_no AS int) < $3`, + [wo_id, companyCode, seqNum], + ); + const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq; + if (actualPrevSeq != null) { + const prevProcess = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, + [wo_id, String(actualPrevSeq), companyCode], + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } + } + } + availableQty = Math.max(0, prevGoodQty - myInputQty); + } - logger.info("[pop/production] available-qty 조회", { - work_order_process_id, - prevGoodQty, - myInputQty, - availableQty, - instructionQty: instrQty, - }); + logger.info("[pop/production] available-qty 조회", { + work_order_process_id, + prevGoodQty, + myInputQty, + availableQty, + instructionQty: instrQty, + }); - // 앞공정에서 리워크로 완료된 양품 수량 (마크 표시용) - // rework_source_id별로 개별 추적하여 정확한 미소진 리워크 수량 계산 - let reworkAvailableQty = 0; - if (!isRework && seqNum > 1) { - const prevSeq = String(seqNum - 1); - // 앞공정의 리워크 완료 SPLIT들 (rework_source_id별) - const reworkSplits = await pool.query( - `SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rg + // 앞공정에서 리워크로 완료된 양품 수량 (마크 표시용) + // rework_source_id별로 개별 추적하여 정확한 미소진 리워크 수량 계산 + let reworkAvailableQty = 0; + if (!isRework && seqNum > 1) { + const prevSeq = String(seqNum - 1); + // 앞공정의 리워크 완료 SPLIT들 (rework_source_id별) + const reworkSplits = await pool.query( + `SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rg FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL @@ -1711,43 +1860,43 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) AND status = 'completed' AND good_qty::int > 0 GROUP BY rework_source_id`, - [wo_id, prevSeq, companyCode] - ); - // 현재 공정에서 각 rework_source_id별로 소비된 수량 - for (const rs of reworkSplits.rows) { - const srcId = rs.rework_source_id; - const srcGood = parseInt(rs.rg, 10) || 0; - const consumedResult = await pool.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as consumed + [wo_id, prevSeq, companyCode], + ); + // 현재 공정에서 각 rework_source_id별로 소비된 수량 + for (const rs of reworkSplits.rows) { + const srcId = rs.rework_source_id; + const srcGood = parseInt(rs.rg, 10) || 0; + const consumedResult = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as consumed FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL AND is_rework = 'Y' AND rework_source_id = $4`, - [wo_id, seq_no, companyCode, srcId] - ); - const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; - reworkAvailableQty += Math.max(0, srcGood - consumed); - } - } + [wo_id, seq_no, companyCode, srcId], + ); + const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; + reworkAvailableQty += Math.max(0, srcGood - consumed); + } + } - return res.json({ - success: true, - data: { - prevGoodQty, - myInputQty, - availableQty, - instructionQty: instrQty, - reworkAvailableQty, // 리워크 물량 포함 수량 - }, - }); - } catch (error: any) { - logger.error("[pop/production] available-qty 오류:", error); - return res.status(500).json({ - success: false, - message: error.message || "접수가능량 조회 중 오류가 발생했습니다.", - }); - } + return res.json({ + success: true, + data: { + prevGoodQty, + myInputQty, + availableQty, + instructionQty: instrQty, + reworkAvailableQty, // 리워크 물량 포함 수량 + }, + }); + } catch (error: any) { + logger.error("[pop/production] available-qty 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "접수가능량 조회 중 오류가 발생했습니다.", + }); + } }; /** @@ -1758,33 +1907,38 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) * - 추가 접수 가능 (in_progress 상태에서도) * - status: acceptable/waiting -> in_progress (또는 이미 in_progress면 유지) */ -export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - const client = await pool.connect(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { work_order_process_id, accept_qty } = req.body; +export const acceptProcess = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + const client = await pool.connect(); + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id, accept_qty } = req.body; - if (!work_order_process_id || !accept_qty) { - client.release(); - return res.status(400).json({ - success: false, - message: "work_order_process_id와 accept_qty가 필요합니다.", - }); - } + if (!work_order_process_id || !accept_qty) { + client.release(); + return res.status(400).json({ + success: false, + message: "work_order_process_id와 accept_qty가 필요합니다.", + }); + } - const qty = parseInt(accept_qty, 10); - if (qty <= 0) { - client.release(); - return res.status(400).json({ success: false, message: "접수 수량은 1 이상이어야 합니다." }); - } + const qty = parseInt(accept_qty, 10); + if (qty <= 0) { + client.release(); + return res + .status(400) + .json({ success: false, message: "접수 수량은 1 이상이어야 합니다." }); + } - await client.query("BEGIN"); + await client.query("BEGIN"); - // 원본(마스터) 행 조회 + FOR UPDATE (동시 접수 방지) - const current = await client.query( - `SELECT wop.id, wop.seq_no, wop.wo_id, wop.status, wop.parent_process_id, + // 원본(마스터) 행 조회 + FOR UPDATE (동시 접수 방지) + const current = await client.query( + `SELECT wop.id, wop.seq_no, wop.wo_id, wop.status, wop.parent_process_id, wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, wop.standard_time, wop.equipment_code, wop.routing_detail_id, wi.qty as instruction_qty @@ -1792,112 +1946,141 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code WHERE wop.id = $1 AND wop.company_code = $2 FOR UPDATE OF wop`, - [work_order_process_id, companyCode] - ); + [work_order_process_id, companyCode], + ); - if (current.rowCount === 0) { - await client.query("ROLLBACK"); - client.release(); - return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); - } + if (current.rowCount === 0) { + await client.query("ROLLBACK"); + client.release(); + return res + .status(404) + .json({ success: false, message: "공정을 찾을 수 없습니다." }); + } - const row = current.rows[0]; - const masterId = row.parent_process_id || row.id; + const row = current.rows[0]; + const masterId = row.parent_process_id || row.id; - if (row.status === "completed") { - await client.query("ROLLBACK"); - client.release(); - return res.status(400).json({ success: false, message: "이미 완료된 공정입니다." }); - } - if (row.status !== "acceptable") { - await client.query("ROLLBACK"); - client.release(); - return res.status(400).json({ success: false, message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다.` }); - } + if (row.status === "completed") { + await client.query("ROLLBACK"); + client.release(); + return res + .status(400) + .json({ success: false, message: "이미 완료된 공정입니다." }); + } + if (row.status !== "acceptable") { + await client.query("ROLLBACK"); + client.release(); + return res.status(400).json({ + success: false, + message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다.`, + }); + } - const instrQty = parseInt(row.instruction_qty, 10) || 0; - const seqNum = parseInt(row.seq_no, 10); + const instrQty = parseInt(row.instruction_qty, 10) || 0; + const seqNum = parseInt(row.seq_no, 10); - // 재작업 카드 여부 확인 - const isReworkCard = await client.query( - `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, - [work_order_process_id] - ); - const isRework = isReworkCard.rows[0]?.is_rework === "Y"; - const reworkInputQty = parseInt(isReworkCard.rows[0]?.input_qty, 10) || 0; + // 재작업 카드 여부 확인 + const isReworkCard = await client.query( + `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, + [work_order_process_id], + ); + const isRework = isReworkCard.rows[0]?.is_rework === "Y"; + const reworkInputQty = parseInt(isReworkCard.rows[0]?.input_qty, 10) || 0; - let prevGoodQty: number; - let currentTotalInput: number; - let availableQty: number; + let prevGoodQty: number; + let currentTotalInput: number; + let availableQty: number; - if (isRework) { - // 재작업 카드: 자체 input_qty가 접수 가능 수량 (앞공정과 무관) - prevGoodQty = reworkInputQty; - currentTotalInput = 0; // 재작업 카드는 자체가 마스터, 분할 행 없음 - availableQty = reworkInputQty; - } else { - // 일반 카드: 앞공정 양품 - 기접수합계 - const totalAccepted = await client.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total_input + if (isRework) { + // 재작업 카드: 자체 input_qty가 접수 가능 수량 (앞공정과 무관) + prevGoodQty = reworkInputQty; + currentTotalInput = 0; // 재작업 카드는 자체가 마스터, 분할 행 없음 + availableQty = reworkInputQty; + } else { + // 일반 카드: 앞공정 양품 - 기접수합계 + const totalAccepted = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL AND (is_rework IS NULL OR is_rework != 'Y')`, - [row.wo_id, row.seq_no, companyCode] - ); - currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0; + [row.wo_id, row.seq_no, companyCode], + ); + currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0; - prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); - const prevProcess = await client.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + prevGoodQty = instrQty; + // 첫 공정 여부를 seq_no==1이 아니라 "이 공정보다 작은 seq_no가 있는지"로 판단 + const minSeqCheck = await client.query( + `SELECT MIN(CAST(seq_no AS int)) as min_seq + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL`, + [row.wo_id, companyCode], + ); + const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum; + const isFirstProcess = seqNum <= minSeq; + if (!isFirstProcess) { + // 이전 공정 = 이 공정보다 작은 seq_no 중 가장 큰 값 + const prevProcessSeq = await client.query( + `SELECT MAX(CAST(seq_no AS int)) as prev_seq + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL + AND CAST(seq_no AS int) < $3`, + [row.wo_id, companyCode, seqNum], + ); + const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq; + const prevSeq = + actualPrevSeq != null ? String(actualPrevSeq) : String(seqNum - 1); + const prevProcess = await client.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, - [row.wo_id, prevSeq, companyCode] - ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; - } - } - availableQty = prevGoodQty - currentTotalInput; - } + [row.wo_id, prevSeq, companyCode], + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } + } + availableQty = prevGoodQty - currentTotalInput; + } - if (qty > availableQty) { - await client.query("ROLLBACK"); - client.release(); - return res.status(400).json({ - success: false, - message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`, - }); - } + if (qty > availableQty) { + await client.query("ROLLBACK"); + client.release(); + return res.status(400).json({ + success: false, + message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`, + }); + } - // batch_id: 컬럼이 있으면 포함, 없으면 제외 - const batchId = req.body.batch_id || `BATCH-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; - const hasBatchCol = _batchMigrationDone; + // batch_id: 컬럼이 있으면 포함, 없으면 제외 + const batchId = + req.body.batch_id || + `BATCH-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const hasBatchCol = _batchMigrationDone; - // 리워크 정보 전달: 리워크 카드 접수 / 프론트 전달 / 자동 감지 - let splitIsRework: string | null = null; - let splitReworkSourceId: string | null = null; + // 리워크 정보 전달: 리워크 카드 접수 / 프론트 전달 / 자동 감지 + let splitIsRework: string | null = null; + let splitReworkSourceId: string | null = null; - if (isRework) { - // 케이스 1: 리워크 카드에서 직접 접수 - const parentReworkInfo = await client.query( - `SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`, [work_order_process_id] - ); - splitIsRework = parentReworkInfo?.rows[0]?.is_rework || null; - splitReworkSourceId = parentReworkInfo?.rows[0]?.rework_source_id || null; - } else if (req.body.rework_source_id) { - // 케이스 2: 프론트에서 리워크 추적 정보 전달 - splitIsRework = "Y"; - splitReworkSourceId = req.body.rework_source_id; - } else if (seqNum > 1) { - // 케이스 3: 자동 감지 — 앞공정에서 리워크로 완료된 양품이 있는지 확인 - const prevSeq = String(seqNum - 1); - // rework_source_id별로 개별 추적 - const prevReworkSplits = await client.query( - `SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rework_good + if (isRework) { + // 케이스 1: 리워크 카드에서 직접 접수 + const parentReworkInfo = await client.query( + `SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`, + [work_order_process_id], + ); + splitIsRework = parentReworkInfo?.rows[0]?.is_rework || null; + splitReworkSourceId = parentReworkInfo?.rows[0]?.rework_source_id || null; + } else if (req.body.rework_source_id) { + // 케이스 2: 프론트에서 리워크 추적 정보 전달 + splitIsRework = "Y"; + splitReworkSourceId = req.body.rework_source_id; + } else if (seqNum > 1) { + // 케이스 3: 자동 감지 — 앞공정에서 리워크로 완료된 양품이 있는지 확인 + const prevSeq = String(seqNum - 1); + // rework_source_id별로 개별 추적 + const prevReworkSplits = await client.query( + `SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rework_good FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL @@ -1905,134 +2088,155 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => AND status = 'completed' AND good_qty::int > 0 GROUP BY rework_source_id`, - [row.wo_id, prevSeq, companyCode] - ); + [row.wo_id, prevSeq, companyCode], + ); - // 각 rework_source별로 미소진 수량 확인 - for (const rs of prevReworkSplits.rows) { - const srcId = rs.rework_source_id; - const srcGood = parseInt(rs.rework_good, 10) || 0; - const consumedResult = await client.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as consumed + // 각 rework_source별로 미소진 수량 확인 + for (const rs of prevReworkSplits.rows) { + const srcId = rs.rework_source_id; + const srcGood = parseInt(rs.rework_good, 10) || 0; + const consumedResult = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as consumed FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL AND is_rework = 'Y' AND rework_source_id = $4`, - [row.wo_id, row.seq_no, companyCode, srcId] - ); - const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; - const remaining = srcGood - consumed; + [row.wo_id, row.seq_no, companyCode, srcId], + ); + const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; + const remaining = srcGood - consumed; - if (remaining > 0 && qty <= remaining) { - // 합류 판정: 일반 물량이 있으면 합류(마크 없음), 없으면 마크 부착 - const normalAvailable = availableQty - remaining; - if (normalAvailable <= 0) { - // 일반 물량 없음 → 합류 불가 → 리워크 마크 - splitIsRework = "Y"; - splitReworkSourceId = srcId; - } - // normalAvailable > 0 → 합류 가능 → 마크 없음 (splitIsRework = null) - break; - } - } - } + if (remaining > 0 && qty <= remaining) { + // 합류 판정: 일반 물량이 있으면 합류(마크 없음), 없으면 마크 부착 + const normalAvailable = availableQty - remaining; + if (normalAvailable <= 0) { + // 일반 물량 없음 → 합류 불가 → 리워크 마크 + splitIsRework = "Y"; + splitReworkSourceId = srcId; + } + // normalAvailable > 0 → 합류 가능 → 마크 없음 (splitIsRework = null) + break; + } + } + } - // 분할 행 INSERT (batch_id는 컬럼 존재 시에만, 리워크 정보 포함) - const reworkCols = splitIsRework ? ", is_rework, rework_source_id" : ""; - const reworkVals = splitIsRework ? `, $${hasBatchCol ? 15 : 14}, $${hasBatchCol ? 16 : 15}` : ""; - const reworkParams = splitIsRework ? [splitIsRework, splitReworkSourceId] : []; + // 분할 행 INSERT (batch_id는 컬럼 존재 시에만, 리워크 정보 포함) + const reworkCols = splitIsRework ? ", is_rework, rework_source_id" : ""; + const reworkVals = splitIsRework + ? `, $${hasBatchCol ? 15 : 14}, $${hasBatchCol ? 16 : 15}` + : ""; + const reworkParams = splitIsRework + ? [splitIsRework, splitReworkSourceId] + : []; - const insertCols = `id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, + const insertCols = `id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, equipment_code, routing_detail_id, status, input_qty, good_qty, defect_qty, total_production_qty, result_status, accepted_by, accepted_at, started_at, parent_process_id, company_code, writer${hasBatchCol ? ", batch_id" : ""}${reworkCols}`; - const insertVals = `gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, + const insertVals = `gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, 'in_progress', $10, '0', '0', '0', 'draft', $11, NOW()::text, NOW()::text, $12, $13, $11${hasBatchCol ? ", $14" : ""}${reworkVals}`; - const insertParams = [ - row.wo_id, row.seq_no, row.process_code, row.process_name, - row.is_required, row.is_fixed_order, row.standard_time, - row.equipment_code, row.routing_detail_id, - String(qty), userId, masterId, companyCode, - ...(hasBatchCol ? [batchId] : []), - ...reworkParams, - ]; + const insertParams = [ + row.wo_id, + row.seq_no, + row.process_code, + row.process_name, + row.is_required, + row.is_fixed_order, + row.standard_time, + row.equipment_code, + row.routing_detail_id, + String(qty), + userId, + masterId, + companyCode, + ...(hasBatchCol ? [batchId] : []), + ...reworkParams, + ]; - const result = await client.query( - `INSERT INTO work_order_process (${insertCols}) VALUES (${insertVals}) + const result = await client.query( + `INSERT INTO work_order_process (${insertCols}) VALUES (${insertVals}) RETURNING id, input_qty, status, process_name, result_status, accepted_by`, - insertParams - ); + insertParams, + ); - // 분할 행에 체크리스트 복사 - const splitId = result.rows[0].id; - const checklistCount = await copyChecklistToSplit( - client, masterId, splitId, row.routing_detail_id, companyCode, userId - ); + // 분할 행에 체크리스트 복사 + const splitId = result.rows[0].id; + const checklistCount = await copyChecklistToSplit( + client, + masterId, + splitId, + row.routing_detail_id, + companyCode, + userId, + ); - // 마스터 행의 input_qty를 분할 합계로 갱신 (리워크 접수 시에는 마스터 input_qty 변경 안 함) - let newTotalInput = currentTotalInput + qty; - if (!isRework) { - await client.query( - `UPDATE work_order_process SET input_qty = $3, updated_date = NOW() + // 마스터 행의 input_qty를 분할 합계로 갱신 (리워크 접수 시에는 마스터 input_qty 변경 안 함) + let newTotalInput = currentTotalInput + qty; + if (!isRework) { + await client.query( + `UPDATE work_order_process SET input_qty = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2`, - [masterId, companyCode, String(newTotalInput)] - ); - } else { - newTotalInput = currentTotalInput; // 리워크는 기존 합계 유지 - // 리워크 카드: 전량 접수 시에만 이 카드만 completed로 변경 - // (다른 리워크 카드에 영향 없도록 id 정확히 지정) - const reworkAlreadyAccepted = await client.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total + [masterId, companyCode, String(newTotalInput)], + ); + } else { + newTotalInput = currentTotalInput; // 리워크는 기존 합계 유지 + // 리워크 카드: 전량 접수 시에만 이 카드만 completed로 변경 + // (다른 리워크 카드에 영향 없도록 id 정확히 지정) + const reworkAlreadyAccepted = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total FROM work_order_process WHERE parent_process_id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); - const totalReworkAccepted = (parseInt(reworkAlreadyAccepted.rows[0]?.total, 10) || 0) + qty; - if (totalReworkAccepted >= reworkInputQty) { - await client.query( - `UPDATE work_order_process SET status = 'completed', updated_date = NOW() + [work_order_process_id, companyCode], + ); + const totalReworkAccepted = + (parseInt(reworkAlreadyAccepted.rows[0]?.total, 10) || 0) + qty; + if (totalReworkAccepted >= reworkInputQty) { + await client.query( + `UPDATE work_order_process SET status = 'completed', updated_date = NOW() WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); - } - } + [work_order_process_id, companyCode], + ); + } + } - await client.query("COMMIT"); + await client.query("COMMIT"); - logger.info("[pop/production] accept-process 분할 접수 완료", { - companyCode, userId, masterId, - splitId, - acceptedQty: qty, - totalAccepted: newTotalInput, - prevGoodQty, - checklistCount, - }); + logger.info("[pop/production] accept-process 분할 접수 완료", { + companyCode, + userId, + masterId, + splitId, + acceptedQty: qty, + totalAccepted: newTotalInput, + prevGoodQty, + checklistCount, + }); - const acceptData = result.rows[0] || {}; - if (splitReworkSourceId) { - acceptData.rework_source_id = splitReworkSourceId; - acceptData.is_rework = splitIsRework; - } + const acceptData = result.rows[0] || {}; + if (splitReworkSourceId) { + acceptData.rework_source_id = splitReworkSourceId; + acceptData.is_rework = splitIsRework; + } - return res.json({ - success: true, - data: acceptData, - message: `${qty}개 접수 완료 (총 접수합계: ${newTotalInput})`, - }); - } catch (error: any) { - await client.query("ROLLBACK").catch(() => {}); - logger.error("[pop/production] accept-process 오류:", error); - return res.status(500).json({ - success: false, - message: error.message || "접수 중 오류가 발생했습니다.", - }); - } finally { - client.release(); - } + return res.json({ + success: true, + data: acceptData, + message: `${qty}개 접수 완료 (총 접수합계: ${newTotalInput})`, + }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/production] accept-process 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "접수 중 오류가 발생했습니다.", + }); + } finally { + client.release(); + } }; /** @@ -2040,192 +2244,209 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => * 조건: 아직 실적(total_production_qty)이 없어야 함 */ export const cancelAccept = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); + const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { work_order_process_id } = req.body; + 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는 필수입니다.", - }); - } + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } - const current = await pool.query( - `SELECT id, status, input_qty, total_production_qty, result_status, + const current = await pool.query( + `SELECT id, status, input_qty, total_production_qty, result_status, parent_process_id, wo_id, seq_no, process_name, target_warehouse_id, target_location_code, good_qty, concession_qty FROM work_order_process WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); + [work_order_process_id, companyCode], + ); - if (current.rowCount === 0) { - return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); - } + if (current.rowCount === 0) { + return res + .status(404) + .json({ success: false, message: "공정을 찾을 수 없습니다." }); + } - const proc = current.rows[0]; + const proc = current.rows[0]; - // 분할 행만 취소 가능 (원본 행은 취소 대상이 아님) - if (!proc.parent_process_id) { - return res.status(400).json({ - success: false, - message: "원본 공정은 접수 취소할 수 없습니다. 분할된 접수 카드에서 취소해주세요.", - }); - } + // 분할 행만 취소 가능 (원본 행은 취소 대상이 아님) + if (!proc.parent_process_id) { + return res.status(400).json({ + success: false, + message: + "원본 공정은 접수 취소할 수 없습니다. 분할된 접수 카드에서 취소해주세요.", + }); + } - if (proc.status !== "in_progress") { - return res.status(400).json({ - success: false, - message: `현재 상태(${proc.status})에서는 접수 취소할 수 없습니다. 진행중 상태만 가능합니다.`, - }); - } + if (proc.status !== "in_progress") { + return res.status(400).json({ + success: false, + message: `현재 상태(${proc.status})에서는 접수 취소할 수 없습니다. 진행중 상태만 가능합니다.`, + }); + } - const totalProduced = parseInt(proc.total_production_qty ?? "0", 10) || 0; - const currentInputQty = parseInt(proc.input_qty ?? "0", 10) || 0; - const unproducedQty = currentInputQty - totalProduced; + const totalProduced = parseInt(proc.total_production_qty ?? "0", 10) || 0; + const currentInputQty = parseInt(proc.input_qty ?? "0", 10) || 0; + const unproducedQty = currentInputQty - totalProduced; - if (unproducedQty <= 0) { - return res.status(400).json({ - success: false, - message: "취소할 미소진 접수분이 없습니다. 모든 접수량에 대해 실적이 등록되었습니다.", - }); - } + if (unproducedQty <= 0) { + return res.status(400).json({ + success: false, + message: + "취소할 미소진 접수분이 없습니다. 모든 접수량에 대해 실적이 등록되었습니다.", + }); + } - let cancelledQty = unproducedQty; - const client = await pool.connect(); + const cancelledQty = unproducedQty; + const client = await pool.connect(); - try { - await client.query("BEGIN"); + try { + await client.query("BEGIN"); - if (totalProduced === 0) { - // 실적이 없으면 체크리스트 먼저 삭제 → 분할 행 삭제 - await client.query( - `DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); - await client.query( - `DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); - } else { - // 실적이 있으면 input_qty를 실적 수량으로 축소 + completed - await client.query( - `UPDATE work_order_process + if (totalProduced === 0) { + // 실적이 없으면 체크리스트 먼저 삭제 → 분할 행 삭제 + await client.query( + `DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`, + [work_order_process_id, companyCode], + ); + await client.query( + `DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode], + ); + } else { + // 실적이 있으면 input_qty를 실적 수량으로 축소 + completed + await client.query( + `UPDATE work_order_process SET input_qty = $3, status = 'completed', result_status = 'confirmed', completed_at = NOW()::text, completed_by = $4, updated_date = NOW(), writer = $4 WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode, String(totalProduced), userId] - ); - } + [work_order_process_id, companyCode, String(totalProduced), userId], + ); + } - // 재고 원복: 분할 행에 target_warehouse_id가 있으면 입고된 수량을 차감 - if (proc.target_warehouse_id) { - const inboundQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); - if (inboundQty > 0) { - // work_instruction에서 item_id 조회 - const wiResult = await client.query( - `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, - [proc.wo_id, companyCode] - ); - if (wiResult.rowCount > 0) { - const itemResult = await client.query( - `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, - [wiResult.rows[0].item_id, companyCode] - ); - if (itemResult.rowCount > 0) { - const itemCode = itemResult.rows[0].item_number; - const locCode = proc.target_location_code || proc.target_warehouse_id; - await client.query( - `UPDATE inventory_stock + // 재고 원복: 분할 행에 target_warehouse_id가 있으면 입고된 수량을 차감 + if (proc.target_warehouse_id) { + const inboundQty = + parseInt(proc.good_qty || "0", 10) + + parseInt(proc.concession_qty || "0", 10); + if (inboundQty > 0) { + // work_instruction에서 item_id 조회 + const wiResult = await client.query( + `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, + [proc.wo_id, companyCode], + ); + if (wiResult.rowCount > 0) { + const itemResult = await client.query( + `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, + [wiResult.rows[0].item_id, companyCode], + ); + if (itemResult.rowCount > 0) { + const itemCode = itemResult.rows[0].item_number; + const locCode = + proc.target_location_code || proc.target_warehouse_id; + await client.query( + `UPDATE inventory_stock SET current_qty = GREATEST((COALESCE(current_qty::numeric, 0) - $4::numeric), 0)::text, updated_date = NOW(), writer = $5 WHERE company_code = $1 AND item_code = $2 AND warehouse_code = $3 AND location_code = $6`, - [companyCode, itemCode, proc.target_warehouse_id, String(inboundQty), userId, locCode] - ); - } - } - } - } + [ + companyCode, + itemCode, + proc.target_warehouse_id, + String(inboundQty), + userId, + locCode, + ], + ); + } + } + } + } - // 마스터 행의 input_qty를 분할 합계로 재계산 - const remainingSplits = await client.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total_input + // 마스터 행의 input_qty를 분할 합계로 재계산 + const remainingSplits = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input FROM work_order_process WHERE parent_process_id = $1 AND company_code = $2`, - [proc.parent_process_id, companyCode] - ); - const newMasterInput = parseInt(remainingSplits.rows[0].total_input, 10) || 0; + [proc.parent_process_id, companyCode], + ); + const newMasterInput = + parseInt(remainingSplits.rows[0].total_input, 10) || 0; - // 원본(마스터) 행: input_qty 복원 + acceptable 상태 유지 - await client.query( - `UPDATE work_order_process + // 원본(마스터) 행: input_qty 복원 + acceptable 상태 유지 + await client.query( + `UPDATE work_order_process SET status = 'acceptable', input_qty = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`, - [proc.parent_process_id, companyCode, String(newMasterInput)] - ); + [proc.parent_process_id, companyCode, String(newMasterInput)], + ); - await client.query("COMMIT"); - } catch (txErr) { - await client.query("ROLLBACK"); - throw txErr; - } finally { - client.release(); - } + await client.query("COMMIT"); + } catch (txErr) { + await client.query("ROLLBACK"); + throw txErr; + } finally { + client.release(); + } - logger.info("[pop/production] cancel-accept 완료 (분할 행)", { - companyCode, userId, work_order_process_id, - masterId: proc.parent_process_id, - previousInputQty: currentInputQty, - totalProduced, - cancelledQty, - action: totalProduced === 0 ? "DELETE" : "SHRINK", - }); + logger.info("[pop/production] cancel-accept 완료 (분할 행)", { + companyCode, + userId, + work_order_process_id, + masterId: proc.parent_process_id, + previousInputQty: currentInputQty, + totalProduced, + cancelledQty, + action: totalProduced === 0 ? "DELETE" : "SHRINK", + }); - return res.json({ - success: true, - data: { id: work_order_process_id, process_name: proc.process_name }, - message: `미소진 ${cancelledQty}개 접수가 취소되었습니다.`, - }); - } catch (error: any) { - logger.error("[pop/production] cancel-accept 오류:", error); - return res.status(500).json({ - success: false, - message: error.message || "접수 취소 중 오류가 발생했습니다.", - }); - } + return res.json({ + success: true, + data: { id: work_order_process_id, process_name: proc.process_name }, + message: `미소진 ${cancelledQty}개 접수가 취소되었습니다.`, + }); + } catch (error: any) { + logger.error("[pop/production] cancel-accept 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "접수 취소 중 오류가 발생했습니다.", + }); + } }; /** * 창고 목록 조회 (POP 생산용) */ export const getWarehouses = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const result = await pool.query( - `SELECT id, warehouse_code, warehouse_name, warehouse_type + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const result = await pool.query( + `SELECT id, warehouse_code, warehouse_name, warehouse_type FROM warehouse_info WHERE company_code = $1 AND COALESCE(status, '') != '삭제' ORDER BY warehouse_name`, - [companyCode] - ); - return res.json({ success: true, data: result.rows }); - } catch (error: any) { - logger.error("[pop/production] 창고 목록 조회 실패:", error); - return res.status(500).json({ success: false, message: error.message }); - } + [companyCode], + ); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] 창고 목록 조회 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } }; /** @@ -2233,39 +2454,41 @@ export const getWarehouses = async ( * warehouseId는 warehouse_info.id → warehouse_code를 조회해서 warehouse_location과 매칭 */ export const getWarehouseLocations = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const { warehouseId } = req.params; - if (!warehouseId) { - return res.status(400).json({ success: false, message: "warehouseId는 필수입니다." }); - } + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { warehouseId } = req.params; + if (!warehouseId) { + return res + .status(400) + .json({ success: false, message: "warehouseId는 필수입니다." }); + } - // warehouse_info.id → warehouse_code 변환 - const whInfo = await pool.query( - `SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`, - [warehouseId, companyCode] - ); - if (whInfo.rowCount === 0) { - return res.json({ success: true, data: [] }); - } - const warehouseCode = whInfo.rows[0].warehouse_code; + // warehouse_info.id → warehouse_code 변환 + const whInfo = await pool.query( + `SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`, + [warehouseId, companyCode], + ); + if (whInfo.rowCount === 0) { + return res.json({ success: true, data: [] }); + } + const warehouseCode = whInfo.rows[0].warehouse_code; - const result = await pool.query( - `SELECT id, location_code, location_name + const result = await pool.query( + `SELECT id, location_code, location_name FROM warehouse_location WHERE warehouse_code = $1 AND company_code = $2 ORDER BY location_name`, - [warehouseCode, companyCode] - ); - return res.json({ success: true, data: result.rows }); - } catch (error: any) { - logger.error("[pop/production] 창고 위치 조회 실패:", error); - return res.status(500).json({ success: false, message: error.message }); - } + [warehouseCode, companyCode], + ); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] 창고 위치 조회 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } }; /** @@ -2273,73 +2496,73 @@ export const getWarehouseLocations = async ( * 같은 wo_id에서 현재 seq_no보다 큰 공정(마스터 행)이 없으면 마지막 */ export const isLastProcess = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const { processId } = req.params; - if (!processId) { - return res.json({ success: true, data: { isLast: false } }); - } + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res.json({ success: true, data: { isLast: false } }); + } - // 현재 공정의 wo_id와 seq_no 조회 (분할 행이면 parent의 seq_no 기준) - const process = await pool.query( - `SELECT wo_id, seq_no, parent_process_id + // 현재 공정의 wo_id와 seq_no 조회 (분할 행이면 parent의 seq_no 기준) + const process = await pool.query( + `SELECT wo_id, seq_no, parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, - [processId, companyCode] - ); - if (process.rowCount === 0) { - return res.json({ success: true, data: { isLast: false } }); - } + [processId, companyCode], + ); + if (process.rowCount === 0) { + return res.json({ success: true, data: { isLast: false } }); + } - const { wo_id, seq_no, parent_process_id } = process.rows[0]; + const { wo_id, seq_no, parent_process_id } = process.rows[0]; - // 분할 행이면 마스터의 seq_no 기준으로 판단 - let effectiveSeqNo = seq_no; - if (parent_process_id) { - const master = await pool.query( - `SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`, - [parent_process_id, companyCode] - ); - if (master.rowCount > 0) { - effectiveSeqNo = master.rows[0].seq_no; - } - } + // 분할 행이면 마스터의 seq_no 기준으로 판단 + let effectiveSeqNo = seq_no; + if (parent_process_id) { + const master = await pool.query( + `SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`, + [parent_process_id, companyCode], + ); + if (master.rowCount > 0) { + effectiveSeqNo = master.rows[0].seq_no; + } + } - const next = await pool.query( - `SELECT id FROM work_order_process + const next = await pool.query( + `SELECT id FROM work_order_process WHERE wo_id = $1 AND company_code = $2 AND CAST(seq_no AS int) > CAST($3 AS int) AND parent_process_id IS NULL LIMIT 1`, - [wo_id, companyCode, effectiveSeqNo] - ); + [wo_id, companyCode, effectiveSeqNo], + ); - // 현재 공정의 기존 창고 설정도 반환 (기본값 세팅용) - const warehouseInfo = await pool.query( - `SELECT target_warehouse_id, target_location_code + // 현재 공정의 기존 창고 설정도 반환 (기본값 세팅용) + const warehouseInfo = await pool.query( + `SELECT target_warehouse_id, target_location_code FROM work_order_process WHERE id = $1 AND company_code = $2`, - [processId, companyCode] - ); + [processId, companyCode], + ); - return res.json({ - success: true, - data: { - isLast: next.rowCount === 0, - woId: wo_id, - seqNo: effectiveSeqNo, - targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null, - targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null, - }, - }); - } catch (error: any) { - logger.error("[pop/production] 마지막 공정 확인 오류:", error); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ + success: true, + data: { + isLast: next.rowCount === 0, + woId: wo_id, + seqNo: effectiveSeqNo, + targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null, + targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null, + }, + }); + } catch (error: any) { + logger.error("[pop/production] 마지막 공정 확인 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } }; /** @@ -2348,56 +2571,69 @@ export const isLastProcess = async ( * 마스터 행에 저장하여 checkAndCompleteWorkInstruction이 참조할 수 있도록 한다. */ export const updateTargetWarehouse = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { work_order_process_id, target_warehouse_id, target_location_code } = req.body; + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id, target_warehouse_id, target_location_code } = + req.body; - if (!work_order_process_id || !target_warehouse_id) { - return res.status(400).json({ - success: false, - message: "work_order_process_id와 target_warehouse_id는 필수입니다.", - }); - } + if (!work_order_process_id || !target_warehouse_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 target_warehouse_id는 필수입니다.", + }); + } - // 분할 행이면 마스터 행도 함께 업데이트 - const procInfo = await pool.query( - `SELECT parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); + // 분할 행이면 마스터 행도 함께 업데이트 + const procInfo = await pool.query( + `SELECT parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode], + ); - const idsToUpdate = [work_order_process_id]; - if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) { - idsToUpdate.push(procInfo.rows[0].parent_process_id); - } + const idsToUpdate = [work_order_process_id]; + if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) { + idsToUpdate.push(procInfo.rows[0].parent_process_id); + } - for (const id of idsToUpdate) { - await pool.query( - `UPDATE work_order_process + for (const id of idsToUpdate) { + await pool.query( + `UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW() WHERE id = $1 AND company_code = $2`, - [id, companyCode, target_warehouse_id, target_location_code || null, userId] - ); - } + [ + id, + companyCode, + target_warehouse_id, + target_location_code || null, + userId, + ], + ); + } - logger.info("[pop/production] 목표 창고 업데이트", { - companyCode, userId, work_order_process_id, - target_warehouse_id, target_location_code, - updatedIds: idsToUpdate, - }); + logger.info("[pop/production] 목표 창고 업데이트", { + companyCode, + userId, + work_order_process_id, + target_warehouse_id, + target_location_code, + updatedIds: idsToUpdate, + }); - return res.json({ success: true, data: { target_warehouse_id, target_location_code } }); - } catch (error: any) { - logger.error("[pop/production] 목표 창고 업데이트 오류:", error); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ + success: true, + data: { target_warehouse_id, target_location_code }, + }); + } catch (error: any) { + logger.error("[pop/production] 목표 창고 업데이트 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } }; /** @@ -2407,153 +2643,167 @@ export const updateTargetWarehouse = async ( * 이중 입고 방지: target_warehouse_id가 이미 설정된 경우 "이미 입고됨" 반환. */ export const inventoryInbound = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); - const client = await pool.connect(); + const pool = getPool(); + const client = await pool.connect(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { work_order_process_id, warehouse_code, location_code } = req.body; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id, warehouse_code, location_code } = req.body; - if (!work_order_process_id || !warehouse_code) { - return res.status(400).json({ - success: false, - message: "work_order_process_id와 warehouse_code는 필수입니다.", - }); - } + if (!work_order_process_id || !warehouse_code) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 warehouse_code는 필수입니다.", + }); + } - await client.query("BEGIN"); + await client.query("BEGIN"); - // 1. work_order_process에서 wo_id, good_qty, parent_process_id, 기존 target_warehouse_id 조회 - const procResult = await client.query( - `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no, is_rework + // 1. work_order_process에서 wo_id, good_qty, parent_process_id, 기존 target_warehouse_id 조회 + const procResult = await client.query( + `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no, is_rework FROM work_order_process WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); + [work_order_process_id, companyCode], + ); - if (procResult.rowCount === 0) { - await client.query("ROLLBACK"); - return res.status(404).json({ - success: false, - message: "해당 공정을 찾을 수 없습니다.", - }); - } + if (procResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "해당 공정을 찾을 수 없습니다.", + }); + } - const proc = procResult.rows[0]; + const proc = procResult.rows[0]; - // 이중 입고 방지: 이미 target_warehouse_id가 설정되어 있으면 거부 - if (proc.target_warehouse_id) { - await client.query("ROLLBACK"); - return res.status(409).json({ - success: false, - message: "이미 재고 입고가 완료된 공정입니다.", - data: { existing_warehouse: proc.target_warehouse_id }, - }); - } + // 이중 입고 방지: 이미 target_warehouse_id가 설정되어 있으면 거부 + if (proc.target_warehouse_id) { + await client.query("ROLLBACK"); + return res.status(409).json({ + success: false, + message: "이미 재고 입고가 완료된 공정입니다.", + data: { existing_warehouse: proc.target_warehouse_id }, + }); + } - const goodQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); + const goodQty = + parseInt(proc.good_qty || "0", 10) + + parseInt(proc.concession_qty || "0", 10); - if (goodQty <= 0) { - await client.query("ROLLBACK"); - return res.status(400).json({ - success: false, - message: "양품 수량이 0이므로 재고 입고할 수 없습니다.", - }); - } + if (goodQty <= 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ + success: false, + message: "양품 수량이 0이므로 재고 입고할 수 없습니다.", + }); + } - // 2. work_instruction에서 item_id 조회 - const wiResult = await client.query( - `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, - [proc.wo_id, companyCode] - ); + // 2. work_instruction에서 item_id 조회 + const wiResult = await client.query( + `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, + [proc.wo_id, companyCode], + ); - if (wiResult.rowCount === 0) { - await client.query("ROLLBACK"); - return res.status(404).json({ - success: false, - message: "작업지시를 찾을 수 없습니다.", - }); - } + if (wiResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "작업지시를 찾을 수 없습니다.", + }); + } - const itemId = wiResult.rows[0].item_id; + const itemId = wiResult.rows[0].item_id; - // 3. item_info에서 item_number 조회 - const itemResult = await client.query( - `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, - [itemId, companyCode] - ); + // 3. item_info에서 item_number 조회 + const itemResult = await client.query( + `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, + [itemId, companyCode], + ); - if (itemResult.rowCount === 0) { - await client.query("ROLLBACK"); - return res.status(404).json({ - success: false, - message: "품목 정보를 찾을 수 없습니다.", - }); - } + if (itemResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "품목 정보를 찾을 수 없습니다.", + }); + } - const itemCode = itemResult.rows[0].item_number; - const effectiveLocationCode = location_code || null; + const itemCode = itemResult.rows[0].item_number; + const effectiveLocationCode = location_code || null; - // 4. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) - await upsertInventoryStock(client, companyCode, itemCode, warehouse_code, effectiveLocationCode, goodQty, userId); + // 4. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) + await upsertInventoryStock( + client, + companyCode, + itemCode, + warehouse_code, + effectiveLocationCode, + goodQty, + userId, + ); - // 5. work_order_process에 target_warehouse_id 저장 (현재 행 + 마스터 행) - const idsToUpdate = [work_order_process_id]; - if (proc.parent_process_id) { - idsToUpdate.push(proc.parent_process_id); - } + // 5. work_order_process에 target_warehouse_id 저장 (현재 행 + 마스터 행) + const idsToUpdate = [work_order_process_id]; + if (proc.parent_process_id) { + idsToUpdate.push(proc.parent_process_id); + } - for (const id of idsToUpdate) { - await client.query( - `UPDATE work_order_process + for (const id of idsToUpdate) { + await client.query( + `UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW() WHERE id = $1 AND company_code = $2`, - [id, companyCode, warehouse_code, location_code || null, userId] - ); - } + [id, companyCode, warehouse_code, location_code || null, userId], + ); + } - // 6. 리워크 마크 해제 (창고 입고 = 정상 제품 인정, 이력은 rework_source_id에 영구 보존) - if (proc.is_rework === "Y") { - await client.query( - `UPDATE work_order_process SET is_rework = NULL, updated_date = NOW() + // 6. 리워크 마크 해제 (창고 입고 = 정상 제품 인정, 이력은 rework_source_id에 영구 보존) + if (proc.is_rework === "Y") { + await client.query( + `UPDATE work_order_process SET is_rework = NULL, updated_date = NOW() WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] - ); - } + [work_order_process_id, companyCode], + ); + } - await client.query("COMMIT"); + await client.query("COMMIT"); - logger.info("[pop/production] 독립 재고 입고 완료", { - companyCode, userId, work_order_process_id, - itemCode, warehouse_code, location_code: effectiveLocationCode, - qty: goodQty, - reworkCleared: proc.is_rework === "Y", - }); + logger.info("[pop/production] 독립 재고 입고 완료", { + companyCode, + userId, + work_order_process_id, + itemCode, + warehouse_code, + location_code: effectiveLocationCode, + qty: goodQty, + reworkCleared: proc.is_rework === "Y", + }); - return res.json({ - success: true, - message: "재고 입고가 완료되었습니다.", - data: { - item_code: itemCode, - warehouse_code, - location_code: effectiveLocationCode, - qty: goodQty, - }, - }); - } catch (error: any) { - await client.query("ROLLBACK").catch(() => {}); - logger.error("[pop/production] 독립 재고 입고 오류:", error); - return res.status(500).json({ success: false, message: error.message }); - } finally { - client.release(); - } + return res.json({ + success: true, + message: "재고 입고가 완료되었습니다.", + data: { + item_code: itemCode, + warehouse_code, + location_code: effectiveLocationCode, + qty: goodQty, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/production] 독립 재고 입고 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } }; /** @@ -2561,73 +2811,81 @@ export const inventoryInbound = async ( * 품목 + 수량 + 창고만으로 inventory_stock UPSERT + inbound_mng 이력 기록 */ export const quickInventoryInbound = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); - const client = await pool.connect(); + const pool = getPool(); + const client = await pool.connect(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { item_id, qty, warehouse_code, location_code, remark } = req.body; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { item_id, qty, warehouse_code, location_code, remark } = req.body; - // 필수 파라미터 검증 - if (!item_id || !qty || !warehouse_code) { - return res.status(400).json({ - success: false, - message: "item_id, qty, warehouse_code는 필수입니다.", - }); - } + // 필수 파라미터 검증 + if (!item_id || !qty || !warehouse_code) { + return res.status(400).json({ + success: false, + message: "item_id, qty, warehouse_code는 필수입니다.", + }); + } - const parsedQty = parseInt(String(qty), 10); - if (isNaN(parsedQty) || parsedQty <= 0) { - return res.status(400).json({ - success: false, - message: "수량은 1 이상의 정수여야 합니다.", - }); - } + const parsedQty = parseInt(String(qty), 10); + if (isNaN(parsedQty) || parsedQty <= 0) { + return res.status(400).json({ + success: false, + message: "수량은 1 이상의 정수여야 합니다.", + }); + } - await client.query("BEGIN"); + await client.query("BEGIN"); - // 1. item_info에서 item_number, item_name 조회 - const itemResult = await client.query( - `SELECT item_number, item_name, size, material, unit + // 1. item_info에서 item_number, item_name 조회 + const itemResult = await client.query( + `SELECT item_number, item_name, size, material, unit FROM item_info WHERE id = $1 AND company_code = $2`, - [item_id, companyCode] - ); + [item_id, companyCode], + ); - if (itemResult.rowCount === 0) { - await client.query("ROLLBACK"); - return res.status(404).json({ - success: false, - message: "품목 정보를 찾을 수 없습니다.", - }); - } + if (itemResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "품목 정보를 찾을 수 없습니다.", + }); + } - const item = itemResult.rows[0]; - const itemCode = item.item_number; - const effectiveLocationCode = location_code || null; + const item = itemResult.rows[0]; + const itemCode = item.item_number; + const effectiveLocationCode = location_code || null; - // 2. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) - await upsertInventoryStock(client, companyCode, itemCode, warehouse_code, effectiveLocationCode, parsedQty, userId); + // 2. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) + await upsertInventoryStock( + client, + companyCode, + itemCode, + warehouse_code, + effectiveLocationCode, + parsedQty, + userId, + ); - // 3. inbound_mng에 간이입고 이력 기록 - const seqResult = await client.query( - `SELECT COALESCE(MAX( + // 3. inbound_mng에 간이입고 이력 기록 + const seqResult = await client.query( + `SELECT COALESCE(MAX( CASE WHEN inbound_number ~ '^QIB-[0-9]{4}-[0-9]+$' THEN CAST(SUBSTRING(inbound_number FROM '[0-9]+$') AS INTEGER) ELSE 0 END ), 0) + 1 AS next_seq FROM inbound_mng WHERE company_code = $1`, - [companyCode] - ); - const nextSeq = seqResult.rows[0].next_seq; - const year = new Date().getFullYear(); - const inboundNumber = `QIB-${year}-${String(nextSeq).padStart(4, "0")}`; + [companyCode], + ); + const nextSeq = seqResult.rows[0].next_seq; + const year = new Date().getFullYear(); + const inboundNumber = `QIB-${year}-${String(nextSeq).padStart(4, "0")}`; - await client.query( - `INSERT INTO inbound_mng ( + await client.query( + `INSERT INTO inbound_mng ( id, company_code, inbound_number, inbound_type, inbound_date, item_number, item_name, spec, material, unit, inbound_qty, warehouse_code, location_code, @@ -2640,42 +2898,55 @@ export const quickInventoryInbound = async ( '완료', $11, $12, NOW(), NOW(), $13, $13, $13 )`, - [ - companyCode, inboundNumber, - item.item_number, item.item_name, item.size, item.material, item.unit, - parsedQty, warehouse_code, effectiveLocationCode, - remark || "POP 간이입고", remark || null, - userId, - ] - ); + [ + companyCode, + inboundNumber, + item.item_number, + item.item_name, + item.size, + item.material, + item.unit, + parsedQty, + warehouse_code, + effectiveLocationCode, + remark || "POP 간이입고", + remark || null, + userId, + ], + ); - await client.query("COMMIT"); + await client.query("COMMIT"); - logger.info("[pop/production] 간이 재고 입고 완료", { - companyCode, userId, item_id, - itemCode, warehouse_code, location_code: effectiveLocationCode, - qty: parsedQty, inboundNumber, - }); + logger.info("[pop/production] 간이 재고 입고 완료", { + companyCode, + userId, + item_id, + itemCode, + warehouse_code, + location_code: effectiveLocationCode, + qty: parsedQty, + inboundNumber, + }); - return res.json({ - success: true, - message: "간이 재고 입고가 완료되었습니다.", - data: { - inbound_number: inboundNumber, - item_code: itemCode, - item_name: item.item_name, - warehouse_code, - location_code: effectiveLocationCode, - qty: parsedQty, - }, - }); - } catch (error: any) { - await client.query("ROLLBACK").catch(() => {}); - logger.error("[pop/production] 간이 재고 입고 오류:", error); - return res.status(500).json({ success: false, message: error.message }); - } finally { - client.release(); - } + return res.json({ + success: true, + message: "간이 재고 입고가 완료되었습니다.", + data: { + inbound_number: inboundNumber, + item_code: itemCode, + item_name: item.item_name, + warehouse_code, + location_code: effectiveLocationCode, + qty: parsedQty, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/production] 간이 재고 입고 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } }; /** @@ -2683,17 +2954,22 @@ export const quickInventoryInbound = async ( * 작업지시(wo_id) 기준으로 모든 재작업 체인을 반환한다. * 원본 → 재작업1 → 재작업2 → ... 순서로 체인 추적. */ -export const getReworkHistory = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const woId = req.query.wo_id as string || req.params.woId; - if (!woId) { - return res.status(400).json({ success: false, message: "wo_id는 필수입니다." }); - } +export const getReworkHistory = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const woId = (req.query.wo_id as string) || req.params.woId; + if (!woId) { + return res + .status(400) + .json({ success: false, message: "wo_id는 필수입니다." }); + } - const result = await pool.query( - `SELECT id, seq_no, process_code, process_name, status, + const result = await pool.query( + `SELECT id, seq_no, process_code, process_name, status, input_qty, good_qty, defect_qty, concession_qty, is_rework, rework_source_id, parent_process_id, accepted_by, accepted_at, started_at, completed_at, @@ -2702,195 +2978,240 @@ export const getReworkHistory = async (req: AuthenticatedRequest, res: Response) WHERE wo_id = $1 AND company_code = $2 AND (is_rework = 'Y' OR is_rework = '1' OR defect_qty::int > 0 OR parent_process_id IS NOT NULL) ORDER BY created_date ASC`, - [woId, companyCode] - ); + [woId, companyCode], + ); - // 체인 구성: rework_source_id를 따라 트리 구조 - const rows = result.rows; - const byId: Record = {}; - for (const r of rows) byId[r.id] = r; + // 체인 구성: rework_source_id를 따라 트리 구조 + const rows = result.rows; + const byId: Record = {}; + for (const r of rows) byId[r.id] = r; - const chains: Array<{ - source: typeof rows[0]; - reworks: typeof rows; - totalReworkCount: number; - }> = []; + const chains: Array<{ + source: (typeof rows)[0]; + reworks: typeof rows; + totalReworkCount: number; + }> = []; - // 원본 행(불량 발생한 것) 찾기 - const reworkSourceIds = new Set(rows.filter(r => r.rework_source_id).map(r => r.rework_source_id)); - const sources = rows.filter(r => reworkSourceIds.has(r.id) || (parseInt(r.defect_qty || "0", 10) > 0 && r.is_rework !== "Y")); + // 원본 행(불량 발생한 것) 찾기 + const reworkSourceIds = new Set( + rows.filter((r) => r.rework_source_id).map((r) => r.rework_source_id), + ); + const sources = rows.filter( + (r) => + reworkSourceIds.has(r.id) || + (parseInt(r.defect_qty || "0", 10) > 0 && r.is_rework !== "Y"), + ); - for (const src of sources) { - const chain: typeof rows = []; - const visited = new Set(); - // 이 소스에서 시작하는 재작업 체인 추적 - const queue = rows.filter(r => r.rework_source_id === src.id); - while (queue.length > 0) { - const item = queue.shift()!; - if (visited.has(item.id)) continue; - visited.add(item.id); - chain.push(item); - // 이 재작업에서 또 재작업이 나온 것 찾기 - const next = rows.filter(r => r.rework_source_id === item.id); - queue.push(...next); - } - chains.push({ - source: src, - reworks: chain, - totalReworkCount: chain.length, - }); - } + for (const src of sources) { + const chain: typeof rows = []; + const visited = new Set(); + // 이 소스에서 시작하는 재작업 체인 추적 + const queue = rows.filter((r) => r.rework_source_id === src.id); + while (queue.length > 0) { + const item = queue.shift()!; + if (visited.has(item.id)) continue; + visited.add(item.id); + chain.push(item); + // 이 재작업에서 또 재작업이 나온 것 찾기 + const next = rows.filter((r) => r.rework_source_id === item.id); + queue.push(...next); + } + chains.push({ + source: src, + reworks: chain, + totalReworkCount: chain.length, + }); + } - return res.json({ - success: true, - data: { - wo_id: woId, - total_rework_count: rows.filter(r => r.is_rework === "Y" || r.is_rework === "1").length, - chains, - all_records: rows, - }, - }); - } catch (error: any) { - logger.error("[pop/production] rework-history 오류:", error); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ + success: true, + data: { + wo_id: woId, + total_rework_count: rows.filter( + (r) => r.is_rework === "Y" || r.is_rework === "1", + ).length, + chains, + all_records: rows, + }, + }); + } catch (error: any) { + logger.error("[pop/production] rework-history 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } }; /** * 공정별 BOM 자재 목록 + 소요량 계산 * work_order_process_id → item_code → bom + bom_detail 조회 */ -export const getBomMaterials = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const { processId } = req.params; - if (!processId) { - return res.status(400).json({ success: false, message: "processId 필수" }); - } +export const getBomMaterials = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res + .status(400) + .json({ success: false, message: "processId 필수" }); + } - // 1. work_order_process → work_instruction → item_code, plan_qty - const procResult = await pool.query( - `SELECT wop.wo_id, wop.process_code, wop.input_qty, wop.plan_qty, + // 1. work_order_process → work_instruction → item_code, plan_qty + const procResult = await pool.query( + `SELECT wop.wo_id, wop.process_code, wop.input_qty, wop.plan_qty, wi.item_id, wi.qty as instruction_qty FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code WHERE wop.id = $1 AND wop.company_code = $2`, - [processId, companyCode] - ); - if (procResult.rowCount === 0) { - return res.json({ success: true, data: { materials: [], processQty: 0 } }); - } - const proc = procResult.rows[0]; - const processQty = parseInt(proc.input_qty || proc.plan_qty || proc.instruction_qty || "0", 10); + [processId, companyCode], + ); + if (procResult.rowCount === 0) { + return res.json({ + success: true, + data: { materials: [], processQty: 0 }, + }); + } + const proc = procResult.rows[0]; + const processQty = parseInt( + proc.input_qty || proc.plan_qty || proc.instruction_qty || "0", + 10, + ); - // 2. item_info → item_code (item_number) - const itemResult = await pool.query( - `SELECT item_number, item_name FROM item_info WHERE id = $1 AND company_code = $2`, - [proc.item_id, companyCode] - ); - if (itemResult.rowCount === 0) { - return res.json({ success: true, data: { materials: [], processQty } }); - } - const itemCode = itemResult.rows[0].item_number; + // 2. item_info → item_code (item_number) + const itemResult = await pool.query( + `SELECT item_number, item_name FROM item_info WHERE id = $1 AND company_code = $2`, + [proc.item_id, companyCode], + ); + if (itemResult.rowCount === 0) { + return res.json({ success: true, data: { materials: [], processQty } }); + } + const itemCode = itemResult.rows[0].item_number; - // 3. BOM 조회 - const bomResult = await pool.query( - `SELECT bd.id, bd.child_item_id, bd.quantity, bd.unit, bd.process_type, bd.loss_rate, + // 3. BOM 조회 + const bomResult = await pool.query( + `SELECT bd.id, bd.child_item_id, bd.quantity, bd.unit, bd.process_type, bd.loss_rate, i.item_name as child_item_name, i.item_number as child_item_code, i.unit as item_unit FROM bom b JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code LEFT JOIN item_info i ON bd.child_item_id = i.id AND i.company_code = b.company_code WHERE (b.item_code = $1 OR b.item_id = $2) AND b.company_code = $3 ORDER BY bd.seq_no ASC`, - [itemCode, proc.item_id, companyCode] - ); + [itemCode, proc.item_id, companyCode], + ); - // 4. 소요량 계산 - const bomBase = await pool.query( - `SELECT base_qty FROM bom WHERE (item_code = $1 OR item_id = $2) AND company_code = $3 LIMIT 1`, - [itemCode, proc.item_id, companyCode] - ); - const baseQty = parseFloat(bomBase.rows[0]?.base_qty || "1") || 1; + // 4. 소요량 계산 + const bomBase = await pool.query( + `SELECT base_qty FROM bom WHERE (item_code = $1 OR item_id = $2) AND company_code = $3 LIMIT 1`, + [itemCode, proc.item_id, companyCode], + ); + const baseQty = parseFloat(bomBase.rows[0]?.base_qty || "1") || 1; - // 기존 투입량 조회 (item_code별 합산 — detail_content에 item_code 저장됨) - const inputResult = await pool.query( - `SELECT detail_content as item_code, SUM(CAST(NULLIF(result_value, '') AS numeric)) as total_input + // 기존 투입량 조회 (item_code별 합산 — detail_content에 item_code 저장됨) + const inputResult = await pool.query( + `SELECT detail_content as item_code, SUM(CAST(NULLIF(result_value, '') AS numeric)) as total_input FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2 AND detail_type = 'material_input' AND result_value IS NOT NULL AND result_value != '' GROUP BY detail_content`, - [processId, companyCode] - ); - const inputMap = new Map(); - for (const row of inputResult.rows) { - inputMap.set(String(row.item_code), parseFloat(row.total_input) || 0); - } + [processId, companyCode], + ); + const inputMap = new Map(); + for (const row of inputResult.rows) { + inputMap.set(String(row.item_code), parseFloat(row.total_input) || 0); + } - const materials = bomResult.rows.map((bd: Record) => { - const bomQty = parseFloat(String(bd.quantity || "0")) || 0; - const lossRate = parseFloat(String(bd.loss_rate || "0")) || 0; - const requiredQty = Math.ceil((processQty / baseQty) * bomQty * (1 + lossRate / 100)); - const childItemCode = String(bd.child_item_code || ""); - return { - id: bd.id, - child_item_id: bd.child_item_id, - child_item_code: childItemCode, - child_item_name: bd.child_item_name || "", - bom_qty: bomQty, - unit: bd.unit || bd.item_unit || "", - process_type: bd.process_type || "", - loss_rate: lossRate, - required_qty: requiredQty, - input_qty: inputMap.get(childItemCode) || 0, - }; - }); + const materials = bomResult.rows.map((bd: Record) => { + const bomQty = parseFloat(String(bd.quantity || "0")) || 0; + const lossRate = parseFloat(String(bd.loss_rate || "0")) || 0; + const requiredQty = Math.ceil( + (processQty / baseQty) * bomQty * (1 + lossRate / 100), + ); + const childItemCode = String(bd.child_item_code || ""); + return { + id: bd.id, + child_item_id: bd.child_item_id, + child_item_code: childItemCode, + child_item_name: bd.child_item_name || "", + bom_qty: bomQty, + unit: bd.unit || bd.item_unit || "", + process_type: bd.process_type || "", + loss_rate: lossRate, + required_qty: requiredQty, + input_qty: inputMap.get(childItemCode) || 0, + }; + }); - return res.json({ - success: true, - data: { materials, processQty, baseQty, itemCode, itemName: itemResult.rows[0].item_name }, - }); - } catch (error: any) { - logger.error("[pop/production] bom-materials 오류:", error); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ + success: true, + data: { + materials, + processQty, + baseQty, + itemCode, + itemName: itemResult.rows[0].item_name, + }, + }); + } catch (error: any) { + logger.error("[pop/production] bom-materials 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } }; /** * 자재 투입 기록 저장 * BOM 기준과 다른 수량도 허용 (유동 투입) */ -export const saveMaterialInput = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - const client = await pool.connect(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { work_order_process_id, inputs } = req.body; +export const saveMaterialInput = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + const client = await pool.connect(); + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id, inputs } = req.body; - if (!work_order_process_id || !inputs || !Array.isArray(inputs)) { - return res.status(400).json({ success: false, message: "work_order_process_id, inputs[] 필수" }); - } + if (!work_order_process_id || !inputs || !Array.isArray(inputs)) { + return res.status(400).json({ + success: false, + message: "work_order_process_id, inputs[] 필수", + }); + } - await client.query("BEGIN"); + await client.query("BEGIN"); - const results = []; - for (const input of inputs) { - const { child_item_id, child_item_code, child_item_name, input_qty, unit, bom_detail_id, required_qty, warehouse_code, location_code } = input; - // item_code/qty 등 대안 필드명도 허용 - const effectiveItemId = child_item_id || input.item_id || input.item_code || child_item_code; - const effectiveItemCode = child_item_code || input.item_code || child_item_id; - const effectiveItemName = child_item_name || input.item_name || ""; - const effectiveQty = input_qty || input.qty || input.quantity; + const results = []; + for (const input of inputs) { + const { + child_item_id, + child_item_code, + child_item_name, + input_qty, + unit, + bom_detail_id, + required_qty, + warehouse_code, + location_code, + } = input; + // item_code/qty 등 대안 필드명도 허용 + const effectiveItemId = + child_item_id || input.item_id || input.item_code || child_item_code; + const effectiveItemCode = + child_item_code || input.item_code || child_item_id; + const effectiveItemName = child_item_name || input.item_name || ""; + const effectiveQty = input_qty || input.qty || input.quantity; - if (!effectiveItemId || !effectiveQty) continue; + if (!effectiveItemId || !effectiveQty) continue; - const parsedQty = parseFloat(String(effectiveQty)); - if (isNaN(parsedQty) || parsedQty <= 0) continue; + const parsedQty = parseFloat(String(effectiveQty)); + if (isNaN(parsedQty) || parsedQty <= 0) continue; - // 투입 기록 INSERT (process_work_result에 material_input 타입으로) - const insertResult = await client.query( - `INSERT INTO process_work_result ( + // 투입 기록 INSERT (process_work_result에 material_input 타입으로) + const insertResult = await client.query( + `INSERT INTO process_work_result ( id, company_code, work_order_process_id, detail_type, detail_content, item_title, result_value, unit, is_passed, status, @@ -2901,74 +3222,113 @@ export const saveMaterialInput = async (req: AuthenticatedRequest, res: Response $5, $6, 'Y', 'completed', $7, $8, NOW()::text, $8 ) RETURNING id`, - [ - companyCode, work_order_process_id, - effectiveItemCode || effectiveItemId, effectiveItemName, - String(parsedQty), unit || "", - JSON.stringify({ bom_detail_id, required_qty: required_qty || 0, warehouse_code, location_code }), - userId, - ] - ); + [ + companyCode, + work_order_process_id, + effectiveItemCode || effectiveItemId, + effectiveItemName, + String(parsedQty), + unit || "", + JSON.stringify({ + bom_detail_id, + required_qty: required_qty || 0, + warehouse_code, + location_code, + }), + userId, + ], + ); - // 재고 차감 (warehouse_code가 있을 때만) - if (warehouse_code) { - const locCode = location_code || warehouse_code; - await client.query( - `UPDATE inventory_stock + // 재고 차감: warehouse_code 있으면 그 창고, 없으면 자동으로 재고가 있는 창고 탐색 + let effectiveWh = warehouse_code; + let effectiveLoc = location_code; + if (!effectiveWh) { + const autoStock = await client.query( + `SELECT warehouse_code, location_code 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, effectiveItemCode], + ); + if (autoStock.rows.length > 0) { + effectiveWh = autoStock.rows[0].warehouse_code; + effectiveLoc = autoStock.rows[0].location_code || effectiveWh; + } + } + if (effectiveWh) { + const locCode = effectiveLoc || effectiveWh; + await client.query( + `UPDATE inventory_stock SET current_qty = (COALESCE(current_qty::numeric, 0) - $4::numeric)::text, updated_date = NOW(), writer = $5 WHERE company_code = $1 AND item_code = $2 AND warehouse_code = $3 AND location_code = $6`, - [companyCode, effectiveItemCode, warehouse_code, String(parsedQty), userId, locCode] - ); - } + [ + companyCode, + effectiveItemCode, + effectiveWh, + String(parsedQty), + userId, + locCode, + ], + ); + } - results.push({ id: insertResult.rows[0].id, child_item_code: effectiveItemCode, input_qty: parsedQty }); - } + results.push({ + id: insertResult.rows[0].id, + child_item_code: effectiveItemCode, + input_qty: parsedQty, + }); + } - await client.query("COMMIT"); + await client.query("COMMIT"); - return res.json({ - success: true, - message: `${results.length}건 자재 투입 완료`, - data: results, - }); - } catch (error: any) { - await client.query("ROLLBACK").catch(() => {}); - logger.error("[pop/production] material-input 오류:", error); - return res.status(500).json({ success: false, message: error.message }); - } finally { - client.release(); - } + return res.json({ + success: true, + message: `${results.length}건 자재 투입 완료`, + data: results, + }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/production] material-input 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } }; /** * 자재 투입 현황 조회 */ -export const getMaterialInputs = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const { processId } = req.params; - if (!processId) { - return res.status(400).json({ success: false, message: "processId 필수" }); - } +export const getMaterialInputs = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res + .status(400) + .json({ success: false, message: "processId 필수" }); + } - const result = await pool.query( - `SELECT id, detail_content as item_code, item_title as item_name, + const result = await pool.query( + `SELECT id, detail_content as item_code, item_title as item_name, result_value as input_qty, unit, remark, recorded_by, recorded_at FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2 AND detail_type = 'material_input' ORDER BY recorded_at ASC`, - [processId, companyCode] - ); + [processId, companyCode], + ); - return res.json({ success: true, data: result.rows }); - } catch (error: any) { - logger.error("[pop/production] material-inputs 조회 오류:", error); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] material-inputs 조회 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } }; /** @@ -2983,21 +3343,21 @@ export const getMaterialInputs = async (req: AuthenticatedRequest, res: Response * 으로 입력 UI를 결정한다. */ export const getChecklistItems = async ( - req: AuthenticatedRequest, - res: Response + req: AuthenticatedRequest, + res: Response, ) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const { processId } = req.params; - if (!processId) { - return res - .status(400) - .json({ success: false, message: "processId 필수" }); - } + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res + .status(400) + .json({ success: false, message: "processId 필수" }); + } - const result = await pool.query( - `SELECT + const result = await pool.query( + `SELECT pwr.id, pwr.company_code, pwr.work_order_process_id, @@ -3040,14 +3400,12 @@ export const getChecklistItems = async ( ORDER BY COALESCE(NULLIF(pwr.item_sort_order, '')::int, 0), COALESCE(NULLIF(pwr.detail_sort_order, '')::int, 0)`, - [processId, companyCode] - ); + [processId, companyCode], + ); - return res.json({ success: true, data: result.rows }); - } catch (error: any) { - logger.error("[pop/production] checklist-items 조회 오류:", error); - return res - .status(500) - .json({ success: false, message: error.message }); - } + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] checklist-items 조회 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } }; diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 9084160f..ef732ace 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -7,70 +7,65 @@ * - 기타입고 → item_info (품목) */ -import { Response } from "express"; -import { AuthenticatedRequest } from "../types/auth"; +import type { Response } from "express"; import { getPool } from "../database/db"; +import type { AuthenticatedRequest } from "../types/auth"; import { logger } from "../utils/logger"; // 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환) export async function getList(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const { - inbound_type, - inbound_status, - search_keyword, - date_from, - date_to, - } = req.query; + try { + const companyCode = req.user!.companyCode; + const { inbound_type, inbound_status, search_keyword, date_from, date_to } = + req.query; - const conditions: string[] = []; - const params: any[] = []; - let paramIdx = 1; + const conditions: string[] = []; + const params: any[] = []; + let paramIdx = 1; - if (companyCode === "*") { - // 최고 관리자: 전체 조회 - } else { - conditions.push(`im.company_code = $${paramIdx}`); - params.push(companyCode); - paramIdx++; - } + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`im.company_code = $${paramIdx}`); + params.push(companyCode); + paramIdx++; + } - if (inbound_type && inbound_type !== "all") { - conditions.push(`im.inbound_type = $${paramIdx}`); - params.push(inbound_type); - paramIdx++; - } + if (inbound_type && inbound_type !== "all") { + conditions.push(`im.inbound_type = $${paramIdx}`); + params.push(inbound_type); + paramIdx++; + } - if (inbound_status && inbound_status !== "all") { - conditions.push(`im.inbound_status = $${paramIdx}`); - params.push(inbound_status); - paramIdx++; - } + if (inbound_status && inbound_status !== "all") { + conditions.push(`im.inbound_status = $${paramIdx}`); + params.push(inbound_status); + paramIdx++; + } - if (search_keyword) { - conditions.push( - `(im.inbound_number ILIKE $${paramIdx} OR COALESCE(id.item_name, im.item_name) ILIKE $${paramIdx} OR COALESCE(id.item_number, im.item_number) ILIKE $${paramIdx} OR COALESCE(id.supplier_name, im.supplier_name) ILIKE $${paramIdx} OR COALESCE(id.reference_number, im.reference_number) ILIKE $${paramIdx})` - ); - params.push(`%${search_keyword}%`); - paramIdx++; - } + if (search_keyword) { + conditions.push( + `(im.inbound_number ILIKE $${paramIdx} OR COALESCE(id.item_name, im.item_name) ILIKE $${paramIdx} OR COALESCE(id.item_number, im.item_number) ILIKE $${paramIdx} OR COALESCE(id.supplier_name, im.supplier_name) ILIKE $${paramIdx} OR COALESCE(id.reference_number, im.reference_number) ILIKE $${paramIdx})`, + ); + params.push(`%${search_keyword}%`); + paramIdx++; + } - if (date_from) { - conditions.push(`im.inbound_date >= $${paramIdx}::date`); - params.push(date_from); - paramIdx++; - } - if (date_to) { - conditions.push(`im.inbound_date <= $${paramIdx}::date`); - params.push(date_to); - paramIdx++; - } + if (date_from) { + conditions.push(`im.inbound_date >= $${paramIdx}::date`); + params.push(date_from); + paramIdx++; + } + if (date_to) { + conditions.push(`im.inbound_date <= $${paramIdx}::date`); + params.push(date_to); + paramIdx++; + } - const whereClause = - conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - const query = ` + const query = ` SELECT im.id, im.company_code, im.inbound_number, im.inbound_type, im.inbound_date, im.warehouse_code, im.location_code, im.inspector, im.manager, @@ -105,44 +100,55 @@ export async function getList(req: AuthenticatedRequest, res: Response) { ORDER BY im.created_date DESC, id.seq_no ASC `; - const pool = getPool(); - const result = await pool.query(query, params); + const pool = getPool(); + const result = await pool.query(query, params); - logger.info("입고 목록 조회", { - companyCode, - rowCount: result.rowCount, - }); + logger.info("입고 목록 조회", { + companyCode, + rowCount: result.rowCount, + }); - 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 }); - } + 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 }); + } } // 입고 등록 (헤더 1건 + 디테일 N건) export async function create(req: AuthenticatedRequest, res: Response) { - const pool = getPool(); - const client = await pool.connect(); + const pool = getPool(); + const client = await pool.connect(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { items, inbound_number, inbound_date, warehouse_code, location_code, inspector, manager, memo } = req.body; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + items, + inbound_number, + inbound_date, + warehouse_code, + location_code, + inspector, + manager, + memo, + } = req.body; - if (!items || !Array.isArray(items) || items.length === 0) { - return res.status(400).json({ success: false, message: "입고 품목이 없습니다." }); - } + if (!items || !Array.isArray(items) || items.length === 0) { + return res + .status(400) + .json({ success: false, message: "입고 품목이 없습니다." }); + } - // 첫 번째 아이템에서 inbound_type 추출 (헤더용) - const inboundType = items[0].inbound_type || null; - const inboundNumber = inbound_number || items[0].inbound_number; + // 첫 번째 아이템에서 inbound_type 추출 (헤더용) + const inboundType = items[0].inbound_type || null; + const inboundNumber = inbound_number || items[0].inbound_number; - await client.query("BEGIN"); + await client.query("BEGIN"); - // 1. 헤더 INSERT (inbound_mng) — 품목 컬럼은 NULL - const headerResult = await client.query( - `INSERT INTO inbound_mng ( + // 1. 헤더 INSERT (inbound_mng) — 품목 컬럼은 NULL + const headerResult = await client.query( + `INSERT INTO inbound_mng ( id, company_code, inbound_number, inbound_type, inbound_date, warehouse_code, location_code, inbound_status, inspector, manager, memo, @@ -153,32 +159,32 @@ export async function create(req: AuthenticatedRequest, res: Response) { $7, $8, $9, $10, NOW(), $11, $11, '입고' ) RETURNING *`, - [ - companyCode, - inboundNumber, - inboundType, - inbound_date || items[0].inbound_date, - warehouse_code || items[0].warehouse_code || null, - location_code || items[0].location_code || null, - items[0].inbound_status || "대기", - inspector || items[0].inspector || null, - manager || items[0].manager || null, - memo || items[0].memo || null, - userId, - ] - ); + [ + companyCode, + inboundNumber, + inboundType, + inbound_date || items[0].inbound_date, + warehouse_code || items[0].warehouse_code || null, + location_code || items[0].location_code || null, + items[0].inbound_status || "대기", + inspector || items[0].inspector || null, + manager || items[0].manager || null, + memo || items[0].memo || null, + userId, + ], + ); - const headerRow = headerResult.rows[0]; - const insertedDetails: any[] = []; + const headerRow = headerResult.rows[0]; + const insertedDetails: any[] = []; - // 2. 디테일 INSERT (inbound_detail) + 재고/발주 업데이트 - for (let i = 0; i < items.length; i++) { - const item = items[i]; - const seqNo = i + 1; + // 2. 디테일 INSERT (inbound_detail) + 재고/발주 업데이트 + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const seqNo = i + 1; - // 2a. inbound_detail INSERT - const detailResult = await client.query( - `INSERT INTO inbound_detail ( + // 2a. inbound_detail INSERT + const detailResult = await client.query( + `INSERT INTO inbound_detail ( id, company_code, inbound_id, seq_no, inbound_type, item_number, item_name, spec, material, unit, inbound_qty, unit_price, total_amount, @@ -193,91 +199,104 @@ export async function create(req: AuthenticatedRequest, res: Response) { $17, $18, $19, NOW(), $20, $20, '입고' ) RETURNING *`, - [ - companyCode, - inboundNumber, - seqNo, - item.inbound_type || inboundType, - item.item_number || null, - item.item_name || null, - item.spec || null, - item.material || null, - item.unit || "EA", - item.inbound_qty || 0, - item.unit_price || 0, - item.total_amount || 0, - item.lot_number || null, - item.reference_number || null, - item.supplier_code || null, - item.supplier_name || null, - item.inspection_status || "대기", - item.memo || null, - item.item_id || null, - userId, - ] - ); + [ + companyCode, + inboundNumber, + seqNo, + item.inbound_type || inboundType, + item.item_number || null, + item.item_name || null, + item.spec || null, + item.material || null, + item.unit || "EA", + item.inbound_qty || 0, + item.unit_price || 0, + item.total_amount || 0, + item.lot_number || null, + item.reference_number || null, + item.supplier_code || null, + item.supplier_name || null, + item.inspection_status || "대기", + item.memo || null, + item.item_id || null, + userId, + ], + ); - insertedDetails.push(detailResult.rows[0]); + insertedDetails.push(detailResult.rows[0]); - // 2b. 재고 업데이트 (inventory_stock): 입고 수량 증가 — 기존 로직 유지 - const itemCode = item.item_number || null; - const whCode = warehouse_code || item.warehouse_code || null; - const locCode = location_code || item.location_code || null; - const inQty = Number(item.inbound_qty) || 0; - if (itemCode && inQty > 0) { - const existingStock = await client.query( - `SELECT id FROM inventory_stock + // 2b. 재고 업데이트 (inventory_stock): 입고 수량 증가 — 기존 로직 유지 + const itemCode = item.item_number || null; + const whCode = warehouse_code || item.warehouse_code || null; + const locCode = location_code || item.location_code || null; + const inQty = Number(item.inbound_qty) || 0; + if (itemCode && inQty > 0) { + const existingStock = await client.query( + `SELECT id FROM inventory_stock WHERE company_code = $1 AND item_code = $2 AND COALESCE(warehouse_code, '') = COALESCE($3, '') AND COALESCE(location_code, '') = COALESCE($4, '') LIMIT 1`, - [companyCode, itemCode, whCode || '', locCode || ''] - ); + [companyCode, itemCode, whCode || "", locCode || ""], + ); - if (existingStock.rows.length > 0) { - await client.query( - `UPDATE inventory_stock + if (existingStock.rows.length > 0) { + await client.query( + `UPDATE inventory_stock SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text), last_in_date = NOW(), updated_date = NOW() WHERE id = $2`, - [inQty, existingStock.rows[0].id] - ); - } else { - await client.query( - `INSERT INTO inventory_stock ( + [inQty, existingStock.rows[0].id], + ); + } else { + await client.query( + `INSERT INTO inventory_stock ( id, company_code, item_code, warehouse_code, location_code, current_qty, safety_qty, last_in_date, created_date, updated_date, writer ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`, - [companyCode, itemCode, whCode, locCode, String(inQty), userId] - ); - } + [companyCode, itemCode, whCode, locCode, String(inQty), userId], + ); + } - // 2b-2. 재고 이력 기록 (inventory_history) - const afterStockRes = await client.query( - `SELECT current_qty FROM inventory_stock + // 2b-2. 재고 이력 기록 (inventory_history) + const afterStockRes = await client.query( + `SELECT current_qty FROM inventory_stock WHERE company_code = $1 AND item_code = $2 AND COALESCE(warehouse_code, '') = COALESCE($3, '') AND COALESCE(location_code, '') = COALESCE($4, '') LIMIT 1`, - [companyCode, itemCode, whCode || '', locCode || ''] - ); - const afterQty = afterStockRes.rows[0]?.current_qty || String(inQty); - await client.query( - `INSERT INTO inventory_history ( + [companyCode, itemCode, whCode || "", locCode || ""], + ); + const afterQty = afterStockRes.rows[0]?.current_qty || String(inQty); + await client.query( + `INSERT INTO inventory_history ( id, company_code, item_code, warehouse_code, location_code, transaction_type, transaction_date, quantity, balance_qty, remark, writer, created_date ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW(), $5, $6, $7, $8, NOW())`, - [companyCode, itemCode, whCode, locCode, String(inQty), afterQty, item.inbound_type || '입고', userId] - ); - } + [ + companyCode, + itemCode, + whCode, + locCode, + String(inQty), + afterQty, + item.inbound_type || "입고", + userId, + ], + ); + } - // 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지 - if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") { - await client.query( - `UPDATE purchase_order_mng + // 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지 + if ( + item.inbound_type === "구매입고" && + item.source_id && + item.source_table === "purchase_order_mng" + ) { + await client.query( + `UPDATE purchase_order_mng SET received_qty = CAST( COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text ), @@ -293,15 +312,19 @@ export async function create(req: AuthenticatedRequest, res: Response) { END, updated_date = NOW() WHERE id = $2 AND company_code = $3`, - [item.inbound_qty || 0, item.source_id, companyCode] - ); - } + [item.inbound_qty || 0, item.source_id, companyCode], + ); + } - // 구매입고인 경우 purchase_detail 품목별 입고수량 업데이트 - if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") { - // 1. 해당 purchase_detail의 received_qty 누적 업데이트 - await client.query( - `UPDATE purchase_detail SET + // 구매입고인 경우 purchase_detail 품목별 입고수량 업데이트 + if ( + item.inbound_type === "구매입고" && + item.source_id && + item.source_table === "purchase_detail" + ) { + // 1. 해당 purchase_detail의 received_qty 누적 업데이트 + await client.query( + `UPDATE purchase_detail SET received_qty = CAST( COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text ), @@ -312,29 +335,29 @@ export async function create(req: AuthenticatedRequest, res: Response) { ), updated_date = NOW() WHERE id = $2 AND company_code = $3`, - [item.inbound_qty || 0, item.source_id, companyCode] - ); + [item.inbound_qty || 0, item.source_id, companyCode], + ); - // 2. 발주 헤더 상태 업데이트 - const detailInfo = await client.query( - `SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`, - [item.source_id, companyCode] - ); - if (detailInfo.rows.length > 0) { - const purchaseNo = detailInfo.rows[0].purchase_no; - // 잔량 있는 디테일이 있는지 확인 - const unreceived = await client.query( - `SELECT id FROM purchase_detail + // 2. 발주 헤더 상태 업데이트 + const detailInfo = await client.query( + `SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`, + [item.source_id, companyCode], + ); + if (detailInfo.rows.length > 0) { + const purchaseNo = detailInfo.rows[0].purchase_no; + // 잔량 있는 디테일이 있는지 확인 + const unreceived = await client.query( + `SELECT id FROM purchase_detail WHERE purchase_no = $1 AND company_code = $2 AND COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0 LIMIT 1`, - [purchaseNo, companyCode] - ); - const newStatus = unreceived.rows.length === 0 ? '입고완료' : '부분입고'; - // 발주 헤더의 received_qty도 디테일 합계로 동기화 - await client.query( - `UPDATE purchase_order_mng SET + [purchaseNo, companyCode], + ); + const newStatus = + unreceived.rows.length === 0 ? "입고완료" : "부분입고"; + await client.query( + `UPDATE purchase_order_mng SET status = $1, received_qty = ( SELECT CAST(COALESCE(SUM(CAST(NULLIF(received_qty, '') AS numeric)), 0) AS text) @@ -351,58 +374,66 @@ export async function create(req: AuthenticatedRequest, res: Response) { ), updated_date = NOW() WHERE purchase_no = $2 AND company_code = $3`, - [newStatus, purchaseNo, companyCode] - ); - } - } - } + [newStatus, purchaseNo, companyCode], + ); + } + } + } - await client.query("COMMIT"); + await client.query("COMMIT"); - logger.info("입고 등록 완료", { - companyCode, - userId, - headerCount: 1, - detailCount: insertedDetails.length, - inbound_number: inboundNumber, - }); + logger.info("입고 등록 완료", { + companyCode, + userId, + headerCount: 1, + detailCount: insertedDetails.length, + inbound_number: inboundNumber, + }); - return res.json({ - success: true, - data: { header: headerRow, details: insertedDetails }, - message: `${insertedDetails.length}건 입고 등록 완료`, - }); - } catch (error: any) { - await client.query("ROLLBACK"); - logger.error("입고 등록 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } finally { - client.release(); - } + return res.json({ + success: true, + data: { header: headerRow, details: insertedDetails }, + message: `${insertedDetails.length}건 입고 등록 완료`, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("입고 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } } // 입고 수정 (헤더 + 디테일 분리 업데이트) export async function update(req: AuthenticatedRequest, res: Response) { - const pool = getPool(); - const client = await pool.connect(); + const pool = getPool(); + const client = await pool.connect(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { id } = req.params; - const { - inbound_date, inbound_qty, unit_price, total_amount, - lot_number, warehouse_code, location_code, - inbound_status, inspection_status, - inspector, manager: mgr, memo, - detail_id, - } = req.body; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { + inbound_date, + inbound_qty, + unit_price, + total_amount, + lot_number, + warehouse_code, + location_code, + inbound_status, + inspection_status, + inspector, + manager: mgr, + memo, + detail_id, + } = req.body; - await client.query("BEGIN"); + await client.query("BEGIN"); - // 헤더 업데이트 (inbound_mng) — 헤더 레벨 필드만 - const headerResult = await client.query( - `UPDATE inbound_mng SET + // 헤더 업데이트 (inbound_mng) — 헤더 레벨 필드만 + const headerResult = await client.query( + `UPDATE inbound_mng SET inbound_date = COALESCE($1::date, inbound_date), warehouse_code = COALESCE($2, warehouse_code), location_code = COALESCE($3, location_code), @@ -414,23 +445,32 @@ export async function update(req: AuthenticatedRequest, res: Response) { updated_by = $8 WHERE id = $9 AND company_code = $10 RETURNING *`, - [ - inbound_date, warehouse_code, location_code, - inbound_status, inspector, mgr, memo, - userId, id, companyCode, - ] - ); + [ + inbound_date, + warehouse_code, + location_code, + inbound_status, + inspector, + mgr, + memo, + userId, + id, + companyCode, + ], + ); - if (headerResult.rowCount === 0) { - await client.query("ROLLBACK"); - return res.status(404).json({ success: false, message: "입고 데이터를 찾을 수 없습니다." }); - } + if (headerResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res + .status(404) + .json({ success: false, message: "입고 데이터를 찾을 수 없습니다." }); + } - // 디테일 업데이트 (inbound_detail) — detail_id가 있으면 디테일 레벨 필드 업데이트 - let detailRow = null; - if (detail_id) { - const detailResult = await client.query( - `UPDATE inbound_detail SET + // 디테일 업데이트 (inbound_detail) — detail_id가 있으면 디테일 레벨 필드 업데이트 + let detailRow = null; + if (detail_id) { + const detailResult = await client.query( + `UPDATE inbound_detail SET inbound_qty = COALESCE($1, inbound_qty), unit_price = COALESCE($2, unit_price), total_amount = COALESCE($3, total_amount), @@ -441,108 +481,126 @@ export async function update(req: AuthenticatedRequest, res: Response) { updated_by = $7 WHERE id = $8 AND company_code = $9 RETURNING *`, - [ - inbound_qty, unit_price, total_amount, - lot_number, inspection_status, memo, - userId, detail_id, companyCode, - ] - ); - detailRow = detailResult.rows[0] || null; - } else { - // 레거시 데이터: detail_id 없이 inbound_mng 자체에 품목 정보 업데이트 - await client.query( - `UPDATE inbound_mng SET + [ + inbound_qty, + unit_price, + total_amount, + lot_number, + inspection_status, + memo, + userId, + detail_id, + companyCode, + ], + ); + detailRow = detailResult.rows[0] || null; + } else { + // 레거시 데이터: detail_id 없이 inbound_mng 자체에 품목 정보 업데이트 + await client.query( + `UPDATE inbound_mng SET inbound_qty = COALESCE($1, inbound_qty), unit_price = COALESCE($2, unit_price), total_amount = COALESCE($3, total_amount), lot_number = COALESCE($4, lot_number), inspection_status = COALESCE($5, inspection_status) WHERE id = $6 AND company_code = $7`, - [ - inbound_qty, unit_price, total_amount, - lot_number, inspection_status, - id, companyCode, - ] - ); - } + [ + inbound_qty, + unit_price, + total_amount, + lot_number, + inspection_status, + id, + companyCode, + ], + ); + } - await client.query("COMMIT"); + await client.query("COMMIT"); - logger.info("입고 수정", { companyCode, userId, id, detail_id }); + logger.info("입고 수정", { companyCode, userId, id, detail_id }); - return res.json({ - success: true, - data: { header: headerResult.rows[0], detail: detailRow }, - }); - } catch (error: any) { - await client.query("ROLLBACK"); - logger.error("입고 수정 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } finally { - client.release(); - } + return res.json({ + success: true, + data: { header: headerResult.rows[0], detail: detailRow }, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("입고 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } } // 입고 삭제 (헤더 + 디테일, 재고/발주 롤백 포함) -export async function deleteReceiving(req: AuthenticatedRequest, res: Response) { - const pool = getPool(); - const client = await pool.connect(); +export async function deleteReceiving( + req: AuthenticatedRequest, + res: Response, +) { + const pool = getPool(); + const client = await pool.connect(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { id } = req.params; + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; - await client.query("BEGIN"); + await client.query("BEGIN"); - // 헤더 정보 조회 (inbound_number, warehouse_code 등) - const headerResult = await client.query( - `SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`, - [id, companyCode] - ); + // 헤더 정보 조회 (inbound_number, warehouse_code 등) + const headerResult = await client.query( + `SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`, + [id, companyCode], + ); - if (headerResult.rowCount === 0) { - await client.query("ROLLBACK"); - return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); - } + if (headerResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res + .status(404) + .json({ success: false, message: "데이터를 찾을 수 없습니다." }); + } - const header = headerResult.rows[0]; - const inboundNumber = header.inbound_number; + const header = headerResult.rows[0]; + const inboundNumber = header.inbound_number; - // 디테일 조회 (재고/발주 롤백용) - const detailResult = await client.query( - `SELECT * FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`, - [inboundNumber, companyCode] - ); + // 디테일 조회 (재고/발주 롤백용) + const detailResult = await client.query( + `SELECT * FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`, + [inboundNumber, companyCode], + ); - // 디테일이 있으면 디테일 기반으로 롤백, 없으면 헤더(레거시) 기반으로 롤백 - const rollbackItems = detailResult.rows.length > 0 - ? detailResult.rows.map((d: any) => ({ - item_number: d.item_number, - inbound_qty: d.inbound_qty, - inbound_type: d.inbound_type || header.inbound_type, - source_table: header.source_table, - source_id: header.source_id, - })) - : [{ - item_number: header.item_number, - inbound_qty: header.inbound_qty, - inbound_type: header.inbound_type, - source_table: header.source_table, - source_id: header.source_id, - }]; + // 디테일이 있으면 디테일 기반으로 롤백, 없으면 헤더(레거시) 기반으로 롤백 + const rollbackItems = + detailResult.rows.length > 0 + ? detailResult.rows.map((d: any) => ({ + item_number: d.item_number, + inbound_qty: d.inbound_qty, + inbound_type: d.inbound_type || header.inbound_type, + source_table: header.source_table, + source_id: header.source_id, + })) + : [ + { + item_number: header.item_number, + inbound_qty: header.inbound_qty, + inbound_type: header.inbound_type, + source_table: header.source_table, + source_id: header.source_id, + }, + ]; - const whCode = header.warehouse_code || null; - const locCode = header.location_code || null; + const whCode = header.warehouse_code || null; + const locCode = header.location_code || null; - for (const item of rollbackItems) { - const itemCode = item.item_number || null; - const inQty = Number(item.inbound_qty) || 0; + for (const item of rollbackItems) { + const itemCode = item.item_number || null; + const inQty = Number(item.inbound_qty) || 0; - // 재고 롤백: 입고 수량만큼 차감 - if (itemCode && inQty > 0) { - await client.query( - `UPDATE inventory_stock + // 재고 롤백: 입고 수량만큼 차감 + if (itemCode && inQty > 0) { + await client.query( + `UPDATE inventory_stock SET current_qty = CAST( GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text ), @@ -550,33 +608,45 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response) WHERE company_code = $2 AND item_code = $3 AND COALESCE(warehouse_code, '') = COALESCE($4, '') AND COALESCE(location_code, '') = COALESCE($5, '')`, - [inQty, companyCode, itemCode, whCode || '', locCode || ''] - ); + [inQty, companyCode, itemCode, whCode || "", locCode || ""], + ); - // 입고취소 이력 기록 - const afterStockRes = await client.query( - `SELECT current_qty FROM inventory_stock + // 입고취소 이력 기록 + const afterStockRes = await client.query( + `SELECT current_qty FROM inventory_stock WHERE company_code = $1 AND item_code = $2 AND COALESCE(warehouse_code, '') = COALESCE($3, '') AND COALESCE(location_code, '') = COALESCE($4, '') LIMIT 1`, - [companyCode, itemCode, whCode || '', locCode || ''] - ); - const afterQty = afterStockRes.rows[0]?.current_qty || '0'; - await client.query( - `INSERT INTO inventory_history ( + [companyCode, itemCode, whCode || "", locCode || ""], + ); + const afterQty = afterStockRes.rows[0]?.current_qty || "0"; + await client.query( + `INSERT INTO inventory_history ( id, company_code, item_code, warehouse_code, location_code, transaction_type, transaction_date, quantity, balance_qty, remark, writer, created_date ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW(), $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`, - [companyCode, itemCode, whCode, locCode, String(-inQty), afterQty, userId] - ); - } + [ + companyCode, + itemCode, + whCode, + locCode, + String(-inQty), + afterQty, + userId, + ], + ); + } - // 구매입고 발주 롤백: purchase_order_mng 기반 - if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") { - await client.query( - `UPDATE purchase_order_mng + // 구매입고 발주 롤백: purchase_order_mng 기반 + if ( + item.inbound_type === "구매입고" && + item.source_id && + item.source_table === "purchase_order_mng" + ) { + await client.query( + `UPDATE purchase_order_mng SET received_qty = CAST( GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text ), @@ -591,21 +661,25 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response) END, updated_date = NOW() WHERE id = $2 AND company_code = $3`, - [inQty, item.source_id, companyCode] - ); - } + [inQty, item.source_id, companyCode], + ); + } - // 구매입고 발주 롤백: purchase_detail 기반 - if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") { - const detailInfo = await client.query( - `SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`, - [item.source_id, companyCode] - ); - if (detailInfo.rows.length > 0) { - const purchaseNo = detailInfo.rows[0].purchase_no; - // 삭제 후 재계산을 위해 현재 입고 건 제외한 미입고 확인 - const unreceived = await client.query( - `SELECT pd.id + // 구매입고 발주 롤백: purchase_detail 기반 + if ( + item.inbound_type === "구매입고" && + item.source_id && + item.source_table === "purchase_detail" + ) { + const detailInfo = await client.query( + `SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`, + [item.source_id, companyCode], + ); + if (detailInfo.rows.length > 0) { + const purchaseNo = detailInfo.rows[0].purchase_no; + // 삭제 후 재계산을 위해 현재 입고 건 제외한 미입고 확인 + const unreceived = await client.query( + `SELECT pd.id FROM purchase_detail pd LEFT JOIN ( SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received @@ -617,76 +691,82 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response) WHERE pd.purchase_no = $2 AND pd.company_code = $1 AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(r.total_received, 0) > 0 LIMIT 1`, - [companyCode, purchaseNo, inboundNumber] - ); - // 잔량 있으면 부분입고, 전량 미입고면 발주확정 - const hasAnyReceived = await client.query( - `SELECT 1 FROM inbound_mng + [companyCode, purchaseNo, inboundNumber], + ); + // 잔량 있으면 부분입고, 전량 미입고면 발주확정 + const hasAnyReceived = await client.query( + `SELECT 1 FROM inbound_mng WHERE source_table = 'purchase_detail' AND company_code = $1 AND inbound_number != $2 LIMIT 1`, - [companyCode, inboundNumber] - ); - const newStatus = hasAnyReceived.rows.length > 0 - ? (unreceived.rows.length === 0 ? '입고완료' : '부분입고') - : '발주확정'; - await client.query( - `UPDATE purchase_order_mng SET status = $1, updated_date = NOW() + [companyCode, inboundNumber], + ); + const newStatus = + hasAnyReceived.rows.length > 0 + ? unreceived.rows.length === 0 + ? "입고완료" + : "부분입고" + : "발주확정"; + await client.query( + `UPDATE purchase_order_mng SET status = $1, updated_date = NOW() WHERE purchase_no = $2 AND company_code = $3`, - [newStatus, purchaseNo, companyCode] - ); - } - } - } + [newStatus, purchaseNo, companyCode], + ); + } + } + } - // 디테일 삭제 - await client.query( - `DELETE FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`, - [inboundNumber, companyCode] - ); + // 디테일 삭제 + await client.query( + `DELETE FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`, + [inboundNumber, companyCode], + ); - // 헤더 삭제 - await client.query( - `DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2`, - [id, companyCode] - ); + // 헤더 삭제 + await client.query( + `DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2`, + [id, companyCode], + ); - await client.query("COMMIT"); + await client.query("COMMIT"); - logger.info("입고 삭제", { companyCode, id, inboundNumber }); + logger.info("입고 삭제", { companyCode, id, inboundNumber }); - return res.json({ success: true, message: "삭제 완료" }); - } catch (error: any) { - await client.query("ROLLBACK"); - logger.error("입고 삭제 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } finally { - client.release(); - } + return res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("입고 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } } // 구매입고용: 발주 데이터 조회 (미입고분) - 신규 헤더-디테일 구조 + 레거시 단일 테이블 UNION ALL -export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const { keyword, page, pageSize } = req.query; - const currentPage = Math.max(1, Number(page) || 1); - const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); - const offset = (currentPage - 1) * limit; +export async function getPurchaseOrders( + req: AuthenticatedRequest, + res: Response, +) { + try { + const companyCode = req.user!.companyCode; + const { keyword, page, pageSize } = req.query; + const currentPage = Math.max(1, Number(page) || 1); + const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); + const offset = (currentPage - 1) * limit; - const params: any[] = [companyCode]; - let paramIdx = 2; + const params: any[] = [companyCode]; + let paramIdx = 2; - let keywordConditionDetail = ""; - let keywordConditionLegacy = ""; - if (keyword) { - keywordConditionDetail = `AND (pd.purchase_no ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_name, ''), ii.item_name) ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_code, ''), ii.item_number) ILIKE $${paramIdx} OR COALESCE(pd.supplier_name, po.supplier_name) ILIKE $${paramIdx})`; - keywordConditionLegacy = `AND (po.purchase_no ILIKE $${paramIdx} OR po.item_name ILIKE $${paramIdx} OR po.item_code ILIKE $${paramIdx} OR po.supplier_name ILIKE $${paramIdx})`; - params.push(`%${keyword}%`); - paramIdx++; - } + let keywordConditionDetail = ""; + let keywordConditionLegacy = ""; + if (keyword) { + keywordConditionDetail = `AND (pd.purchase_no ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_name, ''), ii.item_name) ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_code, ''), ii.item_number) ILIKE $${paramIdx} OR COALESCE(pd.supplier_name, po.supplier_name) ILIKE $${paramIdx})`; + keywordConditionLegacy = `AND (po.purchase_no ILIKE $${paramIdx} OR po.item_name ILIKE $${paramIdx} OR po.item_code ILIKE $${paramIdx} OR po.supplier_name ILIKE $${paramIdx})`; + params.push(`%${keyword}%`); + paramIdx++; + } - const baseQuery = ` + const baseQuery = ` WITH combined AS ( -- 디테일 기반 발주 데이터 (purchase_detail.received_qty로 잔량 계산) SELECT @@ -705,7 +785,13 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response COALESCE(CAST(NULLIF(pd.unit_price, '') AS numeric), 0) AS unit_price, COALESCE(po.status, '') AS status, COALESCE(pd.due_date, po.due_date) AS due_date, - 'purchase_detail' AS source_table + 'purchase_detail' AS source_table, + CASE WHEN EXISTS ( + SELECT 1 FROM item_inspection_info iii + WHERE iii.company_code = pd.company_code + AND COALESCE(iii.is_active, 'Y') = 'Y' + AND iii.item_code = COALESCE(NULLIF(pd.item_code, ''), ii.item_number) + ) THEN 'self' ELSE NULL END AS inspection_type FROM purchase_detail pd LEFT JOIN purchase_order_mng po ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code @@ -738,7 +824,13 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response COALESCE(CAST(NULLIF(po.unit_price, '') AS numeric), 0) AS unit_price, po.status, po.due_date, - 'purchase_order_mng' AS source_table + 'purchase_order_mng' AS source_table, + CASE WHEN EXISTS ( + SELECT 1 FROM item_inspection_info iii + WHERE iii.company_code = po.company_code + AND COALESCE(iii.is_active, 'Y') = 'Y' + AND iii.item_code = po.item_code + ) THEN 'self' ELSE NULL END AS inspection_type FROM purchase_order_mng po WHERE po.company_code = $1 AND NOT EXISTS ( @@ -753,62 +845,62 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response ${keywordConditionLegacy} )`; - const pool = getPool(); + const pool = getPool(); - const countResult = await pool.query( - `${baseQuery} SELECT COUNT(*) AS total FROM combined`, - params - ); - const totalCount = parseInt(countResult.rows[0].total, 10); + const countResult = await pool.query( + `${baseQuery} SELECT COUNT(*) AS total FROM combined`, + params, + ); + const totalCount = parseInt(countResult.rows[0].total, 10); - const dataResult = await pool.query( - `${baseQuery} SELECT * FROM combined ORDER BY order_date DESC, purchase_no LIMIT ${limit} OFFSET ${offset}`, - params - ); + const dataResult = await pool.query( + `${baseQuery} SELECT * FROM combined ORDER BY order_date DESC, purchase_no LIMIT ${limit} OFFSET ${offset}`, + params, + ); - return res.json({ success: true, data: dataResult.rows, totalCount }); - } catch (error: any) { - logger.error("발주 데이터 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ success: true, data: dataResult.rows, totalCount }); + } catch (error: any) { + logger.error("발주 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 반품입고용: 출하 데이터 조회 export async function getShipments(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const { keyword, page, pageSize } = req.query; - const currentPage = Math.max(1, Number(page) || 1); - const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); - const offset = (currentPage - 1) * limit; + try { + const companyCode = req.user!.companyCode; + const { keyword, page, pageSize } = req.query; + const currentPage = Math.max(1, Number(page) || 1); + const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); + const offset = (currentPage - 1) * limit; - const conditions: string[] = ["si.company_code = $1"]; - const params: any[] = [companyCode]; - let paramIdx = 2; + const conditions: string[] = ["si.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; - if (keyword) { - conditions.push( - `(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})` - ); - params.push(`%${keyword}%`); - paramIdx++; - } + if (keyword) { + conditions.push( + `(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`, + ); + params.push(`%${keyword}%`); + paramIdx++; + } - const whereClause = conditions.join(" AND "); - const pool = getPool(); + const whereClause = conditions.join(" AND "); + const pool = getPool(); - const countResult = await pool.query( - `SELECT COUNT(*) AS total + const countResult = await pool.query( + `SELECT COUNT(*) AS total FROM shipment_instruction si JOIN shipment_instruction_detail sid ON si.id = sid.instruction_id AND si.company_code = sid.company_code WHERE ${whereClause}`, - params - ); - const totalCount = parseInt(countResult.rows[0].total, 10); + params, + ); + const totalCount = parseInt(countResult.rows[0].total, 10); - const dataResult = await pool.query( - `SELECT + const dataResult = await pool.query( + `SELECT sid.id AS detail_id, si.id AS instruction_id, si.instruction_no, @@ -829,134 +921,143 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { WHERE ${whereClause} ORDER BY si.instruction_date DESC, si.instruction_no LIMIT ${limit} OFFSET ${offset}`, - params - ); + params, + ); - return res.json({ success: true, data: dataResult.rows, totalCount }); - } catch (error: any) { - logger.error("출하 데이터 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ success: true, data: dataResult.rows, totalCount }); + } catch (error: any) { + logger.error("출하 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 기타입고용: 품목 데이터 조회 export async function getItems(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const { keyword, page, pageSize, division } = req.query; - const currentPage = Math.max(1, Number(page) || 1); - const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); - const offset = (currentPage - 1) * limit; + try { + const companyCode = req.user!.companyCode; + const { keyword, page, pageSize, division } = req.query; + const currentPage = Math.max(1, Number(page) || 1); + const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); + const offset = (currentPage - 1) * limit; - const conditions: string[] = ["company_code = $1"]; - const params: any[] = [companyCode]; - let paramIdx = 2; + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; - if (keyword) { - conditions.push( - `(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})` - ); - params.push(`%${keyword}%`); - paramIdx++; - } + if (keyword) { + conditions.push( + `(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`, + ); + params.push(`%${keyword}%`); + paramIdx++; + } - if (division) { - conditions.push(`division ILIKE $${paramIdx}`); - params.push(`%${division}%`); - paramIdx++; - } + if (division) { + conditions.push(`division ILIKE $${paramIdx}`); + params.push(`%${division}%`); + paramIdx++; + } - const whereClause = conditions.join(" AND "); - const pool = getPool(); + const whereClause = conditions.join(" AND "); + const pool = getPool(); - const countResult = await pool.query( - `SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, - params - ); - const totalCount = parseInt(countResult.rows[0].total, 10); + const countResult = await pool.query( + `SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, + params, + ); + const totalCount = parseInt(countResult.rows[0].total, 10); - const dataResult = await pool.query( - `SELECT + const dataResult = await pool.query( + `SELECT id, item_number, item_name, size AS spec, material, unit, COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price FROM item_info WHERE ${whereClause} ORDER BY item_name LIMIT ${limit} OFFSET ${offset}`, - params - ); + params, + ); - return res.json({ success: true, data: dataResult.rows, totalCount }); - } catch (error: any) { - logger.error("품목 데이터 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ success: true, data: dataResult.rows, totalCount }); + } catch (error: any) { + logger.error("품목 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 입고번호 자동생성 export async function generateNumber(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const ruleId = (req.query.ruleId as string) || (req.query.rule_id as string); + try { + const companyCode = req.user!.companyCode; + const ruleId = + (req.query.ruleId as string) || (req.query.rule_id as string); - // 1순위: POP 화면설정에서 선택한 채번규칙 사용 - if (ruleId && ruleId !== "__none__") { - try { - const { numberingRuleService } = await import("../services/numberingRuleService"); - const newNumber = await numberingRuleService.allocateCode(ruleId, companyCode); - return res.json({ success: true, data: newNumber }); - } catch (e: any) { - logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", { ruleId, error: e.message }); - // 폴백 - } - } + // 1순위: POP 화면설정에서 선택한 채번규칙 사용 + if (ruleId && ruleId !== "__none__") { + try { + const { numberingRuleService } = await import( + "../services/numberingRuleService" + ); + const newNumber = await numberingRuleService.allocateCode( + ruleId, + companyCode, + ); + return res.json({ success: true, data: newNumber }); + } catch (e: any) { + logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", { + ruleId, + error: e.message, + }); + // 폴백 + } + } - // 2순위: 기본 하드코딩 채번 (RCV-YYYY-XXXX) - const pool = getPool(); - const today = new Date(); - const yyyy = today.getFullYear(); - const prefix = `RCV-${yyyy}-`; + // 2순위: 기본 하드코딩 채번 (RCV-YYYY-XXXX) + const pool = getPool(); + const today = new Date(); + const yyyy = today.getFullYear(); + const prefix = `RCV-${yyyy}-`; - const result = await pool.query( - `SELECT inbound_number FROM inbound_mng + const result = await pool.query( + `SELECT inbound_number FROM inbound_mng WHERE company_code = $1 AND inbound_number LIKE $2 ORDER BY inbound_number DESC LIMIT 1`, - [companyCode, `${prefix}%`] - ); + [companyCode, `${prefix}%`], + ); - let seq = 1; - if (result.rows.length > 0) { - const lastNo = result.rows[0].inbound_number; - const lastSeq = parseInt(lastNo.replace(prefix, ""), 10); - if (!isNaN(lastSeq)) seq = lastSeq + 1; - } + let seq = 1; + if (result.rows.length > 0) { + const lastNo = result.rows[0].inbound_number; + const lastSeq = parseInt(lastNo.replace(prefix, ""), 10); + if (!isNaN(lastSeq)) seq = lastSeq + 1; + } - const newNumber = `${prefix}${String(seq).padStart(4, "0")}`; + const newNumber = `${prefix}${String(seq).padStart(4, "0")}`; - return res.json({ success: true, data: newNumber }); - } catch (error: any) { - logger.error("입고번호 생성 실패", { error: error.message }); - return res.status(500).json({ success: false, message: error.message }); - } + return res.json({ success: true, data: newNumber }); + } catch (error: any) { + logger.error("입고번호 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } } // 창고 목록 조회 export async function getWarehouses(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user!.companyCode; - const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); - const result = await pool.query( - `SELECT warehouse_code, warehouse_name, warehouse_type + const result = await pool.query( + `SELECT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info WHERE company_code = $1 AND status != '삭제' ORDER BY warehouse_name`, - [companyCode] - ); + [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 }); - } + 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/inspectionResultRoutes.ts b/backend-node/src/routes/inspectionResultRoutes.ts index 1b2c7cde..1e9d7960 100644 --- a/backend-node/src/routes/inspectionResultRoutes.ts +++ b/backend-node/src/routes/inspectionResultRoutes.ts @@ -1,4 +1,4 @@ -import { Router, Request, Response } from "express"; +import { type Request, type Response, Router } from "express"; import { getPool } from "../database/db"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -9,32 +9,32 @@ router.use(authenticateToken); // ---- 검사 기준 조회 (item_inspection_info) ---- // GET /api/pop/inspection-result/info?itemCode=ITEM-001&inspectionType=입고검사 router.get("/info", async (req: Request, res: Response) => { - const pool = getPool(); - const companyCode = (req as any).user?.companyCode; - const { itemCode, itemId, inspectionType } = req.query; + const pool = getPool(); + const companyCode = (req as any).user?.companyCode; + const { itemCode, itemId, inspectionType } = req.query; - if (!companyCode) { - return res.status(401).json({ success: false, message: "인증 정보 없음" }); - } + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } - const conditions: string[] = ["company_code = $1", "is_active = 'Y'"]; - const params: unknown[] = [companyCode]; - let idx = 2; + const conditions: string[] = ["company_code = $1", "is_active = 'Y'"]; + const params: unknown[] = [companyCode]; + let idx = 2; - if (itemCode) { - conditions.push(`item_code = $${idx++}`); - params.push(itemCode); - } - if (itemId) { - conditions.push(`item_id = $${idx++}`); - params.push(itemId); - } - if (inspectionType) { - conditions.push(`inspection_type = $${idx++}`); - params.push(inspectionType); - } + if (itemCode) { + conditions.push(`item_code = $${idx++}`); + params.push(itemCode); + } + if (itemId) { + conditions.push(`item_id = $${idx++}`); + params.push(itemId); + } + if (inspectionType) { + conditions.push(`inspection_type = $${idx++}`); + params.push(inspectionType); + } - const sql = ` + const sql = ` SELECT id, item_id, item_code, item_name, inspection_type, inspection_item_name, inspection_standard, inspection_method, pass_criteria, is_required, sort_order, memo @@ -43,149 +43,272 @@ router.get("/info", async (req: Request, res: Response) => { ORDER BY sort_order, inspection_item_name `; - try { - const result = await pool.query(sql, params); - return res.json({ success: true, data: result.rows }); - } catch (err: any) { - return res.status(500).json({ success: false, message: err.message }); - } + try { + const result = await pool.query(sql, params); + return res.json({ success: true, data: result.rows }); + } catch (err: any) { + return res.status(500).json({ success: false, message: err.message }); + } }); // ---- 검사 결과 조회 ---- // GET /api/pop/inspection-result?referenceId=xxx&referenceTable=yyy&screenId=zzz router.get("/", async (req: Request, res: Response) => { - const pool = getPool(); - const companyCode = (req as any).user?.companyCode; - const { referenceId, referenceTable, screenId } = req.query; + const pool = getPool(); + const companyCode = (req as any).user?.companyCode; + const { referenceId, referenceTable, screenId } = req.query; - if (!companyCode) { - return res.status(401).json({ success: false, message: "인증 정보 없음" }); - } + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } - const conditions: string[] = ["company_code = $1"]; - const params: unknown[] = [companyCode]; - let idx = 2; + const conditions: string[] = ["company_code = $1"]; + const params: unknown[] = [companyCode]; + let idx = 2; - if (referenceId) { - conditions.push(`reference_id = $${idx++}`); - params.push(referenceId); - } - if (referenceTable) { - conditions.push(`reference_table = $${idx++}`); - params.push(referenceTable); - } - if (screenId) { - conditions.push(`screen_id = $${idx++}`); - params.push(screenId); - } + if (referenceId) { + conditions.push(`reference_id = $${idx++}`); + params.push(referenceId); + } + if (referenceTable) { + conditions.push(`reference_table = $${idx++}`); + params.push(referenceTable); + } + if (screenId) { + conditions.push(`screen_id = $${idx++}`); + params.push(screenId); + } - const sql = ` + const sql = ` SELECT * FROM inspection_result WHERE ${conditions.join(" AND ")} ORDER BY created_date DESC `; - try { - const result = await pool.query(sql, params); - return res.json({ success: true, data: result.rows }); - } catch (err: any) { - return res.status(500).json({ success: false, message: err.message }); - } + try { + const result = await pool.query(sql, params); + return res.json({ success: true, data: result.rows }); + } catch (err: any) { + return res.status(500).json({ success: false, message: err.message }); + } }); -// ---- 검사 결과 저장 (INSERT or UPDATE) ---- +// ---- 검사번호 채번 (PC numberingRuleService 활용) ---- +async function generateInspectionNumber(companyCode: string): Promise { + // PC 채번 서비스 동적 import (순환 참조 방지) + const { numberingRuleService } = await import( + "../services/numberingRuleService" + ); + + // 1) inspection_result_mng / inspection_number 채번 규칙 조회 + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, + "inspection_result_mng", + "inspection_number", + ); + + if (rule && rule.ruleId) { + // 2) PC API와 동일한 allocateCode 호출 → 실제 시퀀스 +1 + return await numberingRuleService.allocateCode(rule.ruleId, companyCode); + } + + // fallback: 채번 규칙 없으면 단순 SELECT MAX + const { getPool } = await import("../database/db"); + const pool = getPool(); + const year = new Date().getFullYear(); + const prefix = `QI-${year}-`; + const result = await pool.query( + `SELECT inspection_number FROM inspection_result_mng + WHERE company_code = $1 AND inspection_number LIKE $2 + ORDER BY inspection_number DESC LIMIT 1`, + [companyCode, `${prefix}%`], + ); + let nextSeq = 1; + if (result.rows.length > 0) { + const lastNumber = result.rows[0].inspection_number; + const match = lastNumber.match(/(\d+)$/); + if (match) nextSeq = parseInt(match[1], 10) + 1; + } + return `${prefix}${String(nextSeq).padStart(4, "0")}`; +} + +// ---- 검사번호 채번 전용 엔드포인트 (검사 모달에서 검사 완료 시) ---- +// POST /api/pop/inspection-result/allocate-number +router.post("/allocate-number", async (req: Request, res: Response) => { + const companyCode = (req as any).user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + try { + const inspectionNumber = await generateInspectionNumber(companyCode); + return res.json({ success: true, data: { inspectionNumber } }); + } catch (err: any) { + return res.status(500).json({ success: false, message: err.message }); + } +}); + +// ---- 검사 결과 저장 (마스터 + 디테일 트랜잭션) ---- // POST /api/pop/inspection-result router.post("/", async (req: Request, res: Response) => { - const pool = getPool(); - const companyCode = (req as any).user?.companyCode; - const writer = (req as any).user?.userId; + const pool = getPool(); + const companyCode = (req as any).user?.companyCode; + const writer = (req as any).user?.userId; - if (!companyCode) { - return res.status(401).json({ success: false, message: "인증 정보 없음" }); - } + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } - const { - referenceTable, - referenceId, - screenId, - itemId, - itemCode, - itemName, - inspectionType, - items, // 검사 항목별 결과 배열 - overallJudgment, - memo, - isCompleted, - } = req.body; + const { + inspectionNumber: providedNumber, // 프론트에서 미리 채번한 번호 (있으면 재사용) + referenceTable, + referenceId, + screenId, + itemId, + itemCode, + itemName, + inspectionType, + items, // 검사 항목별 결과 배열 + overallJudgment, + totalQty, + goodQty, + badQty, + defectDescription, + memo, + inspector, + supplierCode, + supplierName, + isCompleted, + } = req.body; - if (!items || !Array.isArray(items) || items.length === 0) { - return res.status(400).json({ success: false, message: "검사 항목이 없습니다" }); - } + if (!items || !Array.isArray(items) || items.length === 0) { + return res + .status(400) + .json({ success: false, message: "검사 항목이 없습니다" }); + } - const client = await pool.connect(); - try { - await client.query("BEGIN"); + const client = await pool.connect(); + try { + await client.query("BEGIN"); - // 기존 결과 삭제 (동일 referenceId + referenceTable 기준 덮어쓰기) - if (referenceId && referenceTable) { - await client.query( - `DELETE FROM inspection_result + // 1. 동일 referenceId + referenceTable 기존 마스터/디테일 삭제 (덮어쓰기) + if (referenceId && referenceTable) { + await client.query( + `DELETE FROM inspection_result WHERE master_id IN ( + SELECT id FROM inspection_result_mng + WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3 + )`, + [companyCode, referenceId, referenceTable], + ); + await client.query( + `DELETE FROM inspection_result_mng WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3`, - [companyCode, referenceId, referenceTable] - ); - } + [companyCode, referenceId, referenceTable], + ); + } - const insertedIds: string[] = []; - for (const item of items) { - const completedFlag = isCompleted ? "Y" : "N"; - const completedDate = isCompleted ? new Date() : null; - const insertSql = ` - INSERT INTO inspection_result ( - company_code, writer, + // 2. 검사번호 (프론트에서 미리 받았으면 재사용, 없으면 새로 채번) + const inspectionNumber = + providedNumber || (await generateInspectionNumber(companyCode)); + + // 3. 마스터 INSERT + const completedFlag = isCompleted ? "Y" : "N"; + const completedDate = isCompleted ? new Date() : null; + const masterResult = await client.query( + `INSERT INTO inspection_result_mng ( + company_code, writer, inspection_number, + reference_table, reference_id, screen_id, + item_id, item_code, item_name, + inspection_type, total_qty, good_qty, bad_qty, + overall_judgment, defect_description, memo, + inspector, inspection_date, + supplier_code, supplier_name, + is_completed, completed_date + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, $20, $21 + ) RETURNING id, inspection_number`, + [ + companyCode, + writer, + inspectionNumber, + referenceTable || null, + referenceId || null, + screenId || null, + itemId || null, + itemCode || null, + itemName || null, + inspectionType || null, + totalQty != null ? Number(totalQty) : null, + goodQty != null ? Number(goodQty) : null, + badQty != null ? Number(badQty) : null, + overallJudgment || null, + defectDescription || null, + memo || null, + inspector || writer, + supplierCode || null, + supplierName || null, + completedFlag, + completedDate, + ], + ); + const masterId = masterResult.rows[0].id; + + // 4. 디테일 N건 INSERT + const insertedDetailIds: string[] = []; + for (const item of items) { + const detailResult = await client.query( + `INSERT INTO inspection_result ( + company_code, writer, master_id, reference_table, reference_id, screen_id, inspection_info_id, item_id, item_code, item_name, inspection_type, inspection_item_name, inspection_standard, pass_criteria, is_required, measured_value, judgment, overall_judgment, memo, is_completed, completed_date ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20 - ) - RETURNING id - `; - const result = await client.query(insertSql, [ - companyCode, - writer, - referenceTable || null, - referenceId || null, - screenId || null, - item.inspectionInfoId || null, - itemId || item.itemId || null, - itemCode || item.itemCode || null, - itemName || item.itemName || null, - inspectionType || item.inspectionType || null, - item.inspectionItemName || null, - item.inspectionStandard || null, - item.passCriteria || null, - item.isRequired || "Y", - item.measuredValue || null, - item.judgment || null, - overallJudgment || null, - memo || null, - completedFlag, - completedDate, - ]); - insertedIds.push(result.rows[0].id); - } + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21 + ) RETURNING id`, + [ + companyCode, + writer, + masterId, + referenceTable || null, + referenceId || null, + screenId || null, + item.inspectionInfoId || null, + itemId || item.itemId || null, + itemCode || item.itemCode || null, + itemName || item.itemName || null, + inspectionType || item.inspectionType || null, + item.inspectionItemName || null, + item.inspectionStandard || null, + item.passCriteria || null, + item.isRequired || "Y", + item.measuredValue || null, + item.judgment || null, + overallJudgment || null, + memo || null, + completedFlag, + completedDate, + ], + ); + insertedDetailIds.push(detailResult.rows[0].id); + } - await client.query("COMMIT"); - return res.json({ success: true, data: { ids: insertedIds } }); - } catch (err: any) { - await client.query("ROLLBACK"); - return res.status(500).json({ success: false, message: err.message }); - } finally { - client.release(); - } + await client.query("COMMIT"); + return res.json({ + success: true, + data: { + masterId, + inspectionNumber, + detailIds: insertedDetailIds, + }, + }); + } catch (err: any) { + await client.query("ROLLBACK"); + return res.status(500).json({ success: false, message: err.message }); + } finally { + client.release(); + } }); export default router; diff --git a/frontend/app/(pop)/pop/inventory/history/page.tsx b/frontend/app/(pop)/pop/inventory/history/page.tsx new file mode 100644 index 00000000..d68d80c7 --- /dev/null +++ b/frontend/app/(pop)/pop/inventory/history/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { InOutHistory } from "@/components/pop/hardcoded/inventory"; + +export default function InOutHistoryPage() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/inventory/page.tsx b/frontend/app/(pop)/pop/inventory/page.tsx new file mode 100644 index 00000000..9f60e883 --- /dev/null +++ b/frontend/app/(pop)/pop/inventory/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { InventoryHome } from "@/components/pop/hardcoded/inventory"; + +export default function InventoryPage() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/quality/inspection/page.tsx b/frontend/app/(pop)/pop/quality/inspection/page.tsx new file mode 100644 index 00000000..3b12d4e9 --- /dev/null +++ b/frontend/app/(pop)/pop/quality/inspection/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { InspectionList } from "@/components/pop/hardcoded/quality"; + +export default function InspectionListPage() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/quality/page.tsx b/frontend/app/(pop)/pop/quality/page.tsx new file mode 100644 index 00000000..e294e5a8 --- /dev/null +++ b/frontend/app/(pop)/pop/quality/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { QualityHome } from "@/components/pop/hardcoded/quality"; + +export default function QualityPage() { + return ( + + + + ); +} diff --git a/frontend/components/pop/hardcoded/MenuIcons.tsx b/frontend/components/pop/hardcoded/MenuIcons.tsx index 94a56813..4d2f93e2 100644 --- a/frontend/components/pop/hardcoded/MenuIcons.tsx +++ b/frontend/components/pop/hardcoded/MenuIcons.tsx @@ -1,143 +1,219 @@ "use client"; -import React from "react"; import { useRouter } from "next/navigation"; +import type React from "react"; interface MenuIconItem { - id: string; - title: string; - gradient: string; - shadowColor: string; - icon: React.ReactNode; - href: string; + id: string; + title: string; + gradient: string; + shadowColor: string; + icon: React.ReactNode; + href: string; } const MENU_ITEMS: MenuIconItem[] = [ - { - id: "incoming", - title: "입고", - gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)", - shadowColor: "rgba(59,130,246,.3)", - icon: ( - - - - ), - href: "/pop/inbound", - }, - { - id: "outgoing", - title: "출고", - gradient: "linear-gradient(135deg,#22c55e,#15803d)", - shadowColor: "rgba(34,197,94,.3)", - icon: ( - - - - ), - href: "/pop/outbound", - }, - { - id: "production", - title: "생산", - gradient: "linear-gradient(135deg,#f59e0b,#d97706)", - shadowColor: "rgba(245,158,11,.3)", - icon: ( - - - - - ), - href: "/pop/production", - }, - { - id: "quality", - title: "품질", - gradient: "linear-gradient(135deg,#ef4444,#b91c1c)", - shadowColor: "rgba(239,68,68,.3)", - icon: ( - - - - ), - href: "/pop/screens/quality", - }, - { - id: "equipment", - title: "설비", - gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)", - shadowColor: "rgba(139,92,246,.3)", - icon: ( - - - - ), - href: "/pop/screens/equipment", - }, - { - id: "inventory", - title: "재고", - gradient: "linear-gradient(135deg,#06b6d4,#0e7490)", - shadowColor: "rgba(6,182,212,.3)", - icon: ( - - - - ), - href: "/pop/screens/inventory", - }, - // 작업지시, 생산실적은 생산관리(/pop/production) 메뉴 안으로 이동 - { - id: "safety", - title: "안전관리", - gradient: "linear-gradient(135deg,#f97316,#c2410c)", - shadowColor: "rgba(249,115,22,.3)", - icon: ( - - - - ), - href: "/pop/screens/safety", - }, + { + id: "incoming", + title: "입고", + gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)", + shadowColor: "rgba(59,130,246,.3)", + icon: ( + + + + ), + href: "/pop/inbound", + }, + { + id: "outgoing", + title: "출고", + gradient: "linear-gradient(135deg,#22c55e,#15803d)", + shadowColor: "rgba(34,197,94,.3)", + icon: ( + + + + ), + href: "/pop/outbound", + }, + { + id: "production", + title: "생산", + gradient: "linear-gradient(135deg,#f59e0b,#d97706)", + shadowColor: "rgba(245,158,11,.3)", + icon: ( + + + + + ), + href: "/pop/production", + }, + { + id: "quality", + title: "품질", + gradient: "linear-gradient(135deg,#ef4444,#b91c1c)", + shadowColor: "rgba(239,68,68,.3)", + icon: ( + + + + ), + href: "/pop/quality", + }, + { + id: "equipment", + title: "설비", + gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)", + shadowColor: "rgba(139,92,246,.3)", + icon: ( + + + + ), + href: "/pop/screens/equipment", + }, + { + id: "inventory", + title: "재고", + gradient: "linear-gradient(135deg,#06b6d4,#0e7490)", + shadowColor: "rgba(6,182,212,.3)", + icon: ( + + + + ), + href: "/pop/inventory", + }, + // 작업지시, 생산실적은 생산관리(/pop/production) 메뉴 안으로 이동 + { + id: "safety", + title: "안전관리", + gradient: "linear-gradient(135deg,#f97316,#c2410c)", + shadowColor: "rgba(249,115,22,.3)", + icon: ( + + + + ), + href: "/pop/screens/safety", + }, ]; export function MenuIcons() { - const router = useRouter(); + const router = useRouter(); - const handleClick = (item: MenuIconItem) => { - if (item.href === "#") { - alert(`${item.title} 화면은 준비 중입니다.`); - } else { - router.push(item.href); - } - }; + const handleClick = (item: MenuIconItem) => { + if (item.href === "#") { + alert(`${item.title} 화면은 준비 중입니다.`); + } else { + router.push(item.href); + } + }; - return ( -
-

- 메뉴 -

-
- {MENU_ITEMS.map((item) => ( -
handleClick(item)} - > -
- {item.icon} -
- {item.title} -
- ))} -
-
- ); + return ( +
+

+ 메뉴 +

+
+ {MENU_ITEMS.map((item) => ( +
handleClick(item)} + > +
+ {item.icon} +
+ + {item.title} + +
+ ))} +
+
+ ); } diff --git a/frontend/components/pop/hardcoded/common/ConfirmModal.tsx b/frontend/components/pop/hardcoded/common/ConfirmModal.tsx new file mode 100644 index 00000000..34bfff4b --- /dev/null +++ b/frontend/components/pop/hardcoded/common/ConfirmModal.tsx @@ -0,0 +1,82 @@ +"use client"; + +import React from "react"; + +export interface ConfirmModalProps { + open: boolean; + title?: string; + message: string; + confirmText?: string; + cancelText?: string; + variant?: "primary" | "danger" | "success"; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * POP 공용 확인 모달 (native confirm() 대체) + * 모바일 친화 디자인, bottom-sheet 스타일 + */ +export function ConfirmModal({ + open, + title, + message, + confirmText = "확인", + cancelText = "취소", + variant = "primary", + onConfirm, + onCancel, +}: ConfirmModalProps) { + if (!open) return null; + + const confirmBg = + variant === "danger" + ? "bg-gradient-to-b from-red-500 to-red-600 hover:from-red-600 hover:to-red-700" + : variant === "success" + ? "bg-gradient-to-b from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700" + : "bg-gradient-to-b from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700"; + + return ( +
+ {/* Overlay */} +
+ + {/* Center modal */} +
+
e.stopPropagation()} + > + {/* Body */} +
+ {title && ( +

{title}

+ )} +

+ {message} +

+
+ + {/* Buttons */} +
+ +
+ +
+
+
+
+ ); +} diff --git a/frontend/components/pop/hardcoded/inbound/InboundCart.tsx b/frontend/components/pop/hardcoded/inbound/InboundCart.tsx index 36efb141..c6d7a585 100644 --- a/frontend/components/pop/hardcoded/inbound/InboundCart.tsx +++ b/frontend/components/pop/hardcoded/inbound/InboundCart.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; +import React, { useCallback, useEffect, useState } from "react"; import { apiClient } from "@/lib/api/client"; import { InspectionModal, type InspectionResult } from "./InspectionModal"; import type { PackageEntry } from "./NumberPadModal"; @@ -11,9 +11,9 @@ import type { PackageEntry } from "./NumberPadModal"; /* ------------------------------------------------------------------ */ interface Warehouse { - warehouse_code: string; - warehouse_name: string; - warehouse_type?: string; + warehouse_code: string; + warehouse_name: string; + warehouse_type?: string; } /* ------------------------------------------------------------------ */ @@ -21,41 +21,41 @@ interface Warehouse { /* ------------------------------------------------------------------ */ export interface CartItem { - id: string; - /** cart_items 테이블의 PK (UUID) — DB 삭제용 */ - dbId?: string; - /** purchase_detail or purchase_order_mng */ - source_table: string; - /** PK of the source row */ - source_id: string; - purchase_no: string; - item_code: string; - item_name: string; - spec: string; - material: string; - order_qty: number; - remain_qty: number; - /** User-entered quantity */ - inbound_qty: number; - unit_price: number; - supplier_code: string; - supplier_name: string; - order_date: string; - inspection_required?: boolean; - inspection_type?: "self" | "request" | null; - packages?: PackageEntry[]; - inspectionResult?: InspectionResult | null; + id: string; + /** cart_items 테이블의 PK (UUID) — DB 삭제용 */ + dbId?: string; + /** purchase_detail or purchase_order_mng */ + source_table: string; + /** PK of the source row */ + source_id: string; + purchase_no: string; + item_code: string; + item_name: string; + spec: string; + material: string; + order_qty: number; + remain_qty: number; + /** User-entered quantity */ + inbound_qty: number; + unit_price: number; + supplier_code: string; + supplier_name: string; + order_date: string; + inspection_required?: boolean; + inspection_type?: "self" | "request" | null; + packages?: PackageEntry[]; + inspectionResult?: InspectionResult | null; } interface InboundCartProps { - open: boolean; - onClose: () => void; - items: CartItem[]; - onUpdateQty: (id: string, qty: number) => void; - onRemove: (id: string) => void; - onClear: () => void; - supplierName?: string; - onUpdateItems?: (items: CartItem[]) => void; + open: boolean; + onClose: () => void; + items: CartItem[]; + onUpdateQty: (id: string, qty: number) => void; + onRemove: (id: string) => void; + onClear: () => void; + supplierName?: string; + onUpdateItems?: (items: CartItem[]) => void; } /* ------------------------------------------------------------------ */ @@ -63,466 +63,673 @@ interface InboundCartProps { /* ------------------------------------------------------------------ */ export function InboundCart({ - open, - onClose, - items, - onUpdateQty, - onRemove, - onClear, - supplierName, - onUpdateItems, + open, + onClose, + items, + onUpdateQty, + onRemove, + onClear, + supplierName, + onUpdateItems, }: InboundCartProps) { - const router = useRouter(); - const [confirming, setConfirming] = useState(false); - const [resultMsg, setResultMsg] = useState(null); - const [selectedItems, setSelectedItems] = useState>(new Set()); - const [inspectionModalOpen, setInspectionModalOpen] = useState(false); - const [inspectionTarget, setInspectionTarget] = useState(null); + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [resultMsg, setResultMsg] = useState(null); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [inspectionModalOpen, setInspectionModalOpen] = useState(false); + const [inspectionTarget, setInspectionTarget] = useState( + null, + ); - /* Warehouse state */ - const [warehouses, setWarehouses] = useState([]); - const [selectedWarehouse, setSelectedWarehouse] = useState(""); + /* Warehouse state */ + const [warehouses, setWarehouses] = useState([]); + const [selectedWarehouse, setSelectedWarehouse] = useState(""); - /* Fetch warehouses on mount */ - const fetchWarehouses = useCallback(async () => { - try { - const res = await apiClient.get("/receiving/warehouses"); - const data: Warehouse[] = res.data?.data ?? []; - setWarehouses(data); - if (data.length > 0 && !selectedWarehouse) { - setSelectedWarehouse(data[0].warehouse_code); - } - } catch { - // Keep empty - user can still confirm without warehouse - } - }, [selectedWarehouse]); + /* Fetch warehouses on mount */ + const fetchWarehouses = useCallback(async () => { + try { + const res = await apiClient.get("/receiving/warehouses"); + const data: Warehouse[] = res.data?.data ?? []; + setWarehouses(data); + if (data.length > 0 && !selectedWarehouse) { + setSelectedWarehouse(data[0].warehouse_code); + } + } catch { + // Keep empty - user can still confirm without warehouse + } + }, [selectedWarehouse]); - useEffect(() => { - if (open) { - fetchWarehouses(); - } - }, [open, fetchWarehouses]); + useEffect(() => { + if (open) { + fetchWarehouses(); + } + }, [open, fetchWarehouses]); - const totalQty = items.reduce((s, i) => s + i.inbound_qty, 0); - const totalAmount = items.reduce((s, i) => s + i.inbound_qty * i.unit_price, 0); + const totalQty = items.reduce((s, i) => s + i.inbound_qty, 0); + const totalAmount = items.reduce( + (s, i) => s + i.inbound_qty * i.unit_price, + 0, + ); - /* Toggle select */ - const toggleSelect = (id: string) => { - setSelectedItems((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; + /* Toggle select */ + const toggleSelect = (id: string) => { + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; - const toggleSelectAll = () => { - if (selectedItems.size === items.length) { - setSelectedItems(new Set()); - } else { - setSelectedItems(new Set(items.map((i) => i.id))); - } - }; + const toggleSelectAll = () => { + if (selectedItems.size === items.length) { + setSelectedItems(new Set()); + } else { + setSelectedItems(new Set(items.map((i) => i.id))); + } + }; - /* Open inspection modal */ - const openInspection = (item: CartItem) => { - setInspectionTarget(item); - setInspectionModalOpen(true); - }; + /* Open inspection modal */ + const openInspection = (item: CartItem) => { + setInspectionTarget(item); + setInspectionModalOpen(true); + }; - /* Handle inspection complete */ - const handleInspectionComplete = (result: InspectionResult) => { - if (!inspectionTarget || !onUpdateItems) return; - const updated = items.map((item) => - item.id === inspectionTarget.id - ? { ...item, inspectionResult: result } - : item - ); - onUpdateItems(updated); - setInspectionTarget(null); - }; + /* Handle inspection complete */ + const handleInspectionComplete = (result: InspectionResult) => { + if (!inspectionTarget || !onUpdateItems) return; + const updated = items.map((item) => + item.id === inspectionTarget.id + ? { ...item, inspectionResult: result } + : item, + ); + onUpdateItems(updated); + setInspectionTarget(null); + }; - /* Confirm inbound — PC receivingController.create 와 동일한 body 구조 */ - const handleConfirm = async () => { - if (items.length === 0) return; - if (!selectedWarehouse) { - setResultMsg("오류: 입고 창고를 선택해주세요."); - return; - } - setConfirming(true); - setResultMsg(null); + /* Confirm inbound — PC receivingController.create 와 동일한 body 구조 */ + const handleConfirm = async () => { + if (items.length === 0) return; + if (!selectedWarehouse) { + setResultMsg("오류: 입고 창고를 선택해주세요."); + return; + } + setConfirming(true); + setResultMsg(null); - try { - // 1. 입고번호 채번 (RCV-YYYY-XXXX) - let inboundNumber: string | undefined; - try { - const numRes = await apiClient.get("/receiving/generate-number"); - if (numRes.data?.success && numRes.data?.data) { - inboundNumber = numRes.data.data; - } - } catch { - // 채번 실패 시 백엔드가 처리 - } + try { + // 1. 입고번호 채번 (RCV-YYYY-XXXX) + let inboundNumber: string | undefined; + try { + const numRes = await apiClient.get("/receiving/generate-number"); + if (numRes.data?.success && numRes.data?.data) { + inboundNumber = numRes.data.data; + } + } catch { + // 채번 실패 시 백엔드가 처리 + } - // 2. POST /api/receiving — PC create 와 동일한 payload - const payload = { - inbound_number: inboundNumber, - inbound_date: new Date().toISOString().slice(0, 10), - warehouse_code: selectedWarehouse, - inbound_type: "구매입고", - items: items.map((item, idx) => ({ - inbound_type: "구매입고", - item_number: item.item_code, - item_name: item.item_name, - spec: item.spec || "", - material: item.material || "", - unit: "EA", - inbound_qty: String(item.inbound_qty), - unit_price: String(item.unit_price || 0), - total_amount: String((item.inbound_qty || 0) * (item.unit_price || 0)), - reference_number: item.purchase_no, - supplier_code: item.supplier_code, - supplier_name: item.supplier_name, - inspection_status: item.inspectionResult?.completed - ? "검사완료" - : item.inspection_required - ? "검사대기" - : "합격", - source_table: item.source_table, - source_id: item.source_id || item.id, - seq_no: idx + 1, - })), - }; + // 2. POST /api/receiving — PC create 와 동일한 payload + const payload = { + inbound_number: inboundNumber, + inbound_date: new Date().toISOString().slice(0, 10), + warehouse_code: selectedWarehouse, + inbound_type: "구매입고", + items: items.map((item, idx) => ({ + inbound_type: "구매입고", + item_number: item.item_code, + item_name: item.item_name, + spec: item.spec || "", + material: item.material || "", + unit: "EA", + inbound_qty: String(item.inbound_qty), + unit_price: String(item.unit_price || 0), + total_amount: String( + (item.inbound_qty || 0) * (item.unit_price || 0), + ), + reference_number: item.purchase_no, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + inspection_status: item.inspectionResult?.completed + ? "검사완료" + : item.inspection_required + ? "검사대기" + : "합격", + source_table: item.source_table, + source_id: item.source_id || item.id, + seq_no: idx + 1, + })), + }; - const res = await apiClient.post("/receiving", payload); + const res = await apiClient.post("/receiving", payload); - if (res.data?.success) { - // 3. cart_items DB 정리 (백그라운드, 논블로킹) - // cart_items.row_key 로 삭제 (row_key = source_id 로 저장됨) - const rowKeys = items.map((item) => item.source_id || item.id).filter(Boolean); - if (rowKeys.length > 0) { - apiClient.post("/pop/execute-action", { - tasks: [{ type: "cart-save" }], - cartChanges: { - toDelete: rowKeys, - }, - }).catch(() => { - // cart cleanup 실패 시 무시 - }); - } + if (res.data?.success) { + // 2-1. 검사 결과가 있는 항목 → inspection_result에 저장 + const insertedDetails: any[] = + res.data?.data?.details ?? res.data?.data?.items ?? []; + const inboundHeaderNo = + res.data?.data?.header?.inbound_number || inboundNumber || ""; + const inspectionPromises = items + .map((item, idx) => { + if (!item.inspectionResult?.completed) return null; + const matchedDetail = insertedDetails[idx] ?? {}; + const referenceId = + matchedDetail.id || + matchedDetail.detail_id || + `${inboundHeaderNo}-${idx + 1}`; + const goodQty = item.inspectionResult.goodQty || 0; + const badQty = item.inspectionResult.badQty || 0; + const totalQty = goodQty + badQty; + const overallJudgment = badQty === 0 ? "합격" : "불합격"; + return apiClient + .post("/pop/inspection-result", { + inspectionNumber: item.inspectionResult.inspectionNumber, // 카트에서 받은 검사번호 재사용 + referenceTable: "inbound_mng", + referenceId, + screenId: "pop_inbound_inspection", + itemId: item.item_id || null, + itemCode: item.item_code, + itemName: item.item_name, + inspectionType: "입고검사", + overallJudgment, + totalQty, + goodQty, + badQty, + defectDescription: badQty > 0 ? `불량 ${badQty}건` : "", + memo: item.inspectionResult.remark || "", + supplierCode: item.supplier_code || null, + supplierName: item.supplier_name || null, + isCompleted: true, + items: item.inspectionResult.items.map((insp: any) => ({ + inspectionInfoId: insp.id || null, + inspectionItemName: insp.inspection_item_name, + inspectionStandard: insp.inspection_standard, + passCriteria: insp.pass_criteria, + isRequired: insp.is_required || "Y", + measuredValue: insp.measured_value || "", + judgment: insp.result || null, + })), + }) + .catch((err) => { + console.error( + "[inspection_result 저장 실패]", + item.item_code, + err?.message, + ); + }); + }) + .filter(Boolean); - const inboundNo = res.data?.data?.header?.inbound_number || inboundNumber || ""; - setResultMsg(`${items.length}건 입고 등록 완료! (${inboundNo})`); - setTimeout(() => { - onClear(); - onClose(); - router.push("/pop/inbound"); - }, 1500); - } else { - setResultMsg(`오류: ${res.data?.message || "입고 등록에 실패했습니다."}`); - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "입고 등록에 실패했습니다."; - setResultMsg(`오류: ${msg}`); - } finally { - setConfirming(false); - } - }; + if (inspectionPromises.length > 0) { + await Promise.allSettled(inspectionPromises); + } - if (!open) return null; + // 3. cart_items DB 정리 (백그라운드, 논블로킹) + // cart_items.row_key 로 삭제 (row_key = source_id 로 저장됨) + const rowKeys = items + .map((item) => item.source_id || item.id) + .filter(Boolean); + if (rowKeys.length > 0) { + apiClient + .post("/pop/execute-action", { + tasks: [{ type: "cart-save" }], + cartChanges: { + toDelete: rowKeys, + }, + }) + .catch(() => { + // cart cleanup 실패 시 무시 + }); + } - return ( -
- {/* Overlay */} -
+ const inboundNo = + res.data?.data?.header?.inbound_number || inboundNumber || ""; + setResultMsg(`${items.length}건 입고 등록 완료! (${inboundNo})`); + setTimeout(() => { + onClear(); + onClose(); + router.push("/pop/inbound"); + }, 1500); + } else { + setResultMsg( + `오류: ${res.data?.message || "입고 등록에 실패했습니다."}`, + ); + } + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : "입고 등록에 실패했습니다."; + setResultMsg(`오류: ${msg}`); + } finally { + setConfirming(false); + } + }; - {/* Panel */} -
- {/* Header */} -
-
-
- - - -
-
-

입고 장바구니

- {supplierName && ( -

{supplierName}

- )} -
-
- -
+ if (!open) return null; - {/* Select all bar */} - {items.length > 0 && ( -
- - - 전체 선택 ({selectedItems.size}/{items.length}) - -
- )} + return ( +
+ {/* Overlay */} +
- {/* Items */} -
- {items.length === 0 ? ( -
- - - -

담은 품목이 없습니다

-
- ) : ( -
- {items.map((item) => ( -
- {/* Top row: checkbox + name + delete */} -
- {/* Checkbox */} - + {/* Panel */} +
+ {/* Header */} +
+
+
+ + + +
+
+

입고 장바구니

+ {supplierName && ( +

{supplierName}

+ )} +
+
+ +
-
-

{item.item_name}

-

- {item.item_code} | {item.purchase_no} -

-
+ {/* Select all bar */} + {items.length > 0 && ( +
+ + + 전체 선택 ({selectedItems.size}/{items.length}) + +
+ )} - {/* Delete button */} - -
+ {/* Items */} +
+ {items.length === 0 ? ( +
+ + + +

담은 품목이 없습니다

+
+ ) : ( +
+ {items.map((item) => ( +
+ {/* Top row: checkbox + name + delete */} +
+ {/* Checkbox */} + - {/* Spec row */} - {(item.spec || item.material) && ( -

- {[item.spec, item.material].filter(Boolean).join(" | ")} -

- )} +
+

+ {item.item_name} +

+

+ {item.item_code} | {item.purchase_no} +

+
- {/* Package info */} - {item.packages && item.packages.length > 0 && ( -
-
- - 포장완료 - - - {"\uD83D\uDCE6"} {item.packages.map(p => - `${p.count}${p.unit.label} x ${p.qtyPerUnit.toLocaleString()} = ${(p.count * p.qtyPerUnit).toLocaleString()}EA` - ).join(", ")} - -
-
- )} + {/* Delete button */} + +
- {/* Inspection row */} - {(item.inspection_type === "self" || item.inspection_type === "request") && ( -
- -
- )} + {/* Spec row */} + {(item.spec || item.material) && ( +

+ {[item.spec, item.material].filter(Boolean).join(" | ")} +

+ )} - {/* Qty controls */} -
-
- 미입고: {item.remain_qty.toLocaleString()} -
-
- - { - const v = parseInt(e.target.value, 10); - if (!isNaN(v) && v >= 0) onUpdateQty(item.id, Math.min(v, item.remain_qty)); - }} - className="w-16 h-8 text-center text-sm font-semibold border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100" - style={{ fontVariantNumeric: "tabular-nums" }} - /> - -
-
-
- ))} -
- )} -
+ {/* Package info */} + {item.packages && item.packages.length > 0 && ( +
+
+ + 포장완료 + + + {"\uD83D\uDCE6"}{" "} + {item.packages + .map( + (p) => + `${p.count}${p.unit.label} x ${p.qtyPerUnit.toLocaleString()} = ${(p.count * p.qtyPerUnit).toLocaleString()}EA`, + ) + .join(", ")} + +
+
+ )} - {/* Footer summary + confirm */} - {items.length > 0 && ( -
- {/* Result message */} - {resultMsg && ( -
- {resultMsg} -
- )} + {/* Inspection row */} + {(item.inspection_type === "self" || + item.inspection_type === "request") && ( +
+ +
+ )} - {/* Warehouse selection */} -
- - -
+ {/* Qty controls */} +
+
+ + 미입고: {item.remain_qty.toLocaleString()} + +
+
+ + { + const v = parseInt(e.target.value, 10); + if (!isNaN(v) && v >= 0) + onUpdateQty(item.id, Math.min(v, item.remain_qty)); + }} + className="w-16 h-8 text-center text-sm font-semibold border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100" + style={{ fontVariantNumeric: "tabular-nums" }} + /> + +
+
+
+ ))} +
+ )} +
- {/* Summary */} -
- - 총 {items.length}건 - -
- - 합계 수량: {totalQty.toLocaleString()} - - {totalAmount > 0 && ( - - ({totalAmount.toLocaleString()}원) - - )} -
-
+ {/* Footer summary + confirm */} + {items.length > 0 && ( +
+ {/* Result message */} + {resultMsg && ( +
+ {resultMsg} +
+ )} - {/* Buttons */} -
- - -
-
- )} -
+ {/* Warehouse selection */} +
+ + +
- {/* Inspection Modal */} - {inspectionTarget && ( - { setInspectionModalOpen(false); setInspectionTarget(null); }} - onComplete={handleInspectionComplete} - itemCode={inspectionTarget.item_code} - itemName={inspectionTarget.item_name} - totalQty={inspectionTarget.inbound_qty} - initialResult={inspectionTarget.inspectionResult} - /> - )} -
- ); + {/* Summary */} +
+ + 총{" "} + {items.length} + 건 + +
+ + 합계 수량:{" "} + + {totalQty.toLocaleString()} + + + {totalAmount > 0 && ( + + ({totalAmount.toLocaleString()}원) + + )} +
+
+ + {/* Buttons */} +
+ + +
+
+ )} +
+ + {/* Inspection Modal */} + {inspectionTarget && ( + { + setInspectionModalOpen(false); + setInspectionTarget(null); + }} + onComplete={handleInspectionComplete} + itemCode={inspectionTarget.item_code} + itemName={inspectionTarget.item_name} + totalQty={inspectionTarget.inbound_qty} + initialResult={inspectionTarget.inspectionResult} + /> + )} +
+ ); } diff --git a/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx b/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx index d6a90ec3..c0b3d5ba 100644 --- a/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx +++ b/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx @@ -1,80 +1,90 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useRouter } from "next/navigation"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { apiClient } from "@/lib/api/client"; +import { type CartItemWithId, useCartSync } from "../common/useCartSync"; import { InspectionModal, type InspectionResult } from "./InspectionModal"; import { NumberPadModal, type PackageEntry } from "./NumberPadModal"; -import { useCartSync, type CartItemWithId } from "../common/useCartSync"; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ interface Warehouse { - warehouse_code: string; - warehouse_name: string; - warehouse_type?: string; + warehouse_code: string; + warehouse_name: string; + warehouse_type?: string; } /** CartItemWithId -> 화면 표시용 파싱 결과 */ interface CartItemParsed { - id: string; - rowKey: string; - dbId: string; - source_table: string; - source_id: string; - purchase_no: string; - item_code: string; - item_name: string; - spec: string; - material: string; - order_qty: number; - remain_qty: number; - inbound_qty: number; - unit_price: number; - supplier_code: string; - supplier_name: string; - order_date?: string; - inspection_required?: boolean; - inspection_type?: "self" | "request" | null; - packages?: PackageEntry[]; - image?: string | null; + id: string; + rowKey: string; + dbId: string; + source_table: string; + source_id: string; + purchase_no: string; + item_code: string; + item_name: string; + spec: string; + material: string; + order_qty: number; + remain_qty: number; + inbound_qty: number; + unit_price: number; + supplier_code: string; + supplier_name: string; + order_date?: string; + inspection_required?: boolean; + inspection_type?: "self" | "request" | null; + packages?: PackageEntry[]; + image?: string | null; } /* ------------------------------------------------------------------ */ /* Helper: CartItemWithId -> CartItemParsed */ /* ------------------------------------------------------------------ */ function toCartItemParsed(item: CartItemWithId): CartItemParsed { - const data = item.row; - const inspType = data.inspection_type === "self" ? "self" - : data.inspection_type === "request" ? "request" - : null; + const data = item.row; + const inspType = + data.inspection_type === "self" + ? "self" + : data.inspection_type === "request" + ? "request" + : null; - return { - id: item.rowKey || String(data.id ?? ""), - rowKey: item.rowKey, - dbId: item.cartId || "", - source_table: item.sourceTable || String(data.source_table ?? "purchase_detail"), - source_id: item.rowKey || String(data.id ?? ""), - purchase_no: String(data.purchase_no ?? ""), - item_code: String(data.item_code ?? ""), - item_name: String(data.item_name ?? ""), - spec: String(data.spec ?? ""), - material: String(data.material ?? ""), - order_qty: Number(data.order_qty ?? 0), - remain_qty: Number(data.remain_qty ?? 0), - inbound_qty: item.quantity, - unit_price: Number(data.unit_price ?? 0), - supplier_code: String(data.supplier_code ?? ""), - supplier_name: String(data.supplier_name ?? ""), - order_date: data.order_date ? String(data.order_date) : undefined, - inspection_type: inspType, - inspection_required: inspType === "self", - // packageEntries의 실제 런타임 타입은 NumberPadModal의 PackageEntry[] - packages: item.packageEntries as unknown as PackageEntry[] | undefined, - image: data.image ? String(data.image) : null, - }; + return { + id: item.rowKey || String(data.id ?? ""), + rowKey: item.rowKey, + dbId: item.cartId || "", + source_table: + item.sourceTable || String(data.source_table ?? "purchase_detail"), + source_id: item.rowKey || String(data.id ?? ""), + purchase_no: String(data.purchase_no ?? ""), + item_code: String(data.item_code ?? ""), + item_name: String(data.item_name ?? ""), + spec: String(data.spec ?? ""), + material: String(data.material ?? ""), + order_qty: Number(data.order_qty ?? 0), + remain_qty: Number(data.remain_qty ?? 0), + inbound_qty: item.quantity, + unit_price: Number(data.unit_price ?? 0), + supplier_code: String(data.supplier_code ?? ""), + supplier_name: String(data.supplier_name ?? ""), + order_date: data.order_date ? String(data.order_date) : undefined, + inspection_type: inspType, + inspection_required: inspType === "self", + // packageEntries의 실제 런타임 타입은 NumberPadModal의 PackageEntry[] + packages: item.packageEntries as unknown as PackageEntry[] | undefined, + image: data.image ? String(data.image) : null, + }; } /* ------------------------------------------------------------------ */ @@ -82,1036 +92,1223 @@ function toCartItemParsed(item: CartItemWithId): CartItemParsed { /* ------------------------------------------------------------------ */ export function InboundCartPage() { - const router = useRouter(); - - /* Cart sync hook */ - const cart = useCartSync("pop-purchase-inbound", "purchase_detail"); - - /* Derived: parsed items from cart */ - const items = useMemo( - () => cart.cartItems.map(toCartItemParsed), - [cart.cartItems], - ); - - /* Inspection results (local overlay, keyed by rowKey) */ - const [inspectionResults, setInspectionResults] = useState< - Map - >(new Map()); - - /* Selection */ - const [selectedItems, setSelectedItems] = useState>(new Set()); - - /* Auto-select all when items change */ - useEffect(() => { - if (items.length > 0) { - setSelectedItems(new Set(items.map((i) => i.id))); - } - }, [items]); - - /* Warehouse */ - const [warehouses, setWarehouses] = useState([]); - const [selectedWarehouse, setSelectedWarehouse] = useState(""); - const [warehousePickerOpen, setWarehousePickerOpen] = useState(false); - - /* Inbound number */ - const [inboundNumber, setInboundNumber] = useState(""); - - /* Confirm result modal */ - const [confirmResult, setConfirmResult] = useState<{ - inboundNumber: string; - items: CartItemParsed[]; - warehouse: string; - date: string; - } | null>(null); - - /* Inbound date */ - const [inboundDate, setInboundDate] = useState( - new Date().toISOString().slice(0, 10) - ); - - /* Confirm state */ - const [confirming, setConfirming] = useState(false); - const [resultMsg, setResultMsg] = useState(null); - - /* Inspection modal */ - const [inspectionModalOpen, setInspectionModalOpen] = useState(false); - const [inspectionTarget, setInspectionTarget] = useState(null); - - /* Numpad modal (for qty edit) */ - const [numpadOpen, setNumpadOpen] = useState(false); - const [numpadTarget, setNumpadTarget] = useState(null); - - /* Derived: supplier name (all items should be same supplier) */ - const supplierName = items.length > 0 ? items[0].supplier_name : ""; - - /* ------------------------------------------------------------------ */ - /* Fetch warehouses */ - /* ------------------------------------------------------------------ */ - const fetchedRef = useRef(false); - - const fetchWarehouses = useCallback(async () => { - try { - const res = await apiClient.get("/receiving/warehouses"); - const data: Warehouse[] = res.data?.data ?? []; - setWarehouses(data); - if (data.length > 0) { - setSelectedWarehouse(data[0].warehouse_code); - } - } catch { - /* keep empty */ - } - }, []); - - useEffect(() => { - if (fetchedRef.current) return; - fetchedRef.current = true; - fetchWarehouses(); - }, [fetchWarehouses]); - - /* ------------------------------------------------------------------ */ - /* Selection */ - /* ------------------------------------------------------------------ */ - const toggleSelect = (id: string) => { - setSelectedItems((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const toggleSelectAll = () => { - if (selectedItems.size === items.length) { - setSelectedItems(new Set()); - } else { - setSelectedItems(new Set(items.map((i) => i.id))); - } - }; - - /* ------------------------------------------------------------------ */ - /* Qty edit via numpad */ - /* ------------------------------------------------------------------ */ - const openNumpad = (item: CartItemParsed) => { - setNumpadTarget(item); - setNumpadOpen(true); - }; - - const handleNumpadConfirm = (qty: number, packages: PackageEntry[]) => { - if (!numpadTarget) return; - const finalQty = Math.min(qty, numpadTarget.remain_qty); - - cart.updateItemQuantity( - numpadTarget.rowKey, - finalQty, - undefined, - // PackageEntry 타입이 registry vs NumberPadModal에서 다르므로 any 캐스팅 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - packages.length > 0 ? packages as any : undefined, - ); - setNumpadTarget(null); - // Auto-save effect below will persist change to DB - }; - - /* ------------------------------------------------------------------ */ - /* Remove item */ - /* ------------------------------------------------------------------ */ - const handleRemove = (rowKey: string) => { - cart.removeItem(rowKey); - setSelectedItems((prev) => { - const next = new Set(prev); - next.delete(rowKey); - return next; - }); - // Auto-save effect below will persist change to DB - }; - - /* Auto-save: persist dirty changes to DB after a short debounce */ - const autoSaveTimerRef = useRef | null>(null); - useEffect(() => { - if (!cart.isDirty || cart.syncStatus === "saving") return; - if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); - autoSaveTimerRef.current = setTimeout(() => { - cart.saveToDb().catch(() => {}); - }, 500); - return () => { - if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); - }; - }, [cart.isDirty, cart.syncStatus, cart]); - - /* ------------------------------------------------------------------ */ - /* Inspection */ - /* ------------------------------------------------------------------ */ - const openInspection = (item: CartItemParsed) => { - setInspectionTarget(item); - setInspectionModalOpen(true); - }; - - const handleInspectionComplete = (result: InspectionResult) => { - if (!inspectionTarget) return; - setInspectionResults((prev) => { - const next = new Map(prev); - next.set(inspectionTarget.rowKey, result); - return next; - }); - setInspectionTarget(null); - }; - - /* Pass inspection (non-required only) */ - const handlePassInspection = (rowKey: string) => { - const item = items.find((i) => i.rowKey === rowKey); - if (!item) return; - setInspectionResults((prev) => { - const next = new Map(prev); - next.set(rowKey, { - items: [], - goodQty: item.inbound_qty, - badQty: 0, - remark: "pass", - completed: true, - }); - return next; - }); - }; - - const getInspectionResult = (rowKey: string): InspectionResult | null => { - return inspectionResults.get(rowKey) || null; - }; - - /* ------------------------------------------------------------------ */ - /* Validation: required inspections */ - /* ------------------------------------------------------------------ */ - const selectedItemsList = items.filter((i) => selectedItems.has(i.id)); - - const hasUnfinishedRequiredInspection = selectedItemsList.some( - (item) => - item.inspection_required && - item.inspection_type === "self" && - !getInspectionResult(item.rowKey)?.completed - ); - - /* ------------------------------------------------------------------ */ - /* Confirm inbound */ - /* ------------------------------------------------------------------ */ - const handleConfirm = async () => { - if (selectedItemsList.length === 0) return; - - if (!selectedWarehouse) { - setResultMsg("오류: 입고 창고를 선택해주세요."); - return; - } - - if (hasUnfinishedRequiredInspection) { - setResultMsg("오류: 필수 검사를 완료해주세요."); - return; - } - - setConfirming(true); - setResultMsg(null); - - try { - // 확정 시점에 채번 (동시접속 충돌 방지) - // POP 화면설정에서 선택한 채번규칙 사용 (없으면 기본) - let finalNumber = ""; - try { - const settingsRes: any = await apiClient.get("/screen-management/screens/6527/layout-pop").catch(() => null); - const ruleId = settingsRes?.data?.data?.settings?.popConfig?.inbound?.numberingRuleId; - const url = ruleId && ruleId !== "__none__" - ? `/receiving/generate-number?ruleId=${encodeURIComponent(ruleId)}` - : "/receiving/generate-number"; - const numRes = await apiClient.get(url); - if (numRes.data?.success && numRes.data?.data) { - finalNumber = numRes.data.data; - setInboundNumber(finalNumber); - } - } catch { - /* backend will handle */ - } - - // POST /api/receiving -- same payload structure as PC - const payload = { - inbound_number: finalNumber, - inbound_date: inboundDate, - warehouse_code: selectedWarehouse, - inbound_type: "구매입고", - items: selectedItemsList.map((item, idx) => { - const inspResult = getInspectionResult(item.rowKey); - return { - inbound_type: "구매입고", - item_number: item.item_code, - item_name: item.item_name, - spec: item.spec || "", - material: item.material || "", - unit: "EA", - inbound_qty: String(item.inbound_qty), - unit_price: String(item.unit_price || 0), - total_amount: String( - (item.inbound_qty || 0) * (item.unit_price || 0) - ), - reference_number: item.purchase_no, - supplier_code: item.supplier_code, - supplier_name: item.supplier_name, - inbound_status: "입고완료", - inspection_status: inspResult?.completed - ? "검사완료" - : item.inspection_required - ? "검사대기" - : "합격", - source_table: item.source_table, - source_id: item.source_id || item.id, - seq_no: idx + 1, - }; - }), - }; - - const res = await apiClient.post("/receiving", payload); - - if (res.data?.success) { - // Remove confirmed items from cart - direct DB delete for reliability - const confirmedItems = [...selectedItemsList]; - const { dataApi } = await import("@/lib/api/data"); - const confirmPromises = confirmedItems - .filter((item) => item.dbId) - .map((item) => dataApi.updateRecord("cart_items", item.dbId, { status: "confirmed" }).catch(() => {})); - await Promise.all(confirmPromises); - - // Also clean up local state via useCartSync - for (const item of confirmedItems) { - cart.removeItem(item.rowKey); - } - // Reload from DB to sync state - await cart.loadFromDb(); - - const inboundNo = - res.data?.data?.header?.inbound_number || finalNumber || ""; - - // 결과 모달 표시 (바로 이동하지 않음) - setConfirmResult({ - inboundNumber: inboundNo, - items: confirmedItems, - warehouse: warehouses.find(w => w.warehouse_code === selectedWarehouse)?.warehouse_name || selectedWarehouse, - date: inboundDate, - }); - setResultMsg(null); - } else { - setResultMsg( - `오류: ${res.data?.message || "입고 등록에 실패했습니다."}` - ); - } - } catch (err: unknown) { - const msg = - err instanceof Error ? err.message : "입고 등록에 실패했습니다."; - setResultMsg(`오류: ${msg}`); - } finally { - setConfirming(false); - } - }; - - /* ------------------------------------------------------------------ */ - /* Helpers */ - /* ------------------------------------------------------------------ */ - const selectedWarehouseName = - warehouses.find((w) => w.warehouse_code === selectedWarehouse) - ?.warehouse_name || selectedWarehouse; - - const totalQty = selectedItemsList.reduce((s, i) => s + i.inbound_qty, 0); - - /* ------------------------------------------------------------------ */ - /* Render */ - /* ------------------------------------------------------------------ */ - return ( -
- {/* ===== Header ===== */} -
-
- -
-

- 입고 장바구니 -

- {supplierName && ( -

{supplierName}

- )} -
-
- - {/* Confirm button (header only) */} - -
- - {/* ===== Info banner ===== */} -
-
- {supplierName && ( - - {supplierName} - - )} - - {inboundDate} - - {selectedWarehouseName && ( - - | {selectedWarehouseName} - - )} - - {inboundNumber || "확정 시 자동생성"} - -
- - {/* Info fields: 3 columns */} -
- {/* Inbound date */} -
- - setInboundDate(e.target.value)} - className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100 bg-white" - /> -
- - {/* Warehouse selector - card-style touch button */} -
- - -
- - {/* Inbound number (readonly -- 확정 시점에 채번) */} -
- -
- {inboundNumber ? ( - {inboundNumber} - ) : ( - 확정 시 자동생성 - )} -
-
-
-
- - {/* ===== Select all bar ===== */} - {items.length > 0 && ( -
-
- - - 담은 품목{" "} - {items.length} - -
- - -
- )} - - {/* ===== Items list ===== */} - {cart.loading ? ( -
- - - - - 불러오는 중... -
- ) : items.length === 0 ? ( -
- - - -

- 담은 품목이 없습니다 -

-

- 구매입고 화면에서 품목을 담아주세요 -

- -
- ) : ( -
- {items.map((item) => { - const inspResult = getInspectionResult(item.rowKey); - return ( -
- {/* Blue left bar for selected items */} - {selectedItems.has(item.id) && ( -
- )} - - {/* === Header row: checkbox + item code + item name + inspection badge === */} -
- {/* Checkbox */} - - {item.item_code} - {item.item_name} - {item.inspection_type === "self" && ( - - 검사 필수 - - )} - {item.inspection_type === "request" && ( - - 검사의뢰 선택 - - )} -
- - {/* === Body row: image + info + action === */} -
- {/* Product image */} -
- {item.image ? ( - {item.item_name} - ) : ( - {"\uD83D\uDCE6"} - )} -
- - {/* Info columns */} -
-
- 발주일 - {item.order_date || "-"} -
-
- 발주번호 - {item.purchase_no || "-"} -
-
- 발주수량 - {item.order_qty.toLocaleString()} -
-
- 미입고 - {item.remain_qty.toLocaleString()} -
-
- - {/* Action column: qty display + delete button */} -
- {/* Qty display - clickable to open numpad */} - - - {/* Delete button */} - -
-
- - {/* === Package info === */} - {item.packages && item.packages.length > 0 && ( -
-
- - 포장완료 - - - {item.packages.reduce((s, p) => s + p.count * p.qtyPerUnit, 0).toLocaleString()} EA - -
- {item.packages.map((pkg, idx) => ( -
- {pkg.unit.icon} - {pkg.count}{pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA = {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA -
- ))} -
- )} - - {/* === Inspection row === */} - {(item.inspection_type === "self" || - item.inspection_type === "request") && ( -
-
- - - {/* Pass button for non-required */} - {!item.inspection_required && - !inspResult?.completed && ( - - )} -
-
- )} -
- ); - })} -
- )} - - {/* ===== Footer summary (no confirm button -- header only) ===== */} - {items.length > 0 && ( -
- {/* Result message */} - {resultMsg && ( -
- {resultMsg} -
- )} - - {/* Required inspection warning */} - {hasUnfinishedRequiredInspection && ( -
- 필수 검사를 완료해주세요. 검사 미완료 품목이 있어 확정할 수 없습니다. -
- )} - - {/* Summary only (no big confirm button) */} -
- - 선택{" "} - - {selectedItemsList.length} - - /{items.length}건 - - - 합계 수량:{" "} - - {totalQty.toLocaleString()} - {" "} - EA - -
-
- )} - - {/* ===== Warehouse picker modal ===== */} - {warehousePickerOpen && ( -
-
setWarehousePickerOpen(false)} - /> -
- {/* Header */} -
-

창고 선택

- -
- - {/* Warehouse list */} -
- {warehouses.length === 0 ? ( -

- 등록된 창고가 없습니다 -

- ) : ( -
- {warehouses.map((wh) => ( - - ))} -
- )} -
-
-
- )} - - {/* ===== Inspection Modal ===== */} - {inspectionTarget && ( - { - setInspectionModalOpen(false); - setInspectionTarget(null); - }} - onComplete={handleInspectionComplete} - itemCode={inspectionTarget.item_code} - itemName={inspectionTarget.item_name} - totalQty={inspectionTarget.inbound_qty} - initialResult={getInspectionResult(inspectionTarget.rowKey)} - /> - )} - - {/* ===== NumberPad Modal (qty edit) ===== */} - {numpadTarget && ( - { - setNumpadOpen(false); - setNumpadTarget(null); - }} - onConfirm={handleNumpadConfirm} - maxQty={numpadTarget.remain_qty} - itemName={numpadTarget.item_name} - initialQty={numpadTarget.inbound_qty} - initialPackages={numpadTarget.packages} - /> - )} - - {/* ===== 입고 완료 결과 모달 ===== */} - {confirmResult && ( -
-
-
- {/* 헤더 */} -
-
- - - -
-

입고 처리 완료

-

{confirmResult.inboundNumber}

-
- - {/* 처리 내역 */} -
-
- 창고: {confirmResult.warehouse} - {confirmResult.date} -
- -
처리된 품목 ({confirmResult.items.length}건)
-
- {confirmResult.items.map((item) => ( -
-
-

{item.item_name}

-

{item.item_code}

-
- {item.inbound_qty?.toLocaleString()} EA -
- ))} -
-
- - {/* 확인 버튼 */} -
- -
-
-
- )} -
- ); + const router = useRouter(); + + /* Cart sync hook */ + const cart = useCartSync("pop-purchase-inbound", "purchase_detail"); + + /* Derived: parsed items from cart */ + const items = useMemo( + () => cart.cartItems.map(toCartItemParsed), + [cart.cartItems], + ); + + /* Inspection results (local overlay, keyed by rowKey) */ + const [inspectionResults, setInspectionResults] = useState< + Map + >(new Map()); + + /* Selection */ + const [selectedItems, setSelectedItems] = useState>(new Set()); + + /* Auto-select all when items change */ + useEffect(() => { + if (items.length > 0) { + setSelectedItems(new Set(items.map((i) => i.id))); + } + }, [items]); + + /* Sync inspectionResults with cart.row.inspectionResult + * 페이지 새로고침/재진입 시 cart_items에 저장된 inspectionResult를 Map으로 복원. + * 주의: delete는 명시적 검사 취소(handleCancel)에서만 처리. + * (cart.saveToDb 후 row JSON이 stale할 수 있어 delete 로직은 race condition 유발) */ + useEffect(() => { + setInspectionResults((prev) => { + const next = new Map(prev); + let changed = false; + cart.cartItems.forEach((c) => { + const stored = (c.row as Record)?.inspectionResult; + if (stored && typeof stored === "object") { + // 유효한 검사 결과 → Map에 추가 (덮어쓰지 않음, 로컬 우선) + if (!next.has(c.rowKey)) { + next.set(c.rowKey, stored as InspectionResult); + changed = true; + } + } + // null/undefined여도 Map에서 자동 제거하지 않음 — 명시적 cancel만 처리 + }); + // 카트에서 사라진 rowKey만 정리 (실제 카트 삭제 시) + const cartKeys = new Set(cart.cartItems.map((c) => c.rowKey)); + Array.from(next.keys()).forEach((k) => { + if (!cartKeys.has(k)) { + next.delete(k); + changed = true; + } + }); + return changed ? next : prev; + }); + }, [cart.cartItems]); + + /* Warehouse */ + const [warehouses, setWarehouses] = useState([]); + const [selectedWarehouse, setSelectedWarehouse] = useState(""); + const [warehousePickerOpen, setWarehousePickerOpen] = useState(false); + + /* Inbound number */ + const [inboundNumber, setInboundNumber] = useState(""); + + /* Confirm result modal */ + const [confirmResult, setConfirmResult] = useState<{ + inboundNumber: string; + items: CartItemParsed[]; + warehouse: string; + date: string; + } | null>(null); + + /* Inbound date */ + const [inboundDate, setInboundDate] = useState( + new Date().toISOString().slice(0, 10), + ); + + /* Confirm state */ + const [confirming, setConfirming] = useState(false); + const [resultMsg, setResultMsg] = useState(null); + + /* Inspection modal */ + const [inspectionModalOpen, setInspectionModalOpen] = useState(false); + const [inspectionTarget, setInspectionTarget] = + useState(null); + + /* Numpad modal (for qty edit) */ + const [numpadOpen, setNumpadOpen] = useState(false); + const [numpadTarget, setNumpadTarget] = useState(null); + + /* Derived: supplier name (all items should be same supplier) */ + const supplierName = items.length > 0 ? items[0].supplier_name : ""; + + /* ------------------------------------------------------------------ */ + /* Fetch warehouses */ + /* ------------------------------------------------------------------ */ + const fetchedRef = useRef(false); + + const fetchWarehouses = useCallback(async () => { + try { + const res = await apiClient.get("/receiving/warehouses"); + const data: Warehouse[] = res.data?.data ?? []; + setWarehouses(data); + if (data.length > 0) { + setSelectedWarehouse(data[0].warehouse_code); + } + } catch { + /* keep empty */ + } + }, []); + + useEffect(() => { + if (fetchedRef.current) return; + fetchedRef.current = true; + fetchWarehouses(); + }, [fetchWarehouses]); + + /* ------------------------------------------------------------------ */ + /* Selection */ + /* ------------------------------------------------------------------ */ + const toggleSelect = (id: string) => { + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selectedItems.size === items.length) { + setSelectedItems(new Set()); + } else { + setSelectedItems(new Set(items.map((i) => i.id))); + } + }; + + /* ------------------------------------------------------------------ */ + /* Qty edit via numpad */ + /* ------------------------------------------------------------------ */ + const openNumpad = (item: CartItemParsed) => { + setNumpadTarget(item); + setNumpadOpen(true); + }; + + const handleNumpadConfirm = (qty: number, packages: PackageEntry[]) => { + if (!numpadTarget) return; + const finalQty = Math.min(qty, numpadTarget.remain_qty); + + cart.updateItemQuantity( + numpadTarget.rowKey, + finalQty, + undefined, + // PackageEntry 타입이 registry vs NumberPadModal에서 다르므로 any 캐스팅 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + packages.length > 0 ? (packages as any) : undefined, + ); + setNumpadTarget(null); + // Auto-save effect below will persist change to DB + }; + + /* ------------------------------------------------------------------ */ + /* Remove item */ + /* ------------------------------------------------------------------ */ + const handleRemove = (rowKey: string) => { + cart.removeItem(rowKey); + setSelectedItems((prev) => { + const next = new Set(prev); + next.delete(rowKey); + return next; + }); + // Auto-save effect below will persist change to DB + }; + + /* Auto-save: persist dirty changes to DB after a short debounce */ + const autoSaveTimerRef = useRef | null>(null); + useEffect(() => { + if (!cart.isDirty || cart.syncStatus === "saving") return; + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = setTimeout(() => { + cart.saveToDb().catch(() => {}); + }, 500); + return () => { + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + }; + }, [cart.isDirty, cart.syncStatus, cart]); + + /* ------------------------------------------------------------------ */ + /* Inspection */ + /* ------------------------------------------------------------------ */ + const openInspection = (item: CartItemParsed) => { + setInspectionTarget(item); + setInspectionModalOpen(true); + }; + + const handleInspectionComplete = (result: InspectionResult) => { + if (!inspectionTarget) return; + const targetRowKey = inspectionTarget.rowKey; + setInspectionResults((prev) => { + const next = new Map(prev); + next.set(targetRowKey, result); + return next; + }); + // cart_items.row_data에 검사 결과 저장 (페이지 새로고침해도 유지) + cart.updateItemRow(targetRowKey, { inspectionResult: result }); + setInspectionTarget(null); + // 즉시 DB 저장 (자동저장 디바운스를 기다리지 않음) + setTimeout(() => { + cart + .saveToDb() + .catch((err) => console.error("[검사 결과 저장 실패]", err)); + }, 100); + }; + + /* Pass inspection (non-required only) */ + const handlePassInspection = (rowKey: string) => { + const item = items.find((i) => i.rowKey === rowKey); + if (!item) return; + const result: InspectionResult = { + items: [], + goodQty: item.inbound_qty, + badQty: 0, + remark: "pass", + completed: true, + }; + setInspectionResults((prev) => { + const next = new Map(prev); + next.set(rowKey, result); + return next; + }); + cart.updateItemRow(rowKey, { inspectionResult: result }); + }; + + const getInspectionResult = (rowKey: string): InspectionResult | null => { + return inspectionResults.get(rowKey) || null; + }; + + /* ------------------------------------------------------------------ */ + /* Validation: required inspections */ + /* ------------------------------------------------------------------ */ + const selectedItemsList = items.filter((i) => selectedItems.has(i.id)); + + // CEO 정책 (2026-04-09 시연 결정): 검사 필수 항목 미완료 시 확정 차단 + // 검사 빠진 입고가 검사관리에서 추적 안 되므로, 입력 시점에 막음 + const hasUnfinishedRequiredInspection = selectedItemsList.some( + (item) => + item.inspection_required && + item.inspection_type === "self" && + !getInspectionResult(item.rowKey)?.completed, + ); + + /* ------------------------------------------------------------------ */ + /* Confirm inbound */ + /* ------------------------------------------------------------------ */ + const handleConfirm = async () => { + if (selectedItemsList.length === 0) return; + + if (!selectedWarehouse) { + setResultMsg("오류: 입고 창고를 선택해주세요."); + return; + } + + // 검사 미완료여도 확정 가능. 단지 inspection_result에 안 들어가거나 "대기" 상태로 기록. + // (CEO 정책: 입고 자체는 진행, 검사 결과만 누락/대기 상태로 표시) + + setConfirming(true); + setResultMsg(null); + + try { + // 확정 시점에 채번 (동시접속 충돌 방지) + // POP 화면설정에서 선택한 채번규칙 사용 (없으면 기본) + let finalNumber = ""; + try { + const settingsRes: any = await apiClient + .get("/screen-management/screens/6527/layout-pop") + .catch(() => null); + const ruleId = + settingsRes?.data?.data?.settings?.popConfig?.inbound + ?.numberingRuleId; + const url = + ruleId && ruleId !== "__none__" + ? `/receiving/generate-number?ruleId=${encodeURIComponent(ruleId)}` + : "/receiving/generate-number"; + const numRes = await apiClient.get(url); + if (numRes.data?.success && numRes.data?.data) { + finalNumber = numRes.data.data; + setInboundNumber(finalNumber); + } + } catch { + /* backend will handle */ + } + + // POST /api/receiving -- same payload structure as PC + const payload = { + inbound_number: finalNumber, + inbound_date: inboundDate, + warehouse_code: selectedWarehouse, + inbound_type: "구매입고", + items: selectedItemsList.map((item, idx) => { + const inspResult = getInspectionResult(item.rowKey); + return { + inbound_type: "구매입고", + item_number: item.item_code, + item_name: item.item_name, + spec: item.spec || "", + material: item.material || "", + unit: "EA", + inbound_qty: String(item.inbound_qty), + unit_price: String(item.unit_price || 0), + total_amount: String( + (item.inbound_qty || 0) * (item.unit_price || 0), + ), + reference_number: item.purchase_no, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + inbound_status: "입고완료", + inspection_status: inspResult?.completed + ? "검사완료" + : item.inspection_required + ? "검사대기" + : "합격", + source_table: item.source_table, + source_id: item.source_id || item.id, + seq_no: idx + 1, + }; + }), + }; + + const res = await apiClient.post("/receiving", payload); + + if (res.data?.success) { + // 검사 결과를 inspection_result_mng + inspection_result에 저장 + const insertedDetails: Array> = + (res.data?.data?.details as Array>) ?? + (res.data?.data?.items as Array>) ?? + []; + const inboundHeaderNo: string = + (res.data?.data?.header as { inbound_number?: string } | undefined) + ?.inbound_number || + finalNumber || + ""; + const inspectionPromises = selectedItemsList + .map((item, idx) => { + const inspResult = getInspectionResult(item.rowKey); + if (!inspResult?.completed) return null; + const matchedDetail = insertedDetails[idx] ?? {}; + const referenceId = + (matchedDetail.id as string) || + (matchedDetail.detail_id as string) || + `${inboundHeaderNo}-${idx + 1}`; + const goodQty = inspResult.goodQty || 0; + const badQty = inspResult.badQty || 0; + const totalQty = goodQty + badQty; + const overallJudgment = badQty === 0 ? "합격" : "불합격"; + return apiClient + .post("/pop/inspection-result", { + inspectionNumber: inspResult.inspectionNumber, + referenceTable: "inbound_mng", + referenceId, + screenId: "pop_inbound_inspection", + itemId: item.item_id || null, + itemCode: item.item_code, + itemName: item.item_name, + inspectionType: "입고검사", + overallJudgment, + totalQty, + goodQty, + badQty, + defectDescription: badQty > 0 ? `불량 ${badQty}건` : "", + memo: inspResult.remark || "", + supplierCode: item.supplier_code || null, + supplierName: item.supplier_name || null, + isCompleted: true, + items: inspResult.items.map((insp) => ({ + inspectionInfoId: insp.id || null, + inspectionItemName: insp.inspection_item_name, + inspectionStandard: insp.inspection_standard, + passCriteria: insp.pass_criteria, + isRequired: insp.is_required || "Y", + measuredValue: insp.measured_value || "", + judgment: insp.result || null, + })), + }) + .catch((err: unknown) => { + const e = err as { message?: string }; + console.error( + "[inspection_result 저장 실패]", + item.item_code, + e?.message, + ); + }); + }) + .filter(Boolean); + if (inspectionPromises.length > 0) { + await Promise.all(inspectionPromises); + } + + // Remove confirmed items from cart - direct DB delete for reliability + const confirmedItems = [...selectedItemsList]; + const { dataApi } = await import("@/lib/api/data"); + const confirmPromises = confirmedItems + .filter((item) => item.dbId) + .map((item) => + dataApi + .updateRecord("cart_items", item.dbId, { status: "confirmed" }) + .catch(() => {}), + ); + await Promise.all(confirmPromises); + + // Also clean up local state via useCartSync + for (const item of confirmedItems) { + cart.removeItem(item.rowKey); + } + // Reload from DB to sync state + await cart.loadFromDb(); + + const inboundNo = + res.data?.data?.header?.inbound_number || finalNumber || ""; + + // 결과 모달 표시 (바로 이동하지 않음) + setConfirmResult({ + inboundNumber: inboundNo, + items: confirmedItems, + warehouse: + warehouses.find((w) => w.warehouse_code === selectedWarehouse) + ?.warehouse_name || selectedWarehouse, + date: inboundDate, + }); + setResultMsg(null); + } else { + setResultMsg( + `오류: ${res.data?.message || "입고 등록에 실패했습니다."}`, + ); + } + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : "입고 등록에 실패했습니다."; + setResultMsg(`오류: ${msg}`); + } finally { + setConfirming(false); + } + }; + + /* ------------------------------------------------------------------ */ + /* Helpers */ + /* ------------------------------------------------------------------ */ + const selectedWarehouseName = + warehouses.find((w) => w.warehouse_code === selectedWarehouse) + ?.warehouse_name || selectedWarehouse; + + const totalQty = selectedItemsList.reduce((s, i) => s + i.inbound_qty, 0); + + /* ------------------------------------------------------------------ */ + /* Render */ + /* ------------------------------------------------------------------ */ + return ( +
+ {/* ===== Header ===== */} +
+
+ +
+

+ 입고 장바구니 +

+ {supplierName && ( +

{supplierName}

+ )} +
+
+ + {/* Confirm button (header only) */} + +
+ + {/* ===== Info banner ===== */} +
+
+ {supplierName && ( + + {supplierName} + + )} + {inboundDate} + {selectedWarehouseName && ( + + | {selectedWarehouseName} + + )} + + {inboundNumber || "확정 시 자동생성"} + +
+ + {/* Info fields: 3 columns */} +
+ {/* Inbound date */} +
+ + setInboundDate(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100 bg-white" + /> +
+ + {/* Warehouse selector - card-style touch button */} +
+ + +
+ + {/* Inbound number (readonly -- 확정 시점에 채번) */} +
+ +
+ {inboundNumber ? ( + {inboundNumber} + ) : ( + 확정 시 자동생성 + )} +
+
+
+
+ + {/* ===== Select all bar ===== */} + {items.length > 0 && ( +
+
+ + + 담은 품목 {items.length} + +
+ + +
+ )} + + {/* ===== Items list ===== */} + {cart.loading ? ( +
+ + + + + 불러오는 중... +
+ ) : items.length === 0 ? ( +
+ + + +

+ 담은 품목이 없습니다 +

+

+ 구매입고 화면에서 품목을 담아주세요 +

+ +
+ ) : ( +
+ {items.map((item) => { + const inspResult = getInspectionResult(item.rowKey); + return ( +
+ {/* Blue left bar for selected items */} + {selectedItems.has(item.id) && ( +
+ )} + + {/* === Header row: checkbox + item code + item name + inspection badge === */} +
+ {/* Checkbox */} + + + {item.item_code} + + + {item.item_name} + + {item.inspection_type === "self" && ( + + 검사 필수 + + )} + {item.inspection_type === "request" && ( + + 검사의뢰 선택 + + )} +
+ + {/* === Body row: image + info + action === */} +
+ {/* Product image */} +
+ {item.image ? ( + {item.item_name} + ) : ( + + {"\uD83D\uDCE6"} + + )} +
+ + {/* Info columns */} +
+
+ + 발주일 + + + {item.order_date || "-"} + +
+
+ + 발주번호 + + + {item.purchase_no || "-"} + +
+
+ + 발주수량 + + + {item.order_qty.toLocaleString()} + +
+
+ + 미입고 + + + {item.remain_qty.toLocaleString()} + +
+
+ + {/* Action column: qty display + delete button */} +
+ {/* Qty display - clickable to open numpad */} + + + {/* Delete button */} + +
+
+ + {/* === Package info === */} + {item.packages && item.packages.length > 0 && ( +
+
+ + 포장완료 + + + {item.packages + .reduce((s, p) => s + p.count * p.qtyPerUnit, 0) + .toLocaleString()}{" "} + EA + +
+ {item.packages.map((pkg, idx) => ( +
+ {pkg.unit.icon} + + {pkg.count} + {pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA + = {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA + +
+ ))} +
+ )} + + {/* === Inspection row === */} + {(item.inspection_type === "self" || + item.inspection_type === "request") && ( +
+
+ + + {/* Pass button for non-required */} + {!item.inspection_required && !inspResult?.completed && ( + + )} +
+
+ )} +
+ ); + })} +
+ )} + + {/* ===== Result toast (only when message exists) ===== */} + {resultMsg && ( +
+
+ {resultMsg} +
+
+ )} + + {/* ===== Warehouse picker modal ===== */} + {warehousePickerOpen && ( +
+
setWarehousePickerOpen(false)} + /> +
+ {/* Header */} +
+

창고 선택

+ +
+ + {/* Warehouse list */} +
+ {warehouses.length === 0 ? ( +

+ 등록된 창고가 없습니다 +

+ ) : ( +
+ {warehouses.map((wh) => ( + + ))} +
+ )} +
+
+
+ )} + + {/* ===== Inspection Modal ===== */} + {inspectionTarget && ( + { + setInspectionModalOpen(false); + setInspectionTarget(null); + }} + onComplete={handleInspectionComplete} + onCancel={() => { + // 검사 결과 무효화 (완료 → 대기 풀림) + const targetRowKey = inspectionTarget.rowKey; + setInspectionResults((prev) => { + const next = new Map(prev); + next.delete(targetRowKey); + return next; + }); + cart.updateItemRow(targetRowKey, { inspectionResult: null }); + setTimeout(() => cart.saveToDb().catch(() => {}), 100); + }} + itemCode={inspectionTarget.item_code} + itemName={inspectionTarget.item_name} + totalQty={inspectionTarget.inbound_qty} + initialResult={getInspectionResult(inspectionTarget.rowKey)} + /> + )} + + {/* ===== NumberPad Modal (qty edit) ===== */} + {numpadTarget && ( + { + setNumpadOpen(false); + setNumpadTarget(null); + }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget.remain_qty} + itemName={numpadTarget.item_name} + initialQty={numpadTarget.inbound_qty} + initialPackages={numpadTarget.packages} + /> + )} + + {/* ===== 입고 완료 결과 모달 ===== */} + {confirmResult && ( +
+
+
+ {/* 헤더 */} +
+
+ + + +
+

입고 처리 완료

+

+ {confirmResult.inboundNumber} +

+
+ + {/* 처리 내역 */} +
+
+ + 창고:{" "} + + {confirmResult.warehouse} + + + {confirmResult.date} +
+ +
+ 처리된 품목 ({confirmResult.items.length}건) +
+
+ {confirmResult.items.map((item) => ( +
+
+

+ {item.item_name} +

+

+ {item.item_code} +

+
+ + {item.inbound_qty?.toLocaleString()} EA + +
+ ))} +
+
+ + {/* 확인 버튼 */} +
+ +
+
+
+ )} +
+ ); } diff --git a/frontend/components/pop/hardcoded/inbound/InspectionModal.tsx b/frontend/components/pop/hardcoded/inbound/InspectionModal.tsx index bd4ba346..1bf9079f 100644 --- a/frontend/components/pop/hardcoded/inbound/InspectionModal.tsx +++ b/frontend/components/pop/hardcoded/inbound/InspectionModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { apiClient } from "@/lib/api/client"; /* ------------------------------------------------------------------ */ @@ -8,34 +8,36 @@ import { apiClient } from "@/lib/api/client"; /* ------------------------------------------------------------------ */ export interface InspectionItem { - id: string; - inspection_item_name: string; - inspection_standard: string; - inspection_method: string; - pass_criteria: string; - is_required: string; - /** User-entered measured value */ - measured_value: string; - /** "pass" | "fail" | null */ - result: "pass" | "fail" | null; + id: string; + inspection_item_name: string; + inspection_standard: string; + inspection_method: string; + pass_criteria: string; + is_required: string; + /** User-entered measured value */ + measured_value: string; + /** "pass" | "fail" | null */ + result: "pass" | "fail" | null; } export interface InspectionResult { - items: InspectionItem[]; - goodQty: number; - badQty: number; - remark: string; - completed: boolean; + items: InspectionItem[]; + goodQty: number; + badQty: number; + remark: string; + completed: boolean; + inspectionNumber?: string; // 검사 완료 시 채번 받음 (재사용) } interface InspectionModalProps { - open: boolean; - onClose: () => void; - onComplete: (result: InspectionResult) => void; - itemCode: string; - itemName: string; - totalQty: number; - initialResult?: InspectionResult | null; + open: boolean; + onClose: () => void; + onComplete: (result: InspectionResult) => void; + onCancel?: () => void; // 취소 = 검사 무효화 (완료 → 대기) + itemCode: string; + itemName: string; + totalQty: number; + initialResult?: InspectionResult | null; } /* ------------------------------------------------------------------ */ @@ -43,46 +45,46 @@ interface InspectionModalProps { /* ------------------------------------------------------------------ */ const DUMMY_INSPECTION_ITEMS: InspectionItem[] = [ - { - id: "dummy-1", - inspection_item_name: "외관 검사", - inspection_standard: "스크래치, 변색, 찍힘 없음", - inspection_method: "육안 검사", - pass_criteria: "이상 없음", - is_required: "Y", - measured_value: "", - result: null, - }, - { - id: "dummy-2", - inspection_item_name: "치수 검사", - inspection_standard: "규격 +-0.5mm", - inspection_method: "캘리퍼스 측정", - pass_criteria: "허용 오차 이내", - is_required: "Y", - measured_value: "", - result: null, - }, - { - id: "dummy-3", - inspection_item_name: "수량 검사", - inspection_standard: "발주 수량과 일치", - inspection_method: "전수 검사", - pass_criteria: "수량 일치", - is_required: "Y", - measured_value: "", - result: null, - }, - { - id: "dummy-4", - inspection_item_name: "포장 상태", - inspection_standard: "포장 손상 없음", - inspection_method: "육안 검사", - pass_criteria: "이상 없음", - is_required: "", - measured_value: "", - result: null, - }, + { + id: "dummy-1", + inspection_item_name: "외관 검사", + inspection_standard: "스크래치, 변색, 찍힘 없음", + inspection_method: "육안 검사", + pass_criteria: "이상 없음", + is_required: "Y", + measured_value: "", + result: null, + }, + { + id: "dummy-2", + inspection_item_name: "치수 검사", + inspection_standard: "규격 +-0.5mm", + inspection_method: "캘리퍼스 측정", + pass_criteria: "허용 오차 이내", + is_required: "Y", + measured_value: "", + result: null, + }, + { + id: "dummy-3", + inspection_item_name: "수량 검사", + inspection_standard: "발주 수량과 일치", + inspection_method: "전수 검사", + pass_criteria: "수량 일치", + is_required: "Y", + measured_value: "", + result: null, + }, + { + id: "dummy-4", + inspection_item_name: "포장 상태", + inspection_standard: "포장 손상 없음", + inspection_method: "육안 검사", + pass_criteria: "이상 없음", + is_required: "", + measured_value: "", + result: null, + }, ]; /* ------------------------------------------------------------------ */ @@ -90,348 +92,636 @@ const DUMMY_INSPECTION_ITEMS: InspectionItem[] = [ /* ------------------------------------------------------------------ */ export function InspectionModal({ - open, - onClose, - onComplete, - itemCode, - itemName, - totalQty, - initialResult, + open, + onClose, + onComplete, + onCancel, + itemCode, + itemName, + totalQty, + initialResult, }: InspectionModalProps) { - const [inspItems, setInspItems] = useState([]); - const [loading, setLoading] = useState(false); - const [goodQty, setGoodQty] = useState(0); - const [badQty, setBadQty] = useState(0); - const [remark, setRemark] = useState(""); + const [inspItems, setInspItems] = useState([]); + const [loading, setLoading] = useState(false); + const [goodQty, setGoodQty] = useState(0); + const [badQty, setBadQty] = useState(0); + /* NumPad state */ + const [numpadOpen, setNumpadOpen] = useState(false); + const [numpadTitle, setNumpadTitle] = useState(""); + const [numpadValue, setNumpadValue] = useState(""); + const [numpadMax, setNumpadMax] = useState(undefined); + const numpadCallbackRef = React.useRef<((val: string) => void) | null>(null); - /* Fetch inspection items from DB */ - const fetchInspectionItems = useCallback(async () => { - setLoading(true); - try { - const res = await apiClient.get("/pop/execute-action", { - params: { - taskType: "data-list", - targetTable: "item_inspection_info", - filters: JSON.stringify({ item_code: itemCode }), - pageSize: "50", - }, - }); - const data = res.data?.data; - if (Array.isArray(data) && data.length > 0) { - setInspItems( - data.map((r: Record) => ({ - id: String(r.id ?? ""), - inspection_item_name: String(r.inspection_item_name ?? ""), - inspection_standard: String(r.inspection_standard ?? ""), - inspection_method: String(r.inspection_method ?? ""), - pass_criteria: String(r.pass_criteria ?? ""), - is_required: String(r.is_required ?? ""), - measured_value: "", - result: null, - })) - ); - } else { - setInspItems(DUMMY_INSPECTION_ITEMS.map((i) => ({ ...i }))); - } - } catch { - setInspItems(DUMMY_INSPECTION_ITEMS.map((i) => ({ ...i }))); - } finally { - setLoading(false); - } - }, [itemCode]); + const openNumpad = ( + title: string, + currentValue: string | number, + onConfirm: (v: string) => void, + max?: number, + ) => { + setNumpadTitle(title); + setNumpadValue(String(currentValue || "")); + setNumpadMax(max); + numpadCallbackRef.current = onConfirm; + setNumpadOpen(true); + }; + const [remark, setRemark] = useState(""); - /* Init on open */ - useEffect(() => { - if (!open) return; - if (initialResult) { - setInspItems(initialResult.items.map((i) => ({ ...i }))); - setGoodQty(initialResult.goodQty); - setBadQty(initialResult.badQty); - setRemark(initialResult.remark); - } else { - fetchInspectionItems(); - setGoodQty(totalQty); - setBadQty(0); - setRemark(""); - } - }, [open, initialResult, fetchInspectionItems, totalQty]); + /* Fetch inspection items from DB */ + const fetchInspectionItems = useCallback(async () => { + setLoading(true); + try { + const res = await apiClient.get("/pop/inspection-result/info", { + params: { itemCode }, + }); + const data = res.data?.data; + if (Array.isArray(data) && data.length > 0) { + setInspItems( + data.map((r: Record) => ({ + id: String(r.id ?? ""), + inspection_item_name: String(r.inspection_item_name ?? ""), + inspection_standard: String(r.inspection_standard ?? ""), + inspection_method: String(r.inspection_method ?? ""), + pass_criteria: String(r.pass_criteria ?? ""), + is_required: String(r.is_required ?? "Y"), + measured_value: "", + result: null, + })), + ); + } else { + setInspItems(DUMMY_INSPECTION_ITEMS.map((i) => ({ ...i }))); + } + } catch { + setInspItems(DUMMY_INSPECTION_ITEMS.map((i) => ({ ...i }))); + } finally { + setLoading(false); + } + }, [itemCode]); - /* Update item */ - const updateItem = (id: string, field: "measured_value" | "result", value: string) => { - setInspItems((prev) => - prev.map((item) => - item.id === id - ? { ...item, [field]: field === "result" ? (item.result === value ? null : value) : value } - : item - ) - ); - }; + /* Init on open */ + useEffect(() => { + if (!open) return; + if (initialResult) { + setInspItems(initialResult.items.map((i) => ({ ...i }))); + setGoodQty(initialResult.goodQty); + setBadQty(initialResult.badQty); + setRemark(initialResult.remark); + } else { + fetchInspectionItems(); + setGoodQty(totalQty); + setBadQty(0); + setRemark(""); + } + }, [open, initialResult, fetchInspectionItems, totalQty]); - /* Handle good/bad qty sync */ - const handleGoodQtyChange = (val: number) => { - const v = Math.max(0, Math.min(val, totalQty)); - setGoodQty(v); - setBadQty(totalQty - v); - }; + /* Update item */ + const updateItem = ( + id: string, + field: "measured_value" | "result", + value: string, + ) => { + setInspItems((prev) => + prev.map((item) => + item.id === id + ? { + ...item, + [field]: + field === "result" + ? item.result === value + ? null + : value + : value, + } + : item, + ), + ); + }; - const handleBadQtyChange = (val: number) => { - const v = Math.max(0, Math.min(val, totalQty)); - setBadQty(v); - setGoodQty(totalQty - v); - }; + /* Handle good/bad qty sync */ + const handleGoodQtyChange = (val: number) => { + const v = Math.max(0, Math.min(val, totalQty)); + setGoodQty(v); + setBadQty(totalQty - v); + }; - /* Complete */ - const handleComplete = () => { - onComplete({ - items: inspItems, - goodQty, - badQty, - remark, - completed: true, - }); - onClose(); - }; + const handleBadQtyChange = (val: number) => { + const v = Math.max(0, Math.min(val, totalQty)); + setBadQty(v); + setGoodQty(totalQty - v); + }; - if (!open) return null; + /* 검사 완료 가능 여부: 필수 항목이 모두 pass */ + const canComplete = inspItems + .filter((it) => it.is_required === "Y") + .every((it) => it.result === "pass"); - return ( -
- {/* Full-screen slide panel */} -
-
- {/* Header */} -
-

자주검사

- -
+ /* Complete */ + const [allocating, setAllocating] = useState(false); + const handleComplete = async () => { + if (!canComplete) return; + setAllocating(true); + try { + // 1. 기존 inspectionNumber 있으면 재사용, 없으면 채번 호출 + let inspectionNumber = initialResult?.inspectionNumber; + if (!inspectionNumber) { + try { + const res = await apiClient.post( + "/pop/inspection-result/allocate-number", + ); + inspectionNumber = res.data?.data?.inspectionNumber; + } catch (err) { + console.error("[검사번호 채번 실패]", err); + } + } + // 2. 결과 전달 (채번 포함) + onComplete({ + items: inspItems, + goodQty, + badQty, + remark, + completed: true, + inspectionNumber, + }); + onClose(); + } finally { + setAllocating(false); + } + }; - {/* Scrollable body */} -
- {/* Item summary */} -
- - {itemCode} - - - {itemName} - - - {totalQty.toLocaleString()} EA - -
+ if (!open) return null; - {/* Inspection items section */} -
-
-

검사 항목

- - {inspItems.length}개 - -
+ return ( +
+ {/* Overlay */} +
- {loading ? ( -
- - - - - 불러오는 중... -
- ) : inspItems.length === 0 ? ( -
- 등록된 검사 항목이 없습니다 -
- ) : ( -
- {inspItems.map((item) => ( -
- {/* Item header */} -
- - {item.inspection_item_name} - - {item.is_required === "Y" && ( - - 필수 - - )} -
+ {/* Bottom sheet */} +
e.stopPropagation()} + > + {/* Handle bar */} +
+
+
- {/* Info grid */} -
- {item.inspection_standard && ( - <> - 기준 - {item.inspection_standard} - - )} - {item.inspection_method && ( - <> - 방법 - {item.inspection_method} - - )} - {item.pass_criteria && ( - <> - 판정 - {item.pass_criteria} - - )} -
+ {/* Header */} +
+

자주검사

+ +
- {/* Input + result buttons */} -
- updateItem(item.id, "measured_value", e.target.value)} - placeholder="측정값 입력" - className="flex-1 h-9 px-2.5 text-[13px] border border-gray-200 rounded-md outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100" - /> -
- - -
- {/* Camera placeholder */} - -
-
- ))} -
- )} -
+ {/* Scrollable body */} +
+ {/* Item summary */} +
+ + {itemCode} + + + {itemName} + + + {totalQty.toLocaleString()} EA + +
- {/* Final judgment section */} -
-

종합 판정

+ {/* Inspection items section */} +
+
+

검사 항목

+ + {inspItems.length}개 + +
- {/* Good / Bad qty */} -
-
- -
- handleGoodQtyChange(parseInt(e.target.value, 10) || 0)} - className="flex-1 min-w-0 h-10 px-2 text-center text-sm font-semibold border-2 border-green-400 rounded-lg bg-green-50 text-green-700 outline-none focus:ring-2 focus:ring-green-200" - /> - EA -
-
-
- -
- handleBadQtyChange(parseInt(e.target.value, 10) || 0)} - className="flex-1 min-w-0 h-10 px-2 text-center text-sm font-semibold border-2 border-red-400 rounded-lg bg-red-50 text-red-700 outline-none focus:ring-2 focus:ring-red-200" - /> - EA -
-
-
+ {loading ? ( +
+ + + + + 불러오는 중... +
+ ) : inspItems.length === 0 ? ( +
+ 등록된 검사 항목이 없습니다 +
+ ) : ( +
+ {inspItems.map((item) => ( +
+ {/* Item header */} +
+ + {item.inspection_item_name} + + {item.is_required === "Y" && ( + + 필수 + + )} +
- {/* Total summary */} -
- 전체 수량 - - {totalQty.toLocaleString()} EA - -
-
+ {/* Info grid */} +
+ {item.inspection_standard && ( + <> + 기준 + + {item.inspection_standard} + + + )} + {item.inspection_method && ( + <> + 방법 + + {item.inspection_method} + + + )} + {item.pass_criteria && ( + <> + 판정 + + {item.pass_criteria} + + + )} +
- {/* Remark */} -
-

비고

-