Forráskód Böngészése

新增省际度假游轮经营情况日报接口

jincheng 4 hete
szülő
commit
c27ff848e7

+ 1 - 1
ship-module-resource/ship-module-resource-biz/src/main/java/com/yc/ship/module/resource/helper/DateHelper.java

@@ -254,7 +254,7 @@ public class DateHelper {
         return date;
     }
 
-    private static Date parseDate(String string) {
+    public static Date parseDate(String string) {
         String format = "yyyy-MM-dd";
         if (string.matches("\\d{2,4}\\-\\d{1,2}\\-\\d{1,2} \\d{1,2}:\\d{1,2}:\\d{1,2}")) {
             format = "yyyy-MM-dd HH:mm:ss";

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

@@ -0,0 +1,68 @@
+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.module.trade.controller.admin.report.vo.CruiseOpsDailyReqVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyRespVO;
+import com.yc.ship.module.trade.service.report.OpsDailyService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletResponse;
+import javax.validation.Valid;
+import java.io.IOException;
+import java.util.List;
+
+import static com.yc.ship.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
+import static com.yc.ship.framework.common.pojo.CommonResult.success;
+
+/**
+ * 省际度假游轮经营情况日报表 Controller
+ *
+ * @author auto-generated
+ */
+@Tag(name = "管理后台 - 省际度假游轮经营情况日报")
+@RestController
+@RequestMapping("/report/interprovincial/cruise-ops-daily")
+@Validated
+@Slf4j
+public class OpsDailyController {
+
+    @Resource
+    private OpsDailyService opsDailyService;
+
+    /**
+     * 查询省际度假游轮经营情况日报列表
+     *
+     * @param reqVO 查询条件
+     * @return 数据列表
+     */
+    @GetMapping("/list")
+    @Operation(summary = "查询省际度假游轮经营情况日报列表")
+    public CommonResult<List<CruiseOpsDailyRespVO>> getCruiseOpsDailyList(@Valid CruiseOpsDailyReqVO reqVO) {
+        List<CruiseOpsDailyRespVO> list = opsDailyService.getCruiseOpsDailyList(reqVO);
+        return success(list);
+    }
+
+    /**
+     * 导出省际度假游轮经营情况日报 Excel
+     *
+     * @param reqVO    查询条件
+     * @param response HTTP响应
+     * @throws IOException IO异常
+     */
+    @GetMapping("/export-excel")
+    @Operation(summary = "导出省际度假游轮经营情况日报 Excel")
+    @ApiAccessLog(operateType = EXPORT)
+    public void exportCruiseOpsDailyExcel(@Valid CruiseOpsDailyReqVO reqVO,
+                                           HttpServletResponse response) throws IOException {
+        opsDailyService.exportCruiseOpsDailyExcel(reqVO, response);
+    }
+
+}

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

@@ -0,0 +1,21 @@
+package com.yc.ship.module.trade.controller.admin.report.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 省际度假游轮经营情况日报表 Request VO
+ *
+ * @author auto-generated
+ */
+@Schema(description = "管理后台 - 省际度假游轮经营情况日报 Request VO")
+@Data
+public class CruiseOpsDailyReqVO {
+
+    @Schema(description = "开始日期", example = "2026-01-01")
+    private String startDate;
+
+    @Schema(description = "结束日期", example = "2026-03-31")
+    private String endDate;
+
+}

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

@@ -0,0 +1,134 @@
+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 system
+ */
+@Schema(description = "管理后台 - 省际度假游轮经营情况日报 Response VO")
+@Data
+@ExcelIgnoreUnannotated
+public class CruiseOpsDailyRespVO {
+
+    // ========== 基础数据字段 ==========
+
+    @Schema(description = "序号", example = "1")
+    @ExcelProperty("序号")
+    private Integer index;
+
+    @Schema(description = "月份", example = "2026-01")
+    @ExcelProperty("月份")
+    private String month;
+
+    @Schema(description = "日期", example = "2026-01-05")
+    @ExcelProperty("日期")
+    private String date;
+
+    @Schema(description = "船舶", example = "长江行-揽月")
+    @ExcelProperty("船舶")
+    private String ship;
+
+    @Schema(description = "航线", example = "重庆-宜昌")
+    @ExcelProperty("航线")
+    private String route;
+
+    @Schema(description = "航次号", example = "长江行-揽月2026-01-05")
+    @ExcelProperty("航次号")
+    private String voyageNo;
+
+    @Schema(description = "客容量", example = "500")
+    @ExcelProperty("客容量")
+    private Integer passengerCapacity;
+
+    @Schema(description = "载客量", example = "420")
+    @ExcelProperty("载客量")
+    private Integer passengerCount;
+
+    @Schema(description = "载客率", example = "84.0%")
+    @ExcelProperty("载客率")
+    private String passengerRate;
+
+    @Schema(description = "房总数", example = "200")
+    @ExcelProperty("房总数")
+    private Integer totalRooms;
+
+    @Schema(description = "用房数", example = "180")
+    @ExcelProperty("用房数")
+    private String usedRooms;
+
+    @Schema(description = "用房率", example = "90.0%")
+    @ExcelProperty("用房率")
+    private String roomRate;
+
+    @Schema(description = "船票收入", example = "350000.00")
+    @NumberFormat("0.00")
+    @ExcelProperty("船票收入")
+    private BigDecimal ticketIncome;
+
+    @Schema(description = "二消收入", example = "0.00")
+    @NumberFormat("0.00")
+    @ExcelProperty("二消收入")
+    private BigDecimal secondIncome;
+
+    @Schema(description = "收入合计", example = "350000.00")
+    @NumberFormat("0.00")
+    @ExcelProperty("收入合计")
+    private BigDecimal totalIncome;
+
+    // ========== 同比数据字段 ==========
+
+    @Schema(description = "客容量同比率(%)", example = "15.5")
+    @ExcelProperty("客容量同比%")
+    private String passengerCapacityYoy;
+
+    @Schema(description = "载客量同比率(%)", example = "12.3")
+    @ExcelProperty("载客量同比%")
+    private String passengerCountYoy;
+
+    @Schema(description = "载客率同比(百分点)", example = "+2.5")
+    @ExcelProperty("载客率同比")
+    private String passengerRateYoy;
+
+    @Schema(description = "房总数同比率(%)", example = "0.0")
+    @ExcelProperty("房总数同比%")
+    private String totalRoomsYoy;
+
+    @Schema(description = "用房数同比率(%)", example = "10.2")
+    @ExcelProperty("用房数同比%")
+    private String usedRoomsYoy;
+
+    @Schema(description = "用房率同比(百分点)", example = "+1.8")
+    @ExcelProperty("用房率同比")
+    private String roomRateYoy;
+
+    @Schema(description = "船票收入同比率(%)", example = "20.5")
+    @ExcelProperty("船票收入同比%")
+    private String ticketIncomeYoy;
+
+    @Schema(description = "二消收入同比率(%)", example = "18.2")
+    @ExcelProperty("二消收入同比%")
+    private String secondIncomeYoy;
+
+    @Schema(description = "收入合计同比率(%)", example = "19.8")
+    @ExcelProperty("收入合计同比%")
+    private String totalIncomeYoy;
+
+    // ========== 行标记字段(不导出到Excel)==========
+
+    @Schema(description = "是否小计行", hidden = true)
+    @ExcelProperty("是否小计")
+    private Boolean isSubtotal;
+
+    @Schema(description = "是否累计行", hidden = true)
+    @ExcelProperty("是否累计")
+    private Boolean isCumulative;
+
+}

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

@@ -0,0 +1,26 @@
+package com.yc.ship.module.trade.dal.mysql.report;
+
+import com.yc.ship.framework.mybatis.core.mapper.BaseMapperX;
+import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyReqVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyRespVO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 省际度假游轮经营情况日报 Mapper
+ *
+ * @author auto-generated
+ */
+@Mapper
+public interface OpsDailyMapper extends BaseMapperX<CruiseOpsDailyRespVO> {
+
+    /**
+     * 查询省际度假游轮经营情况日报列表(不分页,用于查询和导出)
+     *
+     * @param vo 查询条件
+     * @return 数据列表
+     */
+    List<CruiseOpsDailyRespVO> selectCruiseOpsDailyList(CruiseOpsDailyReqVO vo);
+
+}

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

@@ -0,0 +1,34 @@
+package com.yc.ship.module.trade.service.report;
+
+import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyReqVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyRespVO;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * 省际度假游轮经营情况日报 Service 接口
+ *
+ * @author auto-generated
+ */
+public interface OpsDailyService {
+
+    /**
+     * 查询省际度假游轮经营情况日报列表
+     *
+     * @param reqVO 查询条件
+     * @return 数据列表
+     */
+    List<CruiseOpsDailyRespVO> getCruiseOpsDailyList(CruiseOpsDailyReqVO reqVO);
+
+    /**
+     * 导出省际度假游轮经营情况日报 Excel
+     *
+     * @param reqVO    查询条件
+     * @param response HTTP响应
+     * @throws IOException IO异常
+     */
+    void exportCruiseOpsDailyExcel(CruiseOpsDailyReqVO reqVO, HttpServletResponse response) throws IOException;
+
+}

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

@@ -89,6 +89,7 @@ public class IncomeOrderLedgerServiceImpl implements IncomeOrderLedgerService {
         int totalCompanionCount = 0;
         int totalFreeCount = 0;
         int totalCountSum = 0;
+        int leaderCount = 0;
 
         for (IncomeOrderLedgerRespVO item : list) {
             totalRoomCount += item.getRoomCount() != null ? item.getRoomCount() : 0;
@@ -96,6 +97,7 @@ public class IncomeOrderLedgerServiceImpl implements IncomeOrderLedgerService {
             totalChildCount += item.getChildCount() != null ? item.getChildCount() : 0;
             totalInfantCount += item.getInfantCount() != null ? item.getInfantCount() : 0;
             totalCompanionCount += item.getCompanionCount() != null ? item.getCompanionCount() : 0;
+            leaderCount += item.getLeaderCount() != null ? item.getLeaderCount() : 0;
             totalFreeCount += item.getFreeCount() != null ? item.getFreeCount() : 0;
             totalCountSum += item.getTotalCount() != null ? item.getTotalCount() : 0;
             totalMarketingPrice = totalMarketingPrice.add(
@@ -113,6 +115,7 @@ public class IncomeOrderLedgerServiceImpl implements IncomeOrderLedgerService {
         summary.setChildCount(totalChildCount);
         summary.setInfantCount(totalInfantCount);
         summary.setCompanionCount(totalCompanionCount);
+        summary.setLeaderCount(leaderCount);
         summary.setFreeCount(totalFreeCount);
         summary.setTotalCount(totalCountSum);
         summary.setMarketingPrice(totalMarketingPrice);

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

@@ -0,0 +1,511 @@
+package com.yc.ship.module.trade.service.report.impl;
+
+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.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.dal.mysql.report.OpsDailyMapper;
+import com.yc.ship.module.trade.service.report.OpsDailyService;
+import com.yc.ship.module.trade.utils.excel.CruiseOpsDailyExportStyleHandler;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 省际度假游轮经营情况日报 Service 实现类
+ * <p>
+ * 数据结构:日常数据行 + 月度小计(数据+同比) + 累计行(数据+同比)
+ *
+ * @author system
+ */
+@Service
+@Slf4j
+public class OpsDailyServiceImpl implements OpsDailyService {
+
+    @Resource
+    private OpsDailyMapper cruiseOpsDailyMapper;
+
+    @Override
+    public List<CruiseOpsDailyRespVO> getCruiseOpsDailyList(CruiseOpsDailyReqVO reqVO) {
+        // 1. 查询本期原始数据
+        List<CruiseOpsDailyRespVO> currentDataList = cruiseOpsDailyMapper.selectCruiseOpsDailyList(reqVO);
+        if (CollUtil.isEmpty(currentDataList)) {
+            return new ArrayList<>();
+        }
+
+        // 2. 计算本期数据的派生字段(载客率、用房率、收入合计)
+        calculateDerivedFields(currentDataList);
+
+        // 3. 计算去年同期范围并查询去年数据
+        String startDate = reqVO.getStartDate();
+        String endDate = reqVO.getEndDate();
+        CruiseOpsDailyReqVO lastYearReq = new CruiseOpsDailyReqVO();
+        try {
+            Date startD = DateHelper.parseDate(startDate);
+            Date endD = endDate != null ? DateHelper.parseDate(endDate) : null;
+            // 去年同期
+            lastYearReq.setStartDate(DateHelper.getAppointYearStartDate(startD));
+            if (endD != null) {
+                // 计算去年的结束日期(保持相同的相对天数差)
+                Calendar cal = Calendar.getInstance();
+                cal.setTime(startD);
+                int daysDiff = (int) ((endD.getTime() - startD.getTime()) / (1000 * 60 * 60 * 24));
+                cal.add(Calendar.YEAR, -1);
+                cal.add(Calendar.DATE, daysDiff);
+                lastYearReq.setEndDate(DateHelper.format(cal.getTime(), "yyyy-MM-dd"));
+            } else {
+                // 如果没有结束日期,查到去年同期当天
+                lastYearReq.setEndDate(DateHelper.format(new Date(), "yyyy-MM-dd"));
+            }
+        } catch (Exception e) {
+            log.warn("[getCruiseOpsDailyList] 解析日期失败, 跳过同比计算: {}", e.getMessage());
+        }
+
+        List<CruiseOpsDailyRespVO> lastYearDataList = new ArrayList<>();
+        if (lastYearReq.getStartDate() != null && lastYearReq.getEndDate() != null) {
+            lastYearDataList = cruiseOpsDailyMapper.selectCruiseOpsDailyList(lastYearReq);
+            calculateDerivedFields(lastYearDataList);
+        }
+
+        // 4. 构建完整报表(含小计、累计、同比)
+        return buildReportWithYoy(currentDataList, lastYearDataList);
+    }
+
+    @Override
+    public void exportCruiseOpsDailyExcel(CruiseOpsDailyReqVO reqVO, HttpServletResponse response) throws IOException {
+        List<CruiseOpsDailyRespVO> dataList = getCruiseOpsDailyList(reqVO);
+        if (CollUtil.isEmpty(dataList)) {
+            ExcelUtils.exportEmpty(response, "省际度假游轮经营情况日报表");
+            return;
+        }
+
+        // 1. 构建表头(与前端页面15列一致)
+        List<List<String>> head = buildExportHeaders();
+
+        // 2. 转换数据:同比行的同比值写入对应数据列
+        List<List<Object>> exportData = transformExportData(dataList);
+
+        // 3. 使用 EasyExcel 写出,注册自定义样式处理器
+        CruiseOpsDailyExportStyleHandler styleHandler = new CruiseOpsDailyExportStyleHandler(dataList);
+        EasyExcel.write(response.getOutputStream())
+                .head(head)
+                .autoCloseStream(false)
+                .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
+                .registerWriteHandler(styleHandler)
+                .registerConverter(new LongStringConverter())
+                .sheet("省际度假游轮经营情况日报")
+                .doWrite(exportData);
+
+        response.addHeader("Content-Disposition",
+                "attachment;filename=" + URLEncoder.encode("省际度假游轮经营情况日报表.xls", StandardCharsets.UTF_8.name()));
+        response.setContentType("application/vnd.ms-excel;charset=UTF-8");
+    }
+
+    // ==================== 导出相关私有方法 ====================
+
+    /**
+     * 构建导出表头(与前端页面15列一一对应)
+     */
+    private List<List<String>> buildExportHeaders() {
+        String[] headers = {"序号", "月份", "日期", "船舶", "航线", "航次号",
+                "客容量", "载客量", "载客率", "房总数", "用房数", "用房率",
+                "船票收入", "二消收入", "收入合计"};
+        List<List<String>> head = new ArrayList<>(headers.length);
+        for (String h : headers) {
+            head.add(Collections.singletonList(h));
+        }
+        return head;
+    }
+
+    /**
+     * 将报表数据转为 EasyExcel 导出格式(List<List<Object>>)
+     * 同比行:同比值写入对应数据列位置(与前端页面展示一致)
+     * 小计/累计数据行:日期/船舶/航线列留空(合并到月份列)
+     */
+    private List<List<Object>> transformExportData(List<CruiseOpsDailyRespVO> dataList) {
+        List<List<Object>> result = new ArrayList<>(dataList.size());
+        for (CruiseOpsDailyRespVO row : dataList) {
+            List<Object> rowData = new ArrayList<>(15);
+            boolean isYoy = "同比".equals(row.getVoyageNo());
+
+            if (isYoy) {
+                // 同比行:序号~航线列留空(合并到上一行),航次号显示"同比"
+                rowData.add("");    // 序号 - 合并
+                rowData.add("");    // 月份 - 合并
+                rowData.add("");    // 日期 - 合并
+                rowData.add("");    // 船舶 - 合并
+                rowData.add("");    // 航线 - 合并
+                rowData.add("同比");
+                rowData.add(formatYoyForExport(row.getPassengerCapacityYoy()));
+                rowData.add(formatYoyForExport(row.getPassengerCountYoy()));
+                rowData.add(formatYoyForExport(row.getPassengerRateYoy()));
+                rowData.add(formatYoyForExport(row.getTotalRoomsYoy()));
+                rowData.add(formatYoyForExport(row.getUsedRoomsYoy()));
+                rowData.add(formatYoyForExport(row.getRoomRateYoy()));
+                rowData.add(formatYoyForExport(row.getTicketIncomeYoy()));
+                rowData.add(formatYoyForExport(row.getSecondIncomeYoy()));
+                rowData.add(formatYoyForExport(row.getTotalIncomeYoy()));
+            } else {
+                // 普通行 / 小计数据行 / 累计数据行
+                rowData.add(row.getIndex() != null ? String.valueOf(row.getIndex()) : "");
+                rowData.add(row.getMonth() != null ? row.getMonth() : "");
+                rowData.add(row.getDate() != null ? row.getDate() : "");
+                rowData.add(row.getShip() != null ? row.getShip() : "");
+                rowData.add(row.getRoute() != null ? row.getRoute() : "");
+                rowData.add(row.getVoyageNo() != null ? row.getVoyageNo() : "");
+                rowData.add(formatNumber(row.getPassengerCapacity()));
+                rowData.add(formatNumber(row.getPassengerCount()));
+                rowData.add(row.getPassengerRate() != null ? row.getPassengerRate() : "");
+                rowData.add(formatNumber(row.getTotalRooms()));
+                rowData.add(row.getUsedRooms() != null ? row.getUsedRooms() : "");
+                rowData.add(row.getRoomRate() != null ? row.getRoomRate() : "");
+                rowData.add(formatMoneyForExport(row.getTicketIncome()));
+                rowData.add(formatMoneyForExport(row.getSecondIncome()));
+                rowData.add(formatMoneyForExport(row.getTotalIncome()));
+            }
+
+            result.add(rowData);
+        }
+        return result;
+    }
+
+    /**
+     * 格式化同比值为导出显示格式(带箭头标识)
+     * 正值: ↑ +15.5%   负值: ↓ -12.3%   持平: — 0.0%   无数据: -
+     */
+    private String formatYoyForExport(String yoyValue) {
+        if (yoyValue == null || "-".equals(yoyValue)) {
+            return "-";
+        }
+        String numStr = yoyValue.replace("%", "");
+        try {
+            double num = Double.parseDouble(numStr);
+            if (num > 0) {
+                String prefix = yoyValue.startsWith("+") ? "↑ " : "↑ +";
+                return prefix + yoyValue;
+            } else if (num < 0) {
+                return "↓ " + yoyValue;
+            } else {
+                return "— " + yoyValue;
+            }
+        } catch (NumberFormatException e) {
+            return yoyValue;
+        }
+    }
+
+    /**
+     * 格式化金额为导出显示(与前端 formatMoney 一致)
+     */
+    private String formatMoneyForExport(BigDecimal amount) {
+        if (amount == null) {
+            return "-";
+        }
+        return "¥" + amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
+    }
+
+    /**
+     * 格式化整数为字符串
+     */
+    private String formatNumber(Integer val) {
+        return val != null ? String.valueOf(val) : "";
+    }
+
+    // ==================== 私有方法 ====================
+
+    /**
+     * 计算派生字段(载客率、用房率、收入合计)
+     */
+    private void calculateDerivedFields(List<CruiseOpsDailyRespVO> dataList) {
+        for (CruiseOpsDailyRespVO item : dataList) {
+            // 载客率 = 载客量 / 客容量 * 100
+            if (item.getPassengerCapacity() != null && item.getPassengerCapacity() > 0
+                    && item.getPassengerCount() != null) {
+                double rate = (double) item.getPassengerCount() / item.getPassengerCapacity() * 100;
+                item.setPassengerRate(String.format("%.1f%%", rate));
+            }
+            // 用房率 = 用房数 / 房总数 * 100
+            if (item.getTotalRooms() != null && item.getTotalRooms() > 0
+                    && item.getUsedRooms() != null) {
+                double usedRoomsVal = safeParseInt(item.getUsedRooms());
+                double rate = usedRoomsVal / item.getTotalRooms() * 100;
+                item.setRoomRate(String.format("%.1f%%", rate));
+            }
+            // 收入合计 = 船票收入 + 二消收入
+            BigDecimal ticket = item.getTicketIncome() != null ? item.getTicketIncome() : BigDecimal.ZERO;
+            BigDecimal second = item.getSecondIncome() != null ? item.getSecondIncome() : BigDecimal.ZERO;
+            item.setTotalIncome(ticket.add(second));
+        }
+    }
+
+    /**
+     * 构建完整报表数据结构:
+     * - 日常数据行(带序号)
+     * - 月度小计数据行 + 月度小计同比行(小计同比=当月小计 vs 去年同月小计)
+     * - 累计数据行 + 累计同比行(累计同比=累计本期 vs 累计去年同期,从第二个月起)
+     *
+     * 去年同期月份通过年份减1来匹配(如 2026-01 → 2025-01),
+     * 不再使用位置索引匹配,保证数据对应准确。
+     */
+    private List<CruiseOpsDailyRespVO> buildReportWithYoy(
+            List<CruiseOpsDailyRespVO> currentDataList,
+            List<CruiseOpsDailyRespVO> lastYearDataList) {
+
+        List<CruiseOpsDailyRespVO> result = new ArrayList<>();
+
+        // 1. 本期按月份分组(TreeMap保证有序)
+        Map<String, List<CruiseOpsDailyRespVO>> currentMonthMap = currentDataList.stream()
+                .collect(Collectors.groupingBy(
+                        item -> item.getMonth() != null ? item.getMonth() : "",
+                        TreeMap::new, Collectors.toList()));
+
+        // 2. 去年同期按月份分组(同样用TreeMap保持顺序)
+        Map<String, List<CruiseOpsDailyRespVO>> lastYearMonthMap = lastYearDataList.stream()
+                .collect(Collectors.groupingBy(
+                        item -> item.getMonth() != null ? item.getMonth() : "",
+                        TreeMap::new, Collectors.toList()));
+
+        // 3. 全局序号和累计容器
+        int globalIdx = 1;
+        List<CruiseOpsDailyRespVO> allDailyDataForCumulative = new ArrayList<>();
+        List<CruiseOpsDailyRespVO> allLastYearDailyForCumulative = new ArrayList<>();
+
+        // 4. 遍历每个月份(通过年份-1匹配去年同期月份)
+        int monthIndex = 0;
+        for (Map.Entry<String, List<CruiseOpsDailyRespVO>> entry : currentMonthMap.entrySet()) {
+            String month = entry.getKey();
+            List<CruiseOpsDailyRespVO> dailyItems = entry.getValue();
+
+            // --- 4.1 设置序号 ---
+            for (CruiseOpsDailyRespVO item : dailyItems) {
+                item.setIndex(globalIdx++);
+            }
+
+            // --- 4.2 加入结果和累计容器 ---
+            result.addAll(dailyItems);
+            allDailyDataForCumulative.addAll(dailyItems);
+
+            // --- 4.3 月度小计数据行 ---
+            CruiseOpsDailyRespVO subtotalData = buildSubtotalRow(dailyItems, month);
+            result.add(subtotalData);
+
+            // --- 4.4 月度小计同比行(通过月份key转换匹配去年同期)---
+            String lastYearMonth = getMonthMinusOneYear(month);
+            List<CruiseOpsDailyRespVO> lastYearMonthData = lastYearMonthMap.getOrDefault(
+                    lastYearMonth, new ArrayList<>());
+            CruiseOpsDailyRespVO lastYearSubtotal = buildSubtotalRow(lastYearMonthData, "");
+            CruiseOpsDailyRespVO subtotalYoy = buildYoyRow(subtotalData, lastYearSubtotal);
+            result.add(subtotalYoy);
+
+            // 累加去年同期月度数据(用于累计同比计算)
+            allLastYearDailyForCumulative.addAll(lastYearMonthData);
+
+            // --- 4.5 累计数据行 + 累计同比行(从第二个月起,累计同比=累计本期 vs 累计去年同期) ---
+            if (monthIndex > 0) {
+                // 累计数据行
+                CruiseOpsDailyRespVO cumData = buildSubtotalRow(allDailyDataForCumulative, "累计");
+                result.add(cumData);
+
+                // 累计同比行:用累计本期 vs 累计去年同期 重新计算同比
+                CruiseOpsDailyRespVO lastYearCumData = buildSubtotalRow(allLastYearDailyForCumulative, "");
+                CruiseOpsDailyRespVO cumYoy = buildYoyRow(cumData, lastYearCumData);
+                result.add(cumYoy);
+            }
+
+            monthIndex++;
+        }
+
+        return result;
+    }
+
+    /**
+     * 将月份字符串减去一年(如 2026-01 → 2025-01)
+     */
+    private String getMonthMinusOneYear(String month) {
+        if (month == null || month.length() < 7) {
+            return "";
+        }
+        try {
+            int year = Integer.parseInt(month.substring(0, 4));
+            String rest = month.substring(4);
+            return (year - 1) + rest;
+        } catch (NumberFormatException e) {
+            return "";
+        }
+    }
+
+    /**
+     * 构建小计/汇总行(汇总一组数据的各指标)
+     */
+    private CruiseOpsDailyRespVO buildSubtotalRow(List<CruiseOpsDailyRespVO> items, String monthLabel) {
+        CruiseOpsDailyRespVO row = new CruiseOpsDailyRespVO();
+
+        if ("累计".equals(monthLabel)) {
+            row.setMonth("累计");
+        } else if (!"累计".equals(monthLabel) && monthLabel.contains("小计")) {
+            row.setMonth(monthLabel);
+        } else {
+            // 正常月度小计
+            row.setMonth(monthLabel + "小计");
+        }
+        row.setDate("");
+        row.setShip("");
+        row.setRoute("");
+        row.setVoyageNo("数据");
+        row.setIsSubtotal(!"累计".equals(row.getMonth()));
+        row.setIsCumulative("累计".equals(row.getMonth()));
+
+        if (CollUtil.isEmpty(items)) {
+            return row;
+        }
+
+        // 数值汇总
+        int passengerCapacitySum = 0;
+        int passengerCountSum = 0;
+        int totalRoomsSum = 0;
+        int usedRoomsSum = 0;
+        BigDecimal ticketIncomeSum = BigDecimal.ZERO;
+        BigDecimal secondIncomeSum = BigDecimal.ZERO;
+
+        for (CruiseOpsDailyRespVO item : items) {
+            passengerCapacitySum += safeInt(item.getPassengerCapacity());
+            passengerCountSum += safeInt(item.getPassengerCount());
+            totalRoomsSum += safeInt(item.getTotalRooms());
+            usedRoomsSum += safeParseInt(item.getUsedRooms());
+            ticketIncomeSum = ticketIncomeSum.add(safeBigDecimal(item.getTicketIncome()));
+            secondIncomeSum = secondIncomeSum.add(safeBigDecimal(item.getSecondIncome()));
+        }
+
+        row.setPassengerCapacity(passengerCapacitySum);
+        row.setPassengerCount(passengerCountSum);
+        row.setTotalRooms(totalRoomsSum);
+        row.setUsedRooms(String.valueOf(usedRoomsSum));
+        row.setTicketIncome(ticketIncomeSum);
+        row.setSecondIncome(secondIncomeSum);
+
+        // 派生字段重新计算
+        if (passengerCapacitySum > 0) {
+            double rate = (double) passengerCountSum / passengerCapacitySum * 100;
+            row.setPassengerRate(String.format("%.1f%%", rate));
+        }
+        if (totalRoomsSum > 0) {
+            double rate = (double) usedRoomsSum / totalRoomsSum * 100;
+            row.setRoomRate(String.format("%.1f%%", rate));
+        }
+        row.setTotalIncome(ticketIncomeSum.add(secondIncomeSum));
+
+        return row;
+    }
+
+    /**
+     * 构建同比行(对比本期汇总 vs 往期汇总)
+     * 仅保留同比率数据,原始数值字段不填充(前端同比行只展示同比率)
+     */
+    private CruiseOpsDailyRespVO buildYoyRow(CruiseOpsDailyRespVO currentRow, CruiseOpsDailyRespVO lastYearRow) {
+        CruiseOpsDailyRespVO yoyRow = new CruiseOpsDailyRespVO();
+        yoyRow.setIndex(null);
+        yoyRow.setMonth("");   // 与上一行(小计/累计)的月份合并
+        yoyRow.setDate("");
+        yoyRow.setShip("");
+        yoyRow.setRoute("");
+        yoyRow.setVoyageNo("同比");
+        yoyRow.setIsSubtotal(currentRow.getIsSubtotal() != null && currentRow.getIsSubtotal());
+        yoyRow.setIsCumulative(currentRow.getIsCumulative() != null && currentRow.getIsCumulative());
+
+        // ===== 计算各指标的同比率 =====
+        yoyRow.setPassengerCapacityYoy(calcYoyPercent(
+                safeInt(currentRow.getPassengerCapacity()), safeInt(lastYearRow.getPassengerCapacity())));
+        yoyRow.setPassengerCountYoy(calcYoyPercent(
+                safeInt(currentRow.getPassengerCount()), safeInt(lastYearRow.getPassengerCount())));
+
+        // 载客率同比(百分点差异)
+        yoyRow.setPassengerRateYoy(calcRateDiff(currentRow.getPassengerRate(), lastYearRow.getPassengerRate()));
+
+        yoyRow.setTotalRoomsYoy(calcYoyPercent(
+                safeInt(currentRow.getTotalRooms()), safeInt(lastYearRow.getTotalRooms())));
+        yoyRow.setUsedRoomsYoy(calcYoyPercent(
+                safeParseInt(currentRow.getUsedRooms()), safeParseInt(lastYearRow.getUsedRooms())));
+
+        // 用房率同比(百分点差异)
+        yoyRow.setRoomRateYoy(calcRateDiff(currentRow.getRoomRate(), lastYearRow.getRoomRate()));
+
+        // 收入同比率
+        yoyRow.setTicketIncomeYoy(calcYoyPercent(
+                safeBigDecimal(currentRow.getTicketIncome()), safeBigDecimal(lastYearRow.getTicketIncome())));
+        yoyRow.setSecondIncomeYoy(calcYoyPercent(
+                safeBigDecimal(currentRow.getSecondIncome()), safeBigDecimal(lastYearRow.getSecondIncome())));
+        yoyRow.setTotalIncomeYoy(calcYoyPercent(
+                safeBigDecimal(currentRow.getTotalIncome()), safeBigDecimal(lastYearRow.getTotalIncome())));
+
+        return yoyRow;
+    }
+
+    /**
+     * 计算同比百分比 (本期-往期)/往期*100
+     */
+    private String calcYoyPercent(Number current, Number last) {
+        if (current == null || last == null || last.doubleValue() == 0) {
+            return "-";
+        }
+        try {
+            return MathHelper.getNumberYOY(current, last, 1) + "%";
+        } catch (Exception e) {
+            log.debug("calcYoyPercent error: {},{}", current, last);
+            return "-";
+        }
+    }
+
+    /**
+     * 计算比率百分点差异(如 84.0% - 82.0% = +2.0)
+     */
+    private String calcRateDiff(String currentRate, String lastRate) {
+        if (currentRate == null || lastRate == null || !currentRate.contains("%") || !lastRate.contains("%")) {
+            return "-";
+        }
+        try {
+            double currentVal = Double.parseDouble(currentRate.replace("%", ""));
+            double lastVal = Double.parseDouble(lastRate.replace("%", ""));
+            double diff = currentVal - lastVal;
+            return (diff >= 0 ? "+" : "") + String.format("%.1f", diff);
+        } catch (NumberFormatException e) {
+            return "-";
+        }
+    }
+
+    private int safeInt(Integer val) {
+        return val != null ? val : 0;
+    }
+
+    private int safeParseInt(String val) {
+        if (val == null || val.isEmpty()) {
+            return 0;
+        }
+        try {
+            return Integer.parseInt(val);
+        } catch (NumberFormatException e) {
+            try {
+                return (int) Double.parseDouble(val);
+            } catch (NumberFormatException e2) {
+                return 0;
+            }
+        }
+    }
+
+    private BigDecimal safeBigDecimal(BigDecimal val) {
+        return val != null ? val : BigDecimal.ZERO;
+    }
+
+}

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

@@ -0,0 +1,166 @@
+package com.yc.ship.module.trade.utils.excel;
+
+import com.alibaba.excel.converters.longconverter.LongStringConverter;
+import com.alibaba.excel.write.handler.CellWriteHandler;
+import com.alibaba.excel.write.handler.context.CellWriteHandlerContext;
+import com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyRespVO;
+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;
+
+import java.util.*;
+
+/**
+ * 省际度假游轮经营情况日报表 导出样式处理器
+ * 处理单元格合并和小计/累计行的背景色
+ */
+public class CruiseOpsDailyExportStyleHandler implements CellWriteHandler {
+
+    private static final int TOTAL_COLUMNS = 15;
+
+    private final List<CruiseOpsDailyRespVO> dataList;
+
+    /** 预计算的合并区域 */
+    private final List<CellRangeAddress> mergeRegions = new ArrayList<>();
+
+    /** 小计行(含同比行)在 data 中的索引集合 */
+    private final Set<Integer> subtotalRowIndices = new HashSet<>();
+    /** 累计行(含同比行)在 data 中的索引集合 */
+    private final Set<Integer> cumulativeRowIndices = new HashSet<>();
+
+    /** 已处理的单元格计数 */
+    private int processedCells = 0;
+    private final int totalCells;
+    private boolean mergesApplied = false;
+
+    /** 缓存的样式(避免每个单元格创建新样式) */
+    private CellStyle subtotalStyle;
+    private CellStyle cumulativeStyle;
+
+    public CruiseOpsDailyExportStyleHandler(List<CruiseOpsDailyRespVO> dataList) {
+        this.dataList = dataList;
+        this.totalCells = dataList.size() * TOTAL_COLUMNS;
+        precompute();
+    }
+
+    /**
+     * 预计算合并区域和行类型
+     */
+    private void precompute() {
+        for (int i = 0; i < dataList.size(); i++) {
+            CruiseOpsDailyRespVO row = dataList.get(i);
+            boolean isSubtotal = Boolean.TRUE.equals(row.getIsSubtotal());
+            boolean isCumulative = Boolean.TRUE.equals(row.getIsCumulative());
+            boolean isYoy = "同比".equals(row.getVoyageNo());
+
+            int excelRowIndex = i + 1; // +1 因为第0行是表头
+
+            if (isSubtotal && !isYoy) {
+                // 小计数据行 + 紧随的同比行都标记为小计行
+                subtotalRowIndices.add(i);
+                if (i + 1 < dataList.size()) {
+                    subtotalRowIndices.add(i + 1);
+                }
+                // 序号列:纵向合并2行
+                mergeRegions.add(new CellRangeAddress(excelRowIndex, excelRowIndex + 1, 0, 0));
+                // 月份~航线列(1-4):纵向2行 + 横向4列合并
+                mergeRegions.add(new CellRangeAddress(excelRowIndex, excelRowIndex + 1, 1, 4));
+            }
+
+            if (isCumulative && !isYoy) {
+                cumulativeRowIndices.add(i);
+                if (i + 1 < dataList.size()) {
+                    cumulativeRowIndices.add(i + 1);
+                }
+                mergeRegions.add(new CellRangeAddress(excelRowIndex, excelRowIndex + 1, 0, 0));
+                mergeRegions.add(new CellRangeAddress(excelRowIndex, excelRowIndex + 1, 1, 4));
+            }
+        }
+    }
+
+    @Override
+    public void afterCellDispose(CellWriteHandlerContext context) {
+        // 跳过表头行
+        if (Boolean.TRUE.equals(context.getHead())) {
+            return;
+        }
+
+        Cell cell = context.getCell();
+        int excelRowIndex = cell.getRow().getRowNum();
+        int dataRowIndex = excelRowIndex - 1; // 减去表头行
+
+        if (dataRowIndex < 0 || dataRowIndex >= dataList.size()) {
+            return;
+        }
+
+        Sheet sheet = context.getWriteSheetHolder().getSheet();
+        Workbook workbook = sheet.getWorkbook();
+
+        // 1. 应用背景色
+        applyRowStyle(cell, dataRowIndex, workbook);
+
+        // 2. 所有单元格处理完成后,统一应用合并
+        processedCells++;
+        if (!mergesApplied && processedCells >= totalCells) {
+            mergesApplied = true;
+            for (CellRangeAddress merge : mergeRegions) {
+                sheet.addMergedRegion(merge);
+            }
+        }
+    }
+
+    /**
+     * 根据行类型应用样式
+     */
+    private void applyRowStyle(Cell cell, int dataRowIndex, Workbook workbook) {
+        boolean isSubtotalRow = subtotalRowIndices.contains(dataRowIndex);
+        boolean isCumulativeRow = cumulativeRowIndices.contains(dataRowIndex);
+
+        if (!isSubtotalRow && !isCumulativeRow) {
+            return;
+        }
+
+        CellStyle style;
+        if (isSubtotalRow) {
+            if (subtotalStyle == null) {
+                subtotalStyle = createFillStyle(workbook, new byte[]{(byte) 0xFE, (byte) 0xF0, (byte) 0xF0});
+            }
+            style = subtotalStyle;
+        } else {
+            if (cumulativeStyle == null) {
+                cumulativeStyle = createFillStyle(workbook, new byte[]{(byte) 0xF5, (byte) 0xF7, (byte) 0xFA});
+            }
+            style = cumulativeStyle;
+        }
+
+        cell.setCellStyle(style);
+    }
+
+    /**
+     * 创建带填充色和居中的单元格样式
+     */
+    private CellStyle createFillStyle(Workbook workbook, byte[] rgb) {
+        CellStyle style = workbook.createCellStyle();
+        style.setAlignment(HorizontalAlignment.CENTER);
+        style.setVerticalAlignment(VerticalAlignment.CENTER);
+        style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+
+        // 设置边框
+        style.setBorderTop(BorderStyle.THIN);
+        style.setBorderBottom(BorderStyle.THIN);
+        style.setBorderLeft(BorderStyle.THIN);
+        style.setBorderRight(BorderStyle.THIN);
+
+        // 尝试使用XSSF自定义颜色
+        if (style instanceof XSSFCellStyle) {
+            XSSFColor customColor = new XSSFColor(rgb, null);
+            ((XSSFCellStyle) style).setFillForegroundColor(customColor);
+        } else {
+            // HSSF 降级:使用最接近的 IndexedColor
+            style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
+        }
+
+        return style;
+    }
+}

+ 70 - 0
ship-module-trade/ship-module-trade-biz/src/main/resources/mapper/report/OpsDailyMapper.xml

@@ -0,0 +1,70 @@
+<?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.OpsDailyMapper">
+
+    <!-- 查询省际度假游轮经营情况日报(不分页) -->
+    <select id="selectCruiseOpsDailyList"
+            parameterType="com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyReqVO"
+            resultType="com.yc.ship.module.trade.controller.admin.report.vo.CruiseOpsDailyRespVO">
+        SELECT
+        DATE_FORMAT(v.start_time, '%Y-%m') AS `month`,
+        DATE_FORMAT(v.start_time, '%Y-%m-%d') AS `date`,
+        s.name AS ship,
+        r.name AS route,
+        v.name AS voyageNo,
+        s.capacity AS passengerCapacity,
+        COALESCE(tv.passengerCount, 0) AS passengerCount,
+        COALESCE(rr.totalRooms, 0) AS totalRooms,
+        CAST(COALESCE(SUM(rm.usedRooms), 0) AS SIGNED) AS usedRooms,
+        COALESCE(inc.ticketIncome, 0) AS ticketIncome,
+        0 AS secondIncome
+        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
+        INNER JOIN (
+        SELECT id, voyage_id, pay_status, real_pay_amount
+        FROM trade_order
+        WHERE deleted = 0 AND order_status in (15, 14, 13, 10, 12, 9, 8, 7, 6, 5, 4, 3, 1, 0)
+        ) o ON v.id = o.voyage_id
+        LEFT JOIN (
+        SELECT order_id, SUM(use_room_num) AS usedRooms
+        FROM trade_order_room_model
+        WHERE deleted = 0
+        GROUP BY order_id
+        ) rm ON o.id = rm.order_id
+        LEFT JOIN (
+        SELECT order_id, COUNT(id) AS passengerCount
+        FROM trade_visitor
+        WHERE deleted = 0
+        GROUP BY order_id
+        ) tv ON o.id = tv.order_id
+        LEFT JOIN (
+        SELECT
+        o.id AS order_id,
+        SUM(CASE WHEN o.pay_status = 1 THEN p.pay_amount ELSE o.real_pay_amount END) AS ticketIncome
+        FROM trade_order o
+        LEFT JOIN trade_order_pay p
+        ON o.pay_status = 1 AND p.order_id = o.id AND p.deleted = 0 AND p.pay_status = 1
+        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.id
+        ) inc ON o.id = inc.order_id
+        LEFT JOIN (
+        SELECT ship_id, COUNT(*) AS totalRooms
+        FROM resource_room
+        WHERE deleted = 0
+        GROUP BY ship_id
+        ) rr ON v.ship_id = rr.ship_id
+        WHERE v.deleted = 0
+          <if test="startDate != null and startDate != ''">
+              AND v.start_time &gt;= #{startDate}
+          </if>
+          <if test="endDate != null and endDate != ''">
+              AND v.start_time &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY)
+          </if>
+        GROUP BY v.id
+        ORDER BY v.start_time ASC, s.name ASC, v.name ASC
+    </select>
+
+</mapper>