云
3 天以前 5d818f39c862884b7087488d588fef5b7ce47386
feat(mes): 增加销售退货单与出库单关联功能

- 添加销售退货单关联销售出库单的字段设计(productSalesId, productSalesCode等)
- 实现出库单与退货行的绑定验证逻辑及可退数量校验
- 增加可退销售出库单查询接口和退货行列表获取功能
- 添加相关的错误码常量定义用于退货业务校验
- 重构质检流程移除OQC检验集成优化销售出库流程
- 完善合格库库区库位自动分配和校验机制
- 增加退货明细批次一致性校验确保数据完整性
已添加5个文件
已修改19个文件
1077 ■■■■■ 文件已修改
docs/sale_return_from_outbound_and_no_qc.md 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sale_return_from_outbound_frontend.md 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261028_return_sales_from_outbound.sql 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/MesWmReturnSalesController.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/MesWmReturnSalesRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/MesWmReturnSalesSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/MesWmReturnableProductSalesLineRespVO.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/MesWmReturnableProductSalesRespVO.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/line/MesWmReturnSalesLineRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/line/MesWmReturnSalesLineSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/returnsales/MesWmReturnSalesDO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/returnsales/MesWmReturnSalesLineDO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesMapper.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/returnsales/MesWmReturnSalesLineMapper.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityServiceImpl.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImpl.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesDetailServiceImpl.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineServiceImpl.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesService.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesServiceImpl.java 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/salesnotice/MesWmSalesNoticeServiceImpl.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sale_return_from_outbound_and_no_qc.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,185 @@
# é”€å”®é€€è´§æ”¹é€ ï¼šæŒ‰é”€å”®å‡ºåº“单退货 + å‡ºåº“流通去质检 + æ‰¹æ¬¡ä¿çœŸ
## ä¸€ã€éœ€æ±‚
| ç¼–号 | éœ€æ±‚ | æ¥æº |
|------|------|------|
| E | é”€å”®é€€è´§å•改为「选择销售出库单后退货」,物料、批次、数量上限由出库单带入 | ç”¨æˆ· |
| F | å‡ºåº“流通管理(销售仓库:发货通知 / é”€å”®å‡ºåº“ / é”€å”®é€€è´§ï¼‰ä¸éœ€è¦è´¨æ£€ | ç”¨æˆ· |
| G | é€€è´§å®ŒæˆåŽå…¥åº“,批次号丢失 | ç”¨æˆ·åé¦ˆ |
三张单据的菜单归属(`system_menu`):
- ä»“储物流(5780) > é”€å”®ä»“库(602479, path `sale`)
  - å‘货通知(5867, `wls/salesnotice/index`)
  - é”€å”®å‡ºåº“(5970, `wls/productsales/index`)
  - é”€å”®é€€è´§ï¼ˆ5847, `wls/returnsales/index`)
## äºŒã€çŽ°çŠ¶ä¸Žæ ¹å› 
### 2.1 é€€è´§å•与出库单无关联
- `mes_wm_return_sales` åªæœ‰ `sale_order_id` / `sale_order_code`(ERP é”€å”®è®¢å•)和 `after_sale_return_id`(售后退货申请),**没有销售出库单关联字段**。
- `mes_wm_return_sales_line` åªæœ‰ `item_id` / `batch_id` / `batch_code`,**没有出库单行关联字段**。
- é€€è´§è¡Œç”±å‰ç«¯æ‰‹å·¥é€‰æ‹©ç‰©æ–™ã€æ‰‹å·¥å¡«æ•°é‡ï¼Œä¸ä¸Žä»»ä½•出库数据比对,所以可能选出未出库过的物料。
远端库 `mom-thzynew` å®žé™…数据(退货单 1 / é”€å”®å‡ºåº“单 5):
| å•据 | ç‰©æ–™ | æ‰¹æ¬¡ | æ•°é‡ |
|------|------|------|------|
| é”€å”®å‡ºåº“单 5080580566370931 | 114 | 4846066734735277 | 1 |
| é”€å”®é€€è´§å• 0325446663418652(已完成) | **116** | **空** | 1 |
退货行选的是另一个物料,且没有任何批次——这正是需求 E å’Œ G çš„现象来源。
### 2.2 æ‰¹æ¬¡ä¸ºä»€ä¹ˆä¸¢
1. **退货行表单把批次选择禁用了**:`mom-pro2-before/src/views/wls/returnsales/data.ts` ä¸­ `WmBatchSelect` å›ºå®š `disabled: true`,用户根本无法选批次,`batchId` æ°¸è¿œä¸ºç©ºã€‚
2. **上架明细的批次是手填文本框**:`useDetailFormSchema` åªæœ‰ `batchCode` è¾“入框,且不会从退货行带入;用户不填就为空。
3. **后端不兜底**:`MesWmReturnSalesDetailServiceImpl.fillBatchId` ä»…在 `batchCode` éžç©ºæ—¶åæŸ¥ `batchId`,为空则跳过;`stockReturnSales` ç›´æŽ¥æŠŠæ˜Žç»†çš„ `batchId/batchCode` å†™å…¥åº“存事务,于是库存和流水都没有批次。
4. ç‰©æ–™ 116 çš„ `is_batch_managed = 0`,所以现有「批次管理物料必须选批次」的校验没有拦住。
结果:库存事务 127(`biz_type=120`)`batch_id = NULL`、`batch_code = ''`,库存现有量里也就没有批次。
### 2.3 çŽ°æœ‰è´¨æ£€é“¾è·¯
- é€€è´§ï¼šè¡Œå­—段 `rqc_check_flag` / `rqc_id` / `quality_status`;提交时存在待检行 â†’ çŠ¶æ€ 1(待检验),由 RQC æ£€éªŒå®Œå›žå†™å¹¶æ‹†åˆ†è¡Œï¼ˆ`updateReturnSalesLineWhenRqcFinish`),全部检完再进 2(待执行)。
- é”€å”®å‡ºåº“:行字段 `oqc_check_flag` / `oqc_id` / `quality_status`;提交时按行创建 OQC(`createOqcFromProductSales`),有 OQC è¡Œ â†’ 1(待检测),检完进 2(待拣货);OQC ä¸åˆæ ¼ä¼šç›´æŽ¥å–消出库单。
- å‘货通知:行字段 `oqc_check_flag`,创建出库单时复制到出库行。
## ä¸‰ã€æ–¹æ¡ˆ
### 3.1 éœ€æ±‚ E:按销售出库单退货
#### 3.1.1 æ•°æ®æ¨¡åž‹
主表增加出库单关联(`product_sales_id` å‘½åä¸Žè¡¨å `mes_wm_product_sales` å¯¹é½ï¼Œé¿å…ä¸Ž `sale_order_id` æ··æ·†ï¼‰ï¼š
```sql
ALTER TABLE mes_wm_return_sales
    ADD COLUMN product_sales_id BIGINT NULL COMMENT '销售出库单编号' AFTER sale_order_id,
    ADD COLUMN product_sales_code VARCHAR(100) NULL COMMENT '销售出库单号(冗余)' AFTER product_sales_id;
ALTER TABLE mes_wm_return_sales_line
    ADD COLUMN product_sales_line_id BIGINT NULL COMMENT '销售出库单行编号' AFTER return_id;
CREATE INDEX idx_return_sales_product_sales ON mes_wm_return_sales(product_sales_id);
CREATE INDEX idx_return_sales_line_product_sales_line ON mes_wm_return_sales_line(product_sales_line_id);
```
- `product_sales_id` å…è®¸ä¸ºç©ºï¼šå”®åŽæ¨¡å—通过 RPC(`MesWmReturnSalesApi.createReturnSales`)创建的退货单没有出库单上下文,必须继续可用。
- ç®¡ç†åŽå°æ‰‹å·¥åˆ›å»ºé€€è´§å•æ—¶**必须选择一个已完成状态的销售出库单**(校验在后端做)。
#### 3.1.2 å¯é€€æ•°é‡å£å¾„
```
可退数量 = å‡ºåº“行数量 âˆ’ Î£(该出库行已关联的有效退货行数量)
有效退货行 = æ‰€å±žé€€è´§å•未删除 ä¸” çŠ¶æ€ â‰  å·²å–消(5)
```
注意:RQC æ‹†åˆ†äº§ç”Ÿçš„行也计入,所以口径天然覆盖「部分合格、部分不合格」的历史场景(去质检后不再产生拆分)。
#### 3.1.3 æ–°å¢žæŽ¥å£
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/wm/return-sales/returnable-product-sales/page` | å¯é€€é”€å”®å‡ºåº“单分页,仅返回状态=已完成(4) ä¸”存在可退行>0 çš„单据 |
| GET | `/mes/wm/return-sales/returnable-product-sales/line-list` | æŒ‡å®šå‡ºåº“单的可退行列表(入参 `salesId`) |
可退行返回字段:出库行 ID、物料编码/名称/规格/单位、批次号、出库数量、已退数量、可退数量。
权限沿用 `mes:wm-return-sales:query`。
#### 3.1.4 ä¿å­˜æ ¡éªŒ
创建/修改退货单时:
1. `product_sales_id` éžç©º â†’ å‡ºåº“单必须存在、`deleted=0`、状态 = å·²å®Œæˆ(4)。
2. é€€è´§å• `client_id` å¿…须等于出库单 `client_id`(前端带入后置为只读,后端强制校验)。
3. è¡Œ `product_sales_line_id` éžç©º â†’ è¯¥è¡Œå¿…须属于所选出库单。
4. è¡Œ `item_id` / `batch_id` å¿…须与出库行一致,数量 `> 0` ä¸” `≤ å¯é€€æ•°é‡`(计算时排除当前退货单自身的行,避免编辑时自我占用)。
5. `product_sales_line_id` ä¸ºç©ºçš„行(仅售后单据)沿用现有手工校验。
提交时再整体复核一次可退数量,防止并发单据超退。
#### 3.1.5 å‰ç«¯äº¤äº’(不改前端,仅说明)
- è¡¨å¤´ã€Œé”€å”®å‡ºåº“单」选择框:打开可退单分页弹窗;选中后回填出库单号,并把客户置为只读带入。
- ç‰©æ–™è¡Œæ”¹ä¸ºã€Œä»Žå‡ºåº“行选择」:弹窗列出可退行,勾选后带入物料、批次、`quantity` é»˜è®¤å¡«å¯é€€æ•°é‡ï¼›è¡¨æ ¼å¢žåŠ ã€Œå·²é€€æ•°é‡ / å¯é€€æ•°é‡ã€ä¸¤åˆ—。
- å…è®¸éƒ¨åˆ†é€€è´§ã€å…è®¸åŒä¸€å‡ºåº“行分多次退货(跨单据)。
- åˆ‡æ¢å‡ºåº“单时清空已添加的行。
### 3.2 éœ€æ±‚ F:出库流通去质检
范围建议覆盖销售仓库三张单据(发货通知 / é”€å”®å‡ºåº“ / é”€å”®é€€è´§ï¼‰ï¼Œä¸Žã€Œå‡ºåº“流通管理」菜单口径一致。
| å•据 | åŽ»æŽ‰çš„å†…å®¹ | çŠ¶æ€æµè½¬å˜åŒ– |
|------|-----------|-------------|
| å‘货通知 | è¡Œã€Œæ˜¯å¦æ£€éªŒã€å­—段与列 | æ— ï¼ˆåŽŸæœ¬ä¸å› è´¨æ£€æ”¹çŠ¶æ€ï¼‰ |
| é”€å”®å‡ºåº“ | è¡Œã€Œæ˜¯å¦æ£€éªŒ / å‡ºåŽ‚æ£€éªŒå• / è´¨é‡çŠ¶æ€ã€ã€æäº¤æ—¶è‡ªåŠ¨åˆ›å»º OQC、OQC å›žå†™ä¸Žä¸åˆæ ¼å–消逻辑 | æäº¤ï¼š0 è‰ç¨¿ â†’ 2 å¾…拣货(不再经过 1 å¾…检测) |
| é”€å”®é€€è´§ | è¡Œã€Œæ˜¯å¦éœ€è¦è´¨æ£€ / é€€è´§æ£€éªŒå• / è´¨é‡çŠ¶æ€ã€ã€RQC å›žå†™ä¸Žæ‹†åˆ†è¡Œã€åˆ—表「执行质检」按钮 | æäº¤ï¼š0 è‰ç¨¿ â†’ 2 å¾…执行(不再经过 1 å¾…检验) |
处理原则:
- **数据库列保留不删**,标注废弃,避免历史单据、报表和外部接口直接报错;如后续确认彻底清理,再单独出脚本。
- åŽ†å²å•æ®ä¸­å·²å¤„äºŽ 1(待检验/待检测)的单据:保留状态枚举与页面展示,手工「执行质检」入口移除后需要人工把在途单据推进或取消(上线前盘点一次)。
- å·²äº§ç”Ÿçš„ OQC/RQC æ£€éªŒå•不删除,作为历史留档;不再新建。
- `MesQcSourceDocTypeEnum` ä¸­çš„ `PRODUCT_SALES` / `RETURN_SALES` åˆ†æ”¯ä¿ç•™ï¼ˆåŽ†å²æ£€éªŒå•è¯¦æƒ…ä»è¦èƒ½æ‰“å¼€æ¥æºå•æ®ï¼‰ï¼Œä½†ä¸å†æœ‰æ–°æ•°æ®äº§ç”Ÿã€‚
### 3.3 éœ€æ±‚ G:批次保真
1. **退货行允许选批次**:行表单去掉 `disabled: true`,按物料过滤 `WmBatchSelect`;选中后用批次 `code` å›žå¡« `batchCode`。
2. **后端补全批次号**:行保存时若 `batchId` éžç©ºã€`batchCode` ä¸ºç©ºï¼ŒæŒ‰ `batchId` æŸ¥ `mes_wm_batch.code` å›žå¡«ï¼Œä¿è¯è¡Œä¸Šä¸¤è€…一致。
3. **上架明细继承行的批次**:明细表单的批次改为只读展示;后端保存明细时**忽略前端传入的批次**,强制 `detail.batchId/batchCode = line.batchId/batchCode`,从根上杜绝丢失。
4. **执行上架前置校验**:生成库存事务前校验每条明细的批次与所属行一致(批次管理物料必须非空),不一致直接报错并指出物料。
5. **历史数据**:退货单 1 çš„物料本身选错(出库是 114,退货是 116),建议该单据取消后按新流程重做;如确需保留,需人工把行/明细改回物料 114 + æ‰¹æ¬¡ 4846066734735277 å¹¶é‡ç®—库存。是否修正由业务确认。
## å››ã€æ”¹åŠ¨æ¸…å•
### 4.1 åŽç«¯ï¼ˆyudao-module-mes)
| æ–‡ä»¶ | æ”¹åЍ |
|------|------|
| `dal/dataobject/wm/returnsales/MesWmReturnSalesDO` | å¢žåŠ  `productSalesId` / `productSalesCode` |
| `dal/dataobject/wm/returnsales/MesWmReturnSalesLineDO` | å¢žåŠ  `productSalesLineId` |
| `dal/mysql/wm/returnsales/MesWmReturnSalesLineMapper` | å¢žåŠ æŒ‰å‡ºåº“è¡Œç»Ÿè®¡å·²é€€æ•°é‡çš„æ–¹æ³•ã€æŒ‰å‡ºåº“è¡ŒæŸ¥è¯¢é€€è´§è¡Œçš„æ–¹æ³• |
| `dal/mysql/wm/productsales/MesWmProductSalesLineMapper` | å¢žåŠ  `selectListBySalesIds`(批量) |
| `controller/admin/wm/returnsales/vo/MesWmReturnSalesSaveReqVO` ç­‰ | å¢žåŠ å‡ºåº“å•/出库行字段 |
| `controller/admin/wm/returnsales/MesWmReturnSalesController` | æ–°å¢žå¯é€€å•分页、可退行列表两个接口 |
| `controller/admin/wm/returnsales/vo/...`(新增) | å¯é€€å• RespVO、可退行 RespVO |
| `service/wm/returnsales/MesWmReturnSalesService(Impl)` | å‡ºåº“单校验、可退分页、提交时数量复核、去掉质检分支 |
| `service/wm/returnsales/MesWmReturnSalesLineService(Impl)` | å‡ºåº“行一致性校验、可退数量校验、批次号补全、删除 RQC å›žå†™æ–¹æ³• |
| `service/wm/returnsales/MesWmReturnSalesDetailService(Impl)` | æ˜Žç»†æ‰¹æ¬¡å¼ºåˆ¶ç»§æ‰¿è¡Œã€ä¸Šæž¶å‰æ‰¹æ¬¡æ ¡éªŒ |
| `service/wm/productsales/MesWmProductSalesService(Impl)` / `LineServiceImpl` | åŽ»æŽ‰ OQC åˆ›å»ºä¸Žå›žå†™ï¼Œæäº¤ç›´æŽ¥è¿›å¾…拣货 |
| `service/qc/rqc/MesQcRqcServiceImpl` | ä¿ç•™æ¥æºå•据类型分支,不再为退货单创建 RQC |
| `enums/wm/MesWmReturnSalesStatusEnum` / `MesWmProductSalesStatusEnum` | ä¿ç•™ 1(待检验/待检测)仅作历史兼容,注释标注废弃 |
| `sql/mysql/20261028_return_sales_from_outbound.sql` | ä¸Šè¿° DDL |
### 4.2 å‰ç«¯ï¼ˆmom-pro2-before,待授权后修改)
| æ–‡ä»¶ | æ”¹åЍ |
|------|------|
| `views/wls/returnsales/data.ts` | è¡¨å¤´åŠ å‡ºåº“å•é€‰æ‹©ï¼›è¡Œè¡¨å•æ”¹ä¸ºå‡ºåº“è¡Œé€‰æ‹©ã€æ‰¹æ¬¡è§£ç¦ï¼›åŽ»æŽ‰è´¨æ£€åˆ—ä¸Žå¼€å…³ï¼›è¡¨æ ¼åŠ å·²é€€/可退列 |
| `views/wls/returnsales/index.vue` | åŽ»æŽ‰ã€Œæ‰§è¡Œè´¨æ£€ã€æŒ‰é’®ä¸Žæç¤ºï¼Œå–æ¶ˆæŒ‰é’®çŠ¶æ€èŒƒå›´è°ƒæ•´ |
| `views/wls/returnsales/modules/line-list.vue` | æ·»åŠ ç‰©æ–™æ”¹ä¸ºä»Žå‡ºåº“è¡Œé€‰æ‹©ï¼ˆæˆ–ä¿ç•™æ‰‹å·¥ + å‡ºåº“行下拉) |
| `views/wls/returnsales/modules/detail-form.vue` | æ‰¹æ¬¡åªè¯»ç»§æ‰¿ |
| `views/wls/productsales/data.ts`、`views/wls/salesnotice/data.ts` åŠå¯¹åº”子组件 | åŽ»æŽ‰è´¨æ£€åˆ—/开关 |
| `api/mes/wm/returnsales/*` | å¢žåŠ å¯é€€å•/可退行接口,字段同步 |
## äº”、实施顺序
1. DDL + DO/VO å­—段(可与现有逻辑并存,不破坏线上)。
2. å¯é€€å•分页、可退行列表接口(只读,先让前端能联调)。
3. é€€è´§å•保存/提交校验(出库单、行、数量、批次)。
4. ä¸Šæž¶æ˜Žç»†æ‰¹æ¬¡ç»§æ‰¿ + å…¥åº“前校验(修复批次丢失)。
5. åŽ»è´¨æ£€ï¼šå…ˆå…³æŽ‰æ–°å•æ®çš„è´¨æ£€å…¥å£ä¸ŽçŠ¶æ€åˆ†æ”¯ï¼Œå†æ¸…ç†åŽ†å²åœ¨é€”å•æ®ã€‚
6. å‰ç«¯æ”¹é€ ä¸Žè”调。
## å…­ã€å¾…确认
1. **去质检范围**:是否确认同时去掉「销售出库 OQC」和「发货通知是否检验」,还是只去掉销售退货 RQC?
2. **出库单状态范围**:是否只允许对「已完成(4)」的销售出库单退货?是否也允许「待执行出库(3)」预登记?
3. **退货粒度**:按出库行(物料+批次+数量)即可,还是需要按出库明细(含袋码、库区库位)逐件退回?
4. **数据库质检列**:保留废弃(建议)还是删除?
5. **退货单 1 æ•°æ®**:取消重做,还是人工修正物料与批次?
6. å”®åŽæ¨¡å—通过 RPC åˆ›å»ºçš„退货单是否允许没有出库单关联(建议允许,否则售后流程会被阻断)?
docs/sale_return_from_outbound_frontend.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,240 @@
# é”€å”®é€€è´§æŒ‰å‡ºåº“单退货 + é”€å”®ä»“库去质检 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- é”€å”®ä»“库 / é”€å”®é€€è´§ï¼šåˆ—表、新增/编辑、详情、上架明细弹窗
- é”€å”®ä»“库 / é”€å”®å‡ºåº“:列表、新增/编辑、详情
- é”€å”®ä»“库 / å‘货通知:列表、新增/编辑、详情
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. æ–°å¢žé€€è´§å•:选择**销售出库单**(仅已完成状态、存在可退行)→ å‡ºåº“单号、客户、可退行自动带入退货单。
2. å‡ºåº“单 -> é€€è´§è¡Œï¼šç‰©æ–™ã€æ‰¹æ¬¡ã€æ‰¹æ¬¡å·ã€å‡ºåº“数量、已退数量、可退数量均来自出库行,退货数量默认等于可退数量、上限为可退数量。
3. é€€è´§è¡Œ -> ä¸Šæž¶æ˜Žç»†ï¼šç‰©æ–™ã€æ‰¹æ¬¡ä¸å¯ç¼–辑,全部由退货行带入;只需填写仓库/库区/库位/数量。
4. æäº¤é€€è´§å•:不再经过质检,状态直接由「草稿」变为「待执行」。
5. æ‰§è¡Œé€€è´§ -> æ‰§è¡Œä¸Šæž¶ï¼šä¸Šæž¶æ˜Žç»†çš„æ‰¹æ¬¡ä¸Žé€€è´§è¡Œæ‰¹æ¬¡ä¿æŒä¸€è‡´ï¼Œå…¥åº“流水携带批次号。
## API
### 1. å¯é€€é”€å”®å‡ºåº“单分页(新增)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/wm/return-sales/returnable-product-sales-page` | ä»…返回已完成且存在可退行(出库数量 > å·²é€€æ•°é‡ï¼‰çš„销售出库单 |
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| pageNo | Integer | æ˜¯ | é¡µç  |
| pageSize | Integer | æ˜¯ | æ¯é¡µæ¡æ•° |
| code | String | å¦ | å‡ºåº“单号(模糊) |
| name | String | å¦ | å‡ºåº“单名称(模糊) |
| clientId | Long | å¦ | å®¢æˆ· ID |
| shipmentDate | Date[] | å¦ | å‡ºåº“日期范围 |
| outboundTime | DateTime[] | å¦ | å®žé™…出库时间范围 |
**响应字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | Long | é”€å”®å‡ºåº“单 ID |
| code | String | å‡ºåº“单号 |
| name | String | å‡ºåº“单名称 |
| clientId | Long | å®¢æˆ· ID |
| clientName | String | å®¢æˆ·åç§° |
| salesDate | DateTime | å‡ºåº“日期 |
| outboundTime | DateTime | å®žé™…出库时间 |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "list": [
      {
        "id": 1,
        "code": "PS2026030001",
        "name": "产品出库单",
        "clientId": 10,
        "clientName": "XX客户",
        "salesDate": "2026-03-01 00:00:00",
        "outboundTime": "2026-03-02 10:00:00"
      }
    ],
    "total": 1
  }
}
```
### 2. é”€å”®å‡ºåº“单可退行列表(新增)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/wm/return-sales/returnable-product-sales-line-list` | è¿”回指定出库单中可退数量大于 0 çš„行 |
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| productSalesId | Long | æ˜¯ | é”€å”®å‡ºåº“单 ID |
**响应字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| productSalesLineId | Long | é”€å”®å‡ºåº“单行 ID(退货行必须携带) |
| salesId | Long | é”€å”®å‡ºåº“单 ID |
| itemId | Long | ç‰©æ–™ ID |
| itemCode | String | ç‰©æ–™ç¼–码 |
| itemName | String | ç‰©æ–™åç§° |
| itemSpecification | String | è§„格型号 |
| itemUnit | String | è®¡é‡å•位 |
| batchId | Long | æ‰¹æ¬¡ ID |
| batchCode | String | æ‰¹æ¬¡å· |
| outboundQuantity | BigDecimal | å‡ºåº“数量 |
| returnedQuantity | BigDecimal | å·²é€€æ•°é‡ï¼ˆä¸å«å·²å–消退货单) |
| returnableQuantity | BigDecimal | å¯é€€æ•°é‡ = å‡ºåº“数量 - å·²é€€æ•°é‡ |
**响应示例:**
```json
{
  "code": 0,
  "data": [
    {
      "productSalesLineId": 21,
      "salesId": 1,
      "itemId": 114,
      "itemCode": "M001",
      "itemName": "物料A",
      "itemSpecification": "100*200",
      "itemUnit": "个",
      "batchId": 4846066734735277,
      "batchCode": "4846066734735277",
      "outboundQuantity": 100.00,
      "returnedQuantity": 0.00,
      "returnableQuantity": 100.00
    }
  ]
}
```
### 3. é€€è´§å•保存(字段新增)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | `/mes/wm/return-sales/create` | åˆ›å»ºé€€è´§å• |
| PUT | `/mes/wm/return-sales/update` | ä¿®æ”¹é€€è´§å• |
**请求参数新增:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| productSalesId | Long | å¦ | é”€å”®å‡ºåº“单 ID;传了则必须从该出库单的可退行中选择退货行 |
`productSalesCode` ç”±åŽç«¯æ ¹æ®å‡ºåº“单回填,前端无需传。
**响应字段新增(详情/列表):**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| productSalesId | Long | é”€å”®å‡ºåº“单 ID |
| productSalesCode | String | é”€å”®å‡ºåº“单号 |
**注意:** é€€è´§å•已存在退货行时,不允许更换 `productSalesId`。
### 4. é€€è´§è¡Œä¿å­˜ï¼ˆå­—段新增/变更)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | `/mes/wm/return-sales-line/create` | åˆ›å»ºé€€è´§è¡Œ |
| PUT | `/mes/wm/return-sales-line/update` | ä¿®æ”¹é€€è´§è¡Œ |
**请求参数新增:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| productSalesLineId | Long | æ¡ä»¶å¿…å¡« | é€€è´§å•关联了出库单时必填 |
**请求参数移除:**
| å‚æ•° | è¯´æ˜Ž |
|------|------|
| rqcCheckFlag | é”€å”®ä»“库不再质检,退货行不再需要该字段 |
**字段带入规则:**
| å­—段 | è§„则 |
|------|------|
| itemId | åŽç«¯ä»¥å‡ºåº“行的物料为准,前端传值会被覆盖 |
| batchId / batchCode | åŽç«¯ä»¥å‡ºåº“行的批次为准,前端传值会被覆盖 |
| quantity | å¿…须大于 0 ä¸”不超过可退数量 |
### 5. é€€è´§å•提交(行为变更)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| PUT | `/mes/wm/return-sales/submit` | æäº¤åŽçŠ¶æ€ç›´æŽ¥å˜ä¸ºã€Œå¾…æ‰§è¡Œã€ï¼Œä¸å†ç»è¿‡ã€Œå¾…æ£€éªŒã€ |
**校验:** æäº¤æ—¶ä¼šé‡æ–°æ ¡éªŒé€€è´§è¡Œä¸Žå‡ºåº“单的归属关系,以及各行数量之和不超过可退数量;草稿期间被其它退货单占用时会提示「退货数量不能超过可退数量」。
### 6. ä¸Šæž¶æ˜Žç»†ä¿å­˜ï¼ˆè¡Œä¸ºå˜æ›´ï¼‰
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | `/mes/wm/return-sales-detail/create` | ä¸Šæž¶æ˜Žç»† |
| PUT | `/mes/wm/return-sales-detail/update` | ä¸Šæž¶æ˜Žç»† |
**规则变更:**
| å­—段 | è§„则 |
|------|------|
| itemId | ä»¥é€€è´§è¡Œä¸ºå‡†ï¼Œå‰ç«¯ä¼ å€¼ä¼šè¢«è¦†ç›– |
| batchId / batchCode | ä»¥é€€è´§è¡Œä¸ºå‡†ï¼›ä¼ å…¥ä¸Žè¡Œä¸ä¸€è‡´çš„æ‰¹æ¬¡ä¼šæŠ¥é”™ã€Œä¸Šæž¶æ˜Žç»†çš„æ‰¹æ¬¡å¿…须与退货行批次一致」 |
| quantity | ä¸è¶…过退货行数量 |
上架明细表单中的批次应改为只读展示,不再提供批次选择/手工输入。
### 7. é”€å”®å‡ºåº“ / å‘货通知(质检相关接口保留但不再使用)
| åœºæ™¯ | å˜æ›´ |
|------|------|
| é”€å”®å‡ºåº“提交 | ç›´æŽ¥ç”±ã€Œè‰ç¨¿ã€å˜ä¸ºã€Œå¾…拣货」,不再生成 OQC æ£€éªŒå• |
| é”€å”®å‡ºåº“行编辑 | ä¸å†è‡ªåŠ¨åˆ›å»º OQC æ£€éªŒå• |
| å‘货通知生成销售出库 | ä¸å†å¤åˆ¶è´¨æ£€æ ‡è¯†åˆ°å‡ºåº“行 |
| é€€è´§å•提交 | ä¸å†è¿›å…¥ã€Œå¾…检验」,不再生成 RQC æ£€éªŒå• |
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| é”€å”®å‡ºåº“单号 | é€€è´§å•列表、详情 | æ–°å¢žåˆ—,展示 `productSalesCode` |
| å‡ºåº“单选择 | æ–°å¢ž/编辑退货单 | å¿…填(新流程),只读展示选中出库单 |
| å¯é€€è¡Œé€‰æ‹© | æ–°å¢ž/编辑退货单 | ç‚¹å‡»ã€Œé€‰æ‹©å‡ºåº“行」打开可退行弹窗 |
| å‡ºåº“数量 | å¯é€€è¡Œå¼¹çª— | æ¥è‡ª `outboundQuantity` |
| å·²é€€æ•°é‡ | å¯é€€è¡Œå¼¹çª— | æ¥è‡ª `returnedQuantity` |
| å¯é€€æ•°é‡ | å¯é€€è¡Œå¼¹çª—、退货行 | æ¥è‡ª `returnableQuantity`,退货数量不能超过该值 |
| æ‰¹æ¬¡å· | é€€è´§è¡Œã€ä¸Šæž¶æ˜Žç»† | åªè¯»ï¼Œæ¥è‡ªå‡ºåº“行 |
| è´¨æ£€çŠ¶æ€ | é€€è´§å•、销售出库列表 | ä¸å†å±•示质检状态列 |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| å¯é€‰å‡ºåº“单范围 | ä»…「已完成」状态,且至少有一行可退数量大于 0 |
| å¯é€€æ•°é‡ | å‡ºåº“行数量 - è¯¥è¡Œæ‰€æœ‰æœªå–消退货单的退货数量之和 |
| é€€è´§è¡Œæ¥æº | å…³è”了出库单的退货单,所有行必须来自该出库单 |
| é‡å¤é€€è´§ | å¤šæ¬¡é€€è´§ç´¯è®¡ä¸è¶…过出库数量 |
| æ‰¹æ¬¡æµè½¬ | å‡ºåº“行批次 â†’ é€€è´§è¡Œæ‰¹æ¬¡ â†’ ä¸Šæž¶æ˜Žç»†æ‰¹æ¬¡ â†’ å…¥åº“流水批次,全程一致 |
| çŠ¶æ€æµè½¬ | è‰ç¨¿(0) â†’ å¾…执行(2) â†’ å¾…上架(3) â†’ å·²å®Œæˆ(4);取消(5) |
| å”®åŽæ¥æºé€€è´§å• | å”®åŽæ¨¡å—创建的退货单没有出库单上下文,`productSalesId` ä¸ºç©ºï¼Œä»èµ°åŽŸæµç¨‹ |
## æ³¨æ„äº‹é¡¹
- æ–°å¢žé€€è´§å•时若未选择销售出库单,后端不会强制要求 `productSalesLineId`,但前端新流程应引导用户先选出库单。
- å¯é€€è¡Œå¼¹çª—数据需在每次打开时重新拉取,避免用户停留期间被其它退货单占用导致提交失败。
- é€€è´§æ•°é‡é»˜è®¤å¸¦å…¥å¯é€€æ•°é‡ï¼Œç”¨æˆ·å¯å°äºŽè¯¥å€¼ï¼›è¶…过会在保存和提交时各校验一次。
- ä¸Šæž¶æ˜Žç»†çš„æ‰¹æ¬¡å­—段必须改为只读,避免用户手工填写导致批次校验失败。
- é”€å”®å‡ºåº“、发货通知页面的质检开关、质检状态列、质检单入口需要一并移除,否则会产生永远无法推进的「待检测」状态入口。
sql/mysql/20261028_return_sales_from_outbound.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
-- é”€å”®é€€è´§æ”¹é€ ï¼šé€€è´§å•关联销售出库单(按出库行退货)
-- è¯´æ˜Žï¼šproduct_sales_id å…è®¸ä¸ºç©ºï¼Œå…¼å®¹å”®åŽæ¨¡å—通过 RPC åˆ›å»ºçš„退货单
ALTER TABLE mes_wm_return_sales
    ADD COLUMN product_sales_id BIGINT NULL COMMENT '销售出库单编号' AFTER sale_order_id,
    ADD COLUMN product_sales_code VARCHAR(100) NULL COMMENT '销售出库单号(冗余)' AFTER product_sales_id;
ALTER TABLE mes_wm_return_sales_line
    ADD COLUMN product_sales_line_id BIGINT NULL COMMENT '销售出库单行编号' AFTER return_id;
CREATE INDEX idx_return_sales_product_sales ON mes_wm_return_sales (product_sales_id);
CREATE INDEX idx_return_sales_line_product_sales_line ON mes_wm_return_sales_line (product_sales_line_id);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/MesWmReturnSalesController.java
@@ -10,9 +10,13 @@
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.MesWmProductSalesPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnableProductSalesLineRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnableProductSalesRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import cn.iocoder.yudao.module.mes.service.wm.returnsales.MesWmReturnSalesService;
import io.swagger.v3.oas.annotations.Operation;
@@ -138,6 +142,25 @@
        return success(true);
    }
    @GetMapping("/returnable-product-sales-page")
    @Operation(summary = "获得可退销售出库单分页", description = "仅已完成状态,且存在可退数量大于 0 çš„行")
    @PreAuthorize("@ss.hasPermission('mes:wm-return-sales:query')")
    public CommonResult<PageResult<MesWmReturnableProductSalesRespVO>> getReturnableProductSalesPage(
            @Valid MesWmProductSalesPageReqVO pageReqVO) {
        PageResult<MesWmProductSalesDO> pageResult = returnSalesService.getReturnableProductSalesPage(pageReqVO);
        List<MesWmReturnableProductSalesRespVO> list = buildReturnableProductSalesRespVOList(pageResult.getList());
        return success(new PageResult<>(list, pageResult.getTotal()));
    }
    @GetMapping("/returnable-product-sales-line-list")
    @Operation(summary = "获得销售出库单可退行列表")
    @Parameter(name = "productSalesId", description = "销售出库单编号", required = true)
    @PreAuthorize("@ss.hasPermission('mes:wm-return-sales:query')")
    public CommonResult<List<MesWmReturnableProductSalesLineRespVO>> getReturnableProductSalesLineList(
            @RequestParam("productSalesId") Long productSalesId) {
        return success(returnSalesService.getReturnableProductSalesLineList(productSalesId));
    }
    @GetMapping("/check-quantity")
    @Operation(summary = "校验销售退货单数量", description = "校验每行明细数量之和是否等于行退货数量")
    @Parameter(name = "id", description = "编号", required = true)
@@ -161,4 +184,16 @@
                        vo.setClientName(customer.getName())));
    }
    private List<MesWmReturnableProductSalesRespVO> buildReturnableProductSalesRespVOList(
            List<MesWmProductSalesDO> list) {
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
        }
        Map<Long, CrmCustomerRespDTO> customerMap = crmCustomerApi.getCustomerMap(
                convertSet(list, MesWmProductSalesDO::getClientId));
        return BeanUtils.toBean(list, MesWmReturnableProductSalesRespVO.class, vo ->
                MapUtils.findAndThen(customerMap, vo.getClientId(), customer ->
                        vo.setClientName(customer.getName())));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/MesWmReturnSalesRespVO.java
@@ -30,6 +30,12 @@
    @Schema(description = "客户名称", example = "XX客户")
    private String clientName;
    @Schema(description = "销售出库单 ID", example = "1")
    private Long productSalesId;
    @Schema(description = "销售出库单号", example = "PS2026030001")
    private String productSalesCode;
    @Schema(description = "退货日期")
    private LocalDate returnDate;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/MesWmReturnSalesSaveReqVO.java
@@ -31,6 +31,12 @@
    @NotNull(message = "客户 ID ä¸èƒ½ä¸ºç©º")
    private Long clientId;
    @Schema(description = "销售出库单 ID", example = "1")
    private Long productSalesId;
    @Schema(description = "销售出库单号(后端根据出库单回填,前端无需传)", example = "PS2026030001")
    private String productSalesCode;
    @Schema(description = "退货日期", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "退货日期不能为空")
    private LocalDate returnDate;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/MesWmReturnableProductSalesLineRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
package cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
 * ç®¡ç†åŽå° - å¯é€€é”€å”®å‡ºåº“单行 Response VO
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - å¯é€€é”€å”®å‡ºåº“单行 Response VO")
@Data
public class MesWmReturnableProductSalesLineRespVO {
    @Schema(description = "销售出库单行 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long productSalesLineId;
    @Schema(description = "销售出库单 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long salesId;
    @Schema(description = "物料 ID", example = "1")
    private Long itemId;
    @Schema(description = "物料编码", example = "M001")
    private String itemCode;
    @Schema(description = "物料名称", example = "物料A")
    private String itemName;
    @Schema(description = "规格型号", example = "100*200")
    private String itemSpecification;
    @Schema(description = "计量单位", example = "个")
    private String itemUnit;
    @Schema(description = "批次 ID", example = "1")
    private Long batchId;
    @Schema(description = "批次号", example = "B20250101")
    private String batchCode;
    @Schema(description = "出库数量", example = "100.00")
    private BigDecimal outboundQuantity;
    @Schema(description = "已退数量", example = "10.00")
    private BigDecimal returnedQuantity;
    @Schema(description = "可退数量", example = "90.00")
    private BigDecimal returnableQuantity;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/MesWmReturnableProductSalesRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
 * ç®¡ç†åŽå° - å¯é€€é”€å”®å‡ºåº“单 Response VO
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - å¯é€€é”€å”®å‡ºåº“单 Response VO")
@Data
public class MesWmReturnableProductSalesRespVO {
    @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long id;
    @Schema(description = "出库单号", example = "PS2026030001")
    private String code;
    @Schema(description = "出库单名称", example = "产品出库单")
    private String name;
    @Schema(description = "客户 ID", example = "1")
    private Long clientId;
    @Schema(description = "客户名称", example = "XX客户")
    private String clientName;
    @Schema(description = "出库日期")
    private LocalDateTime salesDate;
    @Schema(description = "实际出库时间")
    private LocalDateTime outboundTime;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/line/MesWmReturnSalesLineRespVO.java
@@ -21,6 +21,9 @@
    @Schema(description = "退货单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long returnId;
    @Schema(description = "销售出库单行 ID", example = "1")
    private Long productSalesLineId;
    @Schema(description = "物料ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long itemId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/line/MesWmReturnSalesLineSaveReqVO.java
@@ -23,6 +23,9 @@
    @NotNull(message = "退货单ID不能为空")
    private Long returnId;
    @Schema(description = "销售出库单行 ID", example = "1")
    private Long productSalesLineId;
    @Schema(description = "物料ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "物料ID不能为空")
    private Long itemId;
@@ -37,9 +40,6 @@
    @Schema(description = "批次号", example = "B20250101")
    private String batchCode;
    @Schema(description = "是否需要质检", example = "true")
    private Boolean rqcCheckFlag;
    @Schema(description = "备注", example = "备注")
    private String remark;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/returnsales/MesWmReturnSalesDO.java
@@ -45,6 +45,17 @@
     */
    private Long clientId;
    /**
     * é”€å”®å‡ºåº“单 ID
     *
     * å…³è” {@link cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO#getId()}
     * å…è®¸ä¸ºç©ºï¼šå”®åŽæ¨¡å—创建的退货单没有出库单上下文
     */
    private Long productSalesId;
    /**
     * é”€å”®å‡ºåº“单号(冗余)
     */
    private String productSalesCode;
    /**
     * é€€è´§æ—¥æœŸ
     */
    private LocalDate returnDate;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/returnsales/MesWmReturnSalesLineDO.java
@@ -37,6 +37,13 @@
     */
    private Long returnId;
    /**
     * é”€å”®å‡ºåº“单行 ID
     *
     * å…³è” {@link cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO#getId()}
     * å…è®¸ä¸ºç©ºï¼šå”®åŽæ¨¡å—创建的退货单没有出库单上下文
     */
    private Long productSalesLineId;
    /**
     * ç‰©æ–™ ID
     *
     * å…³è” {@link MesMdItemDO#getId()}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesMapper.java
@@ -54,6 +54,35 @@
        return selectPage(reqVO, wrapper);
    }
    /**
     * åˆ†é¡µæŸ¥è¯¢å¯é€€é”€å”®å‡ºåº“单:仅已完成状态,且存在「出库数量 > å·²é€€æ•°é‡ã€çš„行
     */
    default PageResult<MesWmProductSalesDO> selectReturnablePage(MesWmProductSalesPageReqVO reqVO,
                                                                 Integer finishedStatus, Integer canceledStatus,
                                                                 List<Long> permittedClientIds) {
        LambdaQueryWrapperX<MesWmProductSalesDO> wrapper = new LambdaQueryWrapperX<MesWmProductSalesDO>()
                .likeIfPresent(MesWmProductSalesDO::getCode, reqVO.getCode())
                .likeIfPresent(MesWmProductSalesDO::getName, reqVO.getName())
                .eqIfPresent(MesWmProductSalesDO::getClientId, reqVO.getClientId())
                .eq(MesWmProductSalesDO::getStatus, finishedStatus)
                .betweenIfPresent(MesWmProductSalesDO::getSalesDate, reqVO.getShipmentDate())
                .betweenIfPresent(MesWmProductSalesDO::getOutboundTime, reqVO.getOutboundTime());
        if (permittedClientIds != null) {
            if (CollUtil.isEmpty(permittedClientIds)) {
                return PageResult.empty();
            }
            wrapper.in(MesWmProductSalesDO::getClientId, permittedClientIds);
        }
        wrapper.apply("EXISTS (SELECT 1 FROM mes_wm_product_sales_line psl " +
                        "WHERE psl.deleted = 0 AND psl.sales_id = mes_wm_product_sales.id AND psl.quantity > " +
                        "(SELECT IFNULL(SUM(rl.quantity), 0) FROM mes_wm_return_sales_line rl " +
                        "INNER JOIN mes_wm_return_sales rs ON rs.id = rl.return_id AND rs.deleted = 0 " +
                        "WHERE rl.deleted = 0 AND rl.product_sales_line_id = psl.id AND rs.status != {0}))",
                canceledStatus);
        wrapper.orderByDesc(MesWmProductSalesDO::getId);
        return selectPage(reqVO, wrapper);
    }
    default MesWmProductSalesDO selectByCode(String code) {
        return selectOne(MesWmProductSalesDO::getCode, code);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/returnsales/MesWmReturnSalesLineMapper.java
@@ -1,12 +1,17 @@
package cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.line.MesWmReturnSalesLinePageReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
/**
@@ -34,4 +39,31 @@
                .eq(MesWmReturnSalesLineDO::getReturnId, returnId));
    }
    default List<MesWmReturnSalesLineDO> selectListByProductSalesLineIds(Collection<Long> productSalesLineIds) {
        return selectList(new LambdaQueryWrapperX<MesWmReturnSalesLineDO>()
                .in(MesWmReturnSalesLineDO::getProductSalesLineId, productSalesLineIds));
    }
    /**
     * ç»Ÿè®¡æ¯ä¸ªé”€å”®å‡ºåº“行已被退货的数量:排除已取消的退货单和已删除的行
     *
     * @param productSalesLineIds é”€å”®å‡ºåº“行 ID é›†åˆ
     * @param canceledStatus      é€€è´§å•已取消状态
     * @param excludeReturnId     éœ€è¦æŽ’除的退货单 ID(校验当前退货单自身的可退数量时使用),可为空
     * @return key=销售出库行 ID,value=已退数量
     */
    @Select("<script>" +
            "SELECT rl.product_sales_line_id AS `key`, IFNULL(SUM(rl.quantity), 0) AS `value` " +
            "FROM mes_wm_return_sales_line rl " +
            "INNER JOIN mes_wm_return_sales rs ON rs.id = rl.return_id AND rs.deleted = 0 AND rs.status != #{canceledStatus} " +
            "<if test='excludeReturnId != null'>AND rs.id != #{excludeReturnId} </if>" +
            "WHERE rl.deleted = 0 AND rl.product_sales_line_id IN " +
            "<foreach collection='productSalesLineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
            "GROUP BY rl.product_sales_line_id" +
            "</script>")
    List<KeyValue<Long, BigDecimal>> selectReturnedQuantityList(
            @Param("productSalesLineIds") Collection<Long> productSalesLineIds,
            @Param("canceledStatus") Integer canceledStatus,
            @Param("excludeReturnId") Long excludeReturnId);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -672,8 +672,16 @@
    ErrorCode WM_RETURN_SALES_DETAIL_QUANTITY_MISMATCH = new ErrorCode(1_040_713_006, "销售退货单行数量与明细数量不一致");
    ErrorCode WM_RETURN_SALES_CANCEL_NOT_ALLOWED = new ErrorCode(1_040_713_007, "销售退货单已完成或已取消,无法取消");
    ErrorCode WM_RETURN_SALES_LINE_NOT_EXISTS = new ErrorCode(1_040_713_100, "销售退货单行不存在");
    ErrorCode WM_RETURN_SALES_PRODUCT_SALES_NOT_EXISTS = new ErrorCode(1_040_713_008, "销售出库单不存在");
    ErrorCode WM_RETURN_SALES_PRODUCT_SALES_NOT_FINISHED = new ErrorCode(1_040_713_009, "只有已完成状态的销售出库单才允许退货");
    ErrorCode WM_RETURN_SALES_CLIENT_MISMATCH = new ErrorCode(1_040_713_010, "退货单客户与销售出库单客户不一致");
    ErrorCode WM_RETURN_SALES_PRODUCT_SALES_IMMUTABLE = new ErrorCode(1_040_713_011, "已存在退货行,不能更换销售出库单");
    ErrorCode WM_RETURN_SALES_LINE_PRODUCT_SALES_REQUIRED = new ErrorCode(1_040_713_101, "已选择销售出库单,退货行必须从出库单行中选择");
    ErrorCode WM_RETURN_SALES_LINE_PRODUCT_SALES_NOT_MATCH = new ErrorCode(1_040_713_102, "销售出库单行不属于所选的销售出库单");
    ErrorCode WM_RETURN_SALES_LINE_QUANTITY_EXCEED = new ErrorCode(1_040_713_103, "退货数量不能超过可退数量");
    ErrorCode WM_RETURN_SALES_DETAIL_NOT_EXISTS = new ErrorCode(1_040_713_200, "销售退货单明细不存在");
    ErrorCode WM_RETURN_SALES_DETAIL_QUANTITY_EXCEED = new ErrorCode(1_040_713_201, "上架明细总数量不能超过退货行数量");
    ErrorCode WM_RETURN_SALES_DETAIL_BATCH_MISMATCH = new ErrorCode(1_040_713_202, "上架明细的批次必须与退货行批次一致");
    // ========== MES ä»“库管理-盘点方案/任务/结果(1-040-714-100) ==========
    ErrorCode WM_STOCK_TAKING_PLAN_NOT_EXISTS = new ErrorCode(1_040_714_100, "盘点方案不存在");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityServiceImpl.java
@@ -193,6 +193,8 @@
        if (qualifiedWarehouse == null) throw exception(WM_WAREHOUSE_NOT_EXISTS);
        if (!MesWmWarehouseTypeEnum.QUALIFIED.getType().equals(qualifiedWarehouse.getType()))
            throw exception(WM_WAREHOUSE_TYPE_NOT_QUALIFIED);
        // æå‰æ ¡éªŒåˆæ ¼åº“已配置库区/库位,避免复检审核放行时才失败
        resolveQualifiedLocation(qualifiedWarehouse);
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
@@ -252,6 +254,8 @@
        if (!MesWmWarehouseTypeEnum.QUALIFIED.getType().equals(qualifiedWarehouse.getType())) {
            throw exception(WM_WAREHOUSE_TYPE_NOT_QUALIFIED);
        }
        // æå‰æ ¡éªŒåˆæ ¼åº“已配置库区/库位,避免复检审核放行时才失败
        resolveQualifiedLocation(qualifiedWarehouse);
        Set<String> codes = new HashSet<>();
        boolean passed = true;
        MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder().code(bag.getCode()).itemId(batch.getItemId())
@@ -296,12 +300,10 @@
        Long qualifiedLocationId = null;
        Long qualifiedAreaId = null;
        if (passed) {
            List<MesWmWarehouseLocationDO> locations = warehouseLocationService.getWarehouseLocationList(qualifiedWarehouse.getId());
            if (CollUtil.isNotEmpty(locations)) {
                qualifiedLocationId = locations.get(0).getId();
                List<MesWmWarehouseAreaDO> areas = warehouseAreaService.getWarehouseAreaList(qualifiedLocationId);
                if (CollUtil.isNotEmpty(areas)) qualifiedAreaId = areas.get(0).getId();
            }
            // åº“存事务必须落到库区、库位,合格库缺少配置时提前给出可操作的提示
            Long[] target = resolveQualifiedLocation(qualifiedWarehouse);
            qualifiedLocationId = target[0];
            qualifiedAreaId = target[1];
        }
        // æŒ‰è¢‹ç ç²’度流转库存:锁定该袋在暂存库中的活动明细,禁止按批次任意扣减
        MesWmMaterialStockBagDO stockBag = materialStockBagMapper.selectActiveByBagCode(bag.getCode());
@@ -688,8 +690,8 @@
     *
     * @param tempStock            ä»Žæš‚存库扣减后返回的暂存记录(携带 item/batch/vendor ç»´åº¦ï¼‰
     * @param qualifiedWarehouseId ç›®æ ‡åˆæ ¼åº“仓库编号
     * @param qualifiedLocationId  ç›®æ ‡åˆæ ¼åº“库区编号(未配置则为 null)
     * @param qualifiedAreaId      ç›®æ ‡åˆæ ¼åº“库位编号(未配置则为 null)
     * @param qualifiedLocationId  ç›®æ ‡åˆæ ¼åº“库区编号
     * @param qualifiedAreaId      ç›®æ ‡åˆæ ¼åº“库位编号
     */
    private MesWmMaterialStockBagDO getOrCreateStockBag(MesProBagCodeDO bag, MesWmMaterialStockDO tempStock) {
        MesWmMaterialStockBagDO stockBag = materialStockBagMapper.selectByBagCode(bag.getCode());
@@ -706,6 +708,25 @@
        return stockBag;
    }
    /**
     * è§£æžåˆæ ¼åº“的默认入库库区与库位:取第一个同时配置了库位的非冻结库区。
     *
     * è´¨æ£€æŒ‰ä»“库维度选择入库目标,库区/库位由系统自动落点;合格库缺少可用库区或库位时,
     * åº“存事务无法写入,直接提示管理员完善仓库配置。
     */
    private Long[] resolveQualifiedLocation(MesWmWarehouseDO qualifiedWarehouse) {
        List<MesWmWarehouseLocationDO> locations = warehouseLocationService.getWarehouseLocationList(qualifiedWarehouse.getId())
                .stream().filter(location -> !Boolean.TRUE.equals(location.getFrozen())).toList();
        if (CollUtil.isEmpty(locations))
            throw new IllegalArgumentException("合格库「" + qualifiedWarehouse.getName() + "」未配置可用库区,请先在仓库管理中完善库区与库位");
        for (MesWmWarehouseLocationDO location : locations) {
            List<MesWmWarehouseAreaDO> areas = warehouseAreaService.getWarehouseAreaList(location.getId())
                    .stream().filter(area -> !Boolean.TRUE.equals(area.getFrozen())).toList();
            if (CollUtil.isNotEmpty(areas)) return new Long[]{location.getId(), areas.get(0).getId()};
        }
        throw new IllegalArgumentException("合格库「" + qualifiedWarehouse.getName() + "」未配置可用库位,请先在仓库管理中完善库区与库位");
    }
    private MesWmMaterialStockDO getOrCreateQualifiedStock(MesWmMaterialStockDO tempStock, Long qualifiedWarehouseId,
                                         Long qualifiedLocationId, Long qualifiedAreaId) {
        MesWmMaterialStockDO qualified = materialStockMapper.selectQualifiedByCompositeKey(
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImpl.java
@@ -17,7 +17,6 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.salesnotice.MesWmSalesNoticeLineDO;
import cn.iocoder.yudao.module.mes.service.wm.salesnotice.MesWmSalesNoticeLineService;
import cn.iocoder.yudao.module.mes.service.qc.oqc.MesQcOqcService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
@@ -57,9 +56,6 @@
    @Resource
    @Lazy
    private MesWmSalesNoticeLineService salesNoticeLineService;
    @Resource
    @Lazy
    private MesQcOqcService oqcService;
    @Override
    public Long createProductSalesLine(MesWmProductSalesLineSaveReqVO createReqVO) {
@@ -83,34 +79,6 @@
        // æ›´æ–°
        MesWmProductSalesLineDO updateObj = BeanUtils.toBean(updateReqVO, MesWmProductSalesLineDO.class);
        productSalesLineMapper.updateById(updateObj);
        // æ£€æŸ¥æ˜¯å¦éœ€è¦åˆ›å»º OQC æ£€éªŒå•:从不需要检验变为需要检验
        if (Boolean.TRUE.equals(updateReqVO.getOqcCheckFlag())
                && !Boolean.TRUE.equals(oldLine.getOqcCheckFlag())) {
            // èŽ·å–å‡ºåº“å•ä¿¡æ¯
            MesWmProductSalesDO sales = productSalesService.getProductSales(updateReqVO.getSalesId());
            if (sales == null) {
                log.warn("[updateProductSalesLine] å‡ºåº“单不存在,salesId={}", updateReqVO.getSalesId());
                return;
            }
            // æž„建出库明细 DO
            MesWmProductSalesLineDO line = new MesWmProductSalesLineDO();
            line.setId(updateReqVO.getId());
            line.setItemId(updateReqVO.getItemId());
            line.setBatchCode(updateReqVO.getBatchCode());
            line.setQuantity(updateReqVO.getQuantity());
            // åˆ›å»º OQC æ£€éªŒå•,让异常抛出
            Long oqcId = oqcService.createOqcFromProductSales(
                    sales.getId(), line, sales.getCode(), sales.getClientId());
            // æ›´æ–°å‡ºåº“明细的 oqcId å’Œè´¨é‡çŠ¶æ€
            productSalesLineMapper.updateById(new MesWmProductSalesLineDO()
                    .setId(updateReqVO.getId())
                    .setOqcId(oqcId)
                    .setQualityStatus(MesWmQualityStatusEnum.PENDING.getStatus()));
        }
    }
    @Override
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java
@@ -29,7 +29,6 @@
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmSalesNoticeStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmStockReserveBizTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
@@ -43,10 +42,8 @@
import cn.iocoder.yudao.module.mes.service.wm.stockreserve.MesWmStockReserveService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
import cn.iocoder.yudao.module.mes.service.qc.oqc.MesQcOqcService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -103,9 +100,6 @@
    private MesWmMaterialStockService materialStockService;
    @Resource
    private MesWmStockReserveService stockReserveService;
    @Resource
    @Lazy
    private MesQcOqcService oqcService;
    @Resource
    private MesTraceIntegrityService integrityService;
@@ -179,37 +173,7 @@
            }
        }
        // èŽ·å–å‡ºåº“å•ä¿¡æ¯
        MesWmProductSalesDO sales = productSalesMapper.selectById(id);
        // æ£€æŸ¥æ‰€æœ‰è¡Œçš„ oqcCheckFlag:如果有需要 OQC æ£€éªŒçš„行,进入待检测状态
        boolean needOqc = CollectionUtils.anyMatch(lines,
                line -> Boolean.TRUE.equals(line.getOqcCheckFlag()));
        if (needOqc) {
            // ä¸ºéœ€è¦ OQC æ£€éªŒä¸”尚未创建检验单的明细创建 OQC æ£€éªŒå•
            List<MesWmProductSalesLineDO> oqcLines = CollectionUtils.filterList(lines,
                    line -> Boolean.TRUE.equals(line.getOqcCheckFlag()) && line.getOqcId() == null);
            for (MesWmProductSalesLineDO line : oqcLines) {
                // ç›´æŽ¥è°ƒç”¨ï¼Œè®©å¼‚常抛出,保证数据一致性
                Long oqcId = oqcService.createOqcFromProductSales(
                        sales.getId(), line, sales.getCode(), sales.getClientId());
                // æ›´æ–°å‡ºåº“明细的 oqcId
                productSalesLineMapper.updateById(new MesWmProductSalesLineDO()
                        .setId(line.getId())
                        .setOqcId(oqcId));
            }
            // æ‰¹é‡åˆå§‹åŒ– OQC è¡Œçš„质量状态为"待检验"
            List<Long> oqcLineIds = convertList(
                    CollectionUtils.filterList(lines, line -> Boolean.TRUE.equals(line.getOqcCheckFlag())),
                    MesWmProductSalesLineDO::getId);
            productSalesLineService.updateProductSalesLineQualityStatus(oqcLineIds, MesWmQualityStatusEnum.PENDING.getStatus());
            // éœ€è¦æ£€éªŒï¼Œè¿›å…¥å¾…检测
            productSalesMapper.updateById(new MesWmProductSalesDO()
                    .setId(id).setStatus(MesWmProductSalesStatusEnum.CONFIRMED.getStatus()));
            return;
        }
        // ä¸éœ€è¦æ£€éªŒï¼Œç›´æŽ¥è¿›å…¥å¾…拣货
        // é”€å”®ä»“库流转不经过质检,提交后直接进入待拣货
        productSalesMapper.updateById(new MesWmProductSalesDO()
                .setId(id).setStatus(MesWmProductSalesStatusEnum.APPROVING.getStatus()));
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesDetailServiceImpl.java
@@ -139,12 +139,27 @@
        // æ ¡éªŒä»“库、库区、库位的关联关系
        warehouseAreaService.validateWarehouseAreaExists(
                reqVO.getWarehouseId(), reqVO.getLocationId(), reqVO.getAreaId());
        // ç‰©æ–™ã€æ‰¹æ¬¡ä»¥é€€è´§è¡Œä¸ºå‡†ï¼ˆé€€è´§æ‰¹æ¬¡æ¥è‡ªé”€å”®å‡ºåº“单,上架时不允许变更)
        reqVO.setItemId(line.getItemId());
        resolveBatch(reqVO, line);
        // æ ¡éªŒåº“位物料/批次混放规则
        materialStockService.checkAreaMixingRule(reqVO.getAreaId(), reqVO.getItemId(), reqVO.getBatchId());
        // æ ¡éªŒæ˜Žç»†æ€»æ•°é‡ä¸è¶…过行数量(排除自身)
        validateDetailQuantityNotExceed(reqVO.getLineId(), reqVO.getQuantity(), reqVO.getId(), line);
    }
        // æ ¹æ® batchCode è§£æž batchId(必须在混放规则校验之前,因为它依赖 batchId)
    /**
     * è§£æžä¸Šæž¶æ˜Žç»†æ‰¹æ¬¡ï¼šè¡Œå·²æŒ‡å®šæ‰¹æ¬¡æ—¶å¼ºåˆ¶æ²¿ç”¨ï¼Œå¦åˆ™æŒ‰æ‰¹æ¬¡å·è§£æž
     */
    private void resolveBatch(MesWmReturnSalesDetailSaveReqVO reqVO, MesWmReturnSalesLineDO line) {
        if (line.getBatchId() != null) {
            if (reqVO.getBatchId() != null && ObjUtil.notEqual(reqVO.getBatchId(), line.getBatchId())) {
                throw exception(WM_RETURN_SALES_DETAIL_BATCH_MISMATCH);
            }
            reqVO.setBatchId(line.getBatchId());
            reqVO.setBatchCode(line.getBatchCode());
            return;
        }
        fillBatchId(reqVO);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineService.java
@@ -7,7 +7,9 @@
import jakarta.validation.Valid;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
 * MES é”€å”®é€€è´§å•行 Service æŽ¥å£
@@ -86,6 +88,15 @@
    void updateQualityStatusByReturnId(Long returnId, Integer qualityStatus);
    /**
     * ç»Ÿè®¡é”€å”®å‡ºåº“行已退数量(排除已取消的退货单)
     *
     * @param productSalesLineIds é”€å”®å‡ºåº“行 ID é›†åˆ
     * @param excludeReturnId     éœ€è¦æŽ’除的退货单 ID(校验当前退货单自身时使用),可为空
     * @return key=销售出库行 ID,value=已退数量
     */
    Map<Long, BigDecimal> getReturnedQuantityMap(Collection<Long> productSalesLineIds, Long excludeReturnId);
    /**
     * RQC æ£€éªŒå®ŒæˆåŽï¼Œæ ¹æ®åˆæ ¼å“/不合格品数量更新销售退货单行
     *
     * <p>拆分逻辑:
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineServiceImpl.java
@@ -2,30 +2,36 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.line.MesWmReturnSalesLinePageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.line.MesWmReturnSalesLineSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesLineMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmReturnSalesStatusEnum;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesLineService;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.MD_ITEM_BATCH_REQUIRED;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.WM_RETURN_SALES_LINE_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
/**
 * MES é”€å”®é€€è´§å•行 Service å®žçŽ°ç±»
@@ -43,12 +49,15 @@
    @Lazy
    private MesWmReturnSalesService returnSalesService;
    @Resource
    private MesMdmItemService mdmItemService;
    private MdmItemApi mdmItemApi;
    @Resource
    private MesWmBatchService batchService;
    @Resource
    @Lazy
    private MesWmReturnSalesDetailService returnSalesDetailService;
    @Resource
    @Lazy
    private MesWmProductSalesLineService productSalesLineService;
    @Override
    public Long createReturnSalesLine(MesWmReturnSalesLineSaveReqVO createReqVO) {
@@ -79,12 +88,46 @@
    /**
     * æ ¡éªŒä¿å­˜æ—¶çš„关联数据
     *
     * <p>退货单关联了销售出库单时,物料、批次一律取自出库行,并校验可退数量;
     * æœªå…³è”出库单(如售后模块创建)时,保持原有物料/批次校验逻辑。
     */
    private void validateLineSaveData(MesWmReturnSalesLineSaveReqVO reqVO) {
        // æ ¡éªŒé€€è´§å•存在 + è‰ç¨¿çŠ¶æ€
        returnSalesService.validateReturnSalesExistsAndPrepare(reqVO.getReturnId());
        MesWmReturnSalesDO returnSales = returnSalesService.validateReturnSalesExistsAndPrepare(reqVO.getReturnId());
        if (returnSales.getProductSalesId() != null) {
            validateOutboundLine(reqVO, returnSales);
            return;
        }
        // æ ¡éªŒç‰©æ–™å­˜åœ¨ + æ‰¹æ¬¡ç®¡ç†
        validateItemBatchManagement(reqVO.getReturnId(), reqVO.getItemId(), reqVO.getBatchId());
    }
    /**
     * æ ¡éªŒå‡ºåº“行归属与可退数量,并从出库行回填物料、批次
     */
    private void validateOutboundLine(MesWmReturnSalesLineSaveReqVO reqVO, MesWmReturnSalesDO returnSales) {
        if (reqVO.getProductSalesLineId() == null) {
            throw exception(WM_RETURN_SALES_LINE_PRODUCT_SALES_REQUIRED);
        }
        MesWmProductSalesLineDO outboundLine =
                productSalesLineService.validateProductSalesLineExists(reqVO.getProductSalesLineId());
        if (ObjUtil.notEqual(outboundLine.getSalesId(), returnSales.getProductSalesId())) {
            throw exception(WM_RETURN_SALES_LINE_PRODUCT_SALES_NOT_MATCH);
        }
        // ç‰©æ–™ã€æ‰¹æ¬¡å¿…须与出库行一致
        reqVO.setItemId(outboundLine.getItemId());
        reqVO.setBatchId(outboundLine.getBatchId());
        reqVO.setBatchCode(outboundLine.getBatchCode());
        // å¯é€€æ•°é‡ = å‡ºåº“数量 - å…¶å®ƒé€€è´§å•已退数量(排除当前退货单)
        BigDecimal returned = getReturnedQuantityMap(
                Collections.singletonList(outboundLine.getId()), returnSales.getId())
                .getOrDefault(outboundLine.getId(), BigDecimal.ZERO);
        BigDecimal returnable = outboundLine.getQuantity().subtract(returned);
        if (reqVO.getQuantity().compareTo(returnable) > 0) {
            throw exception(WM_RETURN_SALES_LINE_QUANTITY_EXCEED);
        }
    }
    @Override
@@ -129,6 +172,16 @@
    }
    @Override
    public Map<Long, BigDecimal> getReturnedQuantityMap(Collection<Long> productSalesLineIds, Long excludeReturnId) {
        if (CollUtil.isEmpty(productSalesLineIds)) {
            return Collections.emptyMap();
        }
        List<KeyValue<Long, BigDecimal>> list = returnSalesLineMapper.selectReturnedQuantityList(
                productSalesLineIds, MesWmReturnSalesStatusEnum.CANCELED.getStatus(), excludeReturnId);
        return CollectionUtils.convertMap(list, KeyValue::getKey, KeyValue::getValue);
    }
    @Override
    public void updateQualityStatusByReturnId(Long returnId, Integer qualityStatus) {
        List<MesWmReturnSalesLineDO> lines = returnSalesLineMapper.selectListByReturnId(returnId);
        for (MesWmReturnSalesLineDO line : lines) {
@@ -155,7 +208,7 @@
     * @param batchId  æ‰¹æ¬¡ID
     */
    private void validateItemBatchManagement(Long returnId, Long itemId, Long batchId) {
        MesMdmItemDO item = mdmItemService.validateItemExistsAndEnable(itemId);
        MdmItemRespDTO item = mdmItemApi.validateItemExistsAndEnable(itemId).getCheckedData();
        // å¦‚果物料启用了批次管理,则必须选择批次
        if (Boolean.TRUE.equals(item.getIsBatchManaged()) && batchId == null) {
            throw exception(MD_ITEM_BATCH_REQUIRED);
@@ -189,9 +242,11 @@
            // 1.C.1 æ–°å¢žä¸€è¡Œä¸åˆæ ¼å“
            MesWmReturnSalesLineDO unqualifiedLine = new MesWmReturnSalesLineDO()
                    .setReturnId(sourceLine.getReturnId())
                    .setProductSalesLineId(sourceLine.getProductSalesLineId())
                    .setItemId(sourceLine.getItemId())
                    .setQuantity(unqualifiedQuantity)
                    .setBatchId(sourceLine.getBatchId())
                    .setBatchCode(sourceLine.getBatchCode())
                    .setRqcId(sourceLine.getRqcId())
                    .setRqcCheckFlag(sourceLine.getRqcCheckFlag())
                    .setQualityStatus(MesWmQualityStatusEnum.FAIL.getStatus())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesService.java
@@ -1,10 +1,15 @@
package cn.iocoder.yudao.module.mes.service.wm.returnsales;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.MesWmProductSalesPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnableProductSalesLineRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import jakarta.validation.Valid;
import java.util.List;
/**
 * MES é”€å”®é€€è´§å• Service æŽ¥å£
@@ -52,6 +57,22 @@
    PageResult<MesWmReturnSalesDO> getReturnSalesPage(MesWmReturnSalesPageReqVO pageReqVO);
    /**
     * èŽ·å¾—å¯é€€é”€å”®å‡ºåº“å•åˆ†é¡µï¼šä»…å·²å®ŒæˆçŠ¶æ€ï¼Œä¸”å­˜åœ¨å¯é€€æ•°é‡å¤§äºŽ 0 çš„行
     *
     * @param pageReqVO åˆ†é¡µå‚æ•°
     * @return é”€å”®å‡ºåº“单分页
     */
    PageResult<MesWmProductSalesDO> getReturnableProductSalesPage(MesWmProductSalesPageReqVO pageReqVO);
    /**
     * èŽ·å¾—é”€å”®å‡ºåº“å•ä¸­ä»å¯é€€è´§çš„è¡Œåˆ—è¡¨
     *
     * @param productSalesId é”€å”®å‡ºåº“单 ID
     * @return å¯é€€è¡Œåˆ—表
     */
    List<MesWmReturnableProductSalesLineRespVO> getReturnableProductSalesLineList(Long productSalesId);
    /**
     * æ ¡éªŒé”€å”®é€€è´§å•是否存在
     *
     * @param id ç¼–号
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesServiceImpl.java
@@ -5,21 +5,30 @@
import cn.hutool.core.util.ObjUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.MesWmProductSalesPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnableProductSalesLineRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDetailDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmReturnSalesStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.mdm.api.unit.MdmUnitMeasureApi;
import cn.iocoder.yudao.module.mdm.api.unit.dto.MdmUnitMeasureRespDTO;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesLineService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
import jakarta.annotation.Resource;
@@ -29,11 +38,14 @@
import java.math.BigDecimal;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
/**
@@ -55,9 +67,15 @@
    @Resource
    private CrmCustomerApi customerApi;
    @Resource
    private MesMdmItemService mdmItemService;
    private MdmItemApi mdmItemApi;
    @Resource
    private MdmUnitMeasureApi mdmUnitMeasureApi;
    @Resource
    private MesWmTransactionService wmTransactionService;
    @Resource
    private MesWmProductSalesMapper productSalesMapper;
    @Resource
    private MesWmProductSalesLineService productSalesLineService;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -76,9 +94,14 @@
    @Transactional(rollbackFor = Exception.class)
    public void updateReturnSales(MesWmReturnSalesSaveReqVO updateReqVO) {
        // 1.1 æ ¡éªŒå­˜åœ¨ + å‡†å¤‡ä¸­çŠ¶æ€
        validateReturnSalesExistsAndPrepare(updateReqVO.getId());
        MesWmReturnSalesDO oldReturnSales = validateReturnSalesExistsAndPrepare(updateReqVO.getId());
        // 1.2 æ ¡éªŒå…³è”数据
        validateSaveData(updateReqVO);
        // 1.3 å·²æœ‰é€€è´§è¡Œæ—¶ï¼Œä¸å…è®¸æ›´æ¢é”€å”®å‡ºåº“单
        if (ObjUtil.notEqual(oldReturnSales.getProductSalesId(), updateReqVO.getProductSalesId())
                && CollUtil.isNotEmpty(returnSalesLineService.getReturnSalesLineListByReturnId(updateReqVO.getId()))) {
            throw exception(WM_RETURN_SALES_PRODUCT_SALES_IMMUTABLE);
        }
        // 2. æ›´æ–°ä¸»è¡¨
        MesWmReturnSalesDO updateObj = BeanUtils.toBean(updateReqVO, MesWmReturnSalesDO.class);
@@ -111,24 +134,113 @@
    }
    @Override
    public PageResult<MesWmProductSalesDO> getReturnableProductSalesPage(MesWmProductSalesPageReqVO pageReqVO) {
        List<Long> permittedClientIds = customerApi.getPermittedCustomerIds();
        return productSalesMapper.selectReturnablePage(pageReqVO,
                MesWmProductSalesStatusEnum.FINISHED.getStatus(),
                MesWmReturnSalesStatusEnum.CANCELED.getStatus(), permittedClientIds);
    }
    @Override
    public List<MesWmReturnableProductSalesLineRespVO> getReturnableProductSalesLineList(Long productSalesId) {
        if (productSalesId == null) {
            return Collections.emptyList();
        }
        MesWmProductSalesDO productSales = productSalesMapper.selectById(productSalesId);
        if (productSales == null) {
            throw exception(WM_RETURN_SALES_PRODUCT_SALES_NOT_EXISTS);
        }
        List<MesWmProductSalesLineDO> lines = productSalesLineService.getProductSalesLineListBySalesId(productSalesId);
        if (CollUtil.isEmpty(lines)) {
            return Collections.emptyList();
        }
        Map<Long, BigDecimal> returnedMap = returnSalesLineService.getReturnedQuantityMap(
                convertList(lines, MesWmProductSalesLineDO::getId), null);
        // ä»…保留可退数量大于 0 çš„出库行
        List<MesWmProductSalesLineDO> returnableLines = lines.stream()
                .filter(line -> line.getQuantity() != null
                        && line.getQuantity().subtract(returnedMap.getOrDefault(line.getId(), BigDecimal.ZERO))
                        .compareTo(BigDecimal.ZERO) > 0)
                .toList();
        if (CollUtil.isEmpty(returnableLines)) {
            return Collections.emptyList();
        }
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(
                convertSet(returnableLines, MesWmProductSalesLineDO::getItemId));
        Map<Long, MdmUnitMeasureRespDTO> unitMap = CollectionUtils.convertMap(
                mdmUnitMeasureApi.getUnitMeasureList(convertSet(itemMap.values(), MdmItemRespDTO::getUnitMeasureId))
                        .getCheckedData(),
                MdmUnitMeasureRespDTO::getId);
        return returnableLines.stream().map(line -> {
            MesWmReturnableProductSalesLineRespVO vo = new MesWmReturnableProductSalesLineRespVO();
            BigDecimal returned = returnedMap.getOrDefault(line.getId(), BigDecimal.ZERO);
            vo.setProductSalesLineId(line.getId());
            vo.setSalesId(line.getSalesId());
            vo.setItemId(line.getItemId());
            vo.setBatchId(line.getBatchId());
            vo.setBatchCode(line.getBatchCode());
            vo.setOutboundQuantity(line.getQuantity());
            vo.setReturnedQuantity(returned);
            vo.setReturnableQuantity(line.getQuantity().subtract(returned));
            MapUtils.findAndThen(itemMap, line.getItemId(), item -> {
                vo.setItemCode(item.getCode());
                vo.setItemName(item.getName());
                vo.setItemSpecification(item.getSpecification());
                MapUtils.findAndThen(unitMap, item.getUnitMeasureId(), unit -> vo.setItemUnit(unit.getName()));
            });
            return vo;
        }).toList();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitReturnSales(Long id) {
        // 1.1 æ ¡éªŒå­˜åœ¨ + è‰ç¨¿çŠ¶æ€
        validateReturnSalesExistsAndPrepare(id);
        MesWmReturnSalesDO returnSales = validateReturnSalesExistsAndPrepare(id);
        // 1.2 æ ¡éªŒè‡³å°‘有一条行
        List<MesWmReturnSalesLineDO> lines = returnSalesLineService.getReturnSalesLineListByReturnId(id);
        if (CollUtil.isEmpty(lines)) {
            throw exception(WM_RETURN_SALES_NO_LINE);
        }
        // 1.3 å…³è”出售库单时,重新校验行归属与可退数量(草稿期间可能已被其它退货单占用)
        if (returnSales.getProductSalesId() != null) {
            validateLinesAgainstProductSales(returnSales, lines);
        }
        // 2.1 ç¡®å®šç›®æ ‡çŠ¶æ€ï¼š1)有待检验物料 â†’ å¾…检验状态;2)无待检验物料 â†’ å¾…执行状态
        boolean hasPendingQc = CollUtil.contains(lines,
                line -> MesWmQualityStatusEnum.PENDING.getStatus().equals(line.getQualityStatus()));
        Integer targetStatus = hasPendingQc ? MesWmReturnSalesStatusEnum.CONFIRMED.getStatus()
                : MesWmReturnSalesStatusEnum.APPROVING.getStatus();
        // 2.2 æäº¤ï¼ˆè‰ç¨¿ -> å¾…检验/待执行)
        // 2. æäº¤ï¼ˆè‰ç¨¿ -> å¾…执行);销售仓库流转不经过质检
        returnSalesMapper.updateById(new MesWmReturnSalesDO()
                .setId(id).setStatus(targetStatus));
                .setId(id).setStatus(MesWmReturnSalesStatusEnum.APPROVING.getStatus()));
    }
    /**
     * æ ¡éªŒé€€è´§å•所有行均来自所选的销售出库单,且本单各行数量之和不超过可退数量
     */
    private void validateLinesAgainstProductSales(MesWmReturnSalesDO returnSales,
                                                  List<MesWmReturnSalesLineDO> lines) {
        if (CollUtil.contains(lines, line -> line.getProductSalesLineId() == null)) {
            throw exception(WM_RETURN_SALES_LINE_PRODUCT_SALES_REQUIRED);
        }
        Map<Long, MesWmProductSalesLineDO> outboundLineMap = convertMap(
                productSalesLineService.getProductSalesLineListBySalesId(returnSales.getProductSalesId()),
                MesWmProductSalesLineDO::getId);
        // æ±‡æ€»æœ¬å•每个出库行的退货数量
        Map<Long, BigDecimal> lineQuantityMap = new HashMap<>();
        for (MesWmReturnSalesLineDO line : lines) {
            if (!outboundLineMap.containsKey(line.getProductSalesLineId())) {
                throw exception(WM_RETURN_SALES_LINE_PRODUCT_SALES_NOT_MATCH);
            }
            lineQuantityMap.merge(line.getProductSalesLineId(), line.getQuantity(), BigDecimal::add);
        }
        // å·²é€€æ•°é‡æŽ’除本单,避免把本单自身计入
        Map<Long, BigDecimal> returnedMap = returnSalesLineService.getReturnedQuantityMap(
                convertSet(lines, MesWmReturnSalesLineDO::getProductSalesLineId), returnSales.getId());
        for (Map.Entry<Long, BigDecimal> entry : lineQuantityMap.entrySet()) {
            BigDecimal returnable = outboundLineMap.get(entry.getKey()).getQuantity()
                    .subtract(returnedMap.getOrDefault(entry.getKey(), BigDecimal.ZERO));
            if (entry.getValue().compareTo(returnable) > 0) {
                throw exception(WM_RETURN_SALES_LINE_QUANTITY_EXCEED);
            }
        }
    }
    @Override
@@ -171,7 +283,7 @@
                        MesWmReturnSalesDetailDO::getQuantity, BigDecimal::add, BigDecimal.ZERO);
                // å¯¹æ¯”行数量与明细总数量,不满足直接抛出
                if (line.getQuantity().compareTo(totalDetailQuantity) > 0) {
                    MesMdmItemDO item = mdmItemService.validateItemExistsAndEnable(line.getItemId());
                    MdmItemRespDTO item = mdmItemApi.validateItemExistsAndEnable(line.getItemId()).getCheckedData();
                    throw exception(WM_RETURN_SALES_DETAIL_QUANTITY_MISMATCH,
                            item.getCode() + " " + item.getName() + " æœªå®Œæˆä¸Šæž¶");
                }
@@ -188,13 +300,24 @@
    private void createTransactionList(MesWmReturnSalesDO returnSales) {
        List<MesWmReturnSalesDetailDO> details = returnSalesDetailService.getReturnSalesDetailListByReturnId(returnSales.getId());
        wmTransactionService.createTransactionList(convertList(details, detail -> new MesWmTransactionSaveReqDTO()
                .setType(MesWmTransactionTypeEnum.IN.getType()).setItemId(detail.getItemId())
                .setQuantity(detail.getQuantity()) // å…¥åº“数量为正数
                .setBatchId(detail.getBatchId()).setBatchCode(detail.getBatchCode())
                .setWarehouseId(detail.getWarehouseId()).setLocationId(detail.getLocationId()).setAreaId(detail.getAreaId())
                .setBizType(MesBizTypeConstants.WM_RETURN_SALES).setBizId(returnSales.getId())
                .setBizCode(returnSales.getCode()).setBizLineId(detail.getLineId())));
        // æ˜Žç»†æœªå¸¦æ‰¹æ¬¡æ—¶å›žé€€åˆ°é€€è´§è¡Œæ‰¹æ¬¡ï¼Œé¿å…åŽ†å²æ•°æ®å…¥åº“ä¸¢æ‰¹æ¬¡
        Map<Long, MesWmReturnSalesLineDO> lineMap = convertMap(
                returnSalesLineService.getReturnSalesLineListByReturnId(returnSales.getId()),
                MesWmReturnSalesLineDO::getId);
        wmTransactionService.createTransactionList(convertList(details, detail -> {
            MesWmReturnSalesLineDO line = lineMap.get(detail.getLineId());
            Long batchId = detail.getBatchId() != null ? detail.getBatchId()
                    : line != null ? line.getBatchId() : null;
            String batchCode = detail.getBatchCode() != null ? detail.getBatchCode()
                    : line != null ? line.getBatchCode() : null;
            return new MesWmTransactionSaveReqDTO()
                    .setType(MesWmTransactionTypeEnum.IN.getType()).setItemId(detail.getItemId())
                    .setQuantity(detail.getQuantity()) // å…¥åº“数量为正数
                    .setBatchId(batchId).setBatchCode(batchCode)
                    .setWarehouseId(detail.getWarehouseId()).setLocationId(detail.getLocationId()).setAreaId(detail.getAreaId())
                    .setBizType(MesBizTypeConstants.WM_RETURN_SALES).setBizId(returnSales.getId())
                    .setBizCode(returnSales.getCode()).setBizLineId(detail.getLineId());
        }));
    }
    @Override
@@ -274,6 +397,21 @@
    private void validateSaveData(MesWmReturnSalesSaveReqVO reqVO) {
        validateCodeUnique(reqVO.getId(), reqVO.getCode());
        customerApi.validateCustomer(reqVO.getClientId());
        // å…³è”销售出库单时,校验出库单状态与客户一致性,并回填出库单号
        if (reqVO.getProductSalesId() == null) {
            return;
        }
        MesWmProductSalesDO productSales = productSalesMapper.selectById(reqVO.getProductSalesId());
        if (productSales == null) {
            throw exception(WM_RETURN_SALES_PRODUCT_SALES_NOT_EXISTS);
        }
        if (ObjUtil.notEqual(MesWmProductSalesStatusEnum.FINISHED.getStatus(), productSales.getStatus())) {
            throw exception(WM_RETURN_SALES_PRODUCT_SALES_NOT_FINISHED);
        }
        if (ObjUtil.notEqual(productSales.getClientId(), reqVO.getClientId())) {
            throw exception(WM_RETURN_SALES_CLIENT_MISMATCH);
        }
        reqVO.setProductSalesCode(productSales.getCode());
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/salesnotice/MesWmSalesNoticeServiceImpl.java
@@ -308,7 +308,6 @@
            salesLine.setQuantity(noticeLine.getQuantity());
            salesLine.setBatchId(noticeLine.getBatchId());
            salesLine.setBatchCode(noticeLine.getBatchCode());
            salesLine.setOqcCheckFlag(noticeLine.getOqcCheckFlag());
            productSalesLineMapper.insert(salesLine);
        }