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:
GA Pro
2026-06-05 00:51:08 +09:00
parent 90256a19a1
commit 284ecc4203
15 changed files with 1004 additions and 0 deletions
+6
View File
@@ -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 />} />
+62
View File
@@ -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>
);
}