feat: P10(#3) 환수 보험사자료 업로드 — 보험사 통보 기반 chargeback 인입 (PL 자율진행)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 롤백/삭제.
|
||||
</content>
|
||||
@@ -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<PageResponse<ChargebackResp>> 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<ChargebackUploadService.UploadResult> upload(
|
||||
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다")
|
||||
@RequestParam String settleMonth,
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
return ApiResponse.ok(chargebackUploadService.upload(settleMonth, file));
|
||||
}
|
||||
}
|
||||
@@ -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<ChargebackResp> 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<ChargebackResp> rows = chargebackMapper.selectList(param);
|
||||
List<ChargebackExcelRow> 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<T> implements Cursor<T> {
|
||||
private final List<T> list;
|
||||
ListCursor(List<T> 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<T> iterator() { return list.iterator(); }
|
||||
@Override public void close() { /* no-op */ }
|
||||
}
|
||||
}
|
||||
@@ -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<ExcelErrorRow> errors = new ArrayList<>();
|
||||
int[] skipCount = {0};
|
||||
int[] successCount = {0};
|
||||
|
||||
excelService.importLargeExcel(file, ChargebackUploadExcelVO.class, 500, batch -> {
|
||||
List<ChargebackVO> 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<ExcelErrorRow> errors
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Map<String, Object>> aggregateByAgent(@Param("settleMonth") String settleMonth);
|
||||
BigDecimal sumByMonth(@Param("settleMonth") String settleMonth);
|
||||
|
||||
// ── P10 환수자료 업로드 / 조회 ──────────────────────────────────────────
|
||||
/** 환수 목록 조회 (agentName / contractorName 조인 포함). PageHelper 페이징 사용. */
|
||||
List<ChargebackResp> selectList(ChargebackSearchParam param);
|
||||
|
||||
/** 환수 목록 건수 (페이징 없이 count만). */
|
||||
int countByCondition(ChargebackSearchParam param);
|
||||
|
||||
/**
|
||||
* 동일 contract + settleMonth 환수 레코드 존재 여부.
|
||||
* 업로드 중복 방지용. 반환값 > 0 이면 이미 존재.
|
||||
*/
|
||||
int existsByContractAndMonth(@Param("contractId") Long contractId,
|
||||
@Param("settleMonth") String settleMonth);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -68,6 +68,19 @@
|
||||
SELECT COUNT(*) FROM contract WHERE policy_no = #{policyNo}
|
||||
</select>
|
||||
|
||||
<!--
|
||||
증권번호로 계약 조회 (P10 환수자료 업로드 시 agent/contract 해소용).
|
||||
contract.policy_no 는 UNIQUE NOT NULL(V2).
|
||||
contractId + agentId 만 필요하므로 ContractVOMap 재사용.
|
||||
미존재 시 null 반환.
|
||||
-->
|
||||
<select id="selectByPolicyNo" resultMap="ContractVOMap">
|
||||
SELECT contract_id, agent_id, policy_no, contract_date, status
|
||||
FROM contract
|
||||
WHERE policy_no = #{policyNo}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insert" parameterType="com.ga.core.vo.product.ContractVO" useGeneratedKeys="true" keyProperty="contractId">
|
||||
INSERT INTO contract (agent_id, product_id, policy_no, contractor_name, insured_name,
|
||||
premium, pay_cycle, pay_period, coverage_period, status,
|
||||
|
||||
@@ -5,6 +5,56 @@
|
||||
|
||||
<resultMap id="CBMap" type="com.ga.core.vo.settle.ChargebackVO"/>
|
||||
|
||||
<resultMap id="CBRespMap" type="com.ga.core.vo.settle.ChargebackResp"/>
|
||||
|
||||
<!-- 조회 공통 컬럼: chargeback 전체 + 조인 필드 -->
|
||||
<sql id="cbRespCols">
|
||||
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
|
||||
</sql>
|
||||
|
||||
<!-- 동적 WHERE 조건 (selectList / countByCondition 공용) -->
|
||||
<sql id="cbWhere">
|
||||
<where>
|
||||
<if test="settleMonth != null and settleMonth != ''">
|
||||
AND cb.settle_month = #{settleMonth}
|
||||
</if>
|
||||
<if test="agentId != null">
|
||||
AND cb.agent_id = #{agentId}
|
||||
</if>
|
||||
<if test="status != null and status != ''">
|
||||
AND cb.status = #{status}
|
||||
</if>
|
||||
<if test="policyNo != null and policyNo != ''">
|
||||
AND cb.policy_no = #{policyNo}
|
||||
</if>
|
||||
<if test="searchKeyword != null and searchKeyword != ''">
|
||||
AND (cb.policy_no LIKE CONCAT('%', #{searchKeyword}, '%')
|
||||
OR a.agent_name LIKE CONCAT('%', #{searchKeyword}, '%'))
|
||||
</if>
|
||||
<if test="startDate != null and startDate != ''">
|
||||
AND cb.created_at >= #{startDate}::date
|
||||
</if>
|
||||
<if test="endDate != null and endDate != ''">
|
||||
AND cb.created_at <= (#{endDate}::date + INTERVAL '1 day')
|
||||
</if>
|
||||
</where>
|
||||
</sql>
|
||||
|
||||
<insert id="insert" parameterType="com.ga.core.vo.settle.ChargebackVO" useGeneratedKeys="true" keyProperty="cbId">
|
||||
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}
|
||||
</select>
|
||||
|
||||
<!-- ── P10 환수자료 업로드 / 조회 ────────────────────────────────────── -->
|
||||
|
||||
<!--
|
||||
환수 목록 조회.
|
||||
PageHelper.startPage() 호출 후 이 쿼리 실행 시 자동 페이징.
|
||||
agent / contract 좌조인: contract 없는 행(보험사 직접 통보, contract_id NULL)도 포함.
|
||||
-->
|
||||
<select id="selectList" resultMap="CBRespMap">
|
||||
SELECT <include refid="cbRespCols"/>
|
||||
FROM chargeback cb
|
||||
JOIN agent a ON a.agent_id = cb.agent_id
|
||||
LEFT JOIN contract ct ON ct.contract_id = cb.contract_id
|
||||
<include refid="cbWhere"/>
|
||||
ORDER BY cb.created_at DESC, cb.cb_id DESC
|
||||
</select>
|
||||
|
||||
<!--
|
||||
페이징 없이 총 건수만 반환.
|
||||
PageHelper 없이 count가 필요한 경우(별도 카운트 쿼리) 사용.
|
||||
-->
|
||||
<select id="countByCondition" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM chargeback cb
|
||||
JOIN agent a ON a.agent_id = cb.agent_id
|
||||
LEFT JOIN contract ct ON ct.contract_id = cb.contract_id
|
||||
<include refid="cbWhere"/>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
동일 contract_id + settle_month 환수 레코드 존재 여부.
|
||||
반환값 > 0 이면 중복 → 업로드 시 해당 행 스킵 + 에러행 리포트.
|
||||
-->
|
||||
<select id="existsByContractAndMonth" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM chargeback
|
||||
WHERE contract_id = #{contractId}
|
||||
AND settle_month = #{settleMonth}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -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: 환수채권 */}
|
||||
<Route path="commission/clawback-receivable" element={<ClawbackReceivable />} />
|
||||
|
||||
{/* P10: 환수자료관리 */}
|
||||
<Route path="settle/chargeback-data" element={<ChargebackData />} />
|
||||
|
||||
{/* P7: 수수료 계산 7도메인 */}
|
||||
<Route path="commission/income" element={<IncomeCommission />} />
|
||||
<Route path="commission/referral" element={<ReferralCommission />} />
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
// ── Types ──────────────────────────────────────────
|
||||
|
||||
export type ChargebackStatus = 'NEW' | 'PROCESSING' | 'SETTLED' | 'CANCELLED';
|
||||
|
||||
export interface ChargebackRow extends Record<string, unknown> {
|
||||
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<PageResponse<ChargebackRow>>(
|
||||
api.get('/api/chargebacks', { params: p }),
|
||||
),
|
||||
|
||||
upload: (settleMonth: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return unwrap<ChargebackUploadResult>(
|
||||
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',
|
||||
}),
|
||||
};
|
||||
@@ -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<string, string> = {
|
||||
NEW: COLOR_PRIMARY,
|
||||
PROCESSING: COLOR_WARNING,
|
||||
SETTLED: COLOR_SUCCESS,
|
||||
CANCELLED: COLOR_ERROR,
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
NEW: '신규',
|
||||
PROCESSING: '처리중',
|
||||
SETTLED: '정산완료',
|
||||
CANCELLED: '취소',
|
||||
};
|
||||
|
||||
function StatusChip({ status }: { status: string }) {
|
||||
const color = STATUS_COLOR[status] ?? GRAY[400];
|
||||
const label = STATUS_LABEL[status] ?? status;
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
color,
|
||||
background: `${color}1A`,
|
||||
border: `1px solid ${color}40`,
|
||||
borderRadius: RADIUS.sm,
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const fmt = (v: unknown) =>
|
||||
typeof v === 'number' ? v.toLocaleString() : (v as string | undefined) ?? '-';
|
||||
|
||||
const GRID_COLS: ColDef<ChargebackRow>[] = [
|
||||
{ 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 ? <StatusChip status={p.data.status} /> : 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<string>('');
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── 목록 검색 파라미터 ───────────────────────────
|
||||
const [params, setParams] = useState<ChargebackSearchParam>({ 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<ChargebackRow>)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="환수자료관리"
|
||||
description="보험사 환수 자료 업로드 및 환수 목록 조회"
|
||||
extra={
|
||||
<PermissionButton menuCode={MENU_CODE} permCode="EXPORT" onClick={handleExport}>
|
||||
엑셀 다운로드
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{/* ── 업로드 영역 ────────────────────────────── */}
|
||||
<ProCard
|
||||
title="보험사 환수자료 업로드"
|
||||
style={{ ...cardStyle, marginBottom: 16 }}
|
||||
bodyStyle={{ padding: '16px 20px' }}
|
||||
>
|
||||
<Space wrap size={12} align="end">
|
||||
<div>
|
||||
<Text style={{ fontSize: 12, color: GRAY[600], display: 'block', marginBottom: 4 }}>
|
||||
정산월
|
||||
</Text>
|
||||
<DatePicker
|
||||
picker="month"
|
||||
style={{ width: 150 }}
|
||||
onChange={(_, val) => setUploadMonth(Array.isArray(val) ? val[0] : val)}
|
||||
format="YYYYMM"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text style={{ fontSize: 12, color: GRAY[600], display: 'block', marginBottom: 4 }}>
|
||||
파일 선택 (xlsx/xls)
|
||||
</Text>
|
||||
<Space size={8}>
|
||||
<Button onClick={() => fileInputRef.current?.click()}>
|
||||
{uploadFile ? uploadFile.name : '파일 선택'}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
setUploadFile(e.target.files?.[0] ?? null);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<PermissionButton
|
||||
menuCode={MENU_CODE}
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={handleUpload}
|
||||
>
|
||||
업로드
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
|
||||
{/* ── 업로드 결과 ─────────────────────────── */}
|
||||
{uploadResult && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space size={24} style={{ marginBottom: 8 }}>
|
||||
<Text style={{ color: COLOR_SUCCESS, fontWeight: 600 }}>
|
||||
성공 {uploadResult.successCount}건
|
||||
</Text>
|
||||
<Text style={{ color: GRAY[500], fontWeight: 600 }}>
|
||||
스킵 {uploadResult.skipCount}건
|
||||
</Text>
|
||||
{uploadResult.errorCount > 0 && (
|
||||
<Text style={{ color: COLOR_ERROR, fontWeight: 600 }}>
|
||||
오류 {uploadResult.errorCount}건
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{uploadResult.errors.length > 0 && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={`오류 행 목록 (${uploadResult.errors.length}건)`}
|
||||
description={
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="rowNo"
|
||||
dataSource={uploadResult.errors}
|
||||
pagination={false}
|
||||
style={{ marginTop: 8 }}
|
||||
columns={[
|
||||
{ title: '행 번호', dataIndex: 'rowNo', width: 80, align: 'right' },
|
||||
{ title: '사유', dataIndex: 'reason' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ProCard>
|
||||
|
||||
{/* ── 검색 폼 ────────────────────────────────── */}
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
{ type: 'text', name: 'policyNo', label: '증권번호', span: 6 },
|
||||
{ type: 'text', name: 'status', label: '상태', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...(v as ChargebackSearchParam), pageSize: 500 })}
|
||||
onReset={() => setParams({ pageSize: 500 })}
|
||||
/>
|
||||
|
||||
{/* ── AG Grid 목록 ───────────────────────────── */}
|
||||
<ProCard style={cardStyle}>
|
||||
<DataGrid<ChargebackRow>
|
||||
rows={rows}
|
||||
columns={GRID_COLS}
|
||||
loading={isLoading}
|
||||
height={560}
|
||||
rowKey="cbId"
|
||||
pinnedBottomRow={pinnedBottom}
|
||||
/>
|
||||
</ProCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user