From 83f05dd11dbb87699ef696bd8c9db12376c0f1bc Mon Sep 17 00:00:00 2001 From: rcx <3084692334@qq.com> Date: Wed, 19 Aug 2026 15:36:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(bms):=E6=96=B0=E5=A2=9E=E8=B4=B9=E7=94=A8?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=88=86=E6=9E=90=E6=8A=A5=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E7=A7=91=E7=9B=AE=E6=8C=89=E6=A8=A1=E6=9D=BF=E7=B1=BB=E5=88=AB?= =?UTF-8?q?=E5=BD=92=E5=B9=B6=E3=80=81=E4=BB=93=E5=82=A8=E6=8B=86=E6=95=A3?= =?UTF-8?q?=E7=A7=9F/=E5=9B=BA=E5=AE=9A=E5=87=BA=E7=A7=9F=E4=B8=A4?= =?UTF-8?q?=E8=A1=8C=E3=80=81=E8=A1=8C=E7=BA=A7=E5=8D=A0=E8=90=A5=E6=94=B6?= =?UTF-8?q?=E6=AF=94=EF=BC=8C=E5=90=AB34=E5=88=97=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E4=B8=8E=E6=99=BA=E8=83=BD=E5=88=86=E6=9E=90=E5=BD=92=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FeeAnalysisReportApplicationService.java | 155 ++++++ .../mapper/FeeAnalysisReportMapper.java | 32 ++ .../repository/po/FeeSubjectMonthStatPO.java | 28 ++ .../FeeAnalysisReportDomainService.java | 297 +++++++++++ .../support/FeeAnalysisCalculator.java | 105 ++++ .../support/FeeAnalysisExcelExporter.java | 325 ++++++++++++ .../support/FeeSubjectCategoryResolver.java | 130 +++++ .../support/IntelligentAnalysisEngine.java | 469 ++++++++++++++++++ .../FeeAnalysisReportQueryDTO.java | 24 + .../FeeAnalysisReportResultDTO.java | 21 + .../feeAnalysisReport/FeeAnalysisRowDTO.java | 34 ++ .../dto/feeAnalysisReport/FeeQuarterDTO.java | 23 + .../IntelligentAnalysisResultDTO.java | 19 + .../dto/feeAnalysisReport/MonthAmountDTO.java | 15 + .../feeAnalysisReport/MonthAnalysisDTO.java | 28 ++ .../feeAnalysisReport/QuarterAnalysisDTO.java | 36 ++ .../feeAnalysisReport/ReasonFactorDTO.java | 19 + .../facadeApi/FeeAnalysisReportApi.java | 56 +++ .../mapper/FeeAnalysisReportMapper.xml | 77 +++ 19 files changed, 1893 insertions(+) create mode 100644 mhd_bms/src/main/java/com/mhd/bms/application/server/feeAnalysisReport/FeeAnalysisReportApplicationService.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/repository/mapper/FeeAnalysisReportMapper.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/repository/po/FeeSubjectMonthStatPO.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/FeeAnalysisReportDomainService.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeAnalysisCalculator.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeAnalysisExcelExporter.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeSubjectCategoryResolver.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/IntelligentAnalysisEngine.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisReportQueryDTO.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisReportResultDTO.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisRowDTO.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeQuarterDTO.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/IntelligentAnalysisResultDTO.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/MonthAmountDTO.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/MonthAnalysisDTO.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/QuarterAnalysisDTO.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/ReasonFactorDTO.java create mode 100644 mhd_bms/src/main/java/com/mhd/bms/interfaces/facadeApi/FeeAnalysisReportApi.java create mode 100644 mhd_bms/src/main/resources/mapper/FeeAnalysisReportMapper.xml diff --git a/mhd_bms/src/main/java/com/mhd/bms/application/server/feeAnalysisReport/FeeAnalysisReportApplicationService.java b/mhd_bms/src/main/java/com/mhd/bms/application/server/feeAnalysisReport/FeeAnalysisReportApplicationService.java new file mode 100644 index 000000000..21f180ffa --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/application/server/feeAnalysisReport/FeeAnalysisReportApplicationService.java @@ -0,0 +1,155 @@ +package com.mhd.bms.application.server.feeAnalysisReport; + +import cn.hutool.core.util.ObjectUtil; +import com.mhd.bms.application.server.feeAnalysisReport.FeeAnalysisReportApplicationService.AuthScope; +import com.mhd.bms.domain.feeAnalysisReport.service.FeeAnalysisReportDomainService; +import com.mhd.bms.domain.feeAnalysisReport.service.support.FeeAnalysisExcelExporter; +import com.mhd.bms.domain.feeAnalysisReport.service.support.IntelligentAnalysisEngine; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeAnalysisReportQueryDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeAnalysisReportResultDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.IntelligentAnalysisResultDTO; +import com.mhd.common.core.exception.ServiceException; +import com.mhd.common.core.exception.digitalLogisticsException.DigitalLogisticsException; +import com.mhd.common.core.exception.digitalLogisticsException.UserError; +import com.mhd.common.security.utils.SecurityUtils; +import com.mhd.system.api.model.LoginUser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; + +/** + * 费用统计分析报表ApplicationService + * 职责:登录校验、多租户/组织数据范围注入、参数校验与默认值、编排领域服务 + * + * @author rcx + * @date 2026-08-17 + */ +@Service +@Slf4j +public class FeeAnalysisReportApplicationService { + + /** 南光特权组织ID:可查全部组织数据(与 BillManageApplicationService 口径一致) */ + private static final Long SPECIAL_ORG_NAN_GUANG = 2827L; + + /** 总部一级组织ID:1 可看全部,其余租户强制隔离 */ + private static final Long HQ_TOP_ORG_ID = 1L; + + @Autowired + private FeeAnalysisReportDomainService feeAnalysisReportDomainService; + @Autowired + private IntelligentAnalysisEngine intelligentAnalysisEngine; + @Autowired + private FeeAnalysisExcelExporter feeAnalysisExcelExporter; + + /** 报表查询(主入口,导出与智能分析复用) */ + public FeeAnalysisReportResultDTO query(FeeAnalysisReportQueryDTO queryDTO) { + AuthScope scope = fillAuthScope(queryDTO); + int year = normalizeYear(queryDTO.getYear()); + Integer type = normalizeType(queryDTO.getAccountExpenseType()); + return feeAnalysisReportDomainService.buildReport(year, type, scope.topOrganizationId, scope.organizationId); + } + + /** 智能分析:先取同一份数据(保证口径一致),再交给规则引擎 */ + public IntelligentAnalysisResultDTO intelligentAnalysis(FeeAnalysisReportQueryDTO queryDTO) { + FeeAnalysisReportResultDTO report = query(queryDTO); + AuthScope scope = fillAuthScope(queryDTO); + return intelligentAnalysisEngine.analyze(report, report.getYear(), + report.getAccountExpenseType(), scope.topOrganizationId, scope.organizationId); + } + + /** 导出:与 /query 完全同源数据;响应头用实际生效年份拼文件名(仿 BillManageApi.exportReceivableList) */ + public void export(FeeAnalysisReportQueryDTO queryDTO, HttpServletResponse response) throws IOException { + FeeAnalysisReportResultDTO report = query(queryDTO); + String fileName = URLEncoder.encode("费用统计分析报表" + report.getYear(), "UTF-8") + .replaceAll("\\+", "%20"); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx"); + feeAnalysisExcelExporter.export(response, report); + } + + /** 年份下拉:库中有费用的年份(降序) */ + public List selectYears(FeeAnalysisReportQueryDTO queryDTO) { + AuthScope scope = fillAuthScope(queryDTO); + return feeAnalysisReportDomainService.selectDistinctYears( + normalizeType(queryDTO.getAccountExpenseType()), scope.topOrganizationId); + } + + // ------------------------------------------------------------------ + // 安全闸门:登录校验 + 多租户隔离 + 2827 特例(分支解析见本节末尾表格) + // ------------------------------------------------------------------ + + /** + * 决定"当前用户能看哪个数据范围",结果通过 AuthScope 传给领域服务。 + * 【为什么必须在后端做】费用是财务敏感数据,隔离条件从登录态注入, + * 前端传什么都无法越权(传别人的组织会被强制改回本人组织)。 + */ + private AuthScope fillAuthScope(FeeAnalysisReportQueryDTO queryDTO) { + // ① 登录校验(照抄 ReportFormApplicationService:84-87) + LoginUser loginUser = SecurityUtils.getLoginUser(); + if (ObjectUtil.isNull(loginUser) || ObjectUtil.isNull(loginUser.getUserPo())) { + throw new DigitalLogisticsException(UserError.TIMEOUT); + } + Long userOrganizationId = loginUser.getUserPo().getOrganizationId(); + Long frontendOrganizationId = queryDTO.getOrganizationId(); // 前端传的组织(可能越权,下面裁决) + + AuthScope scope = new AuthScope(); + if (Objects.equals(userOrganizationId, SPECIAL_ORG_NAN_GUANG)) { + // ② 南光组织(2827):特权账号,可查全部组织(照抄 BillManageApplicationService:97-107) + if (frontendOrganizationId != null) { + scope.organizationId = frontendOrganizationId; // 前端指定了组织 → 按指定的查 + } else { + scope.organizationId = null; // 没指定 → 查全部 + } + scope.topOrganizationId = null; // 明确置 null,不用一级组织条件 + } else { + // ③ 其他组织:防越权裁决(照抄 BillManageApplicationService:108-123) + if (frontendOrganizationId != null && userOrganizationId != null + && !frontendOrganizationId.equals(userOrganizationId)) { + // 前端传了别人的组织 → 强制改回本人组织 + scope.organizationId = userOrganizationId; + } else { + scope.organizationId = frontendOrganizationId != null + ? frontendOrganizationId : userOrganizationId; + } + // ④ 组织条件组合(照抄 BillManageApplicationService:124-133) + if (scope.organizationId != null) { + scope.topOrganizationId = SPECIAL_ORG_NAN_GUANG; // 与账单口径一致:有组织条件时 top 固定 2827 + } else { + // 无组织条件 → 回退一级组织隔离:非总部租户只能看自己租户,总部(1)看全部 + Long top = loginUser.getUserPo().getTopOrganizationId(); + scope.topOrganizationId = (top != null && !top.equals(HQ_TOP_ORG_ID)) ? top : null; + } + } + return scope; + } + + // ------------------------------------------------------------------ + // 参数默认值与校验 + // ------------------------------------------------------------------ + + private int normalizeYear(Integer year) { + int y = (year == null) ? LocalDate.now().getYear() : year; + if (y < 1900 || y > 2100) { + throw new ServiceException("年份参数不合法"); + } + return y; + } + + private Integer normalizeType(Integer accountExpenseType) { + return accountExpenseType == null ? 1 : accountExpenseType; + } + + /** 数据范围值对象:两个字段都为 null 表示不过滤(查全部) */ + public static class AuthScope { + public Long topOrganizationId; + public Long organizationId; + } +} \ No newline at end of file diff --git a/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/repository/mapper/FeeAnalysisReportMapper.java b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/repository/mapper/FeeAnalysisReportMapper.java new file mode 100644 index 000000000..75d0a85ed --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/repository/mapper/FeeAnalysisReportMapper.java @@ -0,0 +1,32 @@ +package com.mhd.bms.domain.feeAnalysisReport.repository.mapper; + +import com.mhd.bms.domain.feeAnalysisReport.repository.po.FeeSubjectMonthStatPO; +import org.apache.ibatis.annotations.Param; +import java.util.List; + +/** + * 费用统计分析报表Mapper + * + * @author rcx + * @date 2026-08-17 + */ +public interface FeeAnalysisReportMapper { + + /** 按科目(一级+二级)×年月汇总费用金额(跨两个年度,区间为左闭右开 [beginDate, endDate)) */ + List sumBySubjectAndMonth(@Param("beginDate") String beginDate, + @Param("endDate") String endDate, + @Param("accountExpenseType") Integer accountExpenseType, + @Param("topOrganizationId") Long topOrganizationId, + @Param("organizationId") Long organizationId); + + /** 智能分析-按科目×客户×年月分解(两期区间,客户归因用) */ + List sumBySubjectAndCustomer(@Param("beginDate") String beginDate, + @Param("endDate") String endDate, + @Param("accountExpenseType") Integer accountExpenseType, + @Param("topOrganizationId") Long topOrganizationId, + @Param("organizationId") Long organizationId); + + /** 年份下拉(降序) */ + List selectDistinctYears(@Param("accountExpenseType") Integer accountExpenseType, + @Param("topOrganizationId") Long topOrganizationId); +} \ No newline at end of file diff --git a/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/repository/po/FeeSubjectMonthStatPO.java b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/repository/po/FeeSubjectMonthStatPO.java new file mode 100644 index 000000000..354d096ea --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/repository/po/FeeSubjectMonthStatPO.java @@ -0,0 +1,28 @@ +package com.mhd.bms.domain.feeAnalysisReport.repository.po; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import java.math.BigDecimal; + +/** 费用统计-科目×年月汇总结果对象 */ +@Data +@ApiModel("费用统计科目月度汇总对象") +public class FeeSubjectMonthStatPO { + @ApiModelProperty("一级科目code") + private String firstSubjectCode; + @ApiModelProperty("一级科目名称") + private String firstSubjectName; + @ApiModelProperty("二级科目code") + private String secondSubjectCode; + @ApiModelProperty("二级科目名称") + private String secondSubjectName; + @ApiModelProperty("统计月份 yyyy-MM") + private String statMonth; + @ApiModelProperty("客户名称(客户归因查询时使用)") + private String customerName; + @ApiModelProperty("金额合计(元)") + private BigDecimal totalAmount; + @ApiModelProperty("推送模式:SCATTERED=散租推送 / FIXED=整租推送或无法判定(由 document_form_json 判定,仅仓租类使用)") + private String pushMode; +} diff --git a/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/FeeAnalysisReportDomainService.java b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/FeeAnalysisReportDomainService.java new file mode 100644 index 000000000..f00b85e15 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/FeeAnalysisReportDomainService.java @@ -0,0 +1,297 @@ +package com.mhd.bms.domain.feeAnalysisReport.service; + +import cn.hutool.core.util.StrUtil; +import com.mhd.bms.domain.feeAnalysisReport.repository.mapper.FeeAnalysisReportMapper; +import com.mhd.bms.domain.feeAnalysisReport.repository.po.FeeSubjectMonthStatPO; +import com.mhd.bms.domain.feeAnalysisReport.service.support.FeeAnalysisCalculator; +import com.mhd.bms.domain.feeAnalysisReport.service.support.FeeAnalysisCalculator.YearQuarter; +import com.mhd.bms.domain.feeAnalysisReport.service.support.FeeSubjectCategoryResolver; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeAnalysisReportResultDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeAnalysisRowDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeQuarterDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.MonthAmountDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.YearMonth; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 费用统计分析报表领域服务:行组装(归组/排序/每行计算/合计行/占营收比) + * + * @author rcx + * @date 2026-08-17 + */ +@Service +@Slf4j +public class FeeAnalysisReportDomainService { + + /** 合计行行名 */ + private static final String TOTAL_ROW_NAME = "合计"; + + @Autowired + private FeeAnalysisReportMapper feeAnalysisReportMapper; + + /** + * 构建整张报表(六步,逐步解释见 §6.3) + * + * @param year 统计年份 Y + * @param accountExpenseType 1-收入 2-支出 + * @param topOrganizationId 一级组织(null=不过滤,总部/南光特例) + * @param organizationId 组织(null=不过滤) + */ + public FeeAnalysisReportResultDTO buildReport(int year, Integer accountExpenseType, + Long topOrganizationId, Long organizationId) { + + // ========== 步骤0:一次 SQL 取两年数据 [Y-1-01-01, Y+1-01-01) ========== + // 为什么要两年:Q1 环比基期=上年Q4、各季度同比基期=上年同季(§1.3 规则3/4) + String beginDate = (year - 1) + "-01-01"; + String endDate = (year + 1) + "-01-01"; + List stats = feeAnalysisReportMapper.sumBySubjectAndMonth( + beginDate, endDate, accountExpenseType, topOrganizationId, organizationId); + log.info("费用统计取数完成 year={}, type={}, 明细组数={}", year, accountExpenseType, stats.size()); + + // ========== 步骤1:按行键(类别|模板二级文案)归组 → Map<行键, Map<年月, 金额[元]>> ========== + Map> rowKeyMap = groupByRowKey(stats); + // 行键 → 科目信息(取首条出现的记录,用于行编码/名称与排序) + Map subjectInfoMap = collectSubjectInfo(stats); + + // ========== 步骤2:行排序 —— 类别模板顺序 → 仓储内散租/固定出租 → 当年累计(元)降序 ========== + Map currentYearTotalMap = calcCurrentYearTotal(rowKeyMap, year); + List orderedRowKeys = sortRowKeys(rowKeyMap.keySet(), subjectInfoMap, currentYearTotalMap); + + // ========== 步骤3:逐行组装(月列/季度块/累计) ========== + List rows = new ArrayList<>(orderedRowKeys.size()); + for (int i = 0; i < orderedRowKeys.size(); i++) { + String rowKey = orderedRowKeys.get(i); + FeeSubjectMonthStatPO subject = subjectInfoMap.get(rowKey); + rows.add(assembleRow(subject, rowKeyMap.get(rowKey), year, i + 1)); + } + + // ========== 步骤4:合计行(各月全科目求和后,用与明细行完全相同的方法计算) ========== + Map totalMonthMap = mergeAllRows(rowKeyMap); + FeeAnalysisRowDTO totalRow = assembleRow(null, totalMonthMap, year, null); + + // ========== 步骤5:占营收比 —— 每行自己的年累计 / 当年总累计(v1.4 行级口径) ========== + fillRevenueRatio(rows, totalRow); + + FeeAnalysisReportResultDTO result = new FeeAnalysisReportResultDTO(); + result.setYear(year); + result.setAccountExpenseType(accountExpenseType); + result.setRemark(""); // 左下角"说明",本期固定空串(需求规则2) + result.setRows(rows); + result.setTotalRow(totalRow); + return result; + } + + /** 年份下拉(透传 Mapper) */ + public List selectDistinctYears(Integer accountExpenseType, Long topOrganizationId) { + return feeAnalysisReportMapper.selectDistinctYears(accountExpenseType, topOrganizationId); + } + + // ------------------------------------------------------------------ + // 步骤1:归组 + // ------------------------------------------------------------------ + + /** + * 行键 = 报表一级(类别) + "|" + 报表二级(具体费用项)。 + * 类别由 FeeSubjectCategoryResolver 按模板映射:平级数据"一级科目名→类别",科目名降为二级; + * 双编码同名科目(2003/TYLSCCSNCZ)按名称归并仍生效;真实两级数据自动透传。 + */ + private String buildRowKey(FeeSubjectMonthStatPO po) { + return FeeSubjectCategoryResolver.firstOf(po) + "|" + FeeSubjectCategoryResolver.secondOf(po); + } + + private Map> groupByRowKey(List stats) { + Map> rowKeyMap = new LinkedHashMap<>(); + for (FeeSubjectMonthStatPO po : stats) { + String rowKey = buildRowKey(po); + BigDecimal amount = po.getTotalAmount() == null ? BigDecimal.ZERO : po.getTotalAmount(); + // merge:同名科目(双编码)或科目年中改名的金额累加到同一行,不覆盖 + rowKeyMap.computeIfAbsent(rowKey, k -> new HashMap<>(24)) + .merge(YearMonth.parse(po.getStatMonth()), amount, BigDecimal::add); + } + return rowKeyMap; + } + + private Map collectSubjectInfo(List stats) { + Map infoMap = new HashMap<>(); + for (FeeSubjectMonthStatPO po : stats) { + infoMap.putIfAbsent(buildRowKey(po), po); + } + return infoMap; + } + + // ------------------------------------------------------------------ + // 步骤2:排序 + // ------------------------------------------------------------------ + + /** 每行当年(1-12月)累计金额(元),用于组内降序排序 */ + private Map calcCurrentYearTotal(Map> rowKeyMap, + int year) { + Map map = new HashMap<>(); + rowKeyMap.forEach((rowKey, monthMap) -> map.put(rowKey, yearSumYuan(monthMap, year))); + return map; + } + + private List sortRowKeys(Set rowKeys, + Map subjectInfoMap, + Map currentYearTotalMap) { + return rowKeys.stream() + .sorted(Comparator + // 第一排序键:一级类别按模板固定顺序(仓储→配送→增值→物业→其他,未知类别沉底) + .comparingInt((String k) -> FeeSubjectCategoryResolver.categoryOrder( + FeeSubjectCategoryResolver.firstOf(subjectInfoMap.get(k)))) + // 仓储收入内:散租→固定出租(模板顺序);其他类别单行,该键并列 + .thenComparingInt(k -> FeeSubjectCategoryResolver.modeOrder( + FeeSubjectCategoryResolver.secondOf(subjectInfoMap.get(k)))) + // 同类别内当年累计(元)降序 + .thenComparing(k -> currentYearTotalMap.getOrDefault(k, BigDecimal.ZERO), + Comparator.reverseOrder())) + .collect(Collectors.toList()); + } + + // ------------------------------------------------------------------ + // 步骤3/4:单行组装(明细行与合计行共用;subject=null 表示合计行) + // ------------------------------------------------------------------ + + private FeeAnalysisRowDTO assembleRow(FeeSubjectMonthStatPO subject, + Map monthMap, + int year, Integer seq) { + FeeAnalysisRowDTO row = new FeeAnalysisRowDTO(); + row.setSeq(seq); + boolean isTotalRow = (subject == null); + row.setTotalRow(isTotalRow); + if (isTotalRow) { + row.setSubjectName(TOTAL_ROW_NAME); + } else { + // 模板两级展示:一级=类别,二级=具体费用项 + if (StrUtil.isNotBlank(subject.getSecondSubjectName())) { + // 真两级数据:原样透传 + row.setFirstSubjectCode(subject.getFirstSubjectCode()); + row.setFirstSubjectName(subject.getFirstSubjectName()); + row.setSecondSubjectCode(subject.getSecondSubjectCode()); + row.setSecondSubjectName(subject.getSecondSubjectName()); + } else { + // 平级数据:一级=类别;二级=模板文案(仓储按模式拆两行,其他类别各一行) + row.setFirstSubjectCode(null); + row.setFirstSubjectName(FeeSubjectCategoryResolver.firstOf(subject)); + String secondName = FeeSubjectCategoryResolver.secondOf(subject); + // 模板文案行由多科目合并而来,编码无意义置 null;科目行保留真实编码 + row.setSecondSubjectCode(FeeSubjectCategoryResolver.isTemplateSecond(secondName) + ? null : subject.getFirstSubjectCode()); + row.setSecondSubjectName(secondName); + } + String firstName = StrUtil.nullToEmpty(row.getFirstSubjectName()); + String secondName = StrUtil.nullToEmpty(row.getSecondSubjectName()); + row.setSubjectName(StrUtil.isBlank(secondName) ? firstName : firstName + "-" + secondName); + } + + // --- 月度列:12 个月,单位元;无任何记录 → null(前端显"—",§1.4 空月规则)--- + List months = new ArrayList<>(12); + for (int m = 1; m <= 12; m++) { + MonthAmountDTO mo = new MonthAmountDTO(); + mo.setMonth(m); + mo.setAmount(monthMap.get(YearMonth.of(year, m))); + months.add(mo); + } + row.setMonths(months); + + // --- 季度块:4 个,金额万元;基期列始终输出数值(无数据=0.00)--- + List quarters = new ArrayList<>(4); + for (int q = 1; q <= 4; q++) { + quarters.add(buildQuarter(monthMap, year, q)); + } + row.setQuarters(quarters); + + // --- 累计列:当年 12 个月求和(元) → 万元(需求规则5,按费用科目=行汇总)--- + row.setAnnualTotal(FeeAnalysisCalculator.toWan(yearSumYuan(monthMap, year))); + return row; + } + + /** 单个季度块:本季(万元) + 环比基期(万元) + 同比基期(万元) + 环比% + 同比% */ + private FeeQuarterDTO buildQuarter(Map monthMap, int year, int quarter) { + FeeQuarterDTO fq = new FeeQuarterDTO(); + fq.setQuarter(quarter); + + // 本季金额:3 个月求和(元) → 万元舍入(比率以万元舍入值为基数,§1.4) + BigDecimal current = FeeAnalysisCalculator.toWan(quarterSumYuan(monthMap, year, quarter)); + // 环比基期:Q1→上年Q4,Q2-Q4→本年上一季 + YearQuarter prev = FeeAnalysisCalculator.prevQuarter(year, quarter); + BigDecimal prevAmount = FeeAnalysisCalculator.toWan(quarterSumYuan(monthMap, prev.year, prev.quarter)); + // 同比基期:上年同季 + YearQuarter yoy = FeeAnalysisCalculator.yoyQuarter(year, quarter); + BigDecimal yoyAmount = FeeAnalysisCalculator.toWan(quarterSumYuan(monthMap, yoy.year, yoy.quarter)); + + fq.setCurrentAmount(current); + fq.setPrevQuarterAmount(prevAmount); + fq.setYoyBaseAmount(yoyAmount); + fq.setQoqRate(FeeAnalysisCalculator.growthRate(current, prevAmount)); + fq.setYoyRate(FeeAnalysisCalculator.growthRate(current, yoyAmount)); + return fq; + } + + /** 某(y,q)季度 3 个月金额求和(元);无记录的月份按 0 计(季度列始终输出数值) */ + private BigDecimal quarterSumYuan(Map monthMap, int year, int quarter) { + BigDecimal sum = BigDecimal.ZERO; + int firstMonth = FeeAnalysisCalculator.firstMonthOfQuarter(quarter); + for (int m = firstMonth; m < firstMonth + 3; m++) { + BigDecimal v = monthMap.get(YearMonth.of(year, m)); + if (v != null) { + sum = sum.add(v); + } + } + return sum; + } + + /** 当年 1-12 月金额求和(元),无记录按 0 */ + private BigDecimal yearSumYuan(Map monthMap, int year) { + BigDecimal sum = BigDecimal.ZERO; + for (int m = 1; m <= 12; m++) { + BigDecimal v = monthMap.get(YearMonth.of(year, m)); + if (v != null) { + sum = sum.add(v); + } + } + return sum; + } + + // ------------------------------------------------------------------ + // 步骤4:合计行数据源 —— 全部行键的月份金额叠加 + // ------------------------------------------------------------------ + + private Map mergeAllRows(Map> rowKeyMap) { + Map totalMonthMap = new HashMap<>(24); + // 只有真实存在记录的月份才会进入 totalMonthMap, + // 因此合计行的月列同样满足"无任何记录→null"规则 + rowKeyMap.forEach((rowKey, monthMap) -> + monthMap.forEach((ym, amount) -> + totalMonthMap.merge(ym, amount, BigDecimal::add))); + return totalMonthMap; + } + + // ------------------------------------------------------------------ + // 步骤5:占营收比(需求规则6) + // ------------------------------------------------------------------ + + /** + * 占营收比 = 每个明细行自己的当年累计【万元】 / 当年总累计【万元】 × 100 + * - 口径变更(2026-08-18):原按一级类别汇总(散租/固定出租同值 97.76%,沿用原型 88.15% 的类别口径); + * 仓储收入按计费模式拆成两行后无区分度,业务确认改为行级占比 + * - 分母 = 合计行 annualTotal(万元);总累计为 0 → 全部 null + * - 合计行固定 100.00 + */ + private void fillRevenueRatio(List rows, FeeAnalysisRowDTO totalRow) { + for (FeeAnalysisRowDTO row : rows) { + row.setRevenueRatio( + FeeAnalysisCalculator.ratioOf(row.getAnnualTotal(), totalRow.getAnnualTotal())); + } + // 合计行:总累计>0 → 100.00;否则 null(与明细行除零规则一致) + totalRow.setRevenueRatio( + totalRow.getAnnualTotal().compareTo(BigDecimal.ZERO) > 0 + ? new BigDecimal("100.00") : null); + } +} diff --git a/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeAnalysisCalculator.java b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeAnalysisCalculator.java new file mode 100644 index 000000000..cf2133d30 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeAnalysisCalculator.java @@ -0,0 +1,105 @@ +package com.mhd.bms.domain.feeAnalysisReport.service.support; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 费用统计分析计算器:万元换算 / 季度归属 / 环比同比基期切换 / 增长率 + * + * 口径依据 §1.4: + * - 月度列单位元,季度/累计列单位万元(元/10000 四舍五入 2 位); + * - 比率以【万元舍入值】为基数计算,保留 2 位小数的百分数数值; + * - 基期=0 且本期=0 → 0.00;基期=0 且本期≠0 → null(前端显"—");基期<0 → null。 + */ +public final class FeeAnalysisCalculator { + + private static final BigDecimal HUNDRED = new BigDecimal("100"); + private static final BigDecimal WAN = new BigDecimal("10000"); + + private FeeAnalysisCalculator() { + } + + /** 元 → 万元(四舍五入2位);null 视为 0。季度列/基期列/累计列/比率基数统一使用 */ + public static BigDecimal toWan(BigDecimal yuan) { + if (yuan == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + return yuan.divide(WAN, 2, RoundingMode.HALF_UP); + } + + /** 季度归属:month(1-12) → quarter(1-4)。Q1=1-3月,Q2=4-6月,Q3=7-9月,Q4=10-12月 */ + public static int quarterOf(int month) { + return (month - 1) / 3 + 1; + } + + /** 季度首月:q1→1, q2→4, q3→7, q4→10 */ + public static int firstMonthOfQuarter(int quarter) { + return (quarter - 1) * 3 + 1; + } + + /** + * 环比基期(需求规则3):Q1 → 上年第 4 季度;Q2/Q3/Q4 → 本年上一季度 + */ + public static YearQuarter prevQuarter(int year, int quarter) { + return quarter == 1 ? new YearQuarter(year - 1, 4) + : new YearQuarter(year, quarter - 1); + } + + /** + * 同比基期(需求规则4):上年同季度 + */ + public static YearQuarter yoyQuarter(int year, int quarter) { + return new YearQuarter(year - 1, quarter); + } + + /** + * 增长率(%) = (本期-基期)/基期 × 100,保留2位。 + * 三条边界(§1.4 除零规则): + * 基期 = 0 且本期 = 0 → 0.00 + * 基期 = 0 且本期 ≠ 0 → null(无法计算,前端/导出显示"—",对应原型 #DIV/0!) + * 基期 < 0 → null(负基期比率无业务意义) + */ + public static BigDecimal growthRate(BigDecimal current, BigDecimal base) { + if (base == null || base.compareTo(BigDecimal.ZERO) == 0) { + return isZero(current) ? zero2() : null; + } + if (base.compareTo(BigDecimal.ZERO) < 0) { + return null; + } + BigDecimal cur = current == null ? BigDecimal.ZERO : current; + return cur.subtract(base) + .divide(base, 6, RoundingMode.HALF_UP) + .multiply(HUNDRED) + .setScale(2, RoundingMode.HALF_UP); + } + + /** 占比(%) = part / total × 100,保留2位;total 为 null 或 0 → null(分母为零) */ + public static BigDecimal ratioOf(BigDecimal part, BigDecimal total) { + if (total == null || total.compareTo(BigDecimal.ZERO) == 0) { + return null; + } + BigDecimal p = part == null ? BigDecimal.ZERO : part; + return p.divide(total, 6, RoundingMode.HALF_UP) + .multiply(HUNDRED) + .setScale(2, RoundingMode.HALF_UP); + } + + private static boolean isZero(BigDecimal v) { + return v == null || v.compareTo(BigDecimal.ZERO) == 0; + } + + private static BigDecimal zero2() { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + + /** 年份+季度 值对象(基期表示) */ + public static class YearQuarter { + public final int year; + public final int quarter; + + public YearQuarter(int year, int quarter) { + this.year = year; + this.quarter = quarter; + } + } +} \ No newline at end of file diff --git a/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeAnalysisExcelExporter.java b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeAnalysisExcelExporter.java new file mode 100644 index 000000000..ac46a19d1 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeAnalysisExcelExporter.java @@ -0,0 +1,325 @@ +package com.mhd.bms.domain.feeAnalysisReport.service.support; + +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeAnalysisReportResultDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeAnalysisRowDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeQuarterDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.MonthAmountDTO; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.math.BigDecimal; + +@Component +@Slf4j +public class FeeAnalysisExcelExporter { + + private static final String[] CN_QUARTER = {"一", "二", "三", "四"}; + private static final String DASH = "—"; + + public void export(HttpServletResponse response, FeeAnalysisReportResultDTO data) throws IOException { + SXSSFWorkbook wb = new SXSSFWorkbook(500); + try { + SXSSFSheet sheet = wb.createSheet(data.getYear() + "年费用统计分析报表"); + + // ---- 样式准备 ---- + CellStyle titleStyle = createTitleStyle(wb); + CellStyle headerNormalStyle = createHeaderStyle(wb, IndexedColors.AUTOMATIC.getIndex()); + CellStyle headerRedStyle = createHeaderStyle(wb, IndexedColors.RED.getIndex()); + CellStyle bannerStyle = createBannerStyle(wb); + + CellStyle monthStyle = numberStyle(wb, false); + CellStyle wanStyle = numberStyle(wb, false); + CellStyle rateStyle = rateStyle(wb, false); + CellStyle textStyle = textStyle(wb, false); + + CellStyle[] boldStyles = { + numberStyle(wb, true), + numberStyle(wb, true), + rateStyle(wb, true), + textStyle(wb, true) + }; + + int year = data.getYear(); + int lastCol = 33; // AH(0-based),共 34 列:A序号 + B一级项目 + C二级项目 + 31 个数据列 + + // ================= 1. 表头(对照模板 xlsx 精确布局) ================= + + // Row 0 (第1行): 左上标题 "{year}年营业收入明细表" + Row row0 = sheet.createRow(0); + row0.setHeightInPoints(15.6f); + Cell titleCell = row0.createCell(1); + titleCell.setCellValue(year + "年营业收入明细表"); + titleCell.setCellStyle(titleStyle); + + // Row 1 (第2行): 留空占位行,匹配模板行高 + Row row1 = sheet.createRow(1); + row1.setHeightInPoints(15.6f); + + // Row 2 & Row 3 (第3、4行): 表头区域 + Row row2 = sheet.createRow(2); + Row row3 = sheet.createRow(3); + row2.setHeightInPoints(25.05f); + row3.setHeightInPoints(25.05f); + + // A3:A4 序号合并 | B3:C4 项目合并 + createMergedCell(sheet, row2, 2, 3, 0, 0, "序号", headerNormalStyle); + createMergedCell(sheet, row2, 2, 3, 1, 2, "项目", headerNormalStyle); + + // D3:AH3 横幅标题 "营业收入构成明细表(HKD)" + createMergedCell(sheet, row2, 2, 2, 3, lastCol, "营业收入构成明细表(HKD)", bannerStyle); + + // Row 3 (第4行): D~AH 具体列头 + int col = 3; + for (int q = 1; q <= 4; q++) { + for (int m = (q - 1) * 3 + 1; m <= q * 3; m++) { + createCellWithStyle(row3, col++, m + "月", headerNormalStyle); + } + createCellWithStyle(row3, col++, year + "年第" + CN_QUARTER[q - 1] + "季度(万)", headerRedStyle); + if (q == 1) { // 仅 Q1 块带有上年 Q4 环比基期列 + createCellWithStyle(row3, col++, (year - 1) + "年第四季度(万)", headerRedStyle); + } + createCellWithStyle(row3, col++, (year - 1) + "年第" + CN_QUARTER[q - 1] + "季度(万)", headerRedStyle); + createCellWithStyle(row3, col++, "第" + CN_QUARTER[q - 1] + "季度环比", headerNormalStyle); + createCellWithStyle(row3, col++, "第" + CN_QUARTER[q - 1] + "季度同比", headerNormalStyle); + } + createCellWithStyle(row3, col++, "累计(万)", headerNormalStyle); + createCellWithStyle(row3, col, "占营收比(%)", headerNormalStyle); + + // 列宽与冻结窗格(冻结前3列 + 前4表头行,即 D5 单元格) + sheet.setColumnWidth(0, (int) (6.33 * 256)); // A列 序号 + sheet.setColumnWidth(1, 14 * 256); // B列 一级项目 + sheet.setColumnWidth(2, 15 * 256); // C列 二级项目 + for (int c = 3; c <= lastCol; c++) { + sheet.setColumnWidth(c, 13 * 256); + } + sheet.createFreezePane(3, 4); // 关键修正:冻结到 D5 + + // ================= 2. 数据行 ================= + int rowIdx = 4; // 从第 5 行 (Row 4) 开始填入数据 + if (data.getRows() != null) { + for (FeeAnalysisRowDTO row : data.getRows()) { + Row excelRow = sheet.createRow(rowIdx++); + excelRow.setHeightInPoints(16.95f); + writeDataRow(excelRow, row, false, monthStyle, wanStyle, rateStyle, textStyle, null); + } + } + + // ================= 3. 合计行 ================= + if (data.getTotalRow() != null) { + Row totalExcelRow = sheet.createRow(rowIdx); + totalExcelRow.setHeightInPoints(16.95f); + // 合计行 A:C 合并 + sheet.addMergedRegion(new CellRangeAddress(rowIdx, rowIdx, 0, 2)); + writeDataRow(totalExcelRow, data.getTotalRow(), true, monthStyle, wanStyle, rateStyle, textStyle, boldStyles); + } + + wb.write(response.getOutputStream()); + } finally { + wb.dispose(); + } + } + + /** 写入单元格数据 */ + private void writeDataRow(Row row, FeeAnalysisRowDTO data, boolean bold, + CellStyle monthStyle, CellStyle wanStyle, CellStyle rateStyle, + CellStyle textStyle, CellStyle[] boldStyles) { + int c; + if (Boolean.TRUE.equals(data.getTotalRow())) { + // 合计行:A~C 已合并,只写入"合计",数据列从 D(下标 3) 开始 + writeText(row, 0, data.getSubjectName() != null ? data.getSubjectName() : "合计", pick(bold, boldStyles, 3, textStyle)); + // 为被合并的 B, C 列补充空单元格以挂载边框样式 + createCellWithStyle(row, 1, "", pick(bold, boldStyles, 3, textStyle)); + createCellWithStyle(row, 2, "", pick(bold, boldStyles, 3, textStyle)); + c = 3; + } else { + // 明细行 + writeText(row, 0, data.getSeq() == null ? DASH : String.valueOf(data.getSeq()), pick(bold, boldStyles, 3, textStyle)); + writeText(row, 1, blankToDash(data.getFirstSubjectName()), pick(bold, boldStyles, 3, textStyle)); + writeText(row, 2, blankToDash(data.getSecondSubjectName()), pick(bold, boldStyles, 3, textStyle)); + c = 3; + } + + for (int q = 1; q <= 4; q++) { + FeeQuarterDTO fq = (data.getQuarters() != null && data.getQuarters().size() >= q) ? data.getQuarters().get(q - 1) : new FeeQuarterDTO(); + for (int m = (q - 1) * 3; m < (q - 1) * 3 + 3; m++) { + MonthAmountDTO mo = (data.getMonths() != null && data.getMonths().size() > m) ? data.getMonths().get(m) : new MonthAmountDTO(); + c = writeAmount(row, c, mo.getAmount(), pick(bold, boldStyles, 0, monthStyle)); + } + c = writeAmount(row, c, fq.getCurrentAmount(), pick(bold, boldStyles, 1, wanStyle)); + if (q == 1) { + c = writeAmount(row, c, fq.getPrevQuarterAmount(), pick(bold, boldStyles, 1, wanStyle)); + } + c = writeAmount(row, c, fq.getYoyBaseAmount(), pick(bold, boldStyles, 1, wanStyle)); + c = writeAmount(row, c, fq.getQoqRate(), pick(bold, boldStyles, 2, rateStyle)); + c = writeAmount(row, c, fq.getYoyRate(), pick(bold, boldStyles, 2, rateStyle)); + } + c = writeAmount(row, c, data.getAnnualTotal(), pick(bold, boldStyles, 1, wanStyle)); + writeAmount(row, c, data.getRevenueRatio(), pick(bold, boldStyles, 2, rateStyle)); + } + + private void createMergedCell(SXSSFSheet sheet, Row row, int firstRow, int lastRow, int firstCol, int lastCol, String text, CellStyle style) { + if (firstRow != lastRow || firstCol != lastCol) { + sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, firstCol, lastCol)); + } + for (int r = firstRow; r <= lastRow; r++) { + Row currRow = sheet.getRow(r); + if (currRow == null) { + currRow = sheet.createRow(r); + } + for (int c = firstCol; c <= lastCol; c++) { + Cell cell = currRow.createCell(c); + cell.setCellStyle(style); + if (r == firstRow && c == firstCol) { + cell.setCellValue(text); + } + } + } + } + + private void createCellWithStyle(Row row, int col, String text, CellStyle style) { + Cell cell = row.createCell(col); + cell.setCellValue(text); + cell.setCellStyle(style); + } + + private int writeAmount(Row row, int col, BigDecimal val, CellStyle style) { + Cell cell = row.createCell(col); + if (val == null) { + cell.setCellValue(DASH); + } else { + cell.setCellValue(val.doubleValue()); + } + cell.setCellStyle(style); + return col + 1; + } + + private String blankToDash(String val) { + return val == null || val.trim().isEmpty() ? DASH : val; + } + + private int writeText(Row row, int col, String val, CellStyle style) { + Cell cell = row.createCell(col); + cell.setCellValue(val == null ? DASH : val); + cell.setCellStyle(style); + return col + 1; + } + + private CellStyle pick(boolean bold, CellStyle[] boldStyles, int idx, CellStyle normal) { + return bold ? boldStyles[idx] : normal; + } + + // ---------------- 样式定义 ---------------- + + private CellStyle createTitleStyle(Workbook wb) { + CellStyle s = wb.createCellStyle(); + Font f = wb.createFont(); + f.setFontName("等线"); + f.setFontHeightInPoints((short) 12); + f.setBold(true); + s.setFont(f); + s.setAlignment(HorizontalAlignment.LEFT); + s.setVerticalAlignment(VerticalAlignment.CENTER); + return s; + } + + private CellStyle createBannerStyle(Workbook wb) { + CellStyle s = wb.createCellStyle(); + + Font f = wb.createFont(); + f.setFontName("等线"); + f.setFontHeightInPoints((short) 18); + f.setBold(true); + + s.setFont(f); + s.setAlignment(HorizontalAlignment.CENTER); + s.setVerticalAlignment(VerticalAlignment.CENTER); + + s.setFillForegroundColor(IndexedColors.LIGHT_YELLOW.getIndex()); + s.setFillPattern(FillPatternType.SOLID_FOREGROUND); + + applyBorders(s); + return s; + } + + private CellStyle createHeaderStyle(Workbook wb, short fontColor) { + CellStyle s = wb.createCellStyle(); + Font f = wb.createFont(); + f.setFontName("等线"); + f.setFontHeightInPoints((short) 9); + f.setBold(true); + f.setColor(fontColor); + s.setFont(f); + s.setAlignment(HorizontalAlignment.CENTER); + s.setVerticalAlignment(VerticalAlignment.CENTER); + s.setWrapText(true); + + s.setFillForegroundColor(IndexedColors.LIGHT_YELLOW.getIndex()); + s.setFillPattern(FillPatternType.SOLID_FOREGROUND); + + applyBorders(s); + return s; + } + + private CellStyle numberStyle(Workbook wb, boolean bold) { + CellStyle s = wb.createCellStyle(); + s.setAlignment(HorizontalAlignment.RIGHT); + s.setVerticalAlignment(VerticalAlignment.CENTER); + s.setDataFormat(wb.createDataFormat().getFormat("#,##0.00")); + applyBorders(s); + + Font f = wb.createFont(); + f.setFontName("等线"); + f.setFontHeightInPoints((short) 11); + if (bold) { + f.setBold(true); + } + s.setFont(f); + return s; + } + + private CellStyle rateStyle(Workbook wb, boolean bold) { + CellStyle s = wb.createCellStyle(); + s.setAlignment(HorizontalAlignment.RIGHT); + s.setVerticalAlignment(VerticalAlignment.CENTER); + s.setDataFormat(wb.createDataFormat().getFormat("0.00\"%\"")); + applyBorders(s); + + Font f = wb.createFont(); + f.setFontName("等线"); + f.setFontHeightInPoints((short) 11); + if (bold) { + f.setBold(true); + } + s.setFont(f); + return s; + } + + private CellStyle textStyle(Workbook wb, boolean bold) { + CellStyle s = wb.createCellStyle(); + s.setAlignment(HorizontalAlignment.CENTER); + s.setVerticalAlignment(VerticalAlignment.CENTER); + applyBorders(s); + + Font f = wb.createFont(); + f.setFontName("等线"); + f.setFontHeightInPoints((short) 11); + if (bold) { + f.setBold(true); + } + s.setFont(f); + return s; + } + + private void applyBorders(CellStyle s) { + s.setBorderTop(BorderStyle.THIN); + s.setBorderBottom(BorderStyle.THIN); + s.setBorderLeft(BorderStyle.THIN); + s.setBorderRight(BorderStyle.THIN); + } +} \ No newline at end of file diff --git a/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeSubjectCategoryResolver.java b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeSubjectCategoryResolver.java new file mode 100644 index 000000000..b14a4cace --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/FeeSubjectCategoryResolver.java @@ -0,0 +1,130 @@ +package com.mhd.bms.domain.feeAnalysisReport.service.support; + +import cn.hutool.core.util.StrUtil; +import com.mhd.bms.domain.feeAnalysisReport.repository.po.FeeSubjectMonthStatPO; + +import java.util.*; + +/** + * 费用科目 → 报表一级类别 映射器 + * 模板《新建 Microsoft Excel 工作表.xlsx》"项目"两级结构:仓储收入/配送收入/增值服务收入/物业收入/其他。 + * 当前 billing_statement 为平级科目(一级=具体费用项、二级为空),报表展示时由本类映射出类别; + * 未来主数据两级化后(二级科目非空)自动透传真实一级,本映射无需删除。 + * + * 映射键用科目【名称】而非编码:规避 2003/TYLSCCSNCZ 双编码同名科目拆行问题。 + * 业务确认(2026-08-18):装卸费、进出仓操作费暂归"增值服务收入"。 + */ +public final class FeeSubjectCategoryResolver { + + public static final String CATEGORY_CC = "仓储收入"; + public static final String CATEGORY_PS = "配送收入"; + public static final String CATEGORY_ZZ = "增值服务收入"; + public static final String CATEGORY_WY = "物业收入"; + public static final String CATEGORY_QT = "其他"; + /** 仓储收入二级(计费模式,模板口径):散租 / 固定出租 */ + public static final String SECOND_SCATTERED = "散租"; + public static final String SECOND_FIXED = "固定出租"; + /** 配送/增值/物业/其他的模板固定二级文案(该类别所有科目合并为一行) */ + public static final String SECOND_PS = "运输"; + public static final String SECOND_ZZ = "贴标签/报关/海运、杂费"; + public static final String SECOND_WY = "水/电费/物业"; + + /** 归"散租"的科目(业务确认:仅散租仓租;其余仓租科目全部归固定出租) */ + private static final Set SCATTERED_SUBJECTS = new HashSet<>(Collections.singletonList("散租仓租")); + + /** 类别展示顺序(模板顺序),未知类别沉底 */ + private static final List CATEGORY_ORDER = Collections.unmodifiableList( + Arrays.asList(CATEGORY_CC, CATEGORY_PS, CATEGORY_ZZ, CATEGORY_WY, CATEGORY_QT)); + + /** 科目名 → 一级类别 */ + private static final Map NAME_TO_CATEGORY = new HashMap<>(); + + static { + // 仓储收入 + map(CATEGORY_CC, "恒温仓仓租", "普通仓室内仓租", "临时仓储室内仓租", + "室外空地仓租", "散租仓租", "整租仓租"); + // 配送收入 + map(CATEGORY_PS, "运费"); + // 增值服务收入 + map(CATEGORY_ZZ, "装卸费", "进出仓操作费"); + // 物业收入 + map(CATEGORY_WY, "水费", "电费"); + // 其余科目未命中兜底归"其他",无需登记 + } + + private static void map(String category, String... subjectNames) { + for (String n : subjectNames) { + NAME_TO_CATEGORY.put(n, category); + } + } + + private FeeSubjectCategoryResolver() { + } + + /** + * 报表一级(类别): + * - 数据本身已两级(二级科目名非空)→ 透传真实一级名; + * - 平级数据(当前现状)→ 科目名查映射,未命中归"其他"。 + */ + public static String firstOf(String firstSubjectName, String secondSubjectName) { + if (StrUtil.isNotBlank(secondSubjectName)) { + return StrUtil.nullToEmpty(firstSubjectName); + } + String name = StrUtil.trimToEmpty(firstSubjectName); + return name.isEmpty() ? CATEGORY_QT : NAME_TO_CATEGORY.getOrDefault(name, CATEGORY_QT); + } + + public static String firstOf(FeeSubjectMonthStatPO po) { + return firstOf(po.getFirstSubjectName(), po.getSecondSubjectName()); + } + + /** + * 报表二级(模板字面文案): + * - 真两级数据 → 透传真实二级名; + * - 仓储收入 → 计费模式:pushMode=SCATTERED→"散租"、FIXED→"固定出租", + * 无模式(防御)按科目名兜底(仅散租仓租→散租); + * - 配送/增值/物业/其他 → 固定文案一行,类别下所有科目金额合并; + * - 完全未知的科目 → "其他"行。 + */ + public static String secondOf(FeeSubjectMonthStatPO po) { + if (StrUtil.isNotBlank(po.getSecondSubjectName())) { + return StrUtil.trimToEmpty(po.getSecondSubjectName()); + } + String name = StrUtil.trimToEmpty(po.getFirstSubjectName()); + String category = NAME_TO_CATEGORY.get(name); + if (CATEGORY_CC.equals(category)) { + if ("SCATTERED".equals(po.getPushMode())) { + return SECOND_SCATTERED; + } + if ("FIXED".equals(po.getPushMode())) { + return SECOND_FIXED; + } + return SCATTERED_SUBJECTS.contains(name) ? SECOND_SCATTERED : SECOND_FIXED; + } + if (CATEGORY_PS.equals(category)) return SECOND_PS; + if (CATEGORY_ZZ.equals(category)) return SECOND_ZZ; + if (CATEGORY_WY.equals(category)) return SECOND_WY; + return CATEGORY_QT; // 未映射科目统一并入"其他"一行 + } + + /** 仓储收入内二级排序(模板顺序:散租在前);非仓储类别返回 2(并列,落回金额降序) */ + public static int modeOrder(String secondName) { + if (SECOND_SCATTERED.equals(secondName)) return 0; + if (SECOND_FIXED.equals(secondName)) return 1; + return 2; + } + + /** 是否模板固定二级文案(行由多科目合并而来,行编码无意义) */ + private static final Set TEMPLATE_SECOND_NAMES = new HashSet<>(Arrays.asList( + SECOND_SCATTERED, SECOND_FIXED, SECOND_PS, SECOND_ZZ, SECOND_WY, CATEGORY_QT)); + + public static boolean isTemplateSecond(String secondName) { + return TEMPLATE_SECOND_NAMES.contains(StrUtil.nullToEmpty(secondName)); + } + + /** 类别展示顺序号;未知类别返回 99 沉底 */ + public static int categoryOrder(String category) { + int idx = CATEGORY_ORDER.indexOf(StrUtil.nullToEmpty(category)); + return idx < 0 ? 99 : idx; + } +} diff --git a/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/IntelligentAnalysisEngine.java b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/IntelligentAnalysisEngine.java new file mode 100644 index 000000000..1239dc643 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/domain/feeAnalysisReport/service/support/IntelligentAnalysisEngine.java @@ -0,0 +1,469 @@ +package com.mhd.bms.domain.feeAnalysisReport.service.support; + +import com.mhd.bms.domain.feeAnalysisReport.repository.mapper.FeeAnalysisReportMapper; +import com.mhd.bms.domain.feeAnalysisReport.repository.po.FeeSubjectMonthStatPO; +import com.mhd.bms.domain.feeAnalysisReport.service.support.FeeAnalysisCalculator.YearQuarter; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeAnalysisReportResultDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeAnalysisRowDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeQuarterDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.IntelligentAnalysisResultDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.MonthAnalysisDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.QuarterAnalysisDTO; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.ReasonFactorDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.YearMonth; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 智能分析规则引擎:季度同比/环比归因 + 月度环比归因 + 年度汇总 + * + * @author rcx + * @date 2026-08-17 + */ +@Component +@Slf4j +public class IntelligentAnalysisEngine { + + /** 触发阈值:|环比/同比| >= 5% 的科目行才生成分析 */ + private static final BigDecimal RATE_THRESHOLD = new BigDecimal("5"); + /** 科目归因取贡献绝对值 TOP5;客户归因 TOP3 */ + private static final int SUBJECT_TOP_N = 5; + private static final int CUSTOMER_TOP_N = 3; + private static final BigDecimal HUNDRED = new BigDecimal("100"); + + @Autowired + private FeeAnalysisReportMapper feeAnalysisReportMapper; + + /** + * @param report 已构建的报表结果(复用 /query 数据保证口径一致) + * @param year / accountExpenseType / topOrganizationId / organizationId 与报表查询同参 + */ + public IntelligentAnalysisResultDTO analyze(FeeAnalysisReportResultDTO report, int year, + Integer accountExpenseType, + Long topOrganizationId, Long organizationId) { + int y = report.getYear(); + // 引擎自带一份数据:月度分析的1月基期=上年12月、客户归因需要月粒度明细,报表DTO里没带 + List stats = feeAnalysisReportMapper.sumBySubjectAndMonth( + (y - 1) + "-01-01", (y + 1) + "-01-01", + accountExpenseType, topOrganizationId, organizationId); + // 科目行名 → (年月 → 金额元);totalMap = 全科目合计 + Map> subjectMap = new LinkedHashMap<>(); + Map totalMap = new HashMap<>(); + for (FeeSubjectMonthStatPO po : stats) { + String name = buildSubjectName(po); + BigDecimal amt = po.getTotalAmount() == null ? BigDecimal.ZERO : po.getTotalAmount(); + subjectMap.computeIfAbsent(name, k -> new HashMap<>()) + .merge(YearMonth.parse(po.getStatMonth()), amt, BigDecimal::add); + totalMap.merge(YearMonth.parse(po.getStatMonth()), amt, BigDecimal::add); + } + + IntelligentAnalysisResultDTO result = new IntelligentAnalysisResultDTO(); + result.setYear(y); + result.setQuarterAnalyses(analyzeQuarters(y, report, subjectMap, totalMap, + accountExpenseType, topOrganizationId, organizationId)); + result.setMonthAnalyses(analyzeMonths(y, subjectMap, totalMap)); + result.setAnnualSummary(buildAnnualSummary(y, totalMap)); + return result; + } + + // ---------------- 季度分析(合计行必生成 + 显著科目行) ---------------- + + private List analyzeQuarters(int y, FeeAnalysisReportResultDTO report, + Map> subjectMap, + Map totalMap, + Integer type, Long topOrgId, Long orgId) { + List list = new ArrayList<>(); + for (int q = 1; q <= 4; q++) { + // 每季度两个维度:QOQ 环比 / YOY 同比(合计行必生成) + list.add(analyzeQuarterTotal(y, q, "QOQ", totalMap, subjectMap, type, topOrgId, orgId)); + list.add(analyzeQuarterTotal(y, q, "YOY", totalMap, subjectMap, type, topOrgId, orgId)); + // 显著科目行(|率|>=5%) + for (FeeAnalysisRowDTO row : report.getRows()) { + BigDecimal qoq = rateOfRow(row, q, "QOQ"); + BigDecimal yoy = rateOfRow(row, q, "YOY"); + if (needAnalyze(qoq)) list.add(analyzeSubjectRow(y, q, "QOQ", qoq, row, type, topOrgId, orgId)); + if (needAnalyze(yoy)) list.add(analyzeSubjectRow(y, q, "YOY", yoy, row, type, topOrgId, orgId)); + } + } + return list; + } + + private boolean needAnalyze(BigDecimal rate) { + return rate != null && rate.abs().compareTo(RATE_THRESHOLD) >= 0; + } + + private BigDecimal rateOfRow(FeeAnalysisRowDTO row, int quarter, String rateType) { + return "QOQ".equals(rateType) + ? row.getQuarters().get(quarter - 1).getQoqRate() + : row.getQuarters().get(quarter - 1).getYoyRate(); + } + + /** 合计行季度归因:科目TOP5贡献 + 客户TOP3贡献 + 模板文案 */ + private QuarterAnalysisDTO analyzeQuarterTotal(int y, int q, String rateType, + Map totalMap, + Map> subjectMap, + Integer type, Long topOrgId, Long orgId) { + YearQuarter cur = new YearQuarter(y, q); + YearQuarter base = "QOQ".equals(rateType) + ? FeeAnalysisCalculator.prevQuarter(y, q) : FeeAnalysisCalculator.yoyQuarter(y, q); + + BigDecimal current = FeeAnalysisCalculator.toWan(quarterSum(totalMap, cur)); + BigDecimal baseAmount = FeeAnalysisCalculator.toWan(quarterSum(totalMap, base)); + BigDecimal rate = FeeAnalysisCalculator.growthRate(current, baseAmount); + BigDecimal delta = current.subtract(baseAmount); + + // ① 科目因子:每科目两期金额差 → 贡献度,按 |delta| 降序 TOP5 + Map curByFactor = new HashMap<>(); + Map baseByFactor = new HashMap<>(); + subjectMap.forEach((name, mm) -> { + curByFactor.put(name, quarterSum(mm, cur)); + baseByFactor.put(name, quarterSum(mm, base)); + }); + List reasons = decompose(curByFactor, baseByFactor, delta, true); + + // ② 客户因子(仅合计行):一次SQL取两期区间,按客户拆季度金额 + List customerReasons = customerTopN(cur, base, type, topOrgId, orgId, delta); + + QuarterAnalysisDTO dto = new QuarterAnalysisDTO(); + dto.setQuarter(q); + dto.setScope("TOTAL"); + dto.setSubjectName("合计"); + dto.setRateType(rateType); + dto.setCurrentAmount(current); + dto.setBaseAmount(baseAmount); + dto.setRate(rate); + dto.setDeltaAmount(delta); + dto.setLevel(levelOf(rate, baseAmount, current)); + dto.setReasons(reasons); + dto.setCustomerReasons(customerReasons); + dto.setSummary(buildQuarterTotalSummary(y, q, rateType, current, baseAmount, rate, delta, + reasons, customerReasons)); + return dto; + } + + /** + * 科目行季度归因:因子 = 该科目下的客户(谁带动了该科目增减)。 + * 金额直接取报表行 FeeQuarterDTO(万元口径),与 /query 完全一致。 + */ + private QuarterAnalysisDTO analyzeSubjectRow(int y, int q, String rateType, BigDecimal rate, + FeeAnalysisRowDTO row, + Integer type, Long topOrgId, Long orgId) { + YearQuarter cur = new YearQuarter(y, q); + YearQuarter base = "QOQ".equals(rateType) + ? FeeAnalysisCalculator.prevQuarter(y, q) : FeeAnalysisCalculator.yoyQuarter(y, q); + FeeQuarterDTO quarterDto = row.getQuarters().get(q - 1); + BigDecimal current = quarterDto.getCurrentAmount(); + BigDecimal baseAmt = "QOQ".equals(rateType) + ? quarterDto.getPrevQuarterAmount() : quarterDto.getYoyBaseAmount(); + BigDecimal delta = current.subtract(baseAmt); + String level = levelOf(rate, baseAmt, current); + + List reasons = + customerTopN(cur, base, type, topOrgId, orgId, delta, row.getSubjectName()); + + QuarterAnalysisDTO dto = new QuarterAnalysisDTO(); + dto.setQuarter(q); + dto.setScope("SUBJECT"); + dto.setSubjectName(row.getSubjectName()); + dto.setRateType(rateType); + dto.setCurrentAmount(current); + dto.setBaseAmount(baseAmt); + dto.setRate(rate); + dto.setDeltaAmount(delta); + dto.setLevel(level); + dto.setReasons(reasons); + dto.setSummary(String.format( + "%d年第%d季度【%s】收入%s万元,%s%s,主要贡献客户:%s。", + y, q, row.getSubjectName(), current.toPlainString(), + "QOQ".equals(rateType) ? (q == 1 ? "环比(对上年第4季度)" : "环比") : "同比", + changeDesc(level, rate, delta), + joinTopFactors(dto.getReasons()))); + return dto; + } + + // ---------------- 月度分析(合计行月环比,1月基期=上年12月) ---------------- + + private List analyzeMonths(int y, + Map> subjectMap, + Map totalMap) { + List list = new ArrayList<>(); + for (int m = 1; m <= 12; m++) { + YearMonth curYm = YearMonth.of(y, m); + YearMonth prevYm = (m == 1) ? YearMonth.of(y - 1, 12) : YearMonth.of(y, m - 1); + BigDecimal cur = totalMap.get(curYm); + BigDecimal prev = totalMap.get(prevYm); + if (cur == null && prev == null) { + continue; // 本月与上月都无记录,不生成 + } + BigDecimal curV = cur == null ? BigDecimal.ZERO : cur; + BigDecimal prevV = prev == null ? BigDecimal.ZERO : prev; + BigDecimal momRate = FeeAnalysisCalculator.growthRate(curV, prevV); + BigDecimal delta = curV.subtract(prevV); + + Map curBy = new HashMap<>(); + Map baseBy = new HashMap<>(); + subjectMap.forEach((name, mm) -> { + curBy.put(name, mm.getOrDefault(curYm, BigDecimal.ZERO)); + baseBy.put(name, mm.getOrDefault(prevYm, BigDecimal.ZERO)); + }); + List reasons = decompose(curBy, baseBy, delta, false); + + MonthAnalysisDTO dto = new MonthAnalysisDTO(); + dto.setMonth(m); + dto.setScope("TOTAL"); + dto.setCurrentAmount(curV); + dto.setPrevMonthAmount(prevV); + dto.setMomRate(momRate); + String level = levelOf(momRate, prevV, curV); + dto.setLevel(level); + dto.setReasons(reasons); + ReasonFactorDTO top = reasons.isEmpty() ? null : reasons.get(0); + String change = "新增".equals(level) + ? "新增(上月无数据)" + : String.format("%s%s", level, + momRate == null ? "" : String.format("%.2f%%", momRate.abs())); + dto.setSummary(String.format( + "%d月收入合计%s元,环比%s月%s,主要由于【%s】%s%s元%s。", + m, curV.toPlainString(), prevYm.getMonthValue(), change, + top == null ? "—" : top.getFactor(), + top == null || top.getDeltaAmount().signum() >= 0 ? "增加" : "减少", + top == null ? "0" : top.getDeltaAmount().abs().toPlainString(), + (top == null || top.getContribution() == null) ? "" + : String.format("(贡献%.2f%%)", top.getContribution()))); + list.add(dto); + } + return list; + } + + // ---------------- 年度汇总 ---------------- + + private String buildAnnualSummary(int y, Map totalMap) { + int maxMonth = 0; + BigDecimal annual = BigDecimal.ZERO; + for (int m = 1; m <= 12; m++) { + BigDecimal v = totalMap.get(YearMonth.of(y, m)); + if (v != null) { + maxMonth = m; + annual = annual.add(v); + } + } + if (maxMonth == 0) { + return y + "年暂无收入数据。"; + } + // 上年同期累计(1~maxMonth),保证同比可比 + BigDecimal lastYearSamePeriod = BigDecimal.ZERO; + for (int m = 1; m <= maxMonth; m++) { + BigDecimal v = totalMap.get(YearMonth.of(y - 1, m)); + if (v != null) { + lastYearSamePeriod = lastYearSamePeriod.add(v); + } + } + BigDecimal yoy = FeeAnalysisCalculator.growthRate( + FeeAnalysisCalculator.toWan(annual), FeeAnalysisCalculator.toWan(lastYearSamePeriod)); + return String.format("截至%d月,%d年累计收入%s万元;同比%s。", + maxMonth, y, FeeAnalysisCalculator.toWan(annual).toPlainString(), + yoy == null ? "无同期数据" + : String.format("%s%.2f%%", yoy.signum() >= 0 ? "增长" : "下降", yoy.abs())); + } + + // ---------------- 通用:贡献度分解 / 等级 / 客户归因 ---------------- + + /** + * 贡献度分解:factorDelta 与 totalDelta 必须同口径后再相除。 + * isWan=true(季度分析)时分子先按万元舍入,与分母(万元)一致 —— 修复"元/万元"混算导致贡献度放大 10000 倍的问题; + * 舍入后为 0 的因子不输出(避免"增加0.00万元,贡献xx%"的噪音行); + * totalDelta 为 0(或 null)→ contribution 输出 null;按 |factorDelta| 降序取 TOP N。 + */ + private List decompose(Map curBy, Map baseBy, + BigDecimal totalDelta, boolean isWan) { + BigDecimal total = totalDelta == null ? BigDecimal.ZERO : totalDelta; + List list = new ArrayList<>(); + for (String factor : curBy.keySet()) { + BigDecimal c = curBy.getOrDefault(factor, BigDecimal.ZERO); + BigDecimal b = baseBy.getOrDefault(factor, BigDecimal.ZERO); + BigDecimal d = isWan ? FeeAnalysisCalculator.toWan(c.subtract(b)) : c.subtract(b); + if (d.signum() == 0) { + continue; + } + ReasonFactorDTO r = new ReasonFactorDTO(); + r.setFactor(factor); + r.setDeltaAmount(d); + r.setContribution(total.signum() == 0 ? null + : d.divide(total, 6, RoundingMode.HALF_UP) + .multiply(HUNDRED).setScale(2, RoundingMode.HALF_UP)); + r.setDescription(String.format("%s收入%s%s%s%s", + factor, d.signum() >= 0 ? "增加" : "减少", + d.abs().toPlainString(), isWan ? "万元" : "元", + r.getContribution() == null ? "" + : String.format(",对整体变动贡献%s%%(%s)", r.getContribution().toPlainString(), + d.signum() == total.signum() ? "正向推动" : "反向抵消"))); + list.add(r); + } + return list.stream() + .sorted(Comparator.comparing((ReasonFactorDTO r) -> r.getDeltaAmount().abs()).reversed()) + .limit(SUBJECT_TOP_N) + .collect(Collectors.toList()); + } + + /** 客户归因(合计行):全部科目的客户 TOP3 */ + private List customerTopN(YearQuarter cur, YearQuarter base, + Integer type, Long topOrgId, Long orgId, + BigDecimal totalDelta) { + return customerTopN(cur, base, type, topOrgId, orgId, totalDelta, null); + } + + /** + * 客户归因:一次 SQL 取两期月份并集区间,按客户分别汇总两期金额(失败降级为空,不影响主分析)。 + * subjectNameFilter 非空时仅统计该科目下的客户(科目行归因),否则统计全部科目(合计行归因)。 + * 区间修正:early 取"年/季更早"的一期 —— 原实现只比较年份,Q2~Q4 同年环比时基期季度被排除在区间外, + * 导致客户基期恒为 0、delta 恒等于本期金额。 + */ + private List customerTopN(YearQuarter cur, YearQuarter base, + Integer type, Long topOrgId, Long orgId, + BigDecimal totalDelta, String subjectNameFilter) { + try { + YearQuarter early = isBefore(base, cur) ? base : cur; + YearQuarter late = isBefore(base, cur) ? cur : base; + String begin = String.format("%d-%02d-01", early.year, + FeeAnalysisCalculator.firstMonthOfQuarter(early.quarter)); + String end = late.quarter == 4 ? (late.year + 1) + "-01-01" + : String.format("%d-%02d-01", late.year, + FeeAnalysisCalculator.firstMonthOfQuarter(late.quarter + 1)); + + List rows = feeAnalysisReportMapper.sumBySubjectAndCustomer( + begin, end, type, topOrgId, orgId); + Map curByCust = new HashMap<>(); + Map baseByCust = new HashMap<>(); + for (FeeSubjectMonthStatPO po : rows) { + if (subjectNameFilter != null && !subjectNameFilter.equals(buildSubjectName(po))) { + continue; + } + YearMonth ym = YearMonth.parse(po.getStatMonth()); + BigDecimal amt = po.getTotalAmount() == null ? BigDecimal.ZERO : po.getTotalAmount(); + String cust = po.getCustomerName() == null ? "未知客户" : po.getCustomerName(); + if (ym.getYear() == cur.year + && FeeAnalysisCalculator.quarterOf(ym.getMonthValue()) == cur.quarter) { + curByCust.merge(cust, amt, BigDecimal::add); + } else if (ym.getYear() == base.year + && FeeAnalysisCalculator.quarterOf(ym.getMonthValue()) == base.quarter) { + baseByCust.merge(cust, amt, BigDecimal::add); + } + } + return decompose(curByCust, baseByCust, totalDelta, true).stream() + .limit(CUSTOMER_TOP_N).collect(Collectors.toList()); + } catch (Exception e) { + log.warn("客户归因查询失败,跳过(不影响主分析): {}", e.getMessage()); + return Collections.emptyList(); + } + } + + private boolean isBefore(YearQuarter a, YearQuarter b) { + return a.year < b.year || (a.year == b.year && a.quarter < b.quarter); + } + + /** + * 等级判定(§7.2 规则): + * 率非空:≥50 大幅增长 / ≥5 增长 / ≤-50 大幅下降 / ≤-5 下降 / 其余 持平; + * 率为 null:基期<0 → 数据异常;基期=0 且本期≠0 → 新增(上期无数据,比率不可算,不再误标"持平")。 + */ + private String levelOf(BigDecimal rate, BigDecimal base, BigDecimal current) { + if (rate == null) { + if (base != null && base.signum() < 0) { + return "数据异常"; + } + return (current != null && current.signum() != 0) ? "新增" : "持平"; + } + if (rate.compareTo(new BigDecimal("50")) >= 0) return "大幅增长"; + if (rate.compareTo(RATE_THRESHOLD) >= 0) return "增长"; + if (rate.compareTo(new BigDecimal("-50")) <= 0) return "大幅下降"; + if (rate.compareTo(RATE_THRESHOLD.negate()) <= 0) return "下降"; + return "持平"; + } + + /** 变动描述:常规"等级+比率+增减额";新增(上期无数据)与基期为负两种特殊口径单独措辞 */ + private String changeDesc(String level, BigDecimal rate, BigDecimal delta) { + if ("新增".equals(level)) { + return String.format("新增(上期无数据,%s%s万元)", + delta.signum() >= 0 ? "增加" : "减少", delta.abs().toPlainString()); + } + if ("数据异常".equals(level)) { + return "数据异常(基期为负,比率无意义)"; + } + return String.format("%s%s(%s%s万元)", level, + rate == null ? "" : String.format("%.2f%%", rate.abs()), + delta.signum() >= 0 ? "增加" : "减少", delta.abs().toPlainString()); + } + + private String buildQuarterTotalSummary(int y, int q, String rateType, + BigDecimal current, BigDecimal base, BigDecimal rate, BigDecimal delta, + List reasons, + List customerReasons) { + String baseDesc = "YOY".equals(rateType) ? "同比" + : (q == 1 ? "环比(对上年第4季度)" : "环比"); + StringBuilder sb = new StringBuilder(); + sb.append(String.format("%d年第%d季度收入合计%s万元,%s%s", + y, q, current.toPlainString(), baseDesc, + changeDesc(levelOf(rate, base, current), rate, delta))); + if (reasons != null && !reasons.isEmpty()) { + ReasonFactorDTO top = reasons.get(0); + sb.append(String.format(",主要由【%s】驱动(%s%s万元,贡献%s%%)", + top.getFactor(), + top.getDeltaAmount().signum() >= 0 ? "增" : "减", + top.getDeltaAmount().abs().toPlainString(), + top.getContribution() == null ? "—" : top.getContribution().abs().toPlainString())); + } + if (customerReasons != null && !customerReasons.isEmpty()) { + ReasonFactorDTO c = customerReasons.get(0); + sb.append(String.format(";其中客户【%s】%s%s万元", + c.getFactor(), c.getDeltaAmount().signum() >= 0 ? "增加" : "减少", + c.getDeltaAmount().abs().toPlainString())); + } + sb.append("。"); + return sb.toString(); + } + + private String joinTopFactors(List reasons) { + if (reasons == null || reasons.isEmpty()) { + return "无显著变动客户"; + } + return reasons.stream() + .limit(2) + .map(r -> String.format("%s(%s%%)", + r.getFactor(), r.getContribution() == null ? "—" : r.getContribution().toPlainString())) + .collect(Collectors.joining("、")); + } + + /** + * 科目行名(与 FeeAnalysisReportDomainService 报表行 subjectName 一致,客户归因过滤依赖相等): + * 二级非空 → "一级-二级";平级数据 → "类别-科目名"(经 FeeSubjectCategoryResolver 映射)。 + */ + private String buildSubjectName(FeeSubjectMonthStatPO po) { + String first = po.getFirstSubjectName() == null ? "" : po.getFirstSubjectName().trim(); + String second = po.getSecondSubjectName() == null ? "" : po.getSecondSubjectName().trim(); + if (first.isEmpty() && second.isEmpty()) { + return ""; + } + if (!second.isEmpty()) { + return first + "-" + second; + } + return FeeSubjectCategoryResolver.firstOf(po) + "-" + FeeSubjectCategoryResolver.secondOf(po); + } + + private BigDecimal quarterSum(Map monthMap, YearQuarter yq) { + BigDecimal sum = BigDecimal.ZERO; + int firstMonth = FeeAnalysisCalculator.firstMonthOfQuarter(yq.quarter); + for (int m = firstMonth; m < firstMonth + 3; m++) { + BigDecimal v = monthMap.get(YearMonth.of(yq.year, m)); + if (v != null) { + sum = sum.add(v); + } + } + return sum; + } +} diff --git a/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisReportQueryDTO.java b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisReportQueryDTO.java new file mode 100644 index 000000000..372cc3dfb --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisReportQueryDTO.java @@ -0,0 +1,24 @@ +// ---------- FeeAnalysisReportQueryDTO.java ---------- +package com.mhd.bms.interfaces.dto.feeAnalysisReport; + +import com.mhd.common.core.web.domain.BaseVOEntity; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +/** 费用统计分析报表查询入参 */ +@Data +public class FeeAnalysisReportQueryDTO extends BaseVOEntity { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("统计年份,默认当前年(1900~2100)") + private Integer year; + + @ApiModelProperty("收支方向:1-收入(默认) 2-支出") + private Integer accountExpenseType; + + @ApiModelProperty("组织ID(可选;越权传值将被后端强制纠正,见 §6.5)") + private Long organizationId; + + @ApiModelProperty("一级组织ID(后端从登录态注入,前端传值无效)") + private Long topOrganizationId; +} \ No newline at end of file diff --git a/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisReportResultDTO.java b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisReportResultDTO.java new file mode 100644 index 000000000..cb043c0a0 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisReportResultDTO.java @@ -0,0 +1,21 @@ +// ---------- FeeAnalysisReportResultDTO.java ---------- +package com.mhd.bms.interfaces.dto.feeAnalysisReport; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import java.util.List; + +/** 报表整体出参 */ +@Data +public class FeeAnalysisReportResultDTO { + @ApiModelProperty("统计年份") + private Integer year; + @ApiModelProperty("收支方向 1收入 2支出") + private Integer accountExpenseType; + @ApiModelProperty("左下角说明(本期固定空串)") + private String remark; + @ApiModelProperty("明细行(费用科目行)") + private List rows; + @ApiModelProperty("合计行") + private FeeAnalysisRowDTO totalRow; +} diff --git a/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisRowDTO.java b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisRowDTO.java new file mode 100644 index 000000000..2bfc15524 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeAnalysisRowDTO.java @@ -0,0 +1,34 @@ +// ---------- FeeAnalysisRowDTO.java ---------- +package com.mhd.bms.interfaces.dto.feeAnalysisReport; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import java.math.BigDecimal; +import java.util.List; + +/** 报表行(明细行与合计行同构) */ +@Data +public class FeeAnalysisRowDTO { + @ApiModelProperty("序号(合计行为 null)") + private Integer seq; + @ApiModelProperty("一级科目code") + private String firstSubjectCode; + @ApiModelProperty("一级科目名称(=费用类别)") + private String firstSubjectName; + @ApiModelProperty("二级科目code") + private String secondSubjectCode; + @ApiModelProperty("二级科目名称") + private String secondSubjectName; + @ApiModelProperty("行名:一级-二级(二级空时=一级名;合计行=“合计”)") + private String subjectName; + @ApiModelProperty("12个月金额,单位元;null=该月无记录(显示“—”)") + private List months; + @ApiModelProperty("4个季度块") + private List quarters; + @ApiModelProperty("累计(万元)") + private BigDecimal annualTotal; + @ApiModelProperty("占营收比(%);null=分母为0") + private BigDecimal revenueRatio; + @ApiModelProperty("是否合计行(导出加粗用)") + private Boolean totalRow; +} diff --git a/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeQuarterDTO.java b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeQuarterDTO.java new file mode 100644 index 000000000..f7c815f00 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/FeeQuarterDTO.java @@ -0,0 +1,23 @@ +// ---------- FeeQuarterDTO.java ---------- +package com.mhd.bms.interfaces.dto.feeAnalysisReport; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import java.math.BigDecimal; + +/** 季度块(金额单位万元,比率单位%) */ +@Data +public class FeeQuarterDTO { + @ApiModelProperty("季度 1-4") + private Integer quarter; + @ApiModelProperty("本季总额(万元)") + private BigDecimal currentAmount; + @ApiModelProperty("环比基期(万元):Q1=上年Q4,Q2-Q4=本年上一季") + private BigDecimal prevQuarterAmount; + @ApiModelProperty("同比基期=上年同季(万元)") + private BigDecimal yoyBaseAmount; + @ApiModelProperty("环比%;null=基期为0或负") + private BigDecimal qoqRate; + @ApiModelProperty("同比%;null=基期为0或负") + private BigDecimal yoyRate; +} \ No newline at end of file diff --git a/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/IntelligentAnalysisResultDTO.java b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/IntelligentAnalysisResultDTO.java new file mode 100644 index 000000000..0d38f9582 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/IntelligentAnalysisResultDTO.java @@ -0,0 +1,19 @@ +// ---------- IntelligentAnalysisResultDTO.java ---------- +package com.mhd.bms.interfaces.dto.feeAnalysisReport; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import java.util.List; + +/** 智能分析出参 */ +@Data +public class IntelligentAnalysisResultDTO { + @ApiModelProperty("年份") + private Integer year; + @ApiModelProperty("季度归因列表(合计行必生成 + |率|>=5%科目行,各含环比/同比)") + private List quarterAnalyses; + @ApiModelProperty("月度归因列表(合计行月环比,1月基期=上年12月)") + private List monthAnalyses; + @ApiModelProperty("年度汇总文案") + private String annualSummary; +} \ No newline at end of file diff --git a/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/MonthAmountDTO.java b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/MonthAmountDTO.java new file mode 100644 index 000000000..84babfa39 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/MonthAmountDTO.java @@ -0,0 +1,15 @@ +// ---------- MonthAmountDTO.java ---------- +package com.mhd.bms.interfaces.dto.feeAnalysisReport; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import java.math.BigDecimal; + +/** 月度单元格 */ +@Data +public class MonthAmountDTO { + @ApiModelProperty("月份 1-12") + private Integer month; + @ApiModelProperty("金额(元);null=无记录") + private BigDecimal amount; +} \ No newline at end of file diff --git a/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/MonthAnalysisDTO.java b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/MonthAnalysisDTO.java new file mode 100644 index 000000000..34e47e106 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/MonthAnalysisDTO.java @@ -0,0 +1,28 @@ +// ---------- MonthAnalysisDTO.java ---------- +package com.mhd.bms.interfaces.dto.feeAnalysisReport; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import java.math.BigDecimal; +import java.util.List; + +/** 月度归因项(单位:元,与月列一致) */ +@Data +public class MonthAnalysisDTO { + @ApiModelProperty("月份 1-12") + private Integer month; + @ApiModelProperty("TOTAL=合计行") + private String scope; + @ApiModelProperty("本月金额(元)") + private BigDecimal currentAmount; + @ApiModelProperty("上月金额(元);1月=上年12月") + private BigDecimal prevMonthAmount; + @ApiModelProperty("环比%(元基数)") + private BigDecimal momRate; + @ApiModelProperty("等级") + private String level; + @ApiModelProperty("科目贡献因子 TOP5") + private List reasons; + @ApiModelProperty("智能分析结论文案") + private String summary; +} \ No newline at end of file diff --git a/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/QuarterAnalysisDTO.java b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/QuarterAnalysisDTO.java new file mode 100644 index 000000000..f04d9f236 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/QuarterAnalysisDTO.java @@ -0,0 +1,36 @@ +// ---------- QuarterAnalysisDTO.java ---------- +package com.mhd.bms.interfaces.dto.feeAnalysisReport; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import java.math.BigDecimal; +import java.util.List; + +/** 季度归因项 */ +@Data +public class QuarterAnalysisDTO { + @ApiModelProperty("季度 1-4") + private Integer quarter; + @ApiModelProperty("TOTAL=合计行 / SUBJECT=科目行") + private String scope; + @ApiModelProperty("科目名(合计行=“合计”)") + private String subjectName; + @ApiModelProperty("QOQ=环比 / YOY=同比") + private String rateType; + @ApiModelProperty("本期(万元)") + private BigDecimal currentAmount; + @ApiModelProperty("基期(万元)") + private BigDecimal baseAmount; + @ApiModelProperty("比率(%)") + private BigDecimal rate; + @ApiModelProperty("等级:大幅增长/增长/持平/下降/大幅下降") + private String level; + @ApiModelProperty("增减额(万元)") + private BigDecimal deltaAmount; + @ApiModelProperty("科目贡献因子 TOP5") + private List reasons; + @ApiModelProperty("客户贡献因子 TOP3(仅合计行)") + private List customerReasons; + @ApiModelProperty("智能分析结论文案") + private String summary; +} \ No newline at end of file diff --git a/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/ReasonFactorDTO.java b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/ReasonFactorDTO.java new file mode 100644 index 000000000..70fb20474 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/interfaces/dto/feeAnalysisReport/ReasonFactorDTO.java @@ -0,0 +1,19 @@ +// ---------- ReasonFactorDTO.java ---------- +package com.mhd.bms.interfaces.dto.feeAnalysisReport; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import java.math.BigDecimal; + +/** 贡献因子 */ +@Data +public class ReasonFactorDTO { + @ApiModelProperty("因子名(科目行名/客户名)") + private String factor; + @ApiModelProperty("变动额(季度分析=万元 / 月度分析=元)") + private BigDecimal deltaAmount; + @ApiModelProperty("贡献度(%=变动额/合计变动额×100);null=合计变动为0") + private BigDecimal contribution; + @ApiModelProperty("单因子描述文案") + private String description; +} \ No newline at end of file diff --git a/mhd_bms/src/main/java/com/mhd/bms/interfaces/facadeApi/FeeAnalysisReportApi.java b/mhd_bms/src/main/java/com/mhd/bms/interfaces/facadeApi/FeeAnalysisReportApi.java new file mode 100644 index 000000000..88f3b6c25 --- /dev/null +++ b/mhd_bms/src/main/java/com/mhd/bms/interfaces/facadeApi/FeeAnalysisReportApi.java @@ -0,0 +1,56 @@ +package com.mhd.bms.interfaces.facadeApi; + +import com.mhd.bms.application.server.feeAnalysisReport.FeeAnalysisReportApplicationService; +import com.mhd.bms.interfaces.dto.feeAnalysisReport.FeeAnalysisReportQueryDTO; +import com.mhd.common.core.web.controller.BaseController; +import com.mhd.common.core.web.domain.AjaxResult; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 费用统计分析报表Api + * + * @author rcx + * @date 2026-08-17 + */ +@Api(tags = "费用统计分析报表") +@RestController +@RequestMapping("/feeAnalysisReportApi") +public class FeeAnalysisReportApi extends BaseController { + + @Autowired + private FeeAnalysisReportApplicationService feeAnalysisReportApplicationService; + + /** 费用统计分析报表查询(年份可选,默认当前年) */ + @ApiOperation("费用统计分析报表查询") + @PostMapping("/query") + public AjaxResult query(@RequestBody FeeAnalysisReportQueryDTO queryDTO) { + return AjaxResult.success("操作成功", feeAnalysisReportApplicationService.query(queryDTO)); + } + + /** 智能分析:季度同比/环比增减原因 + 月度增减原因 */ + @ApiOperation("费用统计智能分析") + @GetMapping("/intelligentAnalysis") + public AjaxResult intelligentAnalysis(FeeAnalysisReportQueryDTO queryDTO) { + return AjaxResult.success("操作成功", feeAnalysisReportApplicationService.intelligentAnalysis(queryDTO)); + } + + /** 导出(响应流输出 xlsx;响应头/文件名在 ApplicationService 里设置,仿 BillManageApi.exportReceivableList) */ + @ApiOperation("导出费用统计分析报表") + @GetMapping(value = "/export", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + public void export(FeeAnalysisReportQueryDTO queryDTO, HttpServletResponse response) throws IOException { + feeAnalysisReportApplicationService.export(queryDTO, response); + } + + /** 年份下拉 */ + @ApiOperation("费用统计年份列表") + @GetMapping("/years") + public AjaxResult years(FeeAnalysisReportQueryDTO queryDTO) { + return AjaxResult.success("操作成功", feeAnalysisReportApplicationService.selectYears(queryDTO)); + } +} \ No newline at end of file diff --git a/mhd_bms/src/main/resources/mapper/FeeAnalysisReportMapper.xml b/mhd_bms/src/main/resources/mapper/FeeAnalysisReportMapper.xml new file mode 100644 index 000000000..2557e6811 --- /dev/null +++ b/mhd_bms/src/main/resources/mapper/FeeAnalysisReportMapper.xml @@ -0,0 +1,77 @@ + + + + + + + bs.DEL_FLAG = 1 + AND bs.BILLING_STATE IN (2, 3) + AND bs.RED_FLUSH_STATE = 1 + AND bs.ACCOUNT_EXPENSE_TYPE = #{accountExpenseType} + + AND bs.TOP_ORGANIZATION_ID = #{topOrganizationId} + + + AND bs.ORGANIZATION_ID = #{organizationId} + + + + + + + + + + + +