feat(汇总大表): 缺数清单加导出前弹窗并在生成件附《数据缺失清单》页(待验收)
4个文件已修改
187 ■■■■■ 已修改文件
HANDOFF.md 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/FreightReadinessCheck.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ReportExport.vue 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -4,6 +4,22 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= 本地 commit + 推送到远端 git(origin main,Gitblit),不再只做本地 commit。
### 10-12(14):汇总大表「缺数清单弹窗 + 生成件《数据缺失清单》页」+ 体检假阳性修复 + 公交 9月缺行核查【待验收】
- **动因**:用户要求把缺数摆到台面上(P0-3 剩余),并在生成件里给一页《数据缺失清单》。
- **改动(3 处)**:
  1. 后端 `ReportExportService.appendMissingDataSheet(...)`:生成收尾在产物末尾追加《数据缺失清单》页(第 20 页),分「一、缺数清单 / 二、数据体检 / 三、其它提示」,写报表期+生成时间+条数;只读提示页、不参与计算;母版若由产物派生已带同名页会先移除再建。
  2. 前端 `ReportExport.vue`:点「导出/生成」先调 `GET /api/report/summaryWorkbook/missing` + `freightCheck`,命中则弹窗(分两段、可滚动)「仍要生成 / 取消」;两项皆空、接口异常或年报模式 → 直接生成。
  3. `FreightReadinessCheck` 修假阳性:②全口径整期未导时不再按 `0−规上` 报「规下为负」(上面已报「缺②全口径」)→ 2026-09 命中 68→34,2026-08 仍 0。
- **实测(dev 8090/8080,仅生成/只读)**:missing(2026-09)=4 条(货运②③、网约车订单、网约车 pin);freightCheck(2026-09)=34、2026-08=0;异步生成 2026-09(refresh=true)→ 1,303,490 B / 20 页 / 清单 39 条(4+34+1),落盘缓存同步刷新为 `_cache/summaryWorkbook_2026-09_month__vc0e2afa47b51bc04.xlsx`;前端 `Compiled successfully`、bundle `/js/6.js` 已含新代码;年报模式两接口返回空。
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_汇总大表缺数清单弹窗与生成件页_2026-10-12.md`。状态:**【待验收】**。
- **【待验收 / 待定夺 / 待处理】三项**:
  1. 【待验收】导出前弹窗 + 生成件《数据缺失清单》页(产物 `docs/生成汇总大表/输出/生成_道路运输量汇总表_2026-09.xlsx` 末页);
  2. 【待用户定夺】排名页缺数期留空:数值列留空符合预期,但《货运周转量排名》`R` 列仍显示 **10.00**(母版 `=10-Q` 在 `Q` 清空后算出;源侧空表无此公式),是否一并清空本次**未改**;
  3. 【待处理】公交 2026-09 少 1 行属**源文件缺行**——`随州市·42138100005·广水市安顺公交有限公司` 在 6/7 月源表第 119 行存在、9 月源表没有(`import_batch#551` 记录 141/141/0,非导入跳过)→ 需向数据来源补件或确认企业停报;**未导入任何数据**。
- **顺带复核**:09 月母版 9 月列数值无误(`班线包车!S9=20647.5363`、`公交!S8=61618.09`、`出租!S6=32787.72`、`中口径明细!S5=3517.9161`;全簿 `46266` 只剩 3 个合法表头行)。
- **环境提示**:本机残留 1 个无窗口 `EXCEL.EXE`(PID 23540,`taskkill` 拒绝访问);已确认**母版未被锁**(读写测试通过),下次用 Excel COM 前先复查该进程。
### 10-12(13):下班关机交接 — 汇总大表缺数/母版脏格修复已提交推送,下一步待办与新会话起步
**接手入口(新会话先读这三处)**:① 根目录 `AGENTS.md`;② 本文件顶部(本节 + 10-12(11)(12));③ 上一会话标题「10.8日比对汇总大表和专项统计的生成逻辑」(因上下文超限报错中断,其工作已由本节这条线做完并补提交)。
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/FreightReadinessCheck.java
@@ -128,10 +128,16 @@
                issues.add(String.format("货运体检:%s %s 缺②全口径周转量(规上 %.4f 在、全口径缺)→ 规下无法计算",
                        period, city, abT));
            }
            double belowF = (totalF == null ? 0 : totalF) - (abF == null ? 0 : abF);
            double belowT = (totalT == null ? 0 : totalT) - (abT == null ? 0 : abT);
            // 2026-10-12:②全口径缺(未导)时不再报「规下为负」——上面已单独报「缺②全口径」,
            // 若这里仍按 0 − 规上 算,会对每个市州多刷一条假阳性(值不是负、是缺),把真问题淹没。
            if (totalF != null) {
                double belowF = totalF - (abF == null ? 0 : abF);
            if (belowF < -TOL) issues.add(String.format("货运体检:%s %s 规下货运量为负(全口径 %.4f − 规上 %.4f = %.4f)", period, city, totalF, abF, belowF));
            }
            if (totalT != null) {
                double belowT = totalT - (abT == null ? 0 : abT);
            if (belowT < -TOL) issues.add(String.format("货运体检:%s %s 规下周转量为负(全口径 %.4f − 规上 %.4f = %.4f)", period, city, totalT, abT, belowT));
            }
            if (totalF != null && abF == null) issues.add(String.format("货运体检:%s %s 有全口径但无规上记录 → 规下等于全口径(%.4f),疑①漏报", period, city, totalF));
            if (totalF != null && abF != null && totalF < abF) issues.add(String.format("货运体检:%s %s 全口径 %.4f 小于规上 %.4f", period, city, totalF, abF));
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -42,8 +42,12 @@
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.FormulaEvaluator;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
@@ -3781,6 +3785,7 @@
            log.info("汇总工作簿公式重算完成:成功 {} 格,失败 {} 格(失败格保留母版旧缓存值,打开时按 fullCalcOnLoad 重算)", recalc[0], recalc[1]);
            if (!wb.getCTWorkbook().isSetCalcPr()) wb.getCTWorkbook().addNewCalcPr();
            wb.getCTWorkbook().getCalcPr().setFullCalcOnLoad(true); // 还原/改写公式后打开即重算
            appendMissingDataSheet(wb, effPeriod, dbProblems); // 2026-10-12:缺数/体检结果写进生成件(用户要求)
            reportProgress(progress, 95, "写出文件");
            return toBytes(wb);
        } finally {
@@ -3788,6 +3793,106 @@
        }
    }
    /**
     * 生成件《数据缺失清单》页(2026-10-12,用户要求)。
     *
     * <p>把本次导出的「缺数清单」与「货运基数体检」结果显式写进产物:用户拿到文件即可看到本报表期
     * 有哪些数没到、哪些数不合理,不必翻导出日志。只读提示页,不参与本表计算。
     */
    private void appendMissingDataSheet(XSSFWorkbook wb, String period, List<String> problems) {
        String sheetName = "数据缺失清单";
        int old = wb.getSheetIndex(sheetName);
        if (old >= 0) wb.removeSheetAt(old); // 母版由「当期产物」派生时可能已带本页,先移除避免重名
        XSSFSheet sh = wb.createSheet(sheetName);
        CellStyle titleStyle = wb.createCellStyle();
        Font titleFont = wb.createFont();
        titleFont.setBold(true);
        titleFont.setFontHeightInPoints((short) 14);
        titleStyle.setFont(titleFont);
        CellStyle metaStyle = wb.createCellStyle();
        Font metaFont = wb.createFont();
        metaFont.setFontHeightInPoints((short) 10);
        metaFont.setColor(IndexedColors.GREY_50_PERCENT.getIndex());
        metaStyle.setFont(metaFont);
        CellStyle sectionStyle = wb.createCellStyle();
        Font sectionFont = wb.createFont();
        sectionFont.setBold(true);
        sectionStyle.setFont(sectionFont);
        CellStyle bodyStyle = wb.createCellStyle();
        bodyStyle.setWrapText(true);
        bodyStyle.setVerticalAlignment(VerticalAlignment.TOP);
        List<String> missing = new ArrayList<>();
        List<String> check = new ArrayList<>();
        List<String> other = new ArrayList<>();
        if (problems != null) {
            for (String p : problems) {
                if (p == null || p.trim().isEmpty()) continue;
                if (p.startsWith("缺数")) missing.add(p);
                else if (p.startsWith("货运体检")) check.add(p);
                else other.add(p);
            }
        }
        int r = 0;
        Row tRow = sh.createRow(r++);
        Cell tCell = tRow.createCell(0);
        tCell.setCellValue("数据缺失清单");
        tCell.setCellStyle(titleStyle);
        Row mRow = sh.createRow(r++);
        Cell mCell = mRow.createCell(0);
        mCell.setCellValue("报表期 " + period + " 生成时间 "
            + new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(new java.util.Date())
            + " 共 " + (missing.size() + check.size() + other.size()) + " 条");
        mCell.setCellStyle(metaStyle);
        Row nRow = sh.createRow(r++);
        Cell nCell = nRow.createCell(0);
        nCell.setCellValue("本页由程序自动生成(只读提示):一、缺数清单=该有的数没有;二、数据体检=数值不合理(如规下为负)。不参与本表计算。");
        nCell.setCellStyle(metaStyle);
        r++;
        r = writeProblemSection(sh, r, "一、缺数清单(该有的数没有)", missing, sectionStyle, bodyStyle);
        r = writeProblemSection(sh, r, "二、数据体检(数值不合理)", check, sectionStyle, bodyStyle);
        if (!other.isEmpty()) {
            r = writeProblemSection(sh, r, "三、其它提示", other, sectionStyle, bodyStyle);
        }
        sh.setColumnWidth(0, 6 * 256);
        sh.setColumnWidth(1, 120 * 256);
        sh.createFreezePane(0, 3);
    }
    /** 写一个清单分区(标题 + 逐条),返回下一个可写行号 */
    private int writeProblemSection(XSSFSheet sh, int rowIdx, String title, List<String> lines,
                                    CellStyle sectionStyle, CellStyle bodyStyle) {
        Row hr = sh.createRow(rowIdx++);
        Cell hc = hr.createCell(0);
        hc.setCellValue(title);
        hc.setCellStyle(sectionStyle);
        sh.addMergedRegion(new CellRangeAddress(rowIdx - 1, rowIdx - 1, 0, 1));
        if (lines.isEmpty()) {
            Row er = sh.createRow(rowIdx++);
            er.createCell(0).setCellValue("—");
            er.createCell(1).setCellValue("无");
            return rowIdx;
        }
        int i = 1;
        for (String line : lines) {
            Row row = sh.createRow(rowIdx++);
            row.createCell(0).setCellValue(i++);
            Cell c = row.createCell(1);
            c.setCellValue(line);
            c.setCellStyle(bodyStyle);
        }
        return rowIdx;
    }
    /** 导出进度回调(异步导出任务用):回调自身异常不影响导出 */
    private static void reportProgress(java.util.function.BiConsumer<Integer, String> progress, int percent, String step) {
        if (progress == null) return;
traffic-audit-web/src/views/ReportExport.vue
@@ -190,6 +190,25 @@
            <el-button size="small" v-else type="primary" @click="closeTask">关闭</el-button>
          </span>
        </el-dialog>
        <!-- 汇总大表导出前缺数清单弹窗(2026-10-12 用户要求:缺数摆到台面上) -->
        <el-dialog title="生成前提示 - 数据缺失清单" :visible.sync="missDialogVisible" width="720px" top="6vh">
          <div class="miss-body">
            <p class="miss-lead">报表期 <b>{{ missDialogPeriod }}</b>:以下数据本次生成会缺失/异常,生成件末尾也会附《数据缺失清单》页。</p>
            <template v-if="missDialogMissing.length">
              <div class="miss-sec">一、缺数清单(该有的数没有) 共 {{ missDialogMissing.length }} 条</div>
              <ul class="miss-list"><li v-for="(t, i) in missDialogMissing" :key="'m' + i">{{ t }}</li></ul>
            </template>
            <template v-if="missDialogCheck.length">
              <div class="miss-sec">二、数据体检(数值不合理) 共 {{ missDialogCheck.length }} 条</div>
              <ul class="miss-list"><li v-for="(t, i) in missDialogCheck" :key="'c' + i">{{ t }}</li></ul>
            </template>
            <p class="miss-tip">缺数多为源数据尚未导入,导入后重新生成即可;体检项请核对源文件。</p>
          </div>
          <span slot="footer">
            <el-button size="small" @click="missDialogVisible = false">取消</el-button>
            <el-button size="small" type="primary" @click="confirmMissDialog">仍要生成</el-button>
          </span>
        </el-dialog>
      </el-main>
    </el-container>
  </el-container>
@@ -228,6 +247,12 @@
      taskFileName: '',
      taskFileSize: 0,
      taskKey: 'summaryWorkbook',
      // 导出前缺数清单弹窗(2026-10-12):缺数/体检摆到台面上,确认后再生成
      missDialogVisible: false,
      missDialogPeriod: '',
      missDialogMissing: [],
      missDialogCheck: [],
      missDialogRefresh: false,
      modules: [
        {
          key: 'summary', title: '汇总大表', items: [
@@ -626,6 +651,28 @@
        })
        .catch(err => this.failWith(err))
    },
    // 导出前缺数检查:调只读接口把缺数/体检结果摆到用户面前,确认后再走异步生成任务
    generateSummaryWorkbook(refresh) {
      if (!this.period) { this.$message.warning('请先选择周期和月份'); return }
      const params = { period: this.period }
      Promise.all([
        api.get('/report/summaryWorkbook/missing', { params, timeout: 60000 }).catch(() => null),
        api.get('/report/summaryWorkbook/freightCheck', { params, timeout: 60000 }).catch(() => null)
      ]).then(res => {
        const miss = (res[0] && res[0].code === 200 && res[0].data) || []
        const chk = (res[1] && res[1].code === 200 && res[1].data) || []
        if (!miss.length && !chk.length) { this.startSummaryTask(refresh); return }
        this.missDialogPeriod = this.period
        this.missDialogMissing = miss
        this.missDialogCheck = chk
        this.missDialogRefresh = refresh
        this.missDialogVisible = true
      })
    },
    confirmMissDialog() {
      this.missDialogVisible = false
      this.startSummaryTask(this.missDialogRefresh)
    },
    restartTask() { this.startSummaryTask(true) },
    closeTask() { this.taskVisible = false; this.stopTaskPolling(); this.loading = '' },
    // 生成中收起弹窗:任务继续在后台执行,完成后仍会自动下载(与原来同步导出的「点完即下载」一致)
@@ -643,7 +690,7 @@
      const doExport = () => {
        if (items.length === 1) {
          // 汇总大表生成一次约 10~20 秒,走异步任务(进度弹窗),避免点击后长时间无响应
          if (items[0].key === 'summaryWorkbook') { this.startSummaryTask(false); return }
          if (items[0].key === 'summaryWorkbook') { this.generateSummaryWorkbook(false); return }
          api.get('/report/export/' + items[0].key, { params, responseType: 'blob', timeout: 120000 })
            .then(res => { this.downloadBlob(res, this.fileNameOf(items[0])); this.$message.success('报表导出成功'); this.loading = '' })
            .catch(err => this.failWith(err))
@@ -794,6 +841,13 @@
.task-meta { margin: 2px 0; font-size: 12px; color:#909399; line-height: 1.6; }
.task-error { margin: 8px 0 0; font-size: 13px; color:#F56C6C; word-break: break-all; }
.task-done { margin: 8px 0 0; font-size: 13px; color:#67C23A; line-height: 1.6; }
/* 导出前缺数清单弹窗 */
.miss-body { max-height: 62vh; overflow-y: auto; }
.miss-lead { font-size: 13px; color: #303133; margin: 0 0 10px; line-height: 1.7; }
.miss-sec { font-size: 13px; font-weight: 600; color: #F56C6C; margin: 10px 0 4px; }
.miss-list { margin: 0; padding-left: 20px; font-size: 13px; color: #303133; line-height: 1.7; }
.miss-list li { margin: 2px 0; }
.miss-tip { font-size: 12px; color: #909399; margin: 10px 0 0; }
</style>
<style>