feat: P9 환수채권 이월/미수금 관리 — 수수료 갭 풀스택 (PL 자율진행)
incar CMS 메뉴(환수이월관리/환수계약리스트/위촉중환수) ↔ GA 부재 + 코드 갭 (AggregateStep net<0 시 경고만, 미회수 환수 채권화·차월상계·대손 생애주기 전무) 을 도출해 풀스택 구현. 미회수 환수가 소실되던 재무 누락을 폐쇄. - DB(V111): clawback_receivable(채권 마스터, UNIQUE agent+origin월) + clawback_recovery(상계 회수 이력) + settle_master 3컬럼(clawback_recovered/ clawback_carryover/payable_amount) + 메뉴 CLAWBACK_RECEIVABLE + 권한 + 공통코드. - core: VO/Resp/SaveReq/SearchParam/Enum/Mapper(.java/.xml) 2테이블 + MapStruct. outstandingBalance(origin<settleMonth 자기상계방지) / FIFO / upsertCarryover(멱등). - api: ClawbackReceivableService/Controller(/api/clawback-receivables) — list/detail/ summary/write-off(OUTSTANDING·RECOVERING만, CLEARED/WRITTEN_OFF 전이)/export(@Transactional). - batch: AggregateStep 통합 — net<0 → carryover 채권화, net>0 → FIFO 상계회수, reverse-먼저 방식으로 동일월 재실행 멱등. net_amount 의미 불변(무결성 보존). payable_amount = max(0,net) − recovered. - fix(잠복): SettleMasterMapper.xml upsert에 신규 3컬럼 누락 → INSERT/ON CONFLICT 추가 (배치가 VO에 set해도 DEFAULT 0으로 미영속되던 버그, PL 검증서 발견). - frontend: ClawbackReceivable 화면(AG Grid 합계행 + 회수이력 타임라인 Drawer + 대손/상환 모달) + api 모듈 + App 라우트. 검증(PL 직접): ./gradlew build(test포함) SUCCESSFUL. Flyway V109~V111 운영DB 적용 (schema v111). 라이브 API: GET 목록/summary 200, write-off WRITTEN_OFF 200(한글사유 영속), 종결 재처리·잘못된 전이상태 E411 차단. 배치 이월/상계/멱등/부분상계 4시나리오 실 스키마 롤백 트랜잭션 검증 PASS(CHECK balance>=0 포함). 잔여 데이터 0. 스펙: docs/DOMAIN_GAP_P9_환수채권이월.md. 후속 갭(시상/인정실적 환수 ledger, 지급대상건 구성, 환수 보험사자료 업로드) 문서화. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,9 @@ const GuaranteeInsurance = React.lazy(() => import('@/pages/org/GuaranteeInsur
|
||||
const ReserveFund = React.lazy(() => import('@/pages/commission/ReserveFund'));
|
||||
const CommissionSimulator = React.lazy(() => import('@/pages/commission/CommissionSimulator'));
|
||||
|
||||
// P9: 환수채권
|
||||
const ClawbackReceivable = React.lazy(() => import('@/pages/commission/ClawbackReceivable'));
|
||||
|
||||
// P7: 수수료 계산 7도메인
|
||||
const IncomeCommission = React.lazy(() => import('@/pages/commission/IncomeCommission'));
|
||||
const ReferralCommission = React.lazy(() => import('@/pages/commission/ReferralCommission'));
|
||||
@@ -224,6 +227,9 @@ export default function App() {
|
||||
<Route path="commission/reserve" element={<ReserveFund />} />
|
||||
<Route path="commission/simulator" element={<CommissionSimulator />} />
|
||||
|
||||
{/* P9: 환수채권 */}
|
||||
<Route path="commission/clawback-receivable" element={<ClawbackReceivable />} />
|
||||
|
||||
{/* P7: 수수료 계산 7도메인 */}
|
||||
<Route path="commission/income" element={<IncomeCommission />} />
|
||||
<Route path="commission/referral" element={<ReferralCommission />} />
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
// ── Types ──────────────────────────────────────────
|
||||
|
||||
export type ClawbackStatus = 'OUTSTANDING' | 'RECOVERING' | 'CLEARED' | 'WRITTEN_OFF';
|
||||
|
||||
export interface ClawbackReceivableRow extends Record<string, unknown> {
|
||||
receivableId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
originSettleMonth: string;
|
||||
originalAmount: number;
|
||||
recoveredAmount: number;
|
||||
balance: number;
|
||||
status: ClawbackStatus;
|
||||
statusName: string;
|
||||
lastRecoveryMonth?: string;
|
||||
writeoffReason?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ClawbackRecoveryItem {
|
||||
settleMonth: string;
|
||||
recoveredAmount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ClawbackReceivableDetail extends ClawbackReceivableRow {
|
||||
recoveries: ClawbackRecoveryItem[];
|
||||
}
|
||||
|
||||
export interface ClawbackSummaryRow extends Record<string, unknown> {
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
totalBalance: number;
|
||||
receivableCount: number;
|
||||
}
|
||||
|
||||
export interface ClawbackSearchParam {
|
||||
agentId?: number;
|
||||
agentName?: string;
|
||||
status?: string;
|
||||
originSettleMonth?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface WriteOffParam {
|
||||
status: 'CLEARED' | 'WRITTEN_OFF';
|
||||
writeoffReason: string;
|
||||
}
|
||||
|
||||
// ── API ───────────────────────────────────────────
|
||||
|
||||
export const clawbackReceivableApi = {
|
||||
list: (p: ClawbackSearchParam) =>
|
||||
unwrap<PageResponse<ClawbackReceivableRow>>(
|
||||
api.get('/api/clawback-receivables', { params: p }),
|
||||
),
|
||||
|
||||
summary: () =>
|
||||
unwrap<ClawbackSummaryRow[]>(
|
||||
api.get('/api/clawback-receivables/summary'),
|
||||
),
|
||||
|
||||
detail: (id: number) =>
|
||||
unwrap<ClawbackReceivableDetail>(
|
||||
api.get(`/api/clawback-receivables/${id}`),
|
||||
),
|
||||
|
||||
writeOff: (id: number, body: WriteOffParam) =>
|
||||
unwrap<ClawbackReceivableRow>(
|
||||
api.put(`/api/clawback-receivables/${id}/write-off`, body),
|
||||
),
|
||||
|
||||
export: (p: ClawbackSearchParam) =>
|
||||
api.get('/api/clawback-receivables/export', {
|
||||
params: p,
|
||||
responseType: 'blob',
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Drawer, Form, Input, Modal, Select, Space, Tag, Timeline, 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 {
|
||||
clawbackReceivableApi,
|
||||
ClawbackReceivableRow,
|
||||
ClawbackReceivableDetail,
|
||||
ClawbackSearchParam,
|
||||
} from '@/api/clawbackReceivable';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_SUCCESS, COLOR_ERROR, COLOR_WARNING, COLOR_PRIMARY } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const fmt = (v: unknown) =>
|
||||
typeof v === 'number' ? v.toLocaleString() : (v as string | undefined) ?? '-';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
OUTSTANDING: COLOR_ERROR,
|
||||
RECOVERING: COLOR_WARNING,
|
||||
CLEARED: COLOR_SUCCESS,
|
||||
WRITTEN_OFF: GRAY[400],
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
OUTSTANDING: '미회수',
|
||||
RECOVERING: '회수중',
|
||||
CLEARED: '상환완료',
|
||||
WRITTEN_OFF: '대손',
|
||||
};
|
||||
|
||||
function StatusChip({ status, statusName }: { status: string; statusName?: string }) {
|
||||
const color = STATUS_COLOR[status] ?? GRAY[400];
|
||||
const label = statusName ?? 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 GRID_COLS: ColDef<ClawbackReceivableRow>[] = [
|
||||
{ field: 'agentName', headerName: '설계사', width: 130, pinned: 'left' },
|
||||
{ field: 'originSettleMonth', headerName: '발생월', width: 110 },
|
||||
{
|
||||
field: 'originalAmount',
|
||||
headerName: '발생액',
|
||||
flex: 1,
|
||||
type: 'numericColumn',
|
||||
valueFormatter: (p) => fmt(p.value),
|
||||
},
|
||||
{
|
||||
field: 'recoveredAmount',
|
||||
headerName: '회수액',
|
||||
flex: 1,
|
||||
type: 'numericColumn',
|
||||
valueFormatter: (p) => fmt(p.value),
|
||||
},
|
||||
{
|
||||
field: 'balance',
|
||||
headerName: '잔액',
|
||||
flex: 1,
|
||||
type: 'numericColumn',
|
||||
valueFormatter: (p) => fmt(p.value),
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
headerName: '상태',
|
||||
width: 120,
|
||||
cellRenderer: (p: { data: ClawbackReceivableRow }) =>
|
||||
p.data ? <StatusChip status={p.data.status} statusName={p.data.statusName} /> : null,
|
||||
},
|
||||
{ field: 'lastRecoveryMonth', headerName: '최근회수월', width: 120 },
|
||||
];
|
||||
|
||||
const MENU_CODE = 'CLAWBACK_RECEIVABLE';
|
||||
|
||||
export default function ClawbackReceivable() {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useState<ClawbackSearchParam>({ pageSize: 200 });
|
||||
const [selectedRow, setSelectedRow] = useState<ClawbackReceivableRow | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [writeOffOpen, setWriteOffOpen] = useState(false);
|
||||
const [writeOffForm] = Form.useForm();
|
||||
|
||||
// ── 목록 조회 ──────────────────────────────────────
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: ['clawback-receivables', params],
|
||||
queryFn: () => clawbackReceivableApi.list(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// ── 상세 조회 (행 클릭 시) ─────────────────────────
|
||||
const { data: detail, isLoading: detailLoading } = useQuery({
|
||||
queryKey: ['clawback-receivable', selectedRow?.receivableId],
|
||||
queryFn: () => clawbackReceivableApi.detail(selectedRow!.receivableId),
|
||||
enabled: !!selectedRow,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// ── 대손/상환 처리 뮤테이션 ──────────────────────────
|
||||
const writeOffMutation = useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: { status: 'CLEARED' | 'WRITTEN_OFF'; writeoffReason: string } }) =>
|
||||
clawbackReceivableApi.writeOff(id, body),
|
||||
onSuccess: () => {
|
||||
message.success('처리 완료');
|
||||
qc.invalidateQueries({ queryKey: ['clawback-receivables'] });
|
||||
qc.invalidateQueries({ queryKey: ['clawback-receivable', selectedRow?.receivableId] });
|
||||
setWriteOffOpen(false);
|
||||
writeOffForm.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
const rows = listData?.list ?? [];
|
||||
|
||||
// ── 합계행 ─────────────────────────────────────────
|
||||
const pinnedBottom =
|
||||
rows.length > 0
|
||||
? ({
|
||||
agentName: '합계',
|
||||
originalAmount: rows.reduce((s, r) => s + (r.originalAmount ?? 0), 0),
|
||||
recoveredAmount: rows.reduce((s, r) => s + (r.recoveredAmount ?? 0), 0),
|
||||
balance: rows.reduce((s, r) => s + (r.balance ?? 0), 0),
|
||||
} as Partial<ClawbackReceivableRow>)
|
||||
: undefined;
|
||||
|
||||
const handleRowClick = (row: ClawbackReceivableRow) => {
|
||||
setSelectedRow(row);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const res = await clawbackReceivableApi.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 handleWriteOffSubmit = async () => {
|
||||
const values = await writeOffForm.validateFields();
|
||||
if (!selectedRow) return;
|
||||
writeOffMutation.mutate({
|
||||
id: selectedRow.receivableId,
|
||||
body: { status: values.status, writeoffReason: values.writeoffReason },
|
||||
});
|
||||
};
|
||||
|
||||
const canWriteOff =
|
||||
selectedRow?.status === 'OUTSTANDING' || selectedRow?.status === 'RECOVERING';
|
||||
|
||||
const cardStyle = {
|
||||
background: '#fff',
|
||||
borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
boxShadow: SHADOW.sm,
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="환수채권관리"
|
||||
description="환수채권 이월/미수금 목록 조회 및 대손/상환 처리"
|
||||
extra={
|
||||
<PermissionButton menuCode={MENU_CODE} permCode="EXPORT" onClick={handleExport}>
|
||||
엑셀 다운로드
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', span: 6, groupCode: 'CLAWBACK_RCV_STATUS' },
|
||||
{ type: 'month', name: 'originSettleMonth', label: '발생월', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...(v as ClawbackSearchParam), pageSize: 200 })}
|
||||
onReset={() => setParams({ pageSize: 200 })}
|
||||
/>
|
||||
|
||||
<ProCard style={cardStyle}>
|
||||
<DataGrid<ClawbackReceivableRow>
|
||||
rows={rows}
|
||||
columns={GRID_COLS}
|
||||
loading={isLoading}
|
||||
height={560}
|
||||
rowKey="receivableId"
|
||||
pinnedBottomRow={pinnedBottom}
|
||||
onSelectionChanged={(e) => {
|
||||
const sel = e.api.getSelectedRows();
|
||||
if (sel.length > 0) handleRowClick(sel[0]);
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
{/* ── 상세 Drawer ─────────────────────────────── */}
|
||||
<Drawer
|
||||
title="환수채권 상세"
|
||||
width={520}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
<PermissionButton
|
||||
menuCode={MENU_CODE}
|
||||
permCode="UPDATE"
|
||||
type="primary"
|
||||
danger
|
||||
disabled={!canWriteOff}
|
||||
onClick={() => setWriteOffOpen(true)}
|
||||
>
|
||||
대손/상환 처리
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{detailLoading || !detail ? (
|
||||
<Text style={{ color: GRAY[400] }}>불러오는 중...</Text>
|
||||
) : (
|
||||
<DrawerContent detail={detail} />
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* ── 대손/상환 처리 모달 ──────────────────────── */}
|
||||
<Modal
|
||||
title="대손/상환 처리"
|
||||
open={writeOffOpen}
|
||||
onOk={handleWriteOffSubmit}
|
||||
onCancel={() => { setWriteOffOpen(false); writeOffForm.resetFields(); }}
|
||||
okText="처리"
|
||||
cancelText="취소"
|
||||
confirmLoading={writeOffMutation.isPending}
|
||||
>
|
||||
<Form form={writeOffForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="status" label="처리 유형" rules={[{ required: true, message: '처리 유형을 선택하세요' }]}>
|
||||
<Select placeholder="선택">
|
||||
<Select.Option value="CLEARED">상환완료 (직접 상환)</Select.Option>
|
||||
<Select.Option value="WRITTEN_OFF">대손 처리</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="writeoffReason" label="사유" rules={[{ required: true, message: '사유를 입력하세요' }]}>
|
||||
<Input.TextArea rows={3} placeholder="처리 사유를 입력하세요" maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 상세 내용 컴포넌트 ─────────────────────────────────
|
||||
|
||||
function DrawerContent({ detail }: { detail: ClawbackReceivableDetail }) {
|
||||
const fields: Array<{ label: string; value: React.ReactNode }> = [
|
||||
{ label: '설계사', value: detail.agentName },
|
||||
{ label: '발생월', value: detail.originSettleMonth },
|
||||
{ label: '발생액', value: fmt(detail.originalAmount) },
|
||||
{ label: '회수액', value: fmt(detail.recoveredAmount) },
|
||||
{ label: '잔액', value: fmt(detail.balance) },
|
||||
{ label: '상태', value: <StatusChip status={detail.status} statusName={detail.statusName} /> },
|
||||
{ label: '최근회수월', value: detail.lastRecoveryMonth ?? '-' },
|
||||
{ label: '등록일', value: detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD') : '-' },
|
||||
];
|
||||
|
||||
if (detail.writeoffReason) {
|
||||
fields.push({ label: '처리사유', value: detail.writeoffReason });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '100px 1fr',
|
||||
gap: '10px 12px',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{fields.map(({ label, value }) => (
|
||||
<>
|
||||
<Text key={`lbl-${label}`} style={{ color: GRAY[500], fontSize: 13 }}>{label}</Text>
|
||||
<Text key={`val-${label}`} style={{ color: GRAY[800], fontSize: 13, fontWeight: 500 }}>{value}</Text>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{detail.recoveries.length > 0 && (
|
||||
<>
|
||||
<Text style={{ fontWeight: 600, color: GRAY[700], fontSize: 14, display: 'block', marginBottom: 12 }}>
|
||||
회수 이력
|
||||
</Text>
|
||||
<Timeline
|
||||
items={detail.recoveries.map((r) => ({
|
||||
color: COLOR_PRIMARY,
|
||||
children: (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text style={{ fontWeight: 600, color: GRAY[700], fontSize: 13 }}>
|
||||
{r.settleMonth} 정산월
|
||||
</Text>
|
||||
<Text style={{ color: GRAY[500], fontSize: 12 }}>
|
||||
회수액: {fmt(r.recoveredAmount)}원
|
||||
</Text>
|
||||
<Text style={{ color: GRAY[400], fontSize: 11 }}>
|
||||
{dayjs(r.createdAt).format('YYYY-MM-DD')}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{detail.recoveries.length === 0 && (
|
||||
<Text style={{ color: GRAY[400], fontSize: 13 }}>회수 이력이 없습니다.</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user