From 8c23f489967edc00007530e31b07987edfdeabcc Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 10 Apr 2026 17:17:23 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20POP=20=EC=9E=AC=EA=B3=A0=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=84=EB=A9=B4=20=EA=B5=AC=ED=98=84=20=E2=80=94?= =?UTF-8?q?=20=EC=9E=AC=EA=B3=A0=EC=A1=B0=EC=A0=95/=EC=9E=AC=EA=B3=A0?= =?UTF-8?q?=EC=9D=B4=EB=8F=99/=EB=8B=A4=EC=A4=91=ED=92=88=EB=AA=A9=20?= =?UTF-8?q?=EA=B3=B5=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재고조정: - fullBleed 좌우 분할, 숫자키패드 모달, 위치불일치 QR스캔+모달 - 임시저장 cart_items 상태관리 (saved/cancelled/confirmed) - 조정이력 별도 페이지, DateRangePicker 통일 - popInventoryController 11개 API (adjust-batch, stock-detail, locations 등) 재고이동: - 창고 탭: 탭 버튼 패턴 + flat 리스트 (아코디언 제거) - 공정 탭: 공정명/설비 필터 모달 (작업지시번호 탭 제거) - move-batch API: 창고→창고 + 공정→창고 (source_type 확장) - 품목 이력 바텀시트 (transaction_type별 색상) 다중품목 공정실행: - syncWorkInstructions LIMIT 1 제거 → detail 전체 순회 - batch_id 기반 품목별 공정 분리 - WorkOrderList/ProcessWork 품목 구분 표시 기타: - PopShell fullBleed 모드 추가 - alert() → 토스트 메시지 교체 - MonitoringSettings import 수정 --- backend-node/src/app.ts | 2 + .../src/controllers/popInventoryController.ts | 1112 +++++++++++++++ .../controllers/popProductionController.ts | 288 ++-- backend-node/src/routes/popInventoryRoutes.ts | 40 + .../pop/inventory/adjust-history/page.tsx | 12 + .../app/(pop)/pop/inventory/history/page.tsx | 2 +- .../components/pop/hardcoded/PopShell.tsx | 8 +- .../pop/hardcoded/inventory/AdjustHistory.tsx | 150 ++ .../pop/hardcoded/inventory/InOutHistory.tsx | 388 ++--- .../pop/hardcoded/inventory/InventoryMove.tsx | 1257 ++++++++++++++--- .../hardcoded/inventory/InventoryTransfer.tsx | 963 +++++++++++-- .../pop/hardcoded/inventory/index.ts | 1 + .../pop/hardcoded/production/ProcessWork.tsx | 22 + .../hardcoded/production/WorkOrderList.tsx | 65 +- frontend/hooks/useAuth.ts | 1 + frontend/public/change-review.html | 419 ++++++ 16 files changed, 4032 insertions(+), 698 deletions(-) create mode 100644 backend-node/src/controllers/popInventoryController.ts create mode 100644 backend-node/src/routes/popInventoryRoutes.ts create mode 100644 frontend/app/(pop)/pop/inventory/adjust-history/page.tsx create mode 100644 frontend/components/pop/hardcoded/inventory/AdjustHistory.tsx create mode 100644 frontend/public/change-review.html diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index c2d9a21f..ff14ba19 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -132,6 +132,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머) +import popInventoryRoutes from "./routes/popInventoryRoutes"; // POP 재고 조정/이동 import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 @@ -297,6 +298,7 @@ app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 app.use("/api/pop", popActionRoutes); // POP 액션 실행 app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리 +app.use("/api/pop/inventory", popInventoryRoutes); // POP 재고 조정/이동 app.use("/api/pop/inspection-result", inspectionResultRoutes); // POP 검사 결과 관리 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); diff --git a/backend-node/src/controllers/popInventoryController.ts b/backend-node/src/controllers/popInventoryController.ts new file mode 100644 index 00000000..0c0ef743 --- /dev/null +++ b/backend-node/src/controllers/popInventoryController.ts @@ -0,0 +1,1112 @@ +import { Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +interface AuthenticatedRequest extends Request { + user?: { userId: string; companyCode: string; userName?: string }; + body: any; + query: any; +} + +/** + * GET /api/pop/inventory/stock-detail + * 재고 목록 + 품목상세(item_info) + 창고명(warehouse_info) JOIN 조회 + */ +export const getStockDetail = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { warehouse_code, keyword } = req.query; + const conditions = ["ist.company_code = $1"]; + const params: any[] = [companyCode]; + let idx = 2; + + // 수량 > 0 인 재고만 + conditions.push("COALESCE(CAST(NULLIF(ist.current_qty, '') AS numeric), 0) > 0"); + + if (warehouse_code && warehouse_code !== "all") { + conditions.push(`ist.warehouse_code = $${idx++}`); + params.push(warehouse_code); + } + + if (keyword) { + conditions.push(`( + ist.item_code ILIKE $${idx} + OR COALESCE(ii.item_name, '') ILIKE $${idx} + OR COALESCE(ii.item_number, '') ILIKE $${idx} + )`); + params.push(`%${keyword}%`); + idx++; + } + + const result = await pool.query( + `SELECT + ist.id, + ist.item_code, + COALESCE(ii.item_name, ist.item_code) AS item_name, + COALESCE(ii.item_number, ist.item_code) AS item_number, + COALESCE(ii.size, '') AS spec, + COALESCE(ii.unit, 'EA') AS unit, + COALESCE(ii.material, '') AS material, + ist.warehouse_code, + COALESCE(wi.warehouse_name, ist.warehouse_code) AS warehouse_name, + COALESCE(ist.location_code, '') AS location_code, + COALESCE(wl.location_name, '') AS location_name, + COALESCE(wl.floor, '') AS floor, + ist.current_qty + FROM inventory_stock ist + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, size, unit, material, company_code + FROM item_info + ORDER BY item_number, company_code, created_date DESC + ) ii ON ist.item_code = ii.item_number AND ist.company_code = ii.company_code + LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code + AND ist.company_code = wi.company_code + LEFT JOIN warehouse_location wl ON ist.location_code = wl.location_code + AND ist.company_code = wl.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY COALESCE(ii.item_name, ist.item_code) + LIMIT 500`, + params + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory] stock-detail 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/temp-load + * 임시저장 cart_items 불러오기 (cart_type + status='saved' 기준) + */ +export const loadTempCart = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const cartType = req.query.cart_type || "inventory-adjust"; + + const result = await pool.query( + `SELECT id, row_data, row_key, cart_type, status, created_date + FROM cart_items + WHERE company_code = $1 AND cart_type = $2 AND status = 'saved' + ORDER BY created_date`, + [companyCode, cartType] + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory] temp-load 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * DELETE /api/pop/inventory/temp-clear + * 임시저장 cart_items 삭제 (cart_type 기준) + */ +export const clearTempCart = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const cartType = req.query.cart_type || "inventory-adjust"; + + await pool.query( + `DELETE FROM cart_items WHERE company_code = $1 AND cart_type = $2`, + [companyCode, cartType] + ); + + return res.json({ success: true, message: "임시저장 삭제 완료" }); + + } catch (error: any) { + logger.error("[pop/inventory] temp-clear 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +interface AdjustItem { + stock_id: string; + item_code: string; + warehouse_code: string; + location_code?: string; + system_qty: number; + type: "confirm" | "adjust"; + actual_qty?: number; + reason?: string; + new_warehouse?: string; + new_location?: string; + memo?: string; +} + +/** + * POST /api/pop/inventory/adjust-batch + * 재고 조정 일괄 확정 + * - confirm: 이상없음 (이력만 기록) + * - adjust: 수량 조정 (inventory_stock UPDATE + inventory_history INSERT) + * - 위치불일치: 기존 위치 차감 + 새 위치 추가 + */ +export const adjustBatch = async (req: any, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const userName = req.user?.userName || userId; + + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { items } = req.body as { items: AdjustItem[] }; + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "조정 항목이 없습니다" }); + } + + await client.query("BEGIN"); + + let confirmCount = 0; + let adjustCount = 0; + const results: Array<{ item_code: string; type: string; status: string }> = []; + + for (const item of items) { + // company_code 필터: 멀티테넌시 필수 + const stockCheck = await client.query( + `SELECT id, item_code, warehouse_code, location_code, + COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) as current_qty + FROM inventory_stock + WHERE id = $1 AND company_code = $2`, + [item.stock_id, companyCode] + ); + + if (stockCheck.rowCount === 0) { + results.push({ item_code: item.item_code, type: item.type, status: "not_found" }); + continue; + } + + const stock = stockCheck.rows[0]; + const systemQty = parseFloat(stock.current_qty) || 0; + + if (item.type === "confirm") { + // 확인 (이상없음): 이력만 기록, 재고 변경 없음 + 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, manager_name, created_date + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, $4, + '조정확인', NOW(), '0', $5, + $6, $7, $8, NOW() + )`, + [ + companyCode, stock.item_code, stock.warehouse_code, stock.location_code || "", + String(systemQty), + JSON.stringify({ reason: "이상없음", type: "confirm", system_qty: systemQty }), + userId, userName + ] + ); + confirmCount++; + results.push({ item_code: item.item_code, type: "confirm", status: "ok" }); + + } else if (item.type === "adjust") { + const actualQty = item.actual_qty ?? systemQty; + const diff = actualQty - systemQty; + + if (item.reason === "위치불일치" && item.new_warehouse) { + // 위치불일치: 기존 위치에서 차감 + 새 위치에 추가 + // 1. 기존 위치 차감 + 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(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(actualQty), userId, item.stock_id, companyCode] + ); + + // 2. 새 위치에 UPSERT + const newLoc = item.new_location || item.new_warehouse; + const existingNew = 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, stock.item_code, item.new_warehouse, newLoc] + ); + + if (existingNew.rows.length > 0) { + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty,'') AS numeric),0) + $1 AS text), + updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(actualQty), userId, existingNew.rows[0].id, companyCode] + ); + } else { + await client.query( + `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, created_date, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), $6)`, + [companyCode, stock.item_code, item.new_warehouse, newLoc, String(actualQty), userId] + ); + } + + // 3. 이력 2건 (출발 + 도착) + const remarkJson = JSON.stringify({ + reason: item.reason, type: "adjust", + system_qty: systemQty, actual_qty: actualQty, + from_warehouse: stock.warehouse_code, from_location: stock.location_code, + to_warehouse: item.new_warehouse, to_location: newLoc, + memo: item.memo || "" + }); + + 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, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '조정', NOW(), $5, '0', $6, $7, $8, NOW())`, + [companyCode, stock.item_code, stock.warehouse_code, stock.location_code || "", + String(-actualQty), remarkJson, userId, userName] + ); + 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, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '조정', NOW(), $5, $6, $7, $8, $9, NOW())`, + [companyCode, stock.item_code, item.new_warehouse, newLoc, + String(actualQty), String(actualQty), remarkJson, userId, userName] + ); + + } else { + // 일반 수량 조정 + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(actualQty), userId, item.stock_id, companyCode] + ); + + // 이력 1건 + const remarkJson = JSON.stringify({ + reason: item.reason || "실사차이", type: "adjust", + system_qty: systemQty, actual_qty: actualQty, diff, + memo: item.memo || "" + }); + + 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, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '조정', NOW(), $5, $6, $7, $8, $9, NOW())`, + [companyCode, stock.item_code, stock.warehouse_code, stock.location_code || "", + String(diff), String(actualQty), remarkJson, userId, userName] + ); + } + + adjustCount++; + results.push({ item_code: item.item_code, type: "adjust", status: "ok" }); + } + } + + await client.query("COMMIT"); + + logger.info("[pop/inventory] adjust-batch 완료", { + companyCode, userId, confirmCount, adjustCount, total: items.length + }); + + return res.json({ + success: true, + message: `확인 ${confirmCount}건, 조정 ${adjustCount}건 처리 완료`, + data: { confirmCount, adjustCount, results } + }); + + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/inventory] adjust-batch 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; + +/** + * GET /api/pop/inventory/adjust-history + * 재고 조정 이력 조회 + */ +export const getAdjustHistory = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { date_from, date_to, item_code } = req.query; + const conditions = ["company_code = $1", "transaction_type IN ('조정', '조정확인')"]; + const params: any[] = [companyCode]; + let idx = 2; + + if (date_from) { + conditions.push(`transaction_date >= $${idx++}`); + params.push(date_from); + } + if (date_to) { + conditions.push(`transaction_date <= $${idx++}::date + interval '1 day'`); + params.push(date_to); + } + if (item_code) { + conditions.push(`item_code ILIKE $${idx++}`); + params.push(`%${item_code}%`); + } + + const result = await pool.query( + `SELECT id, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, + remark, writer, manager_name, created_date + FROM inventory_history + WHERE ${conditions.join(" AND ")} + ORDER BY transaction_date DESC + LIMIT 100`, + params + ); + + // remark JSON 파싱 + const data = result.rows.map((row: any) => { + let detail: any = {}; + try { detail = JSON.parse(row.remark || "{}"); } catch { /* */ } + return { + ...row, + reason: detail.reason || "", + adjust_type: detail.type || "", + system_qty: detail.system_qty, + actual_qty: detail.actual_qty, + diff: detail.diff, + memo: detail.memo || "", + from_warehouse: detail.from_warehouse, + to_warehouse: detail.to_warehouse, + }; + }); + + return res.json({ success: true, data }); + + } catch (error: any) { + logger.error("[pop/inventory] adjust-history 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/locations?warehouse_code=XXX + * 특정 창고의 위치 목록 조회 (warehouse_location 테이블) + */ +export const getLocations = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { warehouse_code } = req.query; + if (!warehouse_code) { + return res.status(400).json({ success: false, message: "warehouse_code 필수" }); + } + + const result = await pool.query( + `SELECT id, location_code, location_name, floor, zone, row_num, level_num, + warehouse_code, warehouse_name + FROM warehouse_location + WHERE company_code = $1 AND warehouse_code = $2 + ORDER BY floor, zone, row_num, level_num`, + [companyCode, warehouse_code] + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory] locations 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/location-lookup?code=XXX + * 위치코드로 창고+위치 정보 조회 (QR 스캔 결과 처리) + */ +export const locationLookup = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { code } = req.query; + if (!code) { + return res.status(400).json({ success: false, message: "code 필수" }); + } + + const result = await pool.query( + `SELECT id, location_code, location_name, floor, zone, row_num, level_num, + warehouse_code, warehouse_name + FROM warehouse_location + WHERE company_code = $1 AND location_code = $2 + LIMIT 1`, + [companyCode, code] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "해당 위치코드를 찾을 수 없습니다" }); + } + + return res.json({ success: true, data: result.rows[0] }); + + } catch (error: any) { + logger.error("[pop/inventory] location-lookup 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * POST /api/pop/inventory/temp-status + * cart_items 상태 일괄 변경 (cart_type + fromStatus → toStatus) + * 개별 건: ids 배열 전달 시 해당 건만 변경 + */ +export const updateCartStatus = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { cart_type, from_status, to_status, ids } = req.body; + if (!cart_type || !to_status) { + return res.status(400).json({ success: false, message: "cart_type, to_status 필수" }); + } + + let result; + if (ids && Array.isArray(ids) && ids.length > 0) { + // 개별 건 상태 변경 + const placeholders = ids.map((_: string, i: number) => `$${i + 4}`).join(", "); + result = await pool.query( + `UPDATE cart_items + SET status = $1, updated_date = NOW() + WHERE company_code = $2 AND cart_type = $3 + AND id IN (${placeholders}) + ${from_status ? "AND status = $" + (ids.length + 4) : ""}`, + from_status + ? [to_status, companyCode, cart_type, ...ids, from_status] + : [to_status, companyCode, cart_type, ...ids] + ); + } else { + // 전체 건 상태 변경 (from_status 기준) + if (!from_status) { + return res.status(400).json({ success: false, message: "전체 변경 시 from_status 필수" }); + } + result = await pool.query( + `UPDATE cart_items + SET status = $1, updated_date = NOW() + WHERE company_code = $2 AND cart_type = $3 AND status = $4`, + [to_status, companyCode, cart_type, from_status] + ); + } + + logger.info("[pop/inventory] temp-status 변경", { + companyCode, cart_type, from_status, to_status, + ids: ids?.length ?? "all", + affected: result.rowCount, + }); + + return res.json({ + success: true, + message: `${result.rowCount}건 상태 변경 완료`, + data: { affected: result.rowCount }, + }); + + } catch (error: any) { + logger.error("[pop/inventory] temp-status 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/process-stock + * 공정 진행 중인 항목의 수량 표시 (양품수량 > 0) + */ +export const getProcessStock = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const result = await pool.query( + `SELECT wop.id, wop.wo_id, wi.work_instruction_no, wop.process_name, wop.seq_no, + COALESCE(wop.input_qty, '0') as input_qty, + COALESCE(wop.good_qty, '0') as good_qty, + wop.item_code, ii.item_name + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, company_code + FROM item_info + ORDER BY item_number, company_code, created_date DESC + ) ii ON wop.item_code = ii.item_number AND wop.company_code = ii.company_code + WHERE wop.company_code = $1 + AND wop.parent_process_id IS NULL + AND COALESCE(CAST(NULLIF(wop.good_qty,'') AS numeric), 0) > 0 + ORDER BY wi.work_instruction_no, wop.seq_no`, + [companyCode] + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory] process-stock 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/item-history?item_code=XXX + * 특정 품목의 재고 이력 조회 + */ +export const getItemHistory = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { item_code } = req.query; + if (!item_code) { + return res.status(400).json({ success: false, message: "item_code 필수" }); + } + + const result = await pool.query( + `SELECT id, item_code, warehouse_code, location_code, transaction_type, + transaction_date, quantity, balance_qty, remark, writer, manager_name, created_date + FROM inventory_history + WHERE company_code = $1 AND item_code = $2 + ORDER BY created_date DESC + LIMIT 50`, + [companyCode, item_code] + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory] item-history 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/process-stock-v2 + * 공정명+설비 필터 기반 품목 리스트 (재고이동 화면용) + * - 필터: process_name, equipment_code (선택) + * - 응답: processNames[], equipments[], processes[] (품목 기준) + * - 대기수량 = N공정 양품 - (N+1)공정 투입합계 + * - 미입고 = 마지막 공정 AND good_qty > 0 AND target_warehouse_id IS NULL + * - 리워크 제외 (is_rework = 'Y' 제외) + */ +export const getProcessStockV2 = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { process_name, equipment_code } = req.query; + + // 1) DISTINCT 공정명 목록 (양품 > 0인 것만) + const processNamesResult = await pool.query( + `SELECT DISTINCT wop.process_name + FROM work_order_process wop + WHERE wop.company_code = $1 + AND wop.parent_process_id IS NULL + AND (wop.is_rework IS NULL OR wop.is_rework != 'Y') + AND COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0 + ORDER BY wop.process_name`, + [companyCode] + ); + + // 2) DISTINCT 설비 목록 (양품 > 0인 것만) + const equipmentsResult = await pool.query( + `SELECT DISTINCT COALESCE(wop.equipment_code, '') AS equipment_code + FROM work_order_process wop + WHERE wop.company_code = $1 + AND wop.parent_process_id IS NULL + AND (wop.is_rework IS NULL OR wop.is_rework != 'Y') + AND COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0 + ORDER BY equipment_code`, + [companyCode] + ); + + // 3) 전체 공정 목록 (양품 > 0, 마스터 행, 리워크 제외) + 필터 적용 + const conditions = [ + "wop.company_code = $1", + "wop.parent_process_id IS NULL", + "(wop.is_rework IS NULL OR wop.is_rework != 'Y')", + "COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0", + ]; + const params: any[] = [companyCode]; + let idx = 2; + + if (process_name) { + conditions.push(`wop.process_name = $${idx++}`); + params.push(process_name); + } + if (equipment_code) { + conditions.push(`COALESCE(wop.equipment_code, '') = $${idx++}`); + params.push(equipment_code); + } + + const processResult = await pool.query( + `SELECT wop.id, wop.wo_id, wop.seq_no, wop.process_name, + COALESCE(wop.equipment_code, '') AS equipment_code, + COALESCE(wop.input_qty, '0') AS input_qty, + COALESCE(wop.good_qty, '0') AS good_qty, + COALESCE(wop.concession_qty, '0') AS concession_qty, + wop.target_warehouse_id, + wop.target_location_code, + wop.status, + wi.work_instruction_no, + COALESCE(ii.item_number, wi.item_id) AS item_number, + COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + LEFT JOIN ( + SELECT DISTINCT ON (id, company_code) + id, item_number, item_name, company_code + FROM item_info + ORDER BY id, company_code, created_date DESC + ) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY wop.process_name, wi.work_instruction_no, CAST(wop.seq_no AS int)`, + params + ); + + const rows = processResult.rows; + + // 작업지시별 공정 그룹핑하여 마지막 공정 판별 + 대기수량 계산 + // wo_id 별로 그룹핑하여 각 공정의 순서 내 마지막인지 확인 + const woProcessMap: Record = {}; + for (const row of rows) { + if (!woProcessMap[row.wo_id]) woProcessMap[row.wo_id] = []; + woProcessMap[row.wo_id].push(row); + } + + // 각 wo_id의 전체 공정 목록 (양품 없는 것 포함) 가져와서 마지막 공정/대기수량 정확히 계산 + const woIds = Object.keys(woProcessMap); + const allProcessMap: Record = {}; + + if (woIds.length > 0) { + const allProcessResult = await pool.query( + `SELECT wop.id, wop.wo_id, wop.seq_no, wop.process_name, + COALESCE(wop.input_qty, '0') AS input_qty, + COALESCE(wop.good_qty, '0') AS good_qty, + COALESCE(wop.concession_qty, '0') AS concession_qty, + wop.target_warehouse_id + FROM work_order_process wop + WHERE wop.wo_id = ANY($1) AND wop.company_code = $2 + AND wop.parent_process_id IS NULL + AND (wop.is_rework IS NULL OR wop.is_rework != 'Y') + ORDER BY wop.wo_id, CAST(wop.seq_no AS int)`, + [woIds, companyCode] + ); + for (const r of allProcessResult.rows) { + if (!allProcessMap[r.wo_id]) allProcessMap[r.wo_id] = []; + allProcessMap[r.wo_id].push(r); + } + } + + // enriched 결과 생성 + const enriched = []; + for (const row of rows) { + const allProcs = allProcessMap[row.wo_id] || []; + const procIdx = allProcs.findIndex((p: any) => p.id === row.id); + const isLastProcess = procIdx === allProcs.length - 1; + + const goodQty = (parseFloat(row.good_qty) || 0) + (parseFloat(row.concession_qty) || 0); + + // 분할 행 양품 합계 + const splitGoodResult = await pool.query( + `SELECT COALESCE(SUM(COALESCE(CAST(NULLIF(good_qty,'') AS numeric),0) + COALESCE(CAST(NULLIF(concession_qty,'') AS numeric),0)), 0) AS total_good + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND (is_rework IS NULL OR is_rework != 'Y')`, + [row.wo_id, row.seq_no, companyCode] + ); + const totalGood = parseFloat(splitGoodResult.rows[0]?.total_good) || goodQty; + const effectiveGood = totalGood > 0 ? totalGood : goodQty; + + // 다음 공정 투입합계 + let nextInputTotal = 0; + if (!isLastProcess && procIdx >= 0 && procIdx < allProcs.length - 1) { + const nextProc = allProcs[procIdx + 1]; + const nextInputResult = await pool.query( + `SELECT COALESCE(SUM(CAST(NULLIF(input_qty,'') AS numeric)), 0) AS total_input + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND (is_rework IS NULL OR is_rework != 'Y')`, + [row.wo_id, nextProc.seq_no, companyCode] + ); + nextInputTotal = parseFloat(nextInputResult.rows[0]?.total_input) || 0; + } + + const waitingQty = effectiveGood - nextInputTotal; + const isUnstored = isLastProcess && effectiveGood > 0 && !row.target_warehouse_id; + + enriched.push({ + id: row.id, + wo_id: row.wo_id, + seq_no: row.seq_no, + process_name: row.process_name, + equipment_code: row.equipment_code, + good_qty: String(effectiveGood), + input_qty: row.input_qty, + status: row.status, + target_warehouse_id: row.target_warehouse_id || null, + target_location_code: row.target_location_code || null, + is_last_process: isLastProcess, + is_unstored: isUnstored, + waiting_qty: Math.max(waitingQty, 0), + next_input_total: nextInputTotal, + item_code: row.item_number, + item_name: row.item_name, + work_instruction_no: row.work_instruction_no, + }); + } + + return res.json({ + success: true, + data: { + processNames: processNamesResult.rows.map((r: any) => r.process_name), + equipments: equipmentsResult.rows.map((r: any) => r.equipment_code).filter((e: string) => e !== ''), + processes: enriched, + }, + }); + + } catch (error: any) { + logger.error("[pop/inventory] process-stock-v2 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +interface MoveItem { + item_code: string; + from_warehouse: string; + from_location?: string; + to_warehouse: string; + to_location?: string; + quantity: number; + stock_id?: string; + source_type?: "warehouse" | "process"; + work_order_process_id?: string; +} + +/** + * POST /api/pop/inventory/move-batch + * 재고 이동 일괄 실행 + * - 출발 창고 inventory_stock 차감 + * - 도착 창고 inventory_stock UPSERT (증가) + * - inventory_history 2건 (출발 -수량, 도착 +수량, transaction_type='이동') + */ +export const moveBatch = async (req: any, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const userName = req.user?.userName || userId; + + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { items } = req.body as { items: MoveItem[] }; + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "이동 항목이 없습니다" }); + } + + await client.query("BEGIN"); + + let moveCount = 0; + const results: Array<{ item_code: string; status: string; message?: string }> = []; + + for (const item of items) { + const sourceType = item.source_type || "warehouse"; + + if (sourceType === "process") { + // ===== 공정 → 창고 입고 ===== + if (!item.work_order_process_id || !item.to_warehouse || !item.item_code) { + results.push({ item_code: item.item_code || "", status: "invalid", message: "공정 입고: 필수값 누락" }); + continue; + } + + // 1. work_order_process 조회 (이중 입고 방지) + const procResult = await client.query( + `SELECT id, wo_id, good_qty, concession_qty, target_warehouse_id, seq_no, parent_process_id, is_rework + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [item.work_order_process_id, companyCode] + ); + + if (procResult.rowCount === 0) { + results.push({ item_code: item.item_code, status: "not_found", message: "공정을 찾을 수 없음" }); + continue; + } + + const proc = procResult.rows[0]; + if (proc.target_warehouse_id) { + results.push({ item_code: item.item_code, status: "already_stored", message: "이미 입고 완료" }); + continue; + } + + const goodQty = (parseFloat(proc.good_qty || "0")) + (parseFloat(proc.concession_qty || "0")); + if (goodQty <= 0) { + results.push({ item_code: item.item_code, status: "no_qty", message: "양품 수량 없음" }); + continue; + } + + const moveQty = item.quantity > 0 ? Math.min(item.quantity, goodQty) : goodQty; + const toLocation = item.to_location || ""; + + // 2. inventory_stock UPSERT (도착 창고) + const existingTo = await client.query( + `SELECT id, current_qty FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND warehouse_code = $3 + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, item.item_code, item.to_warehouse, toLocation] + ); + + if (existingTo.rows.length > 0) { + const toQty = parseFloat(existingTo.rows[0].current_qty) || 0; + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, last_in_date = NOW(), updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(toQty + moveQty), userId, existingTo.rows[0].id, companyCode] + ); + } else { + await client.query( + `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, safety_qty, last_in_date, created_date, updated_date, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`, + [companyCode, item.item_code, item.to_warehouse, toLocation, String(moveQty), userId] + ); + } + + // 3. work_order_process에 target_warehouse_id 저장 (현재 행 + 마스터 행) + const idsToUpdate = [item.work_order_process_id]; + if (proc.parent_process_id) { + idsToUpdate.push(proc.parent_process_id); + } + + for (const id of idsToUpdate) { + await client.query( + `UPDATE work_order_process + SET target_warehouse_id = $3, + target_location_code = $4, + writer = $5, + updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [id, companyCode, item.to_warehouse, toLocation || null, userId] + ); + } + + // 4. 리워크 마크 해제 + if (proc.is_rework === "Y") { + await client.query( + `UPDATE work_order_process SET is_rework = NULL, updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [item.work_order_process_id, companyCode] + ); + } + + // 5. inventory_history 1건 (공정 입고) + const toBalance = existingTo.rows.length > 0 + ? (parseFloat(existingTo.rows[0].current_qty) || 0) + moveQty + : moveQty; + + const remarkJson = JSON.stringify({ + type: "process_inbound", + source: "process", + work_order_process_id: item.work_order_process_id, + to_warehouse: item.to_warehouse, + quantity: moveQty, + }); + + 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, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '공정입고', NOW(), $5, $6, $7, $8, $9, NOW())`, + [companyCode, item.item_code, item.to_warehouse, toLocation, + String(moveQty), String(toBalance), remarkJson, userId, userName] + ); + + moveCount++; + results.push({ item_code: item.item_code, status: "ok" }); + + } else { + // ===== 창고 → 창고 이동 (기존 로직) ===== + if (!item.item_code || !item.from_warehouse || !item.to_warehouse || !item.quantity || item.quantity <= 0) { + results.push({ item_code: item.item_code, status: "invalid", message: "필수값 누락 또는 수량 오류" }); + continue; + } + + // 1. 출발 창고 재고 확인 + 차감 + const fromConditions = [ + "company_code = $1", + "item_code = $2", + "warehouse_code = $3", + ]; + const fromParams: any[] = [companyCode, item.item_code, item.from_warehouse]; + + if (item.stock_id) { + fromConditions.push(`id = $${fromParams.length + 1}`); + fromParams.push(item.stock_id); + } + + const fromStock = await client.query( + `SELECT id, current_qty, location_code + FROM inventory_stock + WHERE ${fromConditions.join(" AND ")} + LIMIT 1`, + fromParams + ); + + if (fromStock.rowCount === 0) { + results.push({ item_code: item.item_code, status: "not_found", message: "출발 재고 없음" }); + continue; + } + + const fromRow = fromStock.rows[0]; + const currentQty = parseFloat(fromRow.current_qty) || 0; + + if (currentQty < item.quantity) { + results.push({ item_code: item.item_code, status: "insufficient", message: `재고 부족 (현재: ${currentQty})` }); + continue; + } + + // 출발 재고 차감 + const newFromQty = currentQty - item.quantity; + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(newFromQty), userId, fromRow.id, companyCode] + ); + + // 2. 도착 창고 UPSERT + const toLocation = item.to_location || ""; + const existingTo = await client.query( + `SELECT id, current_qty FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND warehouse_code = $3 + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, item.item_code, item.to_warehouse, toLocation] + ); + + if (existingTo.rows.length > 0) { + const toQty = parseFloat(existingTo.rows[0].current_qty) || 0; + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(toQty + item.quantity), userId, existingTo.rows[0].id, companyCode] + ); + } else { + await client.query( + `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, created_date, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), $6)`, + [companyCode, item.item_code, item.to_warehouse, toLocation, String(item.quantity), userId] + ); + } + + // 3. inventory_history 2건 (출발 -수량, 도착 +수량) + const remarkJson = JSON.stringify({ + type: "move", + from_warehouse: item.from_warehouse, + to_warehouse: item.to_warehouse, + quantity: item.quantity, + }); + + // 출발 이력 (마이너스) + 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, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '이동', NOW(), $5, $6, $7, $8, $9, NOW())`, + [companyCode, item.item_code, item.from_warehouse, item.from_location || "", + String(-item.quantity), String(newFromQty), remarkJson, userId, userName] + ); + + // 도착 이력 (플러스) + const toBalance = existingTo.rows.length > 0 + ? (parseFloat(existingTo.rows[0].current_qty) || 0) + item.quantity + : item.quantity; + + 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, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '이동', NOW(), $5, $6, $7, $8, $9, NOW())`, + [companyCode, item.item_code, item.to_warehouse, toLocation, + String(item.quantity), String(toBalance), remarkJson, userId, userName] + ); + + moveCount++; + results.push({ item_code: item.item_code, status: "ok" }); + } + } + + await client.query("COMMIT"); + + logger.info("[pop/inventory] move-batch 완료", { + companyCode, userId, moveCount, total: items.length + }); + + return res.json({ + success: true, + message: `${moveCount}건 이동 완료`, + data: { moveCount, results } + }); + + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/inventory] move-batch 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index dabb2f1f..fca4baf2 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -162,6 +162,7 @@ async function generateWorkProcessesForInstruction( planQty: string | null, companyCode: string, userId: string, + batchId?: string | null, ): Promise<{ processes: Array<{ id: string; @@ -171,14 +172,27 @@ async function generateWorkProcessesForInstruction( }>; total_checklists: number; } | null> { - // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 - const existCheck = await client.query( - `SELECT COUNT(*) as cnt FROM work_order_process - WHERE wo_id = $1 AND company_code = $2`, - [workInstructionId, companyCode], - ); - if (parseInt(existCheck.rows[0].cnt, 10) > 0) { - return null; // 이미 존재 + // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 (batch_id 기준 분리) + if (batchId) { + // 다중 품목: 같은 wo_id + 같은 batch_id에 대해 이미 공정이 있으면 skip + const existCheck = await client.query( + `SELECT COUNT(*) as cnt FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 AND batch_id = $3`, + [workInstructionId, companyCode, batchId], + ); + if (parseInt(existCheck.rows[0].cnt, 10) > 0) { + return null; // 이미 존재 + } + } else { + // 기존 동작: batch_id 없으면 wo_id 전체로 체크 + const existCheck = await client.query( + `SELECT COUNT(*) as cnt FROM work_order_process + WHERE wo_id = $1 AND company_code = $2`, + [workInstructionId, companyCode], + ); + if (parseInt(existCheck.rows[0].cnt, 10) > 0) { + return null; // 이미 존재 + } } // 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명) @@ -207,13 +221,13 @@ async function generateWorkProcessesForInstruction( let totalChecklists = 0; for (const rd of routingDetails.rows) { - // 2. work_order_process INSERT + // 2. work_order_process INSERT (batch_id 포함) const wopResult = await client.query( `INSERT INTO work_order_process ( id, company_code, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, plan_qty, - status, routing_detail_id, writer - ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + status, routing_detail_id, batch_id, writer + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, [ companyCode, @@ -229,6 +243,7 @@ async function generateWorkProcessesForInstruction( ? "acceptable" : "waiting", rd.id, + batchId || null, userId, ], ); @@ -358,45 +373,42 @@ export const syncWorkInstructions = async ( userId, }); - // 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목 - // header에 routing 없으면 detail에서 가져옴 (PC가 detail에만 저장하는 경우 대응) + // 미동기화 작업지시 조회 — 다중 품목(detail) 지원 + // 1단계: header routing이 있고 아직 공정이 없는 작업지시 (기존 호환) + // 2단계: detail별 routing이 있고 해당 detail의 공정(batch_id)이 없는 항목 const unsyncedResult = await pool.query( `SELECT wi.id, wi.work_instruction_no, - COALESCE(wi.routing, wid.routing_version_id) AS routing, - COALESCE(NULLIF(wi.qty, ''), wid.qty) AS qty, - COALESCE(wi.item_id, (SELECT id FROM item_info WHERE item_number = wid.item_number AND company_code = $1 LIMIT 1)) AS item_id + wi.routing AS header_routing, + wi.qty AS header_qty, + wi.item_id AS header_item_id FROM work_instruction wi - LEFT JOIN LATERAL ( - SELECT routing_version_id, qty, item_number - FROM work_instruction_detail - WHERE work_instruction_no = wi.work_instruction_no AND company_code = $1 - LIMIT 1 - ) wid ON true WHERE wi.company_code = $1 - AND COALESCE(wi.routing, wid.routing_version_id) IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM work_order_process wop - WHERE wop.wo_id = wi.id AND wop.company_code = $1 + AND ( + -- header routing이 있는데 공정이 아예 없는 경우 + (wi.routing IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM work_order_process wop + WHERE wop.wo_id = wi.id AND wop.company_code = $1 + )) + OR + -- detail에 routing이 있는 경우 (다중 품목 지원) + EXISTS ( + SELECT 1 FROM work_instruction_detail wid + WHERE wid.work_instruction_no = wi.work_instruction_no + AND wid.company_code = $1 + AND wid.routing_version_id IS NOT NULL + AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0 + AND NOT EXISTS ( + SELECT 1 FROM work_order_process wop + WHERE wop.wo_id = wi.id AND wop.company_code = $1 + AND wop.batch_id = wid.item_number + ) + ) )`, [companyCode], ); const unsynced = unsyncedResult.rows; - // header에 routing/qty/item_id가 비어있으면 자동 보정 (detail → header 동기화) - for (const wi of unsynced) { - await pool.query( - `UPDATE work_instruction SET - routing = COALESCE(routing, $2), - qty = COALESCE(NULLIF(qty, ''), $3), - item_id = COALESCE(item_id, $4), - updated_date = NOW() - WHERE id = $1 AND company_code = $5 - AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`, - [wi.id, wi.routing, wi.qty, wi.item_id, companyCode], - ); - } - if (unsynced.length === 0) { return res.json({ success: true, @@ -410,64 +422,178 @@ export const syncWorkInstructions = async ( const details: Array<{ work_instruction_id: string; work_instruction_no: string; + item_number?: string; status: "synced" | "skipped" | "error"; process_count?: number; error?: string; }> = []; for (const wi of unsynced) { - const client = await pool.connect(); - try { - await client.query("BEGIN"); + // detail 목록 조회: routing_version_id가 있고 qty > 0인 것 + const detailResult = await pool.query( + `SELECT wid.item_number, wid.routing_version_id, wid.qty + FROM work_instruction_detail wid + WHERE wid.work_instruction_no = $1 AND wid.company_code = $2 + AND wid.routing_version_id IS NOT NULL + AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0 + ORDER BY wid.created_date ASC`, + [wi.work_instruction_no, companyCode], + ); - const result = await generateWorkProcessesForInstruction( - client, - wi.id, - wi.routing, - wi.qty || null, - companyCode, - userId, + const detailRows = detailResult.rows; + + if (detailRows.length === 0 && wi.header_routing) { + // detail이 없지만 header routing이 있는 경우: 기존 방식 (단일 품목) + // header에 routing/qty/item_id 자동 보정 + const firstDetail = await pool.query( + `SELECT routing_version_id, qty, item_number + FROM work_instruction_detail + WHERE work_instruction_no = $1 AND company_code = $2 + LIMIT 1`, + [wi.work_instruction_no, companyCode], ); + const wid = firstDetail.rows[0]; + if (wid) { + await pool.query( + `UPDATE work_instruction SET + routing = COALESCE(routing, $2), + qty = COALESCE(NULLIF(qty, ''), $3), + item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)), + updated_date = NOW() + WHERE id = $1 AND company_code = $5 + AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`, + [wi.id, wid.routing_version_id, wid.qty, wid.item_number, companyCode], + ); + } - if (!result) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await generateWorkProcessesForInstruction( + client, + wi.id, + wi.header_routing, + wi.header_qty || null, + companyCode, + userId, + ); + if (!result) { + await client.query("ROLLBACK"); + skipped++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + status: "skipped", + }); + } else { + await client.query("COMMIT"); + synced++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + status: "synced", + process_count: result.processes.length, + }); + logger.info("[pop/production] sync: 공정 생성 완료 (header routing)", { + work_instruction_no: wi.work_instruction_no, + process_count: result.processes.length, + }); + } + } catch (err: any) { await client.query("ROLLBACK"); - skipped++; + errors++; details.push({ work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, - status: "skipped", + status: "error", + error: err.message || "알 수 없는 오류", }); - continue; + logger.error("[pop/production] sync: header routing 오류", { + work_instruction_no: wi.work_instruction_no, + error: err.message, + }); + } finally { + client.release(); } + continue; + } - await client.query("COMMIT"); - synced++; - details.push({ - work_instruction_id: wi.id, - work_instruction_no: wi.work_instruction_no, - status: "synced", - process_count: result.processes.length, - }); + // 다중 품목: 각 detail별로 공정 생성 (batch_id = item_number) + // header routing/item_id도 첫 번째 detail 기준 보정 + if (detailRows.length > 0) { + const first = detailRows[0]; + await pool.query( + `UPDATE work_instruction SET + routing = COALESCE(routing, $2), + qty = COALESCE(NULLIF(qty, ''), $3), + item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)), + updated_date = NOW() + WHERE id = $1 AND company_code = $5 + AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`, + [wi.id, first.routing_version_id, first.qty, first.item_number, companyCode], + ); + } - logger.info("[pop/production] sync: 공정 생성 완료", { - work_instruction_no: wi.work_instruction_no, - process_count: result.processes.length, - }); - } catch (err: any) { - await client.query("ROLLBACK"); - errors++; - details.push({ - work_instruction_id: wi.id, - work_instruction_no: wi.work_instruction_no, - status: "error", - error: err.message || "알 수 없는 오류", - }); - logger.error("[pop/production] sync: 개별 오류", { - work_instruction_no: wi.work_instruction_no, - error: err.message, - }); - } finally { - client.release(); + for (const detail of detailRows) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const result = await generateWorkProcessesForInstruction( + client, + wi.id, + detail.routing_version_id, + detail.qty || null, + companyCode, + userId, + detail.item_number, // batch_id = item_number + ); + + if (!result) { + await client.query("ROLLBACK"); + skipped++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + item_number: detail.item_number, + status: "skipped", + }); + continue; + } + + await client.query("COMMIT"); + synced++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + item_number: detail.item_number, + status: "synced", + process_count: result.processes.length, + }); + + logger.info("[pop/production] sync: 다중품목 공정 생성 완료", { + work_instruction_no: wi.work_instruction_no, + item_number: detail.item_number, + process_count: result.processes.length, + }); + } catch (err: any) { + await client.query("ROLLBACK"); + errors++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + item_number: detail.item_number, + status: "error", + error: err.message || "알 수 없는 오류", + }); + logger.error("[pop/production] sync: 다중품목 개별 오류", { + work_instruction_no: wi.work_instruction_no, + item_number: detail.item_number, + error: err.message, + }); + } finally { + client.release(); + } } } diff --git a/backend-node/src/routes/popInventoryRoutes.ts b/backend-node/src/routes/popInventoryRoutes.ts new file mode 100644 index 00000000..958a3d5f --- /dev/null +++ b/backend-node/src/routes/popInventoryRoutes.ts @@ -0,0 +1,40 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { adjustBatch, getAdjustHistory, getStockDetail, loadTempCart, clearTempCart, updateCartStatus, getLocations, locationLookup, getProcessStock, getProcessStockV2, getItemHistory, moveBatch } from "../controllers/popInventoryController"; + +const router = Router(); +router.use(authenticateToken); + +// 재고 목록 + 품목상세 JOIN 조회 +router.get("/stock-detail", getStockDetail); + +// 임시저장 불러오기/삭제/상태변경 +router.get("/temp-load", loadTempCart); +router.delete("/temp-clear", clearTempCart); +router.post("/temp-status", updateCartStatus); + +// 재고 조정 일괄 확정 +router.post("/adjust-batch", adjustBatch); + +// 재고 조정 이력 조회 +router.get("/adjust-history", getAdjustHistory); + +// 창고별 위치 목록 조회 +router.get("/locations", getLocations); + +// 위치코드로 창고+위치 조회 (QR 스캔) +router.get("/location-lookup", locationLookup); + +// 공정 진행 중 수량 조회 +router.get("/process-stock", getProcessStock); + +// 공정별 대기수량/미입고 조회 (v2) +router.get("/process-stock-v2", getProcessStockV2); + +// 품목별 재고 이력 조회 +router.get("/item-history", getItemHistory); + +// 재고 이동 일괄 실행 +router.post("/move-batch", moveBatch); + +export default router; diff --git a/frontend/app/(pop)/pop/inventory/adjust-history/page.tsx b/frontend/app/(pop)/pop/inventory/adjust-history/page.tsx new file mode 100644 index 00000000..abc286ff --- /dev/null +++ b/frontend/app/(pop)/pop/inventory/adjust-history/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { AdjustHistory } from "@/components/pop/hardcoded/inventory/AdjustHistory"; + +export default function AdjustHistoryPage() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/inventory/history/page.tsx b/frontend/app/(pop)/pop/inventory/history/page.tsx index d68d80c7..4801229d 100644 --- a/frontend/app/(pop)/pop/inventory/history/page.tsx +++ b/frontend/app/(pop)/pop/inventory/history/page.tsx @@ -5,7 +5,7 @@ import { InOutHistory } from "@/components/pop/hardcoded/inventory"; export default function InOutHistoryPage() { return ( - + ); diff --git a/frontend/components/pop/hardcoded/PopShell.tsx b/frontend/components/pop/hardcoded/PopShell.tsx index 7f538aef..fcdcea89 100644 --- a/frontend/components/pop/hardcoded/PopShell.tsx +++ b/frontend/components/pop/hardcoded/PopShell.tsx @@ -11,9 +11,10 @@ interface PopShellProps { title?: string; showBack?: boolean; headerRight?: ReactNode; + fullBleed?: boolean; } -export function PopShell({ children, showBanner = true, title, showBack = false, headerRight }: PopShellProps) { +export function PopShell({ children, showBanner = true, title, showBack = false, headerRight, fullBleed = false }: PopShellProps) { const router = useRouter(); const { user, logout } = useAuth(); const displayName = user?.userName || user?.userId || "사용자"; @@ -319,7 +320,10 @@ export function PopShell({ children, showBanner = true, title, showBack = false, } {/* ===== MAIN CONTENT ===== */} -
+
{children}
diff --git a/frontend/components/pop/hardcoded/inventory/AdjustHistory.tsx b/frontend/components/pop/hardcoded/inventory/AdjustHistory.tsx new file mode 100644 index 00000000..50aff97c --- /dev/null +++ b/frontend/components/pop/hardcoded/inventory/AdjustHistory.tsx @@ -0,0 +1,150 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { apiClient } from "@/lib/api/client"; +import { DateRangePicker } from "./DateRangePicker"; + +export function AdjustHistory() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [dateFrom, setDateFrom] = useState(() => { + const d = new Date(); + d.setDate(d.getDate() - 30); + return d.toISOString().slice(0, 10); + }); + const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10)); + const [keyword, setKeyword] = useState(""); + + const fetchHistory = useCallback(async () => { + setLoading(true); + try { + const params: Record = {}; + if (dateFrom) params.date_from = dateFrom; + if (dateTo) params.date_to = dateTo; + if (keyword) params.item_code = keyword; + const res = await apiClient.get("/pop/inventory/adjust-history", { params }); + setItems(res.data?.data || []); + } catch { + setItems([]); + } finally { + setLoading(false); + } + }, [dateFrom, dateTo, keyword]); + + useEffect(() => { fetchHistory(); }, [fetchHistory]); + + const filtered = items; + + return ( +
+ {/* 필터 */} +
+
+
+ { setDateFrom(f); setDateTo(t); }} + /> +
+ + setKeyword(e.target.value)} + placeholder="품목코드 검색" + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-amber-400" + /> +
+
+ +
+
+ + {/* KPI */} +
+
+
+ 전체 + {filtered.length} +
+
+ 확인 + + {filtered.filter((h: any) => h.transaction_type === "조정확인").length} + +
+
+ 조정 + + {filtered.filter((h: any) => h.transaction_type === "조정").length} + +
+
+
+ + {/* 리스트 */} +
+
+ 조정 이력 + 총 {filtered.length}건 +
+ + {loading ? ( +
+
+
+ ) : filtered.length === 0 ? ( +
+ 📋 +

조정 이력이 없습니다

+
+ ) : ( +
+ {filtered.map((h: any) => { + const isConfirm = h.transaction_type === "조정확인"; + const qty = parseFloat(h.quantity || "0"); + return ( +
+
+ {isConfirm ? "✓" : "~"} +
+
+

{h.item_code}

+
+ {h.reason && ( + {h.reason} + )} + {h.warehouse_code} + {h.manager_name || h.writer} +
+
+
+

0 ? "text-blue-600" : "text-red-600"}`}> + {qty === 0 ? "이상없음" : (qty > 0 ? `+${qty}` : qty)} +

+ {h.system_qty != null && qty !== 0 && ( +

{h.system_qty} → {h.actual_qty ?? h.system_qty}

+ )} +

+ {h.transaction_date ? new Date(h.transaction_date).toLocaleDateString() : ""} +

+
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/components/pop/hardcoded/inventory/InOutHistory.tsx b/frontend/components/pop/hardcoded/inventory/InOutHistory.tsx index adc47eee..2b71b57d 100644 --- a/frontend/components/pop/hardcoded/inventory/InOutHistory.tsx +++ b/frontend/components/pop/hardcoded/inventory/InOutHistory.tsx @@ -234,7 +234,7 @@ export function InOutHistory() { const filtered = items.filter((item) => { if (activeTab === "inbound" && item.direction !== "입고") return false; if (activeTab === "outbound" && item.direction !== "출고") return false; - if (activeTab === "transfer") return false; // 준비 중 + if (activeTab === "transfer") return false; if (keyword) { const kw = keyword.toLowerCase(); if ( @@ -260,39 +260,24 @@ export function InOutHistory() { ]; return ( -
- {/* Back + Title */} -
- -
-

- 입출고관리 -

-

- 입고·출고 내역을 조회합니다 -

+ + + + +

입출고관리

{/* Filters */} -
+
- + setKeyword(e.target.value)} placeholder="품목명 / 코드 검색" - className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-cyan-400" + className="w-full px-3 py-3 border border-gray-200 rounded-xl text-base focus:outline-none focus:border-cyan-400" />
- +
-
+
@@ -356,121 +335,77 @@ export function InOutHistory() {
- {/* KPI */} -
-
- - - - + {/* KPI + Tabs */} +
+
+ + + + +
+
+ {TABS.map((tab) => ( + + ))}
-
- - {/* Tabs */} -
- {TABS.map((tab) => ( - - ))}
{/* List */} -
-
- - 입출고 내역 - - 총 {filtered.length}건 +
+
+ 입출고 내역 + 총 {filtered.length}건
{loading ? ( -
+
{[1, 2, 3, 4].map((i) => ( -
+
-
+
-
))}
) : filtered.length === 0 ? ( -
- - - -

- 입출고 내역이 없습니다 -

-

검색 조건을 변경해보세요

+
+ 📦 +

입출고 내역이 없습니다

+

검색 조건을 변경해보세요

) : ( - filtered.map((item) => ( -
setSelectedItem(item)} - className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer active:scale-[0.98]" - > -
+
+ {filtered.map((item) => ( +
- )) + + ))} +
)}
@@ -526,165 +452,87 @@ export function InOutHistory() { className="fixed inset-0 z-50 flex items-end justify-center" onClick={() => setSelectedItem(null)} > - {/* Overlay */} -
- {/* Sheet */} +
e.stopPropagation()} > - {/* Handle bar */}
- - {/* Header */}

- {selectedItem.direction === "입고" ? "입고" : "출고"} 상세 —{" "} - {selectedItem.docNumber} + {selectedItem.direction === "입고" ? "입고" : "출고"} 상세 — {selectedItem.docNumber}

-
- - {/* Body */}
- {/* Row 1: 전표번호 + 구분 */}
- - {/* Row 2: 일시 + 상태 */}
-

- 상태 -

- +

상태

+ {selectedItem.statusLabel}
-
- - {/* Row 3: 품목 */}
-

- 품목 -

-

+

품목

+

{selectedItem.itemName} {selectedItem.itemCode ? ` (${selectedItem.itemCode})` : ""} {selectedItem.spec ? ( - - {selectedItem.spec} - + {selectedItem.spec} ) : null}

- - {/* Row 4: 수량 + LOT */}
-

- 수량 -

-

+

수량

+

{selectedItem.qty.toLocaleString()}{" "} - - {selectedItem.unit} - + {selectedItem.unit}

- +
-
- - {/* Row 5: 창고/위치 + 거래처 */}
-

- 창고 / 위치 -

-

- {selectedItem.warehouse} -

+

창고 / 위치

+

{selectedItem.warehouse}

{selectedItem.locationCode && ( -

- {selectedItem.locationCode} -

+

{selectedItem.locationCode}

)}
- - {/* Row 6: 작업자 + 비고 */}
- +
- - {/* Row 7: 참조번호 + 금액 (있을 때만) */} - {(selectedItem.referenceNumber || - selectedItem.totalAmount > 0) && ( + {(selectedItem.referenceNumber || selectedItem.totalAmount > 0) && (
{selectedItem.referenceNumber ? ( - - ) : ( -
- )} + + ) :
} {selectedItem.totalAmount > 0 ? ( - - ) : ( -
- )} + + ) :
}
)}
- - {/* Footer */}
@@ -694,14 +542,14 @@ export function InOutHistory() { )} + @keyframes slide-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } + } + .animate-slide-up { + animation: slide-up 0.3s ease-out; + } + `}
); } @@ -713,8 +561,8 @@ export function InOutHistory() { function DetailField({ label, value }: { label: string; value: string }) { return (
-

{label}

-

{value}

+

{label}

+

{value}

); } @@ -731,17 +579,15 @@ function KpiCell({ color: string; }) { return ( -
- {icon} +
+ {icon} {value} - - {label} - + {label}
); } diff --git a/frontend/components/pop/hardcoded/inventory/InventoryMove.tsx b/frontend/components/pop/hardcoded/inventory/InventoryMove.tsx index 247076d9..8c7e8f17 100644 --- a/frontend/components/pop/hardcoded/inventory/InventoryMove.tsx +++ b/frontend/components/pop/hardcoded/inventory/InventoryMove.tsx @@ -5,6 +5,10 @@ import { useRouter } from "next/navigation"; import { apiClient } from "@/lib/api/client"; import { PopShell } from "../PopShell"; +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + interface Warehouse { id: string; warehouse_code: string; @@ -15,26 +19,129 @@ interface StockItem { id: string; item_code: string; item_name?: string; + item_number?: string; warehouse_code: string; + warehouse_name?: string; location_code?: string; + location_name?: string; + floor?: string; current_qty: string; + unit?: string; + spec?: string; } -interface PendingItem { - stock: StockItem; - moveQty: number; - toWarehouse: string; +interface ProcessV2Item { + id: string; + wo_id: string; + seq_no: string; + process_name: string; + equipment_code: string; + good_qty: string; + input_qty: string; + status: string; + target_warehouse_id: string | null; + target_location_code: string | null; + is_last_process: boolean; + is_unstored: boolean; + waiting_qty: number; + next_input_total: number; + item_code: string; + item_name: string; + work_instruction_no: string; } +interface HistoryItem { + id: string; + item_code: string; + warehouse_code: string; + location_code?: string; + transaction_type: string; + transaction_date: string; + quantity: string; + balance_qty: string; + remark: string; + writer: string; + manager_name?: string; + created_date: string; +} + +interface PendingMove { + sourceType: "warehouse" | "process"; + // warehouse source + stock?: StockItem; + // process source + processItem?: ProcessV2Item; + // common + toWarehouse: string; + toWarehouseName: string; + moveQty: number; + itemCode: string; + itemName: string; +} + +type TabType = "warehouse" | "process"; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + export function InventoryMove() { const router = useRouter(); + const [activeTab, setActiveTab] = useState("warehouse"); + + // 창고 탭 상태 const [warehouses, setWarehouses] = useState([]); - const [fromWarehouse, setFromWarehouse] = useState(""); - const [toWarehouse, setToWarehouse] = useState(""); - const [stockItems, setStockItems] = useState([]); - const [loading, setLoading] = useState(false); + const [selectedWarehouse, setSelectedWarehouse] = useState("all"); const [searchKeyword, setSearchKeyword] = useState(""); - const [pendingItems, setPendingItems] = useState([]); + const [stockItems, setStockItems] = useState([]); + const [stockLoading, setStockLoading] = useState(true); + + // 공정 탭 상태 + const [processNames, setProcessNames] = useState([]); + const [equipments, setEquipments] = useState([]); + const [selectedProcessName, setSelectedProcessName] = useState(""); + const [selectedEquipment, setSelectedEquipment] = useState(""); + const [processItems, setProcessItems] = useState([]); + const [processLoading, setProcessLoading] = useState(false); + const [showProcessModal, setShowProcessModal] = useState(false); + const [showEquipmentModal, setShowEquipmentModal] = useState(false); + + // 이동 대기열 + const [pendingItems, setPendingItems] = useState([]); + + // 이력 바텀시트 + const [historyTarget, setHistoryTarget] = useState<{ item_code: string; item_name: string } | null>(null); + const [historyItems, setHistoryItems] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + + // 도착 창고 선택 모달 (warehouse source) + const [moveTarget, setMoveTarget] = useState(null); + + // 도착 창고 선택 모달 (process source) + const [processMoveTarget, setProcessMoveTarget] = useState(null); + + // 수량 입력 모달 + const [qtyModalSource, setQtyModalSource] = useState<{ + sourceType: "warehouse" | "process"; + stock?: StockItem; + processItem?: ProcessV2Item; + maxQty: number; + itemCode: string; + itemName: string; + } | null>(null); + const [qtyModalToWh, setQtyModalToWh] = useState(null); + const [qtyInput, setQtyInput] = useState(""); + + // 토스트 + const [toastMsg, setToastMsg] = useState<{ text: string; type: "success" | "error" } | null>(null); + const showToast = (text: string, type: "success" | "error" = "success") => { + setToastMsg({ text, type }); + setTimeout(() => setToastMsg(null), 2500); + }; + + const [submitting, setSubmitting] = useState(false); + + /* ---- 데이터 조회 ---- */ const fetchWarehouses = useCallback(async () => { try { @@ -44,246 +151,996 @@ export function InventoryMove() { }, []); const fetchStock = useCallback(async () => { - if (!fromWarehouse) { setStockItems([]); return; } - setLoading(true); + setStockLoading(true); try { - const res = await apiClient.get("/data/inventory_stock", { - params: { pageSize: "500", filters: JSON.stringify({ warehouse_code: fromWarehouse }) }, - }); - const data = res.data?.data?.data ?? res.data?.data ?? []; - setStockItems(Array.isArray(data) ? data.filter((d: StockItem) => parseFloat(d.current_qty || "0") > 0) : []); + const params: Record = {}; + if (selectedWarehouse !== "all") { + params.warehouse_code = selectedWarehouse; + } + if (searchKeyword) { + params.keyword = searchKeyword; + } + const res = await apiClient.get("/pop/inventory/stock-detail", { params }); + const data = res.data?.data ?? []; + setStockItems(Array.isArray(data) ? data : []); } catch { setStockItems([]); } finally { - setLoading(false); + setStockLoading(false); } - }, [fromWarehouse]); + }, [selectedWarehouse, searchKeyword]); + + const fetchProcessStockV2 = useCallback(async (procName?: string, equipCode?: string) => { + setProcessLoading(true); + try { + const params: Record = {}; + if (procName) params.process_name = procName; + if (equipCode) params.equipment_code = equipCode; + const res = await apiClient.get("/pop/inventory/process-stock-v2", { params }); + const data = res.data?.data; + if (data?.processNames) setProcessNames(data.processNames); + if (data?.equipments) setEquipments(data.equipments); + if (data?.processes) setProcessItems(data.processes); + else setProcessItems([]); + } catch { + setProcessItems([]); + } finally { + setProcessLoading(false); + } + }, []); + + const fetchItemHistory = useCallback(async (itemCode: string, itemName: string) => { + setHistoryTarget({ item_code: itemCode, item_name: itemName }); + setHistoryLoading(true); + try { + const res = await apiClient.get("/pop/inventory/item-history", { + params: { item_code: itemCode }, + }); + setHistoryItems(res.data?.data ?? []); + } catch { + setHistoryItems([]); + } finally { + setHistoryLoading(false); + } + }, []); useEffect(() => { fetchWarehouses(); }, [fetchWarehouses]); useEffect(() => { fetchStock(); }, [fetchStock]); + useEffect(() => { + if (activeTab === "process") fetchProcessStockV2(selectedProcessName || undefined, selectedEquipment || undefined); + }, [activeTab, selectedProcessName, selectedEquipment, fetchProcessStockV2]); + + /* ---- 창고 탭 검색 필터 ---- */ const filtered = stockItems.filter((item) => { if (!searchKeyword) return true; const kw = searchKeyword.toLowerCase(); - return (item.item_code || "").toLowerCase().includes(kw) || (item.item_name || "").toLowerCase().includes(kw); + return ( + (item.item_code || "").toLowerCase().includes(kw) || + (item.item_name || "").toLowerCase().includes(kw) || + (item.item_number || "").toLowerCase().includes(kw) + ); }); - const addToPending = (stock: StockItem) => { - if (!toWarehouse) { alert("도착 창고를 먼저 선택하세요."); return; } - if (pendingItems.find((p) => p.stock.id === stock.id)) return; - const qty = parseFloat(stock.current_qty || "0"); - setPendingItems((prev) => [...prev, { stock, moveQty: qty, toWarehouse }]); + /* ---- 이동 플로우 (창고) ---- */ + + const handleMoveStart = (stock: StockItem) => { + setMoveTarget(stock); + setProcessMoveTarget(null); }; - const removePending = (id: string) => { - setPendingItems((prev) => prev.filter((p) => p.stock.id !== id)); + /* ---- 이동 플로우 (공정) ---- */ + + const handleProcessMoveStart = (proc: ProcessV2Item) => { + setProcessMoveTarget(proc); + setMoveTarget(null); }; - const fromWh = warehouses.find((w) => w.warehouse_code === fromWarehouse); - const toWh = warehouses.find((w) => w.warehouse_code === toWarehouse); + /* ---- 도착 창고 선택 ---- */ + + const handleSelectToWarehouse = (wh: Warehouse) => { + if (moveTarget) { + // 창고 소스 + const maxQty = parseFloat(moveTarget.current_qty || "0"); + setQtyModalSource({ + sourceType: "warehouse", + stock: moveTarget, + maxQty, + itemCode: moveTarget.item_code, + itemName: moveTarget.item_name || moveTarget.item_code, + }); + setQtyModalToWh(wh); + setQtyInput(String(maxQty)); + setMoveTarget(null); + } else if (processMoveTarget) { + // 공정 소스 + const goodQty = parseFloat(processMoveTarget.good_qty || "0"); + setQtyModalSource({ + sourceType: "process", + processItem: processMoveTarget, + maxQty: goodQty, + itemCode: processMoveTarget.item_code, + itemName: processMoveTarget.item_name || processMoveTarget.item_code, + }); + setQtyModalToWh(wh); + setQtyInput(String(goodQty)); + setProcessMoveTarget(null); + } + }; + + // 수량 입력 (숫자키패드) + const handleNumpadPress = (key: string) => { + if (key === "C") { + setQtyInput(""); + } else if (key === "BS") { + setQtyInput((prev) => prev.slice(0, -1)); + } else if (key === ".") { + if (!qtyInput.includes(".")) { + setQtyInput((prev) => prev + "."); + } + } else { + setQtyInput((prev) => prev + key); + } + }; + + // 대기열에 추가 + const handleAddToQueue = () => { + if (!qtyModalSource || !qtyModalToWh) return; + const qty = parseFloat(qtyInput); + if (!qty || qty <= 0) { + showToast("수량을 입력하세요", "error"); + return; + } + if (qty > qtyModalSource.maxQty) { + showToast(`최대 수량은 ${qtyModalSource.maxQty.toLocaleString()}입니다`, "error"); + return; + } + + // 중복 체크 + if (qtyModalSource.sourceType === "warehouse" && qtyModalSource.stock) { + const dup = pendingItems.find( + (p) => p.sourceType === "warehouse" && p.stock?.id === qtyModalSource.stock?.id && p.toWarehouse === qtyModalToWh.warehouse_code + ); + if (dup) { + showToast("이미 대기열에 있는 항목입니다", "error"); + setQtyModalSource(null); + setQtyModalToWh(null); + return; + } + } + if (qtyModalSource.sourceType === "process" && qtyModalSource.processItem) { + const dup = pendingItems.find( + (p) => p.sourceType === "process" && p.processItem?.id === qtyModalSource.processItem?.id + ); + if (dup) { + showToast("이미 대기열에 있는 항목입니다", "error"); + setQtyModalSource(null); + setQtyModalToWh(null); + return; + } + } + + setPendingItems((prev) => [ + ...prev, + { + sourceType: qtyModalSource.sourceType, + stock: qtyModalSource.stock, + processItem: qtyModalSource.processItem, + toWarehouse: qtyModalToWh.warehouse_code, + toWarehouseName: qtyModalToWh.warehouse_name, + moveQty: qty, + itemCode: qtyModalSource.itemCode, + itemName: qtyModalSource.itemName, + }, + ]); + setQtyModalSource(null); + setQtyModalToWh(null); + showToast("대기열에 추가됨"); + }; + + // 대기열에서 제거 + const removePending = (index: number) => { + setPendingItems((prev) => prev.filter((_, i) => i !== index)); + }; + + /* ---- 이동 확정 ---- */ + + const handleConfirmMove = async () => { + if (pendingItems.length === 0) return; + setSubmitting(true); + try { + const res = await apiClient.post("/pop/inventory/move-batch", { + items: pendingItems.map((p) => { + if (p.sourceType === "process" && p.processItem) { + return { + source_type: "process", + work_order_process_id: p.processItem.id, + item_code: p.itemCode, + from_warehouse: "", + to_warehouse: p.toWarehouse, + to_location: "", + quantity: p.moveQty, + }; + } + return { + source_type: "warehouse", + item_code: p.itemCode, + from_warehouse: p.stock?.warehouse_code || "", + from_location: p.stock?.location_code || "", + to_warehouse: p.toWarehouse, + to_location: "", + quantity: p.moveQty, + stock_id: p.stock?.id || "", + }; + }), + }); + if (res.data?.success) { + showToast(res.data.message || "이동 완료"); + setPendingItems([]); + fetchStock(); + if (activeTab === "process") fetchProcessStockV2(selectedProcessName || undefined, selectedEquipment || undefined); + } else { + showToast(res.data?.message || "이동 실패", "error"); + } + } catch (err: unknown) { + const e = err as { response?: { data?: { message?: string } }; message?: string }; + showToast(e?.response?.data?.message || e?.message || "오류 발생", "error"); + } finally { + setSubmitting(false); + } + }; + + /* ---- 이력 트랜잭션 타입 아이콘/색상 ---- */ + + const txTypeStyle = (type: string) => { + if (type?.includes("입고") || type?.includes("공정입고")) return { bg: "bg-blue-100", text: "text-blue-700", icon: "+" }; + if (type?.includes("출고")) return { bg: "bg-green-100", text: "text-green-700", icon: "-" }; + if (type?.includes("조정")) return { bg: "bg-amber-100", text: "text-amber-700", icon: "~" }; + if (type?.includes("공정") || type?.includes("이동")) return { bg: "bg-purple-100", text: "text-purple-700", icon: "M" }; + return { bg: "bg-gray-100", text: "text-gray-700", icon: "?" }; + }; + + // 현재 활성 모달의 소스 창고 (도착 선택 시 필터용) + const activeMoveSourceWarehouse = moveTarget?.warehouse_code || ""; + + /* ------------------------------------------------------------------ */ + /* Render */ + /* ------------------------------------------------------------------ */ return ( - -
- {/* Header */} -
- -
-

📦 재고 이동

-

창고 간 재고를 이동합니다

+ +
+ + {/* ===== 왼쪽: 재고 현황 ===== */} +
+ + {/* 탭: 창고 / 공정 */} +
+ +
-
- {/* 좌우 분할 */} -
- {/* ===== 왼쪽: 출발 창고 + 품목 선택 ===== */} -
- {/* 출발 창고 헤더 */} -
-
- 📤 출발 창고 - FROM -
-
- {warehouses.map((wh) => ( - - ))} -
-
- - {/* 검색 */} - {fromWarehouse && ( -
-
- setSearchKeyword(e.target.value)} - className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-blue-400" - /> - -
-
- )} - - {/* 품목 리스트 */} -
- {!fromWarehouse ? ( -
- 📦 -

출발 창고를 선택하세요

-
- ) : loading ? ( -
-
-
- ) : filtered.length === 0 ? ( -
-

해당 창고에 재고가 없습니다

-
- ) : ( -
- {filtered.map((item) => { - const isPending = pendingItems.some((p) => p.stock.id === item.id); - return ( + {/* 탭 내용 */} +
+ {activeTab === "warehouse" ? ( + /* ---- 창고 탭 ---- */ + <> + {/* 창고 탭 버튼 + 검색 (InventoryTransfer 패턴) */} +
+
+ + {warehouses.map((wh) => ( - ); - })} + ))} +
+
+ setSearchKeyword(e.target.value)} + className="flex-1 px-4 py-3.5 rounded-xl border border-gray-200 text-lg focus:outline-none focus:border-blue-400 bg-white" + /> + +
- )} -
+ + {/* 품목 리스트 (flat divide-y) */} +
+ {stockLoading ? ( +
+
+
+ ) : filtered.length === 0 ? ( +
+

재고 데이터가 없습니다

+
+ ) : ( +
+ {filtered.map((item) => { + const qty = parseFloat(item.current_qty || "0"); + const isPending = pendingItems.some( + (p) => p.sourceType === "warehouse" && p.stock?.id === item.id + ); + return ( +
+ {/* 품목 정보 - 터치하면 이력 */} + + + {/* 수량 */} +
+

{qty.toLocaleString()}

+

{item.unit || "EA"}

+
+ + {/* 이동 버튼 */} + +
+ ); + })} +
+ )} +
+ + ) : ( + /* ---- 공정 탭 ---- */ + <> + {/* 공정/설비 필터 */} +
+
+ {/* 공정 선택 버튼 */} + + {/* 설비 선택 버튼 */} + +
+
+ + {/* 품목 기준 리스트 */} +
+ {processLoading ? ( +
+
+
+ ) : processItems.length === 0 ? ( +
+

+ {processNames.length === 0 ? "양품이 있는 공정이 없습니다" : "해당 조건의 공정 데이터가 없습니다"} +

+
+ ) : ( +
+ {processItems.map((proc) => { + const goodQty = parseFloat(proc.good_qty || "0"); + const isPending = pendingItems.some( + (p) => p.sourceType === "process" && p.processItem?.id === proc.id + ); + const isStored = !!proc.target_warehouse_id; + + return ( +
+
+ {/* 품목 정보 */} + + + {/* 양품 수량 */} +
+

{goodQty.toLocaleString()}

+

양품

+
+ + {/* 이동 버튼 */} + {proc.is_unstored && !isPending && !isStored ? ( + + ) : isPending ? ( + + 대기중 + + ) : isStored ? ( +
+ + + +
+ ) : null} +
+
+ ); + })} +
+ )} +
+ + )} +
+
+ + {/* ===== 오른쪽: 이동 대기열 ===== */} +
+ {/* 헤더 */} +
+

이동 대기

+ + {pendingItems.length}건 +
- {/* ===== 오른쪽: 도착 창고 + 이동 대기열 ===== */} -
- {/* 도착 창고 헤더 */} -
-
- 📥 도착 창고 - TO + {/* 대기열 리스트 */} +
+ {pendingItems.length === 0 ? ( +
+ + + +

이동 대기열이 비었습니다

+

왼쪽에서 품목의 화살표를 터치하세요

-
+ ) : ( +
+ {pendingItems.map((p, idx) => { + const isProcess = p.sourceType === "process"; + return ( +
+
+
+

+ {p.itemName} +

+
+ + {isProcess ? `공정 (${p.processItem?.process_name || ""})` : (p.stock?.warehouse_name || p.stock?.warehouse_code || "")} + + + + + + {p.toWarehouseName} + +
+

+ {p.moveQty.toLocaleString()} EA +

+
+ +
+
+ ); + })} +
+ )} +
+ + {/* 하단 고정 버튼 */} +
+ +
+
+ + {/* ===== 도착 창고 선택 모달 ===== */} + {(moveTarget || processMoveTarget) && ( +
{ setMoveTarget(null); setProcessMoveTarget(null); }} + > +
+
e.stopPropagation()} + > + {/* 헤더 */} +
+
+

도착 창고 선택

+

+ {moveTarget + ? `${moveTarget.item_name || moveTarget.item_code} (${parseFloat(moveTarget.current_qty || "0").toLocaleString()} ${moveTarget.unit || "EA"})` + : processMoveTarget + ? `${processMoveTarget.item_name || processMoveTarget.item_code} (양품 ${parseFloat(processMoveTarget.good_qty || "0").toLocaleString()})` + : "" + } +

+
+ +
+ + {/* 창고 카드 리스트 */} +
{warehouses - .filter((wh) => wh.warehouse_code !== fromWarehouse) + .filter((wh) => wh.warehouse_code !== activeMoveSourceWarehouse) .map((wh) => ( + + ))} +
+
+
+ )} + + {/* ===== 수량 입력 모달 (숫자키패드) ===== */} + {qtyModalSource && qtyModalToWh && ( +
{ setQtyModalSource(null); setQtyModalToWh(null); }} + > +
+
e.stopPropagation()} + > + {/* 헤더 */} +
+
+

+ {qtyModalSource.sourceType === "process" ? "입고 수량 입력" : "이동 수량 입력"} +

+
+

+ {qtyModalSource.itemName} +

+
+ + {qtyModalSource.sourceType === "process" + ? `공정` + : (qtyModalSource.stock?.warehouse_name || qtyModalSource.stock?.warehouse_code || "") + } + + + + + + {qtyModalToWh.warehouse_name} + +
+
+ + {/* 수량 표시 */} +
+
+

+ 최대: {qtyModalSource.maxQty.toLocaleString()} EA +

+

+ {qtyInput || "0"} +

+
+
+ + {/* 숫자 키패드 */} +
+ {["1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0", "BS"].map((key) => ( + ))}
-
- {/* 이동 방향 표시 */} - {fromWh && toWh && ( -
- {fromWh.warehouse_name} - - {toWh.warehouse_name} + {/* 하단 버튼 */} +
+ + +
- )} - - {/* 이동 대기 목록 */} -
- {pendingItems.length === 0 ? ( -
- 📋 -

왼쪽에서 품목을 선택하세요

-

선택한 품목이 여기에 표시됩니다

-
- ) : ( -
- {pendingItems.map((p) => ( -
-
-

{p.stock.item_name || p.stock.item_code}

- -
-

{p.stock.item_code}

-
- - {p.moveQty.toLocaleString()} EA - - - {p.stock.warehouse_code} → {p.toWarehouse} - -
-
- ))} -
- )}
+
+ )} - {/* 하단 확정 바 */} -
-
- 이동 대기: {pendingItems.length}건 + {/* ===== 이력 바텀시트 ===== */} + {historyTarget && ( +
setHistoryTarget(null)} + > +
+
e.stopPropagation()} + style={{ maxHeight: "70vh" }} + > + {/* 헤더 */} +
+
+

재고 이력

+

+ {historyTarget.item_name} ({historyTarget.item_code}) +

+
+
+ + {/* 이력 리스트 */} +
+ {historyLoading ? ( +
+
+
+ ) : historyItems.length === 0 ? ( +
+

이력이 없습니다

+
+ ) : ( +
+ {historyItems.map((h) => { + const style = txTypeStyle(h.transaction_type); + const qty = parseFloat(h.quantity || "0"); + return ( +
+
+ {style.icon} +
+
+
+ + {h.transaction_type || "-"} + + + {h.warehouse_code}{h.location_code ? ` / ${h.location_code}` : ""} + +
+

+ {h.transaction_date ? new Date(h.transaction_date).toLocaleDateString("ko-KR") : ""} + {h.manager_name ? ` / ${h.manager_name}` : ""} +

+
+
+

= 0 ? "text-blue-600" : "text-red-600"}`}> + {qty >= 0 ? "+" : ""}{qty.toLocaleString()} +

+ {h.balance_qty && ( +

+ 잔량 {parseFloat(h.balance_qty || "0").toLocaleString()} +

+ )} +
+
+ ); + })} +
+ )} +
+
+
+ )} +
+ + {/* ===== 공정 선택 바텀시트 ===== */} + {showProcessModal && ( +
setShowProcessModal(false)} + > +
+
e.stopPropagation()} + > +
+

공정 선택

+
+
+ + {processNames.map((name) => ( + + ))}
-
+ )} + + {/* ===== 설비 선택 바텀시트 ===== */} + {showEquipmentModal && ( +
setShowEquipmentModal(false)} + > +
+
e.stopPropagation()} + > +
+

설비 선택

+ +
+
+ + {equipments.map((eq) => ( + + ))} + {equipments.length === 0 && ( +
+

등록된 설비가 없습니다

+
+ )} +
+
+
+ )} + + {/* 토스트 메시지 */} + {toastMsg && ( +
+ {toastMsg.text} +
+ )} ); } diff --git a/frontend/components/pop/hardcoded/inventory/InventoryTransfer.tsx b/frontend/components/pop/hardcoded/inventory/InventoryTransfer.tsx index 427b36ae..ebc086f6 100644 --- a/frontend/components/pop/hardcoded/inventory/InventoryTransfer.tsx +++ b/frontend/components/pop/hardcoded/inventory/InventoryTransfer.tsx @@ -4,6 +4,11 @@ import React, { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { apiClient } from "@/lib/api/client"; import { PopShell } from "../PopShell"; +import { BarcodeScanModal } from "../common/BarcodeScanModal"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ interface Warehouse { id: string; @@ -17,17 +22,43 @@ interface StockItem { item_code: string; item_name?: string; warehouse_code: string; + warehouse_name?: string; location_code?: string; + location_name?: string; + floor?: string; current_qty: string; unit?: string; + spec?: string; } -interface SelectedItem { - stock: StockItem; - adjustQty: string; - type: "confirm" | "adjust"; +interface WarehouseLocation { + id: string; + location_code: string; + location_name: string; + floor?: string; + zone?: string; + row_num?: string; + level_num?: string; + warehouse_code: string; + warehouse_name?: string; } +type AdjustReason = "실사차이" | "파손/훼손" | "유효기간" | "반품처리" | "위치불일치" | "기타"; + +interface ProcessedItem { + stock: StockItem; + type: "confirm" | "adjust"; + actualQty?: number; + reason?: AdjustReason; + newWarehouse?: string; + newLocation?: string; + memo?: string; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + export function InventoryTransfer() { const router = useRouter(); const [warehouses, setWarehouses] = useState([]); @@ -35,7 +66,38 @@ export function InventoryTransfer() { const [stockItems, setStockItems] = useState([]); const [loading, setLoading] = useState(true); const [searchKeyword, setSearchKeyword] = useState(""); - const [selectedItems, setSelectedItems] = useState([]); + const [processedItems, setProcessedItems] = useState([]); + + // 토스트 메시지 + const [toastMsg, setToastMsg] = useState<{ text: string; type: "success" | "error" } | null>(null); + const showToast = (text: string, type: "success" | "error" = "success") => { + setToastMsg({ text, type }); + setTimeout(() => setToastMsg(null), 2500); + }; + + // 임시저장 cart_items ID 추적 (X버튼 개별 취소용) + const [savedCartIds, setSavedCartIds] = useState>({}); + + // 모달 상태 + const [checkModal, setCheckModal] = useState(null); + const [adjustModal, setAdjustModal] = useState(null); + const [adjustQty, setAdjustQty] = useState(""); + const [numpadOpen, setNumpadOpen] = useState(false); + const [numpadValue, setNumpadValue] = useState(""); + const [adjustReason, setAdjustReason] = useState(""); + const [adjustMemo, setAdjustMemo] = useState(""); + const [adjustNewWh, setAdjustNewWh] = useState(""); + const [adjustNewLoc, setAdjustNewLoc] = useState(""); + + // 위치불일치 UI 상태 + const [showBarcodeScan, setShowBarcodeScan] = useState(false); + const [showWhSelectModal, setShowWhSelectModal] = useState(false); + const [showLocSelectModal, setShowLocSelectModal] = useState(false); + const [locSelectWhCode, setLocSelectWhCode] = useState(""); + const [locSelectWhName, setLocSelectWhName] = useState(""); + const [locationList, setLocationList] = useState([]); + const [locationLoading, setLocationLoading] = useState(false); + const [selectedLocationDisplay, setSelectedLocationDisplay] = useState(""); const fetchWarehouses = useCallback(async () => { try { @@ -47,13 +109,13 @@ export function InventoryTransfer() { const fetchStock = useCallback(async () => { setLoading(true); try { - const params: Record = { pageSize: "500" }; + const params: Record = {}; if (selectedWarehouse !== "all") { - params.filters = JSON.stringify({ warehouse_code: selectedWarehouse }); + params.warehouse_code = selectedWarehouse; } - const res = await apiClient.get("/data/inventory_stock", { params }); - const data = res.data?.data?.data ?? res.data?.data ?? []; - setStockItems(Array.isArray(data) ? data.filter((d: StockItem) => parseFloat(d.current_qty || "0") > 0) : []); + const res = await apiClient.get("/pop/inventory/stock-detail", { params }); + const data = res.data?.data ?? []; + setStockItems(Array.isArray(data) ? data : []); } catch { setStockItems([]); } finally { @@ -61,58 +123,329 @@ export function InventoryTransfer() { } }, [selectedWarehouse]); + // 특정 창고의 위치 목록 조회 + const fetchLocations = useCallback(async (warehouseCode: string) => { + setLocationLoading(true); + try { + const res = await apiClient.get("/pop/inventory/locations", { + params: { warehouse_code: warehouseCode }, + }); + setLocationList(res.data?.data || []); + } catch { + setLocationList([]); + } finally { + setLocationLoading(false); + } + }, []); + + // QR 스캔 결과로 위치 조회 + const handleLocationQrScan = useCallback(async (code: string) => { + try { + const res = await apiClient.get("/pop/inventory/location-lookup", { + params: { code }, + }); + if (res.data?.success && res.data.data) { + const loc = res.data.data as WarehouseLocation; + setAdjustNewWh(loc.warehouse_code); + setAdjustNewLoc(loc.location_code); + setSelectedLocationDisplay( + `${loc.warehouse_name || loc.warehouse_code} · ${loc.location_name || loc.location_code}` + ); + } else { + showToast("해당 위치코드를 찾을 수 없습니다", "error"); + } + } catch { + showToast("위치 조회 실패", "error"); + } + setShowBarcodeScan(false); + }, []); + + // 창고 선택 → 위치 목록 모달 + const handleWhSelect = useCallback((wh: Warehouse) => { + setLocSelectWhCode(wh.warehouse_code); + setLocSelectWhName(wh.warehouse_name); + setShowWhSelectModal(false); + fetchLocations(wh.warehouse_code); + setShowLocSelectModal(true); + }, [fetchLocations]); + + // 위치 선택 완료 + const handleLocSelect = useCallback((loc: WarehouseLocation) => { + setAdjustNewWh(loc.warehouse_code); + setAdjustNewLoc(loc.location_code); + setSelectedLocationDisplay( + `${loc.warehouse_name || loc.warehouse_code} · ${loc.location_name || loc.location_code}` + ); + setShowLocSelectModal(false); + }, []); + + // 위치 없이 창고만 선택 (위치 데이터가 없는 경우) + const handleWhOnlySelect = useCallback(() => { + setAdjustNewWh(locSelectWhCode); + setAdjustNewLoc(""); + setSelectedLocationDisplay(locSelectWhName || locSelectWhCode); + setShowLocSelectModal(false); + }, [locSelectWhCode, locSelectWhName]); + useEffect(() => { fetchWarehouses(); }, [fetchWarehouses]); useEffect(() => { fetchStock(); }, [fetchStock]); + // 임시저장 (cart_items 테이블 활용) + const TEMP_KEY = "inventory-adjust"; + const handleTempSave = async () => { + if (processedItems.length === 0) return; + try { + // 1. 기존 saved 건 → cancelled + await apiClient.post("/pop/inventory/temp-status", { + cart_type: TEMP_KEY, + from_status: "saved", + to_status: "cancelled", + }); + // 2. 새 데이터 저장 (tasks 구조 + id + row_data + status) + const newIds: Record = {}; + const toCreate = processedItems.map((p) => { + const id = crypto.randomUUID(); + newIds[p.stock.id] = id; + return { + id, + cart_type: TEMP_KEY, + status: "saved", + row_data: JSON.stringify({ + stock_id: p.stock.id, + item_code: p.stock.item_code, + item_name: p.stock.item_name, + warehouse_code: p.stock.warehouse_code, + warehouse_name: p.stock.warehouse_name, + location_code: p.stock.location_code, + current_qty: p.stock.current_qty, + unit: p.stock.unit, + spec: p.stock.spec, + type: p.type, + actualQty: p.actualQty, + reason: p.reason, + newWarehouse: p.newWarehouse, + newLocation: p.newLocation, + memo: p.memo, + }), + }; + }); + await apiClient.post("/pop/execute-action", { + tasks: [{ id: "cart-save-1", type: "cart-save" }], + data: {}, + cartChanges: { toCreate }, + }); + setSavedCartIds(newIds); + showToast(`${processedItems.length}건 임시저장 완료`); + } catch { + showToast("임시저장 실패", "error"); + } + }; + + // 임시저장 불러오기 (status='saved'인 건만) + const loadTempSave = useCallback(async () => { + try { + const res = await apiClient.get("/pop/inventory/temp-load", { + params: { cart_type: TEMP_KEY }, + }); + const rows = res.data?.data || []; + if (rows.length === 0) return; + const loaded: ProcessedItem[] = []; + const idMap: Record = {}; + for (const row of rows) { + try { + const d = typeof row.row_data === "string" ? JSON.parse(row.row_data) : row.row_data; + loaded.push({ + stock: { + id: d.stock_id, + item_code: d.item_code, + item_name: d.item_name, + warehouse_code: d.warehouse_code, + warehouse_name: d.warehouse_name, + location_code: d.location_code, + current_qty: d.current_qty, + unit: d.unit, + spec: d.spec, + }, + type: d.type, + actualQty: d.actualQty, + reason: d.reason, + newWarehouse: d.newWarehouse, + newLocation: d.newLocation, + memo: d.memo, + }); + idMap[d.stock_id] = row.id; + } catch { /* skip */ } + } + if (loaded.length > 0) { + setProcessedItems(loaded); + setSavedCartIds(idMap); + } + } catch { /* no temp data */ } + }, []); + + useEffect(() => { loadTempSave(); }, [loadTempSave]); + const filtered = stockItems.filter((item) => { if (!searchKeyword) return true; const kw = searchKeyword.toLowerCase(); return (item.item_code || "").toLowerCase().includes(kw) || (item.item_name || "").toLowerCase().includes(kw); }); - const addItem = (stock: StockItem) => { - if (selectedItems.find((s) => s.stock.id === stock.id)) return; - setSelectedItems((prev) => [...prev, { stock, adjustQty: "", type: "confirm" }]); + // 품목 클릭 → 재고 확인 모달 + const handleItemClick = (stock: StockItem) => { + if (processedItems.find((p) => p.stock.id === stock.id)) return; + setCheckModal(stock); }; - const removeItem = (id: string) => { - setSelectedItems((prev) => prev.filter((s) => s.stock.id !== id)); + // 확인(이상없음) + const handleConfirmOk = () => { + if (!checkModal) return; + setProcessedItems((prev) => [...prev, { stock: checkModal, type: "confirm" }]); + setCheckModal(null); }; - const confirmCount = selectedItems.filter((s) => s.type === "confirm").length; - const adjustCount = selectedItems.filter((s) => s.type === "adjust").length; + // 조정 필요 → 조정 모달 열기 + const handleNeedAdjust = () => { + if (!checkModal) return; + setAdjustModal(checkModal); + setAdjustQty(checkModal.current_qty || "0"); + setAdjustReason(""); + setAdjustMemo(""); + setAdjustNewWh(""); + setAdjustNewLoc(""); + setSelectedLocationDisplay(""); + setCheckModal(null); + }; + + // 조정 등록 + const handleAdjustSubmit = () => { + if (!adjustModal || !adjustReason) { + showToast("조정 사유를 선택해주세요.", "error"); + return; + } + setProcessedItems((prev) => [ + ...prev, + { + stock: adjustModal, + type: "adjust", + actualQty: parseFloat(adjustQty) || 0, + reason: adjustReason as AdjustReason, + newWarehouse: adjustNewWh || undefined, + newLocation: adjustNewLoc || undefined, + memo: adjustMemo || undefined, + }, + ]); + setAdjustModal(null); + }; + + const removeProcessed = async (stockId: string) => { + // cart_items에 저장된 건이면 status='cancelled'로 변경 + const cartId = savedCartIds[stockId]; + if (cartId) { + try { + await apiClient.post("/pop/inventory/temp-status", { + cart_type: TEMP_KEY, + to_status: "cancelled", + ids: [cartId], + }); + } catch { /* 실패해도 UI에서는 제거 */ } + setSavedCartIds((prev) => { + const next = { ...prev }; + delete next[stockId]; + return next; + }); + } + setProcessedItems((prev) => prev.filter((p) => p.stock.id !== stockId)); + }; + + const confirmCount = processedItems.filter((p) => p.type === "confirm").length; + const adjustCount = processedItems.filter((p) => p.type === "adjust").length; + const [submitting, setSubmitting] = useState(false); + + // 초기화 (전체 saved → cancelled) + const handleReset = async () => { + // cart_items에 saved 건이 있으면 전부 cancelled + if (Object.keys(savedCartIds).length > 0) { + try { + await apiClient.post("/pop/inventory/temp-status", { + cart_type: TEMP_KEY, + from_status: "saved", + to_status: "cancelled", + }); + } catch { /* 실패해도 UI는 초기화 */ } + } + setProcessedItems([]); + setSavedCartIds({}); + }; + + // 일괄 확정 + const handleBatchConfirm = async () => { + if (processedItems.length === 0) return; + setSubmitting(true); + try { + const res = await apiClient.post("/pop/inventory/adjust-batch", { + items: processedItems.map((p) => ({ + stock_id: p.stock.id, + item_code: p.stock.item_code, + warehouse_code: p.stock.warehouse_code, + location_code: p.stock.location_code || "", + system_qty: parseFloat(p.stock.current_qty || "0"), + type: p.type, + actual_qty: p.actualQty, + reason: p.reason, + new_warehouse: p.newWarehouse, + new_location: p.newLocation, + memo: p.memo, + })), + }); + if (res.data?.success) { + // 확정 성공 → cart_items status='confirmed' + if (Object.keys(savedCartIds).length > 0) { + try { + await apiClient.post("/pop/inventory/temp-status", { + cart_type: TEMP_KEY, + from_status: "saved", + to_status: "confirmed", + }); + } catch { /* 상태 변경 실패해도 확정은 완료 */ } + } + showToast(res.data.message || "처리 완료"); + setProcessedItems([]); + setSavedCartIds({}); + fetchStock(); + } else { + showToast(res.data?.message || "처리 실패", "error"); + } + } catch (err: unknown) { + const e = err as { response?: { data?: { message?: string } }; message?: string }; + showToast(e?.response?.data?.message || e?.message || "오류 발생", "error"); + } finally { + setSubmitting(false); + } + }; + + const REASONS: AdjustReason[] = ["실사차이", "파손/훼손", "유효기간", "반품처리", "위치불일치", "기타"]; + + const historyButton = ( + + ); return ( - -
- {/* Header */} -
-
- -

📦 재고조정

-
-
- - {/* Main — 2단 레이아웃 */} -
- {/* 왼쪽: 제품 선택 */} -
-
-
-

📦 제품 선택

- -
- - {/* 창고 탭 */} -
+ +
+ {/* ===== 왼쪽: 제품 선택 ===== */} +
+ {/* 창고 탭 + 검색 */} +
+
))}
- - {/* 검색 */}
setSearchKeyword(e.target.value)} - className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-amber-400" + className="flex-1 px-4 py-3.5 rounded-xl border border-gray-200 text-lg focus:outline-none focus:border-amber-400 bg-white" /> - +
{/* 품목 리스트 */} -
+
{loading ? (
-
+
) : filtered.length === 0 ? (
- 📦 -

해당 창고에 재고가 없습니다

+ 📦 +

재고 데이터가 없습니다

) : (
- {filtered.map((item) => ( -
-
-
📦
-
-

- {item.item_name || item.item_code} - {item.item_name && ({item.item_code})} + {filtered.map((item) => { + const isProcessed = processedItems.some((p) => p.stock.id === item.id); + const processedInfo = processedItems.find((p) => p.stock.id === item.id); + return ( +

-
-
-

{parseFloat(item.current_qty || "0").toLocaleString()}

-

{item.location_code || item.warehouse_code}

+
+
+

{parseFloat(item.current_qty || "0").toLocaleString()}

+

{item.unit || "EA"}

+
+ {isProcessed ? ( +
+ + + +
+ ) : ( +
+ + +
+ )}
- -
-
- ))} + + ); + })}
)}
- {/* 오른쪽: 처리 결과 */} -
-
-

📋 처리 결과

- - {selectedItems.length}건 - -
- -
- {selectedItems.length === 0 ? ( -
- 📋 -

제품을 스캔/선택하여 처리하세요

+ {/* ===== 오른쪽: 처리 결과 ===== */} +
+
+ {/* 헤더 — 스크롤 영역 안 */} +
+

처리 결과

+
+ 확인 {confirmCount} + 조정 {adjustCount} +
+
+ {processedItems.length === 0 ? ( +
+ 📋 +

품목을 선택하여

+

재고를 확인하세요

) : ( -
- {selectedItems.map((sel) => ( -
-
-

{sel.stock.item_name || sel.stock.item_code}

- +
+ {processedItems.map((p) => { + const qty = parseFloat(p.stock.current_qty || "0"); + const diff = p.type === "adjust" && p.actualQty != null ? p.actualQty - qty : 0; + return ( +
+
+
+

{p.stock.item_name || p.stock.item_code}

+

{p.stock.warehouse_name || p.stock.warehouse_code}{p.stock.location_code ? ` · ${p.stock.location_code}` : ""}

+
+ +
+ {p.type === "confirm" ? ( +
+ 이상없음 + {qty.toLocaleString()} {p.stock.unit || "EA"} +
+ ) : ( +
+
+ + {qty.toLocaleString()} → {p.actualQty?.toLocaleString()} ({diff > 0 ? "+" : ""}{diff}) + +
+ {p.reason && ( + {p.reason} + )} + {p.memo && ( +

{p.memo}

+ )} +
+ )}
-

현재 재고: {parseFloat(sel.stock.current_qty || "0").toLocaleString()}

- -
- ))} + ); + })}
)}
- {/* Footer */} -
-
- 확인 {confirmCount} - 조정 {adjustCount} + {/* 하단 바 */} +
+ + + +
+
+ + {/* ===== 재고 확인 모달 ===== */} + {checkModal && ( +
setCheckModal(null)}> +
+
e.stopPropagation()}> +
+

재고 확인

+
-
- - +
-
+ )} + + {/* ===== 재고 조정 입력 모달 ===== */} + {adjustModal && ( +
setAdjustModal(null)}> +
+
e.stopPropagation()}> +
+

재고 조정

+ +
+
+ {/* 품목 정보 — 1행 */} +
+

{adjustModal.item_name || adjustModal.item_code}

+

{adjustModal.item_code}{adjustModal.spec ? ` · ${adjustModal.spec}` : ""} · {adjustModal.warehouse_name || adjustModal.warehouse_code}

+
+ + {/* 실제 수량 — 터치로 숫자 키패드 열기 */} +
+ + + {(() => { + const d = (parseFloat(adjustQty) || 0) - parseFloat(adjustModal.current_qty || "0"); + if (d === 0) return null; + return ( +

0 ? "text-blue-600" : "text-red-600"}`}> + 차이: {d > 0 ? `+${d}` : d} (전산: {parseFloat(adjustModal.current_qty || "0").toLocaleString()}) +

+ ); + })()} +
+ + {/* 조정 사유 */} +
+ +
+ {REASONS.map((r) => ( + + ))} +
+
+ + {/* 위치불일치 → 실제 위치 (QR스캔 / 직접선택) */} + {adjustReason === "위치불일치" && ( +
+ +

현재: {adjustModal.warehouse_name || adjustModal.warehouse_code} · {adjustModal.location_code || "-"}

+ + {/* 2개 큰 버튼: QR스캔 + 직접선택 */} +
+ + +
+ + {/* 선택 결과 표시 */} + {selectedLocationDisplay && ( +
+
+ + + + {selectedLocationDisplay} +
+ +
+ )} +
+ )} + + {/* 메모 */} +
+ + setAdjustMemo(e.target.value)} + placeholder="메모를 입력하세요" + className="w-full px-4 py-3.5 rounded-xl border-2 border-gray-200 text-base focus:outline-none focus:border-amber-400" + /> +
+
+ + {/* 버튼 — 하단 고정 */} +
+ + +
+
+
+ )} + + {/* ===== 숫자 키패드 모달 ===== */} + {numpadOpen && ( +
setNumpadOpen(false)}> +
+
e.stopPropagation()}> +
+

실제 수량 입력

+

{numpadValue || "0"}

+
+
+ {["7","8","9","←","4","5","6","C","1","2","3",".",].map((k) => ( + + ))} + + +
+
+
+ )} + + {/* ===== QR 스캔 모달 ===== */} + + + {/* ===== 창고 선택 모달 ===== */} + {showWhSelectModal && ( +
setShowWhSelectModal(false)}> +
+
e.stopPropagation()}> +
+

창고 선택

+ +
+
+
+ {warehouses.map((wh) => ( + + ))} +
+
+
+
+ )} + + {/* ===== 위치 선택 모달 ===== */} + {showLocSelectModal && ( +
setShowLocSelectModal(false)}> +
+
e.stopPropagation()}> +
+

{locSelectWhName} - 위치 선택

+ +
+
+ {locationLoading ? ( +
+
+
+ ) : locationList.length === 0 ? ( +
+

등록된 위치가 없습니다

+ +
+ ) : ( +
+ {locationList.map((loc) => ( + + ))} +
+ )} +
+
+
+ )} + + {/* ===== 토스트 메시지 ===== */} + {toastMsg && ( +
+
+ {toastMsg.text} +
+
+ )} +
); diff --git a/frontend/components/pop/hardcoded/inventory/index.ts b/frontend/components/pop/hardcoded/inventory/index.ts index e0999972..5bef8368 100644 --- a/frontend/components/pop/hardcoded/inventory/index.ts +++ b/frontend/components/pop/hardcoded/inventory/index.ts @@ -1,2 +1,3 @@ +export { AdjustHistory } from "./AdjustHistory"; export { InOutHistory } from "./InOutHistory"; export { InventoryHome } from "./InventoryHome"; diff --git a/frontend/components/pop/hardcoded/production/ProcessWork.tsx b/frontend/components/pop/hardcoded/production/ProcessWork.tsx index 748627a0..811b9650 100644 --- a/frontend/components/pop/hardcoded/production/ProcessWork.tsx +++ b/frontend/components/pop/hardcoded/production/ProcessWork.tsx @@ -51,6 +51,7 @@ interface ProcessData { target_location_code: string | null; is_rework: string; routing_detail_id: string | null; + batch_id?: string | null; } interface WorkInstructionInfo { @@ -451,6 +452,27 @@ export function ProcessWork({ processId }: ProcessWorkProps) { } } + // batch_id가 있으면 해당 품목의 이름을 조회 (다중 품목 지원) + if (procData.batch_id) { + try { + const batchItemRes = await dataApi.getTableData("item_info", { + size: 1, + filters: { item_number: procData.batch_id }, + }); + const batchItem = batchItemRes.data?.[0] as Record | undefined; + if (batchItem) { + itemName = String(batchItem.item_name || procData.batch_id); + itemCode = String(batchItem.item_number || procData.batch_id); + } else { + itemName = procData.batch_id; + itemCode = procData.batch_id; + } + } catch { + itemName = procData.batch_id; + itemCode = procData.batch_id; + } + } + setWiInfo({ work_instruction_no: String(wi.work_instruction_no || ""), item_name: itemName, diff --git a/frontend/components/pop/hardcoded/production/WorkOrderList.tsx b/frontend/components/pop/hardcoded/production/WorkOrderList.tsx index 80abab33..258e9a2d 100644 --- a/frontend/components/pop/hardcoded/production/WorkOrderList.tsx +++ b/frontend/components/pop/hardcoded/production/WorkOrderList.tsx @@ -111,6 +111,8 @@ interface WorkOrderProcess { accepted_by?: string; accepted_at?: string | null; created_date?: string; + batch_id?: string | null; + equipment_code?: string; } interface ProcessMng { @@ -222,12 +224,14 @@ function FullscreenWorkModal({ processId, myProcesses, instructionMap, + itemNameMap, onSwitch, onClose, }: { processId: string; myProcesses: WorkOrderProcess[]; instructionMap: Record; + itemNameMap: Record; onSwitch: (id: string) => void; onClose: () => void; }) { @@ -302,10 +306,9 @@ function FullscreenWorkModal({ {wi?.work_instruction_no || "작업지시"}
- 📦 {wi?.item_name || ""} - {wi?.item_code || wi?.item_number - ? `(${wi?.item_code || wi?.item_number})` - : ""} + 📦 {proc.batch_id + ? `${itemNameMap[proc.batch_id] || proc.batch_id}(${proc.batch_id})` + : `${wi?.item_name || ""}${wi?.item_code || wi?.item_number ? `(${wi?.item_code || wi?.item_number})` : ""}`}
{proc.process_name} · {proc.equipment_code || "미배정"} @@ -463,7 +466,11 @@ function CompressedProcessSteps({ allProcesses?: WorkOrderProcess[]; }) { const sorted = [...processes] - .filter((p) => !p.parent_process_id) + .filter((p) => !p.parent_process_id && ( + // 같은 batch_id끼리만 표시 (다중 품목 구분) + (!batchId && !p.batch_id) || + (batchId && p.batch_id === batchId) + )) .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10)); if (sorted.length === 0) return null; @@ -479,7 +486,7 @@ function CompressedProcessSteps({ if (batchId && allProcesses) { const batchSplits = allProcesses.filter( (p) => - (p as Record).batch_id === batchId && + p.batch_id === batchId && p.parent_process_id && p.status === "completed", ); @@ -963,6 +970,7 @@ export function WorkOrderList() { const [allProcesses, setAllProcesses] = useState([]); const [processList, setProcessList] = useState([]); const [equipmentList, setEquipmentList] = useState([]); + const [itemNameMap, setItemNameMap] = useState>({}); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); const [activeTab, setActiveTab] = useState("acceptable"); @@ -1044,22 +1052,31 @@ export function WorkOrderList() { wiRaw = wiRes.data; } // wi_id → id 매핑 + 중복 제거 (header+detail 조인이므로 첫 행만) + // item_number → item_name 매핑 구축 (다중 품목 표시용) const seen = new Set(); const wiData: WorkInstruction[] = []; + const newItemNameMap: Record = {}; for (const raw of wiRaw) { const wiId = String(raw.wi_id || raw.id || ""); + // item_number → item_name 매핑 (모든 행에서 수집) + const rawItemNumber = String(raw.item_number || ""); + const rawItemName = String(raw.item_name || ""); + if (rawItemNumber && rawItemName) { + newItemNameMap[rawItemNumber] = rawItemName; + } if (!wiId || seen.has(wiId)) continue; seen.add(wiId); wiData.push({ ...raw, id: wiId, - item_name: String(raw.item_name || ""), + item_name: rawItemName, item_code: String(raw.item_code || ""), - item_number: String(raw.item_number || ""), + item_number: rawItemNumber, qty: parseInt(String(raw.total_qty || raw.qty || 0), 10), } as unknown as WorkInstruction); } setInstructions(wiData); + setItemNameMap(newItemNameMap); const procRes = await dataApi.getTableData("work_order_process", { size: 1000, @@ -1365,7 +1382,11 @@ export function WorkOrderList() { const openDetailModal = (proc: WorkOrderProcess) => { const wi = instructionMap[proc.wo_id]; const siblings = (processesByWo[proc.wo_id] || []) - .filter((p) => !p.parent_process_id) + .filter((p) => !p.parent_process_id && ( + // 같은 batch_id끼리만 형제 (다중 품목 구분) + (!proc.batch_id && !p.batch_id) || + (proc.batch_id && p.batch_id === proc.batch_id) + )) .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10)); const totalQty = wi ? wi.qty : parseInt(proc.plan_qty || "0", 10); @@ -1448,7 +1469,11 @@ export function WorkOrderList() { /* ---- Helper: get previous process info ---- */ const getPrevProcessInfo = (proc: WorkOrderProcess) => { const siblings = (processesByWo[proc.wo_id] || []) - .filter((p) => !p.parent_process_id) + .filter((p) => !p.parent_process_id && ( + // 같은 batch_id끼리만 형제 (다중 품목 구분) + (!proc.batch_id && !p.batch_id) || + (proc.batch_id && p.batch_id === proc.batch_id) + )) .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10)); const currentIdx = siblings.findIndex((p) => p.id === proc.id); @@ -1726,7 +1751,11 @@ export function WorkOrderList() { const wi = instructionMap[proc.wo_id]; const badge = STATUS_BADGE[proc.status] || STATUS_BADGE.waiting; const siblingProcesses = (processesByWo[proc.wo_id] || []).filter( - (p) => !p.parent_process_id, + (p) => !p.parent_process_id && ( + // 같은 batch_id끼리만 형제 (다중 품목 구분) + (!proc.batch_id && !p.batch_id) || + (proc.batch_id && p.batch_id === proc.batch_id) + ), ); const planQty = parseInt(proc.plan_qty || "0", 10); const goodQty = parseInt(proc.good_qty || "0", 10); @@ -1883,10 +1912,9 @@ export function WorkOrderList() { {/* Sub-info: item name + equipment */} - 📦 {wi?.item_name || "품목"} - {wi?.item_code || wi?.item_number - ? `(${wi?.item_code || wi?.item_number})` - : ""} + 📦 {proc.batch_id + ? `${itemNameMap[proc.batch_id] || proc.batch_id}(${proc.batch_id})` + : `${wi?.item_name || "품목"}${wi?.item_code || wi?.item_number ? `(${wi?.item_code || wi?.item_number})` : ""}`} {" · "} {!isRework ? `⚙️ ${eqName}` @@ -1901,9 +1929,7 @@ export function WorkOrderList() { status={proc.status} onClick={() => openDetailModal(proc)} batchId={ - (proc as Record).batch_id as - | string - | undefined + proc.batch_id ?? undefined } allProcesses={allProcesses} /> @@ -1965,7 +1991,7 @@ export function WorkOrderList() { proc.id, proc.process_name, proc.seq_no, - (proc as Record) + (proc as unknown as Record) .rework_source_id as string | undefined, ) } @@ -2080,6 +2106,7 @@ export function WorkOrderList() { p.status === "in_progress", )} instructionMap={instructionMap} + itemNameMap={itemNameMap} onSwitch={(id) => setWorkModalProcessId(id)} onClose={() => { setWorkModalProcessId(null); diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index 2c752a30..56ba5b9c 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -25,6 +25,7 @@ interface UserInfo { photo?: string | null; companyCode?: string; company_code?: string; + companyName?: string; } interface AuthStatus { diff --git a/frontend/public/change-review.html b/frontend/public/change-review.html new file mode 100644 index 00000000..baecb2c5 --- /dev/null +++ b/frontend/public/change-review.html @@ -0,0 +1,419 @@ + + + + + +POP 변경사항 리뷰 — 공정실행 + 재고이동 + + + + +
+

POP 변경사항 리뷰

+

2026-04-10 | 공정실행 다중품목 + 재고이동 리디자인

+
+ + +
+
+ 1. 공정실행 — 다중품목 지원 구현 완료 +
+
+ PC에서 1개 작업지시에 여러 품목을 넣으면, POP에서도 품목별로 공정을 분리해서 보여줍니다. +
+ +
+
+
BEFORE — 첫 번째 품목만 표시
+
+
+
작업지시 CODE-00002
+
+
+
1. 제조반_계량
+
투입 100 → 양품 90
+
+
90 EA
+
+
+
+
2. 제조반_배합
+
투입 40 → 양품 20
+
+
20 EA
+
+
+
+
3. 제조반_포장
+
투입 20 → 양품 20
+
+
20 EA
+
+
+ ⚠️ 욕조N_CTG28 (제품) 265개 + 원재료 2종이 있지만
첫 번째 품목만 공정 생성됨 +
+
+
+
+ +
+
AFTER — 품목별 공정 그룹
+
+
+
작업지시 CODE-00002
+ +
📦 욕조N_CTG28_반투명 (F_CRT01_265) — 제품 265개
+
+
+
1. 제조반_계량
+
투입 100 → 양품 90
+
+
90
+
+
+
+
2. 제조반_배합
+
투입 40 → 양품 20
+
+
20
+
+
+
+
3. 제조반_포장
+
투입 20 → 양품 20
+
+
20
+
+ +
📦 반제품 가 (별도 라우팅 있을 경우)
+
+
+
1. 혼합
+
투입 50 → 양품 45
+
+
45
+
+
+ ✅ 각 품목이 독립된 공정 세트를 가짐 (batch_id로 구분) +
+
+
+
+
+ +
+

기술 변경 요약

+
    +
  • syncWorkInstructions: LIMIT 1 제거 → work_instruction_detail 전체 순회
  • +
  • qty > 0 AND routing 있는 detail마다 공정 세트 독립 생성
  • +
  • work_order_process.batch_id = item_number 저장 → 품목별 공정 구분
  • +
  • WorkOrderList: 카드에 품목명(품목코드) 표시, batch_id 기준 형제 공정 필터링
  • +
  • ProcessWork: 공정 상세에서 batch_id로 품목 구분 표시
  • +
+
+
+ +
+ + +
+
+ 2. 재고이동 — 창고 탭 리디자인 구현 완료 +
+
+ 아코디언 → 재고조정과 동일한 탭 버튼 패턴. 좌우 분할 + 이동 대기열. +
+ +
+
+
BEFORE — 아코디언 접기/펼치기
+
+
+
+
▶ 맹동창고
+
6건
+
+
+
▶ 외주창고
+
3건
+
+
+
▶ 테스트
+
4건
+
+
+ ⚠️ 품목 많아지면 찾기 힘듦 +
+
+
+
+ +
+
AFTER — 탭 선택 + flat 리스트
+
+
+
+ 전체 + 맹동창고 + 외주창고 + 테스트 +
+
+
+
DEMO-PROD-001 / 데모페일 20L
+
맹동창고
+
+
+
31
+ +
+
+
+
+
793_CTG30_회색 / F_CRT01_040
+
25EA/BOX / 맹동창고
+
+
+
400
+ +
+
+
+
+
ESBC-500 / R_PLAST_024
+
180Kg/Drum / 테스트 · WH-001
+
+
+
500
+ +
+
+
+
+
+
+ +
+
창고 탭 선택
+
+
품목 [→] 터치
+
+
도착 창고 모달
+
+
수량 키패드
+
+
대기열 추가
+
+
이동 확정
+
+
+ +
+ + +
+
+ 3. 재고이동 공정 탭 — 공정/설비 필터 구현 중 +
+
+ "공정/설비 = 가상 창고" 개념. 공정명과 설비로 필터해서 해당 공정의 품목 현황을 봅니다. +
+ +
+
+
BEFORE — 작업지시번호로 선택
+
+
+
+ CODE-00002 + CODE-00005 + CODE-00006 +
+
+ ⚠️ 작업지시 100개 쌓이면?
어떤 번호가 뭔지 모름 +
+
+
+
+ +
+
AFTER — 공정명 + 설비로 필터
+
+
+
+
+
공정
+
제조반_계량 ▼
+
+
+
설비
+
전체 ▼
+
+
+ +
제조반_계량 공정에 있는 품목들
+ +
+
+
페일_PE용기 / P_OTH06_038
+
WI: CODE-00002 · 투입100 · 양품90
+
+
+
대기 50
+
→ 이동
+
+
+
+
+
페일_PE용기 / P_OTH06_038
+
WI: CODE-00005 · 투입50 · 양품50
+
+
+
입고완료
+
+
+
+
+
데모페일 20L / DEMO-PROD-001
+
WI: CODE-00006 · 투입30 · 양품30
+
+
+
입고완료
+
+
+
+
+
+
+ +
+

공정 탭 수량 계산법

+
    +
  • 대기수량 = N공정 양품 - (N+1)공정 투입. 예: 계량 양품90 - 배합 투입40 = 대기 50EA
  • +
  • 공정중 = 투입 - 양품 - 불량. 예: 투입100 - 양품90 - 불량0 = 공정중 10EA
  • +
  • 미입고 = 마지막 공정 양품 > 0 AND 창고 미배정 → ⚠️ 미입고 경고
  • +
  • 입고완료 = target_warehouse_id 있음 → 정상
  • +
+
+ +
+

"공정 = 가상 창고" 비유

+
    +
  • 창고를 선택하면 → 그 창고 안의 품목들이 보이듯
  • +
  • 공정을 선택하면 → 그 공정에서 처리 중인 품목들이 보인다
  • +
  • 설비를 추가 선택하면 → 더 좁혀짐 (1호기에서 계량 중인 것만)
  • +
  • 대기수량이 있는 품목 = "아직 다음 공정으로 안 넘어간 것" → [→ 이동] 가능
  • +
+
+
+ +
+ + +
+
+ 4. 재고조정 — 임시저장 + 상태관리 구현 완료 +
+
+ cart_items 테이블로 임시저장. 상태별 관리 (saved/cancelled/confirmed). +
+ +
+
+
BEFORE — 임시저장 없음
+
+
+
+ 조정 작업 중 화면 이탈하면
모든 데이터 사라짐 +
+
+
+
+ +
+
AFTER — 임시저장 + 상태 관리
+
+
+
+
임시저장 버튼
+
saved
+
+
+
X 버튼 (개별 취소)
+
cancelled
+
+
+
초기화 버튼
+
전체 cancelled
+
+
+
일괄확정 버튼
+
confirmed
+
+
+ ✅ 페이지 새로고침해도 saved 데이터 복원됨 +
+
+
+
+
+
+ +
+ Generated 2026-04-10 | POP 재고관리 리뷰 +
+ + + From e3657b099ddc2a0395e500a75c7b54a90c0ef851 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 10 Apr 2026 17:30:01 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A0=95=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EB=8B=A8=EC=9D=BC/=EB=8B=A4=EC=A4=91=ED=92=88?= =?UTF-8?q?=EB=AA=A9=20=EB=B1=83=EC=A7=80=20+=20=ED=92=88=EB=AA=A9?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단일품목: 회색 뱃지 [단일 · 제품] - 다중품목: 파랑 뱃지 [다중 1/2 · 반제품] - 리워크: 주황 뱃지 유지 (기존) - item_info.type으로 품목타입(제품/반제품/원재료/부재료) 표시 - workInstructionController: getList에 item_type 추가 - WorkOrderList: multiBatchInfo useMemo로 단일/다중 판단 - ProcessWork: batchBadge로 헤더에 뱃지 표시 --- .../controllers/workInstructionController.ts | 3 +- .../pop/hardcoded/production/ProcessWork.tsx | 72 ++++++++++++++-- .../hardcoded/production/WorkOrderList.tsx | 84 ++++++++++++++++++- 3 files changed, 150 insertions(+), 9 deletions(-) diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index a95b08f1..1de120a7 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -86,6 +86,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { d.source_id, d.routing_version_id AS detail_routing_version_id, COALESCE(itm.item_name, '') AS item_name, + COALESCE(itm.type, '') AS item_type, COALESCE(itm.size, '') AS item_spec, COALESCE(e.equipment_name, '') AS equipment_name, COALESCE(e.equipment_code, '') AS equipment_code, @@ -97,7 +98,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { 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, size FROM item_info + SELECT item_name, size, type FROM item_info WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1 ) itm ON true LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code diff --git a/frontend/components/pop/hardcoded/production/ProcessWork.tsx b/frontend/components/pop/hardcoded/production/ProcessWork.tsx index 811b9650..93e78193 100644 --- a/frontend/components/pop/hardcoded/production/ProcessWork.tsx +++ b/frontend/components/pop/hardcoded/production/ProcessWork.tsx @@ -404,6 +404,14 @@ export function ProcessWork({ processId }: ProcessWorkProps) { const [packageUnit, setPackageUnit] = useState(""); const [inboundDone, setInboundDone] = useState(false); + /* ---- Batch Badge (단일/다중품목) ---- */ + const [batchBadge, setBatchBadge] = useState<{ + isMulti: boolean; + index: number; + total: number; + itemType: string; + } | null>(null); + /* ---- Batch History ---- */ const [history, setHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); @@ -453,6 +461,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { } // batch_id가 있으면 해당 품목의 이름을 조회 (다중 품목 지원) + let batchItemType = ""; if (procData.batch_id) { try { const batchItemRes = await dataApi.getTableData("item_info", { @@ -463,6 +472,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) { if (batchItem) { itemName = String(batchItem.item_name || procData.batch_id); itemCode = String(batchItem.item_number || procData.batch_id); + batchItemType = String(batchItem.type || ""); } else { itemName = procData.batch_id; itemCode = procData.batch_id; @@ -472,6 +482,23 @@ export function ProcessWork({ processId }: ProcessWorkProps) { itemCode = procData.batch_id; } } + // item_type이 없으면 WI의 item_number로 조회 + if (!batchItemType && wi.item_number) { + try { + const wiItemRes = await dataApi.getTableData("item_info", { + size: 1, + filters: { item_number: String(wi.item_number) }, + }); + const wiItem = wiItemRes.data?.[0] as Record | undefined; + if (wiItem) { + batchItemType = String(wiItem.type || ""); + } + } catch { + /* non-critical */ + } + } + // batchItemType을 임시 저장 (step 6에서 사용) + (procData as unknown as Record)._itemType = batchItemType; setWiInfo({ work_instruction_no: String(wi.work_instruction_no || ""), @@ -524,22 +551,40 @@ export function ProcessWork({ processId }: ProcessWorkProps) { size: 100, filters: { wo_id: procData.wo_id }, }); - const masters = ((plRes.data ?? []) as ProcessData[]) + const allSiblings = (plRes.data ?? []) as ProcessData[]; + const masters = allSiblings .filter((p) => !p.parent_process_id) - .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10)) - .map((p) => ({ - process_code: p.process_code, - process_name: p.process_name, - })); + .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10)); // 중복 제거 const seen = new Set(); setProcessList( - masters.filter((m) => { + masters.map((p) => ({ + process_code: p.process_code, + process_name: p.process_name, + })).filter((m) => { if (seen.has(m.process_code)) return false; seen.add(m.process_code); return true; }), ); + // 다중품목 판단: 마스터 공정의 DISTINCT batch_id + const uniqueBatches: string[] = []; + for (const p of masters) { + const bid = p.batch_id || ""; + if (bid && !uniqueBatches.includes(bid)) { + uniqueBatches.push(bid); + } + } + const currentBid = procData?.batch_id || ""; + const isMultiBatch = uniqueBatches.length > 1; + const bIdx = currentBid ? uniqueBatches.indexOf(currentBid) + 1 : 1; + const fetchedItemType = String((procData as unknown as Record)?._itemType || ""); + setBatchBadge({ + isMulti: isMultiBatch, + index: Math.max(bIdx, 1), + total: Math.max(uniqueBatches.length, 1), + itemType: fetchedItemType, + }); } catch { setProcessList([]); } @@ -1113,6 +1158,19 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
)} + {batchBadge && ( +
+ {batchBadge.isMulti ? ( + + 다중 {batchBadge.index}/{batchBadge.total}{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""} + + ) : ( + + 단일{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""} + + )} +
+ )}
공정 diff --git a/frontend/components/pop/hardcoded/production/WorkOrderList.tsx b/frontend/components/pop/hardcoded/production/WorkOrderList.tsx index 258e9a2d..ec88679b 100644 --- a/frontend/components/pop/hardcoded/production/WorkOrderList.tsx +++ b/frontend/components/pop/hardcoded/production/WorkOrderList.tsx @@ -225,6 +225,7 @@ function FullscreenWorkModal({ myProcesses, instructionMap, itemNameMap, + multiBatchInfo, onSwitch, onClose, }: { @@ -232,6 +233,7 @@ function FullscreenWorkModal({ myProcesses: WorkOrderProcess[]; instructionMap: Record; itemNameMap: Record; + multiBatchInfo: Record; onSwitch: (id: string) => void; onClose: () => void; }) { @@ -305,6 +307,20 @@ function FullscreenWorkModal({
{wi?.work_instruction_no || "작업지시"}
+ {(() => { + const bInfo = multiBatchInfo[proc.id]; + if (!bInfo) return null; + const typeLabel = bInfo.itemType || ""; + return bInfo.isMulti ? ( + + 다중 {bInfo.index}/{bInfo.total}{typeLabel ? ` · ${typeLabel}` : ""} + + ) : ( + + 단일{typeLabel ? ` · ${typeLabel}` : ""} + + ); + })()} 📦 {proc.batch_id ? `${itemNameMap[proc.batch_id] || proc.batch_id}(${proc.batch_id})` @@ -971,6 +987,7 @@ export function WorkOrderList() { const [processList, setProcessList] = useState([]); const [equipmentList, setEquipmentList] = useState([]); const [itemNameMap, setItemNameMap] = useState>({}); + const [itemTypeMap, setItemTypeMap] = useState>({}); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); const [activeTab, setActiveTab] = useState("acceptable"); @@ -1056,14 +1073,19 @@ export function WorkOrderList() { const seen = new Set(); const wiData: WorkInstruction[] = []; const newItemNameMap: Record = {}; + const newItemTypeMap: Record = {}; for (const raw of wiRaw) { const wiId = String(raw.wi_id || raw.id || ""); - // item_number → item_name 매핑 (모든 행에서 수집) + // item_number → item_name / item_type 매핑 (모든 행에서 수집) const rawItemNumber = String(raw.item_number || ""); const rawItemName = String(raw.item_name || ""); + const rawItemType = String(raw.item_type || ""); if (rawItemNumber && rawItemName) { newItemNameMap[rawItemNumber] = rawItemName; } + if (rawItemNumber && rawItemType) { + newItemTypeMap[rawItemNumber] = rawItemType; + } if (!wiId || seen.has(wiId)) continue; seen.add(wiId); wiData.push({ @@ -1077,6 +1099,7 @@ export function WorkOrderList() { } setInstructions(wiData); setItemNameMap(newItemNameMap); + setItemTypeMap(newItemTypeMap); const procRes = await dataApi.getTableData("work_order_process", { size: 1000, @@ -1141,6 +1164,44 @@ export function WorkOrderList() { return map; }, [allProcesses]); + /** 다중품목 판단: wo_id별 DISTINCT batch_id 집합 + 순번 매핑 */ + const multiBatchInfo = useMemo(() => { + // wo_id → 고유 batch_id 목록 (마스터 행 기준) + const woBatches: Record = {}; + for (const proc of allProcesses) { + if (proc.parent_process_id) continue; // 마스터만 + if (!proc.wo_id) continue; + if (!woBatches[proc.wo_id]) woBatches[proc.wo_id] = []; + const bid = proc.batch_id || ""; + if (bid && !woBatches[proc.wo_id].includes(bid)) { + woBatches[proc.wo_id].push(bid); + } + } + // proc.id → { isMulti, index, total, itemType } + const info: Record = {}; + for (const proc of allProcesses) { + if (!proc.wo_id) continue; + const batches = woBatches[proc.wo_id] || []; + const bid = proc.batch_id || ""; + const isMulti = batches.length > 1; + const index = bid ? batches.indexOf(bid) + 1 : 1; + const total = Math.max(batches.length, 1); + // item_type: batch_id가 있으면 itemTypeMap에서, 없으면 WI의 item_number로 + let itemType = ""; + if (bid) { + itemType = itemTypeMap[bid] || ""; + } + if (!itemType) { + const wi = instructionMap[proc.wo_id]; + if (wi?.item_number) { + itemType = itemTypeMap[wi.item_number] || ""; + } + } + info[proc.id] = { isMulti, index, total, itemType }; + } + return info; + }, [allProcesses, itemTypeMap, instructionMap]); + const masterProcesses = useMemo(() => { // 마스터 행 + 분할 행(진행중/완료/리워크) — 중복 제거 const seen = new Set(); @@ -1910,6 +1971,26 @@ export function WorkOrderList() {
+ {/* 단일/다중품목 뱃지 */} + {(() => { + const bInfo = multiBatchInfo[proc.id]; + if (!bInfo) return null; + const typeLabel = bInfo.itemType || ""; + return ( +
+ {bInfo.isMulti ? ( + + 다중 {bInfo.index}/{bInfo.total}{typeLabel ? ` · ${typeLabel}` : ""} + + ) : ( + + 단일{typeLabel ? ` · ${typeLabel}` : ""} + + )} +
+ ); + })()} + {/* Sub-info: item name + equipment */} 📦 {proc.batch_id @@ -2107,6 +2188,7 @@ export function WorkOrderList() { )} instructionMap={instructionMap} itemNameMap={itemNameMap} + multiBatchInfo={multiBatchInfo} onSwitch={(id) => setWorkModalProcessId(id)} onClose={() => { setWorkModalProcessId(null);