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

新增单一航次库存数据看板接口

jincheng пре 17 часа
родитељ
комит
fd5b8b6451

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

@@ -0,0 +1,66 @@
+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.VoyageStockBoardReqVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardRespVO;
+import com.yc.ship.module.trade.service.report.VoyageStockBoardService;
+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 static com.yc.ship.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
+import static com.yc.ship.framework.common.pojo.CommonResult.success;
+
+/**
+ * 航次库存看板 Controller
+ */
+@Tag(name = "管理后台 - 航次库存看板")
+@RestController
+@RequestMapping("/report/kanbanBoard")
+@Validated
+@Slf4j
+public class KanbanBoardController {
+
+    @Resource
+    private VoyageStockBoardService voyageStockBoardService;
+
+    /**
+     * 查询航次库存看板分页
+     *
+     * @param pageReqVO 分页查询条件
+     * @return 分页结果
+     */
+    @GetMapping("/voyageStockBoard/page")
+    @Operation(summary = "查询航次库存看板分页")
+    public CommonResult<PageResult<VoyageStockBoardRespVO>> getVoyageStockBoardPage(@Valid VoyageStockBoardReqVO pageReqVO) {
+        PageResult<VoyageStockBoardRespVO> pageResult = voyageStockBoardService.getVoyageStockBoardPage(pageReqVO);
+        return success(pageResult);
+    }
+
+    /**
+     * 导出航次库存看板 Excel
+     *
+     * @param pageReqVO 查询条件
+     * @param response  HTTP响应
+     * @throws IOException IO异常
+     */
+    @GetMapping("/voyageStockBoard/export-excel")
+    @Operation(summary = "导出航次库存看板 Excel")
+    @ApiAccessLog(operateType = EXPORT)
+    public void exportVoyageStockBoardExcel(@Valid VoyageStockBoardReqVO pageReqVO,
+                                             HttpServletResponse response) throws IOException {
+        voyageStockBoardService.exportVoyageStockBoardExcel(pageReqVO, response);
+    }
+
+}

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

@@ -0,0 +1,80 @@
+package com.yc.ship.module.trade.controller.admin.report.vo;
+
+import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
+import com.alibaba.excel.annotation.ExcelProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 航次库存看板明细项 VO
+ */
+@Schema(description = "管理后台 - 航次库存看板明细项 VO")
+@Data
+@ExcelIgnoreUnannotated
+public class VoyageStockBoardItemVO {
+
+    @Schema(description = "房型ID", example = "1")
+    private Long roomModelId;
+
+    @Schema(description = "房型名称", example = "豪华标准间")
+    @ExcelProperty("房型")
+    private String roomModelName;
+
+    @Schema(description = "楼层", example = "2")
+    @ExcelProperty("楼层")
+    private Integer floor;
+
+    @Schema(description = "已订间数", example = "26")
+    @ExcelProperty("已订间数")
+    private BigDecimal bookedNum;
+
+    @Schema(description = "总数(已上架房间数)", example = "50")
+    @ExcelProperty("总数")
+    private BigDecimal totalNum;
+
+    @Schema(description = "已订间数/总数", example = "26/50")
+    @ExcelProperty("已订间数/总数")
+    private String bookedRatio;
+
+    @Schema(description = "定金锁位", example = "15")
+    @ExcelProperty("定金锁位")
+    private BigDecimal depositLock;
+
+    @Schema(description = "名单锁位", example = "11")
+    @ExcelProperty("名单锁位")
+    private BigDecimal nameListLock;
+
+    @Schema(description = "留位房间", example = "0")
+    @ExcelProperty("留位房间")
+    private BigDecimal leaveRoom;
+
+    @Schema(description = "切位总房间", example = "0")
+    @ExcelProperty("切位总房间")
+    private BigDecimal cutTotal;
+
+    @Schema(description = "切位已锁位房间数", example = "0")
+    private BigDecimal cutLock;
+
+    @Schema(description = "切位剩余房间数", example = "0")
+    @ExcelProperty("切位剩余房间数")
+    private BigDecimal cutRemain;
+
+    @Schema(description = "剩余可售", example = "24")
+    @ExcelProperty("剩余可售")
+    private BigDecimal remainingSell;
+
+    @Schema(description = "剩余共享", example = "0")
+    @ExcelProperty("剩余共享")
+    private BigDecimal remainingShare;
+
+    @Schema(description = "同比", example = "↑ +400.0%")
+    @ExcelProperty("同比")
+    private String yoy;
+
+    @Schema(description = "环比", example = "↑ +400.0%")
+    @ExcelProperty("环比")
+    private String mom;
+
+}

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

@@ -0,0 +1,28 @@
+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 javax.validation.constraints.NotNull;
+
+/**
+ * 航次库存看板 Request VO
+ */
+@Schema(description = "管理后台 - 航次库存看板 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class VoyageStockBoardReqVO extends PageParam {
+
+    @Schema(description = "游轮ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    @NotNull(message = "游轮ID不能为空")
+    private Long shipId;
+
+    @Schema(description = "航次ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    @NotNull(message = "航次ID不能为空")
+    private Long voyageId;
+
+}

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

@@ -0,0 +1,37 @@
+package com.yc.ship.module.trade.controller.admin.report.vo;
+
+import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
+import com.alibaba.excel.annotation.ExcelProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 航次库存看板 Response VO
+ */
+@Schema(description = "管理后台 - 航次库存看板 Response VO")
+@Data
+@ExcelIgnoreUnannotated
+public class VoyageStockBoardRespVO {
+
+    @Schema(description = "航次名称", example = "4月18下水")
+    private String voyageName;
+
+    @Schema(description = "该航次总库存", example = "259")
+    @ExcelProperty("该航次总库存")
+    private BigDecimal totalStock;
+
+    @Schema(description = "该航次已售库存", example = "72.5")
+    @ExcelProperty("该航次已售库存")
+    private BigDecimal soldStock;
+
+    @Schema(description = "该航次剩余库存", example = "149.5")
+    @ExcelProperty("该航次剩余库存")
+    private BigDecimal remainingStock;
+
+    @Schema(description = "明细列表")
+    private List<VoyageStockBoardItemVO> detailList;
+
+}

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

@@ -0,0 +1,40 @@
+package com.yc.ship.module.trade.dal.mysql.report;
+
+import com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardItemVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardRespVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 航次库存看板 Mapper
+ */
+@Mapper
+public interface VoyageStockBoardMapper {
+
+    /**
+     * 查询航次库存汇总
+     */
+    VoyageStockBoardRespVO selectVoyageStockSummary(@Param("voyageId") Long voyageId);
+
+    /**
+     * 查询航次库存明细
+     */
+    List<VoyageStockBoardItemVO> selectVoyageStockBoardDetail(@Param("voyageId") Long voyageId);
+
+    /**
+     * 查询同比航次ID
+     */
+    Long selectYoyVoyageId(@Param("voyageId") Long voyageId);
+
+    /**
+     * 查询环比航次ID
+     */
+    Long selectMomVoyageId(@Param("voyageId") Long voyageId);
+
+    /**
+     * 查询指定航次的预订间数(用于同比环比)
+     */
+    List<VoyageStockBoardItemVO> selectBookNumByVoyageId(@Param("voyageId") Long voyageId);
+}

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

@@ -0,0 +1,32 @@
+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.VoyageStockBoardReqVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardRespVO;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 航次库存看板 Service 接口
+ */
+public interface VoyageStockBoardService {
+
+    /**
+     * 查询航次库存看板分页
+     *
+     * @param pageReqVO 分页查询条件
+     * @return 分页结果
+     */
+    PageResult<VoyageStockBoardRespVO> getVoyageStockBoardPage(VoyageStockBoardReqVO pageReqVO);
+
+    /**
+     * 导出航次库存看板 Excel
+     *
+     * @param pageReqVO 查询条件
+     * @param response  HTTP响应
+     * @throws IOException IO异常
+     */
+    void exportVoyageStockBoardExcel(VoyageStockBoardReqVO pageReqVO, HttpServletResponse response) throws IOException;
+
+}

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

@@ -0,0 +1,289 @@
+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.common.pojo.PageResult;
+import com.yc.ship.framework.excel.core.util.ExcelUtils;
+import com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardItemVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardReqVO;
+import com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardRespVO;
+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.excel.VoyageStockBoardExportStyleHandler;
+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 实现类
+ */
+@Service
+@Slf4j
+public class VoyageStockBoardServiceImpl implements VoyageStockBoardService {
+
+    @Resource
+    private VoyageStockBoardMapper voyageStockBoardMapper;
+
+    @Override
+    public PageResult<VoyageStockBoardRespVO> getVoyageStockBoardPage(VoyageStockBoardReqVO pageReqVO) {
+        Long voyageId = pageReqVO.getVoyageId();
+
+        // 1. 查询航次汇总
+        VoyageStockBoardRespVO respVO = voyageStockBoardMapper.selectVoyageStockSummary(voyageId);
+        if (respVO == null) {
+            respVO = new VoyageStockBoardRespVO();
+        }
+
+        // 2. 查询明细
+        List<VoyageStockBoardItemVO> detailList = voyageStockBoardMapper.selectVoyageStockBoardDetail(voyageId);
+        if (CollUtil.isEmpty(detailList)) {
+            respVO.setDetailList(new ArrayList<>());
+            return new PageResult<>(Collections.singletonList(respVO), 1L);
+        }
+
+        // 3. 查询同比环比
+        fillYoyAndMom(detailList, voyageId);
+
+        // 4. 计算派生字段
+        calculateDerivedFields(detailList);
+
+        // 5. 按房型拼接名称
+        formatRoomModelName(detailList);
+
+        respVO.setDetailList(detailList);
+        return new PageResult<>(Collections.singletonList(respVO), 1L);
+    }
+
+    @Override
+    public void exportVoyageStockBoardExcel(VoyageStockBoardReqVO pageReqVO, HttpServletResponse response) throws IOException {
+        Long voyageId = pageReqVO.getVoyageId();
+
+        // 1. 查询数据
+        VoyageStockBoardRespVO respVO = voyageStockBoardMapper.selectVoyageStockSummary(voyageId);
+        if (respVO == null) {
+            respVO = new VoyageStockBoardRespVO();
+        }
+
+        List<VoyageStockBoardItemVO> detailList = voyageStockBoardMapper.selectVoyageStockBoardDetail(voyageId);
+        if (CollUtil.isEmpty(detailList)) {
+            ExcelUtils.exportEmpty(response, "航次库存看板");
+            return;
+        }
+
+        fillYoyAndMom(detailList, voyageId);
+        calculateDerivedFields(detailList);
+        formatRoomModelName(detailList);
+        respVO.setDetailList(detailList);
+
+        // 2. 构建多级表头
+        List<List<String>> head = buildExportHeaders();
+
+        // 3. 转换数据
+        List<List<Object>> exportData = transformExportData(respVO);
+
+        response.addHeader("Content-Disposition",
+                "attachment;filename=" + URLEncoder.encode("航次库存看板.xlsx", StandardCharsets.UTF_8.name()));
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8");
+        // 4. 写出(表头从第5行开始,前4行是汇总数据)
+        VoyageStockBoardExportStyleHandler styleHandler = new VoyageStockBoardExportStyleHandler(respVO);
+        EasyExcel.write(response.getOutputStream())
+                .head(head)
+                .relativeHeadRowIndex(4)
+                .autoCloseStream(false)
+                .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
+                .registerWriteHandler(styleHandler)
+                .registerConverter(new LongStringConverter())
+                .sheet("航次库存看板")
+                .doWrite(exportData);
+
+
+    }
+
+    /**
+     * 填充同比环比数据
+     */
+    private void fillYoyAndMom(List<VoyageStockBoardItemVO> detailList, Long voyageId) {
+        Long yoyVoyageId = voyageStockBoardMapper.selectYoyVoyageId(voyageId);
+        Long momVoyageId = voyageStockBoardMapper.selectMomVoyageId(voyageId);
+
+        Map<String, BigDecimal> yoyMap = new HashMap<>();
+        if (yoyVoyageId != null) {
+            List<VoyageStockBoardItemVO> yoyList = voyageStockBoardMapper.selectBookNumByVoyageId(yoyVoyageId);
+            yoyMap = yoyList.stream()
+                    .filter(item -> item.getRoomModelId() != null)
+                    .collect(Collectors.toMap(
+                            item -> item.getRoomModelId() + "_" + item.getFloor(),
+                            VoyageStockBoardItemVO::getBookedNum,
+                            (v1, v2) -> v1));
+        }
+
+        Map<String, BigDecimal> momMap = new HashMap<>();
+        if (momVoyageId != null) {
+            List<VoyageStockBoardItemVO> momList = voyageStockBoardMapper.selectBookNumByVoyageId(momVoyageId);
+            momMap = momList.stream()
+                    .filter(item -> item.getRoomModelId() != null)
+                    .collect(Collectors.toMap(
+                            item -> item.getRoomModelId() + "_" + item.getFloor(),
+                            VoyageStockBoardItemVO::getBookedNum,
+                            (v1, v2) -> v1));
+        }
+
+        for (VoyageStockBoardItemVO item : detailList) {
+            String key = item.getRoomModelId() + "_" + item.getFloor();
+            BigDecimal currentBookNum = safeDecimal(item.getBookedNum());
+
+            BigDecimal yoyBookNum = yoyMap.get(key);
+            item.setYoy(calcYoyPercent(currentBookNum, yoyBookNum));
+
+            BigDecimal momBookNum = momMap.get(key);
+            item.setMom(calcYoyPercent(currentBookNum, momBookNum));
+        }
+    }
+
+    /**
+     * 计算派生字段
+     */
+    private void calculateDerivedFields(List<VoyageStockBoardItemVO> detailList) {
+        for (VoyageStockBoardItemVO item : detailList) {
+            BigDecimal shelfedNum = safeDecimal(item.getTotalNum());
+            BigDecimal depositLock = safeDecimal(item.getDepositLock());
+            BigDecimal nameListLock = safeDecimal(item.getNameListLock());
+            BigDecimal leaveRoom = safeDecimal(item.getLeaveRoom());
+            BigDecimal cutTotal = safeDecimal(item.getCutTotal());
+            BigDecimal cutLock = safeDecimal(item.getCutLock());
+            BigDecimal bookedNum = safeDecimal(item.getBookedNum());
+
+            // 锁位总房间 = 定金锁位 + 名单锁位
+            BigDecimal lockTotal = depositLock.add(nameListLock);
+
+            // 切位剩余房间数 = 切位总房间 - 其中已锁位的房间
+            BigDecimal cutRemain = cutTotal.subtract(cutLock);
+            item.setCutRemain(cutRemain.max(BigDecimal.ZERO));
+
+            // 剩余可售 = 已上架房间数 - 留位房间数 - 锁位房间数
+            BigDecimal remainingSell = shelfedNum.subtract(leaveRoom).subtract(lockTotal);
+            item.setRemainingSell(remainingSell);
+
+            // 剩余共享 = 已上架房间数 - 留位房间数 - 锁位房间数 - 切位剩余房间数
+            BigDecimal remainingShare = remainingSell.subtract(cutRemain);
+            item.setRemainingShare(remainingShare);
+
+            // 已订间数/总数
+            String bookedRatio = bookedNum.stripTrailingZeros().toPlainString() + "/" + shelfedNum.stripTrailingZeros().toPlainString();
+            item.setBookedRatio(bookedRatio);
+        }
+    }
+
+    /**
+     * 按房型拼接汇总名称
+     */
+    private void formatRoomModelName(List<VoyageStockBoardItemVO> detailList) {
+        Map<Long, List<VoyageStockBoardItemVO>> groupByRoomModel = detailList.stream()
+                .collect(Collectors.groupingBy(VoyageStockBoardItemVO::getRoomModelId));
+
+        for (List<VoyageStockBoardItemVO> items : groupByRoomModel.values()) {
+            BigDecimal totalBooked = items.stream()
+                    .map(VoyageStockBoardItemVO::getBookedNum)
+                    .filter(Objects::nonNull)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            BigDecimal totalShelfed = items.stream()
+                    .map(VoyageStockBoardItemVO::getTotalNum)
+                    .filter(Objects::nonNull)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+            String suffix = "(" + totalBooked.stripTrailingZeros().toPlainString()
+                    + "/" + totalShelfed.stripTrailingZeros().toPlainString() + ")";
+
+            VoyageStockBoardItemVO first = items.get(0);
+            first.setRoomModelName(first.getRoomModelName() + " " + suffix);
+        }
+    }
+
+    /**
+     * 计算同比/环比百分比
+     */
+    private String calcYoyPercent(BigDecimal current, BigDecimal last) {
+        if (current == null || last == null || last.compareTo(BigDecimal.ZERO) == 0) {
+            return "-";
+        }
+        BigDecimal diff = current.subtract(last);
+        BigDecimal percent = diff.multiply(BigDecimal.valueOf(100))
+                .divide(last, 1, RoundingMode.HALF_UP);
+        if (percent.compareTo(BigDecimal.ZERO) > 0) {
+            return "↑ +" + percent.stripTrailingZeros().toPlainString() + "%";
+        } else if (percent.compareTo(BigDecimal.ZERO) < 0) {
+            return "↓ " + percent.stripTrailingZeros().toPlainString() + "%";
+        } else {
+            return "— 0%";
+        }
+    }
+
+    private BigDecimal safeDecimal(BigDecimal val) {
+        return val != null ? val : BigDecimal.ZERO;
+    }
+
+    /**
+     * 构建导出多级表头
+     */
+    private List<List<String>> buildExportHeaders() {
+        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<>(Arrays.asList("锁位房间", "定金锁位")));
+        head.add(new ArrayList<>(Arrays.asList("锁位房间", "名单锁位")));
+        head.add(new ArrayList<>(Collections.singletonList("留位房间")));
+        head.add(new ArrayList<>(Arrays.asList("切位房间", "切位总房间")));
+        head.add(new ArrayList<>(Arrays.asList("切位房间", "切位剩余房间数")));
+        head.add(new ArrayList<>(Collections.singletonList("剩余可售")));
+        head.add(new ArrayList<>(Collections.singletonList("剩余共享")));
+        head.add(new ArrayList<>(Collections.singletonList("同比")));
+        head.add(new ArrayList<>(Collections.singletonList("环比")));
+        return head;
+    }
+
+    /**
+     * 转换导出数据
+     */
+    private List<List<Object>> transformExportData(VoyageStockBoardRespVO respVO) {
+        List<List<Object>> result = new ArrayList<>();
+        List<VoyageStockBoardItemVO> detailList = respVO.getDetailList();
+        if (CollUtil.isEmpty(detailList)) {
+            return result;
+        }
+
+        for (VoyageStockBoardItemVO item : detailList) {
+            List<Object> rowData = new ArrayList<>();
+            rowData.add(item.getRoomModelName() != null ? item.getRoomModelName() : "");
+            rowData.add(item.getFloor() != null ? item.getFloor() : "");
+            rowData.add(item.getBookedRatio() != null ? item.getBookedRatio() : "");
+            rowData.add(formatDecimal(item.getDepositLock()));
+            rowData.add(formatDecimal(item.getNameListLock()));
+            rowData.add(formatDecimal(item.getLeaveRoom()));
+            rowData.add(formatDecimal(item.getCutTotal()));
+            rowData.add(formatDecimal(item.getCutRemain()));
+            rowData.add(formatDecimal(item.getRemainingSell()));
+            rowData.add(formatDecimal(item.getRemainingShare()));
+            rowData.add(item.getYoy() != null ? item.getYoy() : "");
+            rowData.add(item.getMom() != null ? item.getMom() : "");
+            result.add(rowData);
+        }
+        return result;
+    }
+
+    private String formatDecimal(BigDecimal val) {
+        if (val == null) return "0";
+        return val.stripTrailingZeros().toPlainString();
+    }
+}

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

@@ -0,0 +1,156 @@
+package com.yc.ship.module.trade.utils.excel;
+
+import com.alibaba.excel.write.handler.CellWriteHandler;
+import com.alibaba.excel.write.handler.SheetWriteHandler;
+import com.alibaba.excel.write.handler.context.CellWriteHandlerContext;
+import com.alibaba.excel.write.handler.context.SheetWriteHandlerContext;
+import com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardRespVO;
+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 VoyageStockBoardExportStyleHandler implements SheetWriteHandler, CellWriteHandler {
+
+    private static final int TOTAL_COLUMNS = 12;
+
+    private final VoyageStockBoardRespVO summary;
+
+    private CellStyle headerStyle;
+    private CellStyle summaryLabelStyle;
+    private CellStyle summaryValueStyle;
+    private CellStyle titleStyle;
+
+    public VoyageStockBoardExportStyleHandler(VoyageStockBoardRespVO summary) {
+        this.summary = summary;
+    }
+
+    @Override
+    public void afterSheetCreate(SheetWriteHandlerContext context) {
+        Sheet sheet = context.getWriteSheetHolder().getSheet();
+        Workbook workbook = sheet.getWorkbook();
+
+        // 第0行:航次名称标题
+        Row titleRow = sheet.createRow(0);
+        Cell titleCell = titleRow.createCell(0);
+        titleCell.setCellValue(summary.getVoyageName() != null ? summary.getVoyageName() : "");
+        titleCell.setCellStyle(createTitleStyle(workbook));
+        // 合并标题行
+        sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, TOTAL_COLUMNS - 1));
+
+        // 第1行:该航次总库存
+        Row row1 = sheet.createRow(1);
+        Cell label1 = row1.createCell(0);
+        label1.setCellValue("该航次总库存");
+        label1.setCellStyle(createSummaryLabelStyle(workbook));
+        Cell val1 = row1.createCell(1);
+        val1.setCellValue(summary.getTotalStock() != null ? summary.getTotalStock().toString() : "0");
+        val1.setCellStyle(createSummaryValueStyle(workbook));
+        sheet.addMergedRegion(new CellRangeAddress(1, 1, 1, TOTAL_COLUMNS - 1));
+
+        // 第2行:该航次已售库存
+        Row row2 = sheet.createRow(2);
+        Cell label2 = row2.createCell(0);
+        label2.setCellValue("该航次已售库存");
+        label2.setCellStyle(createSummaryLabelStyle(workbook));
+        Cell val2 = row2.createCell(1);
+        val2.setCellValue(summary.getSoldStock() != null ? summary.getSoldStock().toString() : "0");
+        val2.setCellStyle(createSummaryValueStyle(workbook));
+        sheet.addMergedRegion(new CellRangeAddress(2, 2, 1, TOTAL_COLUMNS - 1));
+
+        // 第3行:该航次剩余库存
+        Row row3 = sheet.createRow(3);
+        Cell label3 = row3.createCell(0);
+        label3.setCellValue("该航次剩余库存");
+        label3.setCellStyle(createSummaryLabelStyle(workbook));
+        Cell val3 = row3.createCell(1);
+        val3.setCellValue(summary.getRemainingStock() != null ? summary.getRemainingStock().toString() : "0");
+        val3.setCellStyle(createSummaryValueStyle(workbook));
+        sheet.addMergedRegion(new CellRangeAddress(3, 3, 1, TOTAL_COLUMNS - 1));
+    }
+
+    @Override
+    public void afterCellDispose(CellWriteHandlerContext context) {
+        if (Boolean.TRUE.equals(context.getHead())) {
+            applyHeaderStyle(context.getCell(), context.getWriteSheetHolder().getSheet().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);
+    }
+
+    private CellStyle createTitleStyle(Workbook workbook) {
+        if (titleStyle == null) {
+            titleStyle = workbook.createCellStyle();
+            titleStyle.setAlignment(HorizontalAlignment.CENTER);
+            titleStyle.setVerticalAlignment(VerticalAlignment.CENTER);
+            titleStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+            if (titleStyle instanceof XSSFCellStyle) {
+                XSSFColor color = new XSSFColor(new byte[]{(byte) 0xE8, (byte) 0xF4, (byte) 0xF8}, null);
+                ((XSSFCellStyle) titleStyle).setFillForegroundColor(color);
+            }
+            Font font = workbook.createFont();
+            font.setBold(true);
+            font.setFontHeightInPoints((short) 12);
+            titleStyle.setFont(font);
+        }
+        return titleStyle;
+    }
+
+    private CellStyle createSummaryLabelStyle(Workbook workbook) {
+        if (summaryLabelStyle == null) {
+            summaryLabelStyle = workbook.createCellStyle();
+            summaryLabelStyle.setAlignment(HorizontalAlignment.LEFT);
+            summaryLabelStyle.setVerticalAlignment(VerticalAlignment.CENTER);
+            summaryLabelStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+            if (summaryLabelStyle instanceof XSSFCellStyle) {
+                XSSFColor color = new XSSFColor(new byte[]{(byte) 0xF2, (byte) 0xF2, (byte) 0xF2}, null);
+                ((XSSFCellStyle) summaryLabelStyle).setFillForegroundColor(color);
+            }
+            Font font = workbook.createFont();
+            font.setBold(true);
+            summaryLabelStyle.setFont(font);
+        }
+        return summaryLabelStyle;
+    }
+
+    private CellStyle createSummaryValueStyle(Workbook workbook) {
+        if (summaryValueStyle == null) {
+            summaryValueStyle = workbook.createCellStyle();
+            summaryValueStyle.setAlignment(HorizontalAlignment.CENTER);
+            summaryValueStyle.setVerticalAlignment(VerticalAlignment.CENTER);
+            summaryValueStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+            if (summaryValueStyle instanceof XSSFCellStyle) {
+                XSSFColor color = new XSSFColor(new byte[]{(byte) 0xFF, (byte) 0xFF, (byte) 0xFF}, null);
+                ((XSSFCellStyle) summaryValueStyle).setFillForegroundColor(color);
+            }
+        }
+        return summaryValueStyle;
+    }
+}

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

@@ -0,0 +1,105 @@
+<?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.VoyageStockBoardMapper">
+
+    <select id="selectVoyageStockSummary"
+            resultType="com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardRespVO">
+        SELECT
+            pv.name as voyageName,
+            COALESCE(pvs.total_num, 0) as totalStock,
+            COALESCE(pvs.book_num, 0) as soldStock,
+            COALESCE(pvs.surplus_num, 0) as remainingStock
+        FROM product_voyage pv
+        LEFT JOIN product_voyage_stock pvs ON pv.id = pvs.voyage_id AND pvs.deleted = 0
+        WHERE pv.id = #{voyageId} AND pv.deleted = 0
+    </select>
+
+    <select id="selectVoyageStockBoardDetail"
+            resultType="com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardItemVO">
+        SELECT
+            t1.room_model_id as roomModelId,
+            t1.room_model_name as roomModelName,
+            t1.floor,
+            COALESCE(t1.total_num, 0) as totalNum,
+            COALESCE(t1.book_num, 0) as bookedNum,
+            COALESCE(lock_stats.deposit_lock, 0) as depositLock,
+            COALESCE(lock_stats.name_list_lock, 0) as nameListLock,
+            COALESCE(leave_stats.leave_num, 0) as leaveRoom,
+            COALESCE(cut_stats.cut_total, 0) as cutTotal,
+            COALESCE(cut_stats.cut_lock, 0) as cutLock
+        FROM product_voyage_stock_detail t1
+        LEFT JOIN (
+            SELECT
+                torm.room_model_id,
+                torm.floor,
+                SUM(CASE WHEN torder.is_full_pay IS NULL OR torder.is_full_pay = 0 THEN COALESCE(torm.use_room_num, 0) ELSE 0 END) as deposit_lock,
+                SUM(CASE WHEN torder.is_full_pay = 1 THEN COALESCE(torm.use_room_num, 0) ELSE 0 END) as name_list_lock
+            FROM trade_order_room_model torm
+            INNER JOIN trade_order torder ON torm.order_id = torder.id AND torder.deleted = 0
+            WHERE torder.order_status IN (1, 6)
+              AND torm.deleted = 0
+              AND torder.voyage_id = #{voyageId}
+            GROUP BY torm.room_model_id, torm.floor
+        ) lock_stats ON t1.room_model_id = lock_stats.room_model_id AND (t1.floor = lock_stats.floor OR (t1.floor IS NULL AND lock_stats.floor IS NULL))
+        LEFT JOIN (
+            SELECT
+                torm.room_model_id,
+                torm.floor,
+                SUM(COALESCE(torm.use_room_num, 0)) as leave_num
+            FROM trade_order_room_model torm
+            INNER JOIN trade_order torder ON torm.order_id = torder.id AND torder.deleted = 0
+            WHERE torder.order_status = 14
+              AND torm.deleted = 0
+              AND torder.voyage_id = #{voyageId}
+            GROUP BY torm.room_model_id, torm.floor
+        ) leave_stats ON t1.room_model_id = leave_stats.room_model_id AND (t1.floor = leave_stats.floor OR (t1.floor IS NULL AND leave_stats.floor IS NULL))
+        LEFT JOIN (
+            SELECT
+                room_model_id,
+                floor,
+                SUM(COALESCE(num, 0)) as cut_total,
+                SUM(COALESCE(book_num, 0)) as cut_lock
+            FROM product_voyage_stock_distribute_new
+            WHERE deleted = 0 AND voyage_id = #{voyageId} AND type = 1
+            GROUP BY room_model_id, floor
+        ) cut_stats ON t1.room_model_id = cut_stats.room_model_id AND (t1.floor = cut_stats.floor OR (t1.floor IS NULL AND cut_stats.floor IS NULL))
+        WHERE t1.voyage_id = #{voyageId} AND t1.deleted = 0
+        ORDER BY t1.room_model_id, t1.floor
+    </select>
+
+    <select id="selectYoyVoyageId" resultType="java.lang.Long">
+        SELECT pv2.id
+        FROM product_voyage pv1
+        JOIN resource_route r1 ON pv1.route_id = r1.id AND r1.deleted = 0
+        JOIN product_voyage pv2 ON pv1.ship_id = pv2.ship_id
+        JOIN resource_route r2 ON pv2.route_id = r2.id AND r2.deleted = 0 AND r1.direction = r2.direction
+        WHERE pv1.id = #{voyageId}
+          AND pv2.start_time = DATE_SUB(pv1.start_time, INTERVAL 1 YEAR)
+          AND pv2.deleted = 0
+        LIMIT 1
+    </select>
+
+    <select id="selectMomVoyageId" resultType="java.lang.Long">
+        SELECT pv2.id
+        FROM product_voyage pv1
+        JOIN resource_route r1 ON pv1.route_id = r1.id AND r1.deleted = 0
+        JOIN product_voyage pv2 ON pv1.ship_id = pv2.ship_id
+        JOIN resource_route r2 ON pv2.route_id = r2.id AND r2.deleted = 0 AND r1.direction = r2.direction
+        WHERE pv1.id = #{voyageId}
+          AND pv2.start_time &lt; pv1.start_time
+          AND pv2.deleted = 0
+        ORDER BY pv2.start_time DESC
+        LIMIT 1
+    </select>
+
+    <select id="selectBookNumByVoyageId"
+            resultType="com.yc.ship.module.trade.controller.admin.report.vo.VoyageStockBoardItemVO">
+        SELECT
+            room_model_id as roomModelId,
+            floor,
+            COALESCE(book_num, 0) as bookedNum
+        FROM product_voyage_stock_detail
+        WHERE voyage_id = #{voyageId} AND deleted = 0
+    </select>
+
+</mapper>

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

@@ -86,7 +86,7 @@
         LEFT JOIN (
             SELECT
                 o.voyage_id,
-                SUM(o.amount) AS receivableAmount,
+                SUM(o.pay_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