diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 4c28dfed..498a9e7b 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -985,7 +985,7 @@ const checkAndCompleteWorkInstruction = async ( const completedQty = totalGoodResult.rows[0].total_good; - await pool.query( + const updateResult = await pool.query( `UPDATE work_instruction SET status = 'completed', progress_status = 'completed', @@ -993,13 +993,69 @@ const checkAndCompleteWorkInstruction = async ( writer = $4, updated_date = NOW() WHERE id = $1 AND company_code = $2 - AND status != 'completed'`, + AND status != 'completed' + RETURNING id, item_id`, [woId, companyCode, String(completedQty), userId] ); logger.info("[pop/production] 작업지시 전체 완료", { woId, completedQty, companyCode, }); + + // 생산완료→재고 입고: 마지막 공정의 target_warehouse_id가 설정된 경우 inventory_stock UPSERT + if (updateResult.rowCount > 0 && completedQty > 0) { + try { + const itemId = updateResult.rows[0].item_id; + + // item_info에서 item_number 조회 + const itemResult = await pool.query( + `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, + [itemId, companyCode] + ); + if (itemResult.rowCount === 0) { + logger.warn("[pop/production] 재고입고 건너뜀: item_info 없음", { itemId, companyCode }); + return; + } + const itemCode = itemResult.rows[0].item_number; + + // 마지막 공정의 창고 설정 조회 (마스터 행에서) + const warehouseResult = await pool.query( + `SELECT target_warehouse_id, target_location_code + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NULL + LIMIT 1`, + [woId, maxSeq, companyCode] + ); + + if (warehouseResult.rowCount === 0 || !warehouseResult.rows[0].target_warehouse_id) { + logger.info("[pop/production] 재고입고 건너뜀: 목표창고 미설정", { woId }); + return; + } + + const warehouseCode = warehouseResult.rows[0].target_warehouse_id; + const locationCode = warehouseResult.rows[0].target_location_code || warehouseCode; + + // inventory_stock UPSERT + await pool.query( + `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6) + ON CONFLICT (company_code, item_code, warehouse_code, location_code) + DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text, + last_in_date = NOW(), + updated_date = NOW(), + writer = $6`, + [companyCode, itemCode, warehouseCode, locationCode, String(completedQty), userId] + ); + + logger.info("[pop/production] 생산완료→재고 입고 완료", { + woId, itemCode, warehouseCode, locationCode, qty: completedQty, companyCode, + }); + } catch (inventoryError: any) { + // 재고 입고 실패해도 공정 완료는 유지 (재고는 보조 기능) + logger.error("[pop/production] 재고입고 오류 (공정 완료는 유지):", inventoryError); + } + } }; /** @@ -1607,3 +1663,199 @@ export const cancelAccept = async ( }); } }; + +/** + * 창고 목록 조회 (POP 생산용) + */ +export const getWarehouses = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const result = await pool.query( + `SELECT id, warehouse_code, warehouse_name, warehouse_type + FROM warehouse_info + WHERE company_code = $1 AND COALESCE(status, '') != '삭제' + ORDER BY warehouse_name`, + [companyCode] + ); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] 창고 목록 조회 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 특정 창고의 위치(로케이션) 목록 조회 + * warehouseId는 warehouse_info.id → warehouse_code를 조회해서 warehouse_location과 매칭 + */ +export const getWarehouseLocations = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { warehouseId } = req.params; + if (!warehouseId) { + return res.status(400).json({ success: false, message: "warehouseId는 필수입니다." }); + } + + // warehouse_info.id → warehouse_code 변환 + const whInfo = await pool.query( + `SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`, + [warehouseId, companyCode] + ); + if (whInfo.rowCount === 0) { + return res.json({ success: true, data: [] }); + } + const warehouseCode = whInfo.rows[0].warehouse_code; + + const result = await pool.query( + `SELECT id, location_code, location_name + FROM warehouse_location + WHERE warehouse_code = $1 AND company_code = $2 + ORDER BY location_name`, + [warehouseCode, companyCode] + ); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] 창고 위치 조회 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 마지막 공정 여부 확인 + * 같은 wo_id에서 현재 seq_no보다 큰 공정(마스터 행)이 없으면 마지막 + */ +export const isLastProcess = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res.json({ success: true, data: { isLast: false } }); + } + + // 현재 공정의 wo_id와 seq_no 조회 (분할 행이면 parent의 seq_no 기준) + const process = await pool.query( + `SELECT wo_id, seq_no, parent_process_id + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [processId, companyCode] + ); + if (process.rowCount === 0) { + return res.json({ success: true, data: { isLast: false } }); + } + + const { wo_id, seq_no, parent_process_id } = process.rows[0]; + + // 분할 행이면 마스터의 seq_no 기준으로 판단 + let effectiveSeqNo = seq_no; + if (parent_process_id) { + const master = await pool.query( + `SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`, + [parent_process_id, companyCode] + ); + if (master.rowCount > 0) { + effectiveSeqNo = master.rows[0].seq_no; + } + } + + const next = await pool.query( + `SELECT id FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 + AND CAST(seq_no AS int) > CAST($3 AS int) + AND parent_process_id IS NULL + LIMIT 1`, + [wo_id, companyCode, effectiveSeqNo] + ); + + // 현재 공정의 기존 창고 설정도 반환 (기본값 세팅용) + const warehouseInfo = await pool.query( + `SELECT target_warehouse_id, target_location_code + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [processId, companyCode] + ); + + return res.json({ + success: true, + data: { + isLast: next.rowCount === 0, + woId: wo_id, + seqNo: effectiveSeqNo, + targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null, + targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null, + }, + }); + } catch (error: any) { + logger.error("[pop/production] 마지막 공정 확인 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 공정의 목표 창고/위치 업데이트 + * 마지막 공정 완료 전 또는 완료 후 창고를 지정한다. + * 마스터 행에 저장하여 checkAndCompleteWorkInstruction이 참조할 수 있도록 한다. + */ +export const updateTargetWarehouse = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id, target_warehouse_id, target_location_code } = req.body; + + if (!work_order_process_id || !target_warehouse_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 target_warehouse_id는 필수입니다.", + }); + } + + // 분할 행이면 마스터 행도 함께 업데이트 + const procInfo = await pool.query( + `SELECT parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + + const idsToUpdate = [work_order_process_id]; + if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) { + idsToUpdate.push(procInfo.rows[0].parent_process_id); + } + + for (const id of idsToUpdate) { + await pool.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, target_warehouse_id, target_location_code || null, userId] + ); + } + + logger.info("[pop/production] 목표 창고 업데이트", { + companyCode, userId, work_order_process_id, + target_warehouse_id, target_location_code, + updatedIds: idsToUpdate, + }); + + return res.json({ success: true, data: { target_warehouse_id, target_location_code } }); + } catch (error: any) { + logger.error("[pop/production] 목표 창고 업데이트 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index 57417797..d5418b68 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -11,6 +11,10 @@ import { getAvailableQty, acceptProcess, cancelAccept, + getWarehouses, + getWarehouseLocations, + isLastProcess, + updateTargetWarehouse, } from "../controllers/popProductionController"; const router = Router(); @@ -27,5 +31,9 @@ router.get("/result-history", getResultHistory); router.get("/available-qty", getAvailableQty); router.post("/accept-process", acceptProcess); router.post("/cancel-accept", cancelAccept); +router.get("/warehouses", getWarehouses); +router.get("/warehouse-locations/:warehouseId", getWarehouseLocations); +router.get("/is-last-process/:processId", isLastProcess); +router.post("/update-target-warehouse", updateTargetWarehouse); export default router; diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx index 2465cb80..7a2613d1 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -17,6 +17,12 @@ import { dataApi } from "@/lib/api/data"; import { apiClient } from "@/lib/api/client"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useAuth } from "@/hooks/useAuth"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from "@/components/ui/dialog"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; import type { PopWorkDetailConfig, ResultSectionConfig, ResultSectionType, PlcDataConfig } from "../types"; import type { TimelineProcessStep } from "../types"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; @@ -344,6 +350,16 @@ export function PopWorkDetailComponent({ // 불량 유형 목록 (부모에서 1회 로드, ResultPanel에 전달) const [cachedDefectTypes, setCachedDefectTypes] = useState([]); + // 창고 선택 모달 상태 + const [showWarehouseModal, setShowWarehouseModal] = useState(false); + const [warehouses, setWarehouses] = useState<{ id: string; warehouse_code: string; warehouse_name: string; warehouse_type: string }[]>([]); + const [locations, setLocations] = useState<{ id: string; location_code: string; location_name: string }[]>([]); + const [selectedWarehouse, setSelectedWarehouse] = useState(""); + const [selectedLocation, setSelectedLocation] = useState(""); + const [useDefaultWarehouse, setUseDefaultWarehouse] = useState(false); + const [warehouseSaving, setWarehouseSaving] = useState(false); + const [pendingCompletionData, setPendingCompletionData] = useState | null>(null); + const contentRef = useRef(null); // ======================================== @@ -397,6 +413,128 @@ export function PopWorkDetailComponent({ loadDefectTypes(); }, []); + // ======================================== + // 창고 선택 (마지막 공정 완료 시) + // ======================================== + + const loadWarehouses = useCallback(async () => { + try { + const res = await apiClient.get("/pop/production/warehouses"); + if (res.data?.success) { + setWarehouses(res.data.data || []); + } + } catch { /* 실패 시 빈 배열 유지 */ } + }, []); + + const loadLocations = useCallback(async (warehouseId: string) => { + if (!warehouseId) { setLocations([]); return; } + try { + const res = await apiClient.get(`/pop/production/warehouse-locations/${warehouseId}`); + if (res.data?.success) { + setLocations(res.data.data || []); + } + } catch { setLocations([]); } + }, []); + + // 창고 선택 변경 시 위치 목록 갱신 + useEffect(() => { + if (selectedWarehouse) { + loadLocations(selectedWarehouse); + setSelectedLocation(""); + } else { + setLocations([]); + } + }, [selectedWarehouse, loadLocations]); + + /** + * 공정 완료 시 마지막 공정인지 확인하고 창고 모달 표시 + * ResultPanel의 onSaved 또는 handleTimerAction("complete")에서 호출 + */ + const checkLastProcessAndShowWarehouse = useCallback(async (completionData: Partial) => { + if (!workOrderProcessId) return false; + try { + const res = await apiClient.get(`/pop/production/is-last-process/${workOrderProcessId}`); + if (res.data?.success && res.data.data?.isLast) { + const { targetWarehouseId, targetLocationCode } = res.data.data; + // 이미 창고가 설정되어 있으면 모달 건너뛰기 + if (targetWarehouseId) { + return false; + } + // 창고 목록 로드 후 모달 표시 + await loadWarehouses(); + setPendingCompletionData(completionData); + setShowWarehouseModal(true); + return true; + } + } catch { + // 확인 실패 시 모달 없이 진행 + } + return false; + }, [workOrderProcessId, loadWarehouses]); + + const handleWarehouseConfirm = useCallback(async () => { + if (!workOrderProcessId || !selectedWarehouse) { + toast.error("창고를 선택해주세요."); + return; + } + setWarehouseSaving(true); + try { + // warehouse_code를 찾기 (API에서 warehouse_code로 저장하므로) + const wh = warehouses.find((w) => w.id === selectedWarehouse); + const warehouseCode = wh?.warehouse_code || selectedWarehouse; + + await apiClient.post("/pop/production/update-target-warehouse", { + work_order_process_id: workOrderProcessId, + target_warehouse_id: warehouseCode, + target_location_code: selectedLocation || null, + }); + + // 기본 창고 설정 (로컬 스토리지) + if (useDefaultWarehouse && wh) { + localStorage.setItem("pop_default_warehouse", JSON.stringify({ + id: wh.id, + warehouse_code: wh.warehouse_code, + warehouse_name: wh.warehouse_name, + location_code: selectedLocation || null, + })); + } + + toast.success("입고 창고가 설정되었습니다."); + setShowWarehouseModal(false); + + // 보류 중이던 완료 데이터 반영 + if (pendingCompletionData) { + setProcessData((prev) => prev ? { ...prev, ...pendingCompletionData } : prev); + publish("process_completed", { workOrderProcessId, ...pendingCompletionData }); + setPendingCompletionData(null); + } + } catch { + toast.error("창고 설정에 실패했습니다."); + } finally { + setWarehouseSaving(false); + } + }, [workOrderProcessId, selectedWarehouse, selectedLocation, useDefaultWarehouse, warehouses, pendingCompletionData, publish]); + + // 모달 열릴 때 기본 창고 확인 + useEffect(() => { + if (showWarehouseModal) { + const saved = localStorage.getItem("pop_default_warehouse"); + if (saved) { + try { + const def = JSON.parse(saved); + if (def.warehouse_code) { + const found = warehouses.find((w) => w.warehouse_code === def.warehouse_code); + if (found) { + setSelectedWarehouse(found.id); + if (def.location_code) setSelectedLocation(def.location_code); + setUseDefaultWarehouse(true); + } + } + } catch { /* 파싱 실패 무시 */ } + } + } + }, [showWarehouseModal, warehouses]); + // ======================================== // 좌측 사이드바 - 작업항목 그룹핑 // ======================================== @@ -768,17 +906,23 @@ export function PopWorkDetailComponent({ }); const proc = (res.data?.[0] ?? null) as ProcessTimerData | null; if (proc) { - setProcessData(proc); if (action === "complete") { - toast.success("공정이 완료되었습니다."); - publish("process_completed", { workOrderProcessId, goodQty, defectQty }); + // 마지막 공정이면 창고 선택 모달 표시 + const shown = await checkLastProcessAndShowWarehouse(proc); + if (!shown) { + setProcessData(proc); + toast.success("공정이 완료되었습니다."); + publish("process_completed", { workOrderProcessId, goodQty, defectQty }); + } + } else { + setProcessData(proc); } } } catch { toast.error("타이머 제어에 실패했습니다."); } }, - [workOrderProcessId, goodQty, defectQty, publish] + [workOrderProcessId, goodQty, defectQty, publish, checkLastProcessAndShowWarehouse] ); // ======================================== @@ -1071,7 +1215,12 @@ export function PopWorkDetailComponent({ sections={cfg.resultSections ?? []} isProcessCompleted={isProcessCompleted} defectTypes={cachedDefectTypes} - onSaved={(updated) => { + onSaved={async (updated) => { + // 자동 완료 시 마지막 공정이면 창고 선택 모달 표시 + if (updated?.status === "completed") { + const shown = await checkLastProcessAndShowWarehouse(updated); + if (shown) return; // 모달이 뜨면 여기서 중단, 확인 후 반영 + } setProcessData((prev) => prev ? { ...prev, ...updated } : prev); publish("process_completed", { workOrderProcessId, status: updated?.status }); }} @@ -1324,6 +1473,94 @@ export function PopWorkDetailComponent({ )} + + {/* 창고 선택 모달 (마지막 공정 완료 시) */} + { + if (!open && pendingCompletionData) { + // 모달 닫기 시 보류 중이던 완료 데이터 반영 (창고 미선택) + setProcessData((prev) => prev ? { ...prev, ...pendingCompletionData } : prev); + publish("process_completed", { workOrderProcessId, ...pendingCompletionData }); + setPendingCompletionData(null); + } + setShowWarehouseModal(open); + }}> + + + + + 완제품 입고 창고 선택 + + + 마지막 공정이 완료되었습니다. 완제품을 입고할 창고를 선택해주세요. + + +
+
+ + +
+ + {locations.length > 0 && ( +
+ + +
+ )} + + +
+ + + + +
+
); }