Преглед изворни кода

新增客源地分析看板接口

jincheng пре 1 месец
родитељ
комит
137cd9c309
13 измењених фајлова са 660 додато и 19 уклоњено
  1. 28 0
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/controller/admin/report/KanbanBoardController.java
  2. 23 0
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/controller/admin/report/vo/VisitorSourceDashboardReqVO.java
  3. 55 0
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/controller/admin/report/vo/VisitorSourceDashboardRespVO.java
  4. 30 0
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/controller/admin/report/vo/VisitorSourceExportVO.java
  5. 21 0
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/controller/admin/report/vo/VisitorSourceInternationalExportVO.java
  6. 4 0
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/controller/admin/report/vo/YangtzePassengerSummaryRespVO.java
  7. 10 0
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/dal/mysql/report/VoyageStockBoardMapper.java
  8. 17 0
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/service/report/VoyageStockBoardService.java
  9. 5 2
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/service/report/impl/OpsDailyServiceImpl.java
  10. 280 0
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/service/report/impl/VoyageStockBoardServiceImpl.java
  11. 155 9
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/utils/IdCardProvinceUtil.java
  12. 21 0
      ship-module-trade/ship-module-trade-biz/src/main/resources/mapper/report/VoyageStockBoardMapper.xml
  13. 11 8
      ship-module-trade/ship-module-trade-biz/src/main/resources/mapper/report/YangtzePassengerSummaryMapper.xml

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

@@ -91,4 +91,32 @@ public class KanbanBoardController {
         return success(page);
     }
 
+    /**
+     * 查询客源地分析看板数据
+     *
+     * @param reqVO 查询条件
+     * @return 客源地分析看板数据
+     */
+    @GetMapping("/visitorSourceDashboard")
+    @Operation(summary = "查询客源地分析看板数据")
+    public CommonResult<VisitorSourceDashboardRespVO> getVisitorSourceDashboard(@Valid VisitorSourceDashboardReqVO reqVO) {
+        VisitorSourceDashboardRespVO respVO = voyageStockBoardService.getVisitorSourceDashboard(reqVO);
+        return success(respVO);
+    }
+
+    /**
+     * 导出客源地分析看板 Excel
+     *
+     * @param reqVO    查询条件
+     * @param response HTTP响应
+     * @throws IOException IO异常
+     */
+    @GetMapping("/visitorSourceDashboard/export-excel")
+    @Operation(summary = "导出客源地分析看板 Excel")
+    @ApiAccessLog(operateType = EXPORT)
+    public void exportVisitorSourceDashboardExcel(@Valid VisitorSourceDashboardReqVO reqVO,
+                                                   HttpServletResponse response) throws IOException {
+        voyageStockBoardService.exportVisitorSourceDashboardExcel(reqVO, response);
+    }
+
 }

+ 23 - 0
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/controller/admin/report/vo/VisitorSourceDashboardReqVO.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;
+
+/**
+ * 客源地分析看板请求 VO
+ */
+@Schema(description = "管理后台 - 客源地分析看板请求")
+@Data
+public class VisitorSourceDashboardReqVO {
+
+    @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;
+
+}

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

@@ -0,0 +1,55 @@
+package com.yc.ship.module.trade.controller.admin.report.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客源地分析看板响应 VO
+ */
+@Schema(description = "管理后台 - 客源地分析看板响应")
+@Data
+public class VisitorSourceDashboardRespVO {
+
+    @Schema(description = "国内客源地列表(按省份分组,按人数降序)")
+    private List<VisitorSourceItemVO> domesticSourceList;
+
+    @Schema(description = "国际客源地列表(按国家分组,按人数降序)")
+    private List<VisitorSourceItemVO> internationalSourceList;
+
+    @Schema(description = "省份来源排行(省份名称列表,按人数降序)")
+    private List<String> provinceRankList;
+
+    @Schema(description = "全国城市来源排行(城市名称列表,按人数降序)")
+    private List<String> nationalCityRankList;
+
+    @Schema(description = "省份-城市排行映射(key=省份名称, value=该省份按人数排序的城市名称列表)")
+    private Map<String, List<String>> provinceCityRankMap;
+
+    @Schema(description = "国内总人数")
+    private Integer domesticTotal;
+
+    @Schema(description = "国际总人数")
+    private Integer internationalTotal;
+
+    /**
+     * 客源地单项 VO
+     */
+    @Schema(description = "客源地单项")
+    @Data
+    public static class VisitorSourceItemVO {
+
+        @Schema(description = "名称(省份/国家)")
+        private String name;
+
+        @Schema(description = "人数")
+        private Integer count;
+
+        @Schema(description = "占比,如 60.00%")
+        private String ratio;
+
+    }
+
+}

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

@@ -0,0 +1,30 @@
+package com.yc.ship.module.trade.controller.admin.report.vo;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+/**
+ * 客源地分析看板导出 VO
+ */
+@Data
+public class VisitorSourceExportVO {
+
+    @ExcelProperty("省份")
+    private String province;
+
+    @ExcelProperty("人数")
+    private Integer count;
+
+    @ExcelProperty("占比")
+    private String ratio;
+
+    @ExcelProperty("省份来源排行")
+    private String provinceRank;
+
+    @ExcelProperty("该省份城市来源排行")
+    private String cityRank;
+
+    @ExcelProperty("全国城市来源排行")
+    private String nationalCityRank;
+
+}

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

@@ -0,0 +1,21 @@
+package com.yc.ship.module.trade.controller.admin.report.vo;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+/**
+ * 国际市场客源地结构示例导出 VO
+ */
+@Data
+public class VisitorSourceInternationalExportVO {
+
+    @ExcelProperty("国家")
+    private String country;
+
+    @ExcelProperty("人数")
+    private Integer count;
+
+    @ExcelProperty("占比")
+    private String ratio;
+
+}

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

@@ -71,6 +71,10 @@ public class YangtzePassengerSummaryRespVO {
     @ExcelProperty("预计收客人数")
     private Integer estimatedPassengers;
 
+    @Schema(description = "备注", example = "")
+    @ExcelProperty("备注")
+    private String remark;
+
     // ========== 财务情况 ==========
 
     @Schema(description = "财务-应收", example = "500000.00")

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

@@ -2,6 +2,7 @@ package com.yc.ship.module.trade.dal.mysql.report;
 
 import com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardItemVO;
 import com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardRespVO;
+import com.yc.ship.module.trade.dal.dataobject.order.TradeVisitorDO;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
@@ -37,4 +38,13 @@ public interface VoyageStockBoardMapper {
      * 查询指定航次的预订间数(用于同比环比)
      */
     List<VoyageStockBoardItemVO> selectBookNumByVoyageId(@Param("voyageId") Long voyageId);
+
+    /**
+     * 查询客源地分析看板所需的游客列表
+     *
+     * @param shipId    游轮ID
+     * @param voyageIds 航次ID列表
+     * @return 游客列表(含证件类型、证件号、国籍及国籍名称)
+     */
+    List<TradeVisitorDO> selectVisitorListForSourceDashboard(@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

@@ -43,4 +43,21 @@ public interface VoyageStockBoardService {
      * @return
      */
     PageResult<AllVoyageStockBoardRespVO> getAllVoyageStockBoardPage(@Valid AllVoyageStockBoardReqVO reqVO);
+
+    /**
+     * 查询客源地分析看板数据
+     *
+     * @param reqVO 查询条件
+     * @return 客源地分析数据
+     */
+    VisitorSourceDashboardRespVO getVisitorSourceDashboard(@Valid VisitorSourceDashboardReqVO reqVO);
+
+    /**
+     * 导出客源地分析看板 Excel
+     *
+     * @param reqVO    查询条件
+     * @param response HTTP响应
+     * @throws IOException IO异常
+     */
+    void exportVisitorSourceDashboardExcel(@Valid VisitorSourceDashboardReqVO reqVO, HttpServletResponse response) throws IOException;
 }

+ 5 - 2
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/service/report/impl/OpsDailyServiceImpl.java

@@ -661,7 +661,9 @@ public class OpsDailyServiceImpl implements OpsDailyService {
         head.add(new ArrayList<>(Arrays.asList("实收人数", "免票人数")));
         // 11. 预计收客人数
         head.add(new ArrayList<>(Collections.singletonList("预计收客人数")));
-        // 12-15. 财务情况
+        // 12. 备注
+        head.add(new ArrayList<>(Collections.singletonList("备注")));
+        // 13-16. 财务情况
         head.add(new ArrayList<>(Arrays.asList("财务情况", "应收")));
         head.add(new ArrayList<>(Arrays.asList("财务情况", "实收")));
         head.add(new ArrayList<>(Arrays.asList("财务情况", "未收")));
@@ -691,7 +693,7 @@ public class OpsDailyServiceImpl implements OpsDailyService {
     private List<List<Object>> transformExportData2(List<YangtzePassengerSummaryRespVO> dataList) {
         List<List<Object>> result = new ArrayList<>(dataList.size());
         for (YangtzePassengerSummaryRespVO row : dataList) {
-            List<Object> rowData = new ArrayList<>(27);
+            List<Object> rowData = new ArrayList<>(28);
             rowData.add(row.getIndex() != null ? String.valueOf(row.getIndex()) : "");
             rowData.add(row.getShipName() != null ? row.getShipName() : "");
             rowData.add(row.getVoyageInfo() != null ? row.getVoyageInfo() : "");
@@ -703,6 +705,7 @@ public class OpsDailyServiceImpl implements OpsDailyService {
             rowData.add(formatNumber2(row.getTicketedPassengers()));
             rowData.add(formatNumber2(row.getFreePassengers()));
             rowData.add(formatNumber2(row.getEstimatedPassengers()));
+            rowData.add(row.getRemark() != null ? row.getRemark() : "");
             rowData.add(formatMoney(row.getReceivableAmount()));
             rowData.add(formatMoney(row.getReceivedAmount()));
             rowData.add(formatMoney(row.getUnreceivedAmount()));

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

@@ -1,16 +1,22 @@
 package com.yc.ship.module.trade.service.report.impl;
 
 import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
 import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.ExcelWriter;
 import com.alibaba.excel.converters.longconverter.LongStringConverter;
+import com.alibaba.excel.write.metadata.WriteSheet;
 import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
 import com.yc.ship.framework.common.pojo.PageResult;
 import com.yc.ship.framework.excel.core.util.ExcelUtils;
 import com.yc.ship.module.resource.api.ship.dto.RoomModelFloorNumDTO;
 import com.yc.ship.module.resource.dal.mysql.room.ResourceRoomMapper;
 import com.yc.ship.module.trade.controller.admin.report.vo.*;
+import com.yc.ship.module.trade.dal.dataobject.order.TradeVisitorDO;
+import com.yc.ship.module.trade.dal.mysql.order.TradeVisitorMapper;
 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.VoyageStockBoardExportStyleHandler;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -38,6 +44,7 @@ public class VoyageStockBoardServiceImpl implements VoyageStockBoardService {
     @Resource
     private ResourceRoomMapper resourceRoomMapper;
 
+
     @Override
     public PageResult<VoyageStockBoardRespVO> getVoyageStockBoardPage(VoyageStockBoardReqVO pageReqVO) {
         Long voyageId = pageReqVO.getVoyageId();
@@ -390,4 +397,277 @@ public class VoyageStockBoardServiceImpl implements VoyageStockBoardService {
         if (val == null) return "0";
         return val.stripTrailingZeros().toPlainString();
     }
+
+    // ==================== 客源地分析看板 ====================
+
+    @Override
+    public VisitorSourceDashboardRespVO getVisitorSourceDashboard(VisitorSourceDashboardReqVO reqVO) {
+        // 1. 查询游客列表
+        List<TradeVisitorDO> visitorList = voyageStockBoardMapper.selectVisitorListForSourceDashboard(
+                reqVO.getShipId(), reqVO.getVoyageIds());
+        if (CollUtil.isEmpty(visitorList)) {
+            return buildEmptyResp();
+        }
+
+        // 2. 分类统计
+        return analyzeVisitorSource(visitorList);
+    }
+
+    @Override
+    public void exportVisitorSourceDashboardExcel(VisitorSourceDashboardReqVO reqVO, HttpServletResponse response) throws IOException {
+        VisitorSourceDashboardRespVO data = getVisitorSourceDashboard(reqVO);
+
+        List<VisitorSourceExportVO> domesticExportList = buildDomesticExportData(data);
+        List<VisitorSourceInternationalExportVO> internationalExportList = buildInternationalExportData(data);
+
+        if (CollUtil.isEmpty(domesticExportList) && CollUtil.isEmpty(internationalExportList)) {
+            ExcelUtils.exportEmpty(response, "客源地分析看板");
+            return;
+        }
+
+        response.addHeader("Content-Disposition",
+                "attachment;filename=" + URLEncoder.encode("客源地分析看板.xlsx", StandardCharsets.UTF_8.name()));
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8");
+
+        // 使用 EasyExcel 导出多 sheet
+        ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).build();
+
+        // Sheet1: 国内市场客源地结构示例
+        if (CollUtil.isNotEmpty(domesticExportList)) {
+            WriteSheet sheet1 = EasyExcel.writerSheet(0, "国内市场客源地结构示例")
+                    .head(VisitorSourceExportVO.class)
+                    .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
+                    .build();
+            excelWriter.write(domesticExportList, sheet1);
+        }
+
+        // Sheet2: 国际市场客源地结构示例
+        if (CollUtil.isNotEmpty(internationalExportList)) {
+            WriteSheet sheet2 = EasyExcel.writerSheet(1, "国际市场客源地结构示例")
+                    .head(VisitorSourceInternationalExportVO.class)
+                    .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
+                    .build();
+            excelWriter.write(internationalExportList, sheet2);
+        }
+
+        excelWriter.finish();
+    }
+
+    /**
+     * 分析客源地数据
+     */
+    private VisitorSourceDashboardRespVO analyzeVisitorSource(List<TradeVisitorDO> visitorList) {
+        VisitorSourceDashboardRespVO respVO = new VisitorSourceDashboardRespVO();
+
+        // 国内省份统计
+        Map<String, Integer> provinceCountMap = new HashMap<>();
+        // 国内城市统计(全国)
+        Map<String, Integer> cityCountMap = new HashMap<>();
+        // 省份-城市统计
+        Map<String, Map<String, Integer>> provinceCityCountMap = new HashMap<>();
+        // 国际国家统计
+        Map<String, Integer> countryCountMap = new HashMap<>();
+
+        int domesticTotal = 0;
+        int internationalTotal = 0;
+
+        for (TradeVisitorDO visitor : visitorList) {
+            String nationalityName = visitor.getNationalityName();
+            if (nationalityName == null) {
+                nationalityName = "";
+            }
+
+            // 尝试解析国内省份
+            String province = IdCardProvinceUtil.getProvinceName(
+                    visitor.getCredentialType(), visitor.getCredentialNo(), nationalityName);
+
+            if (StrUtil.isNotBlank(province)) {
+                // 国内游客
+                domesticTotal++;
+                provinceCountMap.merge(province, 1, Integer::sum);
+
+                // 解析城市
+                String city = IdCardProvinceUtil.getCityName(visitor.getCredentialType(), visitor.getCredentialNo());
+                if (StrUtil.isBlank(city)) {
+                    city = province; // 城市解析不到,用省份代替
+                }
+                cityCountMap.merge(city, 1, Integer::sum);
+                provinceCityCountMap.computeIfAbsent(province, k -> new HashMap<>())
+                        .merge(city, 1, Integer::sum);
+            } else {
+                // 国际游客
+                String country = StrUtil.isNotBlank(nationalityName) ? nationalityName : "其他";
+                internationalTotal++;
+                countryCountMap.merge(country, 1, Integer::sum);
+            }
+        }
+
+        // 构建国内客源地列表(按人数降序)
+        int finalDomesticTotal = domesticTotal;
+        List<VisitorSourceDashboardRespVO.VisitorSourceItemVO> domesticList = provinceCountMap.entrySet().stream()
+                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
+                .map(entry -> {
+                    VisitorSourceDashboardRespVO.VisitorSourceItemVO item = new VisitorSourceDashboardRespVO.VisitorSourceItemVO();
+                    item.setName(entry.getKey());
+                    item.setCount(entry.getValue());
+                    item.setRatio(calcRatio(entry.getValue(), finalDomesticTotal));
+                    return item;
+                })
+                .collect(Collectors.toList());
+
+        // 构建国际客源地列表(按人数降序)
+        int finalInternationalTotal = internationalTotal;
+        List<VisitorSourceDashboardRespVO.VisitorSourceItemVO> internationalList = countryCountMap.entrySet().stream()
+                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
+                .map(entry -> {
+                    VisitorSourceDashboardRespVO.VisitorSourceItemVO item = new VisitorSourceDashboardRespVO.VisitorSourceItemVO();
+                    item.setName(entry.getKey());
+                    item.setCount(entry.getValue());
+                    item.setRatio(calcRatio(entry.getValue(), finalInternationalTotal));
+                    return item;
+                })
+                .collect(Collectors.toList());
+
+        // 省份来源排行
+        List<String> provinceRankList = domesticList.stream()
+                .map(VisitorSourceDashboardRespVO.VisitorSourceItemVO::getName)
+                .collect(Collectors.toList());
+
+        // 全国城市来源排行
+        List<String> nationalCityRankList = cityCountMap.entrySet().stream()
+                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
+                .map(Map.Entry::getKey)
+                .collect(Collectors.toList());
+
+        // 省份-城市排行映射
+        Map<String, List<String>> provinceCityRankMap = new HashMap<>();
+        for (Map.Entry<String, Map<String, Integer>> entry : provinceCityCountMap.entrySet()) {
+            List<String> cityRank = entry.getValue().entrySet().stream()
+                    .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
+                    .map(Map.Entry::getKey)
+                    .collect(Collectors.toList());
+            provinceCityRankMap.put(entry.getKey(), cityRank);
+        }
+
+        respVO.setDomesticSourceList(domesticList);
+        respVO.setInternationalSourceList(internationalList);
+        respVO.setProvinceRankList(provinceRankList);
+        respVO.setNationalCityRankList(nationalCityRankList);
+        respVO.setProvinceCityRankMap(provinceCityRankMap);
+        respVO.setDomesticTotal(domesticTotal);
+        respVO.setInternationalTotal(internationalTotal);
+
+        return respVO;
+    }
+
+    /**
+     * 计算占比
+     */
+    private String calcRatio(int part, int total) {
+        if (total == 0) {
+            return "0.00%";
+        }
+        BigDecimal ratio = BigDecimal.valueOf(part)
+                .multiply(BigDecimal.valueOf(100))
+                .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP);
+        return ratio.stripTrailingZeros().toPlainString() + "%";
+    }
+
+    /**
+     * 构建空响应
+     */
+    private VisitorSourceDashboardRespVO buildEmptyResp() {
+        VisitorSourceDashboardRespVO respVO = new VisitorSourceDashboardRespVO();
+        respVO.setDomesticSourceList(Collections.emptyList());
+        respVO.setInternationalSourceList(Collections.emptyList());
+        respVO.setProvinceRankList(Collections.emptyList());
+        respVO.setNationalCityRankList(Collections.emptyList());
+        respVO.setProvinceCityRankMap(Collections.emptyMap());
+        respVO.setDomesticTotal(0);
+        respVO.setInternationalTotal(0);
+        return respVO;
+    }
+
+    /**
+     * 构建国内导出数据
+     */
+    private List<VisitorSourceExportVO> buildDomesticExportData(VisitorSourceDashboardRespVO data) {
+        List<VisitorSourceExportVO> list = new ArrayList<>();
+        List<VisitorSourceDashboardRespVO.VisitorSourceItemVO> domesticList = data.getDomesticSourceList();
+        List<String> provinceRankList = data.getProvinceRankList();
+        List<String> nationalCityRankList = data.getNationalCityRankList();
+        Map<String, List<String>> provinceCityRankMap = data.getProvinceCityRankMap();
+
+        if (CollUtil.isEmpty(domesticList)) {
+            return list;
+        }
+
+        int maxRows = Math.max(domesticList.size(),
+                Math.max(provinceRankList.size(), nationalCityRankList.size()));
+
+        for (int i = 0; i < maxRows; i++) {
+            VisitorSourceExportVO vo = new VisitorSourceExportVO();
+
+            // 省份、人数、占比
+            if (i < domesticList.size()) {
+                VisitorSourceDashboardRespVO.VisitorSourceItemVO item = domesticList.get(i);
+                vo.setProvince(item.getName());
+                vo.setCount(item.getCount());
+                vo.setRatio(item.getRatio());
+            }
+
+            // 省份来源排行
+            if (i < provinceRankList.size()) {
+                vo.setProvinceRank(provinceRankList.get(i));
+            }
+
+            // 该省份城市来源排行
+            if (i < domesticList.size()) {
+                String province = domesticList.get(i).getName();
+                List<String> cityList = provinceCityRankMap.get(province);
+                if (CollUtil.isNotEmpty(cityList)) {
+                    // 取前3个城市
+                    StringBuilder cityRankSb = new StringBuilder();
+                    for (int j = 0; j < Math.min(cityList.size(), 3); j++) {
+                        if (j > 0) {
+                            cityRankSb.append(" ");
+                        }
+                        cityRankSb.append(j + 1).append(".").append(cityList.get(j));
+                    }
+                    vo.setCityRank(cityRankSb.toString());
+                }
+            }
+
+            // 全国城市来源排行
+            if (i < nationalCityRankList.size()) {
+                vo.setNationalCityRank((i + 1) + nationalCityRankList.get(i));
+            }
+
+            list.add(vo);
+        }
+
+        return list;
+    }
+
+    /**
+     * 构建国际导出数据
+     */
+    private List<VisitorSourceInternationalExportVO> buildInternationalExportData(VisitorSourceDashboardRespVO data) {
+        List<VisitorSourceInternationalExportVO> list = new ArrayList<>();
+        List<VisitorSourceDashboardRespVO.VisitorSourceItemVO> internationalList = data.getInternationalSourceList();
+
+        if (CollUtil.isEmpty(internationalList)) {
+            return list;
+        }
+
+        for (VisitorSourceDashboardRespVO.VisitorSourceItemVO item : internationalList) {
+            VisitorSourceInternationalExportVO vo = new VisitorSourceInternationalExportVO();
+            vo.setCountry(item.getName());
+            vo.setCount(item.getCount());
+            vo.setRatio(item.getRatio());
+            list.add(vo);
+        }
+
+        return list;
+    }
 }

+ 155 - 9
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/utils/IdCardProvinceUtil.java

@@ -1,11 +1,6 @@
 package com.yc.ship.module.trade.utils;
 
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
+import java.util.*;
 
 /**
  * 证件号码解析工具类 - 根据证件号前2位省份码解析省份名称
@@ -77,6 +72,9 @@ public class IdCardProvinceUtil {
     /** 省份码 -> 省份名称 映射表(不可变,基于GB/T 2260行政区划代码前2位) */
     private static final Map<String, String> PROVINCE_MAP;
 
+    /** 城市码(前4位) -> 城市名称 映射表(主要城市) */
+    private static final Map<String, String> CITY_MAP;
+
     static {
         Map<String, String> map = new HashMap<>(40);
         map.put("11", "北京");
@@ -115,6 +113,115 @@ public class IdCardProvinceUtil {
         map.put("82", "澳门");
         // 91开头的为国外身份证件,无对应省份
         PROVINCE_MAP = Collections.unmodifiableMap(map);
+
+        Map<String, String> cityMap = new HashMap<>(80);
+        // 直辖市
+        cityMap.put("1100", "北京"); cityMap.put("1101", "北京");
+        cityMap.put("1200", "天津"); cityMap.put("1201", "天津");
+        cityMap.put("3100", "上海"); cityMap.put("3101", "上海");
+        cityMap.put("5000", "重庆"); cityMap.put("5001", "重庆");
+        // 河北
+        cityMap.put("1301", "石家庄"); cityMap.put("1302", "唐山"); cityMap.put("1303", "秦皇岛"); cityMap.put("1304", "邯郸");
+        // 山西
+        cityMap.put("1401", "太原"); cityMap.put("1402", "大同");
+        // 内蒙古
+        cityMap.put("1501", "呼和浩特"); cityMap.put("1502", "包头");
+        // 辽宁
+        cityMap.put("2101", "沈阳"); cityMap.put("2102", "大连"); cityMap.put("2103", "鞍山");
+        // 吉林
+        cityMap.put("2201", "长春"); cityMap.put("2202", "吉林");
+        // 黑龙江
+        cityMap.put("2301", "哈尔滨"); cityMap.put("2302", "齐齐哈尔");
+        // 江苏
+        cityMap.put("3201", "南京"); cityMap.put("3202", "无锡"); cityMap.put("3203", "徐州");
+        cityMap.put("3204", "常州"); cityMap.put("3205", "苏州"); cityMap.put("3206", "南通");
+        cityMap.put("3207", "连云港"); cityMap.put("3208", "淮安"); cityMap.put("3209", "盐城");
+        cityMap.put("3210", "扬州"); cityMap.put("3211", "镇江"); cityMap.put("3212", "泰州"); cityMap.put("3213", "宿迁");
+        // 浙江
+        cityMap.put("3301", "杭州"); cityMap.put("3302", "宁波"); cityMap.put("3303", "温州");
+        cityMap.put("3304", "嘉兴"); cityMap.put("3305", "湖州"); cityMap.put("3306", "绍兴");
+        cityMap.put("3307", "金华"); cityMap.put("3308", "衢州"); cityMap.put("3309", "舟山");
+        cityMap.put("3310", "台州"); cityMap.put("3311", "丽水");
+        // 安徽
+        cityMap.put("3401", "合肥"); cityMap.put("3402", "芜湖"); cityMap.put("3403", "蚌埠"); cityMap.put("3404", "淮南");
+        cityMap.put("3405", "马鞍山"); cityMap.put("3406", "淮北"); cityMap.put("3407", "铜陵"); cityMap.put("3408", "安庆");
+        // 福建
+        cityMap.put("3501", "福州"); cityMap.put("3502", "厦门"); cityMap.put("3503", "莆田"); cityMap.put("3504", "三明");
+        cityMap.put("3505", "泉州"); cityMap.put("3506", "漳州"); cityMap.put("3507", "南平"); cityMap.put("3508", "龙岩"); cityMap.put("3509", "宁德");
+        // 江西
+        cityMap.put("3601", "南昌"); cityMap.put("3602", "景德镇"); cityMap.put("3603", "萍乡"); cityMap.put("3604", "九江");
+        cityMap.put("3605", "新余"); cityMap.put("3606", "鹰潭"); cityMap.put("3607", "赣州"); cityMap.put("3608", "吉安");
+        cityMap.put("3609", "宜春"); cityMap.put("3610", "抚州"); cityMap.put("3611", "上饶");
+        // 山东
+        cityMap.put("3701", "济南"); cityMap.put("3702", "青岛"); cityMap.put("3703", "淄博"); cityMap.put("3704", "枣庄");
+        cityMap.put("3705", "东营"); cityMap.put("3706", "烟台"); cityMap.put("3707", "潍坊"); cityMap.put("3708", "济宁");
+        cityMap.put("3709", "泰安"); cityMap.put("3710", "威海"); cityMap.put("3711", "日照"); cityMap.put("3713", "临沂");
+        // 河南
+        cityMap.put("4101", "郑州"); cityMap.put("4102", "开封"); cityMap.put("4103", "洛阳"); cityMap.put("4104", "平顶山");
+        cityMap.put("4105", "安阳"); cityMap.put("4106", "鹤壁"); cityMap.put("4107", "新乡"); cityMap.put("4108", "焦作");
+        cityMap.put("4109", "濮阳"); cityMap.put("4110", "许昌"); cityMap.put("4111", "漯河"); cityMap.put("4112", "三门峡");
+        cityMap.put("4113", "南阳"); cityMap.put("4114", "商丘"); cityMap.put("4115", "信阳"); cityMap.put("4116", "周口"); cityMap.put("4117", "驻马店");
+        // 湖北
+        cityMap.put("4201", "武汉"); cityMap.put("4202", "黄石"); cityMap.put("4203", "十堰"); cityMap.put("4205", "宜昌");
+        cityMap.put("4206", "襄阳"); cityMap.put("4207", "鄂州"); cityMap.put("4208", "荆门"); cityMap.put("4209", "孝感");
+        cityMap.put("4210", "荆州"); cityMap.put("4211", "黄冈"); cityMap.put("4212", "咸宁"); cityMap.put("4213", "随州");
+        cityMap.put("4228", "恩施");
+        // 湖南
+        cityMap.put("4301", "长沙"); cityMap.put("4302", "株洲"); cityMap.put("4303", "湘潭"); cityMap.put("4304", "衡阳");
+        cityMap.put("4305", "邵阳"); cityMap.put("4306", "岳阳"); cityMap.put("4307", "常德"); cityMap.put("4308", "张家界");
+        cityMap.put("4309", "益阳"); cityMap.put("4310", "郴州"); cityMap.put("4311", "永州"); cityMap.put("4312", "怀化");
+        cityMap.put("4313", "娄底"); cityMap.put("4331", "湘西");
+        // 广东
+        cityMap.put("4401", "广州"); cityMap.put("4402", "韶关"); cityMap.put("4403", "深圳"); cityMap.put("4404", "珠海");
+        cityMap.put("4405", "汕头"); cityMap.put("4406", "佛山"); cityMap.put("4407", "江门"); cityMap.put("4408", "湛江");
+        cityMap.put("4409", "茂名"); cityMap.put("4412", "肇庆"); cityMap.put("4413", "惠州"); cityMap.put("4414", "梅州");
+        cityMap.put("4415", "汕尾"); cityMap.put("4416", "河源"); cityMap.put("4417", "阳江"); cityMap.put("4418", "清远");
+        cityMap.put("4419", "东莞"); cityMap.put("4420", "中山"); cityMap.put("4451", "潮州"); cityMap.put("4452", "揭阳"); cityMap.put("4453", "云浮");
+        // 广西
+        cityMap.put("4501", "南宁"); cityMap.put("4502", "柳州"); cityMap.put("4503", "桂林"); cityMap.put("4504", "梧州");
+        cityMap.put("4505", "北海"); cityMap.put("4506", "防城港"); cityMap.put("4507", "钦州"); cityMap.put("4508", "贵港");
+        cityMap.put("4509", "玉林"); cityMap.put("4510", "百色"); cityMap.put("4511", "贺州"); cityMap.put("4512", "河池");
+        cityMap.put("4513", "来宾"); cityMap.put("4514", "崇左");
+        // 海南
+        cityMap.put("4601", "海口"); cityMap.put("4602", "三亚"); cityMap.put("4604", "儋州");
+        // 四川
+        cityMap.put("5101", "成都"); cityMap.put("5103", "自贡"); cityMap.put("5104", "攀枝花"); cityMap.put("5105", "泸州");
+        cityMap.put("5106", "德阳"); cityMap.put("5107", "绵阳"); cityMap.put("5108", "广元"); cityMap.put("5109", "遂宁");
+        cityMap.put("5110", "内江"); cityMap.put("5111", "乐山"); cityMap.put("5113", "南充"); cityMap.put("5114", "眉山");
+        cityMap.put("5115", "宜宾"); cityMap.put("5116", "广安"); cityMap.put("5117", "达州"); cityMap.put("5118", "雅安");
+        cityMap.put("5119", "巴中"); cityMap.put("5120", "资阳"); cityMap.put("5132", "阿坝"); cityMap.put("5133", "甘孜"); cityMap.put("5134", "凉山");
+        // 贵州
+        cityMap.put("5201", "贵阳"); cityMap.put("5202", "六盘水"); cityMap.put("5203", "遵义"); cityMap.put("5204", "安顺");
+        cityMap.put("5205", "毕节"); cityMap.put("5206", "铜仁"); cityMap.put("5223", "黔西南"); cityMap.put("5226", "黔东南"); cityMap.put("5227", "黔南");
+        // 云南
+        cityMap.put("5301", "昆明"); cityMap.put("5303", "曲靖"); cityMap.put("5304", "玉溪"); cityMap.put("5305", "保山");
+        cityMap.put("5306", "昭通"); cityMap.put("5307", "丽江"); cityMap.put("5308", "普洱"); cityMap.put("5309", "临沧");
+        cityMap.put("5323", "楚雄"); cityMap.put("5325", "红河"); cityMap.put("5326", "文山"); cityMap.put("5328", "西双版纳");
+        cityMap.put("5329", "大理"); cityMap.put("5331", "德宏"); cityMap.put("5333", "怒江"); cityMap.put("5334", "迪庆");
+        // 西藏
+        cityMap.put("5401", "拉萨"); cityMap.put("5402", "日喀则"); cityMap.put("5403", "昌都"); cityMap.put("5404", "林芝");
+        cityMap.put("5405", "山南"); cityMap.put("5406", "那曲"); cityMap.put("5425", "阿里");
+        // 陕西
+        cityMap.put("6101", "西安"); cityMap.put("6102", "铜川"); cityMap.put("6103", "宝鸡"); cityMap.put("6104", "咸阳");
+        cityMap.put("6105", "渭南"); cityMap.put("6106", "延安"); cityMap.put("6107", "汉中"); cityMap.put("6108", "榆林");
+        cityMap.put("6109", "安康"); cityMap.put("6110", "商洛");
+        // 甘肃
+        cityMap.put("6201", "兰州"); cityMap.put("6202", "嘉峪关"); cityMap.put("6203", "金昌"); cityMap.put("6204", "白银");
+        cityMap.put("6205", "天水"); cityMap.put("6206", "武威"); cityMap.put("6207", "张掖"); cityMap.put("6208", "平凉");
+        cityMap.put("6209", "酒泉"); cityMap.put("6210", "庆阳"); cityMap.put("6211", "定西"); cityMap.put("6212", "陇南");
+        // 青海
+        cityMap.put("6301", "西宁"); cityMap.put("6302", "海东"); cityMap.put("6322", "海北"); cityMap.put("6323", "黄南");
+        cityMap.put("6325", "海南"); cityMap.put("6326", "果洛"); cityMap.put("6327", "玉树"); cityMap.put("6328", "海西");
+        // 宁夏
+        cityMap.put("6401", "银川"); cityMap.put("6402", "石嘴山"); cityMap.put("6403", "吴忠"); cityMap.put("6404", "固原"); cityMap.put("6405", "中卫");
+        // 新疆
+        cityMap.put("6501", "乌鲁木齐"); cityMap.put("6502", "克拉玛依"); cityMap.put("6504", "吐鲁番"); cityMap.put("6505", "哈密");
+        cityMap.put("6523", "昌吉"); cityMap.put("6527", "博尔塔拉"); cityMap.put("6528", "巴音郭楞"); cityMap.put("6529", "阿克苏");
+        cityMap.put("6530", "克孜勒苏"); cityMap.put("6531", "喀什"); cityMap.put("6532", "和田"); cityMap.put("6540", "伊犁");
+        cityMap.put("6542", "塔城"); cityMap.put("6543", "阿勒泰"); cityMap.put("6590", "自治区直辖县级");
+        // 港澳台
+        cityMap.put("7100", "台湾"); cityMap.put("8100", "香港"); cityMap.put("8200", "澳门");
+        CITY_MAP = Collections.unmodifiableMap(cityMap);
     }
 
     /**
@@ -147,7 +254,7 @@ public class IdCardProvinceUtil {
             return "澳门";
         }
         // 非可解析的证件类型,直接返回空
-        if (!PARSEABLE_CREDENTIAL_TYPES.contains(credentialType)) {
+        if (!PARSEABLE_CREDENTIAL_TYPES.contains(credentialType)  && !"中国".equals(nationalityName)) {
             return "";
         }
         // 证件号为空或长度不足,无法解析
@@ -156,12 +263,12 @@ public class IdCardProvinceUtil {
         }
         String trimmedNo = credentialNo.trim();
         // 校验号码格式
-        if (!isValidIdCardFormat(trimmedNo)) {
+        if (!isValidIdCardFormat(trimmedNo) && !"中国".equals(nationalityName)) {
             return "";
         }
         // 提取省份码(前2位)并查表
         String provinceCode = trimmedNo.substring(0, PROVINCE_CODE_LENGTH);
-        return PROVINCE_MAP.getOrDefault(provinceCode, "");
+        return PROVINCE_MAP.getOrDefault(provinceCode, "其他");
     }
 
     /**
@@ -192,6 +299,45 @@ public class IdCardProvinceUtil {
         return false;
     }
 
+    /**
+     * 根据证件类型和证件号解析城市名称
+     * <p>
+     * 仅支持身份证(0)和港澳台居民居住证(11):通过号码前4位城市码解析;
+     * 其他证件类型返回空字符串。
+     * </p>
+     *
+     * @param credentialType 证件类型
+     * @param credentialNo   证件号码
+     * @return 城市名称,无法解析时返回空字符串
+     */
+    public static String getCityName(Integer credentialType, String credentialNo) {
+        if (credentialType == null) {
+            return "";
+        }
+        // 非可解析的证件类型,直接返回空
+        if (!PARSEABLE_CREDENTIAL_TYPES.contains(credentialType)) {
+            return "";
+        }
+        // 证件号为空或长度不足,无法解析
+        if (credentialNo == null || credentialNo.trim().length() < 4) {
+            return "";
+        }
+        String trimmedNo = credentialNo.trim();
+        // 校验号码格式
+        if (!isValidIdCardFormat(trimmedNo)) {
+            return "";
+        }
+        // 提取城市码(前4位)并查表
+        String cityCode = trimmedNo.substring(0, 4);
+        String cityName = CITY_MAP.get(cityCode);
+        if (cityName != null) {
+            return cityName;
+        }
+        // 如果前4位找不到,尝试用前2位+"00"查找直辖市/省级市
+        String provinceCode = trimmedNo.substring(0, 2);
+        return CITY_MAP.getOrDefault(provinceCode + "00", "");
+    }
+
     /**
      * 判断字符串是否全部由数字组成
      */

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

@@ -103,4 +103,25 @@
         WHERE voyage_id = #{voyageId} AND deleted = 0
     </select>
 
+
+
+    <select id="selectVisitorListForSourceDashboard" resultType="com.yc.ship.module.trade.dal.dataobject.order.TradeVisitorDO">
+        SELECT tv.id, tv.credential_type, tv.credential_no, tv.nationality, a.name as nationalityName
+        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>
+
 </mapper>

+ 11 - 8
ship-module-trade/ship-module-trade-biz/src/main/resources/mapper/report/YangtzePassengerSummaryMapper.xml

@@ -22,14 +22,15 @@
             COALESCE(room_stats.paidRooms, 0) AS paidRooms,
             COALESCE(room_stats.reservedRooms, 0) AS reservedRooms,
             COALESCE(visitor_stats.totalPassengers, 0) AS totalPassengers,
-            COALESCE(visitor_stats.ticketedPassengers, 0) AS ticketedPassengers,
+            COALESCE(free_stats.ticketedPassengers, 0) AS ticketedPassengers,
             COALESCE(free_stats.freePassengers, 0) AS freePassengers,
-            COALESCE(estimated_stats.estimatedPassengers, 0) AS estimatedPassengers,
+            COALESCE(free_stats.estimatedPassengers, 0) AS estimatedPassengers,
+            '' AS remark,
             COALESCE(finance_stats.receivableAmount, 0) AS receivableAmount,
             COALESCE(finance_stats.receivedAmount, 0) AS receivedAmount,
             COALESCE(finance_stats.receivableAmount, 0) - COALESCE(finance_stats.receivedAmount, 0) AS unreceivedAmount,
-            CASE WHEN COALESCE(visitor_stats.ticketedPassengers, 0) > 0
-                 THEN ROUND(COALESCE(finance_stats.receivableAmount, 0) / visitor_stats.ticketedPassengers, 2)
+            CASE WHEN COALESCE(free_stats.ticketedPassengers, 0) > 0
+                 THEN ROUND(COALESCE(finance_stats.receivableAmount, 0) / free_stats.ticketedPassengers, 2)
                  ELSE 0 END AS avgPrice,
             COALESCE(visitor_stats.adultCount, 0) AS adultCount,
             COALESCE(visitor_stats.childCount, 0) AS childCount,
@@ -59,7 +60,7 @@
             SELECT
                 o.voyage_id,
                 COUNT(tv.id) AS totalPassengers,
-                SUM(CASE WHEN o.order_status IN (6) THEN 1 ELSE 0 END) AS ticketedPassengers,
+                SUM(CASE WHEN o.order_status IN (6) THEN 1 ELSE 0 END) AS ticketedPassengers1,
                 SUM(CASE WHEN tv.type IN ('adultTake', 'adultPlus') THEN 1 ELSE 0 END) AS adultCount,
                 SUM(CASE WHEN tv.type IN ('childTake', 'childPlus', 'childNonTake') THEN 1 ELSE 0 END) AS childCount,
                 SUM(CASE WHEN tv.type IN ('babyTake', 'babyPlus', 'babyNonTake') THEN 1 ELSE 0 END) AS infantCount,
@@ -75,7 +76,7 @@
                 o.voyage_id,
                 CASE WHEN o.order_status = 1 or o.order_status = 14 THEN SUM(COALESCE(tot.adult_total_num, 0) + COALESCE(tot.child_total_num, 0)
                     + COALESCE(tot.baby_total_num, 0) + COALESCE(tot.with_total_num, 0)
-                    + COALESCE(tot.leader_total_num, 0)) ELSE 0 END AS estimatedPassengers
+                    + COALESCE(tot.leader_total_num, 0)) ELSE 0 END AS estimatedPassengers1
             FROM trade_order o
             LEFT JOIN trade_order_total tot ON o.id = tot.old_order_id AND tot.deleted = 0
         WHERE o.deleted = 0 AND o.order_status IN (15, 14, 13, 10, 12, 9, 8, 7, 6, 5, 4, 3, 1, 0)
@@ -85,8 +86,10 @@
         LEFT JOIN (
             SELECT
                 o.voyage_id,
-                SUM(CASE WHEN od.name LIKE '免票%' THEN 1 ELSE 0 END) AS freePassengers
-            FROM trade_order o
+                SUM(CASE WHEN od.name LIKE '免票%' THEN 1 ELSE 0 END) AS freePassengers,
+                 SUM(CASE WHEN od.name LIKE '免票%' THEN 0 ELSE 1 END) AS ticketedPassengers,
+                SUM(CASE WHEN (o.order_status = 1 or o.order_status = 14) and tv.type IN ('babyTake','babyPlus','babyNonTake','leader','with','childTake','childPlus','childNonTake','adultPlus', 'adultTake') THEN 1 ELSE 0 END) AS estimatedPassengers
+        FROM trade_order o
             INNER JOIN trade_visitor tv ON o.id = tv.order_id AND tv.deleted = 0
             LEFT JOIN ota_distributor od ON o.source_id = od.id AND od.deleted = 0
         WHERE o.deleted = 0 AND o.order_status IN (15, 14, 13, 10, 12, 9, 8, 7, 6, 5, 4, 3, 1, 0)