feat(backend/receiving): normalize type via category resolver, rollback source on qty edit, aggregate production received_qty
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 { 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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
29
backend-node/src/utils/categoryUtils.ts
Normal file
29
backend-node/src/utils/categoryUtils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user