浏览代码

各航次子表数据统计功能提交

jincheng 3 周之前
父节点
当前提交
aa5602881a

+ 30 - 0
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/controller/admin/report/KanbanBoardController.java

@@ -221,4 +221,34 @@ public class KanbanBoardController {
                                                       HttpServletResponse response) throws IOException {
         voyageStockBoardService.exportFinancialRevenueDashboardExcel(reqVO, response);
     }
+
+    // ==================== 航次数据情况汇总接口 ====================
+
+    /**
+     * 查询航次数据情况汇总
+     *
+     * @param reqVO 查询条件
+     * @return 航次数据情况汇总(多航次合并为一个结果)
+     */
+    @GetMapping("/voyageDataSummary")
+    @Operation(summary = "查询航次数据情况汇总")
+    public CommonResult<VoyageDataSummaryRespVO> getVoyageDataSummary(@Valid VoyageDataSummaryReqVO reqVO) {
+        VoyageDataSummaryRespVO vo = voyageStockBoardService.getVoyageDataSummary(reqVO);
+        return success(vo);
+    }
+
+    /**
+     * 导出航次数据情况汇总 Excel
+     *
+     * @param reqVO    查询条件
+     * @param response HTTP响应
+     * @throws IOException IO异常
+     */
+    @GetMapping("/voyageDataSummary/export-excel")
+    @Operation(summary = "导出航次数据情况汇总 Excel")
+    @ApiAccessLog(operateType = EXPORT)
+    public void exportVoyageDataSummaryExcel(@Valid VoyageDataSummaryReqVO reqVO,
+                                              HttpServletResponse response) throws IOException {
+        voyageStockBoardService.exportVoyageDataSummaryExcel(reqVO, response);
+    }
 }

+ 23 - 0
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/controller/admin/report/vo/VoyageDataSummaryReqVO.java

@@ -0,0 +1,23 @@
+package com.yc.ship.module.trade.controller.admin.report.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+/**
+ * 航次数据情况汇总 Request VO
+ */
+@Schema(description = "管理后台 - 航次数据情况汇总 Request VO")
+@Data
+public class VoyageDataSummaryReqVO {
+
+    @Schema(description = "游轮ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    @NotNull(message = "游轮ID不能为空")
+    private Long shipId;
+
+    @Schema(description = "航次ID列表", example = "[1,2,3]")
+    private List<Long> voyageIds;
+
+}

+ 108 - 0
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/controller/admin/report/vo/VoyageDataSummaryRespVO.java

@@ -0,0 +1,108 @@
+package com.yc.ship.module.trade.controller.admin.report.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 航次数据情况汇总 Response VO
+ */
+@Schema(description = "管理后台 - 航次数据情况汇总 Response VO")
+@Data
+public class VoyageDataSummaryRespVO {
+
+    @Schema(description = "航次ID")
+    private Long voyageId;
+
+    @Schema(description = "航次名称")
+    private String voyageName;
+
+    @Schema(description = "游轮名称")
+    private String shipName;
+
+    @Schema(description = "航向(上水/下水)")
+    private String direction;
+
+    @Schema(description = "总人数")
+    private Integer totalPeople;
+
+    @Schema(description = "总房间数")
+    private BigDecimal totalRooms;
+
+    @Schema(description = "男性人数")
+    private Integer maleCount;
+
+    @Schema(description = "女性人数")
+    private Integer femaleCount;
+
+    @Schema(description = "男女比例,如 44%:54%")
+    private String maleFemaleRatio;
+
+    @Schema(description = "境内人数")
+    private Integer domesticCount;
+
+    @Schema(description = "境外人数")
+    private Integer overseasCount;
+
+    @Schema(description = "境内外比例,如 46%:54%")
+    private String domesticOverseasRatio;
+
+    @Schema(description = "房型预定列表")
+    private List<DimensionItemVO> roomTypeList;
+
+    @Schema(description = "年龄区间列表")
+    private List<DimensionItemVO> ageGroupList;
+
+    @Schema(description = "境内省份列表")
+    private List<DimensionItemVO> domesticProvinceList;
+
+    @Schema(description = "境外国家/区域列表")
+    private List<DimensionItemVO> internationalRegionList;
+
+    @Schema(description = "团队人数")
+    private Integer teamCount;
+
+    @Schema(description = "散客人数")
+    private Integer individualCount;
+
+    @Schema(description = "团散占比,如 44%:39%")
+    private String teamIndividualRatio;
+
+    @Schema(description = "航次信息列表(多航次时用于展示)")
+    private List<VoyageInfoVO> voyageInfoList;
+
+    /**
+     * 航次信息 VO
+     */
+    @Schema(description = "航次信息")
+    @Data
+    public static class VoyageInfoVO {
+        @Schema(description = "航次ID")
+        private Long voyageId;
+        @Schema(description = "航次名称")
+        private String voyageName;
+        @Schema(description = "航向")
+        private String direction;
+    }
+
+    /**
+     * 统计维度单项 VO
+     */
+    @Schema(description = "统计维度单项")
+    @Data
+    public static class DimensionItemVO {
+
+        @Schema(description = "名称")
+        private String name;
+
+        @Schema(description = "数量")
+        private Integer count;
+
+        @Schema(description = "占比,如 59.31%")
+        private String ratio;
+
+    }
+
+}

+ 27 - 0
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/dal/mysql/report/VoyageStockBoardMapper.java

@@ -116,4 +116,31 @@ public interface VoyageStockBoardMapper {
      * @return 代理商付款列表
      */
     List<FinancialRevenueDashboardRespVO.AgentPaymentItemVO> selectPartialPaidAgentList(@Param("vo") FinancialRevenueDashboardReqVO reqVO);
+
+    /**
+     * 查询航次数据汇总所需的游客详细信息
+     *
+     * @param shipId    游轮ID
+     * @param voyageIds 航次ID列表
+     * @return 游客详细信息列表
+     */
+    List<Map<String, Object>> selectVoyageDataSummaryVisitorList(@Param("shipId") Long shipId, @Param("voyageIds") List<Long> voyageIds);
+
+    /**
+     * 查询航次数据汇总所需的房型信息
+     *
+     * @param shipId    游轮ID
+     * @param voyageIds 航次ID列表
+     * @return 房型信息列表
+     */
+    List<Map<String, Object>> selectVoyageDataSummaryRoomList(@Param("shipId") Long shipId, @Param("voyageIds") List<Long> voyageIds);
+
+    /**
+     * 查询航次基本信息
+     *
+     * @param shipId    游轮ID
+     * @param voyageIds 航次ID列表
+     * @return 航次基本信息列表
+     */
+    List<Map<String, Object>> selectVoyageBaseInfoList(@Param("shipId") Long shipId, @Param("voyageIds") List<Long> voyageIds);
 }

+ 17 - 0
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/service/report/VoyageStockBoardService.java

@@ -119,4 +119,21 @@ public interface VoyageStockBoardService {
      * @throws IOException IO异常
      */
     void exportFinancialRevenueDashboardExcel(@Valid FinancialRevenueDashboardReqVO reqVO, HttpServletResponse response) throws IOException;
+
+    /**
+     * 查询航次数据情况汇总(多航次合并为一个结果)
+     *
+     * @param reqVO 查询条件
+     * @return 航次数据情况汇总
+     */
+    VoyageDataSummaryRespVO getVoyageDataSummary(@Valid VoyageDataSummaryReqVO reqVO);
+
+    /**
+     * 导出航次数据情况汇总 Excel
+     *
+     * @param reqVO    查询条件
+     * @param response HTTP响应
+     * @throws IOException IO异常
+     */
+    void exportVoyageDataSummaryExcel(@Valid VoyageDataSummaryReqVO reqVO, HttpServletResponse response) throws IOException;
 }

+ 554 - 0
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/service/report/impl/VoyageStockBoardServiceImpl.java

@@ -20,6 +20,7 @@ import com.yc.ship.module.trade.dal.mysql.report.VoyageStockBoardMapper;
 import com.yc.ship.module.trade.service.report.VoyageStockBoardService;
 import com.yc.ship.module.trade.utils.IdCardProvinceUtil;
 import com.yc.ship.module.trade.utils.excel.AllVoyageStockBoardExportStyleHandler;
+import com.yc.ship.module.trade.utils.excel.VoyageDataSummaryExportStyleHandler;
 import com.yc.ship.module.trade.utils.excel.VoyageStockBoardExportStyleHandler;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -1405,4 +1406,557 @@ public class VoyageStockBoardServiceImpl implements VoyageStockBoardService {
 
         return result;
     }
+
+    // ==================== 航次数据情况汇总(多航次合并为一个结果) ====================
+
+    @Override
+    public VoyageDataSummaryRespVO getVoyageDataSummary(VoyageDataSummaryReqVO reqVO) {
+        // 1. 查询基础数据(不分航次,一次性查所有)
+        List<Map<String, Object>> visitorList = voyageStockBoardMapper.selectVoyageDataSummaryVisitorList(
+                reqVO.getShipId(), reqVO.getVoyageIds());
+        List<Map<String, Object>> roomList = voyageStockBoardMapper.selectVoyageDataSummaryRoomList(
+                reqVO.getShipId(), reqVO.getVoyageIds());
+        List<Map<String, Object>> voyageBaseList = voyageStockBoardMapper.selectVoyageBaseInfoList(reqVO.getShipId(), reqVO.getVoyageIds());
+
+        if (CollUtil.isEmpty(voyageBaseList)) {
+            return buildEmptyVoyageDataSummaryVO(reqVO.getShipId());
+        }
+
+        // 2. 构建合并后的汇总VO
+        return buildMergedVoyageDataSummaryVO(voyageBaseList, visitorList, roomList);
+    }
+
+    @Override
+    public void exportVoyageDataSummaryExcel(VoyageDataSummaryReqVO reqVO, HttpServletResponse response) throws IOException {
+        VoyageDataSummaryRespVO vo = getVoyageDataSummary(reqVO);
+
+        if (vo == null || vo.getTotalPeople() == null || vo.getTotalPeople() == 0) {
+            ExcelUtils.exportEmpty(response, "航次数据情况汇总");
+            return;
+        }
+
+        // 生成文件名和标题
+        String fileName = buildExportFileName(vo);
+        String title = buildExportTitle(vo);
+
+        // 构建动态表头和数据
+        List<List<String>> head = buildVoyageDataSummaryHeaders(vo);
+        List<List<Object>> exportData = buildVoyageDataSummaryExportData(vo);
+        int dataRowCount = exportData.size();
+
+        response.addHeader("Content-Disposition",
+                "attachment;filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()));
+        response.setContentType("application/vnd.ms-excel;charset=UTF-8");
+
+        EasyExcel.write(response.getOutputStream())
+                .head(head)
+                .relativeHeadRowIndex(1)
+                .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
+                .registerWriteHandler(new VoyageDataSummaryExportStyleHandler(title, dataRowCount))
+                .registerConverter(new LongStringConverter())
+                .sheet("各航次情况子表")
+                .doWrite(exportData);
+    }
+
+    /**
+     * 构建空结果VO
+     */
+    private VoyageDataSummaryRespVO buildEmptyVoyageDataSummaryVO(Long shipId) {
+        VoyageDataSummaryRespVO vo = new VoyageDataSummaryRespVO();
+        vo.setShipName("");
+        vo.setVoyageName("");
+        vo.setDirection("");
+        vo.setTotalPeople(0);
+        vo.setTotalRooms(BigDecimal.ZERO);
+        vo.setMaleCount(0);
+        vo.setFemaleCount(0);
+        vo.setMaleFemaleRatio("0%:0%");
+        vo.setDomesticCount(0);
+        vo.setOverseasCount(0);
+        vo.setDomesticOverseasRatio("0%:0%");
+        vo.setTeamCount(0);
+        vo.setIndividualCount(0);
+        vo.setTeamIndividualRatio("0%:0%");
+        vo.setRoomTypeList(Collections.emptyList());
+        vo.setAgeGroupList(Collections.emptyList());
+        vo.setDomesticProvinceList(Collections.emptyList());
+        vo.setInternationalRegionList(Collections.emptyList());
+        vo.setVoyageInfoList(Collections.emptyList());
+        return vo;
+    }
+
+    /**
+     * 构建合并后的航次数据汇总 VO(多航次合并为一个结果)
+     */
+    private VoyageDataSummaryRespVO buildMergedVoyageDataSummaryVO(List<Map<String, Object>> voyageBaseList,
+                                                                    List<Map<String, Object>> visitorList,
+                                                                    List<Map<String, Object>> roomList) {
+        VoyageDataSummaryRespVO vo = new VoyageDataSummaryRespVO();
+
+        // 游轮名称(所有航次相同)
+        String shipName = voyageBaseList.get(0).get("shipName") != null
+                ? String.valueOf(voyageBaseList.get(0).get("shipName")) : "";
+        vo.setShipName(shipName);
+
+        // 构建航次信息列表
+        List<VoyageDataSummaryRespVO.VoyageInfoVO> voyageInfoList = new ArrayList<>();
+        StringBuilder voyageNameSb = new StringBuilder();
+        StringBuilder directionSb = new StringBuilder();
+        Set<String> directionSet = new LinkedHashSet<>();
+
+        for (int i = 0; i < voyageBaseList.size(); i++) {
+            Map<String, Object> base = voyageBaseList.get(i);
+            Long voyageId = Long.valueOf(String.valueOf(base.get("voyageId")));
+            String vName = formatVoyageName(base);
+            String dir = base.get("directionLabel") != null ? String.valueOf(base.get("directionLabel")) : "";
+
+            VoyageDataSummaryRespVO.VoyageInfoVO info = new VoyageDataSummaryRespVO.VoyageInfoVO();
+            info.setVoyageId(voyageId);
+            info.setVoyageName(vName);
+            info.setDirection(dir);
+            voyageInfoList.add(info);
+
+            if (i > 0) {
+                voyageNameSb.append("、");
+            }
+            voyageNameSb.append(vName);
+            if (StrUtil.isNotBlank(dir)) {
+                directionSet.add(dir);
+            }
+        }
+        vo.setVoyageInfoList(voyageInfoList);
+        vo.setVoyageName(voyageNameSb.toString());
+
+        // 方向:单航次直接显示,多航次用顿号连接
+        if (directionSet.size() == 1) {
+            vo.setDirection(directionSet.iterator().next());
+        } else {
+            vo.setDirection(String.join("、", directionSet));
+        }
+
+        // ========== 合并统计计算 ==========
+
+        // 总房间数(按房型名称合并)
+        Map<String, BigDecimal> roomTypeCountMap = new HashMap<>();
+        for (Map<String, Object> room : roomList) {
+            String roomModelName = room.get("roomModelName") != null ? String.valueOf(room.get("roomModelName")) : "";
+            BigDecimal roomNum = new BigDecimal(String.valueOf(room.get("roomNum")));
+            if (StrUtil.isNotBlank(roomModelName) && roomNum != null && roomNum.compareTo(BigDecimal.ZERO) > 0) {
+                roomTypeCountMap.merge(roomModelName, roomNum, BigDecimal::add);
+            }
+        }
+        BigDecimal totalRooms = BigDecimal.valueOf(roomTypeCountMap.values().stream().mapToDouble(BigDecimal::doubleValue).sum());
+        vo.setTotalRooms(totalRooms);
+
+        // 总人数
+        int totalPeople = visitorList.size();
+        vo.setTotalPeople(totalPeople);
+
+        // 性别统计
+        int maleCount = 0;
+        int femaleCount = 0;
+        for (Map<String, Object> v : visitorList) {
+            Integer gender = parseIntValue(v.get("gender"));
+            if (gender != null) {
+                if (gender == 1) maleCount++;
+                else if (gender == 0) femaleCount++;
+            }
+        }
+        vo.setMaleCount(maleCount);
+        vo.setFemaleCount(femaleCount);
+        int genderTotal = maleCount + femaleCount;
+        vo.setMaleFemaleRatio(genderTotal > 0
+                ? calcRatio(maleCount, genderTotal) + ":" + calcRatio(femaleCount, genderTotal)
+                : "0%:0%");
+
+        // 境内外统计(合并所有航次)
+        int domesticCount = 0;
+        int overseasCount = 0;
+        Map<String, Integer> provinceCountMap = new HashMap<>();
+        Map<String, Integer> countryCountMap = new HashMap<>();
+
+        for (Map<String, Object> v : visitorList) {
+            String nationalityName = v.get("nationalityName") != null ? String.valueOf(v.get("nationalityName")) : "";
+            String credentialNo = v.get("credentialNo") != null ? String.valueOf(v.get("credentialNo")) : "";
+            Integer credentialType = parseIntValue(v.get("credentialType"));
+
+            String province = IdCardProvinceUtil.getProvinceName(credentialType, credentialNo, nationalityName);
+
+            if (StrUtil.isNotBlank(province)) {
+                domesticCount++;
+                provinceCountMap.merge(province, 1, Integer::sum);
+            } else {
+                String country = StrUtil.isNotBlank(nationalityName) ? nationalityName : "其他";
+                overseasCount++;
+                countryCountMap.merge(country, 1, Integer::sum);
+            }
+        }
+        vo.setDomesticCount(domesticCount);
+        vo.setOverseasCount(overseasCount);
+        int totalDomesticOverseas = domesticCount + overseasCount;
+        vo.setDomesticOverseasRatio(totalDomesticOverseas > 0
+                ? calcRatio(domesticCount, totalDomesticOverseas) + ":" + calcRatio(overseasCount, totalDomesticOverseas)
+                : "0%:0%");
+
+        // 房型统计(合并后重新计算占比)
+        List<VoyageDataSummaryRespVO.DimensionItemVO> roomTypeList = roomTypeCountMap.entrySet().stream()
+                .sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
+                .map(entry -> {
+                    VoyageDataSummaryRespVO.DimensionItemVO item = new VoyageDataSummaryRespVO.DimensionItemVO();
+                    item.setName(entry.getKey());
+                    item.setCount(entry.getValue().intValue());
+                    item.setRatio(calcRatio(entry.getValue().intValue(), totalRooms.intValue()));
+                    return item;
+                })
+                .collect(Collectors.toList());
+        vo.setRoomTypeList(roomTypeList);
+
+        // 年龄区间统计(合并所有航次)
+        List<VoyageDataSummaryRespVO.DimensionItemVO> ageGroupList = analyzeAgeGroupsMerged(visitorList);
+        vo.setAgeGroupList(ageGroupList);
+
+        // 境内省份(合并后重新计算占比)
+        int finalDomesticCount = domesticCount;
+        List<VoyageDataSummaryRespVO.DimensionItemVO> domesticProvinceList = provinceCountMap.entrySet().stream()
+                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
+                .map(entry -> {
+                    VoyageDataSummaryRespVO.DimensionItemVO item = new VoyageDataSummaryRespVO.DimensionItemVO();
+                    item.setName(entry.getKey());
+                    item.setCount(entry.getValue());
+                    item.setRatio(calcRatio(entry.getValue(), finalDomesticCount));
+                    return item;
+                })
+                .collect(Collectors.toList());
+        vo.setDomesticProvinceList(domesticProvinceList);
+
+        // 境外国家(合并后重新计算占比)
+        int finalOverseasCount = overseasCount;
+        List<VoyageDataSummaryRespVO.DimensionItemVO> internationalRegionList = countryCountMap.entrySet().stream()
+                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
+                .map(entry -> {
+                    VoyageDataSummaryRespVO.DimensionItemVO item = new VoyageDataSummaryRespVO.DimensionItemVO();
+                    item.setName(entry.getKey());
+                    item.setCount(entry.getValue());
+                    item.setRatio(calcRatio(entry.getValue(), finalOverseasCount));
+                    return item;
+                })
+                .collect(Collectors.toList());
+        vo.setInternationalRegionList(internationalRegionList);
+
+        // 团散统计:按订单游客数判断,>=10人为团,<10人为散
+        Map<String, Long> orderVisitorCount = visitorList.stream()
+                .collect(Collectors.groupingBy(
+                        v -> String.valueOf(v.get("orderId")),
+                        Collectors.counting()
+                ));
+
+        int teamCount = 0;
+        int individualCount = 0;
+        for (Long visitorCount : orderVisitorCount.values()) {
+            if (visitorCount >= 10) {
+                teamCount += visitorCount;
+            } else {
+                individualCount += visitorCount;
+            }
+        }
+        vo.setTeamCount(teamCount);
+        vo.setIndividualCount(individualCount);
+        int teamIndividualTotal = teamCount + individualCount;
+        vo.setTeamIndividualRatio(teamIndividualTotal > 0
+                ? calcRatio(teamCount, teamIndividualTotal) + ":" + calcRatio(individualCount, teamIndividualTotal)
+                : "0%:0%");
+
+        return vo;
+    }
+
+    /**
+     * 解析整数值(兼容Number和String)
+     */
+    private Integer parseIntValue(Object obj) {
+        if (obj == null) return null;
+        if (obj instanceof Number) return ((Number) obj).intValue();
+        try {
+            return Integer.parseInt(String.valueOf(obj));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * 格式化航次名称(如 0418航次)
+     */
+    private String formatVoyageName(Map<String, Object> base) {
+        Object startTimeObj = base.get("startTime");
+        if (startTimeObj == null) return "";
+        String startTimeStr = String.valueOf(startTimeObj);
+        try {
+            if (startTimeStr.length() >= 10) {
+                String monthDay = startTimeStr.substring(5, 7) + startTimeStr.substring(8, 10);
+                return monthDay + "航次";
+            }
+        } catch (Exception ignored) {}
+        return startTimeStr;
+    }
+
+    /**
+     * 年龄段分组统计(合并版)
+     */
+    private List<VoyageDataSummaryRespVO.DimensionItemVO> analyzeAgeGroupsMerged(List<Map<String, Object>> visitorList) {
+        String[] ageGroups = {"12岁以下", "12岁-18岁", "18岁-30岁", "30岁-45岁", "45岁-60岁", "60岁以上"};
+        Map<String, Integer> ageGroupCountMap = new LinkedHashMap<>();
+        for (String group : ageGroups) {
+            ageGroupCountMap.put(group, 0);
+        }
+
+        int totalWithAge = 0;
+        for (Map<String, Object> v : visitorList) {
+            Object ageObj = v.get("age");
+            if (ageObj == null) continue;
+            int age;
+            try {
+                age = Integer.parseInt(String.valueOf(ageObj));
+            } catch (Exception e) {
+                continue;
+            }
+            totalWithAge++;
+            String group = getAgeGroupLabel(age);
+            ageGroupCountMap.merge(group, 1, Integer::sum);
+        }
+
+        List<VoyageDataSummaryRespVO.DimensionItemVO> result = new ArrayList<>();
+        for (String group : ageGroups) {
+            Integer count = ageGroupCountMap.getOrDefault(group, 0);
+            VoyageDataSummaryRespVO.DimensionItemVO item = new VoyageDataSummaryRespVO.DimensionItemVO();
+            item.setName(group);
+            item.setCount(count);
+            item.setRatio(calcRatio(count, totalWithAge));
+            result.add(item);
+        }
+        return result;
+    }
+
+    /**
+     * 获取年龄段标签
+     */
+    private String getAgeGroupLabel(int age) {
+        if (age < 12) return "12岁以下";
+        else if (age < 18) return "12岁-18岁";
+        else if (age < 30) return "18岁-30岁";
+        else if (age < 45) return "30岁-45岁";
+        else if (age < 60) return "45岁-60岁";
+        else return "60岁以上";
+    }
+
+    /**
+     * 生成导出文件名
+     * 单航次:长江行·揽月 0418航次(下水)数据情况.xls
+     * 多航次:长江行·揽月 各航次数据情况.xls
+     */
+    private String buildExportFileName(VoyageDataSummaryRespVO vo) {
+        String shipName = vo.getShipName() != null ? vo.getShipName() : "";
+        List<VoyageDataSummaryRespVO.VoyageInfoVO> infoList = vo.getVoyageInfoList();
+        if (CollUtil.isEmpty(infoList) || infoList.size() == 1) {
+            String voyageName = vo.getVoyageName() != null ? vo.getVoyageName() : "";
+            String direction = vo.getDirection() != null ? vo.getDirection() : "";
+            return shipName + " " + voyageName + "(" + direction + ")数据情况.xls";
+        }
+        return shipName + " 各航次数据情况.xls";
+    }
+
+    /**
+     * 构建导出标题
+     */
+    private String buildExportTitle(VoyageDataSummaryRespVO vo) {
+        String shipName = vo.getShipName() != null ? vo.getShipName() : "";
+        List<VoyageDataSummaryRespVO.VoyageInfoVO> infoList = vo.getVoyageInfoList();
+        if (CollUtil.isEmpty(infoList) || infoList.size() == 1) {
+            String voyageName = vo.getVoyageName() != null ? vo.getVoyageName() : "";
+            String direction = vo.getDirection() != null ? vo.getDirection() : "";
+            return shipName + " " + voyageName + "(" + direction + ")数据情况";
+        }
+        return shipName + " 各航次数据情况";
+    }
+
+    /**
+     * 构建航次数据汇总导出表头(2层多级表头,共18列)
+     */
+    private List<List<String>> buildVoyageDataSummaryHeaders(VoyageDataSummaryRespVO vo) {
+        List<List<String>> head = new ArrayList<>();
+
+        // 航次(1列)
+        head.add(Arrays.asList("航次", "航次"));
+
+        // 数据汇总(2列)
+        head.add(Arrays.asList("数据汇总", ""));
+        head.add(Arrays.asList("数据汇总", ""));
+
+        // 预定间数及占比(3列)
+        head.add(Arrays.asList("预定间数及占比", "房型"));
+        head.add(Arrays.asList("预定间数及占比", "间数"));
+        head.add(Arrays.asList("预定间数及占比", "占比"));
+
+        // 年龄区间(3列)
+        head.add(Arrays.asList("年龄区间", "年龄"));
+        head.add(Arrays.asList("年龄区间", "人数"));
+        head.add(Arrays.asList("年龄区间", "占比"));
+
+        // 境内(3列),显示人数/占比
+        String domesticHeader = "境内";
+        if (vo.getDomesticCount() != null && vo.getOverseasCount() != null) {
+            int total = vo.getDomesticCount() + vo.getOverseasCount();
+            if (total > 0) {
+                domesticHeader = "境内(" + vo.getDomesticCount() + "/" + calcRatio(vo.getDomesticCount(), total) + ")";
+            }
+        }
+        head.add(Arrays.asList(domesticHeader, "省份"));
+        head.add(Arrays.asList(domesticHeader, "人数"));
+        head.add(Arrays.asList(domesticHeader, "占比"));
+
+        // 境外(3列),显示人数/占比
+        String overseasHeader = "境外";
+        if (vo.getDomesticCount() != null && vo.getOverseasCount() != null) {
+            int total = vo.getDomesticCount() + vo.getOverseasCount();
+            if (total > 0) {
+                overseasHeader = "境外(" + vo.getOverseasCount() + "/" + calcRatio(vo.getOverseasCount(), total) + ")";
+            }
+        }
+        head.add(Arrays.asList(overseasHeader, "国家/区域"));
+        head.add(Arrays.asList(overseasHeader, "人数"));
+        head.add(Arrays.asList(overseasHeader, "占比"));
+
+        // 团散占比(3列)
+        head.add(Arrays.asList("团散占比", "团队人数"));
+        head.add(Arrays.asList("团散占比", "散客人数"));
+        head.add(Arrays.asList("团散占比", "占比"));
+
+        return head;
+    }
+
+    /**
+     * 构建航次数据汇总导出数据(单个VO)
+     */
+    private List<List<Object>> buildVoyageDataSummaryExportData(VoyageDataSummaryRespVO vo) {
+        List<List<Object>> result = new ArrayList<>();
+
+        List<VoyageDataSummaryRespVO.DimensionItemVO> roomList = vo.getRoomTypeList() != null ? vo.getRoomTypeList() : Collections.emptyList();
+        List<VoyageDataSummaryRespVO.DimensionItemVO> ageList = vo.getAgeGroupList() != null ? vo.getAgeGroupList() : Collections.emptyList();
+        List<VoyageDataSummaryRespVO.DimensionItemVO> domesticList = vo.getDomesticProvinceList() != null ? vo.getDomesticProvinceList() : Collections.emptyList();
+        List<VoyageDataSummaryRespVO.DimensionItemVO> internationalList = vo.getInternationalRegionList() != null ? vo.getInternationalRegionList() : Collections.emptyList();
+
+        // 数据汇总固定4行
+        String[] summaryLabels = {"总人数", "总房间数", "男女比例", "境内外比例"};
+        Object[] summaryValues = {vo.getTotalPeople(), vo.getTotalRooms(), vo.getMaleFemaleRatio(), vo.getDomesticOverseasRatio()};
+
+        // 航次列显示内容:多航次时展示所有航次
+        String voyageCellContent = buildVoyageCellContent(vo);
+
+        int maxRows = Math.max(4, Math.max(roomList.size(),
+                Math.max(ageList.size(),
+                        Math.max(domesticList.size(), internationalList.size()))));
+
+        for (int i = 0; i < maxRows; i++) {
+            List<Object> row = new ArrayList<>();
+
+            // 航次(第1行显示,后续为空,由WriteHandler合并)
+            if (i == 0) {
+                row.add(voyageCellContent);
+            } else {
+                row.add("");
+            }
+
+            // 数据汇总(标签和数值)
+            if (i < 4) {
+                row.add(summaryLabels[i]);
+                row.add(summaryValues[i]);
+            } else {
+                row.add("");
+                row.add("");
+            }
+
+            // 房型
+            if (i < roomList.size()) {
+                row.add(roomList.get(i).getName());
+                row.add(roomList.get(i).getCount());
+                row.add(roomList.get(i).getRatio());
+            } else {
+                row.add("");
+                row.add("");
+                row.add("");
+            }
+
+            // 年龄
+            if (i < ageList.size()) {
+                row.add(ageList.get(i).getName());
+                row.add(ageList.get(i).getCount());
+                row.add(ageList.get(i).getRatio());
+            } else {
+                row.add("");
+                row.add("");
+                row.add("");
+            }
+
+            // 境内
+            if (i < domesticList.size()) {
+                row.add(domesticList.get(i).getName());
+                row.add(domesticList.get(i).getCount());
+                row.add(domesticList.get(i).getRatio());
+            } else {
+                row.add("");
+                row.add("");
+                row.add("");
+            }
+
+            // 境外
+            if (i < internationalList.size()) {
+                row.add(internationalList.get(i).getName());
+                row.add(internationalList.get(i).getCount());
+                row.add(internationalList.get(i).getRatio());
+            } else {
+                row.add("");
+                row.add("");
+                row.add("");
+            }
+
+            // 团散占比(只有第1行)
+            if (i == 0) {
+                row.add(vo.getTeamCount());
+                row.add(vo.getIndividualCount());
+                row.add(vo.getTeamIndividualRatio());
+            } else {
+                row.add("");
+                row.add("");
+                row.add("");
+            }
+
+            result.add(row);
+        }
+
+        return result;
+    }
+
+    /**
+     * 构建航次列的显示内容
+     * 单航次:0418航次\n(下水)
+     * 多航次:0418(下水)\n0422(上水)\n...
+     */
+    private String buildVoyageCellContent(VoyageDataSummaryRespVO vo) {
+        List<VoyageDataSummaryRespVO.VoyageInfoVO> infoList = vo.getVoyageInfoList();
+        if (CollUtil.isEmpty(infoList)) {
+            return vo.getVoyageName() + "\n(" + vo.getDirection() + ")";
+        }
+        if (infoList.size() == 1) {
+            return infoList.get(0).getVoyageName() + "\n(" + infoList.get(0).getDirection() + ")";
+        }
+        // 多航次:每行一个航次
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < infoList.size(); i++) {
+            VoyageDataSummaryRespVO.VoyageInfoVO info = infoList.get(i);
+            if (i > 0) sb.append("\n");
+            sb.append(info.getVoyageName());
+            if (StrUtil.isNotBlank(info.getDirection())) {
+                sb.append("(").append(info.getDirection()).append(")");
+            }
+        }
+        return sb.toString();
+    }
 }

+ 11 - 5
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/utils/IdCardProvinceUtil.java

@@ -237,22 +237,28 @@ public class IdCardProvinceUtil {
      * @return 省份名称,无法解析时返回空字符串
      */
     public static String getProvinceName(Integer credentialType, String credentialNo, String  nationalityName) {
-        if (credentialType == null) {
-            return "";
-        }
         // 台湾通行证、台胞证 → 直接返回"台湾"
-        if (credentialType == CREDENTIAL_TYPE_TAIWAN_PASS
+       /* if (credentialType == CREDENTIAL_TYPE_TAIWAN_PASS
                 || credentialType == CREDENTIAL_TYPE_TAIWAN_COMPATRIOT_PERMIT) {
             return "台湾";
+        }*/
+
+        if ("中国台湾".equals(nationalityName)) {
+            return "台湾";
         }
 
-        if (nationalityName.contains("香港")) {
+        if ("中国香港".equals(nationalityName)) {
             return "香港";
         }
 
         if (nationalityName.contains("澳门")) {
             return "澳门";
         }
+
+        if (credentialType == null) {
+            return "";
+        }
+
         // 非可解析的证件类型,直接返回空
         if (!PARSEABLE_CREDENTIAL_TYPES.contains(credentialType)  && !"中国".equals(nationalityName)) {
             return "";

+ 112 - 0
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/utils/excel/VoyageDataSummaryExportStyleHandler.java

@@ -0,0 +1,112 @@
+package com.yc.ship.module.trade.utils.excel;
+
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.excel.write.handler.CellWriteHandler;
+import com.alibaba.excel.write.handler.SheetWriteHandler;
+import com.alibaba.excel.write.handler.context.CellWriteHandlerContext;
+import com.alibaba.excel.write.handler.context.SheetWriteHandlerContext;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.ss.util.CellRangeAddress;
+import org.apache.poi.xssf.usermodel.XSSFCellStyle;
+import org.apache.poi.xssf.usermodel.XSSFColor;
+
+/**
+ * 航次数据情况汇总 导出样式处理器
+ * 处理顶部标题行、表头样式、航次列与数据汇总列的合并
+ */
+public class VoyageDataSummaryExportStyleHandler implements SheetWriteHandler, CellWriteHandler {
+
+    private static final int TOTAL_COLUMNS = 18;
+
+    private final String title;
+    private final int dataRowCount;
+
+    private CellStyle headerStyle;
+    private CellStyle titleStyle;
+
+    public VoyageDataSummaryExportStyleHandler(String title, int dataRowCount) {
+        this.title = title;
+        this.dataRowCount = dataRowCount;
+    }
+
+    @Override
+    public void afterSheetCreate(SheetWriteHandlerContext context) {
+        Sheet sheet = context.getWriteSheetHolder().getSheet();
+        Workbook workbook = sheet.getWorkbook();
+
+        // 第0行:大标题(合并所有列)
+        if (StrUtil.isNotBlank(title)) {
+            Row titleRow = sheet.createRow(0);
+            Cell titleCell = titleRow.createCell(0);
+            titleCell.setCellValue(title);
+            titleCell.setCellStyle(createTitleStyle(workbook));
+            // 合并标题行,跨所有18列
+            sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, TOTAL_COLUMNS - 1));
+            titleRow.setHeightInPoints(28);
+        }
+
+        // 数据从第3行开始(0=标题, 1-2=表头, 3+=数据)
+        int dataStartRow = 3;
+
+        if (dataRowCount > 1) {
+            // 合并航次列(第0列)
+            sheet.addMergedRegion(new CellRangeAddress(dataStartRow, dataStartRow + dataRowCount - 1, 0, 0));
+
+            // 合并数据汇总列的4个标签单元格(第1列)
+            // 数据汇总有4行:总人数、总房间数、男女比例、境内外比例
+            int summaryRows = Math.min(4, dataRowCount);
+            if (summaryRows > 1) {
+                // 数据汇总标签列(第1列)不需要合并,每行显示不同标签
+                // 但数据汇总值列(第2列)也不需要合并
+            }
+        }
+    }
+
+    @Override
+    public void afterCellDispose(CellWriteHandlerContext context) {
+        if (Boolean.TRUE.equals(context.getHead())) {
+            Cell cell = context.getCell();
+            cell.setCellStyle(createHeaderStyle(cell.getSheet().getWorkbook()));
+        }
+    }
+
+    private CellStyle createTitleStyle(Workbook workbook) {
+        if (titleStyle == null) {
+            titleStyle = workbook.createCellStyle();
+            titleStyle.setAlignment(HorizontalAlignment.CENTER);
+            titleStyle.setVerticalAlignment(VerticalAlignment.CENTER);
+            titleStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+            if (titleStyle instanceof XSSFCellStyle) {
+                XSSFColor color = new XSSFColor(new byte[]{(byte) 0xD6, (byte) 0xE3, (byte) 0xF8}, null);
+                ((XSSFCellStyle) titleStyle).setFillForegroundColor(color);
+            }
+            Font font = workbook.createFont();
+            font.setBold(true);
+            font.setFontHeightInPoints((short) 12);
+            titleStyle.setFont(font);
+        }
+        return titleStyle;
+    }
+
+    private CellStyle createHeaderStyle(Workbook workbook) {
+        if (headerStyle == null) {
+            headerStyle = workbook.createCellStyle();
+            headerStyle.setAlignment(HorizontalAlignment.CENTER);
+            headerStyle.setVerticalAlignment(VerticalAlignment.CENTER);
+            headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+            headerStyle.setBorderTop(BorderStyle.THIN);
+            headerStyle.setBorderBottom(BorderStyle.THIN);
+            headerStyle.setBorderLeft(BorderStyle.THIN);
+            headerStyle.setBorderRight(BorderStyle.THIN);
+            if (headerStyle instanceof XSSFCellStyle) {
+                XSSFColor color = new XSSFColor(new byte[]{(byte) 0xB4, (byte) 0xC7, (byte) 0xE7}, null);
+                ((XSSFCellStyle) headerStyle).setFillForegroundColor(color);
+            }
+            Font font = workbook.createFont();
+            font.setBold(true);
+            font.setFontHeightInPoints((short) 10);
+            headerStyle.setFont(font);
+        }
+        return headerStyle;
+    }
+}

+ 85 - 0
ship-module-trade/ship-module-trade-biz/src/main/resources/mapper/report/VoyageStockBoardMapper.xml

@@ -365,4 +365,89 @@
         ORDER BY amount DESC
     </select>
 
+    <!-- ==================== 航次数据情况汇总 ==================== -->
+
+    <!-- 查询航次数据汇总所需的游客详细信息 -->
+    <select id="selectVoyageDataSummaryVisitorList" resultType="java.util.HashMap">
+        SELECT
+            tv.id as visitorId,
+            tv.gender,
+            tv.age,
+            tv.credential_type as credentialType,
+            tv.credential_no as credentialNo,
+            tv.nationality,
+            a.name as nationalityName,
+            tor.id as orderId,
+            tor.voyage_id as voyageId
+        FROM trade_visitor tv
+        INNER JOIN trade_order tor ON tv.order_id = tor.id AND tor.deleted = 0
+        LEFT JOIN area a ON tv.nationality = a.id
+        LEFT JOIN product_voyage pv ON tor.voyage_id = pv.id
+        WHERE tv.deleted = 0
+        AND tor.order_status IN (15, 14, 13, 10, 12, 9, 8, 7, 6, 5, 4, 3, 1, 0)
+        <if test="shipId != null">
+            AND pv.ship_id = #{shipId}
+        </if>
+        <if test="voyageIds != null and voyageIds.size() > 0">
+            AND tor.voyage_id IN
+            <foreach collection="voyageIds" item="item" separator="," open="(" close=")">
+                #{item}
+            </foreach>
+        </if>
+    </select>
+
+    <!-- 查询航次数据汇总所需的房型信息 -->
+    <select id="selectVoyageDataSummaryRoomList" resultType="java.util.HashMap">
+        SELECT
+            tor.voyage_id as voyageId,
+            torm.room_model_name as roomModelName,
+            SUM(COALESCE(torm.use_room_num, 0)) as roomNum
+        FROM trade_order_room_model torm
+        INNER JOIN trade_order tor ON torm.order_id = tor.id AND tor.deleted = 0
+        WHERE torm.deleted = 0
+        AND tor.order_status IN (15, 14, 13, 10, 12, 9, 8, 7, 6, 5, 4, 3, 1, 0)
+        AND torm.room_model_name IS NOT NULL
+        AND torm.room_model_name != ''
+        <if test="shipId != null">
+            AND tor.ship_id = #{shipId}
+        </if>
+        <if test="voyageIds != null and voyageIds.size() > 0">
+            AND tor.voyage_id IN
+            <foreach collection="voyageIds" item="item" separator="," open="(" close=")">
+                #{item}
+            </foreach>
+        </if>
+        GROUP BY tor.voyage_id, torm.room_model_name
+        ORDER BY SUM(COALESCE(torm.use_room_num, 0)) DESC
+    </select>
+
+    <!-- 查询航次基本信息 -->
+    <select id="selectVoyageBaseInfoList" resultType="java.util.HashMap">
+        SELECT
+            pv.id as voyageId,
+            pv.name as voyageName,
+            pv.start_time as startTime,
+            rs.name as shipName,
+            rr.direction as direction,
+            CASE rr.direction
+                WHEN 1 THEN '上水'
+                WHEN 2 THEN '下水'
+                ELSE '未知'
+            END as directionLabel
+        FROM product_voyage pv
+        INNER JOIN resource_ship rs ON pv.ship_id = rs.id AND rs.deleted = 0
+        INNER JOIN resource_route rr ON pv.route_id = rr.id AND rr.deleted = 0
+        WHERE pv.deleted = 0
+        <if test="shipId != null">
+            AND pv.ship_id = #{shipId}
+        </if>
+        <if test="voyageIds != null and voyageIds.size() > 0">
+            AND pv.id IN
+            <foreach collection="voyageIds" item="item" separator="," open="(" close=")">
+                #{item}
+            </foreach>
+        </if>
+        ORDER BY pv.start_time ASC
+    </select>
+
 </mapper>