feat(bms):新增费用统计分析报表,科目按模板类别归并、仓储拆散租/固定出租两行、行级占营收比,含34列导出与智能分析归因

This commit is contained in:
rcx
2026-08-19 15:36:53 +08:00
parent dd0bf32c35
commit 83f05dd11d
19 changed files with 1893 additions and 0 deletions
@@ -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<String> 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;
}
}
@@ -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<FeeSubjectMonthStatPO> sumBySubjectAndMonth(@Param("beginDate") String beginDate,
@Param("endDate") String endDate,
@Param("accountExpenseType") Integer accountExpenseType,
@Param("topOrganizationId") Long topOrganizationId,
@Param("organizationId") Long organizationId);
/** 智能分析-按科目×客户×年月分解(两期区间,客户归因用) */
List<FeeSubjectMonthStatPO> sumBySubjectAndCustomer(@Param("beginDate") String beginDate,
@Param("endDate") String endDate,
@Param("accountExpenseType") Integer accountExpenseType,
@Param("topOrganizationId") Long topOrganizationId,
@Param("organizationId") Long organizationId);
/** 年份下拉(降序) */
List<String> selectDistinctYears(@Param("accountExpenseType") Integer accountExpenseType,
@Param("topOrganizationId") Long topOrganizationId);
}
@@ -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;
}
@@ -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<FeeSubjectMonthStatPO> stats = feeAnalysisReportMapper.sumBySubjectAndMonth(
beginDate, endDate, accountExpenseType, topOrganizationId, organizationId);
log.info("费用统计取数完成 year={}, type={}, 明细组数={}", year, accountExpenseType, stats.size());
// ========== 步骤1:按行键(类别|模板二级文案)归组 → Map<行键, Map<年月, 金额[元]>> ==========
Map<String, Map<YearMonth, BigDecimal>> rowKeyMap = groupByRowKey(stats);
// 行键 → 科目信息(取首条出现的记录,用于行编码/名称与排序)
Map<String, FeeSubjectMonthStatPO> subjectInfoMap = collectSubjectInfo(stats);
// ========== 步骤2:行排序 —— 类别模板顺序 → 仓储内散租/固定出租 → 当年累计(元)降序 ==========
Map<String, BigDecimal> currentYearTotalMap = calcCurrentYearTotal(rowKeyMap, year);
List<String> orderedRowKeys = sortRowKeys(rowKeyMap.keySet(), subjectInfoMap, currentYearTotalMap);
// ========== 步骤3:逐行组装(月列/季度块/累计) ==========
List<FeeAnalysisRowDTO> 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<YearMonth, BigDecimal> 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<String> 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<String, Map<YearMonth, BigDecimal>> groupByRowKey(List<FeeSubjectMonthStatPO> stats) {
Map<String, Map<YearMonth, BigDecimal>> 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<String, FeeSubjectMonthStatPO> collectSubjectInfo(List<FeeSubjectMonthStatPO> stats) {
Map<String, FeeSubjectMonthStatPO> infoMap = new HashMap<>();
for (FeeSubjectMonthStatPO po : stats) {
infoMap.putIfAbsent(buildRowKey(po), po);
}
return infoMap;
}
// ------------------------------------------------------------------
// 步骤2:排序
// ------------------------------------------------------------------
/** 每行当年(1-12月)累计金额(元),用于组内降序排序 */
private Map<String, BigDecimal> calcCurrentYearTotal(Map<String, Map<YearMonth, BigDecimal>> rowKeyMap,
int year) {
Map<String, BigDecimal> map = new HashMap<>();
rowKeyMap.forEach((rowKey, monthMap) -> map.put(rowKey, yearSumYuan(monthMap, year)));
return map;
}
private List<String> sortRowKeys(Set<String> rowKeys,
Map<String, FeeSubjectMonthStatPO> subjectInfoMap,
Map<String, BigDecimal> 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<YearMonth, BigDecimal> 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<MonthAmountDTO> 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<FeeQuarterDTO> 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<YearMonth, BigDecimal> 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<YearMonth, BigDecimal> 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<YearMonth, BigDecimal> 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<YearMonth, BigDecimal> mergeAllRows(Map<String, Map<YearMonth, BigDecimal>> rowKeyMap) {
Map<YearMonth, BigDecimal> 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<FeeAnalysisRowDTO> 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);
}
}
@@ -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;
}
}
}
@@ -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; // AH0-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);
}
}
@@ -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<String> SCATTERED_SUBJECTS = new HashSet<>(Collections.singletonList("散租仓租"));
/** 类别展示顺序(模板顺序),未知类别沉底 */
private static final List<String> CATEGORY_ORDER = Collections.unmodifiableList(
Arrays.asList(CATEGORY_CC, CATEGORY_PS, CATEGORY_ZZ, CATEGORY_WY, CATEGORY_QT));
/** 科目名 → 一级类别 */
private static final Map<String, String> 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<String> 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;
}
}
@@ -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<FeeSubjectMonthStatPO> stats = feeAnalysisReportMapper.sumBySubjectAndMonth(
(y - 1) + "-01-01", (y + 1) + "-01-01",
accountExpenseType, topOrganizationId, organizationId);
// 科目行名 → (年月 → 金额元);totalMap = 全科目合计
Map<String, Map<YearMonth, BigDecimal>> subjectMap = new LinkedHashMap<>();
Map<YearMonth, BigDecimal> 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<QuarterAnalysisDTO> analyzeQuarters(int y, FeeAnalysisReportResultDTO report,
Map<String, Map<YearMonth, BigDecimal>> subjectMap,
Map<YearMonth, BigDecimal> totalMap,
Integer type, Long topOrgId, Long orgId) {
List<QuarterAnalysisDTO> 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<YearMonth, BigDecimal> totalMap,
Map<String, Map<YearMonth, BigDecimal>> 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<String, BigDecimal> curByFactor = new HashMap<>();
Map<String, BigDecimal> baseByFactor = new HashMap<>();
subjectMap.forEach((name, mm) -> {
curByFactor.put(name, quarterSum(mm, cur));
baseByFactor.put(name, quarterSum(mm, base));
});
List<ReasonFactorDTO> reasons = decompose(curByFactor, baseByFactor, delta, true);
// ② 客户因子(仅合计行):一次SQL取两期区间,按客户拆季度金额
List<ReasonFactorDTO> 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<ReasonFactorDTO> 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<MonthAnalysisDTO> analyzeMonths(int y,
Map<String, Map<YearMonth, BigDecimal>> subjectMap,
Map<YearMonth, BigDecimal> totalMap) {
List<MonthAnalysisDTO> 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<String, BigDecimal> curBy = new HashMap<>();
Map<String, BigDecimal> baseBy = new HashMap<>();
subjectMap.forEach((name, mm) -> {
curBy.put(name, mm.getOrDefault(curYm, BigDecimal.ZERO));
baseBy.put(name, mm.getOrDefault(prevYm, BigDecimal.ZERO));
});
List<ReasonFactorDTO> 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<YearMonth, BigDecimal> 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<ReasonFactorDTO> decompose(Map<String, BigDecimal> curBy, Map<String, BigDecimal> baseBy,
BigDecimal totalDelta, boolean isWan) {
BigDecimal total = totalDelta == null ? BigDecimal.ZERO : totalDelta;
List<ReasonFactorDTO> 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<ReasonFactorDTO> 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<ReasonFactorDTO> 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<FeeSubjectMonthStatPO> rows = feeAnalysisReportMapper.sumBySubjectAndCustomer(
begin, end, type, topOrgId, orgId);
Map<String, BigDecimal> curByCust = new HashMap<>();
Map<String, BigDecimal> 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<ReasonFactorDTO> reasons,
List<ReasonFactorDTO> 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<ReasonFactorDTO> 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<YearMonth, BigDecimal> 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;
}
}
@@ -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;
}
@@ -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<FeeAnalysisRowDTO> rows;
@ApiModelProperty("合计行")
private FeeAnalysisRowDTO totalRow;
}
@@ -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<MonthAmountDTO> months;
@ApiModelProperty("4个季度块")
private List<FeeQuarterDTO> quarters;
@ApiModelProperty("累计(万元)")
private BigDecimal annualTotal;
@ApiModelProperty("占营收比(%);null=分母为0")
private BigDecimal revenueRatio;
@ApiModelProperty("是否合计行(导出加粗用)")
private Boolean totalRow;
}
@@ -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;
}
@@ -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<QuarterAnalysisDTO> quarterAnalyses;
@ApiModelProperty("月度归因列表(合计行月环比,1月基期=上年12月)")
private List<MonthAnalysisDTO> monthAnalyses;
@ApiModelProperty("年度汇总文案")
private String annualSummary;
}
@@ -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;
}
@@ -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<ReasonFactorDTO> reasons;
@ApiModelProperty("智能分析结论文案")
private String summary;
}
@@ -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<ReasonFactorDTO> reasons;
@ApiModelProperty("客户贡献因子 TOP3(仅合计行)")
private List<ReasonFactorDTO> customerReasons;
@ApiModelProperty("智能分析结论文案")
private String summary;
}
@@ -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;
}
@@ -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));
}
}
@@ -0,0 +1,77 @@
<?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.mhd.bms.domain.feeAnalysisReport.repository.mapper.FeeAnalysisReportMapper">
<!-- 公共统计口径:软删/已审核已出账/未红冲/收支方向/组织(组织条件由 §6.5 fillAuthScope 决定是否传入) -->
<sql id="common_stat_where">
bs.DEL_FLAG = 1
AND bs.BILLING_STATE IN (2, 3)
AND bs.RED_FLUSH_STATE = 1
AND bs.ACCOUNT_EXPENSE_TYPE = #{accountExpenseType}
<if test="topOrganizationId != null">
AND bs.TOP_ORGANIZATION_ID = #{topOrganizationId}
</if>
<if test="organizationId != null">
AND bs.ORGANIZATION_ID = #{organizationId}
</if>
</sql>
<!-- 科目×年月汇总:统计区间跨两个年度(本年+上年),季度组装在Java侧完成 -->
<select id="sumBySubjectAndMonth"
resultType="com.mhd.bms.domain.feeAnalysisReport.repository.po.FeeSubjectMonthStatPO">
SELECT
bs.FIRST_SUBJECT_CODE AS firstSubjectCode,
bs.FIRST_SUBJECT_NAME AS firstSubjectName,
bs.SECOND_SUBJECT_CODE AS secondSubjectCode,
bs.SECOND_SUBJECT_NAME AS secondSubjectName,
SUBSTR(TO_CHAR(bs.BUSINESS_DATE, 'YYYY-MM'), 1, 7) AS statMonth,
SUM(bs.BILLING_AMOUNT) AS totalAmount,
CASE WHEN bd.DOCUMENT_FORM_JSON LIKE '%"lease_area":0,%' THEN 'SCATTERED' ELSE 'FIXED' END AS pushMode
FROM BILLING_STATEMENT bs
LEFT JOIN BUSINESS_DOCUMENT bd ON bd.BUSINESS_DOCUMENT_ID = bs.BUSINESS_DOCUMENT_ID
WHERE <include refid="common_stat_where"/>
AND bs.BUSINESS_DATE &gt;= TO_DATE(#{beginDate}, 'YYYY-MM-DD')
AND bs.BUSINESS_DATE &lt; TO_DATE(#{endDate}, 'YYYY-MM-DD')
GROUP BY bs.FIRST_SUBJECT_CODE, bs.FIRST_SUBJECT_NAME,
bs.SECOND_SUBJECT_CODE, bs.SECOND_SUBJECT_NAME,
CASE WHEN bd.DOCUMENT_FORM_JSON LIKE '%"lease_area":0,%' THEN 'SCATTERED' ELSE 'FIXED' END,
SUBSTR(TO_CHAR(bs.BUSINESS_DATE, 'YYYY-MM'), 1, 7)
</select>
<!-- 智能分析:科目×客户×年月,WHERE同上(传入区间覆盖"本期季度+基期季度"两个月窗),用于客户归因 -->
<select id="sumBySubjectAndCustomer"
resultType="com.mhd.bms.domain.feeAnalysisReport.repository.po.FeeSubjectMonthStatPO">
SELECT
bs.FIRST_SUBJECT_CODE AS firstSubjectCode,
bs.FIRST_SUBJECT_NAME AS firstSubjectName,
bs.SECOND_SUBJECT_CODE AS secondSubjectCode,
bs.SECOND_SUBJECT_NAME AS secondSubjectName,
bs.SETTLEMENT_ENTITY AS customerName,
SUBSTR(TO_CHAR(bs.BUSINESS_DATE, 'YYYY-MM'), 1, 7) AS statMonth,
SUM(bs.BILLING_AMOUNT) AS totalAmount,
CASE WHEN bd.DOCUMENT_FORM_JSON LIKE '%"lease_area":0,%' THEN 'SCATTERED' ELSE 'FIXED' END AS pushMode
FROM BILLING_STATEMENT bs
LEFT JOIN BUSINESS_DOCUMENT bd ON bd.BUSINESS_DOCUMENT_ID = bs.BUSINESS_DOCUMENT_ID
WHERE <include refid="common_stat_where"/>
AND bs.BUSINESS_DATE &gt;= TO_DATE(#{beginDate}, 'YYYY-MM-DD')
AND bs.BUSINESS_DATE &lt; TO_DATE(#{endDate}, 'YYYY-MM-DD')
GROUP BY bs.FIRST_SUBJECT_CODE, bs.FIRST_SUBJECT_NAME,
bs.SECOND_SUBJECT_CODE, bs.SECOND_SUBJECT_NAME,
CASE WHEN bd.DOCUMENT_FORM_JSON LIKE '%"lease_area":0,%' THEN 'SCATTERED' ELSE 'FIXED' END,
SUBSTR(TO_CHAR(bs.BUSINESS_DATE, 'YYYY-MM'), 1, 7)
</select>
<!-- 年份下拉(降序) -->
<select id="selectDistinctYears" resultType="java.lang.String">
SELECT DISTINCT SUBSTR(TO_CHAR(bs.BUSINESS_DATE, 'YYYY'), 1, 4)
FROM BILLING_STATEMENT bs
WHERE bs.DEL_FLAG = 1
AND bs.BILLING_STATE IN (2, 3)
AND bs.RED_FLUSH_STATE = 1
AND bs.ACCOUNT_EXPENSE_TYPE = #{accountExpenseType}
<if test="topOrganizationId != null">
AND bs.TOP_ORGANIZATION_ID = #{topOrganizationId}
</if>
ORDER BY 1 DESC
</select>
</mapper>