459 lines
15 KiB
TypeScript
459 lines
15 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 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 } = plan;
|
||
|
|
if (!sourceId || !planQty || planQty <= 0) continue;
|
||
|
|
|
||
|
|
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, CURRENT_DATE, 'READY', $5)
|
||
|
|
RETURNING *`,
|
||
|
|
[companyCode, sourceId, detail.master_id, planQty, 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, CURRENT_DATE, 'READY', $4)
|
||
|
|
RETURNING *`,
|
||
|
|
[companyCode, masterId, planQty, 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 });
|
||
|
|
}
|
||
|
|
}
|