배치 대략적인 완료
This commit is contained in:
parent
eb6fe57839
commit
0450390b2a
|
|
@ -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단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
||||||
|
|
@ -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`
|
||||||
|
|
||||||
|
|
@ -36,7 +36,138 @@ export async function getExternalDbConnector(connectionId: number) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 창고 목록 조회 (사용자 지정 테이블)
|
// 동적 계층 구조 데이터 조회 (범용)
|
||||||
|
export const getHierarchyData = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
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<Response> => {
|
||||||
|
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<Response> => {
|
export const getWarehouses = async (req: Request, res: Response): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, tableName } = req.query;
|
const { externalDbConnectionId, tableName } = req.query;
|
||||||
|
|
@ -83,32 +214,29 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Area 목록 조회 (사용자 지정 테이블)
|
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||||
export const getAreas = async (req: Request, res: Response): Promise<Response> => {
|
export const getAreas = async (req: Request, res: Response): Promise<Response> => {
|
||||||
try {
|
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({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
|
message: "필수 파라미터가 누락되었습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
|
|
||||||
// 테이블명을 사용하여 모든 컬럼 조회
|
const query = `
|
||||||
let query = `SELECT * FROM ${tableName}`;
|
SELECT * FROM ${tableName}
|
||||||
|
WHERE WAREKEY = '${warehouseKey}'
|
||||||
if (warehouseKey) {
|
LIMIT 1000
|
||||||
query += ` WHERE WAREKEY = '${warehouseKey}'`;
|
`;
|
||||||
}
|
|
||||||
|
|
||||||
query += ` LIMIT 1000`;
|
|
||||||
|
|
||||||
const result = await connector.executeQuery(query);
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
logger.info("Area 목록 조회", {
|
logger.info("구역 목록 조회", {
|
||||||
externalDbConnectionId,
|
externalDbConnectionId,
|
||||||
tableName,
|
tableName,
|
||||||
warehouseKey,
|
warehouseKey,
|
||||||
|
|
@ -120,41 +248,38 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
|
||||||
data: result.rows,
|
data: result.rows,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("Area 목록 조회 실패", error);
|
logger.error("구역 목록 조회 실패", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Area 목록 조회 중 오류가 발생했습니다.",
|
message: "구역 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Location 목록 조회 (사용자 지정 테이블)
|
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||||
export const getLocations = async (req: Request, res: Response): Promise<Response> => {
|
export const getLocations = async (req: Request, res: Response): Promise<Response> => {
|
||||||
try {
|
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({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
|
message: "필수 파라미터가 누락되었습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
|
|
||||||
// 테이블명을 사용하여 모든 컬럼 조회
|
const query = `
|
||||||
let query = `SELECT * FROM ${tableName}`;
|
SELECT * FROM ${tableName}
|
||||||
|
WHERE AREAKEY = '${areaKey}'
|
||||||
if (areaKey) {
|
LIMIT 1000
|
||||||
query += ` WHERE AREAKEY = '${areaKey}'`;
|
`;
|
||||||
}
|
|
||||||
|
|
||||||
query += ` LIMIT 1000`;
|
|
||||||
|
|
||||||
const result = await connector.executeQuery(query);
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
logger.info("Location 목록 조회", {
|
logger.info("위치 목록 조회", {
|
||||||
externalDbConnectionId,
|
externalDbConnectionId,
|
||||||
tableName,
|
tableName,
|
||||||
areaKey,
|
areaKey,
|
||||||
|
|
@ -166,37 +291,46 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
|
||||||
data: result.rows,
|
data: result.rows,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("Location 목록 조회 실패", error);
|
logger.error("위치 목록 조회 실패", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Location 목록 조회 중 오류가 발생했습니다.",
|
message: "위치 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자재 목록 조회 (사용자 지정 테이블)
|
// 자재 목록 조회 (동적 컬럼 매핑 지원)
|
||||||
export const getMaterials = async (req: Request, res: Response): Promise<Response> => {
|
export const getMaterials = async (req: Request, res: Response): Promise<Response> => {
|
||||||
try {
|
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({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
|
message: "필수 파라미터가 누락되었습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
|
|
||||||
// 테이블명을 사용하여 모든 컬럼 조회
|
// 동적 쿼리 생성
|
||||||
let query = `SELECT * FROM ${tableName}`;
|
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : '';
|
||||||
|
const query = `
|
||||||
if (locaKey) {
|
SELECT * FROM ${tableName}
|
||||||
query += ` WHERE LOCAKEY = '${locaKey}'`;
|
WHERE ${locationKeyColumn} = '${locaKey}'
|
||||||
}
|
${orderByClause}
|
||||||
|
LIMIT 1000
|
||||||
query += ` LIMIT 1000`;
|
`;
|
||||||
|
|
||||||
|
logger.info(`자재 조회 쿼리: ${query}`);
|
||||||
|
|
||||||
const result = await connector.executeQuery(query);
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
|
|
@ -221,31 +355,28 @@ export const getMaterials = async (req: Request, res: Response): Promise<Respons
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Location별 자재 개수 조회 (배치 시 사용 - 사용자 지정 테이블)
|
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
||||||
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
|
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
|
||||||
try {
|
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({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "외부 DB 연결 ID, 테이블명, Location 키 목록이 필요합니다.",
|
message: "필수 파라미터가 누락되었습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
|
|
||||||
// locaKeys는 쉼표로 구분된 문자열
|
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
|
||||||
const locaKeyArray = (locaKeys as string).split(",");
|
|
||||||
const quotedKeys = locaKeyArray.map((key) => `'${key}'`).join(",");
|
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
LOCAKEY,
|
LOCAKEY as location_key,
|
||||||
COUNT(*) as material_count,
|
COUNT(*) as count
|
||||||
MAX(LOLAYER) as max_layer
|
|
||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
WHERE LOCAKEY IN (${quotedKeys})
|
WHERE LOCAKEY IN (${keysString})
|
||||||
GROUP BY LOCAKEY
|
GROUP BY LOCAKEY
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -254,7 +385,7 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
|
||||||
logger.info("자재 개수 조회", {
|
logger.info("자재 개수 조회", {
|
||||||
externalDbConnectionId,
|
externalDbConnectionId,
|
||||||
tableName,
|
tableName,
|
||||||
locaKeyCount: locaKeyArray.length,
|
locationCount: locationKeys.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -270,4 +401,3 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ export const createLayout = async (
|
||||||
warehouseKey,
|
warehouseKey,
|
||||||
layoutName,
|
layoutName,
|
||||||
description,
|
description,
|
||||||
|
hierarchyConfig,
|
||||||
objects,
|
objects,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
|
@ -147,9 +148,9 @@ export const createLayout = async (
|
||||||
const layoutQuery = `
|
const layoutQuery = `
|
||||||
INSERT INTO digital_twin_layout (
|
INSERT INTO digital_twin_layout (
|
||||||
company_code, external_db_connection_id, warehouse_key,
|
company_code, external_db_connection_id, warehouse_key,
|
||||||
layout_name, description, created_by, updated_by
|
layout_name, description, hierarchy_config, created_by, updated_by
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -159,6 +160,7 @@ export const createLayout = async (
|
||||||
warehouseKey,
|
warehouseKey,
|
||||||
layoutName,
|
layoutName,
|
||||||
description,
|
description,
|
||||||
|
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -174,9 +176,10 @@ export const createLayout = async (
|
||||||
rotation, color,
|
rotation, color,
|
||||||
area_key, loca_key, loc_type,
|
area_key, loca_key, loc_type,
|
||||||
material_count, material_preview_height,
|
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)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
for (const obj of objects) {
|
for (const obj of objects) {
|
||||||
|
|
@ -200,6 +203,9 @@ export const createLayout = async (
|
||||||
obj.parentId || null,
|
obj.parentId || null,
|
||||||
obj.displayOrder || 0,
|
obj.displayOrder || 0,
|
||||||
obj.locked || false,
|
obj.locked || false,
|
||||||
|
obj.hierarchyLevel || 1,
|
||||||
|
obj.parentKey || null,
|
||||||
|
obj.externalKey || null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -240,7 +246,14 @@ export const updateLayout = async (
|
||||||
const companyCode = req.user?.companyCode;
|
const companyCode = req.user?.companyCode;
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { layoutName, description, objects } = req.body;
|
const {
|
||||||
|
layoutName,
|
||||||
|
description,
|
||||||
|
hierarchyConfig,
|
||||||
|
externalDbConnectionId,
|
||||||
|
warehouseKey,
|
||||||
|
objects,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
await client.query("BEGIN");
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
|
@ -249,15 +262,21 @@ export const updateLayout = async (
|
||||||
UPDATE digital_twin_layout
|
UPDATE digital_twin_layout
|
||||||
SET layout_name = $1,
|
SET layout_name = $1,
|
||||||
description = $2,
|
description = $2,
|
||||||
updated_by = $3,
|
hierarchy_config = $3,
|
||||||
|
external_db_connection_id = $4,
|
||||||
|
warehouse_key = $5,
|
||||||
|
updated_by = $6,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $4 AND company_code = $5
|
WHERE id = $7 AND company_code = $8
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const layoutResult = await client.query(updateLayoutQuery, [
|
const layoutResult = await client.query(updateLayoutQuery, [
|
||||||
layoutName,
|
layoutName,
|
||||||
description,
|
description,
|
||||||
|
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
|
||||||
|
externalDbConnectionId || null,
|
||||||
|
warehouseKey || null,
|
||||||
userId,
|
userId,
|
||||||
id,
|
id,
|
||||||
companyCode,
|
companyCode,
|
||||||
|
|
@ -277,7 +296,7 @@ export const updateLayout = async (
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 새 객체 저장
|
// 새 객체 저장 (부모-자식 관계 처리)
|
||||||
if (objects && objects.length > 0) {
|
if (objects && objects.length > 0) {
|
||||||
const objectQuery = `
|
const objectQuery = `
|
||||||
INSERT INTO digital_twin_objects (
|
INSERT INTO digital_twin_objects (
|
||||||
|
|
@ -287,12 +306,53 @@ export const updateLayout = async (
|
||||||
rotation, color,
|
rotation, color,
|
||||||
area_key, loca_key, loc_type,
|
area_key, loca_key, loc_type,
|
||||||
material_count, material_preview_height,
|
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, [
|
await client.query(objectQuery, [
|
||||||
id,
|
id,
|
||||||
obj.type,
|
obj.type,
|
||||||
|
|
@ -310,9 +370,12 @@ export const updateLayout = async (
|
||||||
obj.locType || null,
|
obj.locType || null,
|
||||||
obj.materialCount || 0,
|
obj.materialCount || 0,
|
||||||
obj.materialPreview?.height || null,
|
obj.materialPreview?.height || null,
|
||||||
obj.parentId || null,
|
realParentId, // 실제 DB ID 사용
|
||||||
obj.displayOrder || 0,
|
obj.displayOrder || 0,
|
||||||
obj.locked || false,
|
obj.locked || false,
|
||||||
|
obj.hierarchyLevel || 1,
|
||||||
|
obj.parentKey || null,
|
||||||
|
obj.externalKey || null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import {
|
||||||
|
|
||||||
// 외부 DB 데이터 조회
|
// 외부 DB 데이터 조회
|
||||||
import {
|
import {
|
||||||
|
getHierarchyData,
|
||||||
|
getChildrenData,
|
||||||
getWarehouses,
|
getWarehouses,
|
||||||
getAreas,
|
getAreas,
|
||||||
getLocations,
|
getLocations,
|
||||||
|
|
@ -32,6 +34,12 @@ router.put("/layouts/:id", updateLayout); // 레이아웃 수정
|
||||||
router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제
|
router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제
|
||||||
|
|
||||||
// ========== 외부 DB 데이터 조회 API ==========
|
// ========== 외부 DB 데이터 조회 API ==========
|
||||||
|
|
||||||
|
// 동적 계층 구조 API
|
||||||
|
router.post("/data/hierarchy", getHierarchyData); // 전체 계층 데이터 조회
|
||||||
|
router.post("/data/children", getChildrenData); // 특정 부모의 하위 데이터 조회
|
||||||
|
|
||||||
|
// 테이블 메타데이터 API
|
||||||
router.get("/data/tables/:connectionId", async (req, res) => {
|
router.get("/data/tables/:connectionId", async (req, res) => {
|
||||||
// 테이블 목록 조회
|
// 테이블 목록 조회
|
||||||
try {
|
try {
|
||||||
|
|
@ -56,11 +64,12 @@ router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 레거시 API (호환성 유지)
|
||||||
router.get("/data/warehouses", getWarehouses); // 창고 목록
|
router.get("/data/warehouses", getWarehouses); // 창고 목록
|
||||||
router.get("/data/areas", getAreas); // Area 목록
|
router.get("/data/areas", getAreas); // Area 목록
|
||||||
router.get("/data/locations", getLocations); // Location 목록
|
router.get("/data/locations", getLocations); // Location 목록
|
||||||
router.get("/data/materials", getMaterials); // 자재 목록 (특정 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;
|
export default router;
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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";
|
||||||
|
|
||||||
|
<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
|
||||||
|
|
||||||
|
|
@ -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<void>;
|
||||||
|
onLoadColumns: (tableName: string) => Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HierarchyConfigPanel({
|
||||||
|
externalDbConnectionId,
|
||||||
|
hierarchyConfig,
|
||||||
|
onHierarchyConfigChange,
|
||||||
|
availableTables,
|
||||||
|
onLoadTables,
|
||||||
|
onLoadColumns,
|
||||||
|
}: HierarchyConfigPanelProps) {
|
||||||
|
const [localConfig, setLocalConfig] = useState<HierarchyConfig>(
|
||||||
|
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<HierarchyConfig["material"]>, value: string) => {
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
material: {
|
||||||
|
...localConfig.material,
|
||||||
|
[field]: value,
|
||||||
|
} as NonNullable<HierarchyConfig["material"]>,
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
|
||||||
|
};
|
||||||
|
|
||||||
|
// 창고 설정 변경
|
||||||
|
const handleWarehouseChange = (field: keyof NonNullable<HierarchyConfig["warehouse"]>, value: string) => {
|
||||||
|
const newWarehouse = {
|
||||||
|
...localConfig.warehouse,
|
||||||
|
[field]: value,
|
||||||
|
} as NonNullable<HierarchyConfig["warehouse"]>;
|
||||||
|
setLocalConfig({ ...localConfig, warehouse: newWarehouse });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 설정 적용
|
||||||
|
const handleApplyConfig = () => {
|
||||||
|
onHierarchyConfigChange(localConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!externalDbConnectionId) {
|
||||||
|
return <div className="text-muted-foreground p-4 text-center text-sm">외부 DB를 먼저 선택하세요</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 창고 설정 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4 pb-3">
|
||||||
|
<CardTitle className="text-sm">창고 설정</CardTitle>
|
||||||
|
<CardDescription className="text-[10px]">창고 테이블 및 컬럼 매핑</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 p-4 pt-0">
|
||||||
|
{/* 창고 테이블 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">테이블</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.warehouse?.tableName || ""}
|
||||||
|
onValueChange={async (value) => {
|
||||||
|
handleWarehouseChange("tableName", value);
|
||||||
|
await handleTableChange(value, "warehouse");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue placeholder="테이블 선택..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<SelectItem key={table} value={table} className="text-[10px]">
|
||||||
|
{table}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 창고 컬럼 매핑 */}
|
||||||
|
{localConfig.warehouse?.tableName && columnsCache[localConfig.warehouse.tableName] && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">ID 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.warehouse.keyColumn || ""}
|
||||||
|
onValueChange={(value) => handleWarehouseChange("keyColumn", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue placeholder="선택..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columnsCache[localConfig.warehouse.tableName].map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-[10px]">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">이름 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.warehouse.nameColumn || ""}
|
||||||
|
onValueChange={(value) => handleWarehouseChange("nameColumn", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue placeholder="선택..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columnsCache[localConfig.warehouse.tableName].map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-[10px]">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 계층 레벨 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4 pb-3">
|
||||||
|
<CardTitle className="text-sm">계층 레벨</CardTitle>
|
||||||
|
<CardDescription className="text-[10px]">영역, 하위 영역 등</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 p-4 pt-0">
|
||||||
|
{localConfig.levels.length === 0 && (
|
||||||
|
<div className="text-muted-foreground py-6 text-center text-xs">레벨을 추가하세요</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{localConfig.levels.map((level) => (
|
||||||
|
<Card key={level.level} className="border-muted">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GripVertical className="text-muted-foreground h-4 w-4" />
|
||||||
|
<Input
|
||||||
|
value={level.name}
|
||||||
|
onChange={(e) => handleLevelChange(level.level, "name", e.target.value)}
|
||||||
|
className="h-7 w-32 text-xs"
|
||||||
|
placeholder="레벨명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveLevel(level.level)}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 p-3 pt-0">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">테이블</Label>
|
||||||
|
<Select
|
||||||
|
value={level.tableName}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
handleLevelChange(level.level, "tableName", val);
|
||||||
|
handleTableChange(val, level.level);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<SelectItem key={table} value={table} className="text-xs">
|
||||||
|
{table}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{level.tableName && columnsCache[level.tableName] && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">ID 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={level.keyColumn}
|
||||||
|
onValueChange={(val) => handleLevelChange(level.level, "keyColumn", val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columnsCache[level.tableName].map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">이름 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={level.nameColumn}
|
||||||
|
onValueChange={(val) => handleLevelChange(level.level, "nameColumn", val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columnsCache[level.tableName].map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">부모 키 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={level.parentKeyColumn}
|
||||||
|
onValueChange={(val) => handleLevelChange(level.level, "parentKeyColumn", val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue placeholder="부모 키 컬럼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columnsCache[level.tableName].map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">타입 컬럼 (선택)</Label>
|
||||||
|
<Select
|
||||||
|
value={level.typeColumn || "__none__"}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
handleLevelChange(level.level, "typeColumn", val === "__none__" ? undefined : val)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue placeholder="타입 컬럼 (선택)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
|
{columnsCache[level.tableName].map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddLevel} className="h-8 w-full text-xs">
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
레벨 추가
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 자재 설정 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4 pb-3">
|
||||||
|
<CardTitle className="text-sm">자재 설정</CardTitle>
|
||||||
|
<CardDescription className="text-[10px]">최하위 레벨의 데이터</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 p-4 pt-0">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">테이블</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.material?.tableName || ""}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
handleMaterialChange("tableName", val);
|
||||||
|
handleTableChange(val, "material");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<SelectItem key={table} value={table} className="text-xs">
|
||||||
|
{table}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localConfig.material?.tableName && columnsCache[localConfig.material.tableName] && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">ID 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.material.keyColumn}
|
||||||
|
onValueChange={(val) => handleMaterialChange("keyColumn", val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">위치 키 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.material.locationKeyColumn}
|
||||||
|
onValueChange={(val) => handleMaterialChange("locationKeyColumn", val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">레이어 컬럼 (선택)</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.material.layerColumn || "__none__"}
|
||||||
|
onValueChange={(val) => handleMaterialChange("layerColumn", val === "__none__" ? undefined : val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue placeholder="레이어 컬럼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
|
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">수량 컬럼 (선택)</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.material.quantityColumn || "__none__"}
|
||||||
|
onValueChange={(val) => handleMaterialChange("quantityColumn", val === "__none__" ? undefined : val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue placeholder="수량 컬럼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
|
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-3" />
|
||||||
|
|
||||||
|
{/* 표시 컬럼 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">우측 패널 표시 컬럼</Label>
|
||||||
|
<p className="text-muted-foreground mb-2 text-[9px]">
|
||||||
|
자재 클릭 시 표시할 정보를 선택하고 라벨을 입력하세요
|
||||||
|
</p>
|
||||||
|
<div className="max-h-60 space-y-2 overflow-y-auto rounded border p-2">
|
||||||
|
{columnsCache[localConfig.material.tableName].map((col) => {
|
||||||
|
const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col);
|
||||||
|
const isSelected = !!displayItem;
|
||||||
|
return (
|
||||||
|
<div key={col} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<span className="w-20 shrink-0 text-[10px]">{col}</span>
|
||||||
|
{isSelected && (
|
||||||
|
<Input
|
||||||
|
value={displayItem?.label || col}
|
||||||
|
onChange={(e) => {
|
||||||
|
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]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 적용 버튼 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleApplyConfig} className="h-10 gap-2 text-sm font-medium">
|
||||||
|
설정 적용
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -174,12 +174,24 @@ export const getLocations = async (
|
||||||
// 자재 목록 조회 (특정 Location)
|
// 자재 목록 조회 (특정 Location)
|
||||||
export const getMaterials = async (
|
export const getMaterials = async (
|
||||||
externalDbConnectionId: number,
|
externalDbConnectionId: number,
|
||||||
tableName: string,
|
materialConfig: {
|
||||||
|
tableName: string;
|
||||||
|
keyColumn: string;
|
||||||
|
locationKeyColumn: string;
|
||||||
|
layerColumn?: string;
|
||||||
|
},
|
||||||
locaKey: string,
|
locaKey: string,
|
||||||
): Promise<ApiResponse<MaterialData[]>> => {
|
): Promise<ApiResponse<MaterialData[]>> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get("/digital-twin/data/materials", {
|
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;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -197,12 +209,67 @@ export const getMaterialCounts = async (
|
||||||
locaKeys: string[],
|
locaKeys: string[],
|
||||||
): Promise<ApiResponse<MaterialCount[]>> => {
|
): Promise<ApiResponse<MaterialCount[]>> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get("/digital-twin/data/material-counts", {
|
const response = await apiClient.post("/digital-twin/data/material-counts", {
|
||||||
params: {
|
externalDbConnectionId,
|
||||||
externalDbConnectionId,
|
tableName,
|
||||||
tableName,
|
locationKeys: locaKeys,
|
||||||
locaKeys: locaKeys.join(","),
|
});
|
||||||
},
|
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<ApiResponse<HierarchyData>> => {
|
||||||
|
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<ApiResponse<any[]>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/digital-twin/data/children", {
|
||||||
|
externalDbConnectionId,
|
||||||
|
hierarchyConfig: JSON.stringify(hierarchyConfig),
|
||||||
|
parentLevel,
|
||||||
|
parentKey,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,11 @@ export interface PlacedObject {
|
||||||
// 편집 제한
|
// 편집 제한
|
||||||
locked?: boolean; // true면 이동/크기조절 불가
|
locked?: boolean; // true면 이동/크기조절 불가
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
|
|
||||||
|
// 동적 계층 구조
|
||||||
|
hierarchyLevel?: number; // 1, 2, 3...
|
||||||
|
parentKey?: string; // 부모 객체의 외부 DB 키
|
||||||
|
externalKey?: string; // 자신의 외부 DB 키
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레이아웃
|
// 레이아웃
|
||||||
|
|
@ -82,6 +87,7 @@ export interface DigitalTwinLayout {
|
||||||
warehouseKey: string; // WAREKEY (예: DY99)
|
warehouseKey: string; // WAREKEY (예: DY99)
|
||||||
layoutName: string;
|
layoutName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
hierarchyConfig?: any; // JSON 설정
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdBy?: number;
|
createdBy?: number;
|
||||||
updatedBy?: number;
|
updatedBy?: number;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue