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 {