fix: 优化excel综合导出方法

This commit is contained in:
tangwei 2026-08-05 11:37:36 +08:00
parent 0b8ca9063c
commit 06684578f8
20 changed files with 1102 additions and 1 deletions

View File

@ -68,7 +68,8 @@ public class SecurityConfig {
.requestMatchers("/dict/cache/**").permitAll()
.requestMatchers("/sys/psbmodulelbb/**").permitAll()
.requestMatchers("/base/operationLog/**").permitAll()
.requestMatchers("/system/**").permitAll()
// .requestMatchers("/system/**").permitAll()
.requestMatchers("/qgcExport/**").permitAll()
// .requestMatchers("/eng/**").permitAll()
// .requestMatchers("/eq/**").permitAll()
// .requestMatchers("/env/**").permitAll()

View File

@ -299,4 +299,12 @@ public class SwaggerConfig {
.packagesToScan("com.yfd.platform.qgc_lygk.along.controller")
.build();
}
@Bean
public GroupedOpenApi groupExportApi() {
return GroupedOpenApi.builder()
.group("7. 综合导出")
.packagesToScan("com.yfd.platform.qgc_export.controller")
.build();
}
}

View File

@ -0,0 +1,77 @@
package com.yfd.platform.qgc_export.controller;
import cn.hutool.core.util.StrUtil;
import com.yfd.platform.annotation.Log;
import com.yfd.platform.qgc_export.service.IQgcExportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* 综合导出控制器
* <p>
* 前端传参示例
* <pre>{@code
* POST /qgcExport/exportData
* {
* "tmDimension": "month",
* "dataField": "v,q,z",
* "months": "2026-08,2026-07",
* "stcd": "00001,00002",
* "sysId": "qgc",
* "isDailyDimension": true,
* "isCalcQec": true
* }
* }</pre>
*/
@RestController
@RequestMapping("/qgcExport")
@Tag(name = "综合导出")
public class QgcExportController {
@Resource
private IQgcExportService qgcExportService;
@PostMapping("/exportData")
@Operation(summary = "综合数据导出ZIP 含多 Sheet Excel")
public void exportData(@RequestParam(required = false) String tmDimension,
@RequestParam(required = false) String dataField,
@RequestParam(required = false) String months,
@RequestParam(required = false) String stcd,
@RequestParam(required = false) String sysId,
@RequestParam(defaultValue = "true") boolean isDailyDimension,
@RequestParam(defaultValue = "false") boolean isCalcQec,
HttpServletResponse response) {
// 解析逗号分隔参数
List<String> dataFields = parseCsv(dataField);
List<String> monthList = parseCsv(months);
List<String> stationList = parseCsv(stcd);
if (dataFields.isEmpty()) {
dataFields = Arrays.asList("v", "q", "z");
}
if (tmDimension == null || tmDimension.isEmpty()) {
tmDimension = "month";
}
qgcExportService.exportData(dataFields, monthList, stationList,
isDailyDimension, tmDimension, response);
}
private List<String> parseCsv(String value) {
if (StrUtil.isBlank(value)) {
return Collections.emptyList();
}
return Arrays.stream(value.split(","))
.map(String::trim)
.filter(StrUtil::isNotBlank)
.collect(java.util.stream.Collectors.toList());
}
}

View File

@ -0,0 +1,69 @@
package com.yfd.platform.qgc_export.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 河道水情监测数据表小时级
* 对应 Oracle : QGC_REFA.SD_RIVER_R
*/
@Data
@TableName("SD_RIVER_R")
public class SdRiverR implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.INPUT)
private String id;
/** 站码 */
private String stcd;
/** 时间 */
private Date tm;
/** 水位 (m) */
private BigDecimal z;
/** 流量 (m³/s) */
private BigDecimal q;
/** 流速 (m/s) */
private BigDecimal v;
/** 测流方法 */
private String msqmt;
/** 创建人 */
private String recordUser;
/** 创建时间 */
private Date recordTime;
/** 更新人 */
private String modifyUser;
/** 更新时间 */
private Date modifyTime;
/** 是否已删除: 0=未删除 1=已删除 */
private Integer isDeleted;
/** 删除人 */
private String deleteUser;
/** 删除时间 */
private Date deleteTime;
/** 附件ID */
private String fid;
/** 备注 */
private String remark;
}

View File

@ -0,0 +1,63 @@
package com.yfd.platform.qgc_export.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 河道水情监测数据天统计表日级
* 对应 Oracle : QGC_REFA.SD_RIVERDAY_S
*/
@Data
@TableName("SD_RIVERDAY_S")
public class SdRiverdayS implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.INPUT)
private String id;
/** 站码 */
private String stcd;
/** 时间 */
private Date dt;
/** 水位 (m) */
private BigDecimal z;
/** 流量 (m³/s) */
private BigDecimal q;
/** 流速 (m/s) */
private BigDecimal v;
/** 测流方法 */
private String msqmt;
/** 创建人 */
private String recordUser;
/** 创建时间 */
private Date recordTime;
/** 更新人 */
private String modifyUser;
/** 更新时间 */
private Date modifyTime;
/** 是否已删除: 0=未删除 1=已删除 */
private Integer isDeleted;
/** 删除人 */
private String deleteUser;
/** 删除时间 */
private Date deleteTime;
}

View File

@ -0,0 +1,26 @@
package com.yfd.platform.qgc_export.domain;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
/**
* V_MS_STBPRP_T 视图 仅用于电站测站映射
*/
@Data
@TableName("V_MS_STBPRP_T")
public class StationMapping implements Serializable {
private static final long serialVersionUID = 1L;
@TableField("STCD")
private String stcd;
@TableField("RSTCD")
private String rstcd;
@TableField("ENNM")
private String ennm;
}

View File

@ -0,0 +1,28 @@
package com.yfd.platform.qgc_export.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yfd.platform.qgc_export.domain.SdRiverR;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* SD_RIVER_R 河道水情小时数据 Mapper
*/
public interface SdRiverRMapper extends BaseMapper<SdRiverR> {
/**
* 批量查询站点在指定时间范围内的原始小时数据
*
* @param stcdList 测站编码列表
* @param fieldName 查询字段名V/Q/Z
* @param startTime 起始时间
* @param endTime 结束时间
* @return [STCD, TM, VALUE]
*/
List<Map<String, Object>> selectRawData(@Param("stcdList") List<String> stcdList,
@Param("fieldName") String fieldName,
@Param("startTime") String startTime,
@Param("endTime") String endTime);
}

View File

@ -0,0 +1,28 @@
package com.yfd.platform.qgc_export.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yfd.platform.qgc_export.domain.SdRiverdayS;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* SD_RIVERDAY_S 河道水情日数据 Mapper
*/
public interface SdRiverdaySMapper extends BaseMapper<SdRiverdayS> {
/**
* 批量查询站点在指定时间范围内的原始日数据
*
* @param stcdList 测站编码列表
* @param fieldName 查询字段名V/Q/Z
* @param startTime 起始时间
* @param endTime 结束时间
* @return [STCD, DT, VALUE]
*/
List<Map<String, Object>> selectRawData(@Param("stcdList") List<String> stcdList,
@Param("fieldName") String fieldName,
@Param("startTime") String startTime,
@Param("endTime") String endTime);
}

View File

@ -0,0 +1,10 @@
package com.yfd.platform.qgc_export.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yfd.platform.qgc_export.domain.StationMapping;
/**
* V_MS_STBPRP_T 视图 Mapper 用于电站编码测站编码转换
*/
public interface StationMappingMapper extends BaseMapper<StationMapping> {
}

View File

@ -0,0 +1,33 @@
package com.yfd.platform.qgc_export.processor;
import java.util.Date;
import java.util.List;
/**
* 指标处理器接口 每种指标v/q/z/wq/dwt 实现此接口
* <p>
* 新增指标时实现本接口 + QgcExportServiceImpl 中注入即可
*/
public interface ExportIndicatorProcessor {
/**
* 指标字段名与前端 dataField 中的值对应 "v", "q", "z"
*/
String getFieldName();
/**
* Excel Sheet 页中文标题 "流速(m/s)"
*/
String getSheetName();
/**
* 查询原始数据
*
* @param stcds 测站编码列表已从电站编码转换
* @param startTime 起始时间
* @param endTime 结束时间
* @param isDaily 是否查询日表true=SD_RIVERDAY_S, false=SD_RIVER_R
* @return 原始数据行列表
*/
List<IndicatorDataRow> queryRawData(List<String> stcds, Date startTime, Date endTime, boolean isDaily);
}

View File

@ -0,0 +1,31 @@
package com.yfd.platform.qgc_export.processor;
import java.math.BigDecimal;
import java.util.Date;
/**
* 指标数据行 从数据库查询后的原始数据封装
*/
public class IndicatorDataRow {
/** 测站编码 */
private String stcd;
/** 数据时间 */
private Date tm;
/** 指标值 */
private BigDecimal value;
public IndicatorDataRow() {}
public IndicatorDataRow(String stcd, Date tm, BigDecimal value) {
this.stcd = stcd;
this.tm = tm;
this.value = value;
}
public String getStcd() { return stcd; }
public void setStcd(String stcd) { this.stcd = stcd; }
public Date getTm() { return tm; }
public void setTm(Date tm) { this.tm = tm; }
public BigDecimal getValue() { return value; }
public void setValue(BigDecimal value) { this.value = value; }
}

View File

@ -0,0 +1,20 @@
package com.yfd.platform.qgc_export.processor;
import org.springframework.stereotype.Component;
/**
* 流量(Q) 指标处理器
*/
@Component
public class QProcessor extends RiverIndicatorProcessor {
@Override
public String getFieldName() {
return "Q";
}
@Override
public String getSheetName() {
return "流量(m³/s)";
}
}

View File

@ -0,0 +1,58 @@
package com.yfd.platform.qgc_export.processor;
import com.yfd.platform.qgc_export.mapper.SdRiverRMapper;
import com.yfd.platform.qgc_export.mapper.SdRiverdaySMapper;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.*;
/**
* 河道水情指标处理器基类 V/Q/Z 三个指标共用查询逻辑
* <p>
* 子类VProcessor/QProcessor/ZProcessor需标注 @Component 并提供 getFieldName/getSheetName
*/
public abstract class RiverIndicatorProcessor implements ExportIndicatorProcessor {
protected SdRiverRMapper sdRiverRMapper;
protected SdRiverdaySMapper sdRiverdaySMapper;
private static final SimpleDateFormat SDF_DATETIME = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public void setMappers(SdRiverRMapper rMapper, SdRiverdaySMapper sMapper) {
this.sdRiverRMapper = rMapper;
this.sdRiverdaySMapper = sMapper;
}
@Override
public List<IndicatorDataRow> queryRawData(List<String> stcds, Date startTime, Date endTime, boolean isDaily) {
String startStr = SDF_DATETIME.format(startTime);
String endStr = SDF_DATETIME.format(endTime);
List<Map<String, Object>> rawList;
if (isDaily) {
rawList = sdRiverdaySMapper.selectRawData(stcds, getFieldName(), startStr, endStr);
} else {
rawList = sdRiverRMapper.selectRawData(stcds, getFieldName(), startStr, endStr);
}
List<IndicatorDataRow> rows = new ArrayList<>();
if (rawList != null) {
for (Map<String, Object> map : rawList) {
String stcd = (String) map.get("STCD");
Date tm = (Date) map.get(isDaily ? "DT" : "TM");
BigDecimal value = null;
Object val = map.get("VALUE");
if (val instanceof BigDecimal) {
value = (BigDecimal) val;
} else if (val instanceof Number) {
value = BigDecimal.valueOf(((Number) val).doubleValue());
}
if (stcd != null && tm != null && value != null) {
rows.add(new IndicatorDataRow(stcd, tm, value));
}
}
}
return rows;
}
}

View File

@ -0,0 +1,20 @@
package com.yfd.platform.qgc_export.processor;
import org.springframework.stereotype.Component;
/**
* 流速(V) 指标处理器
*/
@Component
public class VProcessor extends RiverIndicatorProcessor {
@Override
public String getFieldName() {
return "V";
}
@Override
public String getSheetName() {
return "流速(m/s)";
}
}

View File

@ -0,0 +1,20 @@
package com.yfd.platform.qgc_export.processor;
import org.springframework.stereotype.Component;
/**
* 水位(Z) 指标处理器
*/
@Component
public class ZProcessor extends RiverIndicatorProcessor {
@Override
public String getFieldName() {
return "Z";
}
@Override
public String getSheetName() {
return "水位(m)";
}
}

View File

@ -0,0 +1,27 @@
package com.yfd.platform.qgc_export.service;
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 综合导出服务接口
*/
public interface IQgcExportService {
/**
* 执行综合导出生成 ZIP 并写入 HttpServletResponse
*
* @param dataFields 指标字段列表 ["v","q","z"]
* @param months 月份列表 ["2026-08","2026-07"]
* @param stationCodes 电站编码列表RSTCD前端传入
* @param isDailyDimension 是否查日表
* @param tmDimension 时间维度 (hour/day/month/year)
* @param response HttpServletResponse
*/
void exportData(List<String> dataFields,
List<String> months,
List<String> stationCodes,
boolean isDailyDimension,
String tmDimension,
HttpServletResponse response);
}

View File

@ -0,0 +1,321 @@
package com.yfd.platform.qgc_export.service.impl;
import com.yfd.platform.qgc_export.domain.StationMapping;
import com.yfd.platform.qgc_export.mapper.SdRiverRMapper;
import com.yfd.platform.qgc_export.mapper.SdRiverdaySMapper;
import com.yfd.platform.qgc_export.mapper.StationMappingMapper;
import com.yfd.platform.qgc_export.processor.ExportIndicatorProcessor;
import com.yfd.platform.qgc_export.processor.IndicatorDataRow;
import com.yfd.platform.qgc_export.processor.RiverIndicatorProcessor;
import com.yfd.platform.qgc_export.processor.VProcessor;
import com.yfd.platform.qgc_export.processor.QProcessor;
import com.yfd.platform.qgc_export.processor.ZProcessor;
import com.yfd.platform.qgc_export.service.IQgcExportService;
import com.yfd.platform.utils.ExportZipUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
/**
* 综合导出服务实现
* <p>
* 格式每月一个 Excel 文件 ZIP 下载
* 每个 Excel 含多个 Sheet每个指标一个 Sheet每行是一个统计指标
* <pre>
* 日期 | 电站A | 电站B
* 日均最低 | 0.523 | 0.612
* 日均最高 | 1.234 | 1.456
* 月内最低 | 0.412 | 0.523
* 月内最低时间 | 2026-08-15 03:00:00 | 2026-08-14 05:30:00
* 月内最高 | 2.156 | 2.345
* 月内最高时间 | 2026-08-03 12:00:00 | 2026-08-20 18:45:00
* </pre>
*/
@Service
public class QgcExportServiceImpl implements IQgcExportService {
private static final Logger log = LoggerFactory.getLogger(QgcExportServiceImpl.class);
@Resource
private StationMappingMapper stationMappingMapper;
@Resource
private SdRiverRMapper sdRiverRMapper;
@Resource
private SdRiverdaySMapper sdRiverdaySMapper;
@Resource
private VProcessor vProcessor;
@Resource
private QProcessor qProcessor;
@Resource
private ZProcessor zProcessor;
private static final SimpleDateFormat SDF_DATETIME = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
/** 行标题6 个统计指标 */
private static final List<String> INDICATOR_LABELS = Collections.unmodifiableList(
Arrays.asList("日均最低", "日均最高", "月内最低", "月内最低时间", "月内最高", "月内最高时间"));
@Override
public void exportData(List<String> dataFields, List<String> months,
List<String> stationCodes, boolean isDailyDimension,
String tmDimension, HttpServletResponse response) {
// 1. 电站 测站映射 + 电站名称
Map<String, StationInfo> stationInfoMap = resolveStations(stationCodes);
if (stationInfoMap.isEmpty()) {
log.warn("未找到任何电站映射数据,导出取消");
return;
}
// 2. 指标处理器匹配
List<ExportIndicatorProcessor> processors = resolveProcessors(dataFields);
if (processors.isEmpty()) {
log.warn("未找到匹配的指标处理器dataFields={}", dataFields);
return;
}
// 3. 收集电站名称 + 测站列表保持顺序
List<String> stationOrder = new ArrayList<>(stationInfoMap.keySet());
List<String> stationNames = stationOrder.stream()
.map(k -> stationInfoMap.get(k).getStationName())
.toList();
List<String> stcds = stationInfoMap.values().stream()
.map(StationInfo::getStcd)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
// 4. rstcd stcd 映射用于聚合时找数据
Map<String, String> rstcdToStcd = new LinkedHashMap<>();
for (Map.Entry<String, StationInfo> e : stationInfoMap.entrySet()) {
if (e.getValue().getStcd() != null) {
rstcdToStcd.put(e.getKey(), e.getValue().getStcd());
}
}
// 5. 每月一个 ExcelData
List<ExportZipUtil.ExcelData> excelList = new ArrayList<>();
for (String month : months) {
MonthRange mr = buildMonthRange(month);
if (mr == null) continue;
List<ExportZipUtil.SheetData> sheets = new ArrayList<>();
for (ExportIndicatorProcessor processor : processors) {
// 查询 + 聚合这一个月的数据
List<List<Object>> rows = buildIndicatorRows(
processor, mr, stcds, rstcdToStcd, stationOrder);
if (rows != null) {
List<String> headers = new ArrayList<>();
headers.add("日期"); // 表头第一列 = 日期
headers.addAll(stationNames);
sheets.add(new ExportZipUtil.SheetData(processor.getSheetName(), headers, rows));
}
}
if (!sheets.isEmpty()) {
excelList.add(new ExportZipUtil.ExcelData(month, sheets));
}
}
if (excelList.isEmpty()) {
log.warn("没有可导出的数据");
return;
}
// 6. 委托 ExportZipUtil 导出 ZIP
String zipFileName = "综合数据导出_" + System.currentTimeMillis() + ".zip";
ExportZipUtil.exportToResponse(response, zipFileName, excelList);
}
// ======================== 构建单个指标的 Sheet 数据 ========================
/**
* 查询并聚合一个月的数据组装成 SheetData rows
* <p>
* 返回的每一行 = [指标名, station1值, station2值, ...]
*/
private List<List<Object>> buildIndicatorRows(
ExportIndicatorProcessor processor, MonthRange mr,
List<String> stcds, Map<String, String> rstcdToStcd,
List<String> stationOrder) {
if (stcds.isEmpty()) return null;
// 查询日表数据用于日均最低/最高
List<IndicatorDataRow> dailyRows = processor.queryRawData(stcds, mr.getStart(), mr.getEnd(), true);
// 查询小时表数据用于月内最低/最高
List<IndicatorDataRow> hourlyRows = processor.queryRawData(stcds, mr.getStart(), mr.getEnd(), false);
// 按电站聚合
Map<String, Aggregation> aggMap = aggregate(dailyRows, hourlyRows, rstcdToStcd);
// 组装 6
List<List<Object>> rows = new ArrayList<>(6);
for (int i = 0; i < 6; i++) {
List<Object> row = new ArrayList<>();
row.add(INDICATOR_LABELS.get(i)); // 第一列指标名称
for (String rstcd : stationOrder) {
Aggregation agg = aggMap.get(rstcd);
switch (i) {
case 0: row.add(formatOrEmpty(agg != null ? agg.getDailyMin() : null)); break;
case 1: row.add(formatOrEmpty(agg != null ? agg.getDailyMax() : null)); break;
case 2: row.add(formatOrEmpty(agg != null ? agg.getExtremeMin() : null)); break;
case 3: row.add(agg != null && agg.getExtremeMinTime() != null ? agg.getExtremeMinTime() : ""); break;
case 4: row.add(formatOrEmpty(agg != null ? agg.getExtremeMax() : null)); break;
case 5: row.add(agg != null && agg.getExtremeMaxTime() != null ? agg.getExtremeMaxTime() : ""); break;
}
}
rows.add(row);
}
return rows;
}
// ======================== 指标处理器匹配 ========================
private List<ExportIndicatorProcessor> resolveProcessors(List<String> dataFields) {
Map<String, RiverIndicatorProcessor> riverMap = new LinkedHashMap<>();
riverMap.put("v", vProcessor);
riverMap.put("q", qProcessor);
riverMap.put("z", zProcessor);
List<ExportIndicatorProcessor> result = new ArrayList<>();
for (String field : dataFields) {
String lower = field.trim().toLowerCase();
RiverIndicatorProcessor p = riverMap.get(lower);
if (p != null) {
p.setMappers(sdRiverRMapper, sdRiverdaySMapper);
result.add(p);
}
}
return result;
}
// ======================== 电站映射 ========================
private Map<String, StationInfo> resolveStations(List<String> rstcds) {
Map<String, StationInfo> result = new LinkedHashMap<>();
if (rstcds == null || rstcds.isEmpty()) return result;
List<StationMapping> mappings = stationMappingMapper.selectList(
new LambdaQueryWrapper<StationMapping>().in(StationMapping::getRstcd, rstcds));
for (StationMapping m : mappings) {
String rstcd = m.getRstcd();
if (rstcd == null || result.containsKey(rstcd)) continue;
result.put(rstcd, new StationInfo(m.getStcd(),
m.getEnnm() != null ? m.getEnnm() : rstcd));
}
for (String rstcd : rstcds) {
result.putIfAbsent(rstcd, new StationInfo(null, rstcd));
}
return result;
}
// ======================== 月份范围 ========================
private MonthRange buildMonthRange(String month) {
try {
String[] parts = month.split("-");
int year = Integer.parseInt(parts[0]);
int mon = Integer.parseInt(parts[1]);
Calendar cal = Calendar.getInstance();
cal.set(year, mon - 1, 1, 0, 0, 0);
cal.set(Calendar.MILLISECOND, 0);
Date start = cal.getTime();
cal.set(year, mon, 1, 0, 0, 0);
cal.set(Calendar.MILLISECOND, 0);
Date end = cal.getTime();
return new MonthRange(month, start, end);
} catch (Exception e) {
log.warn("解析月份失败: {}", month, e);
return null;
}
}
// ======================== 数据聚合 ========================
private Map<String, Aggregation> aggregate(
List<IndicatorDataRow> dailyRows, List<IndicatorDataRow> hourlyRows,
Map<String, String> rstcdToStcd) {
Map<String, Aggregation> result = new LinkedHashMap<>();
for (String rstcd : rstcdToStcd.keySet()) {
String stcd = rstcdToStcd.get(rstcd);
Aggregation agg = new Aggregation();
// 日均最低/最高筛选该测站的所有日数据
List<BigDecimal> dailyValues = dailyRows.stream()
.filter(r -> Objects.equals(r.getStcd(), stcd) && r.getValue() != null)
.map(IndicatorDataRow::getValue)
.collect(Collectors.toList());
if (!dailyValues.isEmpty()) {
agg.setDailyMin(Collections.min(dailyValues));
agg.setDailyMax(Collections.max(dailyValues));
}
// 月内最低/最高筛选该测站的所有小时数据
List<IndicatorDataRow> hourlyStationData = hourlyRows.stream()
.filter(r -> Objects.equals(r.getStcd(), stcd) && r.getValue() != null)
.collect(Collectors.toList());
if (!hourlyStationData.isEmpty()) {
IndicatorDataRow minRow = Collections.min(hourlyStationData,
Comparator.comparing(IndicatorDataRow::getValue));
IndicatorDataRow maxRow = Collections.max(hourlyStationData,
Comparator.comparing(IndicatorDataRow::getValue));
agg.setExtremeMin(minRow.getValue());
agg.setExtremeMinTime(SDF_DATETIME.format(minRow.getTm()));
agg.setExtremeMax(maxRow.getValue());
agg.setExtremeMaxTime(SDF_DATETIME.format(maxRow.getTm()));
}
result.put(rstcd, agg);
}
return result;
}
private static String formatOrEmpty(BigDecimal value) {
if (value == null) return "";
return value.setScale(3, RoundingMode.HALF_UP).toPlainString();
}
// ======================== 内部数据类 ========================
@lombok.Data
private static class StationInfo {
private final String stcd;
private final String stationName;
}
@lombok.Data
private static class MonthRange {
private final String label;
private final Date start;
private final Date end;
}
@lombok.Data
private static class Aggregation {
private BigDecimal dailyMin;
private BigDecimal dailyMax;
private BigDecimal extremeMin;
private String extremeMinTime;
private BigDecimal extremeMax;
private String extremeMaxTime;
}
}

View File

@ -4,6 +4,7 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
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.slf4j.Logger;
@ -89,6 +90,51 @@ public class ExportZipUtil {
private List<SheetData> sheets;
}
// ======================== 分组表头数据模型 ========================
/**
* 带分组表头的 Sheet 页数据定义支持合并单元格
*
* <p>生成的 Excel 结构
* <pre>
* Row 0: [rowHeaderLabel(合并)] [group0Name(合并 subCols )] [group1Name(合并 subCols )] ...
* Row 1: [ ] [sub0] [sub1] ... [subN] [sub0] [sub1] ... [subN] ...
* Row 2+: rowLabel0 data...
* Row 3 : rowLabel1 data...
* </pre></p>
*
* <h3>使用示例</h3>
* <pre>{@code
* GroupedSheetData sheet = new GroupedSheetData("流速(m/s)", "日期",
* Arrays.asList("XX水电站", "YY水电站"),
* Arrays.asList("日均最低", "日均最高", "月内最低", "月内最低时间", "月内最高", "月内最高时间"));
* // 每行[rowLabel, g0_sub0, g0_sub1, ..., g0_sub5, g1_sub0, ...]
* sheet.getRows().add(Arrays.asList("2026-08", 0.5, 1.2, 0.3, "2026-08-15 03:00:00", 2.1, "...", ...));
* }</pre>
*/
@Data
@NoArgsConstructor
public static class GroupedSheetData {
/** Sheet 页名称 */
private String sheetName;
/** 行标题列名(如"日期",该列在 Row0+Row1 纵向合并) */
private String rowHeaderLabel;
/** 分组名称列表(如["XX水电站","YY水电站"] */
private List<String> groupNames;
/** 子表头列表(每个分组下都有相同的子表头) */
private List<String> subHeaders;
/** 数据行:每行为 [rowLabel, group0.sub0, group0.sub1, ..., group0.subN, group1.sub0, ...] */
private List<List<Object>> rows = new ArrayList<>();
public GroupedSheetData(String sheetName, String rowHeaderLabel,
List<String> groupNames, List<String> subHeaders) {
this.sheetName = sheetName;
this.rowHeaderLabel = rowHeaderLabel;
this.groupNames = groupNames;
this.subHeaders = subHeaders;
}
}
// ======================== 核心导出方法 ========================
/**
@ -167,6 +213,169 @@ public class ExportZipUtil {
os.write(zipBaos.toByteArray());
}
// ======================== 分组表头导出方法 ========================
/**
* 导出分组表头数据为 ZIP每个 GroupedSheetData 生成一个独立的 Excel 文件在 ZIP
*
* @param response HttpServletResponse
* @param zipFileName 下载文件名
* @param sheets 分组表头数据列表每个元素 = ZIP 中一个 .xlsx 文件
*/
public static void exportGroupedToResponse(HttpServletResponse response, String zipFileName,
List<GroupedSheetData> sheets) {
response.setContentType("application/zip");
response.setCharacterEncoding("UTF-8");
try {
response.setHeader("Content-Disposition",
"attachment; filename=" + URLEncoder.encode(zipFileName, "UTF-8"));
} catch (UnsupportedEncodingException e) {
response.setHeader("Content-Disposition", "attachment; filename=export.zip");
}
try (ServletOutputStream os = response.getOutputStream()) {
exportGroupedToStream(os, sheets);
os.flush();
} catch (IOException e) {
log.error("导出分组 ZIP 到 Response 失败", e);
throw new RuntimeException("导出失败: " + e.getMessage(), e);
}
}
/**
* 导出分组表头数据 ZIP 到任意 OutputStream
* <p>
* 每个 GroupedSheetData 作为一个 .xlsx 文件打入 ZIP
*/
public static void exportGroupedToStream(OutputStream os, List<GroupedSheetData> sheets) throws IOException {
if (sheets == null || sheets.isEmpty()) {
log.warn("导出数据为空,跳过 ZIP 生成");
return;
}
ByteArrayOutputStream zipBaos = new ByteArrayOutputStream();
try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(zipBaos)) {
for (GroupedSheetData sheet : sheets) {
SXSSFWorkbook workbook = new SXSSFWorkbook(100);
try {
fillGroupedSheet(workbook, sheet);
byte[] bytes = workbookToBytes(workbook);
String entryName = sanitizeFileName(sheet.getSheetName()) + ".xlsx";
java.util.zip.ZipEntry entry = new java.util.zip.ZipEntry(entryName);
zos.putNextEntry(entry);
zos.write(bytes);
zos.closeEntry();
} finally {
workbook.close();
workbook.dispose();
}
}
zos.finish();
}
os.write(zipBaos.toByteArray());
}
/**
* Workbook 中填充一个带分组表头的 Sheet
*
* @param workbook 目标 Workbook
* @param data 分组表头数据
*/
public static void fillGroupedSheet(Workbook workbook, GroupedSheetData data) {
String name = safeName(data.getSheetName());
Sheet sheet = workbook.createSheet(name);
if (sheet instanceof SXSSFSheet) {
((SXSSFSheet) sheet).trackAllColumnsForAutoSizing();
}
CellStyle groupHeaderStyle = createHeaderStyle(workbook);
CellStyle subHeaderStyle = createSubHeaderStyle(workbook);
CellStyle dataStyle = createDataStyle(workbook);
int groupCount = data.getGroupNames() != null ? data.getGroupNames().size() : 0;
int subCols = data.getSubHeaders() != null ? data.getSubHeaders().size() : 0;
int totalCols = 1 + groupCount * subCols; // 1 = rowHeaderLabel
// Row 0: 分组表头
Row groupRow = sheet.createRow(0);
groupRow.setHeightInPoints(24);
Cell labelCell = groupRow.createCell(0);
labelCell.setCellValue(data.getRowHeaderLabel());
labelCell.setCellStyle(groupHeaderStyle);
// 行标题列 Row0+Row1 合并
if (data.getRowHeaderLabel() != null) {
sheet.addMergedRegion(new CellRangeAddress(0, 1, 0, 0));
}
int colIdx = 1;
for (int g = 0; g < groupCount; g++) {
String groupName = data.getGroupNames().get(g);
Cell gc = groupRow.createCell(colIdx);
gc.setCellValue(groupName);
gc.setCellStyle(groupHeaderStyle);
if (subCols > 1) {
sheet.addMergedRegion(new CellRangeAddress(0, 0, colIdx, colIdx + subCols - 1));
}
colIdx += subCols;
}
// Row 1: 子表头
Row subRow = sheet.createRow(1);
subRow.setHeightInPoints(22);
colIdx = 1;
for (int g = 0; g < groupCount; g++) {
for (int s = 0; s < subCols; s++) {
Cell sc = subRow.createCell(colIdx++);
sc.setCellValue(data.getSubHeaders().get(s));
sc.setCellStyle(subHeaderStyle);
}
}
// Row 2+: 数据行
if (data.getRows() != null) {
int rowIdx = 2;
for (List<Object> rowData : data.getRows()) {
Row dataRow = sheet.createRow(rowIdx++);
for (int c = 0; c < Math.min(rowData.size(), totalCols); c++) {
Cell cell = dataRow.createCell(c);
Object val = rowData.get(c);
if (c == 0) {
// 行标题列用居中对齐
cell.setCellStyle(groupHeaderStyle);
} else {
cell.setCellStyle(dataStyle);
}
setCellValue(cell, val);
}
}
}
// 自动列宽
autoSizeColumns(sheet, totalCols);
// 冻结前两行
sheet.createFreezePane(0, 2);
}
/**
* 创建子表头样式浅蓝背景加粗居中带边框
*/
public static CellStyle createSubHeaderStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setFillForegroundColor(IndexedColors.LIGHT_CORNFLOWER_BLUE.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
Font font = workbook.createFont();
font.setBold(true);
font.setFontHeightInPoints((short) 10);
style.setFont(font);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
setThinBorder(style);
return style;
}
// ======================== Excel 生成方法 ========================
/**

View File

@ -0,0 +1,26 @@
<?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.yfd.platform.qgc_export.mapper.SdRiverRMapper">
<!--
动态字段查询:根据 fieldName 参数选择 V/Q/Z 列
利用 STCD + TM + IS_DELETED 复合索引
-->
<select id="selectRawData" resultType="java.util.HashMap">
SELECT
STCD,
TM,
${fieldName} AS VALUE
FROM SD_RIVER_R
WHERE IS_DELETED = 0
AND STCD IN
<foreach collection="stcdList" item="stcd" open="(" separator="," close=")">
#{stcd}
</foreach>
AND TM &gt;= TO_DATE(#{startTime}, 'YYYY-MM-DD HH24:MI:SS')
AND TM &lt; TO_DATE(#{endTime}, 'YYYY-MM-DD HH24:MI:SS')
AND ${fieldName} IS NOT NULL
</select>
</mapper>

View File

@ -0,0 +1,26 @@
<?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.yfd.platform.qgc_export.mapper.SdRiverdaySMapper">
<!--
动态字段查询:根据 fieldName 参数选择 V/Q/Z 列
利用 STCD + DT + IS_DELETED 复合索引
-->
<select id="selectRawData" resultType="java.util.HashMap">
SELECT
STCD,
DT,
${fieldName} AS VALUE
FROM SD_RIVERDAY_S
WHERE IS_DELETED = 0
AND STCD IN
<foreach collection="stcdList" item="stcd" open="(" separator="," close=")">
#{stcd}
</foreach>
AND DT &gt;= TO_DATE(#{startTime}, 'YYYY-MM-DD HH24:MI:SS')
AND DT &lt; TO_DATE(#{endTime}, 'YYYY-MM-DD HH24:MI:SS')
AND ${fieldName} IS NOT NULL
</select>
</mapper>