/** * 출고관리 컨트롤러 * * 출고유형별 소스 테이블: * - 판매출고 → 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 ( id, 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 ( gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, 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) { // 재고 사전 검증: 부족 시 즉시 에러 (트랜잭션 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 || ''] ); const currentStock = parseFloat(stockCheck.rows[0]?.cur || '0'); if (currentStock < outQty) { throw new Error( `재고 부족: ${item.item_name || itemCode} (창고 ${whCode || '미지정'}) — 현재 재고 ${currentStock}, 요청 출고 ${outQty}` ); } 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 ( 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] ); } // 재고 이력 기록 (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 ( 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] ); } // 판매출고인 경우 출하지시의 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 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 }); } } // 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 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 COALESCE(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 }); } } // 창고별 위치 목록 조회 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(); 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] ); 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 }); } }