9 小时以前 74ad846852f42b4e2258bfddd5c2a33cc1ce9847
feat(mes): 新增工序等待时长和报工数量配置功能

- 在 MesProProcessDO 中新增 waitDuration 和 needReportQuantity 字段
- 在 MesProTaskDO 中新增 waitStartTime 字段用于记录倒计时开始时间
- 添加 PRO_TASK_WAIT_NOT_CONFIGURED、PRO_TASK_WAIT_NOT_STARTED 等错误码
- 实现 validateWaitDuration 方法校验工序等待时长倒计时逻辑
- 添加 startWaitCountdown 接口用于开始报工倒计时
- 在报工审批流程中集成等待时长校验机制
- 支持工序配置不需要填写报工数量的场景
- 优化报工数量为空时的处理逻辑,避免误扣料
- 添加产品入库单自动生成和批量上架功能
- 新增 PRODUCTRECPT_CODE 自动编码规则
- 添加 DEFECT_WAREHOUSE 配置相关错误提示
已添加6个文件
已修改29个文件
803 ■■■■■ 文件已修改
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/process/vo/MesProProcessRespVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/process/vo/MesProProcessSaveReqVO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/route/MesProRouteProcessController.java 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/route/vo/process/MesProRouteProcessRespVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/task/MesProTaskController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/task/vo/MesProTaskRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/task/vo/MesProTaskStartWaitCountdownReqVO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/MesWmProductReceiptController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/vo/MesWmProductReceiptRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/vo/MesWmProductReceiptSaveReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/vo/MesWmProductReceiptStockReqVO.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/vo/line/MesWmProductReceiptLineRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/vo/line/MesWmProductReceiptLineSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/process/MesProProcessDO.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/task/MesProTaskDO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productreceipt/MesWmProductReceiptDO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productreceipt/MesWmProductReceiptLineDO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/warehouse/MesWmWarehouseAreaDO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/warehouse/MesWmWarehouseDO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/warehouse/MesWmWarehouseLocationDO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/task/MesProTaskMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productreceipt/MesWmProductReceiptMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/redis/RedisKeyConstants.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/redis/pro/task/MesProTaskWaitRemindRedisDAO.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/md/autocode/MesMdAutoCodeRuleCodeEnum.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/job/pro/task/MesProTaskWaitRemindJob.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/feedback/MesProFeedbackServiceImpl.java 98 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskServiceImpl.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskWaitRemindService.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskWaitRemindServiceImpl.java 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productproduce/MesWmProductProduceServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productreceipt/MesWmProductReceiptService.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productreceipt/MesWmProductReceiptServiceImpl.java 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/process/vo/MesProProcessRespVO.java
@@ -7,6 +7,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - MES ç”Ÿäº§å·¥åº Response VO")
@@ -34,6 +35,14 @@
    @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
    private Integer status;
    @ExcelProperty("等待时长(小时)")
    @Schema(description = "等待时长(小时),报工前需倒计时等待", example = "2.00")
    private BigDecimal waitDuration;
    @ExcelProperty("是否需要填写报工数量")
    @Schema(description = "是否需要填写报工数量", example = "true")
    private Boolean needReportQuantity;
    @ExcelProperty("备注")
    @Schema(description = "备注", example = "金属板材下料")
    private String remark;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/process/vo/MesProProcessSaveReqVO.java
@@ -5,6 +5,8 @@
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
@Schema(description = "管理后台 - MES ç”Ÿäº§å·¥åºæ–°å¢ž/修改 Request VO")
@Data
public class MesProProcessSaveReqVO {
@@ -27,6 +29,12 @@
    @NotNull(message = "状态不能为空")
    private Integer status;
    @Schema(description = "等待时长(小时),报工前需倒计时等待", example = "2.00")
    private BigDecimal waitDuration;
    @Schema(description = "是否需要填写报工数量", example = "true")
    private Boolean needReportQuantity;
    @Schema(description = "备注", example = "金属板材下料")
    private String remark;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/route/MesProRouteProcessController.java
@@ -166,7 +166,9 @@
        for (int i = 0; i < result.size(); i++) {
            MesProRouteProcessRespVO vo = result.get(i);
            MapUtils.findAndThen(processMap, vo.getProcessId(),
                    process -> vo.setProcessCode(process.getCode()).setProcessName(process.getName()));
                    process -> vo.setProcessCode(process.getCode()).setProcessName(process.getName())
                            .setWaitDuration(process.getWaitDuration())
                            .setNeedReportQuantity(process.getNeedReportQuantity()));
            MapUtils.findAndThen(processMap, vo.getNextProcessId(),
                    nextProcess -> vo.setNextProcessName(nextProcess.getName()));
            // äº§å‡ºäº§å“ï¼ˆæ”¯æŒå¤šé€‰ï¼‰
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/route/vo/process/MesProRouteProcessRespVO.java
@@ -3,6 +3,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@@ -58,6 +59,12 @@
    @Schema(description = "是否倒冲(true=报工时按BOM自动扣料;false=不自动扣料)", example = "true")
    private Boolean backflushFlag;
    @Schema(description = "等待时长(小时),报工前需倒计时等待", example = "2.00")
    private BigDecimal waitDuration;
    @Schema(description = "是否需要填写报工数量(false=报工数量允许为空)", example = "true")
    private Boolean needReportQuantity;
    @Schema(description = "产出产品物料编号(第一个,向后兼容)", example = "100")
    private Long outputItemId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/task/MesProTaskController.java
@@ -13,6 +13,7 @@
import cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.MesProTaskAutoGeneratePreviewRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.MesProTaskAutoGenerateReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.MesProTaskPauseReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.MesProTaskStartWaitCountdownReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.MesProTaskResumeReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.MesProTaskPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.MesProTaskRespVO;
@@ -59,6 +60,7 @@
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.*;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
@@ -151,6 +153,13 @@
        return success(true);
    }
    @PostMapping("/start-wait-countdown")
    @Operation(summary = "开始报工倒计时", description = "工序配置了等待时长时,点击开始倒计时,记录倒计时开始时间")
    @PreAuthorize("@ss.hasPermission('mes:pro-task:update')")
    public CommonResult<LocalDateTime> startWaitCountdown(@Valid @RequestBody MesProTaskStartWaitCountdownReqVO reqVO) {
        return success(taskService.startWaitCountdown(reqVO.getTaskId()));
    }
    @PostMapping("/auto-generate/preview")
    @Operation(summary = "一键排产预览")
    @PreAuthorize("@ss.hasPermission('mes:pro-task:create')")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/task/vo/MesProTaskRespVO.java
@@ -92,6 +92,9 @@
    @Schema(description = "调整数量", example = "0")
    private BigDecimal changedQuantity;
    @Schema(description = "报工倒计时开始时间")
    private LocalDateTime waitStartTime;
    @Schema(description = "客户编号", example = "300")
    private Long clientId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/task/vo/MesProTaskStartWaitCountdownReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - ç”Ÿäº§ä»»åŠ¡å¼€å§‹æŠ¥å·¥å€’è®¡æ—¶ Request VO")
@Data
public class MesProTaskStartWaitCountdownReqVO {
    @Schema(description = "生产任务编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    @NotNull(message = "生产任务编号不能为空")
    private Long taskId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/MesWmProductReceiptController.java
@@ -11,6 +11,7 @@
import cn.iocoder.yudao.module.mes.controller.admin.wm.productreceipt.vo.MesWmProductReceiptPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productreceipt.vo.MesWmProductReceiptRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productreceipt.vo.MesWmProductReceiptSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productreceipt.vo.MesWmProductReceiptStockReqVO;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
@@ -131,6 +132,14 @@
        return success(true);
    }
    @PostMapping("/submit-stock")
    @Operation(summary = "批量上架(勾选是否入库 + ä»“库/库区/库位分配数量)")
    @PreAuthorize("@ss.hasPermission('mes:wm-product-receipt:update')")
    public CommonResult<Boolean> submitStock(@Valid @RequestBody MesWmProductReceiptStockReqVO reqVO) {
        productReceiptService.submitStock(reqVO);
        return success(true);
    }
    @PutMapping("/finish")
    @Operation(summary = "执行入库")
    @Parameter(name = "id", description = "编号", required = true)
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/vo/MesWmProductReceiptRespVO.java
@@ -34,6 +34,9 @@
    @Schema(description = "产品物料编号", example = "1")
    private Long itemId;
    @Schema(description = "来源报工编号", example = "1")
    private Long feedbackId;
    @Schema(description = "产品物料编码", example = "P001")
    @ExcelProperty("产品物料编码")
    private String itemCode;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/vo/MesWmProductReceiptSaveReqVO.java
@@ -25,6 +25,9 @@
    @Schema(description = "生产工单编号", example = "1")
    private Long workOrderId;
    @Schema(description = "来源报工编号", example = "1")
    private Long feedbackId;
    @Schema(description = "收货日期", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "收货日期不能为空")
    private LocalDateTime receiptDate;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/vo/MesWmProductReceiptStockReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
package cn.iocoder.yudao.module.mes.controller.admin.wm.productreceipt.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Schema(description = "管理后台 - MES äº§å“å…¥åº“单批量上架(勾选是否入库 + ä»“库/库区/库位分配) Request VO")
@Data
public class MesWmProductReceiptStockReqVO {
    @Schema(description = "产品入库单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "产品入库单编号不能为空")
    private Long receiptId;
    @Schema(description = "行上架列表")
    @Valid
    private List<Row> rows;
    @Schema(description = "入库单行上架")
    @Data
    public static class Row {
        @Schema(description = "行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
        @NotNull(message = "行编号不能为空")
        private Long lineId;
        @Schema(description = "是否入库", example = "true")
        private Boolean isStock;
        @Schema(description = "仓库/库区/库位分配明细(isStock=true æ—¶å¿…须,且数量之和=行收货数量)")
        @Valid
        private List<Detail> details;
    }
    @Schema(description = "仓库/库区/库位分配明细")
    @Data
    public static class Detail {
        @Schema(description = "仓库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
        @NotNull(message = "仓库编号不能为空")
        private Long warehouseId;
        @Schema(description = "库区编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
        @NotNull(message = "库区编号不能为空")
        private Long locationId;
        @Schema(description = "库位编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
        @NotNull(message = "库位编号不能为空")
        private Long areaId;
        @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "50.00")
        @NotNull(message = "数量不能为空")
        private BigDecimal quantity;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/vo/line/MesWmProductReceiptLineRespVO.java
@@ -43,6 +43,12 @@
    @Schema(description = "批次号", example = "BATCH20260301001")
    private String batchCode;
    @Schema(description = "质量状态", example = "1")
    private Integer qualityStatus;
    @Schema(description = "是否入库", example = "false")
    private Boolean isStock;
    @Schema(description = "备注", example = "备注")
    private String remark;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productreceipt/vo/line/MesWmProductReceiptLineSaveReqVO.java
@@ -36,6 +36,12 @@
    @Schema(description = "批次号", example = "BATCH20260301001")
    private String batchCode;
    @Schema(description = "质量状态", example = "1")
    private Integer qualityStatus;
    @Schema(description = "是否入库", example = "false")
    private Boolean isStock;
    @Schema(description = "备注", example = "备注")
    private String remark;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/process/MesProProcessDO.java
@@ -6,6 +6,8 @@
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
/**
 * MES ç”Ÿäº§å·¥åº DO
 *
@@ -45,6 +47,18 @@
     */
    private Integer status;
    /**
     * ç­‰å¾…时长(小时)
     *
     * æœ‰å€¼ï¼ˆ>0)时,该工序报工前需先点击"开始倒计时",倒计时结束才能报工
     */
    private BigDecimal waitDuration;
    /**
     * æ˜¯å¦éœ€è¦å¡«å†™æŠ¥å·¥æ•°é‡
     *
     * true=报工必须填写数量;false=报工数量允许为空
     */
    private Boolean needReportQuantity;
    /**
     * å¤‡æ³¨
     */
    private String remark;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/task/MesProTaskDO.java
@@ -99,6 +99,12 @@
     * è°ƒæ•´æ•°é‡
     */
    private BigDecimal changedQuantity;
    /**
     * æŠ¥å·¥å€’计时开始时间
     *
     * å·¥åºé…ç½®äº†ç­‰å¾…时长时,操作员点击"开始倒计时"记录;倒计时结束才能报工
     */
    private LocalDateTime waitStartTime;
    /**
     * å®¢æˆ·ç¼–号
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productreceipt/MesWmProductReceiptDO.java
@@ -51,6 +51,13 @@
     */
    private Long itemId;
    /**
     * æ¥æºæŠ¥å·¥ç¼–号
     *
     * å…³è” {@link cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO#getId()},
     * ä¸€æ¬¡æŠ¥å·¥=一条产品入库单,用于幂等与追溯
     */
    private Long feedbackId;
    /**
     * æ”¶è´§æ—¥æœŸ
     */
    private LocalDateTime receiptDate;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productreceipt/MesWmProductReceiptLineDO.java
@@ -3,6 +3,7 @@
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.enums.DictTypeConstants;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -63,6 +64,18 @@
     */
    private String batchCode;
    /**
     * è´¨é‡çŠ¶æ€
     *
     * å­—å…¸ {@link DictTypeConstants#MES_WM_QUALITY_STATUS},报工产出行带入(PASS/FAIL)
     */
    private Integer qualityStatus;
    /**
     * æ˜¯å¦å…¥åº“
     *
     * true=已勾选入库并分配仓库/库区/库位;false/空=执行入库时默认进不合格仓库
     */
    private Boolean isStock;
    /**
     * å¤‡æ³¨
     */
    private String remark;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/warehouse/MesWmWarehouseAreaDO.java
@@ -27,6 +27,11 @@
    public static final String WIP_VIRTUAL_AREA = "WIP_VIRTUAL_AREA";
    /**
     * ä¸åˆæ ¼åº“位编码(配合 {@link MesWmWarehouseDO#DEFECT_WAREHOUSE} ä½¿ç”¨ï¼‰
     */
    public static final String DEFECT_AREA = "DEFECT_AREA";
    /**
     * ç¼–号
     */
    @TableId
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/warehouse/MesWmWarehouseDO.java
@@ -31,6 +31,14 @@
    public static final String WIP_VIRTUAL_WAREHOUSE = "WIP_VIRTUAL_WAREHOUSE";
    /**
     * ä¸åˆæ ¼ä»“库编码
     *
     * ä¸šåŠ¡èƒŒæ™¯ï¼šæŠ¥å·¥å®ŒæˆåŽè‡ªåŠ¨ç”Ÿæˆçš„äº§å“å…¥åº“å•ï¼Œæœªå‹¾é€‰å…¥åº“æˆ–æœªåˆ†é…å®Œçš„æ•°é‡é»˜è®¤å…¥åˆ°æ­¤ä»“åº“ï¼Œ
     * ä¸Ž {@link #WIP_VIRTUAL_WAREHOUSE} ä¸€æ ·æŒ‰ç¼–码约定识别。
     */
    public static final String DEFECT_WAREHOUSE = "DEFECT_WAREHOUSE";
    /**
     * ç¼–号
     */
    @TableId
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/warehouse/MesWmWarehouseLocationDO.java
@@ -27,6 +27,11 @@
    public static final String WIP_VIRTUAL_LOCATION = "WIP_VIRTUAL_LOCATION";
    /**
     * ä¸åˆæ ¼åº“区编码(配合 {@link MesWmWarehouseDO#DEFECT_WAREHOUSE} ä½¿ç”¨ï¼‰
     */
    public static final String DEFECT_LOCATION = "DEFECT_LOCATION";
    /**
     * ç¼–号
     */
    @TableId
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/task/MesProTaskMapper.java
@@ -69,6 +69,14 @@
        return selectPage(reqVO, query);
    }
    default List<MesProTaskDO> selectListByWaitStartTimeNotNull() {
        return selectList(new LambdaQueryWrapperX<MesProTaskDO>()
                .isNotNull(MesProTaskDO::getWaitStartTime)
                .in(MesProTaskDO::getStatus, MesProTaskStatusEnum.PREPARE.getStatus(),
                        MesProTaskStatusEnum.PRODUCING.getStatus(),
                        MesProTaskStatusEnum.PARTIAL_FINISHED.getStatus()));
    }
    default List<MesProTaskDO> selectListByWorkOrderId(Long workOrderId) {
        return selectList(new LambdaQueryWrapperX<MesProTaskDO>()
                .eqIfPresent(MesProTaskDO::getWorkOrderId, workOrderId)
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productreceipt/MesWmProductReceiptMapper.java
@@ -26,4 +26,8 @@
        return selectOne(MesWmProductReceiptDO::getCode, code);
    }
    default MesWmProductReceiptDO selectByFeedbackId(Long feedbackId) {
        return selectOne(MesWmProductReceiptDO::getFeedbackId, feedbackId);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/redis/RedisKeyConstants.java
@@ -41,4 +41,20 @@
     */
    String METERING_EXPIRE_REMIND_OVERDUE = "mes:dv:metering:overdue:remind:";
    /**
     * ç”Ÿäº§ä»»åŠ¡æŠ¥å·¥å€’è®¡æ—¶æé†’åŽ»é‡
     *
     * KEY æ ¼å¼ï¼šmes:pro:task:wait:remind:{taskId}:{level}:{waitStartTime}
     * VALUE æ•°æ®æ ¼å¼ï¼š1
     */
    String TASK_WAIT_REMIND = "mes:pro:task:wait:remind:";
    /**
     * ç”Ÿäº§ä»»åŠ¡æŠ¥å·¥å€’è®¡æ—¶æé†’æ‰«æé—¨é—©
     *
     * KEY æ ¼å¼ï¼šmes:pro:task:wait:remind:scan
     * VALUE æ•°æ®æ ¼å¼ï¼š1
     */
    String TASK_WAIT_REMIND_SCAN = "mes:pro:task:wait:remind:scan";
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/redis/pro/task/MesProTaskWaitRemindRedisDAO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
package cn.iocoder.yudao.module.mes.dal.redis.pro.task;
import cn.iocoder.yudao.module.mes.dal.redis.RedisKeyConstants;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import java.time.Duration;
import java.time.LocalDateTime;
/**
 * MES ç”Ÿäº§ä»»åŠ¡æŠ¥å·¥å€’è®¡æ—¶æé†’ Redis DAO
 *
 * @author è¶…级管理员
 */
@Repository
public class MesProTaskWaitRemindRedisDAO {
    /**
     * æé†’去重 KEY çš„过期时间(24 å°æ—¶ï¼‰ï¼ŒåŒä¸€ä»»åŠ¡åŒä¸€ç­‰çº§åŒä¸€è½®å€’è®¡æ—¶ 24 å°æ—¶å†…只提醒一次
     */
    private static final Duration REMIND_TIMEOUT = Duration.ofHours(24);
    /**
     * æ‰«æé—¨é—©çš„过期时间(2 åˆ†é’Ÿï¼‰ï¼Œé¿å…å¤šå®žä¾‹/多次并发重复扫描
     */
    private static final Duration SCAN_LOCK_TIMEOUT = Duration.ofMinutes(2);
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    /**
     * å°è¯•标记已提醒(同一任务同一等级同一轮倒计时只提醒一次)
     *
     * @param taskId ç”Ÿäº§ä»»åŠ¡ç¼–å·
     * @param level æé†’等级(1=剩余10分钟、2=剩余5分钟、3=倒计时结束)
     * @param waitStartTime å€’计时开始时间,参与 KEY ä¿è¯é‡æ–°å¼€å§‹å€’计时后会重新提醒
     * @return true è¡¨ç¤ºé¦–次标记成功,需要发送提醒
     */
    public boolean tryMarkReminded(Long taskId, Integer level, LocalDateTime waitStartTime) {
        String key = RedisKeyConstants.TASK_WAIT_REMIND + taskId + ":" + level + ":" + waitStartTime;
        return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, "1", REMIND_TIMEOUT));
    }
    /**
     * å°è¯•获取扫描门闩,避免多实例并发重复扫描
     *
     * @return true è¡¨ç¤ºèŽ·å–æˆåŠŸï¼ˆå¯ä»¥æ‰§è¡Œæ‰«æï¼‰
     */
    public boolean tryAcquireScanLock() {
        String key = RedisKeyConstants.TASK_WAIT_REMIND_SCAN;
        return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, "1", SCAN_LOCK_TIMEOUT));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -300,6 +300,9 @@
    ErrorCode PRO_WORKSTATION_PROCESS_MISMATCH = new ErrorCode(1_040_503_009, "工作站所属工序与当前工序不一致"); // ä¿®æ­£ç¼–号冲突
    ErrorCode PRO_TASK_CANNOT_PAUSE = new ErrorCode(1_040_503_007, "只有【生产中】或【部分完成】的任务可以停工");
    ErrorCode PRO_TASK_NOT_PAUSED = new ErrorCode(1_040_503_008, "任务不处于【已停工】状态,无法复工");
    ErrorCode PRO_TASK_WAIT_NOT_CONFIGURED = new ErrorCode(1_040_503_010, "该工序未配置等待时长,无需倒计时");
    ErrorCode PRO_TASK_WAIT_NOT_STARTED = new ErrorCode(1_040_503_011, "该工序设置了等待时长,请先点击\"开始倒计时\"");
    ErrorCode PRO_TASK_WAIT_NOT_ELAPSED = new ErrorCode(1_040_503_012, "等待倒计时未结束,还需等待 {}");
    // ========== MES ç”Ÿäº§ç®¡ç†-生产任务投料(1-040-503-100) ==========
    ErrorCode PRO_TASK_ISSUE_NOT_EXISTS = new ErrorCode(1_040_503_100, "生产任务投料记录不存在");
@@ -630,6 +633,8 @@
    ErrorCode WM_PRODUCT_RECPT_STATUS_ERROR = new ErrorCode(1_040_712_005, "收货单状态不正确");
    ErrorCode WM_PRODUCT_RECPT_CANCEL_NOT_ALLOWED = new ErrorCode(1_040_712_006, "已完成或已取消的收货单不允许取消");
    ErrorCode WM_PRODUCT_RECPT_NO_DETAIL = new ErrorCode(1_040_712_007, "收货单没有上架明细,无法执行入库");
    ErrorCode WM_PRODUCT_RECPT_NO_DEFECT_WAREHOUSE = new ErrorCode(1_040_712_008, "未配置不合格仓库,请先创建 DEFECT_WAREHOUSE ä»“库");
    ErrorCode WM_PRODUCT_RECPT_STOCK_QUANTITY_MISMATCH = new ErrorCode(1_040_712_009, "勾选入库的分配数量与行收货数量不一致");
    ErrorCode WM_PRODUCT_RECPT_LINE_NOT_EXISTS = new ErrorCode(1_040_712_100, "产品收货单行不存在");
    ErrorCode WM_PRODUCT_RECPT_DETAIL_NOT_EXISTS = new ErrorCode(1_040_712_200, "产品收货明细不存在");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/md/autocode/MesMdAutoCodeRuleCodeEnum.java
@@ -56,7 +56,8 @@
    PD_DOCUMENT_AUDIT_CODE("PD_DOCUMENT_AUDIT_CODE", "资料审核单编码"),
    PD_ARCHIVE_CODE("PD_ARCHIVE_CODE", "归档台账编码"),
    PD_PROCESS_PARAM_CODE("PD_PROCESS_PARAM_CODE", "工艺参数编码"),
    PD_PROCESS_PARAM_TEMPLATE_CODE("PD_PROCESS_PARAM_TEMPLATE_CODE", "工艺参数模板编码");
    PD_PROCESS_PARAM_TEMPLATE_CODE("PD_PROCESS_PARAM_TEMPLATE_CODE", "工艺参数模板编码"),
    PRODUCTRECPT_CODE("PRODUCTRECPT_CODE", "产品入库单编码");
    /**
     * è§„则代码
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/job/pro/task/MesProTaskWaitRemindJob.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.mes.job.pro.task;
import cn.iocoder.yudao.module.mes.service.pro.task.MesProTaskWaitRemindService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
 * MES ç”Ÿäº§ä»»åŠ¡æŠ¥å·¥å€’è®¡æ—¶æé†’å®šæ—¶ä»»åŠ¡
 *
 * <p>每分钟扫描一次,按三个等级(剩余 10 åˆ†é’Ÿ / 5 åˆ†é’Ÿ / ç»“束)发送站内信提醒
 *
 * @author è¶…级管理员
 */
@Slf4j
@Component
public class MesProTaskWaitRemindJob {
    @Resource
    private MesProTaskWaitRemindService waitRemindService;
    @Scheduled(cron = "0 * * * * ?")
    public void execute() {
        waitRemindService.sendWaitRemind();
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/feedback/MesProFeedbackServiceImpl.java
@@ -12,6 +12,7 @@
import cn.iocoder.yudao.module.mes.enums.pro.MesProFeedbackTypeEnum;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.process.MesProProcessDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProcessDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderBomDO;
@@ -24,6 +25,7 @@
import cn.iocoder.yudao.module.mes.enums.pro.MesProFeedbackStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProTaskStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.service.pro.process.MesProProcessService;
import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProcessService;
import cn.iocoder.yudao.module.mes.service.pro.task.MesProTaskService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderBomService;
@@ -33,6 +35,7 @@
import cn.iocoder.yudao.module.mes.service.wm.itemconsume.MesWmItemConsumeService;
import cn.iocoder.yudao.module.mes.service.wm.productproduce.MesWmProductProduceLineService;
import cn.iocoder.yudao.module.mes.service.wm.productproduce.MesWmProductProduceService;
import cn.iocoder.yudao.module.mes.service.wm.productreceipt.MesWmProductReceiptService;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemBatchConfigApi;
@@ -48,6 +51,7 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
@@ -78,6 +82,8 @@
    @Resource
    private MesProRouteProcessService routeProcessService;
    @Resource
    private MesProProcessService processService;
    @Resource
    private MesProWorkOrderBomService workOrderBomService;
    @Resource
    private MesProWorkOrderProcessService workOrderProcessService;
@@ -88,6 +94,9 @@
    private MesWmItemConsumeService itemConsumeService;
    @Resource
    private MesWmProductProduceService productProduceService;
    @Resource
    @Lazy // é¿å…å¾ªçŽ¯ä¾èµ–ï¼šäº§å“æ”¶è´§å•è‡ªåŠ¨ç”Ÿæˆé€»è¾‘ä¾èµ–æŠ¥å·¥ Service
    private MesWmProductReceiptService productReceiptService;
    @Resource
    private MesWmProductProduceLineService produceLineService;
    @Resource
@@ -266,13 +275,17 @@
    public boolean approveFeedback(MesProFeedbackApproveReqVO reqVO) {
        // 1.1 æ ¡éªŒå­˜åœ¨ + å®¡æ‰¹ä¸­çŠ¶æ€
        MesProFeedbackDO feedback = validateFeedbackStatusApproving(reqVO.getId());
        // 1.2 æ ¡éªŒæŠ¥å·¥æ•°é‡ > 0
        if (feedback.getFeedbackQuantity() == null
                || feedback.getFeedbackQuantity().compareTo(BigDecimal.ZERO) <= 0) {
            throw exception(PRO_FEEDBACK_QUANTITY_MUST_POSITIVE);
        // 1.2 æ ¡éªŒä»»åŠ¡æœªå®Œæˆ + ç­‰å¾…时长倒计时
        MesProTaskDO task = taskService.validateTaskNotFinished(feedback.getTaskId());
        MesProProcessDO process = validateWaitDuration(task);
        BigDecimal fbQty = feedback.getFeedbackQuantity() == null
                ? BigDecimal.ZERO : feedback.getFeedbackQuantity();
        // 1.3 æ ¡éªŒæŠ¥å·¥æ•°é‡ï¼šé»˜è®¤å¿…é¡» > 0;工序配置"不需要填写报工数量"时允许为空
        if (process == null || !Boolean.FALSE.equals(process.getNeedReportQuantity())) {
            if (fbQty.compareTo(BigDecimal.ZERO) <= 0) {
                throw exception(PRO_FEEDBACK_QUANTITY_MUST_POSITIVE);
            }
        }
        // 1.3 æ ¡éªŒä»»åŠ¡æœªå®Œæˆ
        taskService.validateTaskNotFinished(feedback.getTaskId());
        // 2.1 æŸ¥è¯¢å·¥åºçš„关键工序标识 + æ£€éªŒæ ‡è¯†ï¼ˆéœ€åœ¨ uncheckQuantity æ ¡éªŒä¹‹å‰ï¼Œå› ä¸ºè´¨æ£€å·¥åºå…è®¸ uncheckQuantity > 0)
        MesProRouteProcessDO routeProcess = routeProcessService.getRouteProcessByRouteIdAndProcessId(
@@ -280,13 +293,14 @@
        boolean keyFlag = routeProcess != null && Boolean.TRUE.equals(routeProcess.getKeyFlag());
        boolean checkFlag = routeProcess != null && Boolean.TRUE.equals(routeProcess.getCheckFlag());
        // 2.2 éžè´¨æ£€å·¥åºï¼šåˆæ ¼å“/不良品数量由质检确定;本工序不经过质检,报工未填写时默认全部为合格品
        //     ï¼ˆæ•°é‡ä¸ºç©º/0 æ—¶æŒ‰ 0 å¤„理,避免写入 null)
        if (!checkFlag
                && feedback.getQualifiedQuantity() == null
                && feedback.getUnqualifiedQuantity() == null) {
            feedback.setQualifiedQuantity(feedback.getFeedbackQuantity());
            feedback.setQualifiedQuantity(fbQty);
            feedback.setUnqualifiedQuantity(BigDecimal.ZERO);
            feedbackMapper.updateById(new MesProFeedbackDO().setId(feedback.getId())
                    .setQualifiedQuantity(feedback.getFeedbackQuantity())
                    .setQualifiedQuantity(fbQty)
                    .setUnqualifiedQuantity(BigDecimal.ZERO));
        }
        // 2.3 éžè´¨æ£€å·¥åºï¼šä»æœ‰å¾…检数量时不能审批(质检工序的 uncheckQuantity > 0 æ˜¯æ­£å¸¸çŠ¶æ€ï¼Œä¸åšæ‹¦æˆªï¼‰
@@ -297,8 +311,9 @@
        }
        // 3. ç‰©æ–™æ¶ˆè€—:倒冲工序按 BOM è‡ªåŠ¨æ‰£æ–™ï¼›éžå€’å†²å·¥åºè·³è¿‡ï¼ˆç‰©æ–™èµ°æ‰‹å·¥é¢†æ–™/投料)
        //    æŠ¥å·¥æ•°é‡ä¸ºç©º/0 æ—¶è·³è¿‡å€’冲扣料,避免空数量误扣线边库
        boolean backflushFlag = routeProcess != null && Boolean.TRUE.equals(routeProcess.getBackflushFlag());
        if (backflushFlag) {
        if (backflushFlag && fbQty.compareTo(BigDecimal.ZERO) > 0) {
            // 3.1 æ ¡éªŒå€’冲工序必须配置 BOM æ¶ˆè€—,避免静默不扣料
            List<MesProWorkOrderBomDO> boms = workOrderBomService.getWorkOrderBomListByWorkOrderId(
                    feedback.getWorkOrderId());
@@ -316,6 +331,14 @@
        feedbackMapper.updateById(new MesProFeedbackDO().setId(reqVO.getId())
                .setStatus(MesProFeedbackStatusEnum.FINISHED.getStatus())
                .setUncheckQuantity(BigDecimal.ZERO));
        // 5.1 æŠ¥å·¥æ•°é‡ä¸ºç©º/0("不需要填写报工数量"的工序):无产出可入库,仅更新任务进度并结束
        if (fbQty.compareTo(BigDecimal.ZERO) <= 0) {
            taskService.updateProducedQuantity(feedback.getTaskId(),
                    BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
            checkAndPublishProductionFinished(feedback.getWorkOrderId());
            return true;
        }
        // 6. æ ¹æ®æ˜¯å¦éœ€è¦æ£€éªŒï¼Œå†³å®šäº§å‡ºå’Œå…¥åº“方式
        // 6.2 è°ƒæ•´ åªè¦å®¡æ‰¹æˆåŠŸç›´æŽ¥æ›´æ–°
@@ -341,6 +364,8 @@
            // 6.2 æ— éœ€æ£€éªŒï¼šç”Ÿæˆäº§å‡ºå•(按合格/不合格拆行),直接完成入库
            MesWmProductProduceDO produce = productProduceService.generateProductProduce(feedback, false, reqVO);
            productProduceService.finishProductProduce(produce.getId());
            // 6.3 æŠ¥å·¥äº§å‡ºå®šç¨¿åŽè‡ªåŠ¨ç”Ÿæˆäº§å“å…¥åº“å•ï¼ˆä¸€æ¬¡æŠ¥å·¥=一条,幂等)
            productReceiptService.createProductReceiptByFeedback(feedback.getId());
        }
@@ -477,14 +502,47 @@
        if (routeProcess == null) {
            throw exception(PRO_FEEDBACK_ROUTE_PROCESS_INVALID);
        }
        // 4.1 æ ¡éªŒæ•°é‡ï¼šæ— è®ºæ˜¯å¦éœ€è¦æ£€éªŒï¼ŒæŠ¥å·¥åªéœ€å¡«æŠ¥å·¥æ•°é‡ï¼Œä¸”å¿…é¡» > 0
        // 4. æ ¡éªŒç­‰å¾…时长倒计时:工序配置了等待时长(waitDuration > 0)时,需先开始倒计时并等待结束
        MesProProcessDO process = validateWaitDuration(task);
        // 4.1 æ ¡éªŒæ•°é‡ï¼šé»˜è®¤å¿…é¡» > 0;工序配置"不需要填写报工数量"时允许为空
        // åˆæ ¼å“/不良品数量由质检确定(非质检工序在审批时默认全部为合格品)
        if (reqVO.getFeedbackQuantity() == null
                || reqVO.getFeedbackQuantity().compareTo(BigDecimal.ZERO) <= 0) {
            throw exception(PRO_FEEDBACK_QUANTITY_MUST_POSITIVE);
        if (process == null || !Boolean.FALSE.equals(process.getNeedReportQuantity())) {
            if (reqVO.getFeedbackQuantity() == null
                    || reqVO.getFeedbackQuantity().compareTo(BigDecimal.ZERO) <= 0) {
                throw exception(PRO_FEEDBACK_QUANTITY_MUST_POSITIVE);
            }
        }
        return task;
    }
    /**
     * æ ¡éªŒå·¥åºç­‰å¾…时长倒计时(报工前置条件)
     *
     * <p>读取任务关联的工序主数据 waitDuration(小时):若未配置或为 0 åˆ™ç›´æŽ¥è¿”回;配置了则需要
     * ä»»åŠ¡å·²ç‚¹å‡»"开始倒计时"且倒计时已结束,否则抛出对应异常。返回工序主数据,供调用方复用
     * needReportQuantity ç­‰å­—段。
     *
     * @param task ç”Ÿäº§ä»»åŠ¡
     * @return å·¥åºä¸»æ•°æ®
     */
    private MesProProcessDO validateWaitDuration(MesProTaskDO task) {
        MesProProcessDO process = processService.getProcess(task.getProcessId());
        if (process == null || process.getWaitDuration() == null
                || process.getWaitDuration().compareTo(BigDecimal.ZERO) <= 0) {
            return process; // æœªé…ç½®ç­‰å¾…时长,无需倒计时
        }
        if (task.getWaitStartTime() == null) {
            throw exception(PRO_TASK_WAIT_NOT_STARTED); // è¯·å…ˆç‚¹å‡»"开始倒计时"
        }
        LocalDateTime readyTime = task.getWaitStartTime()
                .plusMinutes((long) (process.getWaitDuration().doubleValue() * 60));
        if (LocalDateTime.now().isBefore(readyTime)) {
            Duration remaining = Duration.between(LocalDateTime.now(), readyTime);
            throw exception(PRO_TASK_WAIT_NOT_ELAPSED,
                    remaining.toHours() + "小时" + remaining.toMinutesPart() + "分钟");
        }
        return process;
    }
    @Override
@@ -504,12 +562,16 @@
        if (routeProcess == null) {
            throw exception(PRO_FEEDBACK_ROUTE_PROCESS_INVALID);
        }
        // 3. æ ¡éªŒæ•°é‡ï¼šåªå¡«æŠ¥å·¥æ•°é‡ï¼Œä¸”å¿…é¡» > 0(合格品/不良品数量由质检确定)
        if (reqVO.getFeedbackQuantity() == null
                || reqVO.getFeedbackQuantity().compareTo(BigDecimal.ZERO) <= 0) {
            throw exception(PRO_FEEDBACK_QUANTITY_MUST_POSITIVE);
        // 3. æ ¡éªŒç­‰å¾…时长倒计时:工序配置了等待时长(waitDuration > 0)时,需先开始倒计时并等待结束
        MesProProcessDO process = validateWaitDuration(task);
        // 4. æ ¡éªŒæ•°é‡ï¼šé»˜è®¤å¿…é¡» > 0;工序配置"不需要填写报工数量"时允许为空(合格品/不良品数量由质检确定)
        if (process == null || !Boolean.FALSE.equals(process.getNeedReportQuantity())) {
            if (reqVO.getFeedbackQuantity() == null
                    || reqVO.getFeedbackQuantity().compareTo(BigDecimal.ZERO) <= 0) {
                throw exception(PRO_FEEDBACK_QUANTITY_MUST_POSITIVE);
            }
        }
        // 4. æž„建报工DO
        // 5. æž„建报工DO
        MesProFeedbackDO feedback = new MesProFeedbackDO()
                .setCode(autoCodeRecordService.generateAutoCode(MesMdAutoCodeRuleCodeEnum.PRO_FEEDBACK_CODE.getCode()))
                .setType(MesProFeedbackTypeEnum.SELF.getType())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskService.java
@@ -195,4 +195,14 @@
     */
    List<cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskAssigneeDO> getTaskAssigneeListByTaskIds(Collection<Long> taskIds);
    /**
     * å¼€å§‹æŠ¥å·¥å€’计时
     *
     * <p>工序配置了等待时长(waitDuration > 0)时,记录倒计时开始时间到任务;重复调用会重置开始时间。
     *
     * @param taskId ç”Ÿäº§ä»»åŠ¡ç¼–å·
     * @return å€’计时开始时间
     */
    java.time.LocalDateTime startWaitCountdown(Long taskId);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskServiceImpl.java
@@ -12,6 +12,7 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.md.unitmeasure.MesMdUnitMeasureDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkstationMachineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.process.MesProProcessDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.process.MesProProcessParamDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskMapper;
@@ -52,11 +53,13 @@
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_FEEDBACK_CANNOT_CREATE_WHEN_PAUSED;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_TASK_ALREADY_FINISHED;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_TASK_CANNOT_PAUSE;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_TASK_HAS_FEEDBACK;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_TASK_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_TASK_NOT_PAUSED;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_TASK_WAIT_NOT_CONFIGURED;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskPauseRecordDO;
@@ -574,4 +577,25 @@
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public LocalDateTime startWaitCountdown(Long taskId) {
        // 1. æ ¡éªŒä»»åŠ¡å­˜åœ¨ä¸”æœªç»ˆæ€
        MesProTaskDO task = validateTaskNotFinished(taskId);
        // 2. æ ¡éªŒåœå·¥çŠ¶æ€ä¸èƒ½æŠ¥å·¥
        if (!MesProTaskStatusEnum.canFeedback(task.getStatus())) {
            throw exception(PRO_FEEDBACK_CANNOT_CREATE_WHEN_PAUSED);
        }
        // 3. æ ¡éªŒå·¥åºé…ç½®äº†ç­‰å¾…æ—¶é•¿
        MesProProcessDO process = processService.getProcess(task.getProcessId());
        if (process == null || process.getWaitDuration() == null
                || process.getWaitDuration().compareTo(BigDecimal.ZERO) <= 0) {
            throw exception(PRO_TASK_WAIT_NOT_CONFIGURED);
        }
        // 4. è®°å½•倒计时开始时间(重复点击重置)
        LocalDateTime now = LocalDateTime.now();
        taskMapper.updateById(new MesProTaskDO().setId(taskId).setWaitStartTime(now));
        return now;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskWaitRemindService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.mes.service.pro.task;
/**
 * MES ç”Ÿäº§ä»»åŠ¡æŠ¥å·¥å€’è®¡æ—¶æé†’ Service æŽ¥å£
 *
 * @author è¶…级管理员
 */
public interface MesProTaskWaitRemindService {
    /**
     * æ‰«æç”Ÿäº§ä»»åŠ¡æŠ¥å·¥å€’è®¡æ—¶ï¼ŒæŒ‰ä¸‰ä¸ªç­‰çº§ï¼ˆå‰©ä½™ 10 åˆ†é’Ÿ / 5 åˆ†é’Ÿ / ç»“束)发送站内信提醒
     */
    void sendWaitRemind();
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskWaitRemindServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,151 @@
package cn.iocoder.yudao.module.mes.service.pro.task;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.process.MesProProcessDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskAssigneeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskAssigneeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskMapper;
import cn.iocoder.yudao.module.mes.dal.redis.pro.task.MesProTaskWaitRemindRedisDAO;
import cn.iocoder.yudao.module.mes.service.pro.process.MesProProcessService;
import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap;
/**
 * MES ç”Ÿäº§ä»»åŠ¡æŠ¥å·¥å€’è®¡æ—¶æé†’ Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Slf4j
@Service
public class MesProTaskWaitRemindServiceImpl implements MesProTaskWaitRemindService {
    /**
     * ä¸‰ä¸ªæé†’等级对应的站内信模板编号
     */
    private static final String[] REMIND_TEMPLATE_CODES = {
            "mes_task_wait_remind_10", // ç­‰çº§ 1:剩余 10 åˆ†é’Ÿ
            "mes_task_wait_remind_5",  // ç­‰çº§ 2:剩余 5 åˆ†é’Ÿ
            "mes_task_wait_remind_end" // ç­‰çº§ 3:倒计时结束
    };
    @Resource
    private MesProTaskMapper taskMapper;
    @Resource
    private MesProTaskAssigneeMapper taskAssigneeMapper;
    @Resource
    private MesProProcessService processService;
    @Resource
    private MesProTaskWaitRemindRedisDAO waitRemindRedisDAO;
    @Resource
    private NotifyMessageSendApi notifyMessageSendApi;
    @Override
    public void sendWaitRemind() {
        try {
            // 1. æŠ¢æ‰«æé—¨é—©ï¼Œé¿å…å¤šå®žä¾‹/多次并发重复扫描
            if (!waitRemindRedisDAO.tryAcquireScanLock()) {
                return;
            }
            // 2. æŸ¥è¯¢å·²å¼€å§‹å€’计时且未终态/未停工的任务
            List<MesProTaskDO> taskList = taskMapper.selectListByWaitStartTimeNotNull();
            if (CollUtil.isEmpty(taskList)) {
                return;
            }
            // 3. æŒ‰ä»»åŠ¡ç¼–å·æ‰¹é‡æŸ¥è¯¢æŠ¥å·¥äººå‘˜
            Map<Long, List<MesProTaskAssigneeDO>> assigneeMap = convertMultiMap(
                    taskAssigneeMapper.selectListByTaskIds(
                            taskList.stream().map(MesProTaskDO::getId).collect(Collectors.toSet())),
                    MesProTaskAssigneeDO::getTaskId);
            // 4. é€ä»»åŠ¡æŒ‰ä¸‰ä¸ªç­‰çº§å‘é€æé†’
            for (MesProTaskDO task : taskList) {
                try {
                    processTaskRemind(task, assigneeMap.get(task.getId()));
                } catch (Exception ex) {
                    // best-effort:单个任务提醒失败不影响其余任务
                    log.error("[sendWaitRemind][任务 {} å‘送倒计时提醒失败]", task.getId(), ex);
                }
            }
        } catch (Exception ex) {
            // best-effort:提醒失败不影响其他流程
            log.error("[sendWaitRemind][发送报工倒计时提醒失败]", ex);
        }
    }
    private void processTaskRemind(MesProTaskDO task, List<MesProTaskAssigneeDO> assigneeList) {
        // 1. å–工序等待时长,未配置则无需倒计时提醒
        MesProProcessDO process = processService.getProcess(task.getProcessId());
        if (process == null || process.getWaitDuration() == null
                || process.getWaitDuration().compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        // 2. è®¡ç®—倒计时结束时间与剩余分钟
        LocalDateTime waitStartTime = task.getWaitStartTime();
        LocalDateTime readyTime = waitStartTime
                .plusMinutes((long) (process.getWaitDuration().doubleValue() * 60));
        long remainingMinutes = Duration.between(LocalDateTime.now(), readyTime).toMinutes(); // å¯ä¸ºè´Ÿ
        // 3. ä¸‰ä¸ªç­‰çº§ç‹¬ç«‹åˆ¤å®šï¼ŒRedis åŽ»é‡ï¼ˆé¦–æ¬¡æ ‡è®°æˆåŠŸæ‰å‘é€ï¼‰
        sendRemindIfDue(task, process, assigneeList, waitStartTime, 1, remainingMinutes <= 10);
        sendRemindIfDue(task, process, assigneeList, waitStartTime, 2, remainingMinutes <= 5);
        sendRemindIfDue(task, process, assigneeList, waitStartTime, 3, remainingMinutes <= 0);
    }
    private void sendRemindIfDue(MesProTaskDO task, MesProProcessDO process,
                                 List<MesProTaskAssigneeDO> assigneeList, LocalDateTime waitStartTime,
                                 int level, boolean due) {
        if (!due) {
            return;
        }
        if (!waitRemindRedisDAO.tryMarkReminded(task.getId(), level, waitStartTime)) {
            return; // è¯¥ç­‰çº§å·²æé†’过
        }
        // ç»„装模板参数
        Map<String, Object> params = new HashMap<>();
        params.put("taskCode", task.getCode());
        params.put("taskName", task.getName());
        params.put("processName", process.getName());
        // æŽ¥æ”¶äººï¼šä¼˜å…ˆæŠ¥å·¥äººå‘˜ï¼›æœªæŒ‡å®šåˆ™ç”¨ä»»åŠ¡åˆ›å»ºäººå…œåº•
        List<Long> receiverUserIds = assigneeList == null ? null
                : assigneeList.stream().map(MesProTaskAssigneeDO::getUserId).collect(Collectors.toList());
        if (CollUtil.isEmpty(receiverUserIds)) {
            Long creatorUserId = parseCreatorUserId(task.getCreator());
            if (creatorUserId == null) {
                return;
            }
            receiverUserIds = List.of(creatorUserId);
        }
        for (Long userId : receiverUserIds) {
            NotifySendSingleToUserReqDTO reqDTO = new NotifySendSingleToUserReqDTO()
                    .setUserId(userId)
                    .setTemplateCode(REMIND_TEMPLATE_CODES[level - 1])
                    .setTemplateParams(params);
            notifyMessageSendApi.sendSingleMessageToAdmin(reqDTO);
        }
    }
    private Long parseCreatorUserId(String creator) {
        if (StrUtil.isBlank(creator)) {
            return null;
        }
        try {
            return Long.valueOf(creator.trim());
        } catch (NumberFormatException ex) {
            return null;
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productproduce/MesWmProductProduceServiceImpl.java
@@ -24,10 +24,12 @@
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
import cn.iocoder.yudao.module.mes.service.wm.productreceipt.MesWmProductReceiptService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseLocationService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -66,6 +68,9 @@
    private MesWmWarehouseLocationService locationService;
    @Resource
    private MesWmWarehouseAreaService areaService;
    @Resource
    @Lazy // é¿å…å¾ªçŽ¯ä¾èµ–ï¼šäº§å“æ”¶è´§å•è‡ªåŠ¨ç”Ÿæˆé€»è¾‘ä¾èµ–ç”Ÿäº§å…¥åº“å• Mapper/行 Service
    private MesWmProductReceiptService productReceiptService;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -300,6 +305,9 @@
        // 4. å®Œæˆäº§å‡ºå•(创建库存事务 + æ›´æ–°çŠ¶æ€ä¸ºå·²å®Œæˆï¼‰
        finishProductProduce(produce.getId());
        // 5. æŠ¥å·¥äº§å‡ºå®šç¨¿åŽè‡ªåŠ¨ç”Ÿæˆäº§å“å…¥åº“å•ï¼ˆä¸€æ¬¡æŠ¥å·¥=一条,幂等)
        productReceiptService.createProductReceiptByFeedback(feedbackId);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productreceipt/MesWmProductReceiptService.java
@@ -3,6 +3,7 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productreceipt.vo.MesWmProductReceiptPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productreceipt.vo.MesWmProductReceiptSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productreceipt.vo.MesWmProductReceiptStockReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productreceipt.MesWmProductReceiptDO;
import jakarta.validation.Valid;
@@ -78,6 +79,27 @@
    void cancelProductReceipt(Long id);
    /**
     * æŠ¥å·¥äº§å‡ºå®šç¨¿åŽè‡ªåŠ¨ç”Ÿæˆäº§å“å…¥åº“å•ï¼ˆä¸€æ¬¡æŠ¥å·¥=一条,幂等)
     *
     * <p>行数据来自生产入库单产出行(合格/不合格分行),质量状态原样带入,
     * is_stock é»˜è®¤ false(未勾选,执行入库时默认进不合格仓库)。
     *
     * @param feedbackId æŠ¥å·¥ç¼–号
     * @return äº§å“å…¥åº“单编号
     */
    Long createProductReceiptByFeedback(Long feedbackId);
    /**
     * æ‰¹é‡ä¸Šæž¶ï¼ˆå‹¾é€‰æ˜¯å¦å…¥åº“ + ä»“库/库区/库位分配数量)
     *
     * <p>仅允许待上架(APPROVING)状态执行;勾选入库的行要求分配数量之和等于行收货数量;
     * æœªå‹¾é€‰å…¥åº“的行不分配明细,执行入库时自动进不合格仓库。
     *
     * @param reqVO ä¸Šæž¶è¯·æ±‚
     */
    void submitStock(MesWmProductReceiptStockReqVO reqVO);
    /**
     * æ ¡éªŒäº§å“æ”¶è´§å•存在且处于可编辑状态(草稿或待上架)
     *
     * @param id ç¼–号
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productreceipt/MesWmProductReceiptServiceImpl.java
@@ -8,18 +8,29 @@
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productreceipt.vo.MesWmProductReceiptPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productreceipt.vo.MesWmProductReceiptSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productreceipt.vo.MesWmProductReceiptStockReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productreceipt.MesWmProductReceiptDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productreceipt.MesWmProductReceiptDetailDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productreceipt.MesWmProductReceiptLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productreceipt.MesWmProductReceiptDetailMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productreceipt.MesWmProductReceiptLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productreceipt.MesWmProductReceiptMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductReceiptStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.service.pro.feedback.MesProFeedbackService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import cn.iocoder.yudao.module.mes.service.wm.productproduce.MesWmProductProduceLineService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
@@ -31,7 +42,9 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
@@ -45,6 +58,10 @@
    @Resource
    private MesWmProductReceiptMapper productReceiptMapper;
    @Resource
    private MesWmProductReceiptLineMapper productReceiptLineMapper;
    @Resource
    private MesWmProductReceiptDetailMapper productReceiptDetailMapper;
    @Resource
    private MesWmProductReceiptLineService productReceiptLineService;
@@ -60,6 +77,14 @@
    private MesWmWarehouseLocationService locationService;
    @Resource
    private MesWmWarehouseAreaService areaService;
    @Resource
    private MesWmProductProduceMapper productProduceMapper;
    @Resource
    private MesWmProductProduceLineService productProduceLineService;
    @Resource
    private MesProFeedbackService feedbackService;
    @Resource
    private MesMdAutoCodeRecordService autoCodeRecordService;
    @Override
    public Long createProductReceipt(MesWmProductReceiptSaveReqVO createReqVO) {
@@ -166,6 +191,8 @@
        if (ObjUtil.notEqual(MesWmProductReceiptStatusEnum.APPROVED.getStatus(), receipt.getStatus())) {
            throw exception(WM_PRODUCT_RECPT_STATUS_ERROR);
        }
        // æœªå‹¾é€‰å…¥åº“或未分配完的数量默认补到不合格仓库
        supplementDefectDetails(receipt);
        // æ ¡éªŒæ˜Žç»†éžç©º
        List<MesWmProductReceiptDetailDO> details = productReceiptDetailService.getProductReceiptDetailListByRecptId(id);
        if (CollUtil.isEmpty(details)) {
@@ -180,6 +207,136 @@
                .setId(id).setStatus(MesWmProductReceiptStatusEnum.FINISHED.getStatus()));
    }
    /**
     * ä¸ºæœªå‹¾é€‰å…¥åº“(is_stock=false/空)或分配数量不足的行补一条不合格仓库明细
     *
     * <p>每行剩余数量 = è¡Œæ”¶è´§æ•°é‡ - å·²åˆ†é…æ˜Žç»†æ•°é‡ä¹‹å’Œï¼Œå‰©ä½™ &gt; 0 æ—¶è‡ªåŠ¨æ’å…¥ä¸åˆæ ¼ä»“åº“ä¸‰çº§æ˜Žç»†ï¼Œ
     * ä¿è¯æ‰§è¡Œå…¥åº“时一个数量对应一条流水。
     */
    private void supplementDefectDetails(MesWmProductReceiptDO receipt) {
        List<MesWmProductReceiptLineDO> lines = productReceiptLineService.getProductReceiptLineListByRecptId(receipt.getId());
        for (MesWmProductReceiptLineDO line : lines) {
            List<MesWmProductReceiptDetailDO> details = productReceiptDetailService.getProductReceiptDetailListByLineId(line.getId());
            BigDecimal sumDetailQty = CollectionUtils.getSumValue(details,
                    MesWmProductReceiptDetailDO::getQuantity, BigDecimal::add, BigDecimal.ZERO);
            BigDecimal remaining = line.getQuantity().subtract(sumDetailQty);
            if (remaining.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            // æŸ¥è¯¢ä¸åˆæ ¼ä»“库三级(按编码约定识别)
            MesWmWarehouseDO defectWarehouse = warehouseService.getWarehouseByCode(MesWmWarehouseDO.DEFECT_WAREHOUSE);
            MesWmWarehouseLocationDO defectLocation = locationService.getWarehouseLocationByCode(MesWmWarehouseLocationDO.DEFECT_LOCATION);
            MesWmWarehouseAreaDO defectArea = areaService.getWarehouseAreaByCode(MesWmWarehouseAreaDO.DEFECT_AREA);
            if (defectWarehouse == null || defectLocation == null || defectArea == null) {
                throw exception(WM_PRODUCT_RECPT_NO_DEFECT_WAREHOUSE);
            }
            productReceiptDetailMapper.insert(new MesWmProductReceiptDetailDO()
                    .setLineId(line.getId())
                    .setReceiptId(receipt.getId())
                    .setItemId(line.getItemId())
                    .setQuantity(remaining)
                    .setBatchId(line.getBatchId())
                    .setWarehouseId(defectWarehouse.getId())
                    .setLocationId(defectLocation.getId())
                    .setAreaId(defectArea.getId()));
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createProductReceiptByFeedback(Long feedbackId) {
        // å¹‚等:一次报工只生成一条产品入库单
        MesWmProductReceiptDO existing = productReceiptMapper.selectByFeedbackId(feedbackId);
        if (existing != null) {
            return existing.getId();
        }
        // æ ¡éªŒæŠ¥å·¥å­˜åœ¨
        MesProFeedbackDO feedback = feedbackService.validateFeedbackExists(feedbackId);
        // ç”Ÿæˆç¼–码并插入入库单
        MesWmProductReceiptDO receipt = new MesWmProductReceiptDO()
                .setCode(autoCodeRecordService.generateAutoCode(MesMdAutoCodeRuleCodeEnum.PRODUCTRECPT_CODE.getCode()))
                .setName("产品入库-" + feedback.getCode())
                .setWorkOrderId(feedback.getWorkOrderId())
                .setItemId(feedback.getItemId())
                .setFeedbackId(feedbackId)
                .setReceiptDate(LocalDateTime.now())
                .setStatus(MesWmProductReceiptStatusEnum.PREPARE.getStatus());
        productReceiptMapper.insert(receipt);
        // ä»Žäº§å‡ºå•产出行生成入库单行(合格/不合格分行,质量状态原样带入)
        MesWmProductProduceDO produce = productProduceMapper.selectByFeedbackId(feedbackId);
        if (produce != null) {
            List<MesWmProductProduceLineDO> produceLines = productProduceLineService.getProductProduceLineListByProduceId(produce.getId());
            for (MesWmProductProduceLineDO produceLine : produceLines) {
                if (produceLine.getQuantity() == null || produceLine.getQuantity().compareTo(BigDecimal.ZERO) <= 0) {
                    continue;
                }
                productReceiptLineMapper.insert(new MesWmProductReceiptLineDO()
                        .setReceiptId(receipt.getId())
                        .setItemId(produceLine.getItemId())
                        .setQuantity(produceLine.getQuantity())
                        .setBatchId(produceLine.getBatchId())
                        .setBatchCode(produceLine.getBatchCode())
                        .setQualityStatus(produceLine.getQualityStatus())
                        .setIsStock(Boolean.FALSE));
            }
        }
        return receipt.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitStock(MesWmProductReceiptStockReqVO reqVO) {
        // æ ¡éªŒå…¥åº“单存在且为待上架(APPROVING)
        MesWmProductReceiptDO receipt = validateProductReceiptExists(reqVO.getReceiptId());
        if (ObjUtil.notEqual(MesWmProductReceiptStatusEnum.APPROVING.getStatus(), receipt.getStatus())) {
            throw exception(WM_PRODUCT_RECPT_STATUS_ERROR);
        }
        List<MesWmProductReceiptStockReqVO.Row> rows = reqVO.getRows();
        if (CollUtil.isEmpty(rows)) {
            throw exception(WM_PRODUCT_RECPT_NO_LINE);
        }
        // é€è¡Œå¤„理:更新是否入库 + é‡å»ºä¸Šæž¶æ˜Žç»†
        for (MesWmProductReceiptStockReqVO.Row row : rows) {
            MesWmProductReceiptLineDO line = productReceiptLineMapper.selectById(row.getLineId());
            if (line == null || !ObjUtil.equal(line.getReceiptId(), receipt.getId())) {
                throw exception(WM_PRODUCT_RECPT_LINE_NOT_EXISTS);
            }
            Boolean isStock = Boolean.TRUE.equals(row.getIsStock());
            productReceiptLineMapper.updateById(new MesWmProductReceiptLineDO()
                    .setId(line.getId()).setIsStock(isStock));
            // åˆ é™¤è¯¥è¡Œæ—§æ˜Žç»†ï¼Œé‡å»ºåˆ†é…
            productReceiptDetailService.deleteProductReceiptDetailByLineId(line.getId());
            if (!isStock) {
                continue; // æœªå‹¾é€‰ï¼šä¸åˆ†é…ï¼Œæ‰§è¡Œå…¥åº“时自动进不合格仓库
            }
            // å‹¾é€‰å…¥åº“:分配数量之和必须等于行收货数量
            List<MesWmProductReceiptStockReqVO.Detail> details = row.getDetails();
            if (CollUtil.isEmpty(details)) {
                throw exception(WM_PRODUCT_RECPT_STOCK_QUANTITY_MISMATCH);
            }
            BigDecimal sumDetailQty = details.stream()
                    .map(MesWmProductReceiptStockReqVO.Detail::getQuantity)
                    .filter(Objects::nonNull)
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
            if (line.getQuantity() == null || sumDetailQty.compareTo(line.getQuantity()) != 0) {
                throw exception(WM_PRODUCT_RECPT_STOCK_QUANTITY_MISMATCH);
            }
            // æ ¡éªŒä»“库层级关系并插入明细
            for (MesWmProductReceiptStockReqVO.Detail detail : details) {
                areaService.validateWarehouseAreaExists(detail.getWarehouseId(), detail.getLocationId(), detail.getAreaId());
                productReceiptDetailMapper.insert(new MesWmProductReceiptDetailDO()
                        .setLineId(line.getId())
                        .setReceiptId(receipt.getId())
                        .setItemId(line.getItemId())
                        .setQuantity(detail.getQuantity())
                        .setBatchId(line.getBatchId())
                        .setWarehouseId(detail.getWarehouseId())
                        .setLocationId(detail.getLocationId())
                        .setAreaId(detail.getAreaId()));
            }
        }
    }
    private void createTransactionList(MesWmProductReceiptDO receipt, List<MesWmProductReceiptDetailDO> details) {
        // 1. æŸ¥è¯¢è™šæ‹Ÿçº¿è¾¹åº“
        MesWmWarehouseDO virtualWarehouse = warehouseService.getWarehouseByCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE);