플로우 구현
This commit is contained in:
203
backend-node/src/services/flowConditionParser.ts
Normal file
203
backend-node/src/services/flowConditionParser.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 플로우 조건 파서
|
||||
* 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":
|
||||
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 "greater_than_or_equal":
|
||||
conditions.push(`${column} >= $${paramIndex}`);
|
||||
params.push(condition.value);
|
||||
paramIndex++;
|
||||
break;
|
||||
|
||||
case "less_than_or_equal":
|
||||
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`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
166
backend-node/src/services/flowConnectionService.ts
Normal file
166
backend-node/src/services/flowConnectionService.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 플로우 연결 서비스
|
||||
*/
|
||||
|
||||
import db from "../database/db";
|
||||
import { FlowStepConnection, CreateFlowConnectionRequest } from "../types/flow";
|
||||
|
||||
export class FlowConnectionService {
|
||||
/**
|
||||
* 플로우 단계 연결 생성
|
||||
*/
|
||||
async create(
|
||||
request: CreateFlowConnectionRequest
|
||||
): Promise<FlowStepConnection> {
|
||||
// 순환 참조 체크
|
||||
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<FlowStepConnection[]> {
|
||||
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<FlowStepConnection | null> {
|
||||
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<boolean> {
|
||||
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<FlowStepConnection[]> {
|
||||
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<FlowStepConnection[]> {
|
||||
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<boolean> {
|
||||
// toStepId에서 출발해서 fromStepId에 도달할 수 있는지 확인
|
||||
const visited = new Set<number>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
181
backend-node/src/services/flowDataMoveService.ts
Normal file
181
backend-node/src/services/flowDataMoveService.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 플로우 데이터 이동 서비스
|
||||
* 데이터의 플로우 단계 이동 및 오딧 로그 관리
|
||||
*/
|
||||
|
||||
import db from "../database/db";
|
||||
import { FlowAuditLog } from "../types/flow";
|
||||
import { FlowDefinitionService } from "./flowDefinitionService";
|
||||
|
||||
export class FlowDataMoveService {
|
||||
private flowDefinitionService: FlowDefinitionService;
|
||||
|
||||
constructor() {
|
||||
this.flowDefinitionService = new FlowDefinitionService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터를 다음 플로우 단계로 이동
|
||||
*/
|
||||
async moveDataToStep(
|
||||
flowId: number,
|
||||
recordId: string,
|
||||
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}`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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]
|
||||
);
|
||||
}
|
||||
|
||||
// 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)
|
||||
`,
|
||||
[
|
||||
flowId,
|
||||
flowDef.tableName,
|
||||
recordId,
|
||||
fromStepId,
|
||||
toStepId,
|
||||
userId,
|
||||
note || null,
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 데이터를 동시에 다음 단계로 이동
|
||||
*/
|
||||
async moveBatchData(
|
||||
flowId: number,
|
||||
recordIds: string[],
|
||||
toStepId: number,
|
||||
userId: string,
|
||||
note?: string
|
||||
): Promise<void> {
|
||||
for (const recordId of recordIds) {
|
||||
await this.moveDataToStep(flowId, recordId, toStepId, userId, note);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터의 플로우 이력 조회
|
||||
*/
|
||||
async getAuditLogs(
|
||||
flowId: number,
|
||||
recordId: string
|
||||
): Promise<FlowAuditLog[]> {
|
||||
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.map((row) => ({
|
||||
id: row.id,
|
||||
flowDefinitionId: row.flow_definition_id,
|
||||
tableName: row.table_name,
|
||||
recordId: row.record_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,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 플로우의 모든 이력 조회
|
||||
*/
|
||||
async getFlowAuditLogs(
|
||||
flowId: number,
|
||||
limit: number = 100
|
||||
): Promise<FlowAuditLog[]> {
|
||||
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,
|
||||
recordId: row.record_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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
171
backend-node/src/services/flowDefinitionService.ts
Normal file
171
backend-node/src/services/flowDefinitionService.ts
Normal file
@@ -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<FlowDefinition> {
|
||||
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<FlowDefinition[]> {
|
||||
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<FlowDefinition | null> {
|
||||
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<FlowDefinition | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
176
backend-node/src/services/flowExecutionService.ts
Normal file
176
backend-node/src/services/flowExecutionService.ts
Normal file
@@ -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<number> {
|
||||
// 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<FlowStepDataList> {
|
||||
// 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<FlowStepDataCount[]> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
202
backend-node/src/services/flowStepService.ts
Normal file
202
backend-node/src/services/flowStepService.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 플로우 단계 서비스
|
||||
*/
|
||||
|
||||
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<FlowStep> {
|
||||
// 조건 검증
|
||||
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
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
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,
|
||||
]);
|
||||
|
||||
return this.mapToFlowStep(result[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 플로우의 모든 단계 조회
|
||||
*/
|
||||
async findByFlowId(flowDefinitionId: number): Promise<FlowStep[]> {
|
||||
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<FlowStep | null> {
|
||||
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<FlowStep | null> {
|
||||
// 조건 검증
|
||||
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 (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<boolean> {
|
||||
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<void> {
|
||||
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,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user