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,59 @@
package com.ga.api.controller.succession;
import com.ga.api.service.succession.ContractSuccessionService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.succession.ContractSuccessionResp;
import com.ga.core.vo.succession.ContractSuccessionSaveReq;
import com.ga.core.vo.succession.ContractSuccessionSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "계약 승계")
@RestController
@RequestMapping("/api/contract-successions")
@RequiredArgsConstructor
public class ContractSuccessionController {
private final ContractSuccessionService service;
@GetMapping
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "READ")
public ApiResponse<PageResponse<ContractSuccessionResp>> list(
@ModelAttribute ContractSuccessionSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{successionId}")
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "READ")
public ApiResponse<ContractSuccessionResp> detail(@PathVariable Long successionId) {
return ApiResponse.ok(service.detail(successionId));
}
@PostMapping
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "CREATE")
@DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession")
public ApiResponse<Long> create(@Valid @RequestBody ContractSuccessionSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{successionId}/cancel")
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "UPDATE")
@DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession")
public ApiResponse<Void> cancel(@PathVariable Long successionId) {
service.cancel(successionId);
return ApiResponse.ok();
}
@DeleteMapping("/{successionId}")
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "DELETE")
@DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession")
public ApiResponse<Void> delete(@PathVariable Long successionId) {
service.delete(successionId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,65 @@
package com.ga.api.service.succession;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.succession.ContractSuccessionMapper;
import com.ga.core.vo.succession.ContractSuccessionResp;
import com.ga.core.vo.succession.ContractSuccessionSaveReq;
import com.ga.core.vo.succession.ContractSuccessionSearchParam;
import com.ga.core.vo.succession.ContractSuccessionVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ContractSuccessionService {
private final ContractSuccessionMapper mapper;
public PageResponse<ContractSuccessionResp> list(ContractSuccessionSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public ContractSuccessionResp detail(Long successionId) {
ContractSuccessionResp resp = mapper.selectById(successionId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(ContractSuccessionSaveReq req) {
if (req.getFromAgentId().equals(req.getToAgentId())) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "전임과 후임 설계사가 같을 수 없습니다");
}
ContractSuccessionVO vo = new ContractSuccessionVO();
vo.setContractId(req.getContractId());
vo.setFromAgentId(req.getFromAgentId());
vo.setToAgentId(req.getToAgentId());
vo.setSuccessionDate(req.getSuccessionDate());
vo.setReason(req.getReason());
vo.setMemo(req.getMemo());
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
mapper.insert(vo);
return vo.getSuccessionId();
}
/** 승계 취소 — status=CANCELED. 이후 정산월부터 유지수수료는 다시 직전 귀속 설계사에게. */
@Transactional
public void cancel(Long successionId) {
ContractSuccessionResp existing = mapper.selectById(successionId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.cancel(successionId, SecurityUtil.getCurrentUserId());
}
@Transactional
public void delete(Long successionId) {
ContractSuccessionResp existing = mapper.selectById(successionId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.delete(successionId);
}
}