Files
invyone/frontend/lib/api/batch.ts
T
hjjeong 067193efa9 fix(배치관리): 대시보드 NaN 제거 + 24시간 차트 더미데이터 → 실데이터
- 백엔드: getBatchManagementGlobalSparklineData 쿼리 추가 (generate_series 로
  24개 슬롯 고정, 회사 전체 배치 LEFT JOIN 집계)
- 백엔드: GET /api/batch-management/sparkline 엔드포인트 추가
- 프론트: BatchStats/SparklineData 타입을 백엔드 mapper 의 snake_case 응답키와
  일치시킴 (today_count, today_failed_count, hour_slot, success_count, ...).
  키 미스매치로 stats 카드가 NaN 으로 표시되던 버그 해소
- 프론트: GlobalSparkline 컴포넌트의 Math.random() 더미 막대를 실데이터 prop 으로
  교체. row-level Sparkline 도 동일 키 정렬로 정상 렌더되도록 수정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:59:52 +09:00

681 lines
19 KiB
TypeScript

// 배치관리 API 클라이언트 (새로운 API로 업데이트)
// 작성일: 2024-12-24
import { apiClient } from "./client";
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
export type BatchExecutionType = "mapping" | "node_flow";
export interface BatchConfig {
id?: number;
batch_name: string;
description?: string;
cron_schedule: string;
is_active?: string;
company_code?: string;
save_mode?: 'INSERT' | 'UPSERT';
conflict_key?: string;
auth_service_name?: string;
execution_type?: BatchExecutionType;
node_flow_id?: number;
node_flow_context?: Record<string, any>;
created_date?: Date;
created_by?: string;
updated_date?: Date;
updated_by?: string;
batch_mappings?: BatchMapping[];
last_status?: string;
last_executed_at?: string;
last_total_records?: number;
}
export interface NodeFlowInfo {
flow_id: number;
flow_name: string;
description?: string;
company_code?: string;
node_count: number;
}
// 백엔드 mapper (batchManagement.xml) 가 snake_case 로 응답하므로 그대로 사용한다.
// 프로젝트 컨벤션: Map 키 = snake_case (CLAUDE.md 백엔드 규칙 참조)
export interface BatchStats {
total_count: number;
active_count: number;
today_count: number;
today_failed_count: number;
yesterday_count: number;
yesterday_failed_count: number;
}
export interface SparklineData {
hour_slot: string;
total_count: number;
success_count: number;
failed_count: number;
}
export interface RecentLog {
id: number;
started_at: string;
finished_at: string | null;
status: string;
total_records: number;
success_records: number;
failed_records: number;
error_message: string | null;
duration_ms: number | null;
}
// 조건 변환 규칙 — mapping_type === 'conditional' 일 때 mapping_config 에 저장.
// 평가: row[from_column_name] 값을 cfg.rules 의 when 과 문자열 동등 비교, 매칭되는 then 반환.
// 매칭 없으면 cfg.default.
export interface ConditionalRule {
when: string;
then: string;
}
export interface ConditionalConfig {
rules: ConditionalRule[];
default: string;
}
export interface BatchMapping {
id?: number;
batch_config_id?: number;
// FROM 정보
from_connection_type: 'internal' | 'external';
from_connection_id?: number;
from_table_name: string;
from_column_name: string;
from_column_type?: string;
// TO 정보
to_connection_type: 'internal' | 'external';
to_connection_id?: number;
to_table_name: string;
to_column_name: string;
to_column_type?: string;
// 매핑 유형 — 'direct' (그대로 복사) / 'fixed' (from_column_name 자체가 고정값) / 'conditional' (when/then 룰)
mapping_type?: 'direct' | 'fixed' | 'conditional';
// conditional 일 때 ConditionalConfig 의 JSON. 백엔드는 JSONB 로 저장.
// 요청 시 string(JSON) 또는 object 둘 다 허용 — 백엔드가 normalize.
mapping_config?: ConditionalConfig | string | null;
mapping_order?: number;
created_date?: Date;
created_by?: string;
}
export interface BatchConfigFilter {
batch_name?: string;
is_active?: string;
company_code?: string;
search?: string;
page?: number;
limit?: number;
}
export interface BatchJob {
id: number;
job_name: string;
job_type: string;
description?: string;
cron_schedule: string;
schedule_cron?: string;
is_active: string;
last_execution?: Date;
last_executed_at?: string;
next_execution?: Date;
execution_count?: number;
success_count?: number;
failure_count?: number;
status?: string;
created_date?: Date;
created_by?: string;
}
export interface ConnectionInfo {
type: 'internal' | 'external';
id?: number;
name: string;
db_type?: string;
}
export interface ColumnInfo {
column_name: string;
data_type: string;
is_nullable?: boolean;
column_default?: string;
}
export interface TableInfo {
table_name: string;
columns: ColumnInfo[];
description?: string | null;
}
export interface BatchMappingRequest {
batchName: string;
description?: string;
cronSchedule: string;
mappings: BatchMapping[];
isActive?: boolean;
executionType?: BatchExecutionType;
nodeFlowId?: number;
nodeFlowContext?: Record<string, any>;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
export class BatchAPI {
private static readonly BASE_PATH = "";
/**
* 배치 설정 목록 조회
* Spring 백엔드: GET /api/batch-management/batch-configs → { success, data: { list, total_count, ... } }
* Node.js 백엔드: GET /api/batch-configs → { success, data: [...], pagination: { ... } }
* 두 백엔드 응답 형식 모두 호환되도록 처리
*/
static async getBatchConfigs(filter: BatchConfigFilter = {}): Promise<{
success: boolean;
data: BatchConfig[];
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
message?: string;
}> {
try {
const params = new URLSearchParams();
if (filter.is_active) params.append("is_active", filter.is_active);
if (filter.company_code) params.append("company_code", filter.company_code);
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());
// SUPER_ADMIN 전사 모드 → cross-tenant fan-out (행마다 company_code 박힘)
if (isCrossTenantMode()) {
const ctResponse = await apiClient.get<any>(
`/admin/cross-tenant/batches?${params.toString()}`
);
const ct = ctResponse.data;
if (ct && ct.success && ct.data) {
const rows: BatchConfig[] = ct.data.rows || [];
return {
success: true,
data: rows,
// cross-tenant 1차는 페이지네이션 비지원 — 클라이언트 기준 1페이지로 표시
pagination: {
page: 1,
limit: rows.length,
total: rows.length,
totalPages: 1,
},
message: ct.message,
// CrossTenantBanner 메타 — truncated/failed 안내 박스용
cross_tenant_meta: {
companies_queried: ct.data.companies_queried,
companies_failed: ct.data.companies_failed,
failed_company_codes: ct.data.failed_company_codes ?? [],
truncated: ct.data.truncated,
truncated_company_codes: ct.data.truncated_company_codes ?? [],
per_company_limit: ct.data.per_company_limit,
},
} as any;
}
}
const response = await apiClient.get<any>(
`/batch-management/batch-configs?${params.toString()}`
);
const raw = response.data;
// Spring 응답: { success, data: { list: [...], total_count, ... } }
// Node.js 응답: { success, data: [...], pagination: { ... } }
let data: BatchConfig[];
let pagination: { page: number; limit: number; total: number; totalPages: number } | undefined;
if (raw.data && Array.isArray(raw.data)) {
// Node.js 형식: data가 직접 배열
data = raw.data;
pagination = raw.pagination;
} else if (raw.data && (raw.data.list || raw.data.data)) {
// Spring 형식: data가 객체 (list 또는 data 키에 배열)
data = raw.data.list || raw.data.data || [];
pagination = {
page: raw.data.page || 1,
limit: raw.data.limit || data.length,
total: raw.data.total_count ?? raw.data.total ?? data.length,
totalPages: raw.data.total_page ?? raw.data.totalPages ?? 1,
};
} else {
data = [];
}
return {
success: raw.success ?? true,
data,
pagination,
message: raw.message,
};
} catch (error) {
console.error("배치 설정 목록 조회 오류:", error);
return {
success: false,
data: [],
message: error instanceof Error ? error.message : "배치 설정 목록 조회에 실패했습니다."
};
}
}
/**
* 특정 배치 설정 조회 (별칭)
*/
static async getBatchConfig(id: number): Promise<BatchConfig> {
return this.getBatchConfigById(id);
}
/**
* 특정 배치 설정 조회
*/
static async getBatchConfigById(id: number): Promise<BatchConfig> {
try {
const response = await apiClient.get<ApiResponse<BatchConfig>>(
`/batch-management/batch-configs/${id}`,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 조회에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 설정을 찾을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 설정 조회 오류:", error);
throw error;
}
}
/**
* 배치 설정 생성
*/
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
try {
const response = await apiClient.post<ApiResponse<BatchConfig>>(
`/batch-management/batch-configs`,
data,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 생성에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 설정 생성 결과를 받을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 설정 생성 오류:", error);
throw error;
}
}
/**
* 배치 설정 수정
*/
static async updateBatchConfig(
id: number,
data: Partial<BatchMappingRequest>
): Promise<BatchConfig> {
try {
const response = await apiClient.put<ApiResponse<BatchConfig>>(
`/batch-management/batch-configs/${id}`,
data,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 수정에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 설정 수정 결과를 받을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 설정 수정 오류:", error);
throw error;
}
}
/**
* 배치 설정 삭제
*/
static async deleteBatchConfig(id: number): Promise<void> {
try {
const response = await apiClient.delete<ApiResponse<void>>(
`/batch-management/batch-configs/${id}`,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 삭제에 실패했습니다.");
}
} catch (error) {
console.error("배치 설정 삭제 오류:", error);
throw error;
}
}
/**
* 사용 가능한 커넥션 목록 조회
*/
static async getConnections(): Promise<ConnectionInfo[]> {
try {
console.log("[BatchAPI] getAvailableConnections 호출 시작");
console.log("[BatchAPI] API URL:", `/batch-management/connections`);
const response = await apiClient.get<ApiResponse<ConnectionInfo[]>>(
`/batch-management/connections`,
);
console.log("[BatchAPI] API 응답:", response);
console.log("[BatchAPI] 응답 데이터:", response.data);
if (!response.data.success) {
console.error("[BatchAPI] API 응답 실패:", response.data);
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
}
const result = response.data.data || [];
console.log("[BatchAPI] 최종 결과:", result);
return result;
} catch (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;
}
}
/**
* 특정 커넥션의 테이블 목록 조회
*/
static async getTablesFromConnection(
connection: ConnectionInfo
): Promise<string[]> {
try {
let url = `/batch-management/connections/${connection.type}`;
if (connection.type === 'external' && connection.id) {
url += `/${connection.id}`;
}
url += '/tables';
const response = await apiClient.get<ApiResponse<TableInfo[]>>(url);
if (!response.data.success) {
throw new Error(response.data.message || "테이블 목록 조회에 실패했습니다.");
}
// TableInfo[]에서 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(
connection: ConnectionInfo,
tableName: string
): Promise<ColumnInfo[]> {
try {
let url = `/batch-management/connections/${connection.type}`;
if (connection.type === 'external' && connection.id) {
url += `/${connection.id}`;
}
url += `/tables/${tableName}/columns`;
const response = await apiClient.get<ApiResponse<ColumnInfo[]>>(url);
if (!response.data.success) {
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
throw error;
}
}
/**
* 배치 작업 목록 조회
*/
static async getBatchJobs(): Promise<BatchJob[]> {
try {
const response = await apiClient.get<ApiResponse<BatchJob[]>>('/batch-management/jobs');
if (!response.data.success) {
throw new Error(response.data.message || "배치 작업 목록 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("배치 작업 목록 조회 오류:", 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;
};
}>(`/batch-management/batch-configs/${batchId}/execute`);
return response.data;
} catch (error) {
console.error("배치 실행 오류:", error);
throw error;
}
}
/**
* 지원되는 배치 작업 타입 조회
*/
static async getSupportedJobTypes(): Promise<string[]> {
try {
const response = await apiClient.get<ApiResponse<string[]>>('/batch-management/job-types');
if (!response.data.success) {
throw new Error(response.data.message || "작업 타입 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("작업 타입 조회 오류:", error);
return [];
}
}
/**
* 배치 작업 삭제
*/
static async deleteBatchJob(id: number): Promise<void> {
try {
const response = await apiClient.delete<ApiResponse<void>>(`/batch-management/jobs/${id}`);
if (!response.data.success) {
throw new Error(response.data.message || "배치 작업 삭제에 실패했습니다.");
}
} catch (error) {
console.error("배치 작업 삭제 오류:", error);
throw error;
}
}
/**
* 배치 작업 실행
*/
static async executeBatchJob(id: number): Promise<void> {
try {
const response = await apiClient.post<ApiResponse<void>>(`/batch-management/jobs/${id}/execute`);
if (!response.data.success) {
throw new Error(response.data.message || "배치 작업 실행에 실패했습니다.");
}
} catch (error) {
console.error("배치 작업 실행 오류:", error);
throw error;
}
}
/**
* auth_tokens 테이블의 서비스명 목록 조회
*/
static async getAuthServiceNames(): Promise<string[]> {
try {
const response = await apiClient.get<{
success: boolean;
data: string[];
}>(`/batch-management/auth-services`);
if (response.data.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("인증 서비스 목록 조회 오류:", error);
return [];
}
}
/**
* 노드 플로우 목록 조회 (배치 설정 시 플로우 선택용)
*/
static async getNodeFlows(): Promise<NodeFlowInfo[]> {
try {
const response = await apiClient.get<ApiResponse<NodeFlowInfo[]>>(
`/batch-management/node-flows`
);
if (response.data.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("노드 플로우 목록 조회 오류:", error);
return [];
}
}
/**
* 배치 대시보드 통계 조회
*/
static async getBatchStats(): Promise<BatchStats | null> {
try {
const response = await apiClient.get<ApiResponse<BatchStats>>(
`/batch-management/stats`
);
if (response.data.success) {
return response.data.data || null;
}
return null;
} catch (error) {
console.error("배치 통계 조회 오류:", error);
return null;
}
}
/**
* 회사 전체 배치의 최근 24시간 스파크라인 데이터 (24개 슬롯 고정)
*/
static async getGlobalSparkline(): Promise<SparklineData[]> {
try {
const response = await apiClient.get<ApiResponse<SparklineData[]>>(
`/batch-management/sparkline`
);
if (response.data.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("글로벌 스파크라인 조회 오류:", error);
return [];
}
}
/**
* 배치별 최근 24시간 스파크라인 데이터
*/
static async getBatchSparkline(batchId: number): Promise<SparklineData[]> {
try {
const response = await apiClient.get<ApiResponse<SparklineData[]>>(
`/batch-management/batch-configs/${batchId}/sparkline`
);
if (response.data.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("스파크라인 조회 오류:", error);
return [];
}
}
/**
* 배치별 최근 실행 로그
*/
static async getBatchRecentLogs(batchId: number, limit: number = 5): Promise<RecentLog[]> {
try {
const response = await apiClient.get<ApiResponse<RecentLog[]>>(
`/batch-management/batch-configs/${batchId}/recent-logs?limit=${limit}`
);
if (response.data.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("최근 실행 로그 조회 오류:", error);
return [];
}
}
}