feat(backend/receiving): normalize type via category resolver, rollback source on qty edit, aggregate production received_qty

This commit is contained in:
kmh
2026-04-24 15:00:34 +09:00
parent ed07595d97
commit 450f1fe9f5
3 changed files with 199 additions and 8 deletions

View File

@@ -10,6 +10,7 @@
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";
@@ -127,6 +128,14 @@ export async function create(req: AuthenticatedRequest, res: Response) {
const insertedRows: any[] = [];
for (const item of items) {
// 저장용 value_code (조건 분기는 원본 item.outbound_type 유지)
const resolvedItemOutboundType = await resolveCategoryCode(
client,
"outbound_mng",
"outbound_type",
item.outbound_type,
);
const result = await client.query(
`INSERT INTO outbound_mng (
id, company_code, outbound_number, outbound_type, outbound_date,
@@ -152,7 +161,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
[
companyCode,
outbound_number || item.outbound_number,
item.outbound_type,
resolvedItemOutboundType,
outbound_date || item.outbound_date,
item.reference_number || null,
item.customer_code || null,
@@ -260,7 +269,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
locCode,
String(-outQty),
afterQty,
item.outbound_type || "출고",
resolvedItemOutboundType || "출고",
userId,
],
);

View File

@@ -10,6 +10,7 @@
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";
@@ -157,7 +158,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
// 헤더용 inbound_type: 단일이면 그 값, 혼합이면 "혼합입고"
const uniqueInboundTypes = [...new Set(items.map((i: any) => i.inbound_type).filter(Boolean))];
const inboundType = uniqueInboundTypes.length === 1
let inboundType = uniqueInboundTypes.length === 1
? uniqueInboundTypes[0]
: uniqueInboundTypes.length > 1
? "혼합입고"
@@ -166,6 +167,8 @@ export async function create(req: AuthenticatedRequest, res: Response) {
await client.query("BEGIN");
inboundType = await resolveCategoryCode(client, "inbound_mng", "inbound_type", inboundType);
// 1. 헤더 — 같은 (company_code, inbound_number) 헤더가 있으면 reuse, 없으면 INSERT (멱등성)
let headerRow: any;
const existingHeader = await client.query(
@@ -188,11 +191,13 @@ export async function create(req: AuthenticatedRequest, res: Response) {
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 *`,
[
@@ -207,6 +212,8 @@ export async function create(req: AuthenticatedRequest, res: Response) {
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];
@@ -229,6 +236,14 @@ export async function create(req: AuthenticatedRequest, res: Response) {
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 (
@@ -250,7 +265,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
companyCode,
inboundNumber,
seqNo,
item.inbound_type || inboundType,
resolvedItemInboundType,
item.item_number || null,
item.item_name || null,
item.spec || null,
@@ -330,7 +345,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
locCode,
String(inQty),
afterQty,
item.inbound_type || "입고",
resolvedItemInboundType || "입고",
userId,
],
);
@@ -518,6 +533,9 @@ export async function update(req: AuthenticatedRequest, res: Response) {
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
@@ -661,6 +679,122 @@ export async function update(req: AuthenticatedRequest, res: Response) {
}
}
// 발주 롤백: 구매입고인 경우 수량 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("입고 수정", {
@@ -672,6 +806,9 @@ export async function update(req: AuthenticatedRequest, res: Response) {
newQty,
oldWhCode,
newWhCode,
inboundType,
srcTable,
srcId,
});
return res.json({
@@ -1190,9 +1327,10 @@ export async function getProductionResults(
COALESCE(ii.material, '') AS material,
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0) AS order_qty,
0 AS received_qty,
COALESCE(rcv.received_qty, 0) AS received_qty,
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0) AS remain_qty,
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0)
- COALESCE(rcv.received_qty, 0) AS remain_qty,
'work_order_process' AS source_table,
wop.result_status,
COALESCE(ii.image, NULL) AS image,
@@ -1210,12 +1348,27 @@ export async function getProductionResults(
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 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'
AND im.company_code = $1
GROUP BY im.source_id
) rcv ON rcv.source_id = wop.id
WHERE wop.company_code = $1
AND wop.process_code = $2
AND wop.parent_process_id IS NULL
AND (wop.is_rework IS NULL OR wop.is_rework != 'Y')
AND COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0
AND wop.target_warehouse_id IS NULL
AND (
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0)
- COALESCE(rcv.received_qty, 0)
) > 0
${keywordCondition}
ORDER BY wi.work_instruction_no, CAST(wop.seq_no AS int)
LIMIT ${limit}`,

View File

@@ -0,0 +1,29 @@
import type { PoolClient } from "pg";
/**
* value_label 로 category_values 를 조회해 value_code 를 반환한다.
* 매칭되는 카테고리가 없으면 입력 label 을 그대로 돌려준다.
*
* company_code 조건은 걸지 않는다 — 같은 label 은 전사에서 동일한 value_code 로
* 관리되는 것을 전제로, 업체 간 데이터 복사 시에도 값이 깨지지 않게 하기 위함.
*/
export async function resolveCategoryCode(
client: PoolClient,
tableName: string,
columnName: string,
label: string | null | undefined,
): Promise<string | null> {
if (!label) return label ?? null;
const result = await client.query(
`SELECT DISTINCT value_code FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_label = $3
AND is_active = true
LIMIT 1`,
[tableName, columnName, label],
);
return result.rows[0]?.value_code ?? label;
}