diff --git a/.gitignore b/.gitignore index f0ae10a0..d63cd7b2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ application-my.yaml .DS_Store **/.DS_Store /.superpowers/ + + +# Local Git worktrees +.worktrees/ diff --git a/docs/superpowers/plans/2026-07-22-outbound-create-plan.md b/docs/superpowers/plans/2026-07-22-outbound-create-plan.md new file mode 100644 index 00000000..81aa2718 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-outbound-create-plan.md @@ -0,0 +1,410 @@ +# 出库单新增功能实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 按确认的布局实现出库单新增页,通过库存箱号展开全部可用子卷或手工新增物料汇总,并事务保存 `wms_iostorinv` 主表和 `wms_iostorinvdtl` 明细。 + +**架构:** 前端由主弹窗、库存选择弹窗、手工汇总弹窗组成,页面只维护现有表字段和临时展示字段。后端提供可用库存查询/箱号展开接口与聚合创建接口;聚合服务调用 base 编码 API、重新计算汇总值,并在一个事务内写入主表和明细。 + +**技术栈:** Java 17、Spring Boot、MyBatis XML、OpenFeign、Vue 3、TypeScript、Ant Design Vue Next、Vben Form、Vxe Grid、Vitest + +--- + +## 文件结构 + +### 后端 + +- 修改 `nl-module-wms/nl-module-wms-server/pom.xml`:引入 `nl-module-base-api`。 +- 创建 `nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/framework/rpc/config/RpcConfiguration.java`:注册 `CodeGenApi` Feign 客户端。 +- 创建 `nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvCreateReqVO.java`:聚合创建请求及内部明细对象。 +- 创建 `nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/AvailableInventoryPageReqVO.java`:库存筛选与分页参数。 +- 创建 `nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/AvailableInventoryRespVO.java`:库存与页面展示结果。 +- 创建 `nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/ExpandAvailableInventoryReqVO.java`:仓库和已选箱号集合。 +- 修改 `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`:实现跨仓库结构和组盘库存的 SQL。 +- 修改 `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/controller/admin/iostorinv/IostorInvController.java`:暴露三个管理端接口。 +- 修改 `nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/enums/ErrorCodeConstants.java`:增加编码和库存失效错误码。 +- 创建 `nl-module-wms/nl-module-wms-server/src/test/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceLocalSpringTest.java`:Spring 集成测试。 + +### 前端 + +- 修改 `nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts`:增加聚合请求、库存结果类型和接口函数。 +- 创建 `nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.ts`:纯函数计算明细数、总重量和去重键。 +- 创建 `nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.test.ts`:汇总联动的红绿测试。 +- 创建 `nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/inventory-select.vue`:库存筛选、多选和确认展开。 +- 创建 `nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/manual-detail.vue`:物料汇总新增。 +- 修改 `nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/form.vue`:实现确认布局、明细联动和聚合提交。 + +--- + +## 任务 1:建立后端请求契约与 RPC 依赖 + +- [ ] **步骤 1:先创建 Spring 集成测试骨架并验证当前编译失败** + +在 `IostorInvServiceLocalSpringTest.java` 中使用 `@SpringBootTest`、`@ActiveProfiles("local")` 和 `@Resource IostorInvService`,先引用尚不存在的 `IostorInvCreateReqVO` 与 `createOutbound`: + +```java +@SpringBootTest +@ActiveProfiles("local") +class IostorInvServiceLocalSpringTest { + @Resource + private IostorInvService iostorInvService; + + @Test + void createOutboundRejectsEmptyDetails() { + IostorInvCreateReqVO reqVO = new IostorInvCreateReqVO(); + Assertions.assertThrows(ConstraintViolationException.class, + () -> iostorInvService.createOutbound(reqVO)); + } +} +``` + +运行: + +```bash +mvn -pl nl-module-wms/nl-module-wms-server -am -DskipTests compile +``` + +预期:FAIL,提示 `IostorInvCreateReqVO` 或 `createOutbound` 不存在。 + +- [ ] **步骤 2:增加 base API 依赖和 Feign 注册** + +在 WMS server 的 `pom.xml` 增加: + +```xml + + cn.nl.cloud + nl-module-base-api + ${revision} + +``` + +创建 `RpcConfiguration`,使用 `@Configuration(value = "wmsRpcConfiguration", proxyBeanMethods = false)` 与 `@EnableFeignClients(clients = CodeGenApi.class)`。 + +- [ ] **步骤 3:创建带注解校验的聚合请求对象** + +`IostorInvCreateReqVO` 顶层包含 `@NotEmpty billType`、`@NotEmpty storId`、`@NotNull bizDate`、`remark`、`@NotEmpty @Valid List details`;内部 `Detail` 包含: + +```java +@NotEmpty(message = "物料编码不能为空") +private String materialCode; +private String materialId; +private String pcsn; +@NotNull(message = "出库重量不能为空") +@DecimalMin(value = "0.001", message = "出库重量必须大于0") +private BigDecimal planQty; +private String qtyUnitId; +private String qtyUnitName; +private String sourceBillCode; +private String sourceBillType; +private String sourceBilldtlId; +private String remark; +``` + +不接收 `billCode`、`billStatus`、`detailCount`、`totalWeight` 和 `seqNo`,这些字段全部由服务端生成。 + +- [ ] **步骤 4:声明 Service 方法并验证测试从编译失败进入校验失败/通过** + +```java +@Validated +public interface IostorInvService { + String createOutbound(@Valid IostorInvCreateReqVO reqVO); +} +``` + +运行: + +```bash +mvn -pl nl-module-wms/nl-module-wms-server -am -Dtest=IostorInvServiceLocalSpringTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +预期:测试能够启动且空请求因参数校验被拒绝;若 local 数据源不可用,保留测试并记录环境阻塞,继续用编译验证生产契约。 + +- [ ] **步骤 5:提交契约变更** + +```bash +git add nl-module-wms/nl-module-wms-server/pom.xml nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/framework/rpc/config/RpcConfiguration.java nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvCreateReqVO.java 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/test/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceLocalSpringTest.java +git commit -m "feat: 定义出库单聚合创建契约" +``` + +## 任务 2:实现可用库存查询和按箱号展开 + +- [ ] **步骤 1:扩展失败测试,定义箱号展开行为** + +在集成测试准备同一仓库下箱号 `BOX-001` 的两个可用子卷和另一个仓库的同箱数据,断言: + +```java +List rows = iostorInvService.expandAvailableInventory( + "STOR-001", List.of("BOX-001", "BOX-001")); +Assertions.assertEquals(2, rows.size()); +Assertions.assertTrue(rows.stream().allMatch(row -> "BOX-001".equals(row.getVehicleCode()))); +``` + +运行指定测试,预期:FAIL,方法和响应对象尚不存在。 + +- [ ] **步骤 2:创建查询参数和响应对象** + +`AvailableInventoryPageReqVO extends PageParam`,字段为 `@NotEmpty storId`、`materialCode`、`vehicleCode`、`pcsn`;`AvailableInventoryRespVO` 使用库存现有字段:`vehicleCode`、`pcsn`、`materialId`、`materialCode`、`materialName`(仅返回展示)、`availableQty`、单位、来源字段及仓库标识。`ExpandAvailableInventoryReqVO` 包含 `@NotEmpty storId` 和 `@NotEmpty List vehicleCodes`。 + +- [ ] **步骤 3:声明 Mapper 方法** + +```java +PageResult selectAvailableInventoryPage(AvailableInventoryPageReqVO reqVO); + +List selectAvailableInventoryByVehicleCodes( + @Param("storId") String storId, + @Param("vehicleCodes") Collection vehicleCodes); +``` + +- [ ] **步骤 4:在 XML 实现统一库存 SQL** + +基础关联为 `wms_group_plate gp INNER JOIN wms_structattr sa ON sa.storagevehicle_code = gp.vehicle_code LEFT JOIN base_materialbase mb ON mb.material_id = gp.material_id AND mb.is_deleted = 0`,固定条件为 `sa.stor_id = #{storId}`、`gp.status = '可用'`、`gp.qty - gp.frozen_qty > 0` 和 WMS 两表 `is_deleted = 0`。分页查询增加物料、箱号、子卷号筛选;展开查询使用 `` 的箱号集合。结果中 `available_qty = gp.qty - gp.frozen_qty`,物料名称从 `mb.material_name` 返回但不写入明细表。 + +- [ ] **步骤 5:Service 对箱号去重后查询全部子卷** + +```java +List distinctVehicleCodes = vehicleCodes.stream().distinct().toList(); +return iostorinvDtlMapper.selectAvailableInventoryByVehicleCodes(storId, distinctVehicleCodes); +``` + +控制器提供: + +```java +@GetMapping("/availableInventoryPage") +public CommonResult> getAvailableInventoryPage( + @Valid AvailableInventoryPageReqVO reqVO) + +@PostMapping("/expandAvailableInventory") +public CommonResult> expandAvailableInventory( + @Valid @RequestBody ExpandAvailableInventoryReqVO reqVO) +``` + +- [ ] **步骤 6:运行集成测试和 Mapper 编译** + +```bash +mvn -pl nl-module-wms/nl-module-wms-server -am -Dtest=IostorInvServiceLocalSpringTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +预期:同箱两子卷全部返回、重复箱号不产生重复行、其他仓库数据不返回。 + +- [ ] **步骤 7:提交库存查询变更** + +```bash +git add nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo 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 nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/IostorInvController.java nl-module-wms/nl-module-wms-server/src/test/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceLocalSpringTest.java +git commit -m "feat: 支持按箱号展开可用库存子卷" +``` + +## 任务 3:实现主表与明细事务保存 + +- [ ] **步骤 1:补充失败测试** + +构造两条重量 `12.500`、`7.250` 的明细,创建后查询数据库并断言:主表 `ioType = "OUT"`、`billStatus = "生成"`、`detailCount = 2`、`totalWeight = 19.750`;明细序号为 1/2、`assignQty = 0`、`unassignQty = planQty`。测试请求不传任何汇总字段。 + +- [ ] **步骤 2:实现编码生成和事务写入** + +`createOutbound` 标记 `@Transactional(rollbackFor = Exception.class)`: + +1. 构造 `CodeGenerateReqDTO`,设置项目现有出入库规则编码 `IO_CODE`。 +2. 调用 `CodeGenApi.generate`,检查 `CommonResult.isSuccess()` 且编码非空;失败抛出中文业务异常。 +3. 手工逐字段创建 `IostorInvDO`,固定 `ioType = "OUT"`、`billStatus = "生成"`。 +4. 使用 `details.stream().map(Detail::getPlanQty).reduce(BigDecimal.ZERO, BigDecimal::add)` 计算总重量并设置明细数。 +5. 插入主表后,循环构造 `IostorinvDtlDO`,写入关联 ID、连续序号、物料、`pcsn`、计划重量、分配数量、单位、来源和备注。 + +- [ ] **步骤 3:增加控制器端点和错误码** + +```java +@PostMapping("/createOutbound") +@PreAuthorize("@ss.hasPermission('wms:iostor-inv:create')") +public CommonResult createOutbound(@Valid @RequestBody IostorInvCreateReqVO reqVO) { + return success(iostorInvService.createOutbound(reqVO)); +} +``` + +错误码仅增加“单据号生成失败”和“所选库存已不可用”,不新增数据库字段或表。 + +- [ ] **步骤 4:验证红绿与事务回滚** + +运行集成测试;再让测试用编码 API 失败配置触发异常,断言主表和明细数量均未增加。 + +- [ ] **步骤 5:提交聚合保存** + +```bash +git add nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/enums/ErrorCodeConstants.java nl-module-wms/nl-module-wms-server/src/test/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceLocalSpringTest.java +git commit -m "feat: 事务保存出库单及明细" +``` + +## 任务 4:扩展前端 API 与明细汇总纯函数 + +- [ ] **步骤 1:先写失败的 Vitest 测试** + +```ts +import { describe, expect, it } from 'vitest'; +import { summarizeDetails } from './detail-summary'; + +describe('summarizeDetails', () => { + it('按明细条数和出库重量计算汇总', () => { + expect(summarizeDetails([{ planQty: 12.5 }, { planQty: 7.25 }])).toEqual({ + detailCount: 2, + totalWeight: 19.75, + }); + }); +}); +``` + +运行: + +```bash +pnpm --dir nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben vitest run apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.test.ts --dom +``` + +预期:FAIL,模块不存在。 + +- [ ] **步骤 2:实现最小汇总函数** + +```ts +export function summarizeDetails(details: Array<{ planQty?: number }>) { + return { + detailCount: details.length, + totalWeight: details.reduce((sum, item) => sum + Number(item.planQty || 0), 0), + }; +} +``` + +- [ ] **步骤 3:在 API 层增加明确类型和三个接口** + +定义 `OutboundDetail`、`OutboundCreateReq`、`AvailableInventory`、`AvailableInventoryPageReq`,并增加: + +```ts +export const getAvailableInventoryPage = (params: AvailableInventoryPageReq) => + requestClient.get>('/wms/iostor-inv/availableInventoryPage', { params }); +export const expandAvailableInventory = (storId: string, vehicleCodes: string[]) => + requestClient.post('/wms/iostor-inv/expandAvailableInventory', { storId, vehicleCodes }); +export const createOutbound = (data: OutboundCreateReq) => + requestClient.post('/wms/iostor-inv/createOutbound', data); +``` + +- [ ] **步骤 4:运行 Vitest 和类型检查** + +```bash +pnpm --dir nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben vitest run apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.test.ts --dom +pnpm --dir nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben --filter @vben/web-antdv-next typecheck +``` + +预期:汇总测试通过,新增 API 类型无错误。 + +- [ ] **步骤 5:提交前端基础能力** + +```bash +git add nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.ts nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.test.ts +git commit -m "feat: 增加出库单前端聚合接口与汇总逻辑" +``` + +## 任务 5:实现库存选择与手工汇总弹窗 + +- [ ] **步骤 1:实现库存选择弹窗** + +`inventory-select.vue` 接收主弹窗传入的 `storId`,表单筛选物料编码、箱号、子卷号,Vxe Grid 使用服务端分页和多选。确认时只收集选中行的 `vehicleCode`,调用 `expandAvailableInventory`;返回结果逐子卷映射为明细,`planQty` 默认为 `availableQty`。勾选过程不自动修改其他子卷的选中状态。 + +- [ ] **步骤 2:实现手工汇总弹窗** + +复用 `views/base/materialbase/components/MaterialSelectModal.vue` 选择一个物料,填写大于 0 的总出库重量和备注;确认对象必须显式包含: + +```ts +{ + materialId, + materialCode, + materialName, + planQty, + qtyUnitId, + qtyUnitName, + vehicleCode: undefined, + pcsn: undefined, + sapBatchNo: undefined, + remark, +} +``` + +- [ ] **步骤 3:运行前端类型检查** + +```bash +pnpm --dir nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben --filter @vben/web-antdv-next typecheck +``` + +预期:两个弹窗 props、emit、API 结果映射均无类型错误。 + +- [ ] **步骤 4:提交两个弹窗** + +```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 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: 增加出库库存选择和手工汇总弹窗" +``` + +## 任务 6:按参考图重构新增主弹窗 + +- [ ] **步骤 1:实现主表布局与默认值** + +重构 `form.vue` 为宽屏弹窗:单据号显示“保存时自动生成”;仓库和业务类型为必选下拉;状态只读“生成”;业务日期使用 `dayjs()` 默认为当天;明细数和总重量只读;备注跨列。仓库使用 `getBsrealStorAttrSimpleList`,业务类型从现有字典缓存中过滤出库业务类型。 + +- [ ] **步骤 2:实现明细表与两种入口** + +列顺序严格按确认原型:序号、物料编码、物料名称、箱号、子卷号、SAP 批次号、出库重量、单位、源单号、明细备注、操作。出库重量可编辑;删除、库存返回、手工汇总返回均调用 `summarizeDetails` 更新只读汇总。 + +- [ ] **步骤 3:实现仓库切换和提交** + +仓库值发生实际变化且明细非空时清空明细。提交只发送 `billType`、`storId`、`bizDate`、`remark` 及 `wms_iostorinvdtl` 可持久化字段;不得发送箱号、物料名称、SAP 批次号、单据号、状态和汇总字段。 + +- [ ] **步骤 4:运行前端测试、类型检查与构建** + +```bash +pnpm --dir nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben vitest run apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.test.ts --dom +pnpm --dir nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben --filter @vben/web-antdv-next typecheck +pnpm --dir nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben --filter @vben/web-antdv-next build +``` + +预期:测试、类型检查和生产构建均以退出码 0 完成。 + +- [ ] **步骤 5:提交主弹窗** + +```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: 按参考布局实现出库单新增页" +``` + +## 任务 7:端到端核对与最终验证 + +- [ ] **步骤 1:核对数据库字段边界** + +检查最终 diff,确认没有 DDL、迁移脚本或 DO 新字段;前端展示字段没有进入 `createOutbound` 请求;所有明细只通过 `IostorinvDtlMapper` 写入 `wms_iostorinvdtl`。 + +- [ ] **步骤 2:运行后端完整模块验证** + +```bash +mvn -pl nl-module-wms/nl-module-wms-server -am clean test -DskipTests=false +``` + +预期:WMS server 及依赖模块编译成功,测试失败数为 0。 + +- [ ] **步骤 3:运行前端完整验证** + +```bash +pnpm --dir nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben vitest run apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.test.ts --dom +pnpm --dir nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben --filter @vben/web-antdv-next typecheck +pnpm --dir nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben --filter @vben/web-antdv-next build +``` + +预期:三条命令均退出码 0。 + +- [ ] **步骤 4:人工验收关键流程** + +启动前后端后确认:新增页默认值正确;未选仓库不能选择库存;选择任一库存记录会按箱号带回全部子卷;勾选本身不联动;手工汇总的箱号、子卷号、SAP 批次号为空;明细汇总实时变化;切换仓库清空明细;保存后主表和全部明细同时存在。 + +- [ ] **步骤 5:提交验证阶段必要修正** + +仅当验证产生修正时提交相关文件: + +```bash +git add nl-module-wms nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv +git commit -m "fix: 完善出库单新增流程验证问题" +``` diff --git a/docs/superpowers/specs/2026-07-22-outbound-create-design.md b/docs/superpowers/specs/2026-07-22-outbound-create-design.md new file mode 100644 index 00000000..bf815e8f --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-outbound-create-design.md @@ -0,0 +1,117 @@ +# 出库单新增功能设计 + +## 目标与范围 + +按照 `.image/出库新增.png` 重构出入库单主表的新增布局,支持从仓库可用库存选择和手工新增物料汇总两种明细添加方式,并将主表与明细作为一个聚合事务保存。 + +本次仅实现出库新增,不新增数据库字段,不扩展入库、明细编辑、分配、确认或回传流程。主表使用 `wms_iostorinv`,明细写入 `wms_iostorinvdtl`。 + +## 页面布局 + +新增页采用已确认的参考图布局: + +- 顶部操作区提供“保存”和“关闭”。 +- 主表区域展示单据号、仓库、业务类型、单据状态、明细数、总重量、业务日期和备注。 +- 明细区域提供“选择库存”和“新增汇总”两个入口。 +- 明细表展示序号、物料编码、物料名称、箱号、子卷号、SAP 批次号、出库重量、单位、源单号、明细备注和操作。 +- 初次打开时明细表为空。 + +## 主表字段与默认值 + +| 页面字段 | `wms_iostorinv` 字段 | 行为 | +| --- | --- | --- | +| 单据号 | `billCode` | 只读;保存时通过 base 模块编码生成 API 生成 | +| 仓库 | `storId`、`storCode`、`storName` | 必填,可选择 | +| 业务类型 | `billType` | 必填,可选择 | +| 出入类型 | `ioType` | 后端固定为出库 | +| 单据状态 | `billStatus` | 默认并固定为“生成” | +| 明细数 | `detailCount` | 根据明细条数自动计算 | +| 总重量 | `totalWeight` | 汇总明细的出库重量 | +| 业务日期 | `bizDate` | 默认当天,可修改 | +| 备注 | `remark` | 选填 | + +前端在明细增删和重量变化时即时更新明细数与总重量。后端保存时必须根据请求明细重新计算,不能直接信任前端汇总字段。 + +## 明细字段与持久化边界 + +明细使用 `wms_iostorinvdtl` 的现有字段: + +| 业务含义 | 明细字段 | 保存规则 | +| --- | --- | --- | +| 主表关联 | `iostorinvId` | 主表插入后回填 | +| 序号 | `seqNo` | 从 1 开始连续生成 | +| 物料 | `materialId`、`materialCode` | 必填 | +| 子卷号 | `pcsn` | 库存明细带入;手工汇总为空 | +| 出库重量 | `planQty` | 必须大于 0 | +| 已分配数量 | `assignQty` | 新增时为 0 | +| 未分配数量 | `unassignQty` | 新增时等于出库重量 | +| 单位 | `qtyUnitId`、`qtyUnitName` | 从库存或物料信息带入 | +| 来源单据 | `sourceBillCode`、`sourceBillType`、`sourceBilldtlId` | 库存存在来源时带入 | +| 备注 | `remark` | 选填 | + +箱号、物料名称和独立 SAP 批次号在 `wms_iostorinvdtl` 中没有对应字段,只用于新增页面展示,不落库,也不与其他字段混用。手工汇总明细的箱号、子卷号和 SAP 批次号均为空。 + +## 明细新增方式 + +### 从库存选择 + +1. 用户必须先选择仓库。 +2. 库存弹窗查询该仓库下的可用库存,并支持按物料、箱号、子卷号等现有库存字段筛选。 +3. 用户可独立勾选库存记录,界面不自动联动勾选同箱的其他子卷。 +4. 确认时以后端为准,提取已选记录涉及的箱号,去重后重新查询这些箱号在所选仓库下的全部可用子卷。 +5. 将查询出的每个子卷逐条带回出库明细表;默认出库重量为该子卷当前可用重量。 +6. 同一箱号重复选择只展开一次,避免重复明细。 + +### 新增物料汇总 + +1. 用户选择物料并输入总出库重量,可填写备注。 +2. 单位从物料现有信息带出。 +3. 新增一条汇总明细,箱号、子卷号和 SAP 批次号保持为空。 + +## 接口与事务设计 + +采用主表与明细聚合保存方案: + +- 新增“创建出库单(含明细)”接口,接收主表可编辑字段和明细列表。 +- 新增按仓库和筛选条件查询可用库存的接口。 +- 库存确认或保存前由后端按箱号展开全部可用子卷,确保展开结果属于当前仓库且仍然可用。 +- 保存服务开启事务,先调用 base 模块编码生成 API 获取单据号,再构造主表、计算汇总、插入主表,最后插入全部 `wms_iostorinvdtl` 明细。 +- 编码生成、主表插入或任一明细插入失败时整体回滚。 + +单据号规则沿用项目已有的出入库编码规则配置和 `CodeGenApi` 调用方式,不在本功能中创建新的编码规则或字段。 + +## 数据校验与错误处理 + +- 仓库、业务类型和业务日期必填。 +- 保存时至少存在一条明细。 +- 每条明细必须包含物料,且出库重量大于 0。 +- 切换仓库时清空当前明细,避免跨仓库数据混用。 +- 库存带入时校验箱号属于所选仓库,并以当前可用库存重新展开;库存已不可用时返回明确的业务错误。 +- 重复箱号在展开前去重。 +- 编码 API 返回失败或空编码时终止保存并回滚。 +- 后端固定出库类型和生成状态,并重新计算明细数、总重量、序号及初始分配数量,防止客户端篡改。 + +## 测试与验收 + +后端测试覆盖: + +- base 编码 API 生成的单据号写入主表。 +- 默认出库类型和生成状态正确。 +- 后端根据明细重算明细数和总重量。 +- 主表与明细在同一事务中写入,异常时整体回滚。 +- 库存查询限定所选仓库和可用数量。 +- 任意选中一个箱号下的记录后,返回该箱号全部可用子卷。 +- 多条选中记录包含重复箱号时只展开一次。 +- 物料缺失、重量非正数、空明细及失效库存被拒绝。 + +前端测试或可自动化验证覆盖: + +- 新增页业务日期默认为当天,状态为生成,明细为空。 +- 明细增删和重量修改会更新明细数与总重量。 +- 未选择仓库不能打开库存选择流程。 +- 库存确认结果逐子卷进入明细表。 +- 手工汇总行的箱号、子卷号和 SAP 批次号为空。 +- 切换仓库会清空现有明细。 +- 提交数据只使用现有主表和明细字段。 + +完成后运行 WMS 模块相关单元测试、前端类型检查和构建,并对照参考图检查新增页布局与两种明细添加流程。 diff --git a/nl-module-base/nl-module-base-server/src/main/resources/mapper/codegen/CodeSequenceMapper.xml b/nl-module-base/nl-module-base-server/src/main/resources/mapper/codegen/CodeSequenceMapper.xml index 74909a0f..5d3572b8 100644 --- a/nl-module-base/nl-module-base-server/src/main/resources/mapper/codegen/CodeSequenceMapper.xml +++ b/nl-module-base/nl-module-base-server/src/main/resources/mapper/codegen/CodeSequenceMapper.xml @@ -14,7 +14,8 @@ VALUES (#{ruleId}, #{resetKey}, #{startAt} + #{step} - 1, #{maxValue}) ON DUPLICATE KEY UPDATE current_value = current_value + #{step}, - max_value = #{maxValue} + max_value = #{maxValue}, + deleted = 0 diff --git a/nl-module-wms/nl-module-wms-api/src/main/java/cn/code/nl/module/wms/enums/ErrorCodeConstants.java b/nl-module-wms/nl-module-wms-api/src/main/java/cn/code/nl/module/wms/enums/ErrorCodeConstants.java index f95fc137..632a1e2c 100644 --- a/nl-module-wms/nl-module-wms-api/src/main/java/cn/code/nl/module/wms/enums/ErrorCodeConstants.java +++ b/nl-module-wms/nl-module-wms-api/src/main/java/cn/code/nl/module/wms/enums/ErrorCodeConstants.java @@ -12,6 +12,10 @@ public interface ErrorCodeConstants { // ================ 出入库表相关错误码 ================= ErrorCode IOSTOR_INV_NOT_EXISTS = new ErrorCode(4, "出入库单主表不存在"); + ErrorCode IOSTOR_INV_CODE_GENERATE_FAILED = new ErrorCode(6_000_4, "单据号生成失败"); + + ErrorCode IOSTOR_INV_INVENTORY_INVALID = new ErrorCode(6_000_5, "出库库存已变化,请刷新后重新选择完整箱库存"); + ErrorCode IOSTORINV_DTL_NOT_EXISTS = new ErrorCode(5, "出入库单明细不存在"); ErrorCode IOSTORINV_DIS_NOT_EXISTS = new ErrorCode(6, "出入库单分配不存在"); diff --git a/nl-module-wms/nl-module-wms-server/pom.xml b/nl-module-wms/nl-module-wms-server/pom.xml index be10ce4c..26dc0cdf 100644 --- a/nl-module-wms/nl-module-wms-server/pom.xml +++ b/nl-module-wms/nl-module-wms-server/pom.xml @@ -129,6 +129,18 @@ cn.nl.cloud nl-spring-boot-starter-monitor + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + @@ -151,4 +163,4 @@ - \ No newline at end of file + diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/IostorInvController.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/IostorInvController.java index 8f3cadd8..635fdf5f 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/IostorInvController.java +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/IostorInvController.java @@ -38,10 +38,33 @@ public class IostorInvController { @Resource private IostorInvService iostorInvService; + @PostMapping("/createOutbound") + @Operation(summary = "创建出库单及明细") + @PreAuthorize("@ss.hasPermission('wms:iostor-inv:create')") + public CommonResult createOutbound(@Valid @RequestBody IostorInvCreateReqVO reqVO) { + return success(iostorInvService.createOutbound(reqVO)); + } + + @GetMapping("/availableInventoryPage") + @Operation(summary = "获得可用库存分页") + @PreAuthorize("@ss.hasPermission('wms:iostor-inv:query')") + public CommonResult> getAvailableInventoryPage( + @Valid AvailableInventoryPageReqVO reqVO) { + return success(iostorInvService.getAvailableInventoryPage(reqVO)); + } + + @PostMapping("/expandAvailableInventory") + @Operation(summary = "按箱号展开全部可用子卷") + @PreAuthorize("@ss.hasPermission('wms:iostor-inv:query')") + public CommonResult> expandAvailableInventory( + @Valid @RequestBody ExpandAvailableInventoryReqVO reqVO) { + return success(iostorInvService.expandAvailableInventory(reqVO)); + } + @PostMapping("/create") @Operation(summary = "创建出入库单主表") @PreAuthorize("@ss.hasPermission('wms:iostor-inv:create')") - public CommonResult createIostorInv(@Valid @RequestBody IostorInvSaveReqVO createReqVO) { + public CommonResult createIostorInv(@Valid @RequestBody IostorInvSaveReqVO createReqVO) { return success(iostorInvService.createIostorInv(createReqVO)); } diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/AvailableInventoryPageReqVO.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/AvailableInventoryPageReqVO.java new file mode 100644 index 00000000..afc4f5fe --- /dev/null +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/AvailableInventoryPageReqVO.java @@ -0,0 +1,27 @@ +package cn.code.nl.module.wms.controller.admin.iostorinv.vo; + +import cn.code.nl.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - 可用库存分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class AvailableInventoryPageReqVO extends PageParam { + + @Schema(description = "仓库标识", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "仓库标识不能为空") + private String storId; + + @Schema(description = "物料编码") + private String materialCode; + + @Schema(description = "箱号") + private String vehicleCode; + + @Schema(description = "批次") + private String pcsn; + +} diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/AvailableInventoryRespVO.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/AvailableInventoryRespVO.java new file mode 100644 index 00000000..9046b1cb --- /dev/null +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/AvailableInventoryRespVO.java @@ -0,0 +1,43 @@ +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; + +@Schema(description = "管理后台 - 可用库存 Response VO") +@Data +public class AvailableInventoryRespVO { + + @Schema(description = "组盘记录标识") + private Long groupId; + @Schema(description = "箱号") + private String vehicleCode; + @Schema(description = "批次") + private String pcsn; + @Schema(description = "物料标识") + private String materialId; + @Schema(description = "物料编码") + private String materialCode; + @Schema(description = "物料名称") + private String materialName; + @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; + +} diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/ExpandAvailableInventoryReqVO.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/ExpandAvailableInventoryReqVO.java new file mode 100644 index 00000000..f35adf56 --- /dev/null +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/ExpandAvailableInventoryReqVO.java @@ -0,0 +1,24 @@ +package cn.code.nl.module.wms.controller.admin.iostorinv.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 按箱号展开可用库存 Request VO") +@Data +public class ExpandAvailableInventoryReqVO { + + @Schema(description = "仓库标识", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "仓库标识不能为空") + private String storId; + + @Schema(description = "箱号列表", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "箱号列表不能为空") + @Size(max = 500, message = "箱号数量不能超过 500 个") + private List<@NotBlank(message = "箱号不能为空") String> vehicleCodes; + +} diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvCreateReqVO.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvCreateReqVO.java new file mode 100644 index 00000000..2b5faf60 --- /dev/null +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvCreateReqVO.java @@ -0,0 +1,81 @@ +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.DecimalMin; +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; + +@Schema(description = "管理后台 - 出库单新增 Request VO") +@Data +public class IostorInvCreateReqVO { + + @Schema(description = "单据类型", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "单据类型不能为空") + private String billType; + + @Schema(description = "仓库标识", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "仓库标识不能为空") + private String storId; + + @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 details; + + @Schema(description = "出库单明细") + @Data + public static class Detail { + + @Schema(description = "组盘记录标识(仅库存选取方式提交)") + private Long groupId; + + @Schema(description = "箱号(仅库存选取方式提交)") + private String vehicleCode; + + @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 = "出库重量不能为空") + @DecimalMin(value = "0.001", message = "出库重量必须大于0") + private BigDecimal planQty; + + @Schema(description = "数量单位标识") + private String qtyUnitId; + + @Schema(description = "数量单位名称") + private String qtyUnitName; + + @Schema(description = "来源单据编号") + private String sourceBillCode; + + @Schema(description = "来源单据类型") + private String sourceBillType; + + @Schema(description = "来源单据明细标识") + private String sourceBilldtlId; + + @Schema(description = "备注") + private String remark; + } +} diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvPageReqVO.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvPageReqVO.java index 224ea155..ea47a533 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvPageReqVO.java +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvPageReqVO.java @@ -8,6 +8,7 @@ import java.math.BigDecimal; import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDateTime; +import static cn.code.nl.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; import static cn.code.nl.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; @Schema(description = "管理后台 - 出入库单主表分页 Request VO") @@ -21,7 +22,7 @@ public class IostorInvPageReqVO extends PageParam { private String billType; @Schema(description = "业务日期") - @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) private LocalDateTime[] bizDate; @Schema(description = "仓库标识", example = "12635") @@ -76,7 +77,7 @@ public class IostorInvPageReqVO extends PageParam { private String syscompanyid; @Schema(description = "是否已上传") - private String isUpload; + private Boolean isUpload; @Schema(description = "回传人", example = "25284") private String uploadOptid; diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvRespVO.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvRespVO.java index 35964cd1..737c5d38 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvRespVO.java +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinv/vo/IostorInvRespVO.java @@ -4,10 +4,13 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; import java.util.*; import java.math.BigDecimal; +import com.fasterxml.jackson.annotation.JsonFormat; import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDateTime; import cn.idev.excel.annotation.*; +import static cn.code.nl.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; + @Schema(description = "管理后台 - 出入库单主表 Response VO") @Data @ExcelIgnoreUnannotated @@ -23,12 +26,17 @@ public class IostorInvRespVO { @Schema(description = "业务日期", requiredMode = Schema.RequiredMode.REQUIRED) @ExcelProperty("业务日期") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY) private LocalDateTime bizDate; @Schema(description = "仓库标识", example = "12635") @ExcelProperty("仓库标识") private String storId; + @Schema(description = "仓库", example = "1号仓") + @ExcelProperty("仓库") + private String storName; + @Schema(description = "来源方标识", example = "21190") @ExcelProperty("来源方标识") private String sourceId; diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinvdtl/IostorinvDtlController.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinvdtl/IostorinvDtlController.java index eef62d4b..dd2f69e2 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinvdtl/IostorinvDtlController.java +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/controller/admin/iostorinvdtl/IostorinvDtlController.java @@ -1,104 +1,104 @@ -package cn.code.nl.module.wms.controller.admin.iostorinvdtl; - -import org.springframework.web.bind.annotation.*; -import jakarta.annotation.Resource; -import org.springframework.validation.annotation.Validated; -import org.springframework.security.access.prepost.PreAuthorize; -import io.swagger.v3.oas.annotations.tags.Tag; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Operation; - -import jakarta.validation.constraints.*; -import jakarta.validation.*; -import jakarta.servlet.http.*; -import java.util.*; -import java.io.IOException; - -import cn.code.nl.framework.common.pojo.PageParam; -import cn.code.nl.framework.common.pojo.PageResult; -import cn.code.nl.framework.common.pojo.CommonResult; -import cn.code.nl.framework.common.util.object.BeanUtils; -import static cn.code.nl.framework.common.pojo.CommonResult.success; - -import cn.code.nl.framework.excel.core.util.ExcelUtils; - -import cn.code.nl.framework.apilog.core.annotation.ApiAccessLog; -import static cn.code.nl.framework.apilog.core.enums.OperateTypeEnum.*; - -import cn.code.nl.module.wms.controller.admin.iostorinvdtl.vo.*; -import cn.code.nl.module.wms.dal.dataobject.iostorinvdtl.IostorinvDtlDO; -import cn.code.nl.module.wms.service.iostorinvdtl.IostorinvDtlService; - -@Tag(name = "管理后台 - 出入库单明细") -@RestController -@RequestMapping("/wms/iostorinv-dtl") -@Validated -public class IostorinvDtlController { - - @Resource - private IostorinvDtlService iostorinvDtlService; - - @PostMapping("/create") - @Operation(summary = "创建出入库单明细") - @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:create')") - public CommonResult createIostorinvDtl(@Valid @RequestBody IostorinvDtlSaveReqVO createReqVO) { - return success(iostorinvDtlService.createIostorinvDtl(createReqVO)); - } - - @PutMapping("/update") - @Operation(summary = "更新出入库单明细") - @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:update')") - public CommonResult updateIostorinvDtl(@Valid @RequestBody IostorinvDtlSaveReqVO updateReqVO) { - iostorinvDtlService.updateIostorinvDtl(updateReqVO); - return success(true); - } - - @DeleteMapping("/delete") - @Operation(summary = "删除出入库单明细") - @Parameter(name = "id", description = "编号", required = true) - @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:delete')") - public CommonResult deleteIostorinvDtl(@RequestParam("id") String id) { - iostorinvDtlService.deleteIostorinvDtl(id); - return success(true); - } - - @DeleteMapping("/delete-list") - @Parameter(name = "ids", description = "编号", required = true) - @Operation(summary = "批量删除出入库单明细") - @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:delete')") - public CommonResult deleteIostorinvDtlList(@RequestParam("ids") List ids) { - iostorinvDtlService.deleteIostorinvDtlListByIds(ids); - return success(true); - } - - @GetMapping("/get") - @Operation(summary = "获得出入库单明细") - @Parameter(name = "id", description = "编号", required = true, example = "1024") - @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:query')") - public CommonResult getIostorinvDtl(@RequestParam("id") String id) { - IostorinvDtlDO iostorinvDtl = iostorinvDtlService.getIostorinvDtl(id); - return success(BeanUtils.toBean(iostorinvDtl, IostorinvDtlRespVO.class)); - } - - @GetMapping("/page") - @Operation(summary = "获得出入库单明细分页") - @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:query')") - public CommonResult> getIostorinvDtlPage(@Valid IostorinvDtlPageReqVO pageReqVO) { - PageResult pageResult = iostorinvDtlService.getIostorinvDtlPage(pageReqVO); - return success(BeanUtils.toBean(pageResult, IostorinvDtlRespVO.class)); - } - - @GetMapping("/export-excel") - @Operation(summary = "导出出入库单明细 Excel") - @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:export')") - @ApiAccessLog(operateType = EXPORT) - public void exportIostorinvDtlExcel(@Valid IostorinvDtlPageReqVO pageReqVO, - HttpServletResponse response) throws IOException { - pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); - List list = iostorinvDtlService.getIostorinvDtlPage(pageReqVO).getList(); - // 导出 Excel - ExcelUtils.write(response, "出入库单明细.xls", "数据", IostorinvDtlRespVO.class, - BeanUtils.toBean(list, IostorinvDtlRespVO.class)); - } - -} \ No newline at end of file +package cn.code.nl.module.wms.controller.admin.iostorinvdtl; + +import org.springframework.web.bind.annotation.*; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.security.access.prepost.PreAuthorize; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Operation; + +import jakarta.validation.constraints.*; +import jakarta.validation.*; +import jakarta.servlet.http.*; +import java.util.*; +import java.io.IOException; + +import cn.code.nl.framework.common.pojo.PageParam; +import cn.code.nl.framework.common.pojo.PageResult; +import cn.code.nl.framework.common.pojo.CommonResult; +import cn.code.nl.framework.common.util.object.BeanUtils; +import static cn.code.nl.framework.common.pojo.CommonResult.success; + +import cn.code.nl.framework.excel.core.util.ExcelUtils; + +import cn.code.nl.framework.apilog.core.annotation.ApiAccessLog; +import static cn.code.nl.framework.apilog.core.enums.OperateTypeEnum.*; + +import cn.code.nl.module.wms.controller.admin.iostorinvdtl.vo.*; +import cn.code.nl.module.wms.dal.dataobject.iostorinvdtl.IostorinvDtlDO; +import cn.code.nl.module.wms.service.iostorinvdtl.IostorinvDtlService; + +@Tag(name = "管理后台 - 出入库单明细") +@RestController +@RequestMapping("/wms/iostorinv-dtl") +@Validated +public class IostorinvDtlController { + + @Resource + private IostorinvDtlService iostorinvDtlService; + + @PostMapping("/create") + @Operation(summary = "创建出入库单明细") + @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:create')") + public CommonResult createIostorinvDtl(@Valid @RequestBody IostorinvDtlSaveReqVO createReqVO) { + return success(iostorinvDtlService.createIostorinvDtl(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新出入库单明细") + @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:update')") + public CommonResult updateIostorinvDtl(@Valid @RequestBody IostorinvDtlSaveReqVO updateReqVO) { + iostorinvDtlService.updateIostorinvDtl(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除出入库单明细") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:delete')") + public CommonResult deleteIostorinvDtl(@RequestParam("id") String id) { + iostorinvDtlService.deleteIostorinvDtl(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Parameter(name = "ids", description = "编号", required = true) + @Operation(summary = "批量删除出入库单明细") + @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:delete')") + public CommonResult deleteIostorinvDtlList(@RequestParam("ids") List ids) { + iostorinvDtlService.deleteIostorinvDtlListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得出入库单明细") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:query')") + public CommonResult getIostorinvDtl(@RequestParam("id") String id) { + IostorinvDtlDO iostorinvDtl = iostorinvDtlService.getIostorinvDtl(id); + return success(BeanUtils.toBean(iostorinvDtl, IostorinvDtlRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得出入库单明细分页") + @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:query')") + public CommonResult> getIostorinvDtlPage(@Valid IostorinvDtlPageReqVO pageReqVO) { + PageResult pageResult = iostorinvDtlService.getIostorinvDtlPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, IostorinvDtlRespVO.class)); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出出入库单明细 Excel") + @PreAuthorize("@ss.hasPermission('wms:iostorinv-dtl:export')") + @ApiAccessLog(operateType = EXPORT) + public void exportIostorinvDtlExcel(@Valid IostorinvDtlPageReqVO pageReqVO, + HttpServletResponse response) throws IOException { + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + List list = iostorinvDtlService.getIostorinvDtlPage(pageReqVO).getList(); + // 导出 Excel + ExcelUtils.write(response, "出入库单明细.xls", "数据", IostorinvDtlRespVO.class, + BeanUtils.toBean(list, IostorinvDtlRespVO.class)); + } + +} diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/dataobject/iostorinv/IostorInvDO.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/dataobject/iostorinv/IostorInvDO.java index 6035f4d9..939927ef 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/dataobject/iostorinv/IostorInvDO.java +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/dataobject/iostorinv/IostorInvDO.java @@ -30,8 +30,8 @@ public class IostorInvDO extends BaseDO { /** * 出入单标识 */ - @TableId(type = IdType.INPUT) - private String iostorinvId; + @TableId + private Long iostorinvId; /** * 单据编号 */ diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/dataobject/iostorinvdtl/IostorinvDtlDO.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/dataobject/iostorinvdtl/IostorinvDtlDO.java index 75c119da..5f65d7ab 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/dataobject/iostorinvdtl/IostorinvDtlDO.java +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/dataobject/iostorinvdtl/IostorinvDtlDO.java @@ -1,110 +1,106 @@ -package cn.code.nl.module.wms.dal.dataobject.iostorinvdtl; - -import lombok.*; -import java.util.*; -import java.math.BigDecimal; -import java.math.BigDecimal; -import java.math.BigDecimal; -import java.math.BigDecimal; -import com.baomidou.mybatisplus.annotation.*; -import cn.code.nl.framework.mybatis.core.dataobject.BaseDO; - -/** - * 出入库单明细 DO - * - * @author 诺力管理员 - */ -@TableName("wms_iostorinvdtl") -@KeySequence("wms_iostorinvdtl_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 -@Data -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class IostorinvDtlDO extends BaseDO { - - /** - * 出入单明细标识 - */ - @TableId(type = IdType.INPUT) - private String iostorinvdtlId; - /** - * 出入单标识 - */ - private String iostorinvId; - /** - * 明细序号 - */ - private Integer seqNo; - /** - * 批次 - */ - private String pcsn; - /** - * 单据明细状态 - */ - private String billStatus; - /** - * 数量计量单位标识 - */ - private String qtyUnitId; - /** - * 数量计量单位名称 - */ - private String qtyUnitName; - /** - * 计划数量 - */ - private BigDecimal planQty; - /** - * 实际数量 - */ - private BigDecimal realQty; - /** - * 来源单据明细标识 - */ - private String sourceBilldtlId; - /** - * 来源单据类型 - */ - private String sourceBillType; - /** - * 来源单编号 - */ - private String sourceBillCode; - /** - * 来源单表名 - */ - private String sourceBillTable; - /** - * 备注 - */ - private String remark; - /** - * 已分配数量 - */ - private BigDecimal assignQty; - /** - * 未分配数量 - */ - private BigDecimal unassignQty; - /** - * 物料编号 - */ - private String materialCode; - /** - * 来源单指定上料口 - */ - private String sourceLoadPort; - /** - * 单据回传策略配置类名 - */ - private String callbackStrategy; - /** - * 物料标识 - */ - private String materialId; - - -} \ No newline at end of file +package cn.code.nl.module.wms.dal.dataobject.iostorinvdtl; + +import lombok.*; +import java.util.*; +import java.math.BigDecimal; +import java.math.BigDecimal; +import java.math.BigDecimal; +import java.math.BigDecimal; +import com.baomidou.mybatisplus.annotation.*; +import cn.code.nl.framework.mybatis.core.dataobject.BaseDO; + +/** + * 出入库单明细 DO + * + * @author 诺力管理员 + */ +@TableName("wms_iostorinvdtl") +@KeySequence("wms_iostorinvdtl_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IostorinvDtlDO { + + /** + * 出入单明细标识 + */ + @TableId + private Long iostorinvdtlId; + /** + * 出入单标识 + */ + private Long iostorinvId; + /** + * 明细序号 + */ + private Integer seqNo; + /** + * 批次 + */ + private String pcsn; + /** + * 单据明细状态 + */ + private String billStatus; + /** + * 数量计量单位标识 + */ + private String qtyUnitId; + + /** + * 计划数量 + */ + private BigDecimal planQty; + /** + * 实际数量 + */ + private BigDecimal realQty; + /** + * 来源单据明细标识 + */ + private String sourceBilldtlId; + /** + * 来源单据类型 + */ + private String sourceBillType; + /** + * 来源单编号 + */ + private String sourceBillCode; + /** + * 来源单表名 + */ + private String sourceBillTable; + /** + * 备注 + */ + private String remark; + /** + * 已分配数量 + */ + private BigDecimal assignQty; + /** + * 未分配数量 + */ + private BigDecimal unassignQty; + /** + * 物料编号 + */ + private String materialCode; + /** + * 来源单指定上料口 + */ + private String sourceLoadPort; + /** + * 单据回传策略配置类名 + */ + private String callbackStrategy; + /** + * 物料标识 + */ + private String materialId; + + +} diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/mysql/iostorinvdtl/IostorinvDtlMapper.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/mysql/iostorinvdtl/IostorinvDtlMapper.java index df00b8ea..61324e0e 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/mysql/iostorinvdtl/IostorinvDtlMapper.java +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/dal/mysql/iostorinvdtl/IostorinvDtlMapper.java @@ -3,10 +3,15 @@ package cn.code.nl.module.wms.dal.mysql.iostorinvdtl; import java.util.*; import cn.code.nl.framework.common.pojo.PageResult; +import cn.code.nl.framework.mybatis.core.util.MyBatisUtils; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import cn.code.nl.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.code.nl.framework.mybatis.core.mapper.BaseMapperX; import cn.code.nl.module.wms.dal.dataobject.iostorinvdtl.IostorinvDtlDO; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import cn.code.nl.module.wms.controller.admin.iostorinv.vo.AvailableInventoryPageReqVO; +import cn.code.nl.module.wms.controller.admin.iostorinv.vo.AvailableInventoryRespVO; import cn.code.nl.module.wms.controller.admin.iostorinvdtl.vo.*; /** @@ -17,6 +22,31 @@ import cn.code.nl.module.wms.controller.admin.iostorinvdtl.vo.*; @Mapper public interface IostorinvDtlMapper extends BaseMapperX { + /** + * 分页查询指定仓库的可用库存。 + */ + default PageResult selectAvailableInventoryPage(AvailableInventoryPageReqVO reqVO) { + Page page = selectAvailableInventoryPage( + MyBatisUtils.buildPage(reqVO), reqVO); + return new PageResult<>(page.getRecords(), page.getTotal()); + } + + /** + * 执行可用库存分页 SQL。 + */ + Page selectAvailableInventoryPage( + Page page, @Param("reqVO") AvailableInventoryPageReqVO reqVO); + + /** + * 查询指定仓库、指定箱号下的全部可用子卷。 + */ + List selectAvailableInventoryByVehicleCodes( + @Param("storId") String storId, @Param("vehicleCodes") Collection vehicleCodes); + + /** 保存前重查并锁定指定仓库、箱号下的全部可用组盘行。 */ + List selectAvailableInventoryByVehicleCodesForUpdate( + @Param("storId") String storId, @Param("vehicleCodes") Collection vehicleCodes); + default PageResult selectPage(IostorinvDtlPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX() .eqIfPresent(IostorinvDtlDO::getIostorinvId, reqVO.getIostorinvId()) @@ -24,7 +54,6 @@ public interface IostorinvDtlMapper extends BaseMapperX { .eqIfPresent(IostorinvDtlDO::getPcsn, reqVO.getPcsn()) .eqIfPresent(IostorinvDtlDO::getBillStatus, reqVO.getBillStatus()) .eqIfPresent(IostorinvDtlDO::getQtyUnitId, reqVO.getQtyUnitId()) - .likeIfPresent(IostorinvDtlDO::getQtyUnitName, reqVO.getQtyUnitName()) .eqIfPresent(IostorinvDtlDO::getPlanQty, reqVO.getPlanQty()) .eqIfPresent(IostorinvDtlDO::getRealQty, reqVO.getRealQty()) .eqIfPresent(IostorinvDtlDO::getSourceBilldtlId, reqVO.getSourceBilldtlId()) @@ -41,4 +70,4 @@ public interface IostorinvDtlMapper extends BaseMapperX { .orderByDesc(IostorinvDtlDO::getIostorinvdtlId)); } -} \ No newline at end of file +} diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvService.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvService.java index 42203b6d..c2acd718 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvService.java +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvService.java @@ -14,13 +14,37 @@ import cn.code.nl.framework.common.pojo.PageParam; */ public interface IostorInvService { + /** + * 创建出库单 + * + * @param reqVO 出库单创建信息 + * @return 出库单编号 + */ + Long createOutbound(@Valid IostorInvCreateReqVO reqVO); + + /** + * 获得指定仓库的可用库存分页。 + * + * @param reqVO 分页查询条件 + * @return 可用库存分页 + */ + PageResult getAvailableInventoryPage(@Valid AvailableInventoryPageReqVO reqVO); + + /** + * 按箱号展开指定仓库中的全部可用子卷。 + * + * @param reqVO 展开条件 + * @return 可用子卷列表 + */ + List expandAvailableInventory(@Valid ExpandAvailableInventoryReqVO reqVO); + /** * 创建出入库单主表 * * @param createReqVO 创建信息 * @return 编号 */ - String createIostorInv(@Valid IostorInvSaveReqVO createReqVO); + Long createIostorInv(@Valid IostorInvSaveReqVO createReqVO); /** * 更新出入库单主表 diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceImpl.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceImpl.java index f6d75cb2..e25d6bd0 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceImpl.java +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceImpl.java @@ -1,11 +1,19 @@ package cn.code.nl.module.wms.service.iostorinv; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.code.nl.framework.common.pojo.CommonResult; +import cn.code.nl.module.base.api.codegen.CodeGenApi; +import cn.code.nl.module.base.api.codegen.dto.CodeGenerateReqDTO; +import cn.code.nl.module.wms.dal.dataobject.iostorinvdtl.IostorinvDtlDO; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import jakarta.annotation.Resource; import org.springframework.validation.annotation.Validated; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.util.*; import cn.code.nl.module.wms.controller.admin.iostorinv.vo.*; import cn.code.nl.module.wms.dal.dataobject.iostorinv.IostorInvDO; @@ -14,6 +22,7 @@ import cn.code.nl.framework.common.pojo.PageParam; import cn.code.nl.framework.common.util.object.BeanUtils; import cn.code.nl.module.wms.dal.mysql.iostorinv.IostorInvMapper; +import cn.code.nl.module.wms.dal.mysql.iostorinvdtl.IostorinvDtlMapper; import static cn.code.nl.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.code.nl.framework.common.util.collection.CollectionUtils.convertList; @@ -27,13 +36,144 @@ import static cn.code.nl.module.wms.enums.ErrorCodeConstants.*; */ @Service @Validated +@Slf4j public class IostorInvServiceImpl implements IostorInvService { @Resource private IostorInvMapper iostorInvMapper; + @Resource + private IostorinvDtlMapper iostorinvDtlMapper; + + @Resource + private CodeGenApi codeGenApi; + + /** + * 创建出库单及其明细。 + */ @Override - public String createIostorInv(IostorInvSaveReqVO createReqVO) { + @Transactional(rollbackFor = Exception.class) + public Long createOutbound(IostorInvCreateReqVO reqVO) { + Map inventorySnapshot = validateAndLockInventory(reqVO); + + String ruleCode = "IO_CODE"; + CodeGenerateReqDTO codeGenerateReqDTO = new CodeGenerateReqDTO(); + codeGenerateReqDTO.setRuleCode(ruleCode); + CommonResult codeResult; + try { + codeResult = codeGenApi.generate(codeGenerateReqDTO); + } catch (RuntimeException ex) { + log.error("[createOutbound][生成出库单号异常,规则编码:{}]", ruleCode, ex); + // 当前统一业务异常不支持 cause,远程异常堆栈已在此处完整记录。 + throw exception(IOSTOR_INV_CODE_GENERATE_FAILED); + } + if (codeResult == null || !codeResult.isSuccess() || StrUtil.isBlank(codeResult.getData())) { + String remoteMessage = codeResult == null ? "返回结果为空" : codeResult.getMsg(); + log.error("[createOutbound][生成出库单号失败,规则编码:{},远程信息:{}]", ruleCode, remoteMessage); + throw exception(IOSTOR_INV_CODE_GENERATE_FAILED); + } + + BigDecimal totalWeight = reqVO.getDetails().stream() + .map(IostorInvCreateReqVO.Detail::getPlanQty) + .reduce(BigDecimal.ZERO, BigDecimal::add); + IostorInvDO iostorInv = new IostorInvDO(); + iostorInv.setBillCode(codeResult.getData()); + iostorInv.setIoType("1"); + iostorInv.setBillType(reqVO.getBillType()); + iostorInv.setBizDate(reqVO.getBizDate()); + iostorInv.setStorId(reqVO.getStorId()); + iostorInv.setBillStatus("10"); + iostorInv.setRemark(reqVO.getRemark()); + iostorInv.setDetailCount(reqVO.getDetails().size()); + iostorInv.setTotalWeight(totalWeight); + iostorInvMapper.insert(iostorInv); + + for (int index = 0; index < reqVO.getDetails().size(); index++) { + IostorInvCreateReqVO.Detail detail = reqVO.getDetails().get(index); + IostorinvDtlDO detailDO = new IostorinvDtlDO(); + detailDO.setIostorinvId(iostorInv.getIostorinvId()); + detailDO.setSeqNo(index + 1); + AvailableInventoryRespVO inventory = inventorySnapshot.get(detail.getGroupId()); + detailDO.setMaterialCode(inventory == null ? detail.getMaterialCode() : inventory.getMaterialCode()); + detailDO.setMaterialId(inventory == null ? detail.getMaterialId() : inventory.getMaterialId()); + detailDO.setPcsn(inventory == null ? detail.getPcsn() : inventory.getPcsn()); + detailDO.setPlanQty(detail.getPlanQty()); + detailDO.setUnassignQty(detail.getPlanQty()); + detailDO.setAssignQty(BigDecimal.ZERO); + detailDO.setBillStatus("10"); + detailDO.setQtyUnitId(inventory == null ? detail.getQtyUnitId() : inventory.getQtyUnitId()); + detailDO.setSourceBillCode(inventory == null ? detail.getSourceBillCode() : inventory.getExtCode()); + detailDO.setSourceBillType(inventory == null ? detail.getSourceBillType() : inventory.getExtType()); + detailDO.setSourceBilldtlId(inventory == null ? detail.getSourceBilldtlId() : inventory.getExtDtlCode()); + detailDO.setRemark(detail.getRemark()); + iostorinvDtlMapper.insert(detailDO); + } + return iostorInv.getIostorinvId(); + } + + private Map validateAndLockInventory(IostorInvCreateReqVO reqVO) { + List inventoryDetails = new ArrayList<>(); + Set requestedGroupIds = new HashSet<>(); + Set vehicleCodes = new LinkedHashSet<>(); + for (IostorInvCreateReqVO.Detail detail : reqVO.getDetails()) { + boolean hasGroupId = detail.getGroupId() != null; + boolean hasVehicleCode = StrUtil.isNotBlank(detail.getVehicleCode()); + if (hasGroupId != hasVehicleCode) { + throw exception(IOSTOR_INV_INVENTORY_INVALID); + } + if (!hasGroupId) { + continue; + } + if (!requestedGroupIds.add(detail.getGroupId())) { + throw exception(IOSTOR_INV_INVENTORY_INVALID); + } + inventoryDetails.add(detail); + vehicleCodes.add(detail.getVehicleCode()); + } + if (inventoryDetails.isEmpty()) { + return Collections.emptyMap(); + } + + List lockedRows = + iostorinvDtlMapper.selectAvailableInventoryByVehicleCodesForUpdate(reqVO.getStorId(), vehicleCodes); + Map lockedByGroupId = new HashMap<>(); + for (AvailableInventoryRespVO row : lockedRows) { + if (lockedByGroupId.put(row.getGroupId(), row) != null) { + throw exception(IOSTOR_INV_INVENTORY_INVALID); + } + } + if (!lockedByGroupId.keySet().equals(requestedGroupIds)) { + throw exception(IOSTOR_INV_INVENTORY_INVALID); + } + for (IostorInvCreateReqVO.Detail detail : inventoryDetails) { + AvailableInventoryRespVO row = lockedByGroupId.get(detail.getGroupId()); + if (!Objects.equals(detail.getVehicleCode(), row.getVehicleCode()) + || !Objects.equals(detail.getMaterialId(), row.getMaterialId()) + || !Objects.equals(detail.getMaterialCode(), row.getMaterialCode()) + || !Objects.equals(detail.getPcsn(), row.getPcsn()) + || detail.getPlanQty() == null || detail.getPlanQty().signum() <= 0 + || detail.getPlanQty().compareTo(row.getAvailableQty()) > 0) { + throw exception(IOSTOR_INV_INVENTORY_INVALID); + } + } + // 建单仅验证库存快照;库存占用和冻结数量由后续分配流程处理。 + return lockedByGroupId; + } + + @Override + public PageResult getAvailableInventoryPage(AvailableInventoryPageReqVO reqVO) { + return iostorinvDtlMapper.selectAvailableInventoryPage(reqVO); + } + + @Override + public List expandAvailableInventory(ExpandAvailableInventoryReqVO reqVO) { + // 用户可以独立勾选子卷;按涉及箱号去重后,由数据库展开箱内全部可用子卷。 + Set vehicleCodes = new LinkedHashSet<>(reqVO.getVehicleCodes()); + return iostorinvDtlMapper.selectAvailableInventoryByVehicleCodes(reqVO.getStorId(), vehicleCodes); + } + + @Override + public Long createIostorInv(IostorInvSaveReqVO createReqVO) { // 插入 IostorInvDO iostorInv = BeanUtils.toBean(createReqVO, IostorInvDO.class); iostorInvMapper.insert(iostorInv); diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinvdtl/IostorinvDtlService.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinvdtl/IostorinvDtlService.java index e109f9ce..21fc23f0 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinvdtl/IostorinvDtlService.java +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinvdtl/IostorinvDtlService.java @@ -1,62 +1,62 @@ -package cn.code.nl.module.wms.service.iostorinvdtl; - -import java.util.*; -import jakarta.validation.*; -import cn.code.nl.module.wms.controller.admin.iostorinvdtl.vo.*; -import cn.code.nl.module.wms.dal.dataobject.iostorinvdtl.IostorinvDtlDO; -import cn.code.nl.framework.common.pojo.PageResult; -import cn.code.nl.framework.common.pojo.PageParam; - -/** - * 出入库单明细 Service 接口 - * - * @author 诺力管理员 - */ -public interface IostorinvDtlService { - - /** - * 创建出入库单明细 - * - * @param createReqVO 创建信息 - * @return 编号 - */ - String createIostorinvDtl(@Valid IostorinvDtlSaveReqVO createReqVO); - - /** - * 更新出入库单明细 - * - * @param updateReqVO 更新信息 - */ - void updateIostorinvDtl(@Valid IostorinvDtlSaveReqVO updateReqVO); - - /** - * 删除出入库单明细 - * - * @param id 编号 - */ - void deleteIostorinvDtl(String id); - - /** - * 批量删除出入库单明细 - * - * @param ids 编号 - */ - void deleteIostorinvDtlListByIds(List ids); - - /** - * 获得出入库单明细 - * - * @param id 编号 - * @return 出入库单明细 - */ - IostorinvDtlDO getIostorinvDtl(String id); - - /** - * 获得出入库单明细分页 - * - * @param pageReqVO 分页查询 - * @return 出入库单明细分页 - */ - PageResult getIostorinvDtlPage(IostorinvDtlPageReqVO pageReqVO); - -} \ No newline at end of file +package cn.code.nl.module.wms.service.iostorinvdtl; + +import java.util.*; +import jakarta.validation.*; +import cn.code.nl.module.wms.controller.admin.iostorinvdtl.vo.*; +import cn.code.nl.module.wms.dal.dataobject.iostorinvdtl.IostorinvDtlDO; +import cn.code.nl.framework.common.pojo.PageResult; +import cn.code.nl.framework.common.pojo.PageParam; + +/** + * 出入库单明细 Service 接口 + * + * @author 诺力管理员 + */ +public interface IostorinvDtlService { + + /** + * 创建出入库单明细 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createIostorinvDtl(@Valid IostorinvDtlSaveReqVO createReqVO); + + /** + * 更新出入库单明细 + * + * @param updateReqVO 更新信息 + */ + void updateIostorinvDtl(@Valid IostorinvDtlSaveReqVO updateReqVO); + + /** + * 删除出入库单明细 + * + * @param id 编号 + */ + void deleteIostorinvDtl(String id); + + /** + * 批量删除出入库单明细 + * + * @param ids 编号 + */ + void deleteIostorinvDtlListByIds(List ids); + + /** + * 获得出入库单明细 + * + * @param id 编号 + * @return 出入库单明细 + */ + IostorinvDtlDO getIostorinvDtl(String id); + + /** + * 获得出入库单明细分页 + * + * @param pageReqVO 分页查询 + * @return 出入库单明细分页 + */ + PageResult getIostorinvDtlPage(IostorinvDtlPageReqVO pageReqVO); + +} diff --git a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinvdtl/IostorinvDtlServiceImpl.java b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinvdtl/IostorinvDtlServiceImpl.java index 6961e5f6..e50c722e 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinvdtl/IostorinvDtlServiceImpl.java +++ b/nl-module-wms/nl-module-wms-server/src/main/java/cn/code/nl/module/wms/service/iostorinvdtl/IostorinvDtlServiceImpl.java @@ -33,7 +33,7 @@ public class IostorinvDtlServiceImpl implements IostorinvDtlService { private IostorinvDtlMapper iostorinvDtlMapper; @Override - public String createIostorinvDtl(IostorinvDtlSaveReqVO createReqVO) { + public Long createIostorinvDtl(IostorinvDtlSaveReqVO createReqVO) { // 插入 IostorinvDtlDO iostorinvDtl = BeanUtils.toBean(createReqVO, IostorinvDtlDO.class); iostorinvDtlMapper.insert(iostorinvDtl); diff --git a/nl-module-wms/nl-module-wms-server/src/main/resources/application.yaml b/nl-module-wms/nl-module-wms-server/src/main/resources/application.yaml index e715a703..de00b69f 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/resources/application.yaml +++ b/nl-module-wms/nl-module-wms-server/src/main/resources/application.yaml @@ -65,10 +65,10 @@ mybatis-plus: map-underscore-to-camel-case: true # 虽然默认为 true ,但是还是显示去指定下。 global-config: db-config: - id-type: NONE # “智能”模式,基于 IdTypeEnvironmentPostProcessor + 数据源的类型,自动适配成 AUTO、INPUT 模式。 + # id-type: NONE # “智能”模式,基于 IdTypeEnvironmentPostProcessor + 数据源的类型,自动适配成 AUTO、INPUT 模式。 # id-type: AUTO # 自增 ID,适合 MySQL 等直接自增的数据库 # id-type: INPUT # 用户输入 ID,适合 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库 -# id-type: ASSIGN_ID # 分配 ID,默认使用雪花算法。注意,Oracle、PostgreSQL、Kingbase、DB2、H2 数据库时,需要去除实体类上的 @KeySequence 注解 + id-type: ASSIGN_ID # 分配 ID,默认使用雪花算法。注意,Oracle、PostgreSQL、Kingbase、DB2、H2 数据库时,需要去除实体类上的 @KeySequence 注解 logic-delete-value: 1 # 逻辑已删除值(默认为 1) logic-not-delete-value: 0 # 逻辑未删除值(默认为 0) banner: false # 关闭控制台的 Banner 打印 diff --git a/nl-module-wms/nl-module-wms-server/src/main/resources/mapper/iostorinvdtl/IostorinvDtlMapper.xml b/nl-module-wms/nl-module-wms-server/src/main/resources/mapper/iostorinvdtl/IostorinvDtlMapper.xml index 9e1b610f..a07c69b6 100644 --- a/nl-module-wms/nl-module-wms-server/src/main/resources/mapper/iostorinvdtl/IostorinvDtlMapper.xml +++ b/nl-module-wms/nl-module-wms-server/src/main/resources/mapper/iostorinvdtl/IostorinvDtlMapper.xml @@ -2,11 +2,94 @@ - + + gp.group_id AS group_id, + gp.vehicle_code AS vehicle_code, + gp.pcsn AS pcsn, + gp.material_id AS material_id, + gp.material_code AS material_code, + mb.material_name AS material_name, + gp.qty - COALESCE(gp.frozen_qty, 0) AS available_qty, + gp.qty_unit_id AS qty_unit_id, + gp.qty_unit_name AS qty_unit_name, + gp.ext_code AS ext_code, + gp.ext_type AS ext_type, + gp.ext_dtl_code AS ext_dtl_code, + sa.stor_id AS stor_id, + sa.stor_code AS stor_code, + sa.stor_name AS stor_name + - \ No newline at end of file + + SELECT + FROM wms_group_plate gp + INNER JOIN ( + SELECT stor_id, storagevehicle_code, MAX(stor_code) AS stor_code, MAX(stor_name) AS stor_name + FROM wms_structattr + WHERE deleted = 0 + GROUP BY stor_id, storagevehicle_code + ) sa ON sa.storagevehicle_code = gp.vehicle_code + LEFT JOIN base_materialbase mb ON mb.material_id = gp.material_id AND mb.deleted = 0 + WHERE sa.stor_id = #{reqVO.storId} + AND gp.status = '可用' + AND gp.qty - COALESCE(gp.frozen_qty, 0) > 0 + AND gp.deleted = 0 + + AND gp.material_code LIKE CONCAT('%', #{reqVO.materialCode}, '%') + + + AND gp.vehicle_code LIKE CONCAT('%', #{reqVO.vehicleCode}, '%') + + + AND gp.pcsn LIKE CONCAT('%', #{reqVO.pcsn}, '%') + + ORDER BY gp.vehicle_code, gp.group_id + + + + SELECT + FROM wms_group_plate gp + INNER JOIN ( + SELECT stor_id, storagevehicle_code, MAX(stor_code) AS stor_code, MAX(stor_name) AS stor_name + FROM wms_structattr + WHERE deleted = 0 + GROUP BY stor_id, storagevehicle_code + ) sa ON sa.storagevehicle_code = gp.vehicle_code + LEFT JOIN base_materialbase mb ON mb.material_id = gp.material_id AND mb.deleted = 0 + WHERE sa.stor_id = #{storId} + AND gp.status = '可用' + AND gp.qty - COALESCE(gp.frozen_qty, 0) > 0 + AND gp.deleted = 0 + AND gp.vehicle_code IN + + #{vehicleCode} + + ORDER BY gp.vehicle_code, gp.group_id + + + + SELECT + FROM wms_group_plate gp + INNER JOIN ( + SELECT stor_id, storagevehicle_code, MAX(stor_code) AS stor_code, MAX(stor_name) AS stor_name + FROM wms_structattr + WHERE deleted = 0 + GROUP BY stor_id, storagevehicle_code + ) sa ON sa.storagevehicle_code = gp.vehicle_code + LEFT JOIN base_materialbase mb ON mb.material_id = gp.material_id AND mb.deleted = 0 + WHERE sa.stor_id = #{storId} + AND gp.status = '可用' + AND gp.qty - COALESCE(gp.frozen_qty, 0) > 0 + AND gp.deleted = 0 + AND gp.vehicle_code IN + + #{vehicleCode} + + ORDER BY gp.vehicle_code, gp.group_id + FOR UPDATE + + + diff --git a/nl-module-wms/nl-module-wms-server/src/test/java/cn/code/nl/module/wms/dal/mysql/iostorinvdtl/IostorinvDtlMapperTest.java b/nl-module-wms/nl-module-wms-server/src/test/java/cn/code/nl/module/wms/dal/mysql/iostorinvdtl/IostorinvDtlMapperTest.java new file mode 100644 index 00000000..f5be587a --- /dev/null +++ b/nl-module-wms/nl-module-wms-server/src/test/java/cn/code/nl/module/wms/dal/mysql/iostorinvdtl/IostorinvDtlMapperTest.java @@ -0,0 +1,119 @@ +package cn.code.nl.module.wms.dal.mysql.iostorinvdtl; + +import cn.code.nl.framework.common.pojo.PageResult; +import cn.code.nl.module.wms.controller.admin.iostorinv.vo.AvailableInventoryPageReqVO; +import cn.code.nl.module.wms.controller.admin.iostorinv.vo.AvailableInventoryRespVO; +import cn.code.nl.module.wms.controller.admin.iostorinv.vo.ExpandAvailableInventoryReqVO; +import cn.code.nl.module.wms.service.iostorinv.IostorInvServiceImpl; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.apache.ibatis.session.SqlSessionFactory; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import jakarta.annotation.Resource; +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringJUnitConfig +@ContextConfiguration(classes = IostorinvDtlMapperTest.TestConfiguration.class) +@Sql("/sql/iostorinv/available-inventory.sql") +@Transactional +class IostorinvDtlMapperTest { + + @Resource + private IostorinvDtlMapper mapper; + + @Test + void shouldExpandAvailableChildrenAndExecutePageFilters() throws Exception { + IostorInvServiceImpl service = new IostorInvServiceImpl(); + Field mapperField = IostorInvServiceImpl.class.getDeclaredField("iostorinvDtlMapper"); + mapperField.setAccessible(true); + mapperField.set(service, mapper); + + ExpandAvailableInventoryReqVO expandReqVO = new ExpandAvailableInventoryReqVO(); + expandReqVO.setStorId("STOR-001"); + expandReqVO.setVehicleCodes(List.of("BOX-001", "BOX-001")); + List expanded = service.expandAvailableInventory(expandReqVO); + + assertEquals(2, expanded.size()); + assertEquals(List.of(1L, 2L), expanded.stream().map(AvailableInventoryRespVO::getGroupId).toList()); + assertTrue(expanded.stream().allMatch(item -> "BOX-001".equals(item.getVehicleCode()))); + assertTrue(expanded.stream().allMatch(item -> "STOR-001".equals(item.getStorId()))); + assertEquals(2, expanded.stream().map(AvailableInventoryRespVO::getPcsn).distinct().count()); + assertTrue(expanded.stream().allMatch(item -> item.getAvailableQty().signum() > 0)); + assertEquals(0, new java.math.BigDecimal("5").compareTo(expanded.get(1).getAvailableQty())); + + List locked = mapper.selectAvailableInventoryByVehicleCodesForUpdate( + "STOR-001", List.of("BOX-001")); + assertEquals(List.of(1L, 2L), locked.stream().map(AvailableInventoryRespVO::getGroupId).toList()); + + AvailableInventoryPageReqVO warehousePageReqVO = new AvailableInventoryPageReqVO(); + warehousePageReqVO.setStorId("STOR-001"); + warehousePageReqVO.setPageNo(1); + warehousePageReqVO.setPageSize(10); + PageResult warehousePage = mapper.selectAvailableInventoryPage(warehousePageReqVO); + assertEquals(2L, warehousePage.getTotal()); + + AvailableInventoryPageReqVO pageReqVO = new AvailableInventoryPageReqVO(); + pageReqVO.setStorId("STOR-001"); + pageReqVO.setMaterialCode("MAT-001"); + pageReqVO.setVehicleCode("BOX-001"); + pageReqVO.setPcsn("PCSN-002"); + pageReqVO.setPageNo(1); + pageReqVO.setPageSize(10); + PageResult page = mapper.selectAvailableInventoryPage(pageReqVO); + + assertEquals(1L, page.getTotal()); + assertEquals("PCSN-002", page.getList().get(0).getPcsn()); + assertEquals(2L, page.getList().get(0).getGroupId()); + assertEquals("材料一", page.getList().get(0).getMaterialName()); + } + + @Configuration(proxyBeanMethods = false) + @MapperScan(basePackageClasses = IostorinvDtlMapper.class) + static class TestConfiguration { + + @Bean + DataSource dataSource() { + return new DriverManagerDataSource("jdbc:h2:mem:available_inventory;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + } + + @Bean + SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { + MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); + factory.setMapperLocations(new org.springframework.core.io.ClassPathResource( + "mapper/iostorinvdtl/IostorinvDtlMapper.xml")); + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + factory.setConfiguration(configuration); + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2)); + factory.setPlugins(interceptor); + return factory.getObject(); + } + + @Bean + PlatformTransactionManager transactionManager(DataSource dataSource) { + return new DataSourceTransactionManager(dataSource); + } + } + +} diff --git a/nl-module-wms/nl-module-wms-server/src/test/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceLocalSpringTest.java b/nl-module-wms/nl-module-wms-server/src/test/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceLocalSpringTest.java new file mode 100644 index 00000000..37f76725 --- /dev/null +++ b/nl-module-wms/nl-module-wms-server/src/test/java/cn/code/nl/module/wms/service/iostorinv/IostorInvServiceLocalSpringTest.java @@ -0,0 +1,377 @@ +package cn.code.nl.module.wms.service.iostorinv; + +import cn.code.nl.framework.common.exception.ServiceException; +import cn.code.nl.framework.common.pojo.CommonResult; +import cn.code.nl.module.base.api.codegen.CodeGenApi; +import cn.code.nl.module.base.api.codegen.dto.CodeGenerateReqDTO; +import cn.code.nl.module.wms.controller.admin.iostorinv.vo.IostorInvCreateReqVO; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import jakarta.annotation.Resource; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.apache.ibatis.session.SqlSessionFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; + +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static cn.code.nl.module.wms.enums.ErrorCodeConstants.IOSTOR_INV_CODE_GENERATE_FAILED; + +@SpringJUnitConfig +@ContextConfiguration(classes = IostorInvServiceLocalSpringTest.TestConfiguration.class) +@Sql("/sql/iostorinv/create-outbound.sql") +class IostorInvServiceLocalSpringTest { + + @Resource + private IostorInvService iostorInvService; + @Resource + private JdbcTemplate jdbcTemplate; + @Resource + private TestCodeGenApi codeGenApi; + @Resource + private SecondDetailInsertFailureAspect detailInsertFailureAspect; + + @BeforeEach + void resetCodeGenApi() { + codeGenApi.setResult(CommonResult.success("OUT-TEST-0001")); + codeGenApi.setFailure(null); + detailInsertFailureAspect.disable(); + } + + @Test + void shouldCreateOutboundUsingLockedInventoryIdentityInsteadOfClientUnitAndSource() { + String id = iostorInvService.createOutbound(buildRequest()); + + assertNotNull(id); + assertFalse(id.isBlank()); + jdbcTemplate.queryForObject("SELECT * FROM wms_iostorinv WHERE iostorinv_id = ?", (rs, rowNum) -> { + assertEquals("OUT-TEST-0001", rs.getString("bill_code")); + assertEquals("OUT", rs.getString("io_type")); + assertEquals("销售出库", rs.getString("bill_type")); + assertEquals(LocalDateTime.of(2026, 7, 22, 0, 0), + rs.getTimestamp("biz_date").toLocalDateTime()); + assertEquals("STOR-01", rs.getString("stor_id")); + assertEquals("生成", rs.getString("bill_status")); + assertEquals("主表备注", rs.getString("remark")); + assertEquals(2, rs.getInt("detail_count")); + assertEquals(new BigDecimal("19.750"), rs.getBigDecimal("total_weight")); + return null; + }, id); + + List details = jdbcTemplate.query( + "SELECT * FROM wms_iostorinvdtl WHERE iostorinv_id = ? ORDER BY seq_no", + (rs, rowNum) -> new DetailRow(rs.getString("iostorinvdtl_id"), rs.getString("iostorinv_id"), + rs.getInt("seq_no"), rs.getString("material_code"), + rs.getString("material_id"), rs.getString("pcsn"), rs.getBigDecimal("plan_qty"), + rs.getBigDecimal("assign_qty"), rs.getBigDecimal("unassign_qty"), + rs.getString("qty_unit_id"), rs.getString("qty_unit_name"), + rs.getString("source_bill_code"), rs.getString("source_bill_type"), + rs.getString("source_billdtl_id"), rs.getString("remark")), id); + assertEquals(2, details.size()); + assertNotNull(details.get(0).detailId()); + assertNotNull(details.get(1).detailId()); + assertFalse(details.get(0).detailId().isBlank()); + assertFalse(details.get(1).detailId().isBlank()); + assertNotEquals(details.get(0).detailId(), details.get(1).detailId()); + assertEquals(id, details.get(0).headerId()); + assertEquals(id, details.get(1).headerId()); + assertEquals(new DetailRow(details.get(0).detailId(), id, 1, "MAT-01", "MID-01", "PCSN-01", + new BigDecimal("12.500"), BigDecimal.ZERO.setScale(3), new BigDecimal("12.500"), + "DB-KG", "数据库千克", "DB-SRC-01", "DB-TYPE", "DB-DTL-01", "明细一"), details.get(0)); + assertEquals(new DetailRow(details.get(1).detailId(), id, 2, "MAT-02", "MID-02", "PCSN-02", + new BigDecimal("7.250"), BigDecimal.ZERO.setScale(3), new BigDecimal("7.250"), + "DB-KG", "数据库千克", "DB-SRC-02", "DB-TYPE", "DB-DTL-02", "明细二"), details.get(1)); + } + + @Test + void shouldNotInsertAnythingWhenCodeGenerationFails() { + codeGenApi.setResult(CommonResult.error(500, "编码服务暂不可用")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> iostorInvService.createOutbound(buildRequest())); + + assertEquals("单据号生成失败", exception.getMessage()); + assertEquals(IOSTOR_INV_CODE_GENERATE_FAILED.getCode(), exception.getCode()); + assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinv", Integer.class)); + assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinvdtl", Integer.class)); + } + + @Test + void shouldRejectBlankGeneratedCodeWithoutInsertingAnything() { + codeGenApi.setResult(CommonResult.success(" ")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> iostorInvService.createOutbound(buildRequest())); + + assertEquals(IOSTOR_INV_CODE_GENERATE_FAILED.getCode(), exception.getCode()); + assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinv", Integer.class)); + assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinvdtl", Integer.class)); + } + + @Test + void shouldConvertRemoteCodeGenerationExceptionToBusinessError() { + codeGenApi.setFailure(new IllegalStateException("远程连接失败")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> iostorInvService.createOutbound(buildRequest())); + + assertEquals(IOSTOR_INV_CODE_GENERATE_FAILED.getCode(), exception.getCode()); + assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinv", Integer.class)); + assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinvdtl", Integer.class)); + } + + @Test + void shouldRollbackRealInsertsWhenInjectedSecondDetailFailureOccurs() { + detailInsertFailureAspect.failOnSecondInsert(); + + assertThrows(DataAccessResourceFailureException.class, + () -> iostorInvService.createOutbound(buildRequest())); + + assertEquals(1, detailInsertFailureAspect.getRowsVisibleBeforeFailure()); + assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinv", Integer.class)); + assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinvdtl", Integer.class)); + } + + @Test + void shouldRejectMissingChildOfSameBoxBeforeGeneratingCode() { + jdbcTemplate.update("INSERT INTO wms_group_plate VALUES (104,'BOX-01','可用','MID-01','MAT-01','PCSN-EXTRA',1,0,'KG','千克',NULL,NULL,NULL,FALSE)"); + assertInventoryRejected(buildRequest()); + } + + @Test + void shouldRejectCrossWarehouseGroup() { + IostorInvCreateReqVO request = buildRequest(); + IostorInvCreateReqVO.Detail detail = request.getDetails().get(0); + detail.setGroupId(103L); + detail.setVehicleCode("BOX-X"); + detail.setPcsn("PCSN-X"); + detail.setPlanQty(new BigDecimal("5")); + assertInventoryRejected(request); + } + + @Test + void shouldRejectQuantityAboveAvailable() { + IostorInvCreateReqVO request = buildRequest(); + request.getDetails().get(1).setPlanQty(new BigDecimal("7.251")); + assertInventoryRejected(request); + } + + @Test + void shouldRejectDuplicateGroup() { + IostorInvCreateReqVO request = buildRequest(); + request.getDetails().get(1).setGroupId(101L); + request.getDetails().get(1).setVehicleCode("BOX-01"); + assertInventoryRejected(request); + } + + @Test + void shouldRejectInventoryChangedToUnavailable() { + jdbcTemplate.update("UPDATE wms_group_plate SET status = '不可用' WHERE group_id = 101"); + assertInventoryRejected(buildRequest()); + } + + @Test + void shouldRejectInventoryChangedToFullyFrozen() { + jdbcTemplate.update("UPDATE wms_group_plate SET frozen_qty = qty WHERE group_id = 101"); + assertInventoryRejected(buildRequest()); + } + + @Test + void shouldRejectInventoryChangedToPartiallyFrozenBelowRequestedQuantity() { + jdbcTemplate.update("UPDATE wms_group_plate SET qty = 10, frozen_qty = 6 WHERE group_id = 101"); + IostorInvCreateReqVO request = buildRequest(); + request.getDetails().get(0).setPlanQty(new BigDecimal("5")); + assertInventoryRejected(request); + } + + private void assertInventoryRejected(IostorInvCreateReqVO request) { + ServiceException ex = assertThrows(ServiceException.class, () -> iostorInvService.createOutbound(request)); + assertEquals("出库库存已变化,请刷新后重新选择完整箱库存", ex.getMessage()); + assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinv", Integer.class)); + assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinvdtl", Integer.class)); + } + + private IostorInvCreateReqVO buildRequest() { + IostorInvCreateReqVO request = new IostorInvCreateReqVO(); + request.setBillType("销售出库"); + request.setStorId("STOR-01"); + request.setBizDate(LocalDateTime.of(2026, 7, 22, 0, 0)); + request.setRemark("主表备注"); + request.setDetails(List.of(buildDetail("01", "12.500", "明细一"), + buildDetail("02", "7.250", "明细二"))); + return request; + } + + private IostorInvCreateReqVO.Detail buildDetail(String suffix, String planQty, String remark) { + IostorInvCreateReqVO.Detail detail = new IostorInvCreateReqVO.Detail(); + detail.setMaterialCode("MAT-" + suffix); + detail.setMaterialId("MID-" + suffix); + detail.setPcsn("PCSN-" + suffix); + detail.setPlanQty(new BigDecimal(planQty)); + detail.setQtyUnitId("KG"); + detail.setQtyUnitName("千克"); + detail.setSourceBillCode("SRC-" + suffix); + detail.setSourceBillType("ORDER"); + detail.setSourceBilldtlId("SRC-DTL-" + suffix); + detail.setRemark(remark); + detail.setGroupId(Long.valueOf("1" + suffix)); + detail.setVehicleCode("BOX-" + suffix); + return detail; + } + + private record DetailRow(String detailId, String headerId, Integer seqNo, String materialCode, + String materialId, String pcsn, + BigDecimal planQty, BigDecimal assignQty, BigDecimal unassignQty, + String qtyUnitId, String qtyUnitName, String sourceBillCode, + String sourceBillType, String sourceBilldtlId, String remark) { + } + + @Configuration(proxyBeanMethods = false) + @EnableTransactionManagement + @EnableAspectJAutoProxy + @MapperScan(basePackages = {"cn.code.nl.module.wms.dal.mysql.iostorinv", + "cn.code.nl.module.wms.dal.mysql.iostorinvdtl"}) + static class TestConfiguration { + + @Bean + DataSource dataSource() { + return new DriverManagerDataSource("jdbc:h2:mem:create_outbound;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + } + + @Bean + SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { + MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); + factory.setMapperLocations(new org.springframework.core.io.ClassPathResource( + "mapper/iostorinvdtl/IostorinvDtlMapper.xml")); + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + factory.setConfiguration(configuration); + return factory.getObject(); + } + + @Bean + PlatformTransactionManager transactionManager(DataSource dataSource) { + return new DataSourceTransactionManager(dataSource); + } + + @Bean + JdbcTemplate jdbcTemplate(DataSource dataSource) { + return new JdbcTemplate(dataSource); + } + + @Bean + static MethodValidationPostProcessor methodValidationPostProcessor() { + return new MethodValidationPostProcessor(); + } + + @Bean + IostorInvService iostorInvService() { + return new IostorInvServiceImpl(); + } + + @Bean + TestCodeGenApi codeGenApi() { + return new TestCodeGenApi(); + } + + @Bean + SecondDetailInsertFailureAspect detailInsertFailureAspect(JdbcTemplate jdbcTemplate) { + return new SecondDetailInsertFailureAspect(jdbcTemplate); + } + } + + static class TestCodeGenApi implements CodeGenApi { + private CommonResult result; + private RuntimeException failure; + + void setResult(CommonResult result) { + this.result = result; + } + + void setFailure(RuntimeException failure) { + this.failure = failure; + } + + @Override + public CommonResult generate(CodeGenerateReqDTO reqDTO) { + assertEquals("IO_CODE", reqDTO.getRuleCode()); + if (failure != null) { + throw failure; + } + return result; + } + + @Override + public CommonResult preview(String ruleCode) { + throw new UnsupportedOperationException(); + } + } + + /** 在第二次明细真实插入的调用点注入数据库访问故障,用于验证事务回滚。 */ + @Aspect + static class SecondDetailInsertFailureAspect { + private final JdbcTemplate jdbcTemplate; + private boolean enabled; + private int insertCount; + private int rowsVisibleBeforeFailure; + + SecondDetailInsertFailureAspect(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void failOnSecondInsert() { + enabled = true; + insertCount = 0; + rowsVisibleBeforeFailure = 0; + } + + void disable() { + enabled = false; + insertCount = 0; + rowsVisibleBeforeFailure = 0; + } + + int getRowsVisibleBeforeFailure() { + return rowsVisibleBeforeFailure; + } + + @Around("bean(iostorinvDtlMapper) && execution(* insert(..))") + Object injectFailureOnSecondInsert(ProceedingJoinPoint joinPoint) throws Throwable { + if (!enabled) { + return joinPoint.proceed(); + } + insertCount++; + if (insertCount == 2) { + rowsVisibleBeforeFailure = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM wms_iostorinvdtl", Integer.class); + throw new DataAccessResourceFailureException("测试注入:第二条明细写入失败"); + } + return joinPoint.proceed(); + } + } +} diff --git a/nl-module-wms/nl-module-wms-server/src/test/resources/sql/iostorinv/available-inventory.sql b/nl-module-wms/nl-module-wms-server/src/test/resources/sql/iostorinv/available-inventory.sql new file mode 100644 index 00000000..cfa74a51 --- /dev/null +++ b/nl-module-wms/nl-module-wms-server/src/test/resources/sql/iostorinv/available-inventory.sql @@ -0,0 +1,59 @@ +DROP TABLE IF EXISTS wms_group_plate; +DROP TABLE IF EXISTS wms_structattr; +DROP TABLE IF EXISTS base_materialbase; + +CREATE TABLE base_materialbase ( + material_id BIGINT PRIMARY KEY, + material_name VARCHAR(64), + deleted BOOLEAN NOT NULL +); + +CREATE TABLE wms_structattr ( + struct_id VARCHAR(64) PRIMARY KEY, + storagevehicle_code VARCHAR(64), + stor_id VARCHAR(64), + stor_code VARCHAR(64), + stor_name VARCHAR(64), + deleted BOOLEAN NOT NULL +); + +CREATE TABLE wms_group_plate ( + group_id BIGINT PRIMARY KEY, + vehicle_code VARCHAR(64), + status VARCHAR(16), + material_id VARCHAR(64), + material_code VARCHAR(64), + pcsn VARCHAR(64), + qty DECIMAL(18, 6), + frozen_qty DECIMAL(18, 6), + qty_unit_id VARCHAR(64), + qty_unit_name VARCHAR(64), + ext_code VARCHAR(64), + ext_type VARCHAR(64), + ext_dtl_code VARCHAR(64), + deleted BOOLEAN NOT NULL +); + +INSERT INTO base_materialbase(material_id, material_name, deleted) +VALUES (1001, '材料一', FALSE), (1002, '已删除材料', TRUE); + +INSERT INTO wms_structattr(struct_id, storagevehicle_code, stor_id, stor_code, stor_name, deleted) +VALUES ('S-001', 'BOX-001', 'STOR-001', 'STOR-CODE-001', '一号仓', FALSE), + ('S-001-B', 'BOX-001', 'STOR-001', 'STOR-CODE-001', '一号仓', FALSE), + ('S-002', 'BOX-001', 'STOR-002', 'STOR-CODE-002', '二号仓', FALSE), + ('S-003', 'BOX-002', 'STOR-001', 'STOR-CODE-001', '一号仓', FALSE), + ('S-004', 'BOX-003', 'STOR-001', 'STOR-CODE-001', '一号仓', FALSE), + ('S-005', 'BOX-004', 'STOR-001', 'STOR-CODE-001', '一号仓', FALSE), + ('S-006', 'BOX-005', 'STOR-001', 'STOR-CODE-001', '一号仓', TRUE); + +INSERT INTO wms_group_plate(group_id, vehicle_code, status, material_id, material_code, pcsn, + qty, frozen_qty, qty_unit_id, qty_unit_name, ext_code, ext_type, + ext_dtl_code, deleted) +VALUES (1, 'BOX-001', '可用', '1001', 'MAT-001', 'PCSN-001', 10, 2, 'UNIT-1', '卷', 'EXT-1', 'TYPE-1', 'DTL-1', FALSE), + (2, 'BOX-001', '可用', '1001', 'MAT-001', 'PCSN-002', 5, 1, 'UNIT-1', '卷', 'EXT-2', 'TYPE-1', 'DTL-2', FALSE), + (3, 'BOX-002', '不可用', '1001', 'MAT-001', 'PCSN-003', 10, 0, 'UNIT-1', '卷', 'EXT-3', 'TYPE-1', 'DTL-3', FALSE), + (4, 'BOX-003', '可用', '1001', 'MAT-001', 'PCSN-004', 10, 10, 'UNIT-1', '卷', 'EXT-4', 'TYPE-1', 'DTL-4', FALSE), + (5, 'BOX-004', '可用', '1001', 'MAT-001', 'PCSN-005', 10, 0, 'UNIT-1', '卷', 'EXT-5', 'TYPE-1', 'DTL-5', TRUE), + (6, 'BOX-005', '可用', '1001', 'MAT-001', 'PCSN-006', 10, 0, 'UNIT-1', '卷', 'EXT-6', 'TYPE-1', 'DTL-6', FALSE); + +UPDATE wms_group_plate SET frozen_qty = NULL WHERE group_id = 2; diff --git a/nl-module-wms/nl-module-wms-server/src/test/resources/sql/iostorinv/create-outbound.sql b/nl-module-wms/nl-module-wms-server/src/test/resources/sql/iostorinv/create-outbound.sql new file mode 100644 index 00000000..2d00a69e --- /dev/null +++ b/nl-module-wms/nl-module-wms-server/src/test/resources/sql/iostorinv/create-outbound.sql @@ -0,0 +1,37 @@ +DROP TABLE IF EXISTS wms_iostorinvdtl; +DROP TABLE IF EXISTS wms_iostorinv; +DROP TABLE IF EXISTS wms_group_plate; +DROP TABLE IF EXISTS wms_structattr; +DROP TABLE IF EXISTS base_materialbase; +CREATE TABLE wms_iostorinv ( + iostorinv_id VARCHAR(64) PRIMARY KEY, + bill_code VARCHAR(64), io_type VARCHAR(32), bill_type VARCHAR(64), biz_date TIMESTAMP, + stor_id VARCHAR(64), stor_code VARCHAR(64), stor_name VARCHAR(128), source_id VARCHAR(64), + source_name VARCHAR(128), source_type VARCHAR(64), total_qty DECIMAL(18, 3), + total_weight DECIMAL(18, 3), detail_count INT, bill_status VARCHAR(32), remark VARCHAR(255), + create_mode VARCHAR(32), dis_optid VARCHAR(64), dis_time TIMESTAMP, confirm_optid VARCHAR(64), + confirm_time TIMESTAMP, sysdeptid VARCHAR(64), syscompanyid VARCHAR(64), is_upload VARCHAR(16), + upload_optid VARCHAR(64), upload_time VARCHAR(64), create_time TIMESTAMP, update_time TIMESTAMP, + creator VARCHAR(64), updater VARCHAR(64), deleted BOOLEAN DEFAULT FALSE +); + +CREATE TABLE wms_iostorinvdtl ( + iostorinvdtl_id VARCHAR(64) PRIMARY KEY, iostorinv_id VARCHAR(64), seq_no INT, + pcsn VARCHAR(128), bill_status VARCHAR(32), qty_unit_id VARCHAR(64), qty_unit_name VARCHAR(64), + plan_qty DECIMAL(18, 3), real_qty DECIMAL(18, 3), source_billdtl_id VARCHAR(64), + source_bill_type VARCHAR(64), source_bill_code VARCHAR(64), source_bill_table VARCHAR(128), + remark VARCHAR(255), assign_qty DECIMAL(18, 3), unassign_qty DECIMAL(18, 3), + material_code VARCHAR(64), source_load_port VARCHAR(64), callback_strategy VARCHAR(255), + material_id VARCHAR(64), create_time TIMESTAMP, update_time TIMESTAMP, + creator VARCHAR(64), updater VARCHAR(64), deleted BOOLEAN DEFAULT FALSE +); + +CREATE TABLE base_materialbase (material_id VARCHAR(64) PRIMARY KEY, material_name VARCHAR(64), deleted BOOLEAN NOT NULL); +CREATE TABLE wms_structattr (struct_id VARCHAR(64) PRIMARY KEY, storagevehicle_code VARCHAR(64), stor_id VARCHAR(64), stor_code VARCHAR(64), stor_name VARCHAR(64), deleted BOOLEAN NOT NULL); +CREATE TABLE wms_group_plate (group_id BIGINT PRIMARY KEY, vehicle_code VARCHAR(64), status VARCHAR(16), material_id VARCHAR(64), material_code VARCHAR(64), pcsn VARCHAR(64), qty DECIMAL(18,3), frozen_qty DECIMAL(18,3), qty_unit_id VARCHAR(64), qty_unit_name VARCHAR(64), ext_code VARCHAR(64), ext_type VARCHAR(64), ext_dtl_code VARCHAR(64), deleted BOOLEAN NOT NULL); +INSERT INTO base_materialbase VALUES ('MID-01','物料一',FALSE), ('MID-02','物料二',FALSE); +INSERT INTO wms_structattr VALUES ('S1','BOX-01','STOR-01','S01','一号仓',FALSE), ('S2','BOX-02','STOR-01','S01','一号仓',FALSE), ('S3','BOX-X','STOR-02','S02','二号仓',FALSE); +INSERT INTO wms_group_plate VALUES + (101,'BOX-01','可用','MID-01','MAT-01','PCSN-01',12.500,NULL,'DB-KG','数据库千克','DB-SRC-01','DB-TYPE','DB-DTL-01',FALSE), + (102,'BOX-02','可用','MID-02','MAT-02','PCSN-02',8.000,0.750,'DB-KG','数据库千克','DB-SRC-02','DB-TYPE','DB-DTL-02',FALSE), + (103,'BOX-X','可用','MID-01','MAT-01','PCSN-X',5.000,0,'KG','千克',NULL,NULL,NULL,FALSE); diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/base/materialbase/index.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/base/materialbase/index.ts index 4c6091e1..e0b1b6bd 100644 --- a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/base/materialbase/index.ts +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/base/materialbase/index.ts @@ -1,11 +1,11 @@ import type { PageParam, PageResult } from '@vben/request'; -import type { Dayjs } from 'dayjs'; import { requestClient } from '#/api/request'; export namespace BaseMaterialBaseApi { /** 物料基本信息信息 */ export interface MaterialBase { + materialId: number | string; // 物料标识 materialCode?: string; // 物料编码 materialName?: string; // 物料名称 materialSpec: string; // 规格 diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts index 6afad210..d180c9b3 100644 --- a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts @@ -1,6 +1,7 @@ -import type { PageParam, PageResult } from '@vben/request'; import type { Dayjs } from 'dayjs'; +import type { PageParam, PageResult } from '@vben/request'; + import { requestClient } from '#/api/request'; export namespace WmsIostorInvApi { @@ -10,7 +11,7 @@ export namespace WmsIostorInvApi { billCode?: string; // 单据编号 ioType?: string; // 出入类型 billType?: string; // 单据类型 - bizDate?: string | Dayjs; // 业务日期 + bizDate?: Dayjs | string; // 业务日期 storId: string; // 仓库标识 storCode: string; // 仓库编码 storName: string; // 仓库名称 @@ -24,15 +25,80 @@ export namespace WmsIostorInvApi { remark: string; // 备注 createMode?: string; // 生成方式 disOptid: string; // 分配人 - disTime?: string | Dayjs; // 分配时间 + disTime?: Dayjs | string; // 分配时间 confirmOptid: string; // 确认人 - confirmTime?: string | Dayjs; // 确认时间 + confirmTime?: Dayjs | string; // 确认时间 sysdeptid: string; // 部门ID syscompanyid: string; // 公司ID isUpload?: string; // 是否已上传 uploadOptid: string; // 回传人 uploadTime: string; // 回传时间 } + + /** 出库单可持久化明细 */ + export interface OutboundDetail { + groupId?: number | string; // 组盘记录标识(库存选择方式) + vehicleCode?: string; // 箱号(库存选择方式) + materialCode: string; // 物料编码 + materialId?: string; // 物料标识 + pcsn?: string; // 批次序列号 + planQty: number; // 出库重量 + qtyUnitId?: string; // 数量单位标识 + qtyUnitName?: string; // 数量单位名称 + sourceBillCode?: string; // 来源单据编号 + sourceBillType?: string; // 来源单据类型 + sourceBilldtlId?: string; // 来源单据明细标识 + remark?: string; // 备注 + } + + /** 出库明细页面展示模型(提交时应转换为 OutboundDetail) */ + export interface OutboundDisplayDetail extends OutboundDetail { + materialName?: string; + rowKey?: string; + sapBatchNo?: string; + } + + /** 出库单新增请求 */ + export interface OutboundCreateReq { + billType: string; // 单据类型 + storId: string; // 仓库标识 + bizDate: number; // 业务日期(毫秒时间戳) + remark?: string; // 备注 + details: OutboundDetail[]; // 出库单明细 + } + + /** 可用库存 */ + export interface AvailableInventory { + groupId: number | string; // 组盘记录标识 + vehicleCode: string; // 箱号 + pcsn?: null | string; // 批次 + materialId: string; // 物料标识 + materialCode: string; // 物料编码 + materialName?: null | string; // 物料名称 + availableQty: number; // 可用数量 + qtyUnitId?: null | string; // 计量单位标识 + qtyUnitName?: null | string; // 计量单位名称 + extCode?: null | string; // 来源单据号 + extType?: null | string; // 来源单据类型 + extDtlCode?: null | string; // 来源单据明细号 + storId: string; // 仓库标识 + storCode?: null | string; // 仓库编码 + storName?: null | string; // 仓库名称 + } + + /** 可用库存分页请求 */ + export interface AvailableInventoryPageReq extends PageParam { + storId: string; // 仓库标识 + materialCode?: string; // 物料编码 + vehicleCode?: string; // 箱号 + pcsn?: string; // 批次 + } + + /** 按箱号展开可用库存请求 */ + export interface ExpandAvailableInventoryReq { + storId: string; // 仓库标识 + vehicleCodes: string[]; // 箱号列表 + } } /** 查询出入库单主表分页 */ @@ -44,9 +110,9 @@ export function getIostorInvPage(params: PageParam) { } /** 查询出入库单主表详情 */ -export function getIostorInv(id: number) { +export function getIostorInv(id: string) { return requestClient.get( - `/wms/iostor-inv/get?id=${id}`, + `/wms/iostor-inv/get?id=${encodeURIComponent(id)}`, ); } @@ -77,4 +143,27 @@ export function exportIostorInv(params: any) { return requestClient.download('/wms/iostor-inv/export-excel', { params }); } +/** 查询可用库存分页 */ +export function getAvailableInventoryPage( + params: WmsIostorInvApi.AvailableInventoryPageReq, + signal?: AbortSignal, +) { + return requestClient.get< + PageResult + >('/wms/iostor-inv/availableInventoryPage', { params, signal }); +} +/** 按箱号展开可用库存 */ +export function expandAvailableInventory( + data: WmsIostorInvApi.ExpandAvailableInventoryReq, +) { + return requestClient.post( + '/wms/iostor-inv/expandAvailableInventory', + data, + ); +} + +/** 创建出库单 */ +export function createOutbound(data: WmsIostorInvApi.OutboundCreateReq) { + return requestClient.post('/wms/iostor-inv/createOutbound', data); +} diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/data.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/data.ts index 15e9a244..bd44aae6 100644 --- a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/data.ts +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/data.ts @@ -3,6 +3,16 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { WmsIostorInvApi } from '#/api/wms/iostorinv'; import { getRangePickerDefaultProps } from '#/utils'; +import { getBsrealStorAttrSimpleList } from '#/api/wms/bsrealstorattr'; +import {getDictOptions} from "@vben/hooks"; +import {DICT_TYPE} from "@vben/constants"; + + +// ========== 启用的仓库下拉 ========== +async function loadWarehouseList() { + const data = await getBsrealStorAttrSimpleList(); + return data || []; +} /** 新增/修改的表单 */ export function useFormSchema(): VbenFormSchema[] { @@ -260,7 +270,7 @@ export function useGridFormSchema(): VbenFormSchema[] { component: 'Select', componentProps: { allowClear: true, - options: [], + options: getDictOptions(DICT_TYPE.WMS_OUT_BILL_TYPE), placeholder: '请选择单据类型', }, }, @@ -275,11 +285,14 @@ export function useGridFormSchema(): VbenFormSchema[] { }, { fieldName: 'storId', - label: '仓库标识', - component: 'Input', + label: '仓库', + component: 'ApiSelect', componentProps: { + api: loadWarehouseList, + labelField: 'storName', + valueField: 'storId', + placeholder: '请选择仓库', allowClear: true, - placeholder: '请输入仓库标识', }, }, { @@ -288,7 +301,7 @@ export function useGridFormSchema(): VbenFormSchema[] { component: 'Select', componentProps: { allowClear: true, - options: [], + options: getDictOptions(DICT_TYPE.WMS_IO_BILL_STATUS), placeholder: '请选择单据状态', }, }, @@ -303,11 +316,12 @@ export function useGridFormSchema(): VbenFormSchema[] { }, { fieldName: 'isUpload', - label: '是否已回传', - component: 'Input', + label: '是否回传', + component: 'Select', componentProps: { allowClear: true, - placeholder: '请输入是否已回传', + options: getDictOptions(DICT_TYPE.COMMON_TRUE_FALSE, 'boolean'), + placeholder: '请选择是否回传', }, } ]; @@ -331,11 +345,11 @@ export function useGridColumns(): VxeTableGridOptions field: 'bizDate', title: '业务日期', minWidth: 120, - formatter: 'formatDateTime', + formatter: 'formatDate', }, { - field: 'storId', - title: '仓库标识', + field: 'storName', + title: '仓库', minWidth: 120, }, { @@ -372,6 +386,10 @@ export function useGridColumns(): VxeTableGridOptions field: 'billStatus', title: '单据状态', minWidth: 120, + cellRender: { + name: 'CellDict', + props: { type: DICT_TYPE.WMS_IO_BILL_STATUS }, + }, }, { field: 'remark', @@ -382,6 +400,10 @@ export function useGridColumns(): VxeTableGridOptions field: 'createMode', title: '生成方式', minWidth: 120, + cellRender: { + name: 'CellDict', + props: { type: DICT_TYPE.WMS_CREATE_TYPE }, + }, }, { field: 'createTime', @@ -444,4 +466,3 @@ export function useGridColumns(): VxeTableGridOptions }, ]; } - diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-builders.test.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-builders.test.ts new file mode 100644 index 00000000..f869193f --- /dev/null +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-builders.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildManualDetail, + collectVehicleCodes, + mapExpandedInventory, + mergeInventorySelections, +} from './detail-builders'; + +describe('mapExpandedInventory', () => { + it('逐条映射展开后的全部子卷,且不把 pcsn 当作 SAP 批次', () => { + const rows = mapExpandedInventory([ + { + availableQty: 12.5, + groupId: 101, + extCode: 'OUT-1', + extDtlCode: 'DTL-1', + extType: 'SALE', + materialCode: 'M-1', + materialId: '1', + materialName: '物料一', + pcsn: 'P-1', + qtyUnitId: 'kg', + qtyUnitName: '千克', + storId: 'S-1', + vehicleCode: 'BOX-1', + }, + { + availableQty: 8, + groupId: '102', + materialCode: 'M-1', + materialId: '1', + pcsn: 'P-2', + storId: 'S-1', + vehicleCode: 'BOX-1', + }, + ]); + + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + pcsn: 'P-1', + planQty: 12.5, + sapBatchNo: undefined, + sourceBillCode: 'OUT-1', + sourceBillType: 'SALE', + sourceBilldtlId: 'DTL-1', + }); + expect(rows[1]?.pcsn).toBe('P-2'); + }); +}); + +describe('mergeInventorySelections', () => { + it('合并跨页记录,并将 number/string 形式的相同 groupId 去重', () => { + const reserved = [{ groupId: '1', vehicleCode: 'BOX-1' }]; + const current = [ + { groupId: 1, vehicleCode: 'BOX-1' }, + { groupId: 2, vehicleCode: 'BOX-2' }, + ]; + + expect(mergeInventorySelections(current, reserved)).toEqual([ + current[0], + current[1], + ]); + }); +}); + +describe('buildManualDetail', () => { + it('构造手工汇总行并显式清空箱号、子卷号和 SAP 批次', () => { + expect( + buildManualDetail( + { + baseUnitId: 7, + materialCode: 'M-1', + materialId: 1, + materialName: '物料一', + }, + 20, + '备注', + ), + ).toMatchObject({ + materialCode: 'M-1', + materialId: '1', + materialName: '物料一', + pcsn: undefined, + planQty: 20, + qtyUnitId: '7', + qtyUnitName: undefined, + remark: '备注', + sapBatchNo: undefined, + vehicleCode: undefined, + }); + }); +}); + +describe('collectVehicleCodes', () => { + it('去重并过滤空箱号', () => { + expect( + collectVehicleCodes([ + { vehicleCode: ' BOX-1 ' }, + { vehicleCode: '' }, + { vehicleCode: 'BOX-1' }, + { vehicleCode: 'BOX-2' }, + ]), + ).toEqual(['BOX-1', 'BOX-2']); + }); + + it('允许 500 个箱号并拒绝 501 个', () => { + expect( + collectVehicleCodes( + Array.from({ length: 500 }, (_, index) => ({ + vehicleCode: `BOX-${index}`, + })), + ), + ).toHaveLength(500); + expect(() => + collectVehicleCodes( + Array.from({ length: 501 }, (_, index) => ({ + vehicleCode: `BOX-${index}`, + })), + ), + ).toThrow('箱号数量不能超过 500 个'); + }); + + it('拒绝没有有效箱号的选择', () => { + expect(() => collectVehicleCodes([{ vehicleCode: ' ' }])).toThrow( + '请选择包含有效箱号的库存', + ); + }); +}); diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-builders.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-builders.ts new file mode 100644 index 00000000..0d3556be --- /dev/null +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-builders.ts @@ -0,0 +1,75 @@ +import type { BaseMaterialBaseApi } from '#/api/base/materialbase'; +import type { WmsIostorInvApi } from '#/api/wms/iostorinv'; + +/** 物料选择弹窗返回行中,手工明细所需的真实字段 */ +export type MaterialSelection = Pick< + BaseMaterialBaseApi.MaterialBase, + 'baseUnitId' | 'materialCode' | 'materialId' | 'materialName' +>; + +export function mapExpandedInventory( + inventory: WmsIostorInvApi.AvailableInventory[], +): WmsIostorInvApi.OutboundDisplayDetail[] { + return inventory.map((row) => ({ + groupId: row.groupId, + materialCode: row.materialCode, + materialId: row.materialId, + materialName: row.materialName ?? undefined, + pcsn: row.pcsn ?? undefined, + planQty: row.availableQty, + qtyUnitId: row.qtyUnitId ?? undefined, + qtyUnitName: row.qtyUnitName ?? undefined, + sapBatchNo: undefined, + sourceBillCode: row.extCode ?? undefined, + sourceBillType: row.extType ?? undefined, + sourceBilldtlId: row.extDtlCode ?? undefined, + vehicleCode: row.vehicleCode, + })); +} + +export function collectVehicleCodes(rows: Array<{ vehicleCode?: string }>) { + const vehicleCodes = [ + ...new Set(rows.map(({ vehicleCode }) => vehicleCode?.trim()).filter(Boolean)), + ] as string[]; + if (vehicleCodes.length === 0) { + throw new Error('请选择包含有效箱号的库存'); + } + if (vehicleCodes.length > 500) { + throw new Error('箱号数量不能超过 500 个'); + } + return vehicleCodes; +} + +/** 合并当前页与 VXE 跨页保留记录,以组盘记录标识去重 */ +export function mergeInventorySelections< + T extends { groupId: number | string }, +>( + current: T[], + reserved: T[], +) { + const selected = new Map(); + [...reserved, ...current].forEach((row) => + selected.set(String(row.groupId), row), + ); + return [...selected.values()]; +} + +export function buildManualDetail( + material: MaterialSelection, + planQty: number, + remark?: string, +): WmsIostorInvApi.OutboundDisplayDetail { + return { + materialCode: material.materialCode ?? '', + materialId: String(material.materialId), + materialName: material.materialName, + pcsn: undefined, + planQty, + qtyUnitId: + material.baseUnitId === undefined ? undefined : String(material.baseUnitId), + qtyUnitName: undefined, + remark: remark || undefined, + sapBatchNo: undefined, + vehicleCode: undefined, + }; +} diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.test.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.test.ts new file mode 100644 index 00000000..76663e17 --- /dev/null +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { summarizeDetails } from './detail-summary'; + +describe('summarizeDetails', () => { + it('汇总明细数量和总重量', () => { + expect( + summarizeDetails([{ planQty: 12.5 }, { planQty: 7.25 }]), + ).toEqual({ detailCount: 2, totalWeight: 19.75 }); + }); + + it('空明细返回零值', () => { + expect(summarizeDetails([])).toEqual({ detailCount: 0, totalWeight: 0 }); + }); + + it('忽略编辑态的空值和非法重量', () => { + expect( + summarizeDetails([ + { planQty: undefined }, + { planQty: '' }, + { planQty: 'invalid' }, + { planQty: Number.NaN }, + { planQty: null }, + ]), + ).toEqual({ detailCount: 5, totalWeight: 0 }); + }); +}); diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.ts new file mode 100644 index 00000000..d85f95a0 --- /dev/null +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-summary.ts @@ -0,0 +1,14 @@ +export interface OutboundDetailLike { + planQty?: null | number | string; +} + +/** 汇总出库明细数量和重量 */ +export function summarizeDetails(details: OutboundDetailLike[]) { + return { + detailCount: details.length, + totalWeight: details.reduce((total, detail) => { + const planQty = Number(detail.planQty); + return total + (Number.isFinite(planQty) ? planQty : 0); + }, 0), + }; +} diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/form.vue b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/form.vue index dc52fb96..4eed162d 100644 --- a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/form.vue +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/form.vue @@ -5,27 +5,52 @@ import { computed, ref } from 'vue'; import { useVbenModal } from '@vben/common-ui'; -import { message } from 'antdv-next'; +import { Button, Input, InputNumber, message, Space, Table } from 'antdv-next'; +import dayjs from 'dayjs'; import { useVbenForm } from '#/adapter/form'; -import { createIostorInv, getIostorInv, updateIostorInv } from '#/api/wms/iostorinv'; +import { getSimpleDictDataList } from '#/api/system/dict/data'; +import { getBsrealStorAttrSimpleList } from '#/api/wms/bsrealstorattr'; +import { + createOutbound, + getIostorInv, + updateIostorInv, +} from '#/api/wms/iostorinv'; import { $t } from '#/locales'; import { useFormSchema } from '../data'; +import { summarizeDetails } from './detail-summary'; +import InventorySelectComponent from './inventory-select.vue'; +import ManualDetailComponent from './manual-detail.vue'; +import { + buildOutboundPayload, + filterOutboundBillTypeOptions, + mergeOutboundInventory, + type OutboundFormDetail, + shouldApplyEditResult, + shouldClearDetailsForWarehouseChange, +} from './outbound-form'; const emit = defineEmits(['success']); const formData = ref(); -const getTitle = computed(() => { - return formData.value?.iostorinvId - ? $t('ui.actionTitle.edit', ['出入库单主表']) - : $t('ui.actionTitle.create', ['出入库单主表']); -}); +const details = ref([]); +const currentStorId = ref(); +let manualRowSequence = 0; +let billTypeRequest: null | ReturnType = null; +let mainModalOpen = false; +let mainOpenToken = 0; +let activeEditId: string | undefined; -const [Form, formApi] = useVbenForm({ +const isEdit = computed(() => Boolean(formData.value?.iostorinvId)); +const getTitle = computed(() => + isEdit.value + ? $t('ui.actionTitle.edit', ['出入库单主表']) + : '出库单新增', +); + +const [LegacyForm, legacyFormApi] = useVbenForm({ commonConfig: { - componentProps: { - class: 'w-full', - }, + componentProps: { class: 'w-full' }, formItemClass: 'col-span-2', labelWidth: 80, }, @@ -34,18 +59,240 @@ const [Form, formApi] = useVbenForm({ showDefaultActions: false, }); +async function handleWarehouseChange(nextStorId?: string) { + const shouldClear = shouldClearDetailsForWarehouseChange( + currentStorId.value, + nextStorId, + ); + currentStorId.value = nextStorId; + if (shouldClear && details.value.length > 0) { + details.value = []; + await refreshSummary(); + } +} + +const [OutboundForm, outboundFormApi] = useVbenForm({ + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-1', + labelWidth: 88, + }, + handleValuesChange: (values) => { + if (Object.hasOwn(values, 'storId')) { + void handleWarehouseChange(values.storId); + } + }, + layout: 'horizontal', + schema: [ + { + component: 'Input', + componentProps: { disabled: true }, + defaultValue: '保存时自动生成', + fieldName: 'billCode', + label: '单据号', + }, + { + component: 'ApiSelect', + componentProps: { + allowClear: true, + api: getBsrealStorAttrSimpleList, + labelField: 'storName', + placeholder: '请选择仓库', + valueField: 'storId', + }, + fieldName: 'storId', + label: '仓库', + rules: 'required', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [], + placeholder: '请选择业务类型', + }, + fieldName: 'billType', + label: '业务类型', + rules: 'required', + }, + { + component: 'Input', + componentProps: { disabled: true }, + defaultValue: '生成', + fieldName: 'billStatus', + label: '单据状态', + }, + { + component: 'InputNumber', + componentProps: { disabled: true, min: 0 }, + defaultValue: 0, + fieldName: 'detailCount', + label: '明细数', + }, + { + component: 'Input', + componentProps: { disabled: true }, + defaultValue: '0.000', + fieldName: 'totalWeight', + label: '总重量', + }, + { + component: 'DatePicker', + componentProps: { class: 'w-full', format: 'YYYY-MM-DD' }, + defaultValue: dayjs(), + fieldName: 'bizDate', + label: '业务日期', + rules: 'required', + }, + { + component: 'Textarea', + componentProps: { allowClear: true, placeholder: '请输入备注', rows: 1 }, + fieldName: 'remark', + label: '备注', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid grid-cols-1 gap-x-4 md:grid-cols-2 xl:grid-cols-4', +}); + +const [InventorySelect, inventorySelectApi] = useVbenModal({ + connectedComponent: InventorySelectComponent, + destroyOnClose: true, +}); +const [ManualDetail, manualDetailApi] = useVbenModal({ + connectedComponent: ManualDetailComponent, + destroyOnClose: true, +}); + +async function loadBillTypeOptions() { + billTypeRequest ??= getSimpleDictDataList(); + const request = billTypeRequest; + try { + const dictItems = await request; + outboundFormApi.updateSchema([ + { + componentProps: { + allowClear: true, + options: filterOutboundBillTypeOptions(dictItems), + placeholder: '请选择业务类型', + }, + fieldName: 'billType', + }, + ]); + } catch { + message.error('出库业务类型加载失败,请稍后重试'); + } finally { + if (billTypeRequest === request) billTypeRequest = null; + } +} + +const columns = [ + { key: 'index', title: '序号', width: 60 }, + { dataIndex: 'materialCode', key: 'materialCode', title: '物料编码', width: 130 }, + { dataIndex: 'materialName', key: 'materialName', title: '物料名称', width: 150 }, + { dataIndex: 'vehicleCode', key: 'vehicleCode', title: '箱号', width: 130 }, + { dataIndex: 'pcsn', key: 'pcsn', title: '子卷号', width: 130 }, + { dataIndex: 'sapBatchNo', key: 'sapBatchNo', title: 'SAP批次号', width: 130 }, + { dataIndex: 'planQty', key: 'planQty', title: '出库重量', width: 130 }, + { dataIndex: 'qtyUnitName', key: 'qtyUnitName', title: '单位', width: 80 }, + { dataIndex: 'sourceBillCode', key: 'sourceBillCode', title: '源单号', width: 140 }, + { dataIndex: 'remark', key: 'remark', title: '明细备注', width: 180 }, + { key: 'action', title: '操作', width: 80, fixed: 'right' as const }, +]; + +async function refreshSummary() { + const summary = summarizeDetails(details.value); + await outboundFormApi.setValues({ + detailCount: summary.detailCount, + totalWeight: summary.totalWeight.toFixed(3), + }); +} + +async function openInventorySelect() { + const values = await outboundFormApi.getValues(); + if (!values.storId) { + message.warning('请先选择仓库'); + return; + } + inventorySelectApi.setData({ storId: values.storId }).open(); +} + +function openManualDetail() { + manualDetailApi.open(); +} + +async function handleInventorySelect(rows: WmsIostorInvApi.OutboundDisplayDetail[]) { + const keyedRows = rows.map((row) => ({ + ...row, + rowKey: `inventory-${String(row.groupId)}`, + })); + details.value = mergeOutboundInventory(details.value, keyedRows); + await refreshSummary(); +} + +async function handleManualSelect(rows: WmsIostorInvApi.OutboundDisplayDetail[]) { + details.value.push( + ...rows.map((row) => ({ + ...row, + rowKey: `manual-${Date.now()}-${++manualRowSequence}`, + })), + ); + await refreshSummary(); +} + +async function removeDetail(index: number) { + details.value.splice(index, 1); + await refreshSummary(); +} + +function validateDetails() { + if (details.value.length === 0) { + message.warning('请至少添加一条出库明细'); + return false; + } + const invalidIndex = details.value.findIndex( + (detail) => + !detail.materialCode || + !Number.isFinite(Number(detail.planQty)) || + Number(detail.planQty) <= 0, + ); + if (invalidIndex >= 0) { + message.warning(`第 ${invalidIndex + 1} 条明细物料编码不能为空,出库重量必须大于 0`); + return false; + } + return true; +} + const [Modal, modalApi] = useVbenModal({ async onConfirm() { - const { valid } = await formApi.validate(); - if (!valid) { + if (isEdit.value) { + const { valid } = await legacyFormApi.validate(); + if (!valid) return; + modalApi.lock(); + try { + await updateIostorInv( + (await legacyFormApi.getValues()) as WmsIostorInvApi.IostorInv, + ); + await modalApi.close(); + emit('success'); + message.success($t('ui.actionMessage.operationSuccess')); + } finally { + modalApi.unlock(); + } return; } + + const { valid } = await outboundFormApi.validate(); + if (!valid || !validateDetails()) return; + const values = await outboundFormApi.getValues(); modalApi.lock(); - // 提交表单 - const data = (await formApi.getValues()) as WmsIostorInvApi.IostorInv; try { - await (formData.value?.iostorinvId ? updateIostorInv(data) : createIostorInv(data)); - // 关闭并提示 + await createOutbound( + buildOutboundPayload( + { ...values, bizDate: dayjs(values.bizDate).valueOf() }, + details.value, + ), + ); await modalApi.close(); emit('success'); message.success($t('ui.actionMessage.operationSuccess')); @@ -53,30 +300,116 @@ const [Modal, modalApi] = useVbenModal({ modalApi.unlock(); } }, - async onOpenChange(isOpen: boolean) { - if (!isOpen) { + async onOpenChange(open) { + const token = ++mainOpenToken; + mainModalOpen = open; + if (!open) { + activeEditId = undefined; formData.value = undefined; + details.value = []; + currentStorId.value = undefined; return; } - // 加载数据 + // 新会话不继承上一编辑详情请求留下的锁状态。 + modalApi.unlock(); const data = modalApi.getData(); - if (!data || !data.iostorinvId) { + if (!data?.iostorinvId) { + activeEditId = undefined; + formData.value = undefined; + details.value = []; + currentStorId.value = undefined; + await outboundFormApi.resetForm(); + if (!mainModalOpen || mainOpenToken !== token || activeEditId) return; + await outboundFormApi.setValues({ + billCode: '保存时自动生成', + billStatus: '生成', + bizDate: dayjs(), + detailCount: 0, + totalWeight: '0.000', + }); + if (!mainModalOpen || mainOpenToken !== token || activeEditId) return; + await loadBillTypeOptions(); return; } + const editId = data.iostorinvId; + activeEditId = editId; + formData.value = data; modalApi.lock(); try { - formData.value = await getIostorInv(data.iostorinvId); - // 设置到 values - await formApi.setValues(formData.value); + const editResult = await getIostorInv(editId); + if ( + !shouldApplyEditResult( + mainModalOpen, + mainOpenToken, + token, + activeEditId, + editId, + ) + ) { + return; + } + formData.value = editResult; + await legacyFormApi.setValues(editResult); } finally { - modalApi.unlock(); + if ( + shouldApplyEditResult( + mainModalOpen, + mainOpenToken, + token, + activeEditId, + editId, + ) + ) { + modalApi.unlock(); + } } }, }); - - + + + + + + 出库明细 + + 选择库存 + 新增汇总 + + + + + {{ index + 1 }} + + + + + + + + 删除 + + + + + + diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/inventory-select.vue b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/inventory-select.vue new file mode 100644 index 00000000..1faaa765 --- /dev/null +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/inventory-select.vue @@ -0,0 +1,185 @@ + + + + + + + diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/manual-detail.vue b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/manual-detail.vue new file mode 100644 index 00000000..b9da5bd1 --- /dev/null +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/manual-detail.vue @@ -0,0 +1,110 @@ + + + + + + 选择物料 + + + + + diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.test.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.test.ts new file mode 100644 index 00000000..0726b1cb --- /dev/null +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildOutboundPayload, + filterOutboundBillTypeOptions, + mergeOutboundInventory, + shouldApplyEditResult, + shouldClearDetailsForWarehouseChange, +} from './outbound-form'; + +describe('shouldApplyEditResult', () => { + it('只允许当前打开会话中同一编辑单据的响应落表', () => { + expect(shouldApplyEditResult(true, 2, 2, 'B', 'B')).toBe(true); + expect(shouldApplyEditResult(true, 2, 1, 'B', 'A')).toBe(false); + expect(shouldApplyEditResult(true, 2, 2, 'B', 'A')).toBe(false); + expect(shouldApplyEditResult(true, 2, 1, undefined, 'A')).toBe(false); + expect(shouldApplyEditResult(false, 2, 2, 'B', 'B')).toBe(false); + }); +}); + +describe('filterOutboundBillTypeOptions', () => { + it('从冷缓存接口结果中只提取启用的出库业务类型', () => { + expect( + filterOutboundBillTypeOptions([ + { dictType: 'in_bill_type', label: '采购入库', status: 0, value: '100' }, + { dictType: 'out_bill_type', label: '销售出库', status: 0, value: '201' }, + { dictType: 'out_bill_type', label: '停用出库', status: 1, value: '299' }, + ]), + ).toEqual([{ label: '销售出库', value: '201' }]); + }); +}); + +describe('buildOutboundPayload', () => { + it('将业务日期转换成毫秒,并剥离所有仅展示字段和汇总字段', () => { + const bizDate = new Date('2026-07-22T08:30:00+08:00'); + const payload = buildOutboundPayload( + { billType: 'SALE', bizDate, remark: '主备注', storId: 'S-1' }, + [ + { + groupId: 11, + materialCode: 'M-1', + materialId: '1', + materialName: '物料一', + pcsn: 'P-1', + planQty: '12.5', + qtyUnitId: 'kg', + qtyUnitName: '千克', + remark: '明细备注', + rowKey: 'inventory-11', + sapBatchNo: 'SAP-1', + sourceBillCode: 'SRC-1', + sourceBillType: 'SALE', + sourceBilldtlId: 'D-1', + vehicleCode: 'BOX-1', + }, + ], + ); + + expect(payload.bizDate).toBe(bizDate.valueOf()); + expect(payload.details).toEqual([ + { + groupId: 11, + materialCode: 'M-1', + materialId: '1', + pcsn: 'P-1', + planQty: 12.5, + qtyUnitId: 'kg', + qtyUnitName: '千克', + remark: '明细备注', + sourceBillCode: 'SRC-1', + sourceBillType: 'SALE', + sourceBilldtlId: 'D-1', + vehicleCode: 'BOX-1', + }, + ]); + expect(payload).not.toHaveProperty('detailCount'); + expect(payload).not.toHaveProperty('totalWeight'); + }); + + it('手工汇总行不附带库存引用字段', () => { + const payload = buildOutboundPayload( + { billType: 'SALE', bizDate: 1, storId: 'S-1' }, + [{ materialCode: 'M-1', planQty: 2 }], + ); + + expect(payload.details[0]?.groupId).toBeUndefined(); + expect(payload.details[0]?.vehicleCode).toBeUndefined(); + }); +}); + +describe('mergeOutboundInventory', () => { + it('按 groupId 去除已添加库存和同批重复库存', () => { + const existing = [{ groupId: 1, materialCode: 'OLD', planQty: 1 }]; + const incoming = [ + { groupId: '1', materialCode: 'DUP', planQty: 2 }, + { groupId: 2, materialCode: 'NEW', planQty: 3 }, + { groupId: '2', materialCode: 'NEW-DUP', planQty: 4 }, + ]; + + expect(mergeOutboundInventory(existing, incoming)).toEqual([ + existing[0], + incoming[1], + ]); + }); +}); + +describe('shouldClearDetailsForWarehouseChange', () => { + it('初始化和同值回填不清空,实际变化才清空', () => { + expect(shouldClearDetailsForWarehouseChange(undefined, 'S-1')).toBe(false); + expect(shouldClearDetailsForWarehouseChange('S-1', 'S-1')).toBe(false); + expect(shouldClearDetailsForWarehouseChange('S-1', 'S-2')).toBe(true); + expect(shouldClearDetailsForWarehouseChange('S-1', undefined)).toBe(true); + }); +}); diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.ts new file mode 100644 index 00000000..c9f5cfba --- /dev/null +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.ts @@ -0,0 +1,97 @@ +import type { WmsIostorInvApi } from '#/api/wms/iostorinv'; + +export interface OutboundDictItem { + dictType: string; + label: string; + status?: number; + value: string; +} + +export function shouldApplyEditResult( + modalOpen: boolean, + currentToken: number, + requestToken: number, + activeEditId?: string, + requestEditId?: string, +) { + return ( + modalOpen && + currentToken === requestToken && + Boolean(requestEditId) && + activeEditId === requestEditId + ); +} + +export interface OutboundFormValues { + billType?: string; + bizDate?: number | string | { valueOf: () => number }; + remark?: string; + storId?: string; +} + +export interface OutboundFormDetail + extends Omit { + groupId?: number | string; + planQty: number | string; + rowKey?: string; +} + +export function filterOutboundBillTypeOptions(items: OutboundDictItem[]) { + return items + .filter((item) => item.dictType === 'out_bill_type' && item.status !== 1) + .map(({ label, value }) => ({ label, value })); +} + +export function buildOutboundPayload( + values: OutboundFormValues, + details: OutboundFormDetail[], +): WmsIostorInvApi.OutboundCreateReq { + return { + billType: values.billType!, + bizDate: Number(values.bizDate?.valueOf()), + details: details.map((detail) => ({ + groupId: detail.groupId, + materialCode: detail.materialCode, + materialId: detail.materialId, + pcsn: detail.pcsn, + planQty: Number(detail.planQty), + qtyUnitId: detail.qtyUnitId, + qtyUnitName: detail.qtyUnitName, + remark: detail.remark, + sourceBillCode: detail.sourceBillCode, + sourceBillType: detail.sourceBillType, + sourceBilldtlId: detail.sourceBilldtlId, + vehicleCode: detail.vehicleCode, + })), + remark: values.remark || undefined, + storId: values.storId!, + }; +} + +/** 库存行跨多次选择时,仍按后端组盘记录标识去重。 */ +export function mergeOutboundInventory( + existing: T[], + incoming: T[], +) { + const result = [...existing]; + const groupIds = new Set( + existing + .map(({ groupId }) => groupId) + .filter((groupId) => groupId !== undefined) + .map(String), + ); + incoming.forEach((row) => { + if (row.groupId === undefined || !groupIds.has(String(row.groupId))) { + result.push(row); + if (row.groupId !== undefined) groupIds.add(String(row.groupId)); + } + }); + return result; +} + +export function shouldClearDetailsForWarehouseChange( + previousStorId?: string, + nextStorId?: string, +) { + return Boolean(previousStorId && previousStorId !== nextStorId); +} diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/packages/constants/src/dict-enum.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/packages/constants/src/dict-enum.ts index 14f2124e..0e79ff8a 100644 --- a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/packages/constants/src/dict-enum.ts +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/packages/constants/src/dict-enum.ts @@ -287,6 +287,9 @@ const WMS_DICT = { WMS_GROUP_PLATE_STATUS: 'wms_group_plate_status', // 组盘记录 WMS_VEHICLE_TYPE: 'wms_vehicle_type', // 载具类型 WMS_OVER_STRUCT_TYPE: 'wms_over_struct_type', // 是否超限 + WMS_OUT_BILL_TYPE: 'wms_out_bill_type', // 出库单据类型 + WMS_IO_BILL_STATUS: 'wms_io_bill_status', // 出入库单据状态 + WMS_CREATE_TYPE: 'wms_create_type', // 生成方式 } as const; /** ========== TASK - 任务管理模块 ========== */