Files
huachuang/docs/superpowers/plans/2026-07-22-outbound-create-plan.md
2026-07-22 09:24:49 +08:00

44 KiB
Raw Blame History

出库单新增页面 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development推荐或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 将出入库单主表的新增弹窗重构为出库单新增页面,包含主表表单 + 出库明细 table支持从库存选择带入和手动新增汇总两种方式。

架构: 前端大弹窗页面form.vue 重构),主表用 useVbenForm 水平布局,明细用 vxe-table 内嵌,新增库存选择弹窗和手动汇总弹窗两个子组件。后端新增 createWithDetails 接口,事务写入主表 + 明细,库存查询 SQL 写在 Mapper XML 中。通过 Feign 调用 base 模块的 CodeGenApi 生成单据号。

技术栈: Java 17, Spring Boot 3, MyBatis-Plus, OpenFeign, Vue 3, Ant Design Vue Next, Vxe Table


文件结构

# 后端 — 新增/修改
nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/
├── framework/rpc/config/RpcConfiguration.java              [新增] Feign 客户端配置
├── controller/admin/iostorinv/vo/
│   ├── IostorInvWithDetailsSaveReqVO.java                   [新增] 带明细的创建请求 VO
│   └── AvailableInventoryVO.java                            [新增] 可用库存查询结果 VO
├── dal/mysql/iostorinvdtl/IostorinvDtlMapper.java          [修改] 新增 selectAvailableInventory 方法
├── resources/mapper/iostorinvdtl/IostorinvDtlMapper.xml    [修改] 新增库存查询 SQL
├── service/iostorinv/IostorInvService.java                  [修改] 新增 createWithDetails 方法
├── service/iostorinv/IostorInvServiceImpl.java              [修改] 实现 createWithDetails
├── service/iostorinvdtl/IostorinvDtlService.java            [修改] 新增 batchInsert 方法
├── service/iostorinvdtl/IostorinvDtlServiceImpl.java        [修改] 实现 batchInsert
└── controller/admin/iostorinv/IostorInvController.java     [修改] 新增 createWithDetails 端点

# 前端 — 新增/修改
nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/
├── api/wms/iostorinv/index.ts                              [修改] 新增类型和 API
├── views/wms/iostorinv/modules/
│   ├── form.vue                                             [重构] 大弹窗 + 主表 + 明细
│   ├── inventory-select.vue                                 [新增] 库存选择弹窗
│   └── manual-detail.vue                                    [新增] 手动新增汇总弹窗
└── packages/constants/src/dict-enum.ts                      [修改] 新增 out_bill_type 字典类型

任务 1后端 — Feign 客户端配置

文件:

  • 创建:nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/framework/rpc/config/RpcConfiguration.java

  • 步骤 1创建 RpcConfiguration注册 CodeGenApi Feign 客户端

package cn.code.nl.module.wms.framework.rpc.config;

import cn.code.nl.module.base.api.codegen.CodeGenApi;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;

/**
 * WMS 模块 RPC 配置
 *
 * @author zhouz
 */
@Configuration(value = "wmsRpcConfiguration", proxyBeanMethods = false)
@EnableFeignClients(clients = {CodeGenApi.class})
public class RpcConfiguration {
}

注意: 需要确认 nl-module-wms-server 的 pom.xml 已依赖 nl-module-base-api,否则 Feign 接口编译不到。如缺失,添加依赖:

<dependency>
    <groupId>cn.code.nl</groupId>
    <artifactId>nl-module-base-api</artifactId>
    <version>${project.version}</version>
</dependency>
  • 步骤 2Commit
git add nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/framework/rpc/config/RpcConfiguration.java
git commit -m "feat: 添加 WMS 模块 Feign 客户端配置,注册 CodeGenApi"

任务 2后端 — 新增请求 VO 和库存查询 VO

文件:

  • 创建:nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvWithDetailsSaveReqVO.java

  • 创建:nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/AvailableInventoryVO.java

  • 步骤 1创建带明细的创建请求 VO

package cn.code.nl.module.wms.controller.admin.iostorinv.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;

/**
 * 管理后台 - 出库单新增含明细Request VO
 *
 * @author zhouz
 */
@Schema(description = "管理后台 - 出库单新增含明细Request VO")
@Data
public class IostorInvWithDetailsSaveReqVO {

    @Schema(description = "单据类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "生产领料出库")
    @NotEmpty(message = "单据类型不能为空")
    private String billType;

    @Schema(description = "仓库标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "12635")
    @NotEmpty(message = "仓库标识不能为空")
    private String storId;

    @Schema(description = "仓库编码")
    private String storCode;

    @Schema(description = "仓库名称", example = "原料仓库")
    private String storName;

    @Schema(description = "业务日期", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "业务日期不能为空")
    private LocalDateTime bizDate;

    @Schema(description = "备注")
    private String remark;

    @Schema(description = "出库明细列表", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotEmpty(message = "请至少添加一条出库明细")
    @Valid
    private List<DetailVO> details;

    /**
     * 出库明细 VO
     */
    @Data
    public static class DetailVO {

        @Schema(description = "物料编码", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotEmpty(message = "物料编码不能为空")
        private String materialCode;

        @Schema(description = "物料标识")
        private String materialId;

        @Schema(description = "批次/子卷号")
        private String pcsn;

        @Schema(description = "出库重量", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotNull(message = "出库重量不能为空")
        private BigDecimal planQty;

        @Schema(description = "计量单位标识")
        private String qtyUnitId;

        @Schema(description = "计量单位名称")
        private String qtyUnitName;

        @Schema(description = "备注")
        private String remark;

        @Schema(description = "来源单据号")
        private String sourceBillCode;

        @Schema(description = "来源单据类型")
        private String sourceBillType;

        @Schema(description = "来源单据明细标识")
        private String sourceBilldtlId;
    }
}
  • 步骤 2创建可用库存查询结果 VO
package cn.code.nl.module.wms.controller.admin.iostorinv.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.math.BigDecimal;

/**
 * 管理后台 - 可用库存查询结果 VO
 *
 * @author zhouz
 */
@Schema(description = "管理后台 - 可用库存查询结果 VO")
@Data
public class AvailableInventoryVO {

    @Schema(description = "载具编码(箱号)")
    private String vehicleCode;

    @Schema(description = "批次/子卷号")
    private String pcsn;

    @Schema(description = "物料编码")
    private String materialCode;

    @Schema(description = "物料标识")
    private String materialId;

    @Schema(description = "组盘数量")
    private BigDecimal qty;

    @Schema(description = "冻结数量")
    private BigDecimal frozenQty;

    @Schema(description = "可用数量")
    private BigDecimal availableQty;

    @Schema(description = "计量单位标识")
    private String qtyUnitId;

    @Schema(description = "计量单位名称")
    private String qtyUnitName;

    @Schema(description = "来源单据号")
    private String extCode;

    @Schema(description = "来源单据类型")
    private String extType;

    @Schema(description = "来源单据明细号")
    private String extDtlCode;

    @Schema(description = "仓库标识")
    private String storId;

    @Schema(description = "仓库编码")
    private String storCode;

    @Schema(description = "仓库名称")
    private String storName;
}
  • 步骤 3Commit
git add nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvWithDetailsSaveReqVO.java nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/AvailableInventoryVO.java
git commit -m "feat: 新增出库单带明细创建请求 VO 和可用库存查询 VO"

任务 3后端 — Mapper XML 新增库存查询 SQL

文件:

  • 修改:nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/mysql/iostorinvdtl/IostorinvDtlMapper.java

  • 修改:nl-module-wms/nl-module-wms-server/src/main/resources/mapper/iostorinvdtl/IostorinvDtlMapper.xml

  • 步骤 1在 IostorinvDtlMapper 接口中新增方法

在文件末尾(} 之前)添加:

    /**
     * 查询可用库存wms_group_plate 关联 wms_structattr
     *
     * @param storId       仓库标识(必传)
     * @param materialCode 物料编码(可选)
     * @param pcsn         批次(可选)
     * @param vehicleCode  载具编码(可选)
     * @return 可用库存列表
     */
    List<AvailableInventoryVO> selectAvailableInventory(
            @Param("storId") String storId,
            @Param("materialCode") String materialCode,
            @Param("pcsn") String pcsn,
            @Param("vehicleCode") String vehicleCode);

同时新增 import

import cn.code.nl.module.wms.controller.admin.iostorinv.vo.AvailableInventoryVO;
import org.apache.ibatis.annotations.Param;
  • 步骤 2在 IostorinvDtlMapper.xml 中新增 SQL

替换空的 XML 文件内容为:

<?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="cn.code.nl.module.wms.dal.mysql.iostorinvdtl.IostorinvDtlMapper">

    <!--
        一般情况下,尽可能使用 Mapper 进行 CRUD 增删改查即可。
        无法满足的场景,例如说多表关联查询,才使用 XML 编写 SQL。
    -->

    <select id="selectAvailableInventory"
            resultType="cn.code.nl.module.wms.controller.admin.iostorinv.vo.AvailableInventoryVO">
        SELECT
            gp.vehicle_code     AS vehicleCode,
            gp.pcsn             AS pcsn,
            gp.material_code    AS materialCode,
            gp.material_id      AS materialId,
            gp.qty              AS qty,
            gp.frozen_qty       AS frozenQty,
            (gp.qty - gp.frozen_qty) AS availableQty,
            gp.qty_unit_id      AS qtyUnitId,
            gp.qty_unit_name    AS qtyUnitName,
            gp.ext_code         AS extCode,
            gp.ext_type         AS extType,
            gp.ext_dtl_code     AS extDtlCode,
            sa.stor_id          AS storId,
            sa.stor_code        AS storCode,
            sa.stor_name        AS storName
        FROM wms_group_plate gp
        INNER JOIN wms_structattr sa
            ON gp.vehicle_code = sa.storagevehicle_code
        WHERE sa.stor_id = #{storId}
          AND gp.status = '可用'
          AND (gp.qty - gp.frozen_qty) > 0
        <if test="materialCode != null and materialCode != ''">
          AND gp.material_code = #{materialCode}
        </if>
        <if test="pcsn != null and pcsn != ''">
          AND gp.pcsn = #{pcsn}
        </if>
        <if test="vehicleCode != null and vehicleCode != ''">
          AND gp.vehicle_code = #{vehicleCode}
        </if>
    </select>

</mapper>
  • 步骤 3Commit
git add nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/mysql/iostorinvdtl/IostorinvDtlMapper.java nl-module-wms/nl-module-wms-server/src/main/resources/mapper/iostorinvdtl/IostorinvDtlMapper.xml
git commit -m "feat: 新增可用库存查询 SQLgroup_plate 关联 structattr"

任务 4后端 — Service 层新增 createWithDetails 和 batchInsert

文件:

  • 修改:nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvService.java

  • 修改:nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceImpl.java

  • 修改:nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinvdtl/IostorinvDtlService.java

  • 修改:nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinvdtl/IostorinvDtlServiceImpl.java

  • 步骤 1在 IostorInvService 接口中新增方法声明

在文件末尾的 } 之前添加:

    /**
     * 创建出库单(含明细)
     *
     * @param reqVO 创建请求(含明细列表)
     * @return 出入单标识
     */
    String createIostorInvWithDetails(@Valid IostorInvWithDetailsSaveReqVO reqVO);

新增 import

import cn.code.nl.module.wms.controller.admin.iostorinv.vo.IostorInvWithDetailsSaveReqVO;
  • 步骤 2在 IostorInvServiceImpl 中实现 createIostorInvWithDetails

添加 @Resource 注入:

    @Resource
    private cn.code.nl.module.base.api.codegen.CodeGenApi codeGenApi;

    @Resource
    private cn.code.nl.module.wms.dal.mysql.iostorinvdtl.IostorinvDtlMapper iostorinvDtlMapper;

新增方法实现(在文件末尾 } 之前):

    @Override
    @Transactional(rollbackFor = Exception.class)
    public String createIostorInvWithDetails(IostorInvWithDetailsSaveReqDTO reqVO) {
        // 1. 调用 base 模块生成单据号
        CodeGenerateReqDTO codeReq = new CodeGenerateReqDTO();
        codeReq.setRuleCode("IO_CODE");
        CommonResult<String> codeResult = codeGenApi.generate(codeReq);
        if (codeResult == null || codeResult.getData() == null) {
            throw exception(IOSTOR_INV_BILL_CODE_GENERATE_FAIL);
        }
        String billCode = codeResult.getData();

        // 2. 构建主表数据
        IostorInvDO master = new IostorInvDO();
        master.setBillCode(billCode);
        master.setIoType("OUT");
        master.setBillType(reqVO.getBillType());
        master.setBizDate(reqVO.getBizDate());
        master.setStorId(reqVO.getStorId());
        master.setStorCode(reqVO.getStorCode());
        master.setStorName(reqVO.getStorName());
        master.setBillStatus("生成");
        master.setRemark(reqVO.getRemark());
        master.setDetailCount(reqVO.getDetails().size());

        // 3. 计算总重量
        BigDecimal totalWeight = reqVO.getDetails().stream()
                .map(IostorInvWithDetailsSaveReqVO.DetailVO::getPlanQty)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        master.setTotalWeight(totalWeight);

        // 4. 插入主表
        iostorInvMapper.insert(master);

        // 5. 批量插入明细
        String iostorinvId = master.getIostorinvId();
        int seqNo = 1;
        for (IostorInvWithDetailsSaveReqVO.DetailVO detail : reqVO.getDetails()) {
            IostorinvDtlDO dtl = new IostorinvDtlDO();
            dtl.setIostorinvId(iostorinvId);
            dtl.setSeqNo(seqNo++);
            dtl.setMaterialCode(detail.getMaterialCode());
            dtl.setMaterialId(detail.getMaterialId());
            dtl.setPcsn(detail.getPcsn());
            dtl.setPlanQty(detail.getPlanQty());
            dtl.setUnassignQty(detail.getPlanQty());  // 初始未分配 = 计划数量
            dtl.setAssignQty(BigDecimal.ZERO);
            dtl.setQtyUnitId(detail.getQtyUnitId());
            dtl.setQtyUnitName(detail.getQtyUnitName());
            dtl.setRemark(detail.getRemark());
            dtl.setSourceBillCode(detail.getSourceBillCode());
            dtl.setSourceBillType(detail.getSourceBillType());
            dtl.setSourceBilldtlId(detail.getSourceBilldtlId());
            iostorinvDtlMapper.insert(dtl);
        }

        return iostorinvId;
    }

新增 import

import cn.code.nl.module.base.api.codegen.CodeGenApi;
import cn.code.nl.module.base.api.codegen.dto.CodeGenerateReqDTO;
import cn.code.nl.module.framework.common.pojo.CommonResult;
import cn.code.nl.module.wms.controller.admin.iostorinv.vo.IostorInvWithDetailsSaveReqVO;
import cn.code.nl.module.wms.dal.dataobject.iostorinvdtl.IostorinvDtlDO;
import cn.code.nl.module.wms.dal.mysql.iostorinvdtl.IostorinvDtlMapper;
import java.math.BigDecimal;
  • 步骤 3在 ErrorCodeConstants 中新增错误码

修改 nl-module-wms/nl-module-wms-api/src/main/java/cn/code/nl/module/wms/enums/ErrorCodeConstants.java,在文件末尾添加:

    // ========== 出入库单 1-030-001-000 ==========
    ErrorCode IOSTOR_INV_BILL_CODE_GENERATE_FAIL = new ErrorCode(1_030_001_000, "单据号生成失败,请稍后重试");

注意: 确认错误码 1_030_001_000 不与已有错误码冲突。如冲突则顺延。

  • 步骤 4IostorinvDtlService 和 Impl 暂不改动

明细的批量插入直接在 IostorInvServiceImpl 中通过 iostorinvDtlMapper.insert() 完成,无需改动 DtlService 接口。跳过此改动。

  • 步骤 5Commit
git add nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvService.java nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceImpl.java nl-module-wms/nl-module-wms-api/src/main/java/cn/code/nl/module/wms/enums/ErrorCodeConstants.java
git commit -m "feat: 实现出库单带明细创建 Service含 CodeGen 单据号生成)"

任务 5后端 — Controller 新增 createWithDetails 端点

文件:

  • 修改:nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/IostorInvController.java

  • 步骤 1新增 createWithDetails 方法和可用库存查询方法

在类末尾的 } 之前添加:

    @PostMapping("/create-with-details")
    @Operation(summary = "创建出库单(含明细)")
    @PreAuthorize("@ss.hasPermission('wms:iostor-inv:create')")
    public CommonResult<String> createIostorInvWithDetails(
            @Valid @RequestBody IostorInvWithDetailsSaveReqVO reqVO) {
        return success(iostorInvService.createIostorInvWithDetails(reqVO));
    }

新增 import

import cn.code.nl.module.wms.controller.admin.iostorinv.vo.IostorInvWithDetailsSaveReqVO;
  • 步骤 2Commit
git add nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/IostorInvController.java
git commit -m "feat: 新增出库单带明细创建 API 端点 POST /create-with-details"

任务 6前端 — API 层扩展

文件:

  • 修改:nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts

  • 步骤 1新增请求类型和 API 方法

在文件末尾的 export function exportIostorInv 之前添加类型定义,并在最后添加 API 方法:

// --- 出库单带明细创建 ---

/** 出库明细项 */
export interface OutboundDetail {
  materialCode: string;
  materialId?: string;
  pcsn?: string;
  planQty: number;
  qtyUnitId?: string;
  qtyUnitName?: string;
  remark?: string;
  sourceBillCode?: string;
  sourceBillType?: string;
  sourceBilldtlId?: string;
}

/** 出库单创建请求(含明细) */
export interface OutboundCreateReq {
  billType: string;
  storId: string;
  storCode?: string;
  storName?: string;
  bizDate: string;
  remark?: string;
  details: OutboundDetail[];
}

/** 可用库存项 */
export interface AvailableInventory {
  vehicleCode: string;
  pcsn: string;
  materialCode: string;
  materialId: string;
  qty: number;
  frozenQty: number;
  availableQty: number;
  qtyUnitId: string;
  qtyUnitName: string;
  extCode: string;
  extType: string;
  extDtlCode: string;
  storId: string;
  storCode: string;
  storName: string;
}

/** 可用库存查询参数 */
export interface AvailableInventoryQuery {
  storId: string;
  materialCode?: string;
  pcsn?: string;
  vehicleCode?: string;
}

在文件末尾(export function exportIostorInv 之后)添加:

/** 创建出库单(含明细) */
export function createIostorInvWithDetails(data: OutboundCreateReq) {
  return requestClient.post<string>('/wms/iostor-inv/create-with-details', data);
}

/** 查询可用库存 */
export function getAvailableInventory(params: AvailableInventoryQuery) {
  return requestClient.get<AvailableInventory[]>(
    '/wms/iostorinvdtl/available-inventory',
    { params },
  );
}
  • 步骤 2Commit
git add nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts
git commit -m "feat: 前端 API 层新增出库单带明细创建和可用库存查询"

任务 7后端 — 可用库存查询 Controller补充

前面任务 3 只在 Mapper 加了 SQL但前端需要一个 HTTP 接口调用。需要在 Controller 或现有 Controller 中暴露。

文件:

  • 修改:nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinvdtl/IostorinvDtlController.java

  • 步骤 1在 IostorinvDtlController 中新增可用库存查询端点

在类末尾 } 之前添加:

    @GetMapping("/available-inventory")
    @Operation(summary = "查询可用库存(组盘关联仓位)")
    @PreAuthorize("@ss.hasPermission('wms:iostor-inv:query')")
    public CommonResult<List<AvailableInventoryVO>> getAvailableInventory(
            @RequestParam("storId") String storId,
            @RequestParam(value = "materialCode", required = false) String materialCode,
            @RequestParam(value = "pcsn", required = false) String pcsn,
            @RequestParam(value = "vehicleCode", required = false) String vehicleCode) {
        List<AvailableInventoryVO> list = iostorinvDtlMapper.selectAvailableInventory(
                storId, materialCode, pcsn, vehicleCode);
        return success(list);
    }

注入 Mapper

    @Resource
    private IostorinvDtlMapper iostorinvDtlMapper;

新增 import

import cn.code.nl.module.wms.controller.admin.iostorinv.vo.AvailableInventoryVO;
import cn.code.nl.module.wms.dal.mysql.iostorinvdtl.IostorinvDtlMapper;
import jakarta.annotation.Resource;
  • 步骤 2Commit
git add nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinvdtl/IostorinvDtlController.java
git commit -m "feat: 新增可用库存查询端点 GET /available-inventory"

任务 8前端 — 重构 form.vue主表表单 + 明细 Table

文件:

  • 修改:nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/form.vue

  • 步骤 1重写 form.vue — 脚本部分

<script lang="ts" setup>
import type { WmsBsrealStorAttrApi } from '#/api/wms/bsrealstorattr';

import { computed, ref, watch } from 'vue';

import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';

import { message } from 'antdv-next';

import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  type OutboundCreateReq,
  type OutboundDetail,
  createIostorInvWithDetails,
} from '#/api/wms/iostorinv';
import { getBsrealStorAttrSimpleList } from '#/api/wms/bsrealstorattr';
import { getSimpleDictDataList } from '#/api/system/dict/data';
import { $t } from '#/locales';

import InventorySelect from './inventory-select.vue';
import ManualDetail from './manual-detail.vue';

const emit = defineEmits(['success']);

// 仓库选项
const storOptions = ref<{ label: string; value: string; storCode: string; storName: string }[]>([]);
async function loadStorOptions() {
  const list = await getBsrealStorAttrSimpleList();
  storOptions.value = (list || []).map((item) => ({
    label: item.storName,
    value: item.storId,
    storCode: item.storCode,
    storName: item.storName,
  }));
}

// 业务类型选项
const billTypeOptions = ref<{ label: string; value: string }[]>([]);
async function loadBillTypeOptions() {
  const list = await getSimpleDictDataList();
  billTypeOptions.value = (list || [])
    .filter((d) => d.dictType === 'out_bill_type')
    .map((d) => ({ label: d.label, value: d.value }));
}

// 主表表单
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-2',
    labelWidth: 80,
  },
  layout: 'horizontal',
  schema: [
    {
      fieldName: 'storId',
      label: '仓库',
      rules: 'required',
      component: 'Select',
      componentProps: {
        options: storOptions,
        placeholder: '请选择仓库',
        fieldNames: { label: 'label', value: 'value' },
      },
    },
    {
      fieldName: 'billType',
      label: '业务类型',
      rules: 'required',
      component: 'Select',
      componentProps: {
        options: billTypeOptions,
        placeholder: '请选择业务类型',
      },
    },
    {
      fieldName: 'billCode',
      label: '单据号',
      component: 'Input',
      componentProps: { disabled: true, placeholder: '保存时自动生成' },
    },
    {
      fieldName: 'bizDate',
      label: '业务日期',
      rules: 'required',
      defaultValue: new Date(),
      component: 'DatePicker',
      componentProps: { showTime: true, format: 'YYYY-MM-DD HH:mm:ss', valueFormat: 'YYYY-MM-DD HH:mm:ss' },
    },
    {
      fieldName: 'billStatus',
      label: '单据状态',
      component: 'Input',
      componentProps: { disabled: true },
      defaultValue: '生成',
    },
    {
      fieldName: 'detailCount',
      label: '明细数',
      component: 'Input',
      componentProps: { disabled: true },
      defaultValue: 0,
    },
    {
      fieldName: 'totalWeight',
      label: '总重量',
      component: 'Input',
      componentProps: { disabled: true },
      defaultValue: '0',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Input',
      componentProps: { placeholder: '请输入备注' },
    },
  ],
  showDefaultActions: false,
});

// 明细数据
const detailList = ref<(OutboundDetail & { vehicleCode?: string; materialName?: string })[]>([]);

// 明细表格
const gridOptions = computed(() => ({
  columns: [
    { type: 'seq', title: '序号', width: 50 },
    { field: 'vehicleCode', title: '箱号', width: 100 },
    { field: 'materialCode', title: '物料编码', width: 120 },
    { field: 'materialName', title: '物料名称', width: 150 },
    { field: 'pcsn', title: '子卷号', width: 100 },
    { field: 'planQty', title: '出库重量', width: 100 },
    { field: 'qtyUnitName', title: '单位', width: 60 },
    { field: 'remark', title: '备注', width: 120 },
    { title: '操作', width: 80, slots: { default: 'actions' } },
  ],
  data: detailList.value,
  height: 'auto',
  rowConfig: { keyField: 'materialCode', isHover: true },
}));

// 联动:仓库切换 → 清空明细
watch(() => formApi.getValues().storId, () => {
  detailList.value = [];
  updateSummary();
});

// 更新主表汇总字段
function updateSummary() {
  const totalWeight = detailList.value
    .reduce((sum, d) => sum + (Number(d.planQty) || 0), 0)
    .toFixed(3);
  formApi.setValues({
    detailCount: detailList.value.length,
    totalWeight: String(totalWeight),
  });
}

// 删除明细行
function handleDeleteDetail(index: number) {
  detailList.value.splice(index, 1);
  updateSummary();
}

// 库存选择弹窗
const [InventorySelectModal, inventorySelectModalApi] = useVbenModal({
  connectedComponent: InventorySelect,
  destroyOnClose: true,
});
function handleSelectFromInventory() {
  const storId = formApi.getValues().storId;
  if (!storId) {
    message.warning('请先选择仓库');
    return;
  }
  inventorySelectModalApi.setData({ storId }).open();
}
function onInventorySelected(rows: any[]) {
  detailList.value.push(
    ...rows.map((r) => ({
      vehicleCode: r.vehicleCode,
      materialCode: r.materialCode,
      materialId: r.materialId,
      materialName: r.materialName,
      pcsn: r.pcsn,
      planQty: r.planQty ?? r.availableQty,
      qtyUnitId: r.qtyUnitId,
      qtyUnitName: r.qtyUnitName,
      sourceBillCode: r.extCode,
      sourceBillType: r.extType,
      sourceBilldtlId: r.extDtlCode,
    })),
  );
  updateSummary();
}

// 手动新增弹窗
const [ManualDetailModal, manualDetailModalApi] = useVbenModal({
  connectedComponent: ManualDetail,
  destroyOnClose: true,
});
function handleManualAdd() {
  manualDetailModalApi.setData({}).open();
}
function onManualAdded(detail: OutboundDetail & { vehicleCode?: string; materialName?: string }) {
  detailList.value.push(detail);
  updateSummary();
}

// 提交
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const masterValues = await formApi.getValues();
    if (!masterValues.storId || !masterValues.billType) {
      message.warning('请填写仓库和业务类型');
      return;
    }
    if (detailList.value.length === 0) {
      message.warning('请至少添加一条出库明细');
      return;
    }
    modalApi.lock();
    try {
      const stor = storOptions.value.find((s) => s.value === masterValues.storId);
      const req: OutboundCreateReq = {
        billType: masterValues.billType,
        storId: masterValues.storId,
        storCode: stor?.storCode,
        storName: stor?.storName,
        bizDate: masterValues.bizDate,
        remark: masterValues.remark,
        details: detailList.value.map((d) => ({
          materialCode: d.materialCode,
          materialId: d.materialId,
          pcsn: d.pcsn,
          planQty: Number(d.planQty),
          qtyUnitId: d.qtyUnitId,
          qtyUnitName: d.qtyUnitName,
          remark: d.remark,
          sourceBillCode: d.sourceBillCode,
          sourceBillType: d.sourceBillType,
          sourceBilldtlId: d.sourceBilldtlId,
        })),
      };
      await createIostorInvWithDetails(req);
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } catch (e: any) {
      message.error(e?.message || '保存失败');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (isOpen) {
      await loadStorOptions();
      await loadBillTypeOptions();
    }
    if (!isOpen) {
      detailList.value = [];
    }
  },
});
</script>
  • 步骤 2重写 form.vue — 模板部分
<template>
  <Modal title="出库新增" class="w-[1200px]">
    <div class="mx-4">
      <h4 class="mb-3 text-base font-medium">基本信息</h4>
      <Form />

      <div class="mt-6">
        <div class="mb-3 flex items-center justify-between">
          <h4 class="text-base font-medium">出库明细</h4>
          <div class="flex gap-2">
            <a-button type="primary" @click="handleSelectFromInventory">
              从库存选择
            </a-button>
            <a-button @click="handleManualAdd">
              手动新增汇总
            </a-button>
          </div>
        </div>

        <VxeTable :grid-options="gridOptions">
          <template #actions="{ rowIndex }">
            <a-button type="link" danger @click="handleDeleteDetail(rowIndex)">
              删除
            </a-button>
          </template>
        </VxeTable>
      </div>
    </div>

    <InventorySelectModal @select="onInventorySelected" />
    <ManualDetailModal @confirm="onManualAdded" />
  </Modal>
</template>

注意: 模板中使用了 VxeTable 组件,需要确认该组件在项目中的实际名称和用法。参考 index.vueuseVbenVxeGrid 的使用方式。如 VxeTable 不可用,改用 vxe-grid 直接绑定。

  • 步骤 3Commit
git add nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/form.vue
git commit -m "feat: 重构出库单新增表单为带明细的单页弹窗"

任务 9前端 — 新增库存选择弹窗组件

文件:

  • 创建:nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/inventory-select.vue

  • 步骤 1创建 inventory-select.vue

<script lang="ts" setup>
import type { AvailableInventory } from '#/api/wms/iostorinv';

import { ref } from 'vue';

import { useVbenModal } from '@vben/common-ui';

import { message } from 'antdv-next';

import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAvailableInventory } from '#/api/wms/iostorinv';
import { getMaterialBaseList } from '#/api/base/materialbase';

const emit = defineEmits(['select']);

const storId = ref('');

// 搜索表单
const formValues = ref({
  materialCode: '',
  pcsn: '',
  vehicleCode: '',
});

// 表格
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: [
      {
        fieldName: 'materialCode',
        label: '物料编码',
        component: 'Input',
        componentProps: { allowClear: true, placeholder: '请输入物料编码' },
      },
      {
        fieldName: 'pcsn',
        label: '批次',
        component: 'Input',
        componentProps: { allowClear: true, placeholder: '请输入批次' },
      },
      {
        fieldName: 'vehicleCode',
        label: '载具编码',
        component: 'Input',
        componentProps: { allowClear: true, placeholder: '请输入载具编码' },
      },
    ],
  },
  gridOptions: {
    columns: [
      { type: 'checkbox', width: 40 },
      { field: 'vehicleCode', title: '箱号', width: 100 },
      { field: 'materialCode', title: '物料编码', width: 120 },
      { field: 'pcsn', title: '子卷号', width: 100 },
      { field: 'availableQty', title: '可用数量', width: 100 },
      { field: 'qtyUnitName', title: '单位', width: 60 },
    ],
    height: 400,
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page, form }, formVals) => {
          if (!storId.value) return { rows: [], total: 0 };
          const list = await getAvailableInventory({
            storId: storId.value,
            materialCode: formVals.materialCode || undefined,
            pcsn: formVals.pcsn || undefined,
            vehicleCode: formVals.vehicleCode || undefined,
          });
          // 批量查询物料名称
          const materialCodes = [...new Set((list || []).map((i) => i.materialCode))];
          let nameMap: Record<string, string> = {};
          if (materialCodes.length > 0) {
            try {
              const materials = await getMaterialBaseList({ materialCodes });
              (materials || []).forEach((m: any) => {
                nameMap[m.materialCode] = m.materialName;
              });
            } catch { /* ignore */ }
          }
          const rows = (list || []).map((item) => ({
            ...item,
            materialName: nameMap[item.materialCode] || '',
            planQty: item.availableQty, // 默认出库重量 = 可用数量
          }));
          return { rows, total: rows.length };
        },
      },
    },
    rowConfig: { keyField: 'vehicleCode', isHover: true },
    checkboxConfig: {
      checkMethod: ({ row, checkedRecords }) => {
        // 同 vehicle_code 联动勾选
        if (!row) return false;
        const sameVehicle = gridApi
          .getGridData()
          .filter((r: any) => r.vehicleCode === row.vehicleCode);
        if (checkedRecords.some((r: any) => r.vehicleCode === row.vehicleCode)) {
          return true;
        }
        return false;
      },
    },
  } as any,
  gridEvents: {
    checkboxChange: ({ row, checked }: { row: any; checked: boolean }) => {
      // 勾选/取消时,同 vehicle_code 的行一起变化
      const allRows = gridApi.getGridData();
      const sameVehicleRows = allRows.filter((r: any) => r.vehicleCode === row.vehicleCode);
      if (checked) {
        sameVehicleRows.forEach((r: any) => gridApi.setCheckboxRow(r, true));
      } else {
        sameVehicleRows.forEach((r: any) => gridApi.setCheckboxRow(r, false));
      }
    },
  },
});

const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const checked = gridApi.getCheckboxRecords();
    if (checked.length === 0) {
      message.warning('请至少选择一条库存记录');
      return;
    }
    emit('select', checked);
    modalApi.close();
  },
  async onOpenChange(isOpen: boolean) {
    if (isOpen) {
      const data = modalApi.getData<{ storId: string }>();
      storId.value = data?.storId || '';
      gridApi.query();
    }
  },
});
</script>

<template>
  <Modal title="选择库存" class="w-[1000px]">
    <Grid />
  </Modal>
</template>

注意: getMaterialBaseList 接口参数需要确认。如接口仅支持 MaterialBaseListReqDTO 条件查询而非批量按编码查询,可能需要使用循环调用 getMaterialBaseByCode 或调整查询方式。此处为简化说明假设支持批量查询。

  • 步骤 2Commit
git add nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/inventory-select.vue
git commit -m "feat: 新增库存选择弹窗组件(支持同箱号联动勾选)"

任务 10前端 — 新增手动新增汇总弹窗组件

文件:

  • 创建:nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/manual-detail.vue

  • 步骤 1创建 manual-detail.vue

<script lang="ts" setup>
import { ref } from 'vue';

import { useVbenModal } from '@vben/common-ui';

import { message } from 'antdv-next';

const emit = defineEmits(['confirm']);

const formData = ref({
  materialCode: '',
  materialId: '',
  materialName: '',
  planQty: undefined as number | undefined,
  qtyUnitId: '',
  qtyUnitName: '',
  pcsn: '',
  remark: '',
});

function onMaterialSelected(rows: any[]) {
  if (rows.length > 0) {
    const m = rows[0];
    formData.value.materialCode = m.materialCode;
    formData.value.materialId = m.materialId;
    formData.value.materialName = m.materialName;
    formData.value.qtyUnitId = m.baseUnitId;
    formData.value.qtyUnitName = ''; // 单位名称需另行查询或通过字典转换
  }
}

const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!formData.value.materialCode) {
      message.warning('请选择物料');
      return;
    }
    if (!formData.value.planQty || formData.value.planQty <= 0) {
      message.warning('请输入出库重量');
      return;
    }
    emit('confirm', { ...formData.value });
    modalApi.close();
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = {
        materialCode: '', materialId: '', materialName: '',
        planQty: undefined, qtyUnitId: '', qtyUnitName: '',
        pcsn: '', remark: '',
      };
    }
  },
});
</script>

<template>
  <Modal title="手动新增汇总" class="w-[500px]">
    <div class="flex flex-col gap-4">
      <div class="flex items-center gap-2">
        <span class="w-20 text-right">物料</span>
        <a-input
          :value="formData.materialName || formData.materialCode"
          placeholder="点击选择物料"
          readonly
          class="flex-1"
        />
        <MaterialSelectModal @select="onMaterialSelected" />
        <!--
          注意MaterialSelectModal 的使用方式参见
          src/views/base/materialbase/components/MaterialSelectModal.vue
          需要通过 useVbenModal 集成
        -->
      </div>
      <div class="flex items-center gap-2">
        <span class="w-20 text-right">出库重量</span>
        <a-input-number
          v-model:value="formData.planQty"
          placeholder="请输入出库重量"
          :min="0"
          class="flex-1"
        />
        <span v-if="formData.qtyUnitName" class="text-gray-500">{{ formData.qtyUnitName }}</span>
      </div>
      <div class="flex items-center gap-2">
        <span class="w-20 text-right">批次</span>
        <a-input
          v-model:value="formData.pcsn"
          placeholder="请输入批次(可选)"
          class="flex-1"
        />
      </div>
      <div class="flex items-center gap-2">
        <span class="w-20 text-right">备注</span>
        <a-input
          v-model:value="formData.remark"
          placeholder="请输入备注(可选)"
          class="flex-1"
        />
      </div>
    </div>
  </Modal>
</template>

注意: MaterialSelectModal 在此处的集成方式需要参考原组件的 useVbenModal 用法。当前简化示例——实际集成时应在 script 中用 useVbenModal({ connectedComponent: MaterialSelectModal }) 打开物料选择弹窗。

  • 步骤 2Commit
git add nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/manual-detail.vue
git commit -m "feat: 新增手动新增汇总弹窗组件"

任务 11前端 — 字典常量补充 & 最终联调验证

文件:

  • 修改:nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/packages/constants/src/dict-enum.ts(可选,取决于实际使用方式)

  • 不强制修改,因为已在 form.vue 中通过 getSimpleDictDataList() 动态过滤 out_bill_type

  • 步骤 1验证 checklist

  1. 确认 nl-module-wms-server 的 pom.xml 包含 nl-module-base-api 依赖
  2. 确认 IostorinvDtlController 文件存在,且注入 IostorinvDtlMapper 无编译错误
  3. 启动后端服务,调用 POST /wms/iostor-inv/create-with-details 接口验证
  4. 调用 GET /wms/iostorinvdtl/available-inventory?storId=xxx 验证库存查询
  5. 前端页面打开"出库新增"弹窗,验证:
    • 仓库下拉加载正确
    • 业务类型下拉加载正确out_bill_type 字典)
    • 单据号显示占位提示
    • "从库存选择"弹窗:只显示对应仓库的库存,同箱号联动勾选
    • "手动新增汇总"弹窗:选物料、填重量
    • 明细增删后,明细数和总重量自动更新
    • 提交保存成功

自检

1. 规格覆盖度:

  • 主表字段映射 → 任务 8form.vue schema+ 任务 4ServiceImpl 字段赋值)
  • 明细字段映射 → 任务 8detailList 结构)+ 任务 4dtl.setXxx
  • 方式 A 从库存选择 → 任务 9inventory-select.vue+ 任务 3Mapper SQL+ 任务 7Controller
  • 方式 B 手动新增 → 任务 10manual-detail.vue
  • 单据号 CodeGenApi → 任务 1RpcConfig+ 任务 4ServiceImpl 调用)
  • 一对多联动 → 任务 9gridEvents.checkboxChange
  • planQty/unassignQty/assignQty → 任务 4ServiceImpl
  • 箱号不存 → 任务 8vehicleCode 仅 detailList 前端字段)
  • 库存查询 Mapper XML → 任务 3
  • 错误处理 → 任务 4异常抛出+ 任务 8前端校验
  • 不考虑的内容(入库/编辑/分配/回传)→ 已明确范围,无遗漏

2. 占位符扫描: 无待定/TODO/占位符

3. 类型一致性:

  • OutboundCreateReq / OutboundDetail → 前端 API 层(任务 6和 form.vue任务 8一致
  • IostorInvWithDetailsSaveReqVO → 后端 Controller任务 5和 Service任务 4一致
  • AvailableInventoryVO → Mapper任务 3、Controller任务 7、前端 API任务 6一致
  • AvailableInventory → 前端 API任务 6和 inventory-select任务 9一致