/** * 입고관리 컨트롤러 * * 입고유형별 소스 테이블: * - 구매입고 → purchase_order_mng (발주) * - 반품입고 → shipment_instruction + shipment_instruction_detail (출하) * - 기타입고 → 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 { inbound_type, inbound_status, search_keyword, date_from, date_to, } = req.query; const conditions: string[] = []; const params: any[] = []; let paramIdx = 1; 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_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 im.item_name ILIKE $${paramIdx} OR im.item_number ILIKE $${paramIdx} OR im.supplier_name ILIKE $${paramIdx} OR 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++; } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const query = ` SELECT im.*, wh.warehouse_name FROM inbound_mng im LEFT JOIN warehouse_info wh ON im.warehouse_code = wh.warehouse_code AND im.company_code = wh.company_code ${whereClause} ORDER BY im.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, 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: "입고 품목이 없습니다." }); } await client.query("BEGIN"); const insertedRows: any[] = []; for (const item of items) { const result = await client.query( `INSERT INTO inbound_mng ( company_code, inbound_number, inbound_type, inbound_date, reference_number, supplier_code, supplier_name, item_number, item_name, spec, material, unit, inbound_qty, unit_price, total_amount, lot_number, warehouse_code, location_code, inbound_status, inspection_status, inspector, manager, memo, source_table, source_id, created_date, created_by, writer, status ) VALUES ( $1, $2, $3, $4::date, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, NOW(), $26, $26, '입고' ) RETURNING *`, [ companyCode, inbound_number || item.inbound_number, item.inbound_type, inbound_date || item.inbound_date, item.reference_number || null, item.supplier_code || null, item.supplier_name || null, 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, warehouse_code || item.warehouse_code || null, location_code || item.location_code || null, item.inbound_status || "대기", item.inspection_status || "대기", inspector || item.inspector || null, manager || item.manager || null, memo || item.memo || null, item.source_table || null, item.source_id || null, userId, ] ); insertedRows.push(result.rows[0]); // 구매입고인 경우 발주의 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 ), remain_qty = CAST( GREATEST(COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text ), status = CASE WHEN COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 >= COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) THEN '입고완료' ELSE '부분입고' END, updated_date = NOW() WHERE id = $2 AND company_code = $3`, [item.inbound_qty || 0, item.source_id, companyCode] ); } } await client.query("COMMIT"); logger.info("입고 등록 완료", { companyCode, userId, count: insertedRows.length, inbound_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 { inbound_date, inbound_qty, unit_price, total_amount, lot_number, warehouse_code, location_code, inbound_status, inspection_status, inspector, manager: mgr, memo, } = req.body; const pool = getPool(); const result = await pool.query( `UPDATE inbound_mng SET inbound_date = COALESCE($1::date, inbound_date), inbound_qty = COALESCE($2, inbound_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), inbound_status = COALESCE($8, inbound_status), inspection_status = COALESCE($9, inspection_status), inspector = COALESCE($10, inspector), manager = COALESCE($11, manager), memo = COALESCE($12, memo), updated_date = NOW(), updated_by = $13 WHERE id = $14 AND company_code = $15 RETURNING *`, [ inbound_date, inbound_qty, unit_price, total_amount, lot_number, warehouse_code, location_code, inbound_status, inspection_status, inspector, 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 deleteReceiving(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { id } = req.params; const pool = getPool(); const result = await pool.query( `DELETE FROM inbound_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 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(remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)) > 0` ); conditions.push(`status NOT IN ('입고완료', '취소')`); 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(remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) ) AS remain_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 getShipments(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.ship_qty, 0) AS ship_qty, COALESCE(sid.order_qty, 0) AS order_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 ")} 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 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 = `RCV-${yyyy}-`; 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}%`] ); 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")}`; 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 }); } }