xyc
4 天以前 922823a3f1faaf0293256e3ffb1285eaeb8a3b23
fix(holiday): 快报百分比按Excel显示精度取值
4个文件已修改
190 ■■■■■ 已修改文件
AGENTS.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayBulletinServiceTest.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
AGENTS.md
@@ -34,6 +34,10 @@
- 沙箱内 `Get-NetTCPConnection` 会因权限“拒绝访问”而静默失败,**不要**据此判定端口未监听;判断服务状态以 backend.log / frontend.log 为准。
- 用户要求:临时脚本/文件未经同意不删;导入失败要在页面显示行号;AI 分析结果要给结构化排版 + 逐字输出动画。
## 重要节假日快报与分析报告口径(2026-10-04 起)
- 快报百分比必须先按导入的 `2026年十一数据.xlsx` 对应单元格的数字格式生成显示值,再按快报统一 1 位小数取整。当前源表不是全局同一精度:多数单元格是 `0.0%`;`人员流动量汇总` 的当日环比 D/P/J、累计同比 V/W/Y/Z/AB/AC,以及水路当日环比 D 是 `0.00%`。
- 核对领导分析报告时,按该分析报告自身显示的小数位核对,不套用快报的 1 位小数口径;目标是复现人工在文档/Excel 上看到的值。
## 工作规范(2026-09-02 起;2026-09-27 按同事提交方式调整)
1. **每做完一个功能,立即测试**(测试要求不变)
HANDOFF.md
@@ -4,7 +4,28 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= 本地 commit + 推送到远端 git(origin main,Gitblit),不再只做本地 commit。
## 最新(2026-10-04)
### 10-04:快报百分比按 Excel 单元格显示精度后再取 1 位
- **用户确认的业务口径**:人工核对以 `2026年十一数据.xlsx` 单元格实际显示值为准。比如总计累计同比 2025 底层精确值为 `-4.448602%`,Excel 单元格格式为 `0.00%`,人看到并用于核对的是 `-4.45%`,再取快报 1 位小数就是 `-4.5%`。此前“直接按底层值取一位得到 -4.4%”的结论已作废。
- **实现**:`HolidayBulletinService` 新增 `bulletinPercent(...)`,严格按百分比来源单元格格式先取显示精度,再取快报 1 位小数;正文措辞与表格百分比共用同一逻辑。当前源表逐列口径:多数源单元格为 `0.0%`;`人员流动量汇总` 的当日环比 D/P/J、累计同比 V/W/Y/Z/AB/AC,以及水路当日环比 D 为 `0.00%`。
- **领导分析报告核对规则**:领导分析报告不套用快报的 1 位小数,一律按该报告自身显示的小数位核对,确保与人工在文档里看到的值一致。
- **验证**:新增/更新单元测试覆盖 `-4.448602%` 的 `-4.4%`(1 位源格式)与 `-4.5%`(2 位源格式),以及 `-0.045% -> -0.05% -> -0.1%` 边界;后端 `mvn test` 全部通过。重启 8090 后用真实库数据生成 2026-10-02 快报,正文和表格与已人工核对的模板逐项一致,总计累计同比 2025/2024 为 `-4.5% / -1.6%`。
- **本地验证产物**:`_tmp_holiday_verify/bulletin_excel_display_rounding_2026-10-02.docx`;功能测试报告与工作日结按本地留档规则写入 `docs/功能测试报告/`、`docs/工作日结/`,不入库。
- **状态**:代码和口径说明随本次提交推送;服务保持运行,访问 `http://localhost:8080`。
## 最新(2026-10-03)
### 【新对话先读】当前状态与下一步(2026-10-03 深夜写入)
- **代码状态(已推送)**:`HEAD = origin/main = f329f50`,内容为 `feat(observation): 新增观测站分日调查数据导入与明细查看`,用户已「验收通过」。工作区除本 HANDOFF.md 的交接更新(尚未提交,等用户确认)外,无其他已跟踪文件改动;仅剩未跟踪的 `_tmp_doc_compare/`、`_tmp_holiday_verify/` 和 `docs/专项统计/重要节假日/观测站分日调查数据 (2026年10月1日).xls`(过程产物与源数据,按约定保留、不入库、不删除)。`docs/专项统计/重要节假日/2026年十一数据.xlsx` 已由用户改回原名,模板下载接口实测 HTTP 200 / 36335 字节。
- **服务状态**:后端 8090、前端 8080 均在运行,访问 `http://localhost:8080`;登录 admin/admin123。数据库 `127.0.0.1:3305/traffic_audit`(root/root)。
- **库内数据**:节假日 2026-09-30(铁路/民航前一日)、2026-10-01、2026-10-02 已导入;**2026-10-03 尚未导入**。观测站 `observation_station_flow` 现有 2026-10-01(692 站)与 2026-10-02(689 站)。
- **下一步(2026-10-04 早上)**:10 月 3 日数据出来后,按既有口径导入 2026-10-03 并生成当天「湖北省国庆假期综合运输客流快报」。领导要求 8:30 前出第一版,而民航数据可能晚于 8:30 才有:民航未到就先出缺民航版本(累计同比只与往年已覆盖日期比较),民航到齐后重新导入并重新生成。完整口径与已验证结论见本文件 10-03 各段。
- **无自动化**:曾建的心跳提醒(id `10-03`)已按用户要求删除,用户 2026-10-04 明确「到时我会手工操作」。明天早上由用户手工导入 10-03 数据并生成快报,助手只在其提出时配合核对/排查。不要在未获用户明确要求时自行创建定时任务或自动写库。
- **数据发现(供参考,无需改代码)**:2026-10-02 比 10-01 少 3 个观测站——`G348L149420501` 土门(宜昌市/市辖区)、`S464L105422828` 大典河(恩施州/鹤峰县)、`S483L105420801` 屈家岭(荆门市/市辖区)。已核对两份源 Excel,确属源数据当天未上报,不是导入漏行。
- **长期待办**:见下方第七节「数值精度统一改造」(既有 16 张表、228 个 double 列改 `DECIMAL(30,15)` + Java `BigDecimal`)。
### 10-03 晚:重要节假日新模板(2019/前一日)适配
- **模板变化**:`2026年十一数据.xlsx` 的道路表增加 2019 年及同比2019;铁路、民航增加“前一日”行;快报模板增加同比2024 列。旧模板已放在 `docs/专项统计/重要节假日/备份/`。
@@ -26,6 +47,7 @@
### 10-03 深夜(2):缺当日民航数据时的累计同比对比期间修正
- **快报民航累计同比对比期间修正(2026-10-03,缺当日民航数据场景)**:当某运输方式当年累计覆盖的日期短于所选日期(典型为 10 月 2 日民航数据未出、民航累计只有 10-01),累计同比改为「只和往年已经覆盖到的日期比较」——先找当年最后一个有该指标数据的日期,再取往年同日累计。修复后缺失民航的 10 月 2 日快报民航累计同比由 `-50.5%/-49.9%` 变为 `-10.0%/-0.9%`(与手工稿 -10.03%/-0.87% 的 1 位小数值一致)。实现见 `HolidayBulletinService.comparableCumulative`(仅作用于民航行),其余行与总人员流动量口径不变。
- **「累计同比 -4.65% vs 快报 -4.6%」结论(2026-10-03,无需改代码)**:Excel 显示的 -4.65% 是**两位小数**结果;底层精确比值是 `-4.64633%`(2026 累计 3082.23818、2025 累计 3232.42742),**一次四舍五入到 1 位小数就是 -4.6%**。把已经取到两位小数的 -4.65 再取一位才得到 -4.7,属二次取整,不采用。同理 2026 对 2024 为 `-1.7710%` → `-1.8%`。快报百分比统一 1 位小数口径不变。
  - **2026-10-04 修正**:按 Excel 单元格显示精度口径,`-4.65%` 再取一位应为 `-4.7%`;以文件顶部最新段为准。
- **「暂缺民航」提示**:用户确认由人工在生成的快报稿上修改,系统不生成该提示行,**不做代码改动**。
- **验证**:`HolidayBulletinServiceTest` 3 个用例通过(新增 2 个覆盖民航累计对比期间与无数据返回 null);端到端用库内真实 2026-10-02 数据(铁路/道路/水路/车流齐全、缺民航)重新生成快报,民航累计同比 `-10.0%/-0.9%`、民航累计 `6.75`、总计累计 `3082.24`、总计累计同比 `-4.6%/-1.8%`,与手工稿口径一致。
- **Git**:本次改动待用户「验收通过」后再提交/推送。
@@ -33,7 +55,7 @@
### 10-03 深夜(3):快报累计正文单位由「亿人次」改「万人次」(待验收)
- **口径(用户/领导确认)**:假期累计数值目前偏小(0.31 亿),正文可读性差,改为与当日一致的**万人次**;例如 `假期以来,全省全社会跨区域人员流动量累计3088.63万人次`,不再输出「0.31亿人次」。表格与其余口径不变。
- **实现**:`HolidayBulletinService` 抽出 `cumulativeParagraph(...)`,直接按万人次(2 位小数)输出累计值;同比仍为「增长/下降 + 一位小数」,**保持一次取整不变**(总计累计同比 2025 精确 -4.4486% → `-4.4%`;手工稿的 -4.5% 属二次取整,不采用)。
- **实现**:`HolidayBulletinService` 抽出 `cumulativeParagraph(...)`,直接按万人次(2 位小数)输出累计值;同比仍为「增长/下降 + 一位小数」,**保持一次取整不变**(该处 2026-10-04 已修正:源单元格为 `0.00%`,Excel 显示 -4.45%,再取一位为 -4.5%;以文件顶部最新段为准)。
- **验证**:`HolidayBulletinServiceTest` 4 个用例通过(新增 `cumulativeParagraphUsesWanUnitAndSingleRounding` 固化「万人次 + 一次取整」);重启后端后重新生成 2026-10-02 快报,正文为 `累计3088.63万人次,比2025年同期下降4.4%,比2024年同期下降1.6%`,与手工稿仅差既定的取整口径;2026-10-01 回归正常(`累计1643.17万人次`)。
- **状态**:待用户「验收通过」后提交/推送。
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java
@@ -42,6 +42,8 @@
    private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
    private static final BigDecimal EPSILON = new BigDecimal("0.0000001");
    private static final int EXCEL_ONE_DECIMAL_PERCENT = 1;
    private static final int EXCEL_TWO_DECIMAL_PERCENT = 2;
    @Value("${holiday.bulletin-template:docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx}")
    private String bulletinTemplatePath;
@@ -79,12 +81,12 @@
        String currentTotal = formatNumber(current.getTotalPersonFlow());
        String ringRatioClause = previousDay == null ? "" : ",环比" + ratioPhrase(
                current.getTotalPersonFlow(), previousDay.getTotalPersonFlow());
                current.getTotalPersonFlow(), previousDay.getTotalPersonFlow(), EXCEL_TWO_DECIMAL_PERCENT);
        String paragraph1 = String.format("%d月%d日(假期第%d天),全省全社会跨区域人员流动量%s万人次%s,比2025年同期%s,比2024年同期%s。",
                date.getMonthValue(), date.getDayOfMonth(), current.getDayIndex(), currentTotal,
                ringRatioClause,
                ratioPhrase(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow()),
                ratioPhrase(current.getTotalPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow()));
                ratioPhrase(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow(), EXCEL_ONE_DECIMAL_PERCENT),
                ratioPhrase(current.getTotalPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow(), EXCEL_ONE_DECIMAL_PERCENT));
        String paragraph2 = cumulativeParagraph(currentCumulative, previousYearCumulative, twoYearsAgoCumulative);
        File template = resolveTemplateFile();
@@ -131,34 +133,46 @@
                current, twoYearsAgo, DailyMetrics::getCivilAviation);
        writeTableRow(table, 2, new String[]{
                number(current.getNonBusinessPersonFlow()),
                ratio(current.getNonBusinessPersonFlow(), previousDay == null ? null : previousDay.getNonBusinessPersonFlow()),
                ratio(current.getNonBusinessPersonFlow(), previousYear == null ? null : previousYear.getNonBusinessPersonFlow()),
                ratio(current.getNonBusinessPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getNonBusinessPersonFlow()),
                ratio(current.getNonBusinessPersonFlow(), previousDay == null ? null : previousDay.getNonBusinessPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(current.getNonBusinessPersonFlow(), previousYear == null ? null : previousYear.getNonBusinessPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current.getNonBusinessPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getNonBusinessPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                number(currentCumulative.getNonBusinessPersonFlow()),
                ratio(currentCumulative.getNonBusinessPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getNonBusinessPersonFlow()),
                        previousYearCumulative == null ? null : previousYearCumulative.getNonBusinessPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(currentCumulative.getNonBusinessPersonFlow(),
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getNonBusinessPersonFlow())
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getNonBusinessPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT)
        });
        writeTableRow(table, 3, new String[]{
                number(current.getRoad()),
                ratio(current.getRoad(), previousDay == null ? null : previousDay.getRoad()),
                ratio(current.getRoad(), previousYear == null ? null : previousYear.getRoad()),
                ratio(current.getRoad(), twoYearsAgo == null ? null : twoYearsAgo.getRoad()),
                ratio(current.getRoad(), previousDay == null ? null : previousDay.getRoad(), EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current.getRoad(), previousYear == null ? null : previousYear.getRoad(), EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current.getRoad(), twoYearsAgo == null ? null : twoYearsAgo.getRoad(), EXCEL_ONE_DECIMAL_PERCENT),
                number(currentCumulative.getRoad()),
                ratio(currentCumulative.getRoad(), previousYearCumulative == null ? null : previousYearCumulative.getRoad()),
                ratio(currentCumulative.getRoad(), twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoad())
                ratio(currentCumulative.getRoad(), previousYearCumulative == null ? null : previousYearCumulative.getRoad(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                ratio(currentCumulative.getRoad(), twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoad(),
                        EXCEL_ONE_DECIMAL_PERCENT)
        });
        writeTableRow(table, 4, new String[]{
                number(current.getRoadPersonFlow()),
                ratio(current.getRoadPersonFlow(), previousDay == null ? null : previousDay.getRoadPersonFlow()),
                ratio(current.getRoadPersonFlow(), previousYear == null ? null : previousYear.getRoadPersonFlow()),
                ratio(current.getRoadPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getRoadPersonFlow()),
                ratio(current.getRoadPersonFlow(), previousDay == null ? null : previousDay.getRoadPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(current.getRoadPersonFlow(), previousYear == null ? null : previousYear.getRoadPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current.getRoadPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getRoadPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                number(currentCumulative.getRoadPersonFlow()),
                ratio(currentCumulative.getRoadPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getRoadPersonFlow()),
                        previousYearCumulative == null ? null : previousYearCumulative.getRoadPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(currentCumulative.getRoadPersonFlow(),
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoadPersonFlow())
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoadPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT)
        });
        writeTableRow(table, 5, transportRow(current.getRail(),
                firstDayRailBase == null ? null : firstDayRailBase.getRail(),
@@ -166,41 +180,54 @@
                twoYearsAgo == null ? null : twoYearsAgo.getRail(),
                currentCumulative.getRail(),
                previousYearCumulative == null ? null : previousYearCumulative.getRail(),
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRail()));
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRail(),
                EXCEL_ONE_DECIMAL_PERCENT));
        writeTableRow(table, 6, transportRow(current.getCivilAviation(),
                firstDayCivilAviationBase == null ? null : firstDayCivilAviationBase.getCivilAviation(),
                previousYear == null ? null : previousYear.getCivilAviation(),
                twoYearsAgo == null ? null : twoYearsAgo.getCivilAviation(),
                currentCumulative.getCivilAviation(),
                previousYearCivilCumulative == null ? null : previousYearCivilCumulative.getCivilAviation(),
                twoYearsAgoCivilCumulative == null ? null : twoYearsAgoCivilCumulative.getCivilAviation()));
                twoYearsAgoCivilCumulative == null ? null : twoYearsAgoCivilCumulative.getCivilAviation(),
                EXCEL_ONE_DECIMAL_PERCENT));
        writeTableRow(table, 7, transportRow(current.getWater(),
                previousDay == null ? null : previousDay.getWater(),
                previousYear == null ? null : previousYear.getWater(),
                twoYearsAgo == null ? null : twoYearsAgo.getWater(),
                currentCumulative.getWater(),
                previousYearCumulative == null ? null : previousYearCumulative.getWater(),
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getWater()));
        writeTableRow(table, 8, transportRow(current.getTotalPersonFlow(),
                previousDay == null ? null : previousDay.getTotalPersonFlow(),
                previousYear == null ? null : previousYear.getTotalPersonFlow(),
                twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow(),
                currentCumulative.getTotalPersonFlow(),
                previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow(),
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow()));
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getWater(),
                EXCEL_TWO_DECIMAL_PERCENT));
        writeTableRow(table, 8, new String[]{
                number(current.getTotalPersonFlow()),
                ratio(current.getTotalPersonFlow(), previousDay == null ? null : previousDay.getTotalPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current.getTotalPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                number(currentCumulative.getTotalPersonFlow()),
                ratio(currentCumulative.getTotalPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(currentCumulative.getTotalPersonFlow(),
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT)
        });
    }
    private String[] transportRow(BigDecimal current, BigDecimal previousDay, BigDecimal previousYear,
                                  BigDecimal twoYearsAgo, BigDecimal currentCumulative,
                                  BigDecimal previousYearCumulative, BigDecimal twoYearsAgoCumulative) {
                                  BigDecimal previousYearCumulative, BigDecimal twoYearsAgoCumulative,
                                  int ringRatioExcelScale) {
        return new String[]{
                number(current),
                ratio(current, previousDay),
                ratio(current, previousYear),
                ratio(current, twoYearsAgo),
                ratio(current, previousDay, ringRatioExcelScale),
                ratio(current, previousYear, EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current, twoYearsAgo, EXCEL_ONE_DECIMAL_PERCENT),
                number(currentCumulative),
                ratio(currentCumulative, previousYearCumulative),
                ratio(currentCumulative, twoYearsAgoCumulative)
                ratio(currentCumulative, previousYearCumulative, EXCEL_ONE_DECIMAL_PERCENT),
                ratio(currentCumulative, twoYearsAgoCumulative, EXCEL_ONE_DECIMAL_PERCENT)
        };
    }
@@ -322,11 +349,10 @@
        return builder.toString();
    }
    private String ratioPhrase(BigDecimal current, BigDecimal base) {
        if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return "无可比数据";
        BigDecimal ratio = HolidayNumberUtils.divide(current, base);
        BigDecimal percent = HolidayNumberUtils.multiply(ratio.subtract(BigDecimal.ONE), BigDecimal.valueOf(100));
        if (percent == null || percent.abs().compareTo(EPSILON) < 0) return "持平";
    private String ratioPhrase(BigDecimal current, BigDecimal base, int excelPercentScale) {
        BigDecimal percent = bulletinPercent(current, base, excelPercentScale);
        if (percent == null) return "无可比数据";
        if (percent.signum() == 0) return "持平";
        return (percent.signum() > 0 ? "增长" : "下降") + HolidayNumberUtils.format1(percent.abs()) + "%";
    }
@@ -351,18 +377,30 @@
        BigDecimal total = currentCumulative == null ? null : currentCumulative.getTotalPersonFlow();
        return String.format("假期以来,全省全社会跨区域人员流动量累计%s万人次,比2025年同期%s,比2024年同期%s。",
                formatNumber(total),
                ratioPhrase(total, previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow()),
                ratioPhrase(total, twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow()));
                ratioPhrase(total, previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratioPhrase(total, twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT));
    }
    private String ratio(BigDecimal current, BigDecimal base) {
        if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return "—";
        BigDecimal ratio = HolidayNumberUtils.divide(current, base);
        if (ratio == null) return "—";
        BigDecimal percent = HolidayNumberUtils.multiply(ratio.subtract(BigDecimal.ONE), BigDecimal.valueOf(100));
    private String ratio(BigDecimal current, BigDecimal base, int excelPercentScale) {
        BigDecimal percent = bulletinPercent(current, base, excelPercentScale);
        if (percent == null) return "—";
        BigDecimal rounded = percent.setScale(1, RoundingMode.HALF_UP);
        return rounded.signum() == 0 ? "0%" : rounded.toPlainString() + "%";
        return percent.signum() == 0 ? "0%" : percent.toPlainString() + "%";
    }
    /**
     * Mirrors the source Excel percentage cell format, then applies the bulletin's one-decimal rule.
     * Excel stores full precision but displays 0.0% or 0.00%; reviewers compare against that display.
     */
    private BigDecimal bulletinPercent(BigDecimal current, BigDecimal base, int excelPercentScale) {
        if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return null;
        BigDecimal ratio = HolidayNumberUtils.divide(current, base);
        if (ratio == null) return null;
        BigDecimal percent = HolidayNumberUtils.multiply(ratio.subtract(BigDecimal.ONE), BigDecimal.valueOf(100));
        if (percent == null) return null;
        BigDecimal displayed = percent.setScale(excelPercentScale, RoundingMode.HALF_UP);
        return displayed.setScale(1, RoundingMode.HALF_UP);
    }
    private String number(BigDecimal value) {
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayBulletinServiceTest.java
@@ -21,12 +21,16 @@
    void formatsTableRatiosAsSignedPercentages() throws Exception {
        HolidayBulletinService service = new HolidayBulletinService(null);
        assertEquals("-4.7%", ratio(service, "95.3", "100"));
        assertEquals("1.0%", ratio(service, "101", "100"));
        assertEquals("0%", ratio(service, "100", "100"));
        assertEquals("0%", ratio(service, "10004", "10000"));
        assertEquals("—", ratio(service, "100", null));
        assertEquals("—", ratio(service, "100", "0"));
        assertEquals("-4.7%", ratio(service, "95.3", "100", 1));
        assertEquals("-4.4%", ratio(service, "95.551398", "100", 1));
        assertEquals("-4.5%", ratio(service, "95.551398", "100", 2));
        assertEquals("-0.1%", ratio(service, "99.955", "100", 2));
        assertEquals("0%", ratio(service, "99.955", "100", 1));
        assertEquals("1.0%", ratio(service, "101", "100", 1));
        assertEquals("0%", ratio(service, "100", "100", 1));
        assertEquals("0%", ratio(service, "10004", "10000", 1));
        assertEquals("—", ratio(service, "100", null, 1));
        assertEquals("—", ratio(service, "100", "0", 1));
    }
    @Test
@@ -78,7 +82,7 @@
    }
    @Test
    void cumulativeParagraphUsesWanUnitAndSingleRounding() throws Exception {
    void cumulativeParagraphUsesExcelDisplayThenBulletinRounding() throws Exception {
        HolidayBulletinService service = new HolidayBulletinService(null);
        DailyMetrics current = new DailyMetrics();
@@ -94,7 +98,7 @@
        String text = (String) method.invoke(service, current, previousYear, twoYearsAgo);
        assertEquals(
                "假期以来,全省全社会跨区域人员流动量累计3088.63万人次,比2025年同期下降4.4%,比2024年同期下降1.6%。",
                "假期以来,全省全社会跨区域人员流动量累计3088.63万人次,比2025年同期下降4.5%,比2024年同期下降1.6%。",
                text);
    }
@@ -107,11 +111,13 @@
        return method.invoke(service, current, comparisonYear, selector);
    }
    private String ratio(HolidayBulletinService service, String current, String base) throws Exception {
        Method method = HolidayBulletinService.class.getDeclaredMethod("ratio", BigDecimal.class, BigDecimal.class);
    private String ratio(HolidayBulletinService service, String current, String base,
                         int excelPercentScale) throws Exception {
        Method method = HolidayBulletinService.class.getDeclaredMethod(
                "ratio", BigDecimal.class, BigDecimal.class, int.class);
        method.setAccessible(true);
        BigDecimal currentValue = current == null ? null : new BigDecimal(current);
        BigDecimal baseValue = base == null ? null : new BigDecimal(base);
        return (String) method.invoke(service, currentValue, baseValue);
        return (String) method.invoke(service, currentValue, baseValue, excelPercentScale);
    }
}