/** * 출하계획 컨트롤러 * * 수주 마스터(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 getList(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { dateFrom, dateTo, status, customer, keyword } = req.query; const conditions: string[] = []; const params: any[] = []; let paramIndex = 1; // 멀티테넌시 if (companyCode === "*") { // 최고 관리자: 전체 조회 } else { conditions.push(`sp.company_code = $${paramIndex}`); params.push(companyCode); paramIndex++; } if (dateFrom) { conditions.push(`sp.plan_date >= $${paramIndex}::date`); params.push(dateFrom); paramIndex++; } if (dateTo) { conditions.push(`sp.plan_date <= $${paramIndex}::date`); params.push(dateTo); paramIndex++; } if (status) { conditions.push(`sp.status = $${paramIndex}`); params.push(status); paramIndex++; } if (customer) { conditions.push(`(c.customer_name ILIKE $${paramIndex} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${paramIndex})`); params.push(`%${customer}%`); paramIndex++; } if (keyword) { conditions.push(`( COALESCE(m.order_no, d.order_no, '') ILIKE $${paramIndex} OR COALESCE(d.part_code, m.part_code, '') ILIKE $${paramIndex} OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${paramIndex} OR sp.shipment_plan_no ILIKE $${paramIndex} )`); params.push(`%${keyword}%`); paramIndex++; } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const query = ` SELECT sp.id, sp.plan_date, sp.plan_qty, sp.status, sp.memo, sp.shipment_plan_no, sp.created_date, sp.created_by, sp.detail_id, sp.sales_order_id, sp.remain_qty, COALESCE(m.order_no, d.order_no, '') AS order_no, COALESCE(d.part_code, m.part_code, '') AS part_code, COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS part_name, COALESCE(d.spec, m.spec, '') AS spec, COALESCE(m.material, '') AS material, COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name, COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code, 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 shipped_qty FROM shipment_plan sp LEFT JOIN sales_order_detail d ON sp.detail_id = d.id AND sp.company_code = d.company_code LEFT JOIN sales_order_mng m ON sp.sales_order_id = m.id AND sp.company_code = m.company_code LEFT JOIN LATERAL ( SELECT item_name FROM item_info WHERE item_number = COALESCE(d.part_code, m.part_code) AND company_code = sp.company_code LIMIT 1 ) i ON true LEFT JOIN customer_mng c ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code ${whereClause} ORDER BY sp.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, stack: error.stack, }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 출하계획 단건 수정 ─── export async function updatePlan(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { id } = req.params; const { planQty, planDate, memo } = req.body; const pool = getPool(); const check = await pool.query( `SELECT id, status FROM shipment_plan WHERE id = $1 AND company_code = $2`, [id, companyCode] ); if (check.rowCount === 0) { return res.status(404).json({ success: false, message: "출하계획을 찾을 수 없습니다" }); } const setClauses: string[] = []; const updateParams: any[] = []; let idx = 1; if (planQty !== undefined) { setClauses.push(`plan_qty = $${idx}`); updateParams.push(planQty); idx++; } if (planDate !== undefined) { setClauses.push(`plan_date = $${idx}::date`); updateParams.push(planDate); idx++; } if (memo !== undefined) { setClauses.push(`memo = $${idx}`); updateParams.push(memo); idx++; } setClauses.push(`updated_date = NOW()`); setClauses.push(`updated_by = $${idx}`); updateParams.push(userId); idx++; updateParams.push(id); updateParams.push(companyCode); const updateQuery = ` UPDATE shipment_plan SET ${setClauses.join(", ")} WHERE id = $${idx - 1} AND company_code = $${idx} RETURNING * `; // 파라미터 인덱스 수정 const finalParams: any[] = []; let pIdx = 1; const setClausesFinal: string[] = []; if (planQty !== undefined) { setClausesFinal.push(`plan_qty = $${pIdx}`); finalParams.push(planQty); pIdx++; } if (planDate !== undefined) { setClausesFinal.push(`plan_date = $${pIdx}::date`); finalParams.push(planDate); pIdx++; } if (memo !== undefined) { setClausesFinal.push(`memo = $${pIdx}`); finalParams.push(memo); pIdx++; } setClausesFinal.push(`updated_date = NOW()`); setClausesFinal.push(`updated_by = $${pIdx}`); finalParams.push(userId); pIdx++; finalParams.push(id); finalParams.push(companyCode); const result = await pool.query( `UPDATE shipment_plan SET ${setClausesFinal.join(", ")} WHERE id = $${pIdx} AND company_code = $${pIdx + 1} RETURNING *`, finalParams ); logger.info("출하계획 수정", { companyCode, planId: id, userId }); 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 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 }); } }