From 4e4088eb717352e0308af754aab24580197680da Mon Sep 17 00:00:00 2001 From: kmh Date: Mon, 30 Mar 2026 17:01:26 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=9E=85=EA=B3=A0/=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=ED=98=84=ED=99=A9/=EB=B6=84=EC=84=9D=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - receivingController: 헤더-디테일 JOIN 구조로 변경, 검색/조회 로직 개선 - materialStatusController: work_instruction 테이블 기반으로 쿼리 수정 - analyticsReportController: 구매 리포트 company_code 필터링 로직 개선 - material-status 페이지: COMPANY_29/COMPANY_7 프론트엔드 업데이트 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controllers/analyticsReportController.ts | 93 +++- .../controllers/materialStatusController.ts | 64 +-- .../src/controllers/receivingController.ts | 433 ++++++++++++++---- .../logistics/material-status/page.tsx | 8 +- .../logistics/material-status/page.tsx | 8 +- frontend/lib/api/materialStatus.ts | 4 +- 6 files changed, 450 insertions(+), 160 deletions(-) diff --git a/backend-node/src/controllers/analyticsReportController.ts b/backend-node/src/controllers/analyticsReportController.ts index d71eeb9a..33109dc2 100644 --- a/backend-node/src/controllers/analyticsReportController.ts +++ b/backend-node/src/controllers/analyticsReportController.ts @@ -218,37 +218,84 @@ export async function getPurchaseReportData(req: any, res: Response): Promise= $${paramIndex}::date`); + conditions.push(`wi.start_date::date >= $${paramIndex}::date`); params.push(dateFrom); paramIndex++; } if (dateTo) { - conditions.push(`p.plan_date <= $${paramIndex}::date`); + conditions.push(`wi.start_date::date <= $${paramIndex}::date`); params.push(dateTo); paramIndex++; } if (itemCode) { - conditions.push(`p.item_code ILIKE $${paramIndex}`); + conditions.push(`d.item_number ILIKE $${paramIndex}`); params.push(`%${itemCode}%`); paramIndex++; } if (itemName) { - conditions.push(`p.item_name ILIKE $${paramIndex}`); + conditions.push(`COALESCE(itm.item_name, '') ILIKE $${paramIndex}`); params.push(`%${itemName}%`); paramIndex++; } @@ -60,22 +60,28 @@ export async function getWorkOrders( conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const query = ` - SELECT - p.id, - p.plan_no, - p.item_code, - p.item_name, - p.plan_qty, - p.completed_qty, - p.plan_date, - p.start_date, - p.end_date, - p.status, - p.work_order_no, - p.company_code - FROM production_plan_mng p + SELECT + d.id, + wi.work_instruction_no AS plan_no, + d.item_number AS item_code, + COALESCE(itm.item_name, '') AS item_name, + d.qty AS plan_qty, + wi.completed_qty, + wi.start_date AS plan_date, + wi.start_date, + wi.end_date, + wi.status, + wi.work_instruction_no AS work_order_no, + wi.company_code + FROM work_instruction wi + INNER JOIN work_instruction_detail d + ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1 + ) itm ON true ${whereClause} - ORDER BY p.plan_date DESC, p.created_date DESC + ORDER BY wi.start_date DESC, wi.created_date DESC `; const result = await pool.query(query, params); @@ -108,14 +114,14 @@ export async function getMaterialStatus( .json({ success: false, message: "작업지시를 선택해주세요." }); } - // 1) 선택된 작업지시의 품목코드 + 수량 조회 + // 1) 선택된 작업지시 상세의 품목코드 + 수량 조회 const planPlaceholders = planIds .map((_, i) => `$${i + 1}`) .join(","); let paramIndex = planIds.length + 1; const companyCondition = - companyCode === "*" ? "" : `AND p.company_code = $${paramIndex}`; + companyCode === "*" ? "" : `AND d.company_code = $${paramIndex}`; const planParams: any[] = [...planIds]; if (companyCode !== "*") { planParams.push(companyCode); @@ -123,9 +129,13 @@ export async function getMaterialStatus( } const planQuery = ` - SELECT p.item_code, p.item_name, p.plan_qty - FROM production_plan_mng p - WHERE p.id IN (${planPlaceholders}) + SELECT d.item_number AS item_code, COALESCE(itm.item_name, '') AS item_name, d.qty AS plan_qty + FROM work_instruction_detail d + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = d.item_number AND company_code = d.company_code LIMIT 1 + ) itm ON true + WHERE d.id IN (${planPlaceholders}) ${companyCondition} `; diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 79188f7f..3a1aeec4 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -12,7 +12,7 @@ import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; -// 입고 목록 조회 +// 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환) export async function getList(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; @@ -50,7 +50,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { if (search_keyword) { conditions.push( - `(im.inbound_number ILIKE $${paramIdx} OR im.item_name ILIKE $${paramIdx} OR im.item_number ILIKE $${paramIdx} OR im.supplier_name ILIKE $${paramIdx} OR im.reference_number ILIKE $${paramIdx})` + `(im.inbound_number ILIKE $${paramIdx} OR COALESCE(id.item_name, im.item_name) ILIKE $${paramIdx} OR COALESCE(id.item_number, im.item_number) ILIKE $${paramIdx} OR COALESCE(id.supplier_name, im.supplier_name) ILIKE $${paramIdx} OR COALESCE(id.reference_number, im.reference_number) ILIKE $${paramIdx})` ); params.push(`%${search_keyword}%`); paramIdx++; @@ -72,14 +72,37 @@ export async function getList(req: AuthenticatedRequest, res: Response) { const query = ` SELECT - im.*, + im.id, im.company_code, im.inbound_number, im.inbound_type, im.inbound_date, + im.warehouse_code, im.location_code, im.inspector, im.manager, + im.inbound_status, im.memo AS header_memo, im.source_table, im.source_id, + im.created_date, im.created_by, im.updated_date, im.updated_by, + im.writer, im.status, im.prev_inbound_qty, im.remark, + COALESCE(id.reference_number, im.reference_number) AS reference_number, + COALESCE(id.supplier_code, im.supplier_code) AS supplier_code, + COALESCE(id.supplier_name, im.supplier_name) AS supplier_name, + COALESCE(id.item_number, im.item_number) AS item_number, + COALESCE(id.item_name, im.item_name) AS item_name, + COALESCE(id.spec, im.spec) AS spec, + COALESCE(id.material, im.material) AS material, + COALESCE(id.unit, im.unit) AS unit, + COALESCE(id.inbound_qty, im.inbound_qty) AS inbound_qty, + COALESCE(id.unit_price, im.unit_price) AS unit_price, + COALESCE(id.total_amount, im.total_amount) AS total_amount, + COALESCE(id.lot_number, im.lot_number) AS lot_number, + COALESCE(id.inspection_status, im.inspection_status) AS inspection_status, + COALESCE(id.memo, im.memo) AS memo, + id.id AS detail_id, + id.seq_no, + id.inbound_type AS detail_inbound_type, wh.warehouse_name FROM inbound_mng im + LEFT JOIN inbound_detail id + ON id.inbound_id = im.inbound_number AND id.company_code = im.company_code LEFT JOIN warehouse_info wh ON im.warehouse_code = wh.warehouse_code AND im.company_code = wh.company_code ${whereClause} - ORDER BY im.created_date DESC + ORDER BY im.created_date DESC, id.seq_no ASC `; const pool = getPool(); @@ -97,7 +120,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { } } -// 입고 등록 (다건) +// 입고 등록 (헤더 1건 + 디테일 N건) export async function create(req: AuthenticatedRequest, res: Response) { const pool = getPool(); const client = await pool.connect(); @@ -111,41 +134,70 @@ export async function create(req: AuthenticatedRequest, res: Response) { return res.status(400).json({ success: false, message: "입고 품목이 없습니다." }); } + // 첫 번째 아이템에서 inbound_type 추출 (헤더용) + const inboundType = items[0].inbound_type || null; + const inboundNumber = inbound_number || items[0].inbound_number; + await client.query("BEGIN"); - const insertedRows: any[] = []; + // 1. 헤더 INSERT (inbound_mng) — 품목 컬럼은 NULL + const headerResult = await client.query( + `INSERT INTO inbound_mng ( + id, company_code, inbound_number, inbound_type, inbound_date, + warehouse_code, location_code, + inbound_status, inspector, manager, memo, + created_date, created_by, writer, status + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, $4::date, + $5, $6, + $7, $8, $9, $10, + NOW(), $11, $11, '입고' + ) RETURNING *`, + [ + companyCode, + inboundNumber, + inboundType, + inbound_date || items[0].inbound_date, + warehouse_code || items[0].warehouse_code || null, + location_code || items[0].location_code || null, + items[0].inbound_status || "대기", + inspector || items[0].inspector || null, + manager || items[0].manager || null, + memo || items[0].memo || null, + userId, + ] + ); - for (const item of items) { - const result = await client.query( - `INSERT INTO inbound_mng ( - company_code, inbound_number, inbound_type, inbound_date, - reference_number, supplier_code, supplier_name, + const headerRow = headerResult.rows[0]; + const insertedDetails: any[] = []; + + // 2. 디테일 INSERT (inbound_detail) + 재고/발주 업데이트 + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const seqNo = i + 1; + + // 2a. inbound_detail INSERT + const detailResult = await client.query( + `INSERT INTO inbound_detail ( + id, company_code, inbound_id, seq_no, inbound_type, item_number, item_name, spec, material, unit, inbound_qty, unit_price, total_amount, - lot_number, warehouse_code, location_code, - inbound_status, inspection_status, - inspector, manager, memo, - source_table, source_id, + lot_number, reference_number, supplier_code, supplier_name, + inspection_status, memo, item_id, created_date, created_by, writer, status ) VALUES ( - $1, $2, $3, $4::date, - $5, $6, $7, - $8, $9, $10, $11, $12, - $13, $14, $15, - $16, $17, $18, - $19, $20, - $21, $22, $23, - $24, $25, - NOW(), $26, $26, '입고' + gen_random_uuid()::text, $1, $2, $3, $4, + $5, $6, $7, $8, $9, + $10, $11, $12, + $13, $14, $15, $16, + $17, $18, $19, + NOW(), $20, $20, '입고' ) RETURNING *`, [ companyCode, - inbound_number || item.inbound_number, - item.inbound_type, - inbound_date || item.inbound_date, - item.reference_number || null, - item.supplier_code || null, - item.supplier_name || null, + inboundNumber, + seqNo, + item.inbound_type || inboundType, item.item_number || null, item.item_name || null, item.spec || null, @@ -155,22 +207,19 @@ export async function create(req: AuthenticatedRequest, res: Response) { item.unit_price || 0, item.total_amount || 0, item.lot_number || null, - warehouse_code || item.warehouse_code || null, - location_code || item.location_code || null, - item.inbound_status || "대기", + item.reference_number || null, + item.supplier_code || null, + item.supplier_name || null, item.inspection_status || "대기", - inspector || item.inspector || null, - manager || item.manager || null, - memo || item.memo || null, - item.source_table || null, - item.source_id || null, + item.memo || null, + item.item_id || null, userId, ] ); - insertedRows.push(result.rows[0]); + insertedDetails.push(detailResult.rows[0]); - // 재고 업데이트 (inventory_stock): 입고 수량 증가 + // 2b. 재고 업데이트 (inventory_stock): 입고 수량 증가 — 기존 로직 유지 const itemCode = item.item_number || null; const whCode = warehouse_code || item.warehouse_code || null; const locCode = location_code || item.location_code || null; @@ -206,7 +255,7 @@ export async function create(req: AuthenticatedRequest, res: Response) { } } - // 구매입고인 경우 발주의 received_qty 업데이트 + // 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지 if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") { await client.query( `UPDATE purchase_order_mng @@ -229,29 +278,34 @@ export async function create(req: AuthenticatedRequest, res: Response) { ); } - // 구매입고인 경우 purchase_detail 기반 발주의 헤더 상태 업데이트 + // 구매입고인 경우 purchase_detail 품목별 입고수량 업데이트 if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") { - // 해당 디테일의 발주번호 조회 + // 1. 해당 purchase_detail의 received_qty 누적 업데이트 + await client.query( + `UPDATE purchase_detail SET + received_qty = CAST( + COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text + ), + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [item.inbound_qty || 0, item.source_id, companyCode] + ); + + // 2. 발주 헤더 상태 업데이트 const detailInfo = await client.query( `SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`, [item.source_id, companyCode] ); if (detailInfo.rows.length > 0) { const purchaseNo = detailInfo.rows[0].purchase_no; - // 해당 발주의 모든 디테일 잔량 확인 + // 잔량 있는 디테일이 있는지 확인 const unreceived = await client.query( - `SELECT pd.id - FROM purchase_detail pd - LEFT JOIN ( - SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received - FROM inbound_mng - WHERE source_table = 'purchase_detail' AND company_code = $1 - GROUP BY source_id - ) r ON r.source_id = pd.id - WHERE pd.purchase_no = $2 AND pd.company_code = $1 - AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(r.total_received, 0) > 0 + `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`, - [companyCode, purchaseNo] + [purchaseNo, companyCode] ); const newStatus = unreceived.rows.length === 0 ? '입고완료' : '부분입고'; await client.query( @@ -268,14 +322,15 @@ export async function create(req: AuthenticatedRequest, res: Response) { logger.info("입고 등록 완료", { companyCode, userId, - count: insertedRows.length, - inbound_number, + headerCount: 1, + detailCount: insertedDetails.length, + inbound_number: inboundNumber, }); return res.json({ success: true, - data: insertedRows, - message: `${insertedRows.length}건 입고 등록 완료`, + data: { header: headerRow, details: insertedDetails }, + message: `${insertedDetails.length}건 입고 등록 완료`, }); } catch (error: any) { await client.query("ROLLBACK"); @@ -286,8 +341,11 @@ 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; @@ -297,71 +355,253 @@ export async function update(req: AuthenticatedRequest, res: Response) { lot_number, warehouse_code, location_code, inbound_status, inspection_status, inspector, manager: mgr, memo, + detail_id, } = req.body; - const pool = getPool(); - const result = await pool.query( + await client.query("BEGIN"); + + // 헤더 업데이트 (inbound_mng) — 헤더 레벨 필드만 + const headerResult = await client.query( `UPDATE inbound_mng SET inbound_date = COALESCE($1::date, inbound_date), - inbound_qty = COALESCE($2, inbound_qty), - unit_price = COALESCE($3, unit_price), - total_amount = COALESCE($4, total_amount), - lot_number = COALESCE($5, lot_number), - warehouse_code = COALESCE($6, warehouse_code), - location_code = COALESCE($7, location_code), - inbound_status = COALESCE($8, inbound_status), - inspection_status = COALESCE($9, inspection_status), - inspector = COALESCE($10, inspector), - manager = COALESCE($11, manager), - memo = COALESCE($12, memo), + warehouse_code = COALESCE($2, warehouse_code), + location_code = COALESCE($3, location_code), + inbound_status = COALESCE($4, inbound_status), + inspector = COALESCE($5, inspector), + manager = COALESCE($6, manager), + memo = COALESCE($7, memo), updated_date = NOW(), - updated_by = $13 - WHERE id = $14 AND company_code = $15 + updated_by = $8 + WHERE id = $9 AND company_code = $10 RETURNING *`, [ - inbound_date, inbound_qty, unit_price, total_amount, - lot_number, warehouse_code, location_code, - inbound_status, inspection_status, - inspector, mgr, memo, + inbound_date, warehouse_code, location_code, + inbound_status, inspector, mgr, memo, userId, id, companyCode, ] ); - if (result.rowCount === 0) { + if (headerResult.rowCount === 0) { + await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "입고 데이터를 찾을 수 없습니다." }); } - logger.info("입고 수정", { companyCode, userId, id }); + // 디테일 업데이트 (inbound_detail) — detail_id가 있으면 디테일 레벨 필드 업데이트 + let detailRow = null; + if (detail_id) { + const detailResult = await client.query( + `UPDATE inbound_detail SET + inbound_qty = COALESCE($1, inbound_qty), + unit_price = COALESCE($2, unit_price), + total_amount = COALESCE($3, total_amount), + lot_number = COALESCE($4, lot_number), + inspection_status = COALESCE($5, inspection_status), + memo = COALESCE($6, memo), + updated_date = NOW(), + updated_by = $7 + WHERE id = $8 AND company_code = $9 + RETURNING *`, + [ + inbound_qty, unit_price, total_amount, + lot_number, inspection_status, memo, + userId, detail_id, companyCode, + ] + ); + detailRow = detailResult.rows[0] || null; + } else { + // 레거시 데이터: detail_id 없이 inbound_mng 자체에 품목 정보 업데이트 + await client.query( + `UPDATE inbound_mng SET + inbound_qty = COALESCE($1, inbound_qty), + unit_price = COALESCE($2, unit_price), + total_amount = COALESCE($3, total_amount), + lot_number = COALESCE($4, lot_number), + inspection_status = COALESCE($5, inspection_status) + WHERE id = $6 AND company_code = $7`, + [ + inbound_qty, unit_price, total_amount, + lot_number, inspection_status, + id, companyCode, + ] + ); + } - return res.json({ success: true, data: result.rows[0] }); + await client.query("COMMIT"); + + logger.info("입고 수정", { companyCode, userId, id, detail_id }); + + return res.json({ + success: true, + data: { header: headerResult.rows[0], detail: detailRow }, + }); } 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 deleteReceiving(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + try { const companyCode = req.user!.companyCode; const { id } = req.params; - const pool = getPool(); - const result = await pool.query( - `DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`, + await client.query("BEGIN"); + + // 헤더 정보 조회 (inbound_number, warehouse_code 등) + const headerResult = await client.query( + `SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`, [id, companyCode] ); - if (result.rowCount === 0) { + if (headerResult.rowCount === 0) { + await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); } - logger.info("입고 삭제", { companyCode, id }); + const header = headerResult.rows[0]; + const inboundNumber = header.inbound_number; + + // 디테일 조회 (재고/발주 롤백용) + const detailResult = await client.query( + `SELECT * FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`, + [inboundNumber, companyCode] + ); + + // 디테일이 있으면 디테일 기반으로 롤백, 없으면 헤더(레거시) 기반으로 롤백 + const rollbackItems = detailResult.rows.length > 0 + ? detailResult.rows.map((d: any) => ({ + item_number: d.item_number, + inbound_qty: d.inbound_qty, + inbound_type: d.inbound_type || header.inbound_type, + source_table: header.source_table, + source_id: header.source_id, + })) + : [{ + item_number: header.item_number, + inbound_qty: header.inbound_qty, + inbound_type: header.inbound_type, + source_table: header.source_table, + source_id: header.source_id, + }]; + + const whCode = header.warehouse_code || null; + const locCode = header.location_code || null; + + for (const item of rollbackItems) { + const itemCode = item.item_number || null; + const inQty = Number(item.inbound_qty) || 0; + + // 재고 롤백: 입고 수량만큼 차감 + if (itemCode && inQty > 0) { + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST( + GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text + ), + updated_date = NOW() + WHERE company_code = $2 AND item_code = $3 + AND COALESCE(warehouse_code, '') = COALESCE($4, '') + AND COALESCE(location_code, '') = COALESCE($5, '')`, + [inQty, companyCode, itemCode, whCode || '', locCode || ''] + ); + } + + // 구매입고 발주 롤백: purchase_order_mng 기반 + if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "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( + COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) + - GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text + ), + status = CASE + WHEN GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) <= 0 + THEN '발주확정' + ELSE '부분입고' + END, + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [inQty, item.source_id, companyCode] + ); + } + + // 구매입고 발주 롤백: purchase_detail 기반 + if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") { + const detailInfo = await client.query( + `SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`, + [item.source_id, companyCode] + ); + if (detailInfo.rows.length > 0) { + const purchaseNo = detailInfo.rows[0].purchase_no; + // 삭제 후 재계산을 위해 현재 입고 건 제외한 미입고 확인 + const unreceived = await client.query( + `SELECT pd.id + FROM purchase_detail pd + LEFT JOIN ( + SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received + FROM inbound_mng + WHERE source_table = 'purchase_detail' AND company_code = $1 + AND inbound_number != $3 + GROUP BY source_id + ) r ON r.source_id = pd.id + WHERE pd.purchase_no = $2 AND pd.company_code = $1 + AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(r.total_received, 0) > 0 + LIMIT 1`, + [companyCode, purchaseNo, inboundNumber] + ); + // 잔량 있으면 부분입고, 전량 미입고면 발주확정 + const hasAnyReceived = await client.query( + `SELECT 1 FROM inbound_mng + WHERE source_table = 'purchase_detail' AND company_code = $1 + AND inbound_number != $2 + LIMIT 1`, + [companyCode, inboundNumber] + ); + const newStatus = hasAnyReceived.rows.length > 0 + ? (unreceived.rows.length === 0 ? '입고완료' : '부분입고') + : '발주확정'; + await client.query( + `UPDATE purchase_order_mng SET status = $1, updated_date = NOW() + WHERE purchase_no = $2 AND company_code = $3`, + [newStatus, purchaseNo, companyCode] + ); + } + } + } + + // 디테일 삭제 + await client.query( + `DELETE FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`, + [inboundNumber, companyCode] + ); + + // 헤더 삭제 + await client.query( + `DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + await client.query("COMMIT"); + + logger.info("입고 삭제", { companyCode, id, inboundNumber }); 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(); } } @@ -387,14 +627,8 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response } const baseQuery = ` - WITH detail_received AS ( - SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received - FROM inbound_mng - WHERE source_table = 'purchase_detail' AND company_code = $1 - GROUP BY source_id - ), - combined AS ( - -- 디테일 기반 발주 데이터 (신규 헤더-디테일 구조, 헤더 없는 디테일도 포함) + WITH combined AS ( + -- 디테일 기반 발주 데이터 (purchase_detail.received_qty로 잔량 계산) SELECT pd.id, COALESCE(po.purchase_no, pd.purchase_no) AS purchase_no, @@ -406,8 +640,8 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response COALESCE(NULLIF(pd.spec, ''), ii.size) AS spec, COALESCE(NULLIF(pd.material, ''), ii.material) AS material, COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) AS order_qty, - COALESCE(dr.total_received, 0) AS received_qty, - COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(dr.total_received, 0) AS remain_qty, + COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) AS received_qty, + COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) AS remain_qty, COALESCE(CAST(NULLIF(pd.unit_price, '') AS numeric), 0) AS unit_price, COALESCE(po.status, '') AS status, COALESCE(pd.due_date, po.due_date) AS due_date, @@ -416,9 +650,8 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response LEFT JOIN purchase_order_mng po ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code LEFT JOIN item_info ii ON pd.item_id = ii.id - LEFT JOIN detail_received dr ON dr.source_id = pd.id WHERE pd.company_code = $1 - AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(dr.total_received, 0) > 0 + AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) > 0 AND COALESCE(pd.approval_status, '') NOT IN ('반려') AND COALESCE(po.status, '') NOT IN ('입고완료', '취소') ${keywordConditionDetail} diff --git a/frontend/app/(main)/COMPANY_29/logistics/material-status/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/material-status/page.tsx index 2d3616dc..6a921ead 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/material-status/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/material-status/page.tsx @@ -81,8 +81,8 @@ export default function MaterialStatusPage() { const [workOrders, setWorkOrders] = useState([]); const [workOrdersLoading, setWorkOrdersLoading] = useState(false); - const [checkedWoIds, setCheckedWoIds] = useState([]); - const [selectedWoId, setSelectedWoId] = useState(null); + const [checkedWoIds, setCheckedWoIds] = useState([]); + const [selectedWoId, setSelectedWoId] = useState(null); const [warehouses, setWarehouses] = useState([]); const [warehouse, setWarehouse] = useState(""); @@ -137,13 +137,13 @@ export default function MaterialStatusPage() { [workOrders] ); - const handleCheckWo = useCallback((id: number, checked: boolean) => { + const handleCheckWo = useCallback((id: string, checked: boolean) => { setCheckedWoIds((prev) => checked ? [...prev, id] : prev.filter((i) => i !== id) ); }, []); - const handleSelectWo = useCallback((id: number) => { + const handleSelectWo = useCallback((id: string) => { setSelectedWoId((prev) => (prev === id ? null : id)); }, []); diff --git a/frontend/app/(main)/COMPANY_7/logistics/material-status/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/material-status/page.tsx index 2d3616dc..6a921ead 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/material-status/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/material-status/page.tsx @@ -81,8 +81,8 @@ export default function MaterialStatusPage() { const [workOrders, setWorkOrders] = useState([]); const [workOrdersLoading, setWorkOrdersLoading] = useState(false); - const [checkedWoIds, setCheckedWoIds] = useState([]); - const [selectedWoId, setSelectedWoId] = useState(null); + const [checkedWoIds, setCheckedWoIds] = useState([]); + const [selectedWoId, setSelectedWoId] = useState(null); const [warehouses, setWarehouses] = useState([]); const [warehouse, setWarehouse] = useState(""); @@ -137,13 +137,13 @@ export default function MaterialStatusPage() { [workOrders] ); - const handleCheckWo = useCallback((id: number, checked: boolean) => { + const handleCheckWo = useCallback((id: string, checked: boolean) => { setCheckedWoIds((prev) => checked ? [...prev, id] : prev.filter((i) => i !== id) ); }, []); - const handleSelectWo = useCallback((id: number) => { + const handleSelectWo = useCallback((id: string) => { setSelectedWoId((prev) => (prev === id ? null : id)); }, []); diff --git a/frontend/lib/api/materialStatus.ts b/frontend/lib/api/materialStatus.ts index 910340c7..698b333c 100644 --- a/frontend/lib/api/materialStatus.ts +++ b/frontend/lib/api/materialStatus.ts @@ -5,7 +5,7 @@ import { apiClient } from "./client"; export interface WorkOrder { - id: number; + id: string; plan_no: string; item_code: string; item_name: string; @@ -69,7 +69,7 @@ export async function getWorkOrders(params: { } export async function getMaterialStatus(params: { - planIds: number[]; + planIds: string[]; warehouseCode?: string; }): Promise> { try { -- 2.43.0 From f2f18db449e79954c32d6fcca3a3ec79108f154e Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 30 Mar 2026 17:02:48 +0900 Subject: [PATCH 2/3] Refactor customer management page to improve price item handling - Update the logic for retrieving and processing customer item prices. - Replace the previous price mapping with a grouping mechanism based on item_id and today's date. - Enhance the handling of customer item codes and names to ensure proper aggregation. - Improve overall readability and maintainability of the code. This commit enhances the functionality of the customer management page by ensuring accurate price data is displayed based on the current date, improving user experience in managing customer items. --- .../(main)/COMPANY_29/sales/customer/page.tsx | 117 +++++++++++------- .../(main)/COMPANY_7/sales/customer/page.tsx | 117 +++++++++++------- 2 files changed, 148 insertions(+), 86 deletions(-) diff --git a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx index c2d521f3..045679e5 100644 --- a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx @@ -284,8 +284,8 @@ export default function CustomerManagementPage() { } catch { /* skip */ } } - // 3. customer_item_prices 조회 (단가 — 있으면 보강) - let priceMap: Record = {}; + // 3. customer_item_prices 조회 + let allPrices: any[] = []; if (mappings.length > 0) { try { const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { @@ -295,36 +295,43 @@ export default function CustomerManagementPage() { ]}, autoFilter: true, }); - const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; - // item_id별 최신 단가 매핑 - for (const p of prices) { - const key = p.item_id; - if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) { - priceMap[key] = p; - } - } + allPrices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; } catch { /* skip */ } } - // 4. 매핑 + 품목정보 + 단가 병합 + 카테고리 코드→라벨 + // 4. 매핑별 행 생성 + 오늘 날짜 기준 단가 + 같은 품목 첫 행만 품목코드/품명 표시 const priceResolve = (col: string, code: string) => { if (!code) return ""; return priceCategoryOptions[col]?.find((o) => o.code === code)?.label || code; }; - setPriceItems(mappings.map((m: any) => { - const itemInfo = itemMap[m.item_id] || {}; - const price = priceMap[m.item_id] || {}; + const today = new Date().toISOString().split("T")[0]; + const seenItemIds = new Set(); + + // item_id로 정렬하여 같은 품목끼리 묶기 + const sortedMappings = [...mappings].sort((a: any, b: any) => (a.item_id || "").localeCompare(b.item_id || "")); + + setPriceItems(sortedMappings.map((m: any) => { + const itemKey = m.item_id || ""; + const itemInfo = itemMap[itemKey] || {}; + const isFirstOfGroup = !seenItemIds.has(itemKey); + if (itemKey) seenItemIds.add(itemKey); + + // 오늘 날짜에 해당하는 단가 + const itemPriceList = allPrices.filter((p: any) => p.item_id === itemKey); + const todayPrice = itemPriceList.find((p: any) => + (!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today) + ) || itemPriceList[0] || {}; + return { ...m, - item_number: m.item_id, - item_name: itemInfo.item_name || "", - base_price_type: priceResolve("base_price_type", price.base_price_type || m.base_price_type || ""), - base_price: price.base_price || m.base_price || "", - discount_type: priceResolve("discount_type", price.discount_type || m.discount_type || ""), - discount_value: price.discount_value || m.discount_value || "", - rounding_type: priceResolve("rounding_unit_value", price.rounding_type || m.rounding_type || ""), - calculated_price: price.calculated_price || m.calculated_price || "", - currency_code: priceResolve("currency_code", price.currency_code || m.currency_code || ""), + item_number: isFirstOfGroup ? itemKey : "", + item_name: isFirstOfGroup ? (itemInfo.item_name || "") : "", + base_price_type: priceResolve("base_price_type", todayPrice.base_price_type || ""), + base_price: todayPrice.base_price || "", + discount_type: priceResolve("discount_type", todayPrice.discount_type || ""), + discount_value: todayPrice.discount_value || "", + calculated_price: todayPrice.calculated_price || todayPrice.unit_price || "", + currency_code: priceResolve("currency_code", todayPrice.currency_code || ""), }; })); } catch (err) { @@ -572,27 +579,51 @@ export default function CustomerManagementPage() { if (found) itemInfo = found; } catch { /* skip */ } - // 기존 매핑 데이터 → 거래처 품번/품명 - const mappingRows = [{ - _id: `m_existing_${row.id}`, - customer_item_code: row.customer_item_code || "", - customer_item_name: row.customer_item_name || "", - }].filter((m) => m.customer_item_code || m.customer_item_name); + // DB에서 해당 품목의 모든 매핑 조회 + let mappingRows: any[] = []; + try { + const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code }, + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, autoFilter: true, + }); + const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; + mappingRows = allMappings + .filter((m: any) => m.customer_item_code || m.customer_item_name) + .map((m: any) => ({ + _id: `m_existing_${m.id}`, + customer_item_code: m.customer_item_code || "", + customer_item_name: m.customer_item_name || "", + })); + } catch { /* skip */ } - // 기존 단가 데이터 - const priceRows = [{ - _id: `p_existing_${row.id}`, - start_date: row.start_date || "", - end_date: row.end_date || "", - currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI", - base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW", - base_price: row.base_price ? String(row.base_price) : "", - discount_type: row.discount_type || "", - discount_value: row.discount_value ? String(row.discount_value) : "", - rounding_type: row.rounding_type || "", - rounding_unit_value: row.rounding_unit_value || "", - calculated_price: row.calculated_price ? String(row.calculated_price) : "", - }].filter((p) => p.base_price || p.start_date); + // DB에서 해당 품목의 모든 기간별 단가 조회 + let priceRows: any[] = []; + try { + const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code }, + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, autoFilter: true, + }); + const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + priceRows = allPriceData.map((p: any) => ({ + _id: `p_existing_${p.id}`, + start_date: p.start_date ? String(p.start_date).split("T")[0] : "", + end_date: p.end_date ? String(p.end_date).split("T")[0] : "", + currency_code: p.currency_code || "CAT_MLAMDKVN_PZJI", + base_price_type: p.base_price_type || "CAT_MLAMFGFT_4RZW", + base_price: p.base_price ? String(p.base_price) : "", + discount_type: p.discount_type || "", + discount_value: p.discount_value ? String(p.discount_value) : "", + rounding_type: p.rounding_type || "", + rounding_unit_value: p.rounding_unit_value || "", + calculated_price: p.calculated_price ? String(p.calculated_price) : "", + })); + } catch { /* skip */ } // 빈 단가 행이 없으면 하나 추가 if (priceRows.length === 0) { diff --git a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx index c2d521f3..045679e5 100644 --- a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx @@ -284,8 +284,8 @@ export default function CustomerManagementPage() { } catch { /* skip */ } } - // 3. customer_item_prices 조회 (단가 — 있으면 보강) - let priceMap: Record = {}; + // 3. customer_item_prices 조회 + let allPrices: any[] = []; if (mappings.length > 0) { try { const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { @@ -295,36 +295,43 @@ export default function CustomerManagementPage() { ]}, autoFilter: true, }); - const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; - // item_id별 최신 단가 매핑 - for (const p of prices) { - const key = p.item_id; - if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) { - priceMap[key] = p; - } - } + allPrices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; } catch { /* skip */ } } - // 4. 매핑 + 품목정보 + 단가 병합 + 카테고리 코드→라벨 + // 4. 매핑별 행 생성 + 오늘 날짜 기준 단가 + 같은 품목 첫 행만 품목코드/품명 표시 const priceResolve = (col: string, code: string) => { if (!code) return ""; return priceCategoryOptions[col]?.find((o) => o.code === code)?.label || code; }; - setPriceItems(mappings.map((m: any) => { - const itemInfo = itemMap[m.item_id] || {}; - const price = priceMap[m.item_id] || {}; + const today = new Date().toISOString().split("T")[0]; + const seenItemIds = new Set(); + + // item_id로 정렬하여 같은 품목끼리 묶기 + const sortedMappings = [...mappings].sort((a: any, b: any) => (a.item_id || "").localeCompare(b.item_id || "")); + + setPriceItems(sortedMappings.map((m: any) => { + const itemKey = m.item_id || ""; + const itemInfo = itemMap[itemKey] || {}; + const isFirstOfGroup = !seenItemIds.has(itemKey); + if (itemKey) seenItemIds.add(itemKey); + + // 오늘 날짜에 해당하는 단가 + const itemPriceList = allPrices.filter((p: any) => p.item_id === itemKey); + const todayPrice = itemPriceList.find((p: any) => + (!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today) + ) || itemPriceList[0] || {}; + return { ...m, - item_number: m.item_id, - item_name: itemInfo.item_name || "", - base_price_type: priceResolve("base_price_type", price.base_price_type || m.base_price_type || ""), - base_price: price.base_price || m.base_price || "", - discount_type: priceResolve("discount_type", price.discount_type || m.discount_type || ""), - discount_value: price.discount_value || m.discount_value || "", - rounding_type: priceResolve("rounding_unit_value", price.rounding_type || m.rounding_type || ""), - calculated_price: price.calculated_price || m.calculated_price || "", - currency_code: priceResolve("currency_code", price.currency_code || m.currency_code || ""), + item_number: isFirstOfGroup ? itemKey : "", + item_name: isFirstOfGroup ? (itemInfo.item_name || "") : "", + base_price_type: priceResolve("base_price_type", todayPrice.base_price_type || ""), + base_price: todayPrice.base_price || "", + discount_type: priceResolve("discount_type", todayPrice.discount_type || ""), + discount_value: todayPrice.discount_value || "", + calculated_price: todayPrice.calculated_price || todayPrice.unit_price || "", + currency_code: priceResolve("currency_code", todayPrice.currency_code || ""), }; })); } catch (err) { @@ -572,27 +579,51 @@ export default function CustomerManagementPage() { if (found) itemInfo = found; } catch { /* skip */ } - // 기존 매핑 데이터 → 거래처 품번/품명 - const mappingRows = [{ - _id: `m_existing_${row.id}`, - customer_item_code: row.customer_item_code || "", - customer_item_name: row.customer_item_name || "", - }].filter((m) => m.customer_item_code || m.customer_item_name); + // DB에서 해당 품목의 모든 매핑 조회 + let mappingRows: any[] = []; + try { + const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code }, + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, autoFilter: true, + }); + const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; + mappingRows = allMappings + .filter((m: any) => m.customer_item_code || m.customer_item_name) + .map((m: any) => ({ + _id: `m_existing_${m.id}`, + customer_item_code: m.customer_item_code || "", + customer_item_name: m.customer_item_name || "", + })); + } catch { /* skip */ } - // 기존 단가 데이터 - const priceRows = [{ - _id: `p_existing_${row.id}`, - start_date: row.start_date || "", - end_date: row.end_date || "", - currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI", - base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW", - base_price: row.base_price ? String(row.base_price) : "", - discount_type: row.discount_type || "", - discount_value: row.discount_value ? String(row.discount_value) : "", - rounding_type: row.rounding_type || "", - rounding_unit_value: row.rounding_unit_value || "", - calculated_price: row.calculated_price ? String(row.calculated_price) : "", - }].filter((p) => p.base_price || p.start_date); + // DB에서 해당 품목의 모든 기간별 단가 조회 + let priceRows: any[] = []; + try { + const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code }, + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, autoFilter: true, + }); + const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + priceRows = allPriceData.map((p: any) => ({ + _id: `p_existing_${p.id}`, + start_date: p.start_date ? String(p.start_date).split("T")[0] : "", + end_date: p.end_date ? String(p.end_date).split("T")[0] : "", + currency_code: p.currency_code || "CAT_MLAMDKVN_PZJI", + base_price_type: p.base_price_type || "CAT_MLAMFGFT_4RZW", + base_price: p.base_price ? String(p.base_price) : "", + discount_type: p.discount_type || "", + discount_value: p.discount_value ? String(p.discount_value) : "", + rounding_type: p.rounding_type || "", + rounding_unit_value: p.rounding_unit_value || "", + calculated_price: p.calculated_price ? String(p.calculated_price) : "", + })); + } catch { /* skip */ } // 빈 단가 행이 없으면 하나 추가 if (priceRows.length === 0) { -- 2.43.0 From 615687328ffd1b9a82d7899c30a74a814c9b53e4 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 30 Mar 2026 17:03:12 +0900 Subject: [PATCH 3/3] Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node -- 2.43.0