Просмотр исходного кода

全航次库存数据看板版面功能完善

caotao 2 недель назад
Родитель
Сommit
796c59ca09

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

@@ -147,4 +147,18 @@ public class KanbanBoardController {
         voyageStockBoardService.exportVisitorAgeDashboardExcel(reqVO, response);
     }
 
+    /**
+     * 导出全航次库存看板 Excel
+     *
+     * @param reqVO 查询条件
+     * @param response  HTTP响应
+     * @throws IOException IO异常
+     */
+    @GetMapping("/allVoyageStockBoard/export-excel")
+    @Operation(summary = "导出航次库存看板 Excel")
+    @ApiAccessLog(operateType = EXPORT)
+    public void exportAllVoyageStockBoardExcel(@Valid AllVoyageStockBoardReqVO reqVO,
+                                            HttpServletResponse response) throws IOException {
+        voyageStockBoardService.exportAllVoyageStockBoardExcel(reqVO, response);
+    }
 }

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

@@ -9,6 +9,8 @@ import java.util.Map;
 @Schema(description = "管理后台 - 全航次库存看板分页 Response VO")
 @Data
 public class AllVoyageStockBoardRespVO {
+    private Long voyageId;
+
     @Schema(description = "航次")
     private String voyage;
 

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

@@ -1,5 +1,9 @@
 package com.yc.ship.module.trade.dal.mysql.report;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.yc.ship.module.trade.controller.admin.report.vo.AllVoyageStockBoardReqVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.AllVoyageStockBoardRespVO;
 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;
@@ -7,6 +11,7 @@ import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
+import java.util.Map;
 
 /**
  * 航次库存看板 Mapper
@@ -56,4 +61,26 @@ public interface VoyageStockBoardMapper {
      * @return 游客年龄列表
      */
     List<Integer> selectVisitorAgeListForAgeDashboard(@Param("shipId") Long shipId, @Param("voyageIds") List<Long> voyageIds);
+
+    /**
+     * 查询所有航次库存分页
+     * @param objectPage
+     * @param reqVO
+     * @return
+     */
+    IPage<AllVoyageStockBoardRespVO> selectAllVoyageStockPage(Page<Object> objectPage, @Param("vo") AllVoyageStockBoardReqVO reqVO);
+
+    /**
+     * 查询航次总计划、占位计划、切位计划、实团计划
+     * @param voyageId
+     * @return
+     */
+    Map<String, Object> selectVoyageStockNum(@Param("voyageId") Long voyageId);
+
+    /**
+     * 获取航次库存分组统计
+     * @param voyageId
+     * @return
+     */
+    List<Map<String, Object>> selectVoyageStockNumGruopByRoomModelIdAndFloor(Long voyageId);
 }

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

@@ -6,6 +6,7 @@ import com.yc.ship.module.trade.controller.admin.report.vo.*;
 import javax.servlet.http.HttpServletResponse;
 import javax.validation.Valid;
 import java.io.IOException;
+import java.io.UnsupportedEncodingException;
 import java.util.List;
 
 /**
@@ -44,6 +45,13 @@ public interface VoyageStockBoardService {
      */
     PageResult<AllVoyageStockBoardRespVO> getAllVoyageStockBoardPage(@Valid AllVoyageStockBoardReqVO reqVO);
 
+    /**
+     * 导出全航次库存看板 Excel
+     * @param reqVO
+     * @param response
+     */
+    void exportAllVoyageStockBoardExcel(@Valid AllVoyageStockBoardReqVO reqVO, HttpServletResponse response) throws IOException;
+
     /**
      * 查询客源地分析看板数据
      *

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

@@ -7,6 +7,8 @@ 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.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.api.ship.dto.RoomModelFloorNumDTO;
@@ -17,6 +19,7 @@ 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.AllVoyageStockBoardExportStyleHandler;
 import com.yc.ship.module.trade.utils.excel.VoyageStockBoardExportStyleHandler;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -24,6 +27,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
+import java.io.UnsupportedEncodingException;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.net.URLEncoder;
@@ -217,9 +221,278 @@ public class VoyageStockBoardServiceImpl implements VoyageStockBoardService {
 
     @Override
     public PageResult<AllVoyageStockBoardRespVO> getAllVoyageStockBoardPage(AllVoyageStockBoardReqVO reqVO) {
-        return null;
+        IPage<AllVoyageStockBoardRespVO> page = voyageStockBoardMapper.selectAllVoyageStockPage(new Page<>(reqVO.getPageNo(), reqVO.getPageSize()), reqVO);
+        List<AllVoyageStockBoardRespVO> list = page.getRecords();
+        list.forEach(item -> {
+            // 根据航次id查询实团计划、占位计划、切位计划、总计划
+            Map<String,Object> numMap = voyageStockBoardMapper.selectVoyageStockNum(item.getVoyageId());
+            if(numMap != null){
+                item.setTotalPlanSum(numMap.get("totalPlanSum")==null?BigDecimal.ZERO: new BigDecimal(numMap.get("totalPlanSum").toString()));
+                item.setRealGroupTotal(numMap.get("realGroupTotal")==null?BigDecimal.ZERO: new BigDecimal(numMap.get("realGroupTotal").toString()));
+                item.setOccupyTotal(numMap.get("occupyTotal")==null?BigDecimal.ZERO: new BigDecimal(numMap.get("occupyTotal").toString()));
+                item.setSwitchTotal(numMap.get("switchTotal")==null?BigDecimal.ZERO: new BigDecimal(numMap.get("switchTotal").toString()));
+            }
+            // 根据航次id查询动态房型数据
+            List<Map<String, Object>> l = voyageStockBoardMapper.selectVoyageStockNumGruopByRoomModelIdAndFloor(item.getVoyageId());
+            Map<String, BigDecimal> map = new HashMap<>();
+            if (CollUtil.isNotEmpty(l)) {
+                for(Map<String, Object> m : l){
+                    // map的key为:已确定数:room_roomModelId_floor_confirmed 虚占位:room_roomModelId_floor_virtual,
+                    // 家庭套房或家庭套房PRO的key为:家庭套房:已确定数:family_floor_confirmed  虚占位:family_floor_virtual
+                    // 家庭套房PRO:已确定数: family_pro_floor_confirmed  虚占位:family_pro_floor_virtual
+                    String roomModelId = String.valueOf(m.get("room_model_id"));
+                    String floor = String.valueOf(m.get("floor"));
+                    String roomModelName = String.valueOf(m.get("room_model_name"));
+
+                    BigDecimal confirmed = m.get("confirmed") != null ? new BigDecimal(String.valueOf(m.get("confirmed"))) : BigDecimal.ZERO;
+                    BigDecimal virtualNum = m.get("virtualNum") != null ? new BigDecimal(String.valueOf(m.get("virtualNum"))) : BigDecimal.ZERO;
+
+                    // 根据房型名称判断前缀和key格式
+                    String prefix;
+                    String confirmedKey;
+                    String virtualKey;
+
+                    if (roomModelName != null) {
+                        if (roomModelName.contains("家庭套房PRO")) {
+                            // 家庭套房PRO:不包含roomModelId
+                            prefix = "family_pro";
+                            confirmedKey = prefix + "_" + floor + "_confirmed";
+                            virtualKey = prefix + "_" + floor + "_virtual";
+                        } else if (roomModelName.contains("家庭套房")) {
+                            // 家庭套房:不包含roomModelId
+                            prefix = "family";
+                            confirmedKey = prefix + "_" + floor + "_confirmed";
+                            virtualKey = prefix + "_" + floor + "_virtual";
+                        } else {
+                            // 普通房型:包含roomModelId
+                            prefix = "room";
+                            confirmedKey = prefix + "_" + roomModelId + "_" + floor + "_confirmed";
+                            virtualKey = prefix + "_" + roomModelId + "_" + floor + "_virtual";
+                        }
+                    } else {
+                        // 默认情况:普通房型
+                        confirmedKey = "room_" + roomModelId + "_" + floor + "_confirmed";
+                        virtualKey = "room_" + roomModelId + "_" + floor + "_virtual";
+                    }
+
+                    map.put(confirmedKey, confirmed);
+                    map.put(virtualKey, virtualNum);
+                }
+            }
+            item.setRoomData( map);
+        });
+        return new PageResult<>(list, page.getTotal());
     }
 
+    @Override
+    public void exportAllVoyageStockBoardExcel(AllVoyageStockBoardReqVO reqVO, HttpServletResponse response) throws IOException {
+        // 1. 查询所有数据
+        IPage<AllVoyageStockBoardRespVO> page = voyageStockBoardMapper.selectAllVoyageStockPage(
+                new Page<>(1, Integer.MAX_VALUE), reqVO);
+        List<AllVoyageStockBoardRespVO> list = page.getRecords();
+
+        if (CollUtil.isEmpty(list)) {
+            throw new RuntimeException("没有可导出的数据");
+        }
+
+        // 2. 获取表头配置
+        List<AllVoyageStockBoardTableHeadRespVO> tableHeads = getAllVoyageStockBoardTableHead(reqVO);
+        if (CollUtil.isEmpty(tableHeads)) {
+            throw new RuntimeException("没有可导出的房型数据");
+        }
+
+        // 3. 填充数据
+        list.forEach(item -> {
+            Map<String, Object> numMap = voyageStockBoardMapper.selectVoyageStockNum(item.getVoyageId());
+            if (numMap != null) {
+                item.setTotalPlanSum(numMap.get("totalPlanSum")==null?BigDecimal.ZERO: new BigDecimal(numMap.get("totalPlanSum").toString()));
+                item.setRealGroupTotal(numMap.get("realGroupTotal")==null?BigDecimal.ZERO: new BigDecimal(numMap.get("realGroupTotal").toString()));
+                item.setOccupyTotal(numMap.get("occupyTotal")==null?BigDecimal.ZERO: new BigDecimal(numMap.get("occupyTotal").toString()));
+                item.setSwitchTotal(numMap.get("switchTotal")==null?BigDecimal.ZERO: new BigDecimal(numMap.get("switchTotal").toString()));
+            }
+            // 根据航次id查询动态房型数据
+            List<Map<String, Object>> l = voyageStockBoardMapper.selectVoyageStockNumGruopByRoomModelIdAndFloor(item.getVoyageId());
+            Map<String, BigDecimal> map = new HashMap<>();
+            if (CollUtil.isNotEmpty(l)) {
+                for (Map<String, Object> m : l) {
+                    String roomModelId = String.valueOf(m.get("room_model_id"));
+                    String floor = String.valueOf(m.get("floor"));
+                    String roomModelName = String.valueOf(m.get("room_model_name"));
+
+                    BigDecimal confirmed = m.get("confirmed") != null ? new BigDecimal(String.valueOf(m.get("confirmed"))) : BigDecimal.ZERO;
+                    BigDecimal virtualNum = m.get("virtualNum") != null ? new BigDecimal(String.valueOf(m.get("virtualNum"))) : BigDecimal.ZERO;
+                    // 根据房型名称判断前缀和key格式
+                    String prefix;
+                    String confirmedKey;
+                    String virtualKey;
+                    if (roomModelName != null) {
+                        if (roomModelName.contains("家庭套房PRO")) {
+                            prefix = "family_pro";
+                            confirmedKey = prefix + "_" + floor + "_confirmed";
+                            virtualKey = prefix + "_" + floor + "_virtual";
+                        } else if (roomModelName.contains("家庭套房")) {
+                            prefix = "family";
+                            confirmedKey = prefix + "_" + floor + "_confirmed";
+                            virtualKey = prefix + "_" + floor + "_virtual";
+                        } else {
+                            prefix = "room";
+                            confirmedKey = prefix + "_" + roomModelId + "_" + floor + "_confirmed";
+                            virtualKey = prefix + "_" + roomModelId + "_" + floor + "_virtual";
+                        }
+                    } else {
+                        confirmedKey = "room_" + roomModelId + "_" + floor + "_confirmed";
+                        virtualKey = "room_" + roomModelId + "_" + floor + "_virtual";
+
+                    }
+                    map.put(confirmedKey, confirmed);
+                    map.put(virtualKey, virtualNum);
+                }
+            }
+            item.setRoomData(map);
+        });
+
+
+        // 4. 构建动态表头
+        List<List<String>> headers = buildDynamicExportHeadersFromTableHead(tableHeads);
+        log.info("headers: {}", headers);
+        // 5. 转换导出数据(使用表头配置的key)
+        List<List<Object>> data = transformDynamicExportDataFromTableHead(list, tableHeads);
+        log.info("data: {}", data);
+        // 6. 设置响应头(必须在获取OutputStream之前)
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8");
+        response.setCharacterEncoding("UTF-8");
+        String fileName = URLEncoder.encode("全航次库存看板.xlsx", StandardCharsets.UTF_8.name());
+        response.addHeader("Content-Disposition", "attachment;filename=" + fileName);
+
+        // 7. 使用EasyExcel导出
+        try {
+            EasyExcel.write(response.getOutputStream())
+                    .head(headers)
+                    .autoCloseStream(false)
+                    .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
+                    .registerWriteHandler(new AllVoyageStockBoardExportStyleHandler(tableHeads))
+                    .registerConverter(new LongStringConverter())
+                    .sheet("全航次库存看板")
+                    .doWrite(data);
+        } catch (Exception e) {
+            throw new RuntimeException("导出Excel失败", e);
+        }
+    }
+
+
+    /**
+     * 构建导出表头
+     */
+    private List<List<String>> buildDynamicExportHeadersFromTableHead(List<AllVoyageStockBoardTableHeadRespVO> tableHeads) {
+        List<List<String>> head = new ArrayList<>();
+
+        // 固定列
+        head.add(new ArrayList<>(Collections.singletonList("航次")));
+        head.add(new ArrayList<>(Collections.singletonList("倒计时(天)")));
+        head.add(new ArrayList<>(Collections.singletonList("实团计划合计")));
+        head.add(new ArrayList<>(Collections.singletonList("占位计划合计")));
+        head.add(new ArrayList<>(Collections.singletonList("切位计划合计")));
+        head.add(new ArrayList<>(Collections.singletonList("总计划(含虚占位计划合计)")));
+
+        // 动态房型列
+        for (AllVoyageStockBoardTableHeadRespVO tableHead : tableHeads) {
+            String roomLabel = tableHead.getLabel() + "(" + tableHead.getSumRooms() + "间)";
+
+            // 为每个楼层添加4列:总计划、已确定、虚占位、剩余
+            for (AllVoyageStockBoardTableHeadRespVO.FloorInfo floor : tableHead.getFloors()) {
+                String floorLabel = floor.getName() + " " + floor.getFloors() + "F(" + floor.getTotalRooms() + "间)";
+
+                // 总计划
+                List<String> header1 = new ArrayList<>();
+                header1.add(roomLabel);
+                header1.add(floorLabel);
+                header1.add("总计划(含虚占位)");
+                head.add(header1);
+
+                // 已确定
+                List<String> header2 = new ArrayList<>();
+                header2.add(roomLabel);
+                header2.add(floorLabel);
+                header2.add("其中已确定计划");
+                head.add(header2);
+
+                // 虚占位
+                List<String> header3 = new ArrayList<>();
+                header3.add(roomLabel);
+                header3.add(floorLabel);
+                header3.add("其中虚占位计划");
+                head.add(header3);
+
+                // 剩余
+                List<String> header4 = new ArrayList<>();
+                header4.add(roomLabel);
+                header4.add(floorLabel);
+                header4.add("剩余");
+                head.add(header4);
+            }
+
+            // 房型余量列
+            List<String> remainHeader = new ArrayList<>();
+            remainHeader.add(roomLabel);
+            remainHeader.add("");
+            remainHeader.add("余量");
+            head.add(remainHeader);
+        }
+
+        return head;
+    }
+
+    /**
+     * 基于表头配置转换导出数据
+     */
+    private List<List<Object>> transformDynamicExportDataFromTableHead(List<AllVoyageStockBoardRespVO> list,
+                                                                       List<AllVoyageStockBoardTableHeadRespVO> tableHeads) {
+        List<List<Object>> result = new ArrayList<>();
+
+        for (AllVoyageStockBoardRespVO item : list) {
+            List<Object> rowData = new ArrayList<>();
+
+            // 固定列数据
+            rowData.add(item.getVoyage() != null ? item.getVoyage() : "");
+            rowData.add(item.getCountDown() != null ? item.getCountDown() : "");
+            rowData.add(formatDecimal(item.getRealGroupTotal()));
+            rowData.add(formatDecimal(item.getOccupyTotal()));
+            rowData.add(formatDecimal(item.getSwitchTotal()));
+            rowData.add(formatDecimal(item.getTotalPlanSum()));
+
+            // 动态房型列数据(按照表头配置的key顺序)
+            Map<String, BigDecimal> roomData = item.getRoomData();
+            BigDecimal totalRemain = BigDecimal.ZERO;
+            for (AllVoyageStockBoardTableHeadRespVO tableHead : tableHeads) {
+                for (AllVoyageStockBoardTableHeadRespVO.FloorInfo floor : tableHead.getFloors()) {
+                    String floorKey = floor.getKey();
+                    BigDecimal totalRooms = new BigDecimal(floor.getTotalRooms());
+                    // 已确定
+                    BigDecimal confirmed = roomData != null ? (roomData.get(floorKey + "_confirmed")==null ? BigDecimal.ZERO : roomData.get(floorKey + "_confirmed")) : BigDecimal.ZERO;
+                    rowData.add(formatDecimal(confirmed));
+                    // 虚占位
+                    BigDecimal virtual = roomData != null ? (roomData.get(floorKey + "_virtual")==null ? BigDecimal.ZERO : roomData.get(floorKey + "_virtual")) : BigDecimal.ZERO;
+                    rowData.add(formatDecimal(virtual));
+                    // 总计划
+                    BigDecimal total = confirmed.add(virtual);
+                    rowData.add(formatDecimal(total));
+                    // 剩余
+                    BigDecimal remain = totalRooms.subtract(total);
+                    totalRemain = totalRemain.add( remain);
+                    rowData.add(formatDecimal(remain));
+                }
+
+                // 房型余量
+                rowData.add(totalRemain);
+            }
+
+            result.add(rowData);
+        }
+
+        return result;
+    }
+
+
     /**
      * 填充同比环比数据
      */

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

@@ -0,0 +1,116 @@
+package com.yc.ship.module.trade.utils.excel;
+
+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.AllVoyageStockBoardTableHeadRespVO;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.util.CellRangeAddress;
+
+import java.util.List;
+
+/**
+ * 全航次库存看板 导出样式处理器
+ * 处理"余量"列的单元格合并
+ */
+@Slf4j
+public class AllVoyageStockBoardExportStyleHandler implements CellWriteHandler {
+
+    private final List<AllVoyageStockBoardTableHeadRespVO> tableHeads;
+    private boolean merged = false;
+
+    public AllVoyageStockBoardExportStyleHandler(List<AllVoyageStockBoardTableHeadRespVO> tableHeads) {
+        this.tableHeads = tableHeads;
+    }
+
+    @Override
+    public void afterCellDispose(CellWriteHandlerContext context) {
+        // 只在数据行开始写入时合并一次
+        // 表头占3行(索引0-2),数据从第3行开始
+        if (!merged) {
+            Cell cell = context.getCell();
+
+            // 当开始写入数据行(第3行,索引从0开始)且是表头写入完成后
+            if (cell.getRowIndex() == 3 && Boolean.FALSE.equals(context.getHead())) {
+                Sheet sheet = context.getWriteSheetHolder().getSheet();
+                mergeRemainColumns(sheet);
+                merged = true;
+                log.info("余量列合并完成");
+            }
+        }
+    }
+
+    /**
+     * 合并"余量"列的第1层和第2层单元格
+     * 表头结构(3层):
+     *   - 第0行(表头第0层):房型名称
+     *   - 第1行(表头第1层):楼层名称或空
+     *   - 第2行(表头第2层):总计划/已确定/虚占位/剩余/余量
+     *
+     * 对于"余量"列,需要将第1行(空)和第2行("余量")合并
+     */
+    private void mergeRemainColumns(Sheet sheet) {
+        if (tableHeads == null || tableHeads.isEmpty()) {
+            return;
+        }
+
+        log.info("开始合并余量列,房型数量: {}", tableHeads.size());
+
+        // 计算列偏移
+        // 固定列:航次、倒计时、实团计划、占位计划、切位计划、总计划 = 6列
+        int columnOffset = 6;
+
+        for (int i = 0; i < tableHeads.size(); i++) {
+            AllVoyageStockBoardTableHeadRespVO tableHead = tableHeads.get(i);
+            int floorCount = tableHead.getFloors() != null ? tableHead.getFloors().size() : 0;
+
+            // 每个楼层有4列:总计划、已确定、虚占位、剩余
+            int floorColumns = floorCount * 4;
+
+            // "余量"列在当前房型的最后一列
+            int remainColumnIndex = columnOffset + floorColumns;
+
+            // 合并第1行和第2行的"余量"列(表头的第1层和第2层)
+            // 第1行是空字符串,第2行是"余量"
+            int firstRow = 1; // 表头第1层
+            int lastRow = 2;  // 表头第2层
+
+            try {
+                /*log.info("合并余量列 - 房型: {}, 列索引: {}, 行范围: {}-{}",
+                        tableHead.getLabel(), remainColumnIndex, firstRow, lastRow);
+                sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, remainColumnIndex, remainColumnIndex));*/
+                log.info("合并余量列 - 房型: {}, 列索引: {}, 行范围: {}-{}",
+                        tableHead.getLabel(), remainColumnIndex, firstRow, lastRow);
+
+                // 先获取第2行的单元格内容("余量"文字)
+                Cell remainCell = sheet.getRow(lastRow).getCell(remainColumnIndex);
+                String cellValue = "";
+                if (remainCell != null) {
+                    cellValue = remainCell.getStringCellValue();
+                    log.info("余量列原始值: {}", cellValue);
+                }
+
+                // 执行合并
+                sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, remainColumnIndex, remainColumnIndex));
+
+                // 合并后,重新设置单元格值为"余量"
+                Cell mergedCell = sheet.getRow(firstRow).getCell(remainColumnIndex);
+                if (mergedCell == null) {
+                    mergedCell = sheet.getRow(firstRow).createCell(remainColumnIndex);
+                }
+                mergedCell.setCellValue(cellValue);
+
+                log.info("余量列合并并设置值: {}", cellValue);
+            } catch (Exception e) {
+                log.error("合并余量列失败 - 房型: {}, 列索引: {}", tableHead.getLabel(), remainColumnIndex, e);
+            }
+
+            // 更新列偏移,为下一个房型做准备
+            // +1 是因为"余量"列本身占1列
+            columnOffset = remainColumnIndex + 1;
+        }
+
+        log.info("余量列合并完成");
+    }
+}

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

@@ -142,4 +142,78 @@
         </if>
     </select>
 
+    <select id="selectAllVoyageStockPage"
+            resultType="com.yc.ship.module.trade.controller.admin.report.vo.AllVoyageStockBoardRespVO">
+        select
+            pv.id as voyageId,
+            pv.boarding_time,
+            DATEDIFF(pv.boarding_time, NOW()) AS countDown,
+            pvs.voyage_name as voyage
+        from
+            product_voyage pv
+                LEFT JOIN product_voyage_stock pvs ON pvs.voyage_id = pv.id AND pvs.deleted=0
+                left join trade_order td on td.voyage_id=pv.id and td.deleted=0
+        where pv.deleted=0
+            <if test="vo.shipId != null">
+                and pv.ship_id=#{vo.shipId}
+            </if>
+            <if test="vo.routeId != null">
+                and pv.route_id=#{vo.routeId}
+            </if>
+            <if test="vo.voyageId != null">
+                and pv.id=#{vo.voyageId}
+            </if>
+            <if test="vo.voyageTime != null">
+                and pv.boarding_time between #{vo.voyageTime[0]} and #{vo.voyageTime[1]}
+            </if>
+        GROUP BY
+            pv.id,pv.boarding_time ORDER BY pv.boarding_time ASC
+    </select>
+
+    <select id="selectVoyageStockNum"
+            resultType="java.util.Map">
+
+        SELECT
+            td.voyage_id,
+            SUM(torm.use_room_num) AS totalPlanSum,
+            SUM(CASE WHEN td.order_status IN (1, 6, 13, 15) THEN torm.use_room_num ELSE 0 END) AS realGroupTotal,
+            SUM(CASE WHEN td.order_status IN (14) THEN torm.use_room_num ELSE 0 END) AS occupyTotal,
+            (
+                SELECT SUM(pvs.num)
+                FROM product_voyage_stock_distribute_new pvs
+                WHERE pvs.voyage_id = td.voyage_id
+                  AND pvs.deleted = 0
+            ) AS switchTotal
+        FROM
+            trade_order td
+                LEFT JOIN trade_order_room_model torm ON torm.deleted = 0 AND td.id = torm.order_id
+        WHERE
+            td.deleted = 0
+          AND td.voyage_id = #{voyageId}
+          AND td.order_status > 0
+          AND td.order_status IN (1, 6, 13, 14, 15)
+    </select>
+
+
+    <select id="selectVoyageStockNumGruopByRoomModelIdAndFloor"
+            resultType="java.util.Map">
+        SELECT
+            td.voyage_id,
+            torm.room_model_name,
+            CONCAT(torm.floor, 'f') AS floor,
+            torm.room_model_id,
+            sum(torm.use_room_num) AS roomNum,
+            sum(CASE WHEN td.order_status IN (1, 6, 13, 15) THEN torm.use_room_num ELSE 0 END) AS confirmed,
+            sum(CASE WHEN td.order_status IN (14) THEN torm.use_room_num ELSE 0 END) AS virtualNum
+        FROM
+            trade_order td
+                INNER JOIN trade_order_user tou ON td.id = tou.order_id
+                AND tou.deleted = 0
+                LEFT JOIN trade_order_room_model torm ON torm.deleted = 0 AND td.id = torm.order_id
+                LEFT JOIN resource_room_model rrm ON torm.room_model_id = rrm.id
+        WHERE td.deleted = 0 and td.voyage_id= #{voyageId} and td.order_status>0
+        GROUP BY
+            td.voyage_id, torm.floor, torm.room_model_id order by torm.room_model_id ,torm.floor asc;
+    </select>
+
 </mapper>