/** * 출하계획 컨트롤러 * * 수주 마스터(sales_order_mng, INTEGER id) 또는 * 수주 디테일(sales_order_detail, UUID id) 양쪽에서 호출 가능. * * ID 포맷으로 소스 테이블 자동 감지 → JOIN으로 완전한 정보 조합 */ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; // UUID 포맷 감지 (하이픈 포함 36자) const isUUID = (val: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( val ); type SourceTable = "master" | "detail"; interface NormalizedOrder { sourceId: string; // 원본 ID (master: 정수, detail: UUID) masterId: number | null; detailId: string | null; orderNo: string; partCode: string; partName: string; partnerCode: string; partnerName: string; dueDate: string; orderQty: number; shipQty: number; balanceQty: number; } // ─── 소스 테이블 감지 ─── function detectSource(ids: string[]): SourceTable { if (ids.length === 0) return "detail"; return ids.every((id) => isUUID(id)) ? "detail" : "master"; } // ─── 수주 정보 정규화 (마스터/디테일 양쪽 JOIN) ─── async function getNormalizedOrders( companyCode: string, ids: string[], source: SourceTable ): Promise { const pool = getPool(); if (source === "detail") { // 디테일 기준 → 마스터 JOIN (order_no), 거래처 JOIN (customer_mng) // item_info는 LATERAL로 1건만 매칭 (item_number 중복 대비) const res = await pool.query( `SELECT d.id AS detail_id, m.id AS master_id, d.order_no, d.part_code, COALESCE(d.part_name, i.item_name, d.part_code) AS part_name, COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code, COALESCE(c.customer_name, d.delivery_partner_code, m.partner_id, '') AS partner_name, COALESCE(d.due_date, m.due_date::text, '') AS due_date, COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty, COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS ship_qty, COALESCE(NULLIF(d.balance_qty,'')::numeric, m.balance_qty, 0) AS balance_qty FROM sales_order_detail d LEFT JOIN sales_order_mng m ON d.order_no = m.order_no AND d.company_code = m.company_code LEFT JOIN LATERAL ( SELECT item_name FROM item_info WHERE item_number = d.part_code AND company_code = d.company_code LIMIT 1 ) i ON true LEFT JOIN customer_mng c ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code AND d.company_code = c.company_code WHERE d.company_code = $1 AND d.id = ANY($2::text[])`, [companyCode, ids] ); return res.rows.map((r) => ({ sourceId: r.detail_id, masterId: r.master_id, detailId: r.detail_id, orderNo: r.order_no || "", partCode: r.part_code || "", partName: r.part_name || "", partnerCode: r.partner_code || "", partnerName: r.partner_name || "", dueDate: r.due_date || "", orderQty: Number(r.order_qty || 0), shipQty: Number(r.ship_qty || 0), balanceQty: Number(r.balance_qty || 0), })); } else { // 마스터 기준 → 거래처 JOIN const numericIds = ids.map(Number).filter((n) => !isNaN(n)); // item_info는 LATERAL로 1건만 매칭 (item_number 중복 대비) const res = await pool.query( `SELECT m.id AS master_id, NULL AS detail_id, m.order_no, m.part_code, COALESCE(m.part_name, i.item_name, m.part_code, '') AS part_name, COALESCE(m.partner_id, '') AS partner_code, COALESCE(c.customer_name, m.partner_id, '') AS partner_name, COALESCE(m.due_date::text, '') AS due_date, COALESCE(m.order_qty, 0) AS order_qty, COALESCE(m.ship_qty, 0) AS ship_qty, COALESCE(m.balance_qty, 0) AS balance_qty FROM sales_order_mng m LEFT JOIN LATERAL ( SELECT item_name FROM item_info WHERE item_number = m.part_code AND company_code = m.company_code LIMIT 1 ) i ON true LEFT JOIN customer_mng c ON m.partner_id = c.customer_code AND m.company_code = c.company_code WHERE m.company_code = $1 AND m.id = ANY($2::int[])`, [companyCode, numericIds] ); return res.rows.map((r) => ({ sourceId: String(r.master_id), masterId: r.master_id, detailId: null, orderNo: r.order_no || "", partCode: r.part_code || "", partName: r.part_name || "", partnerCode: r.partner_code || "", partnerName: r.partner_name || "", dueDate: r.due_date || "", orderQty: Number(r.order_qty || 0), shipQty: Number(r.ship_qty || 0), balanceQty: Number(r.balance_qty || 0), })); } } // ─── 품목별 집계 + 기존 출하계획 조회 ─── export async function getAggregate(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { ids } = req.query; if (!ids) { return res .status(400) .json({ success: false, message: "ids 파라미터가 필요합니다" }); } const idList = (ids as string).split(",").filter(Boolean); if (idList.length === 0) { return res .status(400) .json({ success: false, message: "유효한 ID가 필요합니다" }); } const source = detectSource(idList); logger.info("출하계획 집계 조회", { companyCode, source, idCount: idList.length, }); // 1) 정규화된 수주 정보 조회 (JOIN 포함) const orders = await getNormalizedOrders(companyCode, idList, source); if (orders.length === 0) { return res .status(404) .json({ success: false, message: "해당 수주를 찾을 수 없습니다" }); } // 2) 품목별 그룹핑 const partCodeMap = new Map(); for (const order of orders) { const key = order.partCode || "UNKNOWN"; if (!partCodeMap.has(key)) partCodeMap.set(key, []); partCodeMap.get(key)!.push(order); } const pool = getPool(); const result: Record = {}; for (const [partCode, partOrders] of partCodeMap) { // 총수주잔량: 선택된 수주들의 balance_qty 합 const totalBalance = partOrders.reduce( (s, o) => s + (o.balanceQty > 0 ? o.balanceQty : o.orderQty - o.shipQty), 0 ); // 기존 출하계획 조회 (detail_id 또는 sales_order_id 기준) let existingPlans: any[] = []; if (source === "detail") { const planDetailIds = partOrders .map((o) => o.detailId) .filter(Boolean); if (planDetailIds.length > 0) { const planRes = await pool.query( `SELECT id, detail_id, sales_order_id, plan_qty, plan_date, shipment_plan_no, status FROM shipment_plan WHERE company_code = $1 AND detail_id = ANY($2::text[]) ORDER BY created_date DESC`, [companyCode, planDetailIds] ); existingPlans = planRes.rows.map((r) => ({ id: r.id, sourceId: r.detail_id, planQty: Number(r.plan_qty || 0), planDate: r.plan_date, shipmentPlanNo: r.shipment_plan_no, status: r.status, })); } } else { const planMasterIds = partOrders .map((o) => o.masterId) .filter((id): id is number => id != null); if (planMasterIds.length > 0) { const planRes = await pool.query( `SELECT id, sales_order_id, detail_id, plan_qty, plan_date, shipment_plan_no, status FROM shipment_plan WHERE company_code = $1 AND sales_order_id = ANY($2::int[]) ORDER BY created_date DESC`, [companyCode, planMasterIds] ); existingPlans = planRes.rows.map((r) => ({ id: r.id, sourceId: String(r.sales_order_id), planQty: Number(r.plan_qty || 0), planDate: r.plan_date, shipmentPlanNo: r.shipment_plan_no, status: r.status, })); } } const totalPlanQty = existingPlans.reduce((s, p) => s + p.planQty, 0); // 현재고 const stockRes = await pool.query( `SELECT COALESCE(SUM(current_qty::numeric), 0) AS current_stock FROM inventory_stock WHERE company_code = $1 AND item_code = $2`, [companyCode, partCode] ); const currentStock = Number(stockRes.rows[0]?.current_stock || 0); // 생산중수량 const prodRes = await pool.query( `SELECT COALESCE(SUM(plan_qty - COALESCE(completed_qty, 0)), 0) AS in_production FROM production_plan_mng WHERE company_code = $1 AND item_code = $2 AND status IN ('in_progress', 'planned')`, [companyCode, partCode] ); const inProductionQty = Number(prodRes.rows[0]?.in_production || 0); result[partCode] = { totalBalance, totalPlanQty, currentStock, availableStock: currentStock - totalPlanQty, inProductionQty, existingPlans, orders: partOrders.map((o) => ({ sourceId: o.sourceId, orderNo: o.orderNo, partCode: o.partCode, partName: o.partName, partnerName: o.partnerName, dueDate: o.dueDate, orderQty: o.orderQty, shipQty: o.shipQty, balanceQty: o.balanceQty, })), }; } logger.info("출하계획 집계 조회 완료", { companyCode, source, partCodes: Array.from(partCodeMap.keys()), orderCount: orders.length, }); return res.json({ success: true, data: result, source }); } catch (error: any) { logger.error("출하계획 집계 조회 실패", { error: error.message, stack: error.stack, }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 출하계획 일괄 저장 ─── export async function batchSave(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { plans, source } = req.body; if (!Array.isArray(plans) || plans.length === 0) { return res.status(400).json({ success: false, message: "저장할 출하계획 데이터가 필요합니다", }); } // source 자동 감지 (프론트에서 전달, 또는 ID 포맷으로 추론) const detectedSource: SourceTable = source || detectSource(plans.map((p: any) => String(p.sourceId))); const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); const savedPlans = []; for (const plan of plans) { const { sourceId, planQty, planDate } = plan; if (!sourceId || !planQty || planQty <= 0) continue; const planDateValue = planDate || null; if (detectedSource === "detail") { // 디테일 소스: detail_id로 저장 const detailCheck = await client.query( `SELECT d.id, d.order_no, d.part_code, d.qty, d.ship_qty, d.balance_qty, m.id AS master_id FROM sales_order_detail d LEFT JOIN sales_order_mng m ON d.order_no = m.order_no AND d.company_code = m.company_code WHERE d.id = $1 AND d.company_code = $2`, [sourceId, companyCode] ); if (detailCheck.rowCount === 0) { throw new Error(`수주상세 ${sourceId}을 찾을 수 없습니다`); } const detail = detailCheck.rows[0]; const qty = Number(detail.qty || 0); const shipQty = Number(detail.ship_qty || 0); const balanceQty = detail.balance_qty ? Number(detail.balance_qty) : qty - shipQty; if (balanceQty > 0 && planQty > balanceQty) { throw new Error( `수주번호 ${detail.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다` ); } const insertRes = await client.query( `INSERT INTO shipment_plan (company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by) VALUES ($1, $2, $3, $4, COALESCE($5::date, CURRENT_DATE), 'READY', $6) RETURNING *`, [companyCode, sourceId, detail.master_id, planQty, planDateValue, userId] ); savedPlans.push(insertRes.rows[0]); // detail ship_qty 업데이트 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`, [planQty, sourceId, companyCode] ); } else { // 마스터 소스: sales_order_id로 저장 const masterId = Number(sourceId); const masterCheck = await client.query( `SELECT id, order_no, order_qty, ship_qty, balance_qty FROM sales_order_mng WHERE id = $1 AND company_code = $2`, [masterId, companyCode] ); if (masterCheck.rowCount === 0) { throw new Error(`수주 ID ${masterId}을 찾을 수 없습니다`); } const master = masterCheck.rows[0]; const balanceQty = Number(master.balance_qty || 0); if (balanceQty > 0 && planQty > balanceQty) { throw new Error( `수주번호 ${master.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다` ); } const insertRes = await client.query( `INSERT INTO shipment_plan (company_code, sales_order_id, plan_qty, plan_date, status, created_by) VALUES ($1, $2, $3, COALESCE($4::date, CURRENT_DATE), 'READY', $5) RETURNING *`, [companyCode, masterId, planQty, planDateValue, userId] ); savedPlans.push(insertRes.rows[0]); // 마스터 ship_qty 업데이트 await client.query( `UPDATE sales_order_mng SET ship_qty = COALESCE(ship_qty, 0) + $1, balance_qty = COALESCE(order_qty, 0) - COALESCE(ship_qty, 0) - $1, updated_date = NOW() WHERE id = $2 AND company_code = $3`, [planQty, masterId, companyCode] ); } } await client.query("COMMIT"); logger.info("출하계획 일괄 저장 완료", { companyCode, source: detectedSource, savedCount: savedPlans.length, userId, }); return res.json({ success: true, message: `${savedPlans.length}건 저장 완료`, data: savedPlans, }); } catch (txError) { await client.query("ROLLBACK"); throw txError; } finally { client.release(); } } catch (error: any) { logger.error("출하계획 일괄 저장 실패", { error: error.message, stack: error.stack, }); return res.status(500).json({ success: false, message: error.message }); } }