feat: Enhance outbound and receiving update functionalities with inventory adjustments

- 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.
This commit is contained in:
kjs
2026-04-20 14:14:24 +09:00
parent 48b9ba3d2a
commit 9737805bf9
48 changed files with 1256 additions and 357 deletions

View File

@@ -10,6 +10,7 @@
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";
// 출고 목록 조회
@@ -324,6 +325,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
// 출고 수정
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;
@@ -341,8 +345,90 @@ export async function update(req: AuthenticatedRequest, res: Response) {
memo,
} = req.body;
const pool = getPool();
const result = await pool.query(
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),
@@ -375,45 +461,95 @@ export async function update(req: AuthenticatedRequest, res: Response) {
],
);
if (result.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
}
await client.query("COMMIT");
logger.info("출고 수정", { companyCode, userId, id });
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;
const pool = getPool();
const result = await pool.query(
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
await client.query("BEGIN");
// 대상 출고 조회
const oldRes = await client.query(
`SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode],
);
if (result.rowCount === 0) {
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;
logger.info("출고 삭제", { companyCode, id });
// 재고 복구 + 이력
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();
}
}