2025-11-21 02:25:25 +09:00
|
|
|
# 디지털 트윈 동적 계층 구조 마이그레이션 가이드
|
|
|
|
|
|
|
|
|
|
## 개요
|
|
|
|
|
|
|
|
|
|
**기존 구조**: 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";
|
|
|
|
|
|
|
|
|
|
<HierarchyConfigPanel
|
|
|
|
|
externalDbConnectionId={selectedDbConnection}
|
|
|
|
|
hierarchyConfig={hierarchyConfig}
|
|
|
|
|
onHierarchyConfigChange={setHierarchyConfig}
|
|
|
|
|
availableTables={availableTables}
|
|
|
|
|
onLoadTables={loadTablesFromDb}
|
|
|
|
|
onLoadColumns={loadColumnsFromTable}
|
|
|
|
|
/>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 계층 구조 설정 예시
|
|
|
|
|
|
|
|
|
|
```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<HierarchyConfig | null>(null);
|
|
|
|
|
|
|
|
|
|
// 좌측 사이드바에 추가
|
|
|
|
|
<HierarchyConfigPanel
|
|
|
|
|
externalDbConnectionId={selectedDbConnection}
|
|
|
|
|
hierarchyConfig={hierarchyConfig}
|
|
|
|
|
onHierarchyConfigChange={setHierarchyConfig}
|
|
|
|
|
availableTables={availableTables}
|
|
|
|
|
onLoadTables={loadTables}
|
|
|
|
|
onLoadColumns={loadColumns}
|
|
|
|
|
/>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 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
|
|
|
|
|
|
2025-11-25 13:55:00 +09:00
|
|
|
|