feat: 수수료 공식 갭 보완 + 8종 신규모듈 풀스택 (V116~V123)
수수료 공식 갭분석(5 병렬감사) 후 확정 버그 3건 수정 + 개념적으로 빠진 모듈 8종 풀스택 구현. [확정 버그 3건] - 1200% 누적한도(checkContractTotal)를 배치 CalcRecruitStep에 연결(기존 1차년 한도만 적용 → 두 한도 중 더 제한적인 값으로 clip+이연). +CalcRecruitStepTest - BatchInstallmentPlanGenerator planMonth 반영(하드코딩 1 제거, API판과 동일). +단위테스트 - PersistencyBonus 임계유지율 게이트 + FIXED 정액 룰 도출. +PersistencyBonusServiceTest [신규 모듈 8종] - MOD-1 도입수수료(recruiter/development) / MOD-2 선지급·차익정산 / MOD-3 환수 분할상환 - MOD-4 익월부활 재지급 / MOD-5 정산대사 입수 / MOD-6 환수 시효 / MOD-7 환수 감면 - MOD-8 채널별 수수료차등(sales_channel) 각 DB(테이블+메뉴+권한+공통코드)→VO/Mapper/Enum→Service/Controller→화면 풀스택. 배치 정산연계: AggregateStep에 도입/익월부활 가산(11종), 선지급 FIFO 상계(멱등 reverse), MOD-8 채널 우선 요율조회(NULL 폴백 하위호환). [검증] - 전체 ./gradlew build(전 모듈+전 테스트) GREEN - Flyway V116~V123 운영DB 적용(schema v115→v123) - 라이브 스모크 8 GET=200, MOD-1 RATE공식 50000 실측, 액션 POST 5xx 0건 - 적대적 코드리뷰 APPROVED(MOD-8 계산기 미연결 버그 발견·수정) 스펙: docs/DOMAIN_GAP_MOD1-8.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Select, Space, 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 {
|
||||
commissionApi,
|
||||
ChargebackWaiverRow,
|
||||
ChargebackWaiverSaveReq,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'REQUESTED', label: '신청' },
|
||||
{ value: 'APPROVED', label: '승인' },
|
||||
{ value: 'REJECTED', label: '반려' },
|
||||
];
|
||||
|
||||
const COLS: ColDef<ChargebackWaiverRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'contractNo', headerName: '증권번호', width: 140 },
|
||||
{ field: 'originalChargeback', headerName: '원환수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'waiverAmount', headerName: '감면액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'reason', headerName: '사유', flex: 1 },
|
||||
{ field: 'approvalStatus', headerName: '승인상태', width: 110 },
|
||||
{ field: 'approvedAt', headerName: '승인일시', width: 160 },
|
||||
];
|
||||
|
||||
export default function ChargebackWaiver() {
|
||||
const qc = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useState<CommissionSearchParam & { approvalStatus?: string }>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [rejectTarget, setRejectTarget] = useState<ChargebackWaiverRow | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [form] = Form.useForm<ChargebackWaiverSaveReq>();
|
||||
|
||||
const { data: listData, isLoading, isError } = useQuery({
|
||||
queryKey: ['chargebackWaiver', 'list', searchParams],
|
||||
queryFn: () => commissionApi.chargebackWaiverList(searchParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = listData?.list ?? [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: ChargebackWaiverSaveReq) => commissionApi.chargebackWaiverCreate(body),
|
||||
onSuccess: () => {
|
||||
message.success('신청 완료');
|
||||
qc.invalidateQueries({ queryKey: ['chargebackWaiver', 'list'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (requestId: number) => commissionApi.chargebackWaiverApprove(requestId),
|
||||
onSuccess: () => {
|
||||
message.success('승인 완료');
|
||||
qc.invalidateQueries({ queryKey: ['chargebackWaiver', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ requestId, reason }: { requestId: number; reason: string }) =>
|
||||
commissionApi.chargebackWaiverReject(requestId, reason),
|
||||
onSuccess: () => {
|
||||
message.success('반려 완료');
|
||||
qc.invalidateQueries({ queryKey: ['chargebackWaiver', 'list'] });
|
||||
setRejectTarget(null);
|
||||
setRejectReason('');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
createMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleApprove = (row: ChargebackWaiverRow) => Modal.confirm({
|
||||
title: '승인',
|
||||
content: `${row.agentName} 감면 신청(감면액: ${fmt(row.waiverAmount)})을 승인하시겠습니까?`,
|
||||
onOk: () => approveMutation.mutateAsync(row.requestId),
|
||||
});
|
||||
|
||||
const openReject = (row: ChargebackWaiverRow) => {
|
||||
setRejectTarget(row);
|
||||
setRejectReason('');
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
if (!rejectReason.trim()) { message.warning('반려 사유를 입력하세요'); return; }
|
||||
rejectMutation.mutate({ requestId: rejectTarget!.requestId, reason: rejectReason });
|
||||
};
|
||||
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
agentName: '합계',
|
||||
originalChargeback: rows.reduce((s, r) => s + (r.originalChargeback ?? 0), 0),
|
||||
waiverAmount: rows.reduce((s, r) => s + (r.waiverAmount ?? 0), 0),
|
||||
} as Partial<ChargebackWaiverRow> : undefined;
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="환수감면신청" description="환수 감면 신청·승인·반려 워크플로">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/chargeback-waivers 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
{ type: 'code', name: 'approvalStatus', label: '승인상태', span: 6, groupCode: 'WAIVER_STATUS' },
|
||||
]}
|
||||
onSearch={(v) => setSearchParams({ ...v as typeof searchParams, pageSize: 200 })}
|
||||
onReset={() => setSearchParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
||||
/>
|
||||
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="CHARGEBACK_WAIVER" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 감면 신청
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<ChargebackWaiverRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLS,
|
||||
{
|
||||
headerName: '액션', width: 180, pinned: 'right',
|
||||
cellRenderer: (p: { data: ChargebackWaiverRow }) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
menuCode="CHARGEBACK_WAIVER" permCode="UPDATE" size="small"
|
||||
disabled={p.data.approvalStatus !== 'REQUESTED'}
|
||||
onClick={() => handleApprove(p.data)}
|
||||
>
|
||||
승인
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
menuCode="CHARGEBACK_WAIVER" permCode="UPDATE" size="small" danger
|
||||
disabled={p.data.approvalStatus !== 'REQUESTED'}
|
||||
onClick={() => openReject(p.data)}
|
||||
>
|
||||
반려
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={520}
|
||||
rowKey="requestId"
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
{/* 신청 모달 */}
|
||||
<Modal
|
||||
title="감면 신청"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => { setCreateOpen(false); form.resetFields(); }}
|
||||
okText="신청"
|
||||
cancelText="취소"
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="contractId" label="계약 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="settleMonth" label="정산월(YYYYMM)" rules={[{ required: true }]}>
|
||||
<Input placeholder="예: 202601" />
|
||||
</Form.Item>
|
||||
<Form.Item name="originalChargeback" label="원 환수액" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="waiverAmount" label="감면 요청액" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="사유">
|
||||
<Input.TextArea rows={3} placeholder="감면 사유를 입력하세요" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 반려 사유 모달 */}
|
||||
<Modal
|
||||
title="반려 사유 입력"
|
||||
open={!!rejectTarget}
|
||||
onOk={handleReject}
|
||||
onCancel={() => { setRejectTarget(null); setRejectReason(''); }}
|
||||
okText="반려"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="취소"
|
||||
confirmLoading={rejectMutation.isPending}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="반려 사유를 입력하세요"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user