/** * 출하지시 컨트롤러 (shipment_instruction + shipment_instruction_detail) */ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; import { numberingRuleService } from "../services/numberingRuleService"; // ─── 출하지시 목록 조회 ─── 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 idx = 1; if (companyCode !== "*") { conditions.push(`si.company_code = $${idx}`); params.push(companyCode); idx++; } if (dateFrom) { conditions.push(`si.instruction_date >= $${idx}::date`); params.push(dateFrom); idx++; } if (dateTo) { conditions.push(`si.instruction_date <= $${idx}::date`); params.push(dateTo); idx++; } if (status) { conditions.push(`si.status = $${idx}`); params.push(status); idx++; } if (customer) { conditions.push(`(c.customer_name ILIKE $${idx} OR si.partner_id ILIKE $${idx})`); params.push(`%${customer}%`); idx++; } if (keyword) { conditions.push(`(si.instruction_no ILIKE $${idx} OR si.memo ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const query = ` SELECT si.*, COALESCE(c.customer_name, si.partner_id, '') AS customer_name, COALESCE( json_agg( json_build_object( 'id', sid.id, 'item_code', sid.item_code, 'item_name', COALESCE(i.item_name, sid.item_name, sid.item_code), 'spec', sid.spec, 'material', sid.material, 'order_qty', sid.order_qty, 'plan_qty', sid.plan_qty, 'ship_qty', sid.ship_qty, 'source_type', sid.source_type, 'shipment_plan_id', sid.shipment_plan_id, 'sales_order_id', sid.sales_order_id, 'detail_id', sid.detail_id ) ) FILTER (WHERE sid.id IS NOT NULL), '[]' ) AS items FROM shipment_instruction si LEFT JOIN customer_mng c ON si.partner_id = c.customer_code AND si.company_code = c.company_code LEFT JOIN shipment_instruction_detail sid ON si.id = sid.instruction_id AND si.company_code = sid.company_code LEFT JOIN LATERAL ( SELECT item_name FROM item_info WHERE item_number = sid.item_code AND company_code = si.company_code LIMIT 1 ) i ON true ${where} GROUP BY si.id, c.customer_name ORDER BY si.created_date DESC `; const pool = getPool(); const result = await pool.query(query, params); logger.info("출하지시 목록 조회", { companyCode, count: 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 previewNextNo(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; let instructionNo: string; try { const rule = await numberingRuleService.getNumberingRuleByColumn( companyCode, "shipment_instruction", "instruction_no" ); if (rule) { instructionNo = await numberingRuleService.previewCode( rule.ruleId, companyCode, {} ); } else { throw new Error("채번 규칙 없음"); } } catch { const pool = getPool(); const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); const seqRes = await pool.query( `SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`, [companyCode, `SI-${today}-%`] ); const seq = String(seqRes.rows[0].seq).padStart(3, "0"); instructionNo = `SI-${today}-${seq}`; } return res.json({ success: true, instructionNo }); } catch (error: any) { logger.error("출하지시번호 미리보기 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 출하지시 저장 (신규/수정) ─── export async function save(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { id: editId, instructionDate, partnerId, status: orderStatus, memo, carrierName, vehicleNo, driverName, driverContact, arrivalTime, deliveryAddress, items, } = req.body; if (!instructionDate) { return res.status(400).json({ success: false, message: "출하지시일은 필수입니다" }); } if (!items || items.length === 0) { return res.status(400).json({ success: false, message: "품목을 선택해주세요" }); } const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); let instructionId: number; let instructionNo: string; if (editId) { // 수정 const check = await client.query( `SELECT id, instruction_no FROM shipment_instruction WHERE id = $1 AND company_code = $2`, [editId, companyCode] ); if (check.rowCount === 0) { throw new Error("출하지시를 찾을 수 없습니다"); } instructionId = editId; instructionNo = check.rows[0].instruction_no; await client.query( `UPDATE shipment_instruction SET instruction_date = $1::date, partner_id = $2, status = $3, memo = $4, carrier_name = $5, vehicle_no = $6, driver_name = $7, driver_contact = $8, arrival_time = $9, delivery_address = $10, updated_date = NOW(), updated_by = $11 WHERE id = $12 AND company_code = $13`, [ instructionDate, partnerId, orderStatus || "READY", memo, carrierName, vehicleNo, driverName, driverContact, arrivalTime || null, deliveryAddress, userId, editId, companyCode, ] ); // 기존 디테일 삭제 후 재삽입 await client.query( `DELETE FROM shipment_instruction_detail WHERE instruction_id = $1 AND company_code = $2`, [editId, companyCode] ); } else { // 신규 - 채번 규칙이 있으면 사용, 없으면 자체 생성 try { const rule = await numberingRuleService.getNumberingRuleByColumn( companyCode, "shipment_instruction", "instruction_no" ); if (rule) { instructionNo = await numberingRuleService.allocateCode( rule.ruleId, companyCode, { instruction_date: instructionDate } ); logger.info("채번 규칙으로 출하지시번호 생성", { ruleId: rule.ruleId, instructionNo }); } else { throw new Error("채번 규칙 없음 - 폴백"); } } catch { const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); const seqRes = await client.query( `SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`, [companyCode, `SI-${today}-%`] ); const seq = String(seqRes.rows[0].seq).padStart(3, "0"); instructionNo = `SI-${today}-${seq}`; logger.info("폴백으로 출하지시번호 생성", { instructionNo }); } const insertRes = await client.query( `INSERT INTO shipment_instruction (company_code, instruction_no, instruction_date, partner_id, status, memo, carrier_name, vehicle_no, driver_name, driver_contact, arrival_time, delivery_address, created_date, created_by) VALUES ($1, $2, $3::date, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), $13) RETURNING id`, [ companyCode, instructionNo, instructionDate, partnerId, orderStatus || "READY", memo, carrierName, vehicleNo, driverName, driverContact, arrivalTime || null, deliveryAddress, userId, ] ); instructionId = insertRes.rows[0].id; } // 디테일 삽입 for (const item of items) { await client.query( `INSERT INTO shipment_instruction_detail (company_code, instruction_id, shipment_plan_id, sales_order_id, detail_id, item_code, item_name, spec, material, order_qty, plan_qty, ship_qty, source_type, created_date, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14)`, [ companyCode, instructionId, item.shipmentPlanId || null, item.salesOrderId || null, item.detailId || null, item.itemCode, item.itemName, item.spec, item.material, item.orderQty || 0, item.planQty || 0, item.shipQty || 0, item.sourceType || "shipmentPlan", userId, ] ); } await client.query("COMMIT"); logger.info("출하지시 저장 완료", { companyCode, instructionId, instructionNo, itemCount: items.length }); return res.json({ success: true, data: { id: instructionId, instructionNo } }); } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } finally { client.release(); } } catch (error: any) { logger.error("출하지시 저장 실패", { error: error.message, stack: error.stack }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 출하지시 삭제 ─── export async function remove(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { ids } = req.body; if (!ids || !Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ success: false, message: "삭제할 ID가 필요합니다" }); } const pool = getPool(); // CASCADE로 디테일도 자동 삭제 const result = await pool.query( `DELETE FROM shipment_instruction WHERE id = ANY($1::int[]) AND company_code = $2 RETURNING id`, [ids, companyCode] ); logger.info("출하지시 삭제", { companyCode, deletedCount: result.rowCount }); return res.json({ success: true, deletedCount: result.rowCount }); } catch (error: any) { logger.error("출하지시 삭제 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 출하계획 목록 (모달 왼쪽 패널용) ─── export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query; const page = Math.max(1, parseInt(pageStr as string) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20)); const offset = (page - 1) * pageSize; const conditions = ["sp.company_code = $1", "sp.status = 'READY'"]; const params: any[] = [companyCode]; let idx = 2; if (keyword) { conditions.push(`(COALESCE(d.part_code, m.part_code, '') ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } if (customer) { conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${idx})`); params.push(`%${customer}%`); idx++; } const whereClause = conditions.join(" AND "); const fromClause = ` 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 WHERE ${whereClause} `; const pool = getPool(); const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params); const totalCount = parseInt(countResult.rows[0].total); const query = ` SELECT sp.id, sp.plan_qty, sp.plan_date, sp.status, sp.shipment_plan_no, COALESCE(m.order_no, d.order_no, '') AS order_no, COALESCE(d.part_code, m.part_code, '') AS item_code, COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_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, sp.detail_id, sp.sales_order_id ${fromClause} ORDER BY sp.created_date DESC LIMIT $${idx} OFFSET $${idx + 1} `; params.push(pageSize, offset); const result = await pool.query(query, params); return res.json({ success: true, data: result.rows, totalCount, page, pageSize }); } catch (error: any) { logger.error("출하계획 소스 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 수주 목록 (모달 왼쪽 패널용) ─── export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query; const page = Math.max(1, parseInt(pageStr as string) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20)); const offset = (page - 1) * pageSize; const conditions = ["d.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2; if (keyword) { conditions.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } if (customer) { conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(d.delivery_partner_code, m.partner_id, '') ILIKE $${idx})`); params.push(`%${customer}%`); idx++; } const whereClause = conditions.join(" AND "); const fromClause = ` 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 ${whereClause} `; const pool = getPool(); const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params); const totalCount = parseInt(countResult.rows[0].total); const query = ` SELECT d.id, d.order_no, d.part_code AS item_code, COALESCE(i.item_name, d.part_name, d.part_code) AS item_name, COALESCE(d.spec, '') AS spec, COALESCE(m.material, '') AS material, COALESCE(NULLIF(d.qty,'')::numeric, 0) AS qty, COALESCE(NULLIF(d.balance_qty,'')::numeric, 0) AS balance_qty, COALESCE(c.customer_name, COALESCE(d.delivery_partner_code, m.partner_id, '')) AS customer_name, COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code, m.id AS master_id ${fromClause} ORDER BY d.created_date DESC LIMIT $${idx} OFFSET $${idx + 1} `; params.push(pageSize, offset); const result = await pool.query(query, params); return res.json({ success: true, data: result.rows, totalCount, page, pageSize }); } catch (error: any) { logger.error("수주 소스 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } // ─── 품목 목록 (모달 왼쪽 패널용) ─── export async function getItemSource(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; const { keyword, page: pageStr, pageSize: pageSizeStr } = req.query; const page = Math.max(1, parseInt(pageStr as string) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20)); const offset = (page - 1) * pageSize; const conditions = ["company_code = $1"]; const params: any[] = [companyCode]; let idx = 2; if (keyword) { conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } 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); const query = ` SELECT item_number AS item_code, item_name, COALESCE(size, '') AS spec, COALESCE(material, '') AS material FROM item_info WHERE ${whereClause} ORDER BY item_name LIMIT $${idx} OFFSET $${idx + 1} `; params.push(pageSize, offset); const result = await pool.query(query, params); return res.json({ success: true, data: result.rows, totalCount, page, pageSize }); } catch (error: any) { logger.error("품목 소스 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } }