From 284ecc4203a171a3ebc79aa64e2b40e853fd48b0 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Fri, 5 Jun 2026 00:51:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20P10(#3)=20=ED=99=98=EC=88=98=20?= =?UTF-8?q?=EB=B3=B4=ED=97=98=EC=82=AC=EC=9E=90=EB=A3=8C=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=E2=80=94=20=EB=B3=B4=ED=97=98=EC=82=AC=20?= =?UTF-8?q?=ED=86=B5=EB=B3=B4=20=EA=B8=B0=EB=B0=98=20chargeback=20?= =?UTF-8?q?=EC=9D=B8=EC=9E=85=20(PL=20=EC=9E=90=EC=9C=A8=EC=A7=84=ED=96=89?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit incar '환수보험사자료 업로드' 대응. GA는 LAPSE 계약 기반 자동환수만 있고 보험사가 통보한 환수자료(증권번호+환수금액)를 직접 인입하는 경로가 없던 갭. 신규 테이블 없이 기존 chargeback(V6) 재사용. - DB(V113): 메뉴 CHARGEBACK_DATA(환수자료관리, GRP_SETTLE, /settle/chargeback-data) + 권한 READ/CREATE/EXPORT + 역할부여. - core: ChargebackUploadExcelVO(@ExcelColumn 증권번호/환수금액/실효월/환수율) + ChargebackResp/SearchParam + ChargebackMapper.selectList/countByCondition/ existsByContractAndMonth + ContractMapper.selectByPolicyNo(contract_date 포함). - api: ChargebackUploadService.upload(settleMonth,file) — ExcelService.importLargeExcel 로 SAX 파싱, policy_no→contract/agent 해소(미존재=에러행), 중복(contract+월) skip, 실효월 YYYYMM→경과월 변환(ChronoUnit), ChargebackVO status=NEW·remain=cb_amount insertBatch. ChargebackService.list/export. ChargebackController(/api/chargebacks /upload, GET 목록, export). - frontend: ChargebackData 화면(정산월+파일 업로드, 결과 성공/스킵/에러행 표시, AG Grid 목록 합계행, 엑셀) + api/chargeback.ts + App 라우트. 검증: build SUCCESSFUL, Flyway V113(schema v113). 라이브 업로드 e2e — 정상 2건 INSERT (NEW, remain=cb_amount) + 미존재 증권번호 1건 에러행 리포트 + 중복 skip, GET 목록 200, 경과월 변환 동작 확인. 테스트 chargeback 전건 정리. 스펙 docs/DOMAIN_GAP_P10. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/DOMAIN_GAP_P10_환수보험사자료업로드.md | 46 +++ .../settle/ChargebackController.java | 56 ++++ .../api/service/settle/ChargebackService.java | 118 +++++++ .../settle/ChargebackUploadService.java | 140 ++++++++ .../db/migration/V113__환수자료_메뉴.sql | 59 ++++ .../core/mapper/product/ContractMapper.java | 6 + .../core/mapper/settle/ChargebackMapper.java | 16 + .../com/ga/core/vo/settle/ChargebackResp.java | 36 +++ .../core/vo/settle/ChargebackSearchParam.java | 21 ++ .../vo/settle/ChargebackUploadExcelVO.java | 33 ++ .../mapper/product/ContractMapper.xml | 13 + .../mapper/settle/ChargebackMapper.xml | 89 +++++ ga-frontend/src/App.tsx | 6 + ga-frontend/src/api/chargeback.ts | 62 ++++ .../src/pages/settle/ChargebackData.tsx | 303 ++++++++++++++++++ 15 files changed, 1004 insertions(+) create mode 100644 docs/DOMAIN_GAP_P10_환수보험사자료업로드.md create mode 100644 ga-api/src/main/java/com/ga/api/controller/settle/ChargebackController.java create mode 100644 ga-api/src/main/java/com/ga/api/service/settle/ChargebackService.java create mode 100644 ga-api/src/main/java/com/ga/api/service/settle/ChargebackUploadService.java create mode 100644 ga-common/src/main/resources/db/migration/V113__환수자료_메뉴.sql create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/ChargebackResp.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/ChargebackSearchParam.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/ChargebackUploadExcelVO.java create mode 100644 ga-frontend/src/api/chargeback.ts create mode 100644 ga-frontend/src/pages/settle/ChargebackData.tsx diff --git a/docs/DOMAIN_GAP_P10_환수보험사자료업로드.md b/docs/DOMAIN_GAP_P10_환수보험사자료업로드.md new file mode 100644 index 0000000..1175189 --- /dev/null +++ b/docs/DOMAIN_GAP_P10_환수보험사자료업로드.md @@ -0,0 +1,46 @@ +# P10 — 환수 보험사자료 업로드 (#3) + +작성: PL, 2026-06-04. 사용자 "후속 갭 3종 진행" 중 첫 번째. +근거: incar `환수보험사자료 업로드`. GA는 LAPSE 계약 기반 자동환수(ChargebackExceptionStep)만 있고, +보험사가 통보한 환수자료(증권번호+환수금액)를 직접 인입하는 경로가 없음. + +범위: 풀스택(DB→Core→API→Frontend). 신규 테이블 없음 — 기존 `chargeback`(V6) 재사용. +Flyway: **V112 최신 → V113**(메뉴/권한만). 금액 NUMERIC(15,2). + +## 1. chargeback 테이블 (기존, 재사용) +cb_id PK / contract_id FK(nullable) / agent_id FK NN / policy_no / lapse_month INT / +cb_rate / cb_amount NN / paid_amount(0) / remain_amount NN / settle_month CHAR(6) NN / +installment_no / max_installments / status('NEW') / created_at. + +## 2. 업로드 설계 +- **1 업로드 = 1 settleMonth**(요청 파라미터). 파일 각 행 = 계약별 환수 통보. +- 엑셀 헤더(ChargebackUploadExcelVO, @ExcelColumn): `증권번호`(policyNo, order1) / `환수금액`(cbAmount, order2) / `실효월`(lapseMonth YYYYMM 문자열, order3, 선택) / `환수율`(cbRate, order4, 선택). +- **agent/contract 해소**: policy_no → contract 조회 → contract_id + agent_id. 미발견 행은 에러행으로 수집(스킵, 결과에 행번호+사유 반환). ContractMapper에 `selectByPolicyNo(policyNo)` 없으면 core가 추가(contract 테이블 policy_no 컬럼 확인). +- 생성 ChargebackVO: contractId/agentId(해소), policyNo, lapseMonth(YYYYMM→INT 변환, '202401'→202401 또는 경과월? **chargeback.lapse_month는 INT**: 기존 의미가 경과월(month elapsed)인지 실효연월인지 ChargebackExceptionStep 확인 후 동일 의미로 — 자동환수와 일관성. 불명확하면 실효연월 YYYYMM의 끝2자리(월)이 아니라 **경과월**로: contract_date~lapse_date 개월수 계산. 단순화를 위해 파일에 없으면 null 허용), cbRate, cbAmount, paidAmount=0, remainAmount=cbAmount, settleMonth(param), status='NEW'. + - **주의**: 자동환수(ChargebackExceptionStep)와 동일 (contract, settleMonth) 중복 생성 방지 — 동일 contract_id+settle_month NEW 존재 시 스킵 또는 갱신(중복 인입 방지). core에 `existsByContractAndMonth` 또는 service에서 selectByAgent로 확인. 최소: 같은 정산월 재업로드 시 중복 INSERT 경고만(운영자 책임). **권장: contract_id+settle_month UNIQUE 부분 보장 위해 service가 사전 조회 후 skip + 결과 리포트.** +- ExcelService.importLargeExcel(file, ChargebackUploadExcelVO.class, 500, consumer) 사용. consumer에서 해소+검증+chargebackMapper.insertBatch. 트랜잭션: @Transactional. + +## 3. 조회(List) — 신규 +chargeback 목록 화면이 현재 없음. 추가: +- core: ChargebackResp(+agentName/contractNo 조인) / ChargebackSearchParam(settleMonth/agentId/status/policyNo) / ChargebackMapper.selectList + countByCondition. (기존 ChargebackVO 유지) +- api: ChargebackService.list(PageHelper) / detail. 업로드는 ChargebackUploadService.upload(settleMonth, file)→ExcelImportResult-유사(성공/에러행). + +## 4. API +- `POST /api/chargebacks/upload?settleMonth=YYYYMM` (multipart file) — @RequirePermission(CHARGEBACK_DATA, CREATE), @DataChangeLog. 반환: {successCount, errorCount, errors[]}. +- `GET /api/chargebacks` — 목록(@RequirePermission READ, PageResponse). +- `GET /api/chargebacks/export` — 엑셀(@Transactional). +컨트롤러 위치 `controller/settle/ChargebackController` 또는 기존 chargeback 패키지 확인. + +## 5. DB (V113) +- 메뉴 `CHARGEBACK_DATA`(환수자료관리), 상위 GRP_SETTLE, path `/settle/chargeback-data`, component `settle/ChargebackData`. (V97 단계마감 등 GRP_SETTLE 패턴) +- 권한 READ/CREATE/EXPORT + 역할부여 SUPER_ADMIN·ADMIN 전체, MANAGER READ. +- 공통코드 불필요(chargeback status는 기존 코드 재사용 여부 확인 — 없으면 생략). + +## 6. Frontend +- `src/api/chargeback.ts` — list/upload(FormData)/export. +- `src/pages/settle/ChargebackData.tsx` — 상단 정산월 선택 + 파일 업로드 버튼(업로드 결과 성공/에러행 표시) + AG Grid 목록(증권번호/설계사/환수금액/잔여/상태/정산월) + 엑셀. 기존 업로드 화면(UploadPage 등) 결과표시 패턴 참고. +- App.tsx 라우트 `settle/chargeback-data`. + +## 7. 검증(PL) +build+test, Flyway V113, GET 목록 200, **샘플 엑셀 업로드 실호출**(정상행 INSERT + 미존재 증권번호 에러행 리포트) 라이브 확인 후 테스트 chargeback 롤백/삭제. + diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/ChargebackController.java b/ga-api/src/main/java/com/ga/api/controller/settle/ChargebackController.java new file mode 100644 index 0000000..b306be0 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/settle/ChargebackController.java @@ -0,0 +1,56 @@ +package com.ga.api.controller.settle; + +import com.ga.api.service.settle.ChargebackService; +import com.ga.api.service.settle.ChargebackUploadService; +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.settle.ChargebackResp; +import com.ga.core.vo.settle.ChargebackSearchParam; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "환수자료 관리") +@Validated +@RestController +@RequestMapping("/api/chargebacks") +@RequiredArgsConstructor +public class ChargebackController { + + private final ChargebackService chargebackService; + private final ChargebackUploadService chargebackUploadService; + + @Operation(summary = "환수자료 목록 조회") + @GetMapping + @RequirePermission(menu = "CHARGEBACK_DATA", perm = "READ") + public ApiResponse> list(@ModelAttribute ChargebackSearchParam param) { + return ApiResponse.ok(chargebackService.list(param)); + } + + @Operation(summary = "환수자료 엑셀 다운로드") + @GetMapping("/export") + @RequirePermission(menu = "CHARGEBACK_DATA", perm = "EXPORT") + public void export(@ModelAttribute ChargebackSearchParam param, HttpServletResponse response) { + chargebackService.export(param, response); + } + + @Operation(summary = "보험사 환수자료 업로드", + description = "정산월(YYYYMM) + 엑셀 파일(증권번호/환수금액/실효월/환수율). " + + "미존재 증권번호·cbAmount 오류는 에러행으로 반환. 동일 계약+정산월 중복은 스킵.") + @PostMapping("/upload") + @RequirePermission(menu = "CHARGEBACK_DATA", perm = "CREATE") + @DataChangeLog(menu = "CHARGEBACK_DATA", table = "chargeback") + public ApiResponse upload( + @Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") + @RequestParam String settleMonth, + @RequestParam("file") MultipartFile file) { + return ApiResponse.ok(chargebackUploadService.upload(settleMonth, file)); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/settle/ChargebackService.java b/ga-api/src/main/java/com/ga/api/service/settle/ChargebackService.java new file mode 100644 index 0000000..ae7bd4b --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/settle/ChargebackService.java @@ -0,0 +1,118 @@ +package com.ga.api.service.settle; + +import com.ga.common.annotation.ExcelColumn; +import com.ga.common.excel.ExcelService; +import com.ga.common.model.PageResponse; +import com.ga.core.mapper.settle.ChargebackMapper; +import com.ga.core.vo.settle.ChargebackResp; +import com.ga.core.vo.settle.ChargebackSearchParam; +import jakarta.servlet.http.HttpServletResponse; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.apache.ibatis.cursor.Cursor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Iterator; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ChargebackService { + + private final ChargebackMapper chargebackMapper; + private final ExcelService excelService; + + @Transactional(readOnly = true) + public PageResponse list(ChargebackSearchParam param) { + param.startPage(); + return PageResponse.of(chargebackMapper.selectList(param)); + } + + /** + * 환수 목록 엑셀 다운로드. + * + * ga-core ChargebackMapper에 selectCursorForExcel 미존재(ga-core 수정 불가)로 + * selectList 전체 조회 후 List-backed Cursor로 exportLargeExcel 위임. + * 대용량 환경에서는 ga-core에 selectCursorForExcel 추가 권장 (TODO). + */ + @Transactional(readOnly = true) + public void export(ChargebackSearchParam param, HttpServletResponse response) { + List rows = chargebackMapper.selectList(param); + List excelRows = rows.stream().map(ChargebackExcelRow::from).toList(); + excelService.exportLargeExcel( + "환수자료목록", + ChargebackExcelRow.class, + () -> new ListCursor<>(excelRows), + response + ); + } + + // ── 엑셀 출력용 VO (ga-core 수정 불가로 api 모듈 내 정의) ──────────── + @Data + public static class ChargebackExcelRow { + + @ExcelColumn(header = "정산월", order = 1, width = 10) + private String settleMonth; + + @ExcelColumn(header = "증권번호", order = 2, width = 16) + private String policyNo; + + @ExcelColumn(header = "계약자명", order = 3, width = 14) + private String contractorName; + + @ExcelColumn(header = "설계사명", order = 4, width = 12) + private String agentName; + + @ExcelColumn(header = "경과월", order = 5, width = 8) + private Integer lapseMonth; + + @ExcelColumn(header = "환수율", order = 6, width = 8) + private BigDecimal cbRate; + + @ExcelColumn(header = "환수금액", order = 7, width = 14) + private BigDecimal cbAmount; + + @ExcelColumn(header = "지급금액", order = 8, width = 14) + private BigDecimal paidAmount; + + @ExcelColumn(header = "잔여금액", order = 9, width = 14) + private BigDecimal remainAmount; + + @ExcelColumn(header = "상태", order = 10, width = 10) + private String status; + + @ExcelColumn(header = "생성일시", order = 11, width = 18, format = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + static ChargebackExcelRow from(ChargebackResp r) { + ChargebackExcelRow row = new ChargebackExcelRow(); + row.setSettleMonth(r.getSettleMonth()); + row.setPolicyNo(r.getPolicyNo()); + row.setContractorName(r.getContractorName()); + row.setAgentName(r.getAgentName()); + row.setLapseMonth(r.getLapseMonth()); + row.setCbRate(r.getCbRate()); + row.setCbAmount(r.getCbAmount()); + row.setPaidAmount(r.getPaidAmount()); + row.setRemainAmount(r.getRemainAmount()); + row.setStatus(r.getStatus()); + row.setCreatedAt(r.getCreatedAt()); + return row; + } + } + + /** List를 MyBatis Cursor 인터페이스로 감싸는 어댑터. */ + private static class ListCursor implements Cursor { + private final List list; + ListCursor(List list) { this.list = list; } + + @Override public boolean isOpen() { return true; } + @Override public boolean isConsumed() { return false; } + @Override public int getCurrentIndex() { return -1; } + @Override public Iterator iterator() { return list.iterator(); } + @Override public void close() { /* no-op */ } + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/settle/ChargebackUploadService.java b/ga-api/src/main/java/com/ga/api/service/settle/ChargebackUploadService.java new file mode 100644 index 0000000..0369660 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/settle/ChargebackUploadService.java @@ -0,0 +1,140 @@ +package com.ga.api.service.settle; + +import com.ga.common.excel.ExcelErrorRow; +import com.ga.common.excel.ExcelImportResult; +import com.ga.common.excel.ExcelService; +import com.ga.core.mapper.product.ContractMapper; +import com.ga.core.mapper.settle.ChargebackMapper; +import com.ga.core.vo.product.ContractVO; +import com.ga.core.vo.settle.ChargebackUploadExcelVO; +import com.ga.core.vo.settle.ChargebackVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +/** + * 보험사 환수자료 업로드 서비스. + * + * 흐름: + * 1. ExcelService.importLargeExcel → ChargebackUploadExcelVO 파싱 + * 2. 각 행: policyNo → ContractMapper.selectByPolicyNo 해소 + * - 미존재 → 에러행 수집(스킵) + * - 동일 contractId + settleMonth 이미 존재 → skipCount++ (중복) + * - cbAmount null/<=0 → 에러행 + * 3. ChargebackVO 구성 후 insertBatch + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ChargebackUploadService { + + private static final DateTimeFormatter YYYYMM = DateTimeFormatter.ofPattern("yyyyMM"); + + private final ExcelService excelService; + private final ContractMapper contractMapper; + private final ChargebackMapper chargebackMapper; + + @Transactional + public UploadResult upload(String settleMonth, MultipartFile file) { + + List errors = new ArrayList<>(); + int[] skipCount = {0}; + int[] successCount = {0}; + + excelService.importLargeExcel(file, ChargebackUploadExcelVO.class, 500, batch -> { + List toInsert = new ArrayList<>(batch.size()); + + for (ChargebackUploadExcelVO row : batch) { + int rowSeq = successCount[0] + skipCount[0] + errors.size() + 1; + + // 1. cbAmount 검증 + if (row.getCbAmount() == null || row.getCbAmount().compareTo(BigDecimal.ZERO) <= 0) { + errors.add(new ExcelErrorRow(rowSeq, "cbAmount", + row.getCbAmount() == null ? null : row.getCbAmount().toPlainString(), + "환수금액은 0 초과여야 합니다")); + continue; + } + + // 2. 증권번호 → 계약 해소 + if (row.getPolicyNo() == null || row.getPolicyNo().isBlank()) { + errors.add(new ExcelErrorRow(rowSeq, "policyNo", null, "증권번호 누락")); + continue; + } + ContractVO contract = contractMapper.selectByPolicyNo(row.getPolicyNo()); + if (contract == null) { + errors.add(new ExcelErrorRow(rowSeq, "policyNo", row.getPolicyNo(), + "증권번호에 해당하는 계약을 찾을 수 없습니다")); + continue; + } + + // 3. 중복 확인 (contract_id + settle_month) + if (chargebackMapper.existsByContractAndMonth(contract.getContractId(), settleMonth) > 0) { + skipCount[0]++; + log.debug("중복 스킵: contractId={} settleMonth={}", contract.getContractId(), settleMonth); + continue; + } + + // 4. lapseMonth 변환 (YYYYMM → 경과월 INT) + Integer lapseMonthInt = parseLapseMonth(row.getLapseMonth(), contract); + + // 5. ChargebackVO 구성 + ChargebackVO vo = new ChargebackVO(); + vo.setContractId(contract.getContractId()); + vo.setAgentId(contract.getAgentId()); + vo.setPolicyNo(row.getPolicyNo()); + vo.setLapseMonth(lapseMonthInt); + vo.setCbRate(row.getCbRate()); + vo.setCbAmount(row.getCbAmount()); + vo.setPaidAmount(BigDecimal.ZERO); + vo.setRemainAmount(row.getCbAmount()); + vo.setSettleMonth(settleMonth); + vo.setStatus("NEW"); + + toInsert.add(vo); + } + + if (!toInsert.isEmpty()) { + chargebackMapper.insertBatch(toInsert); + successCount[0] += toInsert.size(); + } + }); + + return new UploadResult(successCount[0], errors.size(), skipCount[0], errors); + } + + /** + * 실효월 문자열(YYYYMM)을 경과월(INT)로 변환. + * contract_date 기준 lapseDate까지 개월수를 계산한다. + * 입력 없음 / 파싱 실패 / contractDate 없음 → null 반환. + */ + private Integer parseLapseMonth(String lapseMonthStr, ContractVO contract) { + if (lapseMonthStr == null || lapseMonthStr.isBlank()) return null; + try { + YearMonth lapse = YearMonth.parse(lapseMonthStr, YYYYMM); + if (contract.getContractDate() == null) return null; + YearMonth contractYm = YearMonth.from(contract.getContractDate()); + long months = ChronoUnit.MONTHS.between(contractYm, lapse); + return (int) months; + } catch (Exception e) { + log.debug("lapseMonth 파싱 실패: value={}", lapseMonthStr); + return null; + } + } + + /** 업로드 결과 DTO. */ + public record UploadResult( + int successCount, + int errorCount, + int skipCount, + List errors + ) {} +} diff --git a/ga-common/src/main/resources/db/migration/V113__환수자료_메뉴.sql b/ga-common/src/main/resources/db/migration/V113__환수자료_메뉴.sql new file mode 100644 index 0000000..8491b14 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V113__환수자료_메뉴.sql @@ -0,0 +1,59 @@ +-- V113: 환수자료관리 메뉴/권한 등록 (P10 §5) +-- 신규 테이블 없음 — 기존 chargeback 테이블 재사용. +-- GRP_SETTLE 하위 PAGE: CHARGEBACK_DATA (sort_order 110, HQ_SETTLE=100 다음) +-- 권한: READ / CREATE / EXPORT +-- 역할: SUPER_ADMIN·ADMIN 전체, MANAGER READ 만 +-- 모든 INSERT 멱등 (ON CONFLICT DO NOTHING / WHERE NOT EXISTS) + +-- ============================================================ +-- 1. 메뉴 INSERT: CHARGEBACK_DATA (GRP_SETTLE 하위 PAGE) +-- ============================================================ + +INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order) +SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort +FROM menu p +JOIN (VALUES + ('CHARGEBACK_DATA', '환수자료관리', '/settle/chargeback-data', 'settle/ChargebackData', 110) +) AS x(code, name, path, cmpt, sort) +ON p.menu_code = 'GRP_SETTLE' +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code); + +-- ============================================================ +-- 2. 메뉴 권한 항목 등록 (READ / CREATE / EXPORT) +-- ============================================================ + +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), + ('EXPORT', '엑셀', 60) +) AS p(code, name, sort) +WHERE m.menu_code = 'CHARGEBACK_DATA' +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- ============================================================ +-- 3. 역할별 권한 부여 +-- ============================================================ + +-- SUPER_ADMIN / ADMIN — 전체 권한 (READ / CREATE / EXPORT) +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 = 'CHARGEBACK_DATA' +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 = 'CHARGEBACK_DATA' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; diff --git a/ga-core/src/main/java/com/ga/core/mapper/product/ContractMapper.java b/ga-core/src/main/java/com/ga/core/mapper/product/ContractMapper.java index 9a8d6f0..4f63955 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/product/ContractMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/product/ContractMapper.java @@ -14,6 +14,12 @@ public interface ContractMapper { ContractResp selectDetailById(@Param("contractId") Long contractId); ContractVO selectById(@Param("contractId") Long contractId); int existsByPolicyNo(@Param("policyNo") String policyNo); + + /** + * 증권번호로 계약 조회 (업로드 시 agent/contract 해소용). + * 반환: ContractVO (contractId + agentId 포함). 미존재 시 null. + */ + ContractVO selectByPolicyNo(@Param("policyNo") String policyNo); int insert(ContractVO vo); int update(ContractVO vo); int updateStatus(@Param("contractId") Long contractId, @Param("status") String status); diff --git a/ga-core/src/main/java/com/ga/core/mapper/settle/ChargebackMapper.java b/ga-core/src/main/java/com/ga/core/mapper/settle/ChargebackMapper.java index 9f87bf6..6da18cb 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/settle/ChargebackMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/settle/ChargebackMapper.java @@ -1,5 +1,7 @@ package com.ga.core.mapper.settle; +import com.ga.core.vo.settle.ChargebackResp; +import com.ga.core.vo.settle.ChargebackSearchParam; import com.ga.core.vo.settle.ChargebackVO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -20,4 +22,18 @@ public interface ChargebackMapper { List> aggregateByAgent(@Param("settleMonth") String settleMonth); BigDecimal sumByMonth(@Param("settleMonth") String settleMonth); + + // ── P10 환수자료 업로드 / 조회 ────────────────────────────────────────── + /** 환수 목록 조회 (agentName / contractorName 조인 포함). PageHelper 페이징 사용. */ + List selectList(ChargebackSearchParam param); + + /** 환수 목록 건수 (페이징 없이 count만). */ + int countByCondition(ChargebackSearchParam param); + + /** + * 동일 contract + settleMonth 환수 레코드 존재 여부. + * 업로드 중복 방지용. 반환값 > 0 이면 이미 존재. + */ + int existsByContractAndMonth(@Param("contractId") Long contractId, + @Param("settleMonth") String settleMonth); } diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/ChargebackResp.java b/ga-core/src/main/java/com/ga/core/vo/settle/ChargebackResp.java new file mode 100644 index 0000000..e305e0f --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/ChargebackResp.java @@ -0,0 +1,36 @@ +package com.ga.core.vo.settle; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 환수 목록 조회 응답 VO. + * + * chargeback 테이블 컬럼 + 조인 필드: + * - agentName : agent.agent_name + * - contractNo: contract 식별용 (policy_no 와 동일 역할, 조인 시 포함) + */ +@Data +public class ChargebackResp { + private Long cbId; + private Long contractId; + private Long agentId; + private String agentName; + /** contract.policy_no (chargeback.policy_no와 동일 값, 조인으로 확인) */ + private String policyNo; + /** contract.contractor_name */ + private String contractorName; + /** 경과월 (months elapsed from contract_date to lapse_date) */ + private Integer lapseMonth; + private BigDecimal cbRate; + private BigDecimal cbAmount; + private BigDecimal paidAmount; + private BigDecimal remainAmount; + private String settleMonth; + private Integer installmentNo; + private Integer maxInstallments; + private String status; + private LocalDateTime createdAt; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/ChargebackSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/settle/ChargebackSearchParam.java new file mode 100644 index 0000000..239fb3b --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/ChargebackSearchParam.java @@ -0,0 +1,21 @@ +package com.ga.core.vo.settle; + +import com.ga.common.model.SearchParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 환수 목록 검색 파라미터. + * + * 상속 필드(SearchParam): pageNum / pageSize / status / searchKeyword / startDate / endDate + * 추가 필드: settleMonth / agentId / policyNo + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ChargebackSearchParam extends SearchParam { + /** 정산월 YYYYMM */ + private String settleMonth; + private Long agentId; + /** 증권번호 검색 */ + private String policyNo; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/ChargebackUploadExcelVO.java b/ga-core/src/main/java/com/ga/core/vo/settle/ChargebackUploadExcelVO.java new file mode 100644 index 0000000..393c3e7 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/ChargebackUploadExcelVO.java @@ -0,0 +1,33 @@ +package com.ga.core.vo.settle; + +import com.ga.common.annotation.ExcelColumn; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 보험사 환수자료 업로드용 엑셀 VO. + * + * 파일 헤더 순서: 증권번호 / 환수금액 / 실효월 / 환수율 + * - 실효월(lapseMonth): YYYYMM 문자열, 선택(없으면 null). + * 파일에 입력된 실효연월(예: 202401)을 서비스 계층에서 + * contract_date 기준 경과월(INT)로 변환 후 ChargebackVO.lapseMonth에 저장. + * - 환수율(cbRate): DECIMAL(7,4), 선택. + */ +@Data +public class ChargebackUploadExcelVO { + + @ExcelColumn(header = "증권번호", order = 1) + private String policyNo; + + @ExcelColumn(header = "환수금액", order = 2) + private BigDecimal cbAmount; + + /** 실효월 YYYYMM (선택). 서비스에서 경과월로 변환. */ + @ExcelColumn(header = "실효월", order = 3) + private String lapseMonth; + + /** 환수율 (선택). */ + @ExcelColumn(header = "환수율", order = 4) + private BigDecimal cbRate; +} diff --git a/ga-core/src/main/resources/mapper/product/ContractMapper.xml b/ga-core/src/main/resources/mapper/product/ContractMapper.xml index 8d5f6ef..1857a00 100644 --- a/ga-core/src/main/resources/mapper/product/ContractMapper.xml +++ b/ga-core/src/main/resources/mapper/product/ContractMapper.xml @@ -68,6 +68,19 @@ SELECT COUNT(*) FROM contract WHERE policy_no = #{policyNo} + + + INSERT INTO contract (agent_id, product_id, policy_no, contractor_name, insured_name, premium, pay_cycle, pay_period, coverage_period, status, diff --git a/ga-core/src/main/resources/mapper/settle/ChargebackMapper.xml b/ga-core/src/main/resources/mapper/settle/ChargebackMapper.xml index fdaa0a3..40e77b2 100644 --- a/ga-core/src/main/resources/mapper/settle/ChargebackMapper.xml +++ b/ga-core/src/main/resources/mapper/settle/ChargebackMapper.xml @@ -5,6 +5,56 @@ + + + + + cb.cb_id, + cb.contract_id, + cb.agent_id, + a.agent_name, + cb.policy_no, + ct.contractor_name, + cb.lapse_month, + cb.cb_rate, + cb.cb_amount, + cb.paid_amount, + cb.remain_amount, + cb.settle_month, + cb.installment_no, + cb.max_installments, + cb.status, + cb.created_at + + + + + + + AND cb.settle_month = #{settleMonth} + + + AND cb.agent_id = #{agentId} + + + AND cb.status = #{status} + + + AND cb.policy_no = #{policyNo} + + + AND (cb.policy_no LIKE CONCAT('%', #{searchKeyword}, '%') + OR a.agent_name LIKE CONCAT('%', #{searchKeyword}, '%')) + + + AND cb.created_at >= #{startDate}::date + + + AND cb.created_at <= (#{endDate}::date + INTERVAL '1 day') + + + + INSERT INTO chargeback (contract_id, agent_id, policy_no, lapse_month, cb_rate, cb_amount, paid_amount, remain_amount, settle_month, @@ -48,4 +98,43 @@ SELECT COALESCE(SUM(cb_amount), 0) FROM chargeback WHERE settle_month = #{settleMonth} + + + + + + + + + + + diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index c1ce281..f2ca106 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -33,6 +33,9 @@ const CommissionSimulator = React.lazy(() => import('@/pages/commission/Commiss // P9: 환수채권 const ClawbackReceivable = React.lazy(() => import('@/pages/commission/ClawbackReceivable')); +// P10: 환수자료관리 +const ChargebackData = React.lazy(() => import('@/pages/settle/ChargebackData')); + // P7: 수수료 계산 7도메인 const IncomeCommission = React.lazy(() => import('@/pages/commission/IncomeCommission')); const ReferralCommission = React.lazy(() => import('@/pages/commission/ReferralCommission')); @@ -230,6 +233,9 @@ export default function App() { {/* P9: 환수채권 */} } /> + {/* P10: 환수자료관리 */} + } /> + {/* P7: 수수료 계산 7도메인 */} } /> } /> diff --git a/ga-frontend/src/api/chargeback.ts b/ga-frontend/src/api/chargeback.ts new file mode 100644 index 0000000..dd08191 --- /dev/null +++ b/ga-frontend/src/api/chargeback.ts @@ -0,0 +1,62 @@ +import api, { PageResponse, unwrap } from './request'; + +// ── Types ────────────────────────────────────────── + +export type ChargebackStatus = 'NEW' | 'PROCESSING' | 'SETTLED' | 'CANCELLED'; + +export interface ChargebackRow extends Record { + cbId: number; + policyNo: string; + agentId: number; + agentName: string; + contractorName: string; + lapseMonth: number | null; + cbRate: number | null; + cbAmount: number; + paidAmount: number; + remainAmount: number; + settleMonth: string; + status: ChargebackStatus; + createdAt: string; +} + +export interface ChargebackSearchParam { + settleMonth?: string; + agentId?: number; + status?: string; + policyNo?: string; + pageNum?: number; + pageSize?: number; +} + +export interface ChargebackUploadResult { + successCount: number; + errorCount: number; + skipCount: number; + errors: Array<{ rowNo: number; reason: string }>; +} + +// ── API ─────────────────────────────────────────── + +export const chargebackApi = { + list: (p: ChargebackSearchParam) => + unwrap>( + api.get('/api/chargebacks', { params: p }), + ), + + upload: (settleMonth: string, file: File) => { + const form = new FormData(); + form.append('file', file); + return unwrap( + api.post(`/api/chargebacks/upload?settleMonth=${settleMonth}`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + ); + }, + + export: (p: ChargebackSearchParam) => + api.get('/api/chargebacks/export', { + params: p, + responseType: 'blob', + }), +}; diff --git a/ga-frontend/src/pages/settle/ChargebackData.tsx b/ga-frontend/src/pages/settle/ChargebackData.tsx new file mode 100644 index 0000000..405d37d --- /dev/null +++ b/ga-frontend/src/pages/settle/ChargebackData.tsx @@ -0,0 +1,303 @@ +import { useRef, useState } from 'react'; +import { + Alert, Button, DatePicker, Space, Table, Tag, 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 { + chargebackApi, + ChargebackRow, + ChargebackSearchParam, +} from '@/api/chargeback'; +import { GRAY, RADIUS, SHADOW, COLOR_SUCCESS, COLOR_ERROR, COLOR_WARNING, COLOR_PRIMARY } from '@/theme/tokens'; + +const { Text } = Typography; + +const MENU_CODE = 'CHARGEBACK_DATA'; + +const STATUS_COLOR: Record = { + NEW: COLOR_PRIMARY, + PROCESSING: COLOR_WARNING, + SETTLED: COLOR_SUCCESS, + CANCELLED: COLOR_ERROR, +}; + +const STATUS_LABEL: Record = { + NEW: '신규', + PROCESSING: '처리중', + SETTLED: '정산완료', + CANCELLED: '취소', +}; + +function StatusChip({ status }: { status: string }) { + const color = STATUS_COLOR[status] ?? GRAY[400]; + const label = STATUS_LABEL[status] ?? status; + return ( + + {label} + + ); +} + +const fmt = (v: unknown) => + typeof v === 'number' ? v.toLocaleString() : (v as string | undefined) ?? '-'; + +const GRID_COLS: ColDef[] = [ + { field: 'policyNo', headerName: '증권번호', width: 150, pinned: 'left' }, + { field: 'agentName', headerName: '설계사', width: 120 }, + { field: 'contractorName', headerName: '계약자', width: 120 }, + { + field: 'cbAmount', + headerName: '환수금액', + flex: 1, + type: 'numericColumn', + valueFormatter: (p) => fmt(p.value), + }, + { + field: 'paidAmount', + headerName: '기지급', + flex: 1, + type: 'numericColumn', + valueFormatter: (p) => fmt(p.value), + }, + { + field: 'remainAmount', + headerName: '잔여', + flex: 1, + type: 'numericColumn', + valueFormatter: (p) => fmt(p.value), + }, + { field: 'lapseMonth', headerName: '경과월', width: 100 }, + { + field: 'status', + headerName: '상태', + width: 120, + cellRenderer: (p: { data: ChargebackRow }) => + p.data ? : null, + }, + { field: 'settleMonth', headerName: '정산월', width: 110 }, +]; + +const cardStyle = { + background: '#fff', + borderRadius: RADIUS.lg, + border: `1px solid ${GRAY[100]}`, + boxShadow: SHADOW.sm, +}; + +export default function ChargebackData() { + const qc = useQueryClient(); + + // ── 업로드 상태 ────────────────────────────────── + const [uploadMonth, setUploadMonth] = useState(''); + const [uploadFile, setUploadFile] = useState(null); + const fileInputRef = useRef(null); + + // ── 목록 검색 파라미터 ─────────────────────────── + const [params, setParams] = useState({ pageSize: 500 }); + + // ── 목록 조회 ────────────────────────────────── + const { data: listData, isLoading } = useQuery({ + queryKey: ['chargebacks', params], + queryFn: () => chargebackApi.list(params), + retry: false, + }); + + // ── 업로드 뮤테이션 ────────────────────────────── + const uploadMutation = useMutation({ + mutationFn: ({ month, file }: { month: string; file: File }) => + chargebackApi.upload(month, file), + onSuccess: (result) => { + const { successCount, skipCount, errorCount } = result; + if (errorCount === 0) { + message.success(`업로드 완료 — 성공 ${successCount}건, 스킵 ${skipCount}건`); + } else { + message.warning(`업로드 완료 — 성공 ${successCount}건, 스킵 ${skipCount}건, 오류 ${errorCount}건`); + } + qc.invalidateQueries({ queryKey: ['chargebacks'] }); + // 파일 입력 초기화 + setUploadFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + }, + }); + + const uploadResult = uploadMutation.data; + + const handleUpload = () => { + if (!uploadMonth) { + message.error('정산월을 입력하세요'); + return; + } + if (!uploadFile) { + message.error('파일을 선택하세요'); + return; + } + uploadMutation.mutate({ month: uploadMonth, file: uploadFile }); + }; + + const handleExport = async () => { + try { + const res = await chargebackApi.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 rows = listData?.list ?? []; + + const pinnedBottom = + rows.length > 0 + ? ({ + policyNo: '합계', + cbAmount: rows.reduce((s, r) => s + (r.cbAmount ?? 0), 0), + remainAmount: rows.reduce((s, r) => s + (r.remainAmount ?? 0), 0), + } as Partial) + : undefined; + + return ( + + 엑셀 다운로드 + + } + > + {/* ── 업로드 영역 ────────────────────────────── */} + + +
+ + 정산월 + + setUploadMonth(Array.isArray(val) ? val[0] : val)} + format="YYYYMM" + /> +
+ +
+ + 파일 선택 (xlsx/xls) + + + + { + setUploadFile(e.target.files?.[0] ?? null); + }} + /> + +
+ + + 업로드 + +
+ + {/* ── 업로드 결과 ─────────────────────────── */} + {uploadResult && ( +
+ + + 성공 {uploadResult.successCount}건 + + + 스킵 {uploadResult.skipCount}건 + + {uploadResult.errorCount > 0 && ( + + 오류 {uploadResult.errorCount}건 + + )} + + + {uploadResult.errors.length > 0 && ( + + } + style={{ marginTop: 4 }} + /> + )} +
+ )} +
+ + {/* ── 검색 폼 ────────────────────────────────── */} + setParams({ ...(v as ChargebackSearchParam), pageSize: 500 })} + onReset={() => setParams({ pageSize: 500 })} + /> + + {/* ── AG Grid 목록 ───────────────────────────── */} + + + rows={rows} + columns={GRID_COLS} + loading={isLoading} + height={560} + rowKey="cbId" + pinnedBottomRow={pinnedBottom} + /> + +
+ ); +}