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:
@@ -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>
|
||||
|
||||
@@ -1,12 +1,52 @@
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button, Modal, Form, Select, message } from 'antd';
|
||||
import { DownloadOutlined, FileExcelOutlined } from '@ant-design/icons';
|
||||
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
import { paymentApi, PaymentResp } from '@/api/payment';
|
||||
import { paymentApi, PaymentResp, TransferFileResp } from '@/api/payment';
|
||||
import DatePicker from 'antd/es/date-picker';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { MonthPicker } = DatePicker;
|
||||
|
||||
export default function PaymentList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const { data: statusCodes = [] } = useCommonCodes('PAYMENT_STATUS');
|
||||
const { data: bankCodes = [] } = useCommonCodes('BANK_CODE');
|
||||
|
||||
const [transferModalOpen, setTransferModalOpen] = useState(false);
|
||||
const [resultModalOpen, setResultModalOpen] = useState(false);
|
||||
const [transferResult, setTransferResult] = useState<TransferFileResp | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleGenerateTransferFile = async () => {
|
||||
const values = await form.validateFields();
|
||||
const settleMonth = values.settleMonth ? dayjs(values.settleMonth).format('YYYYMM') : undefined;
|
||||
if (!settleMonth) return;
|
||||
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await paymentApi.generateTransferFile(settleMonth, values.bankCode);
|
||||
setTransferResult(result);
|
||||
setTransferModalOpen(false);
|
||||
setResultModalOpen(true);
|
||||
actionRef.current?.reload();
|
||||
message.success('이체파일이 생성되었습니다');
|
||||
} catch {
|
||||
// 에러는 request.ts 인터셉터에서 처리
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!transferResult) return;
|
||||
window.open(`/api/files/${transferResult.fileId}`, '_blank');
|
||||
};
|
||||
|
||||
const columns: ProColumns<PaymentResp>[] = [
|
||||
{
|
||||
@@ -27,14 +67,26 @@ export default function PaymentList() {
|
||||
},
|
||||
{ title: '지급일', dataIndex: 'payDate', width: 110, search: false },
|
||||
{ title: '실패사유', dataIndex: 'failReason', ellipsis: true, search: false },
|
||||
{
|
||||
title: '이체파일', dataIndex: 'transferFileId', width: 120, search: false,
|
||||
render: (_, r) =>
|
||||
r.transferFileId ? (
|
||||
<a href={`/api/files/${r.transferFileId}`} target="_blank" rel="noreferrer">
|
||||
<DownloadOutlined /> #{r.transferFileId}
|
||||
</a>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer title="지급 관리" description="확정 정산 → 이체 파일 생성 / 결과 추적">
|
||||
<ProTable<PaymentResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="paymentId"
|
||||
columns={columns}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 1200 }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, ...rest } = params as any;
|
||||
const res = await paymentApi.list({ ...rest, pageNum: current, pageSize });
|
||||
@@ -47,7 +99,84 @@ export default function PaymentList() {
|
||||
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
dateFormatter="string"
|
||||
toolBarRender={() => [
|
||||
<Button
|
||||
key="transfer-file"
|
||||
type="primary"
|
||||
icon={<FileExcelOutlined />}
|
||||
onClick={() => { form.resetFields(); setTransferModalOpen(true); }}
|
||||
>
|
||||
이체파일 생성
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 이체파일 생성 입력 모달 */}
|
||||
<Modal
|
||||
title="이체파일 생성"
|
||||
open={transferModalOpen}
|
||||
onOk={handleGenerateTransferFile}
|
||||
onCancel={() => setTransferModalOpen(false)}
|
||||
confirmLoading={generating}
|
||||
okText="생성"
|
||||
cancelText="취소"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="settleMonth"
|
||||
label="정산월"
|
||||
rules={[{ required: true, message: '정산월을 선택해주세요' }]}
|
||||
>
|
||||
<MonthPicker style={{ width: '100%' }} format="YYYYMM" placeholder="정산월 선택" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bankCode"
|
||||
label="은행"
|
||||
rules={[{ required: true, message: '은행을 선택해주세요' }]}
|
||||
>
|
||||
<Select placeholder="은행 선택" allowClear>
|
||||
{bankCodes.map((c) => (
|
||||
<Select.Option key={c.code} value={c.code}>
|
||||
{c.codeName}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 이체파일 생성 결과 모달 */}
|
||||
<Modal
|
||||
title="이체파일 생성 완료"
|
||||
open={resultModalOpen}
|
||||
onOk={() => setResultModalOpen(false)}
|
||||
onCancel={() => setResultModalOpen(false)}
|
||||
okText="확인"
|
||||
cancelButtonProps={{ style: { display: 'none' } }}
|
||||
footer={[
|
||||
<Button key="download" type="primary" icon={<DownloadOutlined />} onClick={handleDownload}>
|
||||
파일 다운로드
|
||||
</Button>,
|
||||
<Button key="close" onClick={() => setResultModalOpen(false)}>
|
||||
닫기
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{transferResult && (
|
||||
<div style={{ lineHeight: 2 }}>
|
||||
<div>파일명: <strong>{transferResult.fileName}</strong></div>
|
||||
<div>파일 ID: <strong>{transferResult.fileId}</strong></div>
|
||||
<div>처리 건수: <strong>{transferResult.paymentCount.toLocaleString()}건</strong></div>
|
||||
<div>
|
||||
합계 금액:{' '}
|
||||
<strong>
|
||||
{Number(transferResult.totalAmount).toLocaleString()}원
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user