From 741fef148cf80944cd2cdef43c6dc794e65f7668 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 31 Mar 2026 01:37:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=AC=EA=B3=A0=EC=9E=85=EA=B3=A0=20?= =?UTF-8?q?=EB=8F=85=EB=A6=BDAPI=20+=20=EC=8B=A4=EC=A0=81/=EC=9E=85?= =?UTF-8?q?=EA=B3=A0=20=ED=83=AD=20=EB=B6=84=EB=A6=AC=20+=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=8A=B8=EB=A6=AC=20UI=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20popProductionController:=20inventoryIn?= =?UTF-8?q?bound=20=EB=8F=85=EB=A6=BD=20API=20(=EC=9D=B4=EC=A4=91=EC=9E=85?= =?UTF-8?q?=EA=B3=A0=20=EB=B0=A9=EC=A7=80)=20-=20popProductionRoutes:=20PO?= =?UTF-8?q?ST=20/inventory-inbound=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20PopWorkDetailComponent:=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=81/=EC=9E=85=EA=B3=A0=20=ED=83=AD=20=EC=99=84=EC=A0=84?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC,=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=9E=AC=EA=B3=A0=EC=9E=85=EA=B3=A0=20UI=20-=20PopWorkDetailCo?= =?UTF-8?q?nfig:=20=EC=9E=AC=EA=B3=A0=EC=9E=85=EA=B3=A0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=98=B5=EC=85=98=20(=EA=B8=B0=EB=B3=B8=EC=B0=BD?= =?UTF-8?q?=EA=B3=A0,=20=EA=B3=A0=EC=A0=95,=20=ED=95=84=EC=88=98)=20-=20ty?= =?UTF-8?q?pes.ts:=20InventoryInboundConfig=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80=20-=20PopCategoryTree:?= =?UTF-8?q?=20=EB=93=A4=EC=97=AC=EC=93=B0=EA=B8=B0=20=EC=B6=95=EC=86=8C(24?= =?UTF-8?q?=E2=86=9216px)=20+=20=E2=8B=AE=20=EB=B2=84=ED=8A=BC=20ml-auto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/popProductionController.ts | 155 +++++++ .../src/routes/popProductionRoutes.ts | 2 + .../pop/management/PopCategoryTree.tsx | 6 +- .../PopWorkDetailComponent.tsx | 438 +++++++++++------- .../pop-work-detail/PopWorkDetailConfig.tsx | 138 +++++- frontend/lib/registry/pop-components/types.ts | 17 + 6 files changed, 575 insertions(+), 181 deletions(-) diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 498a9e7b..aac351e8 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -1859,3 +1859,158 @@ export const updateTargetWarehouse = async ( return res.status(500).json({ success: false, message: error.message }); } }; + +/** + * 독립 재고 입고 API + * 창고 저장 + inventory_stock UPSERT를 한 번에 수행한다. + * 실적(save-result) 완료 후 나중에 창고를 선택해도 재고가 들어가도록 분리. + * 이중 입고 방지: target_warehouse_id가 이미 설정된 경우 "이미 입고됨" 반환. + */ +export const inventoryInbound = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id, warehouse_code, location_code } = req.body; + + if (!work_order_process_id || !warehouse_code) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 warehouse_code는 필수입니다.", + }); + } + + await client.query("BEGIN"); + + // 1. work_order_process에서 wo_id, good_qty, parent_process_id, 기존 target_warehouse_id 조회 + const procResult = await client.query( + `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (procResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "해당 공정을 찾을 수 없습니다.", + }); + } + + const proc = procResult.rows[0]; + + // 이중 입고 방지: 이미 target_warehouse_id가 설정되어 있으면 거부 + if (proc.target_warehouse_id) { + await client.query("ROLLBACK"); + return res.status(409).json({ + success: false, + message: "이미 재고 입고가 완료된 공정입니다.", + data: { existing_warehouse: proc.target_warehouse_id }, + }); + } + + const goodQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); + + if (goodQty <= 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ + success: false, + message: "양품 수량이 0이므로 재고 입고할 수 없습니다.", + }); + } + + // 2. work_instruction에서 item_id 조회 + const wiResult = await client.query( + `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, + [proc.wo_id, companyCode] + ); + + if (wiResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "작업지시를 찾을 수 없습니다.", + }); + } + + const itemId = wiResult.rows[0].item_id; + + // 3. item_info에서 item_number 조회 + const itemResult = await client.query( + `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, + [itemId, companyCode] + ); + + if (itemResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "품목 정보를 찾을 수 없습니다.", + }); + } + + const itemCode = itemResult.rows[0].item_number; + const effectiveLocationCode = location_code || warehouse_code; + + // 4. inventory_stock UPSERT + await client.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, warehouse_code, effectiveLocationCode, String(goodQty), userId] + ); + + // 5. work_order_process에 target_warehouse_id 저장 (현재 행 + 마스터 행) + const idsToUpdate = [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, warehouse_code, location_code || null, userId] + ); + } + + await client.query("COMMIT"); + + logger.info("[pop/production] 독립 재고 입고 완료", { + companyCode, userId, work_order_process_id, + itemCode, warehouse_code, location_code: effectiveLocationCode, + qty: goodQty, + }); + + return res.json({ + success: true, + message: "재고 입고가 완료되었습니다.", + data: { + item_code: itemCode, + warehouse_code, + location_code: effectiveLocationCode, + qty: goodQty, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/production] 독립 재고 입고 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index d5418b68..f0a1dd26 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -15,6 +15,7 @@ import { getWarehouseLocations, isLastProcess, updateTargetWarehouse, + inventoryInbound, } from "../controllers/popProductionController"; const router = Router(); @@ -35,5 +36,6 @@ router.get("/warehouses", getWarehouses); router.get("/warehouse-locations/:warehouseId", getWarehouseLocations); router.get("/is-last-process/:processId", isLastProcess); router.post("/update-target-warehouse", updateTargetWarehouse); +router.post("/inventory-inbound", inventoryInbound); export default router; diff --git a/frontend/components/pop/management/PopCategoryTree.tsx b/frontend/components/pop/management/PopCategoryTree.tsx index f2da3896..9cd6397e 100644 --- a/frontend/components/pop/management/PopCategoryTree.tsx +++ b/frontend/components/pop/management/PopCategoryTree.tsx @@ -197,7 +197,7 @@ function TreeNode({ isSelected ? "bg-primary/10 text-primary" : "hover:bg-muted", "group" )} - style={{ paddingLeft: `${level * 24 + 8}px` }} + style={{ paddingLeft: `${level * 16 + 8}px` }} onClick={() => onGroupSelect(group)} > {/* 트리 연결 표시 (하위 레벨만) */} @@ -254,7 +254,7 @@ function TreeNode({ + + + )} + + {/* 재고 입고 그룹 - 마지막 공정일 때만 표시 */} + {isLastProcess && enableInventory && ( +
+
+
+ +
+ 입고 + + {inventoryDone ? "완료" : "미완료"} + +
+
+
@@ -1216,11 +1256,6 @@ export function PopWorkDetailComponent({ isProcessCompleted={isProcessCompleted} defectTypes={cachedDefectTypes} 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 }); }} @@ -1228,8 +1263,142 @@ export function PopWorkDetailComponent({ )} + {/* 재고 입고 패널 (사이드바에서 선택 시 표시) - 실적과 완전 독립 */} + {isLastProcess && enableInventory && ( +
+
+
+ {/* 헤더 */} +
+
+ +
+
+

완제품 재고 입고

+

마지막 공정 완료 시 완제품을 창고에 입고합니다

+
+ {inventoryDone && 입고 완료} +
+ + {/* 현재 실적 요약 */} +
+
현재 실적 요약
+
+
+ + {parseInt(processData?.good_qty ?? "0", 10) || 0} + + 양품 +
+
+
+ + {parseInt(processData?.defect_qty ?? "0", 10) || 0} + + 불량 +
+
+
+ + {parseInt(processData?.total_production_qty ?? "0", 10) || 0} + + 총 생산 +
+
+
+ + {/* 실적이 없으면 안내 메시지 */} + {(parseInt(processData?.good_qty ?? "0", 10) || 0) <= 0 && !inventoryDone ? ( +
+ +

실적을 먼저 등록해주세요

+

양품 수량이 0개입니다. 실적 입력 탭에서 실적을 등록한 후 재고 입고를 진행해주세요.

+
+ ) : !inventoryDone ? ( +
+ {/* 창고 선택 */} +
+ + +
+ + {/* 위치 선택 */} +
+ + +
+ + {/* 기본 창고 체크박스 */} + + + {/* 입고 버튼 - 양품 수량 표시 */} + + {warehouseSaving ? : } + {warehouseSaving + ? "처리 중..." + : `재고 입고 (양품 ${parseInt(processData?.good_qty ?? "0", 10) || 0}개)` + } + +
+ ) : ( +
+ +

+ {parseInt(processData?.good_qty ?? "0", 10) || 0}개가 {(() => { + const wh = warehouses.find((w) => w.id === selectedWarehouse); + return wh ? `${wh.warehouse_name} 창고에` : "지정된 창고에"; + })()} 입고되었습니다 +

+

완제품이 정상적으로 입고 처리되었습니다.

+
+ )} +
+
+
+ )} + {/* 체크리스트 영역 */} -
+
{cfg.displayMode === "step" ? ( /* ======== 스텝 모드 ======== */ <> @@ -1474,93 +1643,6 @@ export function PopWorkDetailComponent({
)} - {/* 창고 선택 모달 (마지막 공정 완료 시) */} - { - if (!open && pendingCompletionData) { - // 모달 닫기 시 보류 중이던 완료 데이터 반영 (창고 미선택) - setProcessData((prev) => prev ? { ...prev, ...pendingCompletionData } : prev); - publish("process_completed", { workOrderProcessId, ...pendingCompletionData }); - setPendingCompletionData(null); - } - setShowWarehouseModal(open); - }}> - - - - - 완제품 입고 창고 선택 - - - 마지막 공정이 완료되었습니다. 완제품을 입고할 창고를 선택해주세요. - - -
-
- - -
- - {locations.length > 0 && ( -
- - -
- )} - - -
- - - - -
-
); } @@ -1608,6 +1690,8 @@ interface ResultPanelProps { isProcessCompleted: boolean; defectTypes: DefectTypeOption[]; onSaved: (updated: Partial) => void; + /** 실적 등록 전 검증 콜백. false 반환 시 저장 차단. */ + onBeforeSave?: () => boolean; } function ResultPanel({ @@ -1617,6 +1701,7 @@ function ResultPanel({ isProcessCompleted, defectTypes, onSaved, + onBeforeSave, }: ResultPanelProps) { // 이번 차수 입력값 (누적치가 아닌 이번에 생산한 수량) const [batchQty, setBatchQty] = useState(""); @@ -1727,6 +1812,7 @@ function ResultPanel({ }; const handleSubmitBatch = async () => { + if (onBeforeSave && !onBeforeSave()) return; if (!batchQty || parseInt(batchQty, 10) <= 0) { toast.error("생산수량을 입력해주세요."); return; diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx index f65ad30f..20a8dfe2 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx @@ -6,14 +6,17 @@ import { Switch } from "@/components/ui/switch"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Plus, Trash2, ChevronUp, ChevronDown, Zap, Loader2 } from "lucide-react"; +import { Plus, Trash2, ChevronUp, ChevronDown, Zap, Loader2, Warehouse } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; +import { apiClient } from "@/lib/api/client"; import type { PopWorkDetailConfig, WorkDetailInfoBarField, ResultSectionConfig, ResultSectionType, PlcDataConfig, + InventoryInboundConfig, } from "../types"; interface PopWorkDetailConfigPanelProps { @@ -85,6 +88,7 @@ export function PopWorkDetailConfigPanel({ stepControl: config?.stepControl ?? { ...DEFAULT_STEP_CONTROL }, navigation: config?.navigation ?? { ...DEFAULT_NAVIGATION }, resultSections: config?.resultSections ?? [], + inventoryConfig: config?.inventoryConfig, }; const update = (partial: Partial) => { @@ -94,6 +98,37 @@ export function PopWorkDetailConfigPanel({ const [newFieldLabel, setNewFieldLabel] = useState(""); const [newFieldColumn, setNewFieldColumn] = useState(""); + // --- 재고 입고 설정: 창고/위치 목록 --- + const [warehouseOptions, setWarehouseOptions] = useState<{ id: string; warehouse_code: string; warehouse_name: string }[]>([]); + const [locationOptions, setLocationOptions] = useState<{ id: string; location_code: string; location_name: string }[]>([]); + const [warehouseLoading, setWarehouseLoading] = useState(false); + const [locationLoading, setLocationLoading] = useState(false); + + const inventoryEnabled = cfg.inventoryConfig?.enableInventoryInbound ?? true; + + useEffect(() => { + if (!inventoryEnabled) return; + setWarehouseLoading(true); + apiClient.get("/pop/production/warehouses") + .then(res => setWarehouseOptions(res.data?.data ?? [])) + .catch(() => setWarehouseOptions([])) + .finally(() => setWarehouseLoading(false)); + }, [inventoryEnabled]); + + useEffect(() => { + const whId = cfg.inventoryConfig?.defaultWarehouseId; + if (!whId) { setLocationOptions([]); return; } + setLocationLoading(true); + apiClient.get(`/pop/production/warehouse-locations/${whId}`) + .then(res => setLocationOptions(res.data?.data ?? [])) + .catch(() => setLocationOptions([])) + .finally(() => setLocationLoading(false)); + }, [cfg.inventoryConfig?.defaultWarehouseId]); + + const updateInventoryConfig = (partial: Partial) => { + update({ inventoryConfig: { ...cfg.inventoryConfig, ...partial } }); + }; + const addInfoBarField = () => { if (!newFieldLabel || !newFieldColumn) return; const fields = [...(cfg.infoBar.fields ?? []), { label: newFieldLabel, column: newFieldColumn }]; @@ -317,6 +352,104 @@ export function PopWorkDetailConfigPanel({
))} + + {/* 재고 입고 설정 */} +
+

+ + 재고 입고 설정 +

+ + + + {inventoryEnabled && ( +
+ {/* 기본 창고 */} +
+ 기본 창고 + {warehouseLoading ? ( +
+ 불러오는 중... +
+ ) : ( + + )} +
+ + {/* 기본 위치 */} +
+ 기본 위치 + {locationLoading ? ( +
+ 불러오는 중... +
+ ) : ( + + )} +
+ + {/* 창고 선택 고정 */} + + + {/* 실적 등록 전 창고 선택 필수 */} + +
+ )} +
); } @@ -384,7 +517,8 @@ function PlcDataSettingsPanel({ plcConfig, onChange }: PlcDataSettingsPanelProps setTablesLoading(true); try { const res = await ExternalDbConnectionAPI.getTables(Number(connId)); - setTables(res.data ?? []); + const raw = res.data ?? []; + setTables(raw.map((t: any) => typeof t === 'string' ? t : t.table_name ?? String(t))); } catch { setTables([]); } finally { diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index badffc5c..45cdb93e 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -1113,6 +1113,21 @@ export interface ResultSectionConfig { plcConfig?: PlcDataConfig; } +export interface InventoryInboundConfig { + /** 마지막 공정 완료 시 재고 입고 사용 여부 */ + enableInventoryInbound?: boolean; + /** 기본 창고 ID */ + defaultWarehouseId?: string; + /** 기본 창고 이름 (표시용) */ + defaultWarehouseName?: string; + /** 기본 위치 코드 */ + defaultLocationCode?: string; + /** 창고 선택 고정 (사용자 변경 불가) */ + lockWarehouse?: boolean; + /** 실적 등록 전 창고 선택 필수 */ + requireWarehouseBeforeSave?: boolean; +} + export interface PopWorkDetailConfig { showTimer: boolean; /** @deprecated result-input 타입으로 대체 */ @@ -1124,4 +1139,6 @@ export interface PopWorkDetailConfig { stepControl: WorkDetailStepControl; navigation: WorkDetailNavigationConfig; resultSections?: ResultSectionConfig[]; + /** 재고 입고 설정 */ + inventoryConfig?: InventoryInboundConfig; }