2026-09-04 335f64189bdf0fd51864ac3873f6199e94282c27
docs(rule): 添加错误提示文案精确性规则文档

- 新增 error-message-precision.md 文档定义多条件校验提示要求
- 规定提示必须包含具体维度、双方实际值、可执行动作三要素
- 明确逐维度独立比较和业务场景专属文案实现要点

refactor(mes): 优化库存记录选择不一致错误提示

- 将笼统的"信息不一致"提示改为具体的字段差异对比
- 实现逐维度比对机制,返回第一个不一致字段的可读描述
- 添加物料、批次、仓库、库区、库位等维度的名称解析功能
- 为不同业务场景提供针对性的错误提示文案
- 在退货行业务中单独处理物料一致性校验逻辑
已添加1个文件
已修改4个文件
146 ■■■■■ 文件已修改
.claude/rules/error-message-precision.md 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/materialstock/MesWmMaterialStockServiceImpl.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnissue/MesWmReturnIssueDetailServiceImpl.java 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/transaction/MesWmTransactionServiceImpl.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.claude/rules/error-message-precision.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
# é”™è¯¯æç¤ºæ–‡æ¡ˆç²¾ç¡®æ€§è§„则
## è§„则
- é¢å‘用户的校验/异常提示(后端异常 message、Excel å¯¼å…¥æ ¡éªŒæç¤ºã€å‰ç«¯ toast、接口报错文案),**禁止写成把多个条件堆在一个笼统句里**,例如:`库存记录与提交的物料、批次或库位信息不一致`、`数据不合法`、`参数错误`、`记录不一致`。
- ç”¨æˆ·çœ‹åˆ°æç¤ºåŽåº”能**一眼知道是哪个条件不满足、差在哪、怎么改**,而不是只知道"出错了"。
## å¤šæ¡ä»¶æ ¡éªŒæç¤ºè¦æ±‚
提示必须同时包含三要素:
1. **具体维度**:指出是哪一个字段/规则不满足(如:物料、批次、批次号、仓库、库区、库位、数量)。
2. **双方实际值**:给出"库存/单据侧"与"提交值"两侧的可读值对比,并尽量解析成业务名称(如 `成品仓/2区/A-01`),**不要直接暴露纯数字 ID**;名称解析失败时才回退 `ID=xxx` å¹¶æ˜Žç¡®æ ‡æ³¨ã€‚
3. **可执行动作**:告诉用户接下来怎么办(重新选择哪一项、如何刷新重试、删除后重新添加、退回哪一步、联系谁)。
### å†™æ³•示例
```java
// âŒ ç¬¼ç»Ÿã€å¤šæ¡ä»¶å †å ã€æ— æ³•定位
throw exception(WM_MATERIAL_STOCK_SELECTION_MISMATCH);
// âŒ åªç»™æ¡ä»¶ä¸ç»™å€¼ã€ä¸ç»™åŠ¨ä½œã€æš´éœ²ç”Ÿç¡¬ ID
throw new ServiceException(WM_MATERIAL_STOCK_SELECTION_MISMATCH.getCode(),
    "不一致:库位(库存 8 â‰  æäº¤ null)");
// âœ… ç»´åº¦ + ä¸¤ä¾§å¯è¯»å€¼ + åŠ¨ä½œ
throw new ServiceException(WM_MATERIAL_STOCK_SELECTION_MISMATCH.getCode(),
    "所选库存记录与单据不一致:所选库存库位[A-01],单据库位为[未选择]。"
        + "请重新选择该物料正确的库存;若仓库/库区/库位/批次被禁用无法修改,请删除本条明细后重新添加。");
```
## å®žçŽ°è¦ç‚¹
- é€ç»´åº¦ç‹¬ç«‹æ¯”较(`itemId â†’ batchId â†’ batchCode â†’ warehouseId â†’ locationId â†’ areaId`),**按业务优先级只返回/抛出第一处不满足**,避免一次抛多条让用户抓不住重点。
- åŒä¸€é”™è¯¯ç ä½†ä¸šåŠ¡ä¸Šä¸‹æ–‡ä¸åŒï¼ˆå¦‚"执行扣减时库存已被移库/合并/拆分" vs "新增明细时带错字段" vs "退货来源库存物料不符"),**各自给出贴合场景的专属文案**,不要都复用同一句笼统话。
- åç§°è§£æžæ”¾åœ¨**异常分支**再执行(罕见路径),避免每次正常保存都多查表;解析失败必须回退为可读的 `ID=xxx` è€ŒéžæŠ›äºŒæ¬¡å¼‚常。
- å¦‚果某个字段本应由系统自动带出(如选库存后回填的仓库/库区/库位/批次),一旦不一致基本等于前端回填缺失或数据被改,提示里要写明"请重新选择以自动带出"。
## è‡ªæŸ¥æ¸…单
新增或修改任何可能由多个原因触发的提示前,逐项确认:
- [ ] æç¤ºæ˜¯å¦æŒ‡æ˜Žäº†"具体是哪个条件"?
- [ ] æ˜¯å¦åŒæ—¶ç»™å‡ºäº†"期望值 vs å®žé™…值"(尽量用业务名称而非 ID)?
- [ ] æ˜¯å¦ç»™å‡ºäº†"用户下一步能做的动作"?
- [ ] æ˜¯å¦é¿å…äº†æŠŠå¤šä¸ªæ¡ä»¶å †è¿›ä¸€å¥"XX不一致"?
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -486,7 +486,7 @@
    ErrorCode WM_TRANSACTION_LIST_EMPTY = new ErrorCode(1_040_703_011, "库存事务列表不能为空");
    ErrorCode WM_TRANSACTION_BATCH_NOT_EXISTS = new ErrorCode(1_040_703_012, "批次记录不存在");
    ErrorCode WM_MATERIAL_STOCK_REQUIRED = new ErrorCode(1_040_703_013, "库存记录不能为空");
    ErrorCode WM_MATERIAL_STOCK_SELECTION_MISMATCH = new ErrorCode(1_040_703_014, "库存记录与提交的物料、批次或库位信息不一致");
    ErrorCode WM_MATERIAL_STOCK_SELECTION_MISMATCH = new ErrorCode(1_040_703_014, "所选库存记录与单据信息不一致,请核对后重新操作");
    ErrorCode WM_MATERIAL_STOCK_QUANTITY_INVALID = new ErrorCode(1_040_703_015, "调整数量不能为空或零");
    ErrorCode WM_MATERIAL_STOCK_FROZEN = new ErrorCode(1_040_703_016, "库存记录已冻结,无法移库");
    ErrorCode WM_MATERIAL_STOCK_IMPORT_LIST_IS_EMPTY = new ErrorCode(1_040_703_017, "导入的库存数据不能为空");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/materialstock/MesWmMaterialStockServiceImpl.java
@@ -278,14 +278,13 @@
            throw exception(WM_MATERIAL_STOCK_REQUIRED);
        }
        MesWmMaterialStockDO stock = validateMaterialStockExists(materialStockId);
        // 2. æ ¡éªŒåº“存记录的物料、批次、仓库、库区、库位等信息与前端选择的一致,避免串单或越权提交
        if (ObjUtil.notEqual(stock.getItemId(), itemId)
                || ObjUtil.notEqual(stock.getBatchId(), batchId)
                || (batchCode != null && ObjUtil.notEqual(stock.getBatchCode(), batchCode))
                || ObjUtil.notEqual(stock.getWarehouseId(), warehouseId)
                || ObjUtil.notEqual(stock.getLocationId(), locationId)
                || ObjUtil.notEqual(stock.getAreaId(), areaId)) {
            throw exception(WM_MATERIAL_STOCK_SELECTION_MISMATCH);
        // 2. æ ¡éªŒåº“存记录的物料、批次、仓库、库区、库位等信息与单据一致,避免串单或越权提交。
        //    é€ä¸ªç»´åº¦æ¯”对,命中时提示具体差异(库存侧 vs å•据侧)并给出处理办法,避免笼统报"信息不一致"
        String mismatch = buildSelectedStockMismatch(
                stock, itemId, batchId, batchCode, warehouseId, locationId, areaId);
        if (mismatch != null) {
            throw new ServiceException(WM_MATERIAL_STOCK_SELECTION_MISMATCH.getCode(),
                    "所选库存记录与单据不一致:" + mismatch + "。请重新选择该物料正确的库存;若仓库/库区/库位/批次被禁用无法修改,请删除本条明细后重新添加。");
        }
        // 3. æ ¡éªŒåº“存数量充足(如果前端传了 quantity,则必须保证库存数量 >= quantity)
        if (quantity != null && stock.getQuantity() != null && stock.getQuantity().compareTo(quantity) < 0) {
@@ -294,6 +293,74 @@
        return stock;
    }
    /**
     * é€ç»´åº¦æ¯”对待选库存与单据传入的信息,返回第一个不一致维度的可读描述;全部一致返回 null
     */
    private String buildSelectedStockMismatch(MesWmMaterialStockDO stock, Long itemId, Long batchId, String batchCode,
                                              Long warehouseId, Long locationId, Long areaId) {
        if (ObjUtil.notEqual(stock.getItemId(), itemId)) {
            return "所选库存归属物料[" + itemIdText(itemId) + "],单据物料为[" + itemIdText(stock.getItemId()) + "]";
        }
        if (ObjUtil.notEqual(stock.getBatchId(), batchId)) {
            return "所选库存批次[" + batchText(stock.getBatchId(), stock.getBatchCode())
                    + "],单据批次为[" + batchText(batchId, batchCode) + "]";
        }
        if (batchCode != null && ObjUtil.notEqual(stock.getBatchCode(), batchCode)) {
            return "所选库存批次号[" + stock.getBatchCode() + "],单据批次号为[" + batchCode + "]";
        }
        if (ObjUtil.notEqual(stock.getWarehouseId(), warehouseId)) {
            return "所选库存仓库[" + warehouseText(stock.getWarehouseId())
                    + "],单据仓库为[" + warehouseText(warehouseId) + "]";
        }
        if (ObjUtil.notEqual(stock.getLocationId(), locationId)) {
            return "所选库存库区[" + locationText(stock.getLocationId())
                    + "],单据库区为[" + locationText(locationId) + "]";
        }
        if (ObjUtil.notEqual(stock.getAreaId(), areaId)) {
            return "所选库存库位[" + areaText(stock.getAreaId())
                    + "],单据库位为[" + areaText(areaId) + "]";
        }
        return null;
    }
    private String itemIdText(Long itemId) {
        return itemId == null ? "未选择" : ("物料ID=" + itemId);
    }
    private String batchText(Long batchId, String batchCode) {
        if (StrUtil.isNotBlank(batchCode)) {
            return batchCode;
        }
        return batchId == null ? "未选择" : ("批次ID=" + batchId);
    }
    private String warehouseText(Long warehouseId) {
        if (warehouseId == null) {
            return "未选择";
        }
        MesWmWarehouseDO warehouse = warehouseService.getWarehouse(warehouseId);
        return warehouse != null && StrUtil.isNotBlank(warehouse.getName())
                ? warehouse.getName() : ("仓库ID=" + warehouseId);
    }
    private String locationText(Long locationId) {
        if (locationId == null) {
            return "未选择";
        }
        MesWmWarehouseLocationDO location = locationService.getWarehouseLocation(locationId);
        return location != null && StrUtil.isNotBlank(location.getName())
                ? location.getName() : ("库区ID=" + locationId);
    }
    private String areaText(Long areaId) {
        if (areaId == null) {
            return "未选择";
        }
        MesWmWarehouseAreaDO area = areaService.getWarehouseArea(areaId);
        return area != null && StrUtil.isNotBlank(area.getName())
                ? area.getName() : ("库位ID=" + areaId);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long adjustMaterialStockIn(MesWmMaterialStockAdjustReqVO reqVO) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnissue/MesWmReturnIssueDetailServiceImpl.java
@@ -18,6 +18,8 @@
import java.math.BigDecimal;
import java.util.List;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
@@ -123,9 +125,10 @@
            if (stock == null) {
                throw exception(WM_MATERIAL_STOCK_NOT_EXISTS);
            }
            // æ ¡éªŒç‰©æ–™å’Œæ‰¹æ¬¡ä¸€è‡´
            // æ ¡éªŒç‰©æ–™ä¸€è‡´ï¼šæ­¤å¤„只比对物料,因为来源库存(线边/虚拟仓)与上架目标仓库本就不同属正常
            if (ObjUtil.notEqual(stock.getItemId(), reqVO.getItemId())) {
                throw exception(WM_MATERIAL_STOCK_SELECTION_MISMATCH);
                throw new ServiceException(WM_MATERIAL_STOCK_SELECTION_MISMATCH.getCode(),
                        "所选来源库存归属物料与当前退货物料不一致,请重新选择来源库存");
            }
            // æ ¡éªŒåº“存数量充足
            if (reqVO.getQuantity() != null && stock.getQuantity() != null
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/transaction/MesWmTransactionServiceImpl.java
@@ -31,6 +31,8 @@
import java.time.LocalDateTime;
import java.util.List;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
@@ -89,12 +91,14 @@
            if (materialStock == null) {
                throw exception(WM_MATERIAL_STOCK_NOT_EXISTS);
            }
            // æ ¡éªŒç‰©æ–™ã€æ‰¹æ¬¡ã€ä»“库等维度一致(防止传入错误的 materialStockId)
            // æ ¡éªŒç‰©æ–™ã€æ‰¹æ¬¡ã€ä»“库等维度一致(防止传入错误的 materialStockId)。
            // è‹¥æ­¤å¤„不一致,通常是该库存已被移库/合并/拆分,需退回重新拣货,故给出针对性提示
            if (ObjUtil.notEqual(materialStock.getItemId(), reqDTO.getItemId())
                    || ObjUtil.notEqual(materialStock.getWarehouseId(), reqDTO.getWarehouseId())
                    || ObjUtil.notEqual(materialStock.getLocationId(), reqDTO.getLocationId())
                    || ObjUtil.notEqual(materialStock.getAreaId(), reqDTO.getAreaId())) {
                throw exception(WM_MATERIAL_STOCK_SELECTION_MISMATCH);
                throw new ServiceException(WM_MATERIAL_STOCK_SELECTION_MISMATCH.getCode(),
                        "该库存记录已变动(可能被移库/合并/拆分),与出库明细不一致,无法扣减,请退回重新拣货");
            }
        } else if (isInbound) {
            // å…¥åº“事务:根据 forceCreateNewStock æ ‡å¿—决定是创建新记录还是查找/创建