- Updated the `update` function in the outbound controller to include detailed inventory adjustments when modifying outbound records, ensuring accurate stock management. - Implemented rollback mechanisms for both outbound and receiving updates to maintain data integrity in case of errors. - Enhanced the `deleteOutbound` function to include inventory recovery and historical logging for deleted outbound records. - Introduced a new utility function `adjustInventory` to handle inventory changes consistently across different controllers. - Improved error handling and logging for better traceability during outbound and receiving operations.
796 lines
24 KiB
TypeScript
796 lines
24 KiB
TypeScript
/**
|
|
* 출고관리 컨트롤러
|
|
*
|
|
* 출고유형별 소스 테이블:
|
|
* - 판매출고 → shipment_instruction + shipment_instruction_detail (출하지시)
|
|
* - 반품출고 → purchase_order_mng (발주/입고)
|
|
* - 기타출고 → item_info (품목)
|
|
*/
|
|
|
|
import type { Response } from "express";
|
|
import { getPool } from "../database/db";
|
|
import type { AuthenticatedRequest } from "../types/auth";
|
|
import { adjustInventory } from "../utils/inventoryUtils";
|
|
import { logger } from "../utils/logger";
|
|
|
|
// 출고 목록 조회
|
|
export async function getList(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const {
|
|
outbound_type,
|
|
outbound_status,
|
|
search_keyword,
|
|
date_from,
|
|
date_to,
|
|
} = req.query;
|
|
|
|
const conditions: string[] = [];
|
|
const params: any[] = [];
|
|
let paramIdx = 1;
|
|
|
|
if (companyCode === "*") {
|
|
// 최고 관리자: 전체 조회
|
|
} else {
|
|
conditions.push(`om.company_code = $${paramIdx}`);
|
|
params.push(companyCode);
|
|
paramIdx++;
|
|
}
|
|
|
|
if (outbound_type && outbound_type !== "all") {
|
|
conditions.push(`om.outbound_type = $${paramIdx}`);
|
|
params.push(outbound_type);
|
|
paramIdx++;
|
|
}
|
|
|
|
if (outbound_status && outbound_status !== "all") {
|
|
conditions.push(`om.outbound_status = $${paramIdx}`);
|
|
params.push(outbound_status);
|
|
paramIdx++;
|
|
}
|
|
|
|
if (search_keyword) {
|
|
conditions.push(
|
|
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})`,
|
|
);
|
|
params.push(`%${search_keyword}%`);
|
|
paramIdx++;
|
|
}
|
|
|
|
if (date_from) {
|
|
conditions.push(`om.outbound_date >= $${paramIdx}`);
|
|
params.push(date_from);
|
|
paramIdx++;
|
|
}
|
|
if (date_to) {
|
|
conditions.push(`om.outbound_date <= $${paramIdx}`);
|
|
params.push(date_to);
|
|
paramIdx++;
|
|
}
|
|
|
|
const whereClause =
|
|
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
|
|
const query = `
|
|
SELECT
|
|
om.*,
|
|
wh.warehouse_name
|
|
FROM outbound_mng om
|
|
LEFT JOIN warehouse_info wh
|
|
ON om.warehouse_code = wh.warehouse_code
|
|
AND om.company_code = wh.company_code
|
|
${whereClause}
|
|
ORDER BY om.created_date DESC
|
|
`;
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// 출고 등록 (다건)
|
|
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,
|
|
outbound_number,
|
|
outbound_date,
|
|
warehouse_code,
|
|
location_code,
|
|
manager_id,
|
|
memo,
|
|
} = req.body;
|
|
|
|
if (!items || !Array.isArray(items) || items.length === 0) {
|
|
return res
|
|
.status(400)
|
|
.json({ success: false, message: "출고 품목이 없습니다." });
|
|
}
|
|
|
|
await client.query("BEGIN");
|
|
|
|
const insertedRows: any[] = [];
|
|
|
|
for (const item of items) {
|
|
const result = await client.query(
|
|
`INSERT INTO outbound_mng (
|
|
id, company_code, outbound_number, outbound_type, outbound_date,
|
|
reference_number, customer_code, customer_name,
|
|
item_code, item_name, specification, material, unit,
|
|
outbound_qty, unit_price, total_amount,
|
|
lot_number, warehouse_code, location_code,
|
|
outbound_status, manager_id, memo,
|
|
source_type, sales_order_id, shipment_plan_id, item_info_id,
|
|
destination_code, delivery_destination, delivery_address,
|
|
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, $20, $21,
|
|
$22, $23, $24, $25,
|
|
$26, $27, $28,
|
|
NOW(), $29, $29, '출고'
|
|
) RETURNING *`,
|
|
[
|
|
companyCode,
|
|
outbound_number || item.outbound_number,
|
|
item.outbound_type,
|
|
outbound_date || item.outbound_date,
|
|
item.reference_number || null,
|
|
item.customer_code || null,
|
|
item.customer_name || null,
|
|
item.item_code || item.item_number || null,
|
|
item.item_name || null,
|
|
item.spec || item.specification || null,
|
|
item.material || null,
|
|
item.unit || "EA",
|
|
item.outbound_qty || 0,
|
|
item.unit_price || 0,
|
|
item.total_amount || 0,
|
|
item.lot_number || null,
|
|
warehouse_code || item.warehouse_code || null,
|
|
location_code || item.location_code || null,
|
|
item.outbound_status || "대기",
|
|
manager_id || item.manager_id || null,
|
|
memo || item.memo || null,
|
|
item.source_type || null,
|
|
item.sales_order_id || null,
|
|
item.shipment_plan_id || null,
|
|
item.item_info_id || null,
|
|
item.destination_code || null,
|
|
item.delivery_destination || null,
|
|
item.delivery_address || null,
|
|
userId,
|
|
],
|
|
);
|
|
|
|
insertedRows.push(result.rows[0]);
|
|
|
|
// 재고 업데이트 (inventory_stock): 출고 수량 차감
|
|
const itemCode = item.item_code || item.item_number || null;
|
|
const whCode = warehouse_code || item.warehouse_code || null;
|
|
const locCode = location_code || item.location_code || null;
|
|
const outQty = Number(item.outbound_qty) || 0;
|
|
if (itemCode && outQty > 0) {
|
|
// 재고 사전 검증: 부족 시 즉시 에러 (트랜잭션 ROLLBACK)
|
|
const stockCheck = await client.query(
|
|
`SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) as cur
|
|
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 currentStock = parseFloat(stockCheck.rows[0]?.cur || "0");
|
|
if (currentStock < outQty) {
|
|
throw new Error(
|
|
`재고 부족: ${item.item_name || itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${currentStock}, 요청 출고 ${outQty}`,
|
|
);
|
|
}
|
|
|
|
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(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text),
|
|
last_out_date = NOW(),
|
|
updated_date = NOW()
|
|
WHERE id = $2`,
|
|
[outQty, existingStock.rows[0].id],
|
|
);
|
|
} else {
|
|
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
|
|
await client.query(
|
|
`INSERT INTO inventory_stock (
|
|
id, company_code, item_code, warehouse_code, location_code,
|
|
current_qty, safety_qty, last_out_date,
|
|
created_date, updated_date, writer
|
|
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
|
|
[companyCode, itemCode, whCode, locCode, userId],
|
|
);
|
|
}
|
|
|
|
// 재고 이력 기록 (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 || "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, $8, NOW())`,
|
|
[
|
|
companyCode,
|
|
itemCode,
|
|
whCode,
|
|
locCode,
|
|
String(-outQty),
|
|
afterQty,
|
|
item.outbound_type || "출고",
|
|
userId,
|
|
],
|
|
);
|
|
}
|
|
|
|
// 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영
|
|
if (
|
|
item.outbound_type === "판매출고" &&
|
|
item.source_id &&
|
|
item.source_type === "shipment_instruction_detail"
|
|
) {
|
|
const outQtyNum = Number(item.outbound_qty) || 0;
|
|
await client.query(
|
|
`UPDATE shipment_instruction_detail
|
|
SET ship_qty = COALESCE(ship_qty, 0) + $1,
|
|
updated_date = NOW()
|
|
WHERE id = $2 AND company_code = $3`,
|
|
[outQtyNum, item.source_id, companyCode],
|
|
);
|
|
|
|
// 출하지시 상세의 detail_id로 수주상세(sales_order_detail) ship_qty도 업데이트
|
|
const sidRes = await client.query(
|
|
`SELECT detail_id FROM shipment_instruction_detail WHERE id = $1 AND company_code = $2`,
|
|
[item.source_id, companyCode],
|
|
);
|
|
const detailId = sidRes.rows[0]?.detail_id;
|
|
if (detailId) {
|
|
await client.query(
|
|
`UPDATE sales_order_detail
|
|
SET ship_qty = (COALESCE(NULLIF(ship_qty,'')::numeric, 0) + $1)::text,
|
|
balance_qty = (COALESCE(NULLIF(qty,'')::numeric, 0) - COALESCE(NULLIF(ship_qty,'')::numeric, 0) - $1)::text,
|
|
updated_date = NOW()
|
|
WHERE id = $2 AND company_code = $3`,
|
|
[outQtyNum, detailId, companyCode],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
await client.query("COMMIT");
|
|
|
|
logger.info("출고 등록 완료", {
|
|
companyCode,
|
|
userId,
|
|
count: insertedRows.length,
|
|
outbound_number,
|
|
});
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: insertedRows,
|
|
message: `${insertedRows.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 {
|
|
outbound_date,
|
|
outbound_qty,
|
|
unit_price,
|
|
total_amount,
|
|
lot_number,
|
|
warehouse_code,
|
|
location_code,
|
|
outbound_status,
|
|
manager_id: mgr,
|
|
memo,
|
|
} = req.body;
|
|
|
|
await client.query("BEGIN");
|
|
|
|
// 변경 전 값 조회
|
|
const oldRes = await client.query(
|
|
`SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`,
|
|
[id, companyCode],
|
|
);
|
|
if (oldRes.rowCount === 0) {
|
|
await client.query("ROLLBACK");
|
|
return res
|
|
.status(404)
|
|
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
|
|
}
|
|
const old = oldRes.rows[0];
|
|
const oldQty = Number(old.outbound_qty) || 0;
|
|
const oldWhCode = old.warehouse_code || null;
|
|
const oldLocCode = old.location_code || null;
|
|
const itemCode = old.item_code || old.item_number || null;
|
|
const outboundNumber = old.outbound_number;
|
|
|
|
const newQty =
|
|
outbound_qty !== undefined && outbound_qty !== null
|
|
? Number(outbound_qty)
|
|
: oldQty;
|
|
const newWhCode =
|
|
warehouse_code !== undefined ? warehouse_code : oldWhCode;
|
|
const newLocCode =
|
|
location_code !== undefined ? location_code : oldLocCode;
|
|
|
|
// 재고/이력 반영 (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: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}`,
|
|
});
|
|
}
|
|
// 신규 창고 차감 (재고부족 검증)
|
|
if (newQty > 0) {
|
|
await adjustInventory(client, {
|
|
companyCode,
|
|
userId,
|
|
itemCode,
|
|
whCode: newWhCode,
|
|
locCode: newLocCode,
|
|
delta: -newQty,
|
|
transactionType: "출고수정",
|
|
remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}, 수량 ${oldQty}→${newQty}`,
|
|
validateStockEnough: true,
|
|
});
|
|
}
|
|
} else {
|
|
// 창고 동일, 수량만 변경: 기존 복구(+oldQty) + 신규 차감(-newQty) = delta(+복구/-추가차감)
|
|
const delta = oldQty - newQty;
|
|
if (delta !== 0) {
|
|
await adjustInventory(client, {
|
|
companyCode,
|
|
userId,
|
|
itemCode,
|
|
whCode: newWhCode,
|
|
locCode: newLocCode,
|
|
delta,
|
|
transactionType: "출고수정",
|
|
remark: `출고수정 (${outboundNumber}) 수량 ${oldQty}→${newQty}`,
|
|
validateStockEnough: delta < 0,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const result = await client.query(
|
|
`UPDATE outbound_mng SET
|
|
outbound_date = COALESCE($1, outbound_date),
|
|
outbound_qty = COALESCE($2, outbound_qty),
|
|
unit_price = COALESCE($3, unit_price),
|
|
total_amount = COALESCE($4, total_amount),
|
|
lot_number = COALESCE($5, lot_number),
|
|
warehouse_code = COALESCE($6, warehouse_code),
|
|
location_code = COALESCE($7, location_code),
|
|
outbound_status = COALESCE($8, outbound_status),
|
|
manager_id = COALESCE($9, manager_id),
|
|
memo = COALESCE($10, memo),
|
|
updated_date = NOW(),
|
|
updated_by = $11
|
|
WHERE id = $12 AND company_code = $13
|
|
RETURNING *`,
|
|
[
|
|
outbound_date,
|
|
outbound_qty,
|
|
unit_price,
|
|
total_amount,
|
|
lot_number,
|
|
warehouse_code,
|
|
location_code,
|
|
outbound_status,
|
|
mgr,
|
|
memo,
|
|
userId,
|
|
id,
|
|
companyCode,
|
|
],
|
|
);
|
|
|
|
await client.query("COMMIT");
|
|
|
|
logger.info("출고 수정", {
|
|
companyCode,
|
|
userId,
|
|
id,
|
|
oldQty,
|
|
newQty,
|
|
oldWhCode,
|
|
newWhCode,
|
|
});
|
|
|
|
return res.json({ success: true, data: result.rows[0] });
|
|
} 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 deleteOutbound(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");
|
|
|
|
// 대상 출고 조회
|
|
const oldRes = await client.query(
|
|
`SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`,
|
|
[id, companyCode],
|
|
);
|
|
if (oldRes.rowCount === 0) {
|
|
await client.query("ROLLBACK");
|
|
return res
|
|
.status(404)
|
|
.json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
|
}
|
|
const old = oldRes.rows[0];
|
|
const itemCode = old.item_code || old.item_number || null;
|
|
const whCode = old.warehouse_code || null;
|
|
const locCode = old.location_code || null;
|
|
const qty = Number(old.outbound_qty) || 0;
|
|
const outboundNumber = old.outbound_number;
|
|
|
|
// 재고 복구 + 이력
|
|
if (itemCode && qty > 0) {
|
|
await adjustInventory(client, {
|
|
companyCode,
|
|
userId,
|
|
itemCode,
|
|
whCode,
|
|
locCode,
|
|
delta: +qty,
|
|
transactionType: "출고취소",
|
|
remark: `출고 삭제 (${outboundNumber})`,
|
|
});
|
|
} else {
|
|
logger.warn("출고 삭제 - 재고 복구 스킵", {
|
|
companyCode,
|
|
id,
|
|
itemCode,
|
|
qty,
|
|
});
|
|
}
|
|
|
|
await client.query(
|
|
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2`,
|
|
[id, companyCode],
|
|
);
|
|
|
|
await client.query("COMMIT");
|
|
|
|
logger.info("출고 삭제", { companyCode, userId, id, itemCode, qty });
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
// 판매출고용: 출하지시 데이터 조회
|
|
export async function getShipmentInstructions(
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { keyword } = req.query;
|
|
|
|
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 pool = getPool();
|
|
const result = 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.plan_qty, 0) AS plan_qty,
|
|
COALESCE(sid.ship_qty, 0) AS ship_qty,
|
|
COALESCE(sid.order_qty, 0) AS order_qty,
|
|
GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_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 ${conditions.join(" AND ")}
|
|
AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0)
|
|
ORDER BY si.instruction_date DESC, si.instruction_no`,
|
|
params,
|
|
);
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// 반품출고용: 발주(입고) 데이터 조회
|
|
export async function getPurchaseOrders(
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { keyword } = req.query;
|
|
|
|
const conditions: string[] = ["company_code = $1"];
|
|
const params: any[] = [companyCode];
|
|
let paramIdx = 2;
|
|
|
|
// 입고된 것만 (반품 대상)
|
|
conditions.push(
|
|
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`,
|
|
);
|
|
|
|
if (keyword) {
|
|
conditions.push(
|
|
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`,
|
|
);
|
|
params.push(`%${keyword}%`);
|
|
paramIdx++;
|
|
}
|
|
|
|
const pool = getPool();
|
|
const result = await pool.query(
|
|
`SELECT
|
|
id, purchase_no, order_date, supplier_code, supplier_name,
|
|
item_code, item_name, spec, material,
|
|
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
|
|
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
|
|
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
|
|
status, due_date
|
|
FROM purchase_order_mng
|
|
WHERE ${conditions.join(" AND ")}
|
|
ORDER BY order_date DESC, purchase_no`,
|
|
params,
|
|
);
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// 기타출고용: 품목 데이터 조회
|
|
export async function getItems(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { keyword } = req.query;
|
|
|
|
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++;
|
|
}
|
|
|
|
const pool = getPool();
|
|
const result = await pool.query(
|
|
`SELECT
|
|
id, item_number, item_name, size AS spec, material, 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 ${conditions.join(" AND ")}
|
|
ORDER BY item_name`,
|
|
params,
|
|
);
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// 출고번호 자동생성
|
|
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순위: 기본 하드코딩 채번 (OUT-YYYY-XXXX)
|
|
const pool = getPool();
|
|
const today = new Date();
|
|
const yyyy = today.getFullYear();
|
|
const prefix = `OUT-${yyyy}-`;
|
|
|
|
const result = await pool.query(
|
|
`SELECT outbound_number FROM outbound_mng
|
|
WHERE company_code = $1 AND outbound_number LIKE $2
|
|
ORDER BY outbound_number DESC LIMIT 1`,
|
|
[companyCode, `${prefix}%`],
|
|
);
|
|
|
|
let seq = 1;
|
|
if (result.rows.length > 0) {
|
|
const lastNo = result.rows[0].outbound_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 });
|
|
}
|
|
}
|
|
|
|
// 창고별 위치 목록 조회
|
|
export async function getLocations(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const warehouseCode = req.query.warehouse_code as string;
|
|
const pool = getPool();
|
|
|
|
const result = await pool.query(
|
|
`SELECT location_code, location_name, warehouse_code
|
|
FROM warehouse_location
|
|
WHERE company_code = $1 ${warehouseCode ? "AND warehouse_code = $2" : ""}
|
|
ORDER BY location_code`,
|
|
warehouseCode ? [companyCode, warehouseCode] : [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 });
|
|
}
|
|
}
|