feat: 계약 승계(contract succession) — 유지수수료 귀속 후임 전환 (V86)
설계사 해촉/이관 시 보험계약의 유지수수료 귀속을 승계월 기준 후임 설계사로 전환. 모집수수료는 원 모집설계사 귀속 유지(고아계약 승계 표준). - DB V86: contract_succession 테이블 + 인덱스 + 메뉴/권한(GRP_PRD) + 공통코드 2그룹 + 테스트데이터 1건 - core: ContractSuccession VO/Resp/SaveReq/SearchParam + Mapper(.java/.xml) selectEffectiveSuccessions(settleMonth): 계약별 최근 ACTIVE 승계 1건 (DISTINCT ON) - api: ContractSuccessionService/Controller (/api/contract-successions, CRUD+cancel) - batch: CalcMaintainStep 이 effective 승계 사전로드(N+1 회피) 후 effectiveAgentId 결정, CommissionCalculator.calcMaintainLedger 오버로드로 후임 등급 payout + ledger agent_id 후임 기록. AggregateStep 은 ledger agent_id 집계라 무변경. - frontend: api/succession.ts + ContractSuccessions.tsx + App.tsx 라우트 검증: 5모듈 compileJava SUCCESS / ga-frontend tsc 0 / Flyway v86 적용 / API CRUD+cancel+from==to 가드(E411) 라이브 PASS / resolver 쿼리 라이브 검증(계약1→후임 agent2). 배치 풀잡 e2e 는 정산월 원장 재생성(파괴적)이라 미실행. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ import AgentTrainings from '@/pages/agent/AgentTrainings';
|
||||
|
||||
// ── P4: 계약 운영 ────────────────────────────────────
|
||||
import ContractEndorsements from '@/pages/contracts/ContractEndorsements';
|
||||
import ContractSuccessions from '@/pages/contracts/ContractSuccessions';
|
||||
import Complaints from '@/pages/contracts/Complaints';
|
||||
|
||||
// ── P4: 시스템 ──────────────────────────────────────
|
||||
@@ -191,6 +192,7 @@ export default function App() {
|
||||
|
||||
{/* P4: 계약 운영 */}
|
||||
<Route path="contracts/endorsements" element={<ContractEndorsements />} />
|
||||
<Route path="contracts/successions" element={<ContractSuccessions />} />
|
||||
<Route path="contracts/complaints" element={<Complaints />} />
|
||||
|
||||
{/* P4: 시스템 */}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface ContractSuccessionRow extends Record<string, unknown> {
|
||||
successionId: number;
|
||||
contractId: number;
|
||||
policyNo: string;
|
||||
fromAgentId: number;
|
||||
fromAgentName: string;
|
||||
toAgentId: number;
|
||||
toAgentName: string;
|
||||
successionDate: string;
|
||||
reason: string; // TERMINATION / TRANSFER / REASSIGN
|
||||
reasonName: string;
|
||||
memo?: string;
|
||||
status: string; // ACTIVE / CANCELED
|
||||
statusName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ContractSuccessionSaveReq {
|
||||
contractId: number;
|
||||
fromAgentId: number;
|
||||
toAgentId: number;
|
||||
successionDate: string;
|
||||
reason?: string;
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export interface SuccessionSearchParam {
|
||||
contractId?: number;
|
||||
fromAgentId?: number;
|
||||
toAgentId?: number;
|
||||
reason?: string;
|
||||
status?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const successionApi = {
|
||||
list: (p: SuccessionSearchParam) =>
|
||||
unwrap<PageResponse<ContractSuccessionRow>>(
|
||||
api.get('/api/contract-successions', { params: p }),
|
||||
),
|
||||
create: (body: ContractSuccessionSaveReq) =>
|
||||
unwrap<number>(api.post('/api/contract-successions', body)),
|
||||
cancel: (id: number) =>
|
||||
unwrap<void>(api.post(`/api/contract-successions/${id}/cancel`)),
|
||||
delete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/contract-successions/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Select, Tag, message } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ColDef } from '@ag-grid-community/core';
|
||||
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 CodeBadge from '@/components/common/CodeBadge';
|
||||
import { successionApi, ContractSuccessionRow, ContractSuccessionSaveReq, SuccessionSearchParam } from '@/api/succession';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const COLUMNS: ColDef<ContractSuccessionRow>[] = [
|
||||
{ field: 'policyNo', headerName: '증권번호', width: 150, pinned: 'left' },
|
||||
{ field: 'fromAgentName', headerName: '전임 설계사', width: 130 },
|
||||
{ field: 'toAgentName', headerName: '후임 설계사', width: 130 },
|
||||
{ field: 'successionDate', headerName: '승계일', width: 120 },
|
||||
{
|
||||
field: 'reason', headerName: '사유', width: 110,
|
||||
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="SUCCESSION_REASON" value={p.value} />,
|
||||
},
|
||||
{ field: 'memo', headerName: '메모', flex: 1 },
|
||||
{
|
||||
field: 'status', headerName: '상태', width: 100,
|
||||
cellRenderer: (p: { value: string }) =>
|
||||
p.value === 'CANCELED'
|
||||
? <Tag color="default" style={{ margin: 0 }}>취소</Tag>
|
||||
: <Tag color="green" style={{ margin: 0, fontWeight: 700 }}>유효</Tag>,
|
||||
},
|
||||
{ field: 'createdAt', headerName: '등록일', width: 120 },
|
||||
];
|
||||
|
||||
export default function ContractSuccessions() {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useState<SuccessionSearchParam>({ pageSize: 100 });
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['successions', params],
|
||||
queryFn: () => successionApi.list(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields() as ContractSuccessionSaveReq;
|
||||
try {
|
||||
await successionApi.create(values);
|
||||
message.success('계약 승계 등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['successions'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* handled by interceptor */ }
|
||||
};
|
||||
|
||||
const handleCancel = (id: number) => Modal.confirm({
|
||||
title: '승계 취소',
|
||||
content: '이 승계를 취소합니다. 이후 정산월부터 유지수수료는 다시 직전 귀속 설계사에게 계산됩니다.',
|
||||
okText: '취소 처리',
|
||||
cancelText: '닫기',
|
||||
onOk: async () => {
|
||||
await successionApi.cancel(id);
|
||||
message.success('승계 취소 완료');
|
||||
qc.invalidateQueries({ queryKey: ['successions'] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="계약 승계 관리"
|
||||
description="설계사 해촉·이관 시 보험계약의 유지수수료 귀속 설계사를 후임으로 전환 (모집수수료는 원 설계사 귀속 유지)"
|
||||
extra={
|
||||
<PermissionButton menuCode="CONTRACT_SUCCESSION" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 승계 등록
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/contract-successions 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'code', name: 'reason', label: '사유', groupCode: 'SUCCESSION_REASON', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', groupCode: 'SUCCESSION_STATUS', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as SuccessionSearchParam, pageSize: 100 })}
|
||||
onReset={() => setParams({ pageSize: 100 })}
|
||||
/>
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<ContractSuccessionRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLUMNS,
|
||||
{
|
||||
headerName: '액션', width: 110, pinned: 'right',
|
||||
cellRenderer: (p: { data: ContractSuccessionRow }) => (
|
||||
<PermissionButton
|
||||
menuCode="CONTRACT_SUCCESSION" permCode="UPDATE" size="small" danger
|
||||
disabled={p.data.status === 'CANCELED'}
|
||||
onClick={() => handleCancel(p.data.successionId)}
|
||||
>
|
||||
취소
|
||||
</PermissionButton>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={600}
|
||||
rowKey="successionId"
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<Modal title="계약 승계 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
|
||||
<Form form={form} layout="vertical" initialValues={{ reason: 'TERMINATION' }}>
|
||||
<Form.Item name="contractId" label="계약 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="contract_id" />
|
||||
</Form.Item>
|
||||
<Form.Item name="fromAgentId" label="전임 설계사 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="toAgentId" label="후임 설계사 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="successionDate" label="승계일" rules={[{ required: true }]}>
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="사유" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'TERMINATION', label: '해촉' },
|
||||
{ value: 'TRANSFER', label: '이관' },
|
||||
{ value: 'REASSIGN', label: '재배정' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="메모">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user