551 lines
16 KiB
TypeScript
551 lines
16 KiB
TypeScript
|
|
// 배치관리 서비스
|
||
|
|
// 작성일: 2024-12-24
|
||
|
|
|
||
|
|
import { PrismaClient } from "@prisma/client";
|
||
|
|
import {
|
||
|
|
BatchConfig,
|
||
|
|
BatchMapping,
|
||
|
|
BatchConfigFilter,
|
||
|
|
BatchMappingRequest,
|
||
|
|
BatchValidationResult,
|
||
|
|
ApiResponse,
|
||
|
|
ConnectionInfo,
|
||
|
|
TableInfo,
|
||
|
|
ColumnInfo,
|
||
|
|
} from "../types/batchTypes";
|
||
|
|
import { ExternalDbConnectionService } from "./externalDbConnectionService";
|
||
|
|
import { DbConnectionManager } from "./dbConnectionManager";
|
||
|
|
|
||
|
|
const prisma = new PrismaClient();
|
||
|
|
|
||
|
|
export class BatchService {
|
||
|
|
/**
|
||
|
|
* 배치 설정 목록 조회
|
||
|
|
*/
|
||
|
|
static async getBatchConfigs(
|
||
|
|
filter: BatchConfigFilter
|
||
|
|
): Promise<ApiResponse<BatchConfig[]>> {
|
||
|
|
try {
|
||
|
|
const where: any = {};
|
||
|
|
|
||
|
|
// 필터 조건 적용
|
||
|
|
if (filter.is_active) {
|
||
|
|
where.is_active = filter.is_active;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (filter.company_code) {
|
||
|
|
where.company_code = filter.company_code;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 검색 조건 적용
|
||
|
|
if (filter.search && filter.search.trim()) {
|
||
|
|
where.OR = [
|
||
|
|
{
|
||
|
|
batch_name: {
|
||
|
|
contains: filter.search.trim(),
|
||
|
|
mode: "insensitive",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
description: {
|
||
|
|
contains: filter.search.trim(),
|
||
|
|
mode: "insensitive",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
const batchConfigs = await prisma.batch_configs.findMany({
|
||
|
|
where,
|
||
|
|
include: {
|
||
|
|
batch_mappings: true,
|
||
|
|
},
|
||
|
|
orderBy: [{ is_active: "desc" }, { batch_name: "asc" }],
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
data: batchConfigs as BatchConfig[],
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error("배치 설정 목록 조회 오류:", error);
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "배치 설정 목록 조회에 실패했습니다.",
|
||
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 특정 배치 설정 조회
|
||
|
|
*/
|
||
|
|
static async getBatchConfigById(
|
||
|
|
id: number
|
||
|
|
): Promise<ApiResponse<BatchConfig>> {
|
||
|
|
try {
|
||
|
|
const batchConfig = await prisma.batch_configs.findUnique({
|
||
|
|
where: { id },
|
||
|
|
include: {
|
||
|
|
batch_mappings: {
|
||
|
|
orderBy: [
|
||
|
|
{ from_table_name: "asc" },
|
||
|
|
{ from_column_name: "asc" },
|
||
|
|
{ mapping_order: "asc" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!batchConfig) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "배치 설정을 찾을 수 없습니다.",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
data: batchConfig as BatchConfig,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error("배치 설정 조회 오류:", error);
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "배치 설정 조회에 실패했습니다.",
|
||
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 배치 설정 생성
|
||
|
|
*/
|
||
|
|
static async createBatchConfig(
|
||
|
|
data: BatchMappingRequest,
|
||
|
|
userId?: string
|
||
|
|
): Promise<ApiResponse<BatchConfig>> {
|
||
|
|
try {
|
||
|
|
// 매핑 유효성 검사
|
||
|
|
const validation = await this.validateBatchMappings(data.mappings);
|
||
|
|
if (!validation.isValid) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "매핑 유효성 검사 실패",
|
||
|
|
error: validation.errors.join(", "),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 트랜잭션으로 배치 설정과 매핑 생성
|
||
|
|
const result = await prisma.$transaction(async (tx) => {
|
||
|
|
// 배치 설정 생성
|
||
|
|
const batchConfig = await tx.batch_configs.create({
|
||
|
|
data: {
|
||
|
|
batch_name: data.batch_name,
|
||
|
|
description: data.description,
|
||
|
|
cron_schedule: data.cron_schedule,
|
||
|
|
created_by: userId,
|
||
|
|
updated_by: userId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// 배치 매핑 생성
|
||
|
|
const mappings = await Promise.all(
|
||
|
|
data.mappings.map((mapping, index) =>
|
||
|
|
tx.batch_mappings.create({
|
||
|
|
data: {
|
||
|
|
batch_config_id: batchConfig.id,
|
||
|
|
from_connection_type: mapping.from_connection_type,
|
||
|
|
from_connection_id: mapping.from_connection_id,
|
||
|
|
from_table_name: mapping.from_table_name,
|
||
|
|
from_column_name: mapping.from_column_name,
|
||
|
|
from_column_type: mapping.from_column_type,
|
||
|
|
to_connection_type: mapping.to_connection_type,
|
||
|
|
to_connection_id: mapping.to_connection_id,
|
||
|
|
to_table_name: mapping.to_table_name,
|
||
|
|
to_column_name: mapping.to_column_name,
|
||
|
|
to_column_type: mapping.to_column_type,
|
||
|
|
mapping_order: mapping.mapping_order || index + 1,
|
||
|
|
created_by: userId,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
)
|
||
|
|
);
|
||
|
|
|
||
|
|
return {
|
||
|
|
...batchConfig,
|
||
|
|
batch_mappings: mappings,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
data: result as BatchConfig,
|
||
|
|
message: "배치 설정이 성공적으로 생성되었습니다.",
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error("배치 설정 생성 오류:", error);
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "배치 설정 생성에 실패했습니다.",
|
||
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 배치 설정 수정
|
||
|
|
*/
|
||
|
|
static async updateBatchConfig(
|
||
|
|
id: number,
|
||
|
|
data: Partial<BatchMappingRequest>,
|
||
|
|
userId?: string
|
||
|
|
): Promise<ApiResponse<BatchConfig>> {
|
||
|
|
try {
|
||
|
|
// 기존 배치 설정 확인
|
||
|
|
const existingConfig = await prisma.batch_configs.findUnique({
|
||
|
|
where: { id },
|
||
|
|
include: { batch_mappings: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!existingConfig) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "배치 설정을 찾을 수 없습니다.",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 매핑이 제공된 경우 유효성 검사
|
||
|
|
if (data.mappings) {
|
||
|
|
const validation = await this.validateBatchMappings(data.mappings);
|
||
|
|
if (!validation.isValid) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "매핑 유효성 검사 실패",
|
||
|
|
error: validation.errors.join(", "),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 트랜잭션으로 업데이트
|
||
|
|
const result = await prisma.$transaction(async (tx) => {
|
||
|
|
// 배치 설정 업데이트
|
||
|
|
const updateData: any = {
|
||
|
|
updated_by: userId,
|
||
|
|
};
|
||
|
|
|
||
|
|
if (data.batch_name) updateData.batch_name = data.batch_name;
|
||
|
|
if (data.description !== undefined) updateData.description = data.description;
|
||
|
|
if (data.cron_schedule) updateData.cron_schedule = data.cron_schedule;
|
||
|
|
|
||
|
|
const batchConfig = await tx.batch_configs.update({
|
||
|
|
where: { id },
|
||
|
|
data: updateData,
|
||
|
|
});
|
||
|
|
|
||
|
|
// 매핑이 제공된 경우 기존 매핑 삭제 후 새로 생성
|
||
|
|
if (data.mappings) {
|
||
|
|
await tx.batch_mappings.deleteMany({
|
||
|
|
where: { batch_config_id: id },
|
||
|
|
});
|
||
|
|
|
||
|
|
const mappings = await Promise.all(
|
||
|
|
data.mappings.map((mapping, index) =>
|
||
|
|
tx.batch_mappings.create({
|
||
|
|
data: {
|
||
|
|
batch_config_id: id,
|
||
|
|
from_connection_type: mapping.from_connection_type,
|
||
|
|
from_connection_id: mapping.from_connection_id,
|
||
|
|
from_table_name: mapping.from_table_name,
|
||
|
|
from_column_name: mapping.from_column_name,
|
||
|
|
from_column_type: mapping.from_column_type,
|
||
|
|
to_connection_type: mapping.to_connection_type,
|
||
|
|
to_connection_id: mapping.to_connection_id,
|
||
|
|
to_table_name: mapping.to_table_name,
|
||
|
|
to_column_name: mapping.to_column_name,
|
||
|
|
to_column_type: mapping.to_column_type,
|
||
|
|
mapping_order: mapping.mapping_order || index + 1,
|
||
|
|
created_by: userId,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
)
|
||
|
|
);
|
||
|
|
|
||
|
|
return {
|
||
|
|
...batchConfig,
|
||
|
|
batch_mappings: mappings,
|
||
|
|
};
|
||
|
|
} else {
|
||
|
|
return {
|
||
|
|
...batchConfig,
|
||
|
|
batch_mappings: existingConfig.batch_mappings,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
data: result as BatchConfig,
|
||
|
|
message: "배치 설정이 성공적으로 수정되었습니다.",
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error("배치 설정 수정 오류:", error);
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "배치 설정 수정에 실패했습니다.",
|
||
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 배치 설정 삭제 (논리 삭제)
|
||
|
|
*/
|
||
|
|
static async deleteBatchConfig(
|
||
|
|
id: number,
|
||
|
|
userId?: string
|
||
|
|
): Promise<ApiResponse<void>> {
|
||
|
|
try {
|
||
|
|
const existingConfig = await prisma.batch_configs.findUnique({
|
||
|
|
where: { id },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!existingConfig) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "배치 설정을 찾을 수 없습니다.",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
await prisma.batch_configs.update({
|
||
|
|
where: { id },
|
||
|
|
data: {
|
||
|
|
is_active: "N",
|
||
|
|
updated_by: userId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
message: "배치 설정이 성공적으로 삭제되었습니다.",
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error("배치 설정 삭제 오류:", error);
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "배치 설정 삭제에 실패했습니다.",
|
||
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 사용 가능한 커넥션 목록 조회
|
||
|
|
*/
|
||
|
|
static async getAvailableConnections(): Promise<ApiResponse<ConnectionInfo[]>> {
|
||
|
|
try {
|
||
|
|
const connections: ConnectionInfo[] = [];
|
||
|
|
|
||
|
|
// 내부 DB 추가
|
||
|
|
connections.push({
|
||
|
|
type: 'internal',
|
||
|
|
name: 'Internal Database',
|
||
|
|
db_type: 'postgresql',
|
||
|
|
});
|
||
|
|
|
||
|
|
// 외부 DB 연결 조회
|
||
|
|
const externalConnections = await ExternalDbConnectionService.getConnections({
|
||
|
|
is_active: 'Y',
|
||
|
|
});
|
||
|
|
|
||
|
|
if (externalConnections.success && externalConnections.data) {
|
||
|
|
externalConnections.data.forEach((conn) => {
|
||
|
|
connections.push({
|
||
|
|
type: 'external',
|
||
|
|
id: conn.id,
|
||
|
|
name: conn.connection_name,
|
||
|
|
db_type: conn.db_type,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
data: connections,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error("커넥션 목록 조회 오류:", error);
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "커넥션 목록 조회에 실패했습니다.",
|
||
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 특정 커넥션의 테이블 목록 조회
|
||
|
|
*/
|
||
|
|
static async getTablesFromConnection(
|
||
|
|
connectionType: 'internal' | 'external',
|
||
|
|
connectionId?: number
|
||
|
|
): Promise<ApiResponse<string[]>> {
|
||
|
|
try {
|
||
|
|
let tables: string[] = [];
|
||
|
|
|
||
|
|
if (connectionType === 'internal') {
|
||
|
|
// 내부 DB 테이블 조회
|
||
|
|
const result = await prisma.$queryRaw<Array<{ table_name: string }>>`
|
||
|
|
SELECT table_name
|
||
|
|
FROM information_schema.tables
|
||
|
|
WHERE table_schema = 'public'
|
||
|
|
AND table_type = 'BASE TABLE'
|
||
|
|
ORDER BY table_name
|
||
|
|
`;
|
||
|
|
tables = result.map(row => row.table_name);
|
||
|
|
} else if (connectionType === 'external' && connectionId) {
|
||
|
|
// 외부 DB 테이블 조회
|
||
|
|
const tablesResult = await ExternalDbConnectionService.getTables(connectionId);
|
||
|
|
if (tablesResult.success && tablesResult.data) {
|
||
|
|
tables = tablesResult.data;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
data: tables,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error("테이블 목록 조회 오류:", error);
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "테이블 목록 조회에 실패했습니다.",
|
||
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 특정 테이블의 컬럼 정보 조회
|
||
|
|
*/
|
||
|
|
static async getTableColumns(
|
||
|
|
connectionType: 'internal' | 'external',
|
||
|
|
tableName: string,
|
||
|
|
connectionId?: number
|
||
|
|
): Promise<ApiResponse<ColumnInfo[]>> {
|
||
|
|
try {
|
||
|
|
let columns: ColumnInfo[] = [];
|
||
|
|
|
||
|
|
if (connectionType === 'internal') {
|
||
|
|
// 내부 DB 컬럼 조회
|
||
|
|
const result = await prisma.$queryRaw<Array<{
|
||
|
|
column_name: string;
|
||
|
|
data_type: string;
|
||
|
|
is_nullable: string;
|
||
|
|
column_default: string | null;
|
||
|
|
}>>`
|
||
|
|
SELECT
|
||
|
|
column_name,
|
||
|
|
data_type,
|
||
|
|
is_nullable,
|
||
|
|
column_default
|
||
|
|
FROM information_schema.columns
|
||
|
|
WHERE table_schema = 'public'
|
||
|
|
AND table_name = ${tableName}
|
||
|
|
ORDER BY ordinal_position
|
||
|
|
`;
|
||
|
|
|
||
|
|
columns = result.map(row => ({
|
||
|
|
column_name: row.column_name,
|
||
|
|
data_type: row.data_type,
|
||
|
|
is_nullable: row.is_nullable === 'YES',
|
||
|
|
column_default: row.column_default,
|
||
|
|
}));
|
||
|
|
} else if (connectionType === 'external' && connectionId) {
|
||
|
|
// 외부 DB 컬럼 조회
|
||
|
|
const columnsResult = await ExternalDbConnectionService.getTableColumns(
|
||
|
|
connectionId,
|
||
|
|
tableName
|
||
|
|
);
|
||
|
|
if (columnsResult.success && columnsResult.data) {
|
||
|
|
columns = columnsResult.data.map(col => ({
|
||
|
|
column_name: col.column_name,
|
||
|
|
data_type: col.data_type,
|
||
|
|
is_nullable: col.is_nullable,
|
||
|
|
column_default: col.column_default,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
data: columns,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error("컬럼 정보 조회 오류:", error);
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: "컬럼 정보 조회에 실패했습니다.",
|
||
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 배치 매핑 유효성 검사
|
||
|
|
*/
|
||
|
|
private static async validateBatchMappings(
|
||
|
|
mappings: BatchMapping[]
|
||
|
|
): Promise<BatchValidationResult> {
|
||
|
|
const errors: string[] = [];
|
||
|
|
const warnings: string[] = [];
|
||
|
|
|
||
|
|
if (!mappings || mappings.length === 0) {
|
||
|
|
errors.push("최소 하나 이상의 매핑이 필요합니다.");
|
||
|
|
return { isValid: false, errors, warnings };
|
||
|
|
}
|
||
|
|
|
||
|
|
// n:1 매핑 검사 (여러 FROM이 같은 TO로 매핑되는 것 방지)
|
||
|
|
const toMappings = new Map<string, number>();
|
||
|
|
|
||
|
|
mappings.forEach((mapping, index) => {
|
||
|
|
const toKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`;
|
||
|
|
|
||
|
|
if (toMappings.has(toKey)) {
|
||
|
|
errors.push(
|
||
|
|
`매핑 ${index + 1}: TO 컬럼 '${mapping.to_table_name}.${mapping.to_column_name}'에 중복 매핑이 있습니다. n:1 매핑은 허용되지 않습니다.`
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
toMappings.set(toKey, index);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// 1:n 매핑 경고 (같은 FROM에서 여러 TO로 매핑)
|
||
|
|
const fromMappings = new Map<string, number[]>();
|
||
|
|
|
||
|
|
mappings.forEach((mapping, index) => {
|
||
|
|
const fromKey = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}:${mapping.from_column_name}`;
|
||
|
|
|
||
|
|
if (!fromMappings.has(fromKey)) {
|
||
|
|
fromMappings.set(fromKey, []);
|
||
|
|
}
|
||
|
|
fromMappings.get(fromKey)!.push(index);
|
||
|
|
});
|
||
|
|
|
||
|
|
fromMappings.forEach((indices, fromKey) => {
|
||
|
|
if (indices.length > 1) {
|
||
|
|
const [, , tableName, columnName] = fromKey.split(':');
|
||
|
|
warnings.push(
|
||
|
|
`FROM 컬럼 '${tableName}.${columnName}'에서 ${indices.length}개의 TO 컬럼으로 매핑됩니다. (1:n 매핑)`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
isValid: errors.length === 0,
|
||
|
|
errors,
|
||
|
|
warnings,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|