Files
vexplor/backend-node/src/controllers/popInOutHistoryController.ts
kmh 2a6577701b Add POP Inventory Move Feature and Update Shared POP Components
- New POP inventory move page and API (popInventoryMoveController, popInventoryMove client) deployed across COMPANY_7/8/9/10/16/29/30
- Updates to shared POP components (PopShell, AcceptProcessModal, ProcessWork) and inout-manage/inventory pages
- COMPANY_7 POP.md updated with new scope notes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:03:02 +09:00

258 lines
8.2 KiB
TypeScript

/**
* 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 });
}
}