test: E2E 수수료 정산 시나리오 테스트 + 엑셀업로드/분급 버그 수정
- 시나리오 문서: 보험 인입→수수료 지급 8단계 (docs/시나리오_E2E_보험인입_수수료정산.md) - SettlementScenarioTest(9건): 계약1건 생애주기를 실제 계산기로 단계별 검증 - ExcelServiceImportTest(2건): 실 .xlsx 업로드 라운드트립 검증 - fix(CRITICAL): 엑셀 업로드 런타임 깨짐 — xlsx-streamer 2.2.0 ↔ poi 5.2.5 비호환(NoSuchMethodError). excel-streaming-reader 4.3.0(유지보수 fork)로 교체. 영향: ExcelService.importLargeExcel + UploadService(/api/upload 실엔드포인트) - fix: 분급 계획 반올림 잔차로 분급합계≠원금(정산 무결성 위반). 비율합 100% 정규스케줄일 때 마지막 회차가 잔차 흡수. ga-api/ga-batch 양쪽. - 전체 build+test GREEN: 148건 0실패 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,9 +20,9 @@ dependencies {
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
|
||||
|
||||
// Excel
|
||||
// Excel (xlsx-streamer 2.2.0 → POI 5.2.5 비호환(NoSuchMethodError). 유지보수 후속 fork로 교체)
|
||||
api 'org.apache.poi:poi-ooxml:5.2.5'
|
||||
api 'com.monitorjbl:xlsx-streamer:2.2.0'
|
||||
api 'com.github.pjfanning:excel-streaming-reader:4.3.0'
|
||||
|
||||
// OpenAPI / Swagger
|
||||
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.ga.common.code.CommonCodeService;
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
import com.ga.common.util.MaskUtil;
|
||||
import com.monitorjbl.xlsx.StreamingReader;
|
||||
import com.github.pjfanning.xlsx.StreamingReader;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.ga.common.excel;
|
||||
|
||||
import com.ga.common.annotation.ExcelColumn;
|
||||
import com.ga.common.code.CommonCodeService;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 엑셀 업로드(보험사 실적 수신 ① 단계) 라운드트립 테스트.
|
||||
* 실제 .xlsx 바이트를 생성 → ExcelService.importLargeExcel 으로 파싱 →
|
||||
* VO 매핑/타입변환/필수검증/배치 consumer 호출/에러행 수집을 검증한다.
|
||||
*
|
||||
* 시나리오 ① "보험이 들어오는" 입구: 보험사 실적 엑셀이 시스템으로 인입되는 경로.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ExcelServiceImportTest {
|
||||
|
||||
@Mock CommonCodeService codeService; // import 경로에서는 미사용 (export 전용)
|
||||
|
||||
/** 보험사 실적 1행(raw)을 모사하는 업로드 VO. */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CarrierResultRow {
|
||||
@ExcelColumn(header = "증권번호", order = 1, required = true)
|
||||
private String policyNo;
|
||||
@ExcelColumn(header = "설계사코드", order = 2)
|
||||
private String agentCode;
|
||||
@ExcelColumn(header = "보험료", order = 3)
|
||||
private BigDecimal premium;
|
||||
@ExcelColumn(header = "계약일", order = 4)
|
||||
private LocalDate contractDate;
|
||||
@ExcelColumn(header = "회차", order = 5)
|
||||
private Integer commissionYear;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("정상 행은 VO로 정확히 파싱되고, 필수(증권번호) 누락 행은 에러로 수집된다")
|
||||
void 보험사실적_엑셀업로드_정상행파싱_및_필수누락행_에러수집() throws Exception {
|
||||
// given: 헤더 + 정상 2행 + 증권번호 누락 1행
|
||||
byte[] xlsx = buildXlsx(sheet -> {
|
||||
header(sheet);
|
||||
dataRow(sheet, 1, "POL-1001", "AG-10", 1000000, "2024-01-15", 1);
|
||||
dataRow(sheet, 2, "POL-1002", "AG-20", 250000.50, "2024-02-20", 2);
|
||||
dataRow(sheet, 3, null, "AG-30", 500000, "2024-03-01", 1); // 증권번호 누락 → 에러
|
||||
});
|
||||
MultipartFile file = multipart(xlsx);
|
||||
ExcelService excelService = new ExcelService(codeService);
|
||||
|
||||
// when
|
||||
List<CarrierResultRow> saved = new ArrayList<>();
|
||||
ExcelImportResult result = excelService.importLargeExcel(
|
||||
file, CarrierResultRow.class, 100, saved::addAll);
|
||||
|
||||
// then: 합계/성공/에러 카운트
|
||||
assertThat(result.getTotalCount()).isEqualTo(3);
|
||||
assertThat(result.getSuccessCount()).isEqualTo(2);
|
||||
assertThat(result.getErrorCount()).isEqualTo(1);
|
||||
assertThat(result.getErrors()).hasSize(1);
|
||||
assertThat(result.getErrors().get(0).getRowNumber()).isEqualTo(4); // 엑셀 4번째 행(1-base, 헤더 포함)
|
||||
assertThat(result.getErrors().get(0).getErrorMessage()).contains("증권번호");
|
||||
|
||||
// then: 파싱 값/타입 변환 정확성
|
||||
assertThat(saved).hasSize(2);
|
||||
CarrierResultRow r1 = saved.get(0);
|
||||
assertThat(r1.getPolicyNo()).isEqualTo("POL-1001");
|
||||
assertThat(r1.getAgentCode()).isEqualTo("AG-10");
|
||||
assertThat(r1.getPremium()).isEqualByComparingTo("1000000"); // numeric → BigDecimal
|
||||
assertThat(r1.getContractDate()).isEqualTo(LocalDate.of(2024, 1, 15)); // string → LocalDate
|
||||
assertThat(r1.getCommissionYear()).isEqualTo(1); // numeric → Integer
|
||||
assertThat(saved.get(1).getPremium()).isEqualByComparingTo("250000.50");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배치 크기 단위로 consumer가 나누어 호출되고 전 행이 누적 저장된다")
|
||||
void 배치사이즈_단위로_consumer_누적호출() throws Exception {
|
||||
byte[] xlsx = buildXlsx(sheet -> {
|
||||
header(sheet);
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
dataRow(sheet, i, "POL-" + i, "AG-" + i, 100000 * i, "2024-01-15", 1);
|
||||
}
|
||||
});
|
||||
ExcelService excelService = new ExcelService(codeService);
|
||||
|
||||
List<Integer> batchSizes = new ArrayList<>();
|
||||
List<CarrierResultRow> saved = new ArrayList<>();
|
||||
ExcelImportResult result = excelService.importLargeExcel(
|
||||
multipart(xlsx), CarrierResultRow.class, 2, batch -> {
|
||||
batchSizes.add(batch.size());
|
||||
saved.addAll(batch);
|
||||
});
|
||||
|
||||
// 5행 / 배치2 → [2, 2, 1] 로 consumer 3회 호출
|
||||
assertThat(batchSizes).containsExactly(2, 2, 1);
|
||||
assertThat(result.getSuccessCount()).isEqualTo(5);
|
||||
assertThat(result.getErrorCount()).isZero();
|
||||
assertThat(saved).hasSize(5);
|
||||
}
|
||||
|
||||
// ── .xlsx 빌더 헬퍼 ──
|
||||
|
||||
private interface SheetWriter { void write(Sheet sheet); }
|
||||
|
||||
private static byte[] buildXlsx(SheetWriter writer) throws Exception {
|
||||
try (XSSFWorkbook wb = new XSSFWorkbook();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
Sheet sheet = wb.createSheet("Sheet1");
|
||||
writer.write(sheet);
|
||||
wb.write(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static void header(Sheet sheet) {
|
||||
Row h = sheet.createRow(0);
|
||||
String[] cols = {"증권번호", "설계사코드", "보험료", "계약일", "회차"};
|
||||
for (int i = 0; i < cols.length; i++) h.createCell(i).setCellValue(cols[i]);
|
||||
}
|
||||
|
||||
private static void dataRow(Sheet sheet, int rowIdx, String policyNo, String agentCode,
|
||||
double premium, String contractDate, int year) {
|
||||
Row r = sheet.createRow(rowIdx);
|
||||
if (policyNo != null) r.createCell(0).setCellValue(policyNo); // null → 셀 미생성(필수 누락)
|
||||
r.createCell(1).setCellValue(agentCode);
|
||||
r.createCell(2).setCellValue(premium);
|
||||
r.createCell(3).setCellValue(contractDate);
|
||||
r.createCell(4).setCellValue(year);
|
||||
}
|
||||
|
||||
private static MultipartFile multipart(byte[] xlsx) {
|
||||
return new MockMultipartFile(
|
||||
"file", "carrier.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsx);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user