diff --git a/PHASE_FLOW_MANAGEMENT_SYSTEM.md b/PHASE_FLOW_MANAGEMENT_SYSTEM.md new file mode 100644 index 00000000..22b0ada5 --- /dev/null +++ b/PHASE_FLOW_MANAGEMENT_SYSTEM.md @@ -0,0 +1,1138 @@ +# 플로우 관리 시스템 구현 계획서 + +## 1. 개요 + +### 1.1 목적 + +제품의 수명주기나 업무 프로세스를 시각적인 플로우로 정의하고 관리하는 시스템을 구축합니다. +각 플로우 단계는 데이터베이스 테이블의 레코드 조건으로 정의되며, 데이터를 플로우 단계 간 이동시키고 이력을 관리할 수 있습니다. + +### 1.2 주요 기능 + +- 플로우 정의 및 시각적 편집 +- 플로우 단계별 조건 설정 +- 화면관리 시스템과 연동하여 플로우 위젯 배치 +- 플로우 단계별 데이터 카운트 및 리스트 조회 +- 데이터의 플로우 단계 이동 +- 상태 변경 이력 관리 (오딧 로그) + +### 1.3 사용 예시 + +**DTG 제품 수명주기 관리** + +- 플로우 이름: "DTG 제품 라이프사이클" +- 연결 테이블: `product_dtg` +- 플로우 단계: + 1. 구매 (조건: `status = '구매완료' AND install_date IS NULL`) + 2. 설치 (조건: `status = '설치완료' AND disposal_date IS NULL`) + 3. 폐기 (조건: `status = '폐기완료'`) + +--- + +## 2. 데이터베이스 스키마 + +### 2.1 flow_definition (플로우 정의) + +```sql +CREATE TABLE flow_definition ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, -- 플로우 이름 + description TEXT, -- 플로우 설명 + table_name VARCHAR(200) NOT NULL, -- 연결된 테이블명 + is_active BOOLEAN DEFAULT true, -- 활성화 여부 + created_by VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_flow_definition_table ON flow_definition(table_name); +``` + +### 2.2 flow_step (플로우 단계) + +```sql +CREATE TABLE flow_step ( + id SERIAL PRIMARY KEY, + flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE, + step_name VARCHAR(200) NOT NULL, -- 단계 이름 + step_order INTEGER NOT NULL, -- 단계 순서 + condition_json JSONB, -- 조건 설정 (JSON 형태) + color VARCHAR(50) DEFAULT '#3B82F6', -- 단계 표시 색상 + position_x INTEGER DEFAULT 0, -- 캔버스 상의 X 좌표 + position_y INTEGER DEFAULT 0, -- 캔버스 상의 Y 좌표 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_flow_step_definition ON flow_step(flow_definition_id); +``` + +**condition_json 예시:** + +```json +{ + "type": "AND", + "conditions": [ + { + "column": "status", + "operator": "equals", + "value": "구매완료" + }, + { + "column": "install_date", + "operator": "is_null", + "value": null + } + ] +} +``` + +### 2.3 flow_step_connection (플로우 단계 연결) + +```sql +CREATE TABLE flow_step_connection ( + id SERIAL PRIMARY KEY, + flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE, + from_step_id INTEGER NOT NULL REFERENCES flow_step(id) ON DELETE CASCADE, + to_step_id INTEGER NOT NULL REFERENCES flow_step(id) ON DELETE CASCADE, + label VARCHAR(200), -- 연결선 라벨 (선택사항) + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_flow_connection_definition ON flow_step_connection(flow_definition_id); +``` + +### 2.4 flow_data_status (데이터의 현재 플로우 상태) + +```sql +CREATE TABLE flow_data_status ( + id SERIAL PRIMARY KEY, + flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE, + table_name VARCHAR(200) NOT NULL, -- 원본 테이블명 + record_id VARCHAR(100) NOT NULL, -- 원본 테이블의 레코드 ID + current_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL, + updated_by VARCHAR(100), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(flow_definition_id, table_name, record_id) +); + +CREATE INDEX idx_flow_data_status_record ON flow_data_status(table_name, record_id); +CREATE INDEX idx_flow_data_status_step ON flow_data_status(current_step_id); +``` + +### 2.5 flow_audit_log (플로우 상태 변경 이력) + +```sql +CREATE TABLE flow_audit_log ( + id SERIAL PRIMARY KEY, + flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE, + table_name VARCHAR(200) NOT NULL, + record_id VARCHAR(100) NOT NULL, + from_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL, + to_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL, + changed_by VARCHAR(100), + changed_at TIMESTAMP DEFAULT NOW(), + note TEXT -- 변경 사유 +); + +CREATE INDEX idx_flow_audit_record ON flow_audit_log(table_name, record_id, changed_at DESC); +CREATE INDEX idx_flow_audit_definition ON flow_audit_log(flow_definition_id); +``` + +--- + +## 3. 백엔드 API 설계 + +### 3.1 플로우 정의 관리 API + +#### 3.1.1 플로우 생성 + +``` +POST /api/flow/definitions +Body: { + name: string; + description?: string; + tableName: string; +} +Response: { success: boolean; data: FlowDefinition } +``` + +#### 3.1.2 플로우 목록 조회 + +``` +GET /api/flow/definitions +Query: { tableName?: string; isActive?: boolean } +Response: { success: boolean; data: FlowDefinition[] } +``` + +#### 3.1.3 플로우 상세 조회 + +``` +GET /api/flow/definitions/:id +Response: { + success: boolean; + data: { + definition: FlowDefinition; + steps: FlowStep[]; + connections: FlowStepConnection[]; + } +} +``` + +#### 3.1.4 플로우 수정 + +``` +PUT /api/flow/definitions/:id +Body: { name?: string; description?: string; isActive?: boolean } +Response: { success: boolean; data: FlowDefinition } +``` + +#### 3.1.5 플로우 삭제 + +``` +DELETE /api/flow/definitions/:id +Response: { success: boolean } +``` + +### 3.2 플로우 단계 관리 API + +#### 3.2.1 단계 추가 + +``` +POST /api/flow/definitions/:flowId/steps +Body: { + stepName: string; + stepOrder: number; + conditionJson?: object; + color?: string; + positionX?: number; + positionY?: number; +} +Response: { success: boolean; data: FlowStep } +``` + +#### 3.2.2 단계 수정 + +``` +PUT /api/flow/steps/:stepId +Body: { + stepName?: string; + stepOrder?: number; + conditionJson?: object; + color?: string; + positionX?: number; + positionY?: number; +} +Response: { success: boolean; data: FlowStep } +``` + +#### 3.2.3 단계 삭제 + +``` +DELETE /api/flow/steps/:stepId +Response: { success: boolean } +``` + +### 3.3 플로우 연결 관리 API + +#### 3.3.1 단계 연결 생성 + +``` +POST /api/flow/connections +Body: { + flowDefinitionId: number; + fromStepId: number; + toStepId: number; + label?: string; +} +Response: { success: boolean; data: FlowStepConnection } +``` + +#### 3.3.2 연결 삭제 + +``` +DELETE /api/flow/connections/:connectionId +Response: { success: boolean } +``` + +### 3.4 플로우 실행 API + +#### 3.4.1 단계별 데이터 카운트 조회 + +``` +GET /api/flow/:flowId/step/:stepId/count +Response: { + success: boolean; + data: { count: number } +} +``` + +#### 3.4.2 단계별 데이터 리스트 조회 + +``` +GET /api/flow/:flowId/step/:stepId/data +Query: { page?: number; pageSize?: number } +Response: { + success: boolean; + data: { + records: any[]; + total: number; + page: number; + pageSize: number; + } +} +``` + +#### 3.4.3 데이터를 다음 단계로 이동 + +``` +POST /api/flow/move +Body: { + flowId: number; + recordId: string; + toStepId: number; + note?: string; +} +Response: { success: boolean } +``` + +#### 3.4.4 데이터의 플로우 이력 조회 + +``` +GET /api/flow/audit/:flowId/:recordId +Response: { + success: boolean; + data: FlowAuditLog[] +} +``` + +--- + +## 4. 프론트엔드 구조 + +### 4.1 플로우 관리 화면 (`/flow-management`) + +#### 4.1.1 파일 구조 + +``` +frontend/src/app/flow-management/ +├── page.tsx # 메인 페이지 +├── components/ +│ ├── FlowList.tsx # 플로우 목록 +│ ├── FlowEditor.tsx # 플로우 편집기 (React Flow) +│ ├── FlowStepPanel.tsx # 단계 속성 편집 패널 +│ ├── FlowConditionBuilder.tsx # 조건 설정 빌더 +│ └── FlowPreview.tsx # 플로우 미리보기 +``` + +#### 4.1.2 주요 컴포넌트 + +**FlowEditor.tsx** + +- React Flow 라이브러리 사용 +- 플로우 단계를 노드로 표시 +- 단계 간 연결선 표시 +- 드래그앤드롭으로 노드 위치 조정 +- 노드 클릭 시 FlowStepPanel 표시 + +**FlowConditionBuilder.tsx** + +- 테이블 컬럼 선택 +- 연산자 선택 (equals, not_equals, in, not_in, greater_than, less_than, is_null, is_not_null) +- 값 입력 +- AND/OR 조건 그룹핑 +- 조건 추가/제거 + +### 4.2 화면관리 연동 + +#### 4.2.1 새로운 컴포넌트 타입 추가 + +**types/screen.ts에 추가:** + +```typescript +export interface FlowWidgetComponent extends BaseComponent { + type: "flow-widget"; + flowId?: number; + layout?: "horizontal" | "vertical"; + cardWidth?: string; + cardHeight?: string; + showCount?: boolean; + showConnections?: boolean; +} + +export type ComponentData = + | ContainerComponent + | WidgetComponent + | GroupComponent + | DataTableComponent + | ButtonComponent + | SplitPanelComponent + | RepeaterComponent + | FlowWidgetComponent; // 추가 +``` + +#### 4.2.2 FlowWidgetConfigPanel.tsx 생성 + +```typescript +// 플로우 위젯 설정 패널 +interface FlowWidgetConfigPanelProps { + component: FlowWidgetComponent; + onUpdateProperty: (property: string, value: any) => void; +} + +export function FlowWidgetConfigPanel({ + component, + onUpdateProperty, +}: FlowWidgetConfigPanelProps) { + const [flows, setFlows] = useState([]); + + useEffect(() => { + // 플로우 목록 불러오기 + loadFlows(); + }, []); + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + onUpdateProperty("cardWidth", e.target.value)} + /> +
+ +
+ + onUpdateProperty("showCount", checked)} + /> +
+ +
+ + + onUpdateProperty("showConnections", checked) + } + /> +
+
+ ); +} +``` + +#### 4.2.3 RealtimePreview.tsx 수정 + +```typescript +// flow-widget 타입 렌더링 추가 +if (component.type === "flow-widget" && component.flowId) { + return ; +} +``` + +#### 4.2.4 FlowWidgetPreview.tsx 생성 + +```typescript +interface FlowWidgetPreviewProps { + component: FlowWidgetComponent; + interactive?: boolean; // InteractiveScreenViewer에서 true +} + +export function FlowWidgetPreview({ + component, + interactive, +}: FlowWidgetPreviewProps) { + const [flowData, setFlowData] = useState(null); + const [stepCounts, setStepCounts] = useState>({}); + + useEffect(() => { + if (component.flowId) { + loadFlowData(component.flowId); + } + }, [component.flowId]); + + const loadFlowData = async (flowId: number) => { + const response = await fetch(`/api/flow/definitions/${flowId}`); + const result = await response.json(); + if (result.success) { + setFlowData(result.data); + // 각 단계별 데이터 카운트 조회 + loadStepCounts(result.data.steps); + } + }; + + const loadStepCounts = async (steps: FlowStep[]) => { + const counts: Record = {}; + for (const step of steps) { + const response = await fetch( + `/api/flow/${component.flowId}/step/${step.id}/count` + ); + const result = await response.json(); + if (result.success) { + counts[step.id] = result.data.count; + } + } + setStepCounts(counts); + }; + + const handleStepClick = async (stepId: number) => { + if (!interactive) return; + // 단계 클릭 시 데이터 리스트 모달 표시 + // TODO: 구현 + }; + + const layout = component.layout || "horizontal"; + const cardWidth = component.cardWidth || "200px"; + const cardHeight = component.cardHeight || "120px"; + + return ( +
+ {flowData?.steps.map((step: FlowStep, index: number) => ( + + handleStepClick(step.id)} + > + + {step.stepName} + + + {component.showCount && ( +
+ {stepCounts[step.id] || 0} +
+ )} +
+
+ + {component.showConnections && index < flowData.steps.length - 1 && ( +
+ {layout === "vertical" ? "↓" : "→"} +
+ )} +
+ ))} +
+ ); +} +``` + +### 4.3 데이터 리스트 모달 + +#### 4.3.1 FlowStepDataModal.tsx 생성 + +```typescript +interface FlowStepDataModalProps { + flowId: number; + stepId: number; + stepName: string; + isOpen: boolean; + onClose: () => void; +} + +export function FlowStepDataModal({ + flowId, + stepId, + stepName, + isOpen, + onClose, +}: FlowStepDataModalProps) { + const [data, setData] = useState([]); + const [selectedRows, setSelectedRows] = useState>(new Set()); + + useEffect(() => { + if (isOpen) { + loadStepData(); + } + }, [isOpen, stepId]); + + const loadStepData = async () => { + const response = await fetch(`/api/flow/${flowId}/step/${stepId}/data`); + const result = await response.json(); + if (result.success) { + setData(result.data.records); + } + }; + + const handleMoveToNextStep = async () => { + // 선택된 레코드들을 다음 단계로 이동 + // TODO: 구현 + }; + + return ( + + + + {stepName} - 데이터 목록 + + +
+ {/* 데이터 테이블 */} + +
+ + + + + +
+
+ ); +} +``` + +### 4.4 오딧 로그 화면 + +#### 4.4.1 FlowAuditLog.tsx 생성 + +```typescript +interface FlowAuditLogProps { + flowId: number; + recordId: string; +} + +export function FlowAuditLog({ flowId, recordId }: FlowAuditLogProps) { + const [logs, setLogs] = useState([]); + + useEffect(() => { + loadAuditLogs(); + }, [flowId, recordId]); + + const loadAuditLogs = async () => { + const response = await fetch(`/api/flow/audit/${flowId}/${recordId}`); + const result = await response.json(); + if (result.success) { + setLogs(result.data); + } + }; + + return ( +
+

상태 변경 이력

+ +
+ {logs.map((log) => ( + + +
+ {log.fromStepName || "시작"} + + {log.toStepName} +
+
+
변경자: {log.changedBy}
+
변경일시: {new Date(log.changedAt).toLocaleString()}
+ {log.note &&
변경사유: {log.note}
} +
+
+
+ ))} +
+
+ ); +} +``` + +--- + +## 5. 백엔드 구현 + +### 5.1 서비스 파일 구조 + +``` +backend-node/src/services/ +├── flowDefinitionService.ts # 플로우 정의 관리 +├── flowStepService.ts # 플로우 단계 관리 +├── flowConnectionService.ts # 플로우 연결 관리 +├── flowExecutionService.ts # 플로우 실행 (카운트, 데이터 조회) +├── flowDataMoveService.ts # 데이터 이동 및 오딧 로그 +└── flowConditionParser.ts # 조건 JSON을 SQL WHERE절로 변환 +``` + +### 5.2 flowConditionParser.ts 핵심 로직 + +```typescript +export interface FlowCondition { + column: string; + operator: + | "equals" + | "not_equals" + | "in" + | "not_in" + | "greater_than" + | "less_than" + | "is_null" + | "is_not_null"; + value: any; +} + +export interface FlowConditionGroup { + type: "AND" | "OR"; + conditions: FlowCondition[]; +} + +export class FlowConditionParser { + /** + * 조건 JSON을 SQL WHERE 절로 변환 + */ + static toSqlWhere(conditionGroup: FlowConditionGroup): { + where: string; + params: any[]; + } { + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + for (const condition of conditionGroup.conditions) { + const column = this.sanitizeColumnName(condition.column); + + switch (condition.operator) { + case "equals": + conditions.push(`${column} = $${paramIndex}`); + params.push(condition.value); + paramIndex++; + break; + + case "not_equals": + conditions.push(`${column} != $${paramIndex}`); + params.push(condition.value); + paramIndex++; + break; + + case "in": + if (Array.isArray(condition.value) && condition.value.length > 0) { + const placeholders = condition.value + .map(() => `$${paramIndex++}`) + .join(", "); + conditions.push(`${column} IN (${placeholders})`); + params.push(...condition.value); + } + break; + + case "not_in": + if (Array.isArray(condition.value) && condition.value.length > 0) { + const placeholders = condition.value + .map(() => `$${paramIndex++}`) + .join(", "); + conditions.push(`${column} NOT IN (${placeholders})`); + params.push(...condition.value); + } + break; + + case "greater_than": + conditions.push(`${column} > $${paramIndex}`); + params.push(condition.value); + paramIndex++; + break; + + case "less_than": + conditions.push(`${column} < $${paramIndex}`); + params.push(condition.value); + paramIndex++; + break; + + case "is_null": + conditions.push(`${column} IS NULL`); + break; + + case "is_not_null": + conditions.push(`${column} IS NOT NULL`); + break; + } + } + + const joinOperator = conditionGroup.type === "OR" ? " OR " : " AND "; + const where = conditions.length > 0 ? conditions.join(joinOperator) : "1=1"; + + return { where, params }; + } + + /** + * SQL 인젝션 방지를 위한 컬럼명 검증 + */ + private static sanitizeColumnName(columnName: string): string { + // 알파벳, 숫자, 언더스코어만 허용 + if (!/^[a-zA-Z0-9_]+$/.test(columnName)) { + throw new Error(`Invalid column name: ${columnName}`); + } + return columnName; + } +} +``` + +### 5.3 flowExecutionService.ts 핵심 로직 + +```typescript +export class FlowExecutionService { + /** + * 특정 플로우 단계에 해당하는 데이터 카운트 + */ + async getStepDataCount(flowId: number, stepId: number): Promise { + // 1. 플로우 정의 조회 + const flowDef = await this.getFlowDefinition(flowId); + + // 2. 플로우 단계 조회 + const step = await this.getFlowStep(stepId); + + // 3. 조건 JSON을 SQL WHERE절로 변환 + const { where, params } = FlowConditionParser.toSqlWhere( + step.conditionJson + ); + + // 4. 카운트 쿼리 실행 + const query = `SELECT COUNT(*) as count FROM ${flowDef.tableName} WHERE ${where}`; + const result = await db.query(query, params); + + return parseInt(result.rows[0].count); + } + + /** + * 특정 플로우 단계에 해당하는 데이터 리스트 + */ + async getStepDataList( + flowId: number, + stepId: number, + page: number = 1, + pageSize: number = 20 + ): Promise<{ records: any[]; total: number }> { + const flowDef = await this.getFlowDefinition(flowId); + const step = await this.getFlowStep(stepId); + const { where, params } = FlowConditionParser.toSqlWhere( + step.conditionJson + ); + + const offset = (page - 1) * pageSize; + + // 전체 카운트 + const countQuery = `SELECT COUNT(*) as count FROM ${flowDef.tableName} WHERE ${where}`; + const countResult = await db.query(countQuery, params); + const total = parseInt(countResult.rows[0].count); + + // 데이터 조회 + const dataQuery = ` + SELECT * FROM ${flowDef.tableName} + WHERE ${where} + ORDER BY id DESC + LIMIT $${params.length + 1} OFFSET $${params.length + 2} + `; + const dataResult = await db.query(dataQuery, [...params, pageSize, offset]); + + return { + records: dataResult.rows, + total, + }; + } +} +``` + +### 5.4 flowDataMoveService.ts 핵심 로직 + +```typescript +export class FlowDataMoveService { + /** + * 데이터를 다음 플로우 단계로 이동 + */ + async moveDataToStep( + flowId: number, + recordId: string, + toStepId: number, + userId: string, + note?: string + ): Promise { + const client = await db.getClient(); + + try { + await client.query("BEGIN"); + + // 1. 현재 상태 조회 + const currentStatus = await this.getCurrentStatus( + client, + flowId, + recordId + ); + const fromStepId = currentStatus?.currentStepId || null; + + // 2. flow_data_status 업데이트 또는 삽입 + if (currentStatus) { + await client.query( + ` + UPDATE flow_data_status + SET current_step_id = $1, updated_by = $2, updated_at = NOW() + WHERE flow_definition_id = $3 AND record_id = $4 + `, + [toStepId, userId, flowId, recordId] + ); + } else { + const flowDef = await this.getFlowDefinition(flowId); + await client.query( + ` + INSERT INTO flow_data_status + (flow_definition_id, table_name, record_id, current_step_id, updated_by) + VALUES ($1, $2, $3, $4, $5) + `, + [flowId, flowDef.tableName, recordId, toStepId, userId] + ); + } + + // 3. 오딧 로그 기록 + await client.query( + ` + INSERT INTO flow_audit_log + (flow_definition_id, table_name, record_id, from_step_id, to_step_id, changed_by, note) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, + [ + flowId, + currentStatus?.tableName || recordId, + recordId, + fromStepId, + toStepId, + userId, + note, + ] + ); + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + /** + * 데이터의 플로우 이력 조회 + */ + async getAuditLogs(flowId: number, recordId: string): Promise { + const query = ` + SELECT + fal.*, + fs_from.step_name as from_step_name, + fs_to.step_name as to_step_name + FROM flow_audit_log fal + LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id + LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id + WHERE fal.flow_definition_id = $1 AND fal.record_id = $2 + ORDER BY fal.changed_at DESC + `; + + const result = await db.query(query, [flowId, recordId]); + return result.rows; + } +} +``` + +--- + +## 6. 구현 단계 + +### Phase 1: 기본 구조 (1주) + +- [ ] 데이터베이스 마이그레이션 생성 및 실행 +- [ ] 백엔드 서비스 기본 구조 생성 +- [ ] 플로우 정의 CRUD API 구현 +- [ ] 플로우 단계 CRUD API 구현 +- [ ] 플로우 연결 API 구현 + +### Phase 2: 플로우 편집기 (1주) + +- [ ] React Flow 라이브러리 설치 및 설정 +- [ ] FlowEditor 컴포넌트 구현 +- [ ] FlowList 컴포넌트 구현 +- [ ] FlowStepPanel 구현 (단계 속성 편집) +- [ ] FlowConditionBuilder 구현 (조건 설정 UI) +- [ ] 플로우 저장/불러오기 기능 + +### Phase 3: 화면관리 연동 (3일) + +- [ ] FlowWidgetComponent 타입 정의 +- [ ] FlowWidgetConfigPanel 구현 +- [ ] FlowWidgetPreview 구현 +- [ ] RealtimePreview에 flow-widget 렌더링 추가 +- [ ] InteractiveScreenViewer에 flow-widget 렌더링 추가 + +### Phase 4: 플로우 실행 기능 (1주) + +- [ ] FlowConditionParser 구현 (조건 → SQL 변환) +- [ ] FlowExecutionService 구현 (카운트, 데이터 조회) +- [ ] 단계별 데이터 카운트 API +- [ ] 단계별 데이터 리스트 API +- [ ] FlowStepDataModal 구현 +- [ ] 데이터 선택 및 표시 기능 + +### Phase 5: 데이터 이동 및 오딧 (3일) + +- [ ] FlowDataMoveService 구현 +- [ ] 데이터 이동 API +- [ ] 오딧 로그 API +- [ ] FlowAuditLog 컴포넌트 구현 +- [ ] 데이터 이동 시 트랜잭션 처리 + +### Phase 6: 테스트 및 최적화 (3일) + +- [ ] 단위 테스트 작성 +- [ ] 통합 테스트 작성 +- [ ] 성능 최적화 (인덱스, 쿼리 최적화) +- [ ] 사용자 테스트 및 피드백 반영 + +--- + +## 7. 기술 스택 + +### 7.1 프론트엔드 + +- **React Flow**: 플로우 시각화 및 편집 +- **Shadcn/ui**: UI 컴포넌트 +- **TanStack Query**: 데이터 페칭 및 캐싱 + +### 7.2 백엔드 + +- **Node.js + Express**: API 서버 +- **PostgreSQL**: 데이터베이스 +- **TypeScript**: 타입 안전성 + +--- + +## 8. 주요 고려사항 + +### 8.1 성능 + +- 플로우 단계별 데이터 카운트 조회 시 인덱스 활용 +- 대용량 데이터 처리 시 페이징 필수 +- 조건 파싱 결과 캐싱 + +### 8.2 보안 + +- SQL 인젝션 방지: 파라미터 바인딩 사용 +- 컬럼명 검증: 알파벳, 숫자, 언더스코어만 허용 +- 사용자 권한 확인 + +### 8.3 확장성 + +- 복잡한 조건 (중첩 AND/OR) 지원 가능하도록 설계 +- 플로우 분기 (조건부 분기) 지원 가능하도록 설계 +- 자동 플로우 이동 (트리거) 추후 추가 가능 + +### 8.4 사용성 + +- 플로우 편집기의 직관적인 UI +- 드래그앤드롭으로 쉬운 조작 +- 실시간 데이터 카운트 표시 +- 오딧 로그를 통한 추적 가능성 + +--- + +## 9. 예상 일정 + +| Phase | 기간 | 담당 | +| ---------------------------- | ---------- | ------------------ | +| Phase 1: 기본 구조 | 1주 | Backend | +| Phase 2: 플로우 편집기 | 1주 | Frontend | +| Phase 3: 화면관리 연동 | 3일 | Frontend | +| Phase 4: 플로우 실행 | 1주 | Backend + Frontend | +| Phase 5: 데이터 이동 및 오딧 | 3일 | Backend + Frontend | +| Phase 6: 테스트 및 최적화 | 3일 | 전체 | +| **총 예상 기간** | **약 4주** | | + +--- + +## 10. 향후 확장 계획 + +### 10.1 자동 플로우 이동 + +- 특정 조건 충족 시 자동으로 다음 단계로 이동 +- 예: 결재 승인 시 자동으로 '승인완료' 단계로 이동 + +### 10.2 플로우 분기 + +- 조건에 따라 다른 경로로 분기 +- 예: 금액에 따라 '일반 승인' 또는 '특별 승인' 경로 + +### 10.3 플로우 알림 + +- 특정 단계 진입 시 담당자에게 알림 +- 이메일, 시스템 알림 등 + +### 10.4 플로우 템플릿 + +- 자주 사용하는 플로우 패턴을 템플릿으로 저장 +- 템플릿에서 새 플로우 생성 + +### 10.5 플로우 통계 대시보드 + +- 단계별 체류 시간 분석 +- 병목 구간 식별 +- 처리 속도 통계 + +--- + +## 11. 참고 자료 + +### 11.1 React Flow + +- 공식 문서: https://reactflow.dev/ +- 예제: https://reactflow.dev/examples + +### 11.2 유사 시스템 + +- Jira Workflow +- Trello Board +- GitHub Projects + +--- + +## 12. 결론 + +이 플로우 관리 시스템은 제품 수명주기, 업무 프로세스, 승인 프로세스 등 다양한 비즈니스 워크플로우를 시각적으로 정의하고 관리할 수 있는 강력한 도구입니다. + +화면관리 시스템과의 긴밀한 통합을 통해 사용자는 코드 없이도 복잡한 워크플로우를 구축하고 실행할 수 있으며, 오딧 로그를 통해 모든 상태 변경을 추적할 수 있습니다. + +단계별 구현을 통해 안정적으로 개발하고, 향후 확장 가능성을 염두에 두어 장기적으로 유지보수하기 쉬운 시스템을 구축할 수 있습니다. diff --git a/backend-node/scripts/add-external-db-connection.ts b/backend-node/scripts/add-external-db-connection.ts new file mode 100644 index 00000000..b595168a --- /dev/null +++ b/backend-node/scripts/add-external-db-connection.ts @@ -0,0 +1,174 @@ +/** + * 외부 DB 연결 정보 추가 스크립트 + * 비밀번호를 암호화하여 안전하게 저장 + */ + +import { Pool } from "pg"; +import { CredentialEncryption } from "../src/utils/credentialEncryption"; + +async function addExternalDbConnection() { + const pool = new Pool({ + host: process.env.DB_HOST || "localhost", + port: parseInt(process.env.DB_PORT || "5432"), + database: process.env.DB_NAME || "plm", + user: process.env.DB_USER || "postgres", + password: process.env.DB_PASSWORD || "ph0909!!", + }); + + // 환경 변수에서 암호화 키 가져오기 (없으면 기본값 사용) + const encryptionKey = + process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development"; + const encryption = new CredentialEncryption(encryptionKey); + + try { + // 외부 DB 연결 정보 (실제 사용할 외부 DB 정보를 여기에 입력) + const externalDbConnections = [ + { + name: "운영_외부_PostgreSQL", + description: "운영용 외부 PostgreSQL 데이터베이스", + dbType: "postgresql", + host: "39.117.244.52", + port: 11132, + databaseName: "plm", + username: "postgres", + password: "ph0909!!", // 이 값은 암호화되어 저장됩니다 + sslEnabled: false, + isActive: true, + }, + // 필요한 경우 추가 외부 DB 연결 정보를 여기에 추가 + // { + // name: "테스트_MySQL", + // description: "테스트용 MySQL 데이터베이스", + // dbType: "mysql", + // host: "test-mysql.example.com", + // port: 3306, + // databaseName: "testdb", + // username: "testuser", + // password: "testpass", + // sslEnabled: true, + // isActive: true, + // }, + ]; + + for (const conn of externalDbConnections) { + // 비밀번호 암호화 + const encryptedPassword = encryption.encrypt(conn.password); + + // 중복 체크 (이름 기준) + const existingResult = await pool.query( + "SELECT id FROM flow_external_db_connection WHERE name = $1", + [conn.name] + ); + + if (existingResult.rows.length > 0) { + console.log( + `⚠️ 이미 존재하는 연결: ${conn.name} (ID: ${existingResult.rows[0].id})` + ); + + // 기존 연결 업데이트 + await pool.query( + `UPDATE flow_external_db_connection + SET description = $1, + db_type = $2, + host = $3, + port = $4, + database_name = $5, + username = $6, + password_encrypted = $7, + ssl_enabled = $8, + is_active = $9, + updated_at = NOW(), + updated_by = 'system' + WHERE name = $10`, + [ + conn.description, + conn.dbType, + conn.host, + conn.port, + conn.databaseName, + conn.username, + encryptedPassword, + conn.sslEnabled, + conn.isActive, + conn.name, + ] + ); + console.log(`✅ 연결 정보 업데이트 완료: ${conn.name}`); + } else { + // 새 연결 추가 + const result = await pool.query( + `INSERT INTO flow_external_db_connection ( + name, + description, + db_type, + host, + port, + database_name, + username, + password_encrypted, + ssl_enabled, + is_active, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system') + RETURNING id`, + [ + conn.name, + conn.description, + conn.dbType, + conn.host, + conn.port, + conn.databaseName, + conn.username, + encryptedPassword, + conn.sslEnabled, + conn.isActive, + ] + ); + console.log( + `✅ 새 연결 추가 완료: ${conn.name} (ID: ${result.rows[0].id})` + ); + } + + // 연결 테스트 + console.log(`🔍 연결 테스트 중: ${conn.name}...`); + const testPool = new Pool({ + host: conn.host, + port: conn.port, + database: conn.databaseName, + user: conn.username, + password: conn.password, + ssl: conn.sslEnabled, + connectionTimeoutMillis: 5000, + }); + + try { + const client = await testPool.connect(); + await client.query("SELECT 1"); + client.release(); + console.log(`✅ 연결 테스트 성공: ${conn.name}`); + } catch (testError: any) { + console.error(`❌ 연결 테스트 실패: ${conn.name}`, testError.message); + } finally { + await testPool.end(); + } + } + + console.log("\n✅ 모든 외부 DB 연결 정보 처리 완료"); + } catch (error) { + console.error("❌ 외부 DB 연결 정보 추가 오류:", error); + throw error; + } finally { + await pool.end(); + } +} + +// 스크립트 실행 +addExternalDbConnection() + .then(() => { + console.log("✅ 스크립트 완료"); + process.exit(0); + }) + .catch((error) => { + console.error("❌ 스크립트 실패:", error); + process.exit(1); + }); diff --git a/backend-node/scripts/encrypt-password.ts b/backend-node/scripts/encrypt-password.ts new file mode 100644 index 00000000..178de1ad --- /dev/null +++ b/backend-node/scripts/encrypt-password.ts @@ -0,0 +1,16 @@ +/** + * 비밀번호 암호화 유틸리티 + */ + +import { CredentialEncryption } from "../src/utils/credentialEncryption"; + +const encryptionKey = + process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development"; +const encryption = new CredentialEncryption(encryptionKey); +const password = process.argv[2] || "ph0909!!"; + +const encrypted = encryption.encrypt(password); +console.log("\n원본 비밀번호:", password); +console.log("암호화된 비밀번호:", encrypted); +console.log("\n복호화 테스트:", encryption.decrypt(encrypted)); +console.log("✅ 암호화/복호화 성공\n"); diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index caa010b4..c503f548 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -56,6 +56,9 @@ import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D +//import materialRoutes from "./routes/materialRoutes"; // 자재 관리 +import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 +import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 @@ -207,6 +210,9 @@ app.use("/api/todos", todoRoutes); // To-Do 관리 app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D +// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석) +app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결 +app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지) app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts new file mode 100644 index 00000000..e555e6f7 --- /dev/null +++ b/backend-node/src/controllers/flowController.ts @@ -0,0 +1,676 @@ +/** + * 플로우 관리 컨트롤러 + */ + +import { Request, Response } from "express"; +import { FlowDefinitionService } from "../services/flowDefinitionService"; +import { FlowStepService } from "../services/flowStepService"; +import { FlowConnectionService } from "../services/flowConnectionService"; +import { FlowExecutionService } from "../services/flowExecutionService"; +import { FlowDataMoveService } from "../services/flowDataMoveService"; + +export class FlowController { + private flowDefinitionService: FlowDefinitionService; + private flowStepService: FlowStepService; + private flowConnectionService: FlowConnectionService; + private flowExecutionService: FlowExecutionService; + private flowDataMoveService: FlowDataMoveService; + + constructor() { + this.flowDefinitionService = new FlowDefinitionService(); + this.flowStepService = new FlowStepService(); + this.flowConnectionService = new FlowConnectionService(); + this.flowExecutionService = new FlowExecutionService(); + this.flowDataMoveService = new FlowDataMoveService(); + } + + // ==================== 플로우 정의 ==================== + + /** + * 플로우 정의 생성 + */ + createFlowDefinition = async (req: Request, res: Response): Promise => { + try { + const { name, description, tableName } = req.body; + const userId = (req as any).user?.userId || "system"; + + if (!name || !tableName) { + res.status(400).json({ + success: false, + message: "Name and tableName are required", + }); + return; + } + + // 테이블 존재 확인 + const tableExists = + await this.flowDefinitionService.checkTableExists(tableName); + if (!tableExists) { + res.status(400).json({ + success: false, + message: `Table '${tableName}' does not exist`, + }); + return; + } + + const flowDef = await this.flowDefinitionService.create( + { name, description, tableName }, + userId + ); + + res.json({ + success: true, + data: flowDef, + }); + } catch (error: any) { + console.error("Error creating flow definition:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to create flow definition", + }); + } + }; + + /** + * 플로우 정의 목록 조회 + */ + getFlowDefinitions = async (req: Request, res: Response): Promise => { + try { + const { tableName, isActive } = req.query; + + const flows = await this.flowDefinitionService.findAll( + tableName as string | undefined, + isActive !== undefined ? isActive === "true" : undefined + ); + + res.json({ + success: true, + data: flows, + }); + } catch (error: any) { + console.error("Error fetching flow definitions:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to fetch flow definitions", + }); + } + }; + + /** + * 플로우 정의 상세 조회 (단계 및 연결 포함) + */ + getFlowDefinitionDetail = async ( + req: Request, + res: Response + ): Promise => { + try { + const { id } = req.params; + const flowId = parseInt(id); + + const definition = await this.flowDefinitionService.findById(flowId); + if (!definition) { + res.status(404).json({ + success: false, + message: "Flow definition not found", + }); + return; + } + + const steps = await this.flowStepService.findByFlowId(flowId); + const connections = await this.flowConnectionService.findByFlowId(flowId); + + res.json({ + success: true, + data: { + definition, + steps, + connections, + }, + }); + } catch (error: any) { + console.error("Error fetching flow definition detail:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to fetch flow definition detail", + }); + } + }; + + /** + * 플로우 정의 수정 + */ + updateFlowDefinition = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const flowId = parseInt(id); + const { name, description, isActive } = req.body; + + const flowDef = await this.flowDefinitionService.update(flowId, { + name, + description, + isActive, + }); + + if (!flowDef) { + res.status(404).json({ + success: false, + message: "Flow definition not found", + }); + return; + } + + res.json({ + success: true, + data: flowDef, + }); + } catch (error: any) { + console.error("Error updating flow definition:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to update flow definition", + }); + } + }; + + /** + * 플로우 정의 삭제 + */ + deleteFlowDefinition = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const flowId = parseInt(id); + + const success = await this.flowDefinitionService.delete(flowId); + + if (!success) { + res.status(404).json({ + success: false, + message: "Flow definition not found", + }); + return; + } + + res.json({ + success: true, + message: "Flow definition deleted successfully", + }); + } catch (error: any) { + console.error("Error deleting flow definition:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to delete flow definition", + }); + } + }; + + // ==================== 플로우 단계 ==================== + + /** + * 플로우 단계 목록 조회 + */ + getFlowSteps = async (req: Request, res: Response): Promise => { + try { + const { flowId } = req.params; + const flowDefinitionId = parseInt(flowId); + + const steps = await this.flowStepService.findByFlowId(flowDefinitionId); + + res.json({ + success: true, + data: steps, + }); + return; + } catch (error: any) { + console.error("Error fetching flow steps:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to fetch flow steps", + }); + return; + } + }; + + /** + * 플로우 단계 생성 + */ + createFlowStep = async (req: Request, res: Response): Promise => { + try { + const { flowId } = req.params; + const flowDefinitionId = parseInt(flowId); + const { + stepName, + stepOrder, + tableName, + conditionJson, + color, + positionX, + positionY, + } = req.body; + + if (!stepName || stepOrder === undefined) { + res.status(400).json({ + success: false, + message: "stepName and stepOrder are required", + }); + return; + } + + const step = await this.flowStepService.create({ + flowDefinitionId, + stepName, + stepOrder, + tableName, + conditionJson, + color, + positionX, + positionY, + }); + + res.json({ + success: true, + data: step, + }); + } catch (error: any) { + console.error("Error creating flow step:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to create flow step", + }); + } + }; + + /** + * 플로우 단계 수정 + */ + updateFlowStep = async (req: Request, res: Response): Promise => { + try { + const { stepId } = req.params; + const id = parseInt(stepId); + const { + stepName, + stepOrder, + tableName, + conditionJson, + color, + positionX, + positionY, + } = req.body; + + const step = await this.flowStepService.update(id, { + stepName, + stepOrder, + tableName, + conditionJson, + color, + positionX, + positionY, + }); + + if (!step) { + res.status(404).json({ + success: false, + message: "Flow step not found", + }); + return; + } + + res.json({ + success: true, + data: step, + }); + } catch (error: any) { + console.error("Error updating flow step:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to update flow step", + }); + } + }; + + /** + * 플로우 단계 삭제 + */ + deleteFlowStep = async (req: Request, res: Response): Promise => { + try { + const { stepId } = req.params; + const id = parseInt(stepId); + + const success = await this.flowStepService.delete(id); + + if (!success) { + res.status(404).json({ + success: false, + message: "Flow step not found", + }); + return; + } + + res.json({ + success: true, + message: "Flow step deleted successfully", + }); + } catch (error: any) { + console.error("Error deleting flow step:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to delete flow step", + }); + } + }; + + // ==================== 플로우 연결 ==================== + + /** + * 플로우 연결 목록 조회 + */ + getFlowConnections = async (req: Request, res: Response): Promise => { + try { + const { flowId } = req.params; + const flowDefinitionId = parseInt(flowId); + + const connections = + await this.flowConnectionService.findByFlowId(flowDefinitionId); + + res.json({ + success: true, + data: connections, + }); + return; + } catch (error: any) { + console.error("Error fetching flow connections:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to fetch flow connections", + }); + return; + } + }; + + /** + * 플로우 연결 생성 + */ + createConnection = async (req: Request, res: Response): Promise => { + try { + const { flowDefinitionId, fromStepId, toStepId, label } = req.body; + + if (!flowDefinitionId || !fromStepId || !toStepId) { + res.status(400).json({ + success: false, + message: "flowDefinitionId, fromStepId, and toStepId are required", + }); + return; + } + + const connection = await this.flowConnectionService.create({ + flowDefinitionId, + fromStepId, + toStepId, + label, + }); + + res.json({ + success: true, + data: connection, + }); + } catch (error: any) { + console.error("Error creating connection:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to create connection", + }); + } + }; + + /** + * 플로우 연결 삭제 + */ + deleteConnection = async (req: Request, res: Response): Promise => { + try { + const { connectionId } = req.params; + const id = parseInt(connectionId); + + const success = await this.flowConnectionService.delete(id); + + if (!success) { + res.status(404).json({ + success: false, + message: "Connection not found", + }); + return; + } + + res.json({ + success: true, + message: "Connection deleted successfully", + }); + } catch (error: any) { + console.error("Error deleting connection:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to delete connection", + }); + } + }; + + // ==================== 플로우 실행 ==================== + + /** + * 단계별 데이터 카운트 조회 + */ + getStepDataCount = async (req: Request, res: Response): Promise => { + try { + const { flowId, stepId } = req.params; + + const count = await this.flowExecutionService.getStepDataCount( + parseInt(flowId), + parseInt(stepId) + ); + + res.json({ + success: true, + data: { count }, + }); + } catch (error: any) { + console.error("Error getting step data count:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to get step data count", + }); + } + }; + + /** + * 단계별 데이터 리스트 조회 + */ + getStepDataList = async (req: Request, res: Response): Promise => { + try { + const { flowId, stepId } = req.params; + const { page = 1, pageSize = 20 } = req.query; + + const data = await this.flowExecutionService.getStepDataList( + parseInt(flowId), + parseInt(stepId), + parseInt(page as string), + parseInt(pageSize as string) + ); + + res.json({ + success: true, + data, + }); + } catch (error: any) { + console.error("Error getting step data list:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to get step data list", + }); + } + }; + + /** + * 플로우의 모든 단계별 카운트 조회 + */ + getAllStepCounts = async (req: Request, res: Response): Promise => { + try { + const { flowId } = req.params; + + const counts = await this.flowExecutionService.getAllStepCounts( + parseInt(flowId) + ); + + res.json({ + success: true, + data: counts, + }); + } catch (error: any) { + console.error("Error getting all step counts:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to get all step counts", + }); + } + }; + + /** + * 데이터를 다음 단계로 이동 + */ + moveData = async (req: Request, res: Response): Promise => { + try { + const { flowId, recordId, toStepId, note } = req.body; + const userId = (req as any).user?.userId || "system"; + + if (!flowId || !recordId || !toStepId) { + res.status(400).json({ + success: false, + message: "flowId, recordId, and toStepId are required", + }); + return; + } + + await this.flowDataMoveService.moveDataToStep( + flowId, + recordId, + toStepId, + userId, + note + ); + + res.json({ + success: true, + message: "Data moved successfully", + }); + } catch (error: any) { + console.error("Error moving data:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to move data", + }); + } + }; + + /** + * 여러 데이터를 동시에 이동 + */ + moveBatchData = async (req: Request, res: Response): Promise => { + try { + const { flowId, fromStepId, toStepId, dataIds } = req.body; + const userId = (req as any).user?.userId || "system"; + + if ( + !flowId || + !fromStepId || + !toStepId || + !dataIds || + !Array.isArray(dataIds) + ) { + res.status(400).json({ + success: false, + message: + "flowId, fromStepId, toStepId, and dataIds (array) are required", + }); + return; + } + + const result = await this.flowDataMoveService.moveBatchData( + flowId, + fromStepId, + toStepId, + dataIds, + userId + ); + + const successCount = result.results.filter((r) => r.success).length; + const failureCount = result.results.filter((r) => !r.success).length; + + res.json({ + success: result.success, + message: result.success + ? `${successCount}건의 데이터를 성공적으로 이동했습니다` + : `${successCount}건 성공, ${failureCount}건 실패`, + data: { + successCount, + failureCount, + total: dataIds.length, + }, + results: result.results, + }); + } catch (error: any) { + console.error("Error moving batch data:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to move batch data", + }); + } + }; + + /** + * 데이터의 플로우 이력 조회 + */ + getAuditLogs = async (req: Request, res: Response): Promise => { + try { + const { flowId, recordId } = req.params; + + const logs = await this.flowDataMoveService.getAuditLogs( + parseInt(flowId), + recordId + ); + + res.json({ + success: true, + data: logs, + }); + } catch (error: any) { + console.error("Error getting audit logs:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to get audit logs", + }); + } + }; + + /** + * 플로우의 모든 이력 조회 + */ + getFlowAuditLogs = async (req: Request, res: Response): Promise => { + try { + const { flowId } = req.params; + const { limit = 100 } = req.query; + + const logs = await this.flowDataMoveService.getFlowAuditLogs( + parseInt(flowId), + parseInt(limit as string) + ); + + res.json({ + success: true, + data: logs, + }); + } catch (error: any) { + console.error("Error getting flow audit logs:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to get flow audit logs", + }); + } + }; +} diff --git a/backend-node/src/controllers/flowExternalDbConnectionController.ts b/backend-node/src/controllers/flowExternalDbConnectionController.ts new file mode 100644 index 00000000..ed0c1232 --- /dev/null +++ b/backend-node/src/controllers/flowExternalDbConnectionController.ts @@ -0,0 +1,328 @@ +import { Request, Response } from "express"; +import { FlowExternalDbConnectionService } from "../services/flowExternalDbConnectionService"; +import { + CreateFlowExternalDbConnectionRequest, + UpdateFlowExternalDbConnectionRequest, +} from "../types/flow"; +import logger from "../utils/logger"; + +/** + * 플로우 전용 외부 DB 연결 컨트롤러 + */ +export class FlowExternalDbConnectionController { + private service: FlowExternalDbConnectionService; + + constructor() { + this.service = new FlowExternalDbConnectionService(); + } + + /** + * GET /api/flow/external-db-connections + * 모든 외부 DB 연결 목록 조회 + */ + async getAll(req: Request, res: Response): Promise { + try { + const activeOnly = req.query.activeOnly === "true"; + const connections = await this.service.findAll(activeOnly); + + res.json({ + success: true, + data: connections, + message: `${connections.length}개의 외부 DB 연결을 조회했습니다`, + }); + } catch (error: any) { + logger.error("외부 DB 연결 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 목록 조회 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * GET /api/flow/external-db-connections/:id + * 특정 외부 DB 연결 조회 + */ + async getById(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + const connection = await this.service.findById(id); + + if (!connection) { + res.status(404).json({ + success: false, + message: "외부 DB 연결을 찾을 수 없습니다", + }); + return; + } + + res.json({ + success: true, + data: connection, + }); + } catch (error: any) { + logger.error("외부 DB 연결 조회 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 조회 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * POST /api/flow/external-db-connections + * 새 외부 DB 연결 생성 + */ + async create(req: Request, res: Response): Promise { + try { + const request: CreateFlowExternalDbConnectionRequest = req.body; + + // 필수 필드 검증 + if ( + !request.name || + !request.dbType || + !request.host || + !request.port || + !request.databaseName || + !request.username || + !request.password + ) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다", + }); + return; + } + + const userId = (req as any).user?.userId || "system"; + const connection = await this.service.create(request, userId); + + logger.info( + `외부 DB 연결 생성: ${connection.name} (ID: ${connection.id})` + ); + + res.status(201).json({ + success: true, + data: connection, + message: "외부 DB 연결이 생성되었습니다", + }); + } catch (error: any) { + logger.error("외부 DB 연결 생성 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 생성 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * PUT /api/flow/external-db-connections/:id + * 외부 DB 연결 수정 + */ + async update(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + const request: UpdateFlowExternalDbConnectionRequest = req.body; + const userId = (req as any).user?.userId || "system"; + + const connection = await this.service.update(id, request, userId); + + if (!connection) { + res.status(404).json({ + success: false, + message: "외부 DB 연결을 찾을 수 없습니다", + }); + return; + } + + logger.info(`외부 DB 연결 수정: ${connection.name} (ID: ${id})`); + + res.json({ + success: true, + data: connection, + message: "외부 DB 연결이 수정되었습니다", + }); + } catch (error: any) { + logger.error("외부 DB 연결 수정 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 수정 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * DELETE /api/flow/external-db-connections/:id + * 외부 DB 연결 삭제 + */ + async delete(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + const success = await this.service.delete(id); + + if (!success) { + res.status(404).json({ + success: false, + message: "외부 DB 연결을 찾을 수 없습니다", + }); + return; + } + + logger.info(`외부 DB 연결 삭제: ID ${id}`); + + res.json({ + success: true, + message: "외부 DB 연결이 삭제되었습니다", + }); + } catch (error: any) { + logger.error("외부 DB 연결 삭제 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 삭제 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * POST /api/flow/external-db-connections/:id/test + * 외부 DB 연결 테스트 + */ + async testConnection(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + const result = await this.service.testConnection(id); + + if (result.success) { + logger.info(`외부 DB 연결 테스트 성공: ID ${id}`); + res.json({ + success: true, + message: result.message, + }); + } else { + logger.warn(`외부 DB 연결 테스트 실패: ID ${id} - ${result.message}`); + res.status(400).json({ + success: false, + message: result.message, + }); + } + } catch (error: any) { + logger.error("외부 DB 연결 테스트 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 연결 테스트 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * GET /api/flow/external-db-connections/:id/tables + * 외부 DB의 테이블 목록 조회 + */ + async getTables(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + const result = await this.service.getTables(id); + + if (result.success) { + res.json(result); + } else { + res.status(400).json(result); + } + } catch (error: any) { + logger.error("외부 DB 테이블 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 테이블 목록 조회 중 오류가 발생했습니다", + error: error.message, + }); + } + } + + /** + * GET /api/flow/external-db-connections/:id/tables/:tableName/columns + * 외부 DB 특정 테이블의 컬럼 목록 조회 + */ + async getTableColumns(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + const tableName = req.params.tableName; + + if (isNaN(id)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 연결 ID입니다", + }); + return; + } + + if (!tableName) { + res.status(400).json({ + success: false, + message: "테이블명이 필요합니다", + }); + return; + } + + const result = await this.service.getTableColumns(id, tableName); + + if (result.success) { + res.json(result); + } else { + res.status(400).json(result); + } + } catch (error: any) { + logger.error("외부 DB 컬럼 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "외부 DB 컬럼 목록 조회 중 오류가 발생했습니다", + error: error.message, + }); + } + } +} diff --git a/backend-node/src/routes/flowExternalDbConnectionRoutes.ts b/backend-node/src/routes/flowExternalDbConnectionRoutes.ts new file mode 100644 index 00000000..c58c6de0 --- /dev/null +++ b/backend-node/src/routes/flowExternalDbConnectionRoutes.ts @@ -0,0 +1,48 @@ +import { Router } from "express"; +import { FlowExternalDbConnectionController } from "../controllers/flowExternalDbConnectionController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); +const controller = new FlowExternalDbConnectionController(); + +/** + * 플로우 전용 외부 DB 연결 라우트 + * 기존 제어관리 외부 DB 연결과 별도 + */ + +// 모든 외부 DB 연결 목록 조회 (읽기 전용 - 인증 불필요) +// 민감한 정보(비밀번호)는 반환하지 않으므로 안전 +router.get("/", (req, res) => controller.getAll(req, res)); + +// 특정 외부 DB 연결 조회 +router.get("/:id", authenticateToken, (req, res) => + controller.getById(req, res) +); + +// 새 외부 DB 연결 생성 +router.post("/", authenticateToken, (req, res) => controller.create(req, res)); + +// 외부 DB 연결 수정 +router.put("/:id", authenticateToken, (req, res) => + controller.update(req, res) +); + +// 외부 DB 연결 삭제 +router.delete("/:id", authenticateToken, (req, res) => + controller.delete(req, res) +); + +// 외부 DB 연결 테스트 +router.post("/:id/test", authenticateToken, (req, res) => + controller.testConnection(req, res) +); + +// 외부 DB의 테이블 목록 조회 (읽기 전용 - 인증 불필요) +router.get("/:id/tables", (req, res) => controller.getTables(req, res)); + +// 외부 DB의 특정 테이블의 컬럼 목록 조회 (읽기 전용 - 인증 불필요) +router.get("/:id/tables/:tableName/columns", (req, res) => + controller.getTableColumns(req, res) +); + +export default router; diff --git a/backend-node/src/routes/flowRoutes.ts b/backend-node/src/routes/flowRoutes.ts new file mode 100644 index 00000000..93c59ad1 --- /dev/null +++ b/backend-node/src/routes/flowRoutes.ts @@ -0,0 +1,42 @@ +/** + * 플로우 관리 라우터 + */ + +import { Router } from "express"; +import { FlowController } from "../controllers/flowController"; + +const router = Router(); +const flowController = new FlowController(); + +// ==================== 플로우 정의 ==================== +router.post("/definitions", flowController.createFlowDefinition); +router.get("/definitions", flowController.getFlowDefinitions); +router.get("/definitions/:id", flowController.getFlowDefinitionDetail); +router.put("/definitions/:id", flowController.updateFlowDefinition); +router.delete("/definitions/:id", flowController.deleteFlowDefinition); + +// ==================== 플로우 단계 ==================== +router.get("/definitions/:flowId/steps", flowController.getFlowSteps); // 단계 목록 조회 +router.post("/definitions/:flowId/steps", flowController.createFlowStep); +router.put("/steps/:stepId", flowController.updateFlowStep); +router.delete("/steps/:stepId", flowController.deleteFlowStep); + +// ==================== 플로우 연결 ==================== +router.get("/connections/:flowId", flowController.getFlowConnections); // 연결 목록 조회 +router.post("/connections", flowController.createConnection); +router.delete("/connections/:connectionId", flowController.deleteConnection); + +// ==================== 플로우 실행 ==================== +router.get("/:flowId/step/:stepId/count", flowController.getStepDataCount); +router.get("/:flowId/step/:stepId/list", flowController.getStepDataList); +router.get("/:flowId/steps/counts", flowController.getAllStepCounts); + +// ==================== 데이터 이동 ==================== +router.post("/move", flowController.moveData); +router.post("/move-batch", flowController.moveBatchData); + +// ==================== 오딧 로그 ==================== +router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); +router.get("/audit/:flowId", flowController.getFlowAuditLogs); + +export default router; diff --git a/backend-node/src/services/flowConditionParser.ts b/backend-node/src/services/flowConditionParser.ts new file mode 100644 index 00000000..5f2e648a --- /dev/null +++ b/backend-node/src/services/flowConditionParser.ts @@ -0,0 +1,215 @@ +/** + * 플로우 조건 파서 + * JSON 조건을 SQL WHERE 절로 변환 + */ + +import { + FlowCondition, + FlowConditionGroup, + SqlWhereResult, +} from "../types/flow"; + +export class FlowConditionParser { + /** + * 조건 JSON을 SQL WHERE 절로 변환 + */ + static toSqlWhere( + conditionGroup: FlowConditionGroup | null | undefined + ): SqlWhereResult { + if ( + !conditionGroup || + !conditionGroup.conditions || + conditionGroup.conditions.length === 0 + ) { + return { where: "1=1", params: [] }; + } + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + for (const condition of conditionGroup.conditions) { + const column = this.sanitizeColumnName(condition.column); + + switch (condition.operator) { + case "equals": + case "=": + conditions.push(`${column} = $${paramIndex}`); + params.push(condition.value); + paramIndex++; + break; + + case "not_equals": + case "!=": + conditions.push(`${column} != $${paramIndex}`); + params.push(condition.value); + paramIndex++; + break; + + case "in": + if (Array.isArray(condition.value) && condition.value.length > 0) { + const placeholders = condition.value + .map(() => `$${paramIndex++}`) + .join(", "); + conditions.push(`${column} IN (${placeholders})`); + params.push(...condition.value); + } + break; + + case "not_in": + if (Array.isArray(condition.value) && condition.value.length > 0) { + const placeholders = condition.value + .map(() => `$${paramIndex++}`) + .join(", "); + conditions.push(`${column} NOT IN (${placeholders})`); + params.push(...condition.value); + } + break; + + case "greater_than": + case ">": + conditions.push(`${column} > $${paramIndex}`); + params.push(condition.value); + paramIndex++; + break; + + case "less_than": + case "<": + conditions.push(`${column} < $${paramIndex}`); + params.push(condition.value); + paramIndex++; + break; + + case "greater_than_or_equal": + case ">=": + conditions.push(`${column} >= $${paramIndex}`); + params.push(condition.value); + paramIndex++; + break; + + case "less_than_or_equal": + case "<=": + conditions.push(`${column} <= $${paramIndex}`); + params.push(condition.value); + paramIndex++; + break; + + case "is_null": + conditions.push(`${column} IS NULL`); + break; + + case "is_not_null": + conditions.push(`${column} IS NOT NULL`); + break; + + case "like": + conditions.push(`${column} LIKE $${paramIndex}`); + params.push(`%${condition.value}%`); + paramIndex++; + break; + + case "not_like": + conditions.push(`${column} NOT LIKE $${paramIndex}`); + params.push(`%${condition.value}%`); + paramIndex++; + break; + + default: + throw new Error(`Unsupported operator: ${condition.operator}`); + } + } + + if (conditions.length === 0) { + return { where: "1=1", params: [] }; + } + + const joinOperator = conditionGroup.type === "OR" ? " OR " : " AND "; + const where = conditions.join(joinOperator); + + return { where, params }; + } + + /** + * SQL 인젝션 방지를 위한 컬럼명 검증 + */ + private static sanitizeColumnName(columnName: string): string { + // 알파벳, 숫자, 언더스코어, 점(.)만 허용 (테이블명.컬럼명 형태 지원) + if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) { + throw new Error(`Invalid column name: ${columnName}`); + } + return columnName; + } + + /** + * 조건 검증 + */ + static validateConditionGroup(conditionGroup: FlowConditionGroup): void { + if (!conditionGroup) { + throw new Error("Condition group is required"); + } + + if (!["AND", "OR"].includes(conditionGroup.type)) { + throw new Error("Condition group type must be AND or OR"); + } + + if (!Array.isArray(conditionGroup.conditions)) { + throw new Error("Conditions must be an array"); + } + + for (const condition of conditionGroup.conditions) { + this.validateCondition(condition); + } + } + + /** + * 개별 조건 검증 + */ + private static validateCondition(condition: FlowCondition): void { + if (!condition.column) { + throw new Error("Column name is required"); + } + + const validOperators = [ + "equals", + "=", + "not_equals", + "!=", + "in", + "not_in", + "greater_than", + ">", + "less_than", + "<", + "greater_than_or_equal", + ">=", + "less_than_or_equal", + "<=", + "is_null", + "is_not_null", + "like", + "not_like", + ]; + + if (!validOperators.includes(condition.operator)) { + throw new Error(`Invalid operator: ${condition.operator}`); + } + + // is_null, is_not_null은 value가 필요 없음 + if (!["is_null", "is_not_null"].includes(condition.operator)) { + if (condition.value === undefined || condition.value === null) { + throw new Error( + `Value is required for operator: ${condition.operator}` + ); + } + } + + // in, not_in은 배열이어야 함 + if (["in", "not_in"].includes(condition.operator)) { + if (!Array.isArray(condition.value) || condition.value.length === 0) { + throw new Error( + `Operator ${condition.operator} requires a non-empty array value` + ); + } + } + } +} diff --git a/backend-node/src/services/flowConnectionService.ts b/backend-node/src/services/flowConnectionService.ts new file mode 100644 index 00000000..5b1f3d40 --- /dev/null +++ b/backend-node/src/services/flowConnectionService.ts @@ -0,0 +1,166 @@ +/** + * 플로우 연결 서비스 + */ + +import db from "../database/db"; +import { FlowStepConnection, CreateFlowConnectionRequest } from "../types/flow"; + +export class FlowConnectionService { + /** + * 플로우 단계 연결 생성 + */ + async create( + request: CreateFlowConnectionRequest + ): Promise { + // 순환 참조 체크 + if ( + await this.wouldCreateCycle( + request.flowDefinitionId, + request.fromStepId, + request.toStepId + ) + ) { + throw new Error( + "Creating this connection would create a cycle in the flow" + ); + } + + const query = ` + INSERT INTO flow_step_connection ( + flow_definition_id, from_step_id, to_step_id, label + ) + VALUES ($1, $2, $3, $4) + RETURNING * + `; + + const result = await db.query(query, [ + request.flowDefinitionId, + request.fromStepId, + request.toStepId, + request.label || null, + ]); + + return this.mapToFlowConnection(result[0]); + } + + /** + * 특정 플로우의 모든 연결 조회 + */ + async findByFlowId(flowDefinitionId: number): Promise { + const query = ` + SELECT * FROM flow_step_connection + WHERE flow_definition_id = $1 + ORDER BY id ASC + `; + + const result = await db.query(query, [flowDefinitionId]); + return result.map(this.mapToFlowConnection); + } + + /** + * 플로우 연결 단일 조회 + */ + async findById(id: number): Promise { + const query = "SELECT * FROM flow_step_connection WHERE id = $1"; + const result = await db.query(query, [id]); + + if (result.length === 0) { + return null; + } + + return this.mapToFlowConnection(result[0]); + } + + /** + * 플로우 연결 삭제 + */ + async delete(id: number): Promise { + const query = "DELETE FROM flow_step_connection WHERE id = $1 RETURNING id"; + const result = await db.query(query, [id]); + return result.length > 0; + } + + /** + * 특정 단계에서 나가는 연결 조회 + */ + async findOutgoingConnections(stepId: number): Promise { + const query = ` + SELECT * FROM flow_step_connection + WHERE from_step_id = $1 + ORDER BY id ASC + `; + + const result = await db.query(query, [stepId]); + return result.map(this.mapToFlowConnection); + } + + /** + * 특정 단계로 들어오는 연결 조회 + */ + async findIncomingConnections(stepId: number): Promise { + const query = ` + SELECT * FROM flow_step_connection + WHERE to_step_id = $1 + ORDER BY id ASC + `; + + const result = await db.query(query, [stepId]); + return result.map(this.mapToFlowConnection); + } + + /** + * 순환 참조 체크 (DFS) + */ + private async wouldCreateCycle( + flowDefinitionId: number, + fromStepId: number, + toStepId: number + ): Promise { + // toStepId에서 출발해서 fromStepId에 도달할 수 있는지 확인 + const visited = new Set(); + const stack = [toStepId]; + + while (stack.length > 0) { + const current = stack.pop()!; + + if (current === fromStepId) { + return true; // 순환 발견 + } + + if (visited.has(current)) { + continue; + } + + visited.add(current); + + // 현재 노드에서 나가는 모든 연결 조회 + const query = ` + SELECT to_step_id + FROM flow_step_connection + WHERE flow_definition_id = $1 AND from_step_id = $2 + `; + + const result = await db.query(query, [flowDefinitionId, current]); + + for (const row of result) { + stack.push(row.to_step_id); + } + } + + return false; // 순환 없음 + } + + /** + * DB 행을 FlowStepConnection 객체로 변환 + */ + private mapToFlowConnection(row: any): FlowStepConnection { + return { + id: row.id, + flowDefinitionId: row.flow_definition_id, + fromStepId: row.from_step_id, + toStepId: row.to_step_id, + label: row.label, + createdAt: row.created_at, + }; + } +} diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts new file mode 100644 index 00000000..9ed99548 --- /dev/null +++ b/backend-node/src/services/flowDataMoveService.ts @@ -0,0 +1,593 @@ +/** + * 플로우 데이터 이동 서비스 (하이브리드 방식 지원) + * - 상태 변경 방식: 같은 테이블 내에서 상태 컬럼 업데이트 + * - 테이블 이동 방식: 다른 테이블로 데이터 복사 및 매핑 + * - 하이브리드 방식: 두 가지 모두 수행 + */ + +import db from "../database/db"; +import { FlowAuditLog, FlowIntegrationContext } from "../types/flow"; +import { FlowDefinitionService } from "./flowDefinitionService"; +import { FlowStepService } from "./flowStepService"; +import { FlowExternalDbIntegrationService } from "./flowExternalDbIntegrationService"; + +export class FlowDataMoveService { + private flowDefinitionService: FlowDefinitionService; + private flowStepService: FlowStepService; + private externalDbIntegrationService: FlowExternalDbIntegrationService; + + constructor() { + this.flowDefinitionService = new FlowDefinitionService(); + this.flowStepService = new FlowStepService(); + this.externalDbIntegrationService = new FlowExternalDbIntegrationService(); + } + + /** + * 데이터를 다음 플로우 단계로 이동 (하이브리드 지원) + */ + async moveDataToStep( + flowId: number, + fromStepId: number, + toStepId: number, + dataId: any, + userId: string = "system", + additionalData?: Record + ): Promise<{ success: boolean; targetDataId?: any; message?: string }> { + return await db.transaction(async (client) => { + try { + // 1. 단계 정보 조회 + const fromStep = await this.flowStepService.findById(fromStepId); + const toStep = await this.flowStepService.findById(toStepId); + + if (!fromStep || !toStep) { + throw new Error("유효하지 않은 단계입니다"); + } + + let targetDataId = dataId; + let sourceTable = fromStep.tableName; + let targetTable = toStep.tableName || fromStep.tableName; + + // 2. 이동 방식에 따라 처리 + switch (toStep.moveType || "status") { + case "status": + // 상태 변경 방식 + await this.moveByStatusChange( + client, + fromStep, + toStep, + dataId, + additionalData + ); + break; + + case "table": + // 테이블 이동 방식 + targetDataId = await this.moveByTableTransfer( + client, + fromStep, + toStep, + dataId, + additionalData + ); + targetTable = toStep.targetTable || toStep.tableName; + break; + + case "both": + // 하이브리드 방식: 둘 다 수행 + await this.moveByStatusChange( + client, + fromStep, + toStep, + dataId, + additionalData + ); + targetDataId = await this.moveByTableTransfer( + client, + fromStep, + toStep, + dataId, + additionalData + ); + targetTable = toStep.targetTable || toStep.tableName; + break; + + default: + throw new Error(`지원하지 않는 이동 방식: ${toStep.moveType}`); + } + + // 3. 매핑 테이블 업데이트 (테이블 이동 방식일 때) + if (toStep.moveType === "table" || toStep.moveType === "both") { + await this.updateDataMapping( + client, + flowId, + toStepId, + fromStepId, + dataId, + targetDataId + ); + } + + // 4. 외부 DB 연동 실행 (설정된 경우) + if ( + toStep.integrationType && + toStep.integrationType !== "internal" && + toStep.integrationConfig + ) { + await this.executeExternalIntegration( + toStep, + flowId, + targetDataId, + sourceTable, + userId, + additionalData + ); + } + + // 5. 감사 로그 기록 + await this.logDataMove(client, { + flowId, + fromStepId, + toStepId, + moveType: toStep.moveType || "status", + sourceTable, + targetTable, + sourceDataId: String(dataId), + targetDataId: String(targetDataId), + statusFrom: fromStep.statusValue, + statusTo: toStep.statusValue, + userId, + }); + + return { + success: true, + targetDataId, + message: "데이터가 성공적으로 이동되었습니다", + }; + } catch (error: any) { + console.error("데이터 이동 실패:", error); + throw error; + } + }); + } + + /** + * 상태 변경 방식으로 데이터 이동 + */ + private async moveByStatusChange( + client: any, + fromStep: any, + toStep: any, + dataId: any, + additionalData?: Record + ): Promise { + const statusColumn = toStep.statusColumn || "flow_status"; + const tableName = fromStep.tableName; + + // 추가 필드 업데이트 준비 + const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`]; + const values: any[] = [dataId, toStep.statusValue]; + let paramIndex = 3; + + // 추가 데이터가 있으면 함께 업데이트 + if (additionalData) { + for (const [key, value] of Object.entries(additionalData)) { + updates.push(`${key} = $${paramIndex}`); + values.push(value); + paramIndex++; + } + } + + const updateQuery = ` + UPDATE ${tableName} + SET ${updates.join(", ")} + WHERE id = $1 + `; + + const result = await client.query(updateQuery, values); + + if (result.rowCount === 0) { + throw new Error(`데이터를 찾을 수 없습니다: ${dataId}`); + } + } + + /** + * 테이블 이동 방식으로 데이터 이동 + */ + private async moveByTableTransfer( + client: any, + fromStep: any, + toStep: any, + dataId: any, + additionalData?: Record + ): Promise { + const sourceTable = fromStep.tableName; + const targetTable = toStep.targetTable || toStep.tableName; + const fieldMappings = toStep.fieldMappings || {}; + + // 1. 소스 데이터 조회 + const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`; + const sourceResult = await client.query(selectQuery, [dataId]); + + if (sourceResult.length === 0) { + throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`); + } + + const sourceData = sourceResult[0]; + + // 2. 필드 매핑 적용 + const mappedData: Record = {}; + + // 매핑 정의가 있으면 적용 + for (const [sourceField, targetField] of Object.entries(fieldMappings)) { + if (sourceData[sourceField] !== undefined) { + mappedData[targetField as string] = sourceData[sourceField]; + } + } + + // 추가 데이터 병합 + if (additionalData) { + Object.assign(mappedData, additionalData); + } + + // 3. 타겟 테이블에 데이터 삽입 + if (Object.keys(mappedData).length === 0) { + throw new Error("매핑할 데이터가 없습니다"); + } + + const columns = Object.keys(mappedData); + const values = Object.values(mappedData); + const placeholders = columns.map((_, i) => `$${i + 1}`).join(", "); + + const insertQuery = ` + INSERT INTO ${targetTable} (${columns.join(", ")}) + VALUES (${placeholders}) + RETURNING id + `; + + const insertResult = await client.query(insertQuery, values); + return insertResult[0].id; + } + + /** + * 데이터 매핑 테이블 업데이트 + */ + private async updateDataMapping( + client: any, + flowId: number, + currentStepId: number, + prevStepId: number, + sourceDataId: any, + targetDataId: any + ): Promise { + // 기존 매핑 조회 + const selectQuery = ` + SELECT id, step_data_map + FROM flow_data_mapping + WHERE flow_definition_id = $1 + AND step_data_map->$2 = $3 + `; + const mappingResult = await client.query(selectQuery, [ + flowId, + String(prevStepId), + JSON.stringify(String(sourceDataId)), + ]); + + const stepDataMap: Record = + mappingResult.length > 0 ? mappingResult[0].step_data_map : {}; + + // 새 단계 데이터 추가 + stepDataMap[String(currentStepId)] = String(targetDataId); + + if (mappingResult.length > 0) { + // 기존 매핑 업데이트 + const updateQuery = ` + UPDATE flow_data_mapping + SET current_step_id = $1, + step_data_map = $2, + updated_at = NOW() + WHERE id = $3 + `; + await client.query(updateQuery, [ + currentStepId, + JSON.stringify(stepDataMap), + mappingResult[0].id, + ]); + } else { + // 새 매핑 생성 + const insertQuery = ` + INSERT INTO flow_data_mapping + (flow_definition_id, current_step_id, step_data_map) + VALUES ($1, $2, $3) + `; + await client.query(insertQuery, [ + flowId, + currentStepId, + JSON.stringify(stepDataMap), + ]); + } + } + + /** + * 감사 로그 기록 + */ + private async logDataMove(client: any, params: any): Promise { + const query = ` + INSERT INTO flow_audit_log ( + flow_definition_id, from_step_id, to_step_id, + move_type, source_table, target_table, + source_data_id, target_data_id, + status_from, status_to, + changed_by, note + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + `; + + await client.query(query, [ + params.flowId, + params.fromStepId, + params.toStepId, + params.moveType, + params.sourceTable, + params.targetTable, + params.sourceDataId, + params.targetDataId, + params.statusFrom, + params.statusTo, + params.userId, + params.note || null, + ]); + } + + /** + * 여러 데이터를 동시에 다음 단계로 이동 + */ + async moveBatchData( + flowId: number, + fromStepId: number, + toStepId: number, + dataIds: any[], + userId: string = "system" + ): Promise<{ success: boolean; results: any[] }> { + const results = []; + + for (const dataId of dataIds) { + try { + const result = await this.moveDataToStep( + flowId, + fromStepId, + toStepId, + dataId, + userId + ); + results.push({ dataId, ...result }); + } catch (error: any) { + results.push({ dataId, success: false, message: error.message }); + } + } + + return { + success: results.every((r) => r.success), + results, + }; + } + + /** + * 데이터의 플로우 이력 조회 + */ + async getAuditLogs(flowId: number, dataId: string): Promise { + const query = ` + SELECT + fal.*, + fs_from.step_name as from_step_name, + fs_to.step_name as to_step_name + FROM flow_audit_log fal + LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id + LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id + WHERE fal.flow_definition_id = $1 + AND (fal.source_data_id = $2 OR fal.target_data_id = $2) + ORDER BY fal.changed_at DESC + `; + + const result = await db.query(query, [flowId, dataId]); + + return result.map((row) => ({ + id: row.id, + flowDefinitionId: row.flow_definition_id, + tableName: row.table_name || row.source_table, + recordId: row.record_id || row.source_data_id, + fromStepId: row.from_step_id, + toStepId: row.to_step_id, + changedBy: row.changed_by, + changedAt: row.changed_at, + note: row.note, + fromStepName: row.from_step_name, + toStepName: row.to_step_name, + moveType: row.move_type, + sourceTable: row.source_table, + targetTable: row.target_table, + sourceDataId: row.source_data_id, + targetDataId: row.target_data_id, + statusFrom: row.status_from, + statusTo: row.status_to, + })); + } + + /** + * 특정 플로우의 모든 이력 조회 + */ + async getFlowAuditLogs( + flowId: number, + limit: number = 100 + ): Promise { + const query = ` + SELECT + fal.*, + fs_from.step_name as from_step_name, + fs_to.step_name as to_step_name + FROM flow_audit_log fal + LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id + LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id + WHERE fal.flow_definition_id = $1 + ORDER BY fal.changed_at DESC + LIMIT $2 + `; + + const result = await db.query(query, [flowId, limit]); + + return result.map((row) => ({ + id: row.id, + flowDefinitionId: row.flow_definition_id, + tableName: row.table_name || row.source_table, + recordId: row.record_id || row.source_data_id, + fromStepId: row.from_step_id, + toStepId: row.to_step_id, + changedBy: row.changed_by, + changedAt: row.changed_at, + note: row.note, + fromStepName: row.from_step_name, + toStepName: row.to_step_name, + moveType: row.move_type, + sourceTable: row.source_table, + targetTable: row.target_table, + sourceDataId: row.source_data_id, + targetDataId: row.target_data_id, + statusFrom: row.status_from, + statusTo: row.status_to, + })); + } + + /** + * 외부 DB 연동 실행 + */ + private async executeExternalIntegration( + toStep: any, + flowId: number, + dataId: any, + tableName: string | undefined, + userId: string, + additionalData?: Record + ): Promise { + const startTime = Date.now(); + + try { + // 연동 컨텍스트 구성 + const context: FlowIntegrationContext = { + flowId, + stepId: toStep.id, + dataId, + tableName, + currentUser: userId, + variables: { + ...additionalData, + stepName: toStep.stepName, + stepId: toStep.id, + }, + }; + + // 연동 타입별 처리 + switch (toStep.integrationType) { + case "external_db": + const result = await this.externalDbIntegrationService.execute( + context, + toStep.integrationConfig + ); + + // 연동 로그 기록 + await this.logIntegration( + flowId, + toStep.id, + dataId, + toStep.integrationType, + toStep.integrationConfig.connectionId, + toStep.integrationConfig, + result.data, + result.success ? "success" : "failed", + result.error?.message, + Date.now() - startTime, + userId + ); + + if (!result.success) { + throw new Error( + `외부 DB 연동 실패: ${result.error?.message || "알 수 없는 오류"}` + ); + } + break; + + case "rest_api": + // REST API 연동 (추후 구현) + console.warn("REST API 연동은 아직 구현되지 않았습니다"); + break; + + case "webhook": + // Webhook 연동 (추후 구현) + console.warn("Webhook 연동은 아직 구현되지 않았습니다"); + break; + + case "hybrid": + // 복합 연동 (추후 구현) + console.warn("복합 연동은 아직 구현되지 않았습니다"); + break; + + default: + throw new Error(`지원하지 않는 연동 타입: ${toStep.integrationType}`); + } + } catch (error: any) { + console.error("외부 연동 실행 실패:", error); + // 연동 실패 로그 기록 + await this.logIntegration( + flowId, + toStep.id, + dataId, + toStep.integrationType, + toStep.integrationConfig?.connectionId, + toStep.integrationConfig, + null, + "failed", + error.message, + Date.now() - startTime, + userId + ); + throw error; + } + } + + /** + * 외부 연동 로그 기록 + */ + private async logIntegration( + flowId: number, + stepId: number, + dataId: any, + integrationType: string, + connectionId: number | undefined, + requestPayload: any, + responsePayload: any, + status: "success" | "failed" | "timeout" | "rollback", + errorMessage: string | undefined, + executionTimeMs: number, + userId: string + ): Promise { + const query = ` + INSERT INTO flow_integration_log ( + flow_definition_id, step_id, data_id, integration_type, connection_id, + request_payload, response_payload, status, error_message, + execution_time_ms, executed_by, executed_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) + `; + + await db.query(query, [ + flowId, + stepId, + String(dataId), + integrationType, + connectionId || null, + requestPayload ? JSON.stringify(requestPayload) : null, + responsePayload ? JSON.stringify(responsePayload) : null, + status, + errorMessage || null, + executionTimeMs, + userId, + ]); + } +} diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts new file mode 100644 index 00000000..859e0792 --- /dev/null +++ b/backend-node/src/services/flowDefinitionService.ts @@ -0,0 +1,171 @@ +/** + * 플로우 정의 서비스 + */ + +import db from "../database/db"; +import { + FlowDefinition, + CreateFlowDefinitionRequest, + UpdateFlowDefinitionRequest, +} from "../types/flow"; + +export class FlowDefinitionService { + /** + * 플로우 정의 생성 + */ + async create( + request: CreateFlowDefinitionRequest, + userId: string + ): Promise { + const query = ` + INSERT INTO flow_definition (name, description, table_name, created_by) + VALUES ($1, $2, $3, $4) + RETURNING * + `; + + const result = await db.query(query, [ + request.name, + request.description || null, + request.tableName, + userId, + ]); + + return this.mapToFlowDefinition(result[0]); + } + + /** + * 플로우 정의 목록 조회 + */ + async findAll( + tableName?: string, + isActive?: boolean + ): Promise { + let query = "SELECT * FROM flow_definition WHERE 1=1"; + const params: any[] = []; + let paramIndex = 1; + + if (tableName) { + query += ` AND table_name = $${paramIndex}`; + params.push(tableName); + paramIndex++; + } + + if (isActive !== undefined) { + query += ` AND is_active = $${paramIndex}`; + params.push(isActive); + paramIndex++; + } + + query += " ORDER BY created_at DESC"; + + const result = await db.query(query, params); + return result.map(this.mapToFlowDefinition); + } + + /** + * 플로우 정의 단일 조회 + */ + async findById(id: number): Promise { + const query = "SELECT * FROM flow_definition WHERE id = $1"; + const result = await db.query(query, [id]); + + if (result.length === 0) { + return null; + } + + return this.mapToFlowDefinition(result[0]); + } + + /** + * 플로우 정의 수정 + */ + async update( + id: number, + request: UpdateFlowDefinitionRequest + ): Promise { + const fields: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (request.name !== undefined) { + fields.push(`name = $${paramIndex}`); + params.push(request.name); + paramIndex++; + } + + if (request.description !== undefined) { + fields.push(`description = $${paramIndex}`); + params.push(request.description); + paramIndex++; + } + + if (request.isActive !== undefined) { + fields.push(`is_active = $${paramIndex}`); + params.push(request.isActive); + paramIndex++; + } + + if (fields.length === 0) { + return this.findById(id); + } + + fields.push(`updated_at = NOW()`); + + const query = ` + UPDATE flow_definition + SET ${fields.join(", ")} + WHERE id = $${paramIndex} + RETURNING * + `; + params.push(id); + + const result = await db.query(query, params); + + if (result.length === 0) { + return null; + } + + return this.mapToFlowDefinition(result[0]); + } + + /** + * 플로우 정의 삭제 + */ + async delete(id: number): Promise { + const query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id"; + const result = await db.query(query, [id]); + return result.length > 0; + } + + /** + * 테이블 존재 여부 확인 + */ + async checkTableExists(tableName: string): Promise { + const query = ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ) as exists + `; + + const result = await db.query(query, [tableName]); + return result[0].exists; + } + + /** + * DB 행을 FlowDefinition 객체로 변환 + */ + private mapToFlowDefinition(row: any): FlowDefinition { + return { + id: row.id, + name: row.name, + description: row.description, + tableName: row.table_name, + isActive: row.is_active, + createdBy: row.created_by, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts new file mode 100644 index 00000000..ae4f1369 --- /dev/null +++ b/backend-node/src/services/flowExecutionService.ts @@ -0,0 +1,176 @@ +/** + * 플로우 실행 서비스 + * 단계별 데이터 카운트 및 리스트 조회 + */ + +import db from "../database/db"; +import { FlowStepDataCount, FlowStepDataList } from "../types/flow"; +import { FlowDefinitionService } from "./flowDefinitionService"; +import { FlowStepService } from "./flowStepService"; +import { FlowConditionParser } from "./flowConditionParser"; + +export class FlowExecutionService { + private flowDefinitionService: FlowDefinitionService; + private flowStepService: FlowStepService; + + constructor() { + this.flowDefinitionService = new FlowDefinitionService(); + this.flowStepService = new FlowStepService(); + } + + /** + * 특정 플로우 단계에 해당하는 데이터 카운트 + */ + async getStepDataCount(flowId: number, stepId: number): Promise { + // 1. 플로우 정의 조회 + const flowDef = await this.flowDefinitionService.findById(flowId); + if (!flowDef) { + throw new Error(`Flow definition not found: ${flowId}`); + } + + // 2. 플로우 단계 조회 + const step = await this.flowStepService.findById(stepId); + if (!step) { + throw new Error(`Flow step not found: ${stepId}`); + } + + if (step.flowDefinitionId !== flowId) { + throw new Error(`Step ${stepId} does not belong to flow ${flowId}`); + } + + // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 + const tableName = step.tableName || flowDef.tableName; + + // 4. 조건 JSON을 SQL WHERE절로 변환 + const { where, params } = FlowConditionParser.toSqlWhere( + step.conditionJson + ); + + // 5. 카운트 쿼리 실행 + const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`; + const result = await db.query(query, params); + + return parseInt(result[0].count); + } + + /** + * 특정 플로우 단계에 해당하는 데이터 리스트 + */ + async getStepDataList( + flowId: number, + stepId: number, + page: number = 1, + pageSize: number = 20 + ): Promise { + // 1. 플로우 정의 조회 + const flowDef = await this.flowDefinitionService.findById(flowId); + if (!flowDef) { + throw new Error(`Flow definition not found: ${flowId}`); + } + + // 2. 플로우 단계 조회 + const step = await this.flowStepService.findById(stepId); + if (!step) { + throw new Error(`Flow step not found: ${stepId}`); + } + + if (step.flowDefinitionId !== flowId) { + throw new Error(`Step ${stepId} does not belong to flow ${flowId}`); + } + + // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 + const tableName = step.tableName || flowDef.tableName; + + // 4. 조건 JSON을 SQL WHERE절로 변환 + const { where, params } = FlowConditionParser.toSqlWhere( + step.conditionJson + ); + + const offset = (page - 1) * pageSize; + + // 5. 전체 카운트 + const countQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`; + const countResult = await db.query(countQuery, params); + const total = parseInt(countResult[0].count); + + // 6. 테이블의 Primary Key 컬럼 찾기 + let orderByColumn = ""; + try { + const pkQuery = ` + SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass + AND i.indisprimary + LIMIT 1 + `; + const pkResult = await db.query(pkQuery, [tableName]); + if (pkResult.length > 0) { + orderByColumn = pkResult[0].attname; + } + } catch (err) { + // Primary Key를 찾지 못하면 ORDER BY 없이 진행 + console.warn(`Could not find primary key for table ${tableName}:`, err); + } + + // 7. 데이터 조회 + const orderByClause = orderByColumn ? `ORDER BY ${orderByColumn} DESC` : ""; + const dataQuery = ` + SELECT * FROM ${tableName} + WHERE ${where} + ${orderByClause} + LIMIT $${params.length + 1} OFFSET $${params.length + 2} + `; + const dataResult = await db.query(dataQuery, [...params, pageSize, offset]); + + return { + records: dataResult, + total, + page, + pageSize, + }; + } + + /** + * 플로우의 모든 단계별 데이터 카운트 + */ + async getAllStepCounts(flowId: number): Promise { + const steps = await this.flowStepService.findByFlowId(flowId); + const counts: FlowStepDataCount[] = []; + + for (const step of steps) { + const count = await this.getStepDataCount(flowId, step.id); + counts.push({ + stepId: step.id, + count, + }); + } + + return counts; + } + + /** + * 특정 레코드의 현재 플로우 상태 조회 + */ + async getCurrentStatus( + flowId: number, + recordId: string + ): Promise<{ currentStepId: number | null; tableName: string } | null> { + const query = ` + SELECT current_step_id, table_name + FROM flow_data_status + WHERE flow_definition_id = $1 AND record_id = $2 + `; + + const result = await db.query(query, [flowId, recordId]); + + if (result.length === 0) { + return null; + } + + return { + currentStepId: result[0].current_step_id, + tableName: result[0].table_name, + }; + } +} diff --git a/backend-node/src/services/flowExternalDbConnectionService.ts b/backend-node/src/services/flowExternalDbConnectionService.ts new file mode 100644 index 00000000..e12a81a2 --- /dev/null +++ b/backend-node/src/services/flowExternalDbConnectionService.ts @@ -0,0 +1,436 @@ +import db from "../database/db"; +import { + FlowExternalDbConnection, + CreateFlowExternalDbConnectionRequest, + UpdateFlowExternalDbConnectionRequest, +} from "../types/flow"; +import { CredentialEncryption } from "../utils/credentialEncryption"; +import { Pool } from "pg"; +// import mysql from 'mysql2/promise'; // MySQL용 (추후) +// import { ConnectionPool } from 'mssql'; // MSSQL용 (추후) + +/** + * 플로우 전용 외부 DB 연결 관리 서비스 + * (기존 제어관리 외부 DB 연결과 별도) + */ +export class FlowExternalDbConnectionService { + private encryption: CredentialEncryption; + private connectionPools: Map = new Map(); + + constructor() { + // 환경 변수에서 SECRET_KEY를 가져오거나 기본값 설정 + const secretKey = + process.env.SECRET_KEY || "flow-external-db-secret-key-2025"; + this.encryption = new CredentialEncryption(secretKey); + } + + /** + * 외부 DB 연결 생성 + */ + async create( + request: CreateFlowExternalDbConnectionRequest, + userId: string = "system" + ): Promise { + // 비밀번호 암호화 + const encryptedPassword = this.encryption.encrypt(request.password); + + const query = ` + INSERT INTO flow_external_db_connection ( + name, description, db_type, host, port, database_name, username, + password_encrypted, ssl_enabled, connection_options, created_by, updated_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING * + `; + + const result = await db.query(query, [ + request.name, + request.description || null, + request.dbType, + request.host, + request.port, + request.databaseName, + request.username, + encryptedPassword, + request.sslEnabled || false, + request.connectionOptions + ? JSON.stringify(request.connectionOptions) + : null, + userId, + userId, + ]); + + return this.mapToFlowExternalDbConnection(result[0]); + } + + /** + * ID로 외부 DB 연결 조회 + */ + async findById(id: number): Promise { + const query = "SELECT * FROM flow_external_db_connection WHERE id = $1"; + const result = await db.query(query, [id]); + + if (result.length === 0) { + return null; + } + + return this.mapToFlowExternalDbConnection(result[0]); + } + + /** + * 모든 외부 DB 연결 조회 + */ + async findAll( + activeOnly: boolean = false + ): Promise { + let query = "SELECT * FROM flow_external_db_connection"; + if (activeOnly) { + query += " WHERE is_active = true"; + } + query += " ORDER BY name ASC"; + + const result = await db.query(query); + return result.map((row) => this.mapToFlowExternalDbConnection(row)); + } + + /** + * 외부 DB 연결 수정 + */ + async update( + id: number, + request: UpdateFlowExternalDbConnectionRequest, + userId: string = "system" + ): Promise { + const fields: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (request.name !== undefined) { + fields.push(`name = $${paramIndex}`); + params.push(request.name); + paramIndex++; + } + + if (request.description !== undefined) { + fields.push(`description = $${paramIndex}`); + params.push(request.description); + paramIndex++; + } + + if (request.host !== undefined) { + fields.push(`host = $${paramIndex}`); + params.push(request.host); + paramIndex++; + } + + if (request.port !== undefined) { + fields.push(`port = $${paramIndex}`); + params.push(request.port); + paramIndex++; + } + + if (request.databaseName !== undefined) { + fields.push(`database_name = $${paramIndex}`); + params.push(request.databaseName); + paramIndex++; + } + + if (request.username !== undefined) { + fields.push(`username = $${paramIndex}`); + params.push(request.username); + paramIndex++; + } + + if (request.password !== undefined) { + const encryptedPassword = this.encryption.encrypt(request.password); + fields.push(`password_encrypted = $${paramIndex}`); + params.push(encryptedPassword); + paramIndex++; + } + + if (request.sslEnabled !== undefined) { + fields.push(`ssl_enabled = $${paramIndex}`); + params.push(request.sslEnabled); + paramIndex++; + } + + if (request.connectionOptions !== undefined) { + fields.push(`connection_options = $${paramIndex}`); + params.push( + request.connectionOptions + ? JSON.stringify(request.connectionOptions) + : null + ); + paramIndex++; + } + + if (request.isActive !== undefined) { + fields.push(`is_active = $${paramIndex}`); + params.push(request.isActive); + paramIndex++; + } + + if (fields.length === 0) { + return this.findById(id); + } + + fields.push(`updated_by = $${paramIndex}`); + params.push(userId); + paramIndex++; + + fields.push(`updated_at = NOW()`); + + const query = ` + UPDATE flow_external_db_connection + SET ${fields.join(", ")} + WHERE id = $${paramIndex} + RETURNING * + `; + params.push(id); + + const result = await db.query(query, params); + + if (result.length === 0) { + return null; + } + + // 연결 풀 갱신 (비밀번호 변경 시) + if (request.password !== undefined || request.host !== undefined) { + this.closeConnection(id); + } + + return this.mapToFlowExternalDbConnection(result[0]); + } + + /** + * 외부 DB 연결 삭제 + */ + async delete(id: number): Promise { + // 연결 풀 정리 + this.closeConnection(id); + + const query = + "DELETE FROM flow_external_db_connection WHERE id = $1 RETURNING id"; + const result = await db.query(query, [id]); + return result.length > 0; + } + + /** + * 연결 테스트 + */ + async testConnection( + id: number + ): Promise<{ success: boolean; message: string }> { + try { + const connection = await this.findById(id); + if (!connection) { + return { success: false, message: "연결 정보를 찾을 수 없습니다." }; + } + + const pool = await this.getConnectionPool(connection); + + // 간단한 쿼리로 연결 테스트 + const client = await pool.connect(); + try { + await client.query("SELECT 1"); + return { success: true, message: "연결 성공" }; + } finally { + client.release(); + } + } catch (error: any) { + return { success: false, message: error.message }; + } + } + + /** + * 외부 DB의 테이블 목록 조회 + */ + async getTables( + id: number + ): Promise<{ success: boolean; data?: string[]; message?: string }> { + try { + const connection = await this.findById(id); + if (!connection) { + return { success: false, message: "연결 정보를 찾을 수 없습니다." }; + } + + const pool = await this.getConnectionPool(connection); + const client = await pool.connect(); + + try { + let query: string; + switch (connection.dbType) { + case "postgresql": + query = + "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"; + break; + case "mysql": + query = `SELECT table_name as tablename FROM information_schema.tables WHERE table_schema = '${connection.databaseName}' ORDER BY table_name`; + break; + default: + return { + success: false, + message: `지원하지 않는 DB 타입: ${connection.dbType}`, + }; + } + + const result = await client.query(query); + const tables = result.rows.map((row: any) => row.tablename); + + return { success: true, data: tables }; + } finally { + client.release(); + } + } catch (error: any) { + return { success: false, message: error.message }; + } + } + + /** + * 외부 DB의 특정 테이블 컬럼 목록 조회 + */ + async getTableColumns( + id: number, + tableName: string + ): Promise<{ + success: boolean; + data?: { column_name: string; data_type: string }[]; + message?: string; + }> { + try { + const connection = await this.findById(id); + if (!connection) { + return { success: false, message: "연결 정보를 찾을 수 없습니다." }; + } + + const pool = await this.getConnectionPool(connection); + const client = await pool.connect(); + + try { + let query: string; + switch (connection.dbType) { + case "postgresql": + query = `SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position`; + break; + case "mysql": + query = `SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = '${connection.databaseName}' AND table_name = ? + ORDER BY ordinal_position`; + break; + default: + return { + success: false, + message: `지원하지 않는 DB 타입: ${connection.dbType}`, + }; + } + + const result = await client.query(query, [tableName]); + + return { success: true, data: result.rows }; + } finally { + client.release(); + } + } catch (error: any) { + return { success: false, message: error.message }; + } + } + + /** + * 연결 풀 가져오기 (캐싱) + */ + async getConnectionPool(connection: FlowExternalDbConnection): Promise { + if (this.connectionPools.has(connection.id)) { + return this.connectionPools.get(connection.id)!; + } + + // 비밀번호 복호화 + const decryptedPassword = this.encryption.decrypt( + connection.passwordEncrypted + ); + + let pool: Pool; + switch (connection.dbType) { + case "postgresql": + pool = new Pool({ + host: connection.host, + port: connection.port, + database: connection.databaseName, + user: connection.username, + password: decryptedPassword, + ssl: connection.sslEnabled, + // 연결 풀 설정 (고갈 방지) + max: 10, // 최대 연결 수 + min: 2, // 최소 연결 수 + idleTimeoutMillis: 30000, // 30초 유휴 시간 후 연결 해제 + connectionTimeoutMillis: 10000, // 10초 연결 타임아웃 + ...(connection.connectionOptions || {}), + }); + + // 에러 핸들러 등록 + pool.on("error", (err) => { + console.error(`외부 DB 연결 풀 오류 (ID: ${connection.id}):`, err); + }); + break; + // case "mysql": + // pool = mysql.createPool({ ... }); + // break; + // case "mssql": + // pool = new ConnectionPool({ ... }); + // break; + default: + throw new Error(`지원하지 않는 DB 타입: ${connection.dbType}`); + } + + this.connectionPools.set(connection.id, pool); + return pool; + } + + /** + * 연결 풀 정리 + */ + closeConnection(id: number): void { + const pool = this.connectionPools.get(id); + if (pool) { + pool.end(); + this.connectionPools.delete(id); + } + } + + /** + * 모든 연결 풀 정리 + */ + closeAllConnections(): void { + for (const [id, pool] of this.connectionPools.entries()) { + pool.end(); + } + this.connectionPools.clear(); + } + + /** + * DB row를 FlowExternalDbConnection으로 매핑 + */ + private mapToFlowExternalDbConnection(row: any): FlowExternalDbConnection { + return { + id: row.id, + name: row.name, + description: row.description || undefined, + dbType: row.db_type, + host: row.host, + port: row.port, + databaseName: row.database_name, + username: row.username, + passwordEncrypted: row.password_encrypted, + sslEnabled: row.ssl_enabled, + connectionOptions: row.connection_options || undefined, + isActive: row.is_active, + createdBy: row.created_by || undefined, + updatedBy: row.updated_by || undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/backend-node/src/services/flowExternalDbIntegrationService.ts b/backend-node/src/services/flowExternalDbIntegrationService.ts new file mode 100644 index 00000000..80c8e4b1 --- /dev/null +++ b/backend-node/src/services/flowExternalDbIntegrationService.ts @@ -0,0 +1,353 @@ +import { + FlowExternalDbIntegrationConfig, + FlowIntegrationContext, + FlowIntegrationResult, +} from "../types/flow"; +import { FlowExternalDbConnectionService } from "./flowExternalDbConnectionService"; +import { Pool } from "pg"; + +/** + * 플로우 외부 DB 연동 실행 서비스 + * 외부 데이터베이스에 대한 작업(INSERT, UPDATE, DELETE, CUSTOM QUERY) 수행 + */ +export class FlowExternalDbIntegrationService { + private connectionService: FlowExternalDbConnectionService; + + constructor() { + this.connectionService = new FlowExternalDbConnectionService(); + } + + /** + * 외부 DB 연동 실행 + */ + async execute( + context: FlowIntegrationContext, + config: FlowExternalDbIntegrationConfig + ): Promise { + const startTime = Date.now(); + + try { + // 1. 연결 정보 조회 + const connection = await this.connectionService.findById( + config.connectionId + ); + if (!connection) { + return { + success: false, + error: { + code: "CONNECTION_NOT_FOUND", + message: `외부 DB 연결 정보를 찾을 수 없습니다 (ID: ${config.connectionId})`, + }, + }; + } + + if (!connection.isActive) { + return { + success: false, + error: { + code: "CONNECTION_INACTIVE", + message: `외부 DB 연결이 비활성화 상태입니다 (${connection.name})`, + }, + }; + } + + // 2. 쿼리 생성 (템플릿 변수 치환) + const query = this.buildQuery(config, context); + + // 3. 실행 + const pool = await this.connectionService.getConnectionPool(connection); + const result = await this.executeQuery(pool, query); + + const executionTime = Date.now() - startTime; + + return { + success: true, + message: `외부 DB 작업 성공 (${config.operation}, ${executionTime}ms)`, + data: result, + rollbackInfo: { + query: this.buildRollbackQuery(config, context, result), + connectionId: config.connectionId, + }, + }; + } catch (error: any) { + const executionTime = Date.now() - startTime; + + return { + success: false, + error: { + code: "EXTERNAL_DB_ERROR", + message: error.message || "외부 DB 작업 실패", + details: { + operation: config.operation, + tableName: config.tableName, + executionTime, + originalError: error, + }, + }, + }; + } + } + + /** + * 쿼리 실행 + */ + private async executeQuery( + pool: Pool, + query: { sql: string; params: any[] } + ): Promise { + const client = await pool.connect(); + try { + const result = await client.query(query.sql, query.params); + return result.rows; + } finally { + client.release(); + } + } + + /** + * 쿼리 빌드 (템플릿 변수 치환 포함) + */ + private buildQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext + ): { sql: string; params: any[] } { + let sql = ""; + const params: any[] = []; + let paramIndex = 1; + + switch (config.operation) { + case "update": + return this.buildUpdateQuery(config, context, paramIndex); + case "insert": + return this.buildInsertQuery(config, context, paramIndex); + case "delete": + return this.buildDeleteQuery(config, context, paramIndex); + case "custom": + return this.buildCustomQuery(config, context); + default: + throw new Error(`지원하지 않는 작업: ${config.operation}`); + } + } + + /** + * UPDATE 쿼리 빌드 + */ + private buildUpdateQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext, + startIndex: number + ): { sql: string; params: any[] } { + if (!config.updateFields || Object.keys(config.updateFields).length === 0) { + throw new Error("UPDATE 작업에는 updateFields가 필요합니다"); + } + + if ( + !config.whereCondition || + Object.keys(config.whereCondition).length === 0 + ) { + throw new Error("UPDATE 작업에는 whereCondition이 필요합니다"); + } + + const setClauses: string[] = []; + const params: any[] = []; + let paramIndex = startIndex; + + // SET 절 생성 + for (const [key, value] of Object.entries(config.updateFields)) { + setClauses.push(`${key} = $${paramIndex}`); + params.push(this.replaceVariables(value, context)); + paramIndex++; + } + + // WHERE 절 생성 + const whereClauses: string[] = []; + for (const [key, value] of Object.entries(config.whereCondition)) { + whereClauses.push(`${key} = $${paramIndex}`); + params.push(this.replaceVariables(value, context)); + paramIndex++; + } + + const sql = `UPDATE ${config.tableName} SET ${setClauses.join(", ")} WHERE ${whereClauses.join(" AND ")}`; + + return { sql, params }; + } + + /** + * INSERT 쿼리 빌드 + */ + private buildInsertQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext, + startIndex: number + ): { sql: string; params: any[] } { + if (!config.updateFields || Object.keys(config.updateFields).length === 0) { + throw new Error("INSERT 작업에는 updateFields가 필요합니다"); + } + + const columns: string[] = []; + const placeholders: string[] = []; + const params: any[] = []; + let paramIndex = startIndex; + + for (const [key, value] of Object.entries(config.updateFields)) { + columns.push(key); + placeholders.push(`$${paramIndex}`); + params.push(this.replaceVariables(value, context)); + paramIndex++; + } + + const sql = `INSERT INTO ${config.tableName} (${columns.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`; + + return { sql, params }; + } + + /** + * DELETE 쿼리 빌드 + */ + private buildDeleteQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext, + startIndex: number + ): { sql: string; params: any[] } { + if ( + !config.whereCondition || + Object.keys(config.whereCondition).length === 0 + ) { + throw new Error("DELETE 작업에는 whereCondition이 필요합니다"); + } + + const whereClauses: string[] = []; + const params: any[] = []; + let paramIndex = startIndex; + + for (const [key, value] of Object.entries(config.whereCondition)) { + whereClauses.push(`${key} = $${paramIndex}`); + params.push(this.replaceVariables(value, context)); + paramIndex++; + } + + const sql = `DELETE FROM ${config.tableName} WHERE ${whereClauses.join(" AND ")}`; + + return { sql, params }; + } + + /** + * CUSTOM 쿼리 빌드 + */ + private buildCustomQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext + ): { sql: string; params: any[] } { + if (!config.customQuery) { + throw new Error("CUSTOM 작업에는 customQuery가 필요합니다"); + } + + // 템플릿 변수 치환 + const sql = this.replaceVariables(config.customQuery, context); + + // 커스텀 쿼리는 파라미터를 직접 관리 + // 보안을 위해 가능하면 파라미터 바인딩 사용 권장 + return { sql, params: [] }; + } + + /** + * 템플릿 변수 치환 + */ + private replaceVariables(value: any, context: FlowIntegrationContext): any { + if (typeof value !== "string") { + return value; + } + + let result = value; + + // {{dataId}} 치환 + result = result.replace(/\{\{dataId\}\}/g, String(context.dataId)); + + // {{currentUser}} 치환 + result = result.replace(/\{\{currentUser\}\}/g, context.currentUser); + + // {{currentTimestamp}} 치환 + result = result.replace( + /\{\{currentTimestamp\}\}/g, + new Date().toISOString() + ); + + // {{flowId}} 치환 + result = result.replace(/\{\{flowId\}\}/g, String(context.flowId)); + + // {{stepId}} 치환 + result = result.replace(/\{\{stepId\}\}/g, String(context.stepId)); + + // {{tableName}} 치환 + if (context.tableName) { + result = result.replace(/\{\{tableName\}\}/g, context.tableName); + } + + // context.variables의 커스텀 변수 치환 + for (const [key, val] of Object.entries(context.variables)) { + const regex = new RegExp(`\\{\\{${key}\\}\\}`, "g"); + result = result.replace(regex, String(val)); + } + + // NOW() 같은 SQL 함수는 그대로 반환 + if (result === "NOW()" || result.startsWith("CURRENT_")) { + return result; + } + + return result; + } + + /** + * 롤백 쿼리 생성 + */ + private buildRollbackQuery( + config: FlowExternalDbIntegrationConfig, + context: FlowIntegrationContext, + result: any + ): { sql: string; params: any[] } | null { + // 롤백 쿼리 생성 로직 (복잡하므로 실제 구현 시 상세 설계 필요) + // 예: INSERT -> DELETE, UPDATE -> 이전 값으로 UPDATE + + switch (config.operation) { + case "insert": + // INSERT를 롤백하려면 삽입된 레코드를 DELETE + if (result && result[0] && result[0].id) { + return { + sql: `DELETE FROM ${config.tableName} WHERE id = $1`, + params: [result[0].id], + }; + } + break; + case "delete": + // DELETE 롤백은 매우 어려움 (원본 데이터 필요) + console.warn("DELETE 작업의 롤백은 지원하지 않습니다"); + break; + case "update": + // UPDATE 롤백을 위해서는 이전 값을 저장해야 함 + console.warn("UPDATE 작업의 롤백은 현재 구현되지 않았습니다"); + break; + default: + break; + } + + return null; + } + + /** + * 롤백 실행 + */ + async rollback( + connectionId: number, + rollbackQuery: { sql: string; params: any[] } + ): Promise { + const connection = await this.connectionService.findById(connectionId); + if (!connection) { + throw new Error( + `롤백 실패: 연결 정보를 찾을 수 없습니다 (ID: ${connectionId})` + ); + } + + const pool = await this.connectionService.getConnectionPool(connection); + await this.executeQuery(pool, rollbackQuery); + } +} diff --git a/backend-node/src/services/flowStepService.ts b/backend-node/src/services/flowStepService.ts new file mode 100644 index 00000000..e8cf1fb9 --- /dev/null +++ b/backend-node/src/services/flowStepService.ts @@ -0,0 +1,289 @@ +/** + * 플로우 단계 서비스 + */ + +import db from "../database/db"; +import { + FlowStep, + CreateFlowStepRequest, + UpdateFlowStepRequest, + FlowConditionGroup, +} from "../types/flow"; +import { FlowConditionParser } from "./flowConditionParser"; + +export class FlowStepService { + /** + * 플로우 단계 생성 + */ + async create(request: CreateFlowStepRequest): Promise { + // 조건 검증 + if (request.conditionJson) { + FlowConditionParser.validateConditionGroup(request.conditionJson); + } + + const query = ` + INSERT INTO flow_step ( + flow_definition_id, step_name, step_order, table_name, condition_json, + color, position_x, position_y, move_type, status_column, status_value, + target_table, field_mappings, required_fields, + integration_type, integration_config + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING * + `; + + const result = await db.query(query, [ + request.flowDefinitionId, + request.stepName, + request.stepOrder, + request.tableName || null, + request.conditionJson ? JSON.stringify(request.conditionJson) : null, + request.color || "#3B82F6", + request.positionX || 0, + request.positionY || 0, + request.moveType || null, + request.statusColumn || null, + request.statusValue || null, + request.targetTable || null, + request.fieldMappings ? JSON.stringify(request.fieldMappings) : null, + request.requiredFields ? JSON.stringify(request.requiredFields) : null, + request.integrationType || "internal", + request.integrationConfig + ? JSON.stringify(request.integrationConfig) + : null, + ]); + + return this.mapToFlowStep(result[0]); + } + + /** + * 특정 플로우의 모든 단계 조회 + */ + async findByFlowId(flowDefinitionId: number): Promise { + const query = ` + SELECT * FROM flow_step + WHERE flow_definition_id = $1 + ORDER BY step_order ASC + `; + + const result = await db.query(query, [flowDefinitionId]); + return result.map(this.mapToFlowStep); + } + + /** + * 플로우 단계 단일 조회 + */ + async findById(id: number): Promise { + const query = "SELECT * FROM flow_step WHERE id = $1"; + const result = await db.query(query, [id]); + + if (result.length === 0) { + return null; + } + + return this.mapToFlowStep(result[0]); + } + + /** + * 플로우 단계 수정 + */ + async update( + id: number, + request: UpdateFlowStepRequest + ): Promise { + console.log("🔧 FlowStepService.update called with:", { + id, + statusColumn: request.statusColumn, + statusValue: request.statusValue, + fullRequest: JSON.stringify(request), + }); + + // 조건 검증 + if (request.conditionJson) { + FlowConditionParser.validateConditionGroup(request.conditionJson); + } + + const fields: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (request.stepName !== undefined) { + fields.push(`step_name = $${paramIndex}`); + params.push(request.stepName); + paramIndex++; + } + + if (request.stepOrder !== undefined) { + fields.push(`step_order = $${paramIndex}`); + params.push(request.stepOrder); + paramIndex++; + } + + if (request.tableName !== undefined) { + fields.push(`table_name = $${paramIndex}`); + params.push(request.tableName); + paramIndex++; + } + + if (request.conditionJson !== undefined) { + fields.push(`condition_json = $${paramIndex}`); + params.push( + request.conditionJson ? JSON.stringify(request.conditionJson) : null + ); + paramIndex++; + } + + if (request.color !== undefined) { + fields.push(`color = $${paramIndex}`); + params.push(request.color); + paramIndex++; + } + + if (request.positionX !== undefined) { + fields.push(`position_x = $${paramIndex}`); + params.push(request.positionX); + paramIndex++; + } + + if (request.positionY !== undefined) { + fields.push(`position_y = $${paramIndex}`); + params.push(request.positionY); + paramIndex++; + } + + // 하이브리드 플로우 필드 + if (request.moveType !== undefined) { + fields.push(`move_type = $${paramIndex}`); + params.push(request.moveType); + paramIndex++; + } + + if (request.statusColumn !== undefined) { + fields.push(`status_column = $${paramIndex}`); + params.push(request.statusColumn); + paramIndex++; + } + + if (request.statusValue !== undefined) { + fields.push(`status_value = $${paramIndex}`); + params.push(request.statusValue); + paramIndex++; + } + + if (request.targetTable !== undefined) { + fields.push(`target_table = $${paramIndex}`); + params.push(request.targetTable); + paramIndex++; + } + + if (request.fieldMappings !== undefined) { + fields.push(`field_mappings = $${paramIndex}`); + params.push( + request.fieldMappings ? JSON.stringify(request.fieldMappings) : null + ); + paramIndex++; + } + + if (request.requiredFields !== undefined) { + fields.push(`required_fields = $${paramIndex}`); + params.push( + request.requiredFields ? JSON.stringify(request.requiredFields) : null + ); + paramIndex++; + } + + // 외부 연동 필드 + if (request.integrationType !== undefined) { + fields.push(`integration_type = $${paramIndex}`); + params.push(request.integrationType); + paramIndex++; + } + + if (request.integrationConfig !== undefined) { + fields.push(`integration_config = $${paramIndex}`); + params.push( + request.integrationConfig + ? JSON.stringify(request.integrationConfig) + : null + ); + paramIndex++; + } + + if (fields.length === 0) { + return this.findById(id); + } + + fields.push(`updated_at = NOW()`); + + const query = ` + UPDATE flow_step + SET ${fields.join(", ")} + WHERE id = $${paramIndex} + RETURNING * + `; + params.push(id); + + const result = await db.query(query, params); + + if (result.length === 0) { + return null; + } + + return this.mapToFlowStep(result[0]); + } + + /** + * 플로우 단계 삭제 + */ + async delete(id: number): Promise { + const query = "DELETE FROM flow_step WHERE id = $1 RETURNING id"; + const result = await db.query(query, [id]); + return result.length > 0; + } + + /** + * 단계 순서 재정렬 + */ + async reorder( + flowDefinitionId: number, + stepOrders: { id: number; order: number }[] + ): Promise { + await db.transaction(async (client) => { + for (const { id, order } of stepOrders) { + await client.query( + "UPDATE flow_step SET step_order = $1, updated_at = NOW() WHERE id = $2 AND flow_definition_id = $3", + [order, id, flowDefinitionId] + ); + } + }); + } + + /** + * DB 행을 FlowStep 객체로 변환 + */ + private mapToFlowStep(row: any): FlowStep { + return { + id: row.id, + flowDefinitionId: row.flow_definition_id, + stepName: row.step_name, + stepOrder: row.step_order, + tableName: row.table_name || undefined, + conditionJson: row.condition_json as FlowConditionGroup | undefined, + color: row.color, + positionX: row.position_x, + positionY: row.position_y, + // 하이브리드 플로우 지원 필드 + moveType: row.move_type || undefined, + statusColumn: row.status_column || undefined, + statusValue: row.status_value || undefined, + targetTable: row.target_table || undefined, + fieldMappings: row.field_mappings || undefined, + requiredFields: row.required_fields || undefined, + // 외부 연동 필드 + integrationType: row.integration_type || "internal", + integrationConfig: row.integration_config || undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts new file mode 100644 index 00000000..3483b617 --- /dev/null +++ b/backend-node/src/types/flow.ts @@ -0,0 +1,345 @@ +/** + * 플로우 관리 시스템 타입 정의 + */ + +// 플로우 정의 +export interface FlowDefinition { + id: number; + name: string; + description?: string; + tableName: string; + isActive: boolean; + createdBy?: string; + createdAt: Date; + updatedAt: Date; +} + +// 플로우 정의 생성 요청 +export interface CreateFlowDefinitionRequest { + name: string; + description?: string; + tableName: string; +} + +// 플로우 정의 수정 요청 +export interface UpdateFlowDefinitionRequest { + name?: string; + description?: string; + isActive?: boolean; +} + +// 조건 연산자 +export type ConditionOperator = + | "equals" + | "=" + | "not_equals" + | "!=" + | "in" + | "not_in" + | "greater_than" + | ">" + | "less_than" + | "<" + | "greater_than_or_equal" + | ">=" + | "less_than_or_equal" + | "<=" + | "is_null" + | "is_not_null" + | "like" + | "not_like"; + +// 플로우 조건 +export interface FlowCondition { + column: string; + operator: ConditionOperator; + value: any; +} + +// 플로우 조건 그룹 +export interface FlowConditionGroup { + type: "AND" | "OR"; + conditions: FlowCondition[]; +} + +// 플로우 단계 +export interface FlowStep { + id: number; + flowDefinitionId: number; + stepName: string; + stepOrder: number; + tableName?: string; // 이 단계에서 조회할 테이블명 (NULL이면 flow_definition의 tableName 사용) + conditionJson?: FlowConditionGroup; + color: string; + positionX: number; + positionY: number; + // 하이브리드 플로우 지원 필드 + moveType?: "status" | "table" | "both"; // 데이터 이동 방식 + statusColumn?: string; // 상태 컬럼명 (상태 변경 방식) + statusValue?: string; // 이 단계의 상태값 + targetTable?: string; // 타겟 테이블명 (테이블 이동 방식) + fieldMappings?: Record; // 필드 매핑 정보 + requiredFields?: string[]; // 필수 입력 필드 + // 외부 연동 필드 + integrationType?: FlowIntegrationType; // 연동 타입 (기본값: internal) + integrationConfig?: FlowIntegrationConfig; // 연동 설정 (JSONB) + createdAt: Date; + updatedAt: Date; +} + +// 플로우 단계 생성 요청 +export interface CreateFlowStepRequest { + flowDefinitionId: number; + stepName: string; + stepOrder: number; + tableName?: string; // 이 단계에서 조회할 테이블명 + conditionJson?: FlowConditionGroup; + color?: string; + positionX?: number; + positionY?: number; + // 하이브리드 플로우 지원 필드 + moveType?: "status" | "table" | "both"; + statusColumn?: string; + statusValue?: string; + targetTable?: string; + fieldMappings?: Record; + requiredFields?: string[]; + // 외부 연동 필드 + integrationType?: FlowIntegrationType; + integrationConfig?: FlowIntegrationConfig; +} + +// 플로우 단계 수정 요청 +export interface UpdateFlowStepRequest { + stepName?: string; + stepOrder?: number; + tableName?: string; // 이 단계에서 조회할 테이블명 + conditionJson?: FlowConditionGroup; + color?: string; + positionX?: number; + positionY?: number; + // 하이브리드 플로우 지원 필드 + moveType?: "status" | "table" | "both"; + statusColumn?: string; + statusValue?: string; + targetTable?: string; + fieldMappings?: Record; + requiredFields?: string[]; + // 외부 연동 필드 + integrationType?: FlowIntegrationType; + integrationConfig?: FlowIntegrationConfig; +} + +// 플로우 단계 연결 +export interface FlowStepConnection { + id: number; + flowDefinitionId: number; + fromStepId: number; + toStepId: number; + label?: string; + createdAt: Date; +} + +// 플로우 단계 연결 생성 요청 +export interface CreateFlowConnectionRequest { + flowDefinitionId: number; + fromStepId: number; + toStepId: number; + label?: string; +} + +// 플로우 데이터 상태 +export interface FlowDataStatus { + id: number; + flowDefinitionId: number; + tableName: string; + recordId: string; + currentStepId?: number; + updatedBy?: string; + updatedAt: Date; +} + +// 플로우 오딧 로그 +export interface FlowAuditLog { + id: number; + flowDefinitionId: number; + tableName: string; + recordId: string; + fromStepId?: number; + toStepId?: number; + changedBy?: string; + changedAt: Date; + note?: string; + // 하이브리드 플로우 지원 필드 + moveType?: "status" | "table" | "both"; + sourceTable?: string; + targetTable?: string; + sourceDataId?: string; + targetDataId?: string; + statusFrom?: string; + statusTo?: string; + // 조인 필드 + fromStepName?: string; + toStepName?: string; +} + +// 플로우 상세 정보 +export interface FlowDetailResponse { + definition: FlowDefinition; + steps: FlowStep[]; + connections: FlowStepConnection[]; +} + +// 단계별 데이터 카운트 +export interface FlowStepDataCount { + stepId: number; + count: number; +} + +// 단계별 데이터 리스트 +export interface FlowStepDataList { + records: any[]; + total: number; + page: number; + pageSize: number; +} + +// 데이터 이동 요청 +export interface MoveDataRequest { + flowId: number; + recordId: string; + toStepId: number; + note?: string; +} + +// SQL WHERE 절 결과 +export interface SqlWhereResult { + where: string; + params: any[]; +} + +// ==================== 플로우 외부 연동 타입 ==================== + +// 연동 타입 +export type FlowIntegrationType = + | "internal" // 내부 DB (기본값) + | "external_db" // 외부 DB + | "rest_api" // REST API (추후 구현) + | "webhook" // Webhook (추후 구현) + | "hybrid"; // 복합 연동 (추후 구현) + +// 플로우 전용 외부 DB 연결 정보 +export interface FlowExternalDbConnection { + id: number; + name: string; + description?: string; + dbType: "postgresql" | "mysql" | "mssql" | "oracle"; + host: string; + port: number; + databaseName: string; + username: string; + passwordEncrypted: string; // 암호화된 비밀번호 + sslEnabled: boolean; + connectionOptions?: Record; + isActive: boolean; + createdBy?: string; + updatedBy?: string; + createdAt: Date; + updatedAt: Date; +} + +// 외부 DB 연결 생성 요청 +export interface CreateFlowExternalDbConnectionRequest { + name: string; + description?: string; + dbType: "postgresql" | "mysql" | "mssql" | "oracle"; + host: string; + port: number; + databaseName: string; + username: string; + password: string; // 평문 비밀번호 (저장 시 암호화) + sslEnabled?: boolean; + connectionOptions?: Record; +} + +// 외부 DB 연결 수정 요청 +export interface UpdateFlowExternalDbConnectionRequest { + name?: string; + description?: string; + host?: string; + port?: number; + databaseName?: string; + username?: string; + password?: string; // 평문 비밀번호 (저장 시 암호화) + sslEnabled?: boolean; + connectionOptions?: Record; + isActive?: boolean; +} + +// 외부 DB 연동 설정 (integration_config JSON) +export interface FlowExternalDbIntegrationConfig { + type: "external_db"; + connectionId: number; // flow_external_db_connection.id + operation: "update" | "insert" | "delete" | "custom"; + tableName: string; + updateFields?: Record; // 업데이트할 필드 (템플릿 변수 지원) + whereCondition?: Record; // WHERE 조건 (템플릿 변수 지원) + customQuery?: string; // operation이 'custom'인 경우 사용 +} + +// 연동 설정 통합 타입 +export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig; // 나중에 다른 타입 추가 + +// 연동 실행 컨텍스트 +export interface FlowIntegrationContext { + flowId: number; + stepId: number; + dataId: string | number; + tableName?: string; + currentUser: string; + variables: Record; // 템플릿 변수 ({{dataId}}, {{currentUser}} 등) + transactionId?: string; +} + +// 연동 실행 결과 +export interface FlowIntegrationResult { + success: boolean; + message?: string; + data?: any; + error?: { + code: string; + message: string; + details?: any; + }; + rollbackInfo?: any; // 롤백을 위한 정보 +} + +// 외부 연동 실행 로그 +export interface FlowIntegrationLog { + id: number; + flowDefinitionId: number; + stepId: number; + dataId?: string; + integrationType: string; + connectionId?: number; + requestPayload?: Record; + responsePayload?: Record; + status: "success" | "failed" | "timeout" | "rollback"; + errorMessage?: string; + executionTimeMs?: number; + executedBy?: string; + executedAt: Date; +} + +// 외부 연결 권한 +export interface FlowExternalConnectionPermission { + id: number; + connectionId: number; + userId?: number; + roleName?: string; + canView: boolean; + canUse: boolean; + canEdit: boolean; + canDelete: boolean; + createdAt: Date; +} diff --git a/backend-node/src/utils/credentialEncryption.ts b/backend-node/src/utils/credentialEncryption.ts new file mode 100644 index 00000000..89a79b02 --- /dev/null +++ b/backend-node/src/utils/credentialEncryption.ts @@ -0,0 +1,61 @@ +import crypto from "crypto"; + +/** + * 자격 증명 암호화 유틸리티 + * AES-256-GCM 알고리즘 사용 + */ +export class CredentialEncryption { + private algorithm = "aes-256-gcm"; + private key: Buffer; + + constructor(secretKey: string) { + // scrypt로 안전한 키 생성 + this.key = crypto.scryptSync(secretKey, "salt", 32); + } + + /** + * 평문을 암호화 + */ + encrypt(text: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv( + this.algorithm, + this.key, + iv + ) as crypto.CipherGCM; + + let encrypted = cipher.update(text, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const authTag = cipher.getAuthTag(); + + // IV:AuthTag:EncryptedText 형식으로 반환 + return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; + } + + /** + * 암호문을 복호화 + */ + decrypt(encrypted: string): string { + const [ivHex, authTagHex, encryptedText] = encrypted.split(":"); + + if (!ivHex || !authTagHex || !encryptedText) { + throw new Error("Invalid encrypted string format"); + } + + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = crypto.createDecipheriv( + this.algorithm, + this.key, + iv + ) as crypto.DecipherGCM; + + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedText, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } +} diff --git a/docs/FLOW_DATA_STRUCTURE_GUIDE.md b/docs/FLOW_DATA_STRUCTURE_GUIDE.md new file mode 100644 index 00000000..07d46d59 --- /dev/null +++ b/docs/FLOW_DATA_STRUCTURE_GUIDE.md @@ -0,0 +1,302 @@ +# 플로우 데이터 구조 설계 가이드 + +## 개요 + +플로우 관리 시스템에서 각 단계별로 테이블 구조가 다른 경우의 데이터 관리 방법 + +## 추천 아키텍처: 하이브리드 접근 + +### 1. 메인 데이터 테이블 (상태 기반) + +각 플로우의 핵심 데이터를 담는 메인 테이블에 `flow_status` 컬럼을 추가합니다. + +```sql +-- 예시: 제품 수명주기 관리 +CREATE TABLE product_lifecycle ( + id SERIAL PRIMARY KEY, + product_code VARCHAR(50) UNIQUE NOT NULL, + product_name VARCHAR(200) NOT NULL, + flow_status VARCHAR(50) NOT NULL, -- 'purchase', 'installation', 'disposal' + + -- 공통 필드 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by VARCHAR(50), + + -- 단계별 핵심 정보 (NULL 허용) + purchase_date DATE, + purchase_price DECIMAL(15,2), + installation_date DATE, + installation_location VARCHAR(200), + disposal_date DATE, + disposal_method VARCHAR(100), + + -- 인덱스 + INDEX idx_flow_status (flow_status), + INDEX idx_product_code (product_code) +); +``` + +### 2. 단계별 상세 정보 테이블 (선택적) + +각 단계에서 필요한 상세 정보는 별도 테이블에 저장합니다. + +```sql +-- 구매 단계 상세 정보 +CREATE TABLE product_purchase_detail ( + id SERIAL PRIMARY KEY, + product_id INTEGER REFERENCES product_lifecycle(id), + vendor_name VARCHAR(200), + vendor_contact VARCHAR(100), + purchase_order_no VARCHAR(50), + warranty_period INTEGER, -- 월 단위 + warranty_end_date DATE, + specifications JSONB, -- 유연한 사양 정보 + created_at TIMESTAMP DEFAULT NOW() +); + +-- 설치 단계 상세 정보 +CREATE TABLE product_installation_detail ( + id SERIAL PRIMARY KEY, + product_id INTEGER REFERENCES product_lifecycle(id), + technician_name VARCHAR(100), + installation_address TEXT, + installation_notes TEXT, + installation_photos JSONB, -- [{url, description}] + created_at TIMESTAMP DEFAULT NOW() +); + +-- 폐기 단계 상세 정보 +CREATE TABLE product_disposal_detail ( + id SERIAL PRIMARY KEY, + product_id INTEGER REFERENCES product_lifecycle(id), + disposal_company VARCHAR(200), + disposal_certificate_no VARCHAR(100), + environmental_compliance BOOLEAN, + disposal_cost DECIMAL(15,2), + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 3. 플로우 단계 설정 테이블 수정 + +`flow_step` 테이블에 단계별 필드 매핑 정보를 추가합니다. + +```sql +ALTER TABLE flow_step +ADD COLUMN status_value VARCHAR(50), -- 이 단계의 상태값 +ADD COLUMN required_fields JSONB, -- 필수 입력 필드 목록 +ADD COLUMN detail_table_name VARCHAR(200), -- 상세 정보 테이블명 (선택적) +ADD COLUMN field_mappings JSONB; -- 메인 테이블과 상세 테이블 필드 매핑 + +-- 예시 데이터 +INSERT INTO flow_step (flow_definition_id, step_name, step_order, table_name, status_value, required_fields, detail_table_name) VALUES +(1, '구매', 1, 'product_lifecycle', 'purchase', + '["product_code", "product_name", "purchase_date", "purchase_price"]'::jsonb, + 'product_purchase_detail'), +(1, '설치', 2, 'product_lifecycle', 'installation', + '["installation_date", "installation_location"]'::jsonb, + 'product_installation_detail'), +(1, '폐기', 3, 'product_lifecycle', 'disposal', + '["disposal_date", "disposal_method"]'::jsonb, + 'product_disposal_detail'); +``` + +## 데이터 이동 로직 + +### 백엔드 서비스 수정 + +```typescript +// backend-node/src/services/flowDataMoveService.ts + +export class FlowDataMoveService { + /** + * 다음 단계로 데이터 이동 + */ + async moveToNextStep( + flowId: number, + currentStepId: number, + nextStepId: number, + dataId: any + ): Promise { + const client = await db.getClient(); + + try { + await client.query("BEGIN"); + + // 1. 현재 단계와 다음 단계 정보 조회 + const currentStep = await this.getStepInfo(currentStepId); + const nextStep = await this.getStepInfo(nextStepId); + + if (!currentStep || !nextStep) { + throw new Error("유효하지 않은 단계입니다"); + } + + // 2. 메인 테이블의 상태 업데이트 + const updateQuery = ` + UPDATE ${currentStep.table_name} + SET flow_status = $1, + updated_at = NOW() + WHERE id = $2 + AND flow_status = $3 + `; + + const result = await client.query(updateQuery, [ + nextStep.status_value, + dataId, + currentStep.status_value, + ]); + + if (result.rowCount === 0) { + throw new Error("데이터를 찾을 수 없거나 이미 이동되었습니다"); + } + + // 3. 감사 로그 기록 + await this.logDataMove(client, { + flowId, + fromStepId: currentStepId, + toStepId: nextStepId, + dataId, + tableName: currentStep.table_name, + statusFrom: currentStep.status_value, + statusTo: nextStep.status_value, + }); + + await client.query("COMMIT"); + return true; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + private async getStepInfo(stepId: number) { + const query = ` + SELECT id, table_name, status_value, detail_table_name, required_fields + FROM flow_step + WHERE id = $1 + `; + const result = await db.query(query, [stepId]); + return result[0]; + } + + private async logDataMove(client: any, params: any) { + const query = ` + INSERT INTO flow_audit_log ( + flow_definition_id, from_step_id, to_step_id, + data_id, table_name, status_from, status_to, + moved_at, moved_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'system') + `; + + await client.query(query, [ + params.flowId, + params.fromStepId, + params.toStepId, + params.dataId, + params.tableName, + params.statusFrom, + params.statusTo, + ]); + } +} +``` + +## 플로우 조건 설정 + +각 단계의 조건은 `flow_status` 컬럼을 기준으로 설정합니다: + +```json +// 구매 단계 조건 +{ + "operator": "AND", + "conditions": [ + { + "column": "flow_status", + "operator": "=", + "value": "purchase" + } + ] +} + +// 설치 단계 조건 +{ + "operator": "AND", + "conditions": [ + { + "column": "flow_status", + "operator": "=", + "value": "installation" + } + ] +} +``` + +## 프론트엔드 구현 + +### 단계별 폼 렌더링 + +각 단계에서 필요한 필드를 동적으로 렌더링합니다. + +```typescript +// 단계 정보에서 필수 필드 가져오기 +const requiredFields = step.required_fields; // ["purchase_date", "purchase_price"] + +// 동적 폼 생성 +{ + requiredFields.map((fieldName) => ( + + )); +} +``` + +## 장점 + +1. **단순한 데이터 이동**: 상태값만 업데이트 +2. **유연한 구조**: 단계별 상세 정보는 별도 테이블 +3. **완벽한 이력 추적**: 감사 로그로 모든 이동 기록 +4. **쿼리 효율**: 단일 테이블 조회로 각 단계 데이터 확인 +5. **확장성**: 새로운 단계 추가 시 컬럼 추가 또는 상세 테이블 생성 + +## 마이그레이션 스크립트 + +```sql +-- 1. 기존 테이블에 flow_status 컬럼 추가 +ALTER TABLE product_lifecycle +ADD COLUMN flow_status VARCHAR(50) DEFAULT 'purchase'; + +-- 2. 인덱스 생성 +CREATE INDEX idx_product_lifecycle_status ON product_lifecycle(flow_status); + +-- 3. flow_step 테이블 확장 +ALTER TABLE flow_step +ADD COLUMN status_value VARCHAR(50), +ADD COLUMN required_fields JSONB, +ADD COLUMN detail_table_name VARCHAR(200); + +-- 4. 기존 데이터 마이그레이션 +UPDATE flow_step +SET status_value = CASE step_order + WHEN 1 THEN 'purchase' + WHEN 2 THEN 'installation' + WHEN 3 THEN 'disposal' +END +WHERE flow_definition_id = 1; +``` + +## 결론 + +이 하이브리드 접근 방식을 사용하면: + +- 각 단계의 데이터는 같은 메인 테이블에서 `flow_status`로 구분 +- 단계별 추가 정보는 별도 상세 테이블에 저장 (선택적) +- 데이터 이동은 상태값 업데이트만으로 간단하게 처리 +- 완전한 감사 로그와 이력 추적 가능 diff --git a/docs/FLOW_EXTERNAL_INTEGRATION_PLAN.md b/docs/FLOW_EXTERNAL_INTEGRATION_PLAN.md new file mode 100644 index 00000000..a8bdc0da --- /dev/null +++ b/docs/FLOW_EXTERNAL_INTEGRATION_PLAN.md @@ -0,0 +1,762 @@ +# 플로우 관리 시스템 - 외부 연동 확장 계획 + +## 개요 + +현재 플로우 관리 시스템은 내부 데이터베이스의 상태 변경만 지원합니다. +실제 업무 환경에서는 다음과 같은 외부 연동이 필요합니다: + +1. **외부 데이터베이스**: 다른 DB 서버의 데이터 상태 변경 +2. **REST API 호출**: 외부 시스템 API를 통한 상태 업데이트 +3. **Webhook**: 외부 시스템으로 이벤트 전송 +4. **복합 연동**: 내부 DB + 외부 API 동시 처리 + +--- + +## 1. 데이터베이스 스키마 확장 + +### 1.1 플로우 단계 설정 확장 + +```sql +-- flow_step 테이블에 외부 연동 설정 추가 +ALTER TABLE flow_step ADD COLUMN integration_type VARCHAR(50); +-- 값: 'internal' | 'external_db' | 'rest_api' | 'webhook' | 'hybrid' + +ALTER TABLE flow_step ADD COLUMN integration_config JSONB; +-- 외부 연동 상세 설정 (JSON) + +COMMENT ON COLUMN flow_step.integration_type IS '연동 타입: internal/external_db/rest_api/webhook/hybrid'; +COMMENT ON COLUMN flow_step.integration_config IS '외부 연동 설정 (JSON 형식)'; +``` + +### 1.2 외부 연결 정보 관리 테이블 + +```sql +-- 외부 데이터베이스 연결 정보 +CREATE TABLE external_db_connection ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + db_type VARCHAR(50) NOT NULL, -- 'postgresql' | 'mysql' | 'mssql' | 'oracle' + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL, + database_name VARCHAR(100) NOT NULL, + username VARCHAR(100) NOT NULL, + password_encrypted TEXT NOT NULL, -- 암호화된 비밀번호 + ssl_enabled BOOLEAN DEFAULT false, + connection_options JSONB, -- 추가 연결 옵션 + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE external_db_connection IS '외부 데이터베이스 연결 정보'; + +-- 외부 API 연결 정보 +CREATE TABLE external_api_connection ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + base_url VARCHAR(500) NOT NULL, + auth_type VARCHAR(50), -- 'none' | 'basic' | 'bearer' | 'api_key' | 'oauth2' + auth_config JSONB, -- 인증 설정 (암호화된 토큰/키 포함) + default_headers JSONB, -- 기본 헤더 + timeout_ms INTEGER DEFAULT 30000, + retry_count INTEGER DEFAULT 3, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE external_api_connection IS '외부 REST API 연결 정보'; +``` + +--- + +## 2. integration_config JSON 스키마 + +### 2.1 External DB 설정 + +```json +{ + "type": "external_db", + "connectionId": 5, // external_db_connection.id + "operation": "update", // 'update' | 'insert' | 'delete' | 'custom' + "tableName": "external_orders", + "updateFields": { + "status": "approved", + "approved_at": "NOW()", + "approved_by": "{{currentUser}}" + }, + "whereCondition": { + "id": "{{dataId}}", + "company_code": "{{companyCode}}" + }, + "customQuery": null // operation이 'custom'인 경우 사용 +} +``` + +### 2.2 REST API 설정 + +```json +{ + "type": "rest_api", + "connectionId": 3, // external_api_connection.id + "method": "POST", // 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + "endpoint": "/api/orders/{{dataId}}/approve", + "headers": { + "Content-Type": "application/json", + "X-Request-ID": "{{generateUUID}}" + }, + "body": { + "status": "approved", + "approvedBy": "{{currentUser}}", + "approvedAt": "{{currentTimestamp}}", + "notes": "{{notes}}" + }, + "successCondition": { + "statusCode": [200, 201], + "responseField": "success", + "expectedValue": true + }, + "errorHandling": { + "onFailure": "rollback", // 'rollback' | 'continue' | 'retry' + "maxRetries": 3, + "retryDelay": 1000 + } +} +``` + +### 2.3 Webhook 설정 + +```json +{ + "type": "webhook", + "url": "https://external-system.com/webhooks/flow-status-change", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer {{webhookToken}}" + }, + "payload": { + "event": "flow.status.changed", + "flowId": "{{flowId}}", + "stepId": "{{stepId}}", + "dataId": "{{dataId}}", + "previousStatus": "{{previousStatus}}", + "currentStatus": "{{currentStatus}}", + "changedBy": "{{currentUser}}", + "changedAt": "{{currentTimestamp}}" + }, + "async": true, // 비동기 처리 여부 + "timeout": 5000 +} +``` + +### 2.4 Hybrid (복합) 설정 + +```json +{ + "type": "hybrid", + "steps": [ + { + "order": 1, + "name": "internal_db_update", + "type": "internal", + "config": { + "tableName": "orders", + "statusColumn": "order_status", + "statusValue": "approved" + }, + "onError": "rollback" + }, + { + "order": 2, + "name": "notify_external_system", + "type": "rest_api", + "config": { + "connectionId": 3, + "method": "POST", + "endpoint": "/api/notifications/order-approved", + "body": { + "orderId": "{{dataId}}", + "status": "approved" + } + }, + "onError": "log" // API 실패해도 계속 진행 + }, + { + "order": 3, + "name": "update_warehouse_system", + "type": "external_db", + "config": { + "connectionId": 5, + "operation": "update", + "tableName": "warehouse_orders", + "updateFields": { + "status": "ready_to_ship" + }, + "whereCondition": { + "order_ref": "{{dataId}}" + } + }, + "onError": "rollback" + } + ], + "transactionMode": "sequential", // 'sequential' | 'parallel' + "rollbackStrategy": "all" // 'all' | 'completed_only' | 'none' +} +``` + +--- + +## 3. 백엔드 서비스 구조 + +### 3.1 서비스 계층 구조 + +``` +flowDataMoveService (기존) + └── FlowIntegrationService (신규) + ├── InternalDbIntegration + ├── ExternalDbIntegration + ├── RestApiIntegration + ├── WebhookIntegration + └── HybridIntegration +``` + +### 3.2 주요 인터페이스 + +```typescript +// 통합 인터페이스 +interface FlowIntegration { + execute(context: IntegrationContext): Promise; + validate(config: any): ValidationResult; + rollback(context: IntegrationContext): Promise; +} + +// 실행 컨텍스트 +interface IntegrationContext { + flowId: number; + stepId: number; + dataId: string | number; + tableName?: string; + currentUser: string; + variables: Record; // 템플릿 변수 + transactionId?: string; +} + +// 실행 결과 +interface IntegrationResult { + success: boolean; + message?: string; + data?: any; + error?: { + code: string; + message: string; + details?: any; + }; + rollbackInfo?: any; // 롤백을 위한 정보 +} +``` + +### 3.3 외부 DB 연동 서비스 + +```typescript +export class ExternalDbIntegration implements FlowIntegration { + private connectionPool: Map = new Map(); + + async execute(context: IntegrationContext): Promise { + const config = context.step.integrationConfig; + + // 1. 연결 정보 조회 + const connection = await this.getConnection(config.connectionId); + + // 2. 쿼리 생성 (템플릿 변수 치환) + const query = this.buildQuery(config, context); + + // 3. 실행 + try { + const result = await this.executeQuery(connection, query); + + return { + success: true, + data: result, + rollbackInfo: { + query: this.buildRollbackQuery(config, context), + connection: config.connectionId, + }, + }; + } catch (error) { + return { + success: false, + error: { + code: "EXTERNAL_DB_ERROR", + message: error.message, + details: error, + }, + }; + } + } + + async getConnection(connectionId: number) { + // 연결 풀에서 가져오거나 새로 생성 + if (this.connectionPool.has(connectionId)) { + return this.connectionPool.get(connectionId); + } + + const connInfo = await this.loadConnectionInfo(connectionId); + const connection = await this.createConnection(connInfo); + this.connectionPool.set(connectionId, connection); + + return connection; + } + + private buildQuery(config: any, context: IntegrationContext): string { + // 템플릿 변수 치환 + const replacedConfig = this.replaceVariables(config, context); + + switch (config.operation) { + case "update": + return this.buildUpdateQuery(replacedConfig); + case "insert": + return this.buildInsertQuery(replacedConfig); + case "delete": + return this.buildDeleteQuery(replacedConfig); + case "custom": + return replacedConfig.customQuery; + default: + throw new Error(`Unsupported operation: ${config.operation}`); + } + } + + async rollback(context: IntegrationContext): Promise { + const rollbackInfo = context.rollbackInfo; + const connection = await this.getConnection(rollbackInfo.connection); + await this.executeQuery(connection, rollbackInfo.query); + } +} +``` + +### 3.4 REST API 연동 서비스 + +```typescript +export class RestApiIntegration implements FlowIntegration { + private axiosInstances: Map = new Map(); + + async execute(context: IntegrationContext): Promise { + const config = context.step.integrationConfig; + + // 1. API 클라이언트 생성 + const client = await this.getApiClient(config.connectionId); + + // 2. 요청 구성 (템플릿 변수 치환) + const request = this.buildRequest(config, context); + + // 3. API 호출 + try { + const response = await this.executeRequest(client, request); + + // 4. 성공 조건 검증 + const isSuccess = this.validateSuccess(response, config.successCondition); + + if (isSuccess) { + return { + success: true, + data: response.data, + rollbackInfo: { + compensatingRequest: this.buildCompensatingRequest( + config, + context, + response + ), + }, + }; + } else { + throw new Error("API call succeeded but validation failed"); + } + } catch (error) { + // 에러 처리 및 재시도 + return this.handleError(error, config, context); + } + } + + private async executeRequest( + client: AxiosInstance, + request: any + ): Promise { + const { method, endpoint, headers, body, timeout } = request; + + return await client.request({ + method, + url: endpoint, + headers, + data: body, + timeout: timeout || 30000, + }); + } + + private async handleError( + error: any, + config: any, + context: IntegrationContext + ): Promise { + const errorHandling = config.errorHandling; + + if (errorHandling.onFailure === "retry") { + // 재시도 로직 + for (let i = 0; i < errorHandling.maxRetries; i++) { + await this.delay(errorHandling.retryDelay); + try { + return await this.execute(context); + } catch (retryError) { + if (i === errorHandling.maxRetries - 1) { + throw retryError; + } + } + } + } + + return { + success: false, + error: { + code: "REST_API_ERROR", + message: error.message, + details: error.response?.data, + }, + }; + } + + async rollback(context: IntegrationContext): Promise { + const rollbackInfo = context.rollbackInfo; + if (rollbackInfo.compensatingRequest) { + const client = await this.getApiClient(rollbackInfo.connectionId); + await this.executeRequest(client, rollbackInfo.compensatingRequest); + } + } +} +``` + +--- + +## 4. 프론트엔드 UI 확장 + +### 4.1 플로우 단계 설정 패널 확장 + +```typescript +// FlowStepPanel.tsx에 추가 + +// 연동 타입 선택 +; + +// 연동 타입별 설정 UI +{ + integrationType === "external_db" && ( + + ); +} + +{ + integrationType === "rest_api" && ( + + ); +} +``` + +### 4.2 외부 DB 설정 패널 + +```typescript +export function ExternalDbConfigPanel({ config, onChange }) { + return ( +
+ {/* 연결 선택 */} + + + {/* 작업 타입 */} + + + {/* 테이블명 */} + onChange({ ...config, tableName: e.target.value })} + /> + + {/* 업데이트 필드 */} + onChange({ ...config, updateFields: fields })} + /> + + {/* WHERE 조건 */} + + onChange({ ...config, whereCondition: conditions }) + } + /> +
+ ); +} +``` + +### 4.3 REST API 설정 패널 + +```typescript +export function RestApiConfigPanel({ config, onChange }) { + return ( +
+ {/* API 연결 선택 */} + + + {/* HTTP 메서드 */} + + + {/* 엔드포인트 */} + onChange({ ...config, endpoint: e.target.value })} + /> + + {/* 헤더 */} + onChange({ ...config, headers })} + /> + + {/* 요청 본문 */} + onChange({ ...config, body })} + /> + + {/* 성공 조건 */} + + onChange({ ...config, successCondition: condition }) + } + /> +
+ ); +} +``` + +--- + +## 5. 보안 고려사항 + +### 5.1 자격 증명 암호화 + +```typescript +// 비밀번호/토큰 암호화 +import crypto from "crypto"; + +export class CredentialEncryption { + private algorithm = "aes-256-gcm"; + private key: Buffer; + + constructor(secretKey: string) { + this.key = crypto.scryptSync(secretKey, "salt", 32); + } + + encrypt(text: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(this.algorithm, this.key, iv); + + let encrypted = cipher.update(text, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const authTag = cipher.getAuthTag(); + + return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; + } + + decrypt(encrypted: string): string { + const [ivHex, authTagHex, encryptedText] = encrypted.split(":"); + + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv); + + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedText, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } +} +``` + +### 5.2 권한 관리 + +```sql +-- 외부 연결 접근 권한 +CREATE TABLE external_connection_permission ( + id SERIAL PRIMARY KEY, + connection_type VARCHAR(50) NOT NULL, -- 'db' | 'api' + connection_id INTEGER NOT NULL, + user_id INTEGER, + role_id INTEGER, + can_view BOOLEAN DEFAULT false, + can_use BOOLEAN DEFAULT false, + can_edit BOOLEAN DEFAULT false, + can_delete BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +--- + +## 6. 모니터링 및 로깅 + +### 6.1 외부 연동 로그 + +```sql +CREATE TABLE flow_integration_log ( + id SERIAL PRIMARY KEY, + flow_definition_id INTEGER NOT NULL, + step_id INTEGER NOT NULL, + data_id VARCHAR(100), + integration_type VARCHAR(50) NOT NULL, + connection_id INTEGER, + request_payload JSONB, + response_payload JSONB, + status VARCHAR(50) NOT NULL, -- 'success' | 'failed' | 'timeout' | 'rollback' + error_message TEXT, + execution_time_ms INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_flow_integration_log_flow ON flow_integration_log(flow_definition_id); +CREATE INDEX idx_flow_integration_log_status ON flow_integration_log(status); +CREATE INDEX idx_flow_integration_log_created ON flow_integration_log(created_at); +``` + +--- + +## 7. 구현 우선순위 + +### Phase 1: 외부 DB 연동 (2-3주) + +1. 외부 DB 연결 정보 관리 UI +2. ExternalDbIntegration 서비스 구현 +3. 플로우 단계 설정에서 외부 DB 선택 기능 +4. 테스트 및 검증 + +### Phase 2: REST API 연동 (2-3주) + +1. API 연결 정보 관리 UI +2. RestApiIntegration 서비스 구현 +3. 템플릿 변수 시스템 구축 +4. 재시도 및 에러 처리 + +### Phase 3: 복합 연동 (2주) + +1. HybridIntegration 서비스 구현 +2. 트랜잭션 관리 및 롤백 +3. UI에서 복합 시나리오 구성 + +### Phase 4: 모니터링 및 최적화 (1-2주) + +1. 로깅 시스템 구축 +2. 성능 모니터링 대시보드 +3. 알림 시스템 + +--- + +## 8. 사용 예시 + +### 예시 1: 주문 승인 시 외부 ERP 시스템 업데이트 + +``` +플로우: 주문 승인 프로세스 + ↓ +검토중 단계 + ↓ +승인됨 단계 (외부 연동) + - 내부 DB: orders.status = 'approved' + - 외부 ERP API: POST /api/orders/approve + { + "orderId": "{{dataId}}", + "approvedBy": "{{currentUser}}", + "approvedAt": "{{timestamp}}" + } + - Webhook: 회계 시스템에 승인 알림 +``` + +### 예시 2: 재고 이동 시 창고 관리 DB 업데이트 + +``` +플로우: 재고 이동 프로세스 + ↓ +이동 요청 단계 + ↓ +이동 완료 단계 (외부 DB 연동) + - 내부 DB: inventory_transfer.status = 'completed' + - 외부 창고 DB: + UPDATE warehouse_stock + SET quantity = quantity - {{transferQty}} + WHERE product_id = {{productId}} + AND warehouse_id = {{fromWarehouse}} +``` + +--- + +## 9. 기대 효과 + +1. **시스템 통합**: 여러 시스템 간 데이터 동기화 자동화 +2. **업무 효율**: 수동 데이터 입력 감소 +3. **실시간 연동**: 상태 변경 즉시 외부 시스템에 반영 +4. **확장성**: 새로운 외부 시스템 쉽게 추가 +5. **트랜잭션 보장**: 롤백 기능으로 데이터 일관성 유지 + +--- + +## 10. 참고사항 + +- 외부 연동 설정은 관리자 권한 필요 +- 모든 외부 호출은 로그 기록 +- 타임아웃 및 재시도 정책 필수 설정 +- 정기적인 연결 상태 모니터링 필요 +- 보안을 위해 자격 증명은 반드시 암호화 diff --git a/docs/FLOW_HYBRID_MODE_USAGE_GUIDE.md b/docs/FLOW_HYBRID_MODE_USAGE_GUIDE.md new file mode 100644 index 00000000..a93d3785 --- /dev/null +++ b/docs/FLOW_HYBRID_MODE_USAGE_GUIDE.md @@ -0,0 +1,381 @@ +# 플로우 하이브리드 모드 사용 가이드 + +## 개요 + +플로우 관리 시스템은 세 가지 데이터 이동 방식을 지원합니다: + +1. **상태 변경 방식(status)**: 같은 테이블 내에서 상태 컬럼만 업데이트 +2. **테이블 이동 방식(table)**: 완전히 다른 테이블로 데이터 복사 및 이동 +3. **하이브리드 방식(both)**: 두 가지 모두 수행 + +## 1. 상태 변경 방식 (Status Mode) + +### 사용 시나리오 + +- 같은 엔티티가 여러 단계를 거치는 경우 +- 예: 승인 프로세스 (대기 → 검토 → 승인 → 완료) + +### 설정 방법 + +```sql +-- 플로우 정의 생성 +INSERT INTO flow_definition (name, description, table_name, is_active) +VALUES ('문서 승인', '문서 승인 프로세스', 'documents', true); + +-- 단계 생성 (상태 변경 방식) +INSERT INTO flow_step ( + flow_definition_id, step_name, step_order, + table_name, move_type, status_column, status_value, + condition_json +) VALUES +(1, '대기', 1, 'documents', 'status', 'approval_status', 'pending', + '{"operator":"AND","conditions":[{"column":"approval_status","operator":"=","value":"pending"}]}'::jsonb), + +(1, '검토중', 2, 'documents', 'status', 'approval_status', 'reviewing', + '{"operator":"AND","conditions":[{"column":"approval_status","operator":"=","value":"reviewing"}]}'::jsonb), + +(1, '승인됨', 3, 'documents', 'status', 'approval_status', 'approved', + '{"operator":"AND","conditions":[{"column":"approval_status","operator":"=","value":"approved"}]}'::jsonb); +``` + +### 테이블 구조 + +```sql +CREATE TABLE documents ( + id SERIAL PRIMARY KEY, + title VARCHAR(200), + content TEXT, + approval_status VARCHAR(50) DEFAULT 'pending', -- 상태 컬럼 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### 데이터 이동 + +```typescript +// 프론트엔드에서 호출 +await moveData(flowId, currentStepId, nextStepId, documentId); + +// 백엔드에서 처리 +// documents 테이블의 approval_status가 'pending' → 'reviewing'으로 변경됨 +``` + +## 2. 테이블 이동 방식 (Table Mode) + +### 사용 시나리오 + +- 완전히 다른 엔티티를 다루는 경우 +- 예: 제품 수명주기 (구매 주문 → 설치 작업 → 폐기 신청) + +### 설정 방법 + +```sql +-- 플로우 정의 생성 +INSERT INTO flow_definition (name, description, table_name, is_active) +VALUES ('제품 수명주기', '구매→설치→폐기 프로세스', 'purchase_orders', true); + +-- 단계 생성 (테이블 이동 방식) +INSERT INTO flow_step ( + flow_definition_id, step_name, step_order, + table_name, move_type, target_table, + field_mappings, required_fields +) VALUES +(2, '구매', 1, 'purchase_orders', 'table', 'installations', + '{"order_id":"purchase_order_id","product_name":"product_name","product_code":"product_code"}'::jsonb, + '["product_name","purchase_date","purchase_price"]'::jsonb), + +(2, '설치', 2, 'installations', 'table', 'disposals', + '{"installation_id":"installation_id","product_name":"product_name","product_code":"product_code"}'::jsonb, + '["installation_date","installation_location","technician"]'::jsonb), + +(2, '폐기', 3, 'disposals', 'table', NULL, + NULL, + '["disposal_date","disposal_method","disposal_cost"]'::jsonb); +``` + +### 테이블 구조 + +```sql +-- 단계 1: 구매 주문 테이블 +CREATE TABLE purchase_orders ( + id SERIAL PRIMARY KEY, + order_id VARCHAR(50) UNIQUE, + product_name VARCHAR(200), + product_code VARCHAR(50), + purchase_date DATE, + purchase_price DECIMAL(15,2), + vendor_name VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); + +-- 단계 2: 설치 작업 테이블 +CREATE TABLE installations ( + id SERIAL PRIMARY KEY, + purchase_order_id VARCHAR(50), -- 매핑 필드 + product_name VARCHAR(200), + product_code VARCHAR(50), + installation_date DATE, + installation_location TEXT, + technician VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() +); + +-- 단계 3: 폐기 신청 테이블 +CREATE TABLE disposals ( + id SERIAL PRIMARY KEY, + installation_id INTEGER, -- 매핑 필드 + product_name VARCHAR(200), + product_code VARCHAR(50), + disposal_date DATE, + disposal_method VARCHAR(100), + disposal_cost DECIMAL(15,2), + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 데이터 이동 + +```typescript +// 구매 → 설치 단계로 이동 +const result = await moveData( + flowId, + purchaseStepId, + installationStepId, + purchaseOrderId +); + +// 결과: +// 1. purchase_orders 테이블에서 데이터 조회 +// 2. field_mappings에 따라 필드 매핑 +// 3. installations 테이블에 새 레코드 생성 +// 4. flow_data_mapping 테이블에 매핑 정보 저장 +// 5. flow_audit_log에 이동 이력 기록 +``` + +### 매핑 정보 조회 + +```sql +-- 플로우 전체 이력 조회 +SELECT * FROM flow_data_mapping +WHERE flow_definition_id = 2; + +-- 결과 예시: +-- { +-- "current_step_id": 2, +-- "step_data_map": { +-- "1": "123", -- 구매 주문 ID +-- "2": "456" -- 설치 작업 ID +-- } +-- } +``` + +## 3. 하이브리드 방식 (Both Mode) + +### 사용 시나리오 + +- 상태도 변경하면서 다른 테이블로도 이동해야 하는 경우 +- 예: 검토 완료 후 승인 테이블로 이동하면서 원본 테이블의 상태도 변경 + +### 설정 방법 + +```sql +INSERT INTO flow_step ( + flow_definition_id, step_name, step_order, + table_name, move_type, + status_column, status_value, -- 상태 변경용 + target_table, field_mappings, -- 테이블 이동용 + required_fields +) VALUES +(3, '검토 완료', 1, 'review_queue', 'both', + 'status', 'reviewed', + 'approved_items', + '{"item_id":"source_item_id","item_name":"name","review_score":"score"}'::jsonb, + '["review_date","reviewer_id","review_comment"]'::jsonb); +``` + +### 동작 + +1. **상태 변경**: review_queue 테이블의 status를 'reviewed'로 업데이트 +2. **테이블 이동**: approved_items 테이블에 새 레코드 생성 +3. **매핑 저장**: flow_data_mapping에 양쪽 ID 기록 + +## 4. 프론트엔드 구현 + +### FlowWidget에서 데이터 이동 + +```typescript +// frontend/components/screen/widgets/FlowWidget.tsx + +const handleMoveToNext = async () => { + // ... 선택된 데이터 준비 ... + + for (const data of selectedData) { + // Primary Key 추출 (첫 번째 컬럼 또는 'id' 컬럼) + const dataId = data.id || data[stepDataColumns[0]]; + + // API 호출 + const response = await moveData(flowId, currentStepId, nextStepId, dataId); + + if (!response.success) { + toast.error(`이동 실패: ${response.message}`); + continue; + } + + // 성공 시 targetDataId 확인 가능 + if (response.data?.targetDataId) { + console.log(`새 테이블 ID: ${response.data.targetDataId}`); + } + } + + // 데이터 새로고침 + await refreshStepData(); +}; +``` + +### 추가 데이터 전달 + +```typescript +// 다음 단계로 이동하면서 추가 데이터 입력 +const additionalData = { + installation_date: "2025-10-20", + technician: "John Doe", + installation_notes: "Installed successfully", +}; + +const response = await fetch(`/api/flow/${flowId}/move`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fromStepId: currentStepId, + toStepId: nextStepId, + dataId: dataId, + additionalData: additionalData, + }), +}); +``` + +## 5. 감사 로그 조회 + +### 특정 데이터의 이력 조회 + +```typescript +const auditLogs = await getFlowAuditLogs(flowId, dataId); + +// 결과: +[ + { + id: 1, + moveType: "table", + sourceTable: "purchase_orders", + targetTable: "installations", + sourceDataId: "123", + targetDataId: "456", + fromStepName: "구매", + toStepName: "설치", + changedBy: "system", + changedAt: "2025-10-20T10:30:00", + }, + { + id: 2, + moveType: "table", + sourceTable: "installations", + targetTable: "disposals", + sourceDataId: "456", + targetDataId: "789", + fromStepName: "설치", + toStepName: "폐기", + changedBy: "user123", + changedAt: "2025-10-21T14:20:00", + }, +]; +``` + +## 6. 모범 사례 + +### 상태 변경 방식 사용 시 + +✅ **권장**: + +- 단일 엔티티의 생명주기 관리 +- 간단한 승인 프로세스 +- 빠른 상태 조회가 필요한 경우 + +❌ **비권장**: + +- 각 단계마다 완전히 다른 데이터 구조가 필요한 경우 + +### 테이블 이동 방식 사용 시 + +✅ **권장**: + +- 각 단계가 독립적인 엔티티 +- 단계별로 다른 팀/부서에서 관리 +- 각 단계의 데이터 구조가 완전히 다른 경우 + +❌ **비권장**: + +- 단순한 상태 변경만 필요한 경우 (오버엔지니어링) +- 실시간 조회 성능이 중요한 경우 (JOIN 비용) + +### 하이브리드 방식 사용 시 + +✅ **권장**: + +- 원본 데이터는 보존하면서 처리된 데이터는 별도 저장 +- 이중 추적이 필요한 경우 + +## 7. 주의사항 + +1. **필드 매핑 주의**: `field_mappings`의 소스/타겟 필드가 정확해야 함 +2. **필수 필드 검증**: `required_fields`에 명시된 필드는 반드시 입력 +3. **트랜잭션**: 모든 이동은 트랜잭션으로 처리되어 원자성 보장 +4. **Primary Key**: 테이블 이동 시 소스 데이터의 Primary Key가 명확해야 함 +5. **순환 참조 방지**: 플로우 연결 시 사이클이 발생하지 않도록 주의 + +## 8. 트러블슈팅 + +### Q1: "데이터를 찾을 수 없습니다" 오류 + +- 원인: Primary Key가 잘못되었거나 데이터가 이미 이동됨 +- 해결: `flow_audit_log`에서 이동 이력 확인 + +### Q2: "매핑할 데이터가 없습니다" 오류 + +- 원인: `field_mappings`가 비어있거나 소스 필드가 없음 +- 해결: 소스 테이블에 매핑 필드가 존재하는지 확인 + +### Q3: 테이블 이동 후 원본 데이터 처리 + +- 원본 데이터는 자동으로 삭제되지 않음 +- 필요시 별도 로직으로 처리하거나 `is_archived` 플래그 사용 + +## 9. 성능 최적화 + +1. **인덱스 생성**: 상태 컬럼에 인덱스 필수 + +```sql +CREATE INDEX idx_documents_status ON documents(approval_status); +``` + +2. **배치 이동**: 대량 데이터는 배치 API 사용 + +```typescript +await moveBatchData(flowId, fromStepId, toStepId, dataIds); +``` + +3. **매핑 테이블 정리**: 주기적으로 완료된 플로우의 매핑 데이터 아카이빙 + +```sql +DELETE FROM flow_data_mapping +WHERE created_at < NOW() - INTERVAL '1 year' +AND current_step_id IN (SELECT id FROM flow_step WHERE step_order = (SELECT MAX(step_order) FROM flow_step WHERE flow_definition_id = ?)); +``` + +## 결론 + +하이브리드 플로우 시스템은 다양한 비즈니스 요구사항에 유연하게 대응할 수 있습니다: + +- 간단한 상태 관리부터 +- 복잡한 다단계 프로세스까지 +- 하나의 시스템으로 통합 관리 가능 diff --git a/docs/FLOW_MANAGEMENT_UI_DESIGN.md b/docs/FLOW_MANAGEMENT_UI_DESIGN.md new file mode 100644 index 00000000..68ea2c66 --- /dev/null +++ b/docs/FLOW_MANAGEMENT_UI_DESIGN.md @@ -0,0 +1,646 @@ +# 플로우 관리 시스템 UI 설계 + +## 1. 플로우 관리 화면 (/flow-management) + +### 1.1 전체 레이아웃 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 플로우 관리 [+ 새 플로우] [저장] │ +├──────────────┬──────────────────────────────────────┬───────────────┤ +│ │ │ │ +│ 플로우 목록 │ 플로우 편집 캔버스 │ 속성 패널 │ +│ (좌측) │ (중앙) │ (우측) │ +│ │ │ │ +│ ┌────────┐ │ ┌──────┐ │ ┌───────────┐ │ +│ │플로우 1│ │ │ │ ┌──────┐ │ │ 단계명: │ │ +│ ├────────┤ │ │ 구매 │─────▶│ 설치 │ │ │ [구매] │ │ +│ │플로우 2│ │ │ │ └──────┘ │ │ │ │ +│ ├────────┤ │ └──────┘ │ │ │ 색상: │ │ +│ │플로우 3│ │ │ │ │ [파랑] │ │ +│ └────────┘ │ ▼ │ │ │ │ +│ │ ┌──────┐ │ │ 조건 설정: │ │ +│ [테이블 선택]│ │ 폐기 │ │ │ │ │ +│ [product_ │ └──────┘ │ │ 컬럼: │ │ +│ dtg ] │ │ │ [status] │ │ +│ │ │ │ │ │ +│ │ │ │ 연산자: │ │ +│ │ │ │ [equals] │ │ +│ │ │ │ │ │ +│ │ │ │ 값: │ │ +│ │ │ │ [구매완료]│ │ +│ │ │ │ │ │ +│ │ │ │[+조건추가]│ │ +│ │ │ └───────────┘ │ +├──────────────┴──────────────────────────────────────┴───────────────┤ +│ 도구 모음: [노드 추가] [연결] [삭제] [정렬] [줌 인/아웃] [미니맵] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 플로우 노드 상세 + +``` +┌─────────────────────────────┐ +│ 구매 [x] │ ← 닫기 버튼 +├─────────────────────────────┤ +│ 상태: status = '구매완료' │ ← 조건 요약 +│ AND install_date IS NULL │ +├─────────────────────────────┤ +│ 데이터: 15건 │ ← 현재 조건에 맞는 데이터 수 +└─────────────────────────────┘ + ↓ + [연결선 라벨] + ↓ +┌─────────────────────────────┐ +│ 설치 [x] │ +├─────────────────────────────┤ +│ 상태: status = '설치완료' │ +│ AND disposal_date IS NULL │ +├─────────────────────────────┤ +│ 데이터: 8건 │ +└─────────────────────────────┘ +``` + +### 1.3 조건 빌더 UI + +``` +┌─────────────────────────────────────────────┐ +│ 조건 설정 [AND▼] │ +├─────────────────────────────────────────────┤ +│ 조건 1: [- 삭제]│ +│ ┌───────────┬──────────┬──────────────┐ │ +│ │ 컬럼 │ 연산자 │ 값 │ │ +│ │ [status▼] │[equals▼] │[구매완료 ]│ │ +│ └───────────┴──────────┴──────────────┘ │ +│ │ +│ 조건 2: [- 삭제]│ +│ ┌───────────┬──────────┬──────────────┐ │ +│ │ 컬럼 │ 연산자 │ 값 │ │ +│ │[install_ │[is_null▼]│ │ │ +│ │ date ▼]│ │ │ │ +│ └───────────┴──────────┴──────────────┘ │ +│ │ +│ [+ 조건 추가] │ +└─────────────────────────────────────────────┘ + +연산자 옵션: +- equals (같음) +- not_equals (같지 않음) +- in (포함) +- not_in (포함하지 않음) +- greater_than (크다) +- less_than (작다) +- is_null (NULL) +- is_not_null (NULL 아님) +``` + +--- + +## 2. 화면관리에서 플로우 위젯 배치 + +### 2.1 화면 편집기 (ScreenDesigner) + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 화면 편집기 - DTG 제품 관리 [저장] │ +├─────────────┬───────────────────────────────────────┬───────────────┤ +│ │ │ │ +│ 컴포넌트 │ 캔버스 │ 속성 패널 │ +│ │ │ │ +│ ┌─────────┐ │ ┌─────────────────────────────────┐ │ 타입: │ +│ │ 입력필드 │ │ │ DTG 제품 라이프사이클 │ │ [flow-widget] │ +│ ├─────────┤ │ ├─────┬─────┬─────┬─────┬─────────┤ │ │ +│ │ 버튼 │ │ │구매 │ │설치 │ │ 폐기 │ │ 플로우 선택: │ +│ ├─────────┤ │ │ │ → │ │ → │ │ │ [DTG 라이프 │ +│ │ 테이블 │ │ │ 15건│ │ 8건 │ │ 3건 │ │ 사이클 ▼] │ +│ ├─────────┤ │ └─────┴─────┴─────┴─────┴─────────┘ │ │ +│ │플로우 │ │ ◀ 드래그앤드롭으로 배치 │ 레이아웃: │ +│ └─────────┘ │ │ [가로▼] │ +│ │ ┌─────────────────────────────────┐ │ │ +│ │ │ 제품 상세 정보 │ │ 카드 너비: │ +│ │ │ ┌────────────┬───────────────┐ │ │ [200px] │ +│ │ │ │ 제품명: │ [ ] │ │ │ │ +│ │ │ │ 구매일자: │ [ ] │ │ │ 데이터 카운트 │ +│ │ │ │ 설치일자: │ [ ] │ │ │ [✓] 표시 │ +│ │ │ │ 폐기일자: │ [ ] │ │ │ │ +│ │ │ └────────────┴───────────────┘ │ │ 연결선 │ +│ │ └─────────────────────────────────┘ │ [✓] 표시 │ +│ │ │ │ +└─────────────┴───────────────────────────────────────┴───────────────┘ +``` + +### 2.2 플로우 위젯 설정 패널 + +``` +┌─────────────────────────────────────┐ +│ 플로우 위젯 설정 │ +├─────────────────────────────────────┤ +│ 플로우 선택: │ +│ ┌───────────────────────────────┐ │ +│ │ DTG 제품 라이프사이클 ▼ │ │ +│ └───────────────────────────────┘ │ +│ │ +│ 레이아웃: │ +│ ( ) 가로 (•) 세로 │ +│ │ +│ 카드 너비: │ +│ [200px ] │ +│ │ +│ 카드 높이: │ +│ [120px ] │ +│ │ +│ [✓] 데이터 카운트 표시 │ +│ [✓] 연결선 표시 │ +│ [ ] 컴팩트 모드 │ +│ │ +│ 카드 스타일: │ +│ ┌─────────────────────────────┐ │ +│ │ 테두리 색상: [단계별 색상 ▼] │ │ +│ │ 배경 색상: [흰색 ▼] │ │ +│ │ 그림자: [중간 ▼] │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +--- + +## 3. 실제 화면에서 플로우 표시 (InteractiveScreenViewer) + +### 3.1 가로 레이아웃 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ DTG 제품 관리 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ DTG 제품 라이프사이클 │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 구매 │ │ 설치 │ │ 폐기 │ │ +│ │ │ → │ │ → │ │ │ +│ │ 15건 │ │ 8건 │ │ 3건 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ ↑ 클릭하면 데이터 리스트 모달 열림 │ +│ │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ 제품 상세 정보 │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ 제품명: [DTG-001 ] │ │ +│ │ 구매일자: [2024-01-15 ] │ │ +│ │ 설치일자: [2024-02-20 ] │ │ +│ │ 폐기일자: [ ] │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 세로 레이아웃 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ DTG 제품 관리 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ DTG 제품 라이프사이클 │ +│ ┌─────────────┐ │ +│ │ 구매 │ │ +│ │ │ │ +│ │ 15건 │ │ +│ └─────────────┘ │ +│ ↓ │ +│ ┌─────────────┐ │ +│ │ 설치 │ │ +│ │ │ │ +│ │ 8건 │ │ +│ └─────────────┘ │ +│ ↓ │ +│ ┌─────────────┐ │ +│ │ 폐기 │ │ +│ │ │ │ +│ │ 3건 │ │ +│ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 플로우 단계 클릭 시 데이터 리스트 모달 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 구매 단계 - 데이터 목록 [X 닫기] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──┬──────────┬────────────┬────────────┬──────────┬──────────┐ │ +│ │□ │ 제품명 │ 구매일자 │ 구매금액 │ 구매처 │ 상태 │ │ +│ ├──┼──────────┼────────────┼────────────┼──────────┼──────────┤ │ +│ │☑ │ DTG-001 │ 2024-01-15 │ 15,000,000 │ A업체 │ 구매완료 │ │ +│ │☑ │ DTG-002 │ 2024-01-20 │ 15,500,000 │ B업체 │ 구매완료 │ │ +│ │□ │ DTG-003 │ 2024-02-01 │ 14,800,000 │ A업체 │ 구매완료 │ │ +│ │□ │ DTG-004 │ 2024-02-05 │ 16,200,000 │ C업체 │ 구매완료 │ │ +│ │☑ │ DTG-005 │ 2024-02-10 │ 15,000,000 │ B업체 │ 구매완료 │ │ +│ │□ │ DTG-006 │ 2024-02-15 │ 15,300,000 │ A업체 │ 구매완료 │ │ +│ │□ │ DTG-007 │ 2024-02-20 │ 15,700,000 │ B업체 │ 구매완료 │ │ +│ │□ │ DTG-008 │ 2024-02-25 │ 16,000,000 │ C업체 │ 구매완료 │ │ +│ └──┴──────────┴────────────┴────────────┴──────────┴──────────┘ │ +│ │ +│ 선택된 항목: 3개 [1] [2] [3] [4] [5] │ +│ │ +├─────────────────────────────────────────────────────────────────────┤ +│ [취소] [설치 단계로 이동] ← │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 4.1 단계 이동 확인 대화상자 + +``` +┌─────────────────────────────────────────┐ +│ 단계 이동 확인 │ +├─────────────────────────────────────────┤ +│ │ +│ 선택한 3개의 제품을 │ +│ '구매' 단계에서 '설치' 단계로 │ +│ 이동하시겠습니까? │ +│ │ +│ 이동 사유 (선택): │ +│ ┌─────────────────────────────────┐ │ +│ │ 설치 일정 확정 │ │ +│ │ │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ [취소] [확인] │ +└─────────────────────────────────────────┘ +``` + +--- + +## 5. 오딧 로그 (이력) 화면 + +### 5.1 제품 상세 화면 내 이력 탭 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 제품 상세: DTG-001 │ +├──────┬──────────────────────────────────────────────────────────────┤ +│ 기본 │ 플로우 이력 │ 문서 │ AS 이력 │ │ +├──────┴──────────────────────────────────────────────────────────────┤ +│ │ +│ DTG 제품 라이프사이클 이력 │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ 2024-02-20 14:30:25 │ │ +│ │ [구매] → [설치] │ │ +│ │ 변경자: 홍길동 (설치팀) │ │ +│ │ 사유: 고객사 설치 완료 │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ 2024-01-15 09:15:00 │ │ +│ │ [시작] → [구매] │ │ +│ │ 변경자: 김철수 (구매팀) │ │ +│ │ 사유: 신규 제품 구매 등록 │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 타임라인 뷰 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 플로우 이력 (타임라인) │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 2024-01-15 │ +│ │ │ +│ ● 구매 (김철수) │ +│ │ "신규 제품 구매 등록" │ +│ │ │ +│ │ (36일 경과) │ +│ │ │ +│ 2024-02-20 │ +│ │ │ +│ ● 설치 (홍길동) │ +│ │ "고객사 설치 완료" │ +│ │ │ +│ │ (진행 중...) │ +│ │ │ +│ ○ 폐기 (예정) │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. 플로우 위젯 스타일 변형 + +### 6.1 컴팩트 모드 + +``` +┌─────────────────────────────────────────┐ +│ DTG 라이프사이클 │ +│ [구매 15] → [설치 8] → [폐기 3] │ +└─────────────────────────────────────────┘ +``` + +### 6.2 카드 상세 모드 + +``` +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ 구매 │ │ 설치 │ │ 폐기 │ +├──────────────────┤ ├──────────────────┤ ├──────────────────┤ +│ │ │ │ │ │ +│ 15건 │→ │ 8건 │→ │ 3건 │ +│ │ │ │ │ │ +├──────────────────┤ ├──────────────────┤ ├──────────────────┤ +│ 조건: │ │ 조건: │ │ 조건: │ +│ status=구매완료 │ │ status=설치완료 │ │ status=폐기완료 │ +│ │ │ │ │ │ +│ 최근 업데이트: │ │ 최근 업데이트: │ │ 최근 업데이트: │ +│ 2024-02-25 │ │ 2024-02-20 │ │ 2024-01-15 │ +└──────────────────┘ └──────────────────┘ └──────────────────┘ +``` + +### 6.3 프로그레스 바 스타일 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ DTG 제품 라이프사이클 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 구매 ━━━━━━━━━ 설치 ━━━━━━━━━ 폐기 │ +│ 15건 57% 8건 31% 3건 12% │ +│ ████████████▓▓▓▓▓▓▓▓▓░░░░░░░░ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 7. 모바일 반응형 디자인 + +### 7.1 모바일 뷰 (세로) + +``` +┌─────────────────────┐ +│ ☰ DTG 제품 관리 │ +├─────────────────────┤ +│ │ +│ 라이프사이클: │ +│ │ +│ ┌───────────────┐ │ +│ │ 구매 │ │ +│ │ 15건 │ │ +│ └───────────────┘ │ +│ ↓ │ +│ ┌───────────────┐ │ +│ │ 설치 │ │ +│ │ 8건 │ │ +│ └───────────────┘ │ +│ ↓ │ +│ ┌───────────────┐ │ +│ │ 폐기 │ │ +│ │ 3건 │ │ +│ └───────────────┘ │ +│ │ +│ ─────────────── │ +│ │ +│ 제품 정보 │ +│ 제품명: DTG-001 │ +│ 구매일: 2024-01-15 │ +│ 설치일: 2024-02-20 │ +│ │ +└─────────────────────┘ +``` + +--- + +## 8. 플로우 편집기 상세 기능 + +### 8.1 노드 추가 메뉴 + +``` +┌─────────────────────────────────────────┐ +│ 캔버스 우클릭 메뉴 │ +├─────────────────────────────────────────┤ +│ ➕ 단계 추가 │ +│ 🔗 연결선 추가 │ +│ 📋 붙여넣기 │ +│ ─────────────────────────────────── │ +│ 🎨 배경 색상 변경 │ +│ 📏 격자 설정 │ +│ 🔍 확대/축소 │ +└─────────────────────────────────────────┘ +``` + +### 8.2 노드 우클릭 메뉴 + +``` +┌─────────────────────────────────────────┐ +│ 단계 메뉴 │ +├─────────────────────────────────────────┤ +│ ✏️ 편집 │ +│ 📋 복사 │ +│ 🗑️ 삭제 │ +│ ─────────────────────────────────── │ +│ 🔗 다음 단계로 연결 │ +│ 🎨 색상 변경 │ +│ 📊 데이터 미리보기 (15건) │ +│ ─────────────────────────────────── │ +│ ⬆️ 앞으로 가져오기 │ +│ ⬇️ 뒤로 보내기 │ +└─────────────────────────────────────────┘ +``` + +### 8.3 미니맵 + +``` +┌──────────────────────┐ +│ 미니맵 [X] │ +├──────────────────────┤ +│ ┌────────────────┐ │ +│ │ ● │ │ +│ │ ● │ │ +│ │ │ │ +│ │ ● │ │ +│ │ │ │ +│ │ [====] ←현재 │ │ +│ │ 뷰포트│ │ +│ └────────────────┘ │ +│ │ +│ 줌: 100% │ +│ [-] ■■■■■ [+] │ +└──────────────────────┘ +``` + +--- + +## 9. 플로우 템플릿 선택 화면 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 새 플로우 만들기 [X 닫기] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 템플릿 선택: │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 빈 플로우 │ │ 3단계 플로우 │ │ 승인 플로우 │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ ● → ● → ● │ │ ● → ● → ● │ │ +│ │ + │ │ │ │ ↓ ↓ │ │ +│ │ │ │ │ │ ● ● │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 구매→설치 │ │ 품질검사 │ │ 커스텀 │ │ +│ │ │ │ │ │ │ │ +│ │ ● → ● → ● │ │ ● → ● → ● │ │ │ │ +│ │ │ │ ↓ ↓ ↓ │ │ 불러오기 │ │ +│ │ │ │ ● ● ● │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ 또는 │ +│ │ +│ 플로우 이름: [ ] │ +│ 연결 테이블: [product_dtg ▼] │ +│ │ +│ [취소] [빈 플로우로 시작] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 10. 데이터 흐름 다이어그램 + +### 10.1 전체 시스템 흐름 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 플로우 관리 시스템 │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ┌────────────────────────┼────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ 플로우 정의 │ │ 조건 설정 │ │ 시각화 편집 │ +│ (정의/수정) │ │ (SQL 변환) │ │ (React Flow) │ +└───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + └────────────────────────┼────────────────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ 데이터베이스 저장 │ + │ - flow_definition │ + │ - flow_step │ + │ - flow_step_connection │ + └────────────┬────────────┘ + │ + ▼ + ┌────────────────────────┼────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ 화면관리 │ │ 실시간 카운트 │ │ 데이터 이동 │ +│ (위젯 배치) │ │ (조건 조회) │ │ (상태 변경) │ +└───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + └────────────────────────┼────────────────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ 사용자 화면에 표시 │ + │ + 오딧 로그 기록 │ + └─────────────────────────┘ +``` + +--- + +## 11. 사용자 시나리오 플로우 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 시나리오: DTG 제품 라이프사이클 관리 │ +└─────────────────────────────────────────────────────────────────────┘ + +단계 1: 플로우 정의 +───────────────────── +관리자가 플로우 관리 화면에서: +1. "새 플로우" 클릭 +2. 이름: "DTG 제품 라이프사이클" 입력 +3. 테이블: "product_dtg" 선택 +4. "구매", "설치", "폐기" 3개 단계 추가 +5. 각 단계의 조건 설정 +6. 저장 + +단계 2: 화면에 배치 +───────────────────── +관리자가 화면관리에서: +1. "DTG 제품 관리" 화면 열기 +2. 컴포넌트 팔레트에서 "플로우 위젯" 드래그 +3. "DTG 제품 라이프사이클" 플로우 선택 +4. 레이아웃 및 스타일 설정 +5. 저장 + +단계 3: 일반 사용자 사용 +───────────────────── +일반 사용자가: +1. "DTG 제품 관리" 화면 접속 +2. 플로우 위젯에서 각 단계별 건수 확인 + - 구매: 15건 + - 설치: 8건 + - 폐기: 3건 +3. "구매" 단계 클릭 → 데이터 리스트 모달 열림 +4. 설치 완료된 제품 2개 선택 +5. "설치 단계로 이동" 버튼 클릭 +6. 이동 사유 입력: "설치 완료" +7. 확인 → 데이터 이동 및 오딧 로그 기록 + +단계 4: 이력 조회 +───────────────────── +사용자가: +1. 특정 제품(DTG-001) 상세 화면 열기 +2. "플로우 이력" 탭 클릭 +3. 모든 상태 변경 이력 확인 + - 언제, 누가, 어떤 단계로 이동했는지 + - 이동 사유 +``` + +--- + +## 12. 색상 및 테마 + +### 12.1 기본 색상 팔레트 + +``` +플로우 단계 색상: +┌──────┬──────┬──────┬──────┬──────┬──────┐ +│ 파랑 │ 초록 │ 주황 │ 빨강 │ 보라 │ 회색 │ +│#3B82F6│#10B981│#F59E0B│#EF4444│#8B5CF6│#6B7280│ +└──────┴──────┴──────┴──────┴──────┴──────┘ + +상태별 색상: +- 시작: 파랑 +- 진행 중: 초록 +- 대기: 주황 +- 완료: 회색 +- 거부/폐기: 빨강 +``` + +### 12.2 다크모드 + +``` +┌─────────────────────────────────────────┐ +│ 플로우 관리 (다크모드) │ +├─────────────────────────────────────────┤ +│ 배경: #1F2937 (어두운 회색) │ +│ 카드: #374151 (중간 회색) │ +│ 텍스트: #F9FAFB (밝은 회색) │ +│ 강조: #3B82F6 (파랑) │ +└─────────────────────────────────────────┘ +``` + +이상으로 플로우 관리 시스템의 UI 설계를 도식화했습니다! diff --git a/docs/PHASE1_FLOW_IMPLEMENTATION_SUMMARY.md b/docs/PHASE1_FLOW_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 00000000..09e5ed23 --- /dev/null +++ b/docs/PHASE1_FLOW_IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,216 @@ +# Phase 1 플로우 관리 시스템 구현 완료 보고서 + +## 구현 일시 + +2024년 (구현 완료) + +## 구현 내역 + +### 1. 데이터베이스 구조 ✅ + +#### 생성된 테이블 (5개) + +1. **flow_definition** - 플로우 정의 + + - 플로우 이름, 설명, 연결 테이블명 + - 활성화 상태 관리 + - 생성자 및 타임스탬프 + +2. **flow_step** - 플로우 단계 + + - 단계 이름, 순서, 조건(JSONB) + - 색상, 캔버스 위치(X, Y) + - 타임스탬프 + +3. **flow_step_connection** - 플로우 단계 연결 + + - 시작 단계 → 종료 단계 + - 연결선 라벨 + +4. **flow_data_status** - 데이터의 현재 플로우 상태 + + - 레코드별 현재 단계 추적 + - 수정자 및 타임스탬프 + - UNIQUE 제약조건 (flowId + tableName + recordId) + +5. **flow_audit_log** - 플로우 상태 변경 이력 + - 이전 단계 → 이동 단계 + - 변경자, 변경 사유, 타임스탬프 + +#### 생성된 인덱스 (13개) + +- 테이블명, 활성 상태, 단계 순서, 레코드 조회 등 성능 최적화 + +### 2. 백엔드 서비스 구현 ✅ + +#### 서비스 파일 (6개) + +1. **flowConditionParser.ts** + + - JSON 조건을 SQL WHERE 절로 변환 + - 12개 연산자 지원 (equals, not_equals, in, not_in, greater_than, less_than, >=, <=, is_null, is_not_null, like, not_like) + - SQL 인젝션 방지 (컬럼명 검증) + - 조건 유효성 검증 + +2. **flowDefinitionService.ts** + + - 플로우 정의 CRUD + - 테이블 존재 여부 확인 + - 테이블명, 활성 상태로 필터링 + +3. **flowStepService.ts** + + - 플로우 단계 CRUD + - 단계 순서 재정렬 기능 + - 조건 JSON 검증 + +4. **flowConnectionService.ts** + + - 플로우 단계 연결 관리 + - 순환 참조 체크 (DFS 알고리즘) + - 나가는/들어오는 연결 조회 + +5. **flowExecutionService.ts** + + - 단계별 데이터 카운트 조회 + - 단계별 데이터 리스트 조회 (페이징 지원) + - 모든 단계별 카운트 일괄 조회 + - 현재 플로우 상태 조회 + +6. **flowDataMoveService.ts** + - 데이터 단계 이동 (트랜잭션 처리) + - 여러 데이터 일괄 이동 + - 오딧 로그 기록 + - 플로우 이력 조회 (단일 레코드 / 전체 플로우) + +### 3. API 컨트롤러 및 라우터 ✅ + +#### FlowController (20개 엔드포인트) + +**플로우 정의 (5개)** + +- POST /api/flow/definitions - 생성 +- GET /api/flow/definitions - 목록 +- GET /api/flow/definitions/:id - 상세 +- PUT /api/flow/definitions/:id - 수정 +- DELETE /api/flow/definitions/:id - 삭제 + +**플로우 단계 (3개)** + +- POST /api/flow/definitions/:flowId/steps - 생성 +- PUT /api/flow/steps/:stepId - 수정 +- DELETE /api/flow/steps/:stepId - 삭제 + +**플로우 연결 (2개)** + +- POST /api/flow/connections - 생성 +- DELETE /api/flow/connections/:connectionId - 삭제 + +**플로우 실행 (3개)** + +- GET /api/flow/:flowId/step/:stepId/count - 단계별 카운트 +- GET /api/flow/:flowId/step/:stepId/data - 단계별 데이터 리스트 +- GET /api/flow/:flowId/counts - 모든 단계별 카운트 + +**데이터 이동 (2개)** + +- POST /api/flow/move - 단일 데이터 이동 +- POST /api/flow/move-batch - 여러 데이터 일괄 이동 + +**오딧 로그 (2개)** + +- GET /api/flow/audit/:flowId/:recordId - 레코드별 이력 +- GET /api/flow/audit/:flowId - 플로우 전체 이력 + +### 4. 타입 정의 ✅ + +**types/flow.ts** - 완전한 TypeScript 타입 정의 + +- 22개 인터페이스 및 타입 +- 요청/응답 타입 분리 +- ConditionOperator 타입 정의 + +### 5. 통합 완료 ✅ + +- app.ts에 flowRoutes 등록 +- 데이터베이스 마이그레이션 실행 완료 +- 모든 테이블 및 인덱스 생성 완료 + +## 구현된 주요 기능 + +### 1. 조건 시스템 + +- 복잡한 AND/OR 조건 지원 +- 12개 연산자로 유연한 필터링 +- SQL 인젝션 방지 + +### 2. 순환 참조 방지 + +- DFS 알고리즘으로 순환 참조 체크 +- 무한 루프 방지 + +### 3. 트랜잭션 처리 + +- 데이터 이동 시 원자성 보장 +- flow_data_status + flow_audit_log 동시 업데이트 +- 실패 시 자동 롤백 + +### 4. 성능 최적화 + +- 적절한 인덱스 생성 +- 페이징 지원 +- 필터링 쿼리 최적화 + +### 5. 오딧 로그 + +- 모든 상태 변경 추적 +- 변경자, 변경 사유 기록 +- 단계명 조인 (from_step_name, to_step_name) + +## 테스트 준비 + +**test-flow-api.rest** 파일 생성 (20개 테스트 케이스) + +- 플로우 정의 CRUD +- 플로우 단계 관리 +- 플로우 연결 관리 +- 데이터 조회 (카운트, 리스트) +- 데이터 이동 (단일, 일괄) +- 오딧 로그 조회 + +## 다음 단계 (Phase 2) + +### 프론트엔드 구현 + +1. React Flow 라이브러리 설치 +2. FlowEditor 컴포넌트 +3. FlowConditionBuilder UI +4. FlowList 컴포넌트 +5. FlowStepPanel 속성 편집 + +### 예상 소요 시간: 1주 + +## 기술 스택 + +- **Backend**: Node.js + Express + TypeScript +- **Database**: PostgreSQL +- **ORM**: Raw SQL (트랜잭션 세밀 제어) +- **Validation**: 커스텀 검증 로직 + +## 코드 품질 + +- ✅ TypeScript 타입 안전성 +- ✅ 에러 처리 +- ✅ SQL 인젝션 방지 +- ✅ 트랜잭션 관리 +- ✅ 코드 주석 및 문서화 + +## 결론 + +Phase 1의 모든 목표가 성공적으로 완료되었습니다. 백엔드 API가 완전히 구현되었으며, 데이터베이스 스키마도 안정적으로 생성되었습니다. 이제 프론트엔드 구현(Phase 2)을 진행할 준비가 완료되었습니다. + +--- + +**구현 완료일**: 2024년 +**구현자**: AI Assistant +**검토 상태**: 대기 중 diff --git a/frontend/app/(main)/admin/flow-external-db/page.tsx b/frontend/app/(main)/admin/flow-external-db/page.tsx new file mode 100644 index 00000000..2edf9911 --- /dev/null +++ b/frontend/app/(main)/admin/flow-external-db/page.tsx @@ -0,0 +1,384 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { useToast } from "@/hooks/use-toast"; +import { flowExternalDbApi } from "@/lib/api/flowExternalDb"; +import { + FlowExternalDbConnection, + CreateFlowExternalDbConnectionRequest, + UpdateFlowExternalDbConnectionRequest, + DB_TYPE_OPTIONS, + getDbTypeLabel, +} from "@/types/flowExternalDb"; +import { Plus, Pencil, Trash2, TestTube, Loader2 } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; + +export default function FlowExternalDbPage() { + const { toast } = useToast(); + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [showDialog, setShowDialog] = useState(false); + const [editingConnection, setEditingConnection] = useState(null); + const [testingId, setTestingId] = useState(null); + + // 폼 상태 + const [formData, setFormData] = useState< + CreateFlowExternalDbConnectionRequest | UpdateFlowExternalDbConnectionRequest + >({ + name: "", + description: "", + dbType: "postgresql", + host: "", + port: 5432, + databaseName: "", + username: "", + password: "", + sslEnabled: false, + }); + + useEffect(() => { + loadConnections(); + }, []); + + const loadConnections = async () => { + try { + setLoading(true); + const response = await flowExternalDbApi.getAll(); + if (response.success) { + setConnections(response.data); + } + } catch (error: any) { + toast({ + title: "오류", + description: error.message || "외부 DB 연결 목록 조회 실패", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const handleCreate = () => { + setEditingConnection(null); + setFormData({ + name: "", + description: "", + dbType: "postgresql", + host: "", + port: 5432, + databaseName: "", + username: "", + password: "", + sslEnabled: false, + }); + setShowDialog(true); + }; + + const handleEdit = (connection: FlowExternalDbConnection) => { + setEditingConnection(connection); + setFormData({ + name: connection.name, + description: connection.description, + host: connection.host, + port: connection.port, + databaseName: connection.databaseName, + username: connection.username, + password: "", // 비밀번호는 비워둠 + sslEnabled: connection.sslEnabled, + isActive: connection.isActive, + }); + setShowDialog(true); + }; + + const handleSave = async () => { + try { + if (editingConnection) { + // 수정 + await flowExternalDbApi.update(editingConnection.id, formData); + toast({ title: "성공", description: "외부 DB 연결이 수정되었습니다" }); + } else { + // 생성 + await flowExternalDbApi.create(formData as CreateFlowExternalDbConnectionRequest); + toast({ title: "성공", description: "외부 DB 연결이 생성되었습니다" }); + } + setShowDialog(false); + loadConnections(); + } catch (error: any) { + toast({ + title: "오류", + description: error.message, + variant: "destructive", + }); + } + }; + + const handleDelete = async (id: number, name: string) => { + if (!confirm(`"${name}" 연결을 삭제하시겠습니까?`)) { + return; + } + + try { + await flowExternalDbApi.delete(id); + toast({ title: "성공", description: "외부 DB 연결이 삭제되었습니다" }); + loadConnections(); + } catch (error: any) { + toast({ + title: "오류", + description: error.message, + variant: "destructive", + }); + } + }; + + const handleTestConnection = async (id: number, name: string) => { + setTestingId(id); + try { + const result = await flowExternalDbApi.testConnection(id); + toast({ + title: result.success ? "연결 성공" : "연결 실패", + description: result.message, + variant: result.success ? "default" : "destructive", + }); + } catch (error: any) { + toast({ + title: "오류", + description: error.message, + variant: "destructive", + }); + } finally { + setTestingId(null); + } + }; + + return ( +
+
+
+

외부 DB 연결 관리

+

플로우에서 사용할 외부 데이터베이스 연결을 관리합니다

+
+ +
+ + {loading ? ( +
+ +
+ ) : connections.length === 0 ? ( +
+

등록된 외부 DB 연결이 없습니다

+ +
+ ) : ( +
+ + + + 이름 + DB 타입 + 호스트 + 데이터베이스 + 상태 + 작업 + + + + {connections.map((conn) => ( + + +
+
{conn.name}
+ {conn.description &&
{conn.description}
} +
+
+ + {getDbTypeLabel(conn.dbType)} + + + {conn.host}:{conn.port} + + {conn.databaseName} + + {conn.isActive ? "활성" : "비활성"} + + +
+ + + +
+
+
+ ))} +
+
+
+ )} + + {/* 생성/수정 다이얼로그 */} + + + + {editingConnection ? "외부 DB 연결 수정" : "새 외부 DB 연결 추가"} + 외부 데이터베이스 연결 정보를 입력하세요 + + +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="예: 운영_PostgreSQL" + /> +
+ +
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="연결에 대한 설명" + /> +
+ +
+ + +
+ +
+
+ + setFormData({ ...formData, host: e.target.value })} + placeholder="localhost" + /> +
+
+ + setFormData({ ...formData, port: parseInt(e.target.value) || 0 })} + /> +
+
+ +
+ + setFormData({ ...formData, databaseName: e.target.value })} + placeholder="mydb" + /> +
+ +
+ + setFormData({ ...formData, username: e.target.value })} + placeholder="dbuser" + /> +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + placeholder={editingConnection ? "변경하지 않으려면 비워두세요" : "비밀번호"} + /> +
+ +
+ setFormData({ ...formData, sslEnabled: checked })} + /> + +
+ + {editingConnection && ( +
+ setFormData({ ...formData, isActive: checked })} + /> + +
+ )} +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/flow-management/[id]/page.tsx b/frontend/app/(main)/admin/flow-management/[id]/page.tsx new file mode 100644 index 00000000..77d42718 --- /dev/null +++ b/frontend/app/(main)/admin/flow-management/[id]/page.tsx @@ -0,0 +1,323 @@ +"use client"; + +/** + * 플로우 편집기 페이지 + * - React Flow 기반 비주얼 플로우 편집 + * - 단계 추가/수정/삭제 + * - 단계 연결 생성/삭제 + * - 조건 설정 + */ + +import { useState, useEffect, useCallback } from "react"; +import { useParams, useRouter } from "next/navigation"; +import ReactFlow, { + Node, + Edge, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, + Controls, + MiniMap, + Panel, +} from "reactflow"; +import "reactflow/dist/style.css"; +import { ArrowLeft, Plus, Save, Play, Settings, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { useToast } from "@/hooks/use-toast"; +import { + getFlowDefinition, + getFlowSteps, + getFlowConnections, + createFlowStep, + updateFlowStep, + deleteFlowStep, + createFlowConnection, + deleteFlowConnection, + getAllStepCounts, +} from "@/lib/api/flow"; +import { FlowDefinition, FlowStep, FlowStepConnection, FlowNodeData } from "@/types/flow"; +import { FlowNodeComponent } from "@/components/flow/FlowNodeComponent"; +import { FlowStepPanel } from "@/components/flow/FlowStepPanel"; +import { FlowConditionBuilder } from "@/components/flow/FlowConditionBuilder"; + +// 커스텀 노드 타입 등록 +const nodeTypes = { + flowStep: FlowNodeComponent, +}; + +export default function FlowEditorPage() { + const params = useParams(); + const router = useRouter(); + const { toast } = useToast(); + const flowId = Number(params.id); + + // 상태 + const [flowDefinition, setFlowDefinition] = useState(null); + const [steps, setSteps] = useState([]); + const [connections, setConnections] = useState([]); + const [selectedStep, setSelectedStep] = useState(null); + const [stepCounts, setStepCounts] = useState>({}); + const [loading, setLoading] = useState(true); + + // React Flow 상태 + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + // 플로우 데이터 로드 + const loadFlowData = async () => { + setLoading(true); + try { + // 플로우 정의 로드 + const flowRes = await getFlowDefinition(flowId); + if (flowRes.success && flowRes.data) { + setFlowDefinition(flowRes.data); + } + + // 단계 로드 + const stepsRes = await getFlowSteps(flowId); + if (stepsRes.success && stepsRes.data) { + setSteps(stepsRes.data); + } + + // 연결 로드 + const connectionsRes = await getFlowConnections(flowId); + if (connectionsRes.success && connectionsRes.data) { + setConnections(connectionsRes.data); + } + + // 데이터 카운트 로드 + const countsRes = await getAllStepCounts(flowId); + if (countsRes.success && countsRes.data) { + const counts: Record = {}; + countsRes.data.forEach((item) => { + counts[item.stepId] = item.count; + }); + setStepCounts(counts); + } + } catch (error: any) { + toast({ + title: "로딩 실패", + description: error.message, + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadFlowData(); + }, [flowId]); + + // React Flow 노드/엣지 변환 + useEffect(() => { + if (steps.length === 0) return; + + // 노드 생성 + const newNodes: Node[] = steps.map((step) => ({ + id: String(step.id), + type: "flowStep", + position: { x: step.positionX, y: step.positionY }, + data: { + id: step.id, + label: step.stepName, + stepOrder: step.stepOrder, + tableName: step.tableName, + count: stepCounts[step.id] || 0, + condition: step.conditionJson, + }, + })); + + // 엣지 생성 + const newEdges: Edge[] = connections.map((conn) => ({ + id: String(conn.id), + source: String(conn.fromStepId), + target: String(conn.toStepId), + label: conn.label, + type: "smoothstep", + animated: true, + })); + + setNodes(newNodes); + setEdges(newEdges); + }, [steps, connections, stepCounts]); + + // 노드 추가 + const handleAddStep = async () => { + const newStepOrder = steps.length + 1; + const newStep = { + stepName: `단계 ${newStepOrder}`, + stepOrder: newStepOrder, + color: "#3B82F6", + positionX: 100 + newStepOrder * 250, + positionY: 100, + }; + + try { + const response = await createFlowStep(flowId, newStep); + if (response.success && response.data) { + toast({ + title: "단계 추가", + description: "새로운 단계가 추가되었습니다.", + }); + loadFlowData(); + } + } catch (error: any) { + toast({ + title: "추가 실패", + description: error.message, + variant: "destructive", + }); + } + }; + + // 노드 위치 업데이트 + const handleNodeDragStop = useCallback( + async (event: any, node: Node) => { + const step = steps.find((s) => s.id === Number(node.id)); + if (!step) return; + + try { + await updateFlowStep(step.id, { + positionX: Math.round(node.position.x), + positionY: Math.round(node.position.y), + }); + } catch (error: any) { + console.error("위치 업데이트 실패:", error); + } + }, + [steps], + ); + + // 연결 생성 + const handleConnect = useCallback( + async (connection: Connection) => { + if (!connection.source || !connection.target) return; + + try { + const response = await createFlowConnection({ + flowDefinitionId: flowId, + fromStepId: Number(connection.source), + toStepId: Number(connection.target), + }); + + if (response.success) { + toast({ + title: "연결 생성", + description: "단계가 연결되었습니다.", + }); + loadFlowData(); + } + } catch (error: any) { + toast({ + title: "연결 실패", + description: error.message, + variant: "destructive", + }); + } + }, + [flowId], + ); + + // 노드 클릭 + const handleNodeClick = useCallback( + (event: React.MouseEvent, node: Node) => { + const step = steps.find((s) => s.id === Number(node.id)); + if (step) { + setSelectedStep(step); + } + }, + [steps], + ); + + if (loading) { + return ( +
+

로딩 중...

+
+ ); + } + + if (!flowDefinition) { + return ( +
+

플로우를 찾을 수 없습니다.

+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+
+ +
+

{flowDefinition.name}

+

테이블: {flowDefinition.tableName}

+
+
+ +
+ + +
+
+
+ + {/* 편집기 */} +
+ + + + + + +
+
+ 총 단계: {steps.length}개 +
+
+ 연결: {connections.length}개 +
+
+
+
+
+ + {/* 사이드 패널 */} + {selectedStep && ( + setSelectedStep(null)} + onUpdate={loadFlowData} + /> + )} +
+ ); +} diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/flow-management/page.tsx new file mode 100644 index 00000000..999fb6fa --- /dev/null +++ b/frontend/app/(main)/admin/flow-management/page.tsx @@ -0,0 +1,360 @@ +"use client"; + +/** + * 플로우 관리 메인 페이지 + * - 플로우 정의 목록 + * - 플로우 생성/수정/삭제 + * - 플로우 편집기로 이동 + */ + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, Edit2, Trash2, Play, Workflow, Table, Calendar, User } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/hooks/use-toast"; +import { getFlowDefinitions, createFlowDefinition, deleteFlowDefinition } from "@/lib/api/flow"; +import { FlowDefinition } from "@/types/flow"; + +export default function FlowManagementPage() { + const router = useRouter(); + const { toast } = useToast(); + + // 상태 + const [flows, setFlows] = useState([]); + const [loading, setLoading] = useState(true); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [selectedFlow, setSelectedFlow] = useState(null); + + // 생성 폼 상태 + const [formData, setFormData] = useState({ + name: "", + description: "", + tableName: "", + }); + + // 플로우 목록 조회 + const loadFlows = async () => { + setLoading(true); + try { + const response = await getFlowDefinitions({ isActive: true }); + if (response.success && response.data) { + setFlows(response.data); + } else { + toast({ + title: "조회 실패", + description: response.error || "플로우 목록을 불러올 수 없습니다.", + variant: "destructive", + }); + } + } catch (error: any) { + toast({ + title: "오류 발생", + description: error.message, + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadFlows(); + }, []); + + // 플로우 생성 + const handleCreate = async () => { + if (!formData.name || !formData.tableName) { + toast({ + title: "입력 오류", + description: "플로우 이름과 테이블 이름은 필수입니다.", + variant: "destructive", + }); + return; + } + + try { + const response = await createFlowDefinition(formData); + if (response.success && response.data) { + toast({ + title: "생성 완료", + description: "플로우가 성공적으로 생성되었습니다.", + }); + setIsCreateDialogOpen(false); + setFormData({ name: "", description: "", tableName: "" }); + loadFlows(); + } else { + toast({ + title: "생성 실패", + description: response.error || response.message, + variant: "destructive", + }); + } + } catch (error: any) { + toast({ + title: "오류 발생", + description: error.message, + variant: "destructive", + }); + } + }; + + // 플로우 삭제 + const handleDelete = async () => { + if (!selectedFlow) return; + + try { + const response = await deleteFlowDefinition(selectedFlow.id); + if (response.success) { + toast({ + title: "삭제 완료", + description: "플로우가 삭제되었습니다.", + }); + setIsDeleteDialogOpen(false); + setSelectedFlow(null); + loadFlows(); + } else { + toast({ + title: "삭제 실패", + description: response.error, + variant: "destructive", + }); + } + } catch (error: any) { + toast({ + title: "오류 발생", + description: error.message, + variant: "destructive", + }); + } + }; + + // 플로우 편집기로 이동 + const handleEdit = (flowId: number) => { + router.push(`/admin/flow-management/${flowId}`); + }; + + return ( +
+ {/* 헤더 */} +
+
+

+ + 플로우 관리 +

+

업무 프로세스 플로우를 생성하고 관리합니다

+
+ +
+ + {/* 플로우 카드 목록 */} + {loading ? ( +
+

로딩 중...

+
+ ) : flows.length === 0 ? ( + + + +

생성된 플로우가 없습니다

+ +
+
+ ) : ( +
+ {flows.map((flow) => ( + handleEdit(flow.id)} + > + +
+
+ + {flow.name} + {flow.isActive && ( + + 활성 + + )} + + + {flow.description || "설명 없음"} + +
+
+
+ +
+
+ + {flow.tableName} + +
+ + 생성자: {flow.createdBy} +
+
+ + {new Date(flow.updatedAt).toLocaleDateString("ko-KR")} +
+ + +
+ + +
+ + + ))} + + )} + + {/* 생성 다이얼로그 */} + + + + 새 플로우 생성 + + 새로운 업무 프로세스 플로우를 생성합니다 + + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="예: 제품 수명주기 관리" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + setFormData({ ...formData, tableName: e.target.value })} + placeholder="예: products" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 플로우가 관리할 데이터 테이블 이름을 입력하세요 +

+
+ +
+ +