플로우 분기처리 구현

This commit is contained in:
kjs
2025-10-20 15:53:00 +09:00
parent de9491aa29
commit 7d8abc0449
15 changed files with 2098 additions and 203 deletions

View File

@@ -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); // 임시 주석

View File

@@ -573,28 +573,46 @@ export class FlowController {
*/
moveBatchData = async (req: Request, res: Response): Promise<void> => {
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);

View File

@@ -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",

View File

@@ -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<void> {
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<string, any>
): 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<string, any>
): Promise<void> {
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<string, any>
): Promise<any> {
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<string, any> = {};
// 매핑 정의가 있으면 적용
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<void> {
// 기존 매핑 조회
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<string, string> =
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<void> {
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<void> {
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<FlowAuditLog[]> {
async getAuditLogs(flowId: number, dataId: string): Promise<FlowAuditLog[]> {
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,
}));
}
}

View File

@@ -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,
};

View File

@@ -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<string, string>; // 필드 매핑 정보
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<string, string>;
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<string, string>;
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;