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:
GA Pro
2026-06-06 17:32:25 +09:00
parent 00ead64c4c
commit ff6d399dd3
114 changed files with 6419 additions and 53 deletions
@@ -0,0 +1,153 @@
import { useState } from 'react';
import { Alert, Descriptions, Form, Input, InputNumber, Modal, 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 DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import { commissionApi, StatuteConfigRow } from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const COLS: ColDef<StatuteConfigRow>[] = [
{ field: 'entityType', headerName: '대상유형', width: 200 },
{ field: 'expirationMonths', headerName: '시효(개월)', width: 130, type: 'numericColumn' },
{ field: 'isActive', headerName: '활성', width: 80 },
];
export default function StatuteConfig() {
const qc = useQueryClient();
const [editTarget, setEditTarget] = useState<StatuteConfigRow | null>(null);
const [expireMonth, setExpireMonth] = useState(dayjs().format('YYYYMM'));
const [expireResult, setExpireResult] = useState<{ expiredCount: number } | null>(null);
const [form] = Form.useForm<Partial<StatuteConfigRow>>();
const { data: configData, isLoading, isError } = useQuery({
queryKey: ['statuteConfig', 'list'],
queryFn: () => commissionApi.statuteConfigs(),
retry: false,
});
const rows = configData?.list ?? [];
const updateMutation = useMutation({
mutationFn: ({ configId, values }: { configId: number; values: Partial<StatuteConfigRow> }) =>
commissionApi.statuteConfigUpdate(configId, values),
onSuccess: () => {
message.success('설정 수정 완료');
qc.invalidateQueries({ queryKey: ['statuteConfig', 'list'] });
setEditTarget(null);
},
});
const expireMutation = useMutation({
mutationFn: (baseMonth: string) => commissionApi.statuteExpire(baseMonth),
onSuccess: (result) => {
setExpireResult(result);
message.success(`만료 처리 완료 — ${result.expiredCount}`);
qc.invalidateQueries({ queryKey: ['statuteConfig', 'list'] });
},
});
const handleUpdate = async () => {
const values = await form.validateFields();
updateMutation.mutate({ configId: editTarget!.configId, values });
};
const openEdit = (row: StatuteConfigRow) => {
setEditTarget(row);
form.setFieldsValue({
expirationMonths: row.expirationMonths,
isActive: row.isActive,
});
};
const handleExpire = () => Modal.confirm({
title: '만료 실행',
content: `기준월 ${expireMonth} 기준으로 시효 만료된 채권을 WRITTEN_OFF 처리합니다. 계속하시겠습니까?`,
okType: 'danger',
onOk: () => expireMutation.mutateAsync(expireMonth),
});
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/statute 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<ProCard style={{ ...cardStyle, marginBottom: 16 }} title="시효 설정">
<DataGrid<StatuteConfigRow>
rows={rows}
columns={[
...COLS,
{
headerName: '액션', width: 100, pinned: 'right',
cellRenderer: (p: { data: StatuteConfigRow }) => (
<PermissionButton
menuCode="STATUTE_CONFIG" permCode="UPDATE" size="small"
onClick={() => openEdit(p.data)}
>
</PermissionButton>
),
},
]}
loading={isLoading}
height={200}
rowKey="configId"
/>
</ProCard>
<ProCard style={cardStyle} title="만료 실행">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space>
<span>:</span>
<Input
value={expireMonth}
onChange={(e) => setExpireMonth(e.target.value)}
placeholder="YYYYMM"
style={{ width: 120 }}
/>
<PermissionButton
menuCode="STATUTE_CONFIG" permCode="UPDATE"
type="primary" danger
loading={expireMutation.isPending}
onClick={handleExpire}
>
</PermissionButton>
</Space>
{expireResult && (
<Descriptions size="small" bordered>
<Descriptions.Item label="처리 결과">
{expireResult.expiredCount} WRITTEN_OFF .
</Descriptions.Item>
</Descriptions>
)}
</Space>
</ProCard>
<Modal
title="시효 설정 수정"
open={!!editTarget}
onOk={handleUpdate}
onCancel={() => setEditTarget(null)}
okText="저장"
cancelText="취소"
confirmLoading={updateMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="expirationMonths" label="시효 개월수" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={1} max={120} />
</Form.Item>
<Form.Item name="isActive" label="활성(Y/N)" rules={[{ required: true }]}>
<Input placeholder="Y 또는 N" maxLength={1} />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}