Files
vexplor/backend-node/src/controllers/popInOutHistoryController.ts

258 lines
8.2 KiB
TypeScript
Raw Normal View History

/**
* POP
*
* 3-way UNION (DDL/INSERT 0):
* (1) inbound_mng + inbound_detail ( )
* (2) outbound_mng
* (3) inventory_history ///
* (transaction_type IN ('입고','출고') (1)(2) )
*
* 응답: rows + kpi(inbound/outbound/transfer/total) + totalPages + currentPage
*/
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
const HISTORY_TYPES = ["조정", "조정확인", "이동", "공정입고"];
export async function getInOutHistory(
req: AuthenticatedRequest,
res: Response,
) {
try {
const companyCode = req.user!.companyCode;
const {
date_from,
date_to,
keyword,
direction,
warehouse_code,
page = "1",
page_size = "9",
} = req.query as Record<string, string>;
const pageNum = Math.max(1, parseInt(page, 10) || 1);
const sizeNum = Math.min(100, Math.max(1, parseInt(page_size, 10) || 9));
const offset = (pageNum - 1) * sizeNum;
// ===== 3-way UNION CTE =====
// $1: companyCode (또는 '*' 슈퍼관리자)
// $2: HISTORY_TYPES 배열
const unionSql = `
WITH unified AS (
-- (1) (inbound_mng + inbound_detail )
SELECT
'inbound'::text AS source,
'inbound_mng'::text AS doc_table,
im.id AS doc_id,
id.id AS detail_id,
im.inbound_number AS doc_number,
im.inbound_type AS type,
'inbound'::text AS direction,
COALESCE(id.item_number, im.item_number) AS item_code,
COALESCE(id.item_name, im.item_name) AS item_name,
COALESCE(
CAST(NULLIF(id.inbound_qty::text, '') AS numeric),
CAST(NULLIF(im.inbound_qty::text, '') AS numeric),
0
) AS qty,
COALESCE(id.unit, im.unit, 'EA') AS unit,
im.warehouse_code,
wh.warehouse_name,
im.location_code,
im.inbound_status AS status,
im.created_date,
im.company_code,
NULL::text AS remark,
NULL::text AS reason,
NULL::numeric AS balance_qty,
NULL::numeric AS signed_qty
FROM inbound_mng im
LEFT JOIN inbound_detail id
ON id.inbound_id = im.inbound_number
AND id.company_code = im.company_code
LEFT JOIN warehouse_info wh
ON wh.warehouse_code = im.warehouse_code
AND wh.company_code = im.company_code
WHERE ($1::text = '*' OR im.company_code = $1)
UNION ALL
-- (2) (outbound_mng 1-tier)
SELECT
'outbound', 'outbound_mng', om.id, NULL::text, om.outbound_number, om.outbound_type,
'outbound', om.item_code, om.item_name,
COALESCE(CAST(NULLIF(om.outbound_qty::text, '') AS numeric), 0),
COALESCE(om.unit, 'EA'),
om.warehouse_code, wh.warehouse_name, om.location_code,
om.outbound_status, om.created_date, om.company_code,
NULL::text,
NULL::text,
NULL::numeric,
NULL::numeric
FROM outbound_mng om
LEFT JOIN warehouse_info wh
ON wh.warehouse_code = om.warehouse_code
AND wh.company_code = om.company_code
WHERE ($1::text = '*' OR om.company_code = $1)
UNION ALL
-- (3) /// (inventory_history)
-- / (1)(2) (= ANY($2) )
SELECT
'history', 'inventory_history', ih.id, NULL::text, NULL, ih.transaction_type,
'transfer', ih.item_code,
COALESCE(ii.item_name, ih.item_code),
ABS(COALESCE(CAST(NULLIF(ih.quantity::text, '') AS numeric), 0)),
COALESCE(ii.unit, 'EA'),
ih.warehouse_code, wh.warehouse_name, ih.location_code,
ih.transaction_type, ih.created_date, ih.company_code,
ih.remark,
ih.reason,
COALESCE(CAST(NULLIF(ih.balance_qty::text, '') AS numeric), 0) AS balance_qty,
COALESCE(CAST(NULLIF(ih.quantity::text, '') AS numeric), 0) AS signed_qty
FROM inventory_history ih
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, unit, company_code
FROM item_info
ORDER BY item_number, company_code, created_date DESC
) ii ON ih.item_code = ii.item_number
AND ih.company_code = ii.company_code
LEFT JOIN warehouse_info wh
ON wh.warehouse_code = ih.warehouse_code
AND wh.company_code = ih.company_code
-- row (-)/(+) (2026-05-11 )
WHERE ($1::text = '*' OR ih.company_code = $1)
AND ih.transaction_type = ANY($2::text[])
)
`;
// ===== 공통 필터 ($3 부터, direction 제외) =====
// direction 은 데이터/페이지수에만 적용. KPI 는 항상 direction 무시 (전체 카운트 기준)
const baseConditions: string[] = [];
const baseParams: any[] = [companyCode, HISTORY_TYPES];
let paramIdx = 3;
if (date_from) {
baseConditions.push(`created_date::date >= $${paramIdx}::date`);
baseParams.push(date_from);
paramIdx++;
}
if (date_to) {
baseConditions.push(`created_date::date <= $${paramIdx}::date`);
baseParams.push(date_to);
paramIdx++;
}
if (keyword) {
baseConditions.push(
`(item_name ILIKE '%' || $${paramIdx} || '%'
OR item_code ILIKE '%' || $${paramIdx} || '%')`,
);
baseParams.push(keyword);
paramIdx++;
}
if (warehouse_code && warehouse_code !== "all") {
baseConditions.push(`warehouse_code = $${paramIdx}`);
baseParams.push(warehouse_code);
paramIdx++;
}
const baseWhere =
baseConditions.length > 0
? `WHERE ${baseConditions.join(" AND ")}`
: "";
const pool = getPool();
// ===== KPI 카운트 (direction 필터 미적용) =====
const kpiQuery = `
${unionSql}
SELECT
COUNT(*) FILTER (WHERE direction = 'inbound') AS inbound,
COUNT(*) FILTER (WHERE direction = 'outbound') AS outbound,
COUNT(*) FILTER (WHERE direction = 'transfer' AND type <> '조정') AS transfer,
COUNT(*) FILTER (WHERE type = '조정') AS adjust,
COUNT(*) AS total
FROM unified
${baseWhere}
`;
const kpiResult = await pool.query(kpiQuery, baseParams);
const kpi = {
inbound: parseInt(kpiResult.rows[0].inbound, 10) || 0,
outbound: parseInt(kpiResult.rows[0].outbound, 10) || 0,
transfer: parseInt(kpiResult.rows[0].transfer, 10) || 0,
adjust: parseInt(kpiResult.rows[0].adjust, 10) || 0,
total: parseInt(kpiResult.rows[0].total, 10) || 0,
};
// ===== 페이지 데이터 (direction 필터 적용) =====
const dataConditions = [...baseConditions];
const dataParams: any[] = [...baseParams];
let dataIdx = paramIdx;
if (direction === "adjust") {
dataConditions.push(`type = '조정'`);
} else if (direction === "transfer") {
dataConditions.push(`direction = 'transfer' AND type <> '조정'`);
} else if (direction && direction !== "all") {
dataConditions.push(`direction = $${dataIdx}`);
dataParams.push(direction);
dataIdx++;
}
const dataWhere =
dataConditions.length > 0
? `WHERE ${dataConditions.join(" AND ")}`
: "";
const dataQuery = `
${unionSql}
SELECT * FROM unified
${dataWhere}
ORDER BY created_date DESC NULLS LAST
LIMIT $${dataIdx} OFFSET $${dataIdx + 1}
`;
dataParams.push(sizeNum, offset);
const dataResult = await pool.query(dataQuery, dataParams);
// totalPages 는 direction 적용된 카운트 기반
const filteredCount =
direction === "inbound"
? kpi.inbound
: direction === "outbound"
? kpi.outbound
: direction === "transfer"
? kpi.transfer
: direction === "adjust"
? kpi.adjust
: kpi.total;
const totalPages = Math.max(1, Math.ceil(filteredCount / sizeNum));
logger.info("입출고 통합 이력 조회", {
companyCode,
page: pageNum,
rows: dataResult.rowCount,
kpi,
});
return res.json({
success: true,
data: {
rows: dataResult.rows,
kpi,
totalPages,
currentPage: pageNum,
},
});
} catch (error: any) {
logger.error("입출고 통합 이력 조회 실패", { error: error.message });
return res
.status(500)
.json({ success: false, message: error.message });
}
}