2026-05-09 22:47:55 +09:00
|
|
|
import { useState } from 'react';
|
2026-05-11 00:08:32 +09:00
|
|
|
import {
|
|
|
|
|
Alert,
|
|
|
|
|
Button,
|
|
|
|
|
Card,
|
|
|
|
|
Col,
|
|
|
|
|
DatePicker,
|
|
|
|
|
message,
|
|
|
|
|
Row,
|
|
|
|
|
Select,
|
|
|
|
|
Space,
|
|
|
|
|
Statistic,
|
|
|
|
|
Steps,
|
|
|
|
|
Table,
|
|
|
|
|
Tag,
|
|
|
|
|
Typography,
|
|
|
|
|
} from 'antd';
|
|
|
|
|
import {
|
|
|
|
|
CheckCircleOutlined,
|
|
|
|
|
CloseCircleOutlined,
|
|
|
|
|
PlayCircleOutlined,
|
|
|
|
|
ReloadOutlined,
|
|
|
|
|
} from '@ant-design/icons';
|
2026-05-09 22:47:55 +09:00
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
|
import dayjs, { Dayjs } from 'dayjs';
|
2026-05-11 00:08:32 +09:00
|
|
|
import { useNavigate } from 'react-router-dom';
|
2026-05-09 22:47:55 +09:00
|
|
|
import PageContainer from '@/components/common/PageContainer';
|
2026-05-11 00:08:32 +09:00
|
|
|
import PermissionButton from '@/components/common/PermissionButton';
|
|
|
|
|
import { batchRunApi, BatchHistoryResp, BatchJobLogResp } from '@/api/batch';
|
|
|
|
|
|
|
|
|
|
const { Text } = Typography;
|
|
|
|
|
|
|
|
|
|
const STEP_NAMES = [
|
|
|
|
|
{ key: 'receive', label: '데이터 수신' },
|
|
|
|
|
{ key: 'transform', label: '데이터 변환' },
|
|
|
|
|
{ key: 'reconcile', label: '대사 검증' },
|
|
|
|
|
{ key: 'calcRecruit', label: '모집수수료' },
|
|
|
|
|
{ key: 'calcMaintain', label: '유지수수료' },
|
|
|
|
|
{ key: 'chargebackException', label: '환수/예외' },
|
|
|
|
|
{ key: 'calcOverride', label: '오버라이드' },
|
|
|
|
|
{ key: 'aggregate', label: '집계' },
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
function currentStepIndex(stepName?: string): number {
|
|
|
|
|
if (!stepName) return -1;
|
|
|
|
|
return STEP_NAMES.findIndex((s) => s.key === stepName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatMs(ms?: number): string {
|
|
|
|
|
if (ms == null) return '-';
|
|
|
|
|
if (ms < 1000) return `${ms}ms`;
|
|
|
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stepStatus(
|
|
|
|
|
idx: number,
|
|
|
|
|
currentIdx: number,
|
|
|
|
|
jobStatus: BatchJobLogResp['status'],
|
|
|
|
|
): 'finish' | 'process' | 'wait' | 'error' {
|
|
|
|
|
if (jobStatus === 'FAILED' && idx === currentIdx) return 'error';
|
|
|
|
|
if (idx < currentIdx) return 'finish';
|
|
|
|
|
if (idx === currentIdx) return 'process';
|
|
|
|
|
return 'wait';
|
|
|
|
|
}
|
2026-05-09 22:47:55 +09:00
|
|
|
|
|
|
|
|
export default function BatchRun() {
|
2026-05-11 00:08:32 +09:00
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const [settleMonth, setSettleMonth] = useState<Dayjs>(dayjs());
|
|
|
|
|
const [companyCode, setCompanyCode] = useState<string | undefined>(undefined);
|
|
|
|
|
const [jobLogId, setJobLogId] = useState<number | null>(null);
|
|
|
|
|
const [running, setRunning] = useState(false);
|
|
|
|
|
|
|
|
|
|
const { data: jobStatus } = useQuery<BatchJobLogResp>({
|
|
|
|
|
queryKey: ['batch', 'status', jobLogId],
|
|
|
|
|
queryFn: () => batchRunApi.status(jobLogId!),
|
|
|
|
|
enabled: jobLogId != null,
|
|
|
|
|
refetchInterval: (query) => {
|
|
|
|
|
const s = query.state.data?.status;
|
|
|
|
|
return s === 'COMPLETED' || s === 'FAILED' ? false : 5000;
|
2026-05-09 22:47:55 +09:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-11 00:08:32 +09:00
|
|
|
const { data: history = [] } = useQuery<BatchHistoryResp[]>({
|
|
|
|
|
queryKey: ['batch', 'history'],
|
|
|
|
|
queryFn: () => batchRunApi.history(10),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const isFinished =
|
|
|
|
|
jobStatus?.status === 'COMPLETED' || jobStatus?.status === 'FAILED';
|
|
|
|
|
|
2026-05-09 22:47:55 +09:00
|
|
|
const onRun = async () => {
|
2026-05-11 00:08:32 +09:00
|
|
|
if (!settleMonth) {
|
|
|
|
|
message.warning('정산월을 선택하세요');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setRunning(true);
|
2026-05-09 22:47:55 +09:00
|
|
|
try {
|
2026-05-11 00:08:32 +09:00
|
|
|
const id = await batchRunApi.run(
|
|
|
|
|
settleMonth.format('YYYYMM'),
|
|
|
|
|
companyCode,
|
|
|
|
|
);
|
|
|
|
|
setJobLogId(id);
|
|
|
|
|
message.success('정산 배치를 실행했습니다');
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
const e = err as { code?: string; message?: string };
|
|
|
|
|
if (e?.code === 'BATCH_RUNNING') {
|
|
|
|
|
message.error('이미 실행 중인 배치가 있습니다');
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setRunning(false);
|
2026-05-09 22:47:55 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-11 00:08:32 +09:00
|
|
|
const currentIdx = currentStepIndex(jobStatus?.stepName);
|
|
|
|
|
|
2026-05-09 22:47:55 +09:00
|
|
|
return (
|
|
|
|
|
<PageContainer title="정산 배치 실행">
|
2026-05-11 00:08:32 +09:00
|
|
|
{/* 컨트롤 영역 */}
|
2026-05-09 22:47:55 +09:00
|
|
|
<Card style={{ marginBottom: 16 }}>
|
2026-05-11 00:08:32 +09:00
|
|
|
<Space wrap>
|
|
|
|
|
<DatePicker
|
|
|
|
|
picker="month"
|
|
|
|
|
value={settleMonth}
|
|
|
|
|
onChange={(v) => v && setSettleMonth(v)}
|
|
|
|
|
format="YYYY-MM"
|
|
|
|
|
/>
|
|
|
|
|
<Select
|
|
|
|
|
placeholder="보험사 선택 (전체)"
|
|
|
|
|
allowClear
|
|
|
|
|
style={{ width: 180 }}
|
|
|
|
|
value={companyCode}
|
|
|
|
|
onChange={(v) => setCompanyCode(v)}
|
|
|
|
|
options={[]}
|
|
|
|
|
/>
|
|
|
|
|
<PermissionButton
|
|
|
|
|
menuCode="BATCH_RUN"
|
|
|
|
|
permCode="CREATE"
|
|
|
|
|
type="primary"
|
|
|
|
|
icon={<PlayCircleOutlined />}
|
|
|
|
|
loading={running}
|
|
|
|
|
onClick={onRun}
|
|
|
|
|
>
|
|
|
|
|
정산 실행
|
|
|
|
|
</PermissionButton>
|
|
|
|
|
{jobLogId && (
|
|
|
|
|
<Button
|
|
|
|
|
icon={<ReloadOutlined />}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setJobLogId(null);
|
|
|
|
|
setRunning(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
초기화
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-05-09 22:47:55 +09:00
|
|
|
</Space>
|
|
|
|
|
</Card>
|
|
|
|
|
|
2026-05-11 00:08:32 +09:00
|
|
|
{/* 진행 상태 */}
|
|
|
|
|
{jobLogId && jobStatus && (
|
|
|
|
|
<>
|
|
|
|
|
{jobStatus.status === 'FAILED' && jobStatus.errorMessage && (
|
|
|
|
|
<Alert
|
|
|
|
|
type="error"
|
|
|
|
|
message="배치 실패"
|
|
|
|
|
description={jobStatus.errorMessage}
|
|
|
|
|
showIcon
|
|
|
|
|
style={{ marginBottom: 16 }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Card
|
|
|
|
|
title={
|
|
|
|
|
<Space>
|
|
|
|
|
<Text strong>진행 상태</Text>
|
|
|
|
|
<Tag
|
|
|
|
|
color={
|
|
|
|
|
jobStatus.status === 'COMPLETED'
|
|
|
|
|
? 'green'
|
|
|
|
|
: jobStatus.status === 'FAILED'
|
|
|
|
|
? 'red'
|
|
|
|
|
: 'blue'
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{jobStatus.status}
|
|
|
|
|
</Tag>
|
|
|
|
|
</Space>
|
|
|
|
|
}
|
|
|
|
|
style={{ marginBottom: 16 }}
|
|
|
|
|
>
|
|
|
|
|
<Steps
|
|
|
|
|
current={currentIdx}
|
|
|
|
|
status={
|
|
|
|
|
jobStatus.status === 'FAILED'
|
|
|
|
|
? 'error'
|
|
|
|
|
: jobStatus.status === 'COMPLETED'
|
|
|
|
|
? 'finish'
|
|
|
|
|
: 'process'
|
|
|
|
|
}
|
|
|
|
|
items={STEP_NAMES.map((s, idx) => ({
|
|
|
|
|
title: s.label,
|
|
|
|
|
status: stepStatus(idx, currentIdx, jobStatus.status),
|
|
|
|
|
description:
|
|
|
|
|
idx === currentIdx
|
|
|
|
|
? `처리 ${jobStatus.successCount ?? 0}건 / ${formatMs(jobStatus.durationMs)}`
|
|
|
|
|
: undefined,
|
|
|
|
|
}))}
|
2026-05-09 22:47:55 +09:00
|
|
|
/>
|
2026-05-11 00:08:32 +09:00
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* 완료 KPI */}
|
|
|
|
|
{isFinished && (
|
|
|
|
|
<Card style={{ marginBottom: 16 }}>
|
|
|
|
|
<Row gutter={16}>
|
|
|
|
|
<Col span={6}>
|
|
|
|
|
<Statistic
|
|
|
|
|
title="총 건수"
|
|
|
|
|
value={jobStatus.totalCount ?? 0}
|
|
|
|
|
suffix="건"
|
|
|
|
|
/>
|
|
|
|
|
</Col>
|
|
|
|
|
<Col span={6}>
|
|
|
|
|
<Statistic
|
|
|
|
|
title="성공"
|
|
|
|
|
value={jobStatus.successCount ?? 0}
|
|
|
|
|
suffix="건"
|
|
|
|
|
valueStyle={{ color: '#52c41a' }}
|
|
|
|
|
prefix={<CheckCircleOutlined />}
|
|
|
|
|
/>
|
|
|
|
|
</Col>
|
|
|
|
|
<Col span={6}>
|
|
|
|
|
<Statistic
|
|
|
|
|
title="실패"
|
|
|
|
|
value={jobStatus.errorCount ?? 0}
|
|
|
|
|
suffix="건"
|
|
|
|
|
valueStyle={{ color: '#ff4d4f' }}
|
|
|
|
|
prefix={<CloseCircleOutlined />}
|
|
|
|
|
/>
|
|
|
|
|
</Col>
|
|
|
|
|
<Col span={6}>
|
|
|
|
|
<Statistic
|
|
|
|
|
title="소요 시간"
|
|
|
|
|
value={formatMs(jobStatus.durationMs)}
|
|
|
|
|
/>
|
|
|
|
|
</Col>
|
|
|
|
|
</Row>
|
|
|
|
|
<Space style={{ marginTop: 16 }}>
|
|
|
|
|
{jobStatus.status === 'COMPLETED' && (
|
|
|
|
|
<Button
|
|
|
|
|
type="primary"
|
|
|
|
|
onClick={() => navigate('/settle')}
|
|
|
|
|
>
|
|
|
|
|
정산 결과 보기
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button icon={<ReloadOutlined />} onClick={onRun} loading={running}>
|
|
|
|
|
재실행
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
|
|
|
|
</Card>
|
2026-05-09 22:47:55 +09:00
|
|
|
)}
|
2026-05-11 00:08:32 +09:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 최근 배치 이력 */}
|
|
|
|
|
{history.length > 0 && (
|
|
|
|
|
<Card title="최근 배치 이력" size="small">
|
|
|
|
|
<Table<BatchHistoryResp>
|
|
|
|
|
rowKey="jobLogId"
|
|
|
|
|
size="small"
|
|
|
|
|
pagination={false}
|
|
|
|
|
dataSource={history}
|
|
|
|
|
columns={[
|
|
|
|
|
{ title: 'ID', dataIndex: 'jobLogId', width: 70 },
|
|
|
|
|
{ title: '작업명', dataIndex: 'jobName' },
|
|
|
|
|
{
|
|
|
|
|
title: '상태',
|
|
|
|
|
dataIndex: 'status',
|
|
|
|
|
width: 110,
|
|
|
|
|
render: (v: string) => (
|
|
|
|
|
<Tag
|
|
|
|
|
color={
|
|
|
|
|
v === 'COMPLETED'
|
|
|
|
|
? 'green'
|
|
|
|
|
: v === 'FAILED'
|
|
|
|
|
? 'red'
|
|
|
|
|
: 'blue'
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{v}
|
|
|
|
|
</Tag>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{ title: '마지막 Step', dataIndex: 'stepName', width: 160 },
|
|
|
|
|
{ title: '시작', dataIndex: 'startedAt', width: 160 },
|
|
|
|
|
{ title: '종료', dataIndex: 'endedAt', width: 160 },
|
|
|
|
|
{
|
|
|
|
|
title: '소요',
|
|
|
|
|
dataIndex: 'durationMs',
|
|
|
|
|
width: 80,
|
|
|
|
|
render: (v: number) => formatMs(v),
|
|
|
|
|
},
|
|
|
|
|
]}
|
|
|
|
|
/>
|
2026-05-09 22:47:55 +09:00
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
</PageContainer>
|
|
|
|
|
);
|
|
|
|
|
}
|