/** * 출고관리 컨트롤러 * * 출고유형별 소스 테이블: * - 판매출고 → shipment_instruction + shipment_instruction_detail (출하지시) * - 반품출고 → purchase_order_mng (발주/입고) * - 기타출고 → item_info (품목) */ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; 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; const conditions: string[] = []; const params: any[] = []; let paramIdx = 1; 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_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 (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 query = ` SELECT om.*, wh.warehouse_name FROM outbound_mng om LEFT JOIN warehouse_info wh ON om.warehouse_code = wh.warehouse_code AND om.company_code = wh.company_code ${whereClause} ORDER BY om.created_date DESC `; const pool = getPool(); const result = await pool.query(query, params); 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 }); } } // 출고 등록 (다건) export async function create(req: AuthenticatedRequest, res: Response) { 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; if (!items || !Array.isArray(items) || items.length === 0) { return res.status(400).json({ success: false, message: "출고 품목이 없습니다." }); } await client.query("BEGIN"); const insertedRows: any[] = []; for (const item of items) { const result = await client.query( `INSERT INTO outbound_mng ( company_code, outbound_number, outbound_type, outbound_date, reference_number, customer_code, customer_name, item_code, item_name, specification, material, unit, outbound_qty, unit_price, total_amount, lot_number, warehouse_code, location_code, outbound_status, manager_id, memo, source_type, sales_order_id, shipment_plan_id, item_info_id, destination_code, delivery_destination, delivery_address, created_date, created_by, writer, status ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $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, ] ); 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 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 ( company_code, item_code, warehouse_code, location_code, current_qty, safety_qty, last_out_date, created_date, updated_date, writer ) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`, [companyCode, itemCode, whCode, locCode, userId] ); } } // 판매출고인 경우 출하지시의 ship_qty 업데이트 if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") { 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`, [item.outbound_qty || 0, item.source_id, companyCode] ); } } await client.query("COMMIT"); 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(); } } // 출고 수정 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; 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), total_amount = COALESCE($4, total_amount), lot_number = COALESCE($5, lot_number), warehouse_code = COALESCE($6, warehouse_code), location_code = COALESCE($7, location_code), outbound_status = COALESCE($8, outbound_status), manager_id = COALESCE($9, manager_id), memo = COALESCE($10, memo), updated_date = NOW(), 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, ] ); if (result.rowCount === 0) { return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." }); } 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 }); } } // 출고 삭제 export async function deleteOutbound(req: AuthenticatedRequest, res: Response) { 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] ); if (result.rowCount === 0) { return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); } 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 }); } } // 판매출고용: 출하지시 데이터 조회 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; 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 sid.id AS detail_id, si.id AS instruction_id, si.instruction_no, si.instruction_date, si.partner_id, si.status AS instruction_status, sid.item_code, sid.item_name, sid.spec, sid.material, COALESCE(sid.plan_qty, 0) AS plan_qty, COALESCE(sid.ship_qty, 0) AS ship_qty, COALESCE(sid.order_qty, 0) AS order_qty, GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_qty, sid.source_type FROM shipment_instruction si JOIN shipment_instruction_detail sid ON si.id = sid.instruction_id AND si.company_code = sid.company_code 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 ); 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; const conditions: string[] = ["company_code = $1"]; const params: any[] = [companyCode]; let paramIdx = 2; // 입고된 것만 (반품 대상) 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++; } 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, COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty, COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price, status, due_date FROM purchase_order_mng WHERE ${conditions.join(" AND ")} ORDER BY order_date DESC, purchase_no`, params ); return res.json({ success: true, data: result.rows }); } catch (error: any) { logger.error("발주 데이터 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // 기타출고용: 품목 데이터 조회 export async function getItems(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; 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 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 ); 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 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 WHERE company_code = $1 AND outbound_number LIKE $2 ORDER BY outbound_number DESC LIMIT 1`, [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; } 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 }); } } // 창고 목록 조회 export async function getWarehouses(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const pool = getPool(); 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] ); 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 }); } }