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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
130
backend-node/src/utils/inventoryUtils.ts
Normal file
130
backend-node/src/utils/inventoryUtils.ts
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user