diff --git a/ga-api/src/main/java/com/ga/api/controller/report/ReportController.java b/ga-api/src/main/java/com/ga/api/controller/report/ReportController.java new file mode 100644 index 0000000..5efd76f --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/report/ReportController.java @@ -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> 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> 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> 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); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/report/ReportService.java b/ga-api/src/main/java/com/ga/api/service/report/ReportService.java new file mode 100644 index 0000000..27b61f7 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/report/ReportService.java @@ -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 performance(String year) { + return mapper.selectPerformance(year); + } + + public Cursor performanceCursor(String year) { + return mapper.selectPerformanceCursor(year); + } + + @Transactional(readOnly = true) + public List org(String settleMonth) { + return mapper.selectOrg(settleMonth); + } + + public Cursor orgCursor(String settleMonth) { + return mapper.selectOrgCursor(settleMonth); + } + + @Transactional(readOnly = true) + public List chargebackRisk() { + return mapper.selectChargebackRisk(); + } + + public Cursor chargebackRiskCursor() { + return mapper.selectChargebackRiskCursor(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelService.java b/ga-common/src/main/java/com/ga/common/excel/ExcelService.java index 8a2f8cf..ce66144 100644 --- a/ga-common/src/main/java/com/ga/common/excel/ExcelService.java +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelService.java @@ -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 void exportLargeExcel(String fileName, Class clazz, Supplier> cursorSupplier, HttpServletResponse response) { diff --git a/ga-core/src/main/java/com/ga/core/mapper/report/ReportMapper.java b/ga-core/src/main/java/com/ga/core/mapper/report/ReportMapper.java new file mode 100644 index 0000000..295d487 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/report/ReportMapper.java @@ -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 selectPerformance(@Param("year") String year); + + Cursor selectPerformanceCursor(@Param("year") String year); + + List selectOrg(@Param("settleMonth") String settleMonth); + + Cursor selectOrgCursor(@Param("settleMonth") String settleMonth); + + List selectChargebackRisk(); + + Cursor selectChargebackRiskCursor(); +} diff --git a/ga-core/src/main/java/com/ga/core/vo/report/ChargebackRiskRow.java b/ga-core/src/main/java/com/ga/core/vo/report/ChargebackRiskRow.java new file mode 100644 index 0000000..d959076 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/report/ChargebackRiskRow.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/report/OrgReportRow.java b/ga-core/src/main/java/com/ga/core/vo/report/OrgReportRow.java new file mode 100644 index 0000000..31b1b31 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/report/OrgReportRow.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/report/PerfReportRow.java b/ga-core/src/main/java/com/ga/core/vo/report/PerfReportRow.java new file mode 100644 index 0000000..2d29434 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/report/PerfReportRow.java @@ -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; +} diff --git a/ga-core/src/main/resources/mapper/report/ReportMapper.xml b/ga-core/src/main/resources/mapper/report/ReportMapper.xml new file mode 100644 index 0000000..54dd1c2 --- /dev/null +++ b/ga-core/src/main/resources/mapper/report/ReportMapper.xml @@ -0,0 +1,102 @@ + + + + + + + + + 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 + + + + + + + + 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 + + + + + + + + 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 <= cg.months_to) + ORDER BY estimated_chargeback DESC + + + + + +