feat: 배치 관리 시스템 구현
✨ 주요 기능: - 배치 설정 관리 (생성/수정/삭제/실행) - 배치 실행 로그 관리 및 모니터링 - 배치 스케줄러 자동 실행 (cron 기반) - 외부 DB 연결을 통한 데이터 동기화 - Oracle, MSSQL, MariaDB 커넥터 지원 🔧 백엔드 구현: - BatchManagementController: 배치 설정 CRUD - BatchExecutionLogController: 실행 로그 관리 - BatchSchedulerService: 자동 스케줄링 - BatchExternalDbService: 외부 DB 연동 - 배치 관련 테이블 스키마 추가 🎨 프론트엔드 구현: - 배치 관리 대시보드 UI - 배치 생성/수정 폼 - 실행 로그 모니터링 화면 - 수동 실행 및 상태 관리 🛡️ 안전성: - 기존 시스템과 독립적 구현 - 트랜잭션 기반 안전한 데이터 처리 - 에러 핸들링 및 로깅 강화
This commit is contained in:
parent
4abf5b31c0
commit
949aab0b73
File diff suppressed because it is too large
Load Diff
|
|
@ -28,6 +28,7 @@
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.16.2",
|
||||||
|
"@types/mssql": "^9.1.8",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
|
|
@ -38,9 +39,12 @@
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mssql": "^11.0.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.15.0",
|
"mysql2": "^3.15.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.9.7",
|
||||||
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
|
|
@ -58,6 +62,7 @@
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.14",
|
||||||
|
"@types/oracledb": "^6.9.1",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/sanitize-html": "^2.9.5",
|
"@types/sanitize-html": "^2.9.5",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
|
|
||||||
|
|
@ -27,96 +27,121 @@ model external_call_configs {
|
||||||
api_type String? @db.VarChar(20)
|
api_type String? @db.VarChar(20)
|
||||||
config_data Json
|
config_data Json
|
||||||
description String?
|
description String?
|
||||||
company_code String @default("*") @db.VarChar(20)
|
|
||||||
is_active String? @default("Y") @db.Char(1)
|
is_active String? @default("Y") @db.Char(1)
|
||||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
|
||||||
created_by String? @db.VarChar(50)
|
created_by String? @db.VarChar(50)
|
||||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
|
||||||
updated_by String? @db.VarChar(50)
|
updated_by String? @db.VarChar(50)
|
||||||
|
company_code String @default("*") @db.VarChar(20)
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||||
|
}
|
||||||
|
|
||||||
@@index([is_active], map: "idx_external_call_configs_active")
|
model db_type_categories {
|
||||||
@@index([company_code], map: "idx_external_call_configs_company")
|
type_code String @id @db.VarChar(20)
|
||||||
@@index([call_type, api_type], map: "idx_external_call_configs_type")
|
display_name String @db.VarChar(50)
|
||||||
|
icon String? @db.VarChar(50)
|
||||||
|
color String? @db.VarChar(20)
|
||||||
|
sort_order Int? @default(0)
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(6)
|
||||||
|
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
model external_db_connections {
|
model external_db_connections {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
connection_name String @db.VarChar(100)
|
connection_name String @db.VarChar(100)
|
||||||
description String?
|
description String?
|
||||||
db_type String @db.VarChar(20)
|
db_type String @db.VarChar(20)
|
||||||
host String @db.VarChar(255)
|
host String @db.VarChar(255)
|
||||||
port Int
|
port Int
|
||||||
database_name String @db.VarChar(100)
|
database_name String @db.VarChar(100)
|
||||||
username String @db.VarChar(100)
|
username String @db.VarChar(100)
|
||||||
password String
|
password String
|
||||||
connection_timeout Int? @default(30)
|
connection_timeout Int? @default(30)
|
||||||
query_timeout Int? @default(60)
|
query_timeout Int? @default(60)
|
||||||
max_connections Int? @default(10)
|
max_connections Int? @default(10)
|
||||||
ssl_enabled String? @default("N") @db.Char(1)
|
ssl_enabled String? @default("N") @db.Char(1)
|
||||||
ssl_cert_path String? @db.VarChar(500)
|
ssl_cert_path String? @db.VarChar(500)
|
||||||
connection_options Json?
|
connection_options Json?
|
||||||
company_code String? @default("*") @db.VarChar(20)
|
company_code String? @default("*") @db.VarChar(20)
|
||||||
is_active String? @default("Y") @db.Char(1)
|
is_active String? @default("Y") @db.Char(1)
|
||||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
created_by String? @db.VarChar(50)
|
created_by String? @db.VarChar(50)
|
||||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||||
updated_by String? @db.VarChar(50)
|
updated_by String? @db.VarChar(50)
|
||||||
|
collection_configs data_collection_configs[]
|
||||||
|
|
||||||
@@index([connection_name], map: "idx_external_db_connections_name")
|
@@index([connection_name], map: "idx_external_db_connections_name")
|
||||||
|
@@index([db_type], map: "idx_external_db_connections_db_type")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배치관리 테이블들
|
|
||||||
model batch_configs {
|
model batch_configs {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
batch_name String @db.VarChar(100)
|
batch_name String @db.VarChar(100)
|
||||||
description String?
|
description String?
|
||||||
cron_schedule String @db.VarChar(50) // 크론탭 형식
|
cron_schedule String @db.VarChar(50)
|
||||||
is_active String? @default("Y") @db.Char(1)
|
is_active String? @default("Y") @db.Char(1)
|
||||||
company_code String? @default("*") @db.VarChar(20)
|
company_code String? @default("*") @db.VarChar(20)
|
||||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
created_by String? @db.VarChar(50)
|
created_by String? @db.VarChar(50)
|
||||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||||
updated_by String? @db.VarChar(50)
|
updated_by String? @db.VarChar(50)
|
||||||
|
|
||||||
// 관계 설정
|
// 관계 설정
|
||||||
batch_mappings batch_mappings[]
|
batch_mappings batch_mappings[]
|
||||||
|
execution_logs batch_execution_logs[]
|
||||||
|
|
||||||
@@index([batch_name], map: "idx_batch_configs_name")
|
@@index([batch_name], map: "idx_batch_configs_name")
|
||||||
@@index([is_active], map: "idx_batch_configs_active")
|
@@index([is_active], map: "idx_batch_configs_active")
|
||||||
}
|
}
|
||||||
|
|
||||||
model batch_mappings {
|
model batch_mappings {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
batch_config_id Int
|
batch_config_id Int
|
||||||
|
from_connection_type String @db.VarChar(20)
|
||||||
// FROM 정보
|
from_connection_id Int?
|
||||||
from_connection_type String @db.VarChar(20) // 'internal' 또는 'external'
|
from_table_name String @db.VarChar(100)
|
||||||
from_connection_id Int? // external_db_connections.id (외부 DB인 경우)
|
from_column_name String @db.VarChar(100)
|
||||||
from_table_name String @db.VarChar(100)
|
from_column_type String? @db.VarChar(50)
|
||||||
from_column_name String @db.VarChar(100)
|
to_connection_type String @db.VarChar(20)
|
||||||
from_column_type String? @db.VarChar(50)
|
to_connection_id Int?
|
||||||
|
to_table_name String @db.VarChar(100)
|
||||||
// TO 정보
|
to_column_name String @db.VarChar(100)
|
||||||
to_connection_type String @db.VarChar(20) // 'internal' 또는 'external'
|
to_column_type String? @db.VarChar(50)
|
||||||
to_connection_id Int? // external_db_connections.id (외부 DB인 경우)
|
mapping_order Int? @default(1)
|
||||||
to_table_name String @db.VarChar(100)
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
to_column_name String @db.VarChar(100)
|
created_by String? @db.VarChar(50)
|
||||||
to_column_type String? @db.VarChar(50)
|
|
||||||
|
|
||||||
// 매핑 순서 (같은 FROM 컬럼에서 여러 TO로 매핑될 때 순서)
|
|
||||||
mapping_order Int? @default(1)
|
|
||||||
|
|
||||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
|
||||||
created_by String? @db.VarChar(50)
|
|
||||||
|
|
||||||
// 관계 설정
|
// 관계 설정
|
||||||
batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade)
|
batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([batch_config_id], map: "idx_batch_mappings_config")
|
@@index([batch_config_id], map: "idx_batch_mappings_config")
|
||||||
@@index([from_connection_type, from_connection_id], map: "idx_batch_mappings_from")
|
@@index([from_connection_type, from_connection_id], map: "idx_batch_mappings_from")
|
||||||
@@index([to_connection_type, to_connection_id], map: "idx_batch_mappings_to")
|
@@index([to_connection_type, to_connection_id], map: "idx_batch_mappings_to")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model batch_execution_logs {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
batch_config_id Int
|
||||||
|
execution_status String @db.VarChar(20)
|
||||||
|
start_time DateTime @default(now()) @db.Timestamp(6)
|
||||||
|
end_time DateTime? @db.Timestamp(6)
|
||||||
|
duration_ms Int?
|
||||||
|
total_records Int? @default(0)
|
||||||
|
success_records Int? @default(0)
|
||||||
|
failed_records Int? @default(0)
|
||||||
|
error_message String?
|
||||||
|
error_details String?
|
||||||
|
server_name String? @db.VarChar(100)
|
||||||
|
process_id String? @db.VarChar(50)
|
||||||
|
|
||||||
|
// 관계 설정
|
||||||
|
batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([batch_config_id], map: "idx_batch_execution_logs_config")
|
||||||
|
@@index([execution_status], map: "idx_batch_execution_logs_status")
|
||||||
|
@@index([start_time], map: "idx_batch_execution_logs_start_time")
|
||||||
|
}
|
||||||
|
|
||||||
model admin_supply_mng {
|
model admin_supply_mng {
|
||||||
objid Decimal @id @default(0) @db.Decimal
|
objid Decimal @id @default(0) @db.Decimal
|
||||||
supply_code String? @default("NULL::character varying") @db.VarChar(100)
|
supply_code String? @default("NULL::character varying") @db.VarChar(100)
|
||||||
|
|
@ -4020,9 +4045,6 @@ model table_relationships {
|
||||||
updated_date DateTime? @db.Timestamp(6)
|
updated_date DateTime? @db.Timestamp(6)
|
||||||
updated_by String? @db.VarChar(50)
|
updated_by String? @db.VarChar(50)
|
||||||
diagram_id Int?
|
diagram_id Int?
|
||||||
|
|
||||||
// 역방향 관계
|
|
||||||
bridges data_relationship_bridge[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model data_relationship_bridge {
|
model data_relationship_bridge {
|
||||||
|
|
@ -4045,9 +4067,6 @@ model data_relationship_bridge {
|
||||||
to_key_value String? @db.VarChar(500)
|
to_key_value String? @db.VarChar(500)
|
||||||
to_record_id String? @db.VarChar(100)
|
to_record_id String? @db.VarChar(100)
|
||||||
|
|
||||||
// 관계 정의
|
|
||||||
relationship table_relationships? @relation(fields: [relationship_id], references: [relationship_id])
|
|
||||||
|
|
||||||
@@index([connection_type], map: "idx_data_bridge_connection_type")
|
@@index([connection_type], map: "idx_data_bridge_connection_type")
|
||||||
@@index([company_code, is_active], map: "idx_data_bridge_company_active")
|
@@index([company_code, is_active], map: "idx_data_bridge_company_active")
|
||||||
}
|
}
|
||||||
|
|
@ -4140,55 +4159,419 @@ model table_relationships_backup {
|
||||||
}
|
}
|
||||||
|
|
||||||
model test_sales_info {
|
model test_sales_info {
|
||||||
sales_no String @id @db.VarChar(20)
|
sales_no String @id(map: "pk_test_sales_info") @db.VarChar(200)
|
||||||
contract_type String? @db.VarChar(50)
|
contract_type String? @db.VarChar(50)
|
||||||
order_seq Int?
|
order_seq Int?
|
||||||
domestic_foreign String? @db.VarChar(20)
|
domestic_foreign String? @db.VarChar(20)
|
||||||
customer_name String? @db.VarChar(200)
|
customer_name String? @db.VarChar(200)
|
||||||
product_type String? @db.VarChar(100)
|
product_type String? @db.VarChar(100)
|
||||||
machine_type String? @db.VarChar(100)
|
machine_type String? @db.VarChar(100)
|
||||||
customer_project_name String? @db.VarChar(200)
|
customer_project_name String? @db.VarChar(200)
|
||||||
expected_delivery_date DateTime? @db.Date
|
expected_delivery_date DateTime? @db.Date
|
||||||
receiving_location String? @db.VarChar(200)
|
receiving_location String? @db.VarChar(200)
|
||||||
setup_location String? @db.VarChar(200)
|
setup_location String? @db.VarChar(200)
|
||||||
equipment_direction String? @db.VarChar(100)
|
equipment_direction String? @db.VarChar(100)
|
||||||
equipment_count Int? @default(0)
|
equipment_count Int? @default(0)
|
||||||
equipment_type String? @db.VarChar(100)
|
equipment_type String? @db.VarChar(100)
|
||||||
equipment_length Decimal? @db.Decimal(10,2)
|
equipment_length Decimal? @db.Decimal(10, 2)
|
||||||
manager_name String? @db.VarChar(100)
|
manager_name String? @db.VarChar(100)
|
||||||
reg_date DateTime? @default(now()) @db.Timestamp(6)
|
reg_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
status String? @default("진행중") @db.VarChar(50)
|
status String? @default("진행중") @db.VarChar(50)
|
||||||
|
|
||||||
// 관계 정의: 영업 정보에서 프로젝트로
|
|
||||||
projects test_project_info[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model test_project_info {
|
model test_project_info {
|
||||||
project_no String @id @db.VarChar(200)
|
project_no String @id @db.VarChar(200)
|
||||||
sales_no String? @db.VarChar(20)
|
sales_no String? @db.VarChar(20)
|
||||||
contract_type String? @db.VarChar(50)
|
contract_type String? @db.VarChar(50)
|
||||||
order_seq Int?
|
order_seq Int?
|
||||||
domestic_foreign String? @db.VarChar(20)
|
domestic_foreign String? @db.VarChar(20)
|
||||||
customer_name String? @db.VarChar(200)
|
customer_name String? @db.VarChar(200)
|
||||||
|
project_status String? @default("PLANNING") @db.VarChar(50)
|
||||||
// 프로젝트 전용 컬럼들
|
project_start_date DateTime? @db.Date
|
||||||
project_status String? @default("PLANNING") @db.VarChar(50)
|
project_end_date DateTime? @db.Date
|
||||||
project_start_date DateTime? @db.Date
|
project_manager String? @db.VarChar(100)
|
||||||
project_end_date DateTime? @db.Date
|
project_description String?
|
||||||
project_manager String? @db.VarChar(100)
|
created_by String? @db.VarChar(100)
|
||||||
project_description String? @db.Text
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_by String? @db.VarChar(100)
|
||||||
// 시스템 관리 컬럼들
|
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||||
created_by String? @db.VarChar(100)
|
|
||||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
|
||||||
updated_by String? @db.VarChar(100)
|
|
||||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
|
||||||
|
|
||||||
// 관계 정의: 영업 정보 참조
|
|
||||||
sales test_sales_info? @relation(fields: [sales_no], references: [sales_no])
|
|
||||||
|
|
||||||
@@index([sales_no], map: "idx_project_sales_no")
|
@@index([sales_no], map: "idx_project_sales_no")
|
||||||
@@index([project_status], map: "idx_project_status")
|
@@index([project_status], map: "idx_project_status")
|
||||||
@@index([customer_name], map: "idx_project_customer")
|
@@index([customer_name], map: "idx_project_customer")
|
||||||
@@index([project_manager], map: "idx_project_manager")
|
@@index([project_manager], map: "idx_project_manager")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model batch_jobs {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
job_name String @db.VarChar(100)
|
||||||
|
job_type String @db.VarChar(20)
|
||||||
|
description String?
|
||||||
|
created_by String? @db.VarChar(50)
|
||||||
|
updated_by String? @db.VarChar(50)
|
||||||
|
company_code String @default("*") @db.VarChar(20)
|
||||||
|
config_json Json?
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
execution_count Int @default(0)
|
||||||
|
failure_count Int @default(0)
|
||||||
|
last_executed_at DateTime? @db.Timestamp(6)
|
||||||
|
next_execution_at DateTime? @db.Timestamp(6)
|
||||||
|
schedule_cron String? @db.VarChar(100)
|
||||||
|
success_count Int @default(0)
|
||||||
|
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||||
|
is_active String @default("Y") @db.Char(1)
|
||||||
|
|
||||||
|
@@index([job_type], map: "idx_batch_jobs_type")
|
||||||
|
@@index([company_code], map: "idx_batch_jobs_company_code")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||||
|
model batch_job_executions {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
job_id Int
|
||||||
|
execution_id String @unique @db.VarChar(100)
|
||||||
|
start_time DateTime @db.Timestamp(6)
|
||||||
|
end_time DateTime? @db.Timestamp(6)
|
||||||
|
status String @default("STARTED") @db.VarChar(20)
|
||||||
|
exit_code Int?
|
||||||
|
exit_message String?
|
||||||
|
parameters Json?
|
||||||
|
logs String?
|
||||||
|
created_at DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
|
||||||
|
@@index([execution_id], map: "idx_batch_executions_execution_id")
|
||||||
|
@@index([job_id], map: "idx_batch_executions_job_id")
|
||||||
|
@@index([start_time], map: "idx_batch_executions_start_time")
|
||||||
|
@@index([status], map: "idx_batch_executions_status")
|
||||||
|
}
|
||||||
|
|
||||||
|
model batch_job_parameters {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
job_id Int
|
||||||
|
parameter_name String @db.VarChar(100)
|
||||||
|
parameter_value String?
|
||||||
|
parameter_type String? @default("STRING") @db.VarChar(50)
|
||||||
|
is_required Boolean? @default(false)
|
||||||
|
description String?
|
||||||
|
created_at DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_at DateTime? @db.Timestamp(6)
|
||||||
|
|
||||||
|
@@unique([job_id, parameter_name])
|
||||||
|
@@index([job_id], map: "idx_batch_parameters_job_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
model batch_schedules {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
job_id Int
|
||||||
|
schedule_name String @db.VarChar(255)
|
||||||
|
cron_expression String @db.VarChar(100)
|
||||||
|
timezone String? @default("Asia/Seoul") @db.VarChar(50)
|
||||||
|
is_active Boolean? @default(true)
|
||||||
|
start_date DateTime? @db.Date
|
||||||
|
end_date DateTime? @db.Date
|
||||||
|
created_by String @db.VarChar(100)
|
||||||
|
created_at DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_by String? @db.VarChar(100)
|
||||||
|
updated_at DateTime? @db.Timestamp(6)
|
||||||
|
|
||||||
|
@@index([is_active], map: "idx_batch_schedules_active")
|
||||||
|
@@index([job_id], map: "idx_batch_schedules_job_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||||
|
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||||
|
model dataflow_external_calls {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
diagram_id Int
|
||||||
|
source_table String @db.VarChar(100)
|
||||||
|
trigger_condition Json
|
||||||
|
external_call_config_id Int
|
||||||
|
message_template String?
|
||||||
|
is_active String? @default("Y") @db.Char(1)
|
||||||
|
created_by Int?
|
||||||
|
updated_by Int?
|
||||||
|
created_at DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_at DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
model ddl_execution_log {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id String @db.VarChar(100)
|
||||||
|
company_code String @db.VarChar(50)
|
||||||
|
ddl_type String @db.VarChar(50)
|
||||||
|
table_name String @db.VarChar(100)
|
||||||
|
ddl_query String
|
||||||
|
success Boolean
|
||||||
|
error_message String?
|
||||||
|
executed_at DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||||
|
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||||
|
model external_call_logs {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
dataflow_external_call_id Int?
|
||||||
|
external_call_config_id Int
|
||||||
|
trigger_data Json?
|
||||||
|
request_data Json?
|
||||||
|
response_data Json?
|
||||||
|
status String @db.VarChar(20)
|
||||||
|
error_message String?
|
||||||
|
execution_time Int?
|
||||||
|
executed_at DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
|
||||||
|
@@index([executed_at], map: "idx_external_call_logs_executed")
|
||||||
|
}
|
||||||
|
|
||||||
|
model my_custom_table {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
company_code String? @default("*") @db.VarChar(50)
|
||||||
|
customer_name String? @db.VarChar
|
||||||
|
email_address String? @db.VarChar(255)
|
||||||
|
}
|
||||||
|
|
||||||
|
model table_type_columns {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
table_name String @db.VarChar(255)
|
||||||
|
column_name String @db.VarChar(255)
|
||||||
|
input_type String @default("text") @db.VarChar(50)
|
||||||
|
detail_settings String? @default("{}")
|
||||||
|
is_nullable String? @default("Y") @db.VarChar(10)
|
||||||
|
display_order Int? @default(0)
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
|
||||||
|
@@unique([table_name, column_name])
|
||||||
|
@@index([input_type], map: "idx_table_type_columns_input_type")
|
||||||
|
@@index([table_name], map: "idx_table_type_columns_table_name")
|
||||||
|
}
|
||||||
|
|
||||||
|
model test_api_integration_1758589777139 {
|
||||||
|
id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500)
|
||||||
|
created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||||
|
updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||||
|
writer String? @db.VarChar(500)
|
||||||
|
company_code String? @default("*") @db.VarChar(500)
|
||||||
|
product_name String? @db.VarChar(500)
|
||||||
|
price String? @db.VarChar(500)
|
||||||
|
category String? @db.VarChar(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
model test_new_table {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
company_code String? @default("*") @db.VarChar(50)
|
||||||
|
name String? @db.VarChar
|
||||||
|
email String? @db.VarChar(255)
|
||||||
|
user_test_column String? @db.VarChar
|
||||||
|
dsfsdf123215 String? @db.VarChar
|
||||||
|
aaaassda String? @db.VarChar
|
||||||
|
}
|
||||||
|
|
||||||
|
model test_new_table33333 {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
writer String? @db.VarChar(100)
|
||||||
|
company_code String? @default("*") @db.VarChar(50)
|
||||||
|
eeeeeeee String? @db.VarChar(500)
|
||||||
|
wwww String? @db.VarChar(500)
|
||||||
|
sssss String? @db.VarChar(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
model test_new_table44444 {
|
||||||
|
id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500)
|
||||||
|
created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||||
|
updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||||
|
writer String? @db.VarChar(500)
|
||||||
|
company_code String? @db.VarChar(500)
|
||||||
|
ttttttt String? @db.VarChar(500)
|
||||||
|
yyyyyyy String? @db.VarChar(500)
|
||||||
|
uuuuuuu String? @db.VarChar(500)
|
||||||
|
iiiiiii String? @db.VarChar(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
model test_new_table555555 {
|
||||||
|
id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500)
|
||||||
|
created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||||
|
updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||||
|
writer String? @db.VarChar(500)
|
||||||
|
company_code String? @db.VarChar(500)
|
||||||
|
rtrtrtrtr String? @db.VarChar(500)
|
||||||
|
ererwewewe String? @db.VarChar(500)
|
||||||
|
wetyeryrtyut String? @db.VarChar(500)
|
||||||
|
werwqq String? @db.VarChar(500)
|
||||||
|
saved_file_name String? @db.VarChar(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
model test_table_info {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
company_code String? @default("*") @db.VarChar(50)
|
||||||
|
objid Int
|
||||||
|
test_name String? @db.VarChar(250)
|
||||||
|
ggggggggggg String? @db.VarChar
|
||||||
|
test_column_1 String? @db.VarChar
|
||||||
|
test_column_2 String? @db.VarChar
|
||||||
|
test_column_3 String? @db.VarChar
|
||||||
|
final_test_column String? @db.VarChar
|
||||||
|
zzzzzzz String? @db.VarChar
|
||||||
|
bbbbbbb String? @db.VarChar
|
||||||
|
realtime_test String? @db.VarChar
|
||||||
|
table_update_test String? @db.VarChar
|
||||||
|
}
|
||||||
|
|
||||||
|
model test_table_info2222 {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
company_code String? @default("*") @db.VarChar(50)
|
||||||
|
clll_cc String? @db.VarChar
|
||||||
|
eeee_eee String? @db.VarChar
|
||||||
|
saved_file_name String? @db.VarChar
|
||||||
|
debug_test_column String? @db.VarChar
|
||||||
|
field_1 String? @db.VarChar
|
||||||
|
rrrrrrrrrr String? @db.VarChar
|
||||||
|
tttttttt String? @db.VarChar
|
||||||
|
}
|
||||||
|
|
||||||
|
model test_varchar_unified {
|
||||||
|
id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500)
|
||||||
|
created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||||
|
updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||||
|
writer String? @db.VarChar(500)
|
||||||
|
company_code String? @default("*") @db.VarChar(500)
|
||||||
|
product_name String? @db.VarChar(500)
|
||||||
|
price String? @db.VarChar(500)
|
||||||
|
launch_date String? @db.VarChar(500)
|
||||||
|
is_active String? @db.VarChar(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
model test_varchar_unified_1758588878993 {
|
||||||
|
id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500)
|
||||||
|
created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||||
|
updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||||
|
writer String? @db.VarChar(500)
|
||||||
|
company_code String? @default("*") @db.VarChar(500)
|
||||||
|
product_name String? @db.VarChar(500)
|
||||||
|
price String? @db.VarChar(500)
|
||||||
|
launch_date String? @db.VarChar(500)
|
||||||
|
is_active String? @db.VarChar(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
model writer_test_table {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
writer String? @db.VarChar(100)
|
||||||
|
company_code String? @default("*") @db.VarChar(50)
|
||||||
|
test_field String? @db.VarChar
|
||||||
|
field_1 String? @db.VarChar
|
||||||
|
}
|
||||||
|
|
||||||
|
model data_collection_configs {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
config_name String @db.VarChar(100)
|
||||||
|
description String?
|
||||||
|
source_connection_id Int
|
||||||
|
source_table String @db.VarChar(100)
|
||||||
|
target_table String? @db.VarChar(100)
|
||||||
|
collection_type String @db.VarChar(20)
|
||||||
|
schedule_cron String? @db.VarChar(100)
|
||||||
|
is_active String @default("Y") @db.Char(1)
|
||||||
|
last_collected_at DateTime? @db.Timestamp(6)
|
||||||
|
collection_options Json?
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
created_by String? @db.VarChar(50)
|
||||||
|
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||||
|
updated_by String? @db.VarChar(50)
|
||||||
|
company_code String @default("*") @db.VarChar(20)
|
||||||
|
external_connection external_db_connections @relation(fields: [source_connection_id], references: [id])
|
||||||
|
collection_history data_collection_history[]
|
||||||
|
collection_jobs data_collection_jobs[]
|
||||||
|
|
||||||
|
@@index([source_connection_id], map: "idx_data_collection_configs_connection")
|
||||||
|
@@index([is_active], map: "idx_data_collection_configs_active")
|
||||||
|
@@index([company_code], map: "idx_data_collection_configs_company")
|
||||||
|
}
|
||||||
|
|
||||||
|
model data_collection_jobs {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
config_id Int
|
||||||
|
job_status String @db.VarChar(20)
|
||||||
|
started_at DateTime? @db.Timestamp(6)
|
||||||
|
completed_at DateTime? @db.Timestamp(6)
|
||||||
|
records_processed Int? @default(0)
|
||||||
|
error_message String?
|
||||||
|
job_details Json?
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
config data_collection_configs @relation(fields: [config_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([config_id], map: "idx_data_collection_jobs_config")
|
||||||
|
@@index([job_status], map: "idx_data_collection_jobs_status")
|
||||||
|
@@index([created_date], map: "idx_data_collection_jobs_created")
|
||||||
|
}
|
||||||
|
|
||||||
|
model data_collection_history {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
config_id Int
|
||||||
|
collection_date DateTime @db.Timestamp(6)
|
||||||
|
records_collected Int @default(0)
|
||||||
|
execution_time_ms Int @default(0)
|
||||||
|
status String @db.VarChar(20)
|
||||||
|
error_details String?
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
config data_collection_configs @relation(fields: [config_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([config_id], map: "idx_data_collection_history_config")
|
||||||
|
@@index([collection_date], map: "idx_data_collection_history_date")
|
||||||
|
@@index([status], map: "idx_data_collection_history_status")
|
||||||
|
}
|
||||||
|
|
||||||
|
model collection_batch_management {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
batch_name String @db.VarChar(100)
|
||||||
|
description String?
|
||||||
|
batch_type String @db.VarChar(20)
|
||||||
|
schedule_cron String? @db.VarChar(100)
|
||||||
|
is_active String @default("Y") @db.Char(1)
|
||||||
|
config_json Json?
|
||||||
|
last_executed_at DateTime? @db.Timestamp(6)
|
||||||
|
next_execution_at DateTime? @db.Timestamp(6)
|
||||||
|
execution_count Int @default(0)
|
||||||
|
success_count Int @default(0)
|
||||||
|
failure_count Int @default(0)
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
created_by String? @db.VarChar(50)
|
||||||
|
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||||
|
updated_by String? @db.VarChar(50)
|
||||||
|
company_code String @default("*") @db.VarChar(20)
|
||||||
|
batch_executions collection_batch_executions[]
|
||||||
|
|
||||||
|
@@index([batch_type], map: "idx_collection_batch_mgmt_type")
|
||||||
|
@@index([is_active], map: "idx_collection_batch_mgmt_active")
|
||||||
|
@@index([company_code], map: "idx_collection_batch_mgmt_company")
|
||||||
|
@@index([next_execution_at], map: "idx_collection_batch_mgmt_next_execution")
|
||||||
|
}
|
||||||
|
|
||||||
|
model collection_batch_executions {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
batch_id Int
|
||||||
|
execution_status String @db.VarChar(20)
|
||||||
|
started_at DateTime? @db.Timestamp(6)
|
||||||
|
completed_at DateTime? @db.Timestamp(6)
|
||||||
|
execution_time_ms Int?
|
||||||
|
result_data Json?
|
||||||
|
error_message String?
|
||||||
|
log_details String?
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
batch collection_batch_management @relation(fields: [batch_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([batch_id], map: "idx_collection_batch_executions_batch")
|
||||||
|
@@index([execution_status], map: "idx_collection_batch_executions_status")
|
||||||
|
@@index([created_date], map: "idx_collection_batch_executions_created")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,14 @@ import dataRoutes from "./routes/dataRoutes";
|
||||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||||
import batchRoutes from "./routes/batchRoutes";
|
import batchRoutes from "./routes/batchRoutes";
|
||||||
|
import batchManagementRoutes from "./routes/batchManagementRoutes";
|
||||||
|
import batchExecutionLogRoutes from "./routes/batchExecutionLogRoutes";
|
||||||
|
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||||
import ddlRoutes from "./routes/ddlRoutes";
|
import ddlRoutes from "./routes/ddlRoutes";
|
||||||
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
||||||
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
// import userRoutes from './routes/userRoutes';
|
// import userRoutes from './routes/userRoutes';
|
||||||
// import menuRoutes from './routes/menuRoutes';
|
// import menuRoutes from './routes/menuRoutes';
|
||||||
|
|
||||||
|
|
@ -129,8 +135,13 @@ app.use("/api/data", dataRoutes);
|
||||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||||
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||||
app.use("/api/batch-configs", batchRoutes);
|
app.use("/api/batch-configs", batchRoutes);
|
||||||
|
app.use("/api/batch-management", batchManagementRoutes);
|
||||||
|
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
||||||
|
app.use("/api/db-type-categories", dbTypeCategoryRoutes);
|
||||||
app.use("/api/ddl", ddlRoutes);
|
app.use("/api/ddl", ddlRoutes);
|
||||||
app.use("/api/entity-reference", entityReferenceRoutes);
|
app.use("/api/entity-reference", entityReferenceRoutes);
|
||||||
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
|
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
// app.use('/api/menus', menuRoutes);
|
// app.use('/api/menus', menuRoutes);
|
||||||
|
|
||||||
|
|
@ -150,11 +161,19 @@ app.use(errorHandler);
|
||||||
const PORT = config.port;
|
const PORT = config.port;
|
||||||
const HOST = config.host;
|
const HOST = config.host;
|
||||||
|
|
||||||
app.listen(PORT, HOST, () => {
|
app.listen(PORT, HOST, async () => {
|
||||||
logger.info(`🚀 Server is running on ${HOST}:${PORT}`);
|
logger.info(`🚀 Server is running on ${HOST}:${PORT}`);
|
||||||
logger.info(`📊 Environment: ${config.nodeEnv}`);
|
logger.info(`📊 Environment: ${config.nodeEnv}`);
|
||||||
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
|
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
|
||||||
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
|
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
|
||||||
|
|
||||||
|
// 배치 스케줄러 초기화
|
||||||
|
try {
|
||||||
|
await BatchSchedulerService.initialize();
|
||||||
|
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { BatchService } from "../services/batchService";
|
import { BatchService } from "../services/batchService";
|
||||||
import { BatchConfigFilter, BatchMappingRequest } from "../types/batchTypes";
|
import { BatchConfigFilter, CreateBatchConfigRequest, UpdateBatchConfigRequest } from "../types/batchTypes";
|
||||||
|
|
||||||
export interface AuthenticatedRequest extends Request {
|
export interface AuthenticatedRequest extends Request {
|
||||||
user?: {
|
user?: {
|
||||||
|
|
@ -20,172 +20,27 @@ export class BatchController {
|
||||||
*/
|
*/
|
||||||
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
|
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
const { page = 1, limit = 10, search, isActive } = req.query;
|
||||||
|
|
||||||
const filter: BatchConfigFilter = {
|
const filter: BatchConfigFilter = {
|
||||||
is_active: req.query.is_active as string,
|
page: Number(page),
|
||||||
company_code: req.query.company_code as string,
|
limit: Number(limit),
|
||||||
search: req.query.search as string,
|
search: search as string,
|
||||||
|
is_active: isActive as string
|
||||||
};
|
};
|
||||||
|
|
||||||
// 빈 값 제거
|
|
||||||
Object.keys(filter).forEach((key) => {
|
|
||||||
if (!filter[key as keyof BatchConfigFilter]) {
|
|
||||||
delete filter[key as keyof BatchConfigFilter];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await BatchService.getBatchConfigs(filter);
|
const result = await BatchService.getBatchConfigs(filter);
|
||||||
|
|
||||||
if (result.success) {
|
res.json({
|
||||||
return res.status(200).json(result);
|
success: true,
|
||||||
} else {
|
data: result.data,
|
||||||
return res.status(400).json(result);
|
pagination: result.pagination
|
||||||
}
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 설정 목록 조회 오류:", error);
|
console.error("배치 설정 목록 조회 오류:", error);
|
||||||
return res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "서버 내부 오류가 발생했습니다.",
|
message: "배치 설정 목록 조회에 실패했습니다."
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 배치 설정 조회
|
|
||||||
* GET /api/batch-configs/:id
|
|
||||||
*/
|
|
||||||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const id = parseInt(req.params.id);
|
|
||||||
|
|
||||||
if (isNaN(id)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "유효하지 않은 배치 설정 ID입니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await BatchService.getBatchConfigById(id);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json(result);
|
|
||||||
} else {
|
|
||||||
return res.status(404).json(result);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("배치 설정 조회 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "서버 내부 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배치 설정 생성
|
|
||||||
* POST /api/batch-configs
|
|
||||||
*/
|
|
||||||
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const data: BatchMappingRequest = req.body;
|
|
||||||
|
|
||||||
// 필수 필드 검증
|
|
||||||
if (!data.batch_name || !data.cron_schedule || !data.mappings) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "필수 필드가 누락되었습니다. (batch_name, cron_schedule, mappings)",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await BatchService.createBatchConfig(
|
|
||||||
data,
|
|
||||||
req.user?.userId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(201).json(result);
|
|
||||||
} else {
|
|
||||||
return res.status(400).json(result);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("배치 설정 생성 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "서버 내부 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배치 설정 수정
|
|
||||||
* PUT /api/batch-configs/:id
|
|
||||||
*/
|
|
||||||
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const id = parseInt(req.params.id);
|
|
||||||
const data: Partial<BatchMappingRequest> = req.body;
|
|
||||||
|
|
||||||
if (isNaN(id)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "유효하지 않은 배치 설정 ID입니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await BatchService.updateBatchConfig(
|
|
||||||
id,
|
|
||||||
data,
|
|
||||||
req.user?.userId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json(result);
|
|
||||||
} else {
|
|
||||||
return res.status(400).json(result);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("배치 설정 수정 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "서버 내부 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배치 설정 삭제
|
|
||||||
* DELETE /api/batch-configs/:id
|
|
||||||
*/
|
|
||||||
static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const id = parseInt(req.params.id);
|
|
||||||
|
|
||||||
if (isNaN(id)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "유효하지 않은 배치 설정 ID입니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await BatchService.deleteBatchConfig(
|
|
||||||
id,
|
|
||||||
req.user?.userId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json(result);
|
|
||||||
} else {
|
|
||||||
return res.status(404).json(result);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("배치 설정 삭제 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "서버 내부 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,116 +52,230 @@ export class BatchController {
|
||||||
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
|
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const result = await BatchService.getAvailableConnections();
|
const result = await BatchService.getAvailableConnections();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return res.status(200).json(result);
|
res.json(result);
|
||||||
} else {
|
} else {
|
||||||
return res.status(400).json(result);
|
res.status(500).json(result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("커넥션 목록 조회 오류:", error);
|
console.error("커넥션 목록 조회 오류:", error);
|
||||||
return res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "서버 내부 오류가 발생했습니다.",
|
message: "커넥션 목록 조회에 실패했습니다."
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 커넥션의 테이블 목록 조회
|
* 테이블 목록 조회 (내부/외부 DB)
|
||||||
* GET /api/batch-configs/connections/:type/tables
|
* GET /api/batch-configs/connections/:type/tables
|
||||||
* GET /api/batch-configs/connections/:type/:id/tables
|
* GET /api/batch-configs/connections/:type/:id/tables
|
||||||
*/
|
*/
|
||||||
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
|
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const connectionType = req.params.type as 'internal' | 'external';
|
const { type, id } = req.params;
|
||||||
const connectionId = req.params.id ? parseInt(req.params.id) : undefined;
|
|
||||||
|
if (!type || (type !== 'internal' && type !== 'external')) {
|
||||||
if (connectionType !== 'internal' && connectionType !== 'external') {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "유효하지 않은 커넥션 타입입니다. (internal 또는 external)",
|
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connectionType === 'external' && (!connectionId || isNaN(connectionId))) {
|
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||||
return res.status(400).json({
|
const result = await BatchService.getTablesFromConnection(type, connectionId);
|
||||||
success: false,
|
|
||||||
message: "외부 커넥션의 경우 유효한 커넥션 ID가 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await BatchService.getTablesFromConnection(
|
|
||||||
connectionType,
|
|
||||||
connectionId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return res.status(200).json(result);
|
return res.json(result);
|
||||||
} else {
|
} else {
|
||||||
return res.status(400).json(result);
|
return res.status(500).json(result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 목록 조회 오류:", error);
|
console.error("테이블 목록 조회 오류:", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "서버 내부 오류가 발생했습니다.",
|
message: "테이블 목록 조회에 실패했습니다."
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 테이블의 컬럼 정보 조회
|
* 테이블 컬럼 정보 조회 (내부/외부 DB)
|
||||||
* GET /api/batch-configs/connections/:type/tables/:tableName/columns
|
* GET /api/batch-configs/connections/:type/tables/:tableName/columns
|
||||||
* GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns
|
* GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns
|
||||||
*/
|
*/
|
||||||
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
|
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const connectionType = req.params.type as 'internal' | 'external';
|
const { type, id, tableName } = req.params;
|
||||||
const connectionId = req.params.id ? parseInt(req.params.id) : undefined;
|
|
||||||
const tableName = req.params.tableName;
|
if (!type || !tableName) {
|
||||||
|
|
||||||
if (connectionType !== 'internal' && connectionType !== 'external') {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "유효하지 않은 커넥션 타입입니다. (internal 또는 external)",
|
message: "연결 타입과 테이블명을 모두 지정해주세요."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connectionType === 'external' && (!connectionId || isNaN(connectionId))) {
|
if (type !== 'internal' && type !== 'external') {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "외부 커넥션의 경우 유효한 커넥션 ID가 필요합니다.",
|
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tableName) {
|
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||||
return res.status(400).json({
|
const result = await BatchService.getTableColumns(type, connectionId, tableName);
|
||||||
success: false,
|
|
||||||
message: "테이블명이 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await BatchService.getTableColumns(
|
|
||||||
connectionType,
|
|
||||||
tableName,
|
|
||||||
connectionId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return res.status(200).json(result);
|
return res.json(result);
|
||||||
} else {
|
} else {
|
||||||
return res.status(400).json(result);
|
return res.status(500).json(result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("컬럼 정보 조회 오류:", error);
|
console.error("컬럼 정보 조회 오류:", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "서버 내부 오류가 발생했습니다.",
|
message: "컬럼 정보 조회에 실패했습니다."
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* 특정 배치 설정 조회
|
||||||
|
* GET /api/batch-configs/:id
|
||||||
|
*/
|
||||||
|
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const batchConfig = await BatchService.getBatchConfigById(Number(id));
|
||||||
|
|
||||||
|
if (!batchConfig) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 설정을 찾을 수 없습니다."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: batchConfig
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 설정 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 설정 조회에 실패했습니다."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 설정 생성
|
||||||
|
* POST /api/batch-configs
|
||||||
|
*/
|
||||||
|
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { batchName, description, cronSchedule, mappings } = req.body;
|
||||||
|
|
||||||
|
if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchConfig = await BatchService.createBatchConfig({
|
||||||
|
batchName,
|
||||||
|
description,
|
||||||
|
cronSchedule,
|
||||||
|
mappings
|
||||||
|
} as CreateBatchConfigRequest);
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: batchConfig,
|
||||||
|
message: "배치 설정이 성공적으로 생성되었습니다."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 설정 생성 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 설정 생성에 실패했습니다."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 설정 수정
|
||||||
|
* PUT /api/batch-configs/:id
|
||||||
|
*/
|
||||||
|
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { batchName, description, cronSchedule, mappings, isActive } = req.body;
|
||||||
|
|
||||||
|
if (!batchName || !cronSchedule) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchConfig = await BatchService.updateBatchConfig(Number(id), {
|
||||||
|
batchName,
|
||||||
|
description,
|
||||||
|
cronSchedule,
|
||||||
|
mappings,
|
||||||
|
isActive
|
||||||
|
} as UpdateBatchConfigRequest);
|
||||||
|
|
||||||
|
if (!batchConfig) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 설정을 찾을 수 없습니다."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: batchConfig,
|
||||||
|
message: "배치 설정이 성공적으로 수정되었습니다."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 설정 수정 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 설정 수정에 실패했습니다."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 설정 삭제 (논리 삭제)
|
||||||
|
* DELETE /api/batch-configs/:id
|
||||||
|
*/
|
||||||
|
static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await BatchService.deleteBatchConfig(Number(id));
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 설정을 찾을 수 없습니다."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "배치 설정이 성공적으로 삭제되었습니다."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 설정 삭제 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 설정 삭제에 실패했습니다."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
// 배치 실행 로그 컨트롤러
|
||||||
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { BatchExecutionLogService } from "../services/batchExecutionLogService";
|
||||||
|
import { BatchExecutionLogFilter, CreateBatchExecutionLogRequest, UpdateBatchExecutionLogRequest } from "../types/batchExecutionLogTypes";
|
||||||
|
|
||||||
|
export class BatchExecutionLogController {
|
||||||
|
/**
|
||||||
|
* 배치 실행 로그 목록 조회
|
||||||
|
*/
|
||||||
|
static async getExecutionLogs(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
batch_config_id,
|
||||||
|
execution_status,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
page,
|
||||||
|
limit
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const filter: BatchExecutionLogFilter = {
|
||||||
|
batch_config_id: batch_config_id ? Number(batch_config_id) : undefined,
|
||||||
|
execution_status: execution_status as string,
|
||||||
|
start_date: start_date ? new Date(start_date as string) : undefined,
|
||||||
|
end_date: end_date ? new Date(end_date as string) : undefined,
|
||||||
|
page: page ? Number(page) : undefined,
|
||||||
|
limit: limit ? Number(limit) : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await BatchExecutionLogService.getExecutionLogs(filter);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 로그 조회 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 실행 로그 생성
|
||||||
|
*/
|
||||||
|
static async createExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const data: CreateBatchExecutionLogRequest = req.body;
|
||||||
|
|
||||||
|
const result = await BatchExecutionLogService.createExecutionLog(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.status(201).json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 로그 생성 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 실행 로그 업데이트
|
||||||
|
*/
|
||||||
|
static async updateExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const data: UpdateBatchExecutionLogRequest = req.body;
|
||||||
|
|
||||||
|
const result = await BatchExecutionLogService.updateExecutionLog(Number(id), data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 로그 업데이트 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 실행 로그 삭제
|
||||||
|
*/
|
||||||
|
static async deleteExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await BatchExecutionLogService.deleteExecutionLog(Number(id));
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 로그 삭제 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 배치의 최신 실행 로그 조회
|
||||||
|
*/
|
||||||
|
static async getLatestExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { batchConfigId } = req.params;
|
||||||
|
|
||||||
|
const result = await BatchExecutionLogService.getLatestExecutionLog(Number(batchConfigId));
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("최신 배치 실행 로그 조회 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 실행 통계 조회
|
||||||
|
*/
|
||||||
|
static async getExecutionStats(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
batch_config_id,
|
||||||
|
start_date,
|
||||||
|
end_date
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const result = await BatchExecutionLogService.getExecutionStats(
|
||||||
|
batch_config_id ? Number(batch_config_id) : undefined,
|
||||||
|
start_date ? new Date(start_date as string) : undefined,
|
||||||
|
end_date ? new Date(end_date as string) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 통계 조회 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
||||||
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { BatchManagementService, BatchConnectionInfo, BatchTableInfo, BatchColumnInfo } from "../services/batchManagementService";
|
||||||
|
import { BatchService } from "../services/batchService";
|
||||||
|
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||||
|
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
||||||
|
|
||||||
|
export class BatchManagementController {
|
||||||
|
/**
|
||||||
|
* 사용 가능한 커넥션 목록 조회
|
||||||
|
*/
|
||||||
|
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const result = await BatchManagementService.getAvailableConnections();
|
||||||
|
if (result.success) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("커넥션 목록 조회 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "커넥션 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 커넥션의 테이블 목록 조회
|
||||||
|
*/
|
||||||
|
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { type, id } = req.params;
|
||||||
|
|
||||||
|
if (type !== 'internal' && type !== 'external') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||||
|
const result = await BatchManagementService.getTablesFromConnection(type, connectionId);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return res.json(result);
|
||||||
|
} else {
|
||||||
|
return res.status(500).json(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 테이블의 컬럼 정보 조회
|
||||||
|
*/
|
||||||
|
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { type, id, tableName } = req.params;
|
||||||
|
|
||||||
|
if (type !== 'internal' && type !== 'external') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||||
|
const result = await BatchManagementService.getTableColumns(type, connectionId, tableName);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return res.json(result);
|
||||||
|
} else {
|
||||||
|
return res.status(500).json(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 정보 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 설정 생성
|
||||||
|
* POST /api/batch-management/batch-configs
|
||||||
|
*/
|
||||||
|
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { batchName, description, cronSchedule, mappings, isActive } = req.body;
|
||||||
|
|
||||||
|
if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchConfig = await BatchService.createBatchConfig({
|
||||||
|
batchName,
|
||||||
|
description,
|
||||||
|
cronSchedule,
|
||||||
|
mappings,
|
||||||
|
isActive: isActive !== undefined ? isActive : true
|
||||||
|
} as CreateBatchConfigRequest);
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: batchConfig,
|
||||||
|
message: "배치 설정이 성공적으로 생성되었습니다."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 설정 생성 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 설정 생성에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 설정 목록 조회
|
||||||
|
* GET /api/batch-management/batch-configs
|
||||||
|
*/
|
||||||
|
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 10, search, isActive } = req.query;
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
search: search as string,
|
||||||
|
is_active: isActive as string
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await BatchService.getBatchConfigs(filter);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
pagination: result.pagination
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 설정 목록 조회 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 설정 목록 조회에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 수동 실행
|
||||||
|
* POST /api/batch-management/batch-configs/:id/execute
|
||||||
|
*/
|
||||||
|
static async executeBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id || isNaN(Number(id))) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "올바른 배치 설정 ID를 제공해주세요."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배치 설정 조회
|
||||||
|
const batchConfigResult = await BatchService.getBatchConfigById(Number(id));
|
||||||
|
if (!batchConfigResult.success || !batchConfigResult.data) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 설정을 찾을 수 없습니다."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchConfig = batchConfigResult.data as BatchConfig;
|
||||||
|
|
||||||
|
// 배치 실행 로직 (간단한 버전)
|
||||||
|
const startTime = new Date();
|
||||||
|
let totalRecords = 0;
|
||||||
|
let successRecords = 0;
|
||||||
|
let failedRecords = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`배치 실행 시작: ${batchConfig.batch_name} (ID: ${id})`);
|
||||||
|
|
||||||
|
// 실행 로그 생성
|
||||||
|
const executionLog = await BatchService.createExecutionLog({
|
||||||
|
batch_config_id: Number(id),
|
||||||
|
execution_status: 'RUNNING',
|
||||||
|
start_time: startTime,
|
||||||
|
total_records: 0,
|
||||||
|
success_records: 0,
|
||||||
|
failed_records: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// 실제 배치 실행 (매핑이 있는 경우)
|
||||||
|
if (batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) {
|
||||||
|
// 테이블별로 매핑을 그룹화
|
||||||
|
const tableGroups = new Map<string, typeof batchConfig.batch_mappings>();
|
||||||
|
|
||||||
|
for (const mapping of batchConfig.batch_mappings) {
|
||||||
|
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`;
|
||||||
|
if (!tableGroups.has(key)) {
|
||||||
|
tableGroups.set(key, []);
|
||||||
|
}
|
||||||
|
tableGroups.get(key)!.push(mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 테이블 그룹별로 처리
|
||||||
|
for (const [tableKey, mappings] of tableGroups) {
|
||||||
|
try {
|
||||||
|
const firstMapping = mappings[0];
|
||||||
|
console.log(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`);
|
||||||
|
|
||||||
|
// FROM 테이블에서 매핑된 컬럼들만 조회
|
||||||
|
const fromColumns = mappings.map(m => m.from_column_name);
|
||||||
|
const fromData = await BatchService.getDataFromTableWithColumns(
|
||||||
|
firstMapping.from_table_name,
|
||||||
|
fromColumns,
|
||||||
|
firstMapping.from_connection_type as 'internal' | 'external',
|
||||||
|
firstMapping.from_connection_id || undefined
|
||||||
|
);
|
||||||
|
totalRecords += fromData.length;
|
||||||
|
|
||||||
|
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
||||||
|
const mappedData = fromData.map(row => {
|
||||||
|
const mappedRow: any = {};
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||||
|
}
|
||||||
|
return mappedRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
// TO 테이블에 데이터 삽입
|
||||||
|
const insertResult = await BatchService.insertDataToTable(
|
||||||
|
firstMapping.to_table_name,
|
||||||
|
mappedData,
|
||||||
|
firstMapping.to_connection_type as 'internal' | 'external',
|
||||||
|
firstMapping.to_connection_id || undefined
|
||||||
|
);
|
||||||
|
successRecords += insertResult.successCount;
|
||||||
|
failedRecords += insertResult.failedCount;
|
||||||
|
|
||||||
|
console.log(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`테이블 처리 실패: ${tableKey}`, error);
|
||||||
|
failedRecords += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("매핑이 없어서 데이터 처리를 건너뜁니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실행 로그 업데이트 (성공)
|
||||||
|
await BatchService.updateExecutionLog(executionLog.id, {
|
||||||
|
execution_status: 'SUCCESS',
|
||||||
|
end_time: new Date(),
|
||||||
|
duration_ms: Date.now() - startTime.getTime(),
|
||||||
|
total_records: totalRecords,
|
||||||
|
success_records: successRecords,
|
||||||
|
failed_records: failedRecords
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "배치가 성공적으로 실행되었습니다.",
|
||||||
|
data: {
|
||||||
|
batchId: id,
|
||||||
|
totalRecords,
|
||||||
|
successRecords,
|
||||||
|
failedRecords,
|
||||||
|
duration: Date.now() - startTime.getTime()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`배치 실행 실패: ${batchConfig.batch_name}`, error);
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 설정 업데이트
|
||||||
|
* PUT /api/batch-management/batch-configs/:id
|
||||||
|
*/
|
||||||
|
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updateData = req.body;
|
||||||
|
|
||||||
|
if (!id || isNaN(Number(id))) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "올바른 배치 설정 ID를 제공해주세요."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchConfig = await BatchService.updateBatchConfig(Number(id), updateData);
|
||||||
|
|
||||||
|
// 스케줄러에서 배치 스케줄 업데이트
|
||||||
|
await BatchSchedulerService.updateBatchSchedule(Number(id));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: batchConfig,
|
||||||
|
message: "배치 설정이 성공적으로 업데이트되었습니다."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 설정 업데이트 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "배치 설정 업데이트에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||||
|
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||||
|
// @ts-ignore
|
||||||
|
import * as mssql from 'mssql';
|
||||||
|
|
||||||
|
export class MSSQLConnector implements DatabaseConnector {
|
||||||
|
private pool: mssql.ConnectionPool | null = null;
|
||||||
|
private config: ConnectionConfig;
|
||||||
|
|
||||||
|
constructor(config: ConnectionConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (!this.pool) {
|
||||||
|
const config: mssql.config = {
|
||||||
|
server: this.config.host,
|
||||||
|
port: this.config.port,
|
||||||
|
user: this.config.user,
|
||||||
|
password: this.config.password,
|
||||||
|
database: this.config.database,
|
||||||
|
options: {
|
||||||
|
encrypt: this.config.ssl === true,
|
||||||
|
trustServerCertificate: true
|
||||||
|
},
|
||||||
|
connectionTimeout: this.config.connectionTimeoutMillis || 15000,
|
||||||
|
requestTimeout: this.config.queryTimeoutMillis || 15000
|
||||||
|
};
|
||||||
|
this.pool = await new mssql.ConnectionPool(config).connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.pool) {
|
||||||
|
await this.pool.close();
|
||||||
|
this.pool = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(): Promise<ConnectionTestResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
// 버전 정보 조회
|
||||||
|
const versionResult = await this.pool!.request().query('SELECT @@VERSION as version');
|
||||||
|
|
||||||
|
// 데이터베이스 크기 조회
|
||||||
|
const sizeResult = await this.pool!.request()
|
||||||
|
.input('dbName', mssql.VarChar, this.config.database)
|
||||||
|
.query(`
|
||||||
|
SELECT SUM(size * 8 * 1024) as size
|
||||||
|
FROM sys.master_files
|
||||||
|
WHERE database_id = DB_ID(@dbName)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "MSSQL 연결이 성공했습니다.",
|
||||||
|
details: {
|
||||||
|
response_time: responseTime,
|
||||||
|
server_version: versionResult.recordset[0]?.version || "알 수 없음",
|
||||||
|
database_size: this.formatBytes(parseInt(sizeResult.recordset[0]?.size || "0")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "MSSQL 연결에 실패했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "CONNECTION_FAILED",
|
||||||
|
details: error.message || "알 수 없는 오류",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeQuery(query: string): Promise<QueryResult> {
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
const result = await this.pool!.request().query(query);
|
||||||
|
return {
|
||||||
|
rows: result.recordset,
|
||||||
|
rowCount: result.rowsAffected[0],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`쿼리 실행 오류: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTables(): Promise<TableInfo[]> {
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
const result = await this.pool!.request().query(`
|
||||||
|
SELECT
|
||||||
|
t.TABLE_NAME as table_name,
|
||||||
|
c.COLUMN_NAME as column_name,
|
||||||
|
c.DATA_TYPE as data_type,
|
||||||
|
c.IS_NULLABLE as is_nullable,
|
||||||
|
c.COLUMN_DEFAULT as column_default,
|
||||||
|
CAST(p.value AS NVARCHAR(MAX)) as description
|
||||||
|
FROM INFORMATION_SCHEMA.TABLES t
|
||||||
|
LEFT JOIN INFORMATION_SCHEMA.COLUMNS c
|
||||||
|
ON c.TABLE_NAME = t.TABLE_NAME
|
||||||
|
LEFT JOIN sys.extended_properties p
|
||||||
|
ON p.major_id = OBJECT_ID(t.TABLE_NAME)
|
||||||
|
AND p.minor_id = 0
|
||||||
|
AND p.name = 'MS_Description'
|
||||||
|
WHERE t.TABLE_TYPE = 'BASE TABLE'
|
||||||
|
ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 결과를 TableInfo[] 형식으로 변환
|
||||||
|
const tables = new Map<string, TableInfo>();
|
||||||
|
|
||||||
|
result.recordset.forEach((row: any) => {
|
||||||
|
if (!tables.has(row.table_name)) {
|
||||||
|
tables.set(row.table_name, {
|
||||||
|
table_name: row.table_name,
|
||||||
|
columns: [],
|
||||||
|
description: row.description || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.column_name) {
|
||||||
|
tables.get(row.table_name)!.columns.push({
|
||||||
|
column_name: row.column_name,
|
||||||
|
data_type: row.data_type,
|
||||||
|
is_nullable: row.is_nullable === 'YES' ? 'Y' : 'N',
|
||||||
|
column_default: row.column_default
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(tables.values());
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`테이블 목록 조회 오류: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getColumns(tableName: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
const result = await this.pool!.request()
|
||||||
|
.input('tableName', mssql.VarChar, tableName)
|
||||||
|
.query(`
|
||||||
|
SELECT
|
||||||
|
c.COLUMN_NAME as name,
|
||||||
|
c.DATA_TYPE as type,
|
||||||
|
c.IS_NULLABLE as nullable,
|
||||||
|
c.COLUMN_DEFAULT as default_value,
|
||||||
|
c.CHARACTER_MAXIMUM_LENGTH as max_length,
|
||||||
|
c.NUMERIC_PRECISION as precision,
|
||||||
|
c.NUMERIC_SCALE as scale,
|
||||||
|
CAST(p.value AS NVARCHAR(MAX)) as description
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS c
|
||||||
|
LEFT JOIN sys.columns sc
|
||||||
|
ON sc.object_id = OBJECT_ID(@tableName)
|
||||||
|
AND sc.name = c.COLUMN_NAME
|
||||||
|
LEFT JOIN sys.extended_properties p
|
||||||
|
ON p.major_id = sc.object_id
|
||||||
|
AND p.minor_id = sc.column_id
|
||||||
|
AND p.name = 'MS_Description'
|
||||||
|
WHERE c.TABLE_NAME = @tableName
|
||||||
|
ORDER BY c.ORDINAL_POSITION
|
||||||
|
`);
|
||||||
|
|
||||||
|
return result.recordset;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`컬럼 정보 조회 오류: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||||
|
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||||
|
// @ts-ignore
|
||||||
|
import * as mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
export class MariaDBConnector implements DatabaseConnector {
|
||||||
|
private connection: mysql.Connection | null = null;
|
||||||
|
private config: ConnectionConfig;
|
||||||
|
|
||||||
|
constructor(config: ConnectionConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (!this.connection) {
|
||||||
|
this.connection = await mysql.createConnection({
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port,
|
||||||
|
user: this.config.user,
|
||||||
|
password: this.config.password,
|
||||||
|
database: this.config.database,
|
||||||
|
connectTimeout: this.config.connectionTimeoutMillis,
|
||||||
|
ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.end();
|
||||||
|
this.connection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(): Promise<ConnectionTestResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
const [rows] = await this.connection!.query("SELECT VERSION() as version");
|
||||||
|
const version = (rows as any[])[0]?.version || "Unknown";
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
await this.disconnect();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "MariaDB/MySQL 연결이 성공했습니다.",
|
||||||
|
details: {
|
||||||
|
response_time: responseTime,
|
||||||
|
server_version: version,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
await this.disconnect();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "MariaDB/MySQL 연결에 실패했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "CONNECTION_FAILED",
|
||||||
|
details: error.message || "알 수 없는 오류",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeQuery(query: string): Promise<QueryResult> {
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
const [rows, fields] = await this.connection!.query(query);
|
||||||
|
await this.disconnect();
|
||||||
|
return {
|
||||||
|
rows: rows as any[],
|
||||||
|
fields: fields as any[],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
await this.disconnect();
|
||||||
|
throw new Error(`쿼리 실행 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTables(): Promise<TableInfo[]> {
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
const [rows] = await this.connection!.query(`
|
||||||
|
SELECT
|
||||||
|
TABLE_NAME as table_name,
|
||||||
|
TABLE_COMMENT as description
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
ORDER BY TABLE_NAME;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const tables: TableInfo[] = [];
|
||||||
|
for (const row of rows as any[]) {
|
||||||
|
const columns = await this.getColumns(row.table_name);
|
||||||
|
tables.push({
|
||||||
|
table_name: row.table_name,
|
||||||
|
description: row.description || null,
|
||||||
|
columns: columns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.disconnect();
|
||||||
|
return tables;
|
||||||
|
} catch (error: any) {
|
||||||
|
await this.disconnect();
|
||||||
|
throw new Error(`테이블 목록 조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getColumns(tableName: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`);
|
||||||
|
await this.connect();
|
||||||
|
console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`);
|
||||||
|
|
||||||
|
const [rows] = await this.connection!.query(`
|
||||||
|
SELECT
|
||||||
|
COLUMN_NAME as column_name,
|
||||||
|
DATA_TYPE as data_type,
|
||||||
|
IS_NULLABLE as is_nullable,
|
||||||
|
COLUMN_DEFAULT as column_default
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||||
|
ORDER BY ORDINAL_POSITION;
|
||||||
|
`, [tableName]);
|
||||||
|
|
||||||
|
console.log(`[MariaDBConnector] 쿼리 결과:`, rows);
|
||||||
|
console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array');
|
||||||
|
|
||||||
|
await this.disconnect();
|
||||||
|
return rows as any[];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[MariaDBConnector] getColumns 오류:`, error);
|
||||||
|
await this.disconnect();
|
||||||
|
throw new Error(`컬럼 정보 조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
// @ts-ignore
|
||||||
|
import * as oracledb from 'oracledb';
|
||||||
|
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||||
|
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||||
|
|
||||||
|
export class OracleConnector implements DatabaseConnector {
|
||||||
|
private connection: oracledb.Connection | null = null;
|
||||||
|
private config: ConnectionConfig;
|
||||||
|
|
||||||
|
constructor(config: ConnectionConfig) {
|
||||||
|
this.config = config;
|
||||||
|
|
||||||
|
// Oracle XE 21c 특화 설정
|
||||||
|
// oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT;
|
||||||
|
// oracledb.autoCommit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Oracle XE 21c 연결 문자열 구성
|
||||||
|
const connectionString = this.buildConnectionString();
|
||||||
|
|
||||||
|
const connectionConfig: any = {
|
||||||
|
user: this.config.user,
|
||||||
|
password: this.config.password,
|
||||||
|
connectString: connectionString
|
||||||
|
};
|
||||||
|
|
||||||
|
this.connection = await oracledb.getConnection(connectionConfig);
|
||||||
|
console.log('Oracle XE 21c 연결 성공');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Oracle XE 21c 연결 실패:', error);
|
||||||
|
throw new Error(`Oracle 연결 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildConnectionString(): string {
|
||||||
|
const { host, port, database } = this.config;
|
||||||
|
|
||||||
|
// Oracle XE 21c는 기본적으로 XE 서비스명을 사용
|
||||||
|
// 다양한 연결 문자열 형식 지원
|
||||||
|
if (database.includes('/') || database.includes(':')) {
|
||||||
|
// 이미 완전한 연결 문자열인 경우
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oracle XE 21c 표준 형식
|
||||||
|
return `${host}:${port}/${database}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.connection) {
|
||||||
|
try {
|
||||||
|
await this.connection.close();
|
||||||
|
this.connection = null;
|
||||||
|
console.log('Oracle 연결 해제됨');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Oracle 연결 해제 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(): Promise<ConnectionTestResult> {
|
||||||
|
try {
|
||||||
|
if (!this.connection) {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oracle XE 21c 버전 확인 쿼리
|
||||||
|
const result = await this.connection!.execute(
|
||||||
|
'SELECT BANNER FROM V$VERSION WHERE BANNER LIKE \'Oracle%\''
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Oracle 버전:', result.rows);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '연결 성공',
|
||||||
|
details: {
|
||||||
|
server_version: (result.rows as any)?.[0]?.BANNER || 'Unknown'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Oracle 연결 테스트 실패:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '연결 실패',
|
||||||
|
details: {
|
||||||
|
server_version: error.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeQuery(query: string, params: any[] = []): Promise<QueryResult> {
|
||||||
|
if (!this.connection) {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Oracle XE 21c 쿼리 실행 옵션
|
||||||
|
const options: any = {
|
||||||
|
outFormat: oracledb.OUT_FORMAT_OBJECT, // OBJECT format
|
||||||
|
maxRows: 10000, // XE 제한 고려
|
||||||
|
fetchArraySize: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.connection!.execute(query, params, options);
|
||||||
|
const executionTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
console.log('Oracle 쿼리 실행 결과:', {
|
||||||
|
query,
|
||||||
|
rowCount: result.rows?.length || 0,
|
||||||
|
metaData: result.metaData?.length || 0,
|
||||||
|
executionTime: `${executionTime}ms`,
|
||||||
|
actualRows: result.rows,
|
||||||
|
metaDataInfo: result.metaData
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: result.rows || [],
|
||||||
|
rowCount: result.rowsAffected || (result.rows?.length || 0),
|
||||||
|
fields: this.extractFieldInfo(result.metaData || [])
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Oracle 쿼리 실행 실패:', error);
|
||||||
|
throw new Error(`쿼리 실행 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFieldInfo(metaData: any[]): any[] {
|
||||||
|
return metaData.map(field => ({
|
||||||
|
name: field.name,
|
||||||
|
type: this.mapOracleType(field.dbType),
|
||||||
|
length: field.precision || field.byteSize,
|
||||||
|
nullable: field.nullable
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapOracleType(oracleType: any): string {
|
||||||
|
// Oracle XE 21c 타입 매핑 (간단한 방식)
|
||||||
|
if (typeof oracleType === 'string') {
|
||||||
|
return oracleType;
|
||||||
|
}
|
||||||
|
return 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTables(): Promise<TableInfo[]> {
|
||||||
|
try {
|
||||||
|
// 현재 사용자 스키마의 테이블들만 조회
|
||||||
|
const query = `
|
||||||
|
SELECT table_name, USER as owner
|
||||||
|
FROM user_tables
|
||||||
|
ORDER BY table_name
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('Oracle 테이블 조회 시작 - 사용자:', this.config.user);
|
||||||
|
|
||||||
|
const result = await this.executeQuery(query);
|
||||||
|
console.log('사용자 스키마 테이블 조회 결과:', result.rows);
|
||||||
|
|
||||||
|
const tables = result.rows.map((row: any) => ({
|
||||||
|
table_name: row.TABLE_NAME,
|
||||||
|
columns: [],
|
||||||
|
description: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`총 ${tables.length}개의 사용자 테이블을 찾았습니다.`);
|
||||||
|
return tables;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Oracle 테이블 목록 조회 실패:', error);
|
||||||
|
throw new Error(`테이블 목록 조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getColumns(tableName: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
console.log(`[OracleConnector] getColumns 호출: tableName=${tableName}`);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
column_name,
|
||||||
|
data_type,
|
||||||
|
data_length,
|
||||||
|
data_precision,
|
||||||
|
data_scale,
|
||||||
|
nullable,
|
||||||
|
data_default
|
||||||
|
FROM user_tab_columns
|
||||||
|
WHERE table_name = UPPER(:tableName)
|
||||||
|
ORDER BY column_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`[OracleConnector] 쿼리 실행 시작: ${query}`);
|
||||||
|
const result = await this.executeQuery(query, [tableName]);
|
||||||
|
|
||||||
|
console.log(`[OracleConnector] 쿼리 결과:`, result.rows);
|
||||||
|
console.log(`[OracleConnector] 결과 개수:`, result.rows ? result.rows.length : 'null/undefined');
|
||||||
|
|
||||||
|
const mappedResult = result.rows.map((row: any) => ({
|
||||||
|
column_name: row.COLUMN_NAME,
|
||||||
|
data_type: this.formatOracleDataType(row),
|
||||||
|
is_nullable: row.NULLABLE === 'Y' ? 'YES' : 'NO',
|
||||||
|
column_default: row.DATA_DEFAULT
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`[OracleConnector] 매핑된 결과:`, mappedResult);
|
||||||
|
return mappedResult;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[OracleConnector] getColumns 오류:', error);
|
||||||
|
throw new Error(`테이블 컬럼 조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatOracleDataType(row: any): string {
|
||||||
|
const { DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE } = row;
|
||||||
|
|
||||||
|
switch (DATA_TYPE) {
|
||||||
|
case 'NUMBER':
|
||||||
|
if (DATA_PRECISION && DATA_SCALE !== null) {
|
||||||
|
return `NUMBER(${DATA_PRECISION},${DATA_SCALE})`;
|
||||||
|
} else if (DATA_PRECISION) {
|
||||||
|
return `NUMBER(${DATA_PRECISION})`;
|
||||||
|
}
|
||||||
|
return 'NUMBER';
|
||||||
|
case 'VARCHAR2':
|
||||||
|
case 'CHAR':
|
||||||
|
return `${DATA_TYPE}(${DATA_LENGTH})`;
|
||||||
|
default:
|
||||||
|
return DATA_TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
// 배치 실행 로그 라우트
|
||||||
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
import { BatchExecutionLogController } from "../controllers/batchExecutionLogController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-execution-logs
|
||||||
|
* 배치 실행 로그 목록 조회
|
||||||
|
*/
|
||||||
|
router.get("/", authenticateToken, BatchExecutionLogController.getExecutionLogs);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/batch-execution-logs
|
||||||
|
* 배치 실행 로그 생성
|
||||||
|
*/
|
||||||
|
router.post("/", authenticateToken, BatchExecutionLogController.createExecutionLog);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/batch-execution-logs/:id
|
||||||
|
* 배치 실행 로그 업데이트
|
||||||
|
*/
|
||||||
|
router.put("/:id", authenticateToken, BatchExecutionLogController.updateExecutionLog);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/batch-execution-logs/:id
|
||||||
|
* 배치 실행 로그 삭제
|
||||||
|
*/
|
||||||
|
router.delete("/:id", authenticateToken, BatchExecutionLogController.deleteExecutionLog);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-execution-logs/latest/:batchConfigId
|
||||||
|
* 특정 배치의 최신 실행 로그 조회
|
||||||
|
*/
|
||||||
|
router.get("/latest/:batchConfigId", authenticateToken, BatchExecutionLogController.getLatestExecutionLog);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-execution-logs/stats
|
||||||
|
* 배치 실행 통계 조회
|
||||||
|
*/
|
||||||
|
router.get("/stats", authenticateToken, BatchExecutionLogController.getExecutionStats);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
// 배치관리 전용 라우트 (기존 소스와 완전 분리)
|
||||||
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
import { BatchManagementController } from "../controllers/batchManagementController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-management/connections
|
||||||
|
* 사용 가능한 커넥션 목록 조회
|
||||||
|
*/
|
||||||
|
router.get("/connections", BatchManagementController.getAvailableConnections);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-management/connections/:type/tables
|
||||||
|
* 내부 DB 테이블 목록 조회
|
||||||
|
*/
|
||||||
|
router.get("/connections/:type/tables", authenticateToken, BatchManagementController.getTablesFromConnection);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-management/connections/:type/:id/tables
|
||||||
|
* 외부 DB 테이블 목록 조회
|
||||||
|
*/
|
||||||
|
router.get("/connections/:type/:id/tables", authenticateToken, BatchManagementController.getTablesFromConnection);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-management/connections/:type/tables/:tableName/columns
|
||||||
|
* 내부 DB 테이블 컬럼 정보 조회
|
||||||
|
*/
|
||||||
|
router.get("/connections/:type/tables/:tableName/columns", authenticateToken, BatchManagementController.getTableColumns);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-management/connections/:type/:id/tables/:tableName/columns
|
||||||
|
* 외부 DB 테이블 컬럼 정보 조회
|
||||||
|
*/
|
||||||
|
router.get("/connections/:type/:id/tables/:tableName/columns", authenticateToken, BatchManagementController.getTableColumns);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/batch-management/batch-configs
|
||||||
|
* 배치 설정 생성
|
||||||
|
*/
|
||||||
|
router.post("/batch-configs", authenticateToken, BatchManagementController.createBatchConfig);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-management/batch-configs
|
||||||
|
* 배치 설정 목록 조회
|
||||||
|
*/
|
||||||
|
router.get("/batch-configs", authenticateToken, BatchManagementController.getBatchConfigs);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/batch-management/batch-configs/:id
|
||||||
|
* 배치 설정 업데이트
|
||||||
|
*/
|
||||||
|
router.put("/batch-configs/:id", authenticateToken, BatchManagementController.updateBatchConfig);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/batch-management/batch-configs/:id/execute
|
||||||
|
* 배치 수동 실행
|
||||||
|
*/
|
||||||
|
router.post("/batch-configs/:id/execute", authenticateToken, BatchManagementController.executeBatchConfig);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { BatchController } from "../controllers/batchController";
|
import { BatchController } from "../controllers/batchController";
|
||||||
import { authenticateToken } from "../middleware/auth";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ router.get("/", authenticateToken, BatchController.getBatchConfigs);
|
||||||
* GET /api/batch-configs/connections
|
* GET /api/batch-configs/connections
|
||||||
* 사용 가능한 커넥션 목록 조회
|
* 사용 가능한 커넥션 목록 조회
|
||||||
*/
|
*/
|
||||||
router.get("/connections", authenticateToken, BatchController.getAvailableConnections);
|
router.get("/connections", BatchController.getAvailableConnections);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/batch-configs/connections/:type/tables
|
* GET /api/batch-configs/connections/:type/tables
|
||||||
|
|
@ -67,4 +67,4 @@ router.put("/:id", authenticateToken, BatchController.updateBatchConfig);
|
||||||
*/
|
*/
|
||||||
router.delete("/:id", authenticateToken, BatchController.deleteBatchConfig);
|
router.delete("/:id", authenticateToken, BatchController.deleteBatchConfig);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
// 배치 실행 로그 서비스
|
||||||
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import {
|
||||||
|
BatchExecutionLog,
|
||||||
|
CreateBatchExecutionLogRequest,
|
||||||
|
UpdateBatchExecutionLogRequest,
|
||||||
|
BatchExecutionLogFilter,
|
||||||
|
BatchExecutionLogWithConfig
|
||||||
|
} from "../types/batchExecutionLogTypes";
|
||||||
|
import { ApiResponse } from "../types/batchTypes";
|
||||||
|
|
||||||
|
export class BatchExecutionLogService {
|
||||||
|
/**
|
||||||
|
* 배치 실행 로그 목록 조회
|
||||||
|
*/
|
||||||
|
static async getExecutionLogs(
|
||||||
|
filter: BatchExecutionLogFilter = {}
|
||||||
|
): Promise<ApiResponse<BatchExecutionLogWithConfig[]>> {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
batch_config_id,
|
||||||
|
execution_status,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
page = 1,
|
||||||
|
limit = 50
|
||||||
|
} = filter;
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const take = limit;
|
||||||
|
|
||||||
|
// WHERE 조건 구성
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (batch_config_id) {
|
||||||
|
where.batch_config_id = batch_config_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (execution_status) {
|
||||||
|
where.execution_status = execution_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start_date || end_date) {
|
||||||
|
where.start_time = {};
|
||||||
|
if (start_date) {
|
||||||
|
where.start_time.gte = start_date;
|
||||||
|
}
|
||||||
|
if (end_date) {
|
||||||
|
where.start_time.lte = end_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그 조회
|
||||||
|
const [logs, total] = await Promise.all([
|
||||||
|
prisma.batch_execution_logs.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
batch_config: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
batch_name: true,
|
||||||
|
description: true,
|
||||||
|
cron_schedule: true,
|
||||||
|
is_active: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { start_time: 'desc' },
|
||||||
|
skip,
|
||||||
|
take
|
||||||
|
}),
|
||||||
|
prisma.batch_execution_logs.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: logs as BatchExecutionLogWithConfig[],
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 로그 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 실행 로그 생성
|
||||||
|
*/
|
||||||
|
static async createExecutionLog(
|
||||||
|
data: CreateBatchExecutionLogRequest
|
||||||
|
): Promise<ApiResponse<BatchExecutionLog>> {
|
||||||
|
try {
|
||||||
|
const log = await prisma.batch_execution_logs.create({
|
||||||
|
data: {
|
||||||
|
batch_config_id: data.batch_config_id,
|
||||||
|
execution_status: data.execution_status,
|
||||||
|
start_time: data.start_time || new Date(),
|
||||||
|
end_time: data.end_time,
|
||||||
|
duration_ms: data.duration_ms,
|
||||||
|
total_records: data.total_records || 0,
|
||||||
|
success_records: data.success_records || 0,
|
||||||
|
failed_records: data.failed_records || 0,
|
||||||
|
error_message: data.error_message,
|
||||||
|
error_details: data.error_details,
|
||||||
|
server_name: data.server_name || process.env.HOSTNAME || 'unknown',
|
||||||
|
process_id: data.process_id || process.pid?.toString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: log as BatchExecutionLog,
|
||||||
|
message: "배치 실행 로그가 생성되었습니다."
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 로그 생성 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 실행 로그 업데이트
|
||||||
|
*/
|
||||||
|
static async updateExecutionLog(
|
||||||
|
id: number,
|
||||||
|
data: UpdateBatchExecutionLogRequest
|
||||||
|
): Promise<ApiResponse<BatchExecutionLog>> {
|
||||||
|
try {
|
||||||
|
const log = await prisma.batch_execution_logs.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
execution_status: data.execution_status,
|
||||||
|
end_time: data.end_time,
|
||||||
|
duration_ms: data.duration_ms,
|
||||||
|
total_records: data.total_records,
|
||||||
|
success_records: data.success_records,
|
||||||
|
failed_records: data.failed_records,
|
||||||
|
error_message: data.error_message,
|
||||||
|
error_details: data.error_details
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: log as BatchExecutionLog,
|
||||||
|
message: "배치 실행 로그가 업데이트되었습니다."
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 로그 업데이트 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 실행 로그 삭제
|
||||||
|
*/
|
||||||
|
static async deleteExecutionLog(id: number): Promise<ApiResponse<void>> {
|
||||||
|
try {
|
||||||
|
await prisma.batch_execution_logs.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "배치 실행 로그가 삭제되었습니다."
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 로그 삭제 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 배치의 최신 실행 로그 조회
|
||||||
|
*/
|
||||||
|
static async getLatestExecutionLog(
|
||||||
|
batchConfigId: number
|
||||||
|
): Promise<ApiResponse<BatchExecutionLog | null>> {
|
||||||
|
try {
|
||||||
|
const log = await prisma.batch_execution_logs.findFirst({
|
||||||
|
where: { batch_config_id: batchConfigId },
|
||||||
|
orderBy: { start_time: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: log as BatchExecutionLog | null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("최신 배치 실행 로그 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 실행 통계 조회
|
||||||
|
*/
|
||||||
|
static async getExecutionStats(
|
||||||
|
batchConfigId?: number,
|
||||||
|
startDate?: Date,
|
||||||
|
endDate?: Date
|
||||||
|
): Promise<ApiResponse<{
|
||||||
|
total_executions: number;
|
||||||
|
success_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
success_rate: number;
|
||||||
|
average_duration_ms: number;
|
||||||
|
total_records_processed: number;
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (batchConfigId) {
|
||||||
|
where.batch_config_id = batchConfigId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
where.start_time = {};
|
||||||
|
if (startDate) {
|
||||||
|
where.start_time.gte = startDate;
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
where.start_time.lte = endDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await prisma.batch_execution_logs.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
execution_status: true,
|
||||||
|
duration_ms: true,
|
||||||
|
total_records: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const total_executions = logs.length;
|
||||||
|
const success_count = logs.filter((log: any) => log.execution_status === 'SUCCESS').length;
|
||||||
|
const failed_count = logs.filter((log: any) => log.execution_status === 'FAILED').length;
|
||||||
|
const success_rate = total_executions > 0 ? (success_count / total_executions) * 100 : 0;
|
||||||
|
|
||||||
|
const validDurations = logs
|
||||||
|
.filter((log: any) => log.duration_ms !== null)
|
||||||
|
.map((log: any) => log.duration_ms!);
|
||||||
|
const average_duration_ms = validDurations.length > 0
|
||||||
|
? validDurations.reduce((sum: number, duration: number) => sum + duration, 0) / validDurations.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const total_records_processed = logs
|
||||||
|
.filter((log: any) => log.total_records !== null)
|
||||||
|
.reduce((sum: number, log: any) => sum + (log.total_records || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
total_executions,
|
||||||
|
success_count,
|
||||||
|
failed_count,
|
||||||
|
success_rate,
|
||||||
|
average_duration_ms,
|
||||||
|
total_records_processed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 통계 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,686 @@
|
||||||
|
// 배치관리 전용 외부 DB 서비스
|
||||||
|
// 기존 ExternalDbConnectionService와 분리하여 배치관리 시스템에 특화된 기능 제공
|
||||||
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||||
|
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||||
|
import { ApiResponse, ColumnInfo, TableInfo } from "../types/batchTypes";
|
||||||
|
|
||||||
|
export class BatchExternalDbService {
|
||||||
|
/**
|
||||||
|
* 배치관리용 외부 DB 연결 목록 조회
|
||||||
|
*/
|
||||||
|
static async getAvailableConnections(): Promise<ApiResponse<Array<{
|
||||||
|
type: 'internal' | 'external';
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
db_type?: string;
|
||||||
|
}>>> {
|
||||||
|
try {
|
||||||
|
const connections: Array<{
|
||||||
|
type: 'internal' | 'external';
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
db_type?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 내부 DB 추가
|
||||||
|
connections.push({
|
||||||
|
type: 'internal',
|
||||||
|
name: '내부 데이터베이스 (PostgreSQL)',
|
||||||
|
db_type: 'postgresql'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 활성화된 외부 DB 연결 조회
|
||||||
|
const externalConnections = await prisma.external_db_connections.findMany({
|
||||||
|
where: { is_active: 'Y' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
connection_name: true,
|
||||||
|
db_type: true,
|
||||||
|
description: true
|
||||||
|
},
|
||||||
|
orderBy: { connection_name: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 외부 DB 연결 추가
|
||||||
|
externalConnections.forEach(conn => {
|
||||||
|
connections.push({
|
||||||
|
type: 'external',
|
||||||
|
id: conn.id,
|
||||||
|
name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`,
|
||||||
|
db_type: conn.db_type || undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: connections,
|
||||||
|
message: `${connections.length}개의 연결을 조회했습니다.`
|
||||||
|
};
|
||||||
|
} 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<TableInfo[]>> {
|
||||||
|
try {
|
||||||
|
let tables: TableInfo[] = [];
|
||||||
|
|
||||||
|
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 => ({
|
||||||
|
table_name: row.table_name,
|
||||||
|
columns: []
|
||||||
|
}));
|
||||||
|
} else if (connectionType === 'external' && connectionId) {
|
||||||
|
// 외부 DB 테이블 조회
|
||||||
|
const tablesResult = await this.getExternalTables(connectionId);
|
||||||
|
if (tablesResult.success && tablesResult.data) {
|
||||||
|
tables = tablesResult.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: tables,
|
||||||
|
message: `${tables.length}개의 테이블을 조회했습니다.`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치관리 테이블 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치관리용 테이블 컬럼 정보 조회
|
||||||
|
*/
|
||||||
|
static async getTableColumns(
|
||||||
|
connectionType: 'internal' | 'external',
|
||||||
|
connectionId: number | undefined,
|
||||||
|
tableName: string
|
||||||
|
): Promise<ApiResponse<ColumnInfo[]>> {
|
||||||
|
try {
|
||||||
|
console.log(`[BatchExternalDbService] getTableColumns 호출:`, {
|
||||||
|
connectionType,
|
||||||
|
connectionId,
|
||||||
|
tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
let columns: ColumnInfo[] = [];
|
||||||
|
|
||||||
|
if (connectionType === 'internal') {
|
||||||
|
// 내부 DB 컬럼 조회
|
||||||
|
console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 시작: ${tableName}`);
|
||||||
|
|
||||||
|
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
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 결과:`, result);
|
||||||
|
|
||||||
|
columns = result.map(row => ({
|
||||||
|
column_name: row.column_name,
|
||||||
|
data_type: row.data_type,
|
||||||
|
is_nullable: row.is_nullable,
|
||||||
|
column_default: row.column_default,
|
||||||
|
}));
|
||||||
|
} else if (connectionType === 'external' && connectionId) {
|
||||||
|
// 외부 DB 컬럼 조회
|
||||||
|
console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`);
|
||||||
|
|
||||||
|
const columnsResult = await this.getExternalTableColumns(connectionId, tableName);
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 결과:`, columnsResult);
|
||||||
|
|
||||||
|
if (columnsResult.success && columnsResult.data) {
|
||||||
|
columns = columnsResult.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 최종 컬럼 목록:`, columns);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: columns,
|
||||||
|
message: `${columns.length}개의 컬럼을 조회했습니다.`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BatchExternalDbService] 컬럼 정보 조회 오류:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 DB 테이블 목록 조회 (내부 구현)
|
||||||
|
*/
|
||||||
|
private static async getExternalTables(connectionId: number): Promise<ApiResponse<TableInfo[]>> {
|
||||||
|
try {
|
||||||
|
// 연결 정보 조회
|
||||||
|
const connection = await prisma.external_db_connections.findUnique({
|
||||||
|
where: { id: connectionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "연결 정보를 찾을 수 없습니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||||
|
if (!decryptedPassword) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "비밀번호 복호화에 실패했습니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결 설정 준비
|
||||||
|
const config = {
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
database: connection.database_name,
|
||||||
|
user: connection.username,
|
||||||
|
password: decryptedPassword,
|
||||||
|
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||||
|
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||||
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
|
};
|
||||||
|
|
||||||
|
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
||||||
|
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||||
|
const tables = await connector.getTables();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "테이블 목록을 조회했습니다.",
|
||||||
|
data: tables
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 DB 테이블 컬럼 정보 조회 (내부 구현)
|
||||||
|
*/
|
||||||
|
private static async getExternalTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<ColumnInfo[]>> {
|
||||||
|
try {
|
||||||
|
console.log(`[BatchExternalDbService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`);
|
||||||
|
|
||||||
|
// 연결 정보 조회
|
||||||
|
const connection = await prisma.external_db_connections.findUnique({
|
||||||
|
where: { id: connectionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
console.log(`[BatchExternalDbService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "연결 정보를 찾을 수 없습니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 연결 정보 조회 성공:`, {
|
||||||
|
id: connection.id,
|
||||||
|
connection_name: connection.connection_name,
|
||||||
|
db_type: connection.db_type,
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
database_name: connection.database_name
|
||||||
|
});
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||||
|
|
||||||
|
// 연결 설정 준비
|
||||||
|
const config = {
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
database: connection.database_name,
|
||||||
|
user: connection.username,
|
||||||
|
password: decryptedPassword,
|
||||||
|
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||||
|
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||||
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 커넥터 생성 시작: db_type=${connection.db_type}`);
|
||||||
|
|
||||||
|
// 데이터베이스 타입에 따른 커넥터 생성
|
||||||
|
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`);
|
||||||
|
|
||||||
|
// 컬럼 정보 조회
|
||||||
|
console.log(`[BatchExternalDbService] connector.getColumns 호출 전`);
|
||||||
|
const columns = await connector.getColumns(tableName);
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 원본 컬럼 조회 결과:`, columns);
|
||||||
|
console.log(`[BatchExternalDbService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined');
|
||||||
|
|
||||||
|
// 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환
|
||||||
|
const standardizedColumns: ColumnInfo[] = columns.map((col: any) => {
|
||||||
|
console.log(`[BatchExternalDbService] 컬럼 변환 중:`, col);
|
||||||
|
|
||||||
|
// MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만)
|
||||||
|
if (col.name && col.dataType !== undefined) {
|
||||||
|
const result = {
|
||||||
|
column_name: col.name,
|
||||||
|
data_type: col.dataType,
|
||||||
|
is_nullable: col.isNullable ? 'YES' : 'NO',
|
||||||
|
column_default: col.defaultValue || null,
|
||||||
|
};
|
||||||
|
console.log(`[BatchExternalDbService] MySQL/MariaDB 구조로 변환:`, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default}
|
||||||
|
else {
|
||||||
|
const result = {
|
||||||
|
column_name: col.column_name || col.COLUMN_NAME,
|
||||||
|
data_type: col.data_type || col.DATA_TYPE,
|
||||||
|
is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'),
|
||||||
|
column_default: col.column_default || col.COLUMN_DEFAULT || null,
|
||||||
|
};
|
||||||
|
console.log(`[BatchExternalDbService] 표준 구조로 변환:`, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 표준화된 컬럼 목록:`, standardizedColumns);
|
||||||
|
|
||||||
|
// 빈 배열인 경우 경고 로그
|
||||||
|
if (!standardizedColumns || standardizedColumns.length === 0) {
|
||||||
|
console.warn(`[BatchExternalDbService] 컬럼이 비어있음: connectionId=${connectionId}, tableName=${tableName}`);
|
||||||
|
console.warn(`[BatchExternalDbService] 연결 정보:`, {
|
||||||
|
db_type: connection.db_type,
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
database_name: connection.database_name,
|
||||||
|
username: connection.username
|
||||||
|
});
|
||||||
|
|
||||||
|
// 테이블 존재 여부 확인
|
||||||
|
console.warn(`[BatchExternalDbService] 테이블 존재 여부 확인을 위해 테이블 목록 조회 시도`);
|
||||||
|
try {
|
||||||
|
const tables = await connector.getTables();
|
||||||
|
console.warn(`[BatchExternalDbService] 사용 가능한 테이블 목록:`, tables.map(t => t.table_name));
|
||||||
|
|
||||||
|
// 테이블명이 정확한지 확인
|
||||||
|
const tableExists = tables.some(t => t.table_name.toLowerCase() === tableName.toLowerCase());
|
||||||
|
console.warn(`[BatchExternalDbService] 테이블 존재 여부: ${tableExists}`);
|
||||||
|
|
||||||
|
// 정확한 테이블명 찾기
|
||||||
|
const exactTable = tables.find(t => t.table_name.toLowerCase() === tableName.toLowerCase());
|
||||||
|
if (exactTable) {
|
||||||
|
console.warn(`[BatchExternalDbService] 정확한 테이블명: ${exactTable.table_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 테이블명 출력
|
||||||
|
console.warn(`[BatchExternalDbService] 모든 테이블명:`, tables.map(t => `"${t.table_name}"`));
|
||||||
|
|
||||||
|
// 테이블명 비교
|
||||||
|
console.warn(`[BatchExternalDbService] 요청된 테이블명: "${tableName}"`);
|
||||||
|
console.warn(`[BatchExternalDbService] 테이블명 비교 결과:`, tables.map(t => ({
|
||||||
|
table_name: t.table_name,
|
||||||
|
matches: t.table_name.toLowerCase() === tableName.toLowerCase(),
|
||||||
|
exact_match: t.table_name === tableName
|
||||||
|
})));
|
||||||
|
|
||||||
|
// 정확한 테이블명으로 다시 시도
|
||||||
|
if (exactTable && exactTable.table_name !== tableName) {
|
||||||
|
console.warn(`[BatchExternalDbService] 정확한 테이블명으로 다시 시도: ${exactTable.table_name}`);
|
||||||
|
try {
|
||||||
|
const correctColumns = await connector.getColumns(exactTable.table_name);
|
||||||
|
console.warn(`[BatchExternalDbService] 정확한 테이블명으로 조회한 컬럼:`, correctColumns);
|
||||||
|
} catch (correctError) {
|
||||||
|
console.error(`[BatchExternalDbService] 정확한 테이블명으로 조회 실패:`, correctError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (tableError) {
|
||||||
|
console.error(`[BatchExternalDbService] 테이블 목록 조회 실패:`, tableError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: standardizedColumns,
|
||||||
|
message: "컬럼 정보를 조회했습니다."
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BatchExternalDbService] 외부 DB 컬럼 정보 조회 오류:", error);
|
||||||
|
console.error("[BatchExternalDbService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 DB 테이블에서 데이터 조회
|
||||||
|
*/
|
||||||
|
static async getDataFromTable(
|
||||||
|
connectionId: number,
|
||||||
|
tableName: string,
|
||||||
|
limit: number = 100
|
||||||
|
): Promise<ApiResponse<any[]>> {
|
||||||
|
try {
|
||||||
|
console.log(`[BatchExternalDbService] 외부 DB 데이터 조회: connectionId=${connectionId}, tableName=${tableName}`);
|
||||||
|
|
||||||
|
// 외부 DB 연결 정보 조회
|
||||||
|
const connection = await prisma.external_db_connections.findUnique({
|
||||||
|
where: { id: connectionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 연결을 찾을 수 없습니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패스워드 복호화
|
||||||
|
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||||
|
|
||||||
|
// DB 연결 설정
|
||||||
|
const config = {
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
user: connection.username,
|
||||||
|
password: decryptedPassword,
|
||||||
|
database: connection.database_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// DB 커넥터 생성
|
||||||
|
const connector = await DatabaseConnectorFactory.createConnector(
|
||||||
|
connection.db_type || 'postgresql',
|
||||||
|
config,
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 데이터 조회 (DB 타입에 따라 쿼리 구문 변경)
|
||||||
|
let query: string;
|
||||||
|
const dbType = connection.db_type?.toLowerCase() || 'postgresql';
|
||||||
|
|
||||||
|
if (dbType === 'oracle') {
|
||||||
|
query = `SELECT * FROM ${tableName} WHERE ROWNUM <= ${limit}`;
|
||||||
|
} else {
|
||||||
|
query = `SELECT * FROM ${tableName} LIMIT ${limit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`);
|
||||||
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 외부 DB 데이터 조회 완료: ${result.rows.length}개 레코드`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result.rows
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`외부 DB 데이터 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 데이터 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 DB 테이블에서 특정 컬럼들만 조회
|
||||||
|
*/
|
||||||
|
static async getDataFromTableWithColumns(
|
||||||
|
connectionId: number,
|
||||||
|
tableName: string,
|
||||||
|
columns: string[],
|
||||||
|
limit: number = 100
|
||||||
|
): Promise<ApiResponse<any[]>> {
|
||||||
|
try {
|
||||||
|
console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회: connectionId=${connectionId}, tableName=${tableName}, columns=[${columns.join(', ')}]`);
|
||||||
|
|
||||||
|
// 외부 DB 연결 정보 조회
|
||||||
|
const connection = await prisma.external_db_connections.findUnique({
|
||||||
|
where: { id: connectionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 연결을 찾을 수 없습니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패스워드 복호화
|
||||||
|
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||||
|
|
||||||
|
// DB 연결 설정
|
||||||
|
const config = {
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
user: connection.username,
|
||||||
|
password: decryptedPassword,
|
||||||
|
database: connection.database_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// DB 커넥터 생성
|
||||||
|
const connector = await DatabaseConnectorFactory.createConnector(
|
||||||
|
connection.db_type || 'postgresql',
|
||||||
|
config,
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 데이터 조회 (DB 타입에 따라 쿼리 구문 변경)
|
||||||
|
let query: string;
|
||||||
|
const dbType = connection.db_type?.toLowerCase() || 'postgresql';
|
||||||
|
const columnList = columns.join(', ');
|
||||||
|
|
||||||
|
if (dbType === 'oracle') {
|
||||||
|
query = `SELECT ${columnList} FROM ${tableName} WHERE ROWNUM <= ${limit}`;
|
||||||
|
} else {
|
||||||
|
query = `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`);
|
||||||
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회 완료: ${result.rows.length}개 레코드`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result.rows
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`외부 DB 특정 컬럼 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 특정 컬럼 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 DB 테이블에 데이터 삽입
|
||||||
|
*/
|
||||||
|
static async insertDataToTable(
|
||||||
|
connectionId: number,
|
||||||
|
tableName: string,
|
||||||
|
data: any[]
|
||||||
|
): Promise<ApiResponse<{ successCount: number; failedCount: number }>> {
|
||||||
|
try {
|
||||||
|
console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드`);
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { successCount: 0, failedCount: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 DB 연결 정보 조회
|
||||||
|
const connection = await prisma.external_db_connections.findUnique({
|
||||||
|
where: { id: connectionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 연결을 찾을 수 없습니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패스워드 복호화
|
||||||
|
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||||
|
|
||||||
|
// DB 연결 설정
|
||||||
|
const config = {
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
user: connection.username,
|
||||||
|
password: decryptedPassword,
|
||||||
|
database: connection.database_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// DB 커넥터 생성
|
||||||
|
const connector = await DatabaseConnectorFactory.createConnector(
|
||||||
|
connection.db_type || 'postgresql',
|
||||||
|
config,
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리)
|
||||||
|
for (const record of data) {
|
||||||
|
try {
|
||||||
|
const columns = Object.keys(record);
|
||||||
|
const values = Object.values(record);
|
||||||
|
|
||||||
|
// 값들을 SQL 문자열로 변환 (타입별 처리)
|
||||||
|
const formattedValues = values.map(value => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return 'NULL';
|
||||||
|
} else if (value instanceof Date) {
|
||||||
|
// Date 객체를 MySQL/MariaDB 형식으로 변환
|
||||||
|
return `'${value.toISOString().slice(0, 19).replace('T', ' ')}'`;
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
// 문자열이 날짜 형식인지 확인
|
||||||
|
const dateRegex = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/;
|
||||||
|
if (dateRegex.test(value)) {
|
||||||
|
// JavaScript Date 문자열을 MySQL 형식으로 변환
|
||||||
|
const date = new Date(value);
|
||||||
|
return `'${date.toISOString().slice(0, 19).replace('T', ' ')}'`;
|
||||||
|
} else {
|
||||||
|
return `'${value.replace(/'/g, "''")}'`; // SQL 인젝션 방지를 위한 간단한 이스케이프
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
return String(value);
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
return value ? '1' : '0';
|
||||||
|
} else {
|
||||||
|
// 기타 객체는 문자열로 변환
|
||||||
|
return `'${String(value).replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
}).join(', ');
|
||||||
|
|
||||||
|
// Primary Key 컬럼 추정
|
||||||
|
const primaryKeyColumn = columns.includes('id') ? 'id' :
|
||||||
|
columns.includes('user_id') ? 'user_id' :
|
||||||
|
columns[0];
|
||||||
|
|
||||||
|
// UPDATE SET 절 생성 (Primary Key 제외)
|
||||||
|
const updateColumns = columns.filter(col => col !== primaryKeyColumn);
|
||||||
|
|
||||||
|
let query: string;
|
||||||
|
const dbType = connection.db_type?.toLowerCase() || 'mysql';
|
||||||
|
|
||||||
|
if (dbType === 'mysql' || dbType === 'mariadb') {
|
||||||
|
// MySQL/MariaDB: ON DUPLICATE KEY UPDATE 사용
|
||||||
|
if (updateColumns.length > 0) {
|
||||||
|
const updateSet = updateColumns.map(col => `${col} = VALUES(${col})`).join(', ');
|
||||||
|
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})
|
||||||
|
ON DUPLICATE KEY UPDATE ${updateSet}`;
|
||||||
|
} else {
|
||||||
|
// Primary Key만 있는 경우 IGNORE 사용
|
||||||
|
query = `INSERT IGNORE INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 다른 DB는 기본 INSERT 사용
|
||||||
|
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connector.executeQuery(query);
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`외부 DB 레코드 UPSERT 실패:`, error);
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { successCount, failedCount }
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`외부 DB 데이터 삽입 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 데이터 삽입 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,370 @@
|
||||||
|
// 배치관리 전용 서비스 (기존 소스와 완전 분리)
|
||||||
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||||
|
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||||
|
|
||||||
|
// 배치관리 전용 타입 정의
|
||||||
|
export interface BatchConnectionInfo {
|
||||||
|
type: 'internal' | 'external';
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
db_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchTableInfo {
|
||||||
|
table_name: string;
|
||||||
|
columns: BatchColumnInfo[];
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchColumnInfo {
|
||||||
|
column_name: string;
|
||||||
|
data_type: string;
|
||||||
|
is_nullable?: string;
|
||||||
|
column_default?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchManagementService {
|
||||||
|
/**
|
||||||
|
* 배치관리용 연결 목록 조회
|
||||||
|
*/
|
||||||
|
static async getAvailableConnections(): Promise<BatchApiResponse<BatchConnectionInfo[]>> {
|
||||||
|
try {
|
||||||
|
const connections: BatchConnectionInfo[] = [];
|
||||||
|
|
||||||
|
// 내부 DB 추가
|
||||||
|
connections.push({
|
||||||
|
type: 'internal',
|
||||||
|
name: '내부 데이터베이스 (PostgreSQL)',
|
||||||
|
db_type: 'postgresql'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 활성화된 외부 DB 연결 조회
|
||||||
|
const externalConnections = await prisma.external_db_connections.findMany({
|
||||||
|
where: { is_active: 'Y' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
connection_name: true,
|
||||||
|
db_type: true,
|
||||||
|
description: true
|
||||||
|
},
|
||||||
|
orderBy: { connection_name: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 외부 DB 연결 추가
|
||||||
|
externalConnections.forEach(conn => {
|
||||||
|
connections.push({
|
||||||
|
type: 'external',
|
||||||
|
id: conn.id,
|
||||||
|
name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`,
|
||||||
|
db_type: conn.db_type || undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: connections,
|
||||||
|
message: `${connections.length}개의 연결을 조회했습니다.`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치관리 연결 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "연결 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치관리용 테이블 목록 조회
|
||||||
|
*/
|
||||||
|
static async getTablesFromConnection(
|
||||||
|
connectionType: 'internal' | 'external',
|
||||||
|
connectionId?: number
|
||||||
|
): Promise<BatchApiResponse<BatchTableInfo[]>> {
|
||||||
|
try {
|
||||||
|
let tables: BatchTableInfo[] = [];
|
||||||
|
|
||||||
|
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 => ({
|
||||||
|
table_name: row.table_name,
|
||||||
|
columns: []
|
||||||
|
}));
|
||||||
|
} else if (connectionType === 'external' && connectionId) {
|
||||||
|
// 외부 DB 테이블 조회
|
||||||
|
const tablesResult = await this.getExternalTables(connectionId);
|
||||||
|
if (tablesResult.success && tablesResult.data) {
|
||||||
|
tables = tablesResult.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: tables,
|
||||||
|
message: `${tables.length}개의 테이블을 조회했습니다.`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치관리 테이블 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치관리용 테이블 컬럼 정보 조회
|
||||||
|
*/
|
||||||
|
static async getTableColumns(
|
||||||
|
connectionType: 'internal' | 'external',
|
||||||
|
connectionId: number | undefined,
|
||||||
|
tableName: string
|
||||||
|
): Promise<BatchApiResponse<BatchColumnInfo[]>> {
|
||||||
|
try {
|
||||||
|
console.log(`[BatchManagementService] getTableColumns 호출:`, {
|
||||||
|
connectionType,
|
||||||
|
connectionId,
|
||||||
|
tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
let columns: BatchColumnInfo[] = [];
|
||||||
|
|
||||||
|
if (connectionType === 'internal') {
|
||||||
|
// 내부 DB 컬럼 조회
|
||||||
|
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 시작: ${tableName}`);
|
||||||
|
|
||||||
|
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
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 결과:`, result);
|
||||||
|
|
||||||
|
columns = result.map(row => ({
|
||||||
|
column_name: row.column_name,
|
||||||
|
data_type: row.data_type,
|
||||||
|
is_nullable: row.is_nullable,
|
||||||
|
column_default: row.column_default,
|
||||||
|
}));
|
||||||
|
} else if (connectionType === 'external' && connectionId) {
|
||||||
|
// 외부 DB 컬럼 조회
|
||||||
|
console.log(`[BatchManagementService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`);
|
||||||
|
|
||||||
|
const columnsResult = await this.getExternalTableColumns(connectionId, tableName);
|
||||||
|
|
||||||
|
console.log(`[BatchManagementService] 외부 DB 컬럼 조회 결과:`, columnsResult);
|
||||||
|
|
||||||
|
if (columnsResult.success && columnsResult.data) {
|
||||||
|
columns = columnsResult.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BatchManagementService] 최종 컬럼 목록:`, columns);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: columns,
|
||||||
|
message: `${columns.length}개의 컬럼을 조회했습니다.`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BatchManagementService] 컬럼 정보 조회 오류:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 DB 테이블 목록 조회 (내부 구현)
|
||||||
|
*/
|
||||||
|
private static async getExternalTables(connectionId: number): Promise<BatchApiResponse<BatchTableInfo[]>> {
|
||||||
|
try {
|
||||||
|
// 연결 정보 조회
|
||||||
|
const connection = await prisma.external_db_connections.findUnique({
|
||||||
|
where: { id: connectionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "연결 정보를 찾을 수 없습니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||||
|
if (!decryptedPassword) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "비밀번호 복호화에 실패했습니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결 설정 준비
|
||||||
|
const config = {
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
database: connection.database_name,
|
||||||
|
user: connection.username,
|
||||||
|
password: decryptedPassword,
|
||||||
|
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||||
|
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||||
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
|
};
|
||||||
|
|
||||||
|
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
||||||
|
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||||
|
const tables = await connector.getTables();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "테이블 목록을 조회했습니다.",
|
||||||
|
data: tables
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 DB 테이블 컬럼 정보 조회 (내부 구현)
|
||||||
|
*/
|
||||||
|
private static async getExternalTableColumns(connectionId: number, tableName: string): Promise<BatchApiResponse<BatchColumnInfo[]>> {
|
||||||
|
try {
|
||||||
|
console.log(`[BatchManagementService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`);
|
||||||
|
|
||||||
|
// 연결 정보 조회
|
||||||
|
const connection = await prisma.external_db_connections.findUnique({
|
||||||
|
where: { id: connectionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
console.log(`[BatchManagementService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "연결 정보를 찾을 수 없습니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BatchManagementService] 연결 정보 조회 성공:`, {
|
||||||
|
id: connection.id,
|
||||||
|
connection_name: connection.connection_name,
|
||||||
|
db_type: connection.db_type,
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
database_name: connection.database_name
|
||||||
|
});
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||||
|
|
||||||
|
// 연결 설정 준비
|
||||||
|
const config = {
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
database: connection.database_name,
|
||||||
|
user: connection.username,
|
||||||
|
password: decryptedPassword,
|
||||||
|
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||||
|
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||||
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[BatchManagementService] 커넥터 생성 시작: db_type=${connection.db_type}`);
|
||||||
|
|
||||||
|
// 데이터베이스 타입에 따른 커넥터 생성
|
||||||
|
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||||
|
|
||||||
|
console.log(`[BatchManagementService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`);
|
||||||
|
|
||||||
|
// 컬럼 정보 조회
|
||||||
|
console.log(`[BatchManagementService] connector.getColumns 호출 전`);
|
||||||
|
const columns = await connector.getColumns(tableName);
|
||||||
|
|
||||||
|
console.log(`[BatchManagementService] 원본 컬럼 조회 결과:`, columns);
|
||||||
|
console.log(`[BatchManagementService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined');
|
||||||
|
|
||||||
|
// 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환
|
||||||
|
const standardizedColumns: BatchColumnInfo[] = columns.map((col: any) => {
|
||||||
|
console.log(`[BatchManagementService] 컬럼 변환 중:`, col);
|
||||||
|
|
||||||
|
// MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만)
|
||||||
|
if (col.name && col.dataType !== undefined) {
|
||||||
|
const result = {
|
||||||
|
column_name: col.name,
|
||||||
|
data_type: col.dataType,
|
||||||
|
is_nullable: col.isNullable ? 'YES' : 'NO',
|
||||||
|
column_default: col.defaultValue || null,
|
||||||
|
};
|
||||||
|
console.log(`[BatchManagementService] MySQL/MariaDB 구조로 변환:`, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default}
|
||||||
|
else {
|
||||||
|
const result = {
|
||||||
|
column_name: col.column_name || col.COLUMN_NAME,
|
||||||
|
data_type: col.data_type || col.DATA_TYPE,
|
||||||
|
is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'),
|
||||||
|
column_default: col.column_default || col.COLUMN_DEFAULT || null,
|
||||||
|
};
|
||||||
|
console.log(`[BatchManagementService] 표준 구조로 변환:`, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[BatchManagementService] 표준화된 컬럼 목록:`, standardizedColumns);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: standardizedColumns,
|
||||||
|
message: "컬럼 정보를 조회했습니다."
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BatchManagementService] 외부 DB 컬럼 정보 조회 오류:", error);
|
||||||
|
console.error("[BatchManagementService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,397 @@
|
||||||
|
// 배치 스케줄러 서비스
|
||||||
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
|
import * as cron from 'node-cron';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { BatchService } from './batchService';
|
||||||
|
import { BatchExecutionLogService } from './batchExecutionLogService';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
export class BatchSchedulerService {
|
||||||
|
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
||||||
|
private static isInitialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄러 초기화
|
||||||
|
*/
|
||||||
|
static async initialize() {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
logger.info('배치 스케줄러가 이미 초기화되었습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('배치 스케줄러 초기화 시작...');
|
||||||
|
|
||||||
|
// 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||||
|
await this.loadActiveBatchConfigs();
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
logger.info('배치 스케줄러 초기화 완료');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('배치 스케줄러 초기화 실패:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||||
|
*/
|
||||||
|
private static async loadActiveBatchConfigs() {
|
||||||
|
try {
|
||||||
|
const activeConfigs = await prisma.batch_configs.findMany({
|
||||||
|
where: {
|
||||||
|
is_active: 'Y'
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
batch_mappings: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`);
|
||||||
|
|
||||||
|
for (const config of activeConfigs) {
|
||||||
|
await this.scheduleBatchConfig(config);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('활성화된 배치 설정 로드 실패:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 설정을 스케줄에 등록
|
||||||
|
*/
|
||||||
|
static async scheduleBatchConfig(config: any) {
|
||||||
|
try {
|
||||||
|
const { id, batch_name, cron_schedule } = config;
|
||||||
|
|
||||||
|
// 기존 스케줄이 있다면 제거
|
||||||
|
if (this.scheduledTasks.has(id)) {
|
||||||
|
this.scheduledTasks.get(id)?.stop();
|
||||||
|
this.scheduledTasks.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cron 스케줄 유효성 검사
|
||||||
|
if (!cron.validate(cron_schedule)) {
|
||||||
|
logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로운 스케줄 등록
|
||||||
|
const task = cron.schedule(cron_schedule, async () => {
|
||||||
|
await this.executeBatchConfig(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scheduledTasks.set(id, task);
|
||||||
|
logger.info(`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule})`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 설정 스케줄 제거
|
||||||
|
*/
|
||||||
|
static async unscheduleBatchConfig(batchConfigId: number) {
|
||||||
|
try {
|
||||||
|
if (this.scheduledTasks.has(batchConfigId)) {
|
||||||
|
this.scheduledTasks.get(batchConfigId)?.stop();
|
||||||
|
this.scheduledTasks.delete(batchConfigId);
|
||||||
|
logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 설정 업데이트 시 스케줄 재등록
|
||||||
|
*/
|
||||||
|
static async updateBatchSchedule(configId: number) {
|
||||||
|
try {
|
||||||
|
// 기존 스케줄 제거
|
||||||
|
await this.unscheduleBatchConfig(configId);
|
||||||
|
|
||||||
|
// 업데이트된 배치 설정 조회
|
||||||
|
const config = await prisma.batch_configs.findUnique({
|
||||||
|
where: { id: configId },
|
||||||
|
include: { batch_mappings: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성화된 배치만 다시 스케줄 등록
|
||||||
|
if (config.is_active === 'Y') {
|
||||||
|
await this.scheduleBatchConfig(config);
|
||||||
|
logger.info(`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`);
|
||||||
|
} else {
|
||||||
|
logger.info(`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 설정 실행
|
||||||
|
*/
|
||||||
|
private static async executeBatchConfig(config: any) {
|
||||||
|
const startTime = new Date();
|
||||||
|
let executionLog: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
||||||
|
|
||||||
|
// 실행 로그 생성
|
||||||
|
const executionLogResponse = await BatchExecutionLogService.createExecutionLog({
|
||||||
|
batch_config_id: config.id,
|
||||||
|
execution_status: 'RUNNING',
|
||||||
|
start_time: startTime,
|
||||||
|
total_records: 0,
|
||||||
|
success_records: 0,
|
||||||
|
failed_records: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!executionLogResponse.success || !executionLogResponse.data) {
|
||||||
|
logger.error(`배치 실행 로그 생성 실패: ${config.batch_name}`, executionLogResponse.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
executionLog = executionLogResponse.data;
|
||||||
|
|
||||||
|
// 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용)
|
||||||
|
const result = await this.executeBatchMappings(config);
|
||||||
|
|
||||||
|
// 실행 로그 업데이트 (성공)
|
||||||
|
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||||
|
execution_status: 'SUCCESS',
|
||||||
|
end_time: new Date(),
|
||||||
|
duration_ms: Date.now() - startTime.getTime(),
|
||||||
|
total_records: result.totalRecords,
|
||||||
|
success_records: result.successRecords,
|
||||||
|
failed_records: result.failedRecords
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
||||||
|
|
||||||
|
// 실행 로그 업데이트 (실패)
|
||||||
|
if (executionLog) {
|
||||||
|
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||||
|
execution_status: 'FAILED',
|
||||||
|
end_time: new Date(),
|
||||||
|
duration_ms: Date.now() - startTime.getTime(),
|
||||||
|
error_message: error instanceof Error ? error.message : '알 수 없는 오류',
|
||||||
|
error_details: error instanceof Error ? error.stack : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 매핑 실행 (수동 실행과 동일한 로직)
|
||||||
|
*/
|
||||||
|
private static async executeBatchMappings(config: any) {
|
||||||
|
let totalRecords = 0;
|
||||||
|
let successRecords = 0;
|
||||||
|
let failedRecords = 0;
|
||||||
|
|
||||||
|
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
||||||
|
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
|
||||||
|
return { totalRecords, successRecords, failedRecords };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블별로 매핑을 그룹화
|
||||||
|
const tableGroups = new Map<string, typeof config.batch_mappings>();
|
||||||
|
|
||||||
|
for (const mapping of config.batch_mappings) {
|
||||||
|
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`;
|
||||||
|
if (!tableGroups.has(key)) {
|
||||||
|
tableGroups.set(key, []);
|
||||||
|
}
|
||||||
|
tableGroups.get(key)!.push(mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 테이블 그룹별로 처리
|
||||||
|
for (const [tableKey, mappings] of tableGroups) {
|
||||||
|
try {
|
||||||
|
const firstMapping = mappings[0];
|
||||||
|
logger.info(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`);
|
||||||
|
|
||||||
|
// FROM 테이블에서 매핑된 컬럼들만 조회
|
||||||
|
const fromColumns = mappings.map((m: any) => m.from_column_name);
|
||||||
|
const fromData = await BatchService.getDataFromTableWithColumns(
|
||||||
|
firstMapping.from_table_name,
|
||||||
|
fromColumns,
|
||||||
|
firstMapping.from_connection_type as 'internal' | 'external',
|
||||||
|
firstMapping.from_connection_id || undefined
|
||||||
|
);
|
||||||
|
totalRecords += fromData.length;
|
||||||
|
|
||||||
|
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
||||||
|
const mappedData = fromData.map(row => {
|
||||||
|
const mappedRow: any = {};
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||||
|
}
|
||||||
|
return mappedRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
// TO 테이블에 데이터 삽입
|
||||||
|
const insertResult = await BatchService.insertDataToTable(
|
||||||
|
firstMapping.to_table_name,
|
||||||
|
mappedData,
|
||||||
|
firstMapping.to_connection_type as 'internal' | 'external',
|
||||||
|
firstMapping.to_connection_id || undefined
|
||||||
|
);
|
||||||
|
successRecords += insertResult.successCount;
|
||||||
|
failedRecords += insertResult.failedCount;
|
||||||
|
|
||||||
|
logger.info(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`테이블 처리 실패: ${tableKey}`, error);
|
||||||
|
failedRecords += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalRecords, successRecords, failedRecords };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 매핑 처리 (기존 메서드 - 사용 안 함)
|
||||||
|
*/
|
||||||
|
private static async processBatchMappings(config: any) {
|
||||||
|
const { batch_mappings } = config;
|
||||||
|
let totalRecords = 0;
|
||||||
|
let successRecords = 0;
|
||||||
|
let failedRecords = 0;
|
||||||
|
|
||||||
|
if (!batch_mappings || batch_mappings.length === 0) {
|
||||||
|
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
|
||||||
|
return { totalRecords, successRecords, failedRecords };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mapping of batch_mappings) {
|
||||||
|
try {
|
||||||
|
logger.info(`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`);
|
||||||
|
|
||||||
|
// FROM 테이블에서 데이터 조회
|
||||||
|
const fromData = await this.getDataFromSource(mapping);
|
||||||
|
totalRecords += fromData.length;
|
||||||
|
|
||||||
|
// TO 테이블에 데이터 삽입
|
||||||
|
const insertResult = await this.insertDataToTarget(mapping, fromData);
|
||||||
|
successRecords += insertResult.successCount;
|
||||||
|
failedRecords += insertResult.failedCount;
|
||||||
|
|
||||||
|
logger.info(`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`, error);
|
||||||
|
failedRecords += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalRecords, successRecords, failedRecords };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FROM 테이블에서 데이터 조회
|
||||||
|
*/
|
||||||
|
private static async getDataFromSource(mapping: any) {
|
||||||
|
try {
|
||||||
|
if (mapping.from_connection_type === 'internal') {
|
||||||
|
// 내부 DB에서 조회
|
||||||
|
const result = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT * FROM ${mapping.from_table_name}`
|
||||||
|
);
|
||||||
|
return result as any[];
|
||||||
|
} else {
|
||||||
|
// 외부 DB에서 조회 (구현 필요)
|
||||||
|
logger.warn('외부 DB 조회는 아직 구현되지 않았습니다.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TO 테이블에 데이터 삽입
|
||||||
|
*/
|
||||||
|
private static async insertDataToTarget(mapping: any, data: any[]) {
|
||||||
|
let successCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mapping.to_connection_type === 'internal') {
|
||||||
|
// 내부 DB에 삽입
|
||||||
|
for (const record of data) {
|
||||||
|
try {
|
||||||
|
// 매핑된 컬럼만 추출
|
||||||
|
const mappedData = this.mapColumns(record, mapping);
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`INSERT INTO ${mapping.to_table_name} (${Object.keys(mappedData).join(', ')}) VALUES (${Object.values(mappedData).map(() => '?').join(', ')})`,
|
||||||
|
...Object.values(mappedData)
|
||||||
|
);
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`레코드 삽입 실패:`, error);
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 외부 DB에 삽입 (구현 필요)
|
||||||
|
logger.warn('외부 DB 삽입은 아직 구현되지 않았습니다.');
|
||||||
|
failedCount = data.length;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { successCount, failedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 매핑
|
||||||
|
*/
|
||||||
|
private static mapColumns(record: any, mapping: any) {
|
||||||
|
const mappedData: any = {};
|
||||||
|
|
||||||
|
// 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요)
|
||||||
|
mappedData[mapping.to_column_name] = record[mapping.from_column_name];
|
||||||
|
|
||||||
|
return mappedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 스케줄 중지
|
||||||
|
*/
|
||||||
|
static async stopAllSchedules() {
|
||||||
|
try {
|
||||||
|
for (const [id, task] of this.scheduledTasks) {
|
||||||
|
task.stop();
|
||||||
|
logger.info(`배치 스케줄 중지: ID ${id}`);
|
||||||
|
}
|
||||||
|
this.scheduledTasks.clear();
|
||||||
|
this.isInitialized = false;
|
||||||
|
logger.info('모든 배치 스케줄이 중지되었습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('배치 스케줄 중지 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 등록된 스케줄 목록 조회
|
||||||
|
*/
|
||||||
|
static getScheduledTasks() {
|
||||||
|
return Array.from(this.scheduledTasks.keys());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// 배치관리 서비스
|
// 배치관리 서비스
|
||||||
// 작성일: 2024-12-24
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import prisma from "../config/database";
|
||||||
import {
|
import {
|
||||||
BatchConfig,
|
BatchConfig,
|
||||||
BatchMapping,
|
BatchMapping,
|
||||||
|
|
@ -12,12 +12,12 @@ import {
|
||||||
ConnectionInfo,
|
ConnectionInfo,
|
||||||
TableInfo,
|
TableInfo,
|
||||||
ColumnInfo,
|
ColumnInfo,
|
||||||
|
CreateBatchConfigRequest,
|
||||||
|
UpdateBatchConfigRequest,
|
||||||
} from "../types/batchTypes";
|
} from "../types/batchTypes";
|
||||||
import { ExternalDbConnectionService } from "./externalDbConnectionService";
|
import { BatchExternalDbService } from "./batchExternalDbService";
|
||||||
import { DbConnectionManager } from "./dbConnectionManager";
|
import { DbConnectionManager } from "./dbConnectionManager";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export class BatchService {
|
export class BatchService {
|
||||||
/**
|
/**
|
||||||
* 배치 설정 목록 조회
|
* 배치 설정 목록 조회
|
||||||
|
|
@ -55,17 +55,32 @@ export class BatchService {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchConfigs = await prisma.batch_configs.findMany({
|
const page = filter.page || 1;
|
||||||
where,
|
const limit = filter.limit || 10;
|
||||||
include: {
|
const skip = (page - 1) * limit;
|
||||||
batch_mappings: true,
|
|
||||||
},
|
const [batchConfigs, total] = await Promise.all([
|
||||||
orderBy: [{ is_active: "desc" }, { batch_name: "asc" }],
|
prisma.batch_configs.findMany({
|
||||||
});
|
where,
|
||||||
|
include: {
|
||||||
|
batch_mappings: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ is_active: "desc" }, { batch_name: "asc" }],
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.batch_configs.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: batchConfigs as BatchConfig[],
|
data: batchConfigs as BatchConfig[],
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 설정 목록 조회 오류:", error);
|
console.error("배치 설정 목록 조회 오류:", error);
|
||||||
|
|
@ -122,28 +137,18 @@ export class BatchService {
|
||||||
* 배치 설정 생성
|
* 배치 설정 생성
|
||||||
*/
|
*/
|
||||||
static async createBatchConfig(
|
static async createBatchConfig(
|
||||||
data: BatchMappingRequest,
|
data: CreateBatchConfigRequest,
|
||||||
userId?: string
|
userId?: string
|
||||||
): Promise<ApiResponse<BatchConfig>> {
|
): Promise<ApiResponse<BatchConfig>> {
|
||||||
try {
|
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 result = await prisma.$transaction(async (tx) => {
|
||||||
// 배치 설정 생성
|
// 배치 설정 생성
|
||||||
const batchConfig = await tx.batch_configs.create({
|
const batchConfig = await tx.batch_configs.create({
|
||||||
data: {
|
data: {
|
||||||
batch_name: data.batch_name,
|
batch_name: data.batchName,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
cron_schedule: data.cron_schedule,
|
cron_schedule: data.cronSchedule,
|
||||||
created_by: userId,
|
created_by: userId,
|
||||||
updated_by: userId,
|
updated_by: userId,
|
||||||
},
|
},
|
||||||
|
|
@ -198,7 +203,7 @@ export class BatchService {
|
||||||
*/
|
*/
|
||||||
static async updateBatchConfig(
|
static async updateBatchConfig(
|
||||||
id: number,
|
id: number,
|
||||||
data: Partial<BatchMappingRequest>,
|
data: UpdateBatchConfigRequest,
|
||||||
userId?: string
|
userId?: string
|
||||||
): Promise<ApiResponse<BatchConfig>> {
|
): Promise<ApiResponse<BatchConfig>> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -215,18 +220,6 @@ export class BatchService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 매핑이 제공된 경우 유효성 검사
|
|
||||||
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 result = await prisma.$transaction(async (tx) => {
|
||||||
// 배치 설정 업데이트
|
// 배치 설정 업데이트
|
||||||
|
|
@ -234,9 +227,10 @@ export class BatchService {
|
||||||
updated_by: userId,
|
updated_by: userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.batch_name) updateData.batch_name = data.batch_name;
|
if (data.batchName) updateData.batch_name = data.batchName;
|
||||||
if (data.description !== undefined) updateData.description = data.description;
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
if (data.cron_schedule) updateData.cron_schedule = data.cron_schedule;
|
if (data.cronSchedule) updateData.cron_schedule = data.cronSchedule;
|
||||||
|
if (data.isActive !== undefined) updateData.is_active = data.isActive;
|
||||||
|
|
||||||
const batchConfig = await tx.batch_configs.update({
|
const batchConfig = await tx.batch_configs.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|
@ -354,16 +348,14 @@ export class BatchService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// 외부 DB 연결 조회
|
// 외부 DB 연결 조회
|
||||||
const externalConnections = await ExternalDbConnectionService.getConnections({
|
const externalConnections = await BatchExternalDbService.getAvailableConnections();
|
||||||
is_active: 'Y',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (externalConnections.success && externalConnections.data) {
|
if (externalConnections.success && externalConnections.data) {
|
||||||
externalConnections.data.forEach((conn) => {
|
externalConnections.data.forEach((conn) => {
|
||||||
connections.push({
|
connections.push({
|
||||||
type: 'external',
|
type: 'external',
|
||||||
id: conn.id,
|
id: conn.id,
|
||||||
name: conn.connection_name,
|
name: conn.name,
|
||||||
db_type: conn.db_type,
|
db_type: conn.db_type,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -389,9 +381,9 @@ export class BatchService {
|
||||||
static async getTablesFromConnection(
|
static async getTablesFromConnection(
|
||||||
connectionType: 'internal' | 'external',
|
connectionType: 'internal' | 'external',
|
||||||
connectionId?: number
|
connectionId?: number
|
||||||
): Promise<ApiResponse<string[]>> {
|
): Promise<ApiResponse<TableInfo[]>> {
|
||||||
try {
|
try {
|
||||||
let tables: string[] = [];
|
let tables: TableInfo[] = [];
|
||||||
|
|
||||||
if (connectionType === 'internal') {
|
if (connectionType === 'internal') {
|
||||||
// 내부 DB 테이블 조회
|
// 내부 DB 테이블 조회
|
||||||
|
|
@ -402,10 +394,13 @@ export class BatchService {
|
||||||
AND table_type = 'BASE TABLE'
|
AND table_type = 'BASE TABLE'
|
||||||
ORDER BY table_name
|
ORDER BY table_name
|
||||||
`;
|
`;
|
||||||
tables = result.map(row => row.table_name);
|
tables = result.map(row => ({
|
||||||
|
table_name: row.table_name,
|
||||||
|
columns: []
|
||||||
|
}));
|
||||||
} else if (connectionType === 'external' && connectionId) {
|
} else if (connectionType === 'external' && connectionId) {
|
||||||
// 외부 DB 테이블 조회
|
// 외부 DB 테이블 조회
|
||||||
const tablesResult = await ExternalDbConnectionService.getTables(connectionId);
|
const tablesResult = await BatchExternalDbService.getTablesFromConnection(connectionType, connectionId);
|
||||||
if (tablesResult.success && tablesResult.data) {
|
if (tablesResult.success && tablesResult.data) {
|
||||||
tables = tablesResult.data;
|
tables = tablesResult.data;
|
||||||
}
|
}
|
||||||
|
|
@ -430,14 +425,22 @@ export class BatchService {
|
||||||
*/
|
*/
|
||||||
static async getTableColumns(
|
static async getTableColumns(
|
||||||
connectionType: 'internal' | 'external',
|
connectionType: 'internal' | 'external',
|
||||||
tableName: string,
|
connectionId: number | undefined,
|
||||||
connectionId?: number
|
tableName: string
|
||||||
): Promise<ApiResponse<ColumnInfo[]>> {
|
): Promise<ApiResponse<ColumnInfo[]>> {
|
||||||
try {
|
try {
|
||||||
|
console.log(`[BatchService] getTableColumns 호출:`, {
|
||||||
|
connectionType,
|
||||||
|
connectionId,
|
||||||
|
tableName
|
||||||
|
});
|
||||||
|
|
||||||
let columns: ColumnInfo[] = [];
|
let columns: ColumnInfo[] = [];
|
||||||
|
|
||||||
if (connectionType === 'internal') {
|
if (connectionType === 'internal') {
|
||||||
// 내부 DB 컬럼 조회
|
// 내부 DB 컬럼 조회
|
||||||
|
console.log(`[BatchService] 내부 DB 컬럼 조회 시작: ${tableName}`);
|
||||||
|
|
||||||
const result = await prisma.$queryRaw<Array<{
|
const result = await prisma.$queryRaw<Array<{
|
||||||
column_name: string;
|
column_name: string;
|
||||||
data_type: string;
|
data_type: string;
|
||||||
|
|
@ -455,26 +458,31 @@ export class BatchService {
|
||||||
ORDER BY ordinal_position
|
ORDER BY ordinal_position
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
console.log(`[BatchService] 내부 DB 컬럼 조회 결과:`, result);
|
||||||
|
|
||||||
columns = result.map(row => ({
|
columns = result.map(row => ({
|
||||||
column_name: row.column_name,
|
column_name: row.column_name,
|
||||||
data_type: row.data_type,
|
data_type: row.data_type,
|
||||||
is_nullable: row.is_nullable === 'YES',
|
is_nullable: row.is_nullable,
|
||||||
column_default: row.column_default,
|
column_default: row.column_default,
|
||||||
}));
|
}));
|
||||||
} else if (connectionType === 'external' && connectionId) {
|
} else if (connectionType === 'external' && connectionId) {
|
||||||
// 외부 DB 컬럼 조회
|
// 외부 DB 컬럼 조회
|
||||||
const columnsResult = await ExternalDbConnectionService.getTableColumns(
|
console.log(`[BatchService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`);
|
||||||
|
|
||||||
|
const columnsResult = await BatchExternalDbService.getTableColumns(
|
||||||
|
connectionType,
|
||||||
connectionId,
|
connectionId,
|
||||||
tableName
|
tableName
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(`[BatchService] 외부 DB 컬럼 조회 결과:`, columnsResult);
|
||||||
|
|
||||||
if (columnsResult.success && columnsResult.data) {
|
if (columnsResult.success && columnsResult.data) {
|
||||||
columns = columnsResult.data.map(col => ({
|
columns = columnsResult.data;
|
||||||
column_name: col.column_name,
|
|
||||||
data_type: col.data_type,
|
|
||||||
is_nullable: col.is_nullable,
|
|
||||||
column_default: col.column_default,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[BatchService] 외부 DB 컬럼:`, columns);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -491,6 +499,228 @@ export class BatchService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 실행 로그 생성
|
||||||
|
*/
|
||||||
|
static async createExecutionLog(data: {
|
||||||
|
batch_config_id: number;
|
||||||
|
execution_status: string;
|
||||||
|
start_time: Date;
|
||||||
|
total_records: number;
|
||||||
|
success_records: number;
|
||||||
|
failed_records: number;
|
||||||
|
}): Promise<any> {
|
||||||
|
try {
|
||||||
|
const executionLog = await prisma.batch_execution_logs.create({
|
||||||
|
data: {
|
||||||
|
batch_config_id: data.batch_config_id,
|
||||||
|
execution_status: data.execution_status,
|
||||||
|
start_time: data.start_time,
|
||||||
|
total_records: data.total_records,
|
||||||
|
success_records: data.success_records,
|
||||||
|
failed_records: data.failed_records,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return executionLog;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 로그 생성 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 실행 로그 업데이트
|
||||||
|
*/
|
||||||
|
static async updateExecutionLog(
|
||||||
|
id: number,
|
||||||
|
data: {
|
||||||
|
execution_status?: string;
|
||||||
|
end_time?: Date;
|
||||||
|
duration_ms?: number;
|
||||||
|
total_records?: number;
|
||||||
|
success_records?: number;
|
||||||
|
failed_records?: number;
|
||||||
|
error_message?: string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await prisma.batch_execution_logs.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 로그 업데이트 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블에서 데이터 조회 (연결 타입에 따라 내부/외부 DB 구분)
|
||||||
|
*/
|
||||||
|
static async getDataFromTable(
|
||||||
|
tableName: string,
|
||||||
|
connectionType: 'internal' | 'external' = 'internal',
|
||||||
|
connectionId?: number
|
||||||
|
): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
console.log(`[BatchService] 테이블에서 데이터 조회: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ''})`);
|
||||||
|
|
||||||
|
if (connectionType === 'internal') {
|
||||||
|
// 내부 DB에서 데이터 조회
|
||||||
|
const result = await prisma.$queryRawUnsafe(`SELECT * FROM ${tableName} LIMIT 100`);
|
||||||
|
console.log(`[BatchService] 내부 DB 데이터 조회 결과: ${Array.isArray(result) ? result.length : 0}개 레코드`);
|
||||||
|
return result as any[];
|
||||||
|
} else if (connectionType === 'external' && connectionId) {
|
||||||
|
// 외부 DB에서 데이터 조회
|
||||||
|
const result = await BatchExternalDbService.getDataFromTable(connectionId, tableName);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
console.log(`[BatchService] 외부 DB 데이터 조회 결과: ${result.data.length}개 레코드`);
|
||||||
|
return result.data;
|
||||||
|
} else {
|
||||||
|
console.error(`외부 DB 데이터 조회 실패: ${result.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`테이블 데이터 조회 오류 (${tableName}):`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블에서 특정 컬럼들만 조회 (연결 타입에 따라 내부/외부 DB 구분)
|
||||||
|
*/
|
||||||
|
static async getDataFromTableWithColumns(
|
||||||
|
tableName: string,
|
||||||
|
columns: string[],
|
||||||
|
connectionType: 'internal' | 'external' = 'internal',
|
||||||
|
connectionId?: number
|
||||||
|
): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
console.log(`[BatchService] 테이블에서 특정 컬럼 데이터 조회: ${tableName} (${columns.join(', ')}) (${connectionType}${connectionId ? `:${connectionId}` : ''})`);
|
||||||
|
|
||||||
|
if (connectionType === 'internal') {
|
||||||
|
// 내부 DB에서 특정 컬럼만 조회
|
||||||
|
const columnList = columns.join(', ');
|
||||||
|
const result = await prisma.$queryRawUnsafe(`SELECT ${columnList} FROM ${tableName} LIMIT 100`);
|
||||||
|
console.log(`[BatchService] 내부 DB 특정 컬럼 조회 결과: ${Array.isArray(result) ? result.length : 0}개 레코드`);
|
||||||
|
return result as any[];
|
||||||
|
} else if (connectionType === 'external' && connectionId) {
|
||||||
|
// 외부 DB에서 특정 컬럼만 조회
|
||||||
|
const result = await BatchExternalDbService.getDataFromTableWithColumns(connectionId, tableName, columns);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
console.log(`[BatchService] 외부 DB 특정 컬럼 조회 결과: ${result.data.length}개 레코드`);
|
||||||
|
return result.data;
|
||||||
|
} else {
|
||||||
|
console.error(`외부 DB 특정 컬럼 조회 실패: ${result.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`테이블 특정 컬럼 조회 오류 (${tableName}):`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분)
|
||||||
|
*/
|
||||||
|
static async insertDataToTable(
|
||||||
|
tableName: string,
|
||||||
|
data: any[],
|
||||||
|
connectionType: 'internal' | 'external' = 'internal',
|
||||||
|
connectionId?: number
|
||||||
|
): Promise<{
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
console.log(`[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ''}), ${data.length}개 레코드`);
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return { successCount: 0, failedCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionType === 'internal') {
|
||||||
|
// 내부 DB에 데이터 삽입
|
||||||
|
let successCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리)
|
||||||
|
for (const record of data) {
|
||||||
|
try {
|
||||||
|
// 동적 UPSERT 쿼리 생성 (PostgreSQL ON CONFLICT 사용)
|
||||||
|
const columns = Object.keys(record);
|
||||||
|
const values = Object.values(record).map(value => {
|
||||||
|
// Date 객체를 ISO 문자열로 변환 (PostgreSQL이 자동으로 파싱)
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
// JavaScript Date 문자열을 Date 객체로 변환 후 ISO 문자열로
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const dateRegex = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/;
|
||||||
|
if (dateRegex.test(value)) {
|
||||||
|
return new Date(value).toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
||||||
|
|
||||||
|
// Primary Key 컬럼 추정 (일반적으로 id 또는 첫 번째 컬럼)
|
||||||
|
const primaryKeyColumn = columns.includes('id') ? 'id' :
|
||||||
|
columns.includes('user_id') ? 'user_id' :
|
||||||
|
columns[0];
|
||||||
|
|
||||||
|
// UPDATE SET 절 생성 (Primary Key 제외)
|
||||||
|
const updateColumns = columns.filter(col => col !== primaryKeyColumn);
|
||||||
|
const updateSet = updateColumns.map(col => `${col} = EXCLUDED.${col}`).join(', ');
|
||||||
|
|
||||||
|
let query: string;
|
||||||
|
if (updateSet) {
|
||||||
|
// UPSERT: 중복 시 업데이트
|
||||||
|
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})
|
||||||
|
ON CONFLICT (${primaryKeyColumn}) DO UPDATE SET ${updateSet}`;
|
||||||
|
} else {
|
||||||
|
// Primary Key만 있는 경우 중복 시 무시
|
||||||
|
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})
|
||||||
|
ON CONFLICT (${primaryKeyColumn}) DO NOTHING`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe(query, ...values);
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`레코드 UPSERT 실패:`, error);
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BatchService] 내부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개`);
|
||||||
|
return { successCount, failedCount };
|
||||||
|
} else if (connectionType === 'external' && connectionId) {
|
||||||
|
// 외부 DB에 데이터 삽입
|
||||||
|
const result = await BatchExternalDbService.insertDataToTable(connectionId, tableName, data);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
console.log(`[BatchService] 외부 DB 데이터 삽입 완료: 성공 ${result.data.successCount}개, 실패 ${result.data.failedCount}개`);
|
||||||
|
return result.data;
|
||||||
|
} else {
|
||||||
|
console.error(`외부 DB 데이터 삽입 실패: ${result.message}`);
|
||||||
|
return { successCount: 0, failedCount: data.length };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`테이블 데이터 삽입 오류 (${tableName}):`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 배치 매핑 유효성 검사
|
* 배치 매핑 유효성 검사
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// 외부 DB 연결 서비스
|
// 외부 DB 연결 서비스
|
||||||
// 작성일: 2024-12-17
|
// 작성일: 2024-12-17
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import prisma from "../config/database";
|
||||||
import {
|
import {
|
||||||
ExternalDbConnection,
|
ExternalDbConnection,
|
||||||
ExternalDbConnectionFilter,
|
ExternalDbConnectionFilter,
|
||||||
|
|
@ -9,9 +9,7 @@ import {
|
||||||
TableInfo,
|
TableInfo,
|
||||||
} from "../types/externalDbTypes";
|
} from "../types/externalDbTypes";
|
||||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||||
import { DbConnectionManager } from "./dbConnectionManager";
|
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export class ExternalDbConnectionService {
|
export class ExternalDbConnectionService {
|
||||||
/**
|
/**
|
||||||
|
|
@ -81,6 +79,93 @@ export class ExternalDbConnectionService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB 타입별로 그룹화된 외부 DB 연결 목록 조회
|
||||||
|
*/
|
||||||
|
static async getConnectionsGroupedByType(
|
||||||
|
filter: ExternalDbConnectionFilter = {}
|
||||||
|
): Promise<ApiResponse<Record<string, ExternalDbConnection[]>>> {
|
||||||
|
try {
|
||||||
|
// 기본 연결 목록 조회
|
||||||
|
const connectionsResult = await this.getConnections(filter);
|
||||||
|
|
||||||
|
if (!connectionsResult.success || !connectionsResult.data) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "연결 목록 조회에 실패했습니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 타입 카테고리 정보 조회
|
||||||
|
const categories = await prisma.db_type_categories.findMany({
|
||||||
|
where: { is_active: true },
|
||||||
|
orderBy: [
|
||||||
|
{ sort_order: 'asc' },
|
||||||
|
{ display_name: 'asc' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// DB 타입별로 그룹화
|
||||||
|
const groupedConnections: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 카테고리 정보를 포함한 그룹 초기화
|
||||||
|
categories.forEach((category: any) => {
|
||||||
|
groupedConnections[category.type_code] = {
|
||||||
|
category: {
|
||||||
|
type_code: category.type_code,
|
||||||
|
display_name: category.display_name,
|
||||||
|
icon: category.icon,
|
||||||
|
color: category.color,
|
||||||
|
sort_order: category.sort_order
|
||||||
|
},
|
||||||
|
connections: []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연결을 해당 타입 그룹에 배치
|
||||||
|
connectionsResult.data.forEach(connection => {
|
||||||
|
if (groupedConnections[connection.db_type]) {
|
||||||
|
groupedConnections[connection.db_type].connections.push(connection);
|
||||||
|
} else {
|
||||||
|
// 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가
|
||||||
|
if (!groupedConnections['other']) {
|
||||||
|
groupedConnections['other'] = {
|
||||||
|
category: {
|
||||||
|
type_code: 'other',
|
||||||
|
display_name: '기타',
|
||||||
|
icon: 'database',
|
||||||
|
color: '#6B7280',
|
||||||
|
sort_order: 999
|
||||||
|
},
|
||||||
|
connections: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
groupedConnections['other'].connections.push(connection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연결이 없는 빈 그룹 제거
|
||||||
|
Object.keys(groupedConnections).forEach(key => {
|
||||||
|
if (groupedConnections[key].connections.length === 0) {
|
||||||
|
delete groupedConnections[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: groupedConnections,
|
||||||
|
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹화된 연결 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 외부 DB 연결 조회
|
* 특정 외부 DB 연결 조회
|
||||||
*/
|
*/
|
||||||
|
|
@ -239,13 +324,40 @@ export class ExternalDbConnectionService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 비밀번호가 변경되는 경우, 연결 테스트 먼저 수행
|
||||||
|
if (data.password && data.password !== "***ENCRYPTED***") {
|
||||||
|
// 임시 연결 설정으로 테스트
|
||||||
|
const testConfig = {
|
||||||
|
host: data.host || existingConnection.host,
|
||||||
|
port: data.port || existingConnection.port,
|
||||||
|
database: data.database_name || existingConnection.database_name,
|
||||||
|
user: data.username || existingConnection.username,
|
||||||
|
password: data.password, // 새로 입력된 비밀번호로 테스트
|
||||||
|
connectionTimeoutMillis: data.connection_timeout != null ? data.connection_timeout * 1000 : undefined,
|
||||||
|
queryTimeoutMillis: data.query_timeout != null ? data.query_timeout * 1000 : undefined,
|
||||||
|
ssl: (data.ssl_enabled || existingConnection.ssl_enabled) === "Y" ? { rejectUnauthorized: false } : false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연결 테스트 수행
|
||||||
|
const connector = await DatabaseConnectorFactory.createConnector(existingConnection.db_type, testConfig, id);
|
||||||
|
const testResult = await connector.testConnection();
|
||||||
|
|
||||||
|
if (!testResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "새로운 연결 정보로 테스트에 실패했습니다. 수정할 수 없습니다.",
|
||||||
|
error: testResult.error ? `${testResult.error.code}: ${testResult.error.details}` : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 업데이트 데이터 준비
|
// 업데이트 데이터 준비
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
...data,
|
...data,
|
||||||
updated_date: new Date(),
|
updated_date: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 비밀번호가 변경된 경우 암호화
|
// 비밀번호가 변경된 경우 암호화 (연결 테스트 통과 후)
|
||||||
if (data.password && data.password !== "***ENCRYPTED***") {
|
if (data.password && data.password !== "***ENCRYPTED***") {
|
||||||
updateData.password = PasswordEncryption.encrypt(data.password);
|
updateData.password = PasswordEncryption.encrypt(data.password);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -320,7 +432,8 @@ export class ExternalDbConnectionService {
|
||||||
* 데이터베이스 연결 테스트 (ID 기반)
|
* 데이터베이스 연결 테스트 (ID 기반)
|
||||||
*/
|
*/
|
||||||
static async testConnectionById(
|
static async testConnectionById(
|
||||||
id: number
|
id: number,
|
||||||
|
testData?: { password?: string }
|
||||||
): Promise<import("../types/externalDbTypes").ConnectionTestResult> {
|
): Promise<import("../types/externalDbTypes").ConnectionTestResult> {
|
||||||
try {
|
try {
|
||||||
// 저장된 연결 정보 조회
|
// 저장된 연결 정보 조회
|
||||||
|
|
@ -339,9 +452,17 @@ export class ExternalDbConnectionService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비밀번호 복호화
|
// 비밀번호 결정 (테스트용 비밀번호가 제공된 경우 그것을 사용, 아니면 저장된 비밀번호 복호화)
|
||||||
const decryptedPassword = await this.getDecryptedPassword(id);
|
let password: string | null;
|
||||||
if (!decryptedPassword) {
|
if (testData?.password) {
|
||||||
|
password = testData.password;
|
||||||
|
console.log(`🔍 [연결테스트] 새로 입력된 비밀번호 사용: ${password.substring(0, 3)}***`);
|
||||||
|
} else {
|
||||||
|
password = await this.getDecryptedPassword(id);
|
||||||
|
console.log(`🔍 [연결테스트] 저장된 비밀번호 사용: ${password ? password.substring(0, 3) + '***' : 'null'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "비밀번호 복호화에 실패했습니다.",
|
message: "비밀번호 복호화에 실패했습니다.",
|
||||||
|
|
@ -358,14 +479,46 @@ export class ExternalDbConnectionService {
|
||||||
port: connection.port,
|
port: connection.port,
|
||||||
database: connection.database_name,
|
database: connection.database_name,
|
||||||
user: connection.username,
|
user: connection.username,
|
||||||
password: decryptedPassword,
|
password: password,
|
||||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
};
|
};
|
||||||
|
|
||||||
// DbConnectionManager를 통한 연결 테스트
|
// 연결 테스트용 임시 커넥터 생성 (캐시 사용하지 않음)
|
||||||
return await DbConnectionManager.testConnection(id, connection.db_type, config);
|
let connector: any;
|
||||||
|
switch (connection.db_type.toLowerCase()) {
|
||||||
|
case 'postgresql':
|
||||||
|
const { PostgreSQLConnector } = await import('../database/PostgreSQLConnector');
|
||||||
|
connector = new PostgreSQLConnector(config);
|
||||||
|
break;
|
||||||
|
case 'oracle':
|
||||||
|
const { OracleConnector } = await import('../database/OracleConnector');
|
||||||
|
connector = new OracleConnector(config);
|
||||||
|
break;
|
||||||
|
case 'mariadb':
|
||||||
|
case 'mysql':
|
||||||
|
const { MariaDBConnector } = await import('../database/MariaDBConnector');
|
||||||
|
connector = new MariaDBConnector(config);
|
||||||
|
break;
|
||||||
|
case 'mssql':
|
||||||
|
const { MSSQLConnector } = await import('../database/MSSQLConnector');
|
||||||
|
connector = new MSSQLConnector(config);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`지원하지 않는 데이터베이스 타입: ${connection.db_type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}`);
|
||||||
|
|
||||||
|
const testResult = await connector.testConnection();
|
||||||
|
console.log(`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: testResult.success,
|
||||||
|
message: testResult.message,
|
||||||
|
details: testResult.details
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -416,7 +569,7 @@ export class ExternalDbConnectionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 타입 유효성 검사
|
// DB 타입 유효성 검사
|
||||||
const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite"];
|
const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite", "mariadb"];
|
||||||
if (!validDbTypes.includes(data.db_type)) {
|
if (!validDbTypes.includes(data.db_type)) {
|
||||||
throw new Error("지원하지 않는 DB 타입입니다.");
|
throw new Error("지원하지 않는 DB 타입입니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -487,8 +640,9 @@ export class ExternalDbConnectionService {
|
||||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
};
|
};
|
||||||
|
|
||||||
// DbConnectionManager를 통한 쿼리 실행
|
// DatabaseConnectorFactory를 통한 쿼리 실행
|
||||||
const result = await DbConnectionManager.executeQuery(id, connection.db_type, config, query);
|
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id);
|
||||||
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -595,8 +749,9 @@ export class ExternalDbConnectionService {
|
||||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
};
|
};
|
||||||
|
|
||||||
// DbConnectionManager를 통한 테이블 목록 조회
|
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
||||||
const tables = await DbConnectionManager.getTables(id, connection.db_type, config);
|
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id);
|
||||||
|
const tables = await connector.getTables();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -676,4 +831,58 @@ export class ExternalDbConnectionService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 테이블의 컬럼 정보 조회
|
||||||
|
*/
|
||||||
|
static async getTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<any[]>> {
|
||||||
|
let client: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connection = await this.getConnectionById(connectionId);
|
||||||
|
if (!connection.success || !connection.data) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "연결 정보를 찾을 수 없습니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionData = connection.data;
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const decryptedPassword = PasswordEncryption.decrypt(connectionData.password);
|
||||||
|
|
||||||
|
// 연결 설정 준비
|
||||||
|
const config = {
|
||||||
|
host: connectionData.host,
|
||||||
|
port: connectionData.port,
|
||||||
|
database: connectionData.database_name,
|
||||||
|
user: connectionData.username, // ConnectionConfig에서는 user 사용
|
||||||
|
password: decryptedPassword,
|
||||||
|
connectionTimeoutMillis: connectionData.connection_timeout != null ? connectionData.connection_timeout * 1000 : undefined,
|
||||||
|
queryTimeoutMillis: connectionData.query_timeout != null ? connectionData.query_timeout * 1000 : undefined,
|
||||||
|
ssl: connectionData.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 데이터베이스 타입에 따른 커넥터 생성
|
||||||
|
const connector = await DatabaseConnectorFactory.createConnector(connectionData.db_type, config, connectionId);
|
||||||
|
|
||||||
|
// 컬럼 정보 조회
|
||||||
|
const columns = await connector.getColumns(tableName);
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: columns,
|
||||||
|
message: "컬럼 정보를 조회했습니다."
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 정보 조회 오류:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
// 배치 실행 로그 타입 정의
|
||||||
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
|
export interface BatchExecutionLog {
|
||||||
|
id?: number;
|
||||||
|
batch_config_id: number;
|
||||||
|
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||||
|
start_time: Date;
|
||||||
|
end_time?: Date | null;
|
||||||
|
duration_ms?: number | null;
|
||||||
|
total_records?: number | null;
|
||||||
|
success_records?: number | null;
|
||||||
|
failed_records?: number | null;
|
||||||
|
error_message?: string | null;
|
||||||
|
error_details?: string | null;
|
||||||
|
server_name?: string | null;
|
||||||
|
process_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBatchExecutionLogRequest {
|
||||||
|
batch_config_id: number;
|
||||||
|
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||||
|
start_time?: Date;
|
||||||
|
end_time?: Date | null;
|
||||||
|
duration_ms?: number | null;
|
||||||
|
total_records?: number | null;
|
||||||
|
success_records?: number | null;
|
||||||
|
failed_records?: number | null;
|
||||||
|
error_message?: string | null;
|
||||||
|
error_details?: string | null;
|
||||||
|
server_name?: string | null;
|
||||||
|
process_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateBatchExecutionLogRequest {
|
||||||
|
execution_status?: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||||
|
end_time?: Date | null;
|
||||||
|
duration_ms?: number | null;
|
||||||
|
total_records?: number | null;
|
||||||
|
success_records?: number | null;
|
||||||
|
failed_records?: number | null;
|
||||||
|
error_message?: string | null;
|
||||||
|
error_details?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchExecutionLogFilter {
|
||||||
|
batch_config_id?: number;
|
||||||
|
execution_status?: string;
|
||||||
|
start_date?: Date;
|
||||||
|
end_date?: Date;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchExecutionLogWithConfig extends BatchExecutionLog {
|
||||||
|
batch_config?: {
|
||||||
|
id: number;
|
||||||
|
batch_name: string;
|
||||||
|
description?: string | null;
|
||||||
|
cron_schedule: string;
|
||||||
|
is_active?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
// 배치 관리 관련 타입 정의
|
||||||
|
// 작성일: 2024-12-23
|
||||||
|
|
||||||
|
export interface BatchJob {
|
||||||
|
id?: number;
|
||||||
|
job_name: string;
|
||||||
|
description?: string | null;
|
||||||
|
job_type: string;
|
||||||
|
schedule_cron?: string | null;
|
||||||
|
is_active: string; // 'Y' | 'N'
|
||||||
|
config_json?: Record<string, any> | null;
|
||||||
|
last_executed_at?: Date | null;
|
||||||
|
next_execution_at?: Date | null;
|
||||||
|
execution_count: number;
|
||||||
|
success_count: number;
|
||||||
|
failure_count: number;
|
||||||
|
created_date?: Date | null;
|
||||||
|
created_by?: string | null;
|
||||||
|
updated_date?: Date | null;
|
||||||
|
updated_by?: string | null;
|
||||||
|
company_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchJobFilter {
|
||||||
|
job_name?: string;
|
||||||
|
job_type?: string;
|
||||||
|
is_active?: string;
|
||||||
|
company_code?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchExecution {
|
||||||
|
id?: number;
|
||||||
|
job_id: number;
|
||||||
|
execution_status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
started_at?: Date;
|
||||||
|
completed_at?: Date;
|
||||||
|
execution_time_ms?: number;
|
||||||
|
result_data?: Record<string, any>;
|
||||||
|
error_message?: string;
|
||||||
|
log_details?: string;
|
||||||
|
created_date?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchSchedule {
|
||||||
|
id?: number;
|
||||||
|
job_id: number;
|
||||||
|
schedule_name: string;
|
||||||
|
cron_expression: string;
|
||||||
|
timezone?: string;
|
||||||
|
is_active: string;
|
||||||
|
last_triggered_at?: Date;
|
||||||
|
next_trigger_at?: Date;
|
||||||
|
created_date?: Date;
|
||||||
|
created_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchMonitoring {
|
||||||
|
total_jobs: number;
|
||||||
|
active_jobs: number;
|
||||||
|
running_jobs: number;
|
||||||
|
failed_jobs_today: number;
|
||||||
|
successful_jobs_today: number;
|
||||||
|
recent_executions: BatchExecution[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배치 작업 타입 옵션
|
||||||
|
export const BATCH_JOB_TYPE_OPTIONS = [
|
||||||
|
{ value: 'collection', label: '데이터 수집' },
|
||||||
|
{ value: 'sync', label: '데이터 동기화' },
|
||||||
|
{ value: 'cleanup', label: '데이터 정리' },
|
||||||
|
{ value: 'custom', label: '사용자 정의' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 실행 상태 옵션
|
||||||
|
export const EXECUTION_STATUS_OPTIONS = [
|
||||||
|
{ value: 'pending', label: '대기 중' },
|
||||||
|
{ value: 'running', label: '실행 중' },
|
||||||
|
{ value: 'completed', label: '완료' },
|
||||||
|
{ value: 'failed', label: '실패' },
|
||||||
|
{ value: 'cancelled', label: '취소됨' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 스케줄 프리셋
|
||||||
|
export const SCHEDULE_PRESETS = [
|
||||||
|
{ value: '0 */1 * * *', label: '매시간' },
|
||||||
|
{ value: '0 0 */6 * *', label: '6시간마다' },
|
||||||
|
{ value: '0 0 * * *', label: '매일 자정' },
|
||||||
|
{ value: '0 0 * * 0', label: '매주 일요일' },
|
||||||
|
{ value: '0 0 1 * *', label: '매월 1일' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,8 @@ export interface BatchMapping {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchConfigFilter {
|
export interface BatchConfigFilter {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
batch_name?: string;
|
batch_name?: string;
|
||||||
is_active?: string;
|
is_active?: string;
|
||||||
company_code?: string;
|
company_code?: string;
|
||||||
|
|
@ -55,20 +57,43 @@ export interface ConnectionInfo {
|
||||||
export interface TableInfo {
|
export interface TableInfo {
|
||||||
table_name: string;
|
table_name: string;
|
||||||
columns: ColumnInfo[];
|
columns: ColumnInfo[];
|
||||||
|
description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ColumnInfo {
|
export interface ColumnInfo {
|
||||||
column_name: string;
|
column_name: string;
|
||||||
data_type: string;
|
data_type: string;
|
||||||
is_nullable?: boolean;
|
is_nullable?: string;
|
||||||
column_default?: string;
|
column_default?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchMappingRequest {
|
export interface BatchMappingRequest {
|
||||||
batch_name: string;
|
from_connection_type: 'internal' | 'external';
|
||||||
|
from_connection_id?: number;
|
||||||
|
from_table_name: string;
|
||||||
|
from_column_name: string;
|
||||||
|
from_column_type?: string;
|
||||||
|
to_connection_type: 'internal' | 'external';
|
||||||
|
to_connection_id?: number;
|
||||||
|
to_table_name: string;
|
||||||
|
to_column_name: string;
|
||||||
|
to_column_type?: string;
|
||||||
|
mapping_order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBatchConfigRequest {
|
||||||
|
batchName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
cron_schedule: string;
|
cronSchedule: string;
|
||||||
mappings: BatchMapping[];
|
mappings: BatchMappingRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateBatchConfigRequest {
|
||||||
|
batchName?: string;
|
||||||
|
description?: string;
|
||||||
|
cronSchedule?: string;
|
||||||
|
mappings?: BatchMappingRequest[];
|
||||||
|
isActive?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchValidationResult {
|
export interface BatchValidationResult {
|
||||||
|
|
@ -82,4 +107,10 @@ export interface ApiResponse<T> {
|
||||||
data?: T;
|
data?: T;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
pagination?: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
declare module 'oracledb' {
|
||||||
|
export interface Connection {
|
||||||
|
execute(sql: string, bindParams?: any, options?: any): Promise<any>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionConfig {
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
connectString: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnection(config: ConnectionConfig): Promise<Connection>;
|
||||||
|
export function createPool(config: any): Promise<any>;
|
||||||
|
export function getPool(): any;
|
||||||
|
export function close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,6 @@
|
||||||
"@/validators/*": ["src/validators/*"]
|
"@/validators/*": ["src/validators/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*", "src/types/**/*.d.ts"],
|
||||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,507 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Trash2, Plus, ArrowRight, Save, RefreshCw } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
BatchManagementAPI,
|
||||||
|
BatchConnectionInfo,
|
||||||
|
BatchColumnInfo,
|
||||||
|
} from "@/lib/api/batchManagement";
|
||||||
|
|
||||||
|
interface MappingState {
|
||||||
|
from: {
|
||||||
|
connection: BatchConnectionInfo | null;
|
||||||
|
table: string;
|
||||||
|
column: BatchColumnInfo | null;
|
||||||
|
} | null;
|
||||||
|
to: {
|
||||||
|
connection: BatchConnectionInfo | null;
|
||||||
|
table: string;
|
||||||
|
column: BatchColumnInfo | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BatchManagementNewPage() {
|
||||||
|
// 기본 상태
|
||||||
|
const [batchName, setBatchName] = useState("");
|
||||||
|
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
// 커넥션 및 테이블 데이터
|
||||||
|
const [connections, setConnections] = useState<BatchConnectionInfo[]>([]);
|
||||||
|
const [fromTables, setFromTables] = useState<string[]>([]);
|
||||||
|
const [toTables, setToTables] = useState<string[]>([]);
|
||||||
|
const [fromColumns, setFromColumns] = useState<BatchColumnInfo[]>([]);
|
||||||
|
const [toColumns, setToColumns] = useState<BatchColumnInfo[]>([]);
|
||||||
|
|
||||||
|
// 선택된 상태
|
||||||
|
const [fromConnection, setFromConnection] = useState<BatchConnectionInfo | null>(null);
|
||||||
|
const [toConnection, setToConnection] = useState<BatchConnectionInfo | null>(null);
|
||||||
|
const [fromTable, setFromTable] = useState("");
|
||||||
|
const [toTable, setToTable] = useState("");
|
||||||
|
const [selectedFromColumn, setSelectedFromColumn] = useState<BatchColumnInfo | null>(null);
|
||||||
|
|
||||||
|
// 매핑 상태
|
||||||
|
const [mappings, setMappings] = useState<MappingState[]>([]);
|
||||||
|
|
||||||
|
// 초기 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadConnections();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 커넥션 목록 로드
|
||||||
|
const loadConnections = async () => {
|
||||||
|
try {
|
||||||
|
const data = await BatchManagementAPI.getAvailableConnections();
|
||||||
|
setConnections(Array.isArray(data) ? data : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("커넥션 목록 로드 오류:", error);
|
||||||
|
toast.error("커넥션 목록을 불러오는데 실패했습니다.");
|
||||||
|
setConnections([]); // 오류 시 빈 배열로 설정
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// FROM 커넥션 변경 시 테이블 로드
|
||||||
|
const handleFromConnectionChange = async (connectionId: string) => {
|
||||||
|
if (connectionId === 'unknown') return;
|
||||||
|
|
||||||
|
const connection = connections.find((c: BatchConnectionInfo) =>
|
||||||
|
c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!connection) return;
|
||||||
|
|
||||||
|
setFromConnection(connection);
|
||||||
|
setFromTable("");
|
||||||
|
setFromColumns([]);
|
||||||
|
setSelectedFromColumn(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tables = await BatchManagementAPI.getTablesFromConnection(
|
||||||
|
connection.type,
|
||||||
|
connection.id
|
||||||
|
);
|
||||||
|
setFromTables(Array.isArray(tables) ? tables : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("FROM 테이블 목록 로드 오류:", error);
|
||||||
|
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||||
|
setFromTables([]); // 오류 시 빈 배열로 설정
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// TO 커넥션 변경 시 테이블 로드
|
||||||
|
const handleToConnectionChange = async (connectionId: string) => {
|
||||||
|
if (connectionId === 'unknown') return;
|
||||||
|
|
||||||
|
const connection = connections.find((c: BatchConnectionInfo) =>
|
||||||
|
c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!connection) return;
|
||||||
|
|
||||||
|
setToConnection(connection);
|
||||||
|
setToTable("");
|
||||||
|
setToColumns([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tables = await BatchManagementAPI.getTablesFromConnection(
|
||||||
|
connection.type,
|
||||||
|
connection.id
|
||||||
|
);
|
||||||
|
setToTables(Array.isArray(tables) ? tables : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("TO 테이블 목록 로드 오류:", error);
|
||||||
|
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||||
|
setToTables([]); // 오류 시 빈 배열로 설정
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// FROM 테이블 변경 시 컬럼 로드
|
||||||
|
const handleFromTableChange = async (tableName: string) => {
|
||||||
|
if (!fromConnection) return;
|
||||||
|
|
||||||
|
setFromTable(tableName);
|
||||||
|
setSelectedFromColumn(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columns = await BatchManagementAPI.getTableColumns(
|
||||||
|
fromConnection.type,
|
||||||
|
tableName,
|
||||||
|
fromConnection.id
|
||||||
|
);
|
||||||
|
setFromColumns(Array.isArray(columns) ? columns : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("FROM 컬럼 목록 로드 오류:", error);
|
||||||
|
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||||
|
setFromColumns([]); // 오류 시 빈 배열로 설정
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// TO 테이블 변경 시 컬럼 로드
|
||||||
|
const handleToTableChange = async (tableName: string) => {
|
||||||
|
if (!toConnection) return;
|
||||||
|
|
||||||
|
console.log("TO 테이블 변경:", {
|
||||||
|
tableName,
|
||||||
|
connectionType: toConnection.type,
|
||||||
|
connectionId: toConnection.id
|
||||||
|
});
|
||||||
|
|
||||||
|
setToTable(tableName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columns = await BatchManagementAPI.getTableColumns(
|
||||||
|
toConnection.type,
|
||||||
|
tableName,
|
||||||
|
toConnection.id
|
||||||
|
);
|
||||||
|
console.log("TO 컬럼 목록 로드 성공:", columns);
|
||||||
|
setToColumns(Array.isArray(columns) ? columns : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("TO 컬럼 목록 로드 오류:", error);
|
||||||
|
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||||
|
setToColumns([]); // 오류 시 빈 배열로 설정
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// FROM 컬럼 클릭
|
||||||
|
const handleFromColumnClick = (column: BatchColumnInfo) => {
|
||||||
|
setSelectedFromColumn(column);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TO 컬럼 클릭 (매핑 생성)
|
||||||
|
const handleToColumnClick = (column: BatchColumnInfo) => {
|
||||||
|
if (!selectedFromColumn || !fromConnection || !toConnection) {
|
||||||
|
toast.error("FROM 컬럼을 먼저 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// N:1 매핑 방지 (여러 FROM 컬럼이 같은 TO 컬럼에 매핑되는 것 방지)
|
||||||
|
const isAlreadyMapped = mappings.some(mapping =>
|
||||||
|
mapping.to?.connection?.type === toConnection.type &&
|
||||||
|
mapping.to?.connection?.id === toConnection.id &&
|
||||||
|
mapping.to?.table === toTable &&
|
||||||
|
mapping.to?.column?.column_name === column.column_name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isAlreadyMapped) {
|
||||||
|
toast.error("이미 매핑된 TO 컬럼입니다. N:1 매핑은 허용되지 않습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 매핑 추가
|
||||||
|
const newMapping: MappingState = {
|
||||||
|
from: {
|
||||||
|
connection: fromConnection,
|
||||||
|
table: fromTable,
|
||||||
|
column: selectedFromColumn
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
connection: toConnection,
|
||||||
|
table: toTable,
|
||||||
|
column: column
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setMappings([...mappings, newMapping]);
|
||||||
|
setSelectedFromColumn(null);
|
||||||
|
toast.success("매핑이 추가되었습니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 삭제
|
||||||
|
const removeMapping = (index: number) => {
|
||||||
|
setMappings(mappings.filter((_, i) => i !== index));
|
||||||
|
toast.success("매핑이 삭제되었습니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼이 이미 매핑되었는지 확인
|
||||||
|
const isColumnMapped = (
|
||||||
|
connectionType: 'internal' | 'external',
|
||||||
|
connectionId: number | undefined,
|
||||||
|
tableName: string,
|
||||||
|
columnName: string
|
||||||
|
): boolean => {
|
||||||
|
return mappings.some(mapping =>
|
||||||
|
mapping.to?.connection?.type === connectionType &&
|
||||||
|
mapping.to?.connection?.id === connectionId &&
|
||||||
|
mapping.to?.table === tableName &&
|
||||||
|
mapping.to?.column?.column_name === columnName
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 배치 설정 저장
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!batchName.trim()) {
|
||||||
|
toast.error("배치명을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappings.length === 0) {
|
||||||
|
toast.error("최소 하나의 매핑을 설정해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 실제 저장 로직 구현
|
||||||
|
console.log("배치 설정 저장:", {
|
||||||
|
batchName,
|
||||||
|
cronSchedule,
|
||||||
|
description,
|
||||||
|
mappings
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("배치 설정이 저장되었습니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-3xl font-bold">배치관리 시스템 (새 버전)</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={loadConnections} variant="outline">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>기본 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="batchName">배치명 *</Label>
|
||||||
|
<Input
|
||||||
|
id="batchName"
|
||||||
|
value={batchName}
|
||||||
|
onChange={(e) => setBatchName(e.target.value)}
|
||||||
|
placeholder="배치명을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="cronSchedule">실행 스케줄 *</Label>
|
||||||
|
<Input
|
||||||
|
id="cronSchedule"
|
||||||
|
value={cronSchedule}
|
||||||
|
onChange={(e) => setCronSchedule(e.target.value)}
|
||||||
|
placeholder="0 12 * * *"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">설명</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="배치에 대한 설명을 입력하세요"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* FROM 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
FROM (소스)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* FROM 커넥션 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label>커넥션 선택</Label>
|
||||||
|
<Select onValueChange={handleFromConnectionChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.isArray(connections) && connections.map((conn: BatchConnectionInfo) => (
|
||||||
|
<SelectItem
|
||||||
|
key={conn.type === 'internal' ? 'internal' : conn.id || 'unknown'}
|
||||||
|
value={conn.type === 'internal' ? 'internal' : (conn.id ? conn.id.toString() : 'unknown')}
|
||||||
|
>
|
||||||
|
{conn.name} ({conn.db_type?.toUpperCase()})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FROM 테이블 선택 */}
|
||||||
|
{fromConnection && (
|
||||||
|
<div>
|
||||||
|
<Label>테이블 선택</Label>
|
||||||
|
<Select onValueChange={handleFromTableChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="테이블을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.isArray(fromTables) && fromTables.map((table: string) => (
|
||||||
|
<SelectItem key={table} value={table}>
|
||||||
|
{table.toUpperCase()}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FROM 컬럼 목록 */}
|
||||||
|
{fromTable && (
|
||||||
|
<div>
|
||||||
|
<Label>컬럼 선택 (클릭하여 선택)</Label>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{Array.isArray(fromColumns) && fromColumns.map((column: BatchColumnInfo) => (
|
||||||
|
<div
|
||||||
|
key={column.column_name}
|
||||||
|
className={`p-3 border-2 rounded cursor-pointer transition-all ${
|
||||||
|
selectedFromColumn?.column_name === column.column_name
|
||||||
|
? 'border-blue-500 bg-blue-50 font-semibold'
|
||||||
|
: 'hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleFromColumnClick(column)}
|
||||||
|
>
|
||||||
|
<p className="font-medium">{column.column_name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{column.data_type}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* TO 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
TO (대상)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* TO 커넥션 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label>커넥션 선택</Label>
|
||||||
|
<Select onValueChange={handleToConnectionChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.isArray(connections) && connections.map((conn: BatchConnectionInfo) => (
|
||||||
|
<SelectItem
|
||||||
|
key={conn.type === 'internal' ? 'internal' : conn.id || 'unknown'}
|
||||||
|
value={conn.type === 'internal' ? 'internal' : (conn.id ? conn.id.toString() : 'unknown')}
|
||||||
|
>
|
||||||
|
{conn.name} ({conn.db_type?.toUpperCase()})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TO 테이블 선택 */}
|
||||||
|
{toConnection && (
|
||||||
|
<div>
|
||||||
|
<Label>테이블 선택</Label>
|
||||||
|
<Select onValueChange={handleToTableChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="테이블을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.isArray(toTables) && toTables.map((table: string) => (
|
||||||
|
<SelectItem key={table} value={table}>
|
||||||
|
{table.toUpperCase()}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TO 컬럼 목록 */}
|
||||||
|
{toTable && (
|
||||||
|
<div>
|
||||||
|
<Label>컬럼 선택 (클릭하여 매핑)</Label>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{Array.isArray(toColumns) && toColumns.map((column: BatchColumnInfo) => (
|
||||||
|
<div
|
||||||
|
key={column.column_name}
|
||||||
|
className={`p-3 border-2 rounded cursor-pointer transition-all ${
|
||||||
|
isColumnMapped(
|
||||||
|
toConnection!.type,
|
||||||
|
toConnection!.id,
|
||||||
|
toTable,
|
||||||
|
column.column_name
|
||||||
|
)
|
||||||
|
? 'bg-red-100 text-red-700 cursor-not-allowed opacity-60'
|
||||||
|
: selectedFromColumn
|
||||||
|
? 'hover:bg-red-50'
|
||||||
|
: 'cursor-not-allowed opacity-60'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleToColumnClick(column)}
|
||||||
|
>
|
||||||
|
<p className="font-medium">{column.column_name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{column.data_type}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 매핑 목록 */}
|
||||||
|
{mappings.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>매핑 목록 ({mappings.length}개)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mappings.map((mapping, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge className="border">
|
||||||
|
{mapping.from?.connection?.name}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium">{mapping.from?.table}.{mapping.from?.column?.column_name}</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
<Badge className="border">
|
||||||
|
{mapping.to?.connection?.name}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium">{mapping.to?.table}.{mapping.to?.column?.column_name}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeMapping(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,433 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
MoreHorizontal,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
BarChart3
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||||
|
import BatchJobModal from "@/components/admin/BatchJobModal";
|
||||||
|
|
||||||
|
export default function BatchManagementPage() {
|
||||||
|
const [jobs, setJobs] = useState<BatchJob[]>([]);
|
||||||
|
const [filteredJobs, setFilteredJobs] = useState<BatchJob[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [jobTypes, setJobTypes] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [selectedJob, setSelectedJob] = useState<BatchJob | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadJobs();
|
||||||
|
loadJobTypes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
filterJobs();
|
||||||
|
}, [jobs, searchTerm, statusFilter, typeFilter]);
|
||||||
|
|
||||||
|
const loadJobs = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await BatchAPI.getBatchJobs();
|
||||||
|
setJobs(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 작업 목록 조회 오류:", error);
|
||||||
|
toast.error("배치 작업 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadJobTypes = async () => {
|
||||||
|
try {
|
||||||
|
const types = await BatchAPI.getSupportedJobTypes();
|
||||||
|
setJobTypes(types);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("작업 타입 조회 오류:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterJobs = () => {
|
||||||
|
let filtered = jobs;
|
||||||
|
|
||||||
|
// 검색어 필터
|
||||||
|
if (searchTerm) {
|
||||||
|
filtered = filtered.filter(job =>
|
||||||
|
job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
job.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 필터
|
||||||
|
if (statusFilter !== "all") {
|
||||||
|
filtered = filtered.filter(job => job.is_active === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타입 필터
|
||||||
|
if (typeFilter !== "all") {
|
||||||
|
filtered = filtered.filter(job => job.job_type === typeFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredJobs(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setSelectedJob(null);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (job: BatchJob) => {
|
||||||
|
setSelectedJob(job);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (job: BatchJob) => {
|
||||||
|
if (!confirm(`"${job.job_name}" 배치 작업을 삭제하시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await BatchAPI.deleteBatchJob(job.id!);
|
||||||
|
toast.success("배치 작업이 삭제되었습니다.");
|
||||||
|
loadJobs();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 작업 삭제 오류:", error);
|
||||||
|
toast.error("배치 작업 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecute = async (job: BatchJob) => {
|
||||||
|
try {
|
||||||
|
await BatchAPI.executeBatchJob(job.id!);
|
||||||
|
toast.success(`"${job.job_name}" 배치 작업을 실행했습니다.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 작업 실행 오류:", error);
|
||||||
|
toast.error("배치 작업 실행에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalSave = () => {
|
||||||
|
loadJobs();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (isActive: string) => {
|
||||||
|
return isActive === "Y" ? (
|
||||||
|
<Badge className="bg-green-100 text-green-800">활성</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-100 text-red-800">비활성</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeBadge = (type: string) => {
|
||||||
|
const option = jobTypes.find(opt => opt.value === type);
|
||||||
|
const colors = {
|
||||||
|
collection: "bg-blue-100 text-blue-800",
|
||||||
|
sync: "bg-purple-100 text-purple-800",
|
||||||
|
cleanup: "bg-orange-100 text-orange-800",
|
||||||
|
custom: "bg-gray-100 text-gray-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
collection: "📥",
|
||||||
|
sync: "🔄",
|
||||||
|
cleanup: "🧹",
|
||||||
|
custom: "⚙️",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge className={colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800"}>
|
||||||
|
<span className="mr-1">{icons[type as keyof typeof icons] || "📋"}</span>
|
||||||
|
{option?.label || type}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSuccessRate = (job: BatchJob) => {
|
||||||
|
if (job.execution_count === 0) return 100;
|
||||||
|
return Math.round((job.success_count / job.execution_count) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">배치 관리</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
스케줄된 배치 작업을 관리하고 실행 상태를 모니터링합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => window.open('/admin/monitoring', '_blank')}>
|
||||||
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
|
모니터링
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
새 배치 작업
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">총 작업</CardTitle>
|
||||||
|
<div className="text-2xl">📋</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{jobs.length}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
활성: {jobs.filter(j => j.is_active === 'Y').length}개
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">총 실행</CardTitle>
|
||||||
|
<div className="text-2xl">▶️</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{jobs.reduce((sum, job) => sum + job.execution_count, 0)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">누적 실행 횟수</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
||||||
|
<div className="text-2xl">✅</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{jobs.reduce((sum, job) => sum + job.success_count, 0)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">총 성공 횟수</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
||||||
|
<div className="text-2xl">❌</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-red-600">
|
||||||
|
{jobs.reduce((sum, job) => sum + job.failure_count, 0)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">총 실패 횟수</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 및 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>필터 및 검색</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="작업명, 설명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="상태" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="Y">활성</SelectItem>
|
||||||
|
<SelectItem value="N">비활성</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="작업 타입" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체 타입</SelectItem>
|
||||||
|
{jobTypes.map((type) => (
|
||||||
|
<SelectItem key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={loadJobs} disabled={isLoading}>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 배치 작업 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>배치 작업 목록 ({filteredJobs.length}개)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
||||||
|
<p>배치 작업을 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredJobs.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>작업명</TableHead>
|
||||||
|
<TableHead>타입</TableHead>
|
||||||
|
<TableHead>스케줄</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead>실행 통계</TableHead>
|
||||||
|
<TableHead>성공률</TableHead>
|
||||||
|
<TableHead>마지막 실행</TableHead>
|
||||||
|
<TableHead>작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredJobs.map((job) => (
|
||||||
|
<TableRow key={job.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{job.job_name}</div>
|
||||||
|
{job.description && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{job.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{getTypeBadge(job.job_type)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{job.schedule_cron || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{getStatusBadge(job.is_active)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>총 {job.execution_count}회</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
성공 {job.success_count} / 실패 {job.failure_count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`text-sm font-medium ${
|
||||||
|
getSuccessRate(job) >= 90 ? 'text-green-600' :
|
||||||
|
getSuccessRate(job) >= 70 ? 'text-yellow-600' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{getSuccessRate(job)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{job.last_executed_at
|
||||||
|
? new Date(job.last_executed_at).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleEdit(job)}>
|
||||||
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
|
수정
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExecute(job)}
|
||||||
|
disabled={job.is_active !== "Y"}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
실행
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleDelete(job)}>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
삭제
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 배치 작업 모달 */}
|
||||||
|
<BatchJobModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSave={handleModalSave}
|
||||||
|
job={selectedJob}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,544 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
BatchAPI,
|
||||||
|
BatchMapping,
|
||||||
|
ConnectionInfo,
|
||||||
|
ColumnInfo,
|
||||||
|
BatchMappingRequest,
|
||||||
|
} from "@/lib/api/batch";
|
||||||
|
|
||||||
|
export default function BatchCreatePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 기본 정보
|
||||||
|
const [batchName, setBatchName] = useState("");
|
||||||
|
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
// 커넥션 및 데이터
|
||||||
|
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
|
||||||
|
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
|
||||||
|
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
|
||||||
|
const [fromTables, setFromTables] = useState<string[]>([]);
|
||||||
|
const [toTables, setToTables] = useState<string[]>([]);
|
||||||
|
const [fromTable, setFromTable] = useState("");
|
||||||
|
const [toTable, setToTable] = useState("");
|
||||||
|
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
|
||||||
|
// 매핑 상태
|
||||||
|
const [selectedFromColumn, setSelectedFromColumn] = useState<ColumnInfo | null>(null);
|
||||||
|
const [mappings, setMappings] = useState<BatchMapping[]>([]);
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingConnections, setLoadingConnections] = useState(false);
|
||||||
|
|
||||||
|
// 커넥션 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadConnections();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConnections = async () => {
|
||||||
|
setLoadingConnections(true);
|
||||||
|
try {
|
||||||
|
const data = await BatchAPI.getConnections();
|
||||||
|
setConnections(Array.isArray(data) ? data : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("커넥션 로드 실패:", error);
|
||||||
|
toast.error("커넥션 목록을 불러오는데 실패했습니다.");
|
||||||
|
setConnections([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingConnections(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// FROM 커넥션 변경
|
||||||
|
const handleFromConnectionChange = async (connectionId: string) => {
|
||||||
|
if (connectionId === 'unknown') return;
|
||||||
|
|
||||||
|
const connection = connections.find(conn => {
|
||||||
|
if (conn.type === 'internal') {
|
||||||
|
return connectionId === 'internal';
|
||||||
|
}
|
||||||
|
return conn.id ? conn.id.toString() === connectionId : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection) return;
|
||||||
|
|
||||||
|
setFromConnection(connection);
|
||||||
|
setFromTable("");
|
||||||
|
setFromTables([]);
|
||||||
|
setFromColumns([]);
|
||||||
|
setSelectedFromColumn(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tables = await BatchAPI.getTablesFromConnection(connection.type, connection.id);
|
||||||
|
setFromTables(Array.isArray(tables) ? tables : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("FROM 테이블 목록 로드 실패:", error);
|
||||||
|
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// TO 커넥션 변경
|
||||||
|
const handleToConnectionChange = async (connectionId: string) => {
|
||||||
|
if (connectionId === 'unknown') return;
|
||||||
|
|
||||||
|
const connection = connections.find(conn => {
|
||||||
|
if (conn.type === 'internal') {
|
||||||
|
return connectionId === 'internal';
|
||||||
|
}
|
||||||
|
return conn.id ? conn.id.toString() === connectionId : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection) return;
|
||||||
|
|
||||||
|
setToConnection(connection);
|
||||||
|
setToTable("");
|
||||||
|
setToTables([]);
|
||||||
|
setToColumns([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tables = await BatchAPI.getTablesFromConnection(connection.type, connection.id);
|
||||||
|
setToTables(Array.isArray(tables) ? tables : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("TO 테이블 목록 로드 실패:", error);
|
||||||
|
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// FROM 테이블 변경
|
||||||
|
const handleFromTableChange = async (tableName: string) => {
|
||||||
|
setFromTable(tableName);
|
||||||
|
setFromColumns([]);
|
||||||
|
setSelectedFromColumn(null);
|
||||||
|
|
||||||
|
if (!fromConnection || !tableName) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columns = await BatchAPI.getTableColumns(fromConnection.type, fromConnection.id, tableName);
|
||||||
|
setFromColumns(Array.isArray(columns) ? columns : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("FROM 컬럼 목록 로드 실패:", error);
|
||||||
|
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// TO 테이블 변경
|
||||||
|
const handleToTableChange = async (tableName: string) => {
|
||||||
|
setToTable(tableName);
|
||||||
|
setToColumns([]);
|
||||||
|
|
||||||
|
if (!toConnection || !tableName) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columns = await BatchAPI.getTableColumns(toConnection.type, toConnection.id, tableName);
|
||||||
|
setToColumns(Array.isArray(columns) ? columns : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("TO 컬럼 목록 로드 실패:", error);
|
||||||
|
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// FROM 컬럼 선택
|
||||||
|
const handleFromColumnClick = (column: ColumnInfo) => {
|
||||||
|
setSelectedFromColumn(column);
|
||||||
|
toast.info(`FROM 컬럼 선택됨: ${column.column_name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TO 컬럼 선택 (매핑 생성)
|
||||||
|
const handleToColumnClick = (toColumn: ColumnInfo) => {
|
||||||
|
if (!selectedFromColumn || !fromConnection || !toConnection) {
|
||||||
|
toast.error("먼저 FROM 컬럼을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// n:1 매핑 검사
|
||||||
|
const toKey = `${toConnection.type}:${toConnection.id || 'internal'}:${toTable}:${toColumn.column_name}`;
|
||||||
|
const existingMapping = mappings.find(mapping => {
|
||||||
|
const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`;
|
||||||
|
return existingToKey === toKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingMapping) {
|
||||||
|
toast.error("동일한 TO 컬럼에 중복 매핑할 수 없습니다. (n:1 매핑 방지)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMapping: BatchMapping = {
|
||||||
|
from_connection_type: fromConnection.type,
|
||||||
|
from_connection_id: fromConnection.id || null,
|
||||||
|
from_table_name: fromTable,
|
||||||
|
from_column_name: selectedFromColumn.column_name,
|
||||||
|
from_column_type: selectedFromColumn.data_type || '',
|
||||||
|
to_connection_type: toConnection.type,
|
||||||
|
to_connection_id: toConnection.id || null,
|
||||||
|
to_table_name: toTable,
|
||||||
|
to_column_name: toColumn.column_name,
|
||||||
|
to_column_type: toColumn.data_type || '',
|
||||||
|
mapping_order: mappings.length + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
setMappings([...mappings, newMapping]);
|
||||||
|
setSelectedFromColumn(null);
|
||||||
|
toast.success(`매핑 생성: ${selectedFromColumn.column_name} → ${toColumn.column_name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 삭제
|
||||||
|
const removeMapping = (index: number) => {
|
||||||
|
const newMappings = mappings.filter((_, i) => i !== index);
|
||||||
|
const reorderedMappings = newMappings.map((mapping, i) => ({
|
||||||
|
...mapping,
|
||||||
|
mapping_order: i + 1
|
||||||
|
}));
|
||||||
|
setMappings(reorderedMappings);
|
||||||
|
toast.success("매핑이 삭제되었습니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 배치 설정 저장
|
||||||
|
const saveBatchConfig = async () => {
|
||||||
|
if (!batchName.trim()) {
|
||||||
|
toast.error("배치명을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cronSchedule.trim()) {
|
||||||
|
toast.error("실행 스케줄을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappings.length === 0) {
|
||||||
|
toast.error("최소 하나 이상의 매핑을 추가해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const request = {
|
||||||
|
batchName: batchName,
|
||||||
|
description: description || undefined,
|
||||||
|
cronSchedule: cronSchedule,
|
||||||
|
mappings: mappings,
|
||||||
|
isActive: true
|
||||||
|
};
|
||||||
|
|
||||||
|
await BatchAPI.createBatchConfig(request);
|
||||||
|
toast.success("배치 설정이 성공적으로 저장되었습니다!");
|
||||||
|
|
||||||
|
// 목록 페이지로 이동
|
||||||
|
router.push("/admin/batchmng");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 설정 저장 실패:", error);
|
||||||
|
toast.error("배치 설정 저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/admin/batchmng")}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span>목록으로</span>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">배치관리 매핑 시스템</h1>
|
||||||
|
<p className="text-muted-foreground">새로운 배치 매핑을 생성합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>기본 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="batchName">배치명 *</Label>
|
||||||
|
<Input
|
||||||
|
id="batchName"
|
||||||
|
value={batchName}
|
||||||
|
onChange={(e) => setBatchName(e.target.value)}
|
||||||
|
placeholder="배치명을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cronSchedule">실행 스케줄 (Cron) *</Label>
|
||||||
|
<Input
|
||||||
|
id="cronSchedule"
|
||||||
|
value={cronSchedule}
|
||||||
|
onChange={(e) => setCronSchedule(e.target.value)}
|
||||||
|
placeholder="0 12 * * * (매일 12시)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">설명</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="배치에 대한 설명을 입력하세요"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 매핑 설정 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* FROM 섹션 */}
|
||||||
|
<Card className="border-green-200">
|
||||||
|
<CardHeader className="bg-green-50">
|
||||||
|
<CardTitle className="text-green-700">FROM (원본 데이터베이스)</CardTitle>
|
||||||
|
<p className="text-sm text-green-600">
|
||||||
|
1단계: 커넥션을 선택하세요 → 2단계: 테이블을 선택하세요 → 3단계: 컬럼을 클릭해서 매핑하세요
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>커넥션 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={fromConnection?.type === 'internal' ? 'internal' : fromConnection?.id?.toString() || ""}
|
||||||
|
onValueChange={handleFromConnectionChange}
|
||||||
|
disabled={loadingConnections}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.isArray(connections) && connections.map((conn) => (
|
||||||
|
<SelectItem
|
||||||
|
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
|
||||||
|
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
|
||||||
|
>
|
||||||
|
{conn.name} ({conn.type})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>테이블 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={fromTable}
|
||||||
|
onValueChange={handleFromTableChange}
|
||||||
|
disabled={!fromConnection}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="테이블을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fromTables.map((table) => (
|
||||||
|
<SelectItem key={table} value={table}>
|
||||||
|
{table}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FROM 컬럼 목록 */}
|
||||||
|
{fromTable && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-blue-600 font-semibold">{fromTable} 테이블</Label>
|
||||||
|
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
||||||
|
{fromColumns.map((column) => (
|
||||||
|
<div
|
||||||
|
key={column.column_name}
|
||||||
|
onClick={() => handleFromColumnClick(column)}
|
||||||
|
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||||
|
selectedFromColumn?.column_name === column.column_name
|
||||||
|
? 'bg-green-100 border-green-300'
|
||||||
|
: 'hover:bg-gray-50 border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{column.column_name}</div>
|
||||||
|
<div className="text-sm text-gray-500">{column.data_type}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{fromColumns.length === 0 && fromTable && (
|
||||||
|
<div className="text-center text-gray-500 py-4">
|
||||||
|
컬럼을 불러오는 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* TO 섹션 */}
|
||||||
|
<Card className="border-red-200">
|
||||||
|
<CardHeader className="bg-red-50">
|
||||||
|
<CardTitle className="text-red-700">TO (대상 데이터베이스)</CardTitle>
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>커넥션 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={toConnection?.type === 'internal' ? 'internal' : toConnection?.id?.toString() || ""}
|
||||||
|
onValueChange={handleToConnectionChange}
|
||||||
|
disabled={loadingConnections}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.isArray(connections) && connections.map((conn) => (
|
||||||
|
<SelectItem
|
||||||
|
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
|
||||||
|
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
|
||||||
|
>
|
||||||
|
{conn.name} ({conn.type})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>테이블 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={toTable}
|
||||||
|
onValueChange={handleToTableChange}
|
||||||
|
disabled={!toConnection}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="테이블을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{toTables.map((table) => (
|
||||||
|
<SelectItem key={table} value={table}>
|
||||||
|
{table}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TO 컬럼 목록 */}
|
||||||
|
{toTable && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-blue-600 font-semibold">{toTable} 테이블</Label>
|
||||||
|
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
||||||
|
{toColumns.map((column) => (
|
||||||
|
<div
|
||||||
|
key={column.column_name}
|
||||||
|
onClick={() => handleToColumnClick(column)}
|
||||||
|
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||||
|
selectedFromColumn
|
||||||
|
? 'hover:bg-red-50 border-gray-200'
|
||||||
|
: 'bg-gray-100 border-gray-300 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{column.column_name}</div>
|
||||||
|
<div className="text-sm text-gray-500">{column.data_type}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{toColumns.length === 0 && toTable && (
|
||||||
|
<div className="text-center text-gray-500 py-4">
|
||||||
|
컬럼을 불러오는 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 매핑 현황 */}
|
||||||
|
{mappings.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>컬럼 매핑 현황 ({mappings.length}개)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mappings.map((mapping, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-4 border rounded-lg bg-yellow-50">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">
|
||||||
|
{mapping.from_table_name}.{mapping.from_column_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">
|
||||||
|
{mapping.from_column_type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">
|
||||||
|
{mapping.to_table_name}.{mapping.to_column_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">
|
||||||
|
{mapping.to_column_type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeMapping(index)}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 저장 버튼 */}
|
||||||
|
<div className="flex justify-end space-x-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/admin/batchmng")}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={saveBatchConfig}
|
||||||
|
disabled={loading || mappings.length === 0}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>{loading ? "저장 중..." : "배치 매핑 저장"}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,576 +4,353 @@ import React, { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Trash2, Plus, ArrowRight, Save, RefreshCw } from "lucide-react";
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
Clock,
|
||||||
|
Database,
|
||||||
|
ArrowRight
|
||||||
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
BatchAPI,
|
BatchAPI,
|
||||||
BatchConfig,
|
BatchConfig,
|
||||||
BatchMapping,
|
BatchMapping,
|
||||||
ConnectionInfo,
|
|
||||||
ColumnInfo,
|
|
||||||
BatchMappingRequest,
|
|
||||||
} from "@/lib/api/batch";
|
} from "@/lib/api/batch";
|
||||||
|
|
||||||
interface MappingState {
|
|
||||||
from: {
|
|
||||||
connection: ConnectionInfo | null;
|
|
||||||
table: string;
|
|
||||||
column: ColumnInfo | null;
|
|
||||||
} | null;
|
|
||||||
to: {
|
|
||||||
connection: ConnectionInfo | null;
|
|
||||||
table: string;
|
|
||||||
column: ColumnInfo | null;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BatchManagementPage() {
|
export default function BatchManagementPage() {
|
||||||
// 기본 상태
|
const router = useRouter();
|
||||||
const [batchName, setBatchName] = useState("");
|
|
||||||
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
|
|
||||||
// 커넥션 및 테이블 데이터
|
// 상태 관리
|
||||||
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
|
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
|
||||||
const [fromTables, setFromTables] = useState<string[]>([]);
|
|
||||||
const [toTables, setToTables] = useState<string[]>([]);
|
|
||||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
|
|
||||||
// 선택된 상태
|
|
||||||
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
|
|
||||||
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
|
|
||||||
const [fromTable, setFromTable] = useState("");
|
|
||||||
const [toTable, setToTable] = useState("");
|
|
||||||
const [selectedFromColumn, setSelectedFromColumn] = useState<ColumnInfo | null>(null);
|
|
||||||
|
|
||||||
// 매핑 상태
|
|
||||||
const [mappings, setMappings] = useState<BatchMapping[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||||
|
|
||||||
// 초기 데이터 로드
|
// 페이지 로드 시 배치 목록 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConnections();
|
loadBatchConfigs();
|
||||||
}, []);
|
}, [currentPage, searchTerm]);
|
||||||
|
|
||||||
// 커넥션 목록 로드
|
|
||||||
const loadConnections = async () => {
|
|
||||||
try {
|
|
||||||
const data = await BatchAPI.getAvailableConnections();
|
|
||||||
setConnections(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("커넥션 목록 로드 오류:", error);
|
|
||||||
toast.error("커넥션 목록을 불러오는데 실패했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// FROM 커넥션 변경 시 테이블 로드
|
|
||||||
const handleFromConnectionChange = async (connectionId: string) => {
|
|
||||||
const connection = connections.find((c: ConnectionInfo) =>
|
|
||||||
c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!connection) return;
|
|
||||||
|
|
||||||
setFromConnection(connection);
|
|
||||||
setFromTable("");
|
|
||||||
setFromColumns([]);
|
|
||||||
setSelectedFromColumn(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tables = await BatchAPI.getTablesFromConnection(
|
|
||||||
connection.type,
|
|
||||||
connection.id
|
|
||||||
);
|
|
||||||
setFromTables(tables);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("FROM 테이블 목록 로드 오류:", error);
|
|
||||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TO 커넥션 변경 시 테이블 로드
|
|
||||||
const handleToConnectionChange = async (connectionId: string) => {
|
|
||||||
const connection = connections.find((c: ConnectionInfo) =>
|
|
||||||
c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!connection) return;
|
|
||||||
|
|
||||||
setToConnection(connection);
|
|
||||||
setToTable("");
|
|
||||||
setToColumns([]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tables = await BatchAPI.getTablesFromConnection(
|
|
||||||
connection.type,
|
|
||||||
connection.id
|
|
||||||
);
|
|
||||||
setToTables(tables);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("TO 테이블 목록 로드 오류:", error);
|
|
||||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// FROM 테이블 변경 시 컬럼 로드
|
|
||||||
const handleFromTableChange = async (tableName: string) => {
|
|
||||||
if (!fromConnection) return;
|
|
||||||
|
|
||||||
setFromTable(tableName);
|
|
||||||
setSelectedFromColumn(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const columns = await BatchAPI.getTableColumns(
|
|
||||||
fromConnection.type,
|
|
||||||
tableName,
|
|
||||||
fromConnection.id
|
|
||||||
);
|
|
||||||
setFromColumns(columns);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("FROM 컬럼 목록 로드 오류:", error);
|
|
||||||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TO 테이블 변경 시 컬럼 로드
|
|
||||||
const handleToTableChange = async (tableName: string) => {
|
|
||||||
if (!toConnection) return;
|
|
||||||
|
|
||||||
setToTable(tableName);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const columns = await BatchAPI.getTableColumns(
|
|
||||||
toConnection.type,
|
|
||||||
tableName,
|
|
||||||
toConnection.id
|
|
||||||
);
|
|
||||||
setToColumns(columns);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("TO 컬럼 목록 로드 오류:", error);
|
|
||||||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// FROM 컬럼 선택
|
|
||||||
const handleFromColumnClick = (column: ColumnInfo) => {
|
|
||||||
setSelectedFromColumn(column);
|
|
||||||
};
|
|
||||||
|
|
||||||
// TO 컬럼 클릭으로 매핑 생성
|
|
||||||
const handleToColumnClick = (column: ColumnInfo) => {
|
|
||||||
if (!selectedFromColumn || !fromConnection || !toConnection) {
|
|
||||||
toast.error("먼저 FROM 컬럼을 선택해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// n:1 매핑 검사 (같은 TO 컬럼에 여러 FROM이 매핑되는 것 방지)
|
|
||||||
const existingToMapping = mappings.find((m: BatchMapping) =>
|
|
||||||
m.to_connection_type === toConnection.type &&
|
|
||||||
m.to_connection_id === toConnection.id &&
|
|
||||||
m.to_table_name === toTable &&
|
|
||||||
m.to_column_name === column.column_name
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingToMapping) {
|
|
||||||
toast.error("해당 TO 컬럼에는 이미 매핑이 존재합니다. n:1 매핑은 허용되지 않습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 새 매핑 생성
|
|
||||||
const newMapping: BatchMapping = {
|
|
||||||
from_connection_type: fromConnection.type,
|
|
||||||
from_connection_id: fromConnection.id,
|
|
||||||
from_table_name: fromTable,
|
|
||||||
from_column_name: selectedFromColumn.column_name,
|
|
||||||
from_column_type: selectedFromColumn.data_type,
|
|
||||||
to_connection_type: toConnection.type,
|
|
||||||
to_connection_id: toConnection.id,
|
|
||||||
to_table_name: toTable,
|
|
||||||
to_column_name: column.column_name,
|
|
||||||
to_column_type: column.data_type,
|
|
||||||
mapping_order: mappings.length + 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
setMappings([...mappings, newMapping]);
|
|
||||||
setSelectedFromColumn(null);
|
|
||||||
toast.success("매핑이 추가되었습니다.");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 매핑 삭제
|
|
||||||
const removeMapping = (index: number) => {
|
|
||||||
const newMappings = mappings.filter((_: BatchMapping, i: number) => i !== index);
|
|
||||||
setMappings(newMappings);
|
|
||||||
toast.success("매핑이 삭제되었습니다.");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 배치 설정 저장
|
|
||||||
const saveBatchConfig = async () => {
|
|
||||||
if (!batchName.trim()) {
|
|
||||||
toast.error("배치명을 입력해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cronSchedule.trim()) {
|
|
||||||
toast.error("실행주기를 입력해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mappings.length === 0) {
|
|
||||||
toast.error("최소 하나 이상의 컬럼 매핑을 설정해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 배치 설정 목록 조회
|
||||||
|
const loadBatchConfigs = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const request: BatchMappingRequest = {
|
const response = await BatchAPI.getBatchConfigs({
|
||||||
batch_name: batchName,
|
page: currentPage,
|
||||||
description: description || undefined,
|
limit: 10,
|
||||||
cron_schedule: cronSchedule,
|
search: searchTerm || undefined,
|
||||||
mappings: mappings,
|
});
|
||||||
};
|
|
||||||
|
|
||||||
await BatchAPI.createBatchConfig(request);
|
|
||||||
toast.success("배치 설정이 성공적으로 저장되었습니다!");
|
|
||||||
|
|
||||||
// 폼 초기화
|
|
||||||
setBatchName("");
|
|
||||||
setDescription("");
|
|
||||||
setCronSchedule("0 12 * * *");
|
|
||||||
setMappings([]);
|
|
||||||
setFromConnection(null);
|
|
||||||
setToConnection(null);
|
|
||||||
setFromTable("");
|
|
||||||
setToTable("");
|
|
||||||
setFromTables([]);
|
|
||||||
setToTables([]);
|
|
||||||
setFromColumns([]);
|
|
||||||
setToColumns([]);
|
|
||||||
setSelectedFromColumn(null);
|
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setBatchConfigs(response.data);
|
||||||
|
if (response.pagination) {
|
||||||
|
setTotalPages(response.pagination.totalPages);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBatchConfigs([]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 설정 저장 오류:", error);
|
console.error("배치 목록 조회 실패:", error);
|
||||||
toast.error("배치 설정 저장에 실패했습니다.");
|
toast.error("배치 목록을 불러오는데 실패했습니다.");
|
||||||
|
setBatchConfigs([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼이 매핑되었는지 확인
|
// 배치 수동 실행
|
||||||
const isColumnMapped = (
|
const executeBatch = async (batchId: number) => {
|
||||||
connectionType: 'internal' | 'external',
|
setExecutingBatch(batchId);
|
||||||
connectionId: number | undefined,
|
try {
|
||||||
tableName: string,
|
const response = await BatchAPI.executeBatchConfig(batchId);
|
||||||
columnName: string,
|
if (response.success) {
|
||||||
side: 'from' | 'to'
|
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
|
||||||
) => {
|
|
||||||
return mappings.some((mapping: BatchMapping) => {
|
|
||||||
if (side === 'from') {
|
|
||||||
return mapping.from_connection_type === connectionType &&
|
|
||||||
mapping.from_connection_id === connectionId &&
|
|
||||||
mapping.from_table_name === tableName &&
|
|
||||||
mapping.from_column_name === columnName;
|
|
||||||
} else {
|
} else {
|
||||||
return mapping.to_connection_type === connectionType &&
|
toast.error("배치 실행에 실패했습니다.");
|
||||||
mapping.to_connection_id === connectionId &&
|
|
||||||
mapping.to_table_name === tableName &&
|
|
||||||
mapping.to_column_name === columnName;
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 실패:", error);
|
||||||
|
toast.error("배치 실행 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setExecutingBatch(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 배치 활성화/비활성화 토글
|
||||||
|
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
|
||||||
|
try {
|
||||||
|
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
|
||||||
|
await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
|
||||||
|
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
|
||||||
|
loadBatchConfigs(); // 목록 새로고침
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 상태 변경 실패:", error);
|
||||||
|
toast.error("배치 상태 변경에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 배치 삭제
|
||||||
|
const deleteBatch = async (batchId: number, batchName: string) => {
|
||||||
|
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await BatchAPI.deleteBatchConfig(batchId);
|
||||||
|
toast.success("배치가 삭제되었습니다.");
|
||||||
|
loadBatchConfigs(); // 목록 새로고침
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 삭제 실패:", error);
|
||||||
|
toast.error("배치 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색 처리
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearchTerm(value);
|
||||||
|
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 정보 요약 생성
|
||||||
|
const getMappingSummary = (mappings: BatchMapping[]) => {
|
||||||
|
if (!mappings || mappings.length === 0) {
|
||||||
|
return "매핑 없음";
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableGroups = new Map<string, number>();
|
||||||
|
mappings.forEach(mapping => {
|
||||||
|
const key = `${mapping.from_table_name} → ${mapping.to_table_name}`;
|
||||||
|
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
|
||||||
|
`${key} (${count}개 컬럼)`
|
||||||
|
);
|
||||||
|
|
||||||
|
return summaries.join(", ");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 max-w-7xl">
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
<Card className="mb-6">
|
{/* 헤더 */}
|
||||||
<CardHeader className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-2xl font-bold text-center">
|
<div>
|
||||||
배치관리 매핑 시스템
|
<h1 className="text-3xl font-bold">배치 관리</h1>
|
||||||
</CardTitle>
|
<p className="text-muted-foreground">데이터베이스 간 배치 작업을 관리합니다.</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
</Card>
|
<Button
|
||||||
|
onClick={() => router.push("/admin/batchmng/create")}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span>배치 추가</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 기본 설정 섹션 */}
|
{/* 검색 및 필터 */}
|
||||||
<Card className="mb-6">
|
<Card>
|
||||||
<CardHeader className="bg-gray-50">
|
<CardContent className="pt-6">
|
||||||
<CardTitle>기본 설정</CardTitle>
|
<div className="flex items-center space-x-4">
|
||||||
</CardHeader>
|
<div className="flex-1 relative">
|
||||||
<CardContent className="p-6 space-y-4">
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="batchName">배치명 *</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="batchName"
|
placeholder="배치명 또는 설명으로 검색..."
|
||||||
value={batchName}
|
value={searchTerm}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBatchName(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
placeholder="예: 인사정보 동기화 배치"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<Button
|
||||||
<Label htmlFor="cronSchedule">실행주기 (크론탭 형식) *</Label>
|
variant="outline"
|
||||||
<Input
|
onClick={loadBatchConfigs}
|
||||||
id="cronSchedule"
|
disabled={loading}
|
||||||
value={cronSchedule}
|
className="flex items-center space-x-2"
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCronSchedule(e.target.value)}
|
>
|
||||||
placeholder="예: 0 12 * * * (매일 12시)"
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
/>
|
<span>새로고침</span>
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description">비고</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
|
|
||||||
placeholder="배치에 대한 설명을 입력하세요..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 매핑 설정 섹션 */}
|
{/* 배치 목록 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
<Card>
|
||||||
{/* FROM 섹션 */}
|
<CardHeader>
|
||||||
<Card>
|
<CardTitle className="flex items-center justify-between">
|
||||||
<CardHeader className="bg-green-500 text-white">
|
<span>배치 목록 ({batchConfigs.length}개)</span>
|
||||||
<CardTitle>FROM (원본 데이터베이스)</CardTitle>
|
{loading && <RefreshCw className="h-4 w-4 animate-spin" />}
|
||||||
</CardHeader>
|
</CardTitle>
|
||||||
<CardContent className="p-6">
|
</CardHeader>
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
|
<CardContent>
|
||||||
1단계: 커넥션 선택 → 2단계: 테이블 선택 → 3단계: 컬럼 클릭하여 선택
|
{batchConfigs.length === 0 ? (
|
||||||
</div>
|
<div className="text-center py-12">
|
||||||
|
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<div className="space-y-4">
|
<h3 className="text-lg font-semibold mb-2">배치가 없습니다</h3>
|
||||||
<div>
|
<p className="text-muted-foreground mb-4">
|
||||||
<Label>커넥션 선택</Label>
|
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
||||||
<Select onValueChange={handleFromConnectionChange}>
|
</p>
|
||||||
<SelectTrigger>
|
{!searchTerm && (
|
||||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
<Button
|
||||||
</SelectTrigger>
|
onClick={() => router.push("/admin/batchmng/create")}
|
||||||
<SelectContent>
|
className="flex items-center space-x-2"
|
||||||
{connections.map((conn: ConnectionInfo) => (
|
|
||||||
<SelectItem
|
|
||||||
key={conn.type === 'internal' ? 'internal' : conn.id}
|
|
||||||
value={conn.type === 'internal' ? 'internal' : conn.id!.toString()}
|
|
||||||
>
|
|
||||||
{conn.name} ({conn.db_type?.toUpperCase()})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>테이블 선택</Label>
|
|
||||||
<Select
|
|
||||||
value={fromTable}
|
|
||||||
onValueChange={handleFromTableChange}
|
|
||||||
disabled={!fromConnection}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<Plus className="h-4 w-4" />
|
||||||
<SelectValue placeholder={fromConnection ? "테이블을 선택하세요" : "먼저 커넥션을 선택하세요"} />
|
<span>첫 번째 배치 추가</span>
|
||||||
</SelectTrigger>
|
</Button>
|
||||||
<SelectContent>
|
|
||||||
{fromTables.map((table: string) => (
|
|
||||||
<SelectItem key={table} value={table}>
|
|
||||||
{table.toUpperCase()}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{fromTable && fromColumns.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="bg-gray-50 border rounded p-4">
|
|
||||||
<h4 className="font-semibold text-blue-600 mb-3">
|
|
||||||
{fromTable.toUpperCase()} 테이블
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
|
||||||
{fromColumns.map((column: ColumnInfo) => (
|
|
||||||
<div
|
|
||||||
key={column.column_name}
|
|
||||||
className={`p-3 border-2 rounded cursor-pointer transition-all ${
|
|
||||||
selectedFromColumn?.column_name === column.column_name
|
|
||||||
? 'border-blue-500 bg-blue-50 font-semibold'
|
|
||||||
: isColumnMapped(
|
|
||||||
fromConnection!.type,
|
|
||||||
fromConnection!.id,
|
|
||||||
fromTable,
|
|
||||||
column.column_name,
|
|
||||||
'from'
|
|
||||||
)
|
|
||||||
? 'border-green-500 bg-green-50'
|
|
||||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleFromColumnClick(column)}
|
|
||||||
>
|
|
||||||
<div>{column.column_name}</div>
|
|
||||||
<div className="text-xs text-gray-500 italic">
|
|
||||||
{column.data_type}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
) : (
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* TO 섹션 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="bg-red-500 text-white">
|
|
||||||
<CardTitle>TO (대상 데이터베이스)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4 text-sm text-yellow-800">
|
|
||||||
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
{batchConfigs.map((batch) => (
|
||||||
<Label>커넥션 선택</Label>
|
<div key={batch.id} className="border rounded-lg p-6 space-y-4">
|
||||||
<Select onValueChange={handleToConnectionChange}>
|
{/* 배치 기본 정보 */}
|
||||||
<SelectTrigger>
|
<div className="flex items-start justify-between">
|
||||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
<div className="space-y-2">
|
||||||
</SelectTrigger>
|
<div className="flex items-center space-x-3">
|
||||||
<SelectContent>
|
<h3 className="text-lg font-semibold">{batch.batch_name}</h3>
|
||||||
{connections.map((conn: ConnectionInfo) => (
|
<Badge variant={batch.is_active === 'Y' ? 'default' : 'secondary'}>
|
||||||
<SelectItem
|
{batch.is_active === 'Y' ? '활성' : '비활성'}
|
||||||
key={conn.type === 'internal' ? 'internal' : conn.id}
|
</Badge>
|
||||||
value={conn.type === 'internal' ? 'internal' : conn.id!.toString()}
|
</div>
|
||||||
>
|
{batch.description && (
|
||||||
{conn.name} ({conn.db_type?.toUpperCase()})
|
<p className="text-muted-foreground">{batch.description}</p>
|
||||||
</SelectItem>
|
)}
|
||||||
))}
|
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||||
</SelectContent>
|
<div className="flex items-center space-x-1">
|
||||||
</Select>
|
<Clock className="h-4 w-4" />
|
||||||
</div>
|
<span>{batch.cron_schedule}</span>
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>테이블 선택</Label>
|
|
||||||
<Select
|
|
||||||
value={toTable}
|
|
||||||
onValueChange={handleToTableChange}
|
|
||||||
disabled={!toConnection}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={toConnection ? "테이블을 선택하세요" : "먼저 커넥션을 선택하세요"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{toTables.map((table: string) => (
|
|
||||||
<SelectItem key={table} value={table}>
|
|
||||||
{table.toUpperCase()}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{toTable && toColumns.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="bg-gray-50 border rounded p-4">
|
|
||||||
<h4 className="font-semibold text-red-600 mb-3">
|
|
||||||
{toTable.toUpperCase()} 테이블
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
|
||||||
{toColumns.map((column: ColumnInfo) => (
|
|
||||||
<div
|
|
||||||
key={column.column_name}
|
|
||||||
className={`p-3 border-2 rounded cursor-pointer transition-all ${
|
|
||||||
isColumnMapped(
|
|
||||||
toConnection!.type,
|
|
||||||
toConnection!.id,
|
|
||||||
toTable,
|
|
||||||
column.column_name,
|
|
||||||
'to'
|
|
||||||
)
|
|
||||||
? 'border-green-500 bg-green-50'
|
|
||||||
: 'border-gray-200 bg-white hover:border-red-300 hover:shadow-sm'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleToColumnClick(column)}
|
|
||||||
>
|
|
||||||
<div>{column.column_name}</div>
|
|
||||||
<div className="text-xs text-gray-500 italic">
|
|
||||||
{column.data_type}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div>
|
||||||
|
생성일: {new Date(batch.created_date).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼들 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => executeBatch(batch.id)}
|
||||||
|
disabled={executingBatch === batch.id}
|
||||||
|
className="flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
{executingBatch === batch.id ? (
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>실행</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleBatchStatus(batch.id, batch.is_active)}
|
||||||
|
className="flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
{batch.is_active === 'Y' ? (
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>{batch.is_active === 'Y' ? '비활성화' : '활성화'}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push(`/admin/batchmng/edit/${batch.id}`)}
|
||||||
|
className="flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
<span>수정</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteBatch(batch.id, batch.batch_name)}
|
||||||
|
className="flex items-center space-x-1 text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
<span>삭제</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 매핑 현황 섹션 */}
|
{/* 매핑 정보 */}
|
||||||
{mappings.length > 0 && (
|
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
|
||||||
<Card className="mb-6">
|
<div className="space-y-2">
|
||||||
<CardHeader className="bg-yellow-100 border-b">
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
<CardTitle className="text-lg">컬럼 매핑 현황</CardTitle>
|
매핑 정보 ({batch.batch_mappings.length}개)
|
||||||
</CardHeader>
|
</h4>
|
||||||
<CardContent className="p-6">
|
<div className="text-sm">
|
||||||
<div className="space-y-3">
|
{getMappingSummary(batch.batch_mappings)}
|
||||||
{mappings.map((mapping: BatchMapping, index: number) => (
|
</div>
|
||||||
<div key={index} className="flex items-center justify-between p-3 border rounded bg-gray-50">
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
)}
|
||||||
<span className="text-sm">
|
|
||||||
<Badge className="mr-2 border">FROM</Badge>
|
|
||||||
{mapping.from_table_name}.{mapping.from_column_name}
|
|
||||||
<span className="text-gray-500 ml-1">({mapping.from_column_type})</span>
|
|
||||||
</span>
|
|
||||||
<ArrowRight className="h-4 w-4 text-blue-500" />
|
|
||||||
<span className="text-sm">
|
|
||||||
<Badge className="mr-2 border">TO</Badge>
|
|
||||||
{mapping.to_table_name}.{mapping.to_column_name}
|
|
||||||
<span className="text-gray-500 ml-1">({mapping.to_column_type})</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeMapping(index)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 저장 버튼 */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<Button
|
|
||||||
onClick={saveBatchConfig}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white py-3 text-lg font-semibold"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="mr-2 h-5 w-5 animate-spin" />
|
|
||||||
저장 중...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="mr-2 h-5 w-5" />
|
|
||||||
배치 매핑 저장
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
const pageNum = i + 1;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
variant={currentPage === pageNum ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||||
|
import { CollectionAPI } from "@/lib/api/collection";
|
||||||
|
|
||||||
|
interface BatchJobModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
job?: BatchJob | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BatchJobModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
job,
|
||||||
|
}: BatchJobModalProps) {
|
||||||
|
const [formData, setFormData] = useState<Partial<BatchJob>>({
|
||||||
|
job_name: "",
|
||||||
|
description: "",
|
||||||
|
job_type: "collection",
|
||||||
|
schedule_cron: "",
|
||||||
|
is_active: "Y",
|
||||||
|
config_json: {},
|
||||||
|
execution_count: 0,
|
||||||
|
success_count: 0,
|
||||||
|
failure_count: 0,
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [jobTypes, setJobTypes] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
|
const [schedulePresets, setSchedulePresets] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
|
const [collectionConfigs, setCollectionConfigs] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadJobTypes();
|
||||||
|
loadSchedulePresets();
|
||||||
|
loadCollectionConfigs();
|
||||||
|
|
||||||
|
if (job) {
|
||||||
|
setFormData({
|
||||||
|
...job,
|
||||||
|
config_json: job.config_json || {},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
job_name: "",
|
||||||
|
description: "",
|
||||||
|
job_type: "collection",
|
||||||
|
schedule_cron: "",
|
||||||
|
is_active: "Y",
|
||||||
|
config_json: {},
|
||||||
|
execution_count: 0,
|
||||||
|
success_count: 0,
|
||||||
|
failure_count: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, job]);
|
||||||
|
|
||||||
|
const loadJobTypes = async () => {
|
||||||
|
try {
|
||||||
|
const types = await BatchAPI.getSupportedJobTypes();
|
||||||
|
setJobTypes(types);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("작업 타입 조회 오류:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSchedulePresets = async () => {
|
||||||
|
try {
|
||||||
|
const presets = await BatchAPI.getSchedulePresets();
|
||||||
|
setSchedulePresets(presets);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("스케줄 프리셋 조회 오류:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCollectionConfigs = async () => {
|
||||||
|
try {
|
||||||
|
const configs = await CollectionAPI.getCollectionConfigs({
|
||||||
|
is_active: "Y",
|
||||||
|
});
|
||||||
|
setCollectionConfigs(configs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("수집 설정 조회 오류:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!formData.job_name || !formData.job_type) {
|
||||||
|
toast.error("필수 필드를 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (job?.id) {
|
||||||
|
await BatchAPI.updateBatchJob(job.id, formData);
|
||||||
|
toast.success("배치 작업이 수정되었습니다.");
|
||||||
|
} else {
|
||||||
|
await BatchAPI.createBatchJob(formData as BatchJob);
|
||||||
|
toast.success("배치 작업이 생성되었습니다.");
|
||||||
|
}
|
||||||
|
onSave();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 작업 저장 오류:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "배치 작업 저장에 실패했습니다."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSchedulePresetSelect = (preset: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
schedule_cron: preset,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJobTypeChange = (jobType: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
job_type: jobType as any,
|
||||||
|
config_json: {},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCollectionConfigChange = (configId: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
config_json: {
|
||||||
|
...prev.config_json,
|
||||||
|
collectionConfigId: parseInt(configId),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getJobTypeIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'collection': return '📥';
|
||||||
|
case 'sync': return '🔄';
|
||||||
|
case 'cleanup': return '🧹';
|
||||||
|
case 'custom': return '⚙️';
|
||||||
|
default: return '📋';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'Y': return 'bg-green-100 text-green-800';
|
||||||
|
case 'N': return 'bg-red-100 text-red-800';
|
||||||
|
default: return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{job ? "배치 작업 수정" : "새 배치 작업"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">기본 정보</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="job_name">작업명 *</Label>
|
||||||
|
<Input
|
||||||
|
id="job_name"
|
||||||
|
value={formData.job_name || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData(prev => ({ ...prev, job_name: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="배치 작업명을 입력하세요"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="job_type">작업 타입 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.job_type || "collection"}
|
||||||
|
onValueChange={handleJobTypeChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{jobTypes.map((type) => (
|
||||||
|
<SelectItem key={type.value} value={type.value}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{getJobTypeIcon(type.value)}</span>
|
||||||
|
{type.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">설명</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData(prev => ({ ...prev, description: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="배치 작업에 대한 설명을 입력하세요"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업 설정 */}
|
||||||
|
{formData.job_type === 'collection' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">수집 설정</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="collection_config">수집 설정</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.config_json?.collectionConfigId?.toString() || ""}
|
||||||
|
onValueChange={handleCollectionConfigChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="수집 설정을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{collectionConfigs.map((config) => (
|
||||||
|
<SelectItem key={config.id} value={config.id.toString()}>
|
||||||
|
{config.config_name} - {config.source_table}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 스케줄 설정 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">스케줄 설정</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="schedule_cron">Cron 표현식</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="schedule_cron"
|
||||||
|
value={formData.schedule_cron || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="예: 0 0 * * * (매일 자정)"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Select onValueChange={handleSchedulePresetSelect}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="프리셋" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{schedulePresets.map((preset) => (
|
||||||
|
<SelectItem key={preset.value} value={preset.value}>
|
||||||
|
{preset.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실행 통계 (수정 모드일 때만) */}
|
||||||
|
{job?.id && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">실행 통계</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{formData.execution_count || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">총 실행 횟수</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{formData.success_count || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">성공 횟수</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-red-600">
|
||||||
|
{formData.failure_count || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">실패 횟수</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.last_executed_at && (
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
마지막 실행: {new Date(formData.last_executed_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 활성화 설정 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="is_active"
|
||||||
|
checked={formData.is_active === "Y"}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFormData(prev => ({ ...prev, is_active: checked ? "Y" : "N" }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="is_active">활성화</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge className={getStatusColor(formData.is_active || "N")}>
|
||||||
|
{formData.is_active === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "저장 중..." : "저장"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// 배치관리 API 클라이언트
|
// 배치관리 API 클라이언트 (새로운 API로 업데이트)
|
||||||
// 작성일: 2024-12-24
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
@ -61,11 +61,18 @@ export interface ColumnInfo {
|
||||||
column_default?: string;
|
column_default?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TableInfo {
|
||||||
|
table_name: string;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BatchMappingRequest {
|
export interface BatchMappingRequest {
|
||||||
batch_name: string;
|
batchName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
cron_schedule: string;
|
cronSchedule: string;
|
||||||
mappings: BatchMapping[];
|
mappings: BatchMapping[];
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
|
|
@ -76,31 +83,51 @@ export interface ApiResponse<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BatchAPI {
|
export class BatchAPI {
|
||||||
private static readonly BASE_PATH = "/batch-configs";
|
private static readonly BASE_PATH = "/batch-management";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 배치 설정 목록 조회
|
* 배치 설정 목록 조회
|
||||||
*/
|
*/
|
||||||
static async getBatchConfigs(filter: BatchConfigFilter = {}): Promise<BatchConfig[]> {
|
static async getBatchConfigs(filter: BatchConfigFilter = {}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: BatchConfig[];
|
||||||
|
pagination?: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (filter.is_active) params.append("is_active", filter.is_active);
|
if (filter.is_active) params.append("is_active", filter.is_active);
|
||||||
if (filter.company_code) params.append("company_code", filter.company_code);
|
if (filter.company_code) params.append("company_code", filter.company_code);
|
||||||
if (filter.search) params.append("search", filter.search);
|
if (filter.search) params.append("search", filter.search);
|
||||||
|
if (filter.page) params.append("page", filter.page.toString());
|
||||||
|
if (filter.limit) params.append("limit", filter.limit.toString());
|
||||||
|
|
||||||
const response = await apiClient.get<ApiResponse<BatchConfig[]>>(
|
const response = await apiClient.get<{
|
||||||
`${this.BASE_PATH}?${params.toString()}`,
|
success: boolean;
|
||||||
);
|
data: BatchConfig[];
|
||||||
|
pagination?: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
}>(`${this.BASE_PATH}/batch-configs?${params.toString()}`);
|
||||||
|
|
||||||
if (!response.data.success) {
|
return response.data;
|
||||||
throw new Error(response.data.message || "배치 설정 목록 조회에 실패했습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data.data || [];
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 설정 목록 조회 오류:", error);
|
console.error("배치 설정 목록 조회 오류:", error);
|
||||||
throw error;
|
return {
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
message: error instanceof Error ? error.message : "배치 설정 목록 조회에 실패했습니다."
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +137,7 @@ export class BatchAPI {
|
||||||
static async getBatchConfigById(id: number): Promise<BatchConfig> {
|
static async getBatchConfigById(id: number): Promise<BatchConfig> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<ApiResponse<BatchConfig>>(
|
const response = await apiClient.get<ApiResponse<BatchConfig>>(
|
||||||
`${this.BASE_PATH}/${id}`,
|
`${this.BASE_PATH}/batch-configs/${id}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.data.success) {
|
if (!response.data.success) {
|
||||||
|
|
@ -134,7 +161,7 @@ export class BatchAPI {
|
||||||
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
|
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post<ApiResponse<BatchConfig>>(
|
const response = await apiClient.post<ApiResponse<BatchConfig>>(
|
||||||
this.BASE_PATH,
|
`${this.BASE_PATH}/batch-configs`,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -162,7 +189,7 @@ export class BatchAPI {
|
||||||
): Promise<BatchConfig> {
|
): Promise<BatchConfig> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put<ApiResponse<BatchConfig>>(
|
const response = await apiClient.put<ApiResponse<BatchConfig>>(
|
||||||
`${this.BASE_PATH}/${id}`,
|
`${this.BASE_PATH}/batch-configs/${id}`,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -187,7 +214,7 @@ export class BatchAPI {
|
||||||
static async deleteBatchConfig(id: number): Promise<void> {
|
static async deleteBatchConfig(id: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.delete<ApiResponse<void>>(
|
const response = await apiClient.delete<ApiResponse<void>>(
|
||||||
`${this.BASE_PATH}/${id}`,
|
`${this.BASE_PATH}/batch-configs/${id}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.data.success) {
|
if (!response.data.success) {
|
||||||
|
|
@ -202,19 +229,32 @@ export class BatchAPI {
|
||||||
/**
|
/**
|
||||||
* 사용 가능한 커넥션 목록 조회
|
* 사용 가능한 커넥션 목록 조회
|
||||||
*/
|
*/
|
||||||
static async getAvailableConnections(): Promise<ConnectionInfo[]> {
|
static async getConnections(): Promise<ConnectionInfo[]> {
|
||||||
try {
|
try {
|
||||||
|
console.log("[BatchAPI] getAvailableConnections 호출 시작");
|
||||||
|
console.log("[BatchAPI] API URL:", `${this.BASE_PATH}/connections`);
|
||||||
|
|
||||||
const response = await apiClient.get<ApiResponse<ConnectionInfo[]>>(
|
const response = await apiClient.get<ApiResponse<ConnectionInfo[]>>(
|
||||||
`${this.BASE_PATH}/connections`,
|
`${this.BASE_PATH}/connections`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("[BatchAPI] API 응답:", response);
|
||||||
|
console.log("[BatchAPI] 응답 데이터:", response.data);
|
||||||
|
|
||||||
if (!response.data.success) {
|
if (!response.data.success) {
|
||||||
|
console.error("[BatchAPI] API 응답 실패:", response.data);
|
||||||
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
|
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data.data || [];
|
const result = response.data.data || [];
|
||||||
|
console.log("[BatchAPI] 최종 결과:", result);
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("커넥션 목록 조회 오류:", error);
|
console.error("[BatchAPI] 커넥션 목록 조회 오류:", error);
|
||||||
|
console.error("[BatchAPI] 오류 상세:", {
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -233,13 +273,15 @@ export class BatchAPI {
|
||||||
}
|
}
|
||||||
url += '/tables';
|
url += '/tables';
|
||||||
|
|
||||||
const response = await apiClient.get<ApiResponse<string[]>>(url);
|
const response = await apiClient.get<ApiResponse<TableInfo[]>>(url);
|
||||||
|
|
||||||
if (!response.data.success) {
|
if (!response.data.success) {
|
||||||
throw new Error(response.data.message || "테이블 목록 조회에 실패했습니다.");
|
throw new Error(response.data.message || "테이블 목록 조회에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data.data || [];
|
// TableInfo[]에서 table_name만 추출하여 string[]로 변환
|
||||||
|
const tables = response.data.data || [];
|
||||||
|
return tables.map(table => table.table_name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 목록 조회 오류:", error);
|
console.error("테이블 목록 조회 오류:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -251,8 +293,8 @@ export class BatchAPI {
|
||||||
*/
|
*/
|
||||||
static async getTableColumns(
|
static async getTableColumns(
|
||||||
connectionType: 'internal' | 'external',
|
connectionType: 'internal' | 'external',
|
||||||
tableName: string,
|
connectionId: number | undefined,
|
||||||
connectionId?: number
|
tableName: string
|
||||||
): Promise<ColumnInfo[]> {
|
): Promise<ColumnInfo[]> {
|
||||||
try {
|
try {
|
||||||
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
||||||
|
|
@ -273,4 +315,38 @@ export class BatchAPI {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 수동 실행
|
||||||
|
*/
|
||||||
|
static async executeBatchConfig(batchId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: {
|
||||||
|
batchId: string;
|
||||||
|
totalRecords: number;
|
||||||
|
successRecords: number;
|
||||||
|
failedRecords: number;
|
||||||
|
duration: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: {
|
||||||
|
batchId: string;
|
||||||
|
totalRecords: number;
|
||||||
|
successRecords: number;
|
||||||
|
failedRecords: number;
|
||||||
|
duration: number;
|
||||||
|
};
|
||||||
|
}>(`${this.BASE_PATH}/batch-configs/${batchId}/execute`);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 실행 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
// 배치관리 전용 API 클라이언트 (기존 소스와 완전 분리)
|
||||||
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// 배치관리 전용 타입 정의
|
||||||
|
export interface BatchConnectionInfo {
|
||||||
|
type: 'internal' | 'external';
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
db_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchColumnInfo {
|
||||||
|
column_name: string;
|
||||||
|
data_type: string;
|
||||||
|
is_nullable?: string;
|
||||||
|
column_default?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchTableInfo {
|
||||||
|
table_name: string;
|
||||||
|
columns: BatchColumnInfo[];
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BatchManagementAPI = {
|
||||||
|
BASE_PATH: "/api/batch-management",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용 가능한 커넥션 목록 조회
|
||||||
|
*/
|
||||||
|
static async getAvailableConnections(): Promise<BatchConnectionInfo[]> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<BatchApiResponse<BatchConnectionInfo[]>>(
|
||||||
|
`${this.BASE_PATH}/connections`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("커넥션 목록 조회 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 커넥션의 테이블 목록 조회
|
||||||
|
*/
|
||||||
|
static async getTablesFromConnection(
|
||||||
|
connectionType: 'internal' | 'external',
|
||||||
|
connectionId?: number
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
||||||
|
if (connectionType === 'external' && connectionId) {
|
||||||
|
url += `/${connectionId}`;
|
||||||
|
}
|
||||||
|
url += '/tables';
|
||||||
|
|
||||||
|
const response = await apiClient.get<BatchApiResponse<BatchTableInfo[]>>(url);
|
||||||
|
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "테이블 목록 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchTableInfo[]에서 table_name만 추출하여 string[]로 변환
|
||||||
|
const tables = response.data.data || [];
|
||||||
|
return tables.map(table => table.table_name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 조회 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 테이블의 컬럼 정보 조회
|
||||||
|
*/
|
||||||
|
static async getTableColumns(
|
||||||
|
connectionType: 'internal' | 'external',
|
||||||
|
tableName: string,
|
||||||
|
connectionId?: number
|
||||||
|
): Promise<BatchColumnInfo[]> {
|
||||||
|
try {
|
||||||
|
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
||||||
|
if (connectionType === 'external' && connectionId) {
|
||||||
|
url += `/${connectionId}`;
|
||||||
|
}
|
||||||
|
url += `/tables/${tableName}/columns`;
|
||||||
|
|
||||||
|
const response = await apiClient.get<BatchApiResponse<BatchColumnInfo[]>>(url);
|
||||||
|
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 정보 조회 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,13 @@
|
||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.16.2",
|
||||||
|
"@types/mssql": "^9.1.8",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
|
"mssql": "^11.0.1",
|
||||||
"prisma": "^6.16.2"
|
"prisma": "^6.16.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/oracledb": "^6.9.1",
|
||||||
|
"@types/pg": "^8.15.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue