From 9737805bf943fc290c1eeb8a6201f6ca8fedd740 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 20 Apr 2026 14:14:24 +0900 Subject: [PATCH] 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. --- .../src/controllers/outboundController.ts | 166 ++++++++++++-- .../src/controllers/processInfoController.ts | 59 +++-- .../src/controllers/receivingController.ts | 107 ++++++++- backend-node/src/services/reportService.ts | 11 +- backend-node/src/utils/inventoryUtils.ts | 130 +++++++++++ .../COMPANY_10/logistics/packaging/page.tsx | 34 ++- .../COMPANY_10/logistics/receiving/page.tsx | 13 +- .../(main)/COMPANY_10/production/bom/page.tsx | 2 +- .../(main)/COMPANY_10/purchase/order/page.tsx | 42 ++++ .../(main)/COMPANY_10/sales/quote/page.tsx | 8 +- .../COMPANY_16/logistics/packaging/page.tsx | 34 ++- .../COMPANY_16/logistics/receiving/page.tsx | 13 +- .../(main)/COMPANY_16/production/bom/page.tsx | 2 +- .../(main)/COMPANY_16/purchase/order/page.tsx | 44 ++++ .../(main)/COMPANY_16/sales/order/page.tsx | 19 -- .../(main)/COMPANY_16/sales/quote/page.tsx | 8 +- .../COMPANY_29/logistics/packaging/page.tsx | 34 ++- .../COMPANY_29/logistics/receiving/page.tsx | 13 +- .../(main)/COMPANY_29/production/bom/page.tsx | 2 +- .../(main)/COMPANY_29/purchase/order/page.tsx | 42 ++++ .../(main)/COMPANY_29/sales/quote/page.tsx | 8 +- .../COMPANY_30/logistics/packaging/page.tsx | 34 ++- .../COMPANY_30/logistics/receiving/page.tsx | 13 +- .../(main)/COMPANY_30/production/bom/page.tsx | 2 +- .../(main)/COMPANY_30/purchase/order/page.tsx | 42 ++++ .../(main)/COMPANY_30/sales/quote/page.tsx | 8 +- .../COMPANY_7/logistics/packaging/page.tsx | 34 ++- .../COMPANY_7/logistics/receiving/page.tsx | 13 +- .../(main)/COMPANY_7/production/bom/page.tsx | 2 +- .../process-info/ItemRoutingTab.tsx | 61 ++++-- .../(main)/COMPANY_7/purchase/order/page.tsx | 42 ++++ .../app/(main)/COMPANY_7/sales/quote/page.tsx | 8 +- .../COMPANY_8/logistics/packaging/page.tsx | 34 ++- .../COMPANY_8/logistics/receiving/page.tsx | 13 +- .../(main)/COMPANY_8/production/bom/page.tsx | 2 +- .../(main)/COMPANY_8/purchase/order/page.tsx | 42 ++++ .../app/(main)/COMPANY_8/sales/quote/page.tsx | 8 +- .../COMPANY_9/logistics/packaging/page.tsx | 34 ++- .../COMPANY_9/logistics/receiving/page.tsx | 13 +- .../(main)/COMPANY_9/production/bom/page.tsx | 2 +- .../(main)/COMPANY_9/purchase/order/page.tsx | 42 ++++ .../app/(main)/COMPANY_9/sales/quote/page.tsx | 8 +- frontend/app/globals.css | 2 + .../components/common/TimelineScheduler.tsx | 77 ++++++- .../report/designer/MenuSelectModal.tsx | 207 ++++++------------ .../report/designer/ReportDesignerToolbar.tsx | 11 + frontend/hooks/useCurrent2ndLevelMenuObjid.ts | 65 ++++++ frontend/lib/api/processInfo.ts | 3 +- 48 files changed, 1256 insertions(+), 357 deletions(-) create mode 100644 backend-node/src/utils/inventoryUtils.ts create mode 100644 frontend/hooks/useCurrent2ndLevelMenuObjid.ts diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 3f7cc5a5..4665faac 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 { 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(); } } diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index f57a6613..2793353d 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -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 = {}; + let idsByDetail: Record = {}; + let codesByDetail: Record = {}; 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] ); } } diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 0a40b6e8..ac1130bf 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 { 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, diff --git a/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts index ed87075e..3b035ade 100644 --- a/backend-node/src/services/reportService.ts +++ b/backend-node/src/services/reportService.ts @@ -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( - `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 ); diff --git a/backend-node/src/utils/inventoryUtils.ts b/backend-node/src/utils/inventoryUtils.ts new file mode 100644 index 00000000..a2de32a5 --- /dev/null +++ b/backend-node/src/utils/inventoryUtils.ts @@ -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 { + 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, + ], + ); +} diff --git a/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx index 942002e3..74585bb8 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx @@ -528,9 +528,9 @@ export default function PackagingPage() { {/* 4. 콘텐츠 영역 */} {activeTab === "packing" ? ( -
+
{/* 포장재 목록 테이블 */} -
+
=> { const renderMap: Record>> = { @@ -570,8 +570,8 @@ export default function PackagingPage() {
{/* 매칭 품목 서브패널 */} - {selectedPkg && ( - <> + {selectedPkg ? ( +
매칭 품목 @@ -635,14 +635,21 @@ export default function PackagingPage() { )}
- +
+ ) : ( +
+
+ +

좌측 목록에서 포장재를 선택하세요

+
+
)}
) : ( /* 적재함 관리 탭 */ -
+
{/* 적재함 목록 테이블 */} -
+
@@ -709,8 +716,8 @@ export default function PackagingPage() { {/* 포장구성 서브패널 */} - {selectedLoading && ( - <> + {selectedLoading ? ( +
적재 가능 포장단위 @@ -774,7 +781,14 @@ export default function PackagingPage() {
)}
- +
+ ) : ( +
+
+ +

좌측 목록에서 적재함을 선택하세요

+
+
)}
)} diff --git a/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx index 602afeed..b46930e3 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx @@ -250,6 +250,8 @@ interface SelectedSourceItem { total_amount: number; source_table: string; source_id: string; + detail_id?: string; + header_id?: string; } export default function ReceivingPage() { @@ -584,7 +586,7 @@ export default function ReceivingPage() { const first = grouped[0] || row; setEditMode(true); - setEditItemIds(grouped.map((g) => g.id)); + setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`)); setModalInboundNo(inNo); setModalInboundType(first.inbound_type || "구매입고"); setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); @@ -594,8 +596,10 @@ export default function ReceivingPage() { setModalManager((first as any).manager || ""); setModalMemo(first.memo || ""); setSelectedItems( - grouped.map((g) => ({ - key: g.id, + grouped.map((g, idx) => ({ + key: (g as any).detail_id || `${g.id}__${idx}`, + detail_id: (g as any).detail_id || undefined, + header_id: g.id, inbound_type: (g as any).detail_inbound_type || g.inbound_type || "", reference_number: g.reference_number || "", supplier_code: (g as any).supplier_code || "", @@ -782,7 +786,7 @@ export default function ReceivingPage() { await Promise.all([ ...toDelete.map((id) => deleteReceiving(id)), ...toUpdate.map((item) => - updateReceiving(item.key, { + updateReceiving(item.header_id || item.key, { inbound_date: modalInboundDate, inbound_qty: item.inbound_qty, unit_price: item.unit_price, @@ -790,6 +794,7 @@ export default function ReceivingPage() { warehouse_code: modalWarehouse || undefined, location_code: modalLocation || undefined, memo: modalMemo || undefined, + detail_id: item.detail_id, } as any) ), ...(toCreate.length > 0 diff --git a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx index c8618672..84b7afbb 100644 --- a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx @@ -1772,7 +1772,7 @@ export default function BomManagementPage() { {/* 소요량 */} {isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")} {/* 단위 */} - {isVirtualRoot ? "-" : (node.unit || "-")} + {isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")} {/* 공정구분 */} {isVirtualRoot ? "-" : (node.process_type || "-")} {/* 규격 */} diff --git a/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx index bfbca4e3..bf01613e 100644 --- a/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx @@ -555,6 +555,48 @@ export default function PurchaseOrderPage() { if (divLabel) divValues.push(divLabel); filters.push({ columnName: "division", operator: "in", value: divValues }); } + + // 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용 + const supplierCode = masterForm.supplier_code; + if (supplierCode) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[]; + if (rawIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const uuidIds = rawIds.filter((v) => uuidRegex.test(v)); + const codeIds = rawIds.filter((v) => !uuidRegex.test(v)); + + let convertedIds: string[] = []; + if (codeIds.length > 0) { + const convRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: codeIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] }, + autoFilter: true, + }); + const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || []; + convertedIds = convRows.map((r: any) => r.id).filter(Boolean); + } + + const finalIds = [...new Set([...uuidIds, ...convertedIds])]; + if (finalIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + filters.push({ columnName: "id", operator: "in", value: finalIds }); + } catch { /* skip */ } + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: p, size: s, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, diff --git a/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx index f5d3f4f7..73c6fc40 100644 --- a/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx @@ -16,6 +16,7 @@ import { import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { reportApi } from "@/lib/api/reportApi"; +import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { exportToExcel } from "@/lib/utils/excelExport"; import { useAuth } from "@/hooks/useAuth"; @@ -153,10 +154,13 @@ export default function QuoteManagementPage() { useEffect(() => { fetchQuotes(); }, [fetchQuotes]); + const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid(); + useEffect(() => { + if (current2ndLevelMenuObjid === null) return; (async () => { try { - const res = await reportApi.getReports({ page: 1, limit: 100 }); + const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid); if (res.success) { const items = res.data.items ?? []; setReportList(items); @@ -164,7 +168,7 @@ export default function QuoteManagementPage() { } } catch { /* 무시 */ } })(); - }, []); + }, [current2ndLevelMenuObjid]); // ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ── diff --git a/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx index 942002e3..74585bb8 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx @@ -528,9 +528,9 @@ export default function PackagingPage() { {/* 4. 콘텐츠 영역 */} {activeTab === "packing" ? ( -
+
{/* 포장재 목록 테이블 */} -
+
=> { const renderMap: Record>> = { @@ -570,8 +570,8 @@ export default function PackagingPage() {
{/* 매칭 품목 서브패널 */} - {selectedPkg && ( - <> + {selectedPkg ? ( +
매칭 품목 @@ -635,14 +635,21 @@ export default function PackagingPage() { )}
- +
+ ) : ( +
+
+ +

좌측 목록에서 포장재를 선택하세요

+
+
)}
) : ( /* 적재함 관리 탭 */ -
+
{/* 적재함 목록 테이블 */} -
+
@@ -709,8 +716,8 @@ export default function PackagingPage() { {/* 포장구성 서브패널 */} - {selectedLoading && ( - <> + {selectedLoading ? ( +
적재 가능 포장단위 @@ -774,7 +781,14 @@ export default function PackagingPage() {
)}
- +
+ ) : ( +
+
+ +

좌측 목록에서 적재함을 선택하세요

+
+
)}
)} diff --git a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx index 602afeed..b46930e3 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx @@ -250,6 +250,8 @@ interface SelectedSourceItem { total_amount: number; source_table: string; source_id: string; + detail_id?: string; + header_id?: string; } export default function ReceivingPage() { @@ -584,7 +586,7 @@ export default function ReceivingPage() { const first = grouped[0] || row; setEditMode(true); - setEditItemIds(grouped.map((g) => g.id)); + setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`)); setModalInboundNo(inNo); setModalInboundType(first.inbound_type || "구매입고"); setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); @@ -594,8 +596,10 @@ export default function ReceivingPage() { setModalManager((first as any).manager || ""); setModalMemo(first.memo || ""); setSelectedItems( - grouped.map((g) => ({ - key: g.id, + grouped.map((g, idx) => ({ + key: (g as any).detail_id || `${g.id}__${idx}`, + detail_id: (g as any).detail_id || undefined, + header_id: g.id, inbound_type: (g as any).detail_inbound_type || g.inbound_type || "", reference_number: g.reference_number || "", supplier_code: (g as any).supplier_code || "", @@ -782,7 +786,7 @@ export default function ReceivingPage() { await Promise.all([ ...toDelete.map((id) => deleteReceiving(id)), ...toUpdate.map((item) => - updateReceiving(item.key, { + updateReceiving(item.header_id || item.key, { inbound_date: modalInboundDate, inbound_qty: item.inbound_qty, unit_price: item.unit_price, @@ -790,6 +794,7 @@ export default function ReceivingPage() { warehouse_code: modalWarehouse || undefined, location_code: modalLocation || undefined, memo: modalMemo || undefined, + detail_id: item.detail_id, } as any) ), ...(toCreate.length > 0 diff --git a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx index c8618672..84b7afbb 100644 --- a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx @@ -1772,7 +1772,7 @@ export default function BomManagementPage() { {/* 소요량 */} {isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")} {/* 단위 */} - {isVirtualRoot ? "-" : (node.unit || "-")} + {isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")} {/* 공정구분 */} {isVirtualRoot ? "-" : (node.process_type || "-")} {/* 규격 */} diff --git a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx index bfbca4e3..e32841d2 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx @@ -555,6 +555,50 @@ export default function PurchaseOrderPage() { if (divLabel) divValues.push(divLabel); filters.push({ columnName: "division", operator: "in", value: divValues }); } + + // 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용 + const supplierCode = masterForm.supplier_code; + if (supplierCode) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[]; + if (rawIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + // UUID와 문자열(item_number) 분리 + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const uuidIds = rawIds.filter((v) => uuidRegex.test(v)); + const codeIds = rawIds.filter((v) => !uuidRegex.test(v)); + + // 문자열(item_number)을 item_info에서 id로 변환 + let convertedIds: string[] = []; + if (codeIds.length > 0) { + const convRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: codeIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] }, + autoFilter: true, + }); + const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || []; + convertedIds = convRows.map((r: any) => r.id).filter(Boolean); + } + + const finalIds = [...new Set([...uuidIds, ...convertedIds])]; + if (finalIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + filters.push({ columnName: "id", operator: "in", value: finalIds }); + } catch { /* skip */ } + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: p, size: s, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, diff --git a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx index f62967f1..ef5b10aa 100644 --- a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx @@ -1642,10 +1642,8 @@ export default function SalesOrderPage() { 품명 규격 재질 - 포장재 단위 수량 - 포장수량 단가 금액 납기일 @@ -1664,14 +1662,6 @@ export default function SalesOrderPage() { {row.spec} {row.material} - - updateDetailRow(idx, "packing_material", e.target.value)} - placeholder="포장재" - className="h-8 text-xs w-full" - /> - updateDetailRow(idx, "pack_qty", e.target.value)} - className="h-8 text-xs text-right font-mono w-full" - /> - { fetchQuotes(); }, [fetchQuotes]); + const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid(); + useEffect(() => { + if (current2ndLevelMenuObjid === null) return; (async () => { try { - const res = await reportApi.getReports({ page: 1, limit: 100 }); + const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid); if (res.success) { const items = res.data.items ?? []; setReportList(items); @@ -164,7 +168,7 @@ export default function QuoteManagementPage() { } } catch { /* 무시 */ } })(); - }, []); + }, [current2ndLevelMenuObjid]); // ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ── diff --git a/frontend/app/(main)/COMPANY_29/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/packaging/page.tsx index 942002e3..74585bb8 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/packaging/page.tsx @@ -528,9 +528,9 @@ export default function PackagingPage() { {/* 4. 콘텐츠 영역 */} {activeTab === "packing" ? ( -
+
{/* 포장재 목록 테이블 */} -
+
=> { const renderMap: Record>> = { @@ -570,8 +570,8 @@ export default function PackagingPage() {
{/* 매칭 품목 서브패널 */} - {selectedPkg && ( - <> + {selectedPkg ? ( +
매칭 품목 @@ -635,14 +635,21 @@ export default function PackagingPage() { )}
- +
+ ) : ( +
+
+ +

좌측 목록에서 포장재를 선택하세요

+
+
)}
) : ( /* 적재함 관리 탭 */ -
+
{/* 적재함 목록 테이블 */} -
+
@@ -709,8 +716,8 @@ export default function PackagingPage() { {/* 포장구성 서브패널 */} - {selectedLoading && ( - <> + {selectedLoading ? ( +
적재 가능 포장단위 @@ -774,7 +781,14 @@ export default function PackagingPage() {
)}
- +
+ ) : ( +
+
+ +

좌측 목록에서 적재함을 선택하세요

+
+
)}
)} diff --git a/frontend/app/(main)/COMPANY_29/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/receiving/page.tsx index 602afeed..b46930e3 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/receiving/page.tsx @@ -250,6 +250,8 @@ interface SelectedSourceItem { total_amount: number; source_table: string; source_id: string; + detail_id?: string; + header_id?: string; } export default function ReceivingPage() { @@ -584,7 +586,7 @@ export default function ReceivingPage() { const first = grouped[0] || row; setEditMode(true); - setEditItemIds(grouped.map((g) => g.id)); + setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`)); setModalInboundNo(inNo); setModalInboundType(first.inbound_type || "구매입고"); setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); @@ -594,8 +596,10 @@ export default function ReceivingPage() { setModalManager((first as any).manager || ""); setModalMemo(first.memo || ""); setSelectedItems( - grouped.map((g) => ({ - key: g.id, + grouped.map((g, idx) => ({ + key: (g as any).detail_id || `${g.id}__${idx}`, + detail_id: (g as any).detail_id || undefined, + header_id: g.id, inbound_type: (g as any).detail_inbound_type || g.inbound_type || "", reference_number: g.reference_number || "", supplier_code: (g as any).supplier_code || "", @@ -782,7 +786,7 @@ export default function ReceivingPage() { await Promise.all([ ...toDelete.map((id) => deleteReceiving(id)), ...toUpdate.map((item) => - updateReceiving(item.key, { + updateReceiving(item.header_id || item.key, { inbound_date: modalInboundDate, inbound_qty: item.inbound_qty, unit_price: item.unit_price, @@ -790,6 +794,7 @@ export default function ReceivingPage() { warehouse_code: modalWarehouse || undefined, location_code: modalLocation || undefined, memo: modalMemo || undefined, + detail_id: item.detail_id, } as any) ), ...(toCreate.length > 0 diff --git a/frontend/app/(main)/COMPANY_29/production/bom/page.tsx b/frontend/app/(main)/COMPANY_29/production/bom/page.tsx index c8618672..84b7afbb 100644 --- a/frontend/app/(main)/COMPANY_29/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/bom/page.tsx @@ -1772,7 +1772,7 @@ export default function BomManagementPage() { {/* 소요량 */} {isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")} {/* 단위 */} - {isVirtualRoot ? "-" : (node.unit || "-")} + {isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")} {/* 공정구분 */} {isVirtualRoot ? "-" : (node.process_type || "-")} {/* 규격 */} diff --git a/frontend/app/(main)/COMPANY_29/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_29/purchase/order/page.tsx index bfbca4e3..bf01613e 100644 --- a/frontend/app/(main)/COMPANY_29/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_29/purchase/order/page.tsx @@ -555,6 +555,48 @@ export default function PurchaseOrderPage() { if (divLabel) divValues.push(divLabel); filters.push({ columnName: "division", operator: "in", value: divValues }); } + + // 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용 + const supplierCode = masterForm.supplier_code; + if (supplierCode) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[]; + if (rawIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const uuidIds = rawIds.filter((v) => uuidRegex.test(v)); + const codeIds = rawIds.filter((v) => !uuidRegex.test(v)); + + let convertedIds: string[] = []; + if (codeIds.length > 0) { + const convRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: codeIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] }, + autoFilter: true, + }); + const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || []; + convertedIds = convRows.map((r: any) => r.id).filter(Boolean); + } + + const finalIds = [...new Set([...uuidIds, ...convertedIds])]; + if (finalIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + filters.push({ columnName: "id", operator: "in", value: finalIds }); + } catch { /* skip */ } + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: p, size: s, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, diff --git a/frontend/app/(main)/COMPANY_29/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_29/sales/quote/page.tsx index f5d3f4f7..73c6fc40 100644 --- a/frontend/app/(main)/COMPANY_29/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/quote/page.tsx @@ -16,6 +16,7 @@ import { import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { reportApi } from "@/lib/api/reportApi"; +import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { exportToExcel } from "@/lib/utils/excelExport"; import { useAuth } from "@/hooks/useAuth"; @@ -153,10 +154,13 @@ export default function QuoteManagementPage() { useEffect(() => { fetchQuotes(); }, [fetchQuotes]); + const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid(); + useEffect(() => { + if (current2ndLevelMenuObjid === null) return; (async () => { try { - const res = await reportApi.getReports({ page: 1, limit: 100 }); + const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid); if (res.success) { const items = res.data.items ?? []; setReportList(items); @@ -164,7 +168,7 @@ export default function QuoteManagementPage() { } } catch { /* 무시 */ } })(); - }, []); + }, [current2ndLevelMenuObjid]); // ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ── diff --git a/frontend/app/(main)/COMPANY_30/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/packaging/page.tsx index 942002e3..74585bb8 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/packaging/page.tsx @@ -528,9 +528,9 @@ export default function PackagingPage() { {/* 4. 콘텐츠 영역 */} {activeTab === "packing" ? ( -
+
{/* 포장재 목록 테이블 */} -
+
=> { const renderMap: Record>> = { @@ -570,8 +570,8 @@ export default function PackagingPage() {
{/* 매칭 품목 서브패널 */} - {selectedPkg && ( - <> + {selectedPkg ? ( +
매칭 품목 @@ -635,14 +635,21 @@ export default function PackagingPage() { )}
- +
+ ) : ( +
+
+ +

좌측 목록에서 포장재를 선택하세요

+
+
)}
) : ( /* 적재함 관리 탭 */ -
+
{/* 적재함 목록 테이블 */} -
+
@@ -709,8 +716,8 @@ export default function PackagingPage() { {/* 포장구성 서브패널 */} - {selectedLoading && ( - <> + {selectedLoading ? ( +
적재 가능 포장단위 @@ -774,7 +781,14 @@ export default function PackagingPage() {
)}
- +
+ ) : ( +
+
+ +

좌측 목록에서 적재함을 선택하세요

+
+
)}
)} diff --git a/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx index c6875196..771e624e 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx @@ -280,6 +280,8 @@ interface SelectedSourceItem { total_amount: number; source_table: string; source_id: string; + detail_id?: string; + header_id?: string; } export default function ReceivingPage() { @@ -616,7 +618,7 @@ export default function ReceivingPage() { const first = grouped[0] || row; setEditMode(true); - setEditItemIds(grouped.map((g) => g.id)); + setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`)); setModalInboundNo(inNo); setModalInboundType(first.inbound_type || "구매입고"); setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); @@ -626,8 +628,10 @@ export default function ReceivingPage() { setModalManager((first as any).manager || ""); setModalMemo(first.memo || ""); setSelectedItems( - grouped.map((g) => ({ - key: g.id, + grouped.map((g, idx) => ({ + key: (g as any).detail_id || `${g.id}__${idx}`, + detail_id: (g as any).detail_id || undefined, + header_id: g.id, inbound_type: (g as any).detail_inbound_type || g.inbound_type || "", reference_number: g.reference_number || "", supplier_code: (g as any).supplier_code || "", @@ -814,7 +818,7 @@ export default function ReceivingPage() { await Promise.all([ ...toDelete.map((id) => deleteReceiving(id)), ...toUpdate.map((item) => - updateReceiving(item.key, { + updateReceiving(item.header_id || item.key, { inbound_date: modalInboundDate, inbound_qty: item.inbound_qty, unit_price: item.unit_price, @@ -822,6 +826,7 @@ export default function ReceivingPage() { warehouse_code: modalWarehouse || undefined, location_code: modalLocation || undefined, memo: modalMemo || undefined, + detail_id: item.detail_id, } as any) ), ...(toCreate.length > 0 diff --git a/frontend/app/(main)/COMPANY_30/production/bom/page.tsx b/frontend/app/(main)/COMPANY_30/production/bom/page.tsx index db6fd2a9..645d5609 100644 --- a/frontend/app/(main)/COMPANY_30/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_30/production/bom/page.tsx @@ -1772,7 +1772,7 @@ export default function BomManagementPage() { {/* 소요량 */} {isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")} {/* 단위 */} - {isVirtualRoot ? "-" : (node.unit || "-")} + {isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")} {/* 공정구분 */} {isVirtualRoot ? "-" : (node.process_type || "-")} {/* 규격 */} diff --git a/frontend/app/(main)/COMPANY_30/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_30/purchase/order/page.tsx index 476dea38..db457320 100644 --- a/frontend/app/(main)/COMPANY_30/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_30/purchase/order/page.tsx @@ -596,6 +596,48 @@ export default function PurchaseOrderPage() { if (divLabel) divValues.push(divLabel); filters.push({ columnName: "division", operator: "in", value: divValues }); } + + // 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용 + const supplierCode = masterForm.supplier_code; + if (supplierCode) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[]; + if (rawIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const uuidIds = rawIds.filter((v) => uuidRegex.test(v)); + const codeIds = rawIds.filter((v) => !uuidRegex.test(v)); + + let convertedIds: string[] = []; + if (codeIds.length > 0) { + const convRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: codeIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] }, + autoFilter: true, + }); + const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || []; + convertedIds = convRows.map((r: any) => r.id).filter(Boolean); + } + + const finalIds = [...new Set([...uuidIds, ...convertedIds])]; + if (finalIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + filters.push({ columnName: "id", operator: "in", value: finalIds }); + } catch { /* skip */ } + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: p, size: s, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, diff --git a/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx index 7734d120..0379637d 100644 --- a/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx @@ -16,6 +16,7 @@ import { import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { reportApi } from "@/lib/api/reportApi"; +import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { exportToExcel } from "@/lib/utils/excelExport"; import { useAuth } from "@/hooks/useAuth"; @@ -153,10 +154,13 @@ export default function QuoteManagementPage() { useEffect(() => { fetchQuotes(); }, [fetchQuotes]); + const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid(); + useEffect(() => { + if (current2ndLevelMenuObjid === null) return; (async () => { try { - const res = await reportApi.getReports({ page: 1, limit: 100 }); + const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid); if (res.success) { const items = res.data.items ?? []; setReportList(items); @@ -164,7 +168,7 @@ export default function QuoteManagementPage() { } } catch { /* 무시 */ } })(); - }, []); + }, [current2ndLevelMenuObjid]); // ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ── diff --git a/frontend/app/(main)/COMPANY_7/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/packaging/page.tsx index 942002e3..74585bb8 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/packaging/page.tsx @@ -528,9 +528,9 @@ export default function PackagingPage() { {/* 4. 콘텐츠 영역 */} {activeTab === "packing" ? ( -
+
{/* 포장재 목록 테이블 */} -
+
=> { const renderMap: Record>> = { @@ -570,8 +570,8 @@ export default function PackagingPage() {
{/* 매칭 품목 서브패널 */} - {selectedPkg && ( - <> + {selectedPkg ? ( +
매칭 품목 @@ -635,14 +635,21 @@ export default function PackagingPage() { )}
- +
+ ) : ( +
+
+ +

좌측 목록에서 포장재를 선택하세요

+
+
)}
) : ( /* 적재함 관리 탭 */ -
+
{/* 적재함 목록 테이블 */} -
+
@@ -709,8 +716,8 @@ export default function PackagingPage() { {/* 포장구성 서브패널 */} - {selectedLoading && ( - <> + {selectedLoading ? ( +
적재 가능 포장단위 @@ -774,7 +781,14 @@ export default function PackagingPage() {
)}
- +
+ ) : ( +
+
+ +

좌측 목록에서 적재함을 선택하세요

+
+
)}
)} diff --git a/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx index 602afeed..b46930e3 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx @@ -250,6 +250,8 @@ interface SelectedSourceItem { total_amount: number; source_table: string; source_id: string; + detail_id?: string; + header_id?: string; } export default function ReceivingPage() { @@ -584,7 +586,7 @@ export default function ReceivingPage() { const first = grouped[0] || row; setEditMode(true); - setEditItemIds(grouped.map((g) => g.id)); + setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`)); setModalInboundNo(inNo); setModalInboundType(first.inbound_type || "구매입고"); setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); @@ -594,8 +596,10 @@ export default function ReceivingPage() { setModalManager((first as any).manager || ""); setModalMemo(first.memo || ""); setSelectedItems( - grouped.map((g) => ({ - key: g.id, + grouped.map((g, idx) => ({ + key: (g as any).detail_id || `${g.id}__${idx}`, + detail_id: (g as any).detail_id || undefined, + header_id: g.id, inbound_type: (g as any).detail_inbound_type || g.inbound_type || "", reference_number: g.reference_number || "", supplier_code: (g as any).supplier_code || "", @@ -782,7 +786,7 @@ export default function ReceivingPage() { await Promise.all([ ...toDelete.map((id) => deleteReceiving(id)), ...toUpdate.map((item) => - updateReceiving(item.key, { + updateReceiving(item.header_id || item.key, { inbound_date: modalInboundDate, inbound_qty: item.inbound_qty, unit_price: item.unit_price, @@ -790,6 +794,7 @@ export default function ReceivingPage() { warehouse_code: modalWarehouse || undefined, location_code: modalLocation || undefined, memo: modalMemo || undefined, + detail_id: item.detail_id, } as any) ), ...(toCreate.length > 0 diff --git a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx index c8618672..84b7afbb 100644 --- a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx @@ -1772,7 +1772,7 @@ export default function BomManagementPage() { {/* 소요량 */} {isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")} {/* 단위 */} - {isVirtualRoot ? "-" : (node.unit || "-")} + {isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")} {/* 공정구분 */} {isVirtualRoot ? "-" : (node.process_type || "-")} {/* 규격 */} diff --git a/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx index 89f060d8..18bb8106 100644 --- a/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx @@ -93,7 +93,7 @@ export function ItemRoutingTab() { const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); const [formOutsources, setFormOutsources] = useState([]); - const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]); + const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); const [registerDialogOpen, setRegisterDialogOpen] = useState(false); @@ -117,7 +117,7 @@ export function ItemRoutingTab() { page: 1, size: 500, autoFilter: true, }); const rows = res.data?.data?.data || res.data?.data?.rows || []; - setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" }))); + setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" }))); } catch { /* skip */ } })(); }, []); @@ -309,10 +309,19 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); - const loaded = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0 - ? row.outsource_supplier_list - : (row.outsource_supplier ? [row.outsource_supplier] : []); - setFormOutsources(loaded); + // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) + let loadedIds: string[] = []; + if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) { + loadedIds = row.outsource_supplier_ids; + } else { + const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0 + ? row.outsource_supplier_list + : (row.outsource_supplier ? [row.outsource_supplier] : []); + loadedIds = legacyCodes + .map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id) + .filter((v): v is string => Boolean(v)); + } + setFormOutsources(loadedIds); setDetailDialogOpen(true); }; @@ -333,8 +342,10 @@ export function ItemRoutingTab() { return; } const proc = processes.find((p) => p.process_code === formProcessCode); - const outsourceList = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : []; - const outsourcePrimary = outsourceList[0] || ""; + const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : []; + const outsourcePrimaryCode = outsourceIds.length > 0 + ? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "") + : ""; setDetailSubmitting(true); try { @@ -349,8 +360,8 @@ export function ItemRoutingTab() { is_fixed_order: formFixedOrder, work_type: formWorkType, standard_time: st || "0", - outsource_supplier: outsourcePrimary, - outsource_supplier_list: outsourceList, + outsource_supplier: outsourcePrimaryCode, + outsource_supplier_ids: outsourceIds, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -368,8 +379,8 @@ export function ItemRoutingTab() { is_fixed_order: formFixedOrder, work_type: formWorkType, standard_time: st || "0", - outsource_supplier: outsourcePrimary, - outsource_supplier_list: outsourceList, + outsource_supplier: outsourcePrimaryCode, + outsource_supplier_ids: outsourceIds, } : d, ), @@ -406,7 +417,7 @@ export function ItemRoutingTab() { work_type: d.work_type || "내부", standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", - outsource_supplier_list: d.outsource_supplier_list || (d.outsource_supplier ? [d.outsource_supplier] : []), + outsource_supplier_ids: d.outsource_supplier_ids || [], })); setSaving(true); @@ -489,12 +500,16 @@ export function ItemRoutingTab() { const detailsGridData = useMemo( () => details.map((d) => { - const codes = Array.isArray(d.outsource_supplier_list) && d.outsource_supplier_list.length > 0 - ? d.outsource_supplier_list - : (d.outsource_supplier ? [d.outsource_supplier] : []); - const names = codes - .map((c) => subcontractorOptions.find((s) => s.code === c)?.name || c) - .filter(Boolean); + const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0 + ? d.outsource_supplier_ids + : []; + let names = ids + .map((i) => subcontractorOptions.find((s) => s.id === i)?.name) + .filter((v): v is string => Boolean(v)); + // 레거시 폴백: id 매핑 없을 때 단일 code로 표시 + if (names.length === 0 && d.outsource_supplier) { + names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier]; + } return { ...d, process_display: d.process_name || d.process_code, @@ -933,7 +948,7 @@ export function ItemRoutingTab() { {formOutsources.length === 0 ? "외주업체 선택" : formOutsources - .map((c) => subcontractorOptions.find((s) => s.code === c)?.name || c) + .map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i) .join(", ")} {formOutsources.length} @@ -944,17 +959,17 @@ export function ItemRoutingTab() { {subcontractorOptions.length === 0 ? (
등록된 외주업체가 없어요
) : subcontractorOptions.map((s) => { - const checked = formOutsources.includes(s.code); + const checked = formOutsources.includes(s.id); return (