Files
vexplor/backend-node/src/controllers/receivingController.ts
kjs e25ca7beca feat: Enhance outbound and receiving functionalities
- Updated inventory history insertion logic in both outbound and receiving controllers to use consistent field names and types.
- Added a new endpoint for retrieving warehouse locations, improving the ability to manage inventory locations.
- Enhanced the outbound page to include location selection based on the selected warehouse, improving user experience and data accuracy.
- Implemented validation for warehouse code duplication during new warehouse registration in the warehouse management page.

These changes aim to streamline inventory management processes and enhance the overall functionality of the logistics module.
2026-04-03 17:38:14 +09:00

932 lines
35 KiB
TypeScript

/**
* 입고관리 컨트롤러
*
* 입고유형별 소스 테이블:
* - 구매입고 → purchase_order_mng (발주 헤더) + purchase_detail (발주 디테일)
* - 반품입고 → shipment_instruction + shipment_instruction_detail (출하)
* - 기타입고 → item_info (품목)
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환)
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const {
inbound_type,
inbound_status,
search_keyword,
date_from,
date_to,
} = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`im.company_code = $${paramIdx}`);
params.push(companyCode);
paramIdx++;
}
if (inbound_type && inbound_type !== "all") {
conditions.push(`im.inbound_type = $${paramIdx}`);
params.push(inbound_type);
paramIdx++;
}
if (inbound_status && inbound_status !== "all") {
conditions.push(`im.inbound_status = $${paramIdx}`);
params.push(inbound_status);
paramIdx++;
}
if (search_keyword) {
conditions.push(
`(im.inbound_number ILIKE $${paramIdx} OR COALESCE(id.item_name, im.item_name) ILIKE $${paramIdx} OR COALESCE(id.item_number, im.item_number) ILIKE $${paramIdx} OR COALESCE(id.supplier_name, im.supplier_name) ILIKE $${paramIdx} OR COALESCE(id.reference_number, im.reference_number) ILIKE $${paramIdx})`
);
params.push(`%${search_keyword}%`);
paramIdx++;
}
if (date_from) {
conditions.push(`im.inbound_date >= $${paramIdx}::date`);
params.push(date_from);
paramIdx++;
}
if (date_to) {
conditions.push(`im.inbound_date <= $${paramIdx}::date`);
params.push(date_to);
paramIdx++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
im.id, im.company_code, im.inbound_number, im.inbound_type, im.inbound_date,
im.warehouse_code, im.location_code, im.inspector, im.manager,
im.inbound_status, im.memo AS header_memo, im.source_table, im.source_id,
im.created_date, im.created_by, im.updated_date, im.updated_by,
im.writer, im.status, im.prev_inbound_qty, im.remark,
COALESCE(id.reference_number, im.reference_number) AS reference_number,
COALESCE(id.supplier_code, im.supplier_code) AS supplier_code,
COALESCE(id.supplier_name, im.supplier_name) AS supplier_name,
COALESCE(id.item_number, im.item_number) AS item_number,
COALESCE(id.item_name, im.item_name) AS item_name,
COALESCE(id.spec, im.spec) AS spec,
COALESCE(id.material, im.material) AS material,
COALESCE(id.unit, im.unit) AS unit,
COALESCE(id.inbound_qty, im.inbound_qty) AS inbound_qty,
COALESCE(id.unit_price, im.unit_price) AS unit_price,
COALESCE(id.total_amount, im.total_amount) AS total_amount,
COALESCE(id.lot_number, im.lot_number) AS lot_number,
COALESCE(id.inspection_status, im.inspection_status) AS inspection_status,
COALESCE(id.memo, im.memo) AS memo,
id.id AS detail_id,
id.seq_no,
id.inbound_type AS detail_inbound_type,
wh.warehouse_name
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 im.warehouse_code = wh.warehouse_code
AND im.company_code = wh.company_code
${whereClause}
ORDER BY im.created_date DESC, id.seq_no ASC
`;
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 });
return res.status(500).json({ success: false, message: error.message });
}
}
// 입고 등록 (헤더 1건 + 디테일 N건)
export async function create(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { items, inbound_number, inbound_date, warehouse_code, location_code, inspector, manager, memo } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "입고 품목이 없습니다." });
}
// 첫 번째 아이템에서 inbound_type 추출 (헤더용)
const inboundType = items[0].inbound_type || null;
const inboundNumber = inbound_number || items[0].inbound_number;
await client.query("BEGIN");
// 1. 헤더 INSERT (inbound_mng) — 품목 컬럼은 NULL
const headerResult = await client.query(
`INSERT INTO inbound_mng (
id, company_code, inbound_number, inbound_type, inbound_date,
warehouse_code, location_code,
inbound_status, inspector, manager, memo,
created_date, created_by, writer, status
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4::date,
$5, $6,
$7, $8, $9, $10,
NOW(), $11, $11, '입고'
) RETURNING *`,
[
companyCode,
inboundNumber,
inboundType,
inbound_date || items[0].inbound_date,
warehouse_code || items[0].warehouse_code || null,
location_code || items[0].location_code || null,
items[0].inbound_status || "대기",
inspector || items[0].inspector || null,
manager || items[0].manager || null,
memo || items[0].memo || null,
userId,
]
);
const headerRow = headerResult.rows[0];
const insertedDetails: any[] = [];
// 2. 디테일 INSERT (inbound_detail) + 재고/발주 업데이트
for (let i = 0; i < items.length; i++) {
const item = items[i];
const seqNo = i + 1;
// 2a. inbound_detail INSERT
const detailResult = await client.query(
`INSERT INTO inbound_detail (
id, company_code, inbound_id, seq_no, inbound_type,
item_number, item_name, spec, material, unit,
inbound_qty, unit_price, total_amount,
lot_number, reference_number, supplier_code, supplier_name,
inspection_status, memo, item_id,
created_date, created_by, writer, status
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4,
$5, $6, $7, $8, $9,
$10, $11, $12,
$13, $14, $15, $16,
$17, $18, $19,
NOW(), $20, $20, '입고'
) RETURNING *`,
[
companyCode,
inboundNumber,
seqNo,
item.inbound_type || inboundType,
item.item_number || null,
item.item_name || null,
item.spec || null,
item.material || null,
item.unit || "EA",
item.inbound_qty || 0,
item.unit_price || 0,
item.total_amount || 0,
item.lot_number || null,
item.reference_number || null,
item.supplier_code || null,
item.supplier_name || null,
item.inspection_status || "대기",
item.memo || null,
item.item_id || null,
userId,
]
);
insertedDetails.push(detailResult.rows[0]);
// 2b. 재고 업데이트 (inventory_stock): 입고 수량 증가 — 기존 로직 유지
const itemCode = item.item_number || null;
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
const inQty = Number(item.inbound_qty) || 0;
if (itemCode && inQty > 0) {
const existingStock = await client.query(
`SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
if (existingStock.rows.length > 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
last_in_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[inQty, existingStock.rows[0].id]
);
} else {
await client.query(
`INSERT INTO inventory_stock (
id, company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date,
created_date, updated_date, writer
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`,
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
);
}
// 2b-2. 재고 이력 기록 (inventory_history)
const afterStockRes = await client.query(
`SELECT current_qty FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
const afterQty = afterStockRes.rows[0]?.current_qty || String(inQty);
await client.query(
`INSERT INTO inventory_history (
id, company_code, item_code, warehouse_code, location_code,
transaction_type, transaction_date, quantity, balance_qty, remark,
writer, created_date
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW(), $5, $6, $7, $8, NOW())`,
[companyCode, itemCode, whCode, locCode, String(inQty), afterQty, item.inbound_type || '입고', userId]
);
}
// 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
await client.query(
`UPDATE purchase_order_mng
SET received_qty = CAST(
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text
),
remain_qty = CAST(
GREATEST(COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text
),
status = CASE
WHEN COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1
>= COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
THEN '입고완료'
ELSE '부분입고'
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode]
);
}
// 구매입고인 경우 purchase_detail 품목별 입고수량 업데이트
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") {
// 1. 해당 purchase_detail의 received_qty 누적 업데이트
await client.query(
`UPDATE purchase_detail SET
received_qty = CAST(
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text
),
balance_qty = CAST(
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
- $1 AS text
),
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode]
);
// 2. 발주 헤더 상태 업데이트
const detailInfo = await client.query(
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode]
);
if (detailInfo.rows.length > 0) {
const purchaseNo = detailInfo.rows[0].purchase_no;
// 잔량 있는 디테일이 있는지 확인
const unreceived = await client.query(
`SELECT id FROM purchase_detail
WHERE purchase_no = $1 AND company_code = $2
AND COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0
LIMIT 1`,
[purchaseNo, companyCode]
);
const newStatus = unreceived.rows.length === 0 ? '입고완료' : '부분입고';
await client.query(
`UPDATE purchase_order_mng SET status = $1, updated_date = NOW()
WHERE purchase_no = $2 AND company_code = $3`,
[newStatus, purchaseNo, companyCode]
);
}
}
}
await client.query("COMMIT");
logger.info("입고 등록 완료", {
companyCode,
userId,
headerCount: 1,
detailCount: insertedDetails.length,
inbound_number: inboundNumber,
});
return res.json({
success: true,
data: { header: headerRow, details: insertedDetails },
message: `${insertedDetails.length}건 입고 등록 완료`,
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("입고 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// 입고 수정 (헤더 + 디테일 분리 업데이트)
export async function update(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const {
inbound_date, inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, manager: mgr, memo,
detail_id,
} = req.body;
await client.query("BEGIN");
// 헤더 업데이트 (inbound_mng) — 헤더 레벨 필드만
const headerResult = await client.query(
`UPDATE inbound_mng SET
inbound_date = COALESCE($1::date, inbound_date),
warehouse_code = COALESCE($2, warehouse_code),
location_code = COALESCE($3, location_code),
inbound_status = COALESCE($4, inbound_status),
inspector = COALESCE($5, inspector),
manager = COALESCE($6, manager),
memo = COALESCE($7, memo),
updated_date = NOW(),
updated_by = $8
WHERE id = $9 AND company_code = $10
RETURNING *`,
[
inbound_date, warehouse_code, location_code,
inbound_status, inspector, mgr, memo,
userId, id, companyCode,
]
);
if (headerResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
}
// 디테일 업데이트 (inbound_detail) — detail_id가 있으면 디테일 레벨 필드 업데이트
let detailRow = null;
if (detail_id) {
const detailResult = await client.query(
`UPDATE inbound_detail SET
inbound_qty = COALESCE($1, inbound_qty),
unit_price = COALESCE($2, unit_price),
total_amount = COALESCE($3, total_amount),
lot_number = COALESCE($4, lot_number),
inspection_status = COALESCE($5, inspection_status),
memo = COALESCE($6, memo),
updated_date = NOW(),
updated_by = $7
WHERE id = $8 AND company_code = $9
RETURNING *`,
[
inbound_qty, unit_price, total_amount,
lot_number, inspection_status, memo,
userId, detail_id, companyCode,
]
);
detailRow = detailResult.rows[0] || null;
} else {
// 레거시 데이터: detail_id 없이 inbound_mng 자체에 품목 정보 업데이트
await client.query(
`UPDATE inbound_mng SET
inbound_qty = COALESCE($1, inbound_qty),
unit_price = COALESCE($2, unit_price),
total_amount = COALESCE($3, total_amount),
lot_number = COALESCE($4, lot_number),
inspection_status = COALESCE($5, inspection_status)
WHERE id = $6 AND company_code = $7`,
[
inbound_qty, unit_price, total_amount,
lot_number, inspection_status,
id, companyCode,
]
);
}
await client.query("COMMIT");
logger.info("입고 수정", { companyCode, userId, id, detail_id });
return res.json({
success: true,
data: { header: headerResult.rows[0], detail: detailRow },
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("입고 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// 입고 삭제 (헤더 + 디테일, 재고/발주 롤백 포함)
export async function deleteReceiving(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await client.query("BEGIN");
// 헤더 정보 조회 (inbound_number, warehouse_code 등)
const headerResult = await client.query(
`SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
if (headerResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
const header = headerResult.rows[0];
const inboundNumber = header.inbound_number;
// 디테일 조회 (재고/발주 롤백용)
const detailResult = await client.query(
`SELECT * FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`,
[inboundNumber, companyCode]
);
// 디테일이 있으면 디테일 기반으로 롤백, 없으면 헤더(레거시) 기반으로 롤백
const rollbackItems = detailResult.rows.length > 0
? detailResult.rows.map((d: any) => ({
item_number: d.item_number,
inbound_qty: d.inbound_qty,
inbound_type: d.inbound_type || header.inbound_type,
source_table: header.source_table,
source_id: header.source_id,
}))
: [{
item_number: header.item_number,
inbound_qty: header.inbound_qty,
inbound_type: header.inbound_type,
source_table: header.source_table,
source_id: header.source_id,
}];
const whCode = header.warehouse_code || null;
const locCode = header.location_code || null;
for (const item of rollbackItems) {
const itemCode = item.item_number || null;
const inQty = Number(item.inbound_qty) || 0;
// 재고 롤백: 입고 수량만큼 차감
if (itemCode && inQty > 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(
GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text
),
updated_date = NOW()
WHERE company_code = $2 AND item_code = $3
AND COALESCE(warehouse_code, '') = COALESCE($4, '')
AND COALESCE(location_code, '') = COALESCE($5, '')`,
[inQty, companyCode, itemCode, whCode || '', locCode || '']
);
// 입고취소 이력 기록
const afterStockRes = await client.query(
`SELECT current_qty FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
await client.query(
`INSERT INTO inventory_history (
id, company_code, item_code, warehouse_code, location_code,
transaction_type, transaction_date, quantity, balance_qty, remark,
writer, created_date
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW(), $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`,
[companyCode, itemCode, whCode, locCode, String(-inQty), afterQty, userId]
);
}
// 구매입고 발주 롤백: purchase_order_mng 기반
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
await client.query(
`UPDATE purchase_order_mng
SET received_qty = CAST(
GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text
),
remain_qty = CAST(
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text
),
status = CASE
WHEN GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) <= 0
THEN '발주확정'
ELSE '부분입고'
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[inQty, item.source_id, companyCode]
);
}
// 구매입고 발주 롤백: purchase_detail 기반
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") {
const detailInfo = await client.query(
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode]
);
if (detailInfo.rows.length > 0) {
const purchaseNo = detailInfo.rows[0].purchase_no;
// 삭제 후 재계산을 위해 현재 입고 건 제외한 미입고 확인
const unreceived = await client.query(
`SELECT pd.id
FROM purchase_detail pd
LEFT JOIN (
SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received
FROM inbound_mng
WHERE source_table = 'purchase_detail' AND company_code = $1
AND inbound_number != $3
GROUP BY source_id
) r ON r.source_id = pd.id
WHERE pd.purchase_no = $2 AND pd.company_code = $1
AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(r.total_received, 0) > 0
LIMIT 1`,
[companyCode, purchaseNo, inboundNumber]
);
// 잔량 있으면 부분입고, 전량 미입고면 발주확정
const hasAnyReceived = await client.query(
`SELECT 1 FROM inbound_mng
WHERE source_table = 'purchase_detail' AND company_code = $1
AND inbound_number != $2
LIMIT 1`,
[companyCode, inboundNumber]
);
const newStatus = hasAnyReceived.rows.length > 0
? (unreceived.rows.length === 0 ? '입고완료' : '부분입고')
: '발주확정';
await client.query(
`UPDATE purchase_order_mng SET status = $1, updated_date = NOW()
WHERE purchase_no = $2 AND company_code = $3`,
[newStatus, purchaseNo, companyCode]
);
}
}
}
// 디테일 삭제
await client.query(
`DELETE FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`,
[inboundNumber, companyCode]
);
// 헤더 삭제
await client.query(
`DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
await client.query("COMMIT");
logger.info("입고 삭제", { companyCode, id, inboundNumber });
return res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("입고 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// 구매입고용: 발주 데이터 조회 (미입고분) - 신규 헤더-디테일 구조 + 레거시 단일 테이블 UNION ALL
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page, pageSize } = req.query;
const currentPage = Math.max(1, Number(page) || 1);
const limit = Math.min(500, Math.max(1, Number(pageSize) || 20));
const offset = (currentPage - 1) * limit;
const params: any[] = [companyCode];
let paramIdx = 2;
let keywordConditionDetail = "";
let keywordConditionLegacy = "";
if (keyword) {
keywordConditionDetail = `AND (pd.purchase_no ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_name, ''), ii.item_name) ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_code, ''), ii.item_number) ILIKE $${paramIdx} OR COALESCE(pd.supplier_name, po.supplier_name) ILIKE $${paramIdx})`;
keywordConditionLegacy = `AND (po.purchase_no ILIKE $${paramIdx} OR po.item_name ILIKE $${paramIdx} OR po.item_code ILIKE $${paramIdx} OR po.supplier_name ILIKE $${paramIdx})`;
params.push(`%${keyword}%`);
paramIdx++;
}
const baseQuery = `
WITH combined AS (
-- 디테일 기반 발주 데이터 (purchase_detail.received_qty로 잔량 계산)
SELECT
pd.id,
COALESCE(po.purchase_no, pd.purchase_no) AS purchase_no,
po.order_date,
COALESCE(pd.supplier_code, po.supplier_code) AS supplier_code,
COALESCE(pd.supplier_name, po.supplier_name) AS supplier_name,
COALESCE(NULLIF(pd.item_code, ''), ii.item_number) AS item_code,
COALESCE(NULLIF(pd.item_name, ''), ii.item_name) AS item_name,
COALESCE(NULLIF(pd.spec, ''), ii.size) AS spec,
COALESCE(NULLIF(pd.material, ''), ii.material) AS material,
COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) AS received_qty,
COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) AS remain_qty,
COALESCE(CAST(NULLIF(pd.unit_price, '') AS numeric), 0) AS unit_price,
COALESCE(po.status, '') AS status,
COALESCE(pd.due_date, po.due_date) AS due_date,
'purchase_detail' AS source_table
FROM purchase_detail pd
LEFT JOIN purchase_order_mng po
ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code
LEFT JOIN item_info ii ON pd.item_id = ii.id
WHERE pd.company_code = $1
AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) > 0
AND COALESCE(pd.approval_status, '') NOT IN ('반려')
AND COALESCE(po.status, '') NOT IN ('입고완료', '취소')
${keywordConditionDetail}
UNION ALL
-- 레거시 단일 테이블 데이터 (purchase_detail에 없는 발주)
SELECT
po.id,
po.purchase_no,
po.order_date,
po.supplier_code,
po.supplier_name,
po.item_code,
po.item_name,
po.spec,
po.material,
COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) AS received_qty,
COALESCE(CAST(NULLIF(po.remain_qty, '') AS numeric),
COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0)
) AS remain_qty,
COALESCE(CAST(NULLIF(po.unit_price, '') AS numeric), 0) AS unit_price,
po.status,
po.due_date,
'purchase_order_mng' AS source_table
FROM purchase_order_mng po
WHERE po.company_code = $1
AND NOT EXISTS (
SELECT 1 FROM purchase_detail pd
WHERE pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code
)
AND COALESCE(CAST(NULLIF(po.remain_qty, '') AS numeric),
COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0)
) > 0
AND po.status NOT IN ('입고완료', '취소')
${keywordConditionLegacy}
)`;
const pool = getPool();
const countResult = await pool.query(
`${baseQuery} SELECT COUNT(*) AS total FROM combined`,
params
);
const totalCount = parseInt(countResult.rows[0].total, 10);
const dataResult = await pool.query(
`${baseQuery} SELECT * FROM combined ORDER BY order_date DESC, purchase_no LIMIT ${limit} OFFSET ${offset}`,
params
);
return res.json({ success: true, data: dataResult.rows, totalCount });
} catch (error: any) {
logger.error("발주 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 반품입고용: 출하 데이터 조회
export async function getShipments(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page, pageSize } = req.query;
const currentPage = Math.max(1, Number(page) || 1);
const limit = Math.min(500, Math.max(1, Number(pageSize) || 20));
const offset = (currentPage - 1) * limit;
const conditions: string[] = ["si.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const whereClause = conditions.join(" AND ");
const pool = getPool();
const countResult = await pool.query(
`SELECT COUNT(*) AS total
FROM shipment_instruction si
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id AND si.company_code = sid.company_code
WHERE ${whereClause}`,
params
);
const totalCount = parseInt(countResult.rows[0].total, 10);
const dataResult = await pool.query(
`SELECT
sid.id AS detail_id,
si.id AS instruction_id,
si.instruction_no,
si.instruction_date,
si.partner_id,
si.status AS instruction_status,
sid.item_code,
sid.item_name,
sid.spec,
sid.material,
COALESCE(sid.ship_qty, 0) AS ship_qty,
COALESCE(sid.order_qty, 0) AS order_qty,
sid.source_type
FROM shipment_instruction si
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id
AND si.company_code = sid.company_code
WHERE ${whereClause}
ORDER BY si.instruction_date DESC, si.instruction_no
LIMIT ${limit} OFFSET ${offset}`,
params
);
return res.json({ success: true, data: dataResult.rows, totalCount });
} catch (error: any) {
logger.error("출하 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 기타입고용: 품목 데이터 조회
export async function getItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page, pageSize, division } = req.query;
const currentPage = Math.max(1, Number(page) || 1);
const limit = Math.min(500, Math.max(1, Number(pageSize) || 20));
const offset = (currentPage - 1) * limit;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
if (division) {
conditions.push(`division ILIKE $${paramIdx}`);
params.push(`%${division}%`);
paramIdx++;
}
const whereClause = conditions.join(" AND ");
const pool = getPool();
const countResult = await pool.query(
`SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`,
params
);
const totalCount = parseInt(countResult.rows[0].total, 10);
const dataResult = await pool.query(
`SELECT
id, item_number, item_name, size AS spec, material, unit,
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
FROM item_info
WHERE ${whereClause}
ORDER BY item_name
LIMIT ${limit} OFFSET ${offset}`,
params
);
return res.json({ success: true, data: dataResult.rows, totalCount });
} catch (error: any) {
logger.error("품목 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 입고번호 자동생성
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const today = new Date();
const yyyy = today.getFullYear();
const prefix = `RCV-${yyyy}-`;
const result = await pool.query(
`SELECT inbound_number FROM inbound_mng
WHERE company_code = $1 AND inbound_number LIKE $2
ORDER BY inbound_number DESC LIMIT 1`,
[companyCode, `${prefix}%`]
);
let seq = 1;
if (result.rows.length > 0) {
const lastNo = result.rows[0].inbound_number;
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
if (!isNaN(lastSeq)) seq = lastSeq + 1;
}
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
return res.json({ success: true, data: newNumber });
} catch (error: any) {
logger.error("입고번호 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 창고 목록 조회
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const result = await pool.query(
`SELECT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1 AND status != '삭제'
ORDER BY warehouse_name`,
[companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("창고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}