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:
GA Pro
2026-06-05 00:18:58 +09:00
parent b12858c991
commit 51ee03453c
23 changed files with 1700 additions and 6 deletions
@@ -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>
)}
</>
);
}