feat: 补齐排产约束与缺料校验,新增风险监测,优化隐患通知与招投标联动

## 问题
按需求验收计划逐项测试后确认以下缺口:
1. 需求13 排产数据:工单缺排产优先级与内外销分类,物料缺外协提前期
2. 需求14 自动排产:排产时未校验物料齐套性,可能生成无法执行的计划
3. 需求20 隐患整改:分派强制责任人有系统账号,未绑定员工的现场场景受阻
4. 需求21 风险监测:缺少基于设备采集数据的风险阈值监测
5. 需求22 询报比价:招投标状态流转与中标后续处理不完整

## 修复
### 需求13 排产数据
- MesProWorkOrderDO 新增 schedulePriority(0-100)、marketType(DOMESTIC/EXPORT)
- MdmItemDO 新增 outsourceLeadTime(外协提前期,天)
- 新增 MesSchedulePriority 统一优先级排序规则
- Save/Resp VO 同步补字段并校验

### 需求14 自动排产
- 新增 MesScheduleMaterialCheck:按工单 BOM 展开物料,汇总库存,缺料阻断排产
- MesProBatchScheduleService 接入齐套性校验

### 需求20 隐患整改
- SafetyHazardAssignReqVO 新增 notifyUserId
- 责任人未绑定账号时允许指定通知账号;已绑定时强制一致,避免通知错人

### 需求21 风险监测
- 新增 SafetyRiskSourceController/Service/DO/Mapper:风险源维护
- 新增 SafetyRiskThreshold:基于设备采集值(电流、转速等)的阈值判定
- 复用现有数采数据,不引入新外部接口

### 需求22 询报比价
- SrmTenderProjectServiceImpl / SrmBidAwardServiceImpl 完善比价与中标流转

## 其他
- MesWmItemReceiptStatusListener:采购入库状态联动
- .gitignore:/docs/ 改为 /docs/* 并放行 docs/sql 迁移脚本
(/docs/ 会让 git 不进入目录,目录内例外规则无法生效)

## 验证
mvn compile EXIT=0,编译通过。功能验证待本地启动后按验收计划复测。
已添加27个文件
已修改14个文件
1082 ■■■■■ 文件已修改
.gitignore 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/item/vo/MdmItemRespVO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/item/vo/MdmItemSaveReqVO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/dataobject/item/MdmItemDO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/vo/MesProWorkOrderRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/vo/MesProWorkOrderSaveReqVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/SafetyRiskSourceController.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/hazard/SafetyHazardAssignReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/risk/SafetyRiskSourceSaveReqVO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/workorder/MesProWorkOrderDO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/safety/SafetyRiskSourceDO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/safety/SafetyRiskSourceMapper.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/SafetyErrorCodeConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesProBatchScheduleService.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesScheduleMaterialCheck.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesSchedulePriority.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyHazardService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyRiskSourceService.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyRiskThreshold.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/listener/MesWmItemReceiptStatusListener.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/api/returnsales/AfterSaleReturnSourceTest.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/collect/CollectOfflinePolicyTest.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeMatchTest.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeSearchServiceTest.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeWriteServiceTest.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesScheduleMaterialCheckTest.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesSchedulePriorityTest.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/safety/SafetyDocumentPolicyTest.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/safety/SafetyHazardNoticeTest.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/safety/SafetyRiskSourceServiceTest.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/safety/SafetyRiskThresholdTest.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptStatusListenerTest.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesReturnInboundAfterSaleTest.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesReturnQualityAfterSaleTest.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/salesnotice/SalesNoticeOrderGuardTest.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/tender/SrmBidAwardServiceImpl.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/tender/SrmPurchaseQuoteValidator.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/tender/SrmTenderProjectServiceImpl.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/tender/SrmPurchaseQuoteValidationTest.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/tender/SrmQuoteOwnershipTest.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/tender/SrmTenderConfigServiceTest.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -61,7 +61,11 @@
######################################################################
# AI / Personal Docs(个人工作文档,不纳入版本控制)
/docs/
# ç”¨ /docs/* è€Œéž /docs/,否则 git ä¸è¿›å…¥ç›®å½•,目录内的例外规则无法生效
/docs/*
# ä¾‹å¤–:docs/sql ä¸‹çš„æ•°æ®åº“迁移脚本属于交付物,必须入库
!/docs/sql/
!/docs/sql/**/*.sql
/TODO.md
/fix_quartz_and_issues.md
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/item/vo/MdmItemRespVO.java
@@ -94,6 +94,12 @@
    @Schema(description = "最高库存", example = "500")
    private BigDecimal maxStock;
    @Schema(description = "采购提前期(天)")
    private Integer leadTime;
    @Schema(description = "外协提前期(天)")
    private Integer outsourceLeadTime;
    @Schema(description = "保质期天数", example = "365")
    private Integer expiryDay;
@@ -138,4 +144,4 @@
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
    private LocalDateTime createTime;
}
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/item/vo/MdmItemSaveReqVO.java
@@ -72,6 +72,14 @@
    @Schema(description = "最高库存", example = "500")
    private BigDecimal maxStock;
    @Schema(description = "采购提前期(天)")
    @jakarta.validation.constraints.Min(value = 0, message = "采购提前期不能为负数")
    private Integer leadTime;
    @Schema(description = "外协提前期(天)")
    @jakarta.validation.constraints.Min(value = 0, message = "外协提前期不能为负数")
    private Integer outsourceLeadTime;
    @Schema(description = "保质期天数", example = "365")
    private Integer expiryDay;
@@ -115,4 +123,4 @@
    private Long mdmItemId;
}
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/dataobject/item/MdmItemDO.java
@@ -138,6 +138,9 @@
     */
    private Integer leadTime;
    /** å¤–协提前期(天);为空表示未配置。 */
    private Integer outsourceLeadTime;
    /**
     * æ˜¯å¦æ‰¹æ¬¡ç®¡ç†
     */
@@ -213,4 +216,4 @@
     */
    private String remark;
}
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/vo/MesProWorkOrderRespVO.java
@@ -108,6 +108,12 @@
    @ExcelProperty("需求日期")
    private LocalDateTime requestDate;
    @Schema(description = "排产优先级,数字越大越优先")
    private Integer schedulePriority;
    @Schema(description = "市场类型:DOMESTIC å†…销,EXPORT å¤–销")
    private String marketType;
    @Schema(description = "父工单编号", example = "0")
    private Long parentId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/vo/MesProWorkOrderSaveReqVO.java
@@ -64,6 +64,15 @@
    @NotNull(message = "需求日期不能为空")
    private LocalDateTime requestDate;
    @Schema(description = "排产优先级,范围 0 è‡³ 100,数值越大越优先")
    @jakarta.validation.constraints.Min(0)
    @jakarta.validation.constraints.Max(100)
    private Integer schedulePriority;
    @Schema(description = "市场类型:DOMESTIC å†…销,EXPORT å¤–销")
    @jakarta.validation.constraints.Pattern(regexp = "DOMESTIC|EXPORT", message = "市场类型仅支持内销或外销")
    private String marketType;
    @Schema(description = "父工单编号", example = "0")
    private Long parentId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/SafetyRiskSourceController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.mes.controller.admin.safety;
import cn.iocoder.yudao.framework.common.pojo.*;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.risk.SafetyRiskSourceSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyRiskSourceDO;
import cn.iocoder.yudao.module.mes.service.safety.SafetyRiskSourceService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@RestController @RequestMapping("/safety/risk-source") @Validated
public class SafetyRiskSourceController {
    @Resource private SafetyRiskSourceService service;
    @GetMapping("/page") @PreAuthorize("@ss.hasPermission('safety:risk:query')")
    public CommonResult<PageResult<SafetyRiskSourceDO>> page(@Valid PageParam page,@RequestParam(value="name",required=false) String name){return success(service.page(page,name));}
    @PostMapping("/create") @PreAuthorize("@ss.hasPermission('safety:risk:create')")
    public CommonResult<Long> create(@Valid @RequestBody SafetyRiskSourceSaveReqVO req){req.setId(null);return success(service.save(req));}
    @PutMapping("/update") @PreAuthorize("@ss.hasPermission('safety:risk:update')")
    public CommonResult<Boolean> update(@Valid @RequestBody SafetyRiskSourceSaveReqVO req){if(req.getId()==null)throw cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException("危险源编号不能为空");service.save(req);return success(true);}
    @DeleteMapping("/delete") @PreAuthorize("@ss.hasPermission('safety:risk:delete')")
    public CommonResult<Boolean> delete(@RequestParam("id") Long id){service.delete(id);return success(true);}
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/hazard/SafetyHazardAssignReqVO.java
@@ -26,6 +26,9 @@
    @NotNull(message = "请选择整改责任人")
    private Long assigneeId;
    @Schema(description = "员工未绑定账号时,明确指定的通知接收账号")
    private Long notifyUserId;
    @Schema(description = "整改期限")
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate rectifyDeadline;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/risk/SafetyRiskSourceSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package cn.iocoder.yudao.module.mes.controller.admin.safety.vo.risk;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.math.BigDecimal;
@Data public class SafetyRiskSourceSaveReqVO {
    private Long id;
    @NotBlank @Size(max=120) private String name;
    @NotBlank @Pattern(regexp="MAJOR|HIGH_VOLTAGE|OTHER") private String category;
    @Size(max=160) private String location;
    @NotBlank @Size(max=80) private String metricName;
    @Size(max=32) private String unit;
    @Digits(integer=14,fraction=6) private BigDecimal lowerLimit;
    @Digits(integer=14,fraction=6) private BigDecimal upperLimit;
    @Digits(integer=14,fraction=6) private BigDecimal currentValue;
    @Size(max=500) private String remark;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/workorder/MesProWorkOrderDO.java
@@ -123,6 +123,12 @@
     * éœ€æ±‚日期
     */
    private LocalDateTime requestDate;
    /** æŽ’产优先级,数值越大越优先。 */
    private Integer schedulePriority;
    /** ä¸šåŠ¡äººå‘˜ç¡®è®¤çš„å†…å¤–é”€åˆ†ç±»ã€‚ */
    private String marketType;
    /**
     * çˆ¶å·¥å•编号
     *
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/safety/SafetyRiskSourceDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.safety;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@TableName("safety_risk_source")
@Data @EqualsAndHashCode(callSuper = true)
public class SafetyRiskSourceDO extends BaseDO {
    @TableId private Long id;
    private String name;
    private String category;
    private String location;
    private String metricName;
    private String unit;
    @TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal lowerLimit;
    @TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal upperLimit;
    @TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal currentValue;
    @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime observedAt;
    private String riskStatus;
    private String remark;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/safety/SafetyRiskSourceMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,5 @@
package cn.iocoder.yudao.module.mes.dal.mysql.safety;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyRiskSourceDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface SafetyRiskSourceMapper extends BaseMapperX<SafetyRiskSourceDO> {}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/SafetyErrorCodeConstants.java
@@ -35,7 +35,7 @@
    ErrorCode SAFETY_HAZARD_STATUS_ERROR = new ErrorCode(1_040_964_001, "当前隐患状态不允许该操作");
    ErrorCode SAFETY_HAZARD_ASSIGNEE_REQUIRED = new ErrorCode(1_040_964_002, "分派必须指定整改责任人");
    ErrorCode SAFETY_HAZARD_SAME_OPERATOR = new ErrorCode(1_040_964_003, "整改人与复查人不能是同一人");
    ErrorCode SAFETY_HAZARD_ASSIGNEE_NO_ACCOUNT = new ErrorCode(1_040_964_004, "整改责任人未关联系统账号,无法发送整改通知");
    ErrorCode SAFETY_HAZARD_ASSIGNEE_NO_ACCOUNT = new ErrorCode(1_040_964_004, "整改责任人未绑定系统账号,请选择通知接收账号");
    ErrorCode SAFETY_HAZARD_NOTICE_UNAVAILABLE = new ErrorCode(1_040_964_005, "整改通知模板未启用,请联系管理员处理");
    // ========== é«˜å±ä½œä¸šç¥¨ 1_040_965_xxx ==========
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesProBatchScheduleService.java
@@ -14,6 +14,13 @@
import cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.dv.machinery.MesDvMachineryMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderBomMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstock.MesWmMaterialStockMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.warehouse.MesWmWarehouseMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderBomDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.enums.pro.*;
import cn.iocoder.yudao.module.mes.service.md.workstation.MesMdWorkstationService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
@@ -42,6 +49,9 @@
public class MesProBatchScheduleService {
    @Resource private MesProTaskMapper taskMapper;
    @Resource private MesProWorkOrderMapper workOrderMapper;
    @Resource private MesProWorkOrderBomMapper workOrderBomMapper;
    @Resource private MesWmMaterialStockMapper materialStockMapper;
    @Resource private MesWmWarehouseMapper warehouseMapper;
    @Resource private MesDvMachineryMapper machineryMapper;
    @Resource private MesMdWorkstationService workstationService;
    @Resource private MesProWorkOrderProcessService workOrderProcessService;
@@ -128,6 +138,34 @@
        result.setPreviewTime(now());
        var orders = workOrderMapper.selectByIds(request.getWorkOrderIds());
        var orderMap = orders.stream().collect(Collectors.toMap(MesProWorkOrderDO::getId, order -> order));
        var bomByOrder = new HashMap<Long, List<MesProWorkOrderBomDO>>();
        for (var order : orders) bomByOrder.put(order.getId(), workOrderBomMapper.selectListByWorkOrderId(order.getId()).stream()
                .sorted(Comparator.comparing(MesProWorkOrderBomDO::getId)).toList());
        var materialIds = bomByOrder.values().stream().flatMap(Collection::stream).map(MesProWorkOrderBomDO::getItemId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        var warehouses = warehouseMapper.selectList().stream().collect(Collectors.toMap(MesWmWarehouseDO::getId, w -> w));
        var stocks = materialIds.isEmpty() ? List.<MesWmMaterialStockDO>of() : materialStockMapper.selectList(
                new LambdaQueryWrapperX<MesWmMaterialStockDO>().in(MesWmMaterialStockDO::getItemId, materialIds));
        Map<Long, BigDecimal> availableStock = new HashMap<>();
        for (var stock : stocks) {
            var warehouse = warehouses.get(stock.getWarehouseId());
            if (warehouse == null || Boolean.TRUE.equals(warehouse.getFrozen()) || Boolean.TRUE.equals(stock.getFrozen())
                    || MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE.equals(warehouse.getCode())) continue;
            var available = Optional.ofNullable(stock.getQuantity()).orElse(BigDecimal.ZERO)
                    .subtract(Optional.ofNullable(stock.getFrozenQuantity()).orElse(BigDecimal.ZERO))
                    .subtract(Optional.ofNullable(stock.getReservedQuantity()).orElse(BigDecimal.ZERO)).max(BigDecimal.ZERO);
            availableStock.merge(stock.getItemId(), available, BigDecimal::add);
        }
        var materialInfo = materialIds.isEmpty() ? Map.<Long, cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO>of()
                : itemService.getItemMap(materialIds);
        for (Long itemId : materialIds) {
            var item = materialInfo.get(itemId);
            if (item != null && Boolean.TRUE.equals(item.getSafeStockFlag()) && item.getMinStock() != null) {
                availableStock.compute(itemId, (key, value) -> Optional.ofNullable(value).orElse(BigDecimal.ZERO)
                        .subtract(item.getMinStock().max(BigDecimal.ZERO)).max(BigDecimal.ZERO));
            }
        }
        var stockSnapshot = new TreeMap<>(availableStock);
        var stations = workstationService.getWorkstationListByStatus(0).stream()
                .sorted(Comparator.comparing(MesMdWorkstationDO::getId)).toList();
        var machineMap = machineryMapper.selectList().stream().collect(Collectors.toMap(MesDvMachineryDO::getId, machine -> machine));
@@ -154,8 +192,7 @@
                        stationMachines.getOrDefault(task.getWorkstationId(), Set.of()), task.getStartTime(), task.getEndTime()));
            }
        }
        var sorted = orders.stream().sorted(Comparator.comparing(MesProWorkOrderDO::getRequestDate,
                Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(MesProWorkOrderDO::getId)).toList();
        var sorted = orders.stream().sorted(MesSchedulePriority.comparator()).toList();
        List<Long> orderSequence = new ArrayList<>();
        Set<Long> visiting = new HashSet<>();
        for (var order : sorted) addChildrenFirst(order.getId(), orderMap, orderSequence, visiting);
@@ -182,6 +219,20 @@
                        || (order.getQuantityProduced() != null && order.getQuantityProduced().compareTo(order.getQuantity()) >= 0)) {
                    throw new IllegalArgumentException("工单数量无效或已生产完成");
                }
                var boms = bomByOrder.getOrDefault(id, List.of());
                if (boms.isEmpty()) throw new IllegalArgumentException("工单没有 BOM,无法核对物料齐套,请先维护");
                BigDecimal unscheduled = order.getQuantity().subtract(Optional.ofNullable(order.getQuantityScheduled()).orElse(BigDecimal.ZERO));
                if (unscheduled.signum() <= 0) throw new IllegalArgumentException("工单已完成排产");
                Map<Long, BigDecimal> demand = new HashMap<>();
                for (var bom : boms) {
                    if (bom.getItemId() == null || bom.getQuantity() == null || bom.getQuantity().signum() <= 0)
                        throw new IllegalArgumentException("工单 BOM ç‰©æ–™æˆ–需求数量无效,请先维护");
                    var requiredItem = materialInfo.get(bom.getItemId());
                    if (requiredItem == null || !Objects.equals(requiredItem.getStatus(), 0))
                        throw new IllegalArgumentException("工单 BOM å­˜åœ¨å·²åˆ é™¤æˆ–停用的物料,请先维护");
                    demand.merge(bom.getItemId(), bom.getQuantity().multiply(unscheduled).divide(order.getQuantity(), 6, RoundingMode.UP), BigDecimal::add);
                }
                var trialStock = MesScheduleMaterialCheck.allocate(availableStock, demand);
                LocalDateTime earliest = calendar.nextWorkingTime(request.getStartTime());
                // çˆ¶å·¥å•必须等子工单;同一批次按依赖顺序排,未纳入的未完成子工单阻止排产。
                for (var child : workOrderMapper.selectList(MesProWorkOrderDO::getParentId, id)) {
@@ -300,7 +351,7 @@
                }
                if (plans.isEmpty()) throw new IllegalArgumentException("所有工序已完成排产,不可重复提交");
                if (result.getTasks().size() + plans.size() > 200) throw new IllegalArgumentException("一次最多生成200个任务,请拆分工单批次排产");
                result.getTasks().addAll(plans); busy = trialBusy;
                result.getTasks().addAll(plans); busy = trialBusy; availableStock = trialStock;
                orderPlan.setTaskCount(plans.size()); orderPlan.setEndTime(earliest); ends.put(id, earliest);
                if (order.getRequestDate() != null && earliest.toLocalDate().isAfter(order.getRequestDate().toLocalDate())) {
                    orderPlan.setLate(true); result.getWarnings().add("工单" + order.getCode() + "预计完成时间晚于交期");
@@ -316,6 +367,8 @@
        fingerprint.put("tasks", result.getTasks()); fingerprint.put("holidays", holidayDates.stream().sorted().toList());
        fingerprint.put("workOrders", orders.stream().sorted(Comparator.comparing(MesProWorkOrderDO::getId)).toList());
        fingerprint.put("paramTemplates", templateHashes);
        fingerprint.put("materialStock", stockSnapshot);
        fingerprint.put("workOrderBoms", new TreeMap<>(bomByOrder));
        result.setPreviewHash(DigestUtil.sha256Hex(JsonUtils.toJsonString(fingerprint)));
        return result;
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesScheduleMaterialCheck.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.mes.service.pro.task.schedule;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
/** åœ¨ä¸€æ¬¡æŽ’产预览内分配库存快照,防止多个工单重复使用同一库存。 */
final class MesScheduleMaterialCheck {
    private MesScheduleMaterialCheck() {}
    static Map<Long, BigDecimal> allocate(Map<Long, BigDecimal> available, Map<Long, BigDecimal> required) {
        var remaining = new HashMap<>(available);
        required.forEach((itemId, quantity) -> {
            if (itemId == null || quantity == null || quantity.signum() <= 0)
                throw new IllegalArgumentException("工单 BOM ç‰©æ–™æˆ–需求数量无效");
            var stock = remaining.getOrDefault(itemId, BigDecimal.ZERO);
            if (stock.compareTo(quantity) < 0)
                throw new IllegalArgumentException("物料#" + itemId + "缺料:需求 " + quantity.stripTrailingZeros().toPlainString()
                        + ",可用 " + stock.stripTrailingZeros().toPlainString());
            remaining.put(itemId, stock.subtract(quantity));
        });
        return remaining;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesSchedulePriority.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package cn.iocoder.yudao.module.mes.service.pro.task.schedule;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import java.util.Comparator;
final class MesSchedulePriority {
    private MesSchedulePriority() {}
    static Comparator<MesProWorkOrderDO> comparator() {
        return Comparator.comparingInt((MesProWorkOrderDO order) -> order.getSchedulePriority() == null ? 0 : order.getSchedulePriority())
                .reversed().thenComparing(MesProWorkOrderDO::getRequestDate, Comparator.nullsLast(Comparator.naturalOrder()))
                .thenComparing(MesProWorkOrderDO::getId);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyHazardService.java
@@ -101,8 +101,12 @@
            throw exception(SAFETY_HAZARD_ASSIGNEE_REQUIRED);
        }
        var assignee = refResolver.requireEmployee(reqVO.getAssigneeId(), SAFETY_HAZARD_ASSIGNEE_REQUIRED);
        if (assignee.getUserId() == null) throw exception(SAFETY_HAZARD_ASSIGNEE_NO_ACCOUNT);
        adminUserService.validateUserList(List.of(assignee.getUserId()));
        Long notifyUserId = assignee.getUserId() != null ? assignee.getUserId() : reqVO.getNotifyUserId();
        if (notifyUserId == null) throw exception(SAFETY_HAZARD_ASSIGNEE_NO_ACCOUNT);
        if (assignee.getUserId() != null && reqVO.getNotifyUserId() != null && !assignee.getUserId().equals(reqVO.getNotifyUserId())) {
            throw cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException("责任人已绑定账号,通知接收账号必须与其一致");
        }
        adminUserService.validateUserList(List.of(notifyUserId));
        SafetyHazardDO update = new SafetyHazardDO();
        update.setId(hazard.getId());
        update.setAssigneeId(assignee.getId());
@@ -115,7 +119,7 @@
        }
        hazardMapper.updateById(update);
        NotifySendSingleToUserReqDTO notice = new NotifySendSingleToUserReqDTO();
        notice.setUserId(assignee.getUserId());
        notice.setUserId(notifyUserId);
        notice.setTemplateCode("safety_hazard_assigned");
        notice.setTemplateParams(Map.of("code", hazard.getCode(), "title", hazard.getTitle(),
                "deadline", reqVO.getRectifyDeadline() == null ? "未设置" : reqVO.getRectifyDeadline().toString()));
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyRiskSourceService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.mes.service.safety;
import cn.iocoder.yudao.framework.common.pojo.*;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.risk.SafetyRiskSourceSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyRiskSourceDO;
import cn.iocoder.yudao.module.mes.dal.mysql.safety.SafetyRiskSourceMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@Service public class SafetyRiskSourceService {
    @Resource private SafetyRiskSourceMapper mapper;
    public PageResult<SafetyRiskSourceDO> page(PageParam page, String name) {
        return mapper.selectPage(page, new LambdaQueryWrapperX<SafetyRiskSourceDO>().likeIfPresent(SafetyRiskSourceDO::getName,name).orderByDesc(SafetyRiskSourceDO::getId));
    }
    @Transactional(rollbackFor=Exception.class)
    public Long save(SafetyRiskSourceSaveReqVO req) {
        SafetyRiskSourceDO old = req.getId()==null ? null : mapper.selectById(req.getId());
        if (req.getId()!=null && old==null) throw invalidParamException("危险源不存在");
        String status;
        try { status=SafetyRiskThreshold.evaluate(req.getCurrentValue(),req.getLowerLimit(),req.getUpperLimit()); }
        catch (IllegalArgumentException e) { throw invalidParamException(e.getMessage()); }
        var row=BeanUtils.toBean(req,SafetyRiskSourceDO.class);
        row.setRiskStatus(status);
        row.setObservedAt(req.getCurrentValue()==null ? null : old==null || !Objects.equals(old.getCurrentValue(),req.getCurrentValue()) ? LocalDateTime.now() : old.getObservedAt());
        if (old==null) mapper.insert(row); else mapper.updateById(row);
        return row.getId();
    }
    public void delete(Long id) { if (mapper.selectById(id)==null) throw invalidParamException("危险源不存在"); mapper.deleteById(id); }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyRiskThreshold.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
package cn.iocoder.yudao.module.mes.service.safety;
import java.math.BigDecimal;
final class SafetyRiskThreshold {
    private SafetyRiskThreshold() {}
    static String evaluate(BigDecimal value, BigDecimal low, BigDecimal high) {
        if (low != null && high != null && low.compareTo(high) > 0) throw new IllegalArgumentException("下限不能大于上限");
        if (value == null || (low == null && high == null)) return "UNKNOWN";
        return (low != null && value.compareTo(low) < 0) || (high != null && value.compareTo(high) > 0) ? "ALARM" : "NORMAL";
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/listener/MesWmItemReceiptStatusListener.java
@@ -10,6 +10,8 @@
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.util.Objects;
/**
 * é‡‡è´­å…¥åº“单 BPM å®¡æ‰¹çŠ¶æ€ç›‘å¬å™¨
 *
@@ -48,6 +50,11 @@
            return;
        }
        // ä¸åŒä¸šåŠ¡çš„æµç¨‹å¯èƒ½ä½¿ç”¨ç›¸åŒçš„æ•°å­—ä¸šåŠ¡ä¸»é”®ï¼Œåªå¤„ç†è¯¥å…¥åº“å•ç»‘å®šçš„æµç¨‹å®žä¾‹ã€‚
        if (event.getId() == null || !Objects.equals(receipt.getProcessInstanceId(), event.getId())) {
            return;
        }
        // 3. æ ¡éªŒå…¥åº“单状态为审批中
        if (!MesWmItemReceiptStatusEnum.APPROVING.getStatus().equals(receipt.getStatus())) {
            log.warn("[onApplicationEvent] å…¥åº“单({}) ä¸å¤„于审批中状态,当前状态:{}", receiptId, receipt.getStatus());
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/api/returnsales/AfterSaleReturnSourceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.mes.api.returnsales;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.api.returnsales.dto.MesWmReturnSalesCreateReqDTO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AfterSaleReturnSourceTest {
    @Test
    void sourceReturnIdSurvivesBothConversions() {
        var request = new MesWmReturnSalesCreateReqDTO();
        request.setAfterSaleReturnId(73L);
        var save = BeanUtils.toBean(request, MesWmReturnSalesSaveReqVO.class);
        var entity = BeanUtils.toBean(save, MesWmReturnSalesDO.class);
        assertEquals(73L, entity.getAfterSaleReturnId());
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/collect/CollectOfflinePolicyTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.mes.service.collect;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
class CollectOfflinePolicyTest {
    private final LocalDateTime t = LocalDateTime.of(2026, 11, 6, 8, 0);
    private final Duration timeout = Duration.ofMinutes(5);
    @Test void recordsOnlyGapsAfterLastObservedSampleAndClipsAtQueryBounds() {
        var intervals = CollectOfflinePolicy.calculate(List.of(t, t.plusMinutes(2), t.plusMinutes(12)),
                t.plusMinutes(5), t.plusMinutes(20), timeout);
        assertEquals(2, intervals.size());
        assertEquals(t.plusMinutes(7), intervals.get(0).start());
        assertEquals(t.plusMinutes(12), intervals.get(0).end());
        assertEquals(t.plusMinutes(17), intervals.get(1).start());
        assertEquals(t.plusMinutes(20), intervals.get(1).end());
        assertTrue(CollectOfflinePolicy.calculate(List.of(), t, t.plusHours(1), timeout).isEmpty());
    }
    @Test void doesNotInferBeforeFirstSampleOrCountShortGaps() {
        var result = CollectOfflinePolicy.calculate(List.of(t.plusMinutes(5), t.plusMinutes(9)),
                t, t.plusMinutes(12), timeout);
        assertTrue(result.isEmpty());
    }
    @Test void openOfflineIntervalIsClippedToTheRequestedWindow() {
        var result = CollectOfflinePolicy.calculate(List.of(t), t.plusMinutes(10), t.plusMinutes(20), timeout);
        assertEquals(1, result.size());
        assertEquals(t.plusMinutes(10), result.getFirst().start());
        assertEquals(t.plusMinutes(20), result.getFirst().end());
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeMatchTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.mes.service.pd.knowledge;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class KnowledgeMatchTest {
    @Test
    void matchesOnlyWhenAllSpecifiedConditionsAreSatisfied() {
        var knowledge = new KnowledgeMatch.Criteria(10L, 20L, new BigDecimal("10"),
                new BigDecimal("35"), "EXPORT", Set.of("IEC-1", "IEC-2"));
        assertTrue(KnowledgeMatch.matches(knowledge, new KnowledgeMatch.Request(10L, 20L,
                new BigDecimal("10"), "EXPORT", Set.of("IEC-1", "IEC-2"))));
        assertFalse(KnowledgeMatch.matches(knowledge, new KnowledgeMatch.Request(10L, 20L,
                new BigDecimal("36"), "EXPORT", Set.of("IEC-1", "IEC-2"))));
        assertFalse(KnowledgeMatch.matches(knowledge, new KnowledgeMatch.Request(10L, 20L,
                new BigDecimal("20"), "DOMESTIC", Set.of("IEC-1", "IEC-2"))));
        assertFalse(KnowledgeMatch.matches(knowledge, new KnowledgeMatch.Request(10L, 20L,
                new BigDecimal("20"), "EXPORT", Set.of("IEC-1", "IEC-3"))));
        assertFalse(KnowledgeMatch.matches(knowledge, new KnowledgeMatch.Request(10L, null,
                new BigDecimal("20"), "EXPORT", Set.of("IEC-1", "IEC-2"))));
        assertFalse(KnowledgeMatch.matches(knowledge, new KnowledgeMatch.Request(11L, 20L,
                new BigDecimal("20"), "EXPORT", Set.of("IEC-1", "IEC-2"))));
    }
    @Test
    void unrestrictedKnowledgeMatchesWithoutInventingUnknownRequestValues() {
        var knowledge = new KnowledgeMatch.Criteria(null, null, null, null, "ALL", Set.of());
        assertTrue(KnowledgeMatch.matches(knowledge, new KnowledgeMatch.Request(null, null,
                null, null, Set.of())));
        var voltageRestricted = new KnowledgeMatch.Criteria(null, null, BigDecimal.ONE,
                null, "ALL", Set.of());
        assertFalse(KnowledgeMatch.matches(voltageRestricted, new KnowledgeMatch.Request(null,
                null, null, null, Set.of())));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeSearchServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,86 @@
package cn.iocoder.yudao.module.mes.service.pd.knowledge;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeStandardDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeStandardMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdProductBomDO;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdProductBomService;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class KnowledgeSearchServiceTest {
    @Test
    void missingBomReferenceIsNotRecommended() {
        var knowledgeMapper = mock(KnowledgeMapper.class);
        var standardMapper = mock(KnowledgeStandardMapper.class);
        var knowledge = new KnowledgeDO(); knowledge.setId(1L); knowledge.setBomId(501L); knowledge.setMarket("ALL");
        when(knowledgeMapper.selectList(any(Wrapper.class))).thenReturn(List.of(knowledge));
        when(standardMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
        var service = new KnowledgeSearchService();
        ReflectionTestUtils.setField(service, "knowledgeMapper", knowledgeMapper);
        ReflectionTestUtils.setField(service, "standardMapper", standardMapper);
        assertEquals(List.of(), service.match(new KnowledgeMatch.Request(null, null, null, null, Set.of())));
    }
    @Test
    void productBomMatchesByComponentRatherThanBomLineId() {
        var knowledgeMapper = mock(KnowledgeMapper.class);
        var standardMapper = mock(KnowledgeStandardMapper.class);
        var itemService = mock(MesMdItemService.class);
        var bomService = mock(MesMdProductBomService.class);
        var knowledge = new KnowledgeDO(); knowledge.setId(1L); knowledge.setProductItemId(42L);
        knowledge.setBomId(70L); knowledge.setMarket("ALL");
        var candidate = new MesMdProductBomDO().setId(502L).setItemId(42L).setBomItemId(70L).setStatus(0);
        when(knowledgeMapper.selectList(any(Wrapper.class))).thenReturn(List.of(knowledge));
        when(standardMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
        when(bomService.getProductBomListByItemId(42L)).thenReturn(List.of(candidate));
        var service = new KnowledgeSearchService();
        ReflectionTestUtils.setField(service, "knowledgeMapper", knowledgeMapper);
        ReflectionTestUtils.setField(service, "standardMapper", standardMapper);
        ReflectionTestUtils.setField(service, "itemService", itemService);
        ReflectionTestUtils.setField(service, "bomService", bomService);
        assertEquals(List.of(1L), service.match(new KnowledgeMatch.Request(42L, null, null, null, Set.of()))
                .stream().map(KnowledgeDO::getId).toList());
        org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
                () -> service.match(new KnowledgeMatch.Request(42L, 71L, null, null, Set.of())));
        candidate.setBomItemId(71L);
        assertEquals(List.of(), service.match(new KnowledgeMatch.Request(42L, null, null, null, Set.of())));
    }
    @Test
    void joinsStandardsToTheRightKnowledgeRecord() {
        var knowledgeMapper = mock(KnowledgeMapper.class);
        var standardMapper = mock(KnowledgeStandardMapper.class);
        var first = new KnowledgeDO(); first.setId(1L); first.setMarket("ALL");
        var second = new KnowledgeDO(); second.setId(2L); second.setMarket("ALL");
        var standard = new KnowledgeStandardDO(); standard.setKnowledgeId(2L); standard.setStandardCode("IEC-1");
        when(knowledgeMapper.selectList(any(Wrapper.class))).thenReturn(List.of(first, second));
        when(standardMapper.selectList(any(Wrapper.class))).thenReturn(List.of(standard));
        var service = new KnowledgeSearchService();
        ReflectionTestUtils.setField(service, "knowledgeMapper", knowledgeMapper);
        ReflectionTestUtils.setField(service, "standardMapper", standardMapper);
        var result = service.match(new KnowledgeMatch.Request(null, null, BigDecimal.ONE, null, Set.of("IEC-1")));
        assertEquals(List.of(2L), result.stream().map(KnowledgeDO::getId).toList());
    }
    @Test
    void noKnowledgeDoesNotQueryStandards() {
        var knowledgeMapper = mock(KnowledgeMapper.class);
        var standardMapper = mock(KnowledgeStandardMapper.class);
        when(knowledgeMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
        var service = new KnowledgeSearchService();
        ReflectionTestUtils.setField(service, "knowledgeMapper", knowledgeMapper);
        ReflectionTestUtils.setField(service, "standardMapper", standardMapper);
        assertEquals(List.of(), service.match(new KnowledgeMatch.Request(null, null, null, null, Set.of())));
        verifyNoInteractions(standardMapper);
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeWriteServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,90 @@
package cn.iocoder.yudao.module.mes.service.pd.knowledge;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeStandardMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdProductBomDO;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdProductBomService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
class KnowledgeWriteServiceTest {
    @Test
    void bomMustBelongToSelectedProduct() {
        var mapper = mock(KnowledgeMapper.class);
        var items = mock(MesMdItemService.class);
        var boms = mock(MesMdProductBomService.class);
        when(boms.getProductBomListByItemId(42L)).thenReturn(java.util.List.of(new MesMdProductBomDO().setId(8L).setItemId(99L).setBomItemId(7L).setStatus(0)));
        var service = new KnowledgeWriteService();
        ReflectionTestUtils.setField(service, "knowledgeMapper", mapper);
        ReflectionTestUtils.setField(service, "itemService", items);
        ReflectionTestUtils.setField(service, "bomService", boms);
        var input = new KnowledgeWriteService.SaveRequest(); input.setCode("KN-BOM"); input.setName("测试");
        input.setMarket("ALL"); input.setProductItemId(42L); input.setBomId(7L);
        assertThrows(RuntimeException.class, () -> service.create(input));
        verifyNoInteractions(mapper);
    }
    @Test
    void referenceRequiresBothKnowledgeAndRoute() {
        var mapper = mock(KnowledgeMapper.class);
        var service = new KnowledgeWriteService();
        ReflectionTestUtils.setField(service, "knowledgeMapper", mapper);
        assertThrows(RuntimeException.class, () -> service.referenceRoute(1L, null));
        verifyNoInteractions(mapper);
    }
    @Test
    void emptyResourceCapabilityIsRejectedBeforeWriting() {
        var mapper = mock(KnowledgeMapper.class);
        var service = new KnowledgeWriteService();
        ReflectionTestUtils.setField(service, "knowledgeMapper", mapper);
        var input = new KnowledgeWriteService.SaveRequest();
        input.setCode("KN-2"); input.setName("测试知识"); input.setMarket("ALL");
        var resource = new KnowledgeWriteService.ResourceInput(); resource.setResourceType("OTHER");
        resource.setCapabilityDescription(" "); input.setResources(List.of(resource));
        assertThrows(RuntimeException.class, () -> service.create(input));
        verifyNoInteractions(mapper);
    }
    @Test
    void revisionRequiresPublishedSource() {
        var mapper = mock(KnowledgeMapper.class);
        var draft = new KnowledgeDO(); draft.setId(7L); draft.setStatus(0);
        when(mapper.selectById(7L)).thenReturn(draft);
        var service = new KnowledgeWriteService();
        ReflectionTestUtils.setField(service, "knowledgeMapper", mapper);
        assertThrows(RuntimeException.class, () -> service.revise(7L));
        verify(mapper, never()).insert(any(KnowledgeDO.class));
    }
    @Test
    void invalidMarketIsRejectedBeforeWriting() {
        var mapper = mock(KnowledgeMapper.class);
        var service = new KnowledgeWriteService();
        ReflectionTestUtils.setField(service, "knowledgeMapper", mapper);
        var input = new KnowledgeWriteService.SaveRequest();
        input.setCode("KN-1"); input.setName("测试知识");
        assertThrows(RuntimeException.class, () -> service.create(input));
        verifyNoInteractions(mapper);
    }
    @Test
    void publishedVersionCannotBeEdited() {
        var mapper = mock(KnowledgeMapper.class);
        var standards = mock(KnowledgeStandardMapper.class);
        var existing = new KnowledgeDO(); existing.setId(9L); existing.setStatus(1);
        when(mapper.selectById(9L)).thenReturn(existing);
        var service = new KnowledgeWriteService();
        ReflectionTestUtils.setField(service, "knowledgeMapper", mapper);
        ReflectionTestUtils.setField(service, "standardMapper", standards);
        var input = new KnowledgeWriteService.SaveRequest();
        input.setId(9L);
        assertThrows(RuntimeException.class, () -> service.update(input));
        verifyNoInteractions(standards);
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesScheduleMaterialCheckTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.mes.service.pro.task.schedule;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class MesScheduleMaterialCheckTest {
    @Test void oneBatchCannotUseSameStockTwice() {
        var stock = Map.of(1L, new BigDecimal("10"));
        var first = MesScheduleMaterialCheck.allocate(stock, Map.of(1L, new BigDecimal("6")));
        assertEquals(0, first.get(1L).compareTo(new BigDecimal("4")));
        assertThrows(IllegalArgumentException.class, () -> MesScheduleMaterialCheck.allocate(first, Map.of(1L, new BigDecimal("5"))));
        assertEquals(0, stock.get(1L).compareTo(new BigDecimal("10")));
    }
    @Test void shortageOrInvalidRequirementIsRejected() {
        assertThrows(IllegalArgumentException.class, () -> MesScheduleMaterialCheck.allocate(Map.of(), Map.of(2L, BigDecimal.ONE)));
        assertThrows(IllegalArgumentException.class, () -> MesScheduleMaterialCheck.allocate(Map.of(2L, BigDecimal.TEN), Map.of(2L, BigDecimal.ZERO)));
    }
    @Test void sufficientStockCanBeAllocated() {
        assertEquals(0, MesScheduleMaterialCheck.allocate(Map.of(2L, BigDecimal.TEN), Map.of(2L, BigDecimal.TEN)).get(2L).signum());
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesSchedulePriorityTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.mes.service.pro.task.schedule;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class MesSchedulePriorityTest {
    @Test void higherPriorityFirstThenEarlierDeliveryThenId() {
        var low = new MesProWorkOrderDO().setId(1L).setSchedulePriority(0).setRequestDate(LocalDateTime.of(2026,1,1,0,0));
        var high = new MesProWorkOrderDO().setId(2L).setSchedulePriority(9).setRequestDate(LocalDateTime.of(2026,2,1,0,0));
        var older = new MesProWorkOrderDO().setId(3L).setSchedulePriority(9).setRequestDate(LocalDateTime.of(2026,1,2,0,0));
        assertEquals(List.of(3L,2L,1L), List.of(low,high,older).stream().sorted(MesSchedulePriority.comparator()).map(MesProWorkOrderDO::getId).toList());
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/safety/SafetyDocumentPolicyTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package cn.iocoder.yudao.module.mes.service.safety;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SafetyDocumentPolicyTest {
    @Test void onlyPendingDocumentsCanBeApprovedAndTheSubmitterCannotApprove() {
        assertThrows(RuntimeException.class, () -> SafetyDocumentPolicy.checkApproval("DRAFT", 1L, 2L));
        assertThrows(RuntimeException.class, () -> SafetyDocumentPolicy.checkApproval("PENDING", 1L, 1L));
        assertDoesNotThrow(() -> SafetyDocumentPolicy.checkApproval("PENDING", 1L, 2L));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/safety/SafetyHazardNoticeTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
package cn.iocoder.yudao.module.mes.service.safety;
import cn.iocoder.yudao.module.hrm.api.employee.dto.HrmEmployeeSimpleDTO;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.hazard.SafetyHazardAssignReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyHazardDO;
import cn.iocoder.yudao.module.mes.dal.mysql.safety.SafetyHazardMapper;
import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import org.mockito.ArgumentCaptor;
import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
import cn.iocoder.yudao.module.system.service.user.AdminUserService;
import java.util.List;
class SafetyHazardNoticeTest {
    @Test void unlinkedEmployeeCanUseExplicitNotificationAccount() {
        var service = new SafetyHazardService();
        var mapper = mock(SafetyHazardMapper.class);
        var resolver = mock(SafetyRefResolver.class);
        var notify = mock(NotifyMessageSendApi.class);
        var users = mock(AdminUserService.class);
        ReflectionTestUtils.setField(service, "hazardMapper", mapper);
        ReflectionTestUtils.setField(service, "refResolver", resolver);
        ReflectionTestUtils.setField(service, "notifyMessageSendApi", notify);
        ReflectionTestUtils.setField(service, "adminUserService", users);
        var hazard = new SafetyHazardDO(); hazard.setId(1L); hazard.setStatus("REPORTED"); hazard.setCode("HZ1"); hazard.setTitle("测试隐患");
        when(mapper.selectByIdForUpdate(1L)).thenReturn(hazard);
        var employee = new HrmEmployeeSimpleDTO(); employee.setId(2L); employee.setName("责任人");
        when(resolver.requireEmployee(eq(2L), any())).thenReturn(employee);
        when(notify.sendSingleMessageToAdmin(any())).thenReturn(9L);
        var req = new SafetyHazardAssignReqVO(); req.setId(1L); req.setAssigneeId(2L); req.setNotifyUserId(3L);
        service.assignHazard(req);
        var captor = ArgumentCaptor.forClass(NotifySendSingleToUserReqDTO.class);
        verify(notify).sendSingleMessageToAdmin(captor.capture());
        org.junit.jupiter.api.Assertions.assertEquals(3L, captor.getValue().getUserId());
        verify(users).validateUserList(List.of(3L));
    }
    @Test
    void disabledTemplateFailsAssignment() {
        var service = new SafetyHazardService();
        var mapper = mock(SafetyHazardMapper.class);
        var resolver = mock(SafetyRefResolver.class);
        var notify = mock(NotifyMessageSendApi.class);
        ReflectionTestUtils.setField(service, "hazardMapper", mapper);
        ReflectionTestUtils.setField(service, "refResolver", resolver);
        ReflectionTestUtils.setField(service, "notifyMessageSendApi", notify);
        ReflectionTestUtils.setField(service, "adminUserService", mock(AdminUserService.class));
        var hazard = new SafetyHazardDO(); hazard.setId(1L); hazard.setStatus("REPORTED"); hazard.setCode("HZ1"); hazard.setTitle("测试隐患");
        when(mapper.selectByIdForUpdate(1L)).thenReturn(hazard);
        var employee = new HrmEmployeeSimpleDTO(); employee.setId(2L); employee.setUserId(3L); employee.setName("责任人");
        when(resolver.requireEmployee(eq(2L), any())).thenReturn(employee);
        when(notify.sendSingleMessageToAdmin(any())).thenReturn(null);
        var req = new SafetyHazardAssignReqVO(); req.setId(1L); req.setAssigneeId(2L);
        assertThrows(RuntimeException.class, () -> service.assignHazard(req));
    }
    @Test
    void assignmentSendsNoticeToLinkedAccount() {
        var service = new SafetyHazardService();
        var mapper = mock(SafetyHazardMapper.class);
        var resolver = mock(SafetyRefResolver.class);
        var notify = mock(NotifyMessageSendApi.class);
        ReflectionTestUtils.setField(service, "hazardMapper", mapper);
        ReflectionTestUtils.setField(service, "refResolver", resolver);
        ReflectionTestUtils.setField(service, "notifyMessageSendApi", notify);
        ReflectionTestUtils.setField(service, "adminUserService", mock(AdminUserService.class));
        var hazard = new SafetyHazardDO(); hazard.setId(1L); hazard.setStatus("REPORTED"); hazard.setCode("HZ1"); hazard.setTitle("测试隐患");
        when(mapper.selectByIdForUpdate(1L)).thenReturn(hazard);
        var employee = new HrmEmployeeSimpleDTO(); employee.setId(2L); employee.setUserId(3L); employee.setName("责任人");
        when(resolver.requireEmployee(eq(2L), any())).thenReturn(employee);
        when(notify.sendSingleMessageToAdmin(any())).thenReturn(9L);
        when(mapper.updateById(any(SafetyHazardDO.class))).thenReturn(1);
        var req = new SafetyHazardAssignReqVO(); req.setId(1L); req.setAssigneeId(2L);
        service.assignHazard(req);
        var sent = ArgumentCaptor.forClass(NotifySendSingleToUserReqDTO.class);
        verify(notify).sendSingleMessageToAdmin(sent.capture());
        org.junit.jupiter.api.Assertions.assertEquals(3L, sent.getValue().getUserId());
        org.junit.jupiter.api.Assertions.assertEquals("HZ1", sent.getValue().getTemplateParams().get("code"));
    }
    @Test
    void rejectAssignmentWhenResponsibleEmployeeHasNoAccount() {
        var service = new SafetyHazardService();
        var mapper = mock(SafetyHazardMapper.class);
        var resolver = mock(SafetyRefResolver.class);
        var notify = mock(NotifyMessageSendApi.class);
        ReflectionTestUtils.setField(service, "hazardMapper", mapper);
        ReflectionTestUtils.setField(service, "refResolver", resolver);
        ReflectionTestUtils.setField(service, "notifyMessageSendApi", notify);
        var hazard = new SafetyHazardDO(); hazard.setId(1L); hazard.setStatus("REPORTED");
        when(mapper.selectByIdForUpdate(1L)).thenReturn(hazard);
        var employee = new HrmEmployeeSimpleDTO(); employee.setId(2L); employee.setName("责任人");
        when(resolver.requireEmployee(eq(2L), any())).thenReturn(employee);
        var req = new SafetyHazardAssignReqVO(); req.setId(1L); req.setAssigneeId(2L);
        assertThrows(RuntimeException.class, () -> service.assignHazard(req));
        verify(notify, never()).sendSingleMessageToAdmin(any());
        verify(mapper, never()).updateById(any(SafetyHazardDO.class));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/safety/SafetyRiskSourceServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.mes.service.safety;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.risk.SafetyRiskSourceSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyRiskSourceDO;
import cn.iocoder.yudao.module.mes.dal.mysql.safety.SafetyRiskSourceMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class SafetyRiskSourceServiceTest {
    @Test void savingAboveLimitCreatesAlarmWithObservationTime() {
        var service=new SafetyRiskSourceService();var mapper=mock(SafetyRiskSourceMapper.class);
        ReflectionTestUtils.setField(service,"mapper",mapper);
        var req=new SafetyRiskSourceSaveReqVO();req.setName("测试危险源");req.setCategory("MAJOR");req.setMetricName("温度");
        req.setUpperLimit(BigDecimal.TEN);req.setCurrentValue(new BigDecimal("11"));
        service.save(req);
        var captor=ArgumentCaptor.forClass(SafetyRiskSourceDO.class);verify(mapper).insert(captor.capture());
        assertEquals("ALARM",captor.getValue().getRiskStatus());assertNotNull(captor.getValue().getObservedAt());
    }
    @Test void invalidRangeDoesNotInsert() {
        var service=new SafetyRiskSourceService();var mapper=mock(SafetyRiskSourceMapper.class);
        ReflectionTestUtils.setField(service,"mapper",mapper);
        var req=new SafetyRiskSourceSaveReqVO();req.setLowerLimit(BigDecimal.TEN);req.setUpperLimit(BigDecimal.ONE);
        assertThrows(RuntimeException.class,()->service.save(req));verify(mapper,never()).insert(any(SafetyRiskSourceDO.class));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/safety/SafetyRiskThresholdTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
package cn.iocoder.yudao.module.mes.service.safety;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
class SafetyRiskThresholdTest {
    @Test void normalAndOutOfRange() {
        assertEquals("NORMAL", SafetyRiskThreshold.evaluate(new BigDecimal("5"), BigDecimal.ZERO, BigDecimal.TEN));
        assertEquals("ALARM", SafetyRiskThreshold.evaluate(new BigDecimal("11"), BigDecimal.ZERO, BigDecimal.TEN));
        assertEquals("ALARM", SafetyRiskThreshold.evaluate(new BigDecimal("-1"), BigDecimal.ZERO, BigDecimal.TEN));
    }
    @Test void noDataOrNoLimitIsUnknown() {
        assertEquals("UNKNOWN", SafetyRiskThreshold.evaluate(null, BigDecimal.ZERO, BigDecimal.TEN));
        assertEquals("UNKNOWN", SafetyRiskThreshold.evaluate(BigDecimal.ONE, null, null));
    }
    @Test void reversedLimitsAreRejected() {
        assertThrows(IllegalArgumentException.class, () -> SafetyRiskThreshold.evaluate(BigDecimal.ONE, BigDecimal.TEN, BigDecimal.ZERO));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptStatusListenerTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.mes.service.wm.itemreceipt;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemreceipt.MesWmItemReceiptDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemreceipt.MesWmItemReceiptMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmItemReceiptStatusEnum;
import cn.iocoder.yudao.module.mes.service.wm.itemreceipt.listener.MesWmItemReceiptStatusListener;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.mockito.Mockito.*;
class MesWmItemReceiptStatusListenerTest {
    @Test void ignoresAnotherProcessWithSameBusinessKey() {
        var listener = new MesWmItemReceiptStatusListener();
        var mapper = mock(MesWmItemReceiptMapper.class);
        var service = mock(MesWmItemReceiptService.class);
        ReflectionTestUtils.setField(listener, "itemReceiptMapper", mapper);
        ReflectionTestUtils.setField(listener, "itemReceiptService", service);
        var receipt = new MesWmItemReceiptDO().setId(12L).setProcessInstanceId("receipt-process").setStatus(MesWmItemReceiptStatusEnum.APPROVING.getStatus());
        when(mapper.selectById(12L)).thenReturn(receipt);
        var event = new BpmProcessInstanceStatusEvent(this).setBusinessKey("12").setId("unrelated-process").setStatus(2);
        listener.onApplicationEvent(event);
        verifyNoInteractions(service);
    }
    @Test void updatesMatchingProcess() {
        var listener = new MesWmItemReceiptStatusListener();
        var mapper = mock(MesWmItemReceiptMapper.class);
        var service = mock(MesWmItemReceiptService.class);
        ReflectionTestUtils.setField(listener, "itemReceiptMapper", mapper);
        ReflectionTestUtils.setField(listener, "itemReceiptService", service);
        when(mapper.selectById(12L)).thenReturn(new MesWmItemReceiptDO().setId(12L).setProcessInstanceId("receipt-process").setStatus(MesWmItemReceiptStatusEnum.APPROVING.getStatus()));
        listener.onApplicationEvent(new BpmProcessInstanceStatusEvent(this).setBusinessKey("12").setId("receipt-process").setStatus(2));
        verify(service).updateItemReceiptAuditStatus(12L, 2);
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesReturnInboundAfterSaleTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package cn.iocoder.yudao.module.mes.service.wm.returnsales;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.aftersales.api.AfterSaleTicketApi;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDetailDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmReturnSalesStatusEnum;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.math.BigDecimal;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
class MesReturnInboundAfterSaleTest {
    @Test
    void stockCompletionFailsIfLinkedAfterSaleServiceIsUnavailable() {
        var mapper = mock(MesWmReturnSalesMapper.class);
        var lines = mock(MesWmReturnSalesLineService.class);
        var details = mock(MesWmReturnSalesDetailService.class);
        @SuppressWarnings("unchecked") ObjectProvider<AfterSaleTicketApi> provider = mock(ObjectProvider.class);
        var doc = new MesWmReturnSalesDO().setId(42L).setStatus(MesWmReturnSalesStatusEnum.APPROVED.getStatus()).setAfterSaleReturnId(73L);
        when(mapper.selectById(42L)).thenReturn(doc);
        when(lines.getReturnSalesLineListByReturnId(42L)).thenReturn(List.of());
        when(details.getReturnSalesDetailListByReturnId(42L)).thenReturn(List.of());
        var service = new MesWmReturnSalesServiceImpl();
        ReflectionTestUtils.setField(service, "returnSalesMapper", mapper);
        ReflectionTestUtils.setField(service, "returnSalesLineService", lines);
        ReflectionTestUtils.setField(service, "returnSalesDetailService", details);
        ReflectionTestUtils.setField(service, "wmTransactionService", mock(MesWmTransactionService.class));
        ReflectionTestUtils.setField(service, "afterSaleTicketApiProvider", provider);
        assertThrows(RuntimeException.class, () -> service.stockReturnSales(42L));
    }
    @Test
    void stockCompletionNotifiesSourceAfterSaleReturn() {
        var mapper = mock(MesWmReturnSalesMapper.class);
        var lines = mock(MesWmReturnSalesLineService.class);
        var details = mock(MesWmReturnSalesDetailService.class);
        var transactions = mock(MesWmTransactionService.class);
        var api = mock(AfterSaleTicketApi.class);
        @SuppressWarnings("unchecked") ObjectProvider<AfterSaleTicketApi> provider = mock(ObjectProvider.class);
        when(provider.getIfAvailable()).thenReturn(api);
        var doc = new MesWmReturnSalesDO().setId(42L).setStatus(MesWmReturnSalesStatusEnum.APPROVED.getStatus()).setAfterSaleReturnId(73L);
        when(mapper.selectById(42L)).thenReturn(doc);
        var line = new MesWmReturnSalesLineDO().setId(8L).setItemId(9L).setQuantity(BigDecimal.ONE);
        var detail = new MesWmReturnSalesDetailDO().setLineId(8L).setItemId(9L).setQuantity(BigDecimal.ONE);
        when(lines.getReturnSalesLineListByReturnId(42L)).thenReturn(List.of(line));
        when(details.getReturnSalesDetailListByReturnId(42L)).thenReturn(List.of(detail));
        when(api.onReturnInboundFinished(73L)).thenReturn(CommonResult.success(true));
        var service = new MesWmReturnSalesServiceImpl();
        ReflectionTestUtils.setField(service, "returnSalesMapper", mapper);
        ReflectionTestUtils.setField(service, "returnSalesLineService", lines);
        ReflectionTestUtils.setField(service, "returnSalesDetailService", details);
        ReflectionTestUtils.setField(service, "wmTransactionService", transactions);
        ReflectionTestUtils.setField(service, "afterSaleTicketApiProvider", provider);
        service.stockReturnSales(42L);
        verify(api).onReturnInboundFinished(73L);
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesReturnQualityAfterSaleTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
package cn.iocoder.yudao.module.mes.service.wm.returnsales;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.aftersales.api.AfterSaleTicketApi;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesLineMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.mockito.Mockito.*;
class MesReturnQualityAfterSaleTest {
    @Test
    void mixedQualityIsNotReportedAsFullyPassed() {
        var mapper = mock(MesWmReturnSalesLineMapper.class);
        var docs = mock(MesWmReturnSalesService.class);
        var api = mock(AfterSaleTicketApi.class);
        @SuppressWarnings("unchecked") ObjectProvider<AfterSaleTicketApi> provider = mock(ObjectProvider.class);
        when(provider.getIfAvailable()).thenReturn(api);
        var passed = new MesWmReturnSalesLineDO().setId(8L).setReturnId(42L).setQualityStatus(MesWmQualityStatusEnum.PASS.getStatus());
        var failed = new MesWmReturnSalesLineDO().setId(9L).setReturnId(42L).setQualityStatus(MesWmQualityStatusEnum.FAIL.getStatus());
        when(mapper.selectById(8L)).thenReturn(passed);
        when(mapper.selectListByReturnId(42L)).thenReturn(List.of(passed, failed));
        when(docs.validateReturnSalesExists(42L)).thenReturn(new MesWmReturnSalesDO().setId(42L).setAfterSaleReturnId(73L));
        when(api.onReturnQualityFinished(73L, "存在不合格", null)).thenReturn(CommonResult.success(true));
        var service = new MesWmReturnSalesLineServiceImpl();
        ReflectionTestUtils.setField(service, "returnSalesLineMapper", mapper);
        ReflectionTestUtils.setField(service, "returnSalesService", docs);
        ReflectionTestUtils.setField(service, "afterSaleTicketApiProvider", provider);
        service.updateReturnSalesLineWhenRqcFinish(8L, 42L, 1, BigDecimal.ONE, BigDecimal.ZERO);
        verify(api).onReturnQualityFinished(73L, "存在不合格", null);
    }
    @Test
    void pendingLinesDoNotReportCompletion() {
        var mapper = mock(MesWmReturnSalesLineMapper.class);
        var docs = mock(MesWmReturnSalesService.class);
        var api = mock(AfterSaleTicketApi.class);
        @SuppressWarnings("unchecked") ObjectProvider<AfterSaleTicketApi> provider = mock(ObjectProvider.class);
        var line = new MesWmReturnSalesLineDO().setId(8L).setReturnId(42L).setQualityStatus(MesWmQualityStatusEnum.PASS.getStatus());
        var pending = new MesWmReturnSalesLineDO().setId(9L).setReturnId(42L).setQualityStatus(MesWmQualityStatusEnum.PENDING.getStatus());
        when(mapper.selectById(8L)).thenReturn(line);
        when(mapper.selectListByReturnId(42L)).thenReturn(List.of(line, pending));
        var service = new MesWmReturnSalesLineServiceImpl();
        ReflectionTestUtils.setField(service, "returnSalesLineMapper", mapper);
        ReflectionTestUtils.setField(service, "returnSalesService", docs);
        ReflectionTestUtils.setField(service, "afterSaleTicketApiProvider", provider);
        service.updateReturnSalesLineWhenRqcFinish(8L, 42L, 1, BigDecimal.ONE, BigDecimal.ZERO);
        verifyNoInteractions(api);
        verify(docs, never()).updateReturnSalesStatus(anyLong(), anyInt());
    }
    @Test
    void allPassedResultsAreReportedToSourceReturn() {
        var mapper = mock(MesWmReturnSalesLineMapper.class);
        var docs = mock(MesWmReturnSalesService.class);
        var api = mock(AfterSaleTicketApi.class);
        @SuppressWarnings("unchecked") ObjectProvider<AfterSaleTicketApi> provider = mock(ObjectProvider.class);
        when(provider.getIfAvailable()).thenReturn(api);
        var line = new MesWmReturnSalesLineDO().setId(8L).setReturnId(42L).setQualityStatus(MesWmQualityStatusEnum.PASS.getStatus());
        when(mapper.selectById(8L)).thenReturn(line);
        when(mapper.selectListByReturnId(42L)).thenReturn(List.of(line));
        when(docs.validateReturnSalesExists(42L)).thenReturn(new MesWmReturnSalesDO().setId(42L).setAfterSaleReturnId(73L));
        when(api.onReturnQualityFinished(73L, "合格", null)).thenReturn(CommonResult.success(true));
        var service = new MesWmReturnSalesLineServiceImpl();
        ReflectionTestUtils.setField(service, "returnSalesLineMapper", mapper);
        ReflectionTestUtils.setField(service, "returnSalesService", docs);
        ReflectionTestUtils.setField(service, "afterSaleTicketApiProvider", provider);
        service.updateReturnSalesLineWhenRqcFinish(8L, 42L, 1, BigDecimal.ONE, BigDecimal.ZERO);
        verify(api).onReturnQualityFinished(73L, "合格", null);
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/salesnotice/SalesNoticeOrderGuardTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.mes.service.wm.salesnotice;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.salesnotice.MesWmSalesNoticeMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.salesnotice.MesWmSalesNoticeDO;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
class SalesNoticeOrderGuardTest {
    @Test
    void cancelledOrderCannotGenerateDeliveryNotice() {
        var orders = mock(ErpSaleOrderApi.class);
        var notices = mock(MesWmSalesNoticeMapper.class);
        when(orders.lockSaleOrderForNotice(1L, null)).thenThrow(new IllegalArgumentException("订单已作废"));
        var service = new MesWmSalesNoticeServiceImpl();
        ReflectionTestUtils.setField(service, "saleOrderApi", orders);
        ReflectionTestUtils.setField(service, "salesNoticeMapper", notices);
        assertThrows(RuntimeException.class, () -> service.generateSalesNoticeFromSaleOrder(1L));
        verify(notices, never()).insert(any(MesWmSalesNoticeDO.class));
    }
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/tender/SrmBidAwardServiceImpl.java
@@ -181,11 +181,7 @@
        if (CollUtil.isEmpty(quotes)) {
            throw ServiceExceptionUtil.exception(ErrorCodeConstants.AWARD_QUOTE_NOT_EXISTS);
        }
        Map<Long, SrmSupplierQuoteDO> quoteMap = quotes.stream()
                .collect(Collectors.toMap(
                        SrmSupplierQuoteDO::getTenderMaterialId,
                        Function.identity(),
                        (a, b) -> a));
        Map<Long, SrmSupplierQuoteDO> quoteMap = SrmPurchaseQuoteValidator.validate(materials, quotes);
        // 5. æ‰¹é‡æŸ¥ MDM ç‰©æ–™ï¼ŒèŽ·å–è®¡é‡å•ä½
        Set<Long> productIds = materials.stream()
@@ -193,6 +189,9 @@
                .collect(Collectors.toSet());
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemList(productIds).getCheckedData()
                .stream().collect(Collectors.toMap(MdmItemRespDTO::getId, Function.identity()));
        if (productIds.stream().anyMatch(productId -> !itemMap.containsKey(productId))) {
            throw ServiceExceptionUtil.invalidParamException("存在无法匹配的物料,请先维护物料后再生成采购订单");
        }
        // 6. æž„建订单项(报价为“单价”口径:行金额 = æŠ¥ä»·å•ä»· Ã— æ•°é‡ï¼›
        //    å®šæ ‡é‡‘额与报价合计不一致时按行金额比例分摊,订单行单价仍取报价单价)
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/tender/SrmPurchaseQuoteValidator.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package cn.iocoder.yudao.module.srm.service.tender;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmSupplierQuoteDO;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmTenderMaterialDO;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
/** ç”Ÿæˆé‡‡è´­å•前核对每一项物料,避免静默丢失订单明细。 */
final class SrmPurchaseQuoteValidator {
    private SrmPurchaseQuoteValidator() {}
    static Map<Long, SrmSupplierQuoteDO> validate(List<SrmTenderMaterialDO> materials, List<SrmSupplierQuoteDO> quotes) {
        if (materials == null || materials.isEmpty()) throw invalidParamException("招标项目没有物料,不能生成采购订单");
        Map<Long, SrmSupplierQuoteDO> byMaterial = new HashMap<>();
        if (quotes != null) for (var quote : quotes) {
            if (quote.getTenderMaterialId() == null || byMaterial.putIfAbsent(quote.getTenderMaterialId(), quote) != null)
                throw invalidParamException("存在重复或无物料的报价,请先核对");
        }
        for (var material : materials) {
            var quote = byMaterial.get(material.getId());
            if (material.getId() == null || material.getProductId() == null || material.getQuantity() == null
                    || material.getQuantity().signum() <= 0 || quote == null || quote.getQuotePrice() == null
                    || quote.getQuotePrice().signum() < 0)
                throw invalidParamException("存在未报价或物料数量无效的明细,请补齐后再生成采购订单");
        }
        if (byMaterial.size() != materials.size()) throw invalidParamException("报价包含不属于当前项目的物料,请核对");
        return byMaterial;
    }
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/tender/SrmTenderProjectServiceImpl.java
@@ -342,11 +342,7 @@
        if (CollUtil.isEmpty(quotes)) {
            throw ServiceExceptionUtil.exception(ErrorCodeConstants.AWARD_QUOTE_NOT_EXISTS);
        }
        Map<Long, SrmSupplierQuoteDO> quoteMap = quotes.stream()
                .collect(Collectors.toMap(
                        SrmSupplierQuoteDO::getTenderMaterialId,
                        Function.identity(),
                        (a, b) -> a));
        Map<Long, SrmSupplierQuoteDO> quoteMap = SrmPurchaseQuoteValidator.validate(materials, quotes);
        // æŸ¥ MDM ç‰©æ–™
        Set<Long> productIds = materials.stream()
@@ -354,6 +350,9 @@
                .collect(Collectors.toSet());
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemList(productIds).getCheckedData()
                .stream().collect(Collectors.toMap(MdmItemRespDTO::getId, Function.identity()));
        if (productIds.stream().anyMatch(id -> !itemMap.containsKey(id))) {
            throw ServiceExceptionUtil.invalidParamException("存在无法匹配的物料,请先维护物料后再生成采购订单");
        }
        // è®¡ç®—价格(报价为“单价”口径:行金额 = æŠ¥ä»·å•ä»· Ã— æ•°é‡ï¼›
        // å®šæ ‡é‡‘额与报价合计不一致时按行金额比例分摊,订单行单价仍取报价单价)
yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/tender/SrmPurchaseQuoteValidationTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.srm.service.tender;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmSupplierQuoteDO;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmTenderMaterialDO;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class SrmPurchaseQuoteValidationTest {
    private SrmTenderMaterialDO material(long id) { return new SrmTenderMaterialDO().setId(id).setProductId(id).setQuantity(BigDecimal.ONE); }
    private SrmSupplierQuoteDO quote(long id) { return new SrmSupplierQuoteDO().setTenderMaterialId(id).setQuotePrice(BigDecimal.TEN); }
    @Test void missingQuoteCannotCreatePartialOrder() {
        assertThrows(RuntimeException.class, () -> SrmPurchaseQuoteValidator.validate(List.of(material(1),material(2)), List.of(quote(1))));
    }
    @Test void duplicateQuoteCannotSilentlyChooseOne() {
        assertThrows(RuntimeException.class, () -> SrmPurchaseQuoteValidator.validate(List.of(material(1)), List.of(quote(1),quote(1))));
    }
    @Test void completeQuoteProducesAllMaterials() {
        assertEquals(2, SrmPurchaseQuoteValidator.validate(List.of(material(1),material(2)), List.of(quote(1),quote(2))).size());
    }
}
yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/tender/SrmQuoteOwnershipTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.srm.service.tender;
import cn.iocoder.yudao.module.srm.controller.admin.tender.vo.SrmSupplierQuoteSaveReqVO;
import cn.iocoder.yudao.module.srm.dal.dataobject.*;
import cn.iocoder.yudao.module.srm.dal.mysql.*;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class SrmQuoteOwnershipTest {
    @Test void rejectsMaterialFromAnotherProject() {
        var service = new SrmTenderBidServiceImpl();
        var bids = mock(SrmTenderBidMapper.class); var projects = mock(SrmTenderProjectMapper.class);
        var quotes = mock(SrmSupplierQuoteMapper.class); var materials = mock(SrmTenderMaterialMapper.class);
        ReflectionTestUtils.setField(service, "tenderBidMapper", bids);
        ReflectionTestUtils.setField(service, "tenderProjectMapper", projects);
        ReflectionTestUtils.setField(service, "supplierQuoteMapper", quotes);
        ReflectionTestUtils.setField(service, "tenderMaterialMapper", materials);
        var bid = new SrmTenderBidDO(); bid.setId(1L); bid.setTenderProjectId(10L); bid.setBidStatus(0);
        var project = new SrmTenderProjectDO(); project.setId(10L); project.setTenderStatus(10);
        var material = new SrmTenderMaterialDO(); material.setId(9L); material.setTenderProjectId(11L);
        when(bids.selectById(1L)).thenReturn(bid); when(projects.selectById(10L)).thenReturn(project);
        when(materials.selectById(9L)).thenReturn(material);
        var req = new SrmSupplierQuoteSaveReqVO(); req.setBidId(1L); req.setTenderMaterialId(9L); req.setQuotePrice(BigDecimal.ONE);
        assertThrows(RuntimeException.class, () -> service.createQuote(req));
        verify(quotes, never()).insert(any(SrmSupplierQuoteDO.class));
    }
    @Test void rejectsEditingQuoteWithAnotherBidId() {
        var service = new SrmTenderBidServiceImpl();
        var bids = mock(SrmTenderBidMapper.class); var projects = mock(SrmTenderProjectMapper.class);
        var quotes = mock(SrmSupplierQuoteMapper.class); var materials = mock(SrmTenderMaterialMapper.class);
        ReflectionTestUtils.setField(service, "tenderBidMapper", bids);
        ReflectionTestUtils.setField(service, "tenderProjectMapper", projects);
        ReflectionTestUtils.setField(service, "supplierQuoteMapper", quotes);
        ReflectionTestUtils.setField(service, "tenderMaterialMapper", materials);
        var bid = new SrmTenderBidDO(); bid.setId(1L); bid.setTenderProjectId(10L); bid.setBidStatus(0);
        var project = new SrmTenderProjectDO(); project.setId(10L); project.setTenderStatus(10);
        var quote = new SrmSupplierQuoteDO(); quote.setId(5L); quote.setBidId(2L); quote.setTenderMaterialId(9L);
        when(bids.selectById(1L)).thenReturn(bid); when(projects.selectById(10L)).thenReturn(project);
        when(quotes.selectById(5L)).thenReturn(quote);
        var req = new SrmSupplierQuoteSaveReqVO(); req.setId(5L); req.setBidId(1L); req.setTenderMaterialId(9L); req.setQuotePrice(BigDecimal.ONE);
        assertThrows(RuntimeException.class, () -> service.updateQuote(req));
        verify(quotes, never()).updateById(any(SrmSupplierQuoteDO.class));
    }
}
yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/tender/SrmTenderConfigServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.srm.service.tender;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmTenderConfigDO;
import cn.iocoder.yudao.module.srm.dal.mysql.SrmTenderConfigMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.*;
class SrmTenderConfigServiceTest {
    @Test void readsAndUpdatesTheSameMostRecentlyEditedConfiguration() {
        var mapper = mock(SrmTenderConfigMapper.class);
        var config = new SrmTenderConfigDO(); config.setId(2L); config.setDefaultBiddingMode("FULL");
        when(mapper.selectLatest()).thenReturn(config);
        var service = new SrmTenderConfigServiceImpl();
        ReflectionTestUtils.setField(service, "srmTenderConfigMapper", mapper);
        assertSame(config, service.getConfig());
        service.saveConfig("SIMPLE");
        verify(mapper).updateById(config);
    }
}