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();
}
}

View File

@@ -384,26 +384,33 @@ export async function getRoutingDetails(req: AuthenticatedRequest, res: Response
const rows = result.rows;
const detailIds = rows.map((r: any) => r.id).filter(Boolean);
let mappingByDetail: Record<string, string[]> = {};
let idsByDetail: Record<string, string[]> = {};
let codesByDetail: Record<string, string[]> = {};
if (detailIds.length > 0) {
const mapRes = await pool.query(
`SELECT routing_detail_id, subcontractor_code
FROM item_routing_subcontractor
WHERE routing_detail_id = ANY($1::uuid[])
ORDER BY seq_order`,
`SELECT irs.routing_detail_id, irs.subcontractor_id, sm.subcontractor_code
FROM item_routing_subcontractor irs
LEFT JOIN subcontractor_mng sm ON irs.subcontractor_id = sm.id
WHERE irs.routing_detail_id = ANY($1::varchar[])
ORDER BY irs.seq_order`,
[detailIds]
);
for (const m of mapRes.rows) {
const key = String(m.routing_detail_id);
if (!mappingByDetail[key]) mappingByDetail[key] = [];
mappingByDetail[key].push(m.subcontractor_code);
(idsByDetail[key] ||= []).push(m.subcontractor_id);
if (m.subcontractor_code) (codesByDetail[key] ||= []).push(m.subcontractor_code);
}
}
const enriched = rows.map((r: any) => {
const list = mappingByDetail[String(r.id)] || [];
// 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼에 값이 있으면 배열로 포장
if (list.length === 0 && r.outsource_supplier) list.push(r.outsource_supplier);
return { ...r, outsource_supplier_list: list };
const ids = idsByDetail[String(r.id)] || [];
const codes = codesByDetail[String(r.id)] || [];
// 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼(code)에 값이 있으면 code 배열로 반환
const legacyCodes = ids.length === 0 && r.outsource_supplier ? [r.outsource_supplier] : codes;
return {
...r,
outsource_supplier_ids: ids,
outsource_supplier_list: legacyCodes, // 하위호환 별칭 (code 배열)
};
});
return res.json({ success: true, data: enriched });
@@ -440,24 +447,36 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
);
for (const d of details) {
const suppliers: string[] = Array.isArray(d.outsource_supplier_list)
? d.outsource_supplier_list.filter((s: any) => typeof s === "string" && s.trim() !== "")
: (d.outsource_supplier ? [d.outsource_supplier] : []);
const primaryLegacy = suppliers[0] || d.outsource_supplier || "";
const supplierIds: string[] = Array.isArray(d.outsource_supplier_ids)
? d.outsource_supplier_ids.filter((s: any) => typeof s === "string" && s.trim() !== "")
: [];
// legacy code 해석: 첫 번째 subcontractor_id → subcontractor_code 조회
let legacyCode = "";
if (supplierIds.length > 0) {
const codeRes = await client.query(
`SELECT subcontractor_code FROM subcontractor_mng WHERE id=$1 LIMIT 1`,
[supplierIds[0]]
);
legacyCode = codeRes.rows[0]?.subcontractor_code || "";
} else if (d.outsource_supplier) {
// 프론트가 아직 id 없이 code만 보낸 경우(레거시 호환)
legacyCode = d.outsource_supplier;
}
const insertRes = await client.query(
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", primaryLegacy, writer]
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, writer]
);
const newDetailId = insertRes.rows[0].id;
for (let i = 0; i < suppliers.length; i++) {
for (let i = 0; i < supplierIds.length; i++) {
await client.query(
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_code, seq_order)
VALUES (gen_random_uuid(), $1, $2, $3, $4)`,
[companyCode, newDetailId, suppliers[i], i]
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_id, seq_order)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4)`,
[companyCode, newDetailId, supplierIds[i], i]
);
}
}

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";
// 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환)
@@ -472,6 +473,45 @@ export async function update(req: AuthenticatedRequest, res: Response) {
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 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
@@ -506,13 +546,6 @@ export async function update(req: AuthenticatedRequest, res: Response) {
],
);
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) {
@@ -563,9 +596,67 @@ export async function update(req: AuthenticatedRequest, res: Response) {
);
}
// 재고/이력 반영 (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}`,
});
}
}
}
await client.query("COMMIT");
logger.info("입고 수정", { companyCode, userId, id, detail_id });
logger.info("입고 수정", {
companyCode,
userId,
id,
detail_id,
oldQty,
newQty,
oldWhCode,
newWhCode,
});
return res.json({
success: true,

View File

@@ -884,18 +884,23 @@ export class ReportService {
menuObjid: number,
companyCode: string
): Promise<{ items: ReportMaster[]; total: number }> {
// 매핑 없는 리포트(글로벌)는 어느 메뉴에서나 보이고,
// 매핑 있는 리포트는 해당 menu_objid에 매핑된 경우에만 보임.
const companyFilter = companyCode !== "*" ? " AND rm.company_code = $2" : "";
const params = companyCode !== "*" ? [menuObjid, companyCode] : [menuObjid];
const items = await query<ReportMaster>(
`SELECT rm.report_id, rm.report_name_kor, rm.report_name_eng,
`SELECT DISTINCT rm.report_id, rm.report_name_kor, rm.report_name_eng,
rm.template_id, rt.template_name_kor AS template_name,
rm.report_type, rm.company_code, rm.description, rm.use_yn,
rm.created_at, rm.created_by, rm.updated_at, rm.updated_by
FROM report_master rm
JOIN report_menu_mapping rmm ON rm.report_id = rmm.report_id
LEFT JOIN report_template rt ON rm.template_id = rt.template_id
WHERE rmm.menu_objid = $1 AND rm.use_yn = 'Y'${companyFilter}
WHERE rm.use_yn = 'Y'${companyFilter}
AND (
NOT EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id)
OR EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id AND menu_objid = $1)
)
ORDER BY rm.report_name_kor ASC`,
params
);

View File

@@ -0,0 +1,130 @@
import type { PoolClient } from "pg";
export interface AdjustInventoryParams {
companyCode: string;
userId: string;
itemCode: string;
whCode: string | null;
locCode: string | null;
delta: number;
transactionType: string;
remark: string;
validateStockEnough?: boolean;
}
export async function adjustInventory(
client: PoolClient,
params: AdjustInventoryParams,
): Promise<void> {
const {
companyCode,
userId,
itemCode,
whCode,
locCode,
delta,
transactionType,
remark,
validateStockEnough,
} = params;
if (!itemCode || delta === 0) return;
if (validateStockEnough && delta < 0) {
const stockRes = 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 cur = parseFloat(stockRes.rows[0]?.cur || "0");
if (cur + delta < 0) {
throw new Error(
`재고 부족: 품목 ${itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${cur}, 차감 요청 ${-delta}`,
);
}
}
const existing = 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 (existing.rows.length > 0) {
if (delta >= 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`,
[delta, existing.rows[0].id],
);
} else {
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`,
[delta, existing.rows[0].id],
);
}
} else {
const initQty = Math.max(delta, 0);
await client.query(
`INSERT INTO inventory_stock (
id, company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date, last_out_date,
created_date, updated_date, writer
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4,
$5, '0',
${delta > 0 ? "NOW()" : "NULL"},
${delta < 0 ? "NOW()" : "NULL"},
NOW(), NOW(), $6
)`,
[companyCode, itemCode, whCode, locCode, String(initQty), userId],
);
}
const afterRes = 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 = afterRes.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,
$5, NOW(), $6, $7, $8,
$9, NOW()
)`,
[
companyCode,
itemCode,
whCode,
locCode,
transactionType,
(delta > 0 ? "+" : "") + String(delta),
afterQty,
remark,
userId,
],
);
}