feat: 리포트 3종 백엔드 구현(실적/조직별/환수위험) + Excel export 트랜잭션 수정

- ReportController/Service/Mapper(.xml) + VO 3종(PerfReportRow/OrgReportRow/ChargebackRiskRow)
  - /api/report/performance: settle_month별 settle_master 집계(설계사수/모집/유지/실지급)
  - /api/report/org: 정산월 기준 organization 조인 조직별 집계
  - /api/report/chargeback-risk: recruit_ledger 계약별 경과월 + chargeback_grade 환수율/위험등급
  - 각 read(List) + export(Cursor→xlsx). 신규 테이블 없음(읽기전용 집계)
- ExcelService.exportLargeExcel @Transactional(readOnly=true) 추가 — Cursor 가 세션 밖에서
  닫혀 모든 export(기존 agent 포함)가 500 나던 잠복버그 수정. 1줄로 export 인프라 전체 복구

검증: 3 read 200(실데이터), 3 export 200(유효 xlsx), agent export 복구 확인.
Playwright 84화면 재실행 FAIL 0 (PASS59/WARN25, WARN은 차트/폼 등 탐지한계)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-02 00:51:37 +09:00
parent 81871615cd
commit 1d77671ea0
8 changed files with 369 additions and 0 deletions
@@ -0,0 +1,84 @@
package com.ga.api.controller.report;
import com.ga.api.service.report.ReportService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.excel.ExcelService;
import com.ga.common.model.ApiResponse;
import com.ga.core.vo.report.ChargebackRiskRow;
import com.ga.core.vo.report.OrgReportRow;
import com.ga.core.vo.report.PerfReportRow;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 리포트 — 실적 / 조직별 / 환수위험. 읽기전용 집계 (신규 테이블 없음).
* 각 화면의 조회 + 엑셀 export 엔드포인트 제공.
*/
@Tag(name = "리포트")
@RestController
@RequestMapping("/api/report")
@RequiredArgsConstructor
public class ReportController {
private final ReportService service;
private final ExcelService excelService;
private String defaultYear(String year) {
return (year != null && !year.isBlank()) ? year : String.valueOf(LocalDate.now().getYear());
}
private String defaultMonth(String settleMonth) {
return (settleMonth != null && !settleMonth.isBlank())
? settleMonth : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
}
// ── 실적 리포트 ──
@GetMapping("/performance")
@RequirePermission(menu = "REPORT_PERFORMANCE", perm = "READ")
public ApiResponse<List<PerfReportRow>> performance(@RequestParam(required = false) String year) {
return ApiResponse.ok(service.performance(defaultYear(year)));
}
@GetMapping("/performance/export")
@RequirePermission(menu = "REPORT_PERFORMANCE", perm = "READ")
public void performanceExport(@RequestParam(required = false) String year, HttpServletResponse response) {
String y = defaultYear(year);
excelService.exportLargeExcel("실적리포트", PerfReportRow.class,
() -> service.performanceCursor(y), response);
}
// ── 조직별 리포트 ──
@GetMapping("/org")
@RequirePermission(menu = "REPORT_ORG", perm = "READ")
public ApiResponse<List<OrgReportRow>> org(@RequestParam(required = false) String settleMonth) {
return ApiResponse.ok(service.org(defaultMonth(settleMonth)));
}
@GetMapping("/org/export")
@RequirePermission(menu = "REPORT_ORG", perm = "READ")
public void orgExport(@RequestParam(required = false) String settleMonth, HttpServletResponse response) {
String m = defaultMonth(settleMonth);
excelService.exportLargeExcel("조직별리포트", OrgReportRow.class,
() -> service.orgCursor(m), response);
}
// ── 환수위험 리포트 ──
@GetMapping("/chargeback-risk")
@RequirePermission(menu = "REPORT_CHARGEBACK", perm = "READ")
public ApiResponse<List<ChargebackRiskRow>> chargebackRisk() {
return ApiResponse.ok(service.chargebackRisk());
}
@GetMapping("/chargeback-risk/export")
@RequirePermission(menu = "REPORT_CHARGEBACK", perm = "READ")
public void chargebackRiskExport(HttpServletResponse response) {
excelService.exportLargeExcel("환수위험리포트", ChargebackRiskRow.class,
() -> service.chargebackRiskCursor(), response);
}
}
@@ -0,0 +1,50 @@
package com.ga.api.service.report;
import com.ga.core.mapper.report.ReportMapper;
import com.ga.core.vo.report.ChargebackRiskRow;
import com.ga.core.vo.report.OrgReportRow;
import com.ga.core.vo.report.PerfReportRow;
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 리포트 서비스 — 기존 정산 데이터 위 읽기전용 집계.
* Cursor 변형은 엑셀 export 용 (ExcelService.exportLargeExcel 가 트랜잭션 내에서 소비).
*/
@Service
@RequiredArgsConstructor
public class ReportService {
private final ReportMapper mapper;
@Transactional(readOnly = true)
public List<PerfReportRow> performance(String year) {
return mapper.selectPerformance(year);
}
public Cursor<PerfReportRow> performanceCursor(String year) {
return mapper.selectPerformanceCursor(year);
}
@Transactional(readOnly = true)
public List<OrgReportRow> org(String settleMonth) {
return mapper.selectOrg(settleMonth);
}
public Cursor<OrgReportRow> orgCursor(String settleMonth) {
return mapper.selectOrgCursor(settleMonth);
}
@Transactional(readOnly = true)
public List<ChargebackRiskRow> chargebackRisk() {
return mapper.selectChargebackRisk();
}
public Cursor<ChargebackRiskRow> chargebackRiskCursor() {
return mapper.selectChargebackRiskCursor();
}
}
@@ -13,6 +13,7 @@ import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
@@ -42,6 +43,7 @@ public class ExcelService {
/* =========================================================
다운로드 (Export) — SXSSF + Cursor 스트리밍
========================================================= */
@Transactional(readOnly = true)
public <T> void exportLargeExcel(String fileName, Class<T> clazz,
Supplier<Cursor<T>> cursorSupplier,
HttpServletResponse response) {
@@ -0,0 +1,31 @@
package com.ga.core.mapper.report;
import com.ga.core.vo.report.ChargebackRiskRow;
import com.ga.core.vo.report.OrgReportRow;
import com.ga.core.vo.report.PerfReportRow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.cursor.Cursor;
import java.util.List;
/**
* 리포트 Mapper — 기존 정산 데이터 위 읽기전용 집계 (신규 테이블 없음).
* 실적/조직별 = settle_master 집계, 환수위험 = recruit_ledger + chargeback_grade.
* 각 select 는 조회용(List)과 엑셀 export 용(Cursor) 두 변형을 제공.
*/
@Mapper
public interface ReportMapper {
List<PerfReportRow> selectPerformance(@Param("year") String year);
Cursor<PerfReportRow> selectPerformanceCursor(@Param("year") String year);
List<OrgReportRow> selectOrg(@Param("settleMonth") String settleMonth);
Cursor<OrgReportRow> selectOrgCursor(@Param("settleMonth") String settleMonth);
List<ChargebackRiskRow> selectChargebackRisk();
Cursor<ChargebackRiskRow> selectChargebackRiskCursor();
}
@@ -0,0 +1,43 @@
package com.ga.core.vo.report;
import com.ga.common.annotation.ExcelColumn;
import lombok.Data;
import java.math.BigDecimal;
/** 환수위험 리포트 행 — 계약별 경과월 기준 환수 가능성 분석. */
@Data
public class ChargebackRiskRow {
private Long agentId;
@ExcelColumn(header = "설계사", order = 1, width = 12)
private String agentName;
@ExcelColumn(header = "소속", order = 2, width = 18)
private String orgName;
@ExcelColumn(header = "증권번호", order = 3, width = 16)
private String policyNo;
@ExcelColumn(header = "보험사", order = 4, width = 14)
private String companyName;
@ExcelColumn(header = "모집월", order = 5, width = 10)
private String recruitMonth;
@ExcelColumn(header = "경과월", order = 6, width = 8)
private Integer monthsElapsed;
@ExcelColumn(header = "모집수수료", order = 7, width = 14)
private BigDecimal recruitAmount;
@ExcelColumn(header = "환수율", order = 8, width = 8)
private BigDecimal chargebackRate;
@ExcelColumn(header = "예상환수액", order = 9, width = 14)
private BigDecimal estimatedChargeback;
@ExcelColumn(header = "위험등급", order = 10, width = 10)
private String riskLevel;
}
@@ -0,0 +1,31 @@
package com.ga.core.vo.report;
import com.ga.common.annotation.ExcelColumn;
import lombok.Data;
import java.math.BigDecimal;
/** 조직별 리포트 행 — 정산월 기준 조직 단위 집계. */
@Data
public class OrgReportRow {
private Long orgId;
@ExcelColumn(header = "조직명", order = 1, width = 18)
private String orgName;
@ExcelColumn(header = "유형", order = 2, width = 10)
private String orgType;
@ExcelColumn(header = "설계사수", order = 3, width = 10)
private Long agentCount;
@ExcelColumn(header = "모집수수료", order = 4, width = 16)
private BigDecimal recruitTotal;
@ExcelColumn(header = "유지수수료", order = 5, width = 16)
private BigDecimal maintainTotal;
@ExcelColumn(header = "실지급합계", order = 6, width = 16)
private BigDecimal netAmount;
}
@@ -0,0 +1,26 @@
package com.ga.core.vo.report;
import com.ga.common.annotation.ExcelColumn;
import lombok.Data;
import java.math.BigDecimal;
/** 실적 리포트 행 — 정산월별 집계 (settle_master). */
@Data
public class PerfReportRow {
@ExcelColumn(header = "정산월", order = 1, width = 12)
private String settleMonth;
@ExcelColumn(header = "설계사수", order = 2, width = 10)
private Long agentCount;
@ExcelColumn(header = "모집수수료", order = 3, width = 16)
private BigDecimal recruitTotal;
@ExcelColumn(header = "유지수수료", order = 4, width = 16)
private BigDecimal maintainTotal;
@ExcelColumn(header = "실지급", order = 5, width = 16)
private BigDecimal netAmount;
}
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.report.ReportMapper">
<!--
리포트 Mapper — 읽기전용 집계 (신규 테이블 없음).
mapUnderscoreToCamelCase=true 전제: 집계 컬럼에 snake alias 부여.
-->
<!-- 1. 실적 리포트 — 정산월별 settle_master 집계 -->
<sql id="performanceSql">
SELECT settle_month,
COUNT(DISTINCT agent_id) AS agent_count,
COALESCE(SUM(recruit_total), 0) AS recruit_total,
COALESCE(SUM(maintain_total), 0) AS maintain_total,
COALESCE(SUM(net_amount), 0) AS net_amount
FROM settle_master
WHERE settle_month LIKE #{year} || '%'
GROUP BY settle_month
ORDER BY settle_month
</sql>
<select id="selectPerformance" resultType="com.ga.core.vo.report.PerfReportRow">
<include refid="performanceSql"/>
</select>
<select id="selectPerformanceCursor" resultType="com.ga.core.vo.report.PerfReportRow" fetchSize="500">
<include refid="performanceSql"/>
</select>
<!-- 2. 조직별 리포트 — 정산월 기준 조직 단위 집계 -->
<sql id="orgSql">
SELECT o.org_id,
o.org_name,
o.org_type,
COUNT(DISTINCT sm.agent_id) AS agent_count,
COALESCE(SUM(sm.recruit_total), 0) AS recruit_total,
COALESCE(SUM(sm.maintain_total), 0) AS maintain_total,
COALESCE(SUM(sm.net_amount), 0) AS net_amount
FROM settle_master sm
JOIN agent a ON a.agent_id = sm.agent_id
JOIN organization o ON o.org_id = a.org_id
WHERE sm.settle_month = #{settleMonth}
GROUP BY o.org_id, o.org_name, o.org_type
ORDER BY net_amount DESC
</sql>
<select id="selectOrg" resultType="com.ga.core.vo.report.OrgReportRow">
<include refid="orgSql"/>
</select>
<select id="selectOrgCursor" resultType="com.ga.core.vo.report.OrgReportRow" fetchSize="500">
<include refid="orgSql"/>
</select>
<!-- 3. 환수위험 리포트 — 계약별 경과월 기준 환수율 적용 -->
<sql id="chargebackRiskSql">
WITH rl AS (
SELECT contract_id,
agent_id,
MIN(policy_no) AS policy_no,
MIN(company_code) AS company_code,
MIN(settle_month) AS recruit_month,
SUM(agent_amount) AS recruit_amount
FROM recruit_ledger
GROUP BY contract_id, agent_id
)
SELECT rl.agent_id,
a.agent_name,
o.org_name,
rl.policy_no,
ic.company_name,
rl.recruit_month,
elapsed.months_elapsed,
rl.recruit_amount,
COALESCE(cg.chargeback_percent, 0) AS chargeback_rate,
ROUND(rl.recruit_amount * COALESCE(cg.chargeback_percent, 0) / 100.0) AS estimated_chargeback,
CASE WHEN COALESCE(cg.chargeback_percent, 0) >= 50 THEN 'HIGH'
WHEN COALESCE(cg.chargeback_percent, 0) >= 10 THEN 'MEDIUM'
ELSE 'LOW' END AS risk_level
FROM rl
JOIN agent a ON a.agent_id = rl.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
LEFT JOIN insurance_company ic ON ic.company_code = rl.company_code
CROSS JOIN LATERAL (
SELECT (EXTRACT(YEAR FROM CURRENT_DATE)::int * 12 + EXTRACT(MONTH FROM CURRENT_DATE)::int)
- (substr(rl.recruit_month, 1, 4)::int * 12 + substr(rl.recruit_month, 5, 2)::int) AS months_elapsed
) elapsed
LEFT JOIN chargeback_grade cg
ON cg.is_active = 'Y'
AND elapsed.months_elapsed >= cg.months_from
AND (cg.months_to IS NULL OR elapsed.months_elapsed &lt;= cg.months_to)
ORDER BY estimated_chargeback DESC
</sql>
<select id="selectChargebackRisk" resultType="com.ga.core.vo.report.ChargebackRiskRow">
<include refid="chargebackRiskSql"/>
</select>
<select id="selectChargebackRiskCursor" resultType="com.ga.core.vo.report.ChargebackRiskRow" fetchSize="500">
<include refid="chargebackRiskSql"/>
</select>
</mapper>