Browse Source

增加消息实现

jingbo 1 tháng trước cách đây
mục cha
commit
f62c66ac2e
18 tập tin đã thay đổi với 1357 bổ sung và 38 xóa
  1. 21 5
      src/main/java/com/jeesite/modules/mes/dingtalk/DingMsgCard.java
  2. 19 25
      src/main/java/com/jeesite/modules/mes/dingtalk/DingMsgCode.java
  3. 29 1
      src/main/java/com/jeesite/modules/mes/dingtalk/MesDingMsgTableInitializer.java
  4. 267 3
      src/main/java/com/jeesite/modules/mes/dingtalk/MesDingTalkAlertService.java
  5. 7 0
      src/main/java/com/jeesite/modules/mes/entity/MesQuality.java
  6. 24 0
      src/main/java/com/jeesite/modules/mes/service/MesBatchSnService.java
  7. 60 0
      src/main/java/com/jeesite/modules/mes/service/MesErpScheduleService.java
  8. 17 0
      src/main/java/com/jeesite/modules/mes/service/MesLineProcessConsumeService.java
  9. 20 0
      src/main/java/com/jeesite/modules/mes/service/MesProductRecordService.java
  10. 37 0
      src/main/java/com/jeesite/modules/mes/web/MesProductRecordController.java
  11. 2 1
      src/main/java/com/jeesite/modules/mes/web/MesQualityController.java
  12. 6 0
      src/main/java/com/jeesite/modules/mes/web/MesRepairQualityController.java
  13. 127 3
      src/main/java/com/jeesite/modules/mes/web/MesScrapController.java
  14. 129 0
      src/main/java/com/jeesite/modules/scheduling/DevicePreventTaskScheduling.java
  15. 220 0
      src/main/java/com/jeesite/modules/scheduling/ProdMaterial3DayScheduling.java
  16. 147 0
      src/main/java/com/jeesite/modules/scheduling/ProdOeeScheduling.java
  17. 91 0
      src/main/java/com/jeesite/modules/scheduling/ProdPlanRateScheduling.java
  18. 134 0
      src/main/java/com/jeesite/modules/scheduling/ProdStockScheduling.java

+ 21 - 5
src/main/java/com/jeesite/modules/mes/dingtalk/DingMsgCard.java

@@ -122,15 +122,20 @@ public class DingMsgCard {
 
 	public DingMsgCard applyPlaceholders(String msgName, String time, String lineTitle, String lineSn,
 			String userNames, String triggerDesc) {
+		return applyPlaceholders(msgName, time, lineTitle, lineSn, userNames, triggerDesc, null);
+	}
+
+	public DingMsgCard applyPlaceholders(String msgName, String time, String lineTitle, String lineSn,
+			String userNames, String triggerDesc, java.util.Map<String, String> extra) {
 		DingMsgCard out = new DingMsgCard();
-		out.setTitle(replaceAll(title, msgName, time, lineTitle, lineSn, userNames, triggerDesc));
+		out.setTitle(replaceAll(title, msgName, time, lineTitle, lineSn, userNames, triggerDesc, extra));
 		for (Item item : items) {
 			if (item == null) {
 				continue;
 			}
 			out.addItem(
-					replaceAll(item.getLabel(), msgName, time, lineTitle, lineSn, userNames, triggerDesc),
-					replaceAll(item.getValue(), msgName, time, lineTitle, lineSn, userNames, triggerDesc));
+					replaceAll(item.getLabel(), msgName, time, lineTitle, lineSn, userNames, triggerDesc, extra),
+					replaceAll(item.getValue(), msgName, time, lineTitle, lineSn, userNames, triggerDesc, extra));
 		}
 		return out;
 	}
@@ -158,16 +163,27 @@ public class DingMsgCard {
 	}
 
 	private static String replaceAll(String text, String msgName, String time, String lineTitle, String lineSn,
-			String userNames, String triggerDesc) {
+			String userNames, String triggerDesc, java.util.Map<String, String> extra) {
 		if (text == null) {
 			return "";
 		}
-		return text.replace("{消息名称}", msgName).replace("${msgName}", msgName)
+		String out = text.replace("{消息名称}", msgName).replace("${msgName}", msgName)
 				.replace("{发送时间}", time).replace("${time}", time)
 				.replace("{产线名称}", lineTitle).replace("${lineTitle}", lineTitle)
 				.replace("{产线编号}", lineSn).replace("${lineSn}", lineSn)
 				.replace("{接收人}", userNames).replace("${userNames}", userNames)
 				.replace("{触发说明}", triggerDesc);
+		if (extra != null) {
+			for (java.util.Map.Entry<String, String> entry : extra.entrySet()) {
+				if (entry.getKey() == null) {
+					continue;
+				}
+				String key = entry.getKey();
+				String val = entry.getValue() == null ? "" : entry.getValue();
+				out = out.replace("{" + key + "}", val);
+			}
+		}
+		return out;
 	}
 
 	public String getTitle() {

+ 19 - 25
src/main/java/com/jeesite/modules/mes/dingtalk/DingMsgCode.java

@@ -5,38 +5,32 @@ package com.jeesite.modules.mes.dingtalk;
  */
 public enum DingMsgCode {
 
-	DEVICE_MAINT_L2("DEVICE", "设备", "二级保养及夹具普查提醒",
-			"每月20日任务提前3天,每天10:00和15:00各推一次", 10),
-	DEVICE_MAINT_L3("DEVICE", "设备", "三级保养到期提醒",
-			"到期前15天,每天推送一次", 20),
+	DEVICE_PREVENT_TASK("DEVICE", "设备", "预防性任务提示",
+			"每天中午12点进行定时任务:查询预防性维护计划,在开始前3天或者已经开始(未结束),但是没有完成的任务,进行提醒", 10),
 	PROD_PLAN_RATE("PROD", "生产", "生产计划完成率低于97%",
-			"排产计划最后一天中午12:00检查", 30),
+			"每天中午12点进行定时任务:排产计划中的最后一天中午12:00,如果完成率低于97%,进行提醒", 20),
 	PROD_MATERIAL_3DAY("PROD", "生产", "低于3天计划量的物料",
-			"每天检查物料库存是否不足3天计划量", 40),
+			"每天中午12点进行定时任务:查询当前日期的计划数据,计算平均每天需要做工数量,结合产线每个工位需要的物料进行计算,如果库存中物料少于三天的需要物料进行提醒", 30),
 	PROD_OEE_85("PROD", "生产", "设备OEE低于85%",
-			"每天低于85%时推送一次", 50),
-	PROD_STOCK_500("PROD", "生产", "成品库存低于500件",
-			"每天中午12:00检查", 60),
-	QM_FIRST_NG("QUALITY", "质量", "首件NG时推送",
-			"首件检查结果为NG时立即推送", 70),
-	QM_SPOT_NG("QUALITY", "质量", "抽检NG时推送",
-			"中间件抽检结果为NG时立即推送", 80),
-	QM_LAST_NG("QUALITY", "质量", "末件NG时推送",
-			"末件检查结果为NG时立即推送", 90),
-	QM_REPAIR_ONLINE("QUALITY", "质量", "返修完成重新上线",
-			"返修机完成并重新上线时推送", 100),
+			"每1小时进行定时任务:看设备综合效率管理大屏中的平均开动率,如果低于85%,则发送数据", 40),
+	PROD_STOCK_500("PROD", "生产", "产品成品库存低于500件",
+			"每天中午12点进行定时任务:查询批次码记录表中仓库中的批次码,计算数量,如果少于500,进行提醒", 50),
+	QM_FIRST_NG("QUALITY", "质量", "首件NG时进行消息推送",
+			"触发任务:使用pda抽检模块提交首件检查为NG时触发", 60),
+	QM_SPOT_NG("QUALITY", "质量", "抽检NG时进行消息推送",
+			"触发任务:使用pda抽检模块提交抽检为NG时触发", 70),
+	QM_LAST_NG("QUALITY", "质量", "末件NG时进行消息推送",
+			"触发任务:使用pda抽检模块提交末件检查为NG时触发", 80),
+	QM_REPAIR_ONLINE("QUALITY", "质量", "返修机完成重新上线",
+			"触发任务:返修检查OK时", 90),
 	QM_SCRAP("QUALITY", "质量", "报废",
-			"产品报废时推送", 110),
-	QM_CMM("QUALITY", "质量", "三坐标测量",
-			"暂未对接业务,仅支持配置和测试发送", 120),
-	QM_LAB("QUALITY", "质量", "实验室实验",
-			"暂未对接业务,仅支持配置和测试发送", 130),
+			"触发任务:当调用报废接口提交时触发", 100),
 	QM_NG_3("QUALITY", "质量", "当班同产品不良数超过3",
-			"当班同产品不良累计超过3件时推送", 140),
+			"触发任务:加工提交结果为NG时,查询当班NG数量,当NG数量为4时触发", 110),
 	QM_NG_10("QUALITY", "质量", "当班同产品不良数超过10",
-			"当班同产品不良累计超过10件时推送", 150),
+			"触发任务:加工提交结果为NG时,查询当班NG数量,当NG数量为11时触发", 120),
 	QM_NG_50("QUALITY", "质量", "当班同产品不良数超过50",
-			"当班同产品不良累计超过50件时推送", 160);
+			"触发任务:加工提交结果为NG时,查询当班NG数量,当NG数量为51时触发", 130);
 
 	private final String module;
 	private final String moduleName;

+ 29 - 1
src/main/java/com/jeesite/modules/mes/dingtalk/MesDingMsgTableInitializer.java

@@ -22,7 +22,7 @@ public class MesDingMsgTableInitializer implements ApplicationRunner {
 			+ "`msg_code` varchar(64) NOT NULL COMMENT '消息编码',"
 			+ "`msg_name` varchar(100) NOT NULL COMMENT '消息名称',"
 			+ "`module` varchar(20) NOT NULL COMMENT '模块',"
-			+ "`trigger_desc` varchar(255) DEFAULT '' COMMENT '触发说明',"
+			+ "`trigger_desc` varchar(500) DEFAULT '' COMMENT '触发说明',"
 			+ "`msg_content` text COMMENT '发送内容',"
 			+ "`is_enabled` char(1) NOT NULL DEFAULT '0' COMMENT '是否启用',"
 			+ "`sort_order` int DEFAULT 0 COMMENT '排序',"
@@ -66,7 +66,9 @@ public class MesDingMsgTableInitializer implements ApplicationRunner {
 			jdbcTemplate.execute(CREATE_CONFIG_SQL);
 			jdbcTemplate.execute(CREATE_LINE_SQL);
 			ensureMsgContentColumn();
+			ensureTriggerDescWidth();
 			seedConfigs();
+			removeObsoleteConfigs();
 			ensureMenu();
 			logger.info("钉钉消息推送配置表已就绪");
 		} catch (Exception ex) {
@@ -81,6 +83,11 @@ public class MesDingMsgTableInitializer implements ApplicationRunner {
 					"SELECT COUNT(1) FROM mes_ding_msg_config WHERE msg_code = ?", Integer.class, code.getCode());
 			if (exists != null && exists > 0) {
 				jdbcTemplate.update(
+						"UPDATE mes_ding_msg_config SET msg_name = ?, module = ?, trigger_desc = ?, sort_order = ?, "
+								+ "update_by = 'system', update_date = NOW() WHERE msg_code = ?",
+						code.getMsgName(), code.getModule(), code.getTriggerDesc(),
+						code.getSortOrder(), code.getCode());
+				jdbcTemplate.update(
 						"UPDATE mes_ding_msg_config SET msg_content = ? WHERE msg_code = ? AND (msg_content IS NULL OR msg_content = '')",
 						code.getDefaultContent(), code.getCode());
 				jdbcTemplate.update(
@@ -105,6 +112,27 @@ public class MesDingMsgTableInitializer implements ApplicationRunner {
 		}
 	}
 
+	/** 已有表加宽触发说明,避免新说明被截断 */
+	private void ensureTriggerDescWidth() {
+		jdbcTemplate.execute("ALTER TABLE mes_ding_msg_config MODIFY COLUMN trigger_desc varchar(500) DEFAULT '' COMMENT '触发说明'");
+	}
+
+	/** 去掉已取消的预置类型(含产线接收人) */
+	private void removeObsoleteConfigs() {
+		StringBuilder in = new StringBuilder();
+		for (DingMsgCode code : DingMsgCode.values()) {
+			if (in.length() > 0) {
+				in.append(',');
+			}
+			in.append('\'').append(code.getCode()).append('\'');
+		}
+		jdbcTemplate.update(
+				"DELETE FROM mes_ding_msg_line WHERE config_id IN ("
+						+ "SELECT id FROM (SELECT id FROM mes_ding_msg_config WHERE msg_code NOT IN ("
+						+ in + ")) t)");
+		jdbcTemplate.update("DELETE FROM mes_ding_msg_config WHERE msg_code NOT IN (" + in + ")");
+	}
+
 	/** 已有表补加发送内容列 */
 	private void ensureMsgContentColumn() {
 		Integer cnt = jdbcTemplate.queryForObject(

+ 267 - 3
src/main/java/com/jeesite/modules/mes/dingtalk/MesDingTalkAlertService.java

@@ -4,23 +4,35 @@ import com.jeesite.common.lang.DateUtils;
 import com.jeesite.common.lang.StringUtils;
 import com.jeesite.common.service.ServiceException;
 import com.jeesite.modules.mes.entity.*;
+import com.jeesite.modules.mes.service.MesDingMsgConfigService;
+import com.jeesite.modules.sys.utils.DictUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 
+import java.util.ArrayList;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 @Service
 public class MesDingTalkAlertService {
 
+    private static final Logger logger = LoggerFactory.getLogger(MesDingTalkAlertService.class);
+
     private final DingTalkRobotClient robotClient;
     private final DingTalkAppClient appClient;
+    private final MesDingMsgConfigService mesDingMsgConfigService;
 
-    public MesDingTalkAlertService(DingTalkRobotClient robotClient, DingTalkAppClient appClient) {
+    public MesDingTalkAlertService(DingTalkRobotClient robotClient, DingTalkAppClient appClient,
+            MesDingMsgConfigService mesDingMsgConfigService) {
         this.robotClient = robotClient;
         this.appClient = appClient;
+        this.mesDingMsgConfigService = mesDingMsgConfigService;
     }
 
     /**
@@ -40,9 +52,116 @@ public class MesDingTalkAlertService {
     }
 
     /**
-     * 把配置渲染成钉钉 OA 卡片(占位符换成实际值)。
+     * 正式推送:消息已启用、产线已配接收人才发工作通知。
      */
+    public boolean sendEnabled(String msgCode, String lineSn, Map<String, String> extra,
+            List<DingMsgCard.Item> extraItems) {
+        if (!mesDingMsgConfigService.isEnabled(msgCode)) {
+            logger.info("钉钉消息未启用,跳过:{}", msgCode);
+            return false;
+        }
+        MesDingMsgConfig config = mesDingMsgConfigService.getByMsgCode(msgCode);
+        if (config == null) {
+            logger.warn("钉钉消息配置不存在:{}", msgCode);
+            return false;
+        }
+        MesDingMsgLine line = mesDingMsgConfigService.findLine(config.getId(), lineSn);
+        if (line == null) {
+            logger.info("产线未配置接收人,跳过:msgCode={}, lineSn={}", msgCode, lineSn);
+            return false;
+        }
+        List<String> mobiles = splitMobiles(line.getMobiles());
+        if (mobiles.isEmpty()) {
+            logger.warn("产线接收人无手机号,跳过:lineSn={}", lineSn);
+            return false;
+        }
+        DingMsgCard card = renderCard(config, line, extra);
+        if (extraItems != null) {
+            for (DingMsgCard.Item item : extraItems) {
+                if (item == null || (StringUtils.isBlank(item.getLabel()) && StringUtils.isBlank(item.getValue()))) {
+                    continue;
+                }
+                card.addItem(item.getLabel(), item.getValue());
+            }
+        }
+        boolean ok = appClient.sendOa("MES通知", card.getTitle(), card.toOaForm(), mobiles);
+        if (!ok) {
+            logger.warn("钉钉工作通知发送失败:msgCode={}, lineSn={}", msgCode, lineSn);
+        }
+        return ok;
+    }
+
+    /**
+     * 正式推送:发给该消息已配置的全部产线接收人(手机号去重)。
+     * 维护计划本身不绑产线时用这个。
+     */
+    public boolean sendEnabledAllLines(String msgCode, Map<String, String> extra,
+            List<DingMsgCard.Item> extraItems) {
+        if (!mesDingMsgConfigService.isEnabled(msgCode)) {
+            logger.info("钉钉消息未启用,跳过:{}", msgCode);
+            return false;
+        }
+        MesDingMsgConfig config = mesDingMsgConfigService.getByMsgCode(msgCode);
+        if (config == null) {
+            logger.warn("钉钉消息配置不存在:{}", msgCode);
+            return false;
+        }
+        List<MesDingMsgLine> lines = config.getMesDingMsgLineList();
+        if (lines == null || lines.isEmpty()) {
+            logger.info("未配置任何产线接收人,跳过:{}", msgCode);
+            return false;
+        }
+        List<String> mobiles = new java.util.ArrayList<String>();
+        List<String> lineTitles = new java.util.ArrayList<String>();
+        List<String> userNames = new java.util.ArrayList<String>();
+        for (MesDingMsgLine line : lines) {
+            if (line == null) {
+                continue;
+            }
+            if (StringUtils.isNotBlank(line.getLineTitle())) {
+                lineTitles.add(line.getLineTitle());
+            }
+            if (StringUtils.isNotBlank(line.getUserNames())) {
+                userNames.add(line.getUserNames());
+            }
+            for (String mobile : splitMobiles(line.getMobiles())) {
+                if (!mobiles.contains(mobile)) {
+                    mobiles.add(mobile);
+                }
+            }
+        }
+        if (mobiles.isEmpty()) {
+            logger.warn("已配产线均无有效手机号,跳过:{}", msgCode);
+            return false;
+        }
+        MesDingMsgLine merged = new MesDingMsgLine();
+        merged.setLineSn("");
+        merged.setLineTitle(String.join("、", lineTitles));
+        merged.setUserNames(String.join("、", userNames));
+        DingMsgCard card = renderCard(config, merged, extra);
+        if (extraItems != null) {
+            for (DingMsgCard.Item item : extraItems) {
+                if (item == null || (StringUtils.isBlank(item.getLabel()) && StringUtils.isBlank(item.getValue()))) {
+                    continue;
+                }
+                card.addItem(item.getLabel(), item.getValue());
+            }
+        }
+        boolean ok = appClient.sendOa("MES通知", card.getTitle(), card.toOaForm(), mobiles);
+        if (!ok) {
+            logger.warn("钉钉工作通知发送失败:msgCode={}", msgCode);
+        }
+        return ok;
+    }
+
     public DingMsgCard renderCard(MesDingMsgConfig config, MesDingMsgLine line) {
+        return renderCard(config, line, null);
+    }
+
+    /**
+     * 把配置渲染成钉钉 OA 卡片(占位符换成实际值)。
+     */
+    public DingMsgCard renderCard(MesDingMsgConfig config, MesDingMsgLine line, Map<String, String> extra) {
         String content = config == null ? "" : config.getMsgContent();
         DingMsgCard card = DingMsgCard.parse(content);
         if (card.getItems().isEmpty() && config != null) {
@@ -57,7 +176,7 @@ public class MesDingTalkAlertService {
         String msgName = config == null ? "" : StringUtils.defaultString(config.getMsgName());
         String triggerDesc = config == null ? "" : StringUtils.defaultString(config.getTriggerDesc());
         String time = DateUtils.formatDateTime(new Date());
-        return card.applyPlaceholders(msgName, time, lineTitle, lineSn, userNames, triggerDesc);
+        return card.applyPlaceholders(msgName, time, lineTitle, lineSn, userNames, triggerDesc, extra);
     }
 
     /**
@@ -115,6 +234,151 @@ public class MesDingTalkAlertService {
                 field("结果", alarm.getResult()),
                 field("记录ID", alarm.getId())));
     }
+
+    /**
+     * PDA 抽检提交:仅当结果为 NG 时,按检查类型推对应消息给该产线接收人。
+     * 1首件 / 2中间件(抽检) / 3末件。
+     */
+    @Async
+    public void sendPdaQualityNg(MesQuality quality) {
+        if (quality == null) {
+            return;
+        }
+        if (!isNg(quality.getResult())) {
+            return;
+        }
+        DingMsgCode code = qualityNgCode(quality.getType());
+        if (code == null) {
+            logger.info("抽检NG类型未匹配消息:type={}", quality.getType());
+            return;
+        }
+        if (StringUtils.isBlank(quality.getLineSn())) {
+            logger.info("抽检NG无产线编号,跳过:{}", code.getCode());
+            return;
+        }
+        String typeName = DictUtils.getDictLabel("mes_quality_type", quality.getType(), quality.getType());
+        Map<String, String> extra = new HashMap<String, String>();
+        extra.put("工件码", StringUtils.defaultString(quality.getSn()));
+        extra.put("工位", StringUtils.defaultString(quality.getOprno()));
+        extra.put("检查类型", StringUtils.defaultString(typeName));
+        extra.put("检查结果", StringUtils.defaultString(quality.getResult()));
+        extra.put("备注", StringUtils.defaultString(quality.getRemark()));
+        List<DingMsgCard.Item> extraItems = new ArrayList<DingMsgCard.Item>();
+        extraItems.add(new DingMsgCard.Item("工件码", extra.get("工件码")));
+        extraItems.add(new DingMsgCard.Item("工位", extra.get("工位")));
+        extraItems.add(new DingMsgCard.Item("检查类型", extra.get("检查类型")));
+        extraItems.add(new DingMsgCard.Item("检查结果", extra.get("检查结果")));
+        if (StringUtils.isNotBlank(quality.getRemark())) {
+            extraItems.add(new DingMsgCard.Item("备注", extra.get("备注")));
+        }
+        sendEnabled(code.getCode(), quality.getLineSn(), extra, extraItems);
+    }
+
+    private DingMsgCode qualityNgCode(String type) {
+        if (MesQuality.TYPE_FIRST.equals(type)) {
+            return DingMsgCode.QM_FIRST_NG;
+        }
+        if (MesQuality.TYPE_SPOT.equals(type)) {
+            return DingMsgCode.QM_SPOT_NG;
+        }
+        if (MesQuality.TYPE_LAST.equals(type)) {
+            return DingMsgCode.QM_LAST_NG;
+        }
+        return null;
+    }
+
+    private boolean isNg(String result) {
+        return StringUtils.isNotBlank(result) && "NG".equalsIgnoreCase(result.trim());
+    }
+
+    /**
+     * 返修检查 OK、工件重新上线时推送。对应接口 mesRepairQuality/add,result=1。
+     */
+    @Async
+    public void sendRepairOnline(MesRepair repair) {
+        if (repair == null) {
+            return;
+        }
+        if (!"1".equals(repair.getResult())) {
+            return;
+        }
+        if (StringUtils.isBlank(repair.getLineSn())) {
+            logger.info("返修重新上线无产线编号,跳过:sn={}", repair.getSn());
+            return;
+        }
+        Map<String, String> extra = new HashMap<String, String>();
+        extra.put("工件码", StringUtils.defaultString(repair.getSn()));
+        extra.put("责任工位", StringUtils.defaultString(repair.getOprno()));
+        extra.put("返回工位", StringUtils.defaultString(repair.getReturnOprno()));
+        extra.put("返修工位", StringUtils.defaultString(repair.getFxOprno()));
+        extra.put("返修原因", StringUtils.defaultString(repair.getItemTitle()));
+        extra.put("检查结果", "OK");
+        List<DingMsgCard.Item> extraItems = new ArrayList<DingMsgCard.Item>();
+        extraItems.add(new DingMsgCard.Item("工件码", extra.get("工件码")));
+        extraItems.add(new DingMsgCard.Item("责任工位", extra.get("责任工位")));
+        extraItems.add(new DingMsgCard.Item("返回工位", extra.get("返回工位")));
+        extraItems.add(new DingMsgCard.Item("返修工位", extra.get("返修工位")));
+        if (StringUtils.isNotBlank(repair.getItemTitle())) {
+            extraItems.add(new DingMsgCard.Item("返修原因", extra.get("返修原因")));
+        }
+        extraItems.add(new DingMsgCard.Item("检查结果", extra.get("检查结果")));
+        sendEnabled(DingMsgCode.QM_REPAIR_ONLINE.getCode(), repair.getLineSn(), extra, extraItems);
+    }
+
+    /**
+     * 报废提交时推送。对应接口 mesScrap/add。
+     * 报废表无产线,由调用方从生产记录传入 lineSn;没有产线则发给该消息已配置的全部接收人。
+     */
+    @Async
+    public void sendScrap(MesScrap scrap, String lineSn) {
+        if (scrap == null) {
+            return;
+        }
+        Map<String, String> extra = new HashMap<String, String>();
+        extra.put("工件码", StringUtils.defaultString(scrap.getSn()));
+        extra.put("产品类型", StringUtils.defaultString(scrap.getProductCate()));
+        extra.put("报废原因", StringUtils.defaultString(scrap.getReason()));
+        List<DingMsgCard.Item> extraItems = new ArrayList<DingMsgCard.Item>();
+        extraItems.add(new DingMsgCard.Item("工件码", extra.get("工件码")));
+        if (StringUtils.isNotBlank(scrap.getProductCate())) {
+            extraItems.add(new DingMsgCard.Item("产品类型", extra.get("产品类型")));
+        }
+        extraItems.add(new DingMsgCard.Item("报废原因", extra.get("报废原因")));
+        if (StringUtils.isNotBlank(lineSn)) {
+            sendEnabled(DingMsgCode.QM_SCRAP.getCode(), lineSn, extra, extraItems);
+        } else {
+            sendEnabledAllLines(DingMsgCode.QM_SCRAP.getCode(), extra, extraItems);
+        }
+    }
+
+    /**
+     * 当班同产品不良数达到 4 / 11 / 51 时分别推送超过 3 / 10 / 50。
+     */
+    @Async
+    public void sendShiftProductNg(String lineSn, String cateSn, String oprno, String sn, long ngCount) {
+        DingMsgCode code = null;
+        if (ngCount == 4L) {
+            code = DingMsgCode.QM_NG_3;
+        } else if (ngCount == 11L) {
+            code = DingMsgCode.QM_NG_10;
+        } else if (ngCount == 51L) {
+            code = DingMsgCode.QM_NG_50;
+        }
+        if (code == null || StringUtils.isBlank(lineSn)) {
+            return;
+        }
+        Map<String, String> extra = new HashMap<String, String>();
+        extra.put("当班不良数", String.valueOf(ngCount));
+        extra.put("产品类型", StringUtils.defaultString(cateSn));
+        extra.put("工件码", StringUtils.defaultString(sn));
+        extra.put("工位", StringUtils.defaultString(oprno));
+        List<DingMsgCard.Item> extraItems = new ArrayList<DingMsgCard.Item>();
+        extraItems.add(new DingMsgCard.Item("当班不良数", extra.get("当班不良数")));
+        extraItems.add(new DingMsgCard.Item("产品类型", extra.get("产品类型")));
+        extraItems.add(new DingMsgCard.Item("工件码", extra.get("工件码")));
+        extraItems.add(new DingMsgCard.Item("工位", extra.get("工位")));
+        sendEnabled(code.getCode(), lineSn, extra, extraItems);
+    }
     @Async
     public void sendDeviceAlarm(MesDeviceAlarm alarm) {
         sendMarkdown("设备异常预警", buildMessage("设备异常预警",

+ 7 - 0
src/main/java/com/jeesite/modules/mes/entity/MesQuality.java

@@ -36,6 +36,13 @@ import java.util.List;
 public class MesQuality extends DataEntity<MesQuality> {
 	
 	private static final long serialVersionUID = 1L;
+	/** 抽检类型:首件 */
+	public static final String TYPE_FIRST = "1";
+	/** 抽检类型:中间件(抽检) */
+	public static final String TYPE_SPOT = "2";
+	/** 抽检类型:末件 */
+	public static final String TYPE_LAST = "3";
+
 	private String sn;		// 工件号
 	private String oprno;		// 工位
 	private String type;		// 类型

+ 24 - 0
src/main/java/com/jeesite/modules/mes/service/MesBatchSnService.java

@@ -171,6 +171,30 @@ public class MesBatchSnService extends CrudService<MesBatchSnDao, MesBatchSn> {
 	}
 
 	/**
+	 * 查询仓库中的批次码(生产入库后、尚未发货检/调拨)。
+	 */
+	public List<MesBatchSn> findInWarehouseList() {
+		MesBatchSn query = new MesBatchSn();
+		query.setBatchStatus(MesBatchSn.BATCH_STATUS_IN_WAREHOUSE);
+		List<MesBatchSn> list = findList(query);
+		return list == null ? new ArrayList<MesBatchSn>() : list;
+	}
+
+	/**
+	 * 仓库中成品件数:批次码绑定数量合计。
+	 */
+	public long sumInWarehouseQty() {
+		long total = 0L;
+		for (MesBatchSn batch : findInWarehouseList()) {
+			if (batch == null) {
+				continue;
+			}
+			total += batch.getNums() == null ? 0L : batch.getNums();
+		}
+		return total;
+	}
+
+	/**
 	 * 统计指定时间范围内 ERP 已入库批次码绑定数量合计。
 	 */
 	public long sumInstockQty(Date start, Date end) {

+ 60 - 0
src/main/java/com/jeesite/modules/mes/service/MesErpScheduleService.java

@@ -238,6 +238,66 @@ public class MesErpScheduleService extends CrudService<MesErpScheduleDao, MesErp
 	}
 
 	/**
+	 * 排产结束日等于指定日期的计划(用于最后一天中午检查完成率)。
+	 */
+	public List<MesErpSchedule> findEndingOn(LocalDate date) {
+		if (date == null) {
+			date = LocalDate.now();
+		}
+		String dateStr = date.format(DATE_FORMAT);
+		List<MesErpSchedule> plans = mesErpScheduleDao.findListInDateRange(dateStr, dateStr);
+		List<MesErpSchedule> ending = new ArrayList<>();
+		if (plans == null) {
+			return ending;
+		}
+		for (MesErpSchedule plan : plans) {
+			if (plan == null) {
+				continue;
+			}
+			if (dateStr.equals(normalizeDate(plan.getScheduleEndDate()))) {
+				ending.add(plan);
+			}
+		}
+		return ending;
+	}
+
+	/**
+	 * 排产计划完成率(产线工艺100000 OK数 / 排产数量),百分数。
+	 */
+	public BigDecimal calcScheduleCompletionPercent(MesErpSchedule plan) {
+		if (plan == null || plan.getScheduleQty() == null || plan.getScheduleQty() <= 0) {
+			return BigDecimal.ZERO;
+		}
+		long okQty = countScheduleOkQty(plan);
+		return BigDecimal.valueOf(okQty)
+				.multiply(BigDecimal.valueOf(100))
+				.divide(BigDecimal.valueOf(plan.getScheduleQty()), 1, RoundingMode.HALF_UP);
+	}
+
+	/**
+	 * 排产期间该产线总结果(工艺号100000)OK数量。
+	 */
+	public long countScheduleOkQty(MesErpSchedule plan) {
+		if (plan == null || StringUtils.isBlank(plan.getLineSn())) {
+			return 0L;
+		}
+		String startDate = normalizeDate(plan.getScheduleStartDate());
+		String endDate = normalizeDate(plan.getScheduleEndDate());
+		if (StringUtils.isBlank(startDate) || StringUtils.isBlank(endDate)) {
+			return 0L;
+		}
+		MesProductRecord query = new MesProductRecord();
+		query.setContent("OK");
+		query.setCraft(CRAFT_TOTAL_RESULT);
+		query.setLineSn(plan.getLineSn().trim());
+		query.getSqlMap().getWhere()
+				.and("a.update_date", QueryType.GTE, startDate + " 00:00:00")
+				.and("a.update_date", QueryType.LTE, endDate + " 23:59:59");
+		Long count = mesProductRecordService.findCount(query);
+		return count == null ? 0L : count;
+	}
+
+	/**
 	 * 中控大屏生产订单明细:优先当日排产,否则最近有效排产 + ERP 生产入库完成量。
 	 */
 	public List<Map<String, Object>> buildIntegratedScreenOrderDetails(LocalDate date) {

+ 17 - 0
src/main/java/com/jeesite/modules/mes/service/MesLineProcessConsumeService.java

@@ -1,12 +1,15 @@
 package com.jeesite.modules.mes.service;
 
+import com.jeesite.common.entity.DataEntity;
 import com.jeesite.common.entity.Page;
+import com.jeesite.common.lang.StringUtils;
 import com.jeesite.common.service.CrudService;
 import com.jeesite.modules.mes.dao.MesLineProcessConsumeDao;
 import com.jeesite.modules.mes.entity.MesLineProcessConsume;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.util.Collections;
 import java.util.List;
 
 /**
@@ -49,4 +52,18 @@ public class MesLineProcessConsumeService extends CrudService<MesLineProcessCons
 	public void delete(MesLineProcessConsume mesLineProcessConsume) {
 		super.delete(mesLineProcessConsume);
 	}
+
+	/**
+	 * 查询工位上启用中的加工消耗绑定。
+	 */
+	public List<MesLineProcessConsume> findActiveByProcessId(String lineProcessId) {
+		if (StringUtils.isBlank(lineProcessId)) {
+			return Collections.emptyList();
+		}
+		MesLineProcessConsume query = new MesLineProcessConsume();
+		query.setLineProcessId(lineProcessId);
+		query.setStatus(DataEntity.STATUS_NORMAL);
+		List<MesLineProcessConsume> list = super.findList(query);
+		return list == null ? Collections.emptyList() : list;
+	}
 }

+ 20 - 0
src/main/java/com/jeesite/modules/mes/service/MesProductRecordService.java

@@ -1428,4 +1428,24 @@ public class MesProductRecordService extends CrudService<MesProductRecordDao, Me
 		query.getSqlMap().getWhere().and("oprno", QueryType.LIKE, fOprno);
 		return findList(query);
 	}
+
+	/**
+	 * 统计当班某产线、同产品类型的工位总结果 NG 数量。
+	 */
+	public long countShiftNg(String lineSn, String cateSn, String start, String end) {
+		if (StringUtils.isBlank(lineSn) || StringUtils.isBlank(cateSn)
+				|| StringUtils.isBlank(start) || StringUtils.isBlank(end)) {
+			return 0L;
+		}
+		MesProductRecord query = new MesProductRecord();
+		query.setLineSn(lineSn);
+		query.setCateSn(cateSn);
+		query.setCraft("100000");
+		query.setContent("NG");
+		query.getSqlMap().getWhere()
+				.and("update_date", QueryType.GTE, start)
+				.and("update_date", QueryType.LT, end);
+		Long count = super.findCount(query);
+		return count == null ? 0L : count.longValue();
+	}
 }

+ 37 - 0
src/main/java/com/jeesite/modules/mes/web/MesProductRecordController.java

@@ -13,12 +13,14 @@ import com.jeesite.common.utils.excel.ExcelExport;
 import com.jeesite.common.web.BaseController;
 import com.jeesite.modules.file.entity.FileUpload;
 import com.jeesite.modules.file.service.FileUploadService;
+import com.jeesite.modules.mes.dingtalk.MesDingTalkAlertService;
 import com.jeesite.modules.mes.entity.*;
 import com.jeesite.modules.mes.req.MesDeviceStateReq;
 import com.jeesite.modules.mes.req.MesProductQmReq;
 import com.jeesite.modules.mes.req.MesSearchDateReq;
 import com.jeesite.modules.mes.resp.BindMaterialResp;
 import com.jeesite.modules.mes.resp.CommonResp;
+import com.jeesite.modules.mes.resp.MesShiftDataResp;
 import com.jeesite.modules.mes.resp.ParamsResp;
 import com.jeesite.modules.mes.service.*;
 import com.jeesite.modules.mes.util.CommonUitl;
@@ -192,6 +194,8 @@ public class MesProductRecordController extends BaseController {
 
 	@Autowired
 	private MesOp050RecordService mesOp050RecordService;
+	@Autowired
+	private MesDingTalkAlertService mesDingTalkAlertService;
 
 
 	/**
@@ -2213,6 +2217,7 @@ public class MesProductRecordController extends BaseController {
 		if(mesLineProcess1.getType().equals("3")){ // 单部件
 			List<BindMaterialResp> bmlists = mesLineProcessMaterialService.getBindMaterial(oprno, lineSn);
 			mesProductRecordService.updateRecordResult(sn,oprno,craft,"",content,userCode,params,lineSn,true,bmlists);
+			notifyShiftProductNg(sn, lineSn, oprno, content);
 		}else{
 			if(mesLineProcess1.getIsPz().equals("1")){ // 需要先拍照,拍照后才能提交
 				MesProductGp12 mesProductGp12 = new MesProductGp12();
@@ -2288,6 +2293,7 @@ public class MesProductRecordController extends BaseController {
 				// 2.判断物料批次是否符合绑定
 				List<BindMaterialResp> bmlists = mesLineProcessMaterialService.getBindMaterial(oprno, lineSn);
 				mesProductRecordService.updateRecordResult(sn,oprno,craft,"",content,userCode,params,lineSn,true,bmlists);
+				notifyShiftProductNg(sn, lineSn, oprno, content);
 			}
 
 		}
@@ -2295,6 +2301,37 @@ public class MesProductRecordController extends BaseController {
 		return "RSOK";
 	}
 
+	/**
+	 * 加工总结果为 NG 时:统计当班同产线同产品 NG 数,达到 4/11/51 分别推送。
+	 */
+	private void notifyShiftProductNg(String sn, String lineSn, String oprno, String content) {
+		if (!"NG".equalsIgnoreCase(StringUtils.defaultString(content)) || StringUtils.isBlank(lineSn)) {
+			return;
+		}
+		try {
+			MesShiftDataResp shift = mesShiftService.getCurShift();
+			if (shift == null || StringUtils.isBlank(shift.getStart()) || StringUtils.isBlank(shift.getEnd())) {
+				return;
+			}
+			String cateSn = "";
+			MesProduct product = mesProductService.findBySn(sn);
+			if (product != null) {
+				cateSn = product.getCate();
+			}
+			if (StringUtils.isBlank(cateSn)) {
+				cateSn = mesLineService.findInfoCate(lineSn);
+			}
+			if (StringUtils.isBlank(cateSn)) {
+				return;
+			}
+			long ngCount = mesProductRecordService.countShiftNg(lineSn, cateSn, shift.getStart(), shift.getEnd());
+			mesDingTalkAlertService.sendShiftProductNg(lineSn, cateSn,
+					CommonUitl.formatOprno(oprno), sn, ngCount);
+		} catch (Exception ex) {
+			logger.error("当班同产品不良数提醒失败:{}", ex.getMessage(), ex);
+		}
+	}
+
 	//绑定报文
 	//msg=aaaabbbbbABWMBDWGWOP040 GY400002ID151245P00000106200123062900001      SNWL51248P0000010620012306290001      DA2023-08-18ZT14:32:33YHjinjushijinjushi1111
 	private String bindRecord(String msg){

+ 2 - 1
src/main/java/com/jeesite/modules/mes/web/MesQualityController.java

@@ -120,7 +120,7 @@ public class MesQualityController extends BaseController {
 		boolean isNewRecord = mesQuality.getIsNewRecord();
 		mesQualityService.save(mesQuality);
 		if (isNewRecord) {
-			mesDingTalkAlertService.sendProductionQuality(mesQuality);
+			mesDingTalkAlertService.sendPdaQualityNg(mesQuality);
 		}
 		return renderResult(Global.TRUE, text("保存抽检记录成功!"));
 	}
@@ -374,6 +374,7 @@ public class MesQualityController extends BaseController {
 		mesQuality.setSn(sn);
 		mesQuality.setRemark(remark);
 		mesQualityService.save(mesQuality);
+		mesDingTalkAlertService.sendPdaQualityNg(mesQuality);
 
 		if(!mesProductRecord.getContent().equals(result)){
 			mesProductRecord.setContent(result);

+ 6 - 0
src/main/java/com/jeesite/modules/mes/web/MesRepairQualityController.java

@@ -9,6 +9,7 @@ import com.jeesite.common.web.BaseController;
 import com.jeesite.modules.file.entity.FileUpload;
 import com.jeesite.modules.file.service.FileUploadService;
 import com.jeesite.modules.file.utils.FileUploadUtils;
+import com.jeesite.modules.mes.dingtalk.MesDingTalkAlertService;
 import com.jeesite.modules.mes.entity.*;
 import com.jeesite.modules.mes.resp.CommonResp;
 import com.jeesite.modules.mes.service.*;
@@ -56,6 +57,8 @@ public class MesRepairQualityController extends BaseController {
 	private MesLineProcessService mesLineProcessService;
 	@Autowired
 	private MesRepairItemsService mesRepairItemsService;
+	@Autowired
+	private MesDingTalkAlertService mesDingTalkAlertService;
 
 	/**
 	 * 获取数据
@@ -283,6 +286,9 @@ public class MesRepairQualityController extends BaseController {
 			pd.setState("4"); // 待处理
 		}
 		mesProductService.save(pd);
+		if (result.equals("1")) {
+			mesDingTalkAlertService.sendRepairOnline(mesRepair);
+		}
 
 		resp.setResult(Global.TRUE);
 		return resp;

+ 127 - 3
src/main/java/com/jeesite/modules/mes/web/MesScrapController.java

@@ -12,11 +12,15 @@ import com.jeesite.common.web.BaseController;
 import com.jeesite.modules.file.entity.FileUpload;
 import com.jeesite.modules.file.service.FileUploadService;
 import com.jeesite.modules.file.utils.FileUploadUtils;
+import com.jeesite.modules.mes.dingtalk.MesDingTalkAlertService;
 import com.jeesite.modules.mes.entity.MesProduct;
+import com.jeesite.modules.mes.entity.MesProductRecord;
 import com.jeesite.modules.mes.entity.MesRepair;
 import com.jeesite.modules.mes.entity.MesScrap;
 import com.jeesite.modules.mes.resp.CommonResp;
+import com.jeesite.modules.mes.service.MesProductRecordService;
 import com.jeesite.modules.mes.service.MesProductService;
+import com.jeesite.modules.mes.service.MesRepairService;
 import com.jeesite.modules.mes.service.MesScrapService;
 import com.jeesite.modules.sys.entity.User;
 import com.jeesite.modules.sys.utils.UserUtils;
@@ -50,7 +54,13 @@ public class MesScrapController extends BaseController {
 	@Autowired
 	private MesProductService mesProductService;
 	@Autowired
+	private MesProductRecordService mesProductRecordService;
+	@Autowired
 	private FileUploadService fileUploadService;
+	@Autowired
+	private MesDingTalkAlertService mesDingTalkAlertService;
+	@Autowired
+	private MesRepairService mesRepairService;
 	/**
 	 * 获取数据
 	 */
@@ -160,6 +170,14 @@ public class MesScrapController extends BaseController {
 			resp.setMessage("产品已报废");
 			return resp;
 		}
+		// PDA 待处理列表带过来的是产品 id,不能当成报废记录去更新
+		if(!StringUtils.isEmpty(mesScrap.getId())){
+			MesScrap exists = mesScrapService.get(mesScrap.getId());
+			if(ObjectUtils.isEmpty(exists)){
+				mesScrap.setId(null);
+				mesScrap.setIsNewRecord(true);
+			}
+		}
 //		if(!mesProduct.getState().equals("4")){
 //			resp.setMessage("只能报废待处理状态的产品");
 //			return resp;
@@ -173,6 +191,13 @@ public class MesScrapController extends BaseController {
 		mesProduct.setState("3");
 		mesProductService.update(mesProduct);
 
+		String lineSn = "";
+		MesProductRecord record = mesProductRecordService.findBySn(mesScrap.getSn());
+		if (record != null) {
+			lineSn = record.getLineSn();
+		}
+		mesDingTalkAlertService.sendScrap(mesScrap, lineSn);
+
 		resp.setResult(Global.TRUE);
 		return resp;
 	}
@@ -187,6 +212,20 @@ public class MesScrapController extends BaseController {
 		mesScrap.setPage(new Page<>(request, response));
 		Page<MesScrap> page = mesScrapService.findPage(mesScrap);
 
+		// 返修检查 NG 后产品为待处理,尚无 mes_scrap。PDA 扫码在本地过滤列表,需把待处理工件一并返回
+		String sn = mesScrap.getSn();
+		if(StringUtils.isEmpty(sn)){
+			sn = request.getParameter("sn");
+		}
+		List<MesScrap> pending = findPendingScrapList(sn);
+		if(!ListUtils.isEmpty(pending) && page.getPageNo() <= 1){
+			List<MesScrap> merged = ListUtils.newArrayList();
+			merged.addAll(pending);
+			merged.addAll(page.getList());
+			page.setList(merged);
+			page.setCount(page.getCount() + pending.size());
+		}
+
 		CommonResp<Page> resp = new CommonResp<>();
 
 		resp.setData(page);
@@ -201,8 +240,38 @@ public class MesScrapController extends BaseController {
 	@RequestMapping(value = "info")
 	@ResponseBody
 	public CommonResp info(MesScrap mesScrap, HttpServletRequest request) {
-		mesScrap.setId(request.getParameter("id"));
-		MesScrap info = mesScrapService.get(mesScrap);
+		CommonResp<MesScrap> resp = new CommonResp<>();
+		String id = request.getParameter("id");
+		String sn = request.getParameter("sn");
+		if(StringUtils.isEmpty(sn)){
+			sn = mesScrap.getSn();
+		}
+
+		MesScrap info = null;
+		if(!StringUtils.isEmpty(id)){
+			mesScrap.setId(id);
+			info = mesScrapService.get(mesScrap);
+		}
+		if(ObjectUtils.isEmpty(info) && !StringUtils.isEmpty(sn)){
+			MesScrap query = new MesScrap();
+			query.setSn(sn);
+			List<MesScrap> list = mesScrapService.findList(query);
+			if(!ListUtils.isEmpty(list)){
+				info = list.get(0);
+			}
+		}
+		if(ObjectUtils.isEmpty(info)){
+			MesScrap pending = findPendingScrapByIdOrSn(id, sn);
+			if(ObjectUtils.isEmpty(pending)){
+				resp.setResult(Global.FALSE);
+				resp.setMessage("记录不存在");
+				return resp;
+			}
+			pending.setImg(ListUtils.newArrayList());
+			resp.setData(pending);
+			resp.setResult(Global.TRUE);
+			return resp;
+		}
 
 		String host = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + "/js";
 
@@ -221,10 +290,65 @@ public class MesScrapController extends BaseController {
 			imgList.add(map1);
 		}
 		info.setImg(imgList);
-		CommonResp<MesScrap> resp = new CommonResp<>();
 		resp.setData(info);
+		resp.setResult(Global.TRUE);
 		return resp;
 	}
 
+	/**
+	 * 待处理(返修检查 NG)工件,供 PDA 报废列表扫码。
+	 */
+	private List<MesScrap> findPendingScrapList(String sn) {
+		MesProduct query = new MesProduct();
+		query.setState("4");
+		if(!StringUtils.isEmpty(sn)){
+			query.setSn(sn);
+		}
+		List<MesProduct> products = mesProductService.findList(query);
+		List<MesScrap> list = ListUtils.newArrayList();
+		if(ListUtils.isEmpty(products)){
+			return list;
+		}
+		for (MesProduct product : products) {
+			list.add(buildPendingScrap(product));
+		}
+		return list;
+	}
 
+	private MesScrap findPendingScrapByIdOrSn(String id, String sn) {
+		MesProduct product = null;
+		if(!StringUtils.isEmpty(id)){
+			product = mesProductService.get(id);
+		}
+		if(ObjectUtils.isEmpty(product) && !StringUtils.isEmpty(sn)){
+			MesProduct query = new MesProduct();
+			query.setSn(sn);
+			product = mesProductService.findInfo(query);
+		}
+		if(ObjectUtils.isEmpty(product) || !"4".equals(product.getState())){
+			return null;
+		}
+		return buildPendingScrap(product);
+	}
+
+	private MesScrap buildPendingScrap(MesProduct product) {
+		MesScrap scrap = new MesScrap();
+		scrap.setId(product.getId());
+		scrap.setSn(product.getSn());
+		scrap.setProductCate(product.getCate());
+		scrap.setCreateDate(product.getUpdateDate());
+		scrap.setUpdateDate(product.getUpdateDate());
+		MesRepair repairQuery = new MesRepair();
+		repairQuery.setSn(product.getSn());
+		repairQuery.setResult("2");
+		MesRepair repair = mesRepairService.findInfo(repairQuery);
+		if(!ObjectUtils.isEmpty(repair) && !StringUtils.isEmpty(repair.getItemTitle())){
+			scrap.setReason(repair.getItemTitle());
+		}else if(!ObjectUtils.isEmpty(repair) && !StringUtils.isEmpty(repair.getRemark())){
+			scrap.setReason(repair.getRemark());
+		}else{
+			scrap.setReason("返修检查NG");
+		}
+		return scrap;
+	}
 }

+ 129 - 0
src/main/java/com/jeesite/modules/scheduling/DevicePreventTaskScheduling.java

@@ -0,0 +1,129 @@
+package com.jeesite.modules.scheduling;
+
+import com.jeesite.common.lang.DateUtils;
+import com.jeesite.common.lang.StringUtils;
+import com.jeesite.modules.mes.dingtalk.DingMsgCard;
+import com.jeesite.modules.mes.dingtalk.DingMsgCode;
+import com.jeesite.modules.mes.dingtalk.MesDingTalkAlertService;
+import com.jeesite.modules.mes.entity.MesDevice;
+import com.jeesite.modules.mes.entity.MesDevicePreventTask;
+import com.jeesite.modules.mes.service.MesDevicePreventTaskService;
+import com.jeesite.modules.mes.service.MesDeviceService;
+import com.jeesite.modules.mes.service.MesDingMsgConfigService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 预防性任务提示。每天中午12点:开始前3天,或已开始且未结束、尚未完成的计划。
+ * 设备有产线则按产线推;没有则发给该消息已配置的全部产线接收人。
+ */
+@Configuration
+@EnableScheduling
+public class DevicePreventTaskScheduling {
+
+	private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+	@Autowired
+	private MesDevicePreventTaskService mesDevicePreventTaskService;
+	@Autowired
+	private MesDeviceService mesDeviceService;
+	@Autowired
+	private MesDingMsgConfigService mesDingMsgConfigService;
+	@Autowired
+	private MesDingTalkAlertService mesDingTalkAlertService;
+
+	@Scheduled(cron = "0 0 12 * * ?")
+	public void remindPreventTasks() {
+		try {
+			if (!mesDingMsgConfigService.isEnabled(DingMsgCode.DEVICE_PREVENT_TASK.getCode())) {
+				logger.info("预防性任务提示未启用,跳过本次定时任务");
+				return;
+			}
+			Date now = new Date();
+			Date in3Days = DateUtils.addDays(now, 3);
+			List<MesDevicePreventTask> all = mesDevicePreventTaskService.findList(new MesDevicePreventTask());
+			if (all == null || all.isEmpty()) {
+				return;
+			}
+			int sendCount = 0;
+			Map<String, MesDevice> deviceCache = new HashMap<String, MesDevice>();
+			for (MesDevicePreventTask task : all) {
+				if (!needRemind(task, now, in3Days)) {
+					continue;
+				}
+				MesDevice device = getDevice(deviceCache, task.getDeviceId());
+				String lineSn = device == null ? "" : StringUtils.defaultString(device.getLineSn());
+				Map<String, String> extra = new HashMap<String, String>();
+				extra.put("设备名称", StringUtils.defaultString(task.getDeviceName()));
+				extra.put("任务名称", StringUtils.defaultString(task.getPreventTitle()));
+				extra.put("开始时间", formatDate(task.getStartDate()));
+				extra.put("结束时间", formatDate(task.getEndDate()));
+				extra.put("执行人", StringUtils.defaultString(task.getUserName()));
+				List<DingMsgCard.Item> extraItems = new ArrayList<DingMsgCard.Item>();
+				extraItems.add(new DingMsgCard.Item("设备", extra.get("设备名称")));
+				extraItems.add(new DingMsgCard.Item("任务", extra.get("任务名称")));
+				extraItems.add(new DingMsgCard.Item("开始时间", extra.get("开始时间")));
+				extraItems.add(new DingMsgCard.Item("结束时间", extra.get("结束时间")));
+				extraItems.add(new DingMsgCard.Item("执行人", extra.get("执行人")));
+				boolean sent;
+				if (StringUtils.isNotBlank(lineSn)) {
+					sent = mesDingTalkAlertService.sendEnabled(DingMsgCode.DEVICE_PREVENT_TASK.getCode(),
+							lineSn, extra, extraItems);
+				} else {
+					// 维护计划不绑产线:发给该消息已配置的全部产线接收人
+					sent = mesDingTalkAlertService.sendEnabledAllLines(
+							DingMsgCode.DEVICE_PREVENT_TASK.getCode(), extra, extraItems);
+				}
+				if (sent) {
+					sendCount++;
+				}
+			}
+			logger.info("预防性任务提示本次推送 {} 条", sendCount);
+		} catch (Exception ex) {
+			logger.error("预防性任务提示定时任务失败:{}", ex.getMessage(), ex);
+		}
+	}
+
+	/**
+	 * 未完成,且开始前3天内,或已经开始但计划结束时间未到。
+	 */
+	private boolean needRemind(MesDevicePreventTask task, Date now, Date in3Days) {
+		if (task == null || "1".equals(task.getState())) {
+			return false;
+		}
+		Date start = task.getStartDate();
+		if (start == null) {
+			return false;
+		}
+		boolean upcoming = !start.before(now) && !start.after(in3Days);
+		Date end = task.getEndDate();
+		boolean startedNotEnded = !start.after(now) && (end == null || !end.before(now));
+		return upcoming || startedNotEnded;
+	}
+
+	private MesDevice getDevice(Map<String, MesDevice> cache, String deviceId) {
+		if (StringUtils.isBlank(deviceId)) {
+			return null;
+		}
+		if (cache.containsKey(deviceId)) {
+			return cache.get(deviceId);
+		}
+		MesDevice device = mesDeviceService.get(new MesDevice(deviceId));
+		cache.put(deviceId, device);
+		return device;
+	}
+
+	private String formatDate(Date date) {
+		return date == null ? "" : DateUtils.formatDateTime(date);
+	}
+}

+ 220 - 0
src/main/java/com/jeesite/modules/scheduling/ProdMaterial3DayScheduling.java

@@ -0,0 +1,220 @@
+package com.jeesite.modules.scheduling;
+
+import com.jeesite.common.lang.StringUtils;
+import com.jeesite.modules.mes.dingtalk.DingMsgCard;
+import com.jeesite.modules.mes.dingtalk.DingMsgCode;
+import com.jeesite.modules.mes.dingtalk.MesDingTalkAlertService;
+import com.jeesite.modules.mes.entity.MesLine;
+import com.jeesite.modules.mes.entity.MesLineProcess;
+import com.jeesite.modules.mes.entity.MesLineProcessConsume;
+import com.jeesite.modules.mes.entity.MesMaterialKcTotal;
+import com.jeesite.modules.mes.service.MesDingMsgConfigService;
+import com.jeesite.modules.mes.service.MesErpScheduleService;
+import com.jeesite.modules.mes.service.MesLineProcessConsumeService;
+import com.jeesite.modules.mes.service.MesLineProcessService;
+import com.jeesite.modules.mes.service.MesLineService;
+import com.jeesite.modules.mes.service.MesMaterialKcTotalService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 低于3天计划量的物料。每天中午12点:
+ * 查当日排产得到日产量 → 按产线工位消耗绑定算出物料日需求 → 乘3对比仓库总库存。
+ */
+@Configuration
+@EnableScheduling
+public class ProdMaterial3DayScheduling {
+
+	private static final int DAYS = 3;
+
+	private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+	@Autowired
+	private MesErpScheduleService mesErpScheduleService;
+	@Autowired
+	private MesLineService mesLineService;
+	@Autowired
+	private MesLineProcessService mesLineProcessService;
+	@Autowired
+	private MesLineProcessConsumeService mesLineProcessConsumeService;
+	@Autowired
+	private MesMaterialKcTotalService mesMaterialKcTotalService;
+	@Autowired
+	private MesDingMsgConfigService mesDingMsgConfigService;
+	@Autowired
+	private MesDingTalkAlertService mesDingTalkAlertService;
+
+	@Scheduled(cron = "0 0 12 * * ?")
+	public void checkMaterialFor3Days() {
+		try {
+			if (!mesDingMsgConfigService.isEnabled(DingMsgCode.PROD_MATERIAL_3DAY.getCode())) {
+				logger.info("低于3天计划量物料提醒未启用,跳过本次定时任务");
+				return;
+			}
+			Map<String, Integer> dailyByLine = mesErpScheduleService.calcDailyPlanCapacityByLine(LocalDate.now());
+			if (dailyByLine == null || dailyByLine.isEmpty()) {
+				logger.info("当天没有有效排产计划,不检查3天物料");
+				return;
+			}
+			Map<String, LineNeed> lineNeeds = new LinkedHashMap<String, LineNeed>();
+			Map<String, Long> materialNeedAll = new HashMap<String, Long>();
+			for (Map.Entry<String, Integer> entry : dailyByLine.entrySet()) {
+				String lineSn = entry.getKey();
+				int dailyQty = entry.getValue() == null ? 0 : entry.getValue();
+				if (StringUtils.isBlank(lineSn) || dailyQty <= 0) {
+					continue;
+				}
+				MesLine line = findLine(lineSn);
+				if (line == null || StringUtils.isBlank(line.getId())) {
+					logger.info("产线不存在,跳过物料检查:{}", lineSn);
+					continue;
+				}
+				LineNeed lineNeed = buildLineNeed(line, dailyQty);
+				if (lineNeed.materials.isEmpty()) {
+					continue;
+				}
+				lineNeeds.put(lineSn, lineNeed);
+				for (MaterialNeed need : lineNeed.materials.values()) {
+					Long old = materialNeedAll.get(need.materialId);
+					materialNeedAll.put(need.materialId, (old == null ? 0L : old) + need.threeDayNeed);
+				}
+			}
+			int sendCount = 0;
+			for (LineNeed lineNeed : lineNeeds.values()) {
+				List<MaterialNeed> shorts = new ArrayList<MaterialNeed>();
+				for (MaterialNeed need : lineNeed.materials.values()) {
+					long stock = stockOf(need.materialId);
+					need.stock = stock;
+					long allNeed = materialNeedAll.get(need.materialId) == null
+							? need.threeDayNeed : materialNeedAll.get(need.materialId);
+					if (stock < allNeed) {
+						shorts.add(need);
+					}
+				}
+				if (shorts.isEmpty()) {
+					continue;
+				}
+				if (sendLineAlert(lineNeed, shorts)) {
+					sendCount++;
+				}
+			}
+			logger.info("低于3天计划量物料提醒本次推送 {} 条", sendCount);
+		} catch (Exception ex) {
+			logger.error("低于3天计划量物料定时任务失败:{}", ex.getMessage(), ex);
+		}
+	}
+
+	private LineNeed buildLineNeed(MesLine line, int dailyQty) {
+		LineNeed lineNeed = new LineNeed();
+		lineNeed.lineSn = line.getSn();
+		lineNeed.lineTitle = StringUtils.defaultString(line.getTitle());
+		lineNeed.dailyQty = dailyQty;
+		List<MesLineProcess> processes = mesLineProcessService.findParentListByLine(line.getId(), line.getSn());
+		if (processes == null) {
+			return lineNeed;
+		}
+		for (MesLineProcess process : processes) {
+			if (process == null || StringUtils.isBlank(process.getId())) {
+				continue;
+			}
+			List<MesLineProcessConsume> binds = mesLineProcessConsumeService.findActiveByProcessId(process.getId());
+			if (binds == null) {
+				continue;
+			}
+			for (MesLineProcessConsume bind : binds) {
+				if (bind == null || StringUtils.isBlank(bind.getMaterialId())) {
+					continue;
+				}
+				int perPiece = bind.getNums() == null ? 0 : bind.getNums();
+				if (perPiece <= 0) {
+					continue;
+				}
+				MaterialNeed need = lineNeed.materials.get(bind.getMaterialId());
+				if (need == null) {
+					need = new MaterialNeed();
+					need.materialId = bind.getMaterialId();
+					need.materialTitle = StringUtils.defaultString(bind.getMaterialTitle());
+					need.erpSn = StringUtils.defaultString(bind.getErpSn());
+					lineNeed.materials.put(bind.getMaterialId(), need);
+				}
+				need.perPiece += perPiece;
+				need.stations.add(StringUtils.defaultIfBlank(process.getTitle(), process.getOprno()));
+			}
+		}
+		for (MaterialNeed need : lineNeed.materials.values()) {
+			need.dailyNeed = (long) dailyQty * need.perPiece;
+			need.threeDayNeed = need.dailyNeed * DAYS;
+		}
+		return lineNeed;
+	}
+
+	private boolean sendLineAlert(LineNeed lineNeed, List<MaterialNeed> shorts) {
+		StringBuilder detail = new StringBuilder();
+		for (int i = 0; i < shorts.size(); i++) {
+			MaterialNeed need = shorts.get(i);
+			if (i > 0) {
+				detail.append(";");
+			}
+			detail.append(StringUtils.defaultIfBlank(need.materialTitle, need.materialId));
+			if (StringUtils.isNotBlank(need.erpSn)) {
+				detail.append("(").append(need.erpSn).append(")");
+			}
+			detail.append(" 库存").append(need.stock)
+					.append(",3天需求").append(need.threeDayNeed)
+					.append(",工位").append(String.join("/", need.stations));
+		}
+		Map<String, String> extra = new HashMap<String, String>();
+		extra.put("日计划产量", String.valueOf(lineNeed.dailyQty));
+		extra.put("缺料明细", detail.toString());
+		List<DingMsgCard.Item> extraItems = new ArrayList<DingMsgCard.Item>();
+		extraItems.add(new DingMsgCard.Item("产线", lineNeed.lineTitle + "(" + lineNeed.lineSn + ")"));
+		extraItems.add(new DingMsgCard.Item("日计划产量", extra.get("日计划产量")));
+		extraItems.add(new DingMsgCard.Item("缺料明细", extra.get("缺料明细")));
+		return mesDingTalkAlertService.sendEnabled(DingMsgCode.PROD_MATERIAL_3DAY.getCode(),
+				lineNeed.lineSn, extra, extraItems);
+	}
+
+	private MesLine findLine(String lineSn) {
+		MesLine query = new MesLine();
+		query.setSn(lineSn.trim());
+		query.setStatus(MesLine.STATUS_NORMAL);
+		return mesLineService.findInfo(query);
+	}
+
+	private long stockOf(String materialId) {
+		MesMaterialKcTotal total = mesMaterialKcTotalService.findByMaterialId(materialId);
+		if (total == null || total.getTotalNums() == null) {
+			return 0L;
+		}
+		return total.getTotalNums();
+	}
+
+	private static class LineNeed {
+		private String lineSn;
+		private String lineTitle;
+		private int dailyQty;
+		private final Map<String, MaterialNeed> materials = new LinkedHashMap<String, MaterialNeed>();
+	}
+
+	private static class MaterialNeed {
+		private String materialId;
+		private String materialTitle;
+		private String erpSn;
+		private int perPiece;
+		private long dailyNeed;
+		private long threeDayNeed;
+		private long stock;
+		private final List<String> stations = new ArrayList<String>();
+	}
+}

+ 147 - 0
src/main/java/com/jeesite/modules/scheduling/ProdOeeScheduling.java

@@ -0,0 +1,147 @@
+package com.jeesite.modules.scheduling;
+
+import com.jeesite.common.lang.StringUtils;
+import com.jeesite.modules.mes.dingtalk.DingMsgCard;
+import com.jeesite.modules.mes.dingtalk.DingMsgCode;
+import com.jeesite.modules.mes.dingtalk.MesDingTalkAlertService;
+import com.jeesite.modules.mes.service.MesDeviceOeeStatsService;
+import com.jeesite.modules.mes.service.MesDingMsgConfigService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 设备OEE低于85%。每小时读取设备综合效率大屏的平均开动率,低于阈值则推送。
+ * 大屏是全厂平均,发给该消息已配置的全部产线接收人。
+ */
+@Configuration
+@EnableScheduling
+public class ProdOeeScheduling {
+
+	private static final double RATE_LIMIT = 85D;
+	private static final int LOW_DEVICE_SHOW_MAX = 8;
+
+	private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+	@Autowired
+	private MesDeviceOeeStatsService mesDeviceOeeStatsService;
+	@Autowired
+	private MesDingMsgConfigService mesDingMsgConfigService;
+	@Autowired
+	private MesDingTalkAlertService mesDingTalkAlertService;
+
+	@Scheduled(cron = "0 0 * * * ?")
+	public void checkAvgOpenRate() {
+		try {
+			if (!mesDingMsgConfigService.isEnabled(DingMsgCode.PROD_OEE_85.getCode())) {
+				logger.info("设备OEE低于85%提醒未启用,跳过本次定时任务");
+				return;
+			}
+			Map<String, Object> screen = mesDeviceOeeStatsService.buildOeeScreenData();
+			double avgOpenRate = toDouble(screen.get("avgOpenRate"));
+			if (avgOpenRate >= RATE_LIMIT) {
+				logger.info("平均开动率 {}%,不低于85%,不推送", formatRate(avgOpenRate));
+				return;
+			}
+			String avgText = formatRate(avgOpenRate) + "%";
+			String statTime = StringUtils.defaultString((String) screen.get("statDate"))
+					+ " " + StringUtils.defaultString((String) screen.get("statTime"));
+			String lowDevices = buildLowDeviceText(screen.get("devices"));
+			Map<String, String> extra = new HashMap<String, String>();
+			extra.put("平均开动率", avgText);
+			extra.put("设备总数", String.valueOf(toLong(screen.get("deviceTotal"))));
+			extra.put("运行设备", String.valueOf(toLong(screen.get("runningCount"))));
+			extra.put("统计时间", statTime.trim());
+			extra.put("低于85%设备", lowDevices);
+			List<DingMsgCard.Item> extraItems = new ArrayList<DingMsgCard.Item>();
+			extraItems.add(new DingMsgCard.Item("平均开动率", avgText));
+			extraItems.add(new DingMsgCard.Item("设备总数", extra.get("设备总数")));
+			extraItems.add(new DingMsgCard.Item("运行设备", extra.get("运行设备")));
+			extraItems.add(new DingMsgCard.Item("统计时间", extra.get("统计时间")));
+			if (StringUtils.isNotBlank(lowDevices)) {
+				extraItems.add(new DingMsgCard.Item("低于85%设备", lowDevices));
+			}
+			boolean sent = mesDingTalkAlertService.sendEnabledAllLines(
+					DingMsgCode.PROD_OEE_85.getCode(), extra, extraItems);
+			logger.info("设备OEE低于85%提醒本次{},平均开动率 {}", sent ? "已推送" : "未推送", avgText);
+		} catch (Exception ex) {
+			logger.error("设备OEE低于85%定时任务失败:{}", ex.getMessage(), ex);
+		}
+	}
+
+	/**
+	 * 列出开动率低于85%的设备,最多展示若干台。
+	 */
+	@SuppressWarnings("unchecked")
+	private String buildLowDeviceText(Object devicesObj) {
+		if (!(devicesObj instanceof List)) {
+			return "";
+		}
+		List<Map<String, Object>> devices = (List<Map<String, Object>>) devicesObj;
+		List<String> low = new ArrayList<String>();
+		int extraCount = 0;
+		for (Map<String, Object> device : devices) {
+			if (device == null) {
+				continue;
+			}
+			double openRate = toDouble(device.get("openRate"));
+			if (openRate >= RATE_LIMIT) {
+				continue;
+			}
+			if (low.size() >= LOW_DEVICE_SHOW_MAX) {
+				extraCount++;
+				continue;
+			}
+			String name = StringUtils.defaultIfBlank(String.valueOf(device.get("name")), "-");
+			low.add(name + " " + formatRate(openRate) + "%");
+		}
+		if (low.isEmpty()) {
+			return "";
+		}
+		String text = StringUtils.join(low, ";");
+		if (extraCount > 0) {
+			text = text + " 等共" + (low.size() + extraCount) + "台";
+		}
+		return text;
+	}
+
+	private double toDouble(Object value) {
+		if (value == null) {
+			return 0D;
+		}
+		if (value instanceof Number) {
+			return ((Number) value).doubleValue();
+		}
+		try {
+			return Double.parseDouble(String.valueOf(value));
+		} catch (Exception ignored) {
+			return 0D;
+		}
+	}
+
+	private long toLong(Object value) {
+		if (value == null) {
+			return 0L;
+		}
+		if (value instanceof Number) {
+			return ((Number) value).longValue();
+		}
+		try {
+			return Long.parseLong(String.valueOf(value));
+		} catch (Exception ignored) {
+			return 0L;
+		}
+	}
+
+	private String formatRate(double rate) {
+		return String.format("%.1f", rate);
+	}
+}

+ 91 - 0
src/main/java/com/jeesite/modules/scheduling/ProdPlanRateScheduling.java

@@ -0,0 +1,91 @@
+package com.jeesite.modules.scheduling;
+
+import com.jeesite.common.lang.StringUtils;
+import com.jeesite.modules.mes.dingtalk.DingMsgCard;
+import com.jeesite.modules.mes.dingtalk.DingMsgCode;
+import com.jeesite.modules.mes.dingtalk.MesDingTalkAlertService;
+import com.jeesite.modules.mes.entity.MesErpSchedule;
+import com.jeesite.modules.mes.service.MesDingMsgConfigService;
+import com.jeesite.modules.mes.service.MesErpScheduleService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 生产计划完成率低于97%。每天中午12点检查当天是排产最后一天的计划,按产线推送。
+ */
+@Configuration
+@EnableScheduling
+public class ProdPlanRateScheduling {
+
+	private static final BigDecimal RATE_LIMIT = new BigDecimal("97");
+
+	private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+	@Autowired
+	private MesErpScheduleService mesErpScheduleService;
+	@Autowired
+	private MesDingMsgConfigService mesDingMsgConfigService;
+	@Autowired
+	private MesDingTalkAlertService mesDingTalkAlertService;
+
+	@Scheduled(cron = "0 0 12 * * ?")
+	public void checkPlanCompleteRate() {
+		try {
+			if (!mesDingMsgConfigService.isEnabled(DingMsgCode.PROD_PLAN_RATE.getCode())) {
+				logger.info("生产计划完成率提醒未启用,跳过本次定时任务");
+				return;
+			}
+			List<MesErpSchedule> endingPlans = mesErpScheduleService.findEndingOn(LocalDate.now());
+			if (endingPlans == null || endingPlans.isEmpty()) {
+				logger.info("今天没有处于排产最后一天的计划");
+				return;
+			}
+			int sendCount = 0;
+			for (MesErpSchedule plan : endingPlans) {
+				if (plan == null || StringUtils.isBlank(plan.getLineSn())) {
+					continue;
+				}
+				BigDecimal rate = mesErpScheduleService.calcScheduleCompletionPercent(plan);
+				if (rate.compareTo(RATE_LIMIT) >= 0) {
+					logger.info("产线 {} 完成率 {}%,不低于97%,不推送", plan.getLineSn(), rate.toPlainString());
+					continue;
+				}
+				long okQty = mesErpScheduleService.countScheduleOkQty(plan);
+				int planQty = plan.getScheduleQty() == null ? 0 : plan.getScheduleQty();
+				Map<String, String> extra = new HashMap<String, String>();
+				extra.put("完成率", rate.toPlainString() + "%");
+				extra.put("计划数量", String.valueOf(planQty));
+				extra.put("完成数量", String.valueOf(okQty));
+				extra.put("物料名称", StringUtils.defaultString(plan.getMaterialName()));
+				extra.put("排产日期", StringUtils.defaultString(plan.getScheduleStartDate())
+						+ " ~ " + StringUtils.defaultString(plan.getScheduleEndDate()));
+				List<DingMsgCard.Item> extraItems = new ArrayList<DingMsgCard.Item>();
+				extraItems.add(new DingMsgCard.Item("产线", StringUtils.defaultString(plan.getLineTitle())
+						+ "(" + plan.getLineSn() + ")"));
+				extraItems.add(new DingMsgCard.Item("物料", extra.get("物料名称")));
+				extraItems.add(new DingMsgCard.Item("排产日期", extra.get("排产日期")));
+				extraItems.add(new DingMsgCard.Item("计划数量", extra.get("计划数量")));
+				extraItems.add(new DingMsgCard.Item("完成数量", extra.get("完成数量")));
+				extraItems.add(new DingMsgCard.Item("完成率", extra.get("完成率")));
+				if (mesDingTalkAlertService.sendEnabled(DingMsgCode.PROD_PLAN_RATE.getCode(),
+						plan.getLineSn(), extra, extraItems)) {
+					sendCount++;
+				}
+			}
+			logger.info("生产计划完成率提醒本次推送 {} 条", sendCount);
+		} catch (Exception ex) {
+			logger.error("生产计划完成率定时任务失败:{}", ex.getMessage(), ex);
+		}
+	}
+}

+ 134 - 0
src/main/java/com/jeesite/modules/scheduling/ProdStockScheduling.java

@@ -0,0 +1,134 @@
+package com.jeesite.modules.scheduling;
+
+import com.jeesite.common.lang.StringUtils;
+import com.jeesite.modules.mes.dingtalk.DingMsgCard;
+import com.jeesite.modules.mes.dingtalk.DingMsgCode;
+import com.jeesite.modules.mes.dingtalk.MesDingTalkAlertService;
+import com.jeesite.modules.mes.entity.MesBatchSn;
+import com.jeesite.modules.mes.entity.MesDingMsgConfig;
+import com.jeesite.modules.mes.entity.MesDingMsgLine;
+import com.jeesite.modules.mes.service.MesBatchSnService;
+import com.jeesite.modules.mes.service.MesDingMsgConfigService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 产品成品库存低于500件。每天中午12点:
+ * 按产线统计批次码「仓库中」的绑定数量,低于500则推给该产线接收人。
+ */
+@Configuration
+@EnableScheduling
+public class ProdStockScheduling {
+
+	private static final long QTY_LIMIT = 500L;
+
+	private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+	@Autowired
+	private MesBatchSnService mesBatchSnService;
+	@Autowired
+	private MesDingMsgConfigService mesDingMsgConfigService;
+	@Autowired
+	private MesDingTalkAlertService mesDingTalkAlertService;
+
+	@Scheduled(cron = "0 0 12 * * ?")
+	public void checkWarehouseStock() {
+		try {
+			if (!mesDingMsgConfigService.isEnabled(DingMsgCode.PROD_STOCK_500.getCode())) {
+				logger.info("成品库存低于500件提醒未启用,跳过本次定时任务");
+				return;
+			}
+			Map<String, LineStock> byLine = groupWarehouseByLine();
+			fillConfiguredLines(byLine);
+			if (byLine.isEmpty()) {
+				logger.info("没有可检查的产线成品库存");
+				return;
+			}
+			int sendCount = 0;
+			for (LineStock stock : byLine.values()) {
+				if (stock.qty >= QTY_LIMIT) {
+					logger.info("产线 {} 仓库中成品 {} 件({} 个批次码),不低于500,不推送",
+							stock.lineSn, stock.qty, stock.batchCount);
+					continue;
+				}
+				Map<String, String> extra = new HashMap<String, String>();
+				extra.put("成品库存", stock.qty + "件");
+				extra.put("批次码数量", String.valueOf(stock.batchCount));
+				extra.put("库存下限", QTY_LIMIT + "件");
+				List<DingMsgCard.Item> extraItems = new ArrayList<DingMsgCard.Item>();
+				extraItems.add(new DingMsgCard.Item("成品库存", extra.get("成品库存")));
+				extraItems.add(new DingMsgCard.Item("批次码数量", extra.get("批次码数量")));
+				extraItems.add(new DingMsgCard.Item("库存下限", extra.get("库存下限")));
+				if (mesDingTalkAlertService.sendEnabled(DingMsgCode.PROD_STOCK_500.getCode(),
+						stock.lineSn, extra, extraItems)) {
+					sendCount++;
+				}
+			}
+			logger.info("成品库存低于500件提醒本次推送 {} 条", sendCount);
+		} catch (Exception ex) {
+			logger.error("成品库存低于500件定时任务失败:{}", ex.getMessage(), ex);
+		}
+	}
+
+	/**
+	 * 按产线汇总仓库中批次码的绑定件数。
+	 */
+	private Map<String, LineStock> groupWarehouseByLine() {
+		Map<String, LineStock> byLine = new LinkedHashMap<String, LineStock>();
+		List<MesBatchSn> batches = mesBatchSnService.findInWarehouseList();
+		if (batches == null) {
+			return byLine;
+		}
+		for (MesBatchSn batch : batches) {
+			if (batch == null || StringUtils.isBlank(batch.getLineSn())) {
+				continue;
+			}
+			LineStock stock = byLine.get(batch.getLineSn());
+			if (stock == null) {
+				stock = new LineStock();
+				stock.lineSn = batch.getLineSn();
+				byLine.put(stock.lineSn, stock);
+			}
+			stock.qty += batch.getNums() == null ? 0L : batch.getNums();
+			stock.batchCount++;
+		}
+		return byLine;
+	}
+
+	/**
+	 * 已配置接收人的产线即使仓库中没有批次码,也按 0 件检查。
+	 */
+	private void fillConfiguredLines(Map<String, LineStock> byLine) {
+		MesDingMsgConfig config = mesDingMsgConfigService.getByMsgCode(DingMsgCode.PROD_STOCK_500.getCode());
+		if (config == null || config.getMesDingMsgLineList() == null) {
+			return;
+		}
+		for (MesDingMsgLine line : config.getMesDingMsgLineList()) {
+			if (line == null || StringUtils.isBlank(line.getLineSn())) {
+				continue;
+			}
+			if (byLine.containsKey(line.getLineSn())) {
+				continue;
+			}
+			LineStock stock = new LineStock();
+			stock.lineSn = line.getLineSn();
+			byLine.put(stock.lineSn, stock);
+		}
+	}
+
+	private static class LineStock {
+		private String lineSn;
+		private long qty;
+		private int batchCount;
+	}
+}