Compare commits
18 Commits
d8358d8234
...
4a644f06c5
| Author | SHA1 | Date |
|---|---|---|
|
|
4a644f06c5 | |
|
|
9a38c2aea9 | |
|
|
8817eb685e | |
|
|
b1814e6ab8 | |
|
|
2c677c2fb8 | |
|
|
6a04ae450d | |
|
|
e459025d8a | |
|
|
353d8d2bb0 | |
|
|
41f40ac216 | |
|
|
dbad9bbc0c | |
|
|
af08b67331 | |
|
|
9e3746bdad | |
|
|
8e6f8d2a27 | |
|
|
3344a5785c | |
|
|
f50dd520ae | |
|
|
441a5712c1 | |
|
|
978a4937ad | |
|
|
898866a2f0 |
|
|
@ -0,0 +1,599 @@
|
|||
# Entity 조인 기능 개발 계획서
|
||||
|
||||
> **ID값을 의미있는 데이터로 자동 변환하는 스마트 테이블 시스템**
|
||||
|
||||
---
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 🎯 목표
|
||||
|
||||
테이블 타입 관리에서 Entity 웹타입으로 설정된 컬럼을 참조 테이블과 조인하여, ID값 대신 의미있는 데이터(예: 사용자명)를 TableList 컴포넌트에서 자동으로 표시하는 기능 구현
|
||||
|
||||
### 🔍 현재 문제점
|
||||
|
||||
```
|
||||
Before: 회사 테이블에서
|
||||
┌─────────────┬─────────┬────────────┐
|
||||
│ company_name│ writer │ created_at │
|
||||
├─────────────┼─────────┼────────────┤
|
||||
│ 삼성전자 │ user001 │ 2024-01-15 │
|
||||
│ LG전자 │ user002 │ 2024-01-16 │
|
||||
└─────────────┴─────────┴────────────┘
|
||||
😕 user001이 누구인지 알 수 없음
|
||||
```
|
||||
|
||||
```
|
||||
After: Entity 조인 적용 시
|
||||
┌─────────────┬─────────────┬────────────┐
|
||||
│ company_name│ writer_name │ created_at │
|
||||
├─────────────┼─────────────┼────────────┤
|
||||
│ 삼성전자 │ 김철수 │ 2024-01-15 │
|
||||
│ LG전자 │ 박영희 │ 2024-01-16 │
|
||||
└─────────────┴─────────────┴────────────┘
|
||||
😍 즉시 누가 등록했는지 알 수 있음
|
||||
```
|
||||
|
||||
### 🚀 핵심 기능
|
||||
|
||||
1. **자동 Entity 감지**: Entity 웹타입으로 설정된 컬럼 자동 스캔
|
||||
2. **스마트 조인**: 참조 테이블과 자동 LEFT JOIN 수행
|
||||
3. **컬럼 별칭**: `writer` → `writer_name`으로 자동 변환
|
||||
4. **성능 최적화**: 필요한 컬럼만 선택적 조인
|
||||
5. **캐시 시스템**: 참조 데이터 캐싱으로 성능 향상
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술 설계
|
||||
|
||||
### 📊 데이터베이스 구조
|
||||
|
||||
#### 현재 Entity 설정 (column_labels 테이블)
|
||||
|
||||
```sql
|
||||
column_labels 테이블:
|
||||
- table_name: 'companies'
|
||||
- column_name: 'writer'
|
||||
- web_type: 'entity'
|
||||
- reference_table: 'user_info' -- 참조할 테이블
|
||||
- reference_column: 'user_id' -- 조인 조건 컬럼
|
||||
- display_column: 'user_name' -- ⭐ 새로 추가할 필드 (표시할 컬럼)
|
||||
```
|
||||
|
||||
#### 필요한 스키마 확장
|
||||
|
||||
```sql
|
||||
-- column_labels 테이블에 display_column 컬럼 추가
|
||||
ALTER TABLE column_labels
|
||||
ADD COLUMN display_column VARCHAR(255) NULL
|
||||
COMMENT '참조 테이블에서 표시할 컬럼명';
|
||||
|
||||
-- 기본값 설정 (없으면 reference_column 사용)
|
||||
UPDATE column_labels
|
||||
SET display_column = CASE
|
||||
WHEN web_type = 'entity' AND reference_table = 'user_info' THEN 'user_name'
|
||||
WHEN web_type = 'entity' AND reference_table = 'companies' THEN 'company_name'
|
||||
ELSE reference_column
|
||||
END
|
||||
WHERE web_type = 'entity' AND display_column IS NULL;
|
||||
```
|
||||
|
||||
### 🏗️ 백엔드 아키텍처
|
||||
|
||||
#### 1. Entity 조인 감지 서비스
|
||||
|
||||
```typescript
|
||||
// src/services/entityJoinService.ts
|
||||
|
||||
export interface EntityJoinConfig {
|
||||
sourceTable: string; // companies
|
||||
sourceColumn: string; // writer
|
||||
referenceTable: string; // user_info
|
||||
referenceColumn: string; // user_id (조인 키)
|
||||
displayColumn: string; // user_name (표시할 값)
|
||||
aliasColumn: string; // writer_name (결과 컬럼명)
|
||||
}
|
||||
|
||||
export class EntityJoinService {
|
||||
/**
|
||||
* 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성
|
||||
*/
|
||||
async detectEntityJoins(tableName: string): Promise<EntityJoinConfig[]>;
|
||||
|
||||
/**
|
||||
* Entity 조인이 포함된 SQL 쿼리 생성
|
||||
*/
|
||||
buildJoinQuery(
|
||||
tableName: string,
|
||||
joinConfigs: EntityJoinConfig[],
|
||||
selectColumns: string[],
|
||||
whereClause: string,
|
||||
orderBy: string,
|
||||
limit: number,
|
||||
offset: number
|
||||
): string;
|
||||
|
||||
/**
|
||||
* 참조 테이블 데이터 캐싱
|
||||
*/
|
||||
async cacheReferenceData(tableName: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 캐시 시스템
|
||||
|
||||
```typescript
|
||||
// src/services/referenceCache.ts
|
||||
|
||||
export class ReferenceCacheService {
|
||||
private cache = new Map<string, Map<string, any>>();
|
||||
|
||||
/**
|
||||
* 작은 참조 테이블 전체 캐싱 (user_info, departments 등)
|
||||
*/
|
||||
async preloadReferenceTable(
|
||||
tableName: string,
|
||||
keyColumn: string,
|
||||
displayColumn: string
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* 캐시에서 참조 값 조회
|
||||
*/
|
||||
getLookupValue(table: string, key: string): any | null;
|
||||
|
||||
/**
|
||||
* 배치 룩업 (성능 최적화)
|
||||
*/
|
||||
async batchLookup(
|
||||
requests: BatchLookupRequest[]
|
||||
): Promise<BatchLookupResponse[]>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 테이블 데이터 서비스 확장
|
||||
|
||||
```typescript
|
||||
// tableManagementService.ts 확장
|
||||
|
||||
export class TableManagementService {
|
||||
/**
|
||||
* Entity 조인이 포함된 데이터 조회
|
||||
*/
|
||||
async getTableDataWithEntityJoins(
|
||||
tableName: string,
|
||||
options: {
|
||||
page: number;
|
||||
size: number;
|
||||
search?: Record<string, any>;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
enableEntityJoin?: boolean; // 🎯 Entity 조인 활성화
|
||||
}
|
||||
): Promise<{
|
||||
data: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
entityJoinInfo?: {
|
||||
// 🎯 조인 정보
|
||||
joinConfigs: EntityJoinConfig[];
|
||||
strategy: "full_join" | "cache_lookup";
|
||||
performance: {
|
||||
queryTime: number;
|
||||
cacheHitRate: number;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### 🎨 프론트엔드 구조
|
||||
|
||||
#### 1. Entity 타입 설정 UI 확장
|
||||
|
||||
```typescript
|
||||
// frontend/app/(main)/admin/tableMng/page.tsx 확장
|
||||
|
||||
// Entity 타입 설정 시 표시할 컬럼도 선택 가능하도록 확장
|
||||
{column.webType === "entity" && (
|
||||
<div className="space-y-2">
|
||||
{/* 기존: 참조 테이블 선택 */}
|
||||
<Select value={column.referenceTable} onValueChange={...}>
|
||||
<SelectContent>
|
||||
{referenceTableOptions.map(option => ...)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 🎯 새로 추가: 표시할 컬럼 선택 */}
|
||||
<Select value={column.displayColumn} onValueChange={...}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="표시할 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getDisplayColumnOptions(column.referenceTable).map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 2. TableList 컴포넌트 확장
|
||||
|
||||
```typescript
|
||||
// TableListComponent.tsx 확장
|
||||
|
||||
// Entity 조인 데이터 조회
|
||||
const result = await tableTypeApi.getTableDataWithEntityJoins(
|
||||
tableConfig.selectedTable,
|
||||
{
|
||||
page: currentPage,
|
||||
size: localPageSize,
|
||||
search: searchConditions,
|
||||
sortBy: sortColumn,
|
||||
sortOrder: sortDirection,
|
||||
enableEntityJoin: true, // 🎯 Entity 조인 활성화
|
||||
}
|
||||
);
|
||||
|
||||
// Entity 조인된 컬럼 시각적 구분
|
||||
<TableHead>
|
||||
<div className="flex items-center space-x-1">
|
||||
{isEntityJoinedColumn && (
|
||||
<span className="text-xs text-blue-600" title="Entity 조인됨">
|
||||
🔗
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(isEntityJoinedColumn && "text-blue-700 font-medium")}>
|
||||
{getColumnDisplayName(column)}
|
||||
</span>
|
||||
</div>
|
||||
</TableHead>;
|
||||
```
|
||||
|
||||
#### 3. API 타입 확장
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/screen.ts 확장
|
||||
|
||||
export const tableTypeApi = {
|
||||
// 🎯 Entity 조인 지원 데이터 조회
|
||||
getTableDataWithEntityJoins: async (
|
||||
tableName: string,
|
||||
params: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: Record<string, any>;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
enableEntityJoin?: boolean;
|
||||
}
|
||||
): Promise<{
|
||||
data: Record<string, any>[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
entityJoinInfo?: {
|
||||
joinConfigs: EntityJoinConfig[];
|
||||
strategy: string;
|
||||
performance: any;
|
||||
};
|
||||
}> => {
|
||||
// 구현...
|
||||
},
|
||||
|
||||
// 🎯 참조 테이블의 표시 가능한 컬럼 목록 조회
|
||||
getReferenceTableColumns: async (
|
||||
tableName: string
|
||||
): Promise<
|
||||
{
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
}[]
|
||||
> => {
|
||||
// 구현...
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ 구현 단계
|
||||
|
||||
### Phase 1: 백엔드 기반 구축 (2일)
|
||||
|
||||
#### Day 1: Entity 조인 감지 시스템 ✅ **완료!**
|
||||
|
||||
```typescript
|
||||
✅ 구현 목록:
|
||||
1. EntityJoinService 클래스 생성
|
||||
- detectEntityJoins(): Entity 컬럼 스캔 및 조인 설정 생성
|
||||
- buildJoinQuery(): LEFT JOIN 쿼리 자동 생성
|
||||
- validateJoinConfig(): 조인 설정 유효성 검증
|
||||
|
||||
2. 데이터베이스 스키마 확장
|
||||
- column_labels 테이블에 display_column 추가
|
||||
- 기존 Entity 설정 데이터 마이그레이션
|
||||
|
||||
3. 단위 테스트 작성
|
||||
- Entity 감지 로직 테스트
|
||||
- SQL 쿼리 생성 테스트
|
||||
```
|
||||
|
||||
#### Day 2: 캐시 시스템 및 성능 최적화
|
||||
|
||||
```typescript
|
||||
✅ 구현 목록:
|
||||
1. ReferenceCacheService 구현
|
||||
- 작은 참조 테이블 전체 캐싱 (user_info, departments)
|
||||
- 배치 룩업으로 성능 최적화
|
||||
- TTL 기반 캐시 무효화
|
||||
|
||||
2. TableManagementService 확장
|
||||
- getTableDataWithEntityJoins() 메서드 추가
|
||||
- 조인 vs 캐시 룩업 전략 자동 선택
|
||||
- 성능 메트릭 수집
|
||||
|
||||
3. 통합 테스트
|
||||
- 실제 테이블 데이터로 조인 테스트
|
||||
- 성능 벤치마크 (조인 vs 캐시)
|
||||
```
|
||||
|
||||
### Phase 2: 프론트엔드 연동 (2일)
|
||||
|
||||
#### Day 3: 관리자 UI 확장
|
||||
|
||||
```typescript
|
||||
✅ 구현 목록:
|
||||
1. 테이블 타입 관리 페이지 확장
|
||||
- Entity 타입 설정 시 display_column 선택 UI
|
||||
- 참조 테이블 변경 시 표시 컬럼 목록 자동 업데이트
|
||||
- 설정 미리보기 기능
|
||||
|
||||
2. API 연동
|
||||
- Entity 설정 저장/조회 API 연동
|
||||
- 참조 테이블 컬럼 목록 조회 API
|
||||
- 에러 처리 및 사용자 피드백
|
||||
|
||||
3. 사용성 개선
|
||||
- 자동 추천 시스템 (user_info → user_name 자동 선택)
|
||||
- 설정 검증 및 경고 메시지
|
||||
```
|
||||
|
||||
#### Day 4: TableList 컴포넌트 확장
|
||||
|
||||
```typescript
|
||||
✅ 구현 목록:
|
||||
1. Entity 조인 데이터 표시
|
||||
- getTableDataWithEntityJoins API 호출
|
||||
- 조인된 컬럼 시각적 구분 (🔗 아이콘)
|
||||
- 컬럼명 자동 변환 (writer → writer_name)
|
||||
|
||||
2. 성능 모니터링 UI
|
||||
- 조인 전략 표시 (full_join / cache_lookup)
|
||||
- 실시간 성능 메트릭 (쿼리 시간, 캐시 적중률)
|
||||
- 조인 정보 툴팁
|
||||
|
||||
3. 사용자 경험 최적화
|
||||
- 로딩 상태 최적화
|
||||
- 에러 발생 시 원본 데이터 표시
|
||||
- 성능 경고 알림
|
||||
```
|
||||
|
||||
### Phase 3: 고급 기능 및 최적화 (1일)
|
||||
|
||||
#### Day 5: 고급 기능 및 완성도
|
||||
|
||||
```typescript
|
||||
✅ 구현 목록:
|
||||
1. 다중 Entity 조인 지원
|
||||
- 하나의 테이블에서 여러 Entity 컬럼 동시 조인
|
||||
- 조인 순서 최적화
|
||||
- 중복 조인 방지
|
||||
|
||||
2. 스마트 기능
|
||||
- 자주 사용되는 Entity 설정 템플릿
|
||||
- 조인 성능 기반 자동 추천
|
||||
- 데이터 유효성 실시간 검증
|
||||
|
||||
3. 완성도 향상
|
||||
- 상세한 로깅 및 모니터링
|
||||
- 사용자 가이드 및 툴팁
|
||||
- 전체 시스템 통합 테스트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 예상 결과
|
||||
|
||||
### 🎯 핵심 사용 시나리오
|
||||
|
||||
#### 시나리오 1: 회사 관리 테이블
|
||||
|
||||
```sql
|
||||
-- Entity 설정
|
||||
companies.writer (entity) → user_info.user_name
|
||||
|
||||
-- 실행되는 쿼리
|
||||
SELECT
|
||||
c.*,
|
||||
u.user_name as writer_name
|
||||
FROM companies c
|
||||
LEFT JOIN user_info u ON c.writer = u.user_id
|
||||
WHERE c.company_name ILIKE '%삼성%'
|
||||
ORDER BY c.created_date DESC
|
||||
LIMIT 20;
|
||||
|
||||
-- 화면 표시
|
||||
┌─────────────┬─────────────┬─────────────┐
|
||||
│ company_name│ writer_name │ created_date│
|
||||
├─────────────┼─────────────┼─────────────┤
|
||||
│ 삼성전자 │ 김철수 │ 2024-01-15 │
|
||||
│ 삼성SDI │ 박영희 │ 2024-01-16 │
|
||||
└─────────────┴─────────────┴─────────────┘
|
||||
```
|
||||
|
||||
#### 시나리오 2: 프로젝트 관리 테이블
|
||||
|
||||
```sql
|
||||
-- Entity 설정 (다중)
|
||||
projects.manager_id (entity) → user_info.user_name
|
||||
projects.company_id (entity) → companies.company_name
|
||||
|
||||
-- 실행되는 쿼리
|
||||
SELECT
|
||||
p.*,
|
||||
u.user_name as manager_name,
|
||||
c.company_name as company_name
|
||||
FROM projects p
|
||||
LEFT JOIN user_info u ON p.manager_id = u.user_id
|
||||
LEFT JOIN companies c ON p.company_id = c.company_id
|
||||
ORDER BY p.created_date DESC;
|
||||
|
||||
-- 화면 표시
|
||||
┌──────────────┬──────────────┬──────────────┬─────────────┐
|
||||
│ project_name │ manager_name │ company_name │ created_date│
|
||||
├──────────────┼──────────────┼──────────────┼─────────────┤
|
||||
│ ERP 개발 │ 김철수 │ 삼성전자 │ 2024-01-15 │
|
||||
│ AI 프로젝트 │ 박영희 │ LG전자 │ 2024-01-16 │
|
||||
└──────────────┴──────────────┴──────────────┴─────────────┘
|
||||
```
|
||||
|
||||
### 📈 성능 예상 지표
|
||||
|
||||
#### 캐시 전략 성능
|
||||
|
||||
```
|
||||
🎯 작은 참조 테이블 (user_info < 1000건)
|
||||
- 전체 캐싱: 메모리 사용량 ~1MB
|
||||
- 룩업 속도: O(1) - 평균 0.1ms
|
||||
- 캐시 적중률: 95%+
|
||||
|
||||
🎯 큰 참조 테이블 (companies > 10000건)
|
||||
- 쿼리 조인: 평균 50-100ms
|
||||
- 인덱스 최적화로 성능 보장
|
||||
- 페이징으로 메모리 효율성 확보
|
||||
```
|
||||
|
||||
#### 사용자 경험 개선
|
||||
|
||||
```
|
||||
Before: "user001이 누구지? 🤔"
|
||||
→ 별도 조회 필요 (추가 5-10초)
|
||||
|
||||
After: "김철수님이 등록하셨구나! 😍"
|
||||
→ 즉시 이해 (0초)
|
||||
|
||||
💰 업무 효율성: 직원 1명당 하루 2-3분 절약
|
||||
→ 100명 기준 연간 80-120시간 절약
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 고려사항 및 제약
|
||||
|
||||
### ⚠️ 주의사항
|
||||
|
||||
#### 1. 성능 영향
|
||||
|
||||
```
|
||||
✅ 대응 방안:
|
||||
- 작은 참조 테이블 (< 1000건): 전체 캐싱
|
||||
- 큰 참조 테이블 (> 1000건): 인덱스 최적화 + 쿼리 조인
|
||||
- 조인 수 제한: 테이블당 최대 5개 Entity 컬럼
|
||||
- 자동 성능 모니터링 및 알림
|
||||
```
|
||||
|
||||
#### 2. 데이터 일관성
|
||||
|
||||
```
|
||||
✅ 대응 방안:
|
||||
- 참조 테이블 데이터 변경 시 캐시 자동 무효화
|
||||
- Foreign Key 제약조건 권장 (필수 아님)
|
||||
- 참조 데이터 없는 경우 원본 ID 표시
|
||||
- 실시간 데이터 유효성 검증
|
||||
```
|
||||
|
||||
#### 3. 사용자 설정 복잡도
|
||||
|
||||
```
|
||||
✅ 대응 방안:
|
||||
- 자동 추천 시스템 (user_info → user_name)
|
||||
- 일반적인 Entity 설정 템플릿 제공
|
||||
- 설정 미리보기 및 검증 기능
|
||||
- 단계별 설정 가이드 제공
|
||||
```
|
||||
|
||||
### 🚀 확장 가능성
|
||||
|
||||
#### 1. 고급 Entity 기능
|
||||
|
||||
- **조건부 조인**: WHERE 조건이 있는 Entity 조인
|
||||
- **계층적 Entity**: Entity 안의 또 다른 Entity (user → department → company)
|
||||
- **집계 Entity**: 관련 데이터 개수나 합계 표시 (project_count, total_amount)
|
||||
|
||||
#### 2. 성능 최적화
|
||||
|
||||
- **지능형 캐싱**: 사용 빈도 기반 캐시 전략
|
||||
- **배경 업데이트**: 사용자 요청과 독립적인 캐시 갱신
|
||||
- **분산 캐싱**: Redis 등 외부 캐시 서버 연동
|
||||
|
||||
#### 3. 사용자 경험
|
||||
|
||||
- **실시간 프리뷰**: Entity 설정 변경 시 즉시 미리보기
|
||||
- **자동 완성**: Entity 설정 시 테이블/컬럼 자동 완성
|
||||
- **성능 인사이트**: 조인 성능 분석 및 최적화 제안
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 개발 완료 기준
|
||||
|
||||
#### 백엔드 ✅
|
||||
|
||||
- [x] EntityJoinService 구현 및 테스트 ✅
|
||||
- [x] ReferenceCacheService 구현 및 테스트 ✅
|
||||
- [x] column_labels 스키마 확장 (display_column) ✅
|
||||
- [x] getTableDataWithEntityJoins API 구현 ✅
|
||||
- [x] TableManagementService 확장 ✅
|
||||
- [x] 새로운 API 엔드포인트 추가: `/api/table-management/tables/:tableName/data-with-joins` ✅
|
||||
- [ ] 성능 벤치마크 (< 100ms 목표)
|
||||
- [ ] 에러 처리 및 fallback 로직
|
||||
|
||||
#### 프론트엔드 ✅
|
||||
|
||||
- [x] Entity 타입 설정 UI 확장 (display_column 선택) ✅
|
||||
- [ ] TableList Entity 조인 데이터 표시
|
||||
- [ ] 조인된 컬럼 시각적 구분 (🔗 아이콘)
|
||||
- [ ] 성능 모니터링 UI (쿼리 시간, 캐시 적중률)
|
||||
- [ ] 에러 상황 사용자 피드백
|
||||
|
||||
#### 시스템 통합 ✅
|
||||
|
||||
- [ ] 전체 기능 통합 테스트
|
||||
- [ ] 성능 테스트 (다양한 데이터 크기)
|
||||
- [ ] 사용자 시나리오 테스트
|
||||
- [ ] 문서화 및 사용 가이드
|
||||
- [ ] 프로덕션 배포 준비
|
||||
|
||||
---
|
||||
|
||||
## 🎯 결론
|
||||
|
||||
이 Entity 조인 기능은 단순한 데이터 표시 개선을 넘어서 **사용자 경험의 혁신**을 가져올 것입니다.
|
||||
|
||||
**"user001"** 같은 의미없는 ID 대신 **"김철수님"** 같은 의미있는 정보를 즉시 보여줌으로써, 업무 효율성을 크게 향상시킬 수 있습니다.
|
||||
|
||||
특히 **자동 감지**와 **스마트 캐싱** 시스템으로 개발자와 사용자 모두에게 편리한 기능이 될 것으로 기대됩니다.
|
||||
|
||||
---
|
||||
|
||||
**🚀 "ID에서 이름으로, 데이터에서 정보로의 진화!"**
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -14,12 +14,13 @@ import authRoutes from "./routes/authRoutes";
|
|||
import adminRoutes from "./routes/adminRoutes";
|
||||
import multilangRoutes from "./routes/multilangRoutes";
|
||||
import tableManagementRoutes from "./routes/tableManagementRoutes";
|
||||
import entityJoinRoutes from "./routes/entityJoinRoutes";
|
||||
import screenManagementRoutes from "./routes/screenManagementRoutes";
|
||||
import commonCodeRoutes from "./routes/commonCodeRoutes";
|
||||
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
|
||||
import fileRoutes from "./routes/fileRoutes";
|
||||
import companyManagementRoutes from "./routes/companyManagementRoutes";
|
||||
import dataflowRoutes from "./routes/dataflowRoutes";
|
||||
// import dataflowRoutes from "./routes/dataflowRoutes"; // 임시 주석
|
||||
import dataflowDiagramRoutes from "./routes/dataflowDiagramRoutes";
|
||||
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
|
||||
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
|
||||
|
|
@ -105,12 +106,13 @@ app.use("/api/auth", authRoutes);
|
|||
app.use("/api/admin", adminRoutes);
|
||||
app.use("/api/multilang", multilangRoutes);
|
||||
app.use("/api/table-management", tableManagementRoutes);
|
||||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
app.use("/api/company-management", companyManagementRoutes);
|
||||
app.use("/api/dataflow", dataflowRoutes);
|
||||
// app.use("/api/dataflow", dataflowRoutes); // 임시 주석
|
||||
app.use("/api/dataflow-diagrams", dataflowDiagramRoutes);
|
||||
app.use("/api/admin/web-types", webTypeStandardRoutes);
|
||||
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import { Request, Response } from "express";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { ApiResponse } from "../types/common";
|
||||
import { EventTriggerService } from "../services/eventTriggerService";
|
||||
|
||||
/**
|
||||
* 조건부 연결 조건 테스트
|
||||
*/
|
||||
export async function testConditionalConnection(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 조건부 연결 조건 테스트 시작 ===");
|
||||
|
||||
const { diagramId } = req.params;
|
||||
const { testData } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!companyCode) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "회사 코드가 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_COMPANY_CODE",
|
||||
details: "인증된 사용자의 회사 코드를 찾을 수 없습니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!diagramId || !testData) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "다이어그램 ID와 테스트 데이터가 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "diagramId와 testData가 필요합니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await EventTriggerService.testConditionalConnection(
|
||||
parseInt(diagramId),
|
||||
testData,
|
||||
companyCode
|
||||
);
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
message: "조건부 연결 테스트를 성공적으로 완료했습니다.",
|
||||
data: result,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("조건부 연결 테스트 실패:", error);
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "조건부 연결 테스트에 실패했습니다.",
|
||||
error: {
|
||||
code: "CONDITIONAL_CONNECTION_TEST_FAILED",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
};
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 연결 액션 수동 실행
|
||||
*/
|
||||
export async function executeConditionalActions(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 조건부 연결 액션 수동 실행 시작 ===");
|
||||
|
||||
const { diagramId } = req.params;
|
||||
const { triggerType, tableName, data } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!companyCode) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "회사 코드가 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_COMPANY_CODE",
|
||||
details: "인증된 사용자의 회사 코드를 찾을 수 없습니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!diagramId || !triggerType || !tableName || !data) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "diagramId, triggerType, tableName, data가 모두 필요합니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await EventTriggerService.executeEventTriggers(
|
||||
triggerType,
|
||||
tableName,
|
||||
data,
|
||||
companyCode
|
||||
);
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "조건부 연결 액션을 성공적으로 실행했습니다.",
|
||||
data: results,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("조건부 연결 액션 실행 실패:", error);
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "조건부 연결 액션 실행에 실패했습니다.",
|
||||
error: {
|
||||
code: "CONDITIONAL_ACTION_EXECUTION_FAILED",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
};
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { logger } from "../utils/logger";
|
|||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { ApiResponse } from "../types/common";
|
||||
import { DataflowService } from "../services/dataflowService";
|
||||
import { EventTriggerService } from "../services/eventTriggerService";
|
||||
|
||||
/**
|
||||
* 테이블 관계 생성
|
||||
|
|
|
|||
|
|
@ -93,10 +93,20 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
|
|||
diagram_name,
|
||||
relationships,
|
||||
node_positions,
|
||||
category,
|
||||
control,
|
||||
plan,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
} = req.body;
|
||||
|
||||
logger.info(`새 관계도 생성 요청:`, { diagram_name, company_code });
|
||||
logger.info(`node_positions:`, node_positions);
|
||||
logger.info(`category:`, category);
|
||||
logger.info(`control:`, control);
|
||||
logger.info(`plan:`, plan);
|
||||
logger.info(`전체 요청 Body:`, JSON.stringify(req.body, null, 2));
|
||||
const companyCode =
|
||||
company_code ||
|
||||
(req.query.companyCode as string) ||
|
||||
|
|
@ -115,10 +125,27 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
|
|||
});
|
||||
}
|
||||
|
||||
// 🔍 백엔드에서 받은 실제 데이터 로깅
|
||||
console.log(
|
||||
"🔍 백엔드에서 받은 control 데이터:",
|
||||
JSON.stringify(control, null, 2)
|
||||
);
|
||||
console.log(
|
||||
"🔍 백엔드에서 받은 plan 데이터:",
|
||||
JSON.stringify(plan, null, 2)
|
||||
);
|
||||
console.log(
|
||||
"🔍 백엔드에서 받은 category 데이터:",
|
||||
JSON.stringify(category, null, 2)
|
||||
);
|
||||
|
||||
const newDiagram = await createDataflowDiagramService({
|
||||
diagram_name,
|
||||
relationships,
|
||||
node_positions,
|
||||
category,
|
||||
control,
|
||||
plan,
|
||||
company_code: companyCode,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
|
|
@ -162,6 +189,14 @@ export const updateDataflowDiagram = async (req: Request, res: Response) => {
|
|||
const userId =
|
||||
updated_by || (req.headers["x-user-id"] as string) || "SYSTEM";
|
||||
|
||||
logger.info(`관계도 수정 요청 - ID: ${diagramId}, Company: ${companyCode}`);
|
||||
logger.info(`요청 Body:`, JSON.stringify(req.body, null, 2));
|
||||
logger.info(`node_positions:`, req.body.node_positions);
|
||||
logger.info(`요청 Body 키들:`, Object.keys(req.body));
|
||||
logger.info(`요청 Body 타입:`, typeof req.body);
|
||||
logger.info(`node_positions 타입:`, typeof req.body.node_positions);
|
||||
logger.info(`node_positions 값:`, req.body.node_positions);
|
||||
|
||||
if (isNaN(diagramId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
import { Request, Response } from "express";
|
||||
import { logger } from "../utils/logger";
|
||||
import { TableManagementService } from "../services/tableManagementService";
|
||||
import { entityJoinService } from "../services/entityJoinService";
|
||||
import { referenceCacheService } from "../services/referenceCacheService";
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
|
||||
/**
|
||||
* Entity 조인 기능 컨트롤러
|
||||
* ID값을 의미있는 데이터로 자동 변환하는 API 제공
|
||||
*/
|
||||
export class EntityJoinController {
|
||||
/**
|
||||
* Entity 조인이 포함된 테이블 데이터 조회
|
||||
* GET /api/table-management/tables/:tableName/data-with-joins
|
||||
*/
|
||||
async getTableDataWithJoins(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const {
|
||||
page = 1,
|
||||
size = 20,
|
||||
search,
|
||||
sortBy,
|
||||
sortOrder = "asc",
|
||||
enableEntityJoin = true,
|
||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||
...otherParams
|
||||
} = req.query;
|
||||
|
||||
logger.info(`Entity 조인 데이터 요청: ${tableName}`, {
|
||||
page,
|
||||
size,
|
||||
enableEntityJoin,
|
||||
search,
|
||||
});
|
||||
|
||||
// 검색 조건 처리
|
||||
let searchConditions: Record<string, any> = {};
|
||||
if (search) {
|
||||
try {
|
||||
// search가 문자열인 경우 JSON 파싱
|
||||
searchConditions =
|
||||
typeof search === "string" ? JSON.parse(search) : search;
|
||||
} catch (error) {
|
||||
logger.warn("검색 조건 파싱 오류:", error);
|
||||
searchConditions = {};
|
||||
}
|
||||
}
|
||||
|
||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||
tableName,
|
||||
{
|
||||
page: Number(page),
|
||||
size: Number(size),
|
||||
search:
|
||||
Object.keys(searchConditions).length > 0
|
||||
? searchConditions
|
||||
: undefined,
|
||||
sortBy: sortBy as string,
|
||||
sortOrder: sortOrder as string,
|
||||
enableEntityJoin:
|
||||
enableEntityJoin === "true" || enableEntityJoin === true,
|
||||
}
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Entity 조인 데이터 조회 성공",
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Entity 조인 데이터 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Entity 조인 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 Entity 조인 설정 조회
|
||||
* GET /api/table-management/tables/:tableName/entity-joins
|
||||
*/
|
||||
async getEntityJoinConfigs(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
|
||||
logger.info(`Entity 조인 설정 조회: ${tableName}`);
|
||||
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Entity 조인 설정 조회 성공",
|
||||
data: {
|
||||
tableName,
|
||||
joinConfigs,
|
||||
count: joinConfigs.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Entity 조인 설정 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Entity 조인 설정 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 테이블의 표시 가능한 컬럼 목록 조회
|
||||
* GET /api/table-management/reference-tables/:tableName/columns
|
||||
*/
|
||||
async getReferenceTableColumns(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
|
||||
logger.info(`참조 테이블 컬럼 조회: ${tableName}`);
|
||||
|
||||
const columns =
|
||||
await tableManagementService.getReferenceTableColumns(tableName);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "참조 테이블 컬럼 조회 성공",
|
||||
data: {
|
||||
tableName,
|
||||
columns,
|
||||
count: columns.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("참조 테이블 컬럼 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "참조 테이블 컬럼 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 Entity 설정 업데이트 (display_column 포함)
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/entity-settings
|
||||
*/
|
||||
async updateEntitySettings(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const {
|
||||
webType,
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
columnLabel,
|
||||
description,
|
||||
} = req.body;
|
||||
|
||||
logger.info(`Entity 설정 업데이트: ${tableName}.${columnName}`, req.body);
|
||||
|
||||
// Entity 타입인 경우 필수 필드 검증
|
||||
if (webType === "entity") {
|
||||
if (!referenceTable || !referenceColumn) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"Entity 타입의 경우 referenceTable과 referenceColumn이 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await tableManagementService.updateColumnLabel(tableName, columnName, {
|
||||
webType,
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
columnLabel,
|
||||
description,
|
||||
});
|
||||
|
||||
// Entity 설정 변경 시 관련 캐시 무효화
|
||||
if (webType === "entity" && referenceTable) {
|
||||
referenceCacheService.invalidateCache(
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn
|
||||
);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Entity 설정 업데이트 성공",
|
||||
data: {
|
||||
tableName,
|
||||
columnName,
|
||||
settings: {
|
||||
webType,
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Entity 설정 업데이트 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Entity 설정 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 상태 조회
|
||||
* GET /api/table-management/cache/status
|
||||
*/
|
||||
async getCacheStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
logger.info("캐시 상태 조회");
|
||||
|
||||
const cacheInfo = referenceCacheService.getCacheInfo();
|
||||
const overallHitRate = referenceCacheService.getOverallCacheHitRate();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "캐시 상태 조회 성공",
|
||||
data: {
|
||||
overallHitRate,
|
||||
caches: cacheInfo,
|
||||
summary: {
|
||||
totalCaches: cacheInfo.length,
|
||||
totalSize: cacheInfo.reduce((sum, cache) => sum + cache.size, 0),
|
||||
averageHitRate:
|
||||
cacheInfo.length > 0
|
||||
? cacheInfo.reduce((sum, cache) => sum + cache.hitRate, 0) /
|
||||
cacheInfo.length
|
||||
: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("캐시 상태 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "캐시 상태 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 무효화
|
||||
* DELETE /api/table-management/cache
|
||||
*/
|
||||
async invalidateCache(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { table, keyColumn, displayColumn } = req.query;
|
||||
|
||||
logger.info("캐시 무효화 요청", { table, keyColumn, displayColumn });
|
||||
|
||||
if (table && keyColumn && displayColumn) {
|
||||
// 특정 캐시만 무효화
|
||||
referenceCacheService.invalidateCache(
|
||||
table as string,
|
||||
keyColumn as string,
|
||||
displayColumn as string
|
||||
);
|
||||
} else {
|
||||
// 전체 캐시 무효화
|
||||
referenceCacheService.invalidateCache();
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "캐시 무효화 완료",
|
||||
data: {
|
||||
target: table ? `${table}.${keyColumn}.${displayColumn}` : "전체",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("캐시 무효화 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "캐시 무효화 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 참조 테이블 자동 캐싱
|
||||
* POST /api/table-management/cache/preload
|
||||
*/
|
||||
async preloadCommonCaches(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
logger.info("공통 참조 테이블 자동 캐싱 시작");
|
||||
|
||||
await referenceCacheService.autoPreloadCommonTables();
|
||||
|
||||
const cacheInfo = referenceCacheService.getCacheInfo();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "공통 참조 테이블 캐싱 완료",
|
||||
data: {
|
||||
preloadedCaches: cacheInfo.length,
|
||||
caches: cacheInfo,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("공통 참조 테이블 캐싱 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "공통 참조 테이블 캐싱 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const entityJoinController = new EntityJoinController();
|
||||
|
|
@ -17,6 +17,10 @@ import {
|
|||
copyDiagram,
|
||||
deleteDiagram,
|
||||
} from "../controllers/dataflowController";
|
||||
import {
|
||||
testConditionalConnection,
|
||||
executeConditionalActions,
|
||||
} from "../controllers/conditionalConnectionController";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -128,4 +132,18 @@ router.get(
|
|||
getDiagramRelationshipsByRelationshipId
|
||||
);
|
||||
|
||||
// ==================== 조건부 연결 관리 라우트 ====================
|
||||
|
||||
/**
|
||||
* 조건부 연결 조건 테스트
|
||||
* POST /api/dataflow/diagrams/:diagramId/test-conditions
|
||||
*/
|
||||
router.post("/diagrams/:diagramId/test-conditions", testConditionalConnection);
|
||||
|
||||
/**
|
||||
* 조건부 연결 액션 수동 실행
|
||||
* POST /api/dataflow/diagrams/:diagramId/execute-actions
|
||||
*/
|
||||
router.post("/diagrams/:diagramId/execute-actions", executeConditionalActions);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
import { Router } from "express";
|
||||
import { entityJoinController } from "../controllers/entityJoinController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용 (테스트 시에는 주석 처리)
|
||||
// router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* Entity 조인 기능 라우트
|
||||
*
|
||||
* 🎯 핵심 기능:
|
||||
* - Entity 조인이 포함된 테이블 데이터 조회
|
||||
* - Entity 조인 설정 관리
|
||||
* - 참조 테이블 컬럼 정보 조회
|
||||
* - 캐시 상태 및 관리
|
||||
*/
|
||||
|
||||
// ========================================
|
||||
// 🎯 Entity 조인 데이터 조회
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Entity 조인이 포함된 테이블 데이터 조회
|
||||
* GET /api/table-management/tables/:tableName/data-with-joins
|
||||
*
|
||||
* Query Parameters:
|
||||
* - page: 페이지 번호 (default: 1)
|
||||
* - size: 페이지 크기 (default: 20)
|
||||
* - sortBy: 정렬 컬럼
|
||||
* - sortOrder: 정렬 순서 (asc/desc)
|
||||
* - enableEntityJoin: Entity 조인 활성화 (default: true)
|
||||
* - [기타]: 검색 조건 (컬럼명=값)
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* data: [...], // 조인된 데이터
|
||||
* total: 100,
|
||||
* page: 1,
|
||||
* size: 20,
|
||||
* totalPages: 5,
|
||||
* entityJoinInfo?: {
|
||||
* joinConfigs: [...],
|
||||
* strategy: "full_join" | "cache_lookup",
|
||||
* performance: { queryTime: 50, cacheHitRate?: 0.95 }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.get(
|
||||
"/tables/:tableName/data-with-joins",
|
||||
entityJoinController.getTableDataWithJoins.bind(entityJoinController)
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 🎯 Entity 조인 설정 관리
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 테이블의 Entity 조인 설정 조회
|
||||
* GET /api/table-management/tables/:tableName/entity-joins
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* tableName: "companies",
|
||||
* joinConfigs: [
|
||||
* {
|
||||
* sourceTable: "companies",
|
||||
* sourceColumn: "writer",
|
||||
* referenceTable: "user_info",
|
||||
* referenceColumn: "user_id",
|
||||
* displayColumn: "user_name",
|
||||
* aliasColumn: "writer_name"
|
||||
* }
|
||||
* ],
|
||||
* count: 1
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.get(
|
||||
"/tables/:tableName/entity-joins",
|
||||
entityJoinController.getEntityJoinConfigs.bind(entityJoinController)
|
||||
);
|
||||
|
||||
/**
|
||||
* 컬럼 Entity 설정 업데이트
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/entity-settings
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* webType: "entity",
|
||||
* referenceTable: "user_info",
|
||||
* referenceColumn: "user_id",
|
||||
* displayColumn: "user_name", // 🎯 새로 추가된 필드
|
||||
* columnLabel?: "작성자",
|
||||
* description?: "작성자 정보"
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* tableName: "companies",
|
||||
* columnName: "writer",
|
||||
* settings: { ... }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.put(
|
||||
"/tables/:tableName/columns/:columnName/entity-settings",
|
||||
entityJoinController.updateEntitySettings.bind(entityJoinController)
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 🎯 참조 테이블 정보
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 참조 테이블의 표시 가능한 컬럼 목록 조회
|
||||
* GET /api/table-management/reference-tables/:tableName/columns
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* tableName: "user_info",
|
||||
* columns: [
|
||||
* {
|
||||
* columnName: "user_id",
|
||||
* displayName: "user_id",
|
||||
* dataType: "character varying"
|
||||
* },
|
||||
* {
|
||||
* columnName: "user_name",
|
||||
* displayName: "user_name",
|
||||
* dataType: "character varying"
|
||||
* }
|
||||
* ],
|
||||
* count: 2
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.get(
|
||||
"/reference-tables/:tableName/columns",
|
||||
entityJoinController.getReferenceTableColumns.bind(entityJoinController)
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 🎯 캐시 관리
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 캐시 상태 조회
|
||||
* GET /api/table-management/cache/status
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* overallHitRate: 0.95,
|
||||
* caches: [
|
||||
* {
|
||||
* cacheKey: "user_info.user_id.user_name",
|
||||
* size: 150,
|
||||
* hitRate: 0.98,
|
||||
* lastUpdated: "2024-01-15T10:30:00Z"
|
||||
* }
|
||||
* ],
|
||||
* summary: {
|
||||
* totalCaches: 3,
|
||||
* totalSize: 450,
|
||||
* averageHitRate: 0.93
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.get(
|
||||
"/cache/status",
|
||||
entityJoinController.getCacheStatus.bind(entityJoinController)
|
||||
);
|
||||
|
||||
/**
|
||||
* 캐시 무효화
|
||||
* DELETE /api/table-management/cache
|
||||
*
|
||||
* Query Parameters (선택적):
|
||||
* - table: 특정 테이블 캐시만 무효화
|
||||
* - keyColumn: 키 컬럼
|
||||
* - displayColumn: 표시 컬럼
|
||||
*
|
||||
* 모든 파라미터가 없으면 전체 캐시 무효화
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* target: "user_info.user_id.user_name" | "전체"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.delete(
|
||||
"/cache",
|
||||
entityJoinController.invalidateCache.bind(entityJoinController)
|
||||
);
|
||||
|
||||
/**
|
||||
* 공통 참조 테이블 자동 캐싱
|
||||
* POST /api/table-management/cache/preload
|
||||
*
|
||||
* 일반적으로 자주 사용되는 참조 테이블들을 자동으로 캐싱
|
||||
* - user_info (사용자 정보)
|
||||
* - comm_code (공통 코드)
|
||||
* - dept_info (부서 정보)
|
||||
* - companies (회사 정보)
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* preloadedCaches: 4,
|
||||
* caches: [...]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
router.post(
|
||||
"/cache/preload",
|
||||
entityJoinController.preloadCommonCaches.bind(entityJoinController)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
|
@ -6,8 +6,14 @@ const prisma = new PrismaClient();
|
|||
// 타입 정의
|
||||
interface CreateDataflowDiagramData {
|
||||
diagram_name: string;
|
||||
relationships: any; // JSON 데이터
|
||||
node_positions?: any; // JSON 데이터 (노드 위치 정보)
|
||||
relationships: Record<string, unknown>; // JSON 데이터
|
||||
node_positions?: Record<string, unknown> | null; // JSON 데이터 (노드 위치 정보)
|
||||
|
||||
// 🔥 수정: 배열 구조로 변경된 조건부 연결 관련 필드
|
||||
control?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 조건 설정)
|
||||
category?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 연결 종류)
|
||||
plan?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 실행 계획)
|
||||
|
||||
company_code: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
|
|
@ -15,8 +21,14 @@ interface CreateDataflowDiagramData {
|
|||
|
||||
interface UpdateDataflowDiagramData {
|
||||
diagram_name?: string;
|
||||
relationships?: any; // JSON 데이터
|
||||
node_positions?: any; // JSON 데이터 (노드 위치 정보)
|
||||
relationships?: Record<string, unknown>; // JSON 데이터
|
||||
node_positions?: Record<string, unknown> | null; // JSON 데이터 (노드 위치 정보)
|
||||
|
||||
// 🔥 수정: 배열 구조로 변경된 조건부 연결 관련 필드
|
||||
control?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 조건 설정)
|
||||
category?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 연결 종류)
|
||||
plan?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 실행 계획)
|
||||
|
||||
updated_by: string;
|
||||
}
|
||||
|
||||
|
|
@ -33,7 +45,13 @@ export const getDataflowDiagrams = async (
|
|||
const offset = (page - 1) * size;
|
||||
|
||||
// 검색 조건 구성
|
||||
const whereClause: any = {};
|
||||
const whereClause: {
|
||||
company_code?: string;
|
||||
diagram_name?: {
|
||||
contains: string;
|
||||
mode: "insensitive";
|
||||
};
|
||||
} = {};
|
||||
|
||||
// company_code가 '*'가 아닌 경우에만 필터링
|
||||
if (companyCode !== "*") {
|
||||
|
|
@ -87,7 +105,10 @@ export const getDataflowDiagramById = async (
|
|||
companyCode: string
|
||||
) => {
|
||||
try {
|
||||
const whereClause: any = {
|
||||
const whereClause: {
|
||||
diagram_id: number;
|
||||
company_code?: string;
|
||||
} = {
|
||||
diagram_id: diagramId,
|
||||
};
|
||||
|
||||
|
|
@ -117,8 +138,15 @@ export const createDataflowDiagram = async (
|
|||
const newDiagram = await prisma.dataflow_diagrams.create({
|
||||
data: {
|
||||
diagram_name: data.diagram_name,
|
||||
relationships: data.relationships,
|
||||
node_positions: data.node_positions || null,
|
||||
relationships: data.relationships as Prisma.InputJsonValue,
|
||||
node_positions: data.node_positions as
|
||||
| Prisma.InputJsonValue
|
||||
| undefined,
|
||||
category: data.category
|
||||
? (data.category as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
control: data.control as Prisma.InputJsonValue | undefined,
|
||||
plan: data.plan as Prisma.InputJsonValue | undefined,
|
||||
company_code: data.company_code,
|
||||
created_by: data.created_by,
|
||||
updated_by: data.updated_by,
|
||||
|
|
@ -141,8 +169,15 @@ export const updateDataflowDiagram = async (
|
|||
companyCode: string
|
||||
) => {
|
||||
try {
|
||||
logger.info(
|
||||
`관계도 수정 서비스 시작 - ID: ${diagramId}, Company: ${companyCode}`
|
||||
);
|
||||
|
||||
// 먼저 해당 관계도가 존재하는지 확인
|
||||
const whereClause: any = {
|
||||
const whereClause: {
|
||||
diagram_id: number;
|
||||
company_code?: string;
|
||||
} = {
|
||||
diagram_id: diagramId,
|
||||
};
|
||||
|
||||
|
|
@ -155,7 +190,15 @@ export const updateDataflowDiagram = async (
|
|||
where: whereClause,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`기존 관계도 조회 결과:`,
|
||||
existingDiagram ? `ID ${existingDiagram.diagram_id} 발견` : "관계도 없음"
|
||||
);
|
||||
|
||||
if (!existingDiagram) {
|
||||
logger.warn(
|
||||
`관계도 ID ${diagramId}를 찾을 수 없음 - Company: ${companyCode}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -166,9 +209,24 @@ export const updateDataflowDiagram = async (
|
|||
},
|
||||
data: {
|
||||
...(data.diagram_name && { diagram_name: data.diagram_name }),
|
||||
...(data.relationships && { relationships: data.relationships }),
|
||||
...(data.relationships && {
|
||||
relationships: data.relationships as Prisma.InputJsonValue,
|
||||
}),
|
||||
...(data.node_positions !== undefined && {
|
||||
node_positions: data.node_positions,
|
||||
node_positions: data.node_positions
|
||||
? (data.node_positions as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
}),
|
||||
...(data.category !== undefined && {
|
||||
category: data.category
|
||||
? (data.category as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
}),
|
||||
...(data.control !== undefined && {
|
||||
control: data.control as Prisma.InputJsonValue | undefined,
|
||||
}),
|
||||
...(data.plan !== undefined && {
|
||||
plan: data.plan as Prisma.InputJsonValue | undefined,
|
||||
}),
|
||||
updated_by: data.updated_by,
|
||||
updated_at: new Date(),
|
||||
|
|
@ -191,7 +249,10 @@ export const deleteDataflowDiagram = async (
|
|||
) => {
|
||||
try {
|
||||
// 먼저 해당 관계도가 존재하는지 확인
|
||||
const whereClause: any = {
|
||||
const whereClause: {
|
||||
diagram_id: number;
|
||||
company_code?: string;
|
||||
} = {
|
||||
diagram_id: diagramId,
|
||||
};
|
||||
|
||||
|
|
@ -233,7 +294,10 @@ export const copyDataflowDiagram = async (
|
|||
) => {
|
||||
try {
|
||||
// 원본 관계도 조회
|
||||
const whereClause: any = {
|
||||
const whereClause: {
|
||||
diagram_id: number;
|
||||
company_code?: string;
|
||||
} = {
|
||||
diagram_id: diagramId,
|
||||
};
|
||||
|
||||
|
|
@ -262,7 +326,12 @@ export const copyDataflowDiagram = async (
|
|||
: originalDiagram.diagram_name;
|
||||
|
||||
// 같은 패턴의 이름들을 찾아서 가장 큰 번호 찾기
|
||||
const copyWhereClause: any = {
|
||||
const copyWhereClause: {
|
||||
diagram_name: {
|
||||
startsWith: string;
|
||||
};
|
||||
company_code?: string;
|
||||
} = {
|
||||
diagram_name: {
|
||||
startsWith: baseName,
|
||||
},
|
||||
|
|
@ -298,7 +367,11 @@ export const copyDataflowDiagram = async (
|
|||
const copiedDiagram = await prisma.dataflow_diagrams.create({
|
||||
data: {
|
||||
diagram_name: copyName,
|
||||
relationships: originalDiagram.relationships as any,
|
||||
relationships: originalDiagram.relationships as Prisma.InputJsonValue,
|
||||
node_positions: originalDiagram.node_positions
|
||||
? (originalDiagram.node_positions as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
category: originalDiagram.category || undefined,
|
||||
company_code: companyCode,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import prisma from "../config/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { EventTriggerService } from "./eventTriggerService";
|
||||
|
||||
export interface FormDataResult {
|
||||
id: number;
|
||||
|
|
@ -247,6 +248,22 @@ export class DynamicFormService {
|
|||
// 결과를 표준 형식으로 변환
|
||||
const insertedRecord = Array.isArray(result) ? result[0] : result;
|
||||
|
||||
// 🔥 조건부 연결 실행 (INSERT 트리거)
|
||||
try {
|
||||
if (company_code) {
|
||||
await EventTriggerService.executeEventTriggers(
|
||||
"insert",
|
||||
tableName,
|
||||
insertedRecord as Record<string, any>,
|
||||
company_code
|
||||
);
|
||||
console.log("🚀 조건부 연결 트리거 실행 완료 (INSERT)");
|
||||
}
|
||||
} catch (triggerError) {
|
||||
console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError);
|
||||
// 트리거 오류는 로그만 남기고 메인 저장 프로세스는 계속 진행
|
||||
}
|
||||
|
||||
return {
|
||||
id: insertedRecord.id || insertedRecord.objid || 0,
|
||||
screenId: screenId,
|
||||
|
|
@ -343,6 +360,22 @@ export class DynamicFormService {
|
|||
|
||||
const updatedRecord = Array.isArray(result) ? result[0] : result;
|
||||
|
||||
// 🔥 조건부 연결 실행 (UPDATE 트리거)
|
||||
try {
|
||||
if (company_code) {
|
||||
await EventTriggerService.executeEventTriggers(
|
||||
"update",
|
||||
tableName,
|
||||
updatedRecord as Record<string, any>,
|
||||
company_code
|
||||
);
|
||||
console.log("🚀 조건부 연결 트리거 실행 완료 (UPDATE)");
|
||||
}
|
||||
} catch (triggerError) {
|
||||
console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError);
|
||||
// 트리거 오류는 로그만 남기고 메인 업데이트 프로세스는 계속 진행
|
||||
}
|
||||
|
||||
return {
|
||||
id: updatedRecord.id || updatedRecord.objid || id,
|
||||
screenId: 0, // 실제 테이블에는 screenId가 없으므로 0으로 설정
|
||||
|
|
@ -362,7 +395,11 @@ export class DynamicFormService {
|
|||
/**
|
||||
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
||||
*/
|
||||
async deleteFormData(id: number, tableName: string): Promise<void> {
|
||||
async deleteFormData(
|
||||
id: number,
|
||||
tableName: string,
|
||||
companyCode?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||
id,
|
||||
|
|
@ -382,6 +419,28 @@ export class DynamicFormService {
|
|||
const result = await prisma.$queryRawUnsafe(deleteQuery, id);
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||
|
||||
// 🔥 조건부 연결 실행 (DELETE 트리거)
|
||||
try {
|
||||
if (
|
||||
companyCode &&
|
||||
result &&
|
||||
Array.isArray(result) &&
|
||||
result.length > 0
|
||||
) {
|
||||
const deletedRecord = result[0] as Record<string, any>;
|
||||
await EventTriggerService.executeEventTriggers(
|
||||
"delete",
|
||||
tableName,
|
||||
deletedRecord,
|
||||
companyCode
|
||||
);
|
||||
console.log("🚀 조건부 연결 트리거 실행 완료 (DELETE)");
|
||||
}
|
||||
} catch (triggerError) {
|
||||
console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError);
|
||||
// 트리거 오류는 로그만 남기고 메인 삭제 프로세스는 계속 진행
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 서비스: 실제 테이블 삭제 실패:", error);
|
||||
throw new Error(`실제 테이블 삭제 실패: ${error}`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
import {
|
||||
EntityJoinConfig,
|
||||
BatchLookupRequest,
|
||||
BatchLookupResponse,
|
||||
} from "../types/tableManagement";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Entity 조인 기능을 제공하는 서비스
|
||||
* ID값을 의미있는 데이터로 자동 변환하는 스마트 테이블 시스템
|
||||
*/
|
||||
export class EntityJoinService {
|
||||
/**
|
||||
* 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성
|
||||
*/
|
||||
async detectEntityJoins(tableName: string): Promise<EntityJoinConfig[]> {
|
||||
try {
|
||||
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
||||
|
||||
// column_labels에서 entity 타입인 컬럼들 조회
|
||||
const entityColumns = await prisma.column_labels.findMany({
|
||||
where: {
|
||||
table_name: tableName,
|
||||
web_type: "entity",
|
||||
reference_table: { not: null },
|
||||
reference_column: { not: null },
|
||||
},
|
||||
select: {
|
||||
column_name: true,
|
||||
reference_table: true,
|
||||
reference_column: true,
|
||||
display_column: true,
|
||||
},
|
||||
});
|
||||
|
||||
const joinConfigs: EntityJoinConfig[] = [];
|
||||
|
||||
for (const column of entityColumns) {
|
||||
if (
|
||||
!column.column_name ||
|
||||
!column.reference_table ||
|
||||
!column.reference_column
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// display_column이 없으면 reference_column 사용
|
||||
const displayColumn = column.display_column || column.reference_column;
|
||||
|
||||
// 별칭 컬럼명 생성 (writer -> writer_name)
|
||||
const aliasColumn = `${column.column_name}_name`;
|
||||
|
||||
const joinConfig: EntityJoinConfig = {
|
||||
sourceTable: tableName,
|
||||
sourceColumn: column.column_name,
|
||||
referenceTable: column.reference_table,
|
||||
referenceColumn: column.reference_column,
|
||||
displayColumn: displayColumn,
|
||||
aliasColumn: aliasColumn,
|
||||
};
|
||||
|
||||
// 조인 설정 유효성 검증
|
||||
if (await this.validateJoinConfig(joinConfig)) {
|
||||
joinConfigs.push(joinConfig);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Entity 조인 설정 생성 완료: ${joinConfigs.length}개`);
|
||||
return joinConfigs;
|
||||
} catch (error) {
|
||||
logger.error(`Entity 조인 감지 실패: ${tableName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity 조인이 포함된 SQL 쿼리 생성
|
||||
*/
|
||||
buildJoinQuery(
|
||||
tableName: string,
|
||||
joinConfigs: EntityJoinConfig[],
|
||||
selectColumns: string[],
|
||||
whereClause: string = "",
|
||||
orderBy: string = "",
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): string {
|
||||
try {
|
||||
// 기본 SELECT 컬럼들
|
||||
const baseColumns = selectColumns.map((col) => `main.${col}`).join(", ");
|
||||
|
||||
// Entity 조인 컬럼들
|
||||
const joinColumns = joinConfigs
|
||||
.map(
|
||||
(config) =>
|
||||
`${config.referenceTable.substring(0, 3)}.${config.displayColumn} AS ${config.aliasColumn}`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
// SELECT 절 구성
|
||||
const selectClause = joinColumns
|
||||
? `${baseColumns}, ${joinColumns}`
|
||||
: baseColumns;
|
||||
|
||||
// FROM 절 (메인 테이블)
|
||||
const fromClause = `FROM ${tableName} main`;
|
||||
|
||||
// LEFT JOIN 절들
|
||||
const joinClauses = joinConfigs
|
||||
.map((config, index) => {
|
||||
const alias = config.referenceTable.substring(0, 3); // user_info -> use, companies -> com
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// WHERE 절
|
||||
const whereSQL = whereClause ? `WHERE ${whereClause}` : "";
|
||||
|
||||
// ORDER BY 절
|
||||
const orderSQL = orderBy ? `ORDER BY ${orderBy}` : "";
|
||||
|
||||
// LIMIT 및 OFFSET
|
||||
let limitSQL = "";
|
||||
if (limit !== undefined) {
|
||||
limitSQL = `LIMIT ${limit}`;
|
||||
if (offset !== undefined) {
|
||||
limitSQL += ` OFFSET ${offset}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 최종 쿼리 조합
|
||||
const query = [
|
||||
`SELECT ${selectClause}`,
|
||||
fromClause,
|
||||
joinClauses,
|
||||
whereSQL,
|
||||
orderSQL,
|
||||
limitSQL,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
logger.debug(`생성된 Entity 조인 쿼리:`, query);
|
||||
return query;
|
||||
} catch (error) {
|
||||
logger.error("Entity 조인 쿼리 생성 실패", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조인 설정 유효성 검증
|
||||
*/
|
||||
private async validateJoinConfig(config: EntityJoinConfig): Promise<boolean> {
|
||||
try {
|
||||
// 참조 테이블 존재 확인
|
||||
const tableExists = await prisma.$queryRaw`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = ${config.referenceTable}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!Array.isArray(tableExists) || tableExists.length === 0) {
|
||||
logger.warn(`참조 테이블이 존재하지 않음: ${config.referenceTable}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 참조 컬럼 존재 확인
|
||||
const columnExists = await prisma.$queryRaw`
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = ${config.referenceTable}
|
||||
AND column_name = ${config.displayColumn}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!Array.isArray(columnExists) || columnExists.length === 0) {
|
||||
logger.warn(
|
||||
`표시 컬럼이 존재하지 않음: ${config.referenceTable}.${config.displayColumn}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("조인 설정 검증 실패", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카운트 쿼리 생성 (페이징용)
|
||||
*/
|
||||
buildCountQuery(
|
||||
tableName: string,
|
||||
joinConfigs: EntityJoinConfig[],
|
||||
whereClause: string = ""
|
||||
): string {
|
||||
try {
|
||||
// JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요)
|
||||
const joinClauses = joinConfigs
|
||||
.map((config, index) => {
|
||||
const alias = config.referenceTable.substring(0, 3);
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// WHERE 절
|
||||
const whereSQL = whereClause ? `WHERE ${whereClause}` : "";
|
||||
|
||||
// COUNT 쿼리 조합
|
||||
const query = [
|
||||
`SELECT COUNT(*) as total`,
|
||||
`FROM ${tableName} main`,
|
||||
joinClauses,
|
||||
whereSQL,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return query;
|
||||
} catch (error) {
|
||||
logger.error("COUNT 쿼리 생성 실패", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 테이블의 컬럼 목록 조회 (UI용)
|
||||
*/
|
||||
async getReferenceTableColumns(tableName: string): Promise<
|
||||
Array<{
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const columns = (await prisma.$queryRaw`
|
||||
SELECT
|
||||
column_name,
|
||||
column_name as display_name,
|
||||
data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = ${tableName}
|
||||
AND data_type IN ('character varying', 'varchar', 'text', 'char')
|
||||
ORDER BY ordinal_position
|
||||
`) as Array<{
|
||||
column_name: string;
|
||||
display_name: string;
|
||||
data_type: string;
|
||||
}>;
|
||||
|
||||
return columns.map((col) => ({
|
||||
columnName: col.column_name,
|
||||
displayName: col.display_name,
|
||||
dataType: col.data_type,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity 조인 전략 결정 (full_join vs cache_lookup)
|
||||
*/
|
||||
async determineJoinStrategy(
|
||||
joinConfigs: EntityJoinConfig[]
|
||||
): Promise<"full_join" | "cache_lookup"> {
|
||||
try {
|
||||
// 참조 테이블 크기 확인
|
||||
for (const config of joinConfigs) {
|
||||
const result = (await prisma.$queryRawUnsafe(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${config.referenceTable}
|
||||
`)) as Array<{ count: bigint }>;
|
||||
|
||||
const count = Number(result[0]?.count || 0);
|
||||
|
||||
// 1000건 이상이면 조인 방식 사용
|
||||
if (count > 1000) {
|
||||
return "full_join";
|
||||
}
|
||||
}
|
||||
|
||||
return "cache_lookup";
|
||||
} catch (error) {
|
||||
logger.error("조인 전략 결정 실패", error);
|
||||
return "full_join"; // 기본값
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const entityJoinService = new EntityJoinService();
|
||||
|
|
@ -0,0 +1,714 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 조건 노드 타입 정의
|
||||
interface ConditionNode {
|
||||
id: string; // 고유 ID
|
||||
type: "condition" | "group-start" | "group-end";
|
||||
field?: string;
|
||||
operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value?: any;
|
||||
dataType?: string;
|
||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
|
||||
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)
|
||||
}
|
||||
|
||||
// 조건 제어 정보
|
||||
interface ConditionControl {
|
||||
triggerType: "insert" | "update" | "delete" | "insert_update";
|
||||
conditionTree: ConditionNode | ConditionNode[] | null;
|
||||
}
|
||||
|
||||
// 연결 카테고리 정보
|
||||
interface ConnectionCategory {
|
||||
type: "simple-key" | "data-save" | "external-call" | "conditional-link";
|
||||
rollbackOnError?: boolean;
|
||||
enableLogging?: boolean;
|
||||
maxRetryCount?: number;
|
||||
}
|
||||
|
||||
// 대상 액션
|
||||
interface TargetAction {
|
||||
id: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
targetTable: string;
|
||||
enabled: boolean;
|
||||
fieldMappings: FieldMapping[];
|
||||
conditions?: Array<{
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: string;
|
||||
logicalOperator?: "AND" | "OR";
|
||||
}>;
|
||||
splitConfig?: {
|
||||
sourceField: string;
|
||||
delimiter: string;
|
||||
targetField: string;
|
||||
};
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 필드 매핑
|
||||
interface FieldMapping {
|
||||
sourceField: string;
|
||||
targetField: string;
|
||||
transformFunction?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
// 실행 계획
|
||||
interface ExecutionPlan {
|
||||
sourceTable: string;
|
||||
targetActions: TargetAction[];
|
||||
}
|
||||
|
||||
// 실행 결과
|
||||
interface ExecutionResult {
|
||||
success: boolean;
|
||||
executedActions: number;
|
||||
failedActions: number;
|
||||
errors: string[];
|
||||
executionTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 연결 실행을 위한 이벤트 트리거 서비스
|
||||
*/
|
||||
export class EventTriggerService {
|
||||
/**
|
||||
* 특정 테이블에 대한 이벤트 트리거 실행
|
||||
*/
|
||||
static async executeEventTriggers(
|
||||
triggerType: "insert" | "update" | "delete",
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<ExecutionResult[]> {
|
||||
const startTime = Date.now();
|
||||
const results: ExecutionResult[] = [];
|
||||
|
||||
try {
|
||||
// 🔥 수정: Raw SQL을 사용하여 JSON 배열 검색
|
||||
const diagrams = (await prisma.$queryRaw`
|
||||
SELECT * FROM dataflow_diagrams
|
||||
WHERE company_code = ${companyCode}
|
||||
AND (
|
||||
category::text = '"data-save"' OR
|
||||
category::jsonb ? 'data-save' OR
|
||||
category::jsonb @> '["data-save"]'
|
||||
)
|
||||
`) as any[];
|
||||
|
||||
// 각 관계도에서 해당 테이블을 소스로 하는 데이터 저장 관계들 필터링
|
||||
const matchingDiagrams = diagrams.filter((diagram) => {
|
||||
// category 배열에서 data-save 연결이 있는지 확인
|
||||
const categories = diagram.category as any[];
|
||||
const hasDataSave = Array.isArray(categories)
|
||||
? categories.some((cat) => cat.category === "data-save")
|
||||
: false;
|
||||
|
||||
if (!hasDataSave) return false;
|
||||
|
||||
// plan 배열에서 해당 테이블을 소스로 하는 항목이 있는지 확인
|
||||
const plans = diagram.plan as any[];
|
||||
const hasMatchingPlan = Array.isArray(plans)
|
||||
? plans.some((plan) => plan.sourceTable === tableName)
|
||||
: false;
|
||||
|
||||
// control 배열에서 해당 트리거 타입이 있는지 확인
|
||||
const controls = diagram.control as any[];
|
||||
const hasMatchingControl = Array.isArray(controls)
|
||||
? controls.some((control) => control.triggerType === triggerType)
|
||||
: false;
|
||||
|
||||
return hasDataSave && hasMatchingPlan && hasMatchingControl;
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Found ${matchingDiagrams.length} matching data-save connections for table ${tableName} with trigger ${triggerType}`
|
||||
);
|
||||
|
||||
// 각 다이어그램에 대해 조건부 연결 실행
|
||||
for (const diagram of matchingDiagrams) {
|
||||
try {
|
||||
const result = await this.executeDiagramTrigger(
|
||||
diagram,
|
||||
data,
|
||||
companyCode
|
||||
);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
logger.error(`Error executing diagram ${diagram.diagram_id}:`, error);
|
||||
results.push({
|
||||
success: false,
|
||||
executedActions: 0,
|
||||
failedActions: 1,
|
||||
errors: [error instanceof Error ? error.message : "Unknown error"],
|
||||
executionTime: Date.now() - startTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("Error in executeEventTriggers:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 다이어그램의 트리거 실행
|
||||
*/
|
||||
private static async executeDiagramTrigger(
|
||||
diagram: any,
|
||||
data: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<ExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
let executedActions = 0;
|
||||
let failedActions = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
const control = diagram.control as unknown as ConditionControl;
|
||||
const category = diagram.category as unknown as ConnectionCategory;
|
||||
const plan = diagram.plan as unknown as ExecutionPlan;
|
||||
|
||||
logger.info(
|
||||
`Executing diagram ${diagram.diagram_id} (${diagram.diagram_name})`
|
||||
);
|
||||
|
||||
// 조건 평가
|
||||
if (control.conditionTree) {
|
||||
const conditionMet = await this.evaluateCondition(
|
||||
control.conditionTree,
|
||||
data
|
||||
);
|
||||
if (!conditionMet) {
|
||||
logger.info(
|
||||
`Conditions not met for diagram ${diagram.diagram_id}, skipping execution`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
executedActions: 0,
|
||||
failedActions: 0,
|
||||
errors: [],
|
||||
executionTime: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 대상 액션들 실행
|
||||
for (const action of plan.targetActions) {
|
||||
if (!action.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.executeTargetAction(action, data, companyCode);
|
||||
executedActions++;
|
||||
|
||||
if (category.enableLogging) {
|
||||
logger.info(
|
||||
`Successfully executed action ${action.id} on table ${action.targetTable}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failedActions++;
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
errors.push(`Action ${action.id}: ${errorMsg}`);
|
||||
|
||||
logger.error(`Failed to execute action ${action.id}:`, error);
|
||||
|
||||
// 오류 시 롤백 처리
|
||||
if (category.rollbackOnError) {
|
||||
logger.warn(`Rolling back due to error in action ${action.id}`);
|
||||
// TODO: 롤백 로직 구현
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: failedActions === 0,
|
||||
executedActions,
|
||||
failedActions,
|
||||
errors,
|
||||
executionTime: Date.now() - startTime,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error executing diagram ${diagram.diagram_id}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
executedActions: 0,
|
||||
failedActions: 1,
|
||||
errors: [error instanceof Error ? error.message : "Unknown error"],
|
||||
executionTime: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 평가 (플랫 구조 + 그룹핑 지원)
|
||||
*/
|
||||
private static async evaluateCondition(
|
||||
condition: ConditionNode | ConditionNode[],
|
||||
data: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
// 단일 조건인 경우 (하위 호환성)
|
||||
if (!Array.isArray(condition)) {
|
||||
if (condition.type === "condition") {
|
||||
return this.evaluateSingleCondition(condition, data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 조건 배열인 경우 (새로운 그룹핑 시스템)
|
||||
return this.evaluateConditionList(condition, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 리스트 평가 (괄호 그룹핑 지원)
|
||||
*/
|
||||
private static async evaluateConditionList(
|
||||
conditions: ConditionNode[],
|
||||
data: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
if (conditions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 조건을 평가 가능한 표현식으로 변환
|
||||
const expression = await this.buildConditionExpression(conditions, data);
|
||||
|
||||
// 표현식 평가
|
||||
return this.evaluateExpression(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건들을 평가 가능한 표현식으로 변환
|
||||
*/
|
||||
private static async buildConditionExpression(
|
||||
conditions: ConditionNode[],
|
||||
data: Record<string, any>
|
||||
): Promise<string> {
|
||||
const tokens: string[] = [];
|
||||
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
const condition = conditions[i];
|
||||
|
||||
if (condition.type === "group-start") {
|
||||
// 이전 조건과의 논리 연산자 추가
|
||||
if (i > 0 && condition.logicalOperator) {
|
||||
tokens.push(condition.logicalOperator);
|
||||
}
|
||||
tokens.push("(");
|
||||
} else if (condition.type === "group-end") {
|
||||
tokens.push(")");
|
||||
} else if (condition.type === "condition") {
|
||||
// 이전 조건과의 논리 연산자 추가
|
||||
if (i > 0 && condition.logicalOperator) {
|
||||
tokens.push(condition.logicalOperator);
|
||||
}
|
||||
|
||||
// 조건 평가 결과를 토큰으로 추가
|
||||
const result = await this.evaluateSingleCondition(condition, data);
|
||||
tokens.push(result.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 논리 표현식 평가 (괄호 우선순위 지원)
|
||||
*/
|
||||
private static evaluateExpression(expression: string): boolean {
|
||||
try {
|
||||
// 안전한 논리 표현식 평가
|
||||
// true/false와 AND/OR/괄호만 포함된 표현식을 평가
|
||||
const sanitizedExpression = expression
|
||||
.replace(/\bAND\b/g, "&&")
|
||||
.replace(/\bOR\b/g, "||")
|
||||
.replace(/\btrue\b/g, "true")
|
||||
.replace(/\bfalse\b/g, "false");
|
||||
|
||||
// 보안을 위해 허용된 문자만 확인
|
||||
if (!/^[true|false|\s|&|\||\(|\)]+$/.test(sanitizedExpression)) {
|
||||
logger.warn(`Invalid expression: ${expression}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Function constructor를 사용한 안전한 평가
|
||||
const result = new Function(`return ${sanitizedExpression}`)();
|
||||
return Boolean(result);
|
||||
} catch (error) {
|
||||
logger.error(`Error evaluating expression: ${expression}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션별 조건들 평가 (AND/OR 연산자 지원)
|
||||
*/
|
||||
private static async evaluateActionConditions(
|
||||
conditions: Array<{
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: string;
|
||||
logicalOperator?: "AND" | "OR";
|
||||
}>,
|
||||
data: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
if (conditions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let result = await this.evaluateActionCondition(conditions[0], data);
|
||||
|
||||
for (let i = 1; i < conditions.length; i++) {
|
||||
const prevCondition = conditions[i - 1];
|
||||
const currentCondition = conditions[i];
|
||||
const currentResult = await this.evaluateActionCondition(
|
||||
currentCondition,
|
||||
data
|
||||
);
|
||||
|
||||
if (prevCondition.logicalOperator === "OR") {
|
||||
result = result || currentResult;
|
||||
} else {
|
||||
// 기본값은 AND
|
||||
result = result && currentResult;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 단일 조건 평가
|
||||
*/
|
||||
private static async evaluateActionCondition(
|
||||
condition: {
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: string;
|
||||
},
|
||||
data: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
const fieldValue = data[condition.field];
|
||||
const conditionValue = condition.value;
|
||||
|
||||
switch (condition.operator) {
|
||||
case "=":
|
||||
return fieldValue == conditionValue;
|
||||
case "!=":
|
||||
return fieldValue != conditionValue;
|
||||
case ">":
|
||||
return Number(fieldValue) > Number(conditionValue);
|
||||
case "<":
|
||||
return Number(fieldValue) < Number(conditionValue);
|
||||
case ">=":
|
||||
return Number(fieldValue) >= Number(conditionValue);
|
||||
case "<=":
|
||||
return Number(fieldValue) <= Number(conditionValue);
|
||||
case "LIKE":
|
||||
return String(fieldValue).includes(String(conditionValue));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 조건 평가
|
||||
*/
|
||||
private static evaluateSingleCondition(
|
||||
condition: ConditionNode,
|
||||
data: Record<string, any>
|
||||
): boolean {
|
||||
const { field, operator_type, value } = condition;
|
||||
|
||||
if (!field || !operator_type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fieldValue = data[field];
|
||||
|
||||
switch (operator_type) {
|
||||
case "=":
|
||||
return fieldValue == value;
|
||||
case "!=":
|
||||
return fieldValue != value;
|
||||
case ">":
|
||||
return Number(fieldValue) > Number(value);
|
||||
case "<":
|
||||
return Number(fieldValue) < Number(value);
|
||||
case ">=":
|
||||
return Number(fieldValue) >= Number(value);
|
||||
case "<=":
|
||||
return Number(fieldValue) <= Number(value);
|
||||
case "LIKE":
|
||||
return String(fieldValue).includes(String(value));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 액션 실행
|
||||
*/
|
||||
private static async executeTargetAction(
|
||||
action: TargetAction,
|
||||
sourceData: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<void> {
|
||||
// 액션별 조건 평가
|
||||
if (action.conditions && action.conditions.length > 0) {
|
||||
const conditionMet = await this.evaluateActionConditions(
|
||||
action.conditions,
|
||||
sourceData
|
||||
);
|
||||
if (!conditionMet) {
|
||||
logger.info(
|
||||
`Action conditions not met for action ${action.id}, skipping execution`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 필드 매핑을 통해 대상 데이터 생성
|
||||
const targetData: Record<string, any> = {};
|
||||
|
||||
for (const mapping of action.fieldMappings) {
|
||||
let value = sourceData[mapping.sourceField];
|
||||
|
||||
// 변환 함수 적용
|
||||
if (mapping.transformFunction) {
|
||||
value = this.applyTransformFunction(value, mapping.transformFunction);
|
||||
}
|
||||
|
||||
// 기본값 설정
|
||||
if (value === undefined || value === null) {
|
||||
value = mapping.defaultValue;
|
||||
}
|
||||
|
||||
targetData[mapping.targetField] = value;
|
||||
}
|
||||
|
||||
// 회사 코드 추가
|
||||
targetData.company_code = companyCode;
|
||||
|
||||
// 액션 타입별 실행
|
||||
switch (action.actionType) {
|
||||
case "insert":
|
||||
await this.executeInsertAction(action.targetTable, targetData);
|
||||
break;
|
||||
case "update":
|
||||
await this.executeUpdateAction(
|
||||
action.targetTable,
|
||||
targetData,
|
||||
undefined // 액션별 조건은 이미 평가했으므로 WHERE 조건은 undefined
|
||||
);
|
||||
break;
|
||||
case "delete":
|
||||
await this.executeDeleteAction(
|
||||
action.targetTable,
|
||||
targetData,
|
||||
undefined // 액션별 조건은 이미 평가했으므로 WHERE 조건은 undefined
|
||||
);
|
||||
break;
|
||||
case "upsert":
|
||||
await this.executeUpsertAction(action.targetTable, targetData);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported action type: ${action.actionType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT 액션 실행
|
||||
*/
|
||||
private static async executeInsertAction(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<void> {
|
||||
// 동적 테이블 INSERT 실행
|
||||
const sql = `INSERT INTO ${tableName} (${Object.keys(data).join(", ")}) VALUES (${Object.keys(
|
||||
data
|
||||
)
|
||||
.map(() => "?")
|
||||
.join(", ")})`;
|
||||
|
||||
await prisma.$executeRawUnsafe(sql, ...Object.values(data));
|
||||
logger.info(`Inserted data into ${tableName}:`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE 액션 실행
|
||||
*/
|
||||
private static async executeUpdateAction(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
conditions?: ConditionNode
|
||||
): Promise<void> {
|
||||
// 조건이 없으면 실행하지 않음 (안전장치)
|
||||
if (!conditions) {
|
||||
throw new Error(
|
||||
"UPDATE action requires conditions to prevent accidental mass updates"
|
||||
);
|
||||
}
|
||||
|
||||
// 동적 테이블 UPDATE 실행
|
||||
const setClause = Object.keys(data)
|
||||
.map((key) => `${key} = ?`)
|
||||
.join(", ");
|
||||
const whereClause = this.buildWhereClause(conditions);
|
||||
|
||||
const sql = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause}`;
|
||||
|
||||
await prisma.$executeRawUnsafe(sql, ...Object.values(data));
|
||||
logger.info(`Updated data in ${tableName}:`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 액션 실행
|
||||
*/
|
||||
private static async executeDeleteAction(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
conditions?: ConditionNode
|
||||
): Promise<void> {
|
||||
// 조건이 없으면 실행하지 않음 (안전장치)
|
||||
if (!conditions) {
|
||||
throw new Error(
|
||||
"DELETE action requires conditions to prevent accidental mass deletions"
|
||||
);
|
||||
}
|
||||
|
||||
// 동적 테이블 DELETE 실행
|
||||
const whereClause = this.buildWhereClause(conditions);
|
||||
const sql = `DELETE FROM ${tableName} WHERE ${whereClause}`;
|
||||
|
||||
await prisma.$executeRawUnsafe(sql);
|
||||
logger.info(`Deleted data from ${tableName} with conditions`);
|
||||
}
|
||||
|
||||
/**
|
||||
* UPSERT 액션 실행
|
||||
*/
|
||||
private static async executeUpsertAction(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<void> {
|
||||
// PostgreSQL UPSERT 구현
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const conflictColumns = ["id", "company_code"]; // 기본 충돌 컬럼
|
||||
|
||||
const sql = `
|
||||
INSERT INTO ${tableName} (${columns.join(", ")})
|
||||
VALUES (${columns.map(() => "?").join(", ")})
|
||||
ON CONFLICT (${conflictColumns.join(", ")})
|
||||
DO UPDATE SET ${columns.map((col) => `${col} = EXCLUDED.${col}`).join(", ")}
|
||||
`;
|
||||
|
||||
await prisma.$executeRawUnsafe(sql, ...values);
|
||||
logger.info(`Upserted data into ${tableName}:`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE 절 구성
|
||||
*/
|
||||
private static buildWhereClause(conditions: ConditionNode): string {
|
||||
// 간단한 WHERE 절 구성 (실제 구현에서는 더 복잡한 로직 필요)
|
||||
if (
|
||||
conditions.type === "condition" &&
|
||||
conditions.field &&
|
||||
conditions.operator_type
|
||||
) {
|
||||
return `${conditions.field} ${conditions.operator_type} '${conditions.value}'`;
|
||||
}
|
||||
|
||||
return "1=1"; // 기본값
|
||||
}
|
||||
|
||||
/**
|
||||
* 변환 함수 적용
|
||||
*/
|
||||
private static applyTransformFunction(
|
||||
value: any,
|
||||
transformFunction: string
|
||||
): any {
|
||||
try {
|
||||
// 안전한 변환 함수들만 허용
|
||||
switch (transformFunction) {
|
||||
case "UPPER":
|
||||
return String(value).toUpperCase();
|
||||
case "LOWER":
|
||||
return String(value).toLowerCase();
|
||||
case "TRIM":
|
||||
return String(value).trim();
|
||||
case "NOW":
|
||||
return new Date();
|
||||
case "UUID":
|
||||
return require("crypto").randomUUID();
|
||||
default:
|
||||
logger.warn(`Unknown transform function: ${transformFunction}`);
|
||||
return value;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error applying transform function ${transformFunction}:`,
|
||||
error
|
||||
);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 연결 테스트 (개발/디버깅용)
|
||||
*/
|
||||
static async testConditionalConnection(
|
||||
diagramId: number,
|
||||
testData: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<{ conditionMet: boolean; result?: ExecutionResult }> {
|
||||
try {
|
||||
const diagram = await prisma.dataflow_diagrams.findUnique({
|
||||
where: { diagram_id: diagramId },
|
||||
});
|
||||
|
||||
if (!diagram) {
|
||||
throw new Error(`Diagram ${diagramId} not found`);
|
||||
}
|
||||
|
||||
const control = diagram.control as unknown as ConditionControl;
|
||||
|
||||
// 조건 평가만 수행
|
||||
const conditionMet = control.conditionTree
|
||||
? await this.evaluateCondition(control.conditionTree, testData)
|
||||
: true;
|
||||
|
||||
if (conditionMet) {
|
||||
// 실제 실행 (테스트 모드)
|
||||
const result = await this.executeDiagramTrigger(
|
||||
diagram,
|
||||
testData,
|
||||
companyCode
|
||||
);
|
||||
return { conditionMet: true, result };
|
||||
}
|
||||
|
||||
return { conditionMet: false };
|
||||
} catch (error) {
|
||||
logger.error("Error testing conditional connection:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EventTriggerService;
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
import {
|
||||
BatchLookupRequest,
|
||||
BatchLookupResponse,
|
||||
} from "../types/tableManagement";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* 참조 테이블 데이터 캐싱 서비스
|
||||
* 작은 참조 테이블의 성능 최적화를 위한 메모리 캐시
|
||||
*/
|
||||
export class ReferenceCacheService {
|
||||
private cache = new Map<string, Map<string, any>>();
|
||||
private cacheStats = new Map<
|
||||
string,
|
||||
{ hits: number; misses: number; lastUpdated: Date }
|
||||
>();
|
||||
private readonly MAX_CACHE_SIZE = 1000; // 테이블당 최대 캐시 크기
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5분 TTL
|
||||
|
||||
/**
|
||||
* 작은 참조 테이블 전체 캐싱
|
||||
*/
|
||||
async preloadReferenceTable(
|
||||
tableName: string,
|
||||
keyColumn: string,
|
||||
displayColumn: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info(`참조 테이블 캐싱 시작: ${tableName}`);
|
||||
|
||||
// 테이블 크기 확인
|
||||
const countResult = (await prisma.$queryRawUnsafe(`
|
||||
SELECT COUNT(*) as count FROM ${tableName}
|
||||
`)) as Array<{ count: bigint }>;
|
||||
|
||||
const count = Number(countResult[0]?.count || 0);
|
||||
|
||||
if (count > this.MAX_CACHE_SIZE) {
|
||||
logger.warn(`테이블이 너무 큼, 캐싱 건너뜀: ${tableName} (${count}건)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 조회 및 캐싱
|
||||
const data = (await prisma.$queryRawUnsafe(`
|
||||
SELECT ${keyColumn} as key, ${displayColumn} as value
|
||||
FROM ${tableName}
|
||||
WHERE ${keyColumn} IS NOT NULL
|
||||
AND ${displayColumn} IS NOT NULL
|
||||
`)) as Array<{ key: any; value: any }>;
|
||||
|
||||
const tableCache = new Map<string, any>();
|
||||
|
||||
for (const row of data) {
|
||||
tableCache.set(String(row.key), row.value);
|
||||
}
|
||||
|
||||
// 캐시 저장
|
||||
const cacheKey = `${tableName}.${keyColumn}.${displayColumn}`;
|
||||
this.cache.set(cacheKey, tableCache);
|
||||
|
||||
// 통계 초기화
|
||||
this.cacheStats.set(cacheKey, {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
lastUpdated: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`참조 테이블 캐싱 완료: ${tableName} (${data.length}건)`);
|
||||
} catch (error) {
|
||||
logger.error(`참조 테이블 캐싱 실패: ${tableName}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시에서 참조 값 조회
|
||||
*/
|
||||
getLookupValue(
|
||||
table: string,
|
||||
keyColumn: string,
|
||||
displayColumn: string,
|
||||
key: string
|
||||
): any | null {
|
||||
const cacheKey = `${table}.${keyColumn}.${displayColumn}`;
|
||||
const tableCache = this.cache.get(cacheKey);
|
||||
|
||||
if (!tableCache) {
|
||||
this.updateCacheStats(cacheKey, false);
|
||||
return null;
|
||||
}
|
||||
|
||||
// TTL 확인
|
||||
const stats = this.cacheStats.get(cacheKey);
|
||||
if (stats && Date.now() - stats.lastUpdated.getTime() > this.CACHE_TTL) {
|
||||
logger.debug(`캐시 TTL 만료: ${cacheKey}`);
|
||||
this.cache.delete(cacheKey);
|
||||
this.cacheStats.delete(cacheKey);
|
||||
this.updateCacheStats(cacheKey, false);
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = tableCache.get(String(key));
|
||||
this.updateCacheStats(cacheKey, value !== undefined);
|
||||
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 룩업 (성능 최적화)
|
||||
*/
|
||||
async batchLookup(
|
||||
requests: BatchLookupRequest[]
|
||||
): Promise<BatchLookupResponse[]> {
|
||||
const responses: BatchLookupResponse[] = [];
|
||||
const missingLookups = new Map<string, BatchLookupRequest[]>();
|
||||
|
||||
// 캐시에서 먼저 조회
|
||||
for (const request of requests) {
|
||||
const cacheKey = `${request.table}.${request.key}.${request.displayColumn}`;
|
||||
const value = this.getLookupValue(
|
||||
request.table,
|
||||
request.key,
|
||||
request.displayColumn,
|
||||
request.key
|
||||
);
|
||||
|
||||
if (value !== null) {
|
||||
responses.push({ key: request.key, value });
|
||||
} else {
|
||||
// 캐시 미스 - DB 조회 필요
|
||||
if (!missingLookups.has(request.table)) {
|
||||
missingLookups.set(request.table, []);
|
||||
}
|
||||
missingLookups.get(request.table)!.push(request);
|
||||
}
|
||||
}
|
||||
|
||||
// 캐시 미스된 항목들 DB에서 조회
|
||||
for (const [tableName, missingRequests] of missingLookups) {
|
||||
try {
|
||||
const keys = missingRequests.map((req) => req.key);
|
||||
const displayColumn = missingRequests[0].displayColumn; // 같은 테이블이므로 동일
|
||||
|
||||
const data = (await prisma.$queryRaw`
|
||||
SELECT key_column as key, ${displayColumn} as value
|
||||
FROM ${tableName}
|
||||
WHERE key_column = ANY(${keys})
|
||||
`) as Array<{ key: any; value: any }>;
|
||||
|
||||
// 결과를 응답에 추가
|
||||
for (const row of data) {
|
||||
responses.push({ key: String(row.key), value: row.value });
|
||||
}
|
||||
|
||||
// 없는 키들은 null로 응답
|
||||
const foundKeys = new Set(data.map((row) => String(row.key)));
|
||||
for (const req of missingRequests) {
|
||||
if (!foundKeys.has(req.key)) {
|
||||
responses.push({ key: req.key, value: null });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`배치 룩업 실패: ${tableName}`, error);
|
||||
|
||||
// 에러 발생 시 null로 응답
|
||||
for (const req of missingRequests) {
|
||||
responses.push({ key: req.key, value: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 통계 업데이트
|
||||
*/
|
||||
private updateCacheStats(cacheKey: string, isHit: boolean): void {
|
||||
let stats = this.cacheStats.get(cacheKey);
|
||||
if (!stats) {
|
||||
stats = { hits: 0, misses: 0, lastUpdated: new Date() };
|
||||
this.cacheStats.set(cacheKey, stats);
|
||||
}
|
||||
|
||||
if (isHit) {
|
||||
stats.hits++;
|
||||
} else {
|
||||
stats.misses++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 적중률 조회
|
||||
*/
|
||||
getCacheHitRate(
|
||||
table: string,
|
||||
keyColumn: string,
|
||||
displayColumn: string
|
||||
): number {
|
||||
const cacheKey = `${table}.${keyColumn}.${displayColumn}`;
|
||||
const stats = this.cacheStats.get(cacheKey);
|
||||
|
||||
if (!stats || stats.hits + stats.misses === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return stats.hits / (stats.hits + stats.misses);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 캐시 적중률 조회
|
||||
*/
|
||||
getOverallCacheHitRate(): number {
|
||||
let totalHits = 0;
|
||||
let totalRequests = 0;
|
||||
|
||||
for (const stats of this.cacheStats.values()) {
|
||||
totalHits += stats.hits;
|
||||
totalRequests += stats.hits + stats.misses;
|
||||
}
|
||||
|
||||
return totalRequests > 0 ? totalHits / totalRequests : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 무효화
|
||||
*/
|
||||
invalidateCache(
|
||||
table?: string,
|
||||
keyColumn?: string,
|
||||
displayColumn?: string
|
||||
): void {
|
||||
if (table && keyColumn && displayColumn) {
|
||||
const cacheKey = `${table}.${keyColumn}.${displayColumn}`;
|
||||
this.cache.delete(cacheKey);
|
||||
this.cacheStats.delete(cacheKey);
|
||||
logger.info(`캐시 무효화: ${cacheKey}`);
|
||||
} else {
|
||||
// 전체 캐시 무효화
|
||||
this.cache.clear();
|
||||
this.cacheStats.clear();
|
||||
logger.info("전체 캐시 무효화");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 상태 조회
|
||||
*/
|
||||
getCacheInfo(): Array<{
|
||||
cacheKey: string;
|
||||
size: number;
|
||||
hitRate: number;
|
||||
lastUpdated: Date;
|
||||
}> {
|
||||
const info: Array<{
|
||||
cacheKey: string;
|
||||
size: number;
|
||||
hitRate: number;
|
||||
lastUpdated: Date;
|
||||
}> = [];
|
||||
|
||||
for (const [cacheKey, tableCache] of this.cache) {
|
||||
const stats = this.cacheStats.get(cacheKey);
|
||||
const hitRate = stats
|
||||
? stats.hits + stats.misses > 0
|
||||
? stats.hits / (stats.hits + stats.misses)
|
||||
: 0
|
||||
: 0;
|
||||
|
||||
info.push({
|
||||
cacheKey,
|
||||
size: tableCache.size,
|
||||
hitRate,
|
||||
lastUpdated: stats?.lastUpdated || new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자주 사용되는 참조 테이블들 자동 캐싱
|
||||
*/
|
||||
async autoPreloadCommonTables(): Promise<void> {
|
||||
try {
|
||||
logger.info("공통 참조 테이블 자동 캐싱 시작");
|
||||
|
||||
// 일반적인 참조 테이블들
|
||||
const commonTables = [
|
||||
{ table: "user_info", key: "user_id", display: "user_name" },
|
||||
{ table: "comm_code", key: "code_id", display: "code_name" },
|
||||
{ table: "dept_info", key: "dept_code", display: "dept_name" },
|
||||
{ table: "companies", key: "company_code", display: "company_name" },
|
||||
];
|
||||
|
||||
for (const { table, key, display } of commonTables) {
|
||||
try {
|
||||
await this.preloadReferenceTable(table, key, display);
|
||||
} catch (error) {
|
||||
logger.warn(`공통 테이블 캐싱 실패 (무시함): ${table}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("공통 참조 테이블 자동 캐싱 완료");
|
||||
} catch (error) {
|
||||
logger.error("공통 참조 테이블 자동 캐싱 실패", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const referenceCacheService = new ReferenceCacheService();
|
||||
|
|
@ -1401,6 +1401,7 @@ export class ScreenManagementService {
|
|||
cl.code_category,
|
||||
cl.reference_table,
|
||||
cl.reference_column,
|
||||
cl.display_column,
|
||||
cl.is_visible,
|
||||
cl.display_order,
|
||||
cl.description
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import {
|
|||
ColumnSettings,
|
||||
TableLabels,
|
||||
ColumnLabels,
|
||||
EntityJoinResponse,
|
||||
EntityJoinConfig,
|
||||
} from "../types/tableManagement";
|
||||
import { entityJoinService } from "./entityJoinService";
|
||||
import { referenceCacheService } from "./referenceCacheService";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
|
|
@ -139,6 +143,7 @@ export class TableManagementService {
|
|||
cl.code_value as "codeValue",
|
||||
cl.reference_table as "referenceTable",
|
||||
cl.reference_column as "referenceColumn",
|
||||
cl.display_column as "displayColumn",
|
||||
cl.display_order as "displayOrder",
|
||||
cl.is_visible as "isVisible"
|
||||
FROM information_schema.columns c
|
||||
|
|
@ -285,6 +290,7 @@ export class TableManagementService {
|
|||
code_value: settings.codeValue,
|
||||
reference_table: settings.referenceTable,
|
||||
reference_column: settings.referenceColumn,
|
||||
display_column: settings.displayColumn, // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
display_order: settings.displayOrder || 0,
|
||||
is_visible:
|
||||
settings.isVisible !== undefined ? settings.isVisible : true,
|
||||
|
|
@ -300,6 +306,7 @@ export class TableManagementService {
|
|||
code_value: settings.codeValue,
|
||||
reference_table: settings.referenceTable,
|
||||
reference_column: settings.referenceColumn,
|
||||
display_column: settings.displayColumn, // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
display_order: settings.displayOrder || 0,
|
||||
is_visible:
|
||||
settings.isVisible !== undefined ? settings.isVisible : true,
|
||||
|
|
@ -1388,4 +1395,375 @@ export class TableManagementService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 🎯 Entity 조인 기능
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Entity 조인이 포함된 데이터 조회
|
||||
*/
|
||||
async getTableDataWithEntityJoins(
|
||||
tableName: string,
|
||||
options: {
|
||||
page: number;
|
||||
size: number;
|
||||
search?: Record<string, any>;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
enableEntityJoin?: boolean;
|
||||
}
|
||||
): Promise<EntityJoinResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
logger.info(`Entity 조인 데이터 조회 시작: ${tableName}`);
|
||||
|
||||
// Entity 조인이 비활성화된 경우 기본 데이터 조회
|
||||
if (!options.enableEntityJoin) {
|
||||
const basicResult = await this.getTableData(tableName, options);
|
||||
return {
|
||||
data: basicResult.data,
|
||||
total: basicResult.total,
|
||||
page: options.page,
|
||||
size: options.size,
|
||||
totalPages: Math.ceil(basicResult.total / options.size),
|
||||
};
|
||||
}
|
||||
|
||||
// Entity 조인 설정 감지
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
|
||||
if (joinConfigs.length === 0) {
|
||||
logger.info(`Entity 조인 설정이 없음: ${tableName}`);
|
||||
const basicResult = await this.getTableData(tableName, options);
|
||||
return {
|
||||
data: basicResult.data,
|
||||
total: basicResult.total,
|
||||
page: options.page,
|
||||
size: options.size,
|
||||
totalPages: Math.ceil(basicResult.total / options.size),
|
||||
};
|
||||
}
|
||||
|
||||
// 조인 전략 결정
|
||||
const strategy =
|
||||
await entityJoinService.determineJoinStrategy(joinConfigs);
|
||||
|
||||
// 테이블 컬럼 정보 조회
|
||||
const columns = await this.getTableColumns(tableName);
|
||||
const selectColumns = columns.data.map((col: any) => col.column_name);
|
||||
|
||||
// WHERE 절 구성
|
||||
const whereClause = this.buildWhereClause(options.search);
|
||||
|
||||
// ORDER BY 절 구성
|
||||
const orderBy = options.sortBy
|
||||
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: "";
|
||||
|
||||
// 페이징 계산
|
||||
const offset = (options.page - 1) * options.size;
|
||||
|
||||
if (strategy === "full_join") {
|
||||
// SQL JOIN 방식
|
||||
return await this.executeJoinQuery(
|
||||
tableName,
|
||||
joinConfigs,
|
||||
selectColumns,
|
||||
whereClause,
|
||||
orderBy,
|
||||
options.size,
|
||||
offset,
|
||||
startTime
|
||||
);
|
||||
} else {
|
||||
// 캐시 룩업 방식
|
||||
return await this.executeCachedLookup(
|
||||
tableName,
|
||||
joinConfigs,
|
||||
options,
|
||||
startTime
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Entity 조인 데이터 조회 실패: ${tableName}`, error);
|
||||
|
||||
// 에러 발생 시 기본 데이터 반환
|
||||
const basicResult = await this.getTableData(tableName, options);
|
||||
return {
|
||||
data: basicResult.data,
|
||||
total: basicResult.total,
|
||||
page: options.page,
|
||||
size: options.size,
|
||||
totalPages: Math.ceil(basicResult.total / options.size),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL JOIN 방식으로 데이터 조회
|
||||
*/
|
||||
private async executeJoinQuery(
|
||||
tableName: string,
|
||||
joinConfigs: EntityJoinConfig[],
|
||||
selectColumns: string[],
|
||||
whereClause: string,
|
||||
orderBy: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
startTime: number
|
||||
): Promise<EntityJoinResponse> {
|
||||
try {
|
||||
// 데이터 조회 쿼리
|
||||
const dataQuery = entityJoinService.buildJoinQuery(
|
||||
tableName,
|
||||
joinConfigs,
|
||||
selectColumns,
|
||||
whereClause,
|
||||
orderBy,
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
|
||||
// 카운트 쿼리
|
||||
const countQuery = entityJoinService.buildCountQuery(
|
||||
tableName,
|
||||
joinConfigs,
|
||||
whereClause
|
||||
);
|
||||
|
||||
// 병렬 실행
|
||||
const [dataResult, countResult] = await Promise.all([
|
||||
prisma.$queryRawUnsafe(dataQuery),
|
||||
prisma.$queryRawUnsafe(countQuery),
|
||||
]);
|
||||
|
||||
const data = Array.isArray(dataResult) ? dataResult : [];
|
||||
const total =
|
||||
Array.isArray(countResult) && countResult.length > 0
|
||||
? Number((countResult[0] as any).total)
|
||||
: 0;
|
||||
|
||||
const queryTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page: Math.floor(offset / limit) + 1,
|
||||
size: limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
entityJoinInfo: {
|
||||
joinConfigs,
|
||||
strategy: "full_join",
|
||||
performance: {
|
||||
queryTime,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("SQL JOIN 쿼리 실행 실패", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 룩업 방식으로 데이터 조회
|
||||
*/
|
||||
private async executeCachedLookup(
|
||||
tableName: string,
|
||||
joinConfigs: EntityJoinConfig[],
|
||||
options: {
|
||||
page: number;
|
||||
size: number;
|
||||
search?: Record<string, any>;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
},
|
||||
startTime: number
|
||||
): Promise<EntityJoinResponse> {
|
||||
try {
|
||||
// 캐시 데이터 미리 로드
|
||||
for (const config of joinConfigs) {
|
||||
await referenceCacheService.preloadReferenceTable(
|
||||
config.referenceTable,
|
||||
config.referenceColumn,
|
||||
config.displayColumn
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 데이터 조회
|
||||
const basicResult = await this.getTableData(tableName, options);
|
||||
|
||||
// Entity 값들을 캐시에서 룩업하여 변환
|
||||
const enhancedData = basicResult.data.map((row: any) => {
|
||||
const enhancedRow = { ...row };
|
||||
|
||||
for (const config of joinConfigs) {
|
||||
const sourceValue = row[config.sourceColumn];
|
||||
if (sourceValue) {
|
||||
const lookupValue = referenceCacheService.getLookupValue(
|
||||
config.referenceTable,
|
||||
config.referenceColumn,
|
||||
config.displayColumn,
|
||||
String(sourceValue)
|
||||
);
|
||||
|
||||
enhancedRow[config.aliasColumn] = lookupValue || sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
return enhancedRow;
|
||||
});
|
||||
|
||||
const queryTime = Date.now() - startTime;
|
||||
const cacheHitRate = referenceCacheService.getOverallCacheHitRate();
|
||||
|
||||
return {
|
||||
data: enhancedData,
|
||||
total: basicResult.total,
|
||||
page: options.page,
|
||||
size: options.size,
|
||||
totalPages: Math.ceil(basicResult.total / options.size),
|
||||
entityJoinInfo: {
|
||||
joinConfigs,
|
||||
strategy: "cache_lookup",
|
||||
performance: {
|
||||
queryTime,
|
||||
cacheHitRate,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("캐시 룩업 실행 실패", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE 절 구성
|
||||
*/
|
||||
private buildWhereClause(search?: Record<string, any>): string {
|
||||
if (!search || Object.keys(search).length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const conditions: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(search)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
if (typeof value === "string") {
|
||||
conditions.push(`main.${key} ILIKE '%${value}%'`);
|
||||
} else {
|
||||
conditions.push(`main.${key} = '${value}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? conditions.join(" AND ") : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 컬럼 정보 조회
|
||||
*/
|
||||
async getTableColumns(tableName: string): Promise<{
|
||||
data: Array<{ column_name: string; data_type: string }>;
|
||||
}> {
|
||||
try {
|
||||
const columns = await prisma.$queryRaw<
|
||||
Array<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
}>
|
||||
>`
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = ${tableName}
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
return { data: columns };
|
||||
} catch (error) {
|
||||
logger.error(`테이블 컬럼 조회 실패: ${tableName}`, error);
|
||||
throw new Error(
|
||||
`테이블 컬럼 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 테이블의 표시 컬럼 목록 조회
|
||||
*/
|
||||
async getReferenceTableColumns(tableName: string): Promise<
|
||||
Array<{
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
}>
|
||||
> {
|
||||
return await entityJoinService.getReferenceTableColumns(tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 라벨 정보 업데이트 (display_column 추가)
|
||||
*/
|
||||
async updateColumnLabel(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
updates: Partial<ColumnLabels>
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info(`컬럼 라벨 업데이트: ${tableName}.${columnName}`);
|
||||
|
||||
await prisma.column_labels.upsert({
|
||||
where: {
|
||||
table_name_column_name: {
|
||||
table_name: tableName,
|
||||
column_name: columnName,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
column_label: updates.columnLabel,
|
||||
web_type: updates.webType,
|
||||
detail_settings: updates.detailSettings,
|
||||
description: updates.description,
|
||||
display_order: updates.displayOrder,
|
||||
is_visible: updates.isVisible,
|
||||
code_category: updates.codeCategory,
|
||||
code_value: updates.codeValue,
|
||||
reference_table: updates.referenceTable,
|
||||
reference_column: updates.referenceColumn,
|
||||
// display_column: updates.displayColumn, // 🎯 새로 추가 (임시 주석)
|
||||
updated_date: new Date(),
|
||||
},
|
||||
create: {
|
||||
table_name: tableName,
|
||||
column_name: columnName,
|
||||
column_label: updates.columnLabel || columnName,
|
||||
web_type: updates.webType || "text",
|
||||
detail_settings: updates.detailSettings,
|
||||
description: updates.description,
|
||||
display_order: updates.displayOrder || 0,
|
||||
is_visible: updates.isVisible !== false,
|
||||
code_category: updates.codeCategory,
|
||||
code_value: updates.codeValue,
|
||||
reference_table: updates.referenceTable,
|
||||
reference_column: updates.referenceColumn,
|
||||
// display_column: updates.displayColumn, // 🎯 새로 추가 (임시 주석)
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`컬럼 라벨 업데이트 완료: ${tableName}.${columnName}`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`컬럼 라벨 업데이트 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`컬럼 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface ColumnTypeInfo {
|
|||
codeValue?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
displayOrder?: number;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
|
@ -39,6 +40,7 @@ export interface ColumnSettings {
|
|||
codeValue: string; // 코드 값
|
||||
referenceTable: string; // 참조 테이블
|
||||
referenceColumn: string; // 참조 컬럼
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
displayOrder?: number; // 표시 순서
|
||||
isVisible?: boolean; // 표시 여부
|
||||
}
|
||||
|
|
@ -65,10 +67,48 @@ export interface ColumnLabels {
|
|||
codeValue?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
createdDate?: Date;
|
||||
updatedDate?: Date;
|
||||
}
|
||||
|
||||
// 🎯 Entity 조인 관련 타입 정의
|
||||
export interface EntityJoinConfig {
|
||||
sourceTable: string; // companies
|
||||
sourceColumn: string; // writer
|
||||
referenceTable: string; // user_info
|
||||
referenceColumn: string; // user_id (조인 키)
|
||||
displayColumn: string; // user_name (표시할 값)
|
||||
aliasColumn: string; // writer_name (결과 컬럼명)
|
||||
}
|
||||
|
||||
export interface EntityJoinResponse {
|
||||
data: Record<string, any>[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
entityJoinInfo?: {
|
||||
joinConfigs: EntityJoinConfig[];
|
||||
strategy: "full_join" | "cache_lookup";
|
||||
performance: {
|
||||
queryTime: number;
|
||||
cacheHitRate?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface BatchLookupRequest {
|
||||
table: string;
|
||||
key: string;
|
||||
displayColumn: string;
|
||||
}
|
||||
|
||||
export interface BatchLookupResponse {
|
||||
key: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
// API 응답 타입
|
||||
export interface TableListResponse {
|
||||
success: boolean;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,294 @@
|
|||
# 🔗 조건부 연결 기능 구현 계획
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
현재 DataFlow 시스템에서 3가지 연결 종류를 지원하고 있으며, 이 중 **데이터 저장**과 **외부 호출** 기능에 실행 조건 로직을 추가해야 합니다.
|
||||
|
||||
### 현재 연결 종류
|
||||
|
||||
1. **단순 키값 연결** - 조건 설정 불필요 (기존 방식 유지)
|
||||
2. **데이터 저장** - 실행 조건 설정 필요 ✨
|
||||
3. **외부 호출** - 실행 조건 설정 필요 ✨
|
||||
|
||||
## 🎯 기능 요구사항
|
||||
|
||||
### 데이터 저장 기능
|
||||
|
||||
```
|
||||
"from 테이블의 컬럼이 특정 조건을 만족하면 to 테이블에 특정 액션을 취할 것"
|
||||
```
|
||||
|
||||
**예시 시나리오:**
|
||||
|
||||
- `work_order` 테이블의 `status = 'APPROVED'` 이고 `quantity > 0` 일 때
|
||||
- `material_requirement` 테이블에 자재 소요량 데이터 INSERT
|
||||
|
||||
### 외부 호출 기능
|
||||
|
||||
```
|
||||
"from테이블의 컬럼이 특정 조건을 만족하면 외부 api호출이나 이메일 발송 등의 동작을 취해야 함"
|
||||
```
|
||||
|
||||
**예시 시나리오:**
|
||||
|
||||
- `employee_master` 테이블의 `employment_status = 'APPROVED'` 일 때
|
||||
- 외부 이메일 API 호출하여 환영 메일 발송
|
||||
|
||||
## 🗄️ 데이터베이스 스키마 변경
|
||||
|
||||
### 1. 컬럼 추가
|
||||
|
||||
```sql
|
||||
-- 기존 데이터 삭제 후 dataflow_diagrams 테이블에 3개 컬럼 추가
|
||||
DELETE FROM dataflow_diagrams; -- 기존 데이터 전체 삭제
|
||||
|
||||
ALTER TABLE dataflow_diagrams
|
||||
ADD COLUMN control JSONB, -- 조건 설정
|
||||
ADD COLUMN category JSONB, -- 연결 종류 설정
|
||||
ADD COLUMN plan JSONB; -- 실행 계획 설정
|
||||
|
||||
-- 인덱스 추가
|
||||
CREATE INDEX idx_dataflow_control_trigger ON dataflow_diagrams USING GIN ((control->'triggerType'));
|
||||
CREATE INDEX idx_dataflow_category_type ON dataflow_diagrams USING GIN ((category->'type'));
|
||||
```
|
||||
|
||||
### 2. 데이터 구조 설계
|
||||
|
||||
#### `control` 컬럼 - 조건 설정
|
||||
|
||||
```json
|
||||
{
|
||||
"triggerType": "insert",
|
||||
"conditionTree": {
|
||||
"type": "group",
|
||||
"operator": "AND",
|
||||
"children": [
|
||||
{
|
||||
"type": "condition",
|
||||
"field": "status",
|
||||
"operator": "=",
|
||||
"value": "APPROVED"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `category` 컬럼 - 연결 종류
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "data-save" // "simple-key" | "data-save" | "external-call"
|
||||
}
|
||||
```
|
||||
|
||||
#### `plan` 컬럼 - 실행 계획
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceTable": "work_order",
|
||||
"targetActions": [
|
||||
{
|
||||
"id": "action_1",
|
||||
"actionType": "insert",
|
||||
"targetTable": "material_requirement",
|
||||
"enabled": true,
|
||||
"fieldMappings": [
|
||||
{
|
||||
"sourceField": "work_order_id",
|
||||
"targetField": "order_id"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 프론트엔드 UI 개선
|
||||
|
||||
### ConnectionSetupModal.tsx 재설계
|
||||
|
||||
#### 현재 구조 문제점
|
||||
|
||||
- 모든 연결 종류에 동일한 UI 적용
|
||||
- 조건 설정 기능 없음
|
||||
- 단순 키값 연결과 조건부 연결의 구분 없음
|
||||
|
||||
#### 개선 방안
|
||||
|
||||
##### 1. 연결 종류별 UI 분기
|
||||
|
||||
```tsx
|
||||
// 연결 종류 선택 후 조건부 렌더링
|
||||
{
|
||||
config.connectionType === "simple-key" && <SimpleKeyConnectionSettings />;
|
||||
}
|
||||
|
||||
{
|
||||
(config.connectionType === "data-save" ||
|
||||
config.connectionType === "external-call") && (
|
||||
<ConditionalConnectionSettings />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
##### 2. 조건 설정 섹션 추가
|
||||
|
||||
```tsx
|
||||
// control.html의 제어 조건 설정 섹션을 참조하여 구현
|
||||
<div className="control-conditions">
|
||||
<h4>📋 실행 조건 설정</h4>
|
||||
<ConditionBuilder
|
||||
conditions={conditions}
|
||||
onConditionsChange={setConditions}
|
||||
availableFields={fromTableColumns}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
##### 3. 액션 설정 섹션
|
||||
|
||||
```tsx
|
||||
<div className="control-actions">
|
||||
<h4>⚡ 실행 액션</h4>
|
||||
{config.connectionType === "data-save" && <DataSaveActionSettings />}
|
||||
{config.connectionType === "external-call" && <ExternalCallActionSettings />}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 새로운 컴포넌트 구조
|
||||
|
||||
```
|
||||
ConnectionSetupModal.tsx
|
||||
├── BasicConnectionInfo (공통)
|
||||
├── ConnectionTypeSelector (공통)
|
||||
├── SimpleKeyConnectionSettings (단순 키값 전용)
|
||||
└── ConditionalConnectionSettings (조건부 연결 전용)
|
||||
├── ConditionBuilder (조건 설정)
|
||||
├── DataSaveActionSettings (데이터 저장 액션)
|
||||
└── ExternalCallActionSettings (외부 호출 액션)
|
||||
```
|
||||
|
||||
## ⚙️ 백엔드 서비스 구현
|
||||
|
||||
### 1. EventTriggerService 생성
|
||||
|
||||
```typescript
|
||||
// backend-node/src/services/eventTriggerService.ts
|
||||
export class EventTriggerService {
|
||||
static async executeEventTriggers(
|
||||
triggerType: "insert" | "update" | "delete",
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<void>;
|
||||
|
||||
static async executeDataSaveAction(
|
||||
action: TargetAction,
|
||||
sourceData: Record<string, any>
|
||||
): Promise<void>;
|
||||
|
||||
static async executeExternalCallAction(
|
||||
action: ExternalCallAction,
|
||||
sourceData: Record<string, any>
|
||||
): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. DynamicFormService 연동
|
||||
|
||||
```typescript
|
||||
// 기존 saveFormData 메서드에 트리거 실행 추가
|
||||
async saveFormData(screenId: number, tableName: string, data: Record<string, any>) {
|
||||
// 기존 저장 로직
|
||||
const result = await this.saveToDatabase(data);
|
||||
|
||||
// 🔥 조건부 연결 실행
|
||||
await EventTriggerService.executeEventTriggers("insert", tableName, data, companyCode);
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. API 엔드포인트 추가
|
||||
|
||||
```typescript
|
||||
// backend-node/src/routes/dataflowRoutes.ts
|
||||
router.post("/diagrams/:id/test-conditions", async (req, res) => {
|
||||
// 조건 테스트 실행
|
||||
});
|
||||
|
||||
router.post("/diagrams/:id/execute-actions", async (req, res) => {
|
||||
// 액션 수동 실행
|
||||
});
|
||||
```
|
||||
|
||||
## 📝 구현 단계별 계획
|
||||
|
||||
### Phase 1: 데이터베이스 준비
|
||||
|
||||
- [ ] dataflow_diagrams 테이블 컬럼 추가 (기존 데이터 삭제 후 진행)
|
||||
- [ ] Prisma 스키마 업데이트
|
||||
|
||||
### Phase 2: 프론트엔드 UI 개선
|
||||
|
||||
- [ ] ConnectionSetupModal.tsx 재구조화
|
||||
- [ ] ConditionBuilder 컴포넌트 개발
|
||||
- [ ] 연결 종류별 설정 컴포넌트 분리
|
||||
- [ ] control.html 참조하여 조건 설정 UI 구현
|
||||
|
||||
### Phase 3: 백엔드 서비스 개발
|
||||
|
||||
- [ ] EventTriggerService 기본 구조 생성
|
||||
- [ ] 조건 평가 엔진 구현
|
||||
- [ ] 데이터 저장 액션 실행 로직
|
||||
- [ ] DynamicFormService 연동
|
||||
|
||||
### Phase 4: 외부 호출 기능
|
||||
|
||||
- [ ] 외부 API 호출 서비스
|
||||
- [ ] 이메일 발송 기능
|
||||
- [ ] 웹훅 지원
|
||||
- [ ] 오류 처리 및 재시도 로직
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. 성능 최적화
|
||||
|
||||
- 조건 평가 시 인덱스 활용
|
||||
- 대량 데이터 처리 시 배치 처리
|
||||
- 비동기 실행으로 메인 로직 블로킹 방지
|
||||
|
||||
### 2. 오류 처리
|
||||
|
||||
- 트랜잭션 롤백 지원
|
||||
- 부분 실패 시 복구 메커니즘
|
||||
|
||||
### 3. 보안
|
||||
|
||||
- SQL 인젝션 방지
|
||||
- 외부 API 호출 시 인증 처리
|
||||
- 민감 데이터 마스킹
|
||||
|
||||
### 4. 확장성
|
||||
|
||||
- 새로운 액션 타입 추가 용이성
|
||||
- 복잡한 조건문 지원
|
||||
- 다양한 외부 서비스 연동
|
||||
|
||||
## 📚 참고 자료
|
||||
|
||||
- [control.html](../control.html) - 제어 조건 설정 UI 참조
|
||||
- [ConnectionSetupModal.tsx](../frontend/components/dataflow/ConnectionSetupModal.tsx) - 현재 구현
|
||||
- [화면간*데이터*관계*설정*시스템\_설계.md](./화면간_데이터_관계_설정_시스템_설계.md) - 전체 시스템 설계
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
1. **데이터베이스 스키마 업데이트** 부터 시작
|
||||
2. **UI 재설계** - control.html 참조하여 조건 설정 UI 구현
|
||||
3. **백엔드 서비스** 단계별 구현
|
||||
4. **외부 호출 기능** 구현
|
||||
|
||||
---
|
||||
|
||||
_이 문서는 조건부 연결 기능 구현을 위한 전체적인 로드맵을 제시합니다. 각 단계별로 상세한 구현 계획을 수립하여 진행할 예정입니다._
|
||||
|
|
@ -43,6 +43,11 @@ export default function DataFlowEditPage() {
|
|||
router.push("/admin/dataflow");
|
||||
};
|
||||
|
||||
// 관계도 이름 업데이트 핸들러
|
||||
const handleDiagramNameUpdate = (newDiagramName: string) => {
|
||||
setDiagramName(newDiagramName);
|
||||
};
|
||||
|
||||
if (!diagramId || !diagramName) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
|
|
@ -74,7 +79,12 @@ export default function DataFlowEditPage() {
|
|||
|
||||
{/* 데이터플로우 디자이너 */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white">
|
||||
<DataFlowDesigner selectedDiagram={diagramName} diagramId={diagramId} onBackToList={handleBackToList} />
|
||||
<DataFlowDesigner
|
||||
selectedDiagram={diagramName}
|
||||
diagramId={diagramId}
|
||||
onBackToList={handleBackToList}
|
||||
onDiagramNameUpdate={handleDiagramNameUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useMultiLang } from "@/hooks/useMultiLang";
|
|||
import { TABLE_MANAGEMENT_KEYS, WEB_TYPE_OPTIONS_WITH_KEYS } from "@/constants/tableManagement";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
// 가상화 스크롤링을 위한 간단한 구현
|
||||
|
||||
interface TableInfo {
|
||||
|
|
@ -39,6 +40,7 @@ interface ColumnTypeInfo {
|
|||
codeValue?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
}
|
||||
|
||||
export default function TableManagementPage() {
|
||||
|
|
@ -61,6 +63,9 @@ export default function TableManagementPage() {
|
|||
const [tableLabel, setTableLabel] = useState("");
|
||||
const [tableDescription, setTableDescription] = useState("");
|
||||
|
||||
// 🎯 Entity 조인 관련 상태
|
||||
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
|
||||
|
||||
// 다국어 텍스트 로드
|
||||
useEffect(() => {
|
||||
const loadTexts = async () => {
|
||||
|
|
@ -97,6 +102,39 @@ export default function TableManagementPage() {
|
|||
return uiTexts[key] || fallback || key;
|
||||
};
|
||||
|
||||
// 🎯 참조 테이블 컬럼 정보 로드
|
||||
const loadReferenceTableColumns = useCallback(
|
||||
async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 로드된 경우이지만 빈 배열이 아닌 경우만 스킵
|
||||
const existingColumns = referenceTableColumns[tableName];
|
||||
if (existingColumns && existingColumns.length > 0) {
|
||||
console.log(`🎯 참조 테이블 컬럼 이미 로드됨: ${tableName}`, existingColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🎯 참조 테이블 컬럼 로드 시작: ${tableName}`);
|
||||
try {
|
||||
const result = await entityJoinApi.getReferenceTableColumns(tableName);
|
||||
console.log(`🎯 참조 테이블 컬럼 로드 성공: ${tableName}`, result.columns);
|
||||
setReferenceTableColumns((prev) => ({
|
||||
...prev,
|
||||
[tableName]: result.columns,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`참조 테이블 컬럼 로드 실패: ${tableName}`, error);
|
||||
setReferenceTableColumns((prev) => ({
|
||||
...prev,
|
||||
[tableName]: [],
|
||||
}));
|
||||
}
|
||||
},
|
||||
[], // 의존성 배열에서 referenceTableColumns 제거
|
||||
);
|
||||
|
||||
// 웹 타입 옵션 (다국어 적용)
|
||||
const webTypeOptions = WEB_TYPE_OPTIONS_WITH_KEYS.map((option) => ({
|
||||
value: option.value,
|
||||
|
|
@ -248,6 +286,7 @@ export default function TableManagementPage() {
|
|||
let codeValue = col.codeValue;
|
||||
let referenceTable = col.referenceTable;
|
||||
let referenceColumn = col.referenceColumn;
|
||||
let displayColumn = col.displayColumn;
|
||||
|
||||
if (settingType === "code") {
|
||||
if (value === "none") {
|
||||
|
|
@ -265,12 +304,27 @@ export default function TableManagementPage() {
|
|||
newDetailSettings = "";
|
||||
referenceTable = undefined;
|
||||
referenceColumn = undefined;
|
||||
displayColumn = undefined;
|
||||
} else {
|
||||
const tableOption = referenceTableOptions.find((option) => option.value === value);
|
||||
newDetailSettings = tableOption ? `참조테이블: ${tableOption.label}` : "";
|
||||
referenceTable = value;
|
||||
referenceColumn = "id"; // 기본값, 나중에 선택할 수 있도록 개선 가능
|
||||
// 🎯 참조 컬럼을 소스 컬럼명과 동일하게 설정 (일반적인 경우)
|
||||
// 예: user_info.dept_code -> dept_info.dept_code
|
||||
referenceColumn = col.columnName;
|
||||
// 참조 테이블의 컬럼 정보 로드
|
||||
loadReferenceTableColumns(value);
|
||||
}
|
||||
} else if (settingType === "entity_reference_column") {
|
||||
// 🎯 Entity 참조 컬럼 변경 (조인할 컬럼)
|
||||
referenceColumn = value;
|
||||
const tableOption = referenceTableOptions.find((option) => option.value === col.referenceTable);
|
||||
newDetailSettings = tableOption ? `참조테이블: ${tableOption.label}` : "";
|
||||
} else if (settingType === "entity_display_column") {
|
||||
// 🎯 Entity 표시 컬럼 변경
|
||||
displayColumn = value;
|
||||
const tableOption = referenceTableOptions.find((option) => option.value === col.referenceTable);
|
||||
newDetailSettings = tableOption ? `참조테이블: ${tableOption.label} (${value})` : "";
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -280,13 +334,14 @@ export default function TableManagementPage() {
|
|||
codeValue,
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
};
|
||||
}
|
||||
return col;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[commonCodeOptions, referenceTableOptions],
|
||||
[commonCodeOptions, referenceTableOptions, loadReferenceTableColumns],
|
||||
);
|
||||
|
||||
// 라벨 변경 핸들러 추가
|
||||
|
|
@ -333,6 +388,7 @@ export default function TableManagementPage() {
|
|||
codeValue: column.codeValue || "",
|
||||
referenceTable: column.referenceTable || "",
|
||||
referenceColumn: column.referenceColumn || "",
|
||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
};
|
||||
|
||||
console.log("저장할 컬럼 설정:", columnSetting);
|
||||
|
|
@ -388,6 +444,7 @@ export default function TableManagementPage() {
|
|||
codeValue: column.codeValue || "",
|
||||
referenceTable: column.referenceTable || "",
|
||||
referenceColumn: column.referenceColumn || "",
|
||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
}));
|
||||
|
||||
console.log("저장할 전체 설정:", { tableLabel, tableDescription, columnSettings });
|
||||
|
|
@ -439,6 +496,22 @@ export default function TableManagementPage() {
|
|||
loadCommonCodeCategories();
|
||||
}, []);
|
||||
|
||||
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
if (columns.length > 0) {
|
||||
const entityColumns = columns.filter(
|
||||
(col) => col.webType === "entity" && col.referenceTable && col.referenceTable !== "none",
|
||||
);
|
||||
|
||||
entityColumns.forEach((col) => {
|
||||
if (col.referenceTable) {
|
||||
console.log(`🎯 기존 Entity 컬럼 발견, 참조 테이블 컬럼 로드: ${col.columnName} -> ${col.referenceTable}`);
|
||||
loadReferenceTableColumns(col.referenceTable);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [columns, loadReferenceTableColumns]);
|
||||
|
||||
// 더 많은 데이터 로드
|
||||
const loadMoreColumns = useCallback(() => {
|
||||
if (selectedTable && columns.length < totalColumns && !columnsLoading) {
|
||||
|
|
@ -448,7 +521,7 @@ export default function TableManagementPage() {
|
|||
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
<div className="mx-auto max-w-none space-y-6 p-6">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -465,7 +538,7 @@ export default function TableManagementPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
{/* 테이블 목록 */}
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
|
|
@ -537,7 +610,7 @@ export default function TableManagementPage() {
|
|||
</Card>
|
||||
|
||||
{/* 컬럼 타입 관리 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<Card className="lg:col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
|
|
@ -595,10 +668,12 @@ export default function TableManagementPage() {
|
|||
<div className="flex items-center border-b border-gray-200 pb-2 text-sm font-medium text-gray-700">
|
||||
<div className="w-40 px-4">컬럼명</div>
|
||||
<div className="w-48 px-4">라벨</div>
|
||||
<div className="w-32 px-4">DB 타입</div>
|
||||
<div className="w-40 px-4">DB 타입</div>
|
||||
<div className="w-48 px-4">웹 타입</div>
|
||||
<div className="w-48 px-4">상세 설정</div>
|
||||
<div className="flex-1 px-4">설명</div>
|
||||
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
|
||||
상세 설정
|
||||
</div>
|
||||
<div className="w-80 px-4">설명</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 리스트 */}
|
||||
|
|
@ -615,7 +690,7 @@ export default function TableManagementPage() {
|
|||
{columns.map((column, index) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="flex items-center border-b border-gray-200 py-3 hover:bg-gray-50"
|
||||
className="flex items-center border-b border-gray-200 py-2 hover:bg-gray-50"
|
||||
>
|
||||
<div className="w-40 px-4">
|
||||
<div className="font-mono text-sm text-gray-700">{column.columnName}</div>
|
||||
|
|
@ -625,10 +700,10 @@ export default function TableManagementPage() {
|
|||
value={column.displayName || ""}
|
||||
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
||||
placeholder={column.columnName}
|
||||
className="h-8 text-sm"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32 px-4">
|
||||
<div className="w-40 px-4">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{column.dbType}
|
||||
</Badge>
|
||||
|
|
@ -638,7 +713,7 @@ export default function TableManagementPage() {
|
|||
value={column.webType}
|
||||
onValueChange={(value) => handleWebTypeChange(column.columnName, value)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -650,14 +725,14 @@ export default function TableManagementPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-48 px-4">
|
||||
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
|
||||
{/* 웹 타입이 'code'인 경우 공통코드 선택 */}
|
||||
{column.webType === "code" && (
|
||||
<Select
|
||||
value={column.codeCategory || "none"}
|
||||
onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="공통코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -671,35 +746,150 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.webType === "entity" && (
|
||||
<Select
|
||||
value={column.referenceTable || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(column.columnName, "entity", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="참조 테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceTableOptions.map((option, index) => (
|
||||
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-1">
|
||||
{/* 🎯 Entity 타입 설정 - 가로 배치 */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-blue-800">Entity 설정</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* 참조 테이블 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-600">참조 테이블</label>
|
||||
<Select
|
||||
value={column.referenceTable || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(column.columnName, "entity", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 bg-white text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceTableOptions.map((option, index) => (
|
||||
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-xs text-gray-500">{option.value}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조인 컬럼 */}
|
||||
{column.referenceTable && column.referenceTable !== "none" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-600">조인 컬럼</label>
|
||||
<Select
|
||||
value={column.referenceColumn || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_reference_column",
|
||||
value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 bg-white text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
||||
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
||||
<SelectItem
|
||||
key={`ref-col-${refCol.columnName}-${index}`}
|
||||
value={refCol.columnName}
|
||||
>
|
||||
<span className="font-medium">{refCol.columnName}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{(!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0) && (
|
||||
<SelectItem value="loading" disabled>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border border-blue-500 border-t-transparent"></div>
|
||||
로딩중
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 표시 컬럼 */}
|
||||
{column.referenceTable && column.referenceTable !== "none" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-600">표시 컬럼</label>
|
||||
<Select
|
||||
value={column.displayColumn || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_display_column",
|
||||
value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 bg-white text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
||||
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
||||
<SelectItem
|
||||
key={`display-col-${refCol.columnName}-${index}`}
|
||||
value={refCol.columnName}
|
||||
>
|
||||
<span className="font-medium">{refCol.columnName}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{(!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0) && (
|
||||
<SelectItem value="loading" disabled>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border border-blue-500 border-t-transparent"></div>
|
||||
로딩중
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설정 완료 표시 - 간소화 */}
|
||||
{column.referenceTable &&
|
||||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" &&
|
||||
column.displayColumn &&
|
||||
column.displayColumn !== "none" && (
|
||||
<div className="mt-1 flex items-center gap-1 rounded bg-green-100 px-2 py-1 text-xs text-green-700">
|
||||
<span className="text-green-600">✓</span>
|
||||
<span className="truncate">
|
||||
{column.columnName} → {column.referenceTable}.{column.displayColumn}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 다른 웹 타입인 경우 빈 공간 */}
|
||||
{column.webType !== "code" && column.webType !== "entity" && (
|
||||
<div className="flex h-8 items-center text-xs text-gray-400">-</div>
|
||||
<div className="flex h-7 items-center text-xs text-gray-400">-</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 px-4">
|
||||
<div className="w-80 px-4">
|
||||
<Input
|
||||
value={column.description || ""}
|
||||
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
||||
placeholder="설명"
|
||||
className="h-8 text-sm"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -2,6 +2,63 @@ import { apiClient, ApiResponse } from "./client";
|
|||
|
||||
// 테이블 간 데이터 관계 설정 관련 타입 정의
|
||||
|
||||
// 조건부 연결 관련 타입들
|
||||
export interface ConditionControl {
|
||||
triggerType: "insert" | "update" | "delete" | "insert_update";
|
||||
conditionTree: ConditionNode | ConditionNode[] | null;
|
||||
}
|
||||
|
||||
export interface ConditionNode {
|
||||
id: string; // 고유 ID
|
||||
type: "condition" | "group-start" | "group-end";
|
||||
field?: string;
|
||||
operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value?: string | number | boolean;
|
||||
dataType?: string;
|
||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
|
||||
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)
|
||||
}
|
||||
|
||||
export interface ConnectionCategory {
|
||||
type: "simple-key" | "data-save" | "external-call" | "conditional-link";
|
||||
rollbackOnError?: boolean;
|
||||
enableLogging?: boolean;
|
||||
maxRetryCount?: number;
|
||||
}
|
||||
|
||||
export interface ExecutionPlan {
|
||||
sourceTable: string;
|
||||
targetActions: TargetAction[];
|
||||
}
|
||||
|
||||
export interface TargetAction {
|
||||
id: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
targetTable: string;
|
||||
enabled: boolean;
|
||||
fieldMappings: FieldMapping[];
|
||||
conditions?: Array<{
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: string;
|
||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||
}>;
|
||||
splitConfig?: {
|
||||
sourceField: string;
|
||||
delimiter: string;
|
||||
targetField: string;
|
||||
};
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FieldMapping {
|
||||
sourceField: string;
|
||||
targetField: string;
|
||||
transformFunction?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
|
|
@ -45,8 +102,7 @@ export interface TableRelationship {
|
|||
from_column_name: string;
|
||||
to_table_name: string;
|
||||
to_column_name: string;
|
||||
relationship_type: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
|
||||
connection_type: "simple-key" | "data-save" | "external-call";
|
||||
connection_type: "simple-key" | "data-save" | "external-call" | "conditional-link";
|
||||
settings?: Record<string, unknown>;
|
||||
company_code: string;
|
||||
is_active?: string;
|
||||
|
|
@ -98,6 +154,11 @@ export interface DataFlowDiagram {
|
|||
relationshipCount: number;
|
||||
tables: string[];
|
||||
companyCode: string; // 회사 코드 추가
|
||||
|
||||
// 조건부 연결 관련 필드
|
||||
control?: ConditionControl; // 조건 설정
|
||||
category?: ConnectionCategory; // 연결 종류
|
||||
plan?: ExecutionPlan; // 실행 계획
|
||||
createdAt: Date;
|
||||
createdBy: string;
|
||||
updatedAt: Date;
|
||||
|
|
@ -134,6 +195,7 @@ export interface JsonDataFlowDiagram {
|
|||
tables: string[];
|
||||
};
|
||||
node_positions?: NodePositions;
|
||||
category?: string; // 연결 종류 ("simple-key", "data-save", "external-call")
|
||||
company_code: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
|
|
@ -148,9 +210,8 @@ export interface JsonRelationship {
|
|||
toTable: string;
|
||||
fromColumns: string[];
|
||||
toColumns: string[];
|
||||
relationshipType: string;
|
||||
connectionType: string;
|
||||
settings?: any;
|
||||
connectionType: "simple-key" | "data-save" | "external-call";
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateDiagramRequest {
|
||||
|
|
@ -160,6 +221,48 @@ export interface CreateDiagramRequest {
|
|||
tables: string[];
|
||||
};
|
||||
node_positions?: NodePositions;
|
||||
// 🔥 수정: 각 관계별 정보를 배열로 저장
|
||||
category?: Array<{
|
||||
id: string;
|
||||
category: "simple-key" | "data-save" | "external-call";
|
||||
}>;
|
||||
// 🔥 전체 실행 조건 - relationships의 id와 동일한 id 사용
|
||||
control?: Array<{
|
||||
id: string; // relationships의 id와 동일
|
||||
triggerType: "insert" | "update" | "delete";
|
||||
conditions?: Array<{
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: unknown;
|
||||
logicalOperator?: "AND" | "OR";
|
||||
}>;
|
||||
}>;
|
||||
// 🔥 저장 액션 - relationships의 id와 동일한 id 사용
|
||||
plan?: Array<{
|
||||
id: string; // relationships의 id와 동일
|
||||
sourceTable: string;
|
||||
actions: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
fieldMappings: Array<{
|
||||
sourceTable?: string;
|
||||
sourceField: string;
|
||||
targetTable?: string;
|
||||
targetField: string;
|
||||
defaultValue?: string;
|
||||
transformFunction?: string;
|
||||
}>;
|
||||
conditions?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
field: string;
|
||||
operator_type: string;
|
||||
value: unknown;
|
||||
logicalOperator?: string;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface JsonDataFlowDiagramsResponse {
|
||||
|
|
@ -241,7 +344,7 @@ export class DataFlowAPI {
|
|||
* 테이블 관계 생성
|
||||
*/
|
||||
static async createRelationship(
|
||||
relationship: any, // 백엔드 API 형식 (camelCase)
|
||||
relationship: Omit<TableRelationship, "relationship_id">, // 백엔드 API 형식 (camelCase)
|
||||
): Promise<TableRelationship> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<TableRelationship>>(
|
||||
|
|
@ -526,8 +629,7 @@ export class DataFlowAPI {
|
|||
to_table_name: rel.toTable,
|
||||
from_column_name: rel.fromColumns.join(","),
|
||||
to_column_name: rel.toColumns.join(","),
|
||||
relationship_type: rel.relationshipType as "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many",
|
||||
connection_type: rel.connectionType as "simple-key" | "data-save" | "external-call",
|
||||
connection_type: (jsonDiagram.category as "simple-key" | "data-save" | "external-call") || "simple-key", // 관계도의 category 사용
|
||||
company_code: companyCode, // 실제 사용자 회사 코드 사용
|
||||
settings: rel.settings || {},
|
||||
created_at: jsonDiagram.created_at,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import { apiClient } from "./client";
|
||||
|
||||
export interface EntityJoinConfig {
|
||||
sourceTable: string;
|
||||
sourceColumn: string;
|
||||
referenceTable: string;
|
||||
referenceColumn: string;
|
||||
displayColumn: string;
|
||||
aliasColumn: string;
|
||||
}
|
||||
|
||||
export interface EntityJoinResponse {
|
||||
data: Record<string, any>[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
entityJoinInfo?: {
|
||||
joinConfigs: EntityJoinConfig[];
|
||||
strategy: "full_join" | "cache_lookup";
|
||||
performance: {
|
||||
queryTime: number;
|
||||
cacheHitRate?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReferenceTableColumn {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
}
|
||||
|
||||
export interface CacheStatus {
|
||||
overallHitRate: number;
|
||||
caches: Array<{
|
||||
cacheKey: string;
|
||||
size: number;
|
||||
hitRate: number;
|
||||
lastUpdated: string;
|
||||
}>;
|
||||
summary: {
|
||||
totalCaches: number;
|
||||
totalSize: number;
|
||||
averageHitRate: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity 조인 기능 API
|
||||
*/
|
||||
export const entityJoinApi = {
|
||||
/**
|
||||
* Entity 조인이 포함된 테이블 데이터 조회
|
||||
*/
|
||||
getTableDataWithJoins: async (
|
||||
tableName: string,
|
||||
params: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: Record<string, any>;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
enableEntityJoin?: boolean;
|
||||
} = {},
|
||||
): Promise<EntityJoinResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params.page) searchParams.append("page", params.page.toString());
|
||||
if (params.size) searchParams.append("size", params.size.toString());
|
||||
if (params.sortBy) searchParams.append("sortBy", params.sortBy);
|
||||
if (params.sortOrder) searchParams.append("sortOrder", params.sortOrder);
|
||||
if (params.enableEntityJoin !== undefined) {
|
||||
searchParams.append("enableEntityJoin", params.enableEntityJoin.toString());
|
||||
}
|
||||
|
||||
// 검색 조건 추가
|
||||
if (params.search) {
|
||||
Object.entries(params.search).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, {
|
||||
params: {
|
||||
...params,
|
||||
search: params.search ? JSON.stringify(params.search) : undefined,
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 테이블의 Entity 조인 설정 조회
|
||||
*/
|
||||
getEntityJoinConfigs: async (
|
||||
tableName: string,
|
||||
): Promise<{
|
||||
tableName: string;
|
||||
joinConfigs: EntityJoinConfig[];
|
||||
count: number;
|
||||
}> => {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/entity-joins`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 참조 테이블의 표시 가능한 컬럼 목록 조회
|
||||
*/
|
||||
getReferenceTableColumns: async (
|
||||
tableName: string,
|
||||
): Promise<{
|
||||
tableName: string;
|
||||
columns: ReferenceTableColumn[];
|
||||
count: number;
|
||||
}> => {
|
||||
const response = await apiClient.get(`/table-management/reference-tables/${tableName}/columns`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 컬럼 Entity 설정 업데이트
|
||||
*/
|
||||
updateEntitySettings: async (
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
settings: {
|
||||
webType: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
columnLabel?: string;
|
||||
description?: string;
|
||||
},
|
||||
): Promise<void> => {
|
||||
await apiClient.put(`/table-management/tables/${tableName}/columns/${columnName}/entity-settings`, settings);
|
||||
},
|
||||
|
||||
/**
|
||||
* 캐시 상태 조회
|
||||
*/
|
||||
getCacheStatus: async (): Promise<CacheStatus> => {
|
||||
const response = await apiClient.get(`/table-management/cache/status`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 캐시 무효화
|
||||
*/
|
||||
invalidateCache: async (params?: { table?: string; keyColumn?: string; displayColumn?: string }): Promise<void> => {
|
||||
await apiClient.delete(`/table-management/cache`, { params });
|
||||
},
|
||||
|
||||
/**
|
||||
* 공통 참조 테이블 자동 캐싱
|
||||
*/
|
||||
preloadCommonCaches: async (): Promise<{
|
||||
preloadedCaches: number;
|
||||
caches: Array<{
|
||||
cacheKey: string;
|
||||
size: number;
|
||||
hitRate: number;
|
||||
lastUpdated: string;
|
||||
}>;
|
||||
}> => {
|
||||
const response = await apiClient.post(`/table-management/cache/preload`);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
|
@ -274,6 +274,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
onRefresh: _onRefresh, // React DOM 속성이 아니므로 필터링
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import React, { useState, useEffect, useMemo } from "react";
|
|||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { TableListConfig, ColumnConfig, TableDataResponse } from "./types";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
|
@ -97,6 +98,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [tableLabel, setTableLabel] = useState<string>("");
|
||||
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20); // 로컬 페이지 크기 상태
|
||||
const [selectedSearchColumn, setSelectedSearchColumn] = useState<string>(""); // 선택된 검색 컬럼
|
||||
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); // 🎯 표시할 컬럼 (Entity 조인 적용됨)
|
||||
|
||||
// 높이 계산 함수
|
||||
const calculateOptimalHeight = () => {
|
||||
|
|
@ -178,11 +180,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// tableTypeApi.getTableData 사용 (POST /api/table-management/tables/:tableName/data)
|
||||
const result = await tableTypeApi.getTableData(tableConfig.selectedTable, {
|
||||
// 🎯 Entity 조인 API 사용 - Entity 조인이 포함된 데이터 조회
|
||||
console.log("🔗 Entity 조인 데이터 조회 시작:", tableConfig.selectedTable);
|
||||
|
||||
const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
page: currentPage,
|
||||
size: localPageSize,
|
||||
search: searchTerm
|
||||
search: searchTerm?.trim()
|
||||
? (() => {
|
||||
// 스마트한 단일 컬럼 선택 로직 (서버가 OR 검색을 지원하지 않음)
|
||||
let searchColumn = selectedSearchColumn || sortColumn; // 사용자 선택 컬럼 최우선, 그 다음 정렬된 컬럼
|
||||
|
|
@ -231,9 +235,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
return { [searchColumn]: searchTerm };
|
||||
})()
|
||||
: {},
|
||||
: undefined,
|
||||
sortBy: sortColumn || undefined,
|
||||
sortOrder: sortDirection,
|
||||
enableEntityJoin: true, // 🎯 Entity 조인 활성화
|
||||
});
|
||||
|
||||
if (result) {
|
||||
|
|
@ -241,8 +246,43 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setTotalPages(result.totalPages || 1);
|
||||
setTotalItems(result.total || 0);
|
||||
|
||||
// 🎯 Entity 조인 정보 로깅
|
||||
if (result.entityJoinInfo) {
|
||||
console.log("🔗 Entity 조인 적용됨:", {
|
||||
strategy: result.entityJoinInfo.strategy,
|
||||
joinConfigs: result.entityJoinInfo.joinConfigs,
|
||||
performance: result.entityJoinInfo.performance,
|
||||
});
|
||||
} else {
|
||||
console.log("🔗 Entity 조인 없음");
|
||||
}
|
||||
|
||||
// 🎯 Entity 조인된 컬럼 처리
|
||||
let processedColumns = [...(tableConfig.columns || [])];
|
||||
|
||||
// 초기 컬럼이 있으면 먼저 설정
|
||||
if (processedColumns.length > 0) {
|
||||
setDisplayColumns(processedColumns);
|
||||
}
|
||||
if (result.entityJoinInfo?.joinConfigs) {
|
||||
result.entityJoinInfo.joinConfigs.forEach((joinConfig) => {
|
||||
// 원본 컬럼을 조인된 컬럼으로 교체
|
||||
const originalColumnIndex = processedColumns.findIndex((col) => col.columnName === joinConfig.sourceColumn);
|
||||
|
||||
if (originalColumnIndex !== -1) {
|
||||
console.log(`🔄 컬럼 교체: ${joinConfig.sourceColumn} → ${joinConfig.aliasColumn}`);
|
||||
processedColumns[originalColumnIndex] = {
|
||||
...processedColumns[originalColumnIndex],
|
||||
columnName: joinConfig.aliasColumn, // dept_code → dept_code_name
|
||||
displayName: processedColumns[originalColumnIndex].displayName || joinConfig.aliasColumn,
|
||||
// isEntityJoined: true, // 🎯 임시 주석 처리 (타입 에러 해결 후 복원)
|
||||
} as ColumnConfig;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 컬럼 정보가 없으면 첫 번째 데이터 행에서 추출
|
||||
if ((!tableConfig.columns || tableConfig.columns.length === 0) && result.data.length > 0) {
|
||||
if ((!processedColumns || processedColumns.length === 0) && result.data.length > 0) {
|
||||
const autoColumns: ColumnConfig[] = Object.keys(result.data[0]).map((key, index) => ({
|
||||
columnName: key,
|
||||
displayName: columnLabels[key] || key, // 라벨명 우선 사용
|
||||
|
|
@ -264,7 +304,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
},
|
||||
});
|
||||
}
|
||||
processedColumns = autoColumns;
|
||||
}
|
||||
|
||||
// 🎯 표시할 컬럼 상태 업데이트
|
||||
setDisplayColumns(processedColumns);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("테이블 데이터 로딩 오류:", err);
|
||||
|
|
@ -336,11 +380,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
}, [tableConfig.selectedTable, localPageSize, currentPage, searchTerm, sortColumn, sortDirection, columnLabels]);
|
||||
|
||||
// 표시할 컬럼 계산
|
||||
// 표시할 컬럼 계산 (Entity 조인 적용됨)
|
||||
const visibleColumns = useMemo(() => {
|
||||
if (!tableConfig.columns) return [];
|
||||
return tableConfig.columns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
|
||||
}, [tableConfig.columns]);
|
||||
if (!displayColumns || displayColumns.length === 0) {
|
||||
// displayColumns가 아직 설정되지 않은 경우 기본 컬럼 사용
|
||||
if (!tableConfig.columns) return [];
|
||||
return tableConfig.columns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
return displayColumns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
|
||||
}, [displayColumns, tableConfig.columns]);
|
||||
|
||||
// 값 포맷팅
|
||||
const formatCellValue = (value: any, format?: string) => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface ColumnConfig {
|
|||
format?: "text" | "number" | "date" | "currency" | "boolean";
|
||||
order: number;
|
||||
dataType?: string; // 컬럼 데이터 타입 (검색 컬럼 선택에 사용)
|
||||
isEntityJoined?: boolean; // 🎯 Entity 조인된 컬럼인지 여부
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -660,6 +660,7 @@ export interface ColumnInfo {
|
|||
codeCategory?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
isVisible?: boolean;
|
||||
displayOrder?: number;
|
||||
description?: string;
|
||||
|
|
@ -675,6 +676,7 @@ export interface ColumnWebTypeSetting {
|
|||
codeCategory?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
isVisible?: boolean;
|
||||
displayOrder?: number;
|
||||
description?: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue