- Introduced new routes and controllers for managing shipping orders, including listing, saving, and previewing next order numbers. - Added design management routes and controller for handling design requests, projects, tasks, and work logs. - Implemented company code filtering for multi-tenancy support in both shipping order and design request functionalities. - Enhanced the shipping plan routes to include listing and updating plans, improving overall shipping management capabilities. These changes aim to provide comprehensive management features for shipping orders and design processes, facilitating better organization and tracking within the application.
672 lines
22 KiB
TypeScript
672 lines
22 KiB
TypeScript
/**
|
|
* 출하계획 컨트롤러
|
|
*
|
|
* 수주 마스터(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<NormalizedOrder[]> {
|
|
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<string, NormalizedOrder[]>();
|
|
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<string, any> = {};
|
|
|
|
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 });
|
|
}
|
|
}
|