feat: 추천 작업 5종 일괄 진행 (4 agents 병렬)

[A] 등록 모달 7페이지 (frontend)
- CompanyList / ProductList / Commission / Payout / Override / Chargeback / ExceptionCode
- ModalForm 580px, Switch Y/N 변환, 날짜 dayjs, PermissionButton
- api/company/product/rule.ts 에 SaveReq + create/update/remove 추가

[B] BatchRun + RoleList 보강 (frontend)
- BatchRun: 정산월/보험사 선택 → 실행, antd Steps 8단계 + 5초 폴링
  (refetchInterval 자동 정지), KPI 4 카드, 최근 이력
- RoleList: 좌(역할 목록) 우(메뉴×6권한 매트릭스 체크박스),
  DIRECTORY 들여쓰기, 전체 토글, Modal.confirm 후 저장

[C] 이체파일 + 은행 어댑터 (api)
- BankTransferFileGenerator 인터페이스 + GenericCsvTransferGenerator
  (UTF-8 BOM, 표준 CSV) + KbBankTransferGenerator (088, 고정폭 + 합계)
- BankTransferFactory (Spring Map 빈)
- TransferFileService: PENDING 조회 → 파일 생성 → file_storage 적재 →
  pay_file_ref 갱신 → status=SENT
- POST /api/payments/transfer-file (DataChangeLog + EXPORT 권한)
- account_no EncryptTypeHandler 자동 복호화 후 파일에 사용
- FileService.saveBytes(byte[]) 오버로드 추가
- PaymentList: 이체파일 생성 ModalForm + 다운로드 컬럼

[D] MockMvc Controller 테스트 (api)
- AbstractControllerTest (SpringBootTest + MockMvc, JwtFilter doAnswer
  로 chain 통과 처리), TestSecurityConfig (filter 빈 override)
- 5 클래스 36 테스트: Agent/Contract/Ledger/Settle/User Controller
- happy path + validation 400 + 권한 403 + 인증 401

검증:
- ./gradlew clean build 성공
- 백엔드 단위테스트 94건 (common 33 + batch 19 + api 36 + 기타 6) 통과
- 프론트 tsc -b / vite build 통과

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-11 00:08:32 +09:00
parent a314a95ecf
commit 87e4e3d1da
35 changed files with 2962 additions and 110 deletions
+281 -37
View File
@@ -1,63 +1,307 @@
import { useState } from 'react';
import { Button, Card, DatePicker, Descriptions, message, Progress, Space } from 'antd';
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';
import { useQuery } from '@tanstack/react-query';
import dayjs, { Dayjs } from 'dayjs';
import { useNavigate } from 'react-router-dom';
import PageContainer from '@/components/common/PageContainer';
import CodeBadge from '@/components/common/CodeBadge';
import { batchApi } from '@/api/settle';
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';
}
export default function BatchRun() {
const [settleMonth, setSettleMonth] = useState<Dayjs | null>(dayjs());
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: status, refetch } = useQuery({
queryKey: ['batch', 'status'],
queryFn: batchApi.status,
refetchInterval: (q) => {
const s = (q.state.data as any)?.status;
return s === 'STARTED' || s === 'RUNNING' ? 3000 : 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;
},
});
const { data: history = [] } = useQuery<BatchHistoryResp[]>({
queryKey: ['batch', 'history'],
queryFn: () => batchRunApi.history(10),
});
const isFinished =
jobStatus?.status === 'COMPLETED' || jobStatus?.status === 'FAILED';
const onRun = async () => {
if (!settleMonth) { message.warning('정산월을 선택하세요'); return; }
if (!settleMonth) {
message.warning('정산월을 선택하세요');
return;
}
setRunning(true);
try {
await batchApi.run(settleMonth.format('YYYYMM'));
message.success('배치 실행 요청됨');
refetch();
} catch {
// 인터셉터에서 처리
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);
}
};
const currentIdx = currentStepIndex(jobStatus?.stepName);
return (
<PageContainer title="정산 배치 실행">
{/* 컨트롤 영역 */}
<Card style={{ marginBottom: 16 }}>
<Space>
<DatePicker picker="month" value={settleMonth} onChange={setSettleMonth} />
<Button type="primary" onClick={onRun}> </Button>
<Button onClick={() => refetch()}> </Button>
<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>
)}
</Space>
</Card>
{status && (
<Card title="최근 배치 상태">
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="작업명">{status.jobName}</Descriptions.Item>
<Descriptions.Item label="정산월">{status.settleMonth}</Descriptions.Item>
<Descriptions.Item label="상태"><CodeBadge groupCode="SETTLE_STATUS" value={status.status} /></Descriptions.Item>
<Descriptions.Item label="현재 Step">{status.currentStep ?? '-'}</Descriptions.Item>
<Descriptions.Item label="시작 시각">{status.startedAt ?? '-'}</Descriptions.Item>
<Descriptions.Item label="종료 시각">{status.finishedAt ?? '-'}</Descriptions.Item>
<Descriptions.Item label="소요 시간">{status.durationSec ? `${status.durationSec}` : '-'}</Descriptions.Item>
<Descriptions.Item label="에러">{status.errorMessage ?? '-'}</Descriptions.Item>
</Descriptions>
{(status.progressPct ?? 0) > 0 && (
<Progress
percent={status.progressPct}
status={status.status === 'FAILED' ? 'exception' : status.status === 'COMPLETED' ? 'success' : 'active'}
style={{ marginTop: 16 }}
{/* 진행 상태 */}
{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,
}))}
/>
</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>
)}
</>
)}
{/* 최근 배치 이력 */}
{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),
},
]}
/>
</Card>
)}
</PageContainer>