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

1326 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 出库单新增页面 实现计划
> **面向 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 客户端**
```java
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 接口编译不到。如缺失,添加依赖:
> ```xml
> <dependency>
> <groupId>cn.code.nl</groupId>
> <artifactId>nl-module-base-api</artifactId>
> <version>${project.version}</version>
> </dependency>
> ```
- [ ] **步骤 2Commit**
```bash
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**
```java
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**
```java
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**
```bash
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 接口中新增方法**
在文件末尾(`}` 之前)添加:
```java
/**
* 查询可用库存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
```java
import cn.code.nl.module.wms.controller.admin.iostorinv.vo.AvailableInventoryVO;
import org.apache.ibatis.annotations.Param;
```
- [ ] **步骤 2在 IostorinvDtlMapper.xml 中新增 SQL**
替换空的 XML 文件内容为:
```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**
```bash
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 接口中新增方法声明**
在文件末尾的 `}` 之前添加:
```java
/**
* 创建出库单(含明细)
*
* @param reqVO 创建请求(含明细列表)
* @return 出入单标识
*/
String createIostorInvWithDetails(@Valid IostorInvWithDetailsSaveReqVO reqVO);
```
新增 import
```java
import cn.code.nl.module.wms.controller.admin.iostorinv.vo.IostorInvWithDetailsSaveReqVO;
```
- [ ] **步骤 2在 IostorInvServiceImpl 中实现 createIostorInvWithDetails**
添加 `@Resource` 注入:
```java
@Resource
private cn.code.nl.module.base.api.codegen.CodeGenApi codeGenApi;
@Resource
private cn.code.nl.module.wms.dal.mysql.iostorinvdtl.IostorinvDtlMapper iostorinvDtlMapper;
```
新增方法实现(在文件末尾 `}` 之前):
```java
@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
```java
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`,在文件末尾添加:
```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**
```bash
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 方法和可用库存查询方法**
在类末尾的 `}` 之前添加:
```java
@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
```java
import cn.code.nl.module.wms.controller.admin.iostorinv.vo.IostorInvWithDetailsSaveReqVO;
```
- [ ] **步骤 2Commit**
```bash
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 方法:
```typescript
// --- 出库单带明细创建 ---
/** 出库明细项 */
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` 之后)添加:
```typescript
/** 创建出库单(含明细) */
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**
```bash
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 中新增可用库存查询端点**
在类末尾 `}` 之前添加:
```java
@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
```java
@Resource
private IostorinvDtlMapper iostorinvDtlMapper;
```
新增 import
```java
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**
```bash
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 — 脚本部分**
```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 — 模板部分**
```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.vue` 中 `useVbenVxeGrid` 的使用方式。如 `VxeTable` 不可用,改用 `vxe-grid` 直接绑定。
- [ ] **步骤 3Commit**
```bash
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**
```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**
```bash
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**
```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**
```bash
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. 规格覆盖度:**
- [x] 主表字段映射 → 任务 8form.vue schema+ 任务 4ServiceImpl 字段赋值)
- [x] 明细字段映射 → 任务 8detailList 结构)+ 任务 4dtl.setXxx
- [x] 方式 A 从库存选择 → 任务 9inventory-select.vue+ 任务 3Mapper SQL+ 任务 7Controller
- [x] 方式 B 手动新增 → 任务 10manual-detail.vue
- [x] 单据号 CodeGenApi → 任务 1RpcConfig+ 任务 4ServiceImpl 调用)
- [x] 一对多联动 → 任务 9gridEvents.checkboxChange
- [x] planQty/unassignQty/assignQty → 任务 4ServiceImpl
- [x] 箱号不存 → 任务 8vehicleCode 仅 detailList 前端字段)
- [x] 库存查询 Mapper XML → 任务 3
- [x] 错误处理 → 任务 4异常抛出+ 任务 8前端校验
- [x] 不考虑的内容(入库/编辑/分配/回传)→ 已明确范围,无遗漏
**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一致