diff --git a/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java b/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java index 9cc53580..f8e9fd41 100644 --- a/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java +++ b/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java @@ -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() diff --git a/backend/src/main/java/com/yfd/platform/config/SwaggerConfig.java b/backend/src/main/java/com/yfd/platform/config/SwaggerConfig.java index 8e63f008..3007f7e6 100644 --- a/backend/src/main/java/com/yfd/platform/config/SwaggerConfig.java +++ b/backend/src/main/java/com/yfd/platform/config/SwaggerConfig.java @@ -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(); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/controller/QgcExportController.java b/backend/src/main/java/com/yfd/platform/qgc_export/controller/QgcExportController.java new file mode 100644 index 00000000..f879110f --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/controller/QgcExportController.java @@ -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; + +/** + * 综合导出控制器 + *

+ * 前端传参示例: + *

{@code
+ * POST /qgcExport/exportData
+ * {
+ *   "tmDimension": "month",
+ *   "dataField": "v,q,z",
+ *   "months": "2026-08,2026-07",
+ *   "stcd": "00001,00002",
+ *   "sysId": "qgc",
+ *   "isDailyDimension": true,
+ *   "isCalcQec": true
+ * }
+ * }
+ */ +@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 dataFields = parseCsv(dataField); + List monthList = parseCsv(months); + List 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 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()); + } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/SdRiverR.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/SdRiverR.java new file mode 100644 index 00000000..cf823808 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/SdRiverR.java @@ -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; +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/SdRiverdayS.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/SdRiverdayS.java new file mode 100644 index 00000000..2d2165a4 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/SdRiverdayS.java @@ -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; +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/StationMapping.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/StationMapping.java new file mode 100644 index 00000000..6b6990c8 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/StationMapping.java @@ -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; +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/mapper/SdRiverRMapper.java b/backend/src/main/java/com/yfd/platform/qgc_export/mapper/SdRiverRMapper.java new file mode 100644 index 00000000..ed67b693 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/mapper/SdRiverRMapper.java @@ -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 { + + /** + * 批量查询站点在指定时间范围内的原始小时数据 + * + * @param stcdList 测站编码列表 + * @param fieldName 查询字段名(V/Q/Z) + * @param startTime 起始时间 + * @param endTime 结束时间 + * @return [STCD, TM, VALUE] + */ + List> selectRawData(@Param("stcdList") List stcdList, + @Param("fieldName") String fieldName, + @Param("startTime") String startTime, + @Param("endTime") String endTime); +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/mapper/SdRiverdaySMapper.java b/backend/src/main/java/com/yfd/platform/qgc_export/mapper/SdRiverdaySMapper.java new file mode 100644 index 00000000..9caf1cf1 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/mapper/SdRiverdaySMapper.java @@ -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 { + + /** + * 批量查询站点在指定时间范围内的原始日数据 + * + * @param stcdList 测站编码列表 + * @param fieldName 查询字段名(V/Q/Z) + * @param startTime 起始时间 + * @param endTime 结束时间 + * @return [STCD, DT, VALUE] + */ + List> selectRawData(@Param("stcdList") List stcdList, + @Param("fieldName") String fieldName, + @Param("startTime") String startTime, + @Param("endTime") String endTime); +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/mapper/StationMappingMapper.java b/backend/src/main/java/com/yfd/platform/qgc_export/mapper/StationMappingMapper.java new file mode 100644 index 00000000..0a0bc456 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/mapper/StationMappingMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/processor/ExportIndicatorProcessor.java b/backend/src/main/java/com/yfd/platform/qgc_export/processor/ExportIndicatorProcessor.java new file mode 100644 index 00000000..83ac09a3 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/processor/ExportIndicatorProcessor.java @@ -0,0 +1,33 @@ +package com.yfd.platform.qgc_export.processor; + +import java.util.Date; +import java.util.List; + +/** + * 指标处理器接口 — 每种指标(v/q/z/wq/dwt 等)实现此接口 + *

+ * 新增指标时:实现本接口 + 在 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 queryRawData(List stcds, Date startTime, Date endTime, boolean isDaily); +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/processor/IndicatorDataRow.java b/backend/src/main/java/com/yfd/platform/qgc_export/processor/IndicatorDataRow.java new file mode 100644 index 00000000..828c5b7c --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/processor/IndicatorDataRow.java @@ -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; } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/processor/QProcessor.java b/backend/src/main/java/com/yfd/platform/qgc_export/processor/QProcessor.java new file mode 100644 index 00000000..81680f10 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/processor/QProcessor.java @@ -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)"; + } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/processor/RiverIndicatorProcessor.java b/backend/src/main/java/com/yfd/platform/qgc_export/processor/RiverIndicatorProcessor.java new file mode 100644 index 00000000..875ef01d --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/processor/RiverIndicatorProcessor.java @@ -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 三个指标共用查询逻辑 + *

+ * 子类(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 queryRawData(List stcds, Date startTime, Date endTime, boolean isDaily) { + String startStr = SDF_DATETIME.format(startTime); + String endStr = SDF_DATETIME.format(endTime); + + List> rawList; + if (isDaily) { + rawList = sdRiverdaySMapper.selectRawData(stcds, getFieldName(), startStr, endStr); + } else { + rawList = sdRiverRMapper.selectRawData(stcds, getFieldName(), startStr, endStr); + } + + List rows = new ArrayList<>(); + if (rawList != null) { + for (Map 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; + } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/processor/VProcessor.java b/backend/src/main/java/com/yfd/platform/qgc_export/processor/VProcessor.java new file mode 100644 index 00000000..3897e376 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/processor/VProcessor.java @@ -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)"; + } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/processor/ZProcessor.java b/backend/src/main/java/com/yfd/platform/qgc_export/processor/ZProcessor.java new file mode 100644 index 00000000..6aeb9903 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/processor/ZProcessor.java @@ -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)"; + } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/service/IQgcExportService.java b/backend/src/main/java/com/yfd/platform/qgc_export/service/IQgcExportService.java new file mode 100644 index 00000000..53f49973 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/service/IQgcExportService.java @@ -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 dataFields, + List months, + List stationCodes, + boolean isDailyDimension, + String tmDimension, + HttpServletResponse response); +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/service/impl/QgcExportServiceImpl.java b/backend/src/main/java/com/yfd/platform/qgc_export/service/impl/QgcExportServiceImpl.java new file mode 100644 index 00000000..24d2949d --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/service/impl/QgcExportServiceImpl.java @@ -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; + +/** + * 综合导出服务实现 + *

+ * 格式:每月一个 Excel 文件 → ZIP 下载。 + * 每个 Excel 含多个 Sheet(每个指标一个 Sheet),每行是一个统计指标。 + *

+ *     日期        | 电站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
+ * 
+ */ +@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 INDICATOR_LABELS = Collections.unmodifiableList( + Arrays.asList("日均最低", "日均最高", "月内最低", "月内最低时间", "月内最高", "月内最高时间")); + + @Override + public void exportData(List dataFields, List months, + List stationCodes, boolean isDailyDimension, + String tmDimension, HttpServletResponse response) { + // 1. 电站 → 测站映射 + 电站名称 + Map stationInfoMap = resolveStations(stationCodes); + if (stationInfoMap.isEmpty()) { + log.warn("未找到任何电站映射数据,导出取消"); + return; + } + + // 2. 指标处理器匹配 + List processors = resolveProcessors(dataFields); + if (processors.isEmpty()) { + log.warn("未找到匹配的指标处理器,dataFields={}", dataFields); + return; + } + + // 3. 收集电站名称 + 测站列表(保持顺序) + List stationOrder = new ArrayList<>(stationInfoMap.keySet()); + List stationNames = stationOrder.stream() + .map(k -> stationInfoMap.get(k).getStationName()) + .toList(); + List stcds = stationInfoMap.values().stream() + .map(StationInfo::getStcd) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + + // 4. rstcd → stcd 映射(用于聚合时找数据) + Map rstcdToStcd = new LinkedHashMap<>(); + for (Map.Entry e : stationInfoMap.entrySet()) { + if (e.getValue().getStcd() != null) { + rstcdToStcd.put(e.getKey(), e.getValue().getStcd()); + } + } + + // 5. 每月一个 ExcelData + List excelList = new ArrayList<>(); + for (String month : months) { + MonthRange mr = buildMonthRange(month); + if (mr == null) continue; + + List sheets = new ArrayList<>(); + for (ExportIndicatorProcessor processor : processors) { + // 查询 + 聚合这一个月的数据 + List> rows = buildIndicatorRows( + processor, mr, stcds, rstcdToStcd, stationOrder); + if (rows != null) { + List 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 + *

+ * 返回的每一行 = [指标名, station1值, station2值, ...] + */ + private List> buildIndicatorRows( + ExportIndicatorProcessor processor, MonthRange mr, + List stcds, Map rstcdToStcd, + List stationOrder) { + + if (stcds.isEmpty()) return null; + + // 查询日表数据(用于日均最低/最高) + List dailyRows = processor.queryRawData(stcds, mr.getStart(), mr.getEnd(), true); + // 查询小时表数据(用于月内最低/最高) + List hourlyRows = processor.queryRawData(stcds, mr.getStart(), mr.getEnd(), false); + + // 按电站聚合 + Map aggMap = aggregate(dailyRows, hourlyRows, rstcdToStcd); + + // 组装 6 行 + List> rows = new ArrayList<>(6); + for (int i = 0; i < 6; i++) { + List 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 resolveProcessors(List dataFields) { + Map riverMap = new LinkedHashMap<>(); + riverMap.put("v", vProcessor); + riverMap.put("q", qProcessor); + riverMap.put("z", zProcessor); + + List 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 resolveStations(List rstcds) { + Map result = new LinkedHashMap<>(); + if (rstcds == null || rstcds.isEmpty()) return result; + + List mappings = stationMappingMapper.selectList( + new LambdaQueryWrapper().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 aggregate( + List dailyRows, List hourlyRows, + Map rstcdToStcd) { + + Map result = new LinkedHashMap<>(); + + for (String rstcd : rstcdToStcd.keySet()) { + String stcd = rstcdToStcd.get(rstcd); + Aggregation agg = new Aggregation(); + + // 日均最低/最高:筛选该测站的所有日数据 + List 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 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; + } +} diff --git a/backend/src/main/java/com/yfd/platform/utils/ExportZipUtil.java b/backend/src/main/java/com/yfd/platform/utils/ExportZipUtil.java index ad3214d8..dc6d248d 100644 --- a/backend/src/main/java/com/yfd/platform/utils/ExportZipUtil.java +++ b/backend/src/main/java/com/yfd/platform/utils/ExportZipUtil.java @@ -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 sheets; } + // ======================== 分组表头数据模型 ======================== + + /** + * 带分组表头的 Sheet 页数据定义(支持合并单元格) + * + *

生成的 Excel 结构: + *

+     * Row 0: [rowHeaderLabel(合并)] [group0Name(合并 subCols 列)] [group1Name(合并 subCols 列)] ...
+     * Row 1: [                  ] [sub0] [sub1] ... [subN]       [sub0] [sub1] ... [subN]     ...
+     * Row 2+: rowLabel0          data...
+     * Row 3 : rowLabel1          data...
+     * 

+ * + *

使用示例

+ *
{@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, "...", ...));
+     * }
+ */ + @Data + @NoArgsConstructor + public static class GroupedSheetData { + /** Sheet 页名称 */ + private String sheetName; + /** 行标题列名(如"日期",该列在 Row0+Row1 纵向合并) */ + private String rowHeaderLabel; + /** 分组名称列表(如["XX水电站","YY水电站"]) */ + private List groupNames; + /** 子表头列表(每个分组下都有相同的子表头) */ + private List subHeaders; + /** 数据行:每行为 [rowLabel, group0.sub0, group0.sub1, ..., group0.subN, group1.sub0, ...] */ + private List> rows = new ArrayList<>(); + + public GroupedSheetData(String sheetName, String rowHeaderLabel, + List groupNames, List 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 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 + *

+ * 每个 GroupedSheetData 作为一个 .xlsx 文件打入 ZIP。 + */ + public static void exportGroupedToStream(OutputStream os, List 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 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 生成方法 ======================== /** diff --git a/backend/src/main/resources/mapper/qgc_export/SdRiverRMapper.xml b/backend/src/main/resources/mapper/qgc_export/SdRiverRMapper.xml new file mode 100644 index 00000000..9a58d094 --- /dev/null +++ b/backend/src/main/resources/mapper/qgc_export/SdRiverRMapper.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/backend/src/main/resources/mapper/qgc_export/SdRiverdaySMapper.xml b/backend/src/main/resources/mapper/qgc_export/SdRiverdaySMapper.xml new file mode 100644 index 00000000..13899c3d --- /dev/null +++ b/backend/src/main/resources/mapper/qgc_export/SdRiverdaySMapper.xml @@ -0,0 +1,26 @@ + + + + + + + +