- 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>
258 lines
8.2 KiB
TypeScript
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 });
|
|
}
|
|
}
|