/** * 출고관리 컨트롤러 * * 출고유형별 소스 테이블: * - 판매출고 → shipment_instruction + shipment_instruction_detail (출하지시) * - 반품출고 → purchase_order_mng (발주/입고) * - 기타출고 → item_info (품목) */ import type { Response } from "express"; import { getPool } from "../database/db"; import type { AuthenticatedRequest } from "../types/auth"; import { adjustInventory } from "../utils/inventoryUtils"; 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 업데이트 + 수주상세 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], ); // 출하지시 상세의 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], ); } } } 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) { const pool = getPool(); const client = await pool.connect(); 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; await client.query("BEGIN"); // 변경 전 값 조회 const oldRes = await client.query( `SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`, [id, companyCode], ); if (oldRes.rowCount === 0) { await client.query("ROLLBACK"); return res .status(404) .json({ success: false, message: "출고 데이터를 찾을 수 없습니다." }); } const old = oldRes.rows[0]; const oldQty = Number(old.outbound_qty) || 0; const oldWhCode = old.warehouse_code || null; const oldLocCode = old.location_code || null; const itemCode = old.item_code || old.item_number || null; const outboundNumber = old.outbound_number; const newQty = outbound_qty !== undefined && outbound_qty !== null ? Number(outbound_qty) : oldQty; const newWhCode = warehouse_code !== undefined ? warehouse_code : oldWhCode; const newLocCode = location_code !== undefined ? location_code : oldLocCode; // 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시 const qtyChanged = newQty !== oldQty; const whChanged = (newWhCode || "") !== (oldWhCode || "") || (newLocCode || "") !== (oldLocCode || ""); if (itemCode && (qtyChanged || whChanged)) { if (whChanged) { // 기존 창고 복구 if (oldQty > 0) { await adjustInventory(client, { companyCode, userId, itemCode, whCode: oldWhCode, locCode: oldLocCode, delta: +oldQty, transactionType: "출고취소", remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}`, }); } // 신규 창고 차감 (재고부족 검증) if (newQty > 0) { await adjustInventory(client, { companyCode, userId, itemCode, whCode: newWhCode, locCode: newLocCode, delta: -newQty, transactionType: "출고수정", remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}, 수량 ${oldQty}→${newQty}`, validateStockEnough: true, }); } } else { // 창고 동일, 수량만 변경: 기존 복구(+oldQty) + 신규 차감(-newQty) = delta(+복구/-추가차감) const delta = oldQty - newQty; if (delta !== 0) { await adjustInventory(client, { companyCode, userId, itemCode, whCode: newWhCode, locCode: newLocCode, delta, transactionType: "출고수정", remark: `출고수정 (${outboundNumber}) 수량 ${oldQty}→${newQty}`, validateStockEnough: delta < 0, }); } } } const result = await client.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, ], ); await client.query("COMMIT"); logger.info("출고 수정", { companyCode, userId, id, oldQty, newQty, oldWhCode, newWhCode, }); return res.json({ success: true, data: result.rows[0] }); } 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 deleteOutbound(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; await client.query("BEGIN"); // 대상 출고 조회 const oldRes = await client.query( `SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`, [id, companyCode], ); if (oldRes.rowCount === 0) { await client.query("ROLLBACK"); return res .status(404) .json({ success: false, message: "데이터를 찾을 수 없습니다." }); } const old = oldRes.rows[0]; const itemCode = old.item_code || old.item_number || null; const whCode = old.warehouse_code || null; const locCode = old.location_code || null; const qty = Number(old.outbound_qty) || 0; const outboundNumber = old.outbound_number; // 재고 복구 + 이력 if (itemCode && qty > 0) { await adjustInventory(client, { companyCode, userId, itemCode, whCode, locCode, delta: +qty, transactionType: "출고취소", remark: `출고 삭제 (${outboundNumber})`, }); } else { logger.warn("출고 삭제 - 재고 복구 스킵", { companyCode, id, itemCode, qty, }); } await client.query( `DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2`, [id, companyCode], ); await client.query("COMMIT"); logger.info("출고 삭제", { companyCode, userId, id, itemCode, qty }); 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(); } } // 판매출고용: 출하지시 데이터 조회 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(width::text, '') AS width, COALESCE(height::text, '') AS height, COALESCE(thickness::text, '') AS thickness, 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 }); } }