diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index a5d87650..6595217a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -56,7 +56,7 @@ 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 materialRoutes from "./routes/materialRoutes"; // 자재 관리 import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 @@ -208,7 +208,7 @@ 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/materials", materialRoutes); // 자재 관리 (임시 주석) app.use("/api/flow", flowRoutes); // 플로우 관리 // 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 index 39624153..e555e6f7 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -573,28 +573,46 @@ export class FlowController { */ moveBatchData = async (req: Request, res: Response): Promise => { try { - const { flowId, recordIds, toStepId, note } = req.body; + const { flowId, fromStepId, toStepId, dataIds } = req.body; const userId = (req as any).user?.userId || "system"; - if (!flowId || !recordIds || !Array.isArray(recordIds) || !toStepId) { + if ( + !flowId || + !fromStepId || + !toStepId || + !dataIds || + !Array.isArray(dataIds) + ) { res.status(400).json({ success: false, - message: "flowId, recordIds (array), and toStepId are required", + message: + "flowId, fromStepId, toStepId, and dataIds (array) are required", }); return; } - await this.flowDataMoveService.moveBatchData( + const result = await this.flowDataMoveService.moveBatchData( flowId, - recordIds, + fromStepId, toStepId, - userId, - note + dataIds, + userId ); + const successCount = result.results.filter((r) => r.success).length; + const failureCount = result.results.filter((r) => !r.success).length; + res.json({ - success: true, - message: `${recordIds.length} records moved successfully`, + 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); diff --git a/backend-node/src/services/flowConditionParser.ts b/backend-node/src/services/flowConditionParser.ts index 707d2415..5f2e648a 100644 --- a/backend-node/src/services/flowConditionParser.ts +++ b/backend-node/src/services/flowConditionParser.ts @@ -33,12 +33,14 @@ export class FlowConditionParser { 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++; @@ -65,24 +67,28 @@ export class FlowConditionParser { 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++; @@ -165,13 +171,19 @@ export class FlowConditionParser { 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", diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index e105b34a..242bd56b 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -1,118 +1,360 @@ /** - * 플로우 데이터 이동 서비스 - * 데이터의 플로우 단계 이동 및 오딧 로그 관리 + * 플로우 데이터 이동 서비스 (하이브리드 방식 지원) + * - 상태 변경 방식: 같은 테이블 내에서 상태 컬럼 업데이트 + * - 테이블 이동 방식: 다른 테이블로 데이터 복사 및 매핑 + * - 하이브리드 방식: 두 가지 모두 수행 */ import db from "../database/db"; import { FlowAuditLog } from "../types/flow"; import { FlowDefinitionService } from "./flowDefinitionService"; +import { FlowStepService } from "./flowStepService"; export class FlowDataMoveService { private flowDefinitionService: FlowDefinitionService; + private flowStepService: FlowStepService; constructor() { this.flowDefinitionService = new FlowDefinitionService(); + this.flowStepService = new FlowStepService(); } /** - * 데이터를 다음 플로우 단계로 이동 + * 데이터를 다음 플로우 단계로 이동 (하이브리드 지원) */ async moveDataToStep( flowId: number, - recordId: string, + fromStepId: number, toStepId: number, - userId: string, - note?: string - ): Promise { - await db.transaction(async (client) => { - // 1. 플로우 정의 조회 - const flowDef = await this.flowDefinitionService.findById(flowId); - if (!flowDef) { - throw new Error(`Flow definition not found: ${flowId}`); - } + 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); - // 2. 현재 상태 조회 - const currentStatusQuery = ` - SELECT current_step_id, table_name - FROM flow_data_status - WHERE flow_definition_id = $1 AND record_id = $2 - `; - const currentStatusResult = await client.query(currentStatusQuery, [ - flowId, - recordId, - ]); - const currentStatus = - currentStatusResult.rows.length > 0 - ? { - currentStepId: currentStatusResult.rows[0].current_step_id, - tableName: currentStatusResult.rows[0].table_name, - } - : null; - const fromStepId = currentStatus?.currentStepId || null; + if (!fromStep || !toStep) { + throw new Error("유효하지 않은 단계입니다"); + } - // 3. 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 { - 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] - ); - } + let targetDataId = dataId; + let sourceTable = fromStep.tableName; + let targetTable = toStep.tableName || fromStep.tableName; - // 4. 오딧 로그 기록 - 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) - `, - [ + // 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. 감사 로그 기록 + await this.logDataMove(client, { flowId, - flowDef.tableName, - recordId, fromStepId, toStepId, + moveType: toStep.moveType || "status", + sourceTable, + targetTable, + sourceDataId: String(dataId), + targetDataId: String(targetDataId), + statusFrom: fromStep.statusValue, + statusTo: toStep.statusValue, userId, - note || null, - ] - ); + }); + + 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, - recordIds: string[], + fromStepId: number, toStepId: number, - userId: string, - note?: string - ): Promise { - for (const recordId of recordIds) { - await this.moveDataToStep(flowId, recordId, toStepId, userId, note); + 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, - recordId: string - ): Promise { + async getAuditLogs(flowId: number, dataId: string): Promise { const query = ` SELECT fal.*, @@ -121,17 +363,18 @@ export class FlowDataMoveService { 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 + 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, recordId]); + const result = await db.query(query, [flowId, dataId]); return result.map((row) => ({ id: row.id, flowDefinitionId: row.flow_definition_id, - tableName: row.table_name, - recordId: row.record_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, @@ -139,6 +382,13 @@ export class FlowDataMoveService { 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, })); } @@ -167,8 +417,8 @@ export class FlowDataMoveService { return result.map((row) => ({ id: row.id, flowDefinitionId: row.flow_definition_id, - tableName: row.table_name, - recordId: row.record_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, @@ -176,6 +426,13 @@ export class FlowDataMoveService { 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, })); } } diff --git a/backend-node/src/services/flowStepService.ts b/backend-node/src/services/flowStepService.ts index 4655ad39..6c55bfbe 100644 --- a/backend-node/src/services/flowStepService.ts +++ b/backend-node/src/services/flowStepService.ts @@ -195,6 +195,13 @@ export class FlowStepService { 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, createdAt: row.created_at, updatedAt: row.updated_at, }; diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 86418ddd..9c5a8270 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -31,13 +31,19 @@ export interface UpdateFlowDefinitionRequest { // 조건 연산자 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" @@ -67,6 +73,13 @@ export interface FlowStep { color: string; positionX: number; positionY: number; + // 하이브리드 플로우 지원 필드 + moveType?: "status" | "table" | "both"; // 데이터 이동 방식 + statusColumn?: string; // 상태 컬럼명 (상태 변경 방식) + statusValue?: string; // 이 단계의 상태값 + targetTable?: string; // 타겟 테이블명 (테이블 이동 방식) + fieldMappings?: Record; // 필드 매핑 정보 + requiredFields?: string[]; // 필수 입력 필드 createdAt: Date; updatedAt: Date; } @@ -81,6 +94,13 @@ export interface CreateFlowStepRequest { color?: string; positionX?: number; positionY?: number; + // 하이브리드 플로우 지원 필드 + moveType?: "status" | "table" | "both"; + statusColumn?: string; + statusValue?: string; + targetTable?: string; + fieldMappings?: Record; + requiredFields?: string[]; } // 플로우 단계 수정 요청 @@ -92,6 +112,13 @@ export interface UpdateFlowStepRequest { color?: string; positionX?: number; positionY?: number; + // 하이브리드 플로우 지원 필드 + moveType?: "status" | "table" | "both"; + statusColumn?: string; + statusValue?: string; + targetTable?: string; + fieldMappings?: Record; + requiredFields?: string[]; } // 플로우 단계 연결 @@ -134,6 +161,14 @@ export interface FlowAuditLog { 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; 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_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/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/flow-management/page.tsx index 56f4a931..999fb6fa 100644 --- a/frontend/app/(main)/admin/flow-management/page.tsx +++ b/frontend/app/(main)/admin/flow-management/page.tsx @@ -148,94 +148,103 @@ export default function FlowManagementPage() { }; return ( -
+
{/* 헤더 */} -
-
-

- +
+
+

+ 플로우 관리

-

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

+

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

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

로딩 중...

+
+

로딩 중...

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

생성된 플로우가 없습니다

-
) : ( -
+
{flows.map((flow) => ( handleEdit(flow.id)} > - +
-
- - {flow.name} - {flow.isActive && 활성} +
+ + {flow.name} + {flow.isActive && ( + + 활성 + + )} - {flow.description || "설명 없음"} + + {flow.description || "설명 없음"} +
- -
-
- - {flow.tableName} + +
+
+
+ {flow.tableName} -
- - 생성자: {flow.createdBy} +
+ + 생성자: {flow.createdBy}
-
- +
+ {new Date(flow.updatedAt).toLocaleDateString("ko-KR")}
-
+
@@ -246,77 +255,101 @@ export default function FlowManagementPage() { {/* 생성 다이얼로그 */} - + - 새 플로우 생성 - 새로운 업무 프로세스 플로우를 생성합니다 + 새 플로우 생성 + + 새로운 업무 프로세스 플로우를 생성합니다 + -
+
- + 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" /> -

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

+

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

- +