/** * 입고관리 컨트롤러 * * 입고유형별 소스 테이블: * - 구매입고 → purchase_order_mng (발주 헤더) + purchase_detail (발주 디테일) * - 반품입고 → 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"; // 입고 목록 조회 (헤더-디테일 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; 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 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++; } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; 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, im.inbound_status, im.memo AS header_memo, im.source_table, im.source_id, im.created_date, im.created_by, im.updated_date, im.updated_by, im.writer, im.status, im.prev_inbound_qty, im.remark, COALESCE(id.reference_number, im.reference_number) AS reference_number, COALESCE(id.supplier_code, im.supplier_code) AS supplier_code, COALESCE(id.supplier_name, im.supplier_name) AS supplier_name, COALESCE(id.item_number, im.item_number) AS item_number, COALESCE(id.item_name, im.item_name) AS item_name, COALESCE(id.spec, im.spec) AS spec, COALESCE(id.material, im.material) AS material, COALESCE(id.unit, im.unit) AS unit, COALESCE(id.inbound_qty, im.inbound_qty) AS inbound_qty, COALESCE(id.unit_price, im.unit_price) AS unit_price, COALESCE(id.total_amount, im.total_amount) AS total_amount, COALESCE(id.lot_number, im.lot_number) AS lot_number, COALESCE(id.inspection_status, im.inspection_status) AS inspection_status, COALESCE(id.memo, im.memo) AS memo, id.id AS detail_id, id.seq_no, id.inbound_type AS detail_inbound_type, wh.warehouse_name FROM inbound_mng im LEFT JOIN inbound_detail id ON id.inbound_id = im.inbound_number AND id.company_code = im.company_code 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, id.seq_no ASC `; 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 }); } } // 입고 등록 (헤더 1건 + 디테일 N건) 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: "입고 품목이 없습니다." }); } // 첫 번째 아이템에서 inbound_type 추출 (헤더용) const inboundType = items[0].inbound_type || null; const inboundNumber = inbound_number || items[0].inbound_number; await client.query("BEGIN"); // 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, created_date, created_by, writer, status ) VALUES ( gen_random_uuid()::text, $1, $2, $3, $4::date, $5, $6, $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, ] ); 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; // 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, lot_number, reference_number, supplier_code, supplier_name, inspection_status, memo, item_id, 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, 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, ] ); 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 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(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 ( 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] ); } // 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 ( 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] ); } // 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 ), 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] ); } // 구매입고인 경우 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 ), balance_qty = CAST( COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1 AS text ), updated_date = NOW() WHERE id = $2 AND company_code = $3`, [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 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 ? '입고완료' : '부분입고'; await client.query( `UPDATE purchase_order_mng SET status = $1, updated_date = NOW() WHERE purchase_no = $2 AND company_code = $3`, [newStatus, purchaseNo, companyCode] ); } } } await client.query("COMMIT"); 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(); } } // 입고 수정 (헤더 + 디테일 분리 업데이트) 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 { 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"); // 헤더 업데이트 (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), inbound_status = COALESCE($4, inbound_status), inspector = COALESCE($5, inspector), manager = COALESCE($6, manager), memo = COALESCE($7, memo), updated_date = NOW(), 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, ] ); 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_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), memo = COALESCE($6, memo), updated_date = NOW(), 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 = 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, ] ); } await client.query("COMMIT"); 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(); } } // 입고 삭제 (헤더 + 디테일, 재고/발주 롤백 포함) 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; 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] ); 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 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 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; // 재고 롤백: 입고 수량만큼 차감 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 ), updated_date = NOW() 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 || ''] ); // 입고취소 이력 기록 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, NOW())`, [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 SET received_qty = CAST( GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text ), remain_qty = CAST( COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text ), status = CASE WHEN GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) <= 0 THEN '발주확정' ELSE '부분입고' END, updated_date = NOW() WHERE id = $2 AND company_code = $3`, [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 FROM purchase_detail pd LEFT JOIN ( SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received FROM inbound_mng WHERE source_table = 'purchase_detail' AND company_code = $1 AND inbound_number != $3 GROUP BY source_id ) r ON r.source_id = pd.id 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 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() WHERE purchase_no = $2 AND company_code = $3`, [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_mng WHERE id = $1 AND company_code = $2`, [id, companyCode] ); await client.query("COMMIT"); 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(); } } // 구매입고용: 발주 데이터 조회 (미입고분) - 신규 헤더-디테일 구조 + 레거시 단일 테이블 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; 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++; } const baseQuery = ` WITH combined AS ( -- 디테일 기반 발주 데이터 (purchase_detail.received_qty로 잔량 계산) SELECT pd.id, COALESCE(po.purchase_no, pd.purchase_no) AS purchase_no, po.order_date, COALESCE(pd.supplier_code, po.supplier_code) AS supplier_code, COALESCE(pd.supplier_name, po.supplier_name) AS supplier_name, COALESCE(NULLIF(pd.item_code, ''), ii.item_number) AS item_code, COALESCE(NULLIF(pd.item_name, ''), ii.item_name) AS item_name, COALESCE(NULLIF(pd.spec, ''), ii.size) AS spec, COALESCE(NULLIF(pd.material, ''), ii.material) AS material, COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) AS order_qty, COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) AS received_qty, COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) AS remain_qty, 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 FROM purchase_detail pd LEFT JOIN purchase_order_mng po ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code LEFT JOIN item_info ii ON pd.item_id = ii.id WHERE pd.company_code = $1 AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) > 0 AND COALESCE(pd.approval_status, '') NOT IN ('반려') AND COALESCE(po.status, '') NOT IN ('입고완료', '취소') ${keywordConditionDetail} UNION ALL -- 레거시 단일 테이블 데이터 (purchase_detail에 없는 발주) SELECT po.id, po.purchase_no, po.order_date, po.supplier_code, po.supplier_name, po.item_code, po.item_name, po.spec, po.material, COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0) AS order_qty, COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) AS received_qty, COALESCE(CAST(NULLIF(po.remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) ) AS remain_qty, COALESCE(CAST(NULLIF(po.unit_price, '') AS numeric), 0) AS unit_price, po.status, po.due_date, 'purchase_order_mng' AS source_table FROM purchase_order_mng po WHERE po.company_code = $1 AND NOT EXISTS ( SELECT 1 FROM purchase_detail pd WHERE pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code ) AND COALESCE(CAST(NULLIF(po.remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) ) > 0 AND po.status NOT IN ('입고완료', '취소') ${keywordConditionLegacy} )`; 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 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 }); } } // 반품입고용: 출하 데이터 조회 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; 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 whereClause = conditions.join(" AND "); const pool = getPool(); 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); const dataResult = 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 ${whereClause} ORDER BY si.instruction_date DESC, si.instruction_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 }); } } // 기타입고용: 품목 데이터 조회 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; 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 (division) { conditions.push(`division ILIKE $${paramIdx}`); params.push(`%${division}%`); paramIdx++; } 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 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 ); 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); // 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}-`; 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 }); } }