소스 검색

新增长江行游轮游客数据及营收统计接口

jincheng 1 일 전
부모
커밋
24f2cef45e

+ 36 - 2
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/controller/admin/report/OpsDailyController.java

@@ -2,8 +2,11 @@ package com.yc.ship.module.trade.controller.admin.report;
 
 import com.yc.ship.framework.apilog.core.annotation.ApiAccessLog;
 import com.yc.ship.framework.common.pojo.CommonResult;
+import com.yc.ship.framework.common.pojo.PageResult;
 import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyReqVO;
 import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyRespVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.YangtzePassengerSummaryPageReqVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.YangtzePassengerSummaryRespVO;
 import com.yc.ship.module.trade.service.report.OpsDailyService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -23,9 +26,9 @@ import static com.yc.ship.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
 import static com.yc.ship.framework.common.pojo.CommonResult.success;
 
 /**
- * 省际度假游轮经营情况日报表 Controller
+ * 省际度假游轮经营情况日报表 / 长江行游轮游客数据及营收统计 Controller
  *
- * @author auto-generated
+ * @author jincheng
  */
 @Tag(name = "管理后台 - 省际度假游轮经营情况日报")
 @RestController
@@ -65,4 +68,35 @@ public class OpsDailyController {
         opsDailyService.exportCruiseOpsDailyExcel(reqVO, response);
     }
 
+    // ==================== 长江行游轮游客数据及营收统计 ====================
+
+    /**
+     * 分页查询长江行游轮游客数据及营收统计
+     *
+     * @param pageReqVO 分页查询条件
+     * @return 分页结果
+     */
+    @GetMapping("/yangtze-passenger-summary/page")
+    @Operation(summary = "分页查询长江行游轮游客数据及营收统计")
+    public CommonResult<PageResult<YangtzePassengerSummaryRespVO>> getYangtzePassengerSummaryPage(
+            @Valid YangtzePassengerSummaryPageReqVO pageReqVO) {
+        PageResult<YangtzePassengerSummaryRespVO> pageResult = opsDailyService.getYangtzePassengerSummaryPage(pageReqVO);
+        return success(pageResult);
+    }
+
+    /**
+     * 导出长江行游轮游客数据及营收统计 Excel
+     *
+     * @param pageReqVO 查询条件
+     * @param response  HTTP响应
+     * @throws IOException IO异常
+     */
+    @GetMapping("/yangtze-passenger-summary/export-excel")
+    @Operation(summary = "导出长江行游轮游客数据及营收统计 Excel")
+    @ApiAccessLog(operateType = EXPORT)
+    public void exportYangtzePassengerSummaryExcel(@Valid YangtzePassengerSummaryPageReqVO pageReqVO,
+                                                     HttpServletResponse response) throws IOException {
+        opsDailyService.exportYangtzePassengerSummaryExcel(pageReqVO, response);
+    }
 }
+

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

@@ -0,0 +1,34 @@
+package com.yc.ship.module.trade.controller.admin.report.vo;
+
+import com.yc.ship.framework.common.pojo.PageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import java.util.List;
+
+/**
+ * 长江行游轮游客数据及营收统计 Request VO
+ *
+ * @author auto-generated
+ */
+@Schema(description = "管理后台 - 长江行游轮游客数据及营收统计 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class YangtzePassengerSummaryPageReqVO extends PageParam {
+
+    @Schema(description = "航次ID列表(支持多选)", example = "[1,2,3]")
+    private List<Long> voyageIds;
+
+    @Schema(description = "游轮ID", example = "1")
+    private Long shipId;
+
+    @Schema(description = "开始日期", example = "2026-01-01")
+    private String startDate;
+
+    @Schema(description = "结束日期", example = "2026-12-31")
+    private String endDate;
+
+}

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

@@ -0,0 +1,150 @@
+package com.yc.ship.module.trade.controller.admin.report.vo;
+
+import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
+import com.alibaba.excel.annotation.ExcelProperty;
+import com.alibaba.excel.annotation.format.NumberFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 长江行游轮游客数据及营收统计 Response VO
+ *
+ * @author auto-generated
+ */
+@Schema(description = "管理后台 - 长江行游轮游客数据及营收统计 Response VO")
+@Data
+@ExcelIgnoreUnannotated
+public class YangtzePassengerSummaryRespVO {
+
+    // ========== 基础信息 ==========
+
+    @Schema(description = "序号", example = "1")
+    @ExcelProperty("序号")
+    private Integer index;
+
+    @Schema(description = "船舶", example = "长江行-揽月")
+    @ExcelProperty("船舶")
+    private String shipName;
+
+    @Schema(description = "航次", example = "2026-05-09 上水")
+    @ExcelProperty("航次")
+    private String voyageInfo;
+
+    @Schema(description = "航次ID", hidden = true)
+    private Long voyageId;
+
+    @Schema(description = "倒计时(天)", example = "3")
+    @ExcelProperty("倒计时(天)")
+    private Integer countdown;
+
+    // ========== 预订间数 ==========
+
+    @Schema(description = "预订间数-总数", example = "200")
+    @ExcelProperty("预订间数-总数")
+    private Integer totalRooms;
+
+    @Schema(description = "预订间数-实收房间数(锁位订单)", example = "180")
+    @ExcelProperty("预订间数-实收房间数")
+    private Integer paidRooms;
+
+    @Schema(description = "预订间数-占位房间数(留位订单)", example = "190")
+    @ExcelProperty("预订间数-占位房间数")
+    private Integer reservedRooms;
+
+    // ========== 实收人数 ==========
+
+    @Schema(description = "实收人数-总数(仅锁位订单)", example = "420")
+    @ExcelProperty("实收人数-总数")
+    private Integer totalPassengers;
+
+    @Schema(description = "实收人数-购票人数(锁位订单非免票)", example = "400")
+    @ExcelProperty("实收人数-购票人数")
+    private Integer ticketedPassengers;
+
+    @Schema(description = "实收人数-免票人数(锁位订单免票)", example = "20")
+    @ExcelProperty("实收人数-免票人数")
+    private Integer freePassengers;
+
+    @Schema(description = "预计收客人数(锁位+留位订单总人数,不管是否有名单)", example = "450")
+    @ExcelProperty("预计收客人数")
+    private Integer estimatedPassengers;
+
+    // ========== 财务情况 ==========
+
+    @Schema(description = "财务-应收", example = "500000.00")
+    @NumberFormat("0.00")
+    @ExcelProperty("财务-应收")
+    private BigDecimal receivableAmount;
+
+    @Schema(description = "财务-实收", example = "450000.00")
+    @NumberFormat("0.00")
+    @ExcelProperty("财务-实收")
+    private BigDecimal receivedAmount;
+
+    @Schema(description = "财务-未收", example = "50000.00")
+    @NumberFormat("0.00")
+    @ExcelProperty("财务-未收")
+    private BigDecimal unreceivedAmount;
+
+    @Schema(description = "财务-均价", example = "1125.00")
+    @NumberFormat("0.00")
+    @ExcelProperty("财务-均价")
+    private BigDecimal avgPrice;
+
+    // ========== 床位载客率 ==========
+
+    @Schema(description = "客容量", example = "528")
+    @ExcelProperty("客容量(人/航次)")
+    private Integer capacity;
+
+    @Schema(description = "床位载客率", example = "79.5%")
+    @ExcelProperty("床位载客率")
+    private String bedOccupancyRate;
+
+    // ========== 实收详情 ==========
+
+    @Schema(description = "实收详情-成人", example = "350")
+    @ExcelProperty("实收详情-成人")
+    private Integer adultCount;
+
+    @Schema(description = "实收详情-儿童", example = "30")
+    @ExcelProperty("实收详情-儿童")
+    private Integer childCount;
+
+    @Schema(description = "实收详情-婴儿", example = "5")
+    @ExcelProperty("实收详情-婴儿")
+    private Integer infantCount;
+
+    @Schema(description = "实收详情-陪同+领队", example = "35")
+    @ExcelProperty("实收详情-陪同+领队")
+    private Integer companionLeaderCount;
+
+    // ========== 团队情况 ==========
+
+    @Schema(description = "团队人次(>10人)", example = "280")
+    @ExcelProperty("团队人次(>10人)")
+    private Integer groupCount;
+
+    @Schema(description = "游客人次(<10人)", example = "140")
+    @ExcelProperty("游客人次(<10人)")
+    private Integer individualCount;
+
+    @Schema(description = "团散比", example = "2:1")
+    @ExcelProperty("团散比")
+    private String groupIndividualRatio;
+
+    @Schema(description = "境内", example = "380")
+    @ExcelProperty("境内")
+    private Integer domesticCount;
+
+    @Schema(description = "境外", example = "40")
+    @ExcelProperty("境外")
+    private Integer overseasCount;
+
+    @Schema(description = "境内外比", example = "9.5:1")
+    @ExcelProperty("境内外比")
+    private String domesticOverseasRatio;
+
+}

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

@@ -0,0 +1,39 @@
+package com.yc.ship.module.trade.dal.mysql.report;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.yc.ship.framework.mybatis.core.mapper.BaseMapperX;
+import com.yc.ship.module.trade.controller.admin.report.vo.YangtzePassengerSummaryPageReqVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.YangtzePassengerSummaryRespVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 长江行游轮游客数据及营收统计 Mapper
+ *
+ * @author auto-generated
+ */
+@Mapper
+public interface YangtzePassengerSummaryMapper extends BaseMapperX<YangtzePassengerSummaryRespVO> {
+
+    /**
+     * 分页查询长江行游轮游客数据及营收统计
+     *
+     * @param page 分页参数
+     * @param vo   查询条件
+     * @return 分页结果
+     */
+    IPage<YangtzePassengerSummaryRespVO> selectYangtzePassengerSummaryPage(
+            IPage<YangtzePassengerSummaryRespVO> page, @Param("vo") YangtzePassengerSummaryPageReqVO vo);
+
+    /**
+     * 查询长江行游轮游客数据及营收统计(不分页,用于导出)
+     *
+     * @param vo 查询条件
+     * @return 数据列表
+     */
+    List<YangtzePassengerSummaryRespVO> selectYangtzePassengerSummaryExportList(
+            @Param("vo") YangtzePassengerSummaryPageReqVO vo);
+
+}

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

@@ -1,7 +1,10 @@
 package com.yc.ship.module.trade.service.report;
 
+import com.yc.ship.framework.common.pojo.PageResult;
 import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyReqVO;
 import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyRespVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.YangtzePassengerSummaryPageReqVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.YangtzePassengerSummaryRespVO;
 
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
@@ -31,4 +34,22 @@ public interface OpsDailyService {
      */
     void exportCruiseOpsDailyExcel(CruiseOpsDailyReqVO reqVO, HttpServletResponse response) throws IOException;
 
+    /**
+     * 分页查询长江行游轮游客数据及营收统计
+     *
+     * @param pageReqVO 分页查询条件
+     * @return 分页结果
+     */
+    PageResult<YangtzePassengerSummaryRespVO> getYangtzePassengerSummaryPage(YangtzePassengerSummaryPageReqVO pageReqVO);
+
+    /**
+     * 导出长江行游轮游客数据及营收统计 Excel
+     *
+     * @param pageReqVO 查询条件
+     * @param response  HTTP响应
+     * @throws IOException IO异常
+     */
+    void exportYangtzePassengerSummaryExcel(YangtzePassengerSummaryPageReqVO pageReqVO,
+                                            HttpServletResponse response) throws IOException;
+
 }

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

@@ -4,14 +4,21 @@ import cn.hutool.core.collection.CollUtil;
 import com.alibaba.excel.EasyExcel;
 import com.alibaba.excel.converters.longconverter.LongStringConverter;
 import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.yc.ship.framework.common.pojo.PageResult;
 import com.yc.ship.framework.excel.core.util.ExcelUtils;
 import com.yc.ship.module.resource.helper.DateHelper;
 import com.yc.ship.module.resource.helper.MathHelper;
 import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyReqVO;
 import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyRespVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.YangtzePassengerSummaryPageReqVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.YangtzePassengerSummaryRespVO;
 import com.yc.ship.module.trade.dal.mysql.report.OpsDailyMapper;
+import com.yc.ship.module.trade.dal.mysql.report.YangtzePassengerSummaryMapper;
 import com.yc.ship.module.trade.service.report.OpsDailyService;
 import com.yc.ship.module.trade.utils.excel.CruiseOpsDailyExportStyleHandler;
+import com.yc.ship.module.trade.utils.excel.YangtzePassengerSummaryExportStyleHandler;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -508,4 +515,223 @@ public class OpsDailyServiceImpl implements OpsDailyService {
         return val != null ? val : BigDecimal.ZERO;
     }
 
+
+    @Resource
+    private YangtzePassengerSummaryMapper yangtzePassengerSummaryMapper;
+
+    @Override
+    public PageResult<YangtzePassengerSummaryRespVO> getYangtzePassengerSummaryPage(YangtzePassengerSummaryPageReqVO pageReqVO) {
+        IPage<YangtzePassengerSummaryRespVO> page = yangtzePassengerSummaryMapper.selectYangtzePassengerSummaryPage(
+                new Page<>(pageReqVO.getPageNo(), pageReqVO.getPageSize()), pageReqVO);
+        List<YangtzePassengerSummaryRespVO> records = page.getRecords();
+        // 设置序号
+        int startIdx = (pageReqVO.getPageNo() - 1) * pageReqVO.getPageSize() + 1;
+        for (int i = 0; i < records.size(); i++) {
+            records.get(i).setIndex(startIdx + i);
+        }
+        // 计算派生字段
+        calculateDerivedFields2(records);
+        return new PageResult<>(records, page.getTotal());
+    }
+
+    @Override
+    public void exportYangtzePassengerSummaryExcel(YangtzePassengerSummaryPageReqVO pageReqVO,
+                                                   HttpServletResponse response) throws IOException {
+        // 查询全部数据(不分页)
+        List<YangtzePassengerSummaryRespVO> dataList = yangtzePassengerSummaryMapper.selectYangtzePassengerSummaryExportList(pageReqVO);
+        if (CollUtil.isEmpty(dataList)) {
+            ExcelUtils.exportEmpty(response, "长江行游轮游客数据及营收统计一览表");
+            return;
+        }
+        calculateDerivedFields2(dataList);
+
+        // 设置序号
+        int idx = 1;
+        for (YangtzePassengerSummaryRespVO row : dataList) {
+            row.setIndex(idx++);
+        }
+
+        // 构建多级表头
+        List<List<String>> head = buildExportHeader();
+
+        // 转换数据
+        List<List<Object>> exportData = transformExportData2(dataList);
+
+        // 先设置响应头
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8");
+        response.addHeader("Content-Disposition",
+                "attachment;filename=" + URLEncoder.encode("长江行游轮游客数据及营收统计一览表.xlsx", StandardCharsets.UTF_8.name()));
+
+        // 使用 EasyExcel 写出
+        YangtzePassengerSummaryExportStyleHandler styleHandler = new YangtzePassengerSummaryExportStyleHandler();
+        EasyExcel.write(response.getOutputStream())
+                .head(head)
+                .autoCloseStream(false)
+                .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
+                .registerWriteHandler(styleHandler)
+                .registerConverter(new LongStringConverter())
+                .sheet("长江行游轮游客数据及营收统计")
+                .doWrite(exportData);
+    }
+
+    // ==================== 私有方法 ====================
+
+    /**
+     * 计算派生字段(床位载客率、团散比、境内外比、均价)
+     */
+    private void calculateDerivedFields2(List<YangtzePassengerSummaryRespVO> dataList) {
+        for (YangtzePassengerSummaryRespVO row : dataList) {
+            // 床位载客率 = 实收人数总数 / 客容量 * 100
+            if (row.getCapacity() != null && row.getCapacity() > 0 && row.getTotalPassengers() != null) {
+                double rate = (double) row.getTotalPassengers() / row.getCapacity() * 100;
+                row.setBedOccupancyRate(String.format("%.1f%%", rate));
+            } else {
+                row.setBedOccupancyRate("-");
+            }
+
+            // 团散比
+            int group = row.getGroupCount() != null ? row.getGroupCount() : 0;
+            int individual = row.getIndividualCount() != null ? row.getIndividualCount() : 0;
+            row.setGroupIndividualRatio(calcRatio(group, individual));
+
+            // 境内外比
+            int domestic = row.getDomesticCount() != null ? row.getDomesticCount() : 0;
+            int overseas = row.getOverseasCount() != null ? row.getOverseasCount() : 0;
+            row.setDomesticOverseasRatio(calcRatio(domestic, overseas));
+
+            // 均价 = 实收 / 购票人数
+            if (row.getReceivedAmount() != null && row.getTicketedPassengers() != null && row.getTicketedPassengers() > 0) {
+                row.setAvgPrice(row.getReceivedAmount().divide(
+                        BigDecimal.valueOf(row.getTicketedPassengers()), 2, RoundingMode.HALF_UP));
+            } else {
+                row.setAvgPrice(BigDecimal.ZERO);
+            }
+
+            // 未收 = 应收 - 实收
+            BigDecimal receivable = row.getReceivableAmount() != null ? row.getReceivableAmount() : BigDecimal.ZERO;
+            BigDecimal received = row.getReceivedAmount() != null ? row.getReceivedAmount() : BigDecimal.ZERO;
+            row.setUnreceivedAmount(receivable.subtract(received));
+        }
+    }
+
+    /**
+     * 计算比率字符串(如 2:1)
+     */
+    private String calcRatio(int a, int b) {
+        if (b == 0 && a == 0) return "-";
+        if (b == 0) return a + ":0";
+        if (a == 0) return "0:" + b;
+        int gcd = gcd(a, b);
+        return (a / gcd) + ":" + (b / gcd);
+    }
+
+    private int gcd(int a, int b) {
+        a = Math.abs(a);
+        b = Math.abs(b);
+        while (b != 0) {
+            int temp = b;
+            b = a % b;
+            a = temp;
+        }
+        return a;
+    }
+
+    /**
+     * 构建导出多级表头
+     * 与图片的列结构保持一致
+     */
+    private List<List<String>> buildExportHeader() {
+        List<List<String>> head = new ArrayList<>();
+
+        // 1. 序号
+        head.add(new ArrayList<>(Collections.singletonList("序号")));
+        // 2. 船舶
+        head.add(new ArrayList<>(Collections.singletonList("船舶")));
+        // 3. 航次
+        head.add(new ArrayList<>(Collections.singletonList("航次")));
+        // 4. 倒计时(天)
+        head.add(new ArrayList<>(Collections.singletonList("倒计时(天)")));
+        // 5-7. 预订间数
+        head.add(new ArrayList<>(Arrays.asList("预订间数", "总数")));
+        head.add(new ArrayList<>(Arrays.asList("预订间数", "实收房间数")));
+        head.add(new ArrayList<>(Arrays.asList("预订间数", "占位房间数")));
+        // 8-10. 实收人数
+        head.add(new ArrayList<>(Arrays.asList("实收人数", "总数")));
+        head.add(new ArrayList<>(Arrays.asList("实收人数", "购票人数")));
+        head.add(new ArrayList<>(Arrays.asList("实收人数", "免票人数")));
+        // 11. 预计收客人数
+        head.add(new ArrayList<>(Collections.singletonList("预计收客人数")));
+        // 12-15. 财务情况
+        head.add(new ArrayList<>(Arrays.asList("财务情况", "应收")));
+        head.add(new ArrayList<>(Arrays.asList("财务情况", "实收")));
+        head.add(new ArrayList<>(Arrays.asList("财务情况", "未收")));
+        head.add(new ArrayList<>(Arrays.asList("财务情况", "均价")));
+        // 16-17. 床位载客率
+        head.add(new ArrayList<>(Arrays.asList("床位载客率(人/航次)", "客容量")));
+        head.add(new ArrayList<>(Arrays.asList("床位载客率(人/航次)", "载客率")));
+        // 18-21. 实收详情
+        head.add(new ArrayList<>(Arrays.asList("实收详情", "成人")));
+        head.add(new ArrayList<>(Arrays.asList("实收详情", "儿童")));
+        head.add(new ArrayList<>(Arrays.asList("实收详情", "婴儿")));
+        head.add(new ArrayList<>(Arrays.asList("实收详情", "陪同+领队")));
+        // 22-27. 团队情况
+        head.add(new ArrayList<>(Arrays.asList("团队情况", "团队人次(>10人)")));
+        head.add(new ArrayList<>(Arrays.asList("团队情况", "散客人次(<10人)")));
+        head.add(new ArrayList<>(Arrays.asList("团队情况", "团散比")));
+        head.add(new ArrayList<>(Arrays.asList("团队情况", "境内")));
+        head.add(new ArrayList<>(Arrays.asList("团队情况", "境外")));
+        head.add(new ArrayList<>(Arrays.asList("团队情况", "境内外比")));
+
+        return head;
+    }
+
+    /**
+     * 将报表数据转为 EasyExcel 导出格式
+     */
+    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);
+            rowData.add(row.getIndex() != null ? String.valueOf(row.getIndex()) : "");
+            rowData.add(row.getShipName() != null ? row.getShipName() : "");
+            rowData.add(row.getVoyageInfo() != null ? row.getVoyageInfo() : "");
+            rowData.add(row.getCountdown() != null ? String.valueOf(row.getCountdown()) : "");
+            rowData.add(formatNumber2(row.getTotalRooms()));
+            rowData.add(formatNumber2(row.getPaidRooms()));
+            rowData.add(formatNumber2(row.getReservedRooms()));
+            rowData.add(formatNumber2(row.getTotalPassengers()));
+            rowData.add(formatNumber2(row.getTicketedPassengers()));
+            rowData.add(formatNumber2(row.getFreePassengers()));
+            rowData.add(formatNumber2(row.getEstimatedPassengers()));
+            rowData.add(formatMoney(row.getReceivableAmount()));
+            rowData.add(formatMoney(row.getReceivedAmount()));
+            rowData.add(formatMoney(row.getUnreceivedAmount()));
+            rowData.add(formatMoney(row.getAvgPrice()));
+            rowData.add(formatNumber2(row.getCapacity()));
+            rowData.add(row.getBedOccupancyRate() != null ? row.getBedOccupancyRate() : "");
+            rowData.add(formatNumber2(row.getAdultCount()));
+            rowData.add(formatNumber2(row.getChildCount()));
+            rowData.add(formatNumber2(row.getInfantCount()));
+            rowData.add(formatNumber2(row.getCompanionLeaderCount()));
+            rowData.add(formatNumber2(row.getGroupCount()));
+            rowData.add(formatNumber2(row.getIndividualCount()));
+            rowData.add(row.getGroupIndividualRatio() != null ? row.getGroupIndividualRatio() : "");
+            rowData.add(formatNumber2(row.getDomesticCount()));
+            rowData.add(formatNumber2(row.getOverseasCount()));
+            rowData.add(row.getDomesticOverseasRatio() != null ? row.getDomesticOverseasRatio() : "");
+            result.add(rowData);
+        }
+        return result;
+    }
+
+    private String formatNumber2(Integer val) {
+        return val != null ? String.valueOf(val) : "0";
+    }
+
+    private String formatMoney(BigDecimal amount) {
+        if (amount == null) return "0.00";
+        return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
+    }
+
+
 }

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

@@ -0,0 +1,62 @@
+package com.yc.ship.module.trade.utils.excel;
+
+import com.alibaba.excel.write.handler.CellWriteHandler;
+import com.alibaba.excel.write.handler.context.CellWriteHandlerContext;
+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 YangtzePassengerSummaryExportStyleHandler implements CellWriteHandler {
+
+    /** 缓存样式 */
+    private CellStyle headerStyle;
+
+    public YangtzePassengerSummaryExportStyleHandler() {
+    }
+
+    @Override
+    public void afterCellDispose(CellWriteHandlerContext context) {
+        Cell cell = context.getCell();
+        Sheet sheet = context.getWriteSheetHolder().getSheet();
+
+        // 表头样式处理(EasyExcel 会自动处理多级表头合并,无需手动合并)
+        if (Boolean.TRUE.equals(context.getHead())) {
+            applyHeaderStyle(cell, sheet.getWorkbook());
+        }
+    }
+
+    /**
+     * 表头样式
+     */
+    private void applyHeaderStyle(Cell cell, 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 headerColor = new XSSFColor(new byte[]{(byte) 0x4A, (byte) 0x90, (byte) 0xD9}, null);
+                ((XSSFCellStyle) headerStyle).setFillForegroundColor(headerColor);
+            }
+
+            Font font = workbook.createFont();
+            font.setBold(true);
+            font.setFontHeightInPoints((short) 11);
+            font.setColor(IndexedColors.WHITE.getIndex());
+            headerStyle.setFont(font);
+        }
+        cell.setCellStyle(headerStyle);
+    }
+
+
+}

+ 160 - 0
ship-module-trade/ship-module-trade-biz/src/main/resources/mapper/report/YangtzePassengerSummaryMapper.xml

@@ -0,0 +1,160 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.yc.ship.module.trade.dal.mysql.report.YangtzePassengerSummaryMapper">
+
+    <!-- 公共查询SQL片段 -->
+    <!-- 业务逻辑:
+         锁位订单:order_status != 14(非留位的有效订单,客人100%会出行)
+         留位订单:order_status = 14(占位订单)
+         实收房间数 = 锁位订单的房间数
+         占位房间数 = 留位订单的房间数
+         实收人数 = 仅锁位订单的游客(有名单的)
+         预计收客人数 = 锁位+留位的所有人数合计(从trade_order_total获取,不管是否有名单)
+    -->
+    <sql id="selectYangtzePassengerSummary">
+        SELECT
+            v.id AS voyageId,
+            s.name AS shipName,
+            CONCAT(DATE_FORMAT(v.start_time, '%Y-%m-%d'), ' ', CASE WHEN r.direction = 1 THEN '上水' ELSE '下水' END) AS voyageInfo,
+            GREATEST(DATEDIFF(DATE(v.start_time), CURDATE()), 0) AS countdown,
+            528 AS capacity,
+            COALESCE(room_stats.totalRooms, 0) AS totalRooms,
+            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(estimated_stats.freePassengers, 0) AS freePassengers,
+            COALESCE(estimated_stats.estimatedPassengers, 0) AS estimatedPassengers,
+            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.receivedAmount, 0) / visitor_stats.ticketedPassengers, 2)
+                 ELSE 0 END AS avgPrice,
+            COALESCE(visitor_stats.adultCount, 0) AS adultCount,
+            COALESCE(visitor_stats.childCount, 0) AS childCount,
+            COALESCE(visitor_stats.infantCount, 0) AS infantCount,
+            COALESCE(visitor_stats.companionLeaderCount, 0) AS companionLeaderCount,
+            COALESCE(team_stats.groupCount, 0) AS groupCount,
+            COALESCE(team_stats.individualCount, 0) AS individualCount,
+            COALESCE(nationality_stats.domesticCount, 0) AS domesticCount,
+            COALESCE(nationality_stats.overseasCount, 0) AS overseasCount
+        FROM product_voyage v
+        INNER JOIN resource_ship s ON v.ship_id = s.id AND s.deleted = 0
+        INNER JOIN resource_route r ON v.route_id = r.id AND r.deleted = 0
+
+        LEFT JOIN (
+            SELECT
+                o.voyage_id,
+                SUM(COALESCE(tot.use_room_num, 0)) AS totalRooms,
+                SUM(CASE WHEN o.order_status = 1 or o.order_status = 6 THEN COALESCE(tot.use_room_num, 0) ELSE 0 END) AS paidRooms,
+                SUM(CASE WHEN o.order_status = 14 THEN COALESCE(tot.use_room_num, 0) ELSE 0 END) AS reservedRooms
+            FROM trade_order_room_model tot
+            LEFT JOIN  trade_order o ON o.id = tot.order_id AND tot.deleted = 0
+            WHERE o.deleted = 0 AND o.order_status IN (14,6,1)
+            GROUP BY o.voyage_id
+        ) room_stats ON v.id = room_stats.voyage_id
+
+        LEFT JOIN (
+            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 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,
+                SUM(CASE WHEN tv.type IN ('with', 'leader') THEN 1 ELSE 0 END) AS companionLeaderCount
+            FROM trade_order o
+            INNER JOIN trade_visitor tv ON o.id = tv.order_id AND tv.deleted = 0
+        WHERE o.deleted = 0 AND o.order_status IN (15, 13, 10, 12, 9, 8, 7, 6, 5, 4, 3, 1, 0)
+            GROUP BY o.voyage_id
+        ) visitor_stats ON v.id = visitor_stats.voyage_id
+
+        LEFT JOIN (
+            SELECT
+                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,
+                sum(IFNULL(tot.free_num, 0)) AS freePassengers
+            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)
+            GROUP BY o.voyage_id
+        ) estimated_stats ON v.id = estimated_stats.voyage_id
+
+        LEFT JOIN (
+            SELECT
+                o.voyage_id,
+                SUM(o.amount) AS receivableAmount,
+                SUM(CASE WHEN o.pay_status = 1 THEN COALESCE(p.pay_amount, o.real_pay_amount)
+                         ELSE COALESCE(o.real_pay_amount, 0) END) AS receivedAmount
+            FROM trade_order o
+            LEFT JOIN (
+                SELECT order_id, SUM(pay_amount) AS pay_amount
+                FROM trade_order_pay
+                WHERE deleted = 0 AND pay_status = 1
+                GROUP BY order_id
+            ) p ON o.pay_status = 1 AND p.order_id = o.id
+            WHERE o.deleted = 0 AND o.order_status IN (15, 14, 13, 10, 12, 9, 8, 7, 6, 5, 4, 3, 1, 0)
+            GROUP BY o.voyage_id
+        ) finance_stats ON v.id = finance_stats.voyage_id
+
+        LEFT JOIN (
+            SELECT
+                o.voyage_id,
+                SUM(CASE WHEN visitor_cnt.cnt >= 10 THEN visitor_cnt.cnt ELSE 0 END) AS groupCount,
+                SUM(CASE WHEN visitor_cnt.cnt &lt; 10 THEN visitor_cnt.cnt ELSE 0 END) AS individualCount
+            FROM trade_order o
+            INNER JOIN (
+                SELECT order_id, COUNT(id) AS cnt
+                FROM trade_visitor
+                WHERE deleted = 0
+                GROUP BY order_id
+            ) visitor_cnt ON o.id = visitor_cnt.order_id
+            WHERE o.deleted = 0 AND o.order_status IN (15, 13, 10, 12, 9, 8, 7, 6, 5, 4, 3, 1, 0)
+            GROUP BY o.voyage_id
+        ) team_stats ON v.id = team_stats.voyage_id
+
+        LEFT JOIN (
+            SELECT
+                o.voyage_id,
+                SUM(CASE WHEN tv.nationality = '1' OR tv.nationality IS NULL OR tv.nationality = '' THEN 1 ELSE 0 END) AS domesticCount,
+                SUM(CASE WHEN tv.nationality != '1' AND tv.nationality IS NOT NULL AND tv.nationality != '' THEN 1 ELSE 0 END) AS overseasCount
+            FROM trade_order o
+            INNER JOIN trade_visitor tv ON o.id = tv.order_id AND tv.deleted = 0
+            WHERE o.deleted = 0 AND o.order_status IN (15, 13, 10, 12, 9, 8, 7, 6, 5, 4, 3, 1, 0)
+            GROUP BY o.voyage_id
+        ) nationality_stats ON v.id = nationality_stats.voyage_id
+        WHERE v.deleted = 0
+        <if test="vo.voyageIds != null and vo.voyageIds.size() > 0">
+            AND v.id IN
+            <foreach collection="vo.voyageIds" item="id" open="(" separator="," close=")">
+                #{id}
+            </foreach>
+        </if>
+        <if test="vo.shipId != null">
+            AND v.ship_id = #{vo.shipId}
+        </if>
+        <if test="vo.startDate != null and vo.startDate != ''">
+            AND v.start_time &gt;= #{vo.startDate}
+        </if>
+        <if test="vo.endDate != null and vo.endDate != ''">
+            AND v.start_time &lt; DATE_ADD(#{vo.endDate}, INTERVAL 1 DAY)
+        </if>
+        ORDER BY v.start_time ASC, s.name ASC
+    </sql>
+
+    <!-- 分页查询 -->
+    <select id="selectYangtzePassengerSummaryPage"
+            resultType="com.yc.ship.module.trade.controller.admin.report.vo.YangtzePassengerSummaryRespVO">
+        <include refid="selectYangtzePassengerSummary"/>
+    </select>
+
+    <!-- 不分页查询(用于导出) -->
+    <select id="selectYangtzePassengerSummaryExportList"
+            resultType="com.yc.ship.module.trade.controller.admin.report.vo.YangtzePassengerSummaryRespVO">
+        <include refid="selectYangtzePassengerSummary"/>
+    </select>
+
+</mapper>