diff --git a/PLAN.MD b/PLAN.MD new file mode 100644 index 00000000..7c3b1007 --- /dev/null +++ b/PLAN.MD @@ -0,0 +1,27 @@ +# 프로젝트: Digital Twin 에디터 안정화 + +## 개요 + +Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다. + +## 핵심 기능 + +1. `DigitalTwinEditor` 버그 수정 +2. 비동기 함수 입력값 유효성 검증 강화 +3. 외부 DB 연결 상태에 따른 방어 코드 추가 + +## 테스트 계획 + +### 1단계: 긴급 버그 수정 + +- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료) +- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인 + +### 2단계: 잠재적 문제 점검 + +- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사 +- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리 + +## 진행 상태 + +- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 diff --git a/PROJECT_STATUS_2025_11_20.md b/PROJECT_STATUS_2025_11_20.md new file mode 100644 index 00000000..570dd789 --- /dev/null +++ b/PROJECT_STATUS_2025_11_20.md @@ -0,0 +1,57 @@ +# 프로젝트 진행 상황 (2025-11-20) + +## 작업 개요: 디지털 트윈 3D 야드 고도화 (동적 계층 구조) + +### 1. 핵심 변경 사항 +기존의 고정된 `Area` -> `Location` 2단계 구조를 유연한 **N-Level 동적 계층 구조**로 변경하고, 공간적 제약을 강화했습니다. + +### 2. 완료된 작업 + +#### 데이터베이스 +- **마이그레이션 실행**: `db/migrations/042_refactor_digital_twin_hierarchy.sql` +- **스키마 변경**: + - `digital_twin_layout` 테이블에 `hierarchy_config` (JSONB) 컬럼 추가 + - `digital_twin_objects` 테이블에 `hierarchy_level`, `parent_key`, `external_key` 컬럼 추가 + - 기존 하드코딩된 테이블 매핑 컬럼 제거 + +#### 백엔드 (Node.js) +- **API 추가/수정**: + - `POST /api/digital-twin/data/hierarchy`: 계층 설정에 따른 전체 데이터 조회 + - `POST /api/digital-twin/data/children`: 특정 부모의 하위 데이터 조회 + - 기존 레거시 API (`getWarehouses` 등) 호환성 유지 +- **컨트롤러 수정**: + - `digitalTwinDataController.ts`: 동적 쿼리 생성 로직 구현 + - `digitalTwinLayoutController.ts`: 레이아웃 저장/수정 시 `hierarchy_config` 및 객체 계층 정보 처리 + +#### 프론트엔드 (React) +- **신규 컴포넌트**: `HierarchyConfigPanel.tsx` + - 레벨 추가/삭제, 테이블 및 컬럼 매핑 설정 UI +- **유틸리티**: `spatialContainment.ts` + - `validateSpatialContainment`: 자식 객체가 부모 객체 내부에 있는지 검증 (AABB) + - `updateChildrenPositions`: 부모 이동 시 자식 객체 자동 이동 (그룹 이동) +- **에디터 통합 (`DigitalTwinEditor.tsx`)**: + - `HierarchyConfigPanel` 적용 + - 동적 데이터 로드 로직 구현 + - 3D 캔버스 드래그앤드롭 시 공간적 종속성 검증 적용 + - 객체 이동 시 그룹 이동 적용 + +### 3. 현재 상태 +- **백엔드 서버**: 재시작 완료, 정상 동작 중 (PostgreSQL 연결 이슈 해결됨) +- **DB**: 마이그레이션 스크립트 실행 완료 + +### 4. 다음 단계 (테스트 필요) +새로운 세션에서 다음 시나리오를 테스트해야 합니다: +1. **계층 설정**: 에디터에서 창고 -> 구역(Lv1) -> 위치(Lv2) 설정 및 매핑 저장 +2. **배치 검증**: + - 구역 배치 후, 위치를 구역 **내부**에 배치 (성공해야 함) + - 위치를 구역 **외부**에 배치 (실패해야 함) +3. **이동 검증**: 구역 이동 시 내부의 위치들도 같이 따라오는지 확인 + +### 5. 관련 파일 +- `frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx` +- `frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx` +- `frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts` +- `backend-node/src/controllers/digitalTwinDataController.ts` +- `backend-node/src/routes/digitalTwinRoutes.ts` +- `db/migrations/042_refactor_digital_twin_hierarchy.sql` + diff --git a/backend-node/src/controllers/digitalTwinDataController.ts b/backend-node/src/controllers/digitalTwinDataController.ts index 51dd85d8..80cb8ccd 100644 --- a/backend-node/src/controllers/digitalTwinDataController.ts +++ b/backend-node/src/controllers/digitalTwinDataController.ts @@ -36,7 +36,138 @@ export async function getExternalDbConnector(connectionId: number) { ); } -// 창고 목록 조회 (사용자 지정 테이블) +// 동적 계층 구조 데이터 조회 (범용) +export const getHierarchyData = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, hierarchyConfig } = req.body; + + if (!externalDbConnectionId || !hierarchyConfig) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID와 계층 구조 설정이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const config = JSON.parse(hierarchyConfig); + + const result: any = { + warehouse: null, + levels: [], + materials: [], + }; + + // 창고 데이터 조회 + if (config.warehouse) { + const warehouseQuery = `SELECT * FROM ${config.warehouse.tableName} LIMIT 100`; + const warehouseResult = await connector.executeQuery(warehouseQuery); + result.warehouse = warehouseResult.rows; + } + + // 각 레벨 데이터 조회 + if (config.levels && Array.isArray(config.levels)) { + for (const level of config.levels) { + const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`; + const levelResult = await connector.executeQuery(levelQuery); + + result.levels.push({ + level: level.level, + name: level.name, + data: levelResult.rows, + }); + } + } + + // 자재 데이터 조회 (개수만) + if (config.material) { + const materialQuery = ` + SELECT + ${config.material.locationKeyColumn} as location_key, + COUNT(*) as count + FROM ${config.material.tableName} + GROUP BY ${config.material.locationKeyColumn} + `; + const materialResult = await connector.executeQuery(materialQuery); + result.materials = materialResult.rows; + } + + logger.info("동적 계층 구조 데이터 조회", { + externalDbConnectionId, + warehouseCount: result.warehouse?.length || 0, + levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })), + }); + + return res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("동적 계층 구조 데이터 조회 실패", error); + return res.status(500).json({ + success: false, + message: "데이터 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 특정 레벨의 하위 데이터 조회 +export const getChildrenData = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body; + + if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) { + return res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const config = JSON.parse(hierarchyConfig); + + // 다음 레벨 찾기 + const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1); + + if (!nextLevel) { + return res.json({ + success: true, + data: [], + message: "하위 레벨이 없습니다.", + }); + } + + // 하위 데이터 조회 + const query = ` + SELECT * FROM ${nextLevel.tableName} + WHERE ${nextLevel.parentKeyColumn} = '${parentKey}' + LIMIT 1000 + `; + + const result = await connector.executeQuery(query); + + logger.info("하위 데이터 조회", { + externalDbConnectionId, + parentLevel, + parentKey, + count: result.rows.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("하위 데이터 조회 실패", error); + return res.status(500).json({ + success: false, + message: "하위 데이터 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 export const getWarehouses = async (req: Request, res: Response): Promise => { try { const { externalDbConnectionId, tableName } = req.query; @@ -83,32 +214,29 @@ export const getWarehouses = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, warehouseKey } = req.query; + const { externalDbConnectionId, warehouseKey, tableName } = req.query; - if (!externalDbConnectionId || !tableName) { + if (!externalDbConnectionId || !warehouseKey || !tableName) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // 테이블명을 사용하여 모든 컬럼 조회 - let query = `SELECT * FROM ${tableName}`; - - if (warehouseKey) { - query += ` WHERE WAREKEY = '${warehouseKey}'`; - } - - query += ` LIMIT 1000`; + const query = ` + SELECT * FROM ${tableName} + WHERE WAREKEY = '${warehouseKey}' + LIMIT 1000 + `; const result = await connector.executeQuery(query); - logger.info("Area 목록 조회", { + logger.info("구역 목록 조회", { externalDbConnectionId, tableName, warehouseKey, @@ -120,41 +248,38 @@ export const getAreas = async (req: Request, res: Response): Promise = data: result.rows, }); } catch (error: any) { - logger.error("Area 목록 조회 실패", error); + logger.error("구역 목록 조회 실패", error); return res.status(500).json({ success: false, - message: "Area 목록 조회 중 오류가 발생했습니다.", + message: "구역 목록 조회 중 오류가 발생했습니다.", error: error.message, }); } }; -// Location 목록 조회 (사용자 지정 테이블) +// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 export const getLocations = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, areaKey } = req.query; + const { externalDbConnectionId, areaKey, tableName } = req.query; - if (!externalDbConnectionId || !tableName) { + if (!externalDbConnectionId || !areaKey || !tableName) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // 테이블명을 사용하여 모든 컬럼 조회 - let query = `SELECT * FROM ${tableName}`; - - if (areaKey) { - query += ` WHERE AREAKEY = '${areaKey}'`; - } - - query += ` LIMIT 1000`; + const query = ` + SELECT * FROM ${tableName} + WHERE AREAKEY = '${areaKey}' + LIMIT 1000 + `; const result = await connector.executeQuery(query); - logger.info("Location 목록 조회", { + logger.info("위치 목록 조회", { externalDbConnectionId, tableName, areaKey, @@ -166,37 +291,46 @@ export const getLocations = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, locaKey } = req.query; + const { + externalDbConnectionId, + locaKey, + tableName, + keyColumn, + locationKeyColumn, + layerColumn + } = req.query; - if (!externalDbConnectionId || !tableName) { + if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // 테이블명을 사용하여 모든 컬럼 조회 - let query = `SELECT * FROM ${tableName}`; - - if (locaKey) { - query += ` WHERE LOCAKEY = '${locaKey}'`; - } - - query += ` LIMIT 1000`; + // 동적 쿼리 생성 + const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : ''; + const query = ` + SELECT * FROM ${tableName} + WHERE ${locationKeyColumn} = '${locaKey}' + ${orderByClause} + LIMIT 1000 + `; + + logger.info(`자재 조회 쿼리: ${query}`); const result = await connector.executeQuery(query); @@ -221,31 +355,28 @@ export const getMaterials = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, locaKeys } = req.query; + const { externalDbConnectionId, locationKeys, tableName } = req.body; - if (!externalDbConnectionId || !tableName || !locaKeys) { + if (!externalDbConnectionId || !locationKeys || !tableName) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID, 테이블명, Location 키 목록이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // locaKeys는 쉼표로 구분된 문자열 - const locaKeyArray = (locaKeys as string).split(","); - const quotedKeys = locaKeyArray.map((key) => `'${key}'`).join(","); + const keysString = locationKeys.map((key: string) => `'${key}'`).join(","); const query = ` SELECT - LOCAKEY, - COUNT(*) as material_count, - MAX(LOLAYER) as max_layer + LOCAKEY as location_key, + COUNT(*) as count FROM ${tableName} - WHERE LOCAKEY IN (${quotedKeys}) + WHERE LOCAKEY IN (${keysString}) GROUP BY LOCAKEY `; @@ -254,7 +385,7 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise 0) { const objectQuery = ` INSERT INTO digital_twin_objects ( @@ -287,12 +306,53 @@ export const updateLayout = async ( rotation, color, area_key, loca_key, loc_type, material_count, material_preview_height, - parent_id, display_order, locked + parent_id, display_order, locked, + hierarchy_level, parent_key, external_key ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + RETURNING id `; - for (const obj of objects) { + // 임시 ID (음수) → 실제 DB ID 매핑 + const idMapping: { [tempId: number]: number } = {}; + + // 1단계: 부모 객체 먼저 저장 (parentId가 없는 것들) + for (const obj of objects.filter((o) => !o.parentId)) { + const result = await client.query(objectQuery, [ + id, + obj.type, + obj.name, + obj.position.x, + obj.position.y, + obj.position.z, + obj.size.x, + obj.size.y, + obj.size.z, + obj.rotation || 0, + obj.color, + obj.areaKey || null, + obj.locaKey || null, + obj.locType || null, + obj.materialCount || 0, + obj.materialPreview?.height || null, + null, // parent_id + obj.displayOrder || 0, + obj.locked || false, + obj.hierarchyLevel || 1, + obj.parentKey || null, + obj.externalKey || null, + ]); + + // 임시 ID와 실제 DB ID 매핑 + if (obj.id) { + idMapping[obj.id] = result.rows[0].id; + } + } + + // 2단계: 자식 객체 저장 (parentId가 있는 것들) + for (const obj of objects.filter((o) => o.parentId)) { + const realParentId = idMapping[obj.parentId!] || null; + await client.query(objectQuery, [ id, obj.type, @@ -310,9 +370,12 @@ export const updateLayout = async ( obj.locType || null, obj.materialCount || 0, obj.materialPreview?.height || null, - obj.parentId || null, + realParentId, // 실제 DB ID 사용 obj.displayOrder || 0, obj.locked || false, + obj.hierarchyLevel || 1, + obj.parentKey || null, + obj.externalKey || null, ]); } } diff --git a/backend-node/src/routes/digitalTwinRoutes.ts b/backend-node/src/routes/digitalTwinRoutes.ts index 3130b470..904096f7 100644 --- a/backend-node/src/routes/digitalTwinRoutes.ts +++ b/backend-node/src/routes/digitalTwinRoutes.ts @@ -12,6 +12,8 @@ import { // 외부 DB 데이터 조회 import { + getHierarchyData, + getChildrenData, getWarehouses, getAreas, getLocations, @@ -32,6 +34,12 @@ router.put("/layouts/:id", updateLayout); // 레이아웃 수정 router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 // ========== 외부 DB 데이터 조회 API ========== + +// 동적 계층 구조 API +router.post("/data/hierarchy", getHierarchyData); // 전체 계층 데이터 조회 +router.post("/data/children", getChildrenData); // 특정 부모의 하위 데이터 조회 + +// 테이블 메타데이터 API router.get("/data/tables/:connectionId", async (req, res) => { // 테이블 목록 조회 try { @@ -56,11 +64,12 @@ router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => { } }); +// 레거시 API (호환성 유지) router.get("/data/warehouses", getWarehouses); // 창고 목록 router.get("/data/areas", getAreas); // Area 목록 router.get("/data/locations", getLocations); // Location 목록 router.get("/data/materials", getMaterials); // 자재 목록 (특정 Location) -router.get("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location) +router.post("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location) - POST로 변경 export default router; diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 83c90e70..7c5ad29f 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -2,10 +2,11 @@ import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck } from "lucide-react"; +import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import dynamic from "next/dynamic"; import { useToast } from "@/hooks/use-toast"; import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin"; @@ -17,9 +18,14 @@ import { updateLayout, getMaterialCounts, getMaterials, + getHierarchyData, + getChildrenData, + type HierarchyData, } from "@/lib/api/digitalTwin"; import type { MaterialData } from "@/types/digitalTwin"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; +import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel"; +import { validateSpatialContainment, updateChildrenPositions, getAllDescendants } from "./spatialContainment"; // 백엔드 DB 객체 타입 (snake_case) interface DbObject { @@ -85,25 +91,49 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const [materials, setMaterials] = useState([]); const [loadingMaterials, setLoadingMaterials] = useState(false); const [showMaterialPanel, setShowMaterialPanel] = useState(false); - - // 테이블 매핑 관련 상태 + + // 동적 계층 구조 설정 + const [hierarchyConfig, setHierarchyConfig] = useState(null); const [availableTables, setAvailableTables] = useState([]); const [loadingTables, setLoadingTables] = useState(false); - const [selectedTables, setSelectedTables] = useState({ + + // 레거시: 테이블 매핑 (구 Area/Location 방식 호환용) + const [selectedTables, setSelectedTables] = useState<{ + warehouse: string; + area: string; + location: string; + material: string; + }>({ warehouse: "", area: "", location: "", material: "", }); - const [tableColumns, setTableColumns] = useState<{ [key: string]: string[] }>({}); - const [selectedColumns, setSelectedColumns] = useState({ + const [tableColumns, setTableColumns] = useState<{ + warehouse?: { name: string; code: string }; + area?: { name: string; code: string; warehouseCode: string }; + location?: { name: string; code: string; areaCode: string }; + }>({}); + const [selectedColumns, setSelectedColumns] = useState<{ + warehouseKey: string; + warehouseName: string; + areaKey: string; + areaName: string; + areaWarehouseKey: string; + locationKey: string; + locationName: string; + locationAreaKey: string; + materialKey?: string; + }>({ warehouseKey: "WAREKEY", warehouseName: "WARENAME", areaKey: "AREAKEY", areaName: "AREANAME", + areaWarehouseKey: "WAREKEY", locationKey: "LOCAKEY", locationName: "LOCANAME", - materialKey: "STKKEY", + locationAreaKey: "AREAKEY", + materialKey: "LOCAKEY", }); // placedObjects를 YardPlacement 형식으로 변환 (useMemo로 최적화) @@ -140,7 +170,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" }); console.log("🔍 외부 DB 연결 목록 (is_active=Y):", connections); - console.log("🔍 연결 ID들:", connections.map(c => c.id)); + console.log( + "🔍 연결 ID들:", + connections.map((c) => c.id), + ); setExternalDbConnections( connections.map((conn) => ({ id: conn.id!, @@ -166,7 +199,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi useEffect(() => { if (!selectedDbConnection) { setAvailableTables([]); - setSelectedTables({ warehouse: "", area: "", location: "", material: "" }); + setSelectedTables({ warehouse: "", area: "", location: "", material: "" }); // warehouse는 HierarchyConfigPanel에서 관리 return; } @@ -196,6 +229,63 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDbConnection]); + // 동적 계층 구조 데이터 로드 + useEffect(() => { + const loadHierarchy = async () => { + if (!selectedDbConnection || !hierarchyConfig) { + return; + } + + // 필수 필드 검증: 창고가 선택되었는지 확인 + if (!hierarchyConfig.warehouseKey) { + return; + } + + // 레벨 설정 검증 + if (!hierarchyConfig.levels || hierarchyConfig.levels.length === 0) { + return; + } + + // 각 레벨의 필수 필드 검증 + for (const level of hierarchyConfig.levels) { + if (!level.tableName || !level.keyColumn || !level.nameColumn) { + return; + } + } + + try { + const response = await getHierarchyData(selectedDbConnection, hierarchyConfig); + if (response.success && response.data) { + const { warehouse, levels, materials } = response.data; + + // 창고 데이터 설정 + if (warehouse) { + setWarehouses(warehouse); + } + + // 레벨 데이터 설정 + // 기존 호환성을 위해 레벨 1 -> Area, 레벨 2 -> Location으로 매핑 + // TODO: UI를 동적으로 생성하도록 개선 필요 + const level1 = levels.find((l) => l.level === 1); + if (level1) { + setAvailableAreas(level1.data); + } + + const level2 = levels.find((l) => l.level === 2); + if (level2) { + setAvailableLocations(level2.data); + } + + console.log("계층 데이터 로드 완료:", response.data); + } + } catch (error) { + console.error("계층 데이터 로드 실패:", error); + } + }; + + loadHierarchy(); + }, [selectedDbConnection, hierarchyConfig]); + // 테이블 컬럼 로드 const loadColumnsForTable = async (tableName: string, type: "warehouse" | "area" | "location" | "material") => { if (!selectedDbConnection || !tableName) return; @@ -203,18 +293,18 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { const { getTablePreview } = await import("@/lib/api/digitalTwin"); const response = await getTablePreview(selectedDbConnection, tableName); - + console.log(`📊 ${type} 테이블 미리보기:`, response); if (response.success && response.data && response.data.length > 0) { const columns = Object.keys(response.data[0]); - setTableColumns(prev => ({ ...prev, [type]: columns })); - + setTableColumns((prev) => ({ ...prev, [type]: columns })); + // 자동 매핑 시도 (기본값 설정) if (type === "warehouse") { - const keyCol = columns.find(c => c.includes("KEY") || c.includes("ID")) || columns[0]; - const nameCol = columns.find(c => c.includes("NAME") || c.includes("NAM")) || columns[1] || columns[0]; - setSelectedColumns(prev => ({ ...prev, warehouseKey: keyCol, warehouseName: nameCol })); + const keyCol = columns.find((c) => c.includes("KEY") || c.includes("ID")) || columns[0]; + const nameCol = columns.find((c) => c.includes("NAME") || c.includes("NAM")) || columns[1] || columns[0]; + setSelectedColumns((prev) => ({ ...prev, warehouseKey: keyCol, warehouseName: nameCol })); } } else { console.warn(`⚠️ ${tableName} 테이블에 데이터가 없습니다.`); @@ -238,9 +328,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi } const loadWarehouses = async () => { + if (!hierarchyConfig?.warehouse?.tableName) { + return; + } try { setLoadingWarehouses(true); - const response = await getWarehouses(selectedDbConnection, selectedTables.warehouse); + const response = await getWarehouses(selectedDbConnection, hierarchyConfig.warehouse.tableName); console.log("📦 창고 API 응답:", response); if (response.success && response.data) { console.log("📦 창고 데이터:", response.data); @@ -279,7 +372,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi loadWarehouses(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDbConnection, selectedTables.warehouse]); // toast 제거, warehouse 테이블 추가 + }, [selectedDbConnection, hierarchyConfig?.warehouse?.tableName]); // hierarchyConfig.warehouse.tableName 추가 // 창고 선택 시 Area 목록 로드 useEffect(() => { @@ -288,6 +381,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi return; } + // Area 테이블명이 설정되지 않으면 API 호출 스킵 + if (!selectedTables.area) { + setAvailableAreas([]); + return; + } + const loadAreas = async () => { try { setLoadingAreas(true); @@ -324,6 +423,51 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const { layout, objects } = response.data; setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 + // 외부 DB 연결 ID 복원 + if (layout.external_db_connection_id) { + setSelectedDbConnection(layout.external_db_connection_id); + } + + // 계층 구조 설정 로드 + if (layout.hierarchy_config) { + try { + // hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용 + const config = + typeof layout.hierarchy_config === "string" + ? JSON.parse(layout.hierarchy_config) + : layout.hierarchy_config; + setHierarchyConfig(config); + + // 선택된 테이블 정보도 복원 + const newSelectedTables: any = { + warehouse: config.warehouse?.tableName || "", + area: "", + location: "", + material: "", + }; + + if (config.levels && config.levels.length > 0) { + // 레벨 1 = Area + if (config.levels[0]?.tableName) { + newSelectedTables.area = config.levels[0].tableName; + } + // 레벨 2 = Location + if (config.levels[1]?.tableName) { + newSelectedTables.location = config.levels[1].tableName; + } + } + + // 자재 테이블 정보 + if (config.material?.tableName) { + newSelectedTables.material = config.material.tableName; + } + + setSelectedTables(newSelectedTables); + } catch (e) { + console.error("계층 구조 설정 파싱 실패:", e); + } + } + // 객체 데이터 변환 (DB -> PlacedObject) const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ id: obj.id, @@ -352,6 +496,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi displayOrder: obj.display_order, locked: obj.locked, visible: obj.visible !== false, + hierarchyLevel: obj.hierarchy_level || 1, + parentKey: obj.parent_key, + externalKey: obj.external_key, })); setPlacedObjects(loadedObjects); @@ -404,29 +551,37 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시) useEffect(() => { - if (!layoutData || !layoutData.layout.externalDbConnectionId || externalDbConnections.length === 0) { + console.log("🔍 useEffect 실행:", { + layoutData: !!layoutData, + external_db_connection_id: layoutData?.layout?.external_db_connection_id, + externalDbConnections: externalDbConnections.length, + }); + + if (!layoutData || !layoutData.layout.external_db_connection_id || externalDbConnections.length === 0) { + console.log("🔍 조건 미충족으로 종료"); return; } const layout = layoutData.layout; console.log("🔍 외부 DB 연결 자동 선택 시도"); - console.log("🔍 레이아웃의 externalDbConnectionId:", layout.externalDbConnectionId); + console.log("🔍 레이아웃의 external_db_connection_id:", layout.external_db_connection_id); console.log("🔍 사용 가능한 연결 목록:", externalDbConnections); - const connectionExists = externalDbConnections.some( - (conn) => conn.id === layout.externalDbConnectionId, - ); + const connectionExists = externalDbConnections.some((conn) => conn.id === layout.external_db_connection_id); console.log("🔍 연결 존재 여부:", connectionExists); if (connectionExists) { - setSelectedDbConnection(layout.externalDbConnectionId); - if (layout.warehouseKey) { - setSelectedWarehouse(layout.warehouseKey); + setSelectedDbConnection(layout.external_db_connection_id); + if (layout.warehouse_key) { + setSelectedWarehouse(layout.warehouse_key); } - console.log("✅ 외부 DB 연결 자동 선택 완료:", layout.externalDbConnectionId); + console.log("✅ 외부 DB 연결 자동 선택 완료:", layout.external_db_connection_id); } else { - console.warn("⚠️ 저장된 외부 DB 연결을 찾을 수 없습니다:", layout.externalDbConnectionId); - console.warn("⚠️ 사용 가능한 연결 ID들:", externalDbConnections.map(c => c.id)); + console.warn("⚠️ 저장된 외부 DB 연결을 찾을 수 없습니다:", layout.external_db_connection_id); + console.warn( + "⚠️ 사용 가능한 연결 ID들:", + externalDbConnections.map((c) => c.id), + ); toast({ variant: "destructive", title: "외부 DB 연결 오류", @@ -514,10 +669,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi let areaKey: string | undefined = undefined; let locaKey: string | undefined = undefined; let locType: string | undefined = undefined; + let hierarchyLevel = 1; + let parentKey: string | undefined = undefined; + let externalKey: string | undefined = undefined; if (draggedTool === "area" && draggedAreaData) { objectName = draggedAreaData.AREANAME; areaKey = draggedAreaData.AREAKEY; + // 계층 정보 설정 (예: Area는 레벨 1) + hierarchyLevel = 1; + externalKey = draggedAreaData.AREAKEY; } else if ( (draggedTool === "location-bed" || draggedTool === "location-stp" || @@ -529,6 +690,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi areaKey = draggedLocationData.AREAKEY; locaKey = draggedLocationData.LOCAKEY; locType = draggedLocationData.LOCTYPE; + // 계층 정보 설정 (예: Location은 레벨 2) + hierarchyLevel = 2; + parentKey = draggedLocationData.AREAKEY; + externalKey = draggedLocationData.LOCAKEY; } const newObject: PlacedObject = { @@ -541,8 +706,45 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi areaKey, locaKey, locType, + hierarchyLevel, + parentKey, + externalKey, }; + // 공간적 종속성 검증 + if (hierarchyConfig && hierarchyLevel > 1) { + const validation = validateSpatialContainment( + { + id: newObject.id, + position: newObject.position, + size: newObject.size, + hierarchyLevel: newObject.hierarchyLevel || 1, + parentId: newObject.parentId, + }, + placedObjects.map((obj) => ({ + id: obj.id, + position: obj.position, + size: obj.size, + hierarchyLevel: obj.hierarchyLevel || 1, + parentId: obj.parentId, + })), + ); + + if (!validation.valid) { + toast({ + variant: "destructive", + title: "배치 오류", + description: "하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다.", + }); + return; + } + + // 부모 ID 설정 + if (validation.parent) { + newObject.parentId = validation.parent.id; + } + } + setPlacedObjects((prev) => [...prev, newObject]); setSelectedObject(newObject); setNextObjectId((prev) => prev - 1); @@ -561,22 +763,35 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi ) { // 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행) setTimeout(() => { - loadMaterialCountsForLocations(); + loadMaterialCountsForLocations([locaKey!]); }, 100); } }; // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string) => { - if (!selectedDbConnection) return; + if (!selectedDbConnection || !hierarchyConfig?.material) { + toast({ + variant: "destructive", + title: "자재 조회 실패", + description: "자재 테이블 설정이 필요합니다.", + }); + return; + } try { setLoadingMaterials(true); setShowMaterialPanel(true); - const response = await getMaterials(selectedDbConnection, selectedTables.material, locaKey); + const response = await getMaterials(selectedDbConnection, hierarchyConfig.material, locaKey); if (response.success && response.data) { - // LOLAYER 순으로 정렬 - const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER); + // layerColumn이 있으면 정렬 + const sortedMaterials = hierarchyConfig.material.layerColumn + ? response.data.sort((a: any, b: any) => { + const aLayer = a[hierarchyConfig.material!.layerColumn!] || 0; + const bLayer = b[hierarchyConfig.material!.layerColumn!] || 0; + return aLayer - bLayer; + }) + : response.data; setMaterials(sortedMaterials); } else { setMaterials([]); @@ -688,20 +903,60 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // 객체 이동 const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => { - // Yard3DCanvas에서 이미 스냅+오프셋이 완료된 좌표를 받음 - // 그대로 저장하면 됨 - setPlacedObjects((prev) => - prev.map((obj) => { + setPlacedObjects((prev) => { + const targetObj = prev.find((obj) => obj.id === objectId); + if (!targetObj) return prev; + + const oldPosition = targetObj.position; + const newPosition = { + x: newX, + y: newY !== undefined ? newY : oldPosition.y, + z: newZ, + }; + + // 1. 이동 대상 객체 업데이트 + let updatedObjects = prev.map((obj) => { if (obj.id === objectId) { - const newPosition = { ...obj.position, x: newX, z: newZ }; - if (newY !== undefined) { - newPosition.y = newY; - } return { ...obj, position: newPosition }; } return obj; - }), - ); + }); + + // 2. 그룹 이동: 자식 객체들도 함께 이동 + const spatialObjects = updatedObjects.map((obj) => ({ + id: obj.id, + position: obj.position, + size: obj.size, + hierarchyLevel: obj.hierarchyLevel || 1, + parentId: obj.parentId, + })); + + const descendants = getAllDescendants(objectId, spatialObjects); + + if (descendants.length > 0) { + const delta = { + x: newPosition.x - oldPosition.x, + y: newPosition.y - oldPosition.y, + z: newPosition.z - oldPosition.z, + }; + + updatedObjects = updatedObjects.map((obj) => { + if (descendants.some((d) => d.id === obj.id)) { + return { + ...obj, + position: { + x: obj.position.x + delta.x, + y: obj.position.y + delta.y, + z: obj.position.z + delta.z, + }, + }; + } + return obj; + }); + } + + return updatedObjects; + }); if (selectedObject?.id === objectId) { setSelectedObject((prev) => { @@ -803,6 +1058,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const response = await updateLayout(layoutId, { layoutName: layoutName, description: undefined, + hierarchyConfig: hierarchyConfig, // 계층 구조 설정 + externalDbConnectionId: selectedDbConnection, // 외부 DB 연결 ID + warehouseKey: selectedWarehouse, // 선택된 창고 objects: placedObjects.map((obj, index) => ({ ...obj, displayOrder: index, // 현재 순서대로 저장 @@ -935,7 +1193,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi {/* 좌측: 외부 DB 선택 + 객체 목록 */}
{/* 스크롤 영역 */} -
+
{/* 외부 DB 선택 */}
@@ -960,183 +1218,110 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
- {/* 테이블 매핑 선택 */} + {/* 창고 테이블 및 컬럼 매핑 */} {selectedDbConnection && (
- - {loadingTables ? ( -
- -
- ) : ( - <> -
- - -
+ - {/* 창고 컬럼 매핑 */} - {selectedTables.warehouse && tableColumns.warehouse && ( -
-
- - -
-
- - -
+ {/* 이 레이아웃의 창고 선택 */} + {hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && ( +
+ + {loadingWarehouses ? ( +
+
+ ) : ( + )} - -
- - -
- -
- - -
- -
- - -
- +
)}
)} - {/* 창고 선택 */} - {selectedDbConnection && selectedTables.warehouse && ( -
- - {loadingWarehouses ? ( -
- -
- ) : ( - - )} -
+ {/* 계층 설정 패널 (신규) */} + {selectedDbConnection && ( + { + // 새로운 객체로 생성하여 참조 변경 (useEffect 트리거를 위해) + setHierarchyConfig({ ...config }); + + // 레벨 테이블 정보를 selectedTables와 동기화 + const newSelectedTables: any = { ...selectedTables }; + + // 창고 테이블 정보 + if (config.warehouse?.tableName) { + newSelectedTables.warehouse = config.warehouse.tableName; + } + + if (config.levels && config.levels.length > 0) { + // 레벨 1 = Area + if (config.levels[0]?.tableName) { + newSelectedTables.area = config.levels[0].tableName; + } + // 레벨 2 = Location + if (config.levels[1]?.tableName) { + newSelectedTables.location = config.levels[1].tableName; + } + } + + // 자재 테이블 정보 + if (config.material?.tableName) { + newSelectedTables.material = config.material.tableName; + } + + setSelectedTables(newSelectedTables); + setHasUnsavedChanges(true); + }} + availableTables={availableTables} + onLoadTables={async () => { + // 이미 로드되어 있으므로 스킵 + }} + onLoadColumns={async (tableName: string) => { + try { + const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName); + if (response.success && response.data) { + // 객체 배열을 문자열 배열로 변환 + return response.data.map((col: any) => + typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), + ); + } + return []; + } catch (error) { + console.error("컬럼 로드 실패:", error); + return []; + } + }} + /> )} {/* Area 목록 */} @@ -1151,29 +1336,43 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi

Area가 없습니다

) : (
- {availableAreas.map((area) => ( -
{ - // Area 정보를 임시 저장 - setDraggedTool("area"); - setDraggedAreaData(area); - }} - onDragEnd={() => { - setDraggedAreaData(null); - }} - className="bg-background hover:bg-accent cursor-grab rounded-lg border p-3 transition-all active:cursor-grabbing" - > -
-
-

{area.AREANAME}

-

{area.AREAKEY}

+ {availableAreas.map((area) => { + // 이미 배치된 Area인지 확인 + const isPlaced = placedObjects.some((obj) => obj.areaKey === area.AREAKEY); + + return ( +
{ + if (isPlaced) return; + // Area 정보를 임시 저장 + setDraggedTool("area"); + setDraggedAreaData(area); + }} + onDragEnd={() => { + setDraggedAreaData(null); + }} + className={`rounded-lg border p-3 transition-all ${ + isPlaced + ? "bg-muted text-muted-foreground cursor-not-allowed opacity-60" + : "bg-background hover:bg-accent cursor-grab active:cursor-grabbing" + }`} + > +
+
+

{area.AREANAME}

+

{area.AREAKEY}

+
+ {isPlaced ? ( + 배치됨 + ) : ( + + )}
-
-
- ))} + ); + })}
)}
@@ -1199,19 +1398,34 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi locationType = "location-dest"; } + // Location이 이미 배치되었는지 확인 + const isLocationPlaced = placedObjects.some( + (obj) => + (obj.type === "location-bed" || + obj.type === "location-stp" || + obj.type === "location-temp" || + obj.type === "location-dest") && + obj.locaKey === location.LOCAKEY, + ); + return (
{ - // Location 정보를 임시 저장 - setDraggedTool(locationType); - setDraggedLocationData(location); + if (!isLocationPlaced) { + setDraggedTool(locationType); + setDraggedLocationData(location); + } }} onDragEnd={() => { setDraggedLocationData(null); }} - className="bg-background hover:bg-accent cursor-grab rounded-lg border p-3 transition-all active:cursor-grabbing" + className={`rounded-lg border p-3 transition-all ${ + isLocationPlaced + ? "bg-muted text-muted-foreground cursor-not-allowed opacity-60" + : "bg-background hover:bg-accent cursor-grab active:cursor-grabbing" + }`} >
@@ -1221,7 +1435,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi {location.LOCTYPE}
- + {isLocationPlaced ? ( + + ) : ( + + )}
); @@ -1331,48 +1549,45 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi 자재가 없습니다
) : ( -
- {materials.map((material, index) => ( -
-
-
-

{material.STKKEY}

-

- 층: {material.LOLAYER} | Area: {material.AREAKEY} -

-
-
-
- {material.STKWIDT && ( -
- 폭: {material.STKWIDT} + + {materials.map((material, index) => { + const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; + const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY"; + const displayColumns = hierarchyConfig?.material?.displayColumns || []; + + const layerValue = material[layerColumn] || index + 1; + const keyValue = material[keyColumn] || `자재 ${index + 1}`; + + return ( + + +
+ 층 {layerValue} + {keyValue}
- )} - {material.STKLENG && ( -
- 길이: {material.STKLENG} -
- )} - {material.STKHEIG && ( -
- 높이: {material.STKHEIG} -
- )} - {material.STKWEIG && ( -
- 무게: {material.STKWEIG} -
- )} -
- {material.STKRMKS && ( -

{material.STKRMKS}

- )} -
- ))} -
+ + + {displayColumns.length === 0 ? ( +

+ 표시할 컬럼이 설정되지 않았습니다 +

+ ) : ( +
+ {displayColumns.map((item) => ( +
+ {item.label}: + + {material[item.column] || "-"} + +
+ ))} +
+ )} +
+ + ); + })} + )}
) : selectedObject ? ( diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HIERARCHY_MIGRATION_GUIDE.md b/frontend/components/admin/dashboard/widgets/yard-3d/HIERARCHY_MIGRATION_GUIDE.md new file mode 100644 index 00000000..5a7e01fb --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HIERARCHY_MIGRATION_GUIDE.md @@ -0,0 +1,410 @@ +# 디지털 트윈 동적 계층 구조 마이그레이션 가이드 + +## 개요 + +**기존 구조**: Area(구역) → Location(위치) 고정 2단계 +**신규 구조**: 동적 N-Level 계층 (영역 → 하위 영역 → ... → 자재) + +--- + +## 1. 데이터베이스 마이그레이션 + +### 실행 방법 +```bash +# PostgreSQL 컨테이너 접속 +docker exec -it pms-db psql -U postgres -d erp + +# 마이그레이션 실행 +\i db/migrations/042_refactor_digital_twin_hierarchy.sql +``` + +### 변경 사항 +- `digital_twin_layout` 테이블에 `hierarchy_config` JSONB 컬럼 추가 +- 기존 테이블 매핑 컬럼들 제거 (warehouse_table_name, area_table_name 등) +- `digital_twin_objects` 테이블에 계층 관련 컬럼 추가: + - `hierarchy_level`: 계층 레벨 (1, 2, 3, ...) + - `parent_key`: 부모 객체의 외부 DB 키 + - `external_key`: 자신의 외부 DB 키 + +--- + +## 2. 백엔드 API 변경 사항 + +### 신규 API 엔드포인트 + +#### 전체 계층 데이터 조회 +``` +POST /api/digital-twin/data/hierarchy +Request Body: +{ + "externalDbConnectionId": 15, + "hierarchyConfig": "{...}" // JSON 문자열 +} + +Response: +{ + "success": true, + "data": { + "warehouse": [...], + "levels": [ + { "level": 1, "name": "구역", "data": [...] }, + { "level": 2, "name": "위치", "data": [...] } + ], + "materials": [ + { "location_key": "LOC001", "count": 150 } + ] + } +} +``` + +#### 특정 부모의 하위 데이터 조회 +``` +POST /api/digital-twin/data/children +Request Body: +{ + "externalDbConnectionId": 15, + "hierarchyConfig": "{...}", + "parentLevel": 1, + "parentKey": "AREA001" +} + +Response: +{ + "success": true, + "data": [...] // 다음 레벨 데이터 +} +``` + +### 레거시 API (호환성 유지) +- `/api/digital-twin/data/warehouses` (GET) +- `/api/digital-twin/data/areas` (GET) +- `/api/digital-twin/data/locations` (GET) +- `/api/digital-twin/data/materials` (GET) +- `/api/digital-twin/data/material-counts` (POST로 변경) + +--- + +## 3. 프론트엔드 변경 사항 + +### 새로운 컴포넌트 + +#### `HierarchyConfigPanel.tsx` +동적 계층 구조 설정 UI + +**사용 방법:** +```tsx +import HierarchyConfigPanel from "./HierarchyConfigPanel"; + + +``` + +### 계층 구조 설정 예시 + +```json +{ + "warehouse": { + "tableName": "MWARMA", + "keyColumn": "WAREKEY", + "nameColumn": "WARENAME" + }, + "levels": [ + { + "level": 1, + "name": "구역", + "tableName": "MAREMA", + "keyColumn": "AREAKEY", + "nameColumn": "AREANAME", + "parentKeyColumn": "WAREKEY", + "objectTypes": ["area"] + }, + { + "level": 2, + "name": "위치", + "tableName": "MLOCMA", + "keyColumn": "LOCAKEY", + "nameColumn": "LOCANAME", + "parentKeyColumn": "AREAKEY", + "typeColumn": "LOCTYPE", + "objectTypes": ["location-bed", "location-stp"] + } + ], + "material": { + "tableName": "WSTKKY", + "keyColumn": "STKKEY", + "locationKeyColumn": "LOCAKEY", + "layerColumn": "LOLAYER", + "quantityColumn": "STKQUAN" + } +} +``` + +--- + +## 4. 공간적 종속성 (Spatial Containment) + +### 새로운 유틸리티: `spatialContainment.ts` + +#### 주요 함수 + +**1. 포함 여부 확인** +```typescript +import { isContainedIn } from "./spatialContainment"; + +const isValid = isContainedIn(childObject, parentObject); +// 자식 객체가 부모 객체 내부에 있는지 AABB로 검증 +``` + +**2. 유효한 부모 찾기** +```typescript +import { findValidParent } from "./spatialContainment"; + +const parent = findValidParent(draggedChild, allObjects, hierarchyLevels); +// 드래그 중인 자식 객체를 포함하는 부모 객체 자동 감지 +``` + +**3. 검증** +```typescript +import { validateSpatialContainment } from "./spatialContainment"; + +const result = validateSpatialContainment(child, allObjects); +if (!result.valid) { + alert("하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다!"); +} +``` + +**4. 그룹 이동 (부모 이동 시 자식도 함께)** +```typescript +import { updateChildrenPositions, getAllDescendants } from "./spatialContainment"; + +// 부모 객체 이동 시 +const updatedChildren = updateChildrenPositions( + parentObject, + oldPosition, + newPosition, + allObjects +); + +// 모든 하위 자손(재귀) 가져오기 +const descendants = getAllDescendants(parentId, allObjects); +``` + +--- + +## 5. DigitalTwinEditor 통합 방법 + +### Step 1: HierarchyConfigPanel 추가 +```tsx +// DigitalTwinEditor.tsx + +import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel"; + +const [hierarchyConfig, setHierarchyConfig] = useState(null); + +// 좌측 사이드바에 추가 + +``` + +### Step 2: 계층 데이터 로드 +```tsx +import { getHierarchyData, getChildrenData } from "@/lib/api/digitalTwin"; + +const loadHierarchyData = async () => { + if (!selectedDbConnection || !hierarchyConfig) return; + + const response = await getHierarchyData(selectedDbConnection, hierarchyConfig); + if (response.success && response.data) { + // 창고 데이터 + setWarehouses(response.data.warehouse); + + // 각 레벨 데이터 + response.data.levels.forEach((level) => { + if (level.level === 1) { + setAvailableAreas(level.data); + } else if (level.level === 2) { + setAvailableLocations(level.data); + } + // ... 추가 레벨 + }); + + // 자재 개수 + setMaterialCounts(response.data.materials); + } +}; +``` + +### Step 3: Yard3DCanvas에서 검증 +```tsx +// Yard3DCanvas.tsx 또는 DigitalTwinEditor.tsx + +import { validateSpatialContainment } from "./spatialContainment"; + +const handleObjectDrop = (droppedObject: PlacedObject) => { + const result = validateSpatialContainment( + { + id: droppedObject.id, + position: droppedObject.position, + size: droppedObject.size, + hierarchyLevel: droppedObject.hierarchyLevel || 1, + parentId: droppedObject.parentId, + }, + placedObjects.map((obj) => ({ + id: obj.id, + position: obj.position, + size: obj.size, + hierarchyLevel: obj.hierarchyLevel || 1, + parentId: obj.parentId, + })) + ); + + if (!result.valid) { + toast({ + variant: "destructive", + title: "배치 오류", + description: "하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다.", + }); + return; // 배치 취소 + } + + // 유효하면 부모 ID 업데이트 + droppedObject.parentId = result.parent?.id; + + // 상태 업데이트 + setPlacedObjects([...placedObjects, droppedObject]); +}; +``` + +### Step 4: 그룹 이동 구현 +```tsx +import { updateChildrenPositions, getAllDescendants } from "./spatialContainment"; + +const handleObjectMove = ( + movedObject: PlacedObject, + oldPosition: { x: number; y: number; z: number }, + newPosition: { x: number; y: number; z: number } +) => { + // 이동한 객체 업데이트 + const updatedObjects = placedObjects.map((obj) => + obj.id === movedObject.id + ? { ...obj, position: newPosition } + : obj + ); + + // 모든 하위 자손 가져오기 + const descendants = getAllDescendants( + movedObject.id, + placedObjects.map((obj) => ({ + id: obj.id, + position: obj.position, + size: obj.size, + hierarchyLevel: obj.hierarchyLevel || 1, + parentId: obj.parentId, + })) + ); + + // 하위 자손들도 같이 이동 + const delta = { + x: newPosition.x - oldPosition.x, + y: newPosition.y - oldPosition.y, + z: newPosition.z - oldPosition.z, + }; + + descendants.forEach((descendant) => { + const index = updatedObjects.findIndex((obj) => obj.id === descendant.id); + if (index !== -1) { + updatedObjects[index].position = { + x: updatedObjects[index].position.x + delta.x, + y: updatedObjects[index].position.y + delta.y, + z: updatedObjects[index].position.z + delta.z, + }; + } + }); + + setPlacedObjects(updatedObjects); +}; +``` + +--- + +## 6. 테스트 시나리오 + +### 테스트 1: 계층 구조 설정 +1. 외부 DB 선택 +2. 창고 테이블 선택 및 컬럼 매핑 +3. 레벨 추가 (레벨 1: 구역, 레벨 2: 위치) +4. 각 레벨의 테이블 및 컬럼 매핑 +5. 자재 테이블 설정 +6. "저장" 클릭하여 `hierarchy_config` 저장 + +### 테스트 2: 데이터 로드 +1. 계층 구조 설정 완료 후 +2. 창고 선택 +3. 각 레벨 데이터가 좌측 패널에 표시되는지 확인 +4. 자재 개수가 올바르게 표시되는지 확인 + +### 테스트 3: 3D 배치 및 공간적 종속성 +1. 레벨 1 (구역) 객체를 3D 캔버스에 드래그앤드롭 +2. 레벨 2 (위치) 객체를 레벨 1 객체 **내부**에 드래그앤드롭 → 성공 +3. 레벨 2 객체를 레벨 1 객체 **외부**에 드롭 → 오류 메시지 표시 + +### 테스트 4: 그룹 이동 +1. 레벨 1 객체를 이동 +2. 해당 레벨 1 객체의 모든 하위 객체(레벨 2, 3, ...)도 같이 이동하는지 확인 +3. 부모-자식 관계가 유지되는지 확인 + +### 테스트 5: 레이아웃 저장/로드 +1. 위 단계를 완료한 후 "저장" 클릭 +2. 페이지 새로고침 +3. 레이아웃을 다시 로드하여 계층 구조 및 객체 위치가 복원되는지 확인 + +--- + +## 7. 마이그레이션 체크리스트 + +- [ ] DB 마이그레이션 실행 (042_refactor_digital_twin_hierarchy.sql) +- [ ] 백엔드 API 테스트 (Postman/cURL) +- [ ] `HierarchyConfigPanel` 컴포넌트 통합 +- [ ] `spatialContainment.ts` 유틸리티 통합 +- [ ] `DigitalTwinEditor`에서 계층 데이터 로드 구현 +- [ ] `Yard3DCanvas`에서 공간적 종속성 검증 구현 +- [ ] 그룹 이동 기능 구현 +- [ ] 모든 테스트 시나리오 통과 +- [ ] 레거시 API와의 호환성 확인 + +--- + +## 8. 주의사항 + +1. **기존 레이아웃 데이터**: 마이그레이션 전 기존 레이아웃이 있다면 백업 필요 +2. **컬럼 매핑 검증**: 외부 DB 테이블의 컬럼명이 변경될 수 있으므로 auto-mapping 로직 필수 +3. **성능**: N-Level이 3단계 이상 깊어지면 재귀 쿼리 성능 모니터링 필요 +4. **권한**: 외부 DB에 대한 읽기 권한 확인 + +--- + +## 9. 향후 개선 사항 + +1. **드래그 중 실시간 검증**: 드래그하는 동안 부모 영역 하이라이트 +2. **시각적 피드백**: 유효한 배치 위치를 그리드에 색상으로 표시 +3. **계층 구조 시각화**: 좌측 패널에 트리 구조로 표시 +4. **Undo/Redo**: 객체 배치 실행 취소 기능 +5. **스냅 가이드**: 부모 영역 테두리에 스냅 가이드라인 표시 + +--- + +**작성일**: 2025-11-20 +**작성자**: AI Assistant + diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx new file mode 100644 index 00000000..30654fb8 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -0,0 +1,559 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Loader2, Plus, Trash2, GripVertical } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; + +// 계층 레벨 설정 인터페이스 +export interface HierarchyLevel { + level: number; + name: string; + tableName: string; + keyColumn: string; + nameColumn: string; + parentKeyColumn: string; + typeColumn?: string; + objectTypes: string[]; +} + +// 전체 계층 구조 설정 +export interface HierarchyConfig { + warehouseKey: string; // 이 레이아웃이 속한 창고 키 (예: "DY99") + warehouse?: { + tableName: string; // 창고 테이블명 (예: "MWARMA") + keyColumn: string; + nameColumn: string; + }; + levels: HierarchyLevel[]; + material?: { + tableName: string; + keyColumn: string; + locationKeyColumn: string; + layerColumn?: string; + quantityColumn?: string; + displayColumns?: Array<{ column: string; label: string }>; // 우측 패널에 표시할 컬럼들 (컬럼명 + 표시명) + }; +} + +interface HierarchyConfigPanelProps { + externalDbConnectionId: number | null; + hierarchyConfig: HierarchyConfig | null; + onHierarchyConfigChange: (config: HierarchyConfig) => void; + availableTables: string[]; + onLoadTables: () => Promise; + onLoadColumns: (tableName: string) => Promise; +} + +export default function HierarchyConfigPanel({ + externalDbConnectionId, + hierarchyConfig, + onHierarchyConfigChange, + availableTables, + onLoadTables, + onLoadColumns, +}: HierarchyConfigPanelProps) { + const [localConfig, setLocalConfig] = useState( + hierarchyConfig || { + warehouseKey: "", + levels: [], + }, + ); + + const [loadingColumns, setLoadingColumns] = useState(false); + const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({}); + + // 외부에서 변경된 경우 동기화 + useEffect(() => { + if (hierarchyConfig) { + setLocalConfig(hierarchyConfig); + } + }, [hierarchyConfig]); + + // 테이블 선택 시 컬럼 로드 + const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => { + if (columnsCache[tableName]) return; // 이미 로드된 경우 스킵 + + setLoadingColumns(true); + try { + const columns = await onLoadColumns(tableName); + setColumnsCache((prev) => ({ ...prev, [tableName]: columns })); + } catch (error) { + console.error("컬럼 로드 실패:", error); + } finally { + setLoadingColumns(false); + } + }; + + // 창고 키 변경 (제거됨 - 상위 컴포넌트에서 처리) + + // 레벨 추가 + const handleAddLevel = () => { + const maxLevel = localConfig.levels.length > 0 ? Math.max(...localConfig.levels.map((l) => l.level)) : 0; + const newLevel: HierarchyLevel = { + level: maxLevel + 1, + name: `레벨 ${maxLevel + 1}`, + tableName: "", + keyColumn: "", + nameColumn: "", + parentKeyColumn: "", + objectTypes: [], + }; + + const newConfig = { + ...localConfig, + levels: [...localConfig.levels, newLevel], + }; + setLocalConfig(newConfig); + // onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음 + }; + + // 레벨 삭제 + const handleRemoveLevel = (level: number) => { + const newConfig = { + ...localConfig, + levels: localConfig.levels.filter((l) => l.level !== level), + }; + setLocalConfig(newConfig); + // onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음 + }; + + // 레벨 설정 변경 + const handleLevelChange = (level: number, field: keyof HierarchyLevel, value: any) => { + const newConfig = { + ...localConfig, + levels: localConfig.levels.map((l) => (l.level === level ? { ...l, [field]: value } : l)), + }; + setLocalConfig(newConfig); + // onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음 + }; + + // 자재 설정 변경 + const handleMaterialChange = (field: keyof NonNullable, value: string) => { + const newConfig = { + ...localConfig, + material: { + ...localConfig.material, + [field]: value, + } as NonNullable, + }; + setLocalConfig(newConfig); + // onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음 + }; + + // 창고 설정 변경 + const handleWarehouseChange = (field: keyof NonNullable, value: string) => { + const newWarehouse = { + ...localConfig.warehouse, + [field]: value, + } as NonNullable; + setLocalConfig({ ...localConfig, warehouse: newWarehouse }); + }; + + // 설정 적용 + const handleApplyConfig = () => { + onHierarchyConfigChange(localConfig); + }; + + if (!externalDbConnectionId) { + return
외부 DB를 먼저 선택하세요
; + } + + return ( +
+ {/* 창고 설정 */} + + + 창고 설정 + 창고 테이블 및 컬럼 매핑 + + + {/* 창고 테이블 선택 */} +
+ + +
+ + {/* 창고 컬럼 매핑 */} + {localConfig.warehouse?.tableName && columnsCache[localConfig.warehouse.tableName] && ( +
+
+ + +
+ +
+ + +
+
+ )} +
+
+ + {/* 계층 레벨 목록 */} + + + 계층 레벨 + 영역, 하위 영역 등 + + + {localConfig.levels.length === 0 && ( +
레벨을 추가하세요
+ )} + + {localConfig.levels.map((level) => ( + + +
+ + handleLevelChange(level.level, "name", e.target.value)} + className="h-7 w-32 text-xs" + placeholder="레벨명" + /> +
+ +
+ +
+ + +
+ + {level.tableName && columnsCache[level.tableName] && ( + <> +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + )} +
+
+ ))} + + +
+
+ + {/* 자재 설정 */} + + + 자재 설정 + 최하위 레벨의 데이터 + + +
+ + +
+ + {localConfig.material?.tableName && columnsCache[localConfig.material.tableName] && ( + <> +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + {/* 표시 컬럼 선택 */} +
+ +

+ 자재 클릭 시 표시할 정보를 선택하고 라벨을 입력하세요 +

+
+ {columnsCache[localConfig.material.tableName].map((col) => { + const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col); + const isSelected = !!displayItem; + return ( +
+ { + const currentDisplay = localConfig.material?.displayColumns || []; + const newDisplay = e.target.checked + ? [...currentDisplay, { column: col, label: col }] + : currentDisplay.filter((d) => d.column !== col); + handleMaterialChange("displayColumns", newDisplay); + }} + className="h-3 w-3 shrink-0" + /> + {col} + {isSelected && ( + { + const currentDisplay = localConfig.material?.displayColumns || []; + const newDisplay = currentDisplay.map((d) => + d.column === col ? { ...d, label: e.target.value } : d, + ); + handleMaterialChange("displayColumns", newDisplay); + }} + placeholder="표시명 입력..." + className="h-6 flex-1 text-[10px]" + /> + )} +
+ ); + })} +
+
+ + )} +
+
+ + {/* 적용 버튼 */} +
+ +
+
+ ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts new file mode 100644 index 00000000..098d33ee --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts @@ -0,0 +1,164 @@ +/** + * 공간적 종속성 검증 유틸리티 + * + * 하위 영역이 상위 영역 내부에 배치되는지 검증 + */ + +export interface SpatialObject { + id: number; + position: { x: number; y: number; z: number }; + size: { x: number; y: number; z: number }; + hierarchyLevel: number; + parentId?: number; + parentKey?: string; // 외부 DB 키 (데이터 바인딩용) +} + +/** + * 객체 A가 객체 B 안에 포함되는지 확인 (AABB) + */ +export function isContainedIn(child: SpatialObject, parent: SpatialObject): boolean { + // AABB (Axis-Aligned Bounding Box) 계산 + const childMin = { + x: child.position.x - child.size.x / 2, + z: child.position.z - child.size.z / 2, + }; + const childMax = { + x: child.position.x + child.size.x / 2, + z: child.position.z + child.size.z / 2, + }; + + const parentMin = { + x: parent.position.x - parent.size.x / 2, + z: parent.position.z - parent.size.z / 2, + }; + const parentMax = { + x: parent.position.x + parent.size.x / 2, + z: parent.position.z + parent.size.z / 2, + }; + + // 자식 객체의 모든 모서리가 부모 객체 내부에 있어야 함 (XZ 평면에서) + return ( + childMin.x >= parentMin.x && + childMax.x <= parentMax.x && + childMin.z >= parentMin.z && + childMax.z <= parentMax.z + ); +} + +/** + * 드래그 시 부모 영역을 찾아서 검증 + * @param child 드래그 중인 자식 객체 + * @param allObjects 모든 배치된 객체들 + * @param hierarchyLevels 계층 레벨 설정 (1, 2, 3, ...) + * @returns 유효한 부모 객체 또는 null + */ +export function findValidParent( + child: SpatialObject, + allObjects: SpatialObject[], + hierarchyLevels: number +): SpatialObject | null { + // 최상위 레벨(레벨 1)은 부모가 없음 + if (child.hierarchyLevel === 1) { + return null; + } + + // 부모 레벨 (자신보다 1단계 위) + const parentLevel = child.hierarchyLevel - 1; + + // 부모 레벨의 모든 객체 중에서 포함하는 객체 찾기 + const possibleParents = allObjects.filter( + (obj) => obj.hierarchyLevel === parentLevel + ); + + for (const parent of possibleParents) { + if (isContainedIn(child, parent)) { + return parent; + } + } + + // 포함하는 부모가 없으면 null + return null; +} + +/** + * 드래그 종료 시 공간적 종속성 검증 + * @param child 드래그 종료된 자식 객체 + * @param allObjects 모든 배치된 객체들 + * @returns { valid: boolean, parent: SpatialObject | null } + */ +export function validateSpatialContainment( + child: SpatialObject, + allObjects: SpatialObject[] +): { valid: boolean; parent: SpatialObject | null } { + // 최상위 레벨은 항상 유효 + if (child.hierarchyLevel === 1) { + return { valid: true, parent: null }; + } + + const parent = findValidParent(child, allObjects, child.hierarchyLevel); + + return { + valid: parent !== null, + parent: parent, + }; +} + +/** + * 부모 객체 이동 시 모든 자식 객체의 위치 재계산 + * @param parent 이동한 부모 객체 + * @param oldPosition 이전 위치 + * @param newPosition 새 위치 + * @param allObjects 모든 배치된 객체들 + * @returns 업데이트된 자식 객체 배열 + */ +export function updateChildrenPositions( + parent: SpatialObject, + oldPosition: { x: number; y: number; z: number }, + newPosition: { x: number; y: number; z: number }, + allObjects: SpatialObject[] +): SpatialObject[] { + const delta = { + x: newPosition.x - oldPosition.x, + y: newPosition.y - oldPosition.y, + z: newPosition.z - oldPosition.z, + }; + + // 직계 자식 (부모 ID가 일치하는 객체) + const directChildren = allObjects.filter( + (obj) => obj.parentId === parent.id + ); + + // 자식들의 위치 업데이트 + return directChildren.map((child) => ({ + ...child, + position: { + x: child.position.x + delta.x, + y: child.position.y + delta.y, + z: child.position.z + delta.z, + }, + })); +} + +/** + * 특정 객체의 모든 하위 자손 찾기 (재귀) + * @param parentId 부모 객체 ID + * @param allObjects 모든 배치된 객체들 + * @returns 모든 하위 자손 객체 배열 + */ +export function getAllDescendants( + parentId: number, + allObjects: SpatialObject[] +): SpatialObject[] { + const directChildren = allObjects.filter((obj) => obj.parentId === parentId); + + let descendants = [...directChildren]; + + // 재귀적으로 손자, 증손자... 찾기 + for (const child of directChildren) { + const grandChildren = getAllDescendants(child.id, allObjects); + descendants = [...descendants, ...grandChildren]; + } + + return descendants; +} + diff --git a/frontend/lib/api/digitalTwin.ts b/frontend/lib/api/digitalTwin.ts index b58b0206..968fc9c4 100644 --- a/frontend/lib/api/digitalTwin.ts +++ b/frontend/lib/api/digitalTwin.ts @@ -174,12 +174,24 @@ export const getLocations = async ( // 자재 목록 조회 (특정 Location) export const getMaterials = async ( externalDbConnectionId: number, - tableName: string, + materialConfig: { + tableName: string; + keyColumn: string; + locationKeyColumn: string; + layerColumn?: string; + }, locaKey: string, ): Promise> => { try { const response = await apiClient.get("/digital-twin/data/materials", { - params: { externalDbConnectionId, tableName, locaKey }, + params: { + externalDbConnectionId, + tableName: materialConfig.tableName, + keyColumn: materialConfig.keyColumn, + locationKeyColumn: materialConfig.locationKeyColumn, + layerColumn: materialConfig.layerColumn, + locaKey + }, }); return response.data; } catch (error: any) { @@ -197,12 +209,67 @@ export const getMaterialCounts = async ( locaKeys: string[], ): Promise> => { try { - const response = await apiClient.get("/digital-twin/data/material-counts", { - params: { - externalDbConnectionId, - tableName, - locaKeys: locaKeys.join(","), - }, + const response = await apiClient.post("/digital-twin/data/material-counts", { + externalDbConnectionId, + tableName, + locationKeys: locaKeys, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// ========== 동적 계층 구조 API ========== + +export interface HierarchyData { + warehouse: any[]; + levels: Array<{ + level: number; + name: string; + data: any[]; + }>; + materials: Array<{ + location_key: string; + count: number; + }>; +} + +// 전체 계층 데이터 조회 +export const getHierarchyData = async ( + externalDbConnectionId: number, + hierarchyConfig: any +): Promise> => { + try { + const response = await apiClient.post("/digital-twin/data/hierarchy", { + externalDbConnectionId, + hierarchyConfig: JSON.stringify(hierarchyConfig), + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 특정 부모의 하위 데이터 조회 +export const getChildrenData = async ( + externalDbConnectionId: number, + hierarchyConfig: any, + parentLevel: number, + parentKey: string +): Promise> => { + try { + const response = await apiClient.post("/digital-twin/data/children", { + externalDbConnectionId, + hierarchyConfig: JSON.stringify(hierarchyConfig), + parentLevel, + parentKey, }); return response.data; } catch (error: any) { diff --git a/frontend/types/digitalTwin.ts b/frontend/types/digitalTwin.ts index b8a3bc1e..4d64e1a2 100644 --- a/frontend/types/digitalTwin.ts +++ b/frontend/types/digitalTwin.ts @@ -72,6 +72,11 @@ export interface PlacedObject { // 편집 제한 locked?: boolean; // true면 이동/크기조절 불가 visible?: boolean; + + // 동적 계층 구조 + hierarchyLevel?: number; // 1, 2, 3... + parentKey?: string; // 부모 객체의 외부 DB 키 + externalKey?: string; // 자신의 외부 DB 키 } // 레이아웃 @@ -82,6 +87,7 @@ export interface DigitalTwinLayout { warehouseKey: string; // WAREKEY (예: DY99) layoutName: string; description?: string; + hierarchyConfig?: any; // JSON 설정 isActive: boolean; createdBy?: number; updatedBy?: number;