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:
GA Pro
2026-05-29 21:28:00 +09:00
parent d8d64625a9
commit bd5ff4ec14
14 changed files with 660 additions and 6 deletions
@@ -0,0 +1,25 @@
package com.ga.core.mapper.succession;
import com.ga.core.vo.succession.ContractSuccessionResp;
import com.ga.core.vo.succession.ContractSuccessionSearchParam;
import com.ga.core.vo.succession.ContractSuccessionVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ContractSuccessionMapper {
ContractSuccessionResp selectById(@Param("successionId") Long successionId);
List<ContractSuccessionResp> selectList(ContractSuccessionSearchParam param);
/**
* 배치 유지수수료 계산용 — 정산월 기준 유효한 승계의 (계약, 후임설계사) 매핑.
* 계약별로 succession_date 의 YYYYMM 이 정산월 이하인 가장 최근 ACTIVE 승계 1건만.
*/
List<ContractSuccessionVO> selectEffectiveSuccessions(@Param("settleMonth") String settleMonth);
int insert(ContractSuccessionVO vo);
int cancel(@Param("successionId") Long successionId, @Param("updatedBy") Long updatedBy);
int delete(@Param("successionId") Long successionId);
}
@@ -0,0 +1,24 @@
package com.ga.core.vo.succession;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
public class ContractSuccessionResp {
private Long successionId;
private Long contractId;
private String policyNo;
private Long fromAgentId;
private String fromAgentName;
private Long toAgentId;
private String toAgentName;
private LocalDate successionDate;
private String reason;
private String reasonName;
private String memo;
private String status;
private String statusName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.ga.core.vo.succession;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
@Data
public class ContractSuccessionSaveReq {
@NotNull
private Long contractId;
@NotNull
private Long fromAgentId;
@NotNull
private Long toAgentId;
@NotNull
private LocalDate successionDate;
/** TERMINATION/TRANSFER/REASSIGN (기본 TERMINATION) */
private String reason;
private String memo;
}
@@ -0,0 +1,15 @@
package com.ga.core.vo.succession;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ContractSuccessionSearchParam extends SearchParam {
private Long contractId;
private Long fromAgentId;
private Long toAgentId;
private String reason;
private String status;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.succession;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* contract_succession — 계약 승계 (V86)
* 유지수수료 귀속 설계사를 승계일 기준으로 전임→후임 전환.
*/
@Data
public class ContractSuccessionVO {
private Long successionId;
private Long contractId;
private Long fromAgentId;
private Long toAgentId;
private LocalDate successionDate;
/** SUCCESSION_REASON: TERMINATION/TRANSFER/REASSIGN */
private String reason;
private String memo;
/** SUCCESSION_STATUS: ACTIVE/CANCELED */
private String status;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.succession.ContractSuccessionMapper">
<resultMap id="VOMap" type="com.ga.core.vo.succession.ContractSuccessionVO"/>
<resultMap id="RespMap" type="com.ga.core.vo.succession.ContractSuccessionResp"/>
<sql id="joinCols">
cs.succession_id, cs.contract_id, ct.policy_no,
cs.from_agent_id, fa.agent_name AS from_agent_name,
cs.to_agent_id, ta.agent_name AS to_agent_name,
cs.succession_date,
cs.reason,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'SUCCESSION_REASON' AND cc.code = cs.reason) AS reason_name,
cs.memo,
cs.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'SUCCESSION_STATUS' AND cc.code = cs.status) AS status_name,
cs.created_at
</sql>
<select id="selectById" resultMap="RespMap">
SELECT <include refid="joinCols"/>
FROM contract_succession cs
LEFT JOIN contract ct ON ct.contract_id = cs.contract_id
LEFT JOIN agent fa ON fa.agent_id = cs.from_agent_id
LEFT JOIN agent ta ON ta.agent_id = cs.to_agent_id
WHERE cs.succession_id = #{successionId}
</select>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="joinCols"/>
FROM contract_succession cs
LEFT JOIN contract ct ON ct.contract_id = cs.contract_id
LEFT JOIN agent fa ON fa.agent_id = cs.from_agent_id
LEFT JOIN agent ta ON ta.agent_id = cs.to_agent_id
<where>
<if test="contractId != null">AND cs.contract_id = #{contractId}</if>
<if test="fromAgentId != null">AND cs.from_agent_id = #{fromAgentId}</if>
<if test="toAgentId != null">AND cs.to_agent_id = #{toAgentId}</if>
<if test="reason != null and reason != ''">AND cs.reason = #{reason}</if>
<if test="status != null and status != ''">AND cs.status = #{status}</if>
</where>
ORDER BY cs.succession_date DESC, cs.succession_id DESC
</select>
<!-- 정산월 기준 계약별 유효 승계 (가장 최근 ACTIVE 1건) -->
<select id="selectEffectiveSuccessions" resultMap="VOMap">
SELECT DISTINCT ON (cs.contract_id)
cs.contract_id, cs.to_agent_id, cs.from_agent_id, cs.succession_date, cs.status
FROM contract_succession cs
WHERE cs.status = 'ACTIVE'
AND TO_CHAR(cs.succession_date, 'YYYYMM') &lt;= #{settleMonth}
ORDER BY cs.contract_id, cs.succession_date DESC, cs.succession_id DESC
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="successionId">
INSERT INTO contract_succession
(contract_id, from_agent_id, to_agent_id, succession_date, reason, memo, status, created_by)
VALUES
(#{contractId}, #{fromAgentId}, #{toAgentId}, #{successionDate},
COALESCE(#{reason}, 'TERMINATION'), #{memo}, 'ACTIVE', #{createdBy})
</insert>
<update id="cancel">
UPDATE contract_succession
SET status = 'CANCELED',
updated_at = NOW(),
updated_by = #{updatedBy}
WHERE succession_id = #{successionId}
</update>
<delete id="delete">
DELETE FROM contract_succession WHERE succession_id = #{successionId}
</delete>
</mapper>