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:
GA Pro
2026-06-03 10:02:55 +09:00
parent 1d77671ea0
commit 3d50286f4a
8 changed files with 667 additions and 14 deletions
+2 -2
View File
@@ -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);
}
}