- Add inventoryTransferController/routes for POP inventory move (send/receive) - Add transactionPackagingService for transactional packaging operations - Enhance popProduction/popInventoryMove/popInventoryAdjust controllers - Enhance receiving/outbound/packaging/workInstruction controllers Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1689 lines
58 KiB
TypeScript
1689 lines
58 KiB
TypeScript
/**
|
|
* 입고관리 컨트롤러
|
|
*
|
|
* 입고유형별 소스 테이블:
|
|
* - 구매입고 → purchase_order_mng (발주 헤더) + purchase_detail (발주 디테일)
|
|
* - 반품입고 → shipment_instruction + shipment_instruction_detail (출하)
|
|
* - 기타입고 → item_info (품목)
|
|
*/
|
|
|
|
import type { Response } from "express";
|
|
import { getPool } from "../database/db";
|
|
import type { AuthenticatedRequest } from "../types/auth";
|
|
import { resolveCategoryCode } from "../utils/categoryUtils";
|
|
import { adjustInventory } from "../utils/inventoryUtils";
|
|
import { logger } from "../utils/logger";
|
|
import {
|
|
ensureLoadingInstance,
|
|
insertPackagingRows,
|
|
type PackageEntryInput,
|
|
} from "../services/transactionPackagingService";
|
|
|
|
// 입고 목록 조회 (헤더-디테일 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 ")}` : "";
|
|
|
|
// 같은 (company_code, inbound_number) 헤더가 N건일 때 1건만 사용 (중복 헤더 가드)
|
|
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,
|
|
COALESCE(ii.width::text, '') AS width,
|
|
COALESCE(ii.height::text, '') AS height,
|
|
COALESCE(ii.thickness::text, '') AS thickness
|
|
FROM (
|
|
SELECT DISTINCT ON (h.company_code, h.inbound_number) h.*
|
|
FROM inbound_mng h
|
|
ORDER BY h.company_code, h.inbound_number, h.created_date NULLS LAST
|
|
) 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
|
|
LEFT JOIN LATERAL (
|
|
SELECT width, height, thickness FROM item_info
|
|
WHERE item_number = COALESCE(id.item_number, im.item_number)
|
|
AND company_code = im.company_code
|
|
LIMIT 1
|
|
) ii ON true
|
|
${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 uniqueInboundTypes = [...new Set(items.map((i: any) => i.inbound_type).filter(Boolean))];
|
|
let inboundType = uniqueInboundTypes.length === 1
|
|
? uniqueInboundTypes[0]
|
|
: uniqueInboundTypes.length > 1
|
|
? "혼합입고"
|
|
: (items[0].inbound_type || null);
|
|
const inboundNumber = inbound_number || items[0].inbound_number;
|
|
|
|
await client.query("BEGIN");
|
|
|
|
// 담당자(처리자) 한글명 조회 — inventory_history에 manager_id/manager_name 동반 기록용
|
|
// (CLAUDE.md "사용자 식별 표시 필수": DB에는 user_id 저장, 표시는 user_name)
|
|
let managerName = userId;
|
|
try {
|
|
const mgrRes = await client.query(
|
|
`SELECT COALESCE(NULLIF(user_name, ''), user_id) AS user_name
|
|
FROM user_info WHERE user_id = $1 AND company_code = $2 LIMIT 1`,
|
|
[userId, companyCode],
|
|
);
|
|
if (mgrRes.rows[0]?.user_name) managerName = mgrRes.rows[0].user_name;
|
|
} catch {
|
|
/* user_info 조회 실패 시 userId fallback 유지 */
|
|
}
|
|
|
|
inboundType = await resolveCategoryCode(client, "inbound_mng", "inbound_type", inboundType);
|
|
|
|
// 1. 헤더 — 같은 (company_code, inbound_number) 헤더가 있으면 reuse, 없으면 INSERT (멱등성)
|
|
let headerRow: any;
|
|
const existingHeader = await client.query(
|
|
`SELECT * FROM inbound_mng
|
|
WHERE company_code = $1 AND inbound_number = $2
|
|
ORDER BY created_date NULLS LAST
|
|
LIMIT 1`,
|
|
[companyCode, inboundNumber],
|
|
);
|
|
if (existingHeader.rows.length > 0) {
|
|
headerRow = existingHeader.rows[0];
|
|
logger.info("입고 헤더 reuse (멱등)", {
|
|
companyCode,
|
|
inboundNumber,
|
|
headerId: headerRow.id,
|
|
});
|
|
} else {
|
|
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,
|
|
source_table, source_id,
|
|
created_date, created_by, writer, status
|
|
) VALUES (
|
|
gen_random_uuid()::text, $1, $2, $3, $4::date,
|
|
$5, $6,
|
|
$7, $8, $9, $10,
|
|
$12, $13,
|
|
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,
|
|
items.length === 1 ? (items[0].source_table || null) : null,
|
|
items.length === 1 ? (items[0].source_id || null) : null,
|
|
],
|
|
);
|
|
headerRow = headerResult.rows[0];
|
|
}
|
|
const insertedDetails: any[] = [];
|
|
|
|
// 멱등성 체크는 제거 — 수정 모달에서 "기존 입고에 새 품목 추가" 케이스가 차단되던 버그.
|
|
// 더블클릭 방지는 프론트 setSaving 가드로 처리. 같은 inbound_number에 detail이 이미 있어도
|
|
// 새 품목 추가는 정상 흐름이므로 그대로 INSERT 진행.
|
|
|
|
// 2. 디테일 INSERT (inbound_detail) + 재고/발주 업데이트
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i];
|
|
const seqNo = i + 1;
|
|
|
|
// 저장용 value_code (조건 분기는 원본 item.inbound_type 유지)
|
|
const resolvedItemInboundType = await resolveCategoryCode(
|
|
client,
|
|
"inbound_mng",
|
|
"inbound_type",
|
|
item.inbound_type || inboundType,
|
|
);
|
|
|
|
// 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,
|
|
resolvedItemInboundType,
|
|
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]);
|
|
|
|
// 2a-bis. transaction_loading + transaction_packaging
|
|
// - loading_code 가 있으면 적재함 인스턴스 ensure (같은 헤더 + 코드는 reuse)
|
|
// - packages 가 있으면 박스 단위로 펼쳐 N row INSERT (라벨 자동 발번)
|
|
try {
|
|
const detailId = detailResult.rows[0].id as string;
|
|
const loadingId = await ensureLoadingInstance(client, {
|
|
companyCode,
|
|
sourceType: "inbound",
|
|
sourceDocId: headerRow.id as string,
|
|
loadingCode: item.loading_code || null,
|
|
loadingName: item.loading_name || null,
|
|
loadingSeq: item.loading_seq ?? 1,
|
|
writer: userId,
|
|
});
|
|
|
|
const rawPkgs: any[] = Array.isArray(item.packages) ? item.packages : [];
|
|
const packagesInput: PackageEntryInput[] = rawPkgs
|
|
.map((p) => ({
|
|
pkg_code: String(p?.pkg_code ?? p?.unit?.value ?? ""),
|
|
count: Number(p?.count ?? 0),
|
|
qty_per_unit: Number(p?.qty_per_unit ?? p?.qtyPerUnit ?? 0),
|
|
}))
|
|
.filter((p) => p.pkg_code && p.count > 0 && p.qty_per_unit > 0);
|
|
|
|
if (packagesInput.length > 0) {
|
|
const labels = await insertPackagingRows(client, {
|
|
companyCode,
|
|
sourceType: "inbound_detail",
|
|
sourceId: detailId,
|
|
inboundNumber,
|
|
packages: packagesInput,
|
|
loadingId,
|
|
warehouseCode: warehouse_code || item.warehouse_code || null,
|
|
locationCode: location_code || item.location_code || null,
|
|
lotNo: item.lot_number || null,
|
|
writer: userId,
|
|
});
|
|
insertedDetails[insertedDetails.length - 1].package_labels = labels;
|
|
}
|
|
} catch (pkgErr: any) {
|
|
logger.error("transaction_packaging/loading 저장 실패", {
|
|
error: pkgErr.message,
|
|
inbound_number: inboundNumber,
|
|
item_number: item.item_number,
|
|
});
|
|
throw pkgErr;
|
|
}
|
|
|
|
// 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, manager_id, manager_name, created_date
|
|
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW(), $5, $6, $7, $8, $9, $10, NOW())`,
|
|
[
|
|
companyCode,
|
|
itemCode,
|
|
whCode,
|
|
locCode,
|
|
String(inQty),
|
|
afterQty,
|
|
resolvedItemInboundType || "입고",
|
|
userId,
|
|
userId,
|
|
managerName,
|
|
],
|
|
);
|
|
}
|
|
|
|
// 2c. source_table 기준 소스 데이터 업데이트 (이중 입고 방지)
|
|
const srcTable = item.source_table;
|
|
const srcId = item.source_id;
|
|
|
|
if (srcTable === "purchase_order_mng" && srcId) {
|
|
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, srcId, companyCode],
|
|
);
|
|
} else if (srcTable === "purchase_detail" && srcId) {
|
|
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, srcId, companyCode],
|
|
);
|
|
|
|
const detailInfo = await client.query(
|
|
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
|
|
[srcId, 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,
|
|
received_qty = (
|
|
SELECT CAST(COALESCE(SUM(CAST(NULLIF(received_qty, '') AS numeric)), 0) AS text)
|
|
FROM purchase_detail
|
|
WHERE purchase_no = $2 AND company_code = $3
|
|
),
|
|
remain_qty = (
|
|
SELECT CAST(COALESCE(SUM(
|
|
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
|
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
|
|
), 0) AS text)
|
|
FROM purchase_detail
|
|
WHERE purchase_no = $2 AND company_code = $3
|
|
),
|
|
updated_date = NOW()
|
|
WHERE purchase_no = $2 AND company_code = $3`,
|
|
[newStatus, purchaseNo, companyCode],
|
|
);
|
|
}
|
|
} else if (srcTable === "work_order_process" && srcId) {
|
|
// 생산입고: target_warehouse_id 세팅 (이중 입고 방지)
|
|
const whCode = warehouse_code || item.warehouse_code || null;
|
|
const locCode = location_code || item.location_code || null;
|
|
await client.query(
|
|
`UPDATE work_order_process
|
|
SET target_warehouse_id = $3,
|
|
target_location_code = $4,
|
|
writer = $5,
|
|
updated_date = NOW()
|
|
WHERE id = $1 AND company_code = $2
|
|
AND target_warehouse_id IS NULL`,
|
|
[srcId, companyCode, whCode, locCode || null, userId],
|
|
);
|
|
} else if (srcTable === "work_order_process_result" && srcId) {
|
|
// wr 단위 입고 — wr에 입고 마킹 컬럼 없음. 멱등은 inbound_mng.source_id 누적으로 보장.
|
|
} else if (srcTable && srcId) {
|
|
// 미처리 소스 테이블 — 추후 업데이트 로직 추가 필요
|
|
logger.warn("입고 소스 업데이트 미처리", {
|
|
source_table: srcTable,
|
|
source_id: srcId,
|
|
inbound_type: item.inbound_type,
|
|
item_number: item.item_number,
|
|
});
|
|
}
|
|
}
|
|
|
|
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");
|
|
|
|
// 변경 전 값 조회 (헤더)
|
|
const oldHeaderRes = await client.query(
|
|
`SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`,
|
|
[id, companyCode],
|
|
);
|
|
if (oldHeaderRes.rowCount === 0) {
|
|
await client.query("ROLLBACK");
|
|
return res
|
|
.status(404)
|
|
.json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
|
|
}
|
|
const oldHeader = oldHeaderRes.rows[0];
|
|
|
|
// 변경 전 값 조회 (디테일, 있을 경우)
|
|
let oldDetail: any = null;
|
|
if (detail_id) {
|
|
const oldDetailRes = await client.query(
|
|
`SELECT * FROM inbound_detail WHERE id = $1 AND company_code = $2`,
|
|
[detail_id, companyCode],
|
|
);
|
|
oldDetail = oldDetailRes.rows[0] || null;
|
|
}
|
|
|
|
const oldQty =
|
|
Number(oldDetail?.inbound_qty ?? oldHeader.inbound_qty) || 0;
|
|
const oldWhCode = oldHeader.warehouse_code || null;
|
|
const oldLocCode = oldHeader.location_code || null;
|
|
const itemCode = oldDetail?.item_number || oldHeader.item_number || null;
|
|
const inboundNumber = oldHeader.inbound_number;
|
|
const inboundType = oldDetail?.inbound_type || oldHeader.inbound_type;
|
|
const srcTable = oldHeader.source_table;
|
|
const srcId = oldHeader.source_id;
|
|
|
|
const newQty =
|
|
inbound_qty !== undefined && inbound_qty !== null
|
|
? Number(inbound_qty)
|
|
: oldQty;
|
|
const newWhCode =
|
|
warehouse_code !== undefined ? warehouse_code : oldWhCode;
|
|
const newLocCode =
|
|
location_code !== undefined ? location_code : oldLocCode;
|
|
|
|
// 입고 레코드 업데이트 (헤더 + 품목 필드 모두)
|
|
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),
|
|
inbound_qty = COALESCE($11::numeric, inbound_qty),
|
|
unit_price = COALESCE($12::numeric, unit_price),
|
|
total_amount = COALESCE($13::numeric, total_amount),
|
|
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,
|
|
inbound_qty || null,
|
|
unit_price || null,
|
|
total_amount || null,
|
|
],
|
|
);
|
|
|
|
// 디테일 업데이트 (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,
|
|
],
|
|
);
|
|
}
|
|
|
|
// 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시
|
|
const qtyChanged = newQty !== oldQty;
|
|
const whChanged =
|
|
(newWhCode || "") !== (oldWhCode || "") ||
|
|
(newLocCode || "") !== (oldLocCode || "");
|
|
|
|
if (itemCode && (qtyChanged || whChanged)) {
|
|
if (whChanged) {
|
|
if (oldQty > 0) {
|
|
await adjustInventory(client, {
|
|
companyCode,
|
|
userId,
|
|
itemCode,
|
|
whCode: oldWhCode,
|
|
locCode: oldLocCode,
|
|
delta: -oldQty,
|
|
transactionType: "입고취소",
|
|
remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}`,
|
|
});
|
|
}
|
|
if (newQty > 0) {
|
|
await adjustInventory(client, {
|
|
companyCode,
|
|
userId,
|
|
itemCode,
|
|
whCode: newWhCode,
|
|
locCode: newLocCode,
|
|
delta: newQty,
|
|
transactionType: "입고수정",
|
|
remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}, 수량 ${oldQty}→${newQty}`,
|
|
});
|
|
}
|
|
} else {
|
|
const delta = newQty - oldQty;
|
|
if (delta !== 0) {
|
|
await adjustInventory(client, {
|
|
companyCode,
|
|
userId,
|
|
itemCode,
|
|
whCode: newWhCode,
|
|
locCode: newLocCode,
|
|
delta,
|
|
transactionType: "입고수정",
|
|
remark: `입고수정 (${inboundNumber}) 수량 ${oldQty}→${newQty}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 발주 롤백: 구매입고인 경우 수량 delta를 원본 purchase_order_mng / purchase_detail에 반영
|
|
if (
|
|
qtyChanged &&
|
|
inboundType === "구매입고" &&
|
|
srcId &&
|
|
(srcTable === "purchase_order_mng" || srcTable === "purchase_detail")
|
|
) {
|
|
const delta = newQty - oldQty;
|
|
|
|
if (srcTable === "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(
|
|
GREATEST(
|
|
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
|
- GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0),
|
|
0
|
|
) AS text
|
|
),
|
|
status = CASE
|
|
WHEN GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0) <= 0
|
|
THEN '발주확정'
|
|
WHEN GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0)
|
|
>= COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
|
THEN '입고완료'
|
|
ELSE '부분입고'
|
|
END,
|
|
updated_date = NOW()
|
|
WHERE id = $2 AND company_code = $3`,
|
|
[delta, srcId, companyCode],
|
|
);
|
|
} else if (srcTable === "purchase_detail") {
|
|
await client.query(
|
|
`UPDATE purchase_detail SET
|
|
received_qty = CAST(
|
|
GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0) AS text
|
|
),
|
|
balance_qty = CAST(
|
|
GREATEST(
|
|
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
|
- GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0),
|
|
0
|
|
) AS text
|
|
),
|
|
updated_date = NOW()
|
|
WHERE id = $2 AND company_code = $3`,
|
|
[delta, srcId, companyCode],
|
|
);
|
|
|
|
const detailInfo = await client.query(
|
|
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
|
|
[srcId, 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 anyReceived = await client.query(
|
|
`SELECT id FROM purchase_detail
|
|
WHERE purchase_no = $1 AND company_code = $2
|
|
AND COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0
|
|
LIMIT 1`,
|
|
[purchaseNo, companyCode],
|
|
);
|
|
const newStatus =
|
|
anyReceived.rows.length === 0
|
|
? "발주확정"
|
|
: unreceived.rows.length === 0
|
|
? "입고완료"
|
|
: "부분입고";
|
|
await client.query(
|
|
`UPDATE purchase_order_mng SET
|
|
status = $1,
|
|
received_qty = (
|
|
SELECT CAST(COALESCE(SUM(CAST(NULLIF(received_qty, '') AS numeric)), 0) AS text)
|
|
FROM purchase_detail
|
|
WHERE purchase_no = $2 AND company_code = $3
|
|
),
|
|
remain_qty = (
|
|
SELECT CAST(COALESCE(SUM(
|
|
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
|
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
|
|
), 0) AS text)
|
|
FROM purchase_detail
|
|
WHERE purchase_no = $2 AND company_code = $3
|
|
),
|
|
updated_date = NOW()
|
|
WHERE purchase_no = $2 AND company_code = $3`,
|
|
[newStatus, purchaseNo, companyCode],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 생산입고 롤백: 수량 변경 시 work_order_process.target_warehouse_id를 NULL로 복귀
|
|
// → POP 생산입고 화면에서 잔량 기준으로 다시 조회 (received_qty는 inbound_detail 집계)
|
|
if (qtyChanged && srcTable === "work_order_process" && srcId) {
|
|
await client.query(
|
|
`UPDATE work_order_process
|
|
SET target_warehouse_id = NULL,
|
|
target_location_code = NULL,
|
|
updated_date = NOW()
|
|
WHERE id = $1 AND company_code = $2`,
|
|
[srcId, companyCode],
|
|
);
|
|
}
|
|
|
|
await client.query("COMMIT");
|
|
|
|
logger.info("입고 수정", {
|
|
companyCode,
|
|
userId,
|
|
id,
|
|
detail_id,
|
|
oldQty,
|
|
newQty,
|
|
oldWhCode,
|
|
newWhCode,
|
|
inboundType,
|
|
srcTable,
|
|
srcId,
|
|
});
|
|
|
|
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 userId = req.user!.userId;
|
|
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,
|
|
NULLIF(ii.unit, '') AS unit,
|
|
COALESCE(po.status, '') AS status,
|
|
COALESCE(pd.due_date, po.due_date) AS due_date,
|
|
'purchase_detail' AS source_table,
|
|
CASE WHEN EXISTS (
|
|
SELECT 1 FROM item_inspection_info iii
|
|
WHERE iii.company_code = pd.company_code
|
|
AND COALESCE(iii.is_active, 'Y') = 'Y'
|
|
AND iii.inspection_type = '입고검사'
|
|
AND iii.item_code = COALESCE(NULLIF(pd.item_code, ''), ii.item_number)
|
|
) THEN 'self' ELSE NULL END AS inspection_type
|
|
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,
|
|
NULL::text AS unit,
|
|
po.status,
|
|
po.due_date,
|
|
'purchase_order_mng' AS source_table,
|
|
CASE WHEN EXISTS (
|
|
SELECT 1 FROM item_inspection_info iii
|
|
WHERE iii.company_code = po.company_code
|
|
AND COALESCE(iii.is_active, 'Y') = 'Y'
|
|
AND iii.inspection_type = '입고검사'
|
|
AND iii.item_code = po.item_code
|
|
) THEN 'self' ELSE NULL END AS inspection_type
|
|
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.partner_id AS partner_code,
|
|
COALESCE(cm.customer_name, si.partner_id) AS partner_name,
|
|
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,
|
|
NULLIF(ii.unit, '') AS unit
|
|
FROM shipment_instruction si
|
|
JOIN shipment_instruction_detail sid
|
|
ON si.id = sid.instruction_id
|
|
AND si.company_code = sid.company_code
|
|
LEFT JOIN customer_mng cm
|
|
ON cm.customer_code = si.partner_id
|
|
AND cm.company_code = si.company_code
|
|
LEFT JOIN item_info ii
|
|
ON ii.item_number = sid.item_code
|
|
AND ii.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 });
|
|
}
|
|
}
|
|
|
|
// 불량입고용: 실제 출고 완료(outbound_mng) 데이터 조회
|
|
// outbound_status IN ('출고완료', '부분출고') 만 노출
|
|
export async function getOutbounds(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) || 50));
|
|
const offset = (currentPage - 1) * limit;
|
|
|
|
const conditions: string[] = [
|
|
"om.company_code = $1",
|
|
"om.outbound_status IN ('출고완료', '부분출고')",
|
|
];
|
|
const params: any[] = [companyCode];
|
|
let paramIdx = 2;
|
|
|
|
if (keyword) {
|
|
conditions.push(
|
|
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.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 outbound_mng om
|
|
WHERE ${whereClause}`,
|
|
params,
|
|
);
|
|
const totalCount = parseInt(countResult.rows[0].total, 10);
|
|
|
|
const dataResult = await pool.query(
|
|
`SELECT
|
|
om.id AS outbound_id,
|
|
om.outbound_number,
|
|
om.outbound_date,
|
|
om.customer_code,
|
|
COALESCE(cm.customer_name, om.customer_name, om.customer_code) AS customer_name,
|
|
om.outbound_status,
|
|
om.item_code,
|
|
om.item_name,
|
|
om.specification AS spec,
|
|
om.material,
|
|
COALESCE(NULLIF(om.outbound_qty, '')::numeric, 0) AS outbound_qty,
|
|
COALESCE(sid_match.order_qty, 0) AS sales_order_qty,
|
|
(COALESCE(sid_match.order_qty, 0)
|
|
- COALESCE(NULLIF(om.outbound_qty, '')::numeric, 0)) AS remain_qty,
|
|
om.source_type,
|
|
NULLIF(om.unit, '') AS unit
|
|
FROM outbound_mng om
|
|
LEFT JOIN customer_mng cm
|
|
ON cm.customer_code = om.customer_code
|
|
AND cm.company_code = om.company_code
|
|
LEFT JOIN LATERAL (
|
|
SELECT COALESCE(sid.order_qty, 0) AS order_qty
|
|
FROM shipment_instruction si
|
|
JOIN shipment_instruction_detail sid
|
|
ON sid.instruction_id = si.id
|
|
AND sid.company_code = si.company_code
|
|
WHERE si.company_code = om.company_code
|
|
AND si.instruction_no = om.reference_number
|
|
AND sid.item_code = om.item_code
|
|
LIMIT 1
|
|
) sid_match ON true
|
|
WHERE ${whereClause}
|
|
ORDER BY om.outbound_date DESC, om.outbound_number DESC
|
|
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, inventory_unit,
|
|
COALESCE(width::text, '') AS width,
|
|
COALESCE(height::text, '') AS height,
|
|
COALESCE(thickness::text, '') AS thickness,
|
|
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 getProductionResults(
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { processCode, keyword, pageSize } = req.query;
|
|
|
|
if (!processCode) {
|
|
return res
|
|
.status(400)
|
|
.json({ success: false, message: "processCode 필수" });
|
|
}
|
|
|
|
const limit = Math.min(500, Math.max(1, Number(pageSize) || 50));
|
|
const params: any[] = [companyCode, processCode];
|
|
let paramIdx = 3;
|
|
|
|
let keywordCondition = "";
|
|
if (keyword) {
|
|
keywordCondition = `AND (wi.work_instruction_no ILIKE $${paramIdx} OR COALESCE(ii.item_name, '') ILIKE $${paramIdx} OR COALESCE(ii.item_number, '') ILIKE $${paramIdx})`;
|
|
params.push(`%${keyword}%`);
|
|
paramIdx++;
|
|
}
|
|
|
|
const pool = getPool();
|
|
|
|
// 실적 컬럼(good_qty/concession_qty/result_status/is_rework)은 work_order_process가 아닌
|
|
// work_order_process_result에 존재 → wopr_agg로 LEFT JOIN하여 합계/대표값 사용
|
|
const dataResult = await pool.query(
|
|
`SELECT
|
|
wr.id,
|
|
wop.id AS wop_id,
|
|
wop.wo_id,
|
|
wi.work_instruction_no,
|
|
wi.start_date AS order_date,
|
|
COALESCE(CAST(NULLIF(wi.qty, '') AS numeric), 0) AS instruction_qty,
|
|
wop.process_code,
|
|
wop.process_name,
|
|
wop.seq_no,
|
|
COALESCE(ii.item_number, wi.item_id) AS item_code,
|
|
COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name,
|
|
COALESCE(ii.size, '') AS spec,
|
|
COALESCE(ii.material, '') AS material,
|
|
NULLIF(ii.unit, '') AS unit,
|
|
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) AS good_qty,
|
|
COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS concession_qty,
|
|
COALESCE(wr.is_rework, '') AS is_rework,
|
|
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
|
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS order_qty,
|
|
COALESCE(rcv.received_qty, 0) AS received_qty,
|
|
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
|
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
|
- COALESCE(rcv.received_qty, 0) AS remain_qty,
|
|
'work_order_process_result' AS source_table,
|
|
wr.result_status,
|
|
COALESCE(prev.skipped, false) AS prev_skipped,
|
|
COALESCE(ii.image, NULL) AS image,
|
|
CASE WHEN EXISTS (
|
|
SELECT 1 FROM item_inspection_info iii
|
|
WHERE iii.company_code = wop.company_code
|
|
AND COALESCE(iii.is_active, 'Y') = 'Y'
|
|
AND iii.item_code = COALESCE(ii.item_number, wi.item_id)
|
|
) THEN 'self' ELSE NULL END AS inspection_type,
|
|
tp_agg.packages,
|
|
tl.loading_code,
|
|
tl.loading_name
|
|
FROM work_order_process_result wr
|
|
JOIN work_order_process wop
|
|
ON wop.id = wr.wop_id
|
|
AND wop.company_code = wr.company_code
|
|
JOIN work_instruction wi
|
|
ON wi.id = wop.wo_id
|
|
AND wi.company_code = wop.company_code
|
|
LEFT JOIN (
|
|
SELECT DISTINCT ON (id, company_code)
|
|
id, item_number, item_name, size, material, unit, image, company_code
|
|
FROM item_info
|
|
ORDER BY id, company_code, created_date DESC
|
|
) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code
|
|
LEFT JOIN (
|
|
SELECT wop_id, company_code,
|
|
SUM(COALESCE(CAST(NULLIF(good_qty, '') AS numeric), 0)) AS sum_good,
|
|
SUM(COALESCE(CAST(NULLIF(concession_qty, '') AS numeric), 0)) AS sum_concession,
|
|
SUM(CASE WHEN is_rework = 'Y' THEN 1 ELSE 0 END) AS rework_count,
|
|
MAX(result_status) AS last_status
|
|
FROM work_order_process_result
|
|
WHERE company_code = $1
|
|
GROUP BY wop_id, company_code
|
|
) wopr_agg ON wopr_agg.wop_id = wop.id AND wopr_agg.company_code = wop.company_code
|
|
LEFT JOIN (
|
|
SELECT im.source_id,
|
|
SUM(COALESCE(CAST(NULLIF(id.inbound_qty::text, '') AS numeric), 0)) AS received_qty
|
|
FROM inbound_detail id
|
|
JOIN inbound_mng im
|
|
ON id.inbound_id = im.inbound_number
|
|
AND id.company_code = im.company_code
|
|
WHERE im.source_table = 'work_order_process_result'
|
|
AND im.company_code = $1
|
|
GROUP BY im.source_id
|
|
) rcv ON rcv.source_id = wr.id
|
|
LEFT JOIN LATERAL (
|
|
SELECT BOOL_OR(wr2.result_status = 'skipped') AS skipped
|
|
FROM work_order_process wop2
|
|
LEFT JOIN work_order_process_result wr2
|
|
ON wr2.wop_id = wop2.id AND wr2.company_code = wop2.company_code
|
|
WHERE wop2.wo_id = wop.wo_id
|
|
AND wop2.company_code = wop.company_code
|
|
AND CAST(wop2.seq_no AS int) = (
|
|
SELECT MAX(CAST(wop3.seq_no AS int))
|
|
FROM work_order_process wop3
|
|
WHERE wop3.wo_id = wop.wo_id
|
|
AND wop3.company_code = wop.company_code
|
|
AND CAST(wop3.seq_no AS int) < CAST(wop.seq_no AS int)
|
|
)
|
|
) prev ON true
|
|
LEFT JOIN (
|
|
SELECT packed.source_id, packed.company_code,
|
|
JSON_AGG(JSON_BUILD_OBJECT(
|
|
'pkg_code', packed.pkg_code,
|
|
'pkg_name', COALESCE(pu.pkg_name, packed.pkg_code),
|
|
'count', packed.cnt,
|
|
'qty_per_unit', packed.qty_per_unit
|
|
)) AS packages
|
|
FROM (
|
|
SELECT source_id, company_code, pkg_code,
|
|
CAST(quantity AS numeric) AS qty_per_unit,
|
|
COUNT(*)::int AS cnt
|
|
FROM transaction_packaging
|
|
WHERE company_code = $1
|
|
AND source_type = 'work_order_process_result'
|
|
GROUP BY source_id, company_code, pkg_code, quantity
|
|
) packed
|
|
LEFT JOIN pkg_unit pu
|
|
ON pu.pkg_code = packed.pkg_code
|
|
AND pu.company_code = packed.company_code
|
|
GROUP BY packed.source_id, packed.company_code
|
|
) tp_agg ON tp_agg.source_id = wr.id AND tp_agg.company_code = wr.company_code
|
|
LEFT JOIN (
|
|
SELECT DISTINCT ON (source_doc_id, company_code)
|
|
source_doc_id, company_code,
|
|
loading_code, loading_name
|
|
FROM transaction_loading
|
|
WHERE company_code = $1
|
|
AND source_type = 'work_order_process_result'
|
|
ORDER BY source_doc_id, company_code, COALESCE(loading_seq, 1) ASC
|
|
) tl ON tl.source_doc_id = wr.id AND tl.company_code = wr.company_code
|
|
WHERE wr.company_code = $1
|
|
AND wop.process_code = $2
|
|
AND (
|
|
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
|
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
|
) > 0
|
|
AND (
|
|
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
|
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
|
- COALESCE(rcv.received_qty, 0)
|
|
) > 0
|
|
${keywordCondition}
|
|
ORDER BY wi.work_instruction_no, CAST(wop.seq_no AS int), wr.created_date NULLS LAST
|
|
LIMIT ${limit}`,
|
|
params,
|
|
);
|
|
|
|
return res.json({ success: true, data: dataResult.rows });
|
|
} 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 ruleId =
|
|
(req.query.ruleId as string) || (req.query.rule_id as string);
|
|
|
|
// 1순위: POP 화면설정에서 선택한 채번규칙 사용
|
|
if (ruleId && ruleId !== "__none__") {
|
|
try {
|
|
const { numberingRuleService } = await import(
|
|
"../services/numberingRuleService"
|
|
);
|
|
const newNumber = await numberingRuleService.allocateCode(
|
|
ruleId,
|
|
companyCode,
|
|
);
|
|
return res.json({ success: true, data: newNumber });
|
|
} catch (e: any) {
|
|
logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", {
|
|
ruleId,
|
|
error: e.message,
|
|
});
|
|
// 폴백
|
|
}
|
|
}
|
|
|
|
// 2순위: 기본 하드코딩 채번 (RCV-YYYY-XXXX)
|
|
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 COALESCE(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 });
|
|
}
|
|
}
|