feat: P10(#3) 환수 보험사자료 업로드 — 보험사 통보 기반 chargeback 인입 (PL 자율진행)
incar '환수보험사자료 업로드' 대응. GA는 LAPSE 계약 기반 자동환수만 있고 보험사가 통보한 환수자료(증권번호+환수금액)를 직접 인입하는 경로가 없던 갭. 신규 테이블 없이 기존 chargeback(V6) 재사용. - DB(V113): 메뉴 CHARGEBACK_DATA(환수자료관리, GRP_SETTLE, /settle/chargeback-data) + 권한 READ/CREATE/EXPORT + 역할부여. - core: ChargebackUploadExcelVO(@ExcelColumn 증권번호/환수금액/실효월/환수율) + ChargebackResp/SearchParam + ChargebackMapper.selectList/countByCondition/ existsByContractAndMonth + ContractMapper.selectByPolicyNo(contract_date 포함). - api: ChargebackUploadService.upload(settleMonth,file) — ExcelService.importLargeExcel 로 SAX 파싱, policy_no→contract/agent 해소(미존재=에러행), 중복(contract+월) skip, 실효월 YYYYMM→경과월 변환(ChronoUnit), ChargebackVO status=NEW·remain=cb_amount insertBatch. ChargebackService.list/export. ChargebackController(/api/chargebacks /upload, GET 목록, export). - frontend: ChargebackData 화면(정산월+파일 업로드, 결과 성공/스킵/에러행 표시, AG Grid 목록 합계행, 엑셀) + api/chargeback.ts + App 라우트. 검증: build SUCCESSFUL, Flyway V113(schema v113). 라이브 업로드 e2e — 정상 2건 INSERT (NEW, remain=cb_amount) + 미존재 증권번호 1건 에러행 리포트 + 중복 skip, GET 목록 200, 경과월 변환 동작 확인. 테스트 chargeback 전건 정리. 스펙 docs/DOMAIN_GAP_P10. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,9 @@ const CommissionSimulator = React.lazy(() => import('@/pages/commission/Commiss
|
||||
// P9: 환수채권
|
||||
const ClawbackReceivable = React.lazy(() => import('@/pages/commission/ClawbackReceivable'));
|
||||
|
||||
// P10: 환수자료관리
|
||||
const ChargebackData = React.lazy(() => import('@/pages/settle/ChargebackData'));
|
||||
|
||||
// P7: 수수료 계산 7도메인
|
||||
const IncomeCommission = React.lazy(() => import('@/pages/commission/IncomeCommission'));
|
||||
const ReferralCommission = React.lazy(() => import('@/pages/commission/ReferralCommission'));
|
||||
@@ -230,6 +233,9 @@ export default function App() {
|
||||
{/* P9: 환수채권 */}
|
||||
<Route path="commission/clawback-receivable" element={<ClawbackReceivable />} />
|
||||
|
||||
{/* P10: 환수자료관리 */}
|
||||
<Route path="settle/chargeback-data" element={<ChargebackData />} />
|
||||
|
||||
{/* P7: 수수료 계산 7도메인 */}
|
||||
<Route path="commission/income" element={<IncomeCommission />} />
|
||||
<Route path="commission/referral" element={<ReferralCommission />} />
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
// ── Types ──────────────────────────────────────────
|
||||
|
||||
export type ChargebackStatus = 'NEW' | 'PROCESSING' | 'SETTLED' | 'CANCELLED';
|
||||
|
||||
export interface ChargebackRow extends Record<string, unknown> {
|
||||
cbId: number;
|
||||
policyNo: string;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
contractorName: string;
|
||||
lapseMonth: number | null;
|
||||
cbRate: number | null;
|
||||
cbAmount: number;
|
||||
paidAmount: number;
|
||||
remainAmount: number;
|
||||
settleMonth: string;
|
||||
status: ChargebackStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ChargebackSearchParam {
|
||||
settleMonth?: string;
|
||||
agentId?: number;
|
||||
status?: string;
|
||||
policyNo?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ChargebackUploadResult {
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
skipCount: number;
|
||||
errors: Array<{ rowNo: number; reason: string }>;
|
||||
}
|
||||
|
||||
// ── API ───────────────────────────────────────────
|
||||
|
||||
export const chargebackApi = {
|
||||
list: (p: ChargebackSearchParam) =>
|
||||
unwrap<PageResponse<ChargebackRow>>(
|
||||
api.get('/api/chargebacks', { params: p }),
|
||||
),
|
||||
|
||||
upload: (settleMonth: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return unwrap<ChargebackUploadResult>(
|
||||
api.post(`/api/chargebacks/upload?settleMonth=${settleMonth}`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
export: (p: ChargebackSearchParam) =>
|
||||
api.get('/api/chargebacks/export', {
|
||||
params: p,
|
||||
responseType: 'blob',
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, DatePicker, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ColDef } from '@ag-grid-community/core';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import {
|
||||
chargebackApi,
|
||||
ChargebackRow,
|
||||
ChargebackSearchParam,
|
||||
} from '@/api/chargeback';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_SUCCESS, COLOR_ERROR, COLOR_WARNING, COLOR_PRIMARY } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const MENU_CODE = 'CHARGEBACK_DATA';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
NEW: COLOR_PRIMARY,
|
||||
PROCESSING: COLOR_WARNING,
|
||||
SETTLED: COLOR_SUCCESS,
|
||||
CANCELLED: COLOR_ERROR,
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
NEW: '신규',
|
||||
PROCESSING: '처리중',
|
||||
SETTLED: '정산완료',
|
||||
CANCELLED: '취소',
|
||||
};
|
||||
|
||||
function StatusChip({ status }: { status: string }) {
|
||||
const color = STATUS_COLOR[status] ?? GRAY[400];
|
||||
const label = STATUS_LABEL[status] ?? status;
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
color,
|
||||
background: `${color}1A`,
|
||||
border: `1px solid ${color}40`,
|
||||
borderRadius: RADIUS.sm,
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const fmt = (v: unknown) =>
|
||||
typeof v === 'number' ? v.toLocaleString() : (v as string | undefined) ?? '-';
|
||||
|
||||
const GRID_COLS: ColDef<ChargebackRow>[] = [
|
||||
{ field: 'policyNo', headerName: '증권번호', width: 150, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 120 },
|
||||
{ field: 'contractorName', headerName: '계약자', width: 120 },
|
||||
{
|
||||
field: 'cbAmount',
|
||||
headerName: '환수금액',
|
||||
flex: 1,
|
||||
type: 'numericColumn',
|
||||
valueFormatter: (p) => fmt(p.value),
|
||||
},
|
||||
{
|
||||
field: 'paidAmount',
|
||||
headerName: '기지급',
|
||||
flex: 1,
|
||||
type: 'numericColumn',
|
||||
valueFormatter: (p) => fmt(p.value),
|
||||
},
|
||||
{
|
||||
field: 'remainAmount',
|
||||
headerName: '잔여',
|
||||
flex: 1,
|
||||
type: 'numericColumn',
|
||||
valueFormatter: (p) => fmt(p.value),
|
||||
},
|
||||
{ field: 'lapseMonth', headerName: '경과월', width: 100 },
|
||||
{
|
||||
field: 'status',
|
||||
headerName: '상태',
|
||||
width: 120,
|
||||
cellRenderer: (p: { data: ChargebackRow }) =>
|
||||
p.data ? <StatusChip status={p.data.status} /> : null,
|
||||
},
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110 },
|
||||
];
|
||||
|
||||
const cardStyle = {
|
||||
background: '#fff',
|
||||
borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
boxShadow: SHADOW.sm,
|
||||
};
|
||||
|
||||
export default function ChargebackData() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
// ── 업로드 상태 ──────────────────────────────────
|
||||
const [uploadMonth, setUploadMonth] = useState<string>('');
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── 목록 검색 파라미터 ───────────────────────────
|
||||
const [params, setParams] = useState<ChargebackSearchParam>({ pageSize: 500 });
|
||||
|
||||
// ── 목록 조회 ──────────────────────────────────
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: ['chargebacks', params],
|
||||
queryFn: () => chargebackApi.list(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// ── 업로드 뮤테이션 ──────────────────────────────
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: ({ month, file }: { month: string; file: File }) =>
|
||||
chargebackApi.upload(month, file),
|
||||
onSuccess: (result) => {
|
||||
const { successCount, skipCount, errorCount } = result;
|
||||
if (errorCount === 0) {
|
||||
message.success(`업로드 완료 — 성공 ${successCount}건, 스킵 ${skipCount}건`);
|
||||
} else {
|
||||
message.warning(`업로드 완료 — 성공 ${successCount}건, 스킵 ${skipCount}건, 오류 ${errorCount}건`);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['chargebacks'] });
|
||||
// 파일 입력 초기화
|
||||
setUploadFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
},
|
||||
});
|
||||
|
||||
const uploadResult = uploadMutation.data;
|
||||
|
||||
const handleUpload = () => {
|
||||
if (!uploadMonth) {
|
||||
message.error('정산월을 입력하세요');
|
||||
return;
|
||||
}
|
||||
if (!uploadFile) {
|
||||
message.error('파일을 선택하세요');
|
||||
return;
|
||||
}
|
||||
uploadMutation.mutate({ month: uploadMonth, file: uploadFile });
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const res = await chargebackApi.export(params);
|
||||
const url = URL.createObjectURL(new Blob([res.data as BlobPart]));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `환수자료_${dayjs().format('YYYYMMDD')}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
/* request.ts 처리 */
|
||||
}
|
||||
};
|
||||
|
||||
const rows = listData?.list ?? [];
|
||||
|
||||
const pinnedBottom =
|
||||
rows.length > 0
|
||||
? ({
|
||||
policyNo: '합계',
|
||||
cbAmount: rows.reduce((s, r) => s + (r.cbAmount ?? 0), 0),
|
||||
remainAmount: rows.reduce((s, r) => s + (r.remainAmount ?? 0), 0),
|
||||
} as Partial<ChargebackRow>)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="환수자료관리"
|
||||
description="보험사 환수 자료 업로드 및 환수 목록 조회"
|
||||
extra={
|
||||
<PermissionButton menuCode={MENU_CODE} permCode="EXPORT" onClick={handleExport}>
|
||||
엑셀 다운로드
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{/* ── 업로드 영역 ────────────────────────────── */}
|
||||
<ProCard
|
||||
title="보험사 환수자료 업로드"
|
||||
style={{ ...cardStyle, marginBottom: 16 }}
|
||||
bodyStyle={{ padding: '16px 20px' }}
|
||||
>
|
||||
<Space wrap size={12} align="end">
|
||||
<div>
|
||||
<Text style={{ fontSize: 12, color: GRAY[600], display: 'block', marginBottom: 4 }}>
|
||||
정산월
|
||||
</Text>
|
||||
<DatePicker
|
||||
picker="month"
|
||||
style={{ width: 150 }}
|
||||
onChange={(_, val) => setUploadMonth(Array.isArray(val) ? val[0] : val)}
|
||||
format="YYYYMM"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text style={{ fontSize: 12, color: GRAY[600], display: 'block', marginBottom: 4 }}>
|
||||
파일 선택 (xlsx/xls)
|
||||
</Text>
|
||||
<Space size={8}>
|
||||
<Button onClick={() => fileInputRef.current?.click()}>
|
||||
{uploadFile ? uploadFile.name : '파일 선택'}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
setUploadFile(e.target.files?.[0] ?? null);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<PermissionButton
|
||||
menuCode={MENU_CODE}
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={handleUpload}
|
||||
>
|
||||
업로드
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
|
||||
{/* ── 업로드 결과 ─────────────────────────── */}
|
||||
{uploadResult && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space size={24} style={{ marginBottom: 8 }}>
|
||||
<Text style={{ color: COLOR_SUCCESS, fontWeight: 600 }}>
|
||||
성공 {uploadResult.successCount}건
|
||||
</Text>
|
||||
<Text style={{ color: GRAY[500], fontWeight: 600 }}>
|
||||
스킵 {uploadResult.skipCount}건
|
||||
</Text>
|
||||
{uploadResult.errorCount > 0 && (
|
||||
<Text style={{ color: COLOR_ERROR, fontWeight: 600 }}>
|
||||
오류 {uploadResult.errorCount}건
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{uploadResult.errors.length > 0 && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={`오류 행 목록 (${uploadResult.errors.length}건)`}
|
||||
description={
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="rowNo"
|
||||
dataSource={uploadResult.errors}
|
||||
pagination={false}
|
||||
style={{ marginTop: 8 }}
|
||||
columns={[
|
||||
{ title: '행 번호', dataIndex: 'rowNo', width: 80, align: 'right' },
|
||||
{ title: '사유', dataIndex: 'reason' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ProCard>
|
||||
|
||||
{/* ── 검색 폼 ────────────────────────────────── */}
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
{ type: 'text', name: 'policyNo', label: '증권번호', span: 6 },
|
||||
{ type: 'text', name: 'status', label: '상태', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...(v as ChargebackSearchParam), pageSize: 500 })}
|
||||
onReset={() => setParams({ pageSize: 500 })}
|
||||
/>
|
||||
|
||||
{/* ── AG Grid 목록 ───────────────────────────── */}
|
||||
<ProCard style={cardStyle}>
|
||||
<DataGrid<ChargebackRow>
|
||||
rows={rows}
|
||||
columns={GRID_COLS}
|
||||
loading={isLoading}
|
||||
height={560}
|
||||
rowKey="cbId"
|
||||
pinnedBottomRow={pinnedBottom}
|
||||
/>
|
||||
</ProCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user