/** * 자재현황 컨트롤러 * - 작업지시(work_instruction + work_instruction_detail) 조회 * - 선택된 작업지시의 BOM 기반 자재소요량 + 재고 현황 조회 * - 창고 목록 조회 */ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { pool } from "../database/db"; import { logger } from "../utils/logger"; // ─── 작업지시 조회 (work_instruction + work_instruction_detail) ─── export async function getWorkOrders( req: AuthenticatedRequest, res: Response ) { try { const companyCode = req.user!.companyCode; const { dateFrom, dateTo, itemCode, itemName } = req.query; const conditions: string[] = []; const params: any[] = []; let paramIndex = 1; if (companyCode === "*") { logger.info("최고 관리자 전체 작업지시 조회"); } else { conditions.push(`wi.company_code = $${paramIndex}`); params.push(companyCode); paramIndex++; } if (dateFrom) { conditions.push(`wi.start_date::date >= $${paramIndex}::date`); params.push(dateFrom); paramIndex++; } if (dateTo) { conditions.push(`wi.start_date::date <= $${paramIndex}::date`); params.push(dateTo); paramIndex++; } if (itemCode) { conditions.push(`d.item_number ILIKE $${paramIndex}`); params.push(`%${itemCode}%`); paramIndex++; } if (itemName) { conditions.push(`COALESCE(itm.item_name, '') ILIKE $${paramIndex}`); params.push(`%${itemName}%`); paramIndex++; } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const query = ` SELECT d.id, wi.work_instruction_no AS plan_no, d.item_number AS item_code, COALESCE(itm.item_name, '') AS item_name, d.qty AS plan_qty, wi.completed_qty, wi.start_date AS plan_date, wi.start_date, wi.end_date, wi.status, wi.work_instruction_no AS work_order_no, wi.company_code FROM work_instruction wi INNER JOIN work_instruction_detail d ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code LEFT JOIN LATERAL ( SELECT item_name FROM item_info WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1 ) itm ON true ${whereClause} ORDER BY wi.start_date DESC, wi.created_date DESC `; 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 getMaterialStatus( req: AuthenticatedRequest, res: Response ) { try { const companyCode = req.user!.companyCode; const { planIds, warehouseCode } = req.body; if (!planIds || !Array.isArray(planIds) || planIds.length === 0) { return res .status(400) .json({ success: false, message: "작업지시를 선택해주세요." }); } // 1) 선택된 작업지시 상세의 품목코드 + 수량 조회 const planPlaceholders = planIds .map((_, i) => `$${i + 1}`) .join(","); let paramIndex = planIds.length + 1; const companyCondition = companyCode === "*" ? "" : `AND d.company_code = $${paramIndex}`; const planParams: any[] = [...planIds]; if (companyCode !== "*") { planParams.push(companyCode); paramIndex++; } const planQuery = ` SELECT d.item_number AS item_code, COALESCE(itm.item_name, '') AS item_name, d.qty AS plan_qty FROM work_instruction_detail d LEFT JOIN LATERAL ( SELECT item_name FROM item_info WHERE item_number = d.item_number AND company_code = d.company_code LIMIT 1 ) itm ON true WHERE d.id IN (${planPlaceholders}) ${companyCondition} `; const planResult = await pool.query(planQuery, planParams); if (planResult.rowCount === 0) { return res.json({ success: true, data: [] }); } // 2) 해당 품목들의 BOM에서 필요 자재 목록 조회 const itemCodes = planResult.rows.map((r: any) => r.item_code); const planQtyMap: Record = {}; for (const row of planResult.rows) { const code = row.item_code; planQtyMap[code] = (planQtyMap[code] || 0) + Number(row.plan_qty || 0); } const itemPlaceholders = itemCodes.map((_: any, i: number) => `$${i + 1}`).join(","); // BOM 조인: bom -> bom_detail -> item_info (자재 정보) const bomCompanyCondition = companyCode === "*" ? "" : `AND b.company_code = $${itemCodes.length + 1}`; const bomParams: any[] = [...itemCodes]; if (companyCode !== "*") { bomParams.push(companyCode); } const bomQuery = ` SELECT b.item_code AS parent_item_code, b.base_qty AS bom_base_qty, bd.child_item_id, bd.quantity AS bom_qty, bd.unit AS bom_unit, bd.loss_rate, ii.item_name AS material_name, ii.item_number AS material_code, ii.unit AS material_unit FROM bom b JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code WHERE b.item_code IN (${itemPlaceholders}) ${bomCompanyCondition} ORDER BY b.item_code, bd.seq_no `; const bomResult = await pool.query(bomQuery, bomParams); // 3) 자재별 필요수량 계산 interface MaterialNeed { childItemId: string; materialCode: string; materialName: string; unit: string; requiredQty: number; } const materialMap: Record = {}; for (const bomRow of bomResult.rows) { const parentQty = planQtyMap[bomRow.parent_item_code] || 0; const baseQty = Number(bomRow.bom_base_qty) || 1; const bomQty = Number(bomRow.bom_qty) || 0; const lossRate = Number(bomRow.loss_rate) || 0; // 필요수량 = (생산수량 / BOM기준수량) * BOM자재수량 * (1 + 로스율/100) const requiredQty = (parentQty / baseQty) * bomQty * (1 + lossRate / 100); const key = bomRow.child_item_id; if (materialMap[key]) { materialMap[key].requiredQty += requiredQty; } else { materialMap[key] = { childItemId: bomRow.child_item_id, materialCode: bomRow.material_code || bomRow.child_item_id, materialName: bomRow.material_name || "알 수 없음", unit: bomRow.bom_unit || bomRow.material_unit || "EA", requiredQty, }; } } const materialIds = Object.keys(materialMap); if (materialIds.length === 0) { return res.json({ success: true, data: [] }); } // 4) 재고 조회 (창고/위치별) const stockPlaceholders = materialIds .map((_, i) => `$${i + 1}`) .join(","); const stockParams: any[] = [...materialIds]; let stockParamIdx = materialIds.length + 1; const stockConditions: string[] = [ `s.item_code IN (${stockPlaceholders})`, ]; if (companyCode !== "*") { stockConditions.push(`s.company_code = $${stockParamIdx}`); stockParams.push(companyCode); stockParamIdx++; } if (warehouseCode) { stockConditions.push(`s.warehouse_code = $${stockParamIdx}`); stockParams.push(warehouseCode); stockParamIdx++; } const stockQuery = ` SELECT s.item_code, s.warehouse_code, s.location_code, COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty FROM inventory_stock s WHERE ${stockConditions.join(" AND ")} AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0 ORDER BY s.item_code, s.warehouse_code, s.location_code `; const stockResult = await pool.query(stockQuery, stockParams); // 5) 결과 조합 // item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음) const stockByItem: Record< string, { location: string; warehouse: string; qty: number }[] > = {}; for (const stockRow of stockResult.rows) { const code = stockRow.item_code; if (!stockByItem[code]) { stockByItem[code] = []; } stockByItem[code].push({ location: stockRow.location_code || "", warehouse: stockRow.warehouse_code || "", qty: Number(stockRow.current_qty), }); } const resultData = materialIds.map((id) => { const material = materialMap[id]; // inventory_stock의 item_code가 item_number 또는 child_item_id일 수 있음 const locations = stockByItem[material.materialCode] || stockByItem[id] || []; const totalCurrentQty = locations.reduce( (sum, loc) => sum + loc.qty, 0 ); return { code: material.materialCode, name: material.materialName, required: Math.round(material.requiredQty * 100) / 100, current: totalCurrentQty, unit: material.unit, locations, }; }); logger.info("자재현황 조회 완료", { companyCode, planCount: planIds.length, materialCount: resultData.length, }); return res.json({ success: true, data: resultData }); } 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; let query: string; let params: any[]; if (companyCode === "*") { query = ` SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info ORDER BY warehouse_code `; params = []; } else { query = ` SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info WHERE company_code = $1 ORDER BY warehouse_code `; params = [companyCode]; } 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 }); } }