From bd5ff4ec14e39a80ef7834a3884b6af9dcf2d007 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Fri, 29 May 2026 21:28:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=84=EC=95=BD=20=EC=8A=B9=EA=B3=84?= =?UTF-8?q?(contract=20succession)=20=E2=80=94=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=EC=88=98=EC=88=98=EB=A3=8C=20=EA=B7=80=EC=86=8D=20=ED=9B=84?= =?UTF-8?q?=EC=9E=84=20=EC=A0=84=ED=99=98=20(V86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계사 해촉/이관 시 보험계약의 유지수수료 귀속을 승계월 기준 후임 설계사로 전환. 모집수수료는 원 모집설계사 귀속 유지(고아계약 승계 표준). - 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) --- .../ContractSuccessionController.java | 59 +++++++ .../succession/ContractSuccessionService.java | 65 ++++++++ .../settlement/calc/CommissionCalculator.java | 20 ++- .../settlement/step/CalcMaintainStep.java | 18 ++- .../resources/db/migration/V86__계약승계.sql | 112 +++++++++++++ .../succession/ContractSuccessionMapper.java | 25 +++ .../vo/succession/ContractSuccessionResp.java | 24 +++ .../succession/ContractSuccessionSaveReq.java | 21 +++ .../ContractSuccessionSearchParam.java | 15 ++ .../vo/succession/ContractSuccessionVO.java | 28 ++++ .../succession/ContractSuccessionMapper.xml | 79 ++++++++++ ga-frontend/src/App.tsx | 2 + ga-frontend/src/api/succession.ts | 50 ++++++ .../pages/contracts/ContractSuccessions.tsx | 148 ++++++++++++++++++ 14 files changed, 660 insertions(+), 6 deletions(-) create mode 100644 ga-api/src/main/java/com/ga/api/controller/succession/ContractSuccessionController.java create mode 100644 ga-api/src/main/java/com/ga/api/service/succession/ContractSuccessionService.java create mode 100644 ga-common/src/main/resources/db/migration/V86__계약승계.sql create mode 100644 ga-core/src/main/java/com/ga/core/mapper/succession/ContractSuccessionMapper.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionResp.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionSaveReq.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionSearchParam.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionVO.java create mode 100644 ga-core/src/main/resources/mapper/succession/ContractSuccessionMapper.xml create mode 100644 ga-frontend/src/api/succession.ts create mode 100644 ga-frontend/src/pages/contracts/ContractSuccessions.tsx diff --git a/ga-api/src/main/java/com/ga/api/controller/succession/ContractSuccessionController.java b/ga-api/src/main/java/com/ga/api/controller/succession/ContractSuccessionController.java new file mode 100644 index 0000000..ad337ac --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/succession/ContractSuccessionController.java @@ -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> list( + @ModelAttribute ContractSuccessionSearchParam param) { + return ApiResponse.ok(service.list(param)); + } + + @GetMapping("/{successionId}") + @RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "READ") + public ApiResponse 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 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 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 delete(@PathVariable Long successionId) { + service.delete(successionId); + return ApiResponse.ok(); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/succession/ContractSuccessionService.java b/ga-api/src/main/java/com/ga/api/service/succession/ContractSuccessionService.java new file mode 100644 index 0000000..a2491df --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/succession/ContractSuccessionService.java @@ -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 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); + } +} diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java b/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java index 0226c85..fb014f6 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java @@ -26,15 +26,25 @@ public class CommissionCalculator { private final AgentMapper agentMapper; public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) { - return buildLedger(c, settleMonth, 1); + return buildLedger(c, settleMonth, 1, c.getAgentId()); } public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear) { - return buildLedger(c, settleMonth, commissionYear); + return buildLedger(c, settleMonth, commissionYear, c.getAgentId()); } - private LedgerVO buildLedger(ContractResp c, String settleMonth, int year) { - AgentVO agent = agentMapper.selectById(c.getAgentId()); + /** + * 계약 승계 반영 유지수수료 계산. + * effectiveAgentId 가 후임 설계사이면 해당 설계사 등급의 payout 비율로 계산되고 + * 원장 agent_id 도 후임으로 기록된다. null 이면 원 모집설계사로 동작. + */ + public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear, Long effectiveAgentId) { + return buildLedger(c, settleMonth, commissionYear, + effectiveAgentId != null ? effectiveAgentId : c.getAgentId()); + } + + private LedgerVO buildLedger(ContractResp c, String settleMonth, int year, Long agentId) { + AgentVO agent = agentMapper.selectById(agentId); if (agent == null) return null; LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now(); @@ -52,7 +62,7 @@ public class CommissionCalculator { LedgerVO led = new LedgerVO(); led.setContractId(c.getContractId()); - led.setAgentId(c.getAgentId()); + led.setAgentId(agentId); led.setPolicyNo(c.getPolicyNo()); led.setCompanyCode(c.getCompanyCode()); led.setProductCode(c.getProductCode()); diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcMaintainStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcMaintainStep.java index 32eed13..af4b736 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcMaintainStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcMaintainStep.java @@ -6,9 +6,11 @@ import com.ga.common.util.DateUtil; import com.ga.common.util.MoneyUtil; import com.ga.core.mapper.ledger.MaintainLedgerMapper; import com.ga.core.mapper.product.ContractMapper; +import com.ga.core.mapper.succession.ContractSuccessionMapper; import com.ga.core.vo.ledger.LedgerVO; import com.ga.core.vo.product.ContractResp; import com.ga.core.vo.product.ContractSearchParam; +import com.ga.core.vo.succession.ContractSuccessionVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.StepContribution; @@ -20,7 +22,9 @@ import org.springframework.stereotype.Component; import java.time.LocalDate; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Step 5 — calcMaintain: 유지수수료 계산 (2회차~) @@ -37,6 +41,7 @@ public class CalcMaintainStep implements Tasklet { private final SettlementContext ctx; private final ContractMapper contractMapper; private final MaintainLedgerMapper maintainMapper; + private final ContractSuccessionMapper successionMapper; private final CommissionCalculator calculator; private static final int BATCH_SIZE = 1000; @@ -52,6 +57,15 @@ public class CalcMaintainStep implements Tasklet { maintainMapper.deleteBySettleMonth(ctx.getSettleMonth()); + // 계약 승계 반영: 정산월 기준 유효 승계(계약→후임설계사) 사전 로드 (N+1 회피) + Map successorByContract = new HashMap<>(); + for (ContractSuccessionVO s : successionMapper.selectEffectiveSuccessions(ctx.getSettleMonth())) { + successorByContract.put(s.getContractId(), s.getToAgentId()); + } + if (!successorByContract.isEmpty()) { + log.info("[Step5] active successions applied: {}", successorByContract.size()); + } + List batch = new ArrayList<>(BATCH_SIZE); int total = 0; @@ -66,7 +80,9 @@ public class CalcMaintainStep implements Tasklet { // 납입주기 매칭 (월납이면 매월, 분기납이면 3개월마다 ...) if (!matchesPayCycle(c.getPayCycle(), monthsElapsed)) continue; - LedgerVO led = calculator.calcMaintainLedger(c, ctx.getSettleMonth(), year); + // 승계된 계약이면 유지수수료 귀속을 후임 설계사로 (없으면 원 모집설계사) + Long effectiveAgentId = successorByContract.getOrDefault(c.getContractId(), c.getAgentId()); + LedgerVO led = calculator.calcMaintainLedger(c, ctx.getSettleMonth(), year, effectiveAgentId); if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue; batch.add(led); if (batch.size() >= BATCH_SIZE) { diff --git a/ga-common/src/main/resources/db/migration/V86__계약승계.sql b/ga-common/src/main/resources/db/migration/V86__계약승계.sql new file mode 100644 index 0000000..1f74b90 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V86__계약승계.sql @@ -0,0 +1,112 @@ +-- V86: 계약 승계 (contract_succession) +-- 설계사 해촉/이관 시 보험계약의 "유지수수료" 귀속 설계사를 후임에게 전환. +-- - 모집수수료(1회차)는 원 모집설계사 귀속이므로 승계 대상 아님. +-- - 유지수수료는 승계월(succession_date의 YYYYMM) 이후 정산월부터 후임 설계사에게 귀속. +-- - 후임 설계사는 본인 등급의 payout 비율로 수령(CommissionCalculator 가 effectiveAgentId 등급 조회). +-- - 한 계약에 시차 승계(A→B→C) 가능: 정산월 기준 가장 최근 ACTIVE 승계가 유효. + +CREATE TABLE contract_succession ( + succession_id BIGSERIAL PRIMARY KEY, + contract_id BIGINT NOT NULL REFERENCES contract(contract_id), + from_agent_id BIGINT NOT NULL REFERENCES agent(agent_id), + to_agent_id BIGINT NOT NULL REFERENCES agent(agent_id), + succession_date DATE NOT NULL, + reason VARCHAR(20) NOT NULL DEFAULT 'TERMINATION', + memo VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by BIGINT, + updated_at TIMESTAMP, + updated_by BIGINT, + CHECK (reason IN ('TERMINATION','TRANSFER','REASSIGN')), + CHECK (status IN ('ACTIVE','CANCELED')), + CHECK (from_agent_id <> to_agent_id) +); + +COMMENT ON TABLE contract_succession IS '계약 승계 — 유지수수료 귀속 설계사 전환 이력'; +COMMENT ON COLUMN contract_succession.contract_id IS '대상 보험계약 FK'; +COMMENT ON COLUMN contract_succession.from_agent_id IS '전임 설계사 (관두거나 이관한 설계사)'; +COMMENT ON COLUMN contract_succession.to_agent_id IS '후임 설계사 (유지수수료를 이어받는 설계사)'; +COMMENT ON COLUMN contract_succession.succession_date IS '승계 효력일 (이 날짜의 YYYYMM 이상 정산월부터 후임 귀속)'; +COMMENT ON COLUMN contract_succession.reason IS 'TERMINATION=해촉 / TRANSFER=이관 / REASSIGN=재배정'; +COMMENT ON COLUMN contract_succession.status IS 'ACTIVE=유효 / CANCELED=취소'; + +-- 배치 effective 조회용: contract_id + 효력일 역순 +CREATE INDEX idx_cs_contract ON contract_succession(contract_id, status, succession_date DESC); +CREATE INDEX idx_cs_to_agent ON contract_succession(to_agent_id, status); +CREATE INDEX idx_cs_from_agent ON contract_succession(from_agent_id, status); + +-- ============================================================ +-- 메뉴 (GRP_PRD 하위, ENDORSE 옆) +-- ============================================================ +INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active) +SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort, 'Y', 'Y' +FROM menu p +JOIN (VALUES + ('CONTRACT_SUCCESSION', '계약 승계', '/contracts/successions', 'contracts/ContractSuccessions', 50) +) AS x(code, name, path, cmpt, sort) ON p.menu_code = 'GRP_PRD' +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code); + +-- 권한 항목 (READ/CREATE/UPDATE/DELETE) +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, p.code, p.name, p.sort +FROM menu m +CROSS JOIN (VALUES + ('READ', '조회', 10), + ('CREATE', '등록', 20), + ('UPDATE', '취소', 30), + ('DELETE', '삭제', 40) +) AS p(code, name, sort) +WHERE m.menu_code = 'CONTRACT_SUCCESSION' +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- 역할 부여: SUPER_ADMIN/ADMIN 전체 +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN') + AND m.menu_code = 'CONTRACT_SUCCESSION' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +-- MANAGER: READ 만 +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code = 'MANAGER' + AND mp.perm_code = 'READ' + AND m.menu_code = 'CONTRACT_SUCCESSION' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +-- ============================================================ +-- 공통코드 +-- ============================================================ +INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES + ('SUCCESSION_REASON', '승계사유', 'TERMINATION/TRANSFER/REASSIGN', 'Y', 470), + ('SUCCESSION_STATUS', '승계상태', 'ACTIVE/CANCELED', 'Y', 480) +ON CONFLICT (group_code) DO NOTHING; + +INSERT INTO common_code (group_code, code, code_name, sort_order, is_active) VALUES + ('SUCCESSION_REASON', 'TERMINATION', '해촉', 10, 'Y'), + ('SUCCESSION_REASON', 'TRANSFER', '이관', 20, 'Y'), + ('SUCCESSION_REASON', 'REASSIGN', '재배정', 30, 'Y'), + ('SUCCESSION_STATUS', 'ACTIVE', '유효', 10, 'Y'), + ('SUCCESSION_STATUS', 'CANCELED', '취소', 20, 'Y') +ON CONFLICT (group_code, code) DO NOTHING; + +-- ============================================================ +-- 테스트 데이터 (소량) — 테이블이 비어있고 활성 계약/타 설계사가 존재할 때만 1건 +-- ============================================================ +INSERT INTO contract_succession (contract_id, from_agent_id, to_agent_id, succession_date, reason, memo, status, created_at) +SELECT c.contract_id, c.agent_id, b.agent_id, DATE '2025-01-01', 'TERMINATION', '테스트 승계 데이터', 'ACTIVE', NOW() +FROM contract c +CROSS JOIN LATERAL ( + SELECT agent_id FROM agent WHERE agent_id <> c.agent_id AND status = 'ACTIVE' ORDER BY agent_id LIMIT 1 +) b +WHERE c.status = 'ACTIVE' + AND NOT EXISTS (SELECT 1 FROM contract_succession) +ORDER BY c.contract_id +LIMIT 1; diff --git a/ga-core/src/main/java/com/ga/core/mapper/succession/ContractSuccessionMapper.java b/ga-core/src/main/java/com/ga/core/mapper/succession/ContractSuccessionMapper.java new file mode 100644 index 0000000..fe42370 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/succession/ContractSuccessionMapper.java @@ -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 selectList(ContractSuccessionSearchParam param); + + /** + * 배치 유지수수료 계산용 — 정산월 기준 유효한 승계의 (계약, 후임설계사) 매핑. + * 계약별로 succession_date 의 YYYYMM 이 정산월 이하인 가장 최근 ACTIVE 승계 1건만. + */ + List 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); +} diff --git a/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionResp.java b/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionResp.java new file mode 100644 index 0000000..3ea4548 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionSaveReq.java new file mode 100644 index 0000000..2bb3142 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionSaveReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionSearchParam.java new file mode 100644 index 0000000..1c20a01 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionSearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionVO.java b/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionVO.java new file mode 100644 index 0000000..1efc101 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/succession/ContractSuccessionVO.java @@ -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; +} diff --git a/ga-core/src/main/resources/mapper/succession/ContractSuccessionMapper.xml b/ga-core/src/main/resources/mapper/succession/ContractSuccessionMapper.xml new file mode 100644 index 0000000..d73f66c --- /dev/null +++ b/ga-core/src/main/resources/mapper/succession/ContractSuccessionMapper.xml @@ -0,0 +1,79 @@ + + + + + + + + + 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 + + + + + + + + + + + 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}) + + + + UPDATE contract_succession + SET status = 'CANCELED', + updated_at = NOW(), + updated_by = #{updatedBy} + WHERE succession_id = #{successionId} + + + + DELETE FROM contract_succession WHERE succession_id = #{successionId} + + + diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index c94ce99..ff3bd97 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -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: 계약 운영 */} } /> + } /> } /> {/* P4: 시스템 */} diff --git a/ga-frontend/src/api/succession.ts b/ga-frontend/src/api/succession.ts new file mode 100644 index 0000000..af9a6ec --- /dev/null +++ b/ga-frontend/src/api/succession.ts @@ -0,0 +1,50 @@ +import api, { PageResponse, unwrap } from './request'; + +export interface ContractSuccessionRow extends Record { + 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>( + api.get('/api/contract-successions', { params: p }), + ), + create: (body: ContractSuccessionSaveReq) => + unwrap(api.post('/api/contract-successions', body)), + cancel: (id: number) => + unwrap(api.post(`/api/contract-successions/${id}/cancel`)), + delete: (id: number) => + unwrap(api.delete(`/api/contract-successions/${id}`)), +}; diff --git a/ga-frontend/src/pages/contracts/ContractSuccessions.tsx b/ga-frontend/src/pages/contracts/ContractSuccessions.tsx new file mode 100644 index 0000000..fdaf6e7 --- /dev/null +++ b/ga-frontend/src/pages/contracts/ContractSuccessions.tsx @@ -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[] = [ + { 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 }) => , + }, + { field: 'memo', headerName: '메모', flex: 1 }, + { + field: 'status', headerName: '상태', width: 100, + cellRenderer: (p: { value: string }) => + p.value === 'CANCELED' + ? 취소 + : 유효, + }, + { field: 'createdAt', headerName: '등록일', width: 120 }, +]; + +export default function ContractSuccessions() { + const qc = useQueryClient(); + const [params, setParams] = useState({ 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 ( + setCreateOpen(true)}> + + 승계 등록 + + } + > + {isError && ( + + )} + + setParams({ ...v as SuccessionSearchParam, pageSize: 100 })} + onReset={() => setParams({ pageSize: 100 })} + /> + + + + rows={rows} + columns={[ + ...COLUMNS, + { + headerName: '액션', width: 110, pinned: 'right', + cellRenderer: (p: { data: ContractSuccessionRow }) => ( + handleCancel(p.data.successionId)} + > + 취소 + + ), + }, + ]} + loading={isLoading} + height={600} + rowKey="successionId" + /> + + + setCreateOpen(false)} okText="등록" cancelText="취소"> +
+ + + + + + + + + + + + + +