From 450f1fe9f5b321f009a085c894b8fd8418540d7e Mon Sep 17 00:00:00 2001 From: kmh Date: Fri, 24 Apr 2026 15:00:34 +0900 Subject: [PATCH] feat(backend/receiving): normalize type via category resolver, rollback source on qty edit, aggregate production received_qty --- .../src/controllers/outboundController.ts | 13 +- .../src/controllers/receivingController.ts | 165 +++++++++++++++++- backend-node/src/utils/categoryUtils.ts | 29 +++ 3 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 backend-node/src/utils/categoryUtils.ts diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 4665faac..29ec2320 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -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, ], ); diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 3c9cf758..f8c635a5 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -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}`, diff --git a/backend-node/src/utils/categoryUtils.ts b/backend-node/src/utils/categoryUtils.ts new file mode 100644 index 00000000..cd3bc46a --- /dev/null +++ b/backend-node/src/utils/categoryUtils.ts @@ -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 { + 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; +}