Browse Source

ERP对接,生产领料功能

jingbo 4 months ago
parent
commit
09883ebbfa
25 changed files with 3116 additions and 285 deletions
  1. 59 1
      src/main/java/com/jeesite/modules/mes/entity/MesMaterialKcLog.java
  2. 70 0
      src/main/java/com/jeesite/modules/mes/erp/ErpInventoryLotOption.java
  3. 515 0
      src/main/java/com/jeesite/modules/mes/erp/ErpInventoryLotService.java
  4. 130 0
      src/main/java/com/jeesite/modules/mes/erp/ErpKingdeeParseUtils.java
  5. 30 0
      src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialBill.java
  6. 224 19
      src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialBillService.java
  7. 27 0
      src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialResult.java
  8. 142 22
      src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialService.java
  9. 10 0
      src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialSubmitRequest.java
  10. 142 0
      src/main/java/com/jeesite/modules/mes/erp/ErpProductionMaterial.java
  11. 328 3
      src/main/java/com/jeesite/modules/mes/erp/ErpProductionMaterialService.java
  12. 22 0
      src/main/java/com/jeesite/modules/mes/erp/KingdeeErpProperties.java
  13. 209 0
      src/main/java/com/jeesite/modules/mes/erp/MesErpPickKcService.java
  14. 38 0
      src/main/java/com/jeesite/modules/mes/erp/MesErpPickKcSyncResult.java
  15. 36 0
      src/main/java/com/jeesite/modules/mes/service/MesMaterialKcLogService.java
  16. 40 0
      src/main/java/com/jeesite/modules/mes/service/MesMaterialService.java
  17. 79 2
      src/main/java/com/jeesite/modules/mes/web/ErpPickMaterialBillController.java
  18. 40 2
      src/main/java/com/jeesite/modules/mes/web/MesDeviceController.java
  19. 10 2
      src/main/resources/config/application.yml
  20. 41 0
      src/main/resources/views/modules/mes/erpPickMaterialBillDetail.html
  21. 8 13
      src/main/resources/views/modules/mes/erpPickMaterialBillList.html
  22. 669 9
      src/main/resources/views/modules/mes/erpPickMaterialBillMaterial.html
  23. 117 63
      src/main/resources/views/modules/mes/mesDeviceOeeScreen.html
  24. 130 106
      src/main/resources/views/modules/mes/mesMaterialKcLogList.html
  25. 0 43
      src/main/resources/views/modules/mes/mesScreenLiuan.html

+ 59 - 1
src/main/java/com/jeesite/modules/mes/entity/MesMaterialKcLog.java

@@ -3,6 +3,7 @@ package com.jeesite.modules.mes.entity;
 import javax.validation.constraints.Size;
 
 import com.jeesite.common.entity.DataEntity;
+import com.jeesite.common.lang.StringUtils;
 import com.jeesite.common.mybatis.annotation.Column;
 import com.jeesite.common.mybatis.annotation.JoinTable;
 import com.jeesite.common.mybatis.annotation.Table;
@@ -22,6 +23,10 @@ import java.util.Date;
 		@Column(name="batch_sn", attrName="batchSn", label="批次码"),
 		@Column(name="old_batch_sn", attrName="oldBatchSn", label="老批次码"),
 		@Column(name="remark", attrName="remark"),
+		@Column(name="source_type", attrName="sourceType", label="来源", isQuery=false),
+		@Column(name="erp_pick_bill_no", attrName="erpPickBillNo", label="ERP领料单号", isQuery=false),
+		@Column(name="erp_pick_line_key", attrName="erpPickLineKey", label="ERP领料分录键"),
+		@Column(name="erp_mo_bill_no", attrName="erpMoBillNo", label="ERP生产订单号"),
 		@Column(name="kw_id", attrName="kwId", label="库位"),
 		@Column(name="expire_date", attrName="expireDate", label="过期时间"),
 		@Column(name="nums", attrName="nums", label="数量", isUpdateForce=true),
@@ -42,7 +47,7 @@ import java.util.Date;
 						columns={
 								@Column(name="title", attrName="kwTitle", label="库位"),
 						}),
-				@JoinTable(type= JoinTable.Type.JOIN, entity=MesMaterialRkRecord.class, attrName="this", alias="e",
+				@JoinTable(type= JoinTable.Type.LEFT_JOIN, entity=MesMaterialRkRecord.class, attrName="this", alias="e",
 						on="e.id = a.rk_record_id",
 						columns={
 								@Column(name="type", attrName="rkType", label="入库类型"),
@@ -51,6 +56,11 @@ import java.util.Date;
 		orderBy="a.id DESC"
 )
 public class MesMaterialKcLog extends DataEntity<MesMaterialKcLog> {
+
+	/** 库存来源:ERP 生产领料 */
+	public static final String SOURCE_TYPE_ERP_PICK = "ERP_PICK";
+	/** 库存来源:来料入库(查询筛选用) */
+	public static final String SOURCE_TYPE_RK = "RK";
 	
 	private static final long serialVersionUID = 1L;
 	private String rkRecordId;		// 入库单id
@@ -65,6 +75,10 @@ public class MesMaterialKcLog extends DataEntity<MesMaterialKcLog> {
 	private String mbt;
 	private String expireDate;
 	private String remark;
+	private String sourceType;
+	private String erpPickBillNo;
+	private String erpPickLineKey;
+	private String erpMoBillNo;
 	private String num;
 	private String rkType;
 	private String oldBatchSn;
@@ -143,6 +157,9 @@ public class MesMaterialKcLog extends DataEntity<MesMaterialKcLog> {
 
 	public void setMaterialTitle(String materialTitle) {
 		this.materialTitle = materialTitle;
+		if (StringUtils.isNotBlank(materialTitle)) {
+			sqlMap.getWhere().and("c.title", QueryType.LIKE, materialTitle.trim());
+		}
 	}
 
 	public String getKwTitle() {
@@ -151,6 +168,9 @@ public class MesMaterialKcLog extends DataEntity<MesMaterialKcLog> {
 
 	public void setKwTitle(String kwTitle) {
 		this.kwTitle = kwTitle;
+		if (StringUtils.isNotBlank(kwTitle)) {
+			sqlMap.getWhere().and("d.title", QueryType.LIKE, kwTitle.trim());
+		}
 	}
 
 	public String getMbt() {
@@ -177,6 +197,41 @@ public class MesMaterialKcLog extends DataEntity<MesMaterialKcLog> {
 		this.remark = remark;
 	}
 
+	public String getSourceType() {
+		return sourceType;
+	}
+
+	public void setSourceType(String sourceType) {
+		this.sourceType = sourceType;
+	}
+
+	public String getErpPickBillNo() {
+		return erpPickBillNo;
+	}
+
+	public void setErpPickBillNo(String erpPickBillNo) {
+		this.erpPickBillNo = erpPickBillNo;
+		if (StringUtils.isNotBlank(erpPickBillNo)) {
+			sqlMap.getWhere().and("erp_pick_bill_no", QueryType.LIKE, erpPickBillNo.trim());
+		}
+	}
+
+	public String getErpPickLineKey() {
+		return erpPickLineKey;
+	}
+
+	public void setErpPickLineKey(String erpPickLineKey) {
+		this.erpPickLineKey = erpPickLineKey;
+	}
+
+	public String getErpMoBillNo() {
+		return erpMoBillNo;
+	}
+
+	public void setErpMoBillNo(String erpMoBillNo) {
+		this.erpMoBillNo = erpMoBillNo;
+	}
+
 	public String getNum() {
 		return num;
 	}
@@ -191,6 +246,9 @@ public class MesMaterialKcLog extends DataEntity<MesMaterialKcLog> {
 
 	public void setRkType(String rkType) {
 		this.rkType = rkType;
+		if (StringUtils.isNotBlank(rkType)) {
+			sqlMap.getWhere().and("e.type", QueryType.EQ, rkType.trim());
+		}
 	}
 
 	public String getOldBatchSn() {

+ 70 - 0
src/main/java/com/jeesite/modules/mes/erp/ErpInventoryLotOption.java

@@ -0,0 +1,70 @@
+package com.jeesite.modules.mes.erp;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * ERP 即时库存中的可用批号选项。
+ */
+public class ErpInventoryLotOption implements Serializable {
+
+	private static final long serialVersionUID = 1L;
+
+	private String lotNumber;
+	private BigDecimal qty;
+	/** 前端展示用,避免 BigDecimal 序列化异常 */
+	private String qtyText;
+	private String stockNumber;
+	private String stockName;
+
+	public String getLotNumber() {
+		return lotNumber;
+	}
+
+	public void setLotNumber(String lotNumber) {
+		this.lotNumber = lotNumber;
+	}
+
+	public BigDecimal getQty() {
+		return qty;
+	}
+
+	public void setQty(BigDecimal qty) {
+		this.qty = qty;
+		this.qtyText = formatQtyText(qty);
+	}
+
+	public String getQtyText() {
+		if (qtyText != null && !qtyText.isEmpty()) {
+			return qtyText;
+		}
+		return formatQtyText(qty);
+	}
+
+	public void setQtyText(String qtyText) {
+		this.qtyText = qtyText;
+	}
+
+	private static String formatQtyText(BigDecimal value) {
+		if (value == null) {
+			return "0";
+		}
+		return value.stripTrailingZeros().toPlainString();
+	}
+
+	public String getStockNumber() {
+		return stockNumber;
+	}
+
+	public void setStockNumber(String stockNumber) {
+		this.stockNumber = stockNumber;
+	}
+
+	public String getStockName() {
+		return stockName;
+	}
+
+	public void setStockName(String stockName) {
+		this.stockName = stockName;
+	}
+}

+ 515 - 0
src/main/java/com/jeesite/modules/mes/erp/ErpInventoryLotService.java

@@ -0,0 +1,515 @@
+package com.jeesite.modules.mes.erp;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.jeesite.common.lang.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 查询金蝶即时库存明细(STK_Inventory)中的可用批号,供生产领料选择/自动推荐。
+ * <p>
+ * 注意:BillQuery 偶发返回与 FilterString 不一致的数据,必须在 Java 侧二次校验物料编码、库存组织、库存状态。
+ */
+@Service
+public class ErpInventoryLotService {
+
+	private static final Logger logger = LoggerFactory.getLogger(ErpInventoryLotService.class);
+	private static final String FORM_ID = "STK_Inventory";
+	private static final String FIELD_KEYS = String.join(",",
+			"FMaterialId.FNumber",
+			"FStockId.FNumber",
+			"FLot.FNumber",
+			"FBaseQty",
+			"FBaseAVBQty",
+			"FStockStatusId.FNumber",
+			"FStockOrgId.FNumber");
+
+	private final KingdeeErpClient erpClient;
+	private final KingdeeErpProperties erpProperties;
+	private final ThreadLocal<String> lastQueryDiagnostic = new ThreadLocal<>();
+
+	public ErpInventoryLotService(KingdeeErpClient erpClient, KingdeeErpProperties erpProperties) {
+		this.erpClient = erpClient;
+		this.erpProperties = erpProperties;
+	}
+
+	public String getLastQueryDiagnostic() {
+		String msg = lastQueryDiagnostic.get();
+		return msg == null ? "" : msg;
+	}
+
+	public List<ErpInventoryLotOption> findAvailableLots(String materialNumber, String stockNumber,
+			String stockStatusNumber, String stockOrgNumber) {
+		if (StringUtils.isBlank(materialNumber)) {
+			return new ArrayList<>();
+		}
+		lastQueryDiagnostic.remove();
+		List<ErpInventoryLotOption> lots = new ArrayList<>();
+		if (StringUtils.isNotBlank(stockNumber)) {
+			lots = queryLots(buildStockFilters(materialNumber, stockNumber, stockStatusNumber, stockOrgNumber), materialNumber,
+					stockStatusNumber, stockOrgNumber);
+		}
+		if (lots.isEmpty()) {
+			lots = findLotsByMaterial(materialNumber, stockStatusNumber, stockOrgNumber);
+		}
+		return sortLots(lots, stockNumber);
+	}
+
+	public List<ErpInventoryLotOption> searchInventoryLots(String materialNumber, String stockNumber,
+			String stockStatusNumber, String stockOrgNumber, String lotKeyword) {
+		if (StringUtils.isBlank(materialNumber)) {
+			return new ArrayList<>();
+		}
+		lastQueryDiagnostic.remove();
+		List<String> filters;
+		if (StringUtils.isNotBlank(stockNumber)) {
+			filters = buildStockFilters(materialNumber, stockNumber, stockStatusNumber, stockOrgNumber);
+		} else {
+			filters = buildMaterialFilters(materialNumber, stockStatusNumber, stockOrgNumber);
+		}
+		if (StringUtils.isNotBlank(lotKeyword)) {
+			List<String> narrowed = new ArrayList<>();
+			String lotLike = " AND FLot.FNumber like '%" + escapeLike(lotKeyword.trim()) + "%'";
+			for (String filter : filters) {
+				narrowed.add(filter + lotLike);
+			}
+			filters = narrowed;
+		}
+		return sortLots(queryLots(filters, materialNumber, stockStatusNumber, stockOrgNumber), stockNumber);
+	}
+
+	public List<ErpInventoryLotOption> findLotsByMaterial(String materialNumber,
+			String stockStatusNumber, String stockOrgNumber) {
+		return queryLots(buildMaterialFilters(materialNumber, stockStatusNumber, stockOrgNumber), materialNumber,
+				stockStatusNumber, stockOrgNumber);
+	}
+
+	private List<String> buildStockFilters(String materialNumber, String stockNumber,
+			String stockStatusNumber, String stockOrgNumber) {
+		String material = escape(materialNumber.trim());
+		String stock = escape(stockNumber.trim());
+		String orgNumber = escape(firstNonBlank(stockOrgNumber, erpProperties.getOrgNumber()));
+		String statusNumber = escape(firstNonBlank(stockStatusNumber, erpProperties.getPickMtrlStockStatus()));
+		Set<String> filters = new LinkedHashSet<>();
+		// 与 ERP 即时库存一致:物料 + 仓库 + 库存组织 + 可用状态 + 可用量>0
+		filters.add(materialStockOrgStatusFilter(material, stock, orgNumber, statusNumber) + " AND FBaseAVBQty>0");
+		filters.add(materialStockOrgStatusFilter(material, stock, orgNumber, statusNumber) + " AND FBaseQty>0");
+		filters.add(materialStockOrgFilter(material, stock, orgNumber) + " AND FStockStatusId.FNumber='" + statusNumber
+				+ "' AND FBaseAVBQty>0");
+		filters.add(materialStockOrgFilter(material, stock, orgNumber) + " AND FBaseAVBQty>0");
+		return new ArrayList<>(filters);
+	}
+
+	private List<String> buildMaterialFilters(String materialNumber, String stockStatusNumber, String stockOrgNumber) {
+		String material = escape(materialNumber.trim());
+		String orgNumber = escape(firstNonBlank(stockOrgNumber, erpProperties.getOrgNumber()));
+		String statusNumber = escape(firstNonBlank(stockStatusNumber, erpProperties.getPickMtrlStockStatus()));
+		Set<String> filters = new LinkedHashSet<>();
+		// 仅使用带库存组织+库存状态的过滤,避免 BillQuery 无组织条件时返回其它组织/物料脏数据
+		filters.add(materialOrgStatusFilter(material, orgNumber, statusNumber) + " AND FBaseAVBQty>0");
+		filters.add(materialOrgStatusFilter(material, orgNumber, statusNumber) + " AND FBaseQty>0");
+		filters.add(materialOrgFilter(material, orgNumber) + " AND FStockStatusId.FNumber='" + statusNumber
+				+ "' AND FBaseAVBQty>0");
+		filters.add(materialOrgFilter(material, orgNumber) + " AND FBaseAVBQty>0");
+		return new ArrayList<>(filters);
+	}
+
+	private String materialStockOrgStatusFilter(String material, String stock, String orgNumber, String statusNumber) {
+		return materialStockOrgFilter(material, stock, orgNumber) + " AND FStockStatusId.FNumber='" + statusNumber + "'";
+	}
+
+	private String materialStockOrgFilter(String material, String stock, String orgNumber) {
+		return "FMaterialId.FNumber='" + material + "' AND FStockId.FNumber='" + stock
+				+ "' AND FStockOrgId.FNumber='" + orgNumber + "'";
+	}
+
+	private String materialOrgStatusFilter(String material, String orgNumber, String statusNumber) {
+		return materialOrgFilter(material, orgNumber) + " AND FStockStatusId.FNumber='" + statusNumber + "'";
+	}
+
+	private String materialOrgFilter(String material, String orgNumber) {
+		return "FMaterialId.FNumber='" + material + "' AND FStockOrgId.FNumber='" + orgNumber + "'";
+	}
+
+	private List<ErpInventoryLotOption> queryLots(List<String> filters, String materialNumber,
+			String stockStatusNumber, String stockOrgNumber) {
+		String lastError = "";
+		for (String filter : filters) {
+			List<ErpInventoryLotOption> lots = queryLots(filter, materialNumber, stockStatusNumber, stockOrgNumber);
+			if (!lots.isEmpty()) {
+				lastQueryDiagnostic.remove();
+				return lots;
+			}
+			if (StringUtils.isNotBlank(getLastQueryDiagnostic())) {
+				lastError = getLastQueryDiagnostic();
+			}
+		}
+		if (StringUtils.isNotBlank(lastError)) {
+			lastQueryDiagnostic.set(lastError);
+		} else {
+			lastQueryDiagnostic.set("ERP 即时库存未返回该物料批号(与 ERP 即时库存查询一致)");
+		}
+		return new ArrayList<>();
+	}
+
+	private List<ErpInventoryLotOption> sortLots(List<ErpInventoryLotOption> lots, String preferStockNumber) {
+		if (lots == null || lots.isEmpty() || StringUtils.isBlank(preferStockNumber)) {
+			return lots == null ? new ArrayList<>() : lots;
+		}
+		lots.sort((a, b) -> {
+			boolean aMatch = preferStockNumber.equals(a.getStockNumber());
+			boolean bMatch = preferStockNumber.equals(b.getStockNumber());
+			if (aMatch != bMatch) {
+				return aMatch ? -1 : 1;
+			}
+			return Comparator.nullsLast(BigDecimal::compareTo).compare(a.getQty(), b.getQty()) * -1;
+		});
+		return lots;
+	}
+
+	private List<ErpInventoryLotOption> queryLots(String filterString, String expectedMaterialNumber,
+			String expectedStockStatusNumber, String expectedStockOrgNumber) {
+		JSONObject data = new JSONObject();
+		data.put("FormId", FORM_ID);
+		data.put("FieldKeys", FIELD_KEYS);
+		data.put("FilterString", filterString);
+		data.put("StartRow", 0);
+		data.put("Limit", 2000);
+		try {
+			JSONArray rows = erpClient.billQuery(data);
+			if (rows == null || rows.isEmpty()) {
+				return new ArrayList<>();
+			}
+			Object first = rows.get(0);
+			if (first instanceof JSONObject) {
+				JSONObject jo = (JSONObject) first;
+				if (jo.containsKey("Result") || jo.containsKey("ResponseStatus")) {
+					lastQueryDiagnostic.set("ERP BillQuery 返回错误:" + jo.toJSONString());
+					logger.warn("ERP inventory lot query error response, filter={}, response={}", filterString, jo.toJSONString());
+					return new ArrayList<>();
+				}
+			}
+			AggregateResult result = aggregateLots(rows, expectedMaterialNumber, expectedStockStatusNumber,
+					expectedStockOrgNumber, filterString);
+			if (result.lots.isEmpty() && result.rawRowCount > 0) {
+				if (result.mismatchRowCount > 0) {
+					lastQueryDiagnostic.set("ERP 返回 " + result.rawRowCount + " 行,但物料/组织/状态与查询条件不一致,已忽略(与 ERP 即时库存空结果一致)");
+					logger.warn("ERP inventory lot rows rejected by validation, expectedMaterial={}, raw={}, mismatch={}, filter={}",
+							expectedMaterialNumber, result.rawRowCount, result.mismatchRowCount, filterString);
+				} else {
+					logger.warn("ERP inventory lot query parsed 0 lots from {} rows, filter={}", result.rawRowCount, filterString);
+				}
+			}
+			return result.lots;
+		} catch (RuntimeException ex) {
+			lastQueryDiagnostic.set("ERP 查询失败:" + ex.getMessage());
+			logger.warn("ERP inventory lot query failed, filter={}, msg={}", filterString, ex.getMessage());
+			return new ArrayList<>();
+		}
+	}
+
+	public String suggestLotNumber(List<ErpInventoryLotOption> lots, BigDecimal requiredQty, String preferStockNumber) {
+		if (lots == null || lots.isEmpty()) {
+			return "";
+		}
+		List<ErpInventoryLotOption> candidates = lots;
+		if (StringUtils.isNotBlank(preferStockNumber)) {
+			List<ErpInventoryLotOption> sameStock = new ArrayList<>();
+			for (ErpInventoryLotOption lot : lots) {
+				if (preferStockNumber.equals(lot.getStockNumber())) {
+					sameStock.add(lot);
+				}
+			}
+			if (!sameStock.isEmpty()) {
+				candidates = sameStock;
+			}
+		}
+		BigDecimal required = requiredQty == null || requiredQty.compareTo(BigDecimal.ZERO) <= 0
+				? BigDecimal.ONE : requiredQty;
+		ErpInventoryLotOption bestEnough = null;
+		for (ErpInventoryLotOption lot : candidates) {
+			BigDecimal qty = lot.getQty() == null ? BigDecimal.ZERO : lot.getQty();
+			if (qty.compareTo(required) < 0) {
+				continue;
+			}
+			if (bestEnough == null || qty.compareTo(bestEnough.getQty()) < 0) {
+				bestEnough = lot;
+			}
+		}
+		if (bestEnough != null) {
+			return bestEnough.getLotNumber();
+		}
+		return candidates.get(0).getLotNumber();
+	}
+
+	public String suggestLotNumber(List<ErpInventoryLotOption> lots, BigDecimal requiredQty) {
+		return suggestLotNumber(lots, requiredQty, "");
+	}
+
+	public String buildLotHint(List<ErpInventoryLotOption> lots, String suggestedLot, BigDecimal requiredQty,
+			String issueStockNumber) {
+		if (lots == null || lots.isEmpty()) {
+			String diagnostic = getLastQueryDiagnostic();
+			if (StringUtils.isNotBlank(diagnostic)) {
+				return diagnostic;
+			}
+			return "ERP 即时库存无该物料批号,请点击「选批号」确认";
+		}
+		long sameStockCount = 0;
+		if (StringUtils.isNotBlank(issueStockNumber)) {
+			for (ErpInventoryLotOption lot : lots) {
+				if (issueStockNumber.equals(lot.getStockNumber())) {
+					sameStockCount++;
+				}
+			}
+		}
+		if (StringUtils.isNotBlank(issueStockNumber) && sameStockCount == 0) {
+			return "发料仓库暂无批号,组织内其他仓库共 " + lots.size() + " 个批号,请点击「选批号」选择";
+		}
+		return buildLotHint(lots, suggestedLot, requiredQty);
+	}
+
+	public String buildLotHint(List<ErpInventoryLotOption> lots, String suggestedLot, BigDecimal requiredQty) {
+		if (lots == null || lots.isEmpty()) {
+			return "ERP 即时库存无该物料批号,请点击「选批号」确认";
+		}
+		BigDecimal required = requiredQty == null ? BigDecimal.ZERO : requiredQty;
+		ErpInventoryLotOption selected = findLot(lots, suggestedLot);
+		if (selected == null) {
+			return "共 " + lots.size() + " 个批号可选,请点击「选批号」或下拉选择";
+		}
+		if (required.compareTo(BigDecimal.ZERO) > 0
+				&& selected.getQty() != null
+				&& selected.getQty().compareTo(required) < 0) {
+			return "已推荐批号 " + suggestedLot + ",库存 " + selected.getQtyText()
+					+ ",少于本次领料 " + formatQtyText(required) + ",请改批号或改数量";
+		}
+		if (lots.size() == 1) {
+			return "已推荐批号 " + suggestedLot + "(库存 " + selected.getQtyText() + ")";
+		}
+		return "已推荐批号 " + suggestedLot + "(库存 " + selected.getQtyText() + "),共 " + lots.size() + " 个可选";
+	}
+
+	private String formatQtyText(BigDecimal value) {
+		if (value == null) {
+			return "0";
+		}
+		return value.stripTrailingZeros().toPlainString();
+	}
+
+	public String diagnoseLotShortage(String materialNumber, String stockNumber, String stockName,
+			String stockStatusNumber, String stockOrgNumber) {
+		String diagnostic = getLastQueryDiagnostic();
+		if (StringUtils.isNotBlank(diagnostic)) {
+			return diagnostic;
+		}
+		List<ErpInventoryLotOption> allLots = findLotsByMaterial(materialNumber, stockStatusNumber, stockOrgNumber);
+		if (!allLots.isEmpty()) {
+			return "发料仓库【" + firstNonBlank(stockName, stockNumber, "发料仓库")
+					+ "】暂无批号,组织内共有 " + allLots.size() + " 个批号,请点击「选批号」选择";
+		}
+		return "ERP 即时库存无该物料批号(与 ERP 查询一致),无法领料。请先在 ERP 做采购入库/生产入库/调拨";
+	}
+
+	private ErpInventoryLotOption findLot(List<ErpInventoryLotOption> lots, String lotNumber) {
+		if (StringUtils.isBlank(lotNumber) || lots == null) {
+			return null;
+		}
+		for (ErpInventoryLotOption lot : lots) {
+			if (lotNumber.equals(lot.getLotNumber())) {
+				return lot;
+			}
+		}
+		return null;
+	}
+
+	private AggregateResult aggregateLots(JSONArray rows, String expectedMaterialNumber,
+			String expectedStockStatusNumber, String expectedStockOrgNumber, String filterString) {
+		Map<String, ErpInventoryLotOption> lotMap = new LinkedHashMap<>();
+		AggregateResult result = new AggregateResult();
+		if (rows == null) {
+			return result;
+		}
+		String expectedMaterial = expectedMaterialNumber == null ? "" : expectedMaterialNumber.trim();
+		String expectedOrg = firstNonBlank(expectedStockOrgNumber, erpProperties.getOrgNumber());
+		String expectedStatus = firstNonBlank(expectedStockStatusNumber, erpProperties.getPickMtrlStockStatus());
+		for (int i = 0; i < rows.size(); i++) {
+			result.rawRowCount++;
+			InventoryRow row = parseInventoryRow(rows.get(i));
+			if (!matchesExpectedRow(row, expectedMaterial, expectedOrg, expectedStatus)) {
+				result.mismatchRowCount++;
+				continue;
+			}
+			if (StringUtils.isBlank(row.lotNumber)) {
+				continue;
+			}
+			BigDecimal qty = chooseQty(row.baseAvbQty, row.baseQty);
+			if (qty.compareTo(BigDecimal.ZERO) <= 0) {
+				continue;
+			}
+			String mapKey = row.lotNumber + "@" + firstNonBlank(row.stockNumber, "");
+			ErpInventoryLotOption option = lotMap.get(mapKey);
+			if (option == null) {
+				option = new ErpInventoryLotOption();
+				option.setLotNumber(row.lotNumber);
+				option.setStockNumber(row.stockNumber);
+				option.setStockName(row.stockNumber);
+				option.setQty(qty);
+				lotMap.put(mapKey, option);
+			} else {
+				option.setQty(option.getQty().add(qty));
+			}
+		}
+		result.lots = new ArrayList<>(lotMap.values());
+		result.lots.sort(Comparator.comparing(ErpInventoryLotOption::getQty, Comparator.nullsLast(BigDecimal::compareTo)).reversed());
+		return result;
+	}
+
+	private boolean matchesExpectedRow(InventoryRow row, String expectedMaterial, String expectedOrg,
+			String expectedStatus) {
+		if (StringUtils.isBlank(expectedMaterial)) {
+			return false;
+		}
+		if (StringUtils.isBlank(row.materialNumber)) {
+			return false;
+		}
+		if (!expectedMaterial.equalsIgnoreCase(row.materialNumber.trim())) {
+			return false;
+		}
+		if (StringUtils.isNotBlank(expectedOrg) && StringUtils.isNotBlank(row.stockOrgNumber)
+				&& !expectedOrg.equalsIgnoreCase(row.stockOrgNumber.trim())) {
+			return false;
+		}
+		if (StringUtils.isNotBlank(expectedStatus) && StringUtils.isNotBlank(row.stockStatusNumber)
+				&& !expectedStatus.equalsIgnoreCase(row.stockStatusNumber.trim())) {
+			return false;
+		}
+		return true;
+	}
+
+	private InventoryRow parseInventoryRow(Object rowObj) {
+		InventoryRow row = new InventoryRow();
+		if (rowObj instanceof JSONArray) {
+			JSONArray arr = (JSONArray) rowObj;
+			row.materialNumber = ErpKingdeeParseUtils.readBillQueryString(rowObj, 0, "FMaterialId.FNumber");
+			row.stockNumber = ErpKingdeeParseUtils.readBillQueryString(rowObj, 1, "FStockId.FNumber");
+			row.lotNumber = ErpKingdeeParseUtils.readBillQueryString(rowObj, 2, "FLot.FNumber");
+			row.baseQty = parseQty(ErpKingdeeParseUtils.readBillQueryString(rowObj, 3, "FBaseQty"));
+			row.baseAvbQty = parseQty(ErpKingdeeParseUtils.readBillQueryString(rowObj, 4, "FBaseAVBQty"));
+			row.stockStatusNumber = ErpKingdeeParseUtils.readBillQueryString(rowObj, 5, "FStockStatusId.FNumber");
+			row.stockOrgNumber = ErpKingdeeParseUtils.readBillQueryString(rowObj, 6, "FStockOrgId.FNumber");
+			// 兼容 BillQuery 返回列顺序与 FieldKeys 不一致的情况:从整行扫描物料编码
+			if (StringUtils.isBlank(row.materialNumber)) {
+				row.materialNumber = scanMaterialNumber(arr);
+			}
+			return row;
+		}
+		if (rowObj instanceof JSONObject) {
+			JSONObject jo = (JSONObject) rowObj;
+			row.materialNumber = ErpKingdeeParseUtils.readString(jo, "FMaterialId.FNumber", "FMaterialId_FNumber");
+			row.stockNumber = ErpKingdeeParseUtils.readString(jo, "FStockId.FNumber", "FStockId_FNumber");
+			row.lotNumber = readLotNumber(jo);
+			row.baseQty = readQty(jo, "FBaseQty");
+			row.baseAvbQty = readQty(jo, "FBaseAVBQty");
+			row.stockStatusNumber = ErpKingdeeParseUtils.readString(jo, "FStockStatusId.FNumber", "FStockStatusId_FNumber");
+			row.stockOrgNumber = ErpKingdeeParseUtils.readString(jo, "FStockOrgId.FNumber", "FStockOrgId_FNumber");
+		}
+		return row;
+	}
+
+	private String scanMaterialNumber(JSONArray arr) {
+		for (int i = 0; i < arr.size(); i++) {
+			Object value = arr.get(i);
+			if (value == null) {
+				continue;
+			}
+			String text = String.valueOf(value).trim();
+			if (text.matches("\\d+(\\.\\d+)*\\.\\d+[A-Za-z0-9\\-]*")) {
+				return text;
+			}
+		}
+		return "";
+	}
+
+	private BigDecimal chooseQty(BigDecimal avbQty, BigDecimal baseQty) {
+		if (avbQty != null && avbQty.compareTo(BigDecimal.ZERO) > 0) {
+			return avbQty;
+		}
+		return baseQty == null ? BigDecimal.ZERO : baseQty;
+	}
+
+	private BigDecimal readQty(JSONObject jo, String key) {
+		BigDecimal qty = jo.getBigDecimal(key);
+		if (qty != null) {
+			return qty;
+		}
+		return parseQty(jo.getString(key));
+	}
+
+	private BigDecimal parseQty(String text) {
+		if (StringUtils.isBlank(text)) {
+			return BigDecimal.ZERO;
+		}
+		try {
+			return new BigDecimal(text.trim());
+		} catch (NumberFormatException ex) {
+			return BigDecimal.ZERO;
+		}
+	}
+
+	private String readLotNumber(JSONObject row) {
+		String lotNumber = ErpKingdeeParseUtils.readString(row, "FLot.FNumber", "FLOT.FNumber", "FLotText", "FLOT.FName");
+		if (StringUtils.isNotBlank(lotNumber)) {
+			return lotNumber.trim();
+		}
+		JSONObject lot = row.getJSONObject("FLot");
+		if (lot != null && StringUtils.isNotBlank(lot.getString("FNumber"))) {
+			return lot.getString("FNumber").trim();
+		}
+		return "";
+	}
+
+	private String escape(String value) {
+		return value == null ? "" : value.trim().replace("'", "''");
+	}
+
+	private String escapeLike(String value) {
+		return escape(value).replace("%", "\\%").replace("_", "\\_");
+	}
+
+	private String firstNonBlank(String... values) {
+		for (String value : values) {
+			if (StringUtils.isNotBlank(value)) {
+				return value.trim();
+			}
+		}
+		return "";
+	}
+
+	private static class InventoryRow {
+		private String materialNumber;
+		private String stockNumber;
+		private String stockOrgNumber;
+		private String stockStatusNumber;
+		private String lotNumber;
+		private BigDecimal baseQty;
+		private BigDecimal baseAvbQty;
+	}
+
+	private static class AggregateResult {
+		private List<ErpInventoryLotOption> lots = new ArrayList<>();
+		private int rawRowCount;
+		private int mismatchRowCount;
+	}
+}

+ 130 - 0
src/main/java/com/jeesite/modules/mes/erp/ErpKingdeeParseUtils.java

@@ -0,0 +1,130 @@
+package com.jeesite.modules.mes.erp;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.jeesite.common.lang.StringUtils;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ * 金蝶 WebAPI 返回值解析工具。
+ */
+public final class ErpKingdeeParseUtils {
+
+	private ErpKingdeeParseUtils() {
+	}
+
+	public static boolean parseBoolean(JSONObject object, String... keys) {
+		Boolean value = parseBooleanObject(object, keys);
+		return value != null && value;
+	}
+
+	public static Boolean parseBooleanObject(JSONObject object, String... keys) {
+		if (object == null || keys == null) {
+			return null;
+		}
+		for (String key : keys) {
+			if (!object.containsKey(key)) {
+				continue;
+			}
+			Object raw = object.get(key);
+			Boolean parsed = parseRawBoolean(raw);
+			if (parsed != null) {
+				return parsed;
+			}
+		}
+		return null;
+	}
+
+	public static Boolean parseRawBoolean(Object raw) {
+		if (raw == null) {
+			return null;
+		}
+		if (raw instanceof Boolean) {
+			return (Boolean) raw;
+		}
+		if (raw instanceof Number) {
+			return ((Number) raw).intValue() != 0;
+		}
+		String text = String.valueOf(raw).trim();
+		if (text.isEmpty()) {
+			return null;
+		}
+		if ("true".equalsIgnoreCase(text) || "1".equals(text) || "y".equalsIgnoreCase(text) || "yes".equalsIgnoreCase(text)) {
+			return true;
+		}
+		if ("false".equalsIgnoreCase(text) || "0".equals(text) || "n".equalsIgnoreCase(text) || "no".equalsIgnoreCase(text)) {
+			return false;
+		}
+		return null;
+	}
+
+	public static String readString(JSONObject row, String... keys) {
+		if (row == null || keys == null) {
+			return "";
+		}
+		for (String key : keys) {
+			String value = row.getString(key);
+			if (StringUtils.isNotBlank(value)) {
+				return value.trim();
+			}
+		}
+		return "";
+	}
+
+	/** 金蝶 BillQuery 行可能是 JSONArray(按 FieldKeys 顺序)或 JSONObject。 */
+	public static String readBillQueryString(Object rowObj, int index, String... jsonKeys) {
+		if (rowObj instanceof JSONArray) {
+			JSONArray row = (JSONArray) rowObj;
+			if (index >= 0 && index < row.size()) {
+				Object value = row.get(index);
+				return value == null ? "" : String.valueOf(value).trim();
+			}
+			return "";
+		}
+		if (rowObj instanceof JSONObject) {
+			return readString((JSONObject) rowObj, jsonKeys);
+		}
+		return "";
+	}
+
+	public static boolean parseBillQueryBoolean(Object rowObj, int index, String... jsonKeys) {
+		if (rowObj instanceof JSONArray) {
+			JSONArray row = (JSONArray) rowObj;
+			if (index >= 0 && index < row.size()) {
+				Boolean value = parseRawBoolean(row.get(index));
+				if (value != null) {
+					return value;
+				}
+			}
+			for (int i = 1; i < row.size(); i++) {
+				Boolean value = parseRawBoolean(row.get(i));
+				if (Boolean.TRUE.equals(value)) {
+					return true;
+				}
+			}
+			return false;
+		}
+		if (rowObj instanceof JSONObject) {
+			return parseBoolean((JSONObject) rowObj, jsonKeys);
+		}
+		return false;
+	}
+
+	public static BigDecimal normalizeQty(BigDecimal qty) {
+		if (qty == null) {
+			return BigDecimal.ZERO;
+		}
+		return qty.setScale(10, RoundingMode.HALF_UP).stripTrailingZeros();
+	}
+
+	/** 与金蝶 Save 接口兼容的数量序列化:整数用 long,避免 2400 与 2400.0 不一致。 */
+	public static Object toErpQty(BigDecimal qty) {
+		BigDecimal normalized = normalizeQty(qty);
+		if (normalized.scale() <= 0) {
+			return normalized.longValue();
+		}
+		return normalized;
+	}
+}

+ 30 - 0
src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialBill.java

@@ -32,6 +32,12 @@ public class ErpPickMaterialBill implements Serializable {
 	private String moBillNo;
 	private String ppbomBillNo;
 	private String stockName;
+	/** 明细行数(列表聚合) */
+	private Integer entryCount;
+	/** 申请数量合计(列表聚合) */
+	private BigDecimal totalAppQty;
+	/** 实发数量合计(列表聚合) */
+	private BigDecimal totalActualQty;
 
 	/** 查询条件:生产订单号 */
 	private String moBillNoQuery;
@@ -214,6 +220,30 @@ public class ErpPickMaterialBill implements Serializable {
 		this.stockName = stockName;
 	}
 
+	public Integer getEntryCount() {
+		return entryCount;
+	}
+
+	public void setEntryCount(Integer entryCount) {
+		this.entryCount = entryCount;
+	}
+
+	public BigDecimal getTotalAppQty() {
+		return totalAppQty;
+	}
+
+	public void setTotalAppQty(BigDecimal totalAppQty) {
+		this.totalAppQty = totalAppQty;
+	}
+
+	public BigDecimal getTotalActualQty() {
+		return totalActualQty;
+	}
+
+	public void setTotalActualQty(BigDecimal totalActualQty) {
+		this.totalActualQty = totalActualQty;
+	}
+
 	public String getMoBillNoQuery() {
 		return moBillNoQuery;
 	}

+ 224 - 19
src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialBillService.java

@@ -7,8 +7,11 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
+import java.math.BigDecimal;
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * ERP 生产领料单列表 BillQuery(PRD_PickMtrl)。
@@ -22,7 +25,16 @@ public class ErpPickMaterialBillService {
 	/** BillQuery 分录字段:除分录内码外,明细字段一般不带 FEntity_ 前缀 */
 	private static final String MO_BILL_NO_FIELD = "FMoBillNo";
 	private static final String MATERIAL_NUMBER_FIELD = "FMaterialId.FNUMBER";
-	private static final String FIELD_KEYS = String.join(",",
+	private static final String HEADER_FIELD_KEYS = String.join(",",
+			"FID",
+			"FBILLNO",
+			"FDate",
+			"FDocumentStatus",
+			"FPrdOrgId.FNUMBER",
+			"FPrdOrgId.FNAME",
+			"FStockOrgId.FNUMBER",
+			"FStockOrgId.FNAME");
+	private static final String DETAIL_FIELD_KEYS = String.join(",",
 			"FID",
 			"FBILLNO",
 			"FDate",
@@ -49,32 +61,178 @@ public class ErpPickMaterialBillService {
 	private final KingdeeErpClient erpClient;
 	private final KingdeeErpProperties erpProperties;
 	private final ErpProductionOrderService erpProductionOrderService;
+	private final MesErpPickKcService mesErpPickKcService;
 
 	public ErpPickMaterialBillService(KingdeeErpClient erpClient, KingdeeErpProperties erpProperties,
-			ErpProductionOrderService erpProductionOrderService) {
+			ErpProductionOrderService erpProductionOrderService, MesErpPickKcService mesErpPickKcService) {
 		this.erpClient = erpClient;
 		this.erpProperties = erpProperties;
 		this.erpProductionOrderService = erpProductionOrderService;
+		this.mesErpPickKcService = mesErpPickKcService;
 	}
 
+	/**
+	 * 列表页:按领料单号聚合。
+	 */
 	public List<ErpPickMaterialBill> findPageList(ErpPickMaterialBill query, int startRow, int limit) {
 		String moFilter = buildProductionOrderFilter(query);
 		if (moFilter == null) {
 			return new ArrayList<>();
 		}
+		if (StringUtils.isNotBlank(query == null ? "" : query.getMaterialNumberQuery())) {
+			return findAggregatedPageList(query, moFilter, startRow, limit);
+		}
+		List<ErpPickMaterialBill> headers = findHeaderList(query, moFilter, startRow, limit);
+		if (headers.isEmpty()) {
+			return headers;
+		}
+		List<String> billNos = new ArrayList<>();
+		Map<String, ErpPickMaterialBill> billMap = new LinkedHashMap<>();
+		for (ErpPickMaterialBill header : headers) {
+			header.setEntryCount(0);
+			header.setTotalAppQty(BigDecimal.ZERO);
+			header.setTotalActualQty(BigDecimal.ZERO);
+			billMap.put(header.getBillNo(), header);
+			billNos.add(header.getBillNo());
+		}
+		for (ErpPickMaterialBill detail : findDetailListByBillNos(billNos, moFilter)) {
+			ErpPickMaterialBill header = billMap.get(detail.getBillNo());
+			if (header == null) {
+				continue;
+			}
+			if (StringUtils.isBlank(header.getMoBillNo()) && StringUtils.isNotBlank(detail.getMoBillNo())) {
+				header.setMoBillNo(detail.getMoBillNo());
+			}
+			if (StringUtils.isBlank(header.getWorkshopName()) && StringUtils.isNotBlank(detail.getWorkshopName())) {
+				header.setWorkshopNumber(detail.getWorkshopNumber());
+				header.setWorkshopName(detail.getWorkshopName());
+			}
+			header.setEntryCount(header.getEntryCount() + 1);
+			if (detail.getAppQty() != null) {
+				header.setTotalAppQty(header.getTotalAppQty().add(detail.getAppQty()));
+			}
+			if (detail.getActualQty() != null) {
+				header.setTotalActualQty(header.getTotalActualQty().add(detail.getActualQty()));
+			}
+		}
+		return new ArrayList<>(billMap.values());
+	}
+
+	/**
+	 * 明细页:指定领料单号的全部分录行。
+	 */
+	public List<ErpPickMaterialBill> findDetailList(String billNo) {
+		if (StringUtils.isBlank(billNo)) {
+			return new ArrayList<>();
+		}
+		List<String> billNos = new ArrayList<>();
+		billNos.add(billNo.trim());
+		List<ErpPickMaterialBill> list = findDetailListByBillNos(billNos, null);
+		try {
+			mesErpPickKcService.increaseFromBillDetails(billNo.trim(), list);
+		} catch (Exception ex) {
+			logger.error("ERP pick bill detail MES kc sync failed, billNo={}", billNo, ex);
+		}
+		return list;
+	}
+
+	private List<ErpPickMaterialBill> findAggregatedPageList(ErpPickMaterialBill query, String moFilter,
+			int startRow, int limit) {
+		int targetSize = startRow + limit;
+		int fetchStart = 0;
+		int fetchLimit = Math.max(limit * 3, 50);
+		int maxFetchRows = Math.max(targetSize * 5, 200);
+		Map<String, ErpPickMaterialBill> billMap = new LinkedHashMap<>();
+
+		while (billMap.size() < targetSize && fetchStart < maxFetchRows) {
+			List<ErpPickMaterialBill> chunk = findDetailList(query, moFilter, fetchStart, fetchLimit);
+			if (chunk.isEmpty()) {
+				break;
+			}
+			for (ErpPickMaterialBill detail : chunk) {
+				ErpPickMaterialBill header = billMap.get(detail.getBillNo());
+				if (header == null) {
+					header = toHeaderFromDetail(detail);
+					header.setEntryCount(0);
+					header.setTotalAppQty(BigDecimal.ZERO);
+					header.setTotalActualQty(BigDecimal.ZERO);
+					billMap.put(detail.getBillNo(), header);
+				}
+				header.setEntryCount(header.getEntryCount() + 1);
+				if (detail.getAppQty() != null) {
+					header.setTotalAppQty(header.getTotalAppQty().add(detail.getAppQty()));
+				}
+				if (detail.getActualQty() != null) {
+					header.setTotalActualQty(header.getTotalActualQty().add(detail.getActualQty()));
+				}
+			}
+			if (chunk.size() < fetchLimit) {
+				break;
+			}
+			fetchStart += fetchLimit;
+		}
+		List<ErpPickMaterialBill> allBills = new ArrayList<>(billMap.values());
+		if (startRow >= allBills.size()) {
+			return new ArrayList<>();
+		}
+		return new ArrayList<>(allBills.subList(startRow, Math.min(startRow + limit, allBills.size())));
+	}
+
+	private List<ErpPickMaterialBill> findHeaderList(ErpPickMaterialBill query, String moFilter, int startRow, int limit) {
 		JSONObject data = new JSONObject();
 		data.put("FormId", FORM_ID);
-		data.put("FieldKeys", FIELD_KEYS);
-		data.put("FilterString", buildFilterString(query, moFilter));
+		data.put("FieldKeys", HEADER_FIELD_KEYS);
+		data.put("FilterString", buildFilterString(query, moFilter, false));
+		data.put("OrderString", "FDate DESC,FBILLNO DESC");
+		data.put("StartRow", Math.max(startRow, 0));
+		data.put("Limit", Math.max(limit, 1));
+		logger.info("ERP pick material bill header query: {}", data.toJSONString());
+		return mapHeaderRows(erpClient.billQuery(data));
+	}
+
+	private List<ErpPickMaterialBill> findDetailList(ErpPickMaterialBill query, String moFilter, int startRow, int limit) {
+		JSONObject data = new JSONObject();
+		data.put("FormId", FORM_ID);
+		data.put("FieldKeys", DETAIL_FIELD_KEYS);
+		data.put("FilterString", buildFilterString(query, moFilter, true));
 		data.put("OrderString", "FDate DESC,FBILLNO DESC,FEntity_FSeq");
 		data.put("StartRow", Math.max(startRow, 0));
 		data.put("Limit", Math.max(limit, 1));
-		logger.info("ERP pick material bill query: {}", data.toJSONString());
+		logger.info("ERP pick material bill detail query: {}", data.toJSONString());
+		return mapDetailRows(erpClient.billQuery(data));
+	}
+
+	private List<ErpPickMaterialBill> findDetailListByBillNos(List<String> billNos, String moFilter) {
+		if (billNos == null || billNos.isEmpty()) {
+			return new ArrayList<>();
+		}
+		JSONObject data = new JSONObject();
+		data.put("FormId", FORM_ID);
+		data.put("FieldKeys", DETAIL_FIELD_KEYS);
+		data.put("FilterString", buildInFilterString(billNos, moFilter));
+		data.put("OrderString", "FBILLNO DESC,FEntity_FSeq");
+		data.put("StartRow", 0);
+		data.put("Limit", 2000);
+		logger.info("ERP pick material bill detail by billNos query: {}", data.toJSONString());
+		return mapDetailRows(erpClient.billQuery(data));
+	}
+
+	private List<ErpPickMaterialBill> mapHeaderRows(JSONArray rows) {
+		Map<String, ErpPickMaterialBill> billMap = new LinkedHashMap<>();
+		for (int i = 0; i < rows.size(); i++) {
+			ErpPickMaterialBill bill = toHeader(rows.getJSONObject(i));
+			if (StringUtils.isBlank(bill.getBillNo())) {
+				continue;
+			}
+			billMap.putIfAbsent(bill.getBillNo(), bill);
+		}
+		return new ArrayList<>(billMap.values());
+	}
 
-		JSONArray rows = erpClient.billQuery(data);
+	private List<ErpPickMaterialBill> mapDetailRows(JSONArray rows) {
 		List<ErpPickMaterialBill> list = new ArrayList<>();
 		for (int i = 0; i < rows.size(); i++) {
-			ErpPickMaterialBill bill = toRow(rows.getJSONObject(i), i);
+			ErpPickMaterialBill bill = toDetail(rows.getJSONObject(i), i);
 			if (StringUtils.isBlank(bill.getMoBillNo())) {
 				continue;
 			}
@@ -83,7 +241,34 @@ public class ErpPickMaterialBillService {
 		return list;
 	}
 
-	private ErpPickMaterialBill toRow(JSONObject row, int index) {
+	private ErpPickMaterialBill toHeader(JSONObject row) {
+		ErpPickMaterialBill bill = new ErpPickMaterialBill();
+		bill.setFid(readString(row, "FID"));
+		bill.setBillNo(readString(row, "FBILLNO", "FBillNo"));
+		bill.setId(bill.getBillNo());
+		fillHeaderFields(bill, row);
+		return bill;
+	}
+
+	private ErpPickMaterialBill toHeaderFromDetail(ErpPickMaterialBill detail) {
+		ErpPickMaterialBill bill = new ErpPickMaterialBill();
+		bill.setFid(detail.getFid());
+		bill.setBillNo(detail.getBillNo());
+		bill.setId(detail.getBillNo());
+		bill.setDocumentStatus(detail.getDocumentStatus());
+		bill.setDocumentStatusName(detail.getDocumentStatusName());
+		bill.setDate(detail.getDate());
+		bill.setPrdOrgNumber(detail.getPrdOrgNumber());
+		bill.setPrdOrgName(detail.getPrdOrgName());
+		bill.setStockOrgNumber(detail.getStockOrgNumber());
+		bill.setStockOrgName(detail.getStockOrgName());
+		bill.setMoBillNo(detail.getMoBillNo());
+		bill.setWorkshopNumber(detail.getWorkshopNumber());
+		bill.setWorkshopName(detail.getWorkshopName());
+		return bill;
+	}
+
+	private ErpPickMaterialBill toDetail(JSONObject row, int index) {
 		ErpPickMaterialBill bill = new ErpPickMaterialBill();
 		bill.setFid(readString(row, "FID"));
 		bill.setBillNo(readString(row, "FBILLNO", "FBillNo"));
@@ -98,13 +283,7 @@ public class ErpPickMaterialBillService {
 			rowId = rowId + "-" + index;
 		}
 		bill.setId(rowId);
-		bill.setDocumentStatus(readString(row, "FDocumentStatus"));
-		bill.setDocumentStatusName(documentStatusName(bill.getDocumentStatus()));
-		bill.setDate(formatErpDate(readString(row, "FDate")));
-		bill.setPrdOrgNumber(readString(row, "FPrdOrgId.FNUMBER", "FPrdOrgId.FNumber"));
-		bill.setPrdOrgName(readString(row, "FPrdOrgId.FNAME", "FPrdOrgId.FName"));
-		bill.setStockOrgNumber(readString(row, "FStockOrgId.FNUMBER", "FStockOrgId.FNumber"));
-		bill.setStockOrgName(readString(row, "FStockOrgId.FNAME", "FStockOrgId.FName"));
+		fillHeaderFields(bill, row);
 		bill.setWorkshopNumber(readString(row,
 				"FEntryWorkShopId.FNUMBER", "FEntryWorkShopId.FNumber",
 				"FWorkShopId.FNUMBER", "FWorkShopId.FNumber"));
@@ -131,6 +310,16 @@ public class ErpPickMaterialBillService {
 		return bill;
 	}
 
+	private void fillHeaderFields(ErpPickMaterialBill bill, JSONObject row) {
+		bill.setDocumentStatus(readString(row, "FDocumentStatus"));
+		bill.setDocumentStatusName(documentStatusName(bill.getDocumentStatus()));
+		bill.setDate(formatErpDate(readString(row, "FDate")));
+		bill.setPrdOrgNumber(readString(row, "FPrdOrgId.FNUMBER", "FPrdOrgId.FNumber"));
+		bill.setPrdOrgName(readString(row, "FPrdOrgId.FNAME", "FPrdOrgId.FName"));
+		bill.setStockOrgNumber(readString(row, "FStockOrgId.FNUMBER", "FStockOrgId.FNumber"));
+		bill.setStockOrgName(readString(row, "FStockOrgId.FNAME", "FStockOrgId.FName"));
+	}
+
 	/**
 	 * 按 MES 可见生产订单范围筛选;无关联生产订单的领料行不查。
 	 *
@@ -153,7 +342,7 @@ public class ErpPickMaterialBillService {
 		return buildInFilter(MO_BILL_NO_FIELD, visibleBillNos);
 	}
 
-	private String buildFilterString(ErpPickMaterialBill query, String moFilter) {
+	private String buildFilterString(ErpPickMaterialBill query, String moFilter, boolean includeMaterialFilter) {
 		String filter = appendAnd(buildCurrentOrgFilter(), moFilter);
 		// 排除未关联生产订单的分录
 		filter = appendAnd(filter, MO_BILL_NO_FIELD + "<>''");
@@ -163,13 +352,29 @@ public class ErpPickMaterialBillService {
 		if (StringUtils.isNotBlank(query.getBillNo())) {
 			filter = appendAnd(filter, "FBILLNO='" + escape(query.getBillNo()) + "'");
 		}
-		if (StringUtils.isNotBlank(query.getMaterialNumberQuery())) {
+		if (includeMaterialFilter && StringUtils.isNotBlank(query.getMaterialNumberQuery())) {
 			filter = appendAnd(filter,
 					MATERIAL_NUMBER_FIELD + " like '%" + escape(query.getMaterialNumberQuery()) + "%'");
 		}
 		return filter;
 	}
 
+	private String buildInFilterString(List<String> billNos, String moFilter) {
+		StringBuilder builder = new StringBuilder(appendAnd(buildCurrentOrgFilter(), MO_BILL_NO_FIELD + "<>''"));
+		if (StringUtils.isNotBlank(moFilter)) {
+			builder.append(" AND ").append(moFilter);
+		}
+		builder.append(" AND FBILLNO IN(");
+		for (int i = 0; i < billNos.size(); i++) {
+			if (i > 0) {
+				builder.append(",");
+			}
+			builder.append("'").append(escape(billNos.get(i))).append("'");
+		}
+		builder.append(")");
+		return builder.toString();
+	}
+
 	private String buildInFilter(String fieldName, List<String> values) {
 		StringBuilder builder = new StringBuilder(fieldName).append(" IN(");
 		for (int i = 0; i < values.size(); i++) {
@@ -229,9 +434,9 @@ public class ErpPickMaterialBillService {
 		return "";
 	}
 
-	private java.math.BigDecimal firstBigDecimal(JSONObject row, String... keys) {
+	private BigDecimal firstBigDecimal(JSONObject row, String... keys) {
 		for (String key : keys) {
-			java.math.BigDecimal value = row.getBigDecimal(key);
+			BigDecimal value = row.getBigDecimal(key);
 			if (value != null) {
 				return value;
 			}

+ 27 - 0
src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialResult.java

@@ -13,6 +13,9 @@ public class ErpPickMaterialResult implements Serializable {
 	private String pickBillNo;
 	private String pickBillId;
 	private String message;
+	private int syncedMaterialCount;
+	private int syncedKcCount;
+	private boolean kcSyncFailed;
 
 	public static ErpPickMaterialResult success(String pickBillNo, String pickBillId) {
 		ErpPickMaterialResult result = new ErpPickMaterialResult();
@@ -45,4 +48,28 @@ public class ErpPickMaterialResult implements Serializable {
 	public String getMessage() {
 		return message;
 	}
+
+	public int getSyncedMaterialCount() {
+		return syncedMaterialCount;
+	}
+
+	public void setSyncedMaterialCount(int syncedMaterialCount) {
+		this.syncedMaterialCount = syncedMaterialCount;
+	}
+
+	public int getSyncedKcCount() {
+		return syncedKcCount;
+	}
+
+	public void setSyncedKcCount(int syncedKcCount) {
+		this.syncedKcCount = syncedKcCount;
+	}
+
+	public boolean isKcSyncFailed() {
+		return kcSyncFailed;
+	}
+
+	public void setKcSyncFailed(boolean kcSyncFailed) {
+		this.kcSyncFailed = kcSyncFailed;
+	}
 }

+ 142 - 22
src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialService.java

@@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
@@ -27,13 +28,19 @@ public class ErpPickMaterialService {
 	private final KingdeeErpClient erpClient;
 	private final KingdeeErpProperties erpProperties;
 	private final ErpProductionMaterialService erpProductionMaterialService;
+	private final ErpInventoryLotService erpInventoryLotService;
+	private final MesErpPickKcService mesErpPickKcService;
 
 	public ErpPickMaterialService(KingdeeErpClient erpClient,
 			KingdeeErpProperties erpProperties,
-			ErpProductionMaterialService erpProductionMaterialService) {
+			ErpProductionMaterialService erpProductionMaterialService,
+			ErpInventoryLotService erpInventoryLotService,
+			MesErpPickKcService mesErpPickKcService) {
 		this.erpClient = erpClient;
 		this.erpProperties = erpProperties;
 		this.erpProductionMaterialService = erpProductionMaterialService;
+		this.erpInventoryLotService = erpInventoryLotService;
+		this.mesErpPickKcService = mesErpPickKcService;
 	}
 
 	public ErpPickMaterialResult submit(ErpPickMaterialSubmitRequest request) {
@@ -68,11 +75,11 @@ public class ErpPickMaterialService {
 				return ErpPickMaterialResult.fail("物料 " + material.getMaterialNumber()
 						+ " 领料数量不能超过未领数量 " + noPickedQty);
 			}
-			String validateMsg = validateMaterial(material);
+			String validateMsg = validateMaterial(material, pickQty, firstNonBlank(line.getLotNumber(), material.getLotNumber()));
 			if (StringUtils.isNotBlank(validateMsg)) {
 				return ErpPickMaterialResult.fail(validateMsg);
 			}
-			pickLines.add(new PickLineContext(material, pickQty));
+			pickLines.add(new PickLineContext(material, pickQty, firstNonBlank(line.getLotNumber(), material.getLotNumber())));
 		}
 
 		if (pickLines.isEmpty()) {
@@ -82,10 +89,30 @@ public class ErpPickMaterialService {
 		JSONObject saveData = buildSaveData(pickLines);
 		logger.info("ERP pick material save request: {}", saveData.toJSONString());
 		JSONObject response = erpClient.save(FORM_ID, saveData);
-		return parseSaveResponse(response);
+		ErpPickMaterialResult result = parseSaveResponse(response);
+		if (result.isSuccess()) {
+			syncMesInventory(result, pickLines);
+		}
+		return result;
+	}
+
+	private void syncMesInventory(ErpPickMaterialResult result, List<PickLineContext> pickLines) {
+		try {
+			List<MesErpPickKcService.SubmitLine> submitLines = new ArrayList<>();
+			for (PickLineContext pickLine : pickLines) {
+				submitLines.add(new MesErpPickKcService.SubmitLine(pickLine.material, pickLine.pickQty));
+			}
+			MesErpPickKcSyncResult syncResult = mesErpPickKcService.increaseFromSubmit(
+					result.getPickBillNo(), submitLines);
+			result.setSyncedMaterialCount(syncResult.getMaterialCreatedCount());
+			result.setSyncedKcCount(syncResult.getKcCreatedCount());
+		} catch (Exception ex) {
+			logger.error("MES inventory sync failed after ERP pick save, billNo={}", result.getPickBillNo(), ex);
+			result.setKcSyncFailed(true);
+		}
 	}
 
-	private String validateMaterial(ErpProductionMaterial material) {
+	private String validateMaterial(ErpProductionMaterial material, BigDecimal pickQty, String lotNumber) {
 		if (StringUtils.isBlank(material.getMaterialNumber())) {
 			return "子项物料编码为空,无法领料";
 		}
@@ -95,7 +122,51 @@ public class ErpPickMaterialService {
 		if (StringUtils.isBlank(material.getUnitNumber())) {
 			return "物料 " + material.getMaterialNumber() + " 单位编码为空,无法领料";
 		}
-		return "";
+		String materialLabel = material.getMaterialNumber() + "【" + firstNonBlank(material.getMaterialName(), "") + "】";
+		BigDecimal qty = pickQty == null ? BigDecimal.ZERO : pickQty;
+		BigDecimal canPickQty = material.getCanPickQty();
+		if (canPickQty != null) {
+			if (canPickQty.compareTo(BigDecimal.ZERO) <= 0) {
+				return "物料 " + materialLabel + " 可领数量 0,"
+						+ firstNonBlank(material.getPickQtyHint(), "存在未审核领料单或已达最小发料批量上限,无法领料");
+			}
+			if (qty.compareTo(canPickQty) > 0) {
+				return "物料 " + materialLabel + " 本次领料 " + formatQty(qty) + " 超过可领数量 "
+						+ formatQty(canPickQty) + "(" + firstNonBlank(material.getPickQtyHint(), "请按 ERP 规则调整") + ")";
+			}
+		}
+		if (material.isMinIssueQtyEnabled() && material.getMinIssueQty() != null
+				&& material.getMinIssueQty().compareTo(BigDecimal.ZERO) > 0) {
+			BigDecimal maxByBatch = qty.divide(material.getMinIssueQty(), 0, RoundingMode.CEILING)
+					.multiply(material.getMinIssueQty());
+			if (qty.compareTo(maxByBatch) > 0) {
+				return "物料 " + materialLabel + " 本次领料 " + formatQty(qty) + " 超出最小发料批量 "
+						+ formatQty(material.getMinIssueQty()) + " 的整数倍上限 " + formatQty(maxByBatch);
+			}
+		}
+		erpProductionMaterialService.resolveBatchManageFlag(material);
+		if (!material.isBatchManage()) {
+			return "";
+		}
+		if (StringUtils.isNotBlank(lotNumber)) {
+			return "";
+		}
+		List<ErpInventoryLotOption> lots = erpInventoryLotService.findLotsByMaterial(
+				material.getMaterialNumber(),
+				material.getStockStatusNumber(),
+				material.getStockOrgNumber());
+		if (lots == null || lots.isEmpty()) {
+			return "物料 " + materialLabel
+					+ " ERP 即时库存无批号(与 ERP 查询一致),无法领料。请先在 ERP 做采购入库/生产入库/调拨后再领";
+		}
+		return "物料 " + materialLabel + " 启用了批号管理,请先点击「选批号」选择批号后再提交";
+	}
+
+	private String formatQty(BigDecimal qty) {
+		if (qty == null) {
+			return "0";
+		}
+		return qty.stripTrailingZeros().toPlainString();
 	}
 
 	private JSONObject buildSaveData(List<PickLineContext> pickLines) {
@@ -132,7 +203,7 @@ public class ErpPickMaterialService {
 		saveData.put("NeedReturnFields", JSONArray.parseArray("[\"FBillNo\"]"));
 		saveData.put("IsDeleteEntry", "true");
 		saveData.put("IsVerifyBaseDataField", "false");
-		saveData.put("IsEntryBatchFill", "true");
+		saveData.put("IsEntryBatchFill", resolveIsEntryBatchFill(pickLines));
 		saveData.put("ValidateFlag", "true");
 		saveData.put("NumberSearch", "true");
 		saveData.put("IsAutoAdjustField", "true");
@@ -141,25 +212,52 @@ public class ErpPickMaterialService {
 		return saveData;
 	}
 
+	/**
+	 * 分录批量填充策略:
+	 * <ul>
+	 *   <li>含非批号管理物料 → true(否则多行时非批号分录缺省字段,Save 失败)</li>
+	 *   <li>纯批号管理且多行 → false(避免批号/数量按首行串填)</li>
+	 *   <li>其余 → true</li>
+	 * </ul>
+	 */
+	private String resolveIsEntryBatchFill(List<PickLineContext> pickLines) {
+		boolean hasNonBatchLine = false;
+		int batchLineCount = 0;
+		for (PickLineContext pickLine : pickLines) {
+			if (pickLine.material.isBatchManage()) {
+				batchLineCount++;
+			} else {
+				hasNonBatchLine = true;
+			}
+		}
+		if (hasNonBatchLine) {
+			return "true";
+		}
+		if (batchLineCount > 1) {
+			return "false";
+		}
+		return "true";
+	}
+
 	private JSONObject buildEntity(PickLineContext pickLine, String orgNumber, String stockOrgNumber,
 			String ownerType, String ownerNumber, String keeperType, String keeperNumber, String stockStatus) {
 		ErpProductionMaterial material = pickLine.material;
-		BigDecimal qty = pickLine.pickQty;
-		String baseUnit = firstNonBlank(material.getBaseUnitNumber(), material.getUnitNumber());
-		String stockUnit = firstNonBlank(material.getStockUnitNumber(), material.getUnitNumber());
+		BigDecimal qty = ErpKingdeeParseUtils.normalizeQty(pickLine.pickQty);
+		String unitNumber = material.getUnitNumber();
+		String baseUnit = firstNonBlank(material.getBaseUnitNumber(), unitNumber);
+		String stockUnit = firstNonBlank(material.getStockUnitNumber(), unitNumber);
+		if (isSameUnit(unitNumber, baseUnit, stockUnit)) {
+			baseUnit = unitNumber;
+			stockUnit = unitNumber;
+		}
 
 		JSONObject entity = new JSONObject(true);
 		entity.put("FEntryID", 0);
 		entity.put("FMaterialId", numberObject(material.getMaterialNumber(), false));
-		entity.put("FUnitID", numberObject(material.getUnitNumber(), false));
+		entity.put("FUnitID", numberObject(unitNumber, false));
 		entity.put("FBaseUnitId", numberObject(baseUnit, false));
 		entity.put("FStockUnitId", numberObject(stockUnit, false));
-		entity.put("FAppQty", qty);
-		entity.put("FActualQty", qty);
-		entity.put("FBaseAppQty", qty);
-		entity.put("FBaseActualQty", qty);
-		entity.put("FStockAppQty", qty);
-		entity.put("FStockActualQty", qty);
+		putPickQuantities(entity, qty);
 		entity.put("FStockId", numberObject(material.getStockNumber(), false));
 		entity.put("FStockStatusId", numberObject(stockStatus, false));
 		entity.put("FMoBillNo", material.getBillNo());
@@ -191,21 +289,41 @@ public class ErpPickMaterialService {
 		if (StringUtils.isNotBlank(material.getWorkshopNumber())) {
 			entity.put("FEntryWorkShopId", numberObject(material.getWorkshopNumber(), false));
 		}
-		if (StringUtils.isNotBlank(material.getLotNumber())) {
-			entity.put("FLot", numberObject(material.getLotNumber(), false));
+		if (StringUtils.isNotBlank(pickLine.lotNumber)) {
+			entity.put("FLot", numberObject(pickLine.lotNumber, false));
 		}
 		entity.put("FEntity_Link", buildEntityLink(material, qty));
 		return entity;
 	}
 
+	/** 数量字段:FBaseStockActualQty 与 FBaseActualQty 在三单位一致时必须相同(金蝶 Save 校验) */
+	private void putPickQuantities(JSONObject entity, BigDecimal qty) {
+		Object erpQty = ErpKingdeeParseUtils.toErpQty(qty);
+		entity.put("FAppQty", erpQty);
+		entity.put("FActualQty", erpQty);
+		entity.put("FBaseAppQty", erpQty);
+		entity.put("FBaseActualQty", erpQty);
+		entity.put("FBaseStockAppQty", erpQty);
+		entity.put("FBaseStockActualQty", erpQty);
+		entity.put("FStockAppQty", erpQty);
+		entity.put("FStockActualQty", erpQty);
+	}
+
+	private boolean isSameUnit(String unitNumber, String baseUnit, String stockUnit) {
+		return StringUtils.isNotBlank(unitNumber)
+				&& unitNumber.equals(baseUnit)
+				&& unitNumber.equals(stockUnit);
+	}
+
 	private JSONArray buildEntityLink(ErpProductionMaterial material, BigDecimal qty) {
+		Object erpQty = ErpKingdeeParseUtils.toErpQty(qty);
 		JSONObject link = new JSONObject(true);
 		link.put("FEntity_Link_FRuleId", erpProperties.getPickMtrlLinkRule());
 		link.put("FEntity_Link_FSTableName", erpProperties.getPickMtrlLinkTable());
 		link.put("FEntity_Link_FSBillId", parseLong(material.getPpbomId()));
 		link.put("FEntity_Link_FSId", parseLong(material.getId()));
-		link.put("FEntity_Link_FBaseActualQtyOld", qty);
-		link.put("FEntity_Link_FBaseActualQty", qty);
+		link.put("FEntity_Link_FBaseActualQtyOld", erpQty);
+		link.put("FEntity_Link_FBaseActualQty", erpQty);
 		JSONArray links = new JSONArray();
 		links.add(link);
 		return links;
@@ -277,10 +395,12 @@ public class ErpPickMaterialService {
 	private static class PickLineContext {
 		private final ErpProductionMaterial material;
 		private final BigDecimal pickQty;
+		private final String lotNumber;
 
-		private PickLineContext(ErpProductionMaterial material, BigDecimal pickQty) {
+		private PickLineContext(ErpProductionMaterial material, BigDecimal pickQty, String lotNumber) {
 			this.material = material;
 			this.pickQty = pickQty;
+			this.lotNumber = lotNumber;
 		}
 	}
 }

+ 10 - 0
src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialSubmitRequest.java

@@ -46,6 +46,8 @@ public class ErpPickMaterialSubmitRequest implements Serializable {
 
 		private String ppbomEntryId;
 		private BigDecimal pickQty;
+		/** 批号(启用批号管理的物料必填) */
+		private String lotNumber;
 
 		public String getPpbomEntryId() {
 			return ppbomEntryId;
@@ -62,5 +64,13 @@ public class ErpPickMaterialSubmitRequest implements Serializable {
 		public void setPickQty(BigDecimal pickQty) {
 			this.pickQty = pickQty;
 		}
+
+		public String getLotNumber() {
+			return lotNumber;
+		}
+
+		public void setLotNumber(String lotNumber) {
+			this.lotNumber = lotNumber;
+		}
 	}
 }

+ 142 - 0
src/main/java/com/jeesite/modules/mes/erp/ErpProductionMaterial.java

@@ -2,6 +2,8 @@ package com.jeesite.modules.mes.erp;
 
 import java.io.Serializable;
 import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
 
 public class ErpProductionMaterial implements Serializable {
 
@@ -20,6 +22,22 @@ public class ErpProductionMaterial implements Serializable {
 	private BigDecimal pickedQty;
 	private BigDecimal noPickedQty;
 	private BigDecimal issueQty;
+	/** 最小发料批量(用料清单) */
+	private BigDecimal minIssueQty;
+	/** 是否领料考虑最小发料批量 */
+	private boolean minIssueQtyEnabled;
+	/** 超发控制方式(如 MinIssueQty / 不允许超发) */
+	private String overControlMode;
+	/** 领料选单数量(含未审核领料单,不含本单) */
+	private BigDecimal selPickedQty;
+	/** 良品退料数量 */
+	private BigDecimal goodReturnQty;
+	/** 来料不良退料数量 */
+	private BigDecimal incDefectReturnQty;
+	/** ERP 可领数量 */
+	private BigDecimal canPickQty;
+	/** 领料数量说明 */
+	private String pickQtyHint;
 	private String unitName;
 	private String stockNumber;
 	private String stockName;
@@ -42,6 +60,18 @@ public class ErpProductionMaterial implements Serializable {
 	private String prdOrgNumber;
 	private String stockOrgNumber;
 	private String lotNumber;
+	/** 是否启用批号管理(来自用料清单子项物料) */
+	private boolean batchManage;
+	/** ERP 即时库存可用批号列表 */
+	private List<ErpInventoryLotOption> availableLots = new ArrayList<>();
+	/** 系统推荐的批号 */
+	private String suggestedLotNumber;
+	/** 批号选择说明(展示给用户) */
+	private String lotHint;
+	/** 是否允许领料(批号管理且 ERP 无可用批号时为 false) */
+	private boolean lotPickable = true;
+	/** 领料状态展示:可领料 / 未查到批次 / 已领完 */
+	private String pickStatusLabel;
 
 	public String getId() {
 		return id;
@@ -147,6 +177,70 @@ public class ErpProductionMaterial implements Serializable {
 		this.issueQty = issueQty;
 	}
 
+	public BigDecimal getMinIssueQty() {
+		return minIssueQty;
+	}
+
+	public void setMinIssueQty(BigDecimal minIssueQty) {
+		this.minIssueQty = minIssueQty;
+	}
+
+	public boolean isMinIssueQtyEnabled() {
+		return minIssueQtyEnabled;
+	}
+
+	public void setMinIssueQtyEnabled(boolean minIssueQtyEnabled) {
+		this.minIssueQtyEnabled = minIssueQtyEnabled;
+	}
+
+	public String getOverControlMode() {
+		return overControlMode;
+	}
+
+	public void setOverControlMode(String overControlMode) {
+		this.overControlMode = overControlMode;
+	}
+
+	public BigDecimal getSelPickedQty() {
+		return selPickedQty;
+	}
+
+	public void setSelPickedQty(BigDecimal selPickedQty) {
+		this.selPickedQty = selPickedQty;
+	}
+
+	public BigDecimal getGoodReturnQty() {
+		return goodReturnQty;
+	}
+
+	public void setGoodReturnQty(BigDecimal goodReturnQty) {
+		this.goodReturnQty = goodReturnQty;
+	}
+
+	public BigDecimal getIncDefectReturnQty() {
+		return incDefectReturnQty;
+	}
+
+	public void setIncDefectReturnQty(BigDecimal incDefectReturnQty) {
+		this.incDefectReturnQty = incDefectReturnQty;
+	}
+
+	public BigDecimal getCanPickQty() {
+		return canPickQty;
+	}
+
+	public void setCanPickQty(BigDecimal canPickQty) {
+		this.canPickQty = canPickQty;
+	}
+
+	public String getPickQtyHint() {
+		return pickQtyHint;
+	}
+
+	public void setPickQtyHint(String pickQtyHint) {
+		this.pickQtyHint = pickQtyHint;
+	}
+
 	public String getUnitName() {
 		return unitName;
 	}
@@ -322,4 +416,52 @@ public class ErpProductionMaterial implements Serializable {
 	public void setLotNumber(String lotNumber) {
 		this.lotNumber = lotNumber;
 	}
+
+	public boolean isBatchManage() {
+		return batchManage;
+	}
+
+	public void setBatchManage(boolean batchManage) {
+		this.batchManage = batchManage;
+	}
+
+	public List<ErpInventoryLotOption> getAvailableLots() {
+		return availableLots;
+	}
+
+	public void setAvailableLots(List<ErpInventoryLotOption> availableLots) {
+		this.availableLots = availableLots;
+	}
+
+	public String getSuggestedLotNumber() {
+		return suggestedLotNumber;
+	}
+
+	public void setSuggestedLotNumber(String suggestedLotNumber) {
+		this.suggestedLotNumber = suggestedLotNumber;
+	}
+
+	public String getLotHint() {
+		return lotHint;
+	}
+
+	public void setLotHint(String lotHint) {
+		this.lotHint = lotHint;
+	}
+
+	public boolean isLotPickable() {
+		return lotPickable;
+	}
+
+	public void setLotPickable(boolean lotPickable) {
+		this.lotPickable = lotPickable;
+	}
+
+	public String getPickStatusLabel() {
+		return pickStatusLabel;
+	}
+
+	public void setPickStatusLabel(String pickStatusLabel) {
+		this.pickStatusLabel = pickStatusLabel;
+	}
 }

+ 328 - 3
src/main/java/com/jeesite/modules/mes/erp/ErpProductionMaterialService.java

@@ -11,23 +11,34 @@ import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.LinkedHashSet;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 
 @Service
 public class ErpProductionMaterialService {
 
 	private static final Logger logger = LoggerFactory.getLogger(ErpProductionMaterialService.class);
 	private static final String FORM_ID = "PRD_PPBOM";
+	private static final String MATERIAL_FORM_ID = "BD_Material";
 	private static final String QUERY_FIELD_KEYS = "FID,FBILLNO";
 	private static final String[] MO_BILL_NO_FIELDS = {"MoBillNo", "MOBillNO", "FMoBillNo", "FMoBillNO"};
 	private static final String[] MO_ENTRY_ID_FIELDS = {"MoEntryId", "MOEntryID", "FMoEntryId", "FMOEntryID"};
 
 	private final KingdeeErpClient erpClient;
+	private final ErpInventoryLotService erpInventoryLotService;
 
-	public ErpProductionMaterialService(KingdeeErpClient erpClient) {
+	public ErpProductionMaterialService(KingdeeErpClient erpClient, ErpInventoryLotService erpInventoryLotService) {
 		this.erpClient = erpClient;
+		this.erpInventoryLotService = erpInventoryLotService;
 	}
 
 	public List<ErpProductionMaterial> findByProductionOrderEntry(String billNo, String entryId) {
+		return findByProductionOrderEntry(billNo, entryId, false);
+	}
+
+	public List<ErpProductionMaterial> findByProductionOrderEntry(String billNo, String entryId, boolean enrichLots) {
 		if (StringUtils.isBlank(billNo) || StringUtils.isBlank(entryId)) {
 			return new ArrayList<>();
 		}
@@ -35,12 +46,17 @@ public class ErpProductionMaterialService {
 		if (ppbom == null) {
 			return new ArrayList<>();
 		}
-		return findDetailByNumber(ppbom.getString("FBILLNO"), billNo, entryId);
+		List<ErpProductionMaterial> list = findDetailByNumber(ppbom.getString("FBILLNO"), billNo, entryId);
+		enrichBatchManageFlags(list);
+		if (enrichLots) {
+			enrichAvailableLots(list);
+		}
+		return list;
 	}
 
 	public Map<String, ErpProductionMaterial> findMaterialMap(String billNo, String entryId) {
 		Map<String, ErpProductionMaterial> map = new LinkedHashMap<>();
-		for (ErpProductionMaterial material : findByProductionOrderEntry(billNo, entryId)) {
+		for (ErpProductionMaterial material : findByProductionOrderEntry(billNo, entryId, true)) {
 			if (StringUtils.isNotBlank(material.getId())) {
 				map.put(material.getId(), material);
 			}
@@ -205,6 +221,7 @@ public class ErpProductionMaterialService {
 		material.setLotText(row.getString("Lot_Text"));
 		JSONObject lot = row.getJSONObject("Lot");
 		material.setLotNumber(lot == null ? row.getString("Lot_Text") : lot.getString("Number"));
+		material.setBatchManage(readBatchManage(materialObj, row));
 		material.setMoId(firstNonBlank(row.getString("MoId"), header.getString("MoId")));
 		material.setMoEntrySeq(row.getString("MoEntrySeq"));
 		material.setOwnerTypeId(firstNonBlank(row.getString("OwnerTypeId"), header.getString("OwnerTypeId")));
@@ -218,9 +235,313 @@ public class ErpProductionMaterialService {
 				getNumber(header.getJSONObject("SupplyOrg")),
 				getNumber(header.getJSONObject("StockOrgId")),
 				material.getPrdOrgNumber()));
+		enrichPickQtyRules(material, row);
 		return material;
 	}
 
+	private void enrichPickQtyRules(ErpProductionMaterial material, JSONObject row) {
+		BigDecimal minIssueQty = readDecimal(row, "MinIssueQty", "FMinIssueQty", "FMinIssueUnitQty");
+		Boolean minIssueFlag = ErpKingdeeParseUtils.parseBooleanObject(row, "ISMinIssueQty", "FISMinIssueQty");
+		String overControlMode = ErpKingdeeParseUtils.readString(row, "OverControlMode", "FOverControlMode");
+		BigDecimal selPickedQty = nullToZero(readDecimal(row, "SelPickedQty", "PickMtrlQty", "FSelPickedQty", "FMselPickedQty"));
+		BigDecimal goodReturnQty = nullToZero(readDecimal(row, "GoodReturnQty", "FGoodReturnQty"));
+		BigDecimal incDefectReturnQty = nullToZero(readDecimal(row, "INCDefectReturnQty", "FINCDefectReturnQty"));
+		BigDecimal canPickQty = readDecimal(row, "CanPickQty", "FCanPickQty", "FCanIssueQty");
+		if (canPickQty == null) {
+			canPickQty = calculateCanPickQty(material.getMustQty(), minIssueQty,
+					minIssueFlag != null && minIssueFlag, overControlMode, selPickedQty, goodReturnQty, incDefectReturnQty);
+		}
+		if (canPickQty.compareTo(BigDecimal.ZERO) < 0) {
+			canPickQty = BigDecimal.ZERO;
+		}
+		material.setMinIssueQty(minIssueQty);
+		material.setMinIssueQtyEnabled(minIssueFlag != null && minIssueFlag);
+		material.setOverControlMode(overControlMode);
+		material.setSelPickedQty(selPickedQty);
+		material.setGoodReturnQty(goodReturnQty);
+		material.setIncDefectReturnQty(incDefectReturnQty);
+		material.setCanPickQty(canPickQty);
+		material.setPickQtyHint(buildPickQtyHint(material, canPickQty));
+	}
+
+	private BigDecimal calculateCanPickQty(BigDecimal mustQty, BigDecimal minIssueQty, boolean minIssueEnabled,
+			String overControlMode, BigDecimal selPickedQty, BigDecimal goodReturnQty, BigDecimal incDefectReturnQty) {
+		BigDecimal must = mustQty == null ? BigDecimal.ZERO : mustQty;
+		BigDecimal baseLimit = must;
+		if (minIssueEnabled && minIssueQty != null && minIssueQty.compareTo(BigDecimal.ZERO) > 0
+				&& isMinIssueBatchControl(overControlMode)) {
+			baseLimit = must.divide(minIssueQty, 0, RoundingMode.CEILING).multiply(minIssueQty);
+		}
+		return baseLimit.subtract(selPickedQty).add(goodReturnQty).add(incDefectReturnQty);
+	}
+
+	private boolean isMinIssueBatchControl(String overControlMode) {
+		if (StringUtils.isBlank(overControlMode)) {
+			return false;
+		}
+		String mode = overControlMode.trim();
+		return "4".equals(mode)
+				|| "MinIssueQty".equalsIgnoreCase(mode)
+				|| mode.contains("最小发料")
+				|| "FI".equalsIgnoreCase(mode);
+	}
+
+	private String buildPickQtyHint(ErpProductionMaterial material, BigDecimal canPickQty) {
+		if (canPickQty == null || canPickQty.compareTo(BigDecimal.ZERO) > 0) {
+			if (material.isMinIssueQtyEnabled() && material.getMinIssueQty() != null
+					&& material.getMinIssueQty().compareTo(BigDecimal.ZERO) > 0) {
+				return "可领 " + formatQty(canPickQty) + ",最小发料批量 " + formatQty(material.getMinIssueQty());
+			}
+			return canPickQty == null ? "" : ("可领 " + formatQty(canPickQty));
+		}
+		StringBuilder hint = new StringBuilder("可领数量 0");
+		if (material.getSelPickedQty() != null && material.getSelPickedQty().compareTo(BigDecimal.ZERO) > 0) {
+			hint.append(",领料选单数 ").append(formatQty(material.getSelPickedQty()));
+		}
+		if (material.isMinIssueQtyEnabled() && material.getMinIssueQty() != null) {
+			hint.append(",最小发料批量 ").append(formatQty(material.getMinIssueQty()));
+		}
+		hint.append(";请检查 ERP 未审核领料单或已领满批量上限");
+		return hint.toString();
+	}
+
+	private String formatQty(BigDecimal qty) {
+		if (qty == null) {
+			return "0";
+		}
+		return qty.stripTrailingZeros().toPlainString();
+	}
+
+	private BigDecimal readDecimal(JSONObject row, String... keys) {
+		if (row == null || keys == null) {
+			return null;
+		}
+		for (String key : keys) {
+			BigDecimal value = row.getBigDecimal(key);
+			if (value != null) {
+				return value;
+			}
+			String text = row.getString(key);
+			if (StringUtils.isNotBlank(text)) {
+				try {
+					return new BigDecimal(text.trim());
+				} catch (NumberFormatException ignored) {
+					// 尝试下一个字段
+				}
+			}
+		}
+		return null;
+	}
+
+	private BigDecimal nullToZero(BigDecimal value) {
+		return value == null ? BigDecimal.ZERO : value;
+	}
+
+	private boolean readBatchManage(JSONObject materialObj, JSONObject row) {
+		Boolean fromMaterial = ErpKingdeeParseUtils.parseBooleanObject(materialObj,
+				"IsBatchManage", "FIsBatchManage", "ISBATCHMANAGE", "FBatchManage");
+		if (fromMaterial != null) {
+			return fromMaterial;
+		}
+		Boolean fromRow = ErpKingdeeParseUtils.parseBooleanObject(row,
+				"IsBatchManage", "FIsBatchManage", "ISBATCHMANAGE", "FBatchManage");
+		return fromRow != null && fromRow;
+	}
+
+	private void enrichBatchManageFlags(List<ErpProductionMaterial> list) {
+		if (list == null || list.isEmpty()) {
+			return;
+		}
+		Set<String> materialNumbers = new LinkedHashSet<>();
+		for (ErpProductionMaterial material : list) {
+			if (StringUtils.isNotBlank(material.getMaterialNumber())) {
+				materialNumbers.add(material.getMaterialNumber().trim());
+			}
+		}
+		if (materialNumbers.isEmpty()) {
+			return;
+		}
+		Map<String, Boolean> batchManageMap = queryMaterialBatchManage(materialNumbers);
+		for (ErpProductionMaterial material : list) {
+			if (StringUtils.isBlank(material.getMaterialNumber())) {
+				continue;
+			}
+			String materialNumber = material.getMaterialNumber().trim();
+			Boolean batchManage = batchManageMap.get(materialNumber);
+			if (batchManage != null && batchManage) {
+				material.setBatchManage(true);
+			}
+		}
+		for (ErpProductionMaterial material : list) {
+			if (material.isBatchManage() || StringUtils.isBlank(material.getMaterialNumber())) {
+				continue;
+			}
+			if (queryViewBatchManage(material.getMaterialNumber())) {
+				material.setBatchManage(true);
+			}
+		}
+	}
+
+	private Map<String, Boolean> queryMaterialBatchManage(Set<String> materialNumbers) {
+		Map<String, Boolean> result = new LinkedHashMap<>();
+		StringBuilder filter = new StringBuilder();
+		for (String materialNumber : materialNumbers) {
+			if (filter.length() > 0) {
+				filter.append(" OR ");
+			}
+			filter.append("FNumber='").append(escape(materialNumber)).append("'");
+		}
+		JSONObject data = new JSONObject();
+		data.put("FormId", MATERIAL_FORM_ID);
+		data.put("FieldKeys", "FNumber,FIsBatchManage,IsBatchManage");
+		data.put("FilterString", filter.toString());
+		data.put("OrderString", "FNumber");
+		data.put("StartRow", 0);
+		data.put("Limit", Math.max(materialNumbers.size(), 1));
+		try {
+			JSONArray rows = erpClient.billQuery(data);
+			for (int i = 0; i < rows.size(); i++) {
+				Object rowObj = rows.get(i);
+				String number = ErpKingdeeParseUtils.readBillQueryString(rowObj, 0, "FNumber");
+				if (StringUtils.isBlank(number)) {
+					continue;
+				}
+				boolean batchManage = ErpKingdeeParseUtils.parseBillQueryBoolean(rowObj, 1,
+						"FIsBatchManage", "IsBatchManage", "FBatchManage");
+				result.put(number.trim(), batchManage);
+			}
+		} catch (RuntimeException e) {
+			logger.warn("ERP material batch manage BillQuery failed: {}", e.getMessage());
+		}
+		return result;
+	}
+
+	private boolean queryViewBatchManage(String materialNumber) {
+		if (StringUtils.isBlank(materialNumber)) {
+			return false;
+		}
+		JSONObject data = new JSONObject();
+		data.put("CreateOrgId", 0);
+		data.put("Number", materialNumber.trim());
+		data.put("Id", "");
+		data.put("IsSortBySeq", false);
+		try {
+			JSONObject response = erpClient.view(MATERIAL_FORM_ID, data);
+			JSONObject result = viewResult(response);
+			if (result == null) {
+				return false;
+			}
+			if (ErpKingdeeParseUtils.parseBoolean(result, "IsBatchManage", "FIsBatchManage", "FBatchManage")) {
+				return true;
+			}
+			JSONArray materialStock = result.getJSONArray("MaterialStock");
+			if (materialStock == null) {
+				return false;
+			}
+			for (int i = 0; i < materialStock.size(); i++) {
+				JSONObject stock = materialStock.getJSONObject(i);
+				if (ErpKingdeeParseUtils.parseBoolean(stock, "IsBatchManage", "FIsBatchManage", "FBatchManage")) {
+					return true;
+				}
+			}
+		} catch (RuntimeException e) {
+			logger.debug("ERP material view batch flag failed, material={}, msg={}",
+					materialNumber, e.getMessage());
+		}
+		return false;
+	}
+
+	/**
+	 * 提交/展示前再次从 ERP 物料主数据确认是否批号管理。
+	 */
+	public void resolveBatchManageFlag(ErpProductionMaterial material) {
+		ensureBatchManageFlag(material);
+	}
+
+	private void ensureBatchManageFlag(ErpProductionMaterial material) {
+		if (material == null || material.isBatchManage() || StringUtils.isBlank(material.getMaterialNumber())) {
+			return;
+		}
+		if (queryViewBatchManage(material.getMaterialNumber())) {
+			material.setBatchManage(true);
+		}
+	}
+
+	private void enrichAvailableLots(List<ErpProductionMaterial> list) {
+		if (list == null || list.isEmpty()) {
+			return;
+		}
+		for (ErpProductionMaterial material : list) {
+			material.setLotPickable(true);
+			ensureBatchManageFlag(material);
+			if (!material.isBatchManage()) {
+				material.setLotHint("");
+				refreshPickStatusLabel(material);
+				continue;
+			}
+			if (StringUtils.isBlank(material.getStockNumber())) {
+				material.setLotPickable(false);
+				material.setLotHint("发料仓库为空,无法查询 ERP 批号");
+				material.setAvailableLots(new ArrayList<>());
+				refreshPickStatusLabel(material);
+				continue;
+			}
+			List<ErpInventoryLotOption> lots = erpInventoryLotService.findAvailableLots(
+					material.getMaterialNumber(),
+					material.getStockNumber(),
+					material.getStockStatusNumber(),
+					material.getStockOrgNumber());
+			material.setAvailableLots(lots);
+			if (lots == null || lots.isEmpty()) {
+				material.setLotPickable(false);
+				material.setLotHint(erpInventoryLotService.diagnoseLotShortage(
+						material.getMaterialNumber(),
+						material.getStockNumber(),
+						material.getStockName(),
+						material.getStockStatusNumber(),
+						material.getStockOrgNumber()));
+				material.setSuggestedLotNumber("");
+				material.setLotNumber("");
+				refreshPickStatusLabel(material);
+				continue;
+			}
+			BigDecimal requiredQty = material.getNoPickedQty() == null ? BigDecimal.ZERO : material.getNoPickedQty();
+			String suggested = erpInventoryLotService.suggestLotNumber(lots, requiredQty, material.getStockNumber());
+			material.setSuggestedLotNumber(suggested);
+			material.setLotHint(erpInventoryLotService.buildLotHint(lots, suggested, requiredQty, material.getStockNumber()));
+			material.setLotPickable(true);
+			if (StringUtils.isNotBlank(suggested)) {
+				material.setLotNumber(suggested);
+			}
+			refreshPickStatusLabel(material);
+		}
+	}
+
+	private void refreshPickStatusLabel(ErpProductionMaterial material) {
+		BigDecimal noPickedQty = material.getNoPickedQty() == null ? BigDecimal.ZERO : material.getNoPickedQty();
+		if (noPickedQty.compareTo(BigDecimal.ZERO) <= 0) {
+			material.setPickStatusLabel("已领完");
+			material.setLotPickable(false);
+			return;
+		}
+		BigDecimal canPickQty = material.getCanPickQty();
+		if (canPickQty != null && canPickQty.compareTo(BigDecimal.ZERO) <= 0) {
+			material.setPickStatusLabel("不可领");
+			return;
+		}
+		if (!material.isBatchManage()) {
+			material.setPickStatusLabel("可领料");
+			material.setLotPickable(true);
+			return;
+		}
+		if (!material.isLotPickable()) {
+			boolean noInventory = material.getAvailableLots() == null || material.getAvailableLots().isEmpty();
+			material.setPickStatusLabel(noInventory ? "无库存" : "待选批号");
+			return;
+		}
+		material.setPickStatusLabel("可领料");
+	}
+
 	private JSONObject firstObject(JSONObject row, String... keys) {
 		for (String key : keys) {
 			JSONObject object = row.getJSONObject(key);
@@ -231,6 +552,10 @@ public class ErpProductionMaterialService {
 		return null;
 	}
 
+	private Boolean readBooleanField(JSONObject object, String... keys) {
+		return ErpKingdeeParseUtils.parseBooleanObject(object, keys);
+	}
+
 	private String getNumber(JSONObject object) {
 		if (object == null) {
 			return "";

+ 22 - 0
src/main/java/com/jeesite/modules/mes/erp/KingdeeErpProperties.java

@@ -53,6 +53,16 @@ public class KingdeeErpProperties {
 	@Value("${erp.k3cloud.pick-mtrl.link-table:T_PRD_PPBOMENTRY}")
 	private String pickMtrlLinkTable;
 
+	/** MES 线边库位 ID(优先);未配置则按 mes-kw-title 查找或自动创建 */
+	@Value("${erp.k3cloud.pick-mtrl.mes-kw-id:}")
+	private String pickMtrlMesKwId;
+
+	@Value("${erp.k3cloud.pick-mtrl.mes-kw-title:ERP线边库}")
+	private String pickMtrlMesKwTitle;
+
+	@Value("${erp.k3cloud.pick-mtrl.mes-ck-name:ERP线边仓}")
+	private String pickMtrlMesCkName;
+
 	/** 标准采购订单单据类型编码 */
 	@Value("${erp.k3cloud.purchase-order.bill-type:CGDD01_SYS}")
 	private String purchaseOrderBillType;
@@ -120,6 +130,18 @@ public class KingdeeErpProperties {
 		return pickMtrlLinkTable;
 	}
 
+	public String getPickMtrlMesKwId() {
+		return pickMtrlMesKwId;
+	}
+
+	public String getPickMtrlMesKwTitle() {
+		return pickMtrlMesKwTitle;
+	}
+
+	public String getPickMtrlMesCkName() {
+		return pickMtrlMesCkName;
+	}
+
 	public String getPurchaseOrderBillType() {
 		return purchaseOrderBillType;
 	}

+ 209 - 0
src/main/java/com/jeesite/modules/mes/erp/MesErpPickKcService.java

@@ -0,0 +1,209 @@
+package com.jeesite.modules.mes.erp;
+
+import com.jeesite.common.lang.StringUtils;
+import com.jeesite.modules.mes.entity.MesMaterial;
+import com.jeesite.modules.mes.entity.MesMaterialCk;
+import com.jeesite.modules.mes.entity.MesMaterialKcLog;
+import com.jeesite.modules.mes.entity.MesMaterialKw;
+import com.jeesite.modules.mes.service.MesMaterialCkService;
+import com.jeesite.modules.mes.service.MesMaterialKcLogService;
+import com.jeesite.modules.mes.service.MesMaterialKwService;
+import com.jeesite.modules.mes.service.MesMaterialService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.List;
+
+/**
+ * ERP 生产领料成功后,同步 MES 物料主数据并增加线边库存(mes_material_kc_log)。
+ */
+@Service
+public class MesErpPickKcService {
+
+	private static final Logger logger = LoggerFactory.getLogger(MesErpPickKcService.class);
+	private static final String SOURCE_TYPE_ERP_PICK = "ERP_PICK";
+	private static final String DEFAULT_EXPIRE_DATE = "2099-01-01 00:00:00";
+
+	private final KingdeeErpProperties erpProperties;
+	private final MesMaterialService mesMaterialService;
+	private final MesMaterialKcLogService mesMaterialKcLogService;
+	private final MesMaterialKwService mesMaterialKwService;
+	private final MesMaterialCkService mesMaterialCkService;
+
+	public MesErpPickKcService(KingdeeErpProperties erpProperties,
+			MesMaterialService mesMaterialService,
+			MesMaterialKcLogService mesMaterialKcLogService,
+			MesMaterialKwService mesMaterialKwService,
+			MesMaterialCkService mesMaterialCkService) {
+		this.erpProperties = erpProperties;
+		this.mesMaterialService = mesMaterialService;
+		this.mesMaterialKcLogService = mesMaterialKcLogService;
+		this.mesMaterialKwService = mesMaterialKwService;
+		this.mesMaterialCkService = mesMaterialCkService;
+	}
+
+	/**
+	 * MES 提交 ERP 领料 Save 成功后调用。
+	 */
+	@Transactional
+	public MesErpPickKcSyncResult increaseFromSubmit(String pickBillNo, List<SubmitLine> lines) {
+		MesErpPickKcSyncResult result = new MesErpPickKcSyncResult();
+		if (StringUtils.isBlank(pickBillNo) || lines == null || lines.isEmpty()) {
+			return result;
+		}
+		String kwId = resolveMesKwId();
+		for (SubmitLine line : lines) {
+			if (line == null || line.getMaterial() == null) {
+				continue;
+			}
+			ErpProductionMaterial material = line.getMaterial();
+			String lineKey = firstNonBlank(material.getId(), material.getSeq(), material.getMaterialNumber());
+			increaseOne(result, kwId, pickBillNo.trim(), lineKey, material.getBillNo(),
+					material.getMaterialNumber(), material.getMaterialName(), material.getSpecification(),
+					line.getPickQty());
+		}
+		logger.info("ERP pick submit synced to MES kc, pickBillNo={}, materialCreated={}, kcCreated={}",
+				pickBillNo, result.getMaterialCreatedCount(), result.getKcCreatedCount());
+		return result;
+	}
+
+	/**
+	 * 从 ERP 领料单明细补同步(幂等,已同步分录跳过)。
+	 */
+	@Transactional
+	public MesErpPickKcSyncResult increaseFromBillDetails(String pickBillNo, List<ErpPickMaterialBill> details) {
+		MesErpPickKcSyncResult result = new MesErpPickKcSyncResult();
+		if (StringUtils.isBlank(pickBillNo) || details == null || details.isEmpty()) {
+			return result;
+		}
+		String kwId = resolveMesKwId();
+		for (ErpPickMaterialBill detail : details) {
+			if (detail == null || StringUtils.isBlank(detail.getMaterialNumber())) {
+				continue;
+			}
+			BigDecimal qty = detail.getActualQty() != null ? detail.getActualQty() : detail.getAppQty();
+			if (qty == null || qty.compareTo(BigDecimal.ZERO) <= 0) {
+				continue;
+			}
+			String lineKey = firstNonBlank(detail.getEntryId(), detail.getSeq(), detail.getMaterialNumber());
+			increaseOne(result, kwId, pickBillNo.trim(), lineKey, detail.getMoBillNo(),
+					detail.getMaterialNumber(), detail.getMaterialName(), "",
+					qty);
+		}
+		if (result.getKcCreatedCount() > 0) {
+			logger.info("ERP pick bill detail synced to MES kc, pickBillNo={}, kcCreated={}",
+					pickBillNo, result.getKcCreatedCount());
+		}
+		return result;
+	}
+
+	private void increaseOne(MesErpPickKcSyncResult result, String kwId, String pickBillNo, String lineKey,
+			String moBillNo, String materialNumber, String materialName, String specification, BigDecimal qty) {
+		if (StringUtils.isBlank(materialNumber) || qty == null || qty.compareTo(BigDecimal.ZERO) <= 0) {
+			return;
+		}
+		String erpSn = materialNumber.trim();
+		String pickLineKey = StringUtils.isNotBlank(lineKey) ? lineKey.trim() : erpSn;
+		if (mesMaterialKcLogService.findByErpPickLine(pickBillNo, pickLineKey) != null) {
+			return;
+		}
+		MesMaterial existing = mesMaterialService.findByErpSn(erpSn);
+		MesMaterial mesMaterial = mesMaterialService.ensureFromErp(erpSn, materialName, specification);
+		if (existing == null && mesMaterial != null) {
+			result.addMaterialCreated();
+		}
+		if (mesMaterial == null || StringUtils.isBlank(mesMaterial.getId())) {
+			logger.warn("ERP pick kc skip: mes material missing, erpSn={}", erpSn);
+			return;
+		}
+		long nums = qty.setScale(0, RoundingMode.HALF_UP).longValue();
+		if (nums <= 0) {
+			return;
+		}
+		MesMaterialKcLog kcLog = new MesMaterialKcLog();
+		kcLog.setMaterialId(mesMaterial.getId());
+		kcLog.setKwId(kwId);
+		kcLog.setBatchSn(pickBillNo + "-" + pickLineKey);
+		kcLog.setNums(nums);
+		kcLog.setExpireDate(DEFAULT_EXPIRE_DATE);
+		kcLog.setSourceType(SOURCE_TYPE_ERP_PICK);
+		kcLog.setErpPickBillNo(pickBillNo);
+		kcLog.setErpPickLineKey(pickLineKey);
+		kcLog.setErpMoBillNo(StringUtils.trim(moBillNo));
+		kcLog.setRemark("ERP生产领料入库");
+		mesMaterialKcLogService.save(kcLog);
+		result.addKcCreated();
+	}
+
+	private String resolveMesKwId() {
+		if (StringUtils.isNotBlank(erpProperties.getPickMtrlMesKwId())) {
+			MesMaterialKw kw = mesMaterialKwService.get(new MesMaterialKw(erpProperties.getPickMtrlMesKwId().trim()));
+			if (kw != null && StringUtils.isNotBlank(kw.getId())) {
+				return kw.getId();
+			}
+			logger.warn("ERP pick kc: configured mes-kw-id not found, id={}", erpProperties.getPickMtrlMesKwId());
+		}
+		String kwTitle = StringUtils.defaultIfBlank(erpProperties.getPickMtrlMesKwTitle(), "ERP线边库").trim();
+		MesMaterialKw query = new MesMaterialKw();
+		query.setTitle(kwTitle);
+		List<MesMaterialKw> kwList = mesMaterialKwService.findList(query);
+		if (kwList != null && !kwList.isEmpty() && StringUtils.isNotBlank(kwList.get(0).getId())) {
+			return kwList.get(0).getId();
+		}
+		return createDefaultKw(kwTitle);
+	}
+
+	private String createDefaultKw(String kwTitle) {
+		String ckName = StringUtils.defaultIfBlank(erpProperties.getPickMtrlMesCkName(), "ERP线边仓").trim();
+		MesMaterialCk ckQuery = new MesMaterialCk();
+		ckQuery.setCkName(ckName);
+		List<MesMaterialCk> ckList = mesMaterialCkService.findList(ckQuery);
+		String ckId;
+		if (ckList != null && !ckList.isEmpty() && StringUtils.isNotBlank(ckList.get(0).getId())) {
+			ckId = ckList.get(0).getId();
+		} else {
+			MesMaterialCk ck = new MesMaterialCk();
+			ck.setCkName(ckName);
+			mesMaterialCkService.save(ck);
+			ckId = ck.getId();
+		}
+		MesMaterialKw kw = new MesMaterialKw();
+		kw.setTitle(kwTitle);
+		kw.setCkId(ckId);
+		mesMaterialKwService.save(kw);
+		logger.info("ERP pick kc: auto created kw={}, ck={}", kwTitle, ckName);
+		return kw.getId();
+	}
+
+	private String firstNonBlank(String... values) {
+		for (String value : values) {
+			if (StringUtils.isNotBlank(value)) {
+				return value.trim();
+			}
+		}
+		return "";
+	}
+
+	/** 提交领料时的分录上下文 */
+	public static class SubmitLine {
+		private final ErpProductionMaterial material;
+		private final BigDecimal pickQty;
+
+		public SubmitLine(ErpProductionMaterial material, BigDecimal pickQty) {
+			this.material = material;
+			this.pickQty = pickQty;
+		}
+
+		public ErpProductionMaterial getMaterial() {
+			return material;
+		}
+
+		public BigDecimal getPickQty() {
+			return pickQty;
+		}
+	}
+}

+ 38 - 0
src/main/java/com/jeesite/modules/mes/erp/MesErpPickKcSyncResult.java

@@ -0,0 +1,38 @@
+package com.jeesite.modules.mes.erp;
+
+import java.io.Serializable;
+
+/**
+ * ERP 领料同步 MES 物料/库存结果。
+ */
+public class MesErpPickKcSyncResult implements Serializable {
+
+	private static final long serialVersionUID = 1L;
+
+	private int materialCreatedCount;
+	private int kcCreatedCount;
+
+	public int getMaterialCreatedCount() {
+		return materialCreatedCount;
+	}
+
+	public void setMaterialCreatedCount(int materialCreatedCount) {
+		this.materialCreatedCount = materialCreatedCount;
+	}
+
+	public int getKcCreatedCount() {
+		return kcCreatedCount;
+	}
+
+	public void setKcCreatedCount(int kcCreatedCount) {
+		this.kcCreatedCount = kcCreatedCount;
+	}
+
+	public void addMaterialCreated() {
+		materialCreatedCount++;
+	}
+
+	public void addKcCreated() {
+		kcCreatedCount++;
+	}
+}

+ 36 - 0
src/main/java/com/jeesite/modules/mes/service/MesMaterialKcLogService.java

@@ -8,6 +8,8 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import com.jeesite.common.entity.Page;
+import com.jeesite.common.lang.StringUtils;
+import com.jeesite.common.mybatis.mapper.query.QueryType;
 import com.jeesite.common.service.CrudService;
 import com.jeesite.modules.mes.entity.MesMaterialKcLog;
 import com.jeesite.modules.mes.dao.MesMaterialKcLogDao;
@@ -37,6 +39,16 @@ public class MesMaterialKcLogService extends CrudService<MesMaterialKcLogDao, Me
 		return mesMaterialKcLogDao.findInfo(mesMaterialKcLog);
 	}
 
+	public MesMaterialKcLog findByErpPickLine(String pickBillNo, String lineKey) {
+		if (StringUtils.isBlank(pickBillNo) || StringUtils.isBlank(lineKey)) {
+			return null;
+		}
+		MesMaterialKcLog query = new MesMaterialKcLog();
+		query.getSqlMap().getWhere().and("erp_pick_bill_no", QueryType.EQ, pickBillNo.trim());
+		query.getSqlMap().getWhere().and("erp_pick_line_key", QueryType.EQ, lineKey.trim());
+		return mesMaterialKcLogDao.findInfo(query);
+	}
+
 	/**
 	 * 查询分页数据
 	 * @param mesMaterialKcLog 查询条件
@@ -45,8 +57,32 @@ public class MesMaterialKcLogService extends CrudService<MesMaterialKcLogDao, Me
 	 */
 	@Override
 	public Page<MesMaterialKcLog> findPage(MesMaterialKcLog mesMaterialKcLog) {
+		applySourceTypeFilter(mesMaterialKcLog);
 		return super.findPage(mesMaterialKcLog);
 	}
+
+	/**
+	 * 来源筛选:ERP_PICK 按 source_type;RK 按有入库单关联的历史来料记录。
+	 */
+	private void applySourceTypeFilter(MesMaterialKcLog mesMaterialKcLog) {
+		if (mesMaterialKcLog == null) {
+			return;
+		}
+		String sourceType = mesMaterialKcLog.getSourceType();
+		if (StringUtils.isBlank(sourceType)) {
+			return;
+		}
+		if (MesMaterialKcLog.SOURCE_TYPE_RK.equals(sourceType)) {
+			mesMaterialKcLog.setSourceType(null);
+			mesMaterialKcLog.getSqlMap().getWhere().and("rk_record_id", QueryType.IS_NOT_NULL, null);
+			mesMaterialKcLog.getSqlMap().getWhere().and("rk_record_id", QueryType.NE, "");
+			return;
+		}
+		if (MesMaterialKcLog.SOURCE_TYPE_ERP_PICK.equals(sourceType)) {
+			mesMaterialKcLog.getSqlMap().getWhere().and("source_type", QueryType.EQ, sourceType);
+			mesMaterialKcLog.setSourceType(null);
+		}
+	}
 	
 	/**
 	 * 查询列表数据

+ 40 - 0
src/main/java/com/jeesite/modules/mes/service/MesMaterialService.java

@@ -2,6 +2,7 @@ package com.jeesite.modules.mes.service;
 
 import java.util.List;
 
+import com.jeesite.common.lang.StringUtils;
 import com.jeesite.modules.mes.dao.MesDeviceDao;
 import com.jeesite.modules.mes.entity.MesDevice;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -9,6 +10,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import com.jeesite.common.entity.Page;
+import com.jeesite.common.mybatis.mapper.query.QueryType;
 import com.jeesite.common.service.CrudService;
 import com.jeesite.modules.mes.entity.MesMaterial;
 import com.jeesite.modules.mes.dao.MesMaterialDao;
@@ -39,6 +41,44 @@ public class MesMaterialService extends CrudService<MesMaterialDao, MesMaterial>
 	}
 
 	/**
+	 * 按 ERP 物料编码查找物料。
+	 */
+	public MesMaterial findByErpSn(String erpSn) {
+		if (StringUtils.isBlank(erpSn)) {
+			return null;
+		}
+		MesMaterial query = new MesMaterial();
+		query.getSqlMap().getWhere().and("erp_sn", QueryType.EQ, erpSn.trim());
+		return mesMaterialDao.findInfo(query);
+	}
+
+	/**
+	 * 领料等业务:ERP 物料不存在于 mes_material 时自动建档。
+	 *
+	 * @return 已有或新建的物料;erpSn 为空时返回 null
+	 */
+	@Transactional
+	public MesMaterial ensureFromErp(String erpSn, String title, String spec) {
+		if (StringUtils.isBlank(erpSn)) {
+			return null;
+		}
+		String erpCode = erpSn.trim();
+		MesMaterial existing = findByErpSn(erpCode);
+		if (existing != null) {
+			return existing;
+		}
+		MesMaterial material = new MesMaterial();
+		material.setErpSn(erpCode);
+		material.setTitle(StringUtils.isNotBlank(title) ? title.trim() : erpCode);
+		material.setSpec(StringUtils.isNotBlank(spec) ? spec.trim() : "");
+		material.setAging(0);
+		material.setStatus(MesMaterial.STATUS_NORMAL);
+		material.setRemark("ERP领料同步");
+		super.save(material);
+		return material;
+	}
+
+	/**
 	 * 查询分页数据
 	 * @param mesMaterial 查询条件
 	 * @param mesMaterial.page 分页对象

+ 79 - 2
src/main/java/com/jeesite/modules/mes/web/ErpPickMaterialBillController.java

@@ -2,7 +2,10 @@ package com.jeesite.modules.mes.web;
 
 import com.jeesite.common.config.Global;
 import com.jeesite.common.entity.Page;
+import com.jeesite.common.lang.StringUtils;
 import com.jeesite.common.web.BaseController;
+import com.jeesite.modules.mes.erp.ErpInventoryLotOption;
+import com.jeesite.modules.mes.erp.ErpInventoryLotService;
 import com.jeesite.modules.mes.erp.ErpPickMaterialBill;
 import com.jeesite.modules.mes.erp.ErpPickMaterialBillService;
 import com.jeesite.modules.mes.erp.ErpPickMaterialResult;
@@ -15,6 +18,7 @@ import com.jeesite.modules.mes.erp.ErpProductionOrderService;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
 import org.springframework.stereotype.Controller;
 import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -22,7 +26,10 @@ import org.springframework.web.bind.annotation.ResponseBody;
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import java.math.BigDecimal;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * ERP 生产领料单列表与新增领料。
@@ -35,15 +42,18 @@ public class ErpPickMaterialBillController extends BaseController {
 	private final ErpProductionOrderService erpProductionOrderService;
 	private final ErpProductionMaterialService erpProductionMaterialService;
 	private final ErpPickMaterialService erpPickMaterialService;
+	private final ErpInventoryLotService erpInventoryLotService;
 
 	public ErpPickMaterialBillController(ErpPickMaterialBillService erpPickMaterialBillService,
 			ErpProductionOrderService erpProductionOrderService,
 			ErpProductionMaterialService erpProductionMaterialService,
-			ErpPickMaterialService erpPickMaterialService) {
+			ErpPickMaterialService erpPickMaterialService,
+			ErpInventoryLotService erpInventoryLotService) {
 		this.erpPickMaterialBillService = erpPickMaterialBillService;
 		this.erpProductionOrderService = erpProductionOrderService;
 		this.erpProductionMaterialService = erpProductionMaterialService;
 		this.erpPickMaterialService = erpPickMaterialService;
+		this.erpInventoryLotService = erpInventoryLotService;
 	}
 
 	@RequiresPermissions("mes:erpPickMaterialBill:view")
@@ -69,6 +79,25 @@ public class ErpPickMaterialBillController extends BaseController {
 	}
 
 	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@RequestMapping(value = "detail")
+	public String detail(ErpPickMaterialBill erpPickMaterialBill, Model model) {
+		model.addAttribute("erpPickMaterialBill", erpPickMaterialBill);
+		return "modules/mes/erpPickMaterialBillDetail";
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@RequestMapping(value = "detailData")
+	@ResponseBody
+	public Page<ErpPickMaterialBill> detailData(ErpPickMaterialBill erpPickMaterialBill,
+			HttpServletRequest request, HttpServletResponse response) {
+		Page<ErpPickMaterialBill> page = new Page<>(request, response);
+		List<ErpPickMaterialBill> list = erpPickMaterialBillService.findDetailList(erpPickMaterialBill.getBillNo());
+		page.setList(list);
+		page.setCount(list.size());
+		return page;
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
 	@RequestMapping(value = "selectOrder")
 	public String selectOrder(ErpProductionOrder erpProductionOrder, Model model) {
 		model.addAttribute("erpProductionOrder", erpProductionOrder);
@@ -124,18 +153,66 @@ public class ErpPickMaterialBillController extends BaseController {
 			HttpServletRequest request, HttpServletResponse response) {
 		Page<ErpProductionMaterial> page = new Page<>(request, response);
 		List<ErpProductionMaterial> list = erpProductionMaterialService.findByProductionOrderEntry(
-				erpProductionOrder.getBillNo(), erpProductionOrder.getEntryId());
+				erpProductionOrder.getBillNo(), erpProductionOrder.getEntryId(), true);
 		page.setList(list);
 		page.setCount(list.size());
 		return page;
 	}
 
 	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@GetMapping(value = "lotOptions")
+	@ResponseBody
+	public Map<String, Object> lotOptions(String materialNumber, String stockNumber, String stockName,
+			String stockStatusNumber, String stockOrgNumber, BigDecimal pickQty) {
+		List<ErpInventoryLotOption> lots = erpInventoryLotService.findAvailableLots(
+				materialNumber, stockNumber, stockStatusNumber, stockOrgNumber);
+		String suggested = erpInventoryLotService.suggestLotNumber(lots, pickQty, stockNumber);
+		Map<String, Object> result = new HashMap<>(4);
+		result.put("lots", lots);
+		result.put("suggestedLotNumber", suggested);
+		result.put("lotPickable", !lots.isEmpty());
+		if (lots.isEmpty()) {
+			result.put("lotHint", erpInventoryLotService.diagnoseLotShortage(
+					materialNumber, stockNumber, stockName, stockStatusNumber, stockOrgNumber));
+		} else {
+			result.put("lotHint", erpInventoryLotService.buildLotHint(lots, suggested, pickQty, stockNumber));
+		}
+		return result;
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@GetMapping(value = "lotSearch")
+	@ResponseBody
+	public Map<String, Object> lotSearch(String materialNumber, String stockNumber, String stockName,
+			String stockStatusNumber, String stockOrgNumber, String lotKeyword) {
+		List<ErpInventoryLotOption> lots = erpInventoryLotService.searchInventoryLots(
+				materialNumber, stockNumber, stockStatusNumber, stockOrgNumber, lotKeyword);
+		Map<String, Object> result = new HashMap<>(3);
+		result.put("lots", lots);
+		if (lots.isEmpty()) {
+			String diagnostic = erpInventoryLotService.getLastQueryDiagnostic();
+			result.put("lotHint", StringUtils.isNotBlank(diagnostic) ? diagnostic : "未查到符合条件的批号库存");
+		} else {
+			result.put("lotHint", "共查到 " + lots.size() + " 条");
+		}
+		return result;
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
 	@PostMapping(value = "submitPickMaterial")
 	@ResponseBody
 	public String submitPickMaterial(@RequestBody ErpPickMaterialSubmitRequest request) {
 		ErpPickMaterialResult result = erpPickMaterialService.submit(request);
 		if (result.isSuccess()) {
+			if (result.isKcSyncFailed()) {
+				return renderResult(Global.TRUE, text("领料单已保存,单号:{0};MES库存同步失败,请打开领料单明细重试",
+						result.getPickBillNo()));
+			}
+			if (result.getSyncedKcCount() > 0 || result.getSyncedMaterialCount() > 0) {
+				return renderResult(Global.TRUE, text("领料单保存成功,单号:{0};新增物料 {1} 个,入库 {2} 行",
+						result.getPickBillNo(), String.valueOf(result.getSyncedMaterialCount()),
+						String.valueOf(result.getSyncedKcCount())));
+			}
 			return renderResult(Global.TRUE, text("领料单保存成功,单号:{0}", result.getPickBillNo()));
 		}
 		return renderResult(Global.FALSE, result.getMessage());

+ 40 - 2
src/main/java/com/jeesite/modules/mes/web/MesDeviceController.java

@@ -578,8 +578,18 @@ public class MesDeviceController extends BaseController {
 			card.put("stateName", getDeviceStateName(state));
 			card.put("openRate", Math.round(openRate * 10D) / 10D);
 			card.put("openMinutes", Math.round(runningSeconds / 60D));
-			card.put("lastReportTime", latestState != null && latestState.getStartDate() != null ? DateUtils.formatDate(latestState.getStartDate(), "HH:mm:ss") : "-");
-			card.put("lastReportDate", latestState != null && latestState.getStartDate() != null ? DateUtils.formatDate(latestState.getStartDate(), "yyyy-MM-dd HH:mm:ss") : "-");
+			String lastReportDate = "-";
+			if (latestState != null && latestState.getStartDate() != null) {
+				lastReportDate = DateUtils.formatDate(latestState.getStartDate(), "yyyy-MM-dd HH:mm:ss");
+			} else {
+				Date workTime = parseWorkTime(device.getWorkTime());
+				if (workTime != null) {
+					lastReportDate = DateUtils.formatDate(workTime, "yyyy-MM-dd HH:mm:ss");
+				}
+			}
+			card.put("lastReportTime", lastReportDate);
+			card.put("lastReportDate", lastReportDate);
+			card.put("reportContent", getDeviceReportContent(latestState, state, statusCode));
 			card.put("imageIndex", i % 8 + 1);
 			deviceCards.add(card);
 
@@ -712,6 +722,34 @@ public class MesDeviceController extends BaseController {
 		return "未知";
 	}
 
+	private String getDeviceReportContent(MesDeviceState latestState, String state, String statusCode) {
+		if (latestState != null && StringUtils.isNotBlank(latestState.getContent())) {
+			return latestState.getContent().trim();
+		}
+		if ("offline".equals(statusCode)) {
+			return "设备离线";
+		}
+		if ("1".equals(state)) {
+			return "开机运行正常";
+		}
+		if ("2".equals(state)) {
+			return "手动运行中";
+		}
+		if ("3".equals(state)) {
+			return "设备故障";
+		}
+		if ("4".equals(state)) {
+			return "设备维修中";
+		}
+		if ("5".equals(state)) {
+			return "质量报警";
+		}
+		if ("6".equals(state)) {
+			return "物流堵料";
+		}
+		return "暂无上报信息";
+	}
+
 	private String getDeviceStateName(String state) {
 		if ("1".equals(state)) {
 			return "正常";

+ 10 - 2
src/main/resources/config/application.yml

@@ -37,9 +37,13 @@ adw300:
 erp:
   k3cloud:
     base-url: https://erp.anhuidayang.com:8800/k3cloud
-    acct-id: 672630265043d5
+#    生产环境
+#    acct-id: 672630265043d5
+#    测试环境
+    acct-id: 6a239b82159033
     username: 经博
-    password: JingBo@030323
+#    password: JingBo@030323
+    password: dy123456.
     org-number: 110
     filter-current-org: true
     inventory-category-filter-enabled: true
@@ -54,6 +58,10 @@ erp:
       keeper-type: BD_KeeperOrg
       link-rule: PRD_PPBOM2PICKMTRL_NORMAL
       link-table: T_PRD_PPBOMENTRY
+      # ERP 领料成功后 MES 线边库存入库库位(mes-kw-id 优先,否则按名称查找/自动创建)
+      mes-kw-id: 2057716801398648832
+      mes-kw-title: ERP线边库
+      mes-ck-name: ERP线边仓
     # 采购订单 PUR_PurchaseOrder,标准采购订单单据类型
     purchase-order:
       bill-type: CGDD01_SYS

+ 41 - 0
src/main/resources/views/modules/mes/erpPickMaterialBillDetail.html

@@ -0,0 +1,41 @@
+<% layout('/layouts/default.html', {title: 'ERP生产领料单明细', libs: ['dataGrid']}){ %>
+<div class="main-content">
+	<div class="box box-main">
+		<div class="box-header">
+			<div class="box-title">
+				<i class="fa fa-list"></i> ${text('ERP生产领料单明细')}:${erpPickMaterialBill.billNo}
+			</div>
+			<div class="box-tools pull-right">
+				<a href="${ctx}/mes/erpPickMaterialBill/list" class="btn btn-default" title="${text('返回')}"><i class="fa fa-mail-reply"></i> ${text('返回')}</a>
+				<a href="#" class="btn btn-default" id="btnSetting" title="${text('设置')}"><i class="fa fa-navicon"></i></a>
+			</div>
+		</div>
+		<div class="box-body">
+			<#form:form id="searchForm" model="${erpPickMaterialBill}" action="${ctx}/mes/erpPickMaterialBill/detailData" method="post" class="form-inline hide"
+					data-page-no="${parameter.pageNo}" data-page-size="${parameter.pageSize}" data-order-by="${parameter.orderBy}">
+				<#form:hidden path="billNo"/>
+			</#form:form>
+			<table id="dataGrid"></table>
+			<div id="dataGridPage"></div>
+		</div>
+	</div>
+</div>
+<% } %>
+<script>
+$('#dataGrid').dataGrid({
+	searchForm: $("#searchForm"),
+	columnModel: [
+		{header:'${text("行号")}', name:'seq', index:'seq', width:70, align:"center", frozen:true},
+		{header:'${text("生产订单")}', name:'moBillNo', index:'moBillNo', width:160, align:"center", frozen:true},
+		{header:'${text("物料编码")}', name:'materialNumber', index:'materialNumber', width:150, align:"left"},
+		{header:'${text("物料名称")}', name:'materialName', index:'materialName', width:160, align:"left"},
+		{header:'${text("单位")}', name:'unitName', index:'unitName', width:70, align:"center"},
+		{header:'${text("申请数量")}', name:'appQty', index:'appQty', width:90, align:"right"},
+		{header:'${text("实发数量")}', name:'actualQty', index:'actualQty', width:90, align:"right"},
+		{header:'${text("用料清单")}', name:'ppbomBillNo', index:'ppbomBillNo', width:140, align:"center"},
+		{header:'${text("车间")}', name:'workshopName', index:'workshopName', width:120, align:"left"},
+		{header:'${text("仓库")}', name:'stockName', index:'stockName', width:120, align:"left"}
+	],
+	ajaxSuccess: function(){}
+});
+</script>

+ 8 - 13
src/main/resources/views/modules/mes/erpPickMaterialBillList.html

@@ -96,7 +96,11 @@ $('#dataGrid').dataGrid({
 
 	columnModel: [
 
-		{header:'${text("领料单号")}', name:'billNo', index:'billNo', width:170, align:"center", frozen:true},
+		{header:'${text("领料单号")}', name:'billNo', index:'billNo', width:170, align:"center", frozen:true, formatter: function(val){
+
+			return '<a href="${ctx}/mes/erpPickMaterialBill/detail?billNo='+encodeURIComponent(val)+'" class="addTabPage" data-title="${text("ERP生产领料单明细")}">'+val+'</a>';
+
+		}},
 
 		{header:'${text("生产订单")}', name:'moBillNo', index:'moBillNo', width:160, align:"center", frozen:true},
 
@@ -110,19 +114,11 @@ $('#dataGrid').dataGrid({
 
 		{header:'${text("车间")}', name:'workshopName', index:'workshopName', width:120, align:"left"},
 
-		{header:'${text("物料编码")}', name:'materialNumber', index:'materialNumber', width:150, align:"left"},
-
-		{header:'${text("物料名称")}', name:'materialName', index:'materialName', width:160, align:"left"},
-
-		{header:'${text("单位")}', name:'unitName', index:'unitName', width:70, align:"center"},
+		{header:'${text("明细行数")}', name:'entryCount', index:'entryCount', width:90, align:"right"},
 
-		{header:'${text("申请数量")}', name:'appQty', index:'appQty', width:90, align:"right"},
+		{header:'${text("申请总数量")}', name:'totalAppQty', index:'totalAppQty', width:100, align:"right"},
 
-		{header:'${text("实发数量")}', name:'actualQty', index:'actualQty', width:90, align:"right"},
-
-		{header:'${text("用料清单")}', name:'ppbomBillNo', index:'ppbomBillNo', width:140, align:"center"},
-
-		{header:'${text("仓库")}', name:'stockName', index:'stockName', width:120, align:"left"}
+		{header:'${text("实发总数量")}', name:'totalActualQty', index:'totalActualQty', width:100, align:"right"}
 
 	],
 
@@ -131,4 +127,3 @@ $('#dataGrid').dataGrid({
 });
 
 </script>
-

+ 669 - 9
src/main/resources/views/modules/mes/erpPickMaterialBillMaterial.html

@@ -1,102 +1,762 @@
 <% layout('/layouts/default.html', {title: '生产用料清单', libs: ['dataGrid']}){ %>
+
 <div class="main-content">
+
 	<div class="box box-main">
+
 		<div class="box-header">
+
 			<div class="box-title">
+
 				<i class="fa fa-list-ul"></i> ${text('增加领料单 - 生产用料清单')}:${erpProductionOrder.billNo}
+
 			</div>
+
 			<div class="box-tools pull-right">
+
 				<a href="javascript:void(0)" class="btn btn-primary" id="btnSubmitPick"><i class="fa fa-check"></i> ${text('提交领料')}</a>
+
 				<a href="${ctx}/mes/erpPickMaterialBill/selectDetail?billNo=${erpProductionOrder.billNo!}" class="btn btn-default addTabPage" title="${text('返回')}"><i class="fa fa-mail-reply"></i> ${text('返回')}</a>
+
 				<a href="#" class="btn btn-default" id="btnSetting" title="${text('设置')}"><i class="fa fa-navicon"></i></a>
+
 			</div>
+
 		</div>
-		<div class="box-body">
+
+		<div class="box-body pick-material-grid-wrap">
+
+			<div class="alert alert-info" style="margin-bottom:10px;padding:8px 12px;">
+
+				<i class="fa fa-info-circle"></i>
+
+				${text('启用批号管理的物料,系统查询 ERP 即时库存批号;发料仓无库存时会查组织内其他仓库。请点击「选批号」弹窗选择(与 ERP 即时库存一致)。')}
+
+			</div>
+
 			<#form:form id="searchForm" model="${erpProductionOrder}" action="${ctx}/mes/erpPickMaterialBill/materialData" method="post" class="form-inline hide"
+
 					data-page-no="${parameter.pageNo}" data-page-size="${parameter.pageSize}" data-order-by="${parameter.orderBy}">
+
 				<#form:hidden path="billNo"/>
+
 				<#form:hidden path="entryId"/>
+
 			</#form:form>
+
 			<table id="dataGrid"></table>
+
 			<div id="dataGridPage"></div>
+
 		</div>
+
 	</div>
+
 </div>
+
 <% } %>
+<style>
+.pick-material-grid-wrap { overflow-x: auto; }
+.pick-row-finished td { background-color: #fafafa !important; color: #999; }
+.pick-row-finished .pick-qty-input { background-color: #eee; }
+.pick-lot-wrap { min-width: 200px; line-height: 1.4; }
+.pick-lot-wrap .pick-lot-actions { margin-bottom: 4px; }
+.pick-lot-wrap .pick-lot-display {
+	display: block;
+	font-size: 11px;
+	color: #666;
+	max-width: 240px;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	cursor: pointer;
+}
+.pick-lot-wrap .pick-lot-display:hover { color: #337ab7; text-decoration: underline; }
+.pick-lot-wrap .pick-lot-select { width: 100%; max-width: 240px; margin-top: 4px; }
+.pick-lot-hint-text {
+	display: block;
+	max-width: 220px;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	cursor: help;
+}
+.pick-qty-hint-text {
+	display: block;
+	max-width: 240px;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	cursor: help;
+}
+.lot-search-table-wrap { overflow: auto; max-height: 340px; }
+.lot-search-table-wrap table { min-width: 620px; margin: 0; font-size: 12px; }
+.lot-search-table-wrap .lot-search-pick { min-width: 48px; }
+</style>
 <script>
+
 var billNo = '${erpProductionOrder.billNo!}';
+
 var entryId = '${erpProductionOrder.entryId!}';
 
+var lotOptionsUrl = '${ctx}/mes/erpPickMaterialBill/lotOptions';
+var lotSearchUrl = '${ctx}/mes/erpPickMaterialBill/lotSearch';
+
+function escapeHtml(text) {
+	if (text == null) return '';
+	return String(text).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+}
+
+function readLotQtyText(lot) {
+	if (!lot) return '0';
+	if (lot.qtyText != null && lot.qtyText !== '') return String(lot.qtyText);
+	var q = lot.qty;
+	if (q == null || q === '') return '0';
+	if (typeof q === 'number') return String(q);
+	if (typeof q === 'string') return q;
+	return String(q);
+}
+
+function formatLotOptionLabel(lot) {
+	var lotNo = lot.lotNumber || '';
+	var qty = readLotQtyText(lot);
+	var stockLabel = lot.stockName || lot.stockNumber || '';
+	return lotNo + ' (可用:' + qty + (stockLabel ? ' ' + stockLabel : '') + ')';
+}
+
+function readRowLotNumber(rowOrId) {
+	var id = (typeof rowOrId === 'object') ? rowOrId.id : rowOrId;
+	var hidden = $('.pick-lot-value[data-id="'+id+'"]').val();
+	if (hidden && $.trim(hidden) !== '') {
+		return $.trim(hidden);
+	}
+	var $select = $('.pick-lot-select[data-id="'+id+'"]');
+	if ($select.length > 0) {
+		var selected = $.trim($select.val() || '');
+		if (selected !== '') {
+			return selected;
+		}
+	}
+	if (typeof rowOrId === 'object') {
+		if (rowOrId.pickStatusLabel === '无库存') {
+			return '';
+		}
+		return rowOrId.lotNumber || rowOrId.suggestedLotNumber || '';
+	}
+	return '';
+}
+
+function isRowLotBlocked(row) {
+	if (row.pickStatusLabel === '已领完') {
+		return true;
+	}
+	if (!row.batchManage) {
+		return false;
+	}
+	if (readRowLotNumber(row)) {
+		return false;
+	}
+	if (row.availableLots && row.availableLots.length > 0) {
+		return false;
+	}
+	return row.pickStatusLabel === '待选批号' || row.lotPickable === false;
+}
+
+function isRowPickable(row) {
+	if (!row.noPickedQty || row.noPickedQty <= 0) {
+		return false;
+	}
+	if (row.canPickQty != null && row.canPickQty <= 0) {
+		return false;
+	}
+	if (row.pickStatusLabel === '无库存' || row.pickStatusLabel === '已领完' || row.pickStatusLabel === '不可领') {
+		return false;
+	}
+	if (!row.batchManage) {
+		return true;
+	}
+	if (readRowLotNumber(row)) {
+		return true;
+	}
+	if (row.availableLots && row.availableLots.length > 0) {
+		return true;
+	}
+	return false;
+}
+
+function formatPickStatus(val, obj, row) {
+	var label = row.pickStatusLabel || (isRowPickable(row) ? '可领料' : '待选批号');
+	if (readRowLotNumber(row)) {
+		label = '可领料';
+	}
+	if (label === '可领料') {
+		return '<span class="text-success pick-status" data-id="'+row.id+'">可领料</span>';
+	}
+	if (label === '已领完') {
+		return '<span class="text-muted pick-status" data-id="'+row.id+'">已领完</span>';
+	}
+	if (label === '无库存') {
+		return '<span class="text-danger pick-status" data-id="'+row.id+'">无库存</span>';
+	}
+	if (label === '不可领') {
+		return '<span class="text-danger pick-status" data-id="'+row.id+'">不可领</span>';
+	}
+	return '<span class="text-warning pick-status" data-id="'+row.id+'">待选批号</span>';
+}
+
+function formatStockStatus(val, obj, row) {
+	if (row.pickStatusLabel === '无库存') {
+		return '<span class="text-danger" title="'+escapeHtml(row.lotHint||'')+'">无库存</span>';
+	}
+	if (row.batchManage && !readRowLotNumber(row) && (!row.availableLots || row.availableLots.length === 0)) {
+		return '<span class="text-warning" title="'+escapeHtml(row.lotHint||'')+'">待选批号</span>';
+	}
+	return escapeHtml(val || '-');
+}
+
 function formatPickQty(val, obj, row){
 	var defaultQty = row.noPickedQty == null ? 0 : row.noPickedQty;
+	if (row.canPickQty != null && row.canPickQty > 0 && defaultQty > row.canPickQty) {
+		defaultQty = row.canPickQty;
+	}
 	var inputVal = val == null || val === '' ? defaultQty : val;
+	var pickable = isRowPickable(row);
+	var readonly = pickable ? '' : 'readonly="readonly"';
 	return '<input type="text" class="form-control input-sm pick-qty-input" data-id="'+row.id+'" '+
-		'data-no-picked="'+defaultQty+'" data-material="'+(row.materialNumber||'')+'" '+
-		'style="width:90px;text-align:right;" value="'+inputVal+'"/>';
+		'data-no-picked="'+(row.noPickedQty == null ? 0 : row.noPickedQty)+'" '+
+		'data-can-pick="'+(row.canPickQty == null ? '' : row.canPickQty)+'" '+
+		'data-material="'+(row.materialNumber||'')+'" '+
+		'data-stock="'+(row.stockNumber||'')+'" data-stock-status="'+(row.stockStatusNumber||'')+'" '+
+		'data-stock-org="'+(row.stockOrgNumber||'')+'" data-batch-manage="'+(row.batchManage?'1':'0')+'" '+
+		readonly+' style="width:90px;text-align:right;" value="'+inputVal+'"/>';
+}
+
+function formatCanPickQty(val, obj, row) {
+	if (val == null || val === '') {
+		return '<span class="text-muted">-</span>';
+	}
+	var cls = val <= 0 ? 'text-danger' : 'text-success';
+	return '<span class="'+cls+'">'+val+'</span>';
+}
+
+function formatPickQtyHint(val, obj, row) {
+	if (!val) {
+		return '';
+	}
+	var cls = (val.indexOf('可领数量 0') >= 0 || val.indexOf('超过') >= 0) ? 'text-danger' : 'text-muted';
+	return '<span class="'+cls+' pick-qty-hint-text" title="'+escapeHtml(val)+'">'+escapeHtml(val)+'</span>';
 }
 
+function buildLotSelectHtml(row) {
+	if (!row.batchManage) {
+		return '<span class="text-muted">-</span>';
+	}
+	var selected = readRowLotNumber(row) || row.lotNumber || row.suggestedLotNumber || '';
+	var lots = row.availableLots || [];
+	var html = '<div class="pick-lot-wrap">';
+	html += '<input type="hidden" class="pick-lot-value" data-id="'+row.id+'" value="'+escapeHtml(selected)+'"/>';
+	html += '<div class="pick-lot-actions">';
+	html += '<a href="javascript:void(0)" class="btn btn-xs btn-primary pick-lot-open" data-id="'+row.id+'" '+
+		'data-material="'+escapeHtml(row.materialNumber||'')+'" data-material-name="'+escapeHtml(row.materialName||'')+'" '+
+		'data-stock="'+escapeHtml(row.stockNumber||'')+'" data-stock-name="'+escapeHtml(row.stockName||'')+'" '+
+		'data-stock-status="'+escapeHtml(row.stockStatusNumber||'')+'" data-stock-org="'+escapeHtml(row.stockOrgNumber||'')+'">'+
+		'<i class="fa fa-search"></i> 选批号</a>';
+	html += '</div>';
+	if (lots.length > 0) {
+		html += '<select class="form-control input-sm pick-lot-select" data-id="'+row.id+'" '+
+			'data-batch-manage="1" data-material="'+escapeHtml(row.materialNumber||'')+'" '+
+			'data-material-name="'+escapeHtml(row.materialName||'')+'" '+
+			'data-stock="'+escapeHtml(row.stockNumber||'')+'" '+
+			'data-stock-name="'+escapeHtml(row.stockName||'')+'" '+
+			'data-stock-status="'+escapeHtml(row.stockStatusNumber||'')+'" '+
+			'data-stock-org="'+escapeHtml(row.stockOrgNumber||'')+'">';
+		html += '<option value="">-- 下拉快速选择 --</option>';
+		for (var i = 0; i < lots.length; i++) {
+			var lot = lots[i];
+			var lotNo = lot.lotNumber || '';
+			var label = formatLotOptionLabel(lot);
+			var sel = (lotNo === selected) ? ' selected' : '';
+			html += '<option value="'+escapeHtml(lotNo)+'"'+sel+'>'+escapeHtml(label)+'</option>';
+		}
+		html += '</select>';
+	}
+	var displayText = selected ? ('已选: ' + selected)
+		: (row.pickStatusLabel === '无库存' ? 'ERP 无库存,无法选批号' : '未选批号,请点击上方按钮查询');
+	html += '<span class="pick-lot-display" data-id="'+row.id+'" title="'+escapeHtml(selected || '点击查询 ERP 即时库存')+'">'+escapeHtml(displayText)+'</span>';
+	html += '</div>';
+	return html;
+}
+
+function formatLotHint(val, obj, row) {
+	if (!row.batchManage) {
+		return '';
+	}
+	var hint = row.lotHint || '';
+	if (!hint) {
+		return '<span class="text-muted">加载中...</span>';
+	}
+	var cls = (hint.indexOf('少于') >= 0 || hint.indexOf('无批号') >= 0 || hint.indexOf('无该物料') >= 0 || hint.indexOf('无法领料') >= 0) ? 'text-danger'
+		: (hint.indexOf('未查到') >= 0 || hint.indexOf('其他仓库') >= 0 || hint.indexOf('选批号') >= 0 || hint.indexOf('请先') >= 0) ? 'text-warning' : 'text-success';
+	return '<span class="'+cls+' pick-lot-hint pick-lot-hint-text" data-id="'+row.id+'" title="'+escapeHtml(hint)+'">'+escapeHtml(hint)+'</span>';
+}
+
+
+
+function formatLotSelect(val, obj, row) {
+
+	return buildLotSelectHtml(row);
+
+}
+
+
+
+function getPickQtyById(id) {
+
+	var $input = $('.pick-qty-input[data-id="'+id+'"]');
+
+	var pickQty = parseFloat($input.val());
+
+	if (isNaN(pickQty) || pickQty <= 0) {
+
+		pickQty = parseFloat($input.data('no-picked'));
+
+	}
+
+	return isNaN(pickQty) ? 0 : pickQty;
+
+}
+
+
+
+function applyRowPickState(id, pickable, hint) {
+	var $chk = $('.pick-select[data-id="'+id+'"]');
+	var $qty = $('.pick-qty-input[data-id="'+id+'"]');
+	var $tr = $chk.closest('tr.jqgrow');
+	if (pickable) {
+		$chk.prop('disabled', false).prop('checked', true);
+		$qty.prop('readonly', false);
+		$tr.removeClass('pick-row-finished');
+	} else {
+		$chk.prop('disabled', true).prop('checked', false);
+		$qty.prop('readonly', true);
+		var status = $('.pick-status[data-id="'+id+'"]').text();
+		if (status === '已领完') {
+			$tr.addClass('pick-row-finished');
+		} else {
+			$tr.removeClass('pick-row-finished');
+		}
+	}
+	if (hint) {
+		var cls = (hint.indexOf('少于') >= 0) ? 'text-danger pick-lot-hint pick-lot-hint-text'
+			: (hint.indexOf('已选') >= 0 || hint.indexOf('已推荐') >= 0) ? 'text-success pick-lot-hint pick-lot-hint-text' : 'text-warning pick-lot-hint pick-lot-hint-text';
+		$('.pick-lot-hint[data-id="'+id+'"]').attr('class', cls).attr('title', hint).text(hint);
+	}
+}
+
+function applyLotSelection(id, lotNumber, hint) {
+	$('.pick-lot-value[data-id="'+id+'"]').val(lotNumber);
+	var displayText = '已选: ' + lotNumber;
+	$('.pick-lot-display[data-id="'+id+'"]').text(displayText).attr('title', lotNumber);
+	var $select = $('.pick-lot-select[data-id="'+id+'"]');
+	if ($select.length > 0) {
+		if ($select.find('option[value="'+lotNumber.replace(/"/g,'\\"')+'"]').length === 0) {
+			$select.append('<option value="'+escapeHtml(lotNumber)+'">'+escapeHtml(lotNumber)+'</option>');
+		}
+		$select.val(lotNumber);
+	}
+	$('.pick-status[data-id="'+id+'"]').html('<span class="text-success">可领料</span>');
+	applyRowPickState(id, true, hint || ('已选批号 '+lotNumber));
+}
+
+function renderLotSearchTable(lots) {
+	if (!lots || lots.length === 0) {
+		return '<div class="text-muted" style="padding:10px;">未查到批号库存</div>';
+	}
+	var html = '<div class="lot-search-table-wrap"><table class="table table-bordered table-hover table-condensed"><thead><tr>'+
+		'<th style="width:160px;">批号</th><th style="width:180px;">仓库</th><th style="width:90px;">可用量</th><th style="width:70px;">操作</th></tr></thead><tbody>';
+	for (var i = 0; i < lots.length; i++) {
+		var lot = lots[i];
+		var lotNo = lot.lotNumber || '';
+		var qty = readLotQtyText(lot);
+		var stock = lot.stockName || lot.stockNumber || '-';
+		html += '<tr><td>'+escapeHtml(lotNo)+'</td><td title="'+escapeHtml(stock)+'">'+escapeHtml(stock)+'</td>'+
+			'<td align="right">'+escapeHtml(qty)+'</td><td align="center">'+
+			'<button type="button" class="btn btn-xs btn-primary lot-search-pick" data-idx="'+i+'">选择</button></td></tr>';
+	}
+	html += '</tbody></table></div>';
+	return html;
+}
+
+function openLotSearchPopup($btn) {
+	var pickRowId = String($btn.data('id'));
+	var params = {
+		materialNumber: $btn.data('material'),
+		stockNumber: '',
+		stockName: $btn.data('stock-name') || '',
+		stockStatusNumber: $btn.data('stock-status') || '',
+		stockOrgNumber: $btn.data('stock-org') || '',
+		lotKeyword: ''
+	};
+	js.layer.open({
+		type: 1,
+		area: ['820px', '560px'],
+		title: 'ERP 即时库存 - ' + ($btn.data('material-name') || $btn.data('material') || ''),
+		resize: false,
+		scrollbar: true,
+		content: js.template('lotSearchTpl', {
+			materialNumber: params.materialNumber,
+			materialName: $btn.data('material-name') || '',
+			issueStockName: $btn.data('stock-name') || $btn.data('stock') || ''
+		}),
+		success: function(layero, index) {
+			layero = $(layero);
+			var currentLots = [];
+			var pickLot = function(lot) {
+				if (!lot) return;
+				var lotNo = lot.lotNumber || '';
+				if (lotNo === '') return;
+				var stock = lot.stockName || lot.stockNumber || '';
+				var qtyText = readLotQtyText(lot);
+				var hint = '已选批号 ' + lotNo + '(可用 ' + qtyText + (stock ? ',仓库 ' + stock : '') + ')';
+				applyLotSelection(pickRowId, lotNo, hint);
+				js.layer.close(index);
+			};
+			var doSearch = function() {
+				params.lotKeyword = $.trim(layero.find('#lotKeyword').val() || '');
+				params.stockNumber = '';
+				layero.find('#lotSearchResult').html('<div class="text-muted" style="padding:10px;">查询中...</div>');
+				$.getJSON(lotSearchUrl, params, function(data) {
+					currentLots = data.lots || [];
+					layero.find('#lotSearchHint').text(data.lotHint || '');
+					layero.find('#lotSearchResult').html(renderLotSearchTable(currentLots));
+				});
+			};
+			layero.off('click.lotPick').on('click.lotPick', '.lot-search-pick', function(e) {
+				e.preventDefault();
+				e.stopPropagation();
+				var idx = parseInt($(this).attr('data-idx'), 10);
+				if (isNaN(idx)) return;
+				pickLot(currentLots[idx]);
+			});
+			layero.find('#btnLotSearch').off('click').on('click', doSearch);
+			layero.find('#lotKeyword').off('keypress').on('keypress', function(e){ if (e.which === 13) doSearch(); });
+			doSearch();
+		}
+	});
+}
+
+function refreshLotOptions(id) {
+	var $qty = $('.pick-qty-input[data-id="'+id+'"]');
+	if ($qty.length === 0 || $qty.data('batch-manage') !== '1') {
+		return;
+	}
+	var $open = $('.pick-lot-open[data-id="'+id+'"]');
+	var params = {
+		materialNumber: $qty.data('material') || $open.data('material'),
+		stockNumber: $qty.data('stock') || $open.data('stock'),
+		stockName: $open.data('stock-name') || '',
+		stockStatusNumber: $qty.data('stock-status') || $open.data('stock-status'),
+		stockOrgNumber: $qty.data('stock-org') || $open.data('stock-org'),
+		pickQty: getPickQtyById(id)
+	};
+	$.getJSON(lotOptionsUrl, params, function(data){
+		var lots = data.lots || [];
+		var suggested = data.suggestedLotNumber || '';
+		var hint = data.lotHint || '';
+		if (hint) {
+			var cls = (hint.indexOf('少于') >= 0) ? 'text-danger' : 'text-warning';
+			$('.pick-lot-hint[data-id="'+id+'"]').attr('class', cls + ' pick-lot-hint pick-lot-hint-text').attr('title', hint).text(hint);
+		}
+		if (lots.length === 0) {
+			$('.pick-status[data-id="'+id+'"]').html('<span class="text-danger pick-status">无库存</span>');
+			applyRowPickState(id, false, hint);
+			return;
+		}
+		applyRowPickState(id, true, hint);
+		var $select = $('.pick-lot-select[data-id="'+id+'"]');
+		if ($select.length === 0) {
+			var rowHtml = buildLotSelectHtml({
+				id: id,
+				batchManage: true,
+				materialNumber: params.materialNumber,
+				materialName: $open.data('material-name') || '',
+				stockNumber: params.stockNumber,
+				stockName: params.stockName,
+				stockStatusNumber: params.stockStatusNumber,
+				stockOrgNumber: params.stockOrgNumber,
+				availableLots: lots,
+				lotNumber: suggested,
+				suggestedLotNumber: suggested
+			});
+			$open.closest('td').html(rowHtml);
+			if (suggested) {
+				applyLotSelection(id, suggested, hint);
+			}
+			return;
+		}
+		var html = '<option value="">快速选择</option>';
+		for (var i = 0; i < lots.length; i++) {
+			var lot = lots[i];
+			var lotNo = lot.lotNumber || '';
+			var label = formatLotOptionLabel(lot);
+			var sel = (lotNo === suggested) ? ' selected' : '';
+			html += '<option value="'+escapeHtml(lotNo)+'"'+sel+'>'+escapeHtml(label)+'</option>';
+		}
+		$select.html(html);
+		if (suggested) {
+			applyLotSelection(id, suggested, hint);
+		}
+	});
+}
+
+
+
 $('#dataGrid').dataGrid({
+
 	searchForm: $("#searchForm"),
+	shrinkToFit: false,
+
 	columnModel: [
+
 		{header:'${text("选择")}', name:'pickSelect', width:50, align:"center", frozen:true, formatter: function(val, obj, row){
-			var disabled = !row.noPickedQty || row.noPickedQty <= 0 ? 'disabled' : '';
-			return '<input type="checkbox" class="pick-select" data-id="'+row.id+'" '+disabled+' checked="checked"/>';
+			var pickable = isRowPickable(row);
+			var disabled = pickable ? '' : 'disabled';
+			var checked = pickable ? 'checked="checked"' : '';
+			return '<input type="checkbox" class="pick-select" data-id="'+row.id+'" '+disabled+' '+checked+'/>';
 		}},
+
 		{header:'${text("用料清单")}', name:'ppbomNo', index:'ppbomNo', width:150, align:"center", frozen:true},
+
 		{header:'${text("序号")}', name:'seq', index:'seq', width:70, align:"center"},
+
 		{header:'${text("子项物料编码")}', name:'materialNumber', index:'materialNumber', width:220, align:"left"},
+
 		{header:'${text("子项物料名称")}', name:'materialName', index:'materialName', width:260, align:"left"},
+
 		{header:'${text("应发数量")}', name:'mustQty', index:'mustQty', width:100, align:"right"},
+
 		{header:'${text("已领数量")}', name:'pickedQty', index:'pickedQty', width:100, align:"right"},
+
 		{header:'${text("未领数量")}', name:'noPickedQty', index:'noPickedQty', width:100, align:"right"},
+
+		{header:'${text("可领数量")}', name:'canPickQty', index:'canPickQty', width:90, align:"right", formatter: formatCanPickQty},
+
 		{header:'${text("本次领料")}', name:'pickQty', index:'pickQty', width:110, align:"right", formatter: formatPickQty},
+
 		{header:'${text("单位")}', name:'unitName', index:'unitName', width:90, align:"center"},
+
 		{header:'${text("发料仓库")}', name:'stockName', index:'stockName', width:180, align:"left"},
-		{header:'${text("库存状态")}', name:'stockStatusName', index:'stockStatusName', width:100, align:"center"},
-		{header:'${text("批号")}', name:'lotText', index:'lotText', width:120, align:"center"}
+
+		{header:'${text("领料状态")}', name:'pickStatusLabel', width:100, align:"center", formatter: formatPickStatus},
+
+		{header:'${text("库存状态")}', name:'stockStatusName', index:'stockStatusName', width:100, align:"center", formatter: formatStockStatus},
+
+		{header:'${text("批号")}', name:'lotNumber', index:'lotNumber', width:260, align:"left", formatter: formatLotSelect},
+
+		{header:'${text("批号说明")}', name:'lotHint', index:'lotHint', width:200, align:"left", formatter: formatLotHint},
+
+		{header:'${text("领料说明")}', name:'pickQtyHint', index:'pickQtyHint', width:220, align:"left", formatter: formatPickQtyHint}
+
 	],
-	ajaxSuccess: function(){}
+
+	ajaxSuccess: function(data){
+		setTimeout(function(){
+			$('.pick-status').each(function(){
+				var text = $.trim($(this).text());
+				if (text === '已领完' || text === '无库存') {
+					$(this).closest('tr.jqgrow').addClass('pick-row-finished');
+				}
+			});
+			$('.pick-qty-input[data-batch-manage="1"]').each(function(){
+				refreshLotOptions($(this).data('id'));
+			});
+		}, 200);
+	}
+});
+
+
+
+$(document).on('change blur input', '.pick-qty-input', function(){
+	var id = $(this).data('id');
+	if ($(this).data('batch-manage') === '1') {
+		refreshLotOptions(id);
+	}
+});
+
+
+
+$(document).on('click', '.pick-lot-open', function(e){
+	e.preventDefault();
+	e.stopPropagation();
+	openLotSearchPopup($(this));
 });
 
+$(document).on('click', '.pick-lot-display', function(e){
+	e.preventDefault();
+	e.stopPropagation();
+	var id = $(this).data('id');
+	var $btn = $('.pick-lot-open[data-id="'+id+'"]');
+	if ($btn.length > 0) {
+		openLotSearchPopup($btn);
+	}
+});
+
+$(document).on('change', '.pick-lot-select', function(){
+	var id = $(this).data('id');
+	var lotNumber = $.trim($(this).val() || '');
+	if (lotNumber === '') {
+		return;
+	}
+	applyLotSelection(id, lotNumber, '已选批号 '+lotNumber);
+});
+
+function readLotNumber(id) {
+	return readRowLotNumber(id);
+}
+
+
+
 $('#btnSubmitPick').click(function(){
+
 	var lines = [];
+
 	var hasError = false;
+
 	$('.pick-select:checked').each(function(){
+
 		var id = $(this).data('id');
+
 		var $input = $('.pick-qty-input[data-id="'+id+'"]');
+
 		var pickQty = parseFloat($input.val());
+
 		if (isNaN(pickQty) || pickQty <= 0) {
+
 			return;
+
 		}
+
 		var noPickedQty = parseFloat($input.data('no-picked'));
+
 		var materialNumber = $input.data('material') || id;
+
 		if (!isNaN(noPickedQty) && pickQty > noPickedQty) {
+
 			js.showMessage('物料 '+materialNumber+' 领料数量不能超过未领数量 '+noPickedQty);
+
+			hasError = true;
+
+			return false;
+
+		}
+
+		var canPickQty = parseFloat($input.data('can-pick'));
+		if (!isNaN(canPickQty) && canPickQty <= 0) {
+			js.showMessage('物料 '+materialNumber+' 可领数量为 0,存在未审核领料单或已达最小发料批量上限');
+			hasError = true;
+			return false;
+		}
+		if (!isNaN(canPickQty) && pickQty > canPickQty) {
+			js.showMessage('物料 '+materialNumber+' 本次领料 '+pickQty+' 超过可领数量 '+canPickQty);
+			hasError = true;
+			return false;
+		}
+
+		var lotNumber = readLotNumber(id);
+		var statusText = $.trim($('.pick-status[data-id="'+id+'"]').text());
+		var materialName = $('.pick-lot-open[data-id="'+id+'"]').data('material-name') || '';
+
+		if (statusText === '无库存') {
+			js.showMessage('物料 '+materialNumber+'【'+materialName+'】ERP 即时库存无批号,无法领料。请先在 ERP 做入库/调拨');
+			hasError = true;
+			return false;
+		}
+
+		if ($input.data('batch-manage') === '1' && lotNumber === '') {
+			js.showMessage('物料 '+materialNumber+'【'+materialName+'】请先点击「选批号」选择 ERP 批号');
 			hasError = true;
 			return false;
 		}
-		lines.push({ ppbomEntryId: String(id), pickQty: pickQty });
+
+		var line = { ppbomEntryId: String(id), pickQty: pickQty };
+
+		if (lotNumber !== '') {
+
+			line.lotNumber = lotNumber;
+
+		}
+
+		lines.push(line);
+
 	});
+
 	if (hasError) {
+
 		return;
+
 	}
+
 	if (lines.length === 0) {
+
 		js.showMessage('请勾选明细并填写大于 0 的本次领料数量');
+
 		return;
+
 	}
+
 	js.confirm('确认提交 '+lines.length+' 行到 ERP 生成生产领料单?', function(){
+
 		$.ajax({
+
 			url: '${ctx}/mes/erpPickMaterialBill/submitPickMaterial',
+
 			type: 'POST',
+
 			contentType: 'application/json;charset=UTF-8',
+
 			data: JSON.stringify({ billNo: billNo, entryId: entryId, lines: lines }),
+
 			success: function(data){
+
 				js.showMessage(data.message);
+
 				if (data.result == Global.TRUE) {
+
 					$('#searchForm').submit();
+
 				}
+
 			}
+
 		});
+
 	});
+
 });
+
 </script>
+<script id="lotSearchTpl" type="text/template">//<!--
+<div class="form-horizontal" style="padding:12px;">
+	<div class="form-group" style="margin-bottom:8px;">
+		<label class="control-label col-sm-3">物料编码</label>
+		<div class="col-sm-9"><p class="form-control-static">{{d.materialNumber}}</p></div>
+	</div>
+	<div class="form-group" style="margin-bottom:8px;">
+		<label class="control-label col-sm-3">物料名称</label>
+		<div class="col-sm-9"><p class="form-control-static">{{d.materialName}}</p></div>
+	</div>
+	<div class="form-group" style="margin-bottom:8px;">
+		<label class="control-label col-sm-3">发料仓库</label>
+		<div class="col-sm-9"><p class="form-control-static">{{d.issueStockName}}(查询组织内全部仓库)</p></div>
+	</div>
+	<div class="form-group" style="margin-bottom:8px;">
+		<label class="control-label col-sm-3">批号</label>
+		<div class="col-sm-6"><input type="text" id="lotKeyword" class="form-control input-sm" placeholder="可选,批号关键字"/></div>
+		<div class="col-sm-3"><button type="button" id="btnLotSearch" class="btn btn-primary btn-sm btn-block"><i class="fa fa-search"></i> 查询</button></div>
+	</div>
+	<div id="lotSearchHint" class="text-muted" style="margin-bottom:6px;"></div>
+	<div id="lotSearchResult"></div>
+</div>
+//--></script>
+
+

+ 117 - 63
src/main/resources/views/modules/mes/mesDeviceOeeScreen.html

@@ -105,6 +105,8 @@
 			border: 1px solid rgba(65, 202, 255, .48);
 			background: linear-gradient(180deg, rgba(8, 36, 64, .9), rgba(5, 23, 42, .74));
 			box-shadow: inset 0 0 26px rgba(43, 169, 231, .14), 0 0 18px rgba(0, 162, 255, .09);
+		}
+		.oee-panel {
 			overflow: hidden;
 		}
 		.oee-panel:before,
@@ -283,8 +285,8 @@
 		}
 		.oee-device-grid {
 			display: grid;
-			grid-template-columns: repeat(auto-fit, minmax(245px, 1fr));
-			grid-auto-rows: auto;
+			grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+			grid-auto-rows: min-content;
 			align-content: start;
 			gap: 12px;
 			min-height: 0;
@@ -294,82 +296,129 @@
 			scrollbar-color: rgba(54, 226, 255, .58) rgba(10, 42, 66, .42);
 		}
 		.oee-card {
-			display: grid;
-			grid-template-rows: 34px minmax(102px, 1fr) auto;
-			align-content: start;
-			gap: 8px;
-			padding: 10px;
+			display: flex;
+			flex-direction: column;
+			gap: 10px;
+			padding: 12px;
 			min-width: 0;
-			min-height: 214px;
+			height: auto;
+			overflow: visible;
 		}
 		.oee-card-head {
-			display: grid;
-			grid-template-columns: 1fr auto;
-			align-items: center;
+			display: flex;
+			flex-direction: column;
 			gap: 8px;
 			min-width: 0;
 		}
 		.oee-device-name {
-			overflow: hidden;
-			text-overflow: ellipsis;
-			white-space: nowrap;
 			color: #edfaff;
-			font-size: 16px;
+			font-size: clamp(14px, 1.1vw, 16px);
 			font-weight: 700;
+			line-height: 1.4;
+			word-break: break-all;
+		}
+		.oee-status-row {
+			display: flex;
+			align-items: flex-start;
+			gap: 10px;
+			min-width: 0;
+		}
+		.oee-status-block {
+			display: flex;
+			flex-direction: column;
+			gap: 4px;
+			min-width: 0;
+			flex: 1;
 		}
 		.oee-status-tag {
-			min-width: 48px;
-			height: 24px;
+			flex-shrink: 0;
+			min-width: 58px;
+			height: 30px;
 			display: inline-flex;
 			align-items: center;
 			justify-content: center;
-			padding: 0 8px;
-			border: 1px solid rgba(255, 255, 255, .16);
-			font-size: 13px;
+			padding: 0 14px;
+			border-radius: 4px;
+			border: 1px solid rgba(255, 255, 255, .2);
+			font-size: 14px;
+			font-weight: 700;
+			letter-spacing: 1px;
 			white-space: nowrap;
+			box-shadow: 0 0 12px rgba(0, 0, 0, .25);
 		}
 		.oee-status-running {
-			color: #35f2c2;
-			background: rgba(53, 242, 194, .1);
-			border-color: rgba(53, 242, 194, .45);
+			color: #05241c;
+			background: linear-gradient(180deg, #5dffd8 0%, #35f2c2 100%);
+			border-color: rgba(93, 255, 216, .9);
+			box-shadow: 0 0 16px rgba(53, 242, 194, .55);
 		}
 		.oee-status-fault {
-			color: #ff7c88;
-			background: rgba(255, 95, 109, .12);
-			border-color: rgba(255, 95, 109, .5);
+			color: #fff;
+			background: linear-gradient(180deg, #ff8a95 0%, #ff5f6d 100%);
+			border-color: rgba(255, 138, 149, .9);
+			box-shadow: 0 0 16px rgba(255, 95, 109, .5);
 		}
 		.oee-status-maintenance {
-			color: #ffc857;
-			background: rgba(255, 200, 87, .12);
-			border-color: rgba(255, 200, 87, .45);
+			color: #3a2800;
+			background: linear-gradient(180deg, #ffe08a 0%, #ffc857 100%);
+			border-color: rgba(255, 224, 138, .9);
+			box-shadow: 0 0 16px rgba(255, 200, 87, .45);
 		}
 		.oee-status-alarm {
-			color: #ffad6c;
-			background: rgba(255, 138, 61, .12);
-			border-color: rgba(255, 138, 61, .45);
+			color: #fff;
+			background: linear-gradient(180deg, #ffb07a 0%, #ff8a3d 100%);
+			border-color: rgba(255, 176, 122, .9);
+			box-shadow: 0 0 16px rgba(255, 138, 61, .45);
 		}
 		.oee-status-offline,
 		.oee-status-unknown {
-			color: #a8b6ce;
-			background: rgba(125, 141, 168, .12);
-			border-color: rgba(125, 141, 168, .45);
+			color: #eef3ff;
+			background: linear-gradient(180deg, #9aa8c2 0%, #7d8da8 100%);
+			border-color: rgba(170, 184, 210, .9);
+			box-shadow: 0 0 12px rgba(125, 141, 168, .35);
+		}
+		.oee-status-desc {
+			color: #9fe8ff;
+			font-size: 12px;
+			line-height: 1.5;
+			word-break: break-all;
+		}
+		.oee-status-time {
+			color: #6ea8c0;
+			font-size: 11px;
+			line-height: 1.5;
+			word-break: break-all;
 		}
 		.oee-device-visual {
-			display: grid;
-			grid-template-columns: minmax(0, 1fr) clamp(66px, 4.5vw, 82px);
+			display: flex;
 			align-items: center;
-			gap: 8px;
-			min-height: 98px;
+			justify-content: space-between;
+			gap: 10px;
+			min-height: 96px;
+			flex-shrink: 0;
+		}
+		.oee-device-photo {
+			flex: 1;
+			min-width: 0;
+			height: 96px;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			border: 1px dashed rgba(79, 184, 238, .2);
+			border-radius: 4px;
+			background: rgba(4, 22, 39, .38);
+			overflow: hidden;
 		}
 		.oee-device-visual img {
 			width: 100%;
-			height: clamp(94px, 12vh, 132px);
+			height: 100%;
 			object-fit: contain;
 			filter: drop-shadow(0 0 18px rgba(61, 219, 255, .32));
 		}
 		.oee-mini-gauge {
-			width: clamp(64px, 4.3vw, 78px);
-			height: clamp(64px, 4.3vw, 78px);
+			flex-shrink: 0;
+			width: 72px;
+			height: 72px;
 			border-radius: 50%;
 			display: flex;
 			align-items: center;
@@ -389,16 +438,16 @@
 			z-index: 1;
 			color: #eaffff;
 			font-family: Impact, "Arial Narrow", sans-serif;
-			font-size: clamp(15px, 1vw, 19px);
+			font-size: 15px;
 		}
 		.oee-card-foot {
 			display: grid;
 			grid-template-columns: 1fr 1fr;
-			row-gap: 6px;
-			column-gap: 8px;
-			padding-top: 8px;
+			gap: 8px 12px;
+			padding-top: 10px;
 			border-top: 1px solid rgba(79, 184, 238, .18);
 			min-width: 0;
+			flex-shrink: 0;
 		}
 		.oee-meta {
 			min-width: 0;
@@ -406,15 +455,14 @@
 		.oee-meta-label {
 			color: #7fb9cf;
 			font-size: 12px;
-			line-height: 16px;
+			line-height: 1.4;
+			margin-bottom: 4px;
 		}
 		.oee-meta-value {
-			overflow: hidden;
-			text-overflow: ellipsis;
-			white-space: nowrap;
 			color: #e3f8ff;
 			font-size: 14px;
-			line-height: 20px;
+			line-height: 1.5;
+			word-break: break-all;
 		}
 		.oee-empty {
 			height: 100%;
@@ -427,20 +475,18 @@
 			font-size: 18px;
 		}
 		@media (max-aspect-ratio: 16/10) {
-			.oee-card {
-				grid-template-rows: 30px minmax(86px, 1fr) auto;
-				gap: 6px;
-				padding: 9px;
-				min-height: 196px;
+			.oee-device-grid {
+				grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+			}
+			.oee-device-photo {
+				height: 84px;
 			}
 			.oee-device-visual {
 				min-height: 84px;
 			}
-			.oee-device-visual img {
-				height: clamp(80px, 10vh, 112px);
-			}
-			.oee-card-foot {
-				row-gap: 4px;
+			.oee-mini-gauge {
+				width: 64px;
+				height: 64px;
 			}
 		}
 	</style>
@@ -612,20 +658,28 @@
 			var statusClass = 'oee-status-' + escapeHtml(item.statusCode || 'unknown');
 			var fallbackUrl = '${ctxStatic}/screen/imgs/small' + safeNumber(item.imageIndex || 1) + '.png';
 			var imageUrl = normalizeImageUrl(item.imagePath, fallbackUrl);
+			var reportTime = item.lastReportTime && item.lastReportTime !== '-' ? item.lastReportTime : '';
+			var reportDesc = item.reportContent || item.stateName || item.statusName || '';
 			html += '<div class="oee-card">'
 				+ '<div class="oee-card-head">'
 				+ '<div class="oee-device-name" title="' + escapeHtml(item.name) + '">' + escapeHtml(item.name) + '</div>'
+				+ '<div class="oee-status-row">'
 				+ '<div class="oee-status-tag ' + statusClass + '">' + escapeHtml(item.statusName) + '</div>'
+				+ '<div class="oee-status-block">'
+				+ (reportDesc ? '<div class="oee-status-desc" title="' + escapeHtml(reportDesc) + '">' + escapeHtml(reportDesc) + '</div>' : '')
+				+ (reportTime ? '<div class="oee-status-time" title="' + escapeHtml(reportTime) + '">最后上报:' + escapeHtml(reportTime) + '</div>' : '')
+				+ '</div>'
+				+ '</div>'
 				+ '</div>'
 				+ '<div class="oee-device-visual">'
+				+ '<div class="oee-device-photo">'
 				+ '<img src="' + escapeHtml(imageUrl) + '" data-fallback="' + escapeHtml(fallbackUrl) + '" onerror="this.onerror=null;this.src=this.getAttribute(\'data-fallback\');" alt="">'
+				+ '</div>'
 				+ '<div class="oee-mini-gauge" style="--rate:' + Math.max(0, Math.min(100, rate)) + '%"><span>' + rate.toFixed(1) + '%</span></div>'
 				+ '</div>'
 				+ '<div class="oee-card-foot">'
 				+ '<div class="oee-meta"><div class="oee-meta-label">设备编号</div><div class="oee-meta-value">' + escapeHtml(item.sn) + '</div></div>'
 				+ '<div class="oee-meta"><div class="oee-meta-label">产线</div><div class="oee-meta-value">' + escapeHtml(item.lineSn) + '</div></div>'
-				+ '<div class="oee-meta"><div class="oee-meta-label">当前状态</div><div class="oee-meta-value">' + escapeHtml(item.stateName) + '</div></div>'
-				+ '<div class="oee-meta"><div class="oee-meta-label">最近上报</div><div class="oee-meta-value">' + escapeHtml(item.lastReportTime) + '</div></div>'
 				+ '</div>'
 				+ '</div>';
 		});

+ 130 - 106
src/main/resources/views/modules/mes/mesMaterialKcLogList.html

@@ -1,106 +1,130 @@
-<% layout('/layouts/default.html', {title: '来料库存管理', libs: ['dataGrid']}){ %>
-<div class="main-content">
-	<div class="box box-main">
-		<div class="box-header">
-			<div class="box-title">
-				<i class="fa icon-notebook"></i> ${text('来料库存管理')}
-			</div>
-			<div class="box-tools pull-right">
-				<a href="#" class="btn btn-default" id="btnSearch" title="${text('查询')}"><i class="fa fa-filter"></i> ${text('查询')}</a>
-				<% if(hasPermi('mes:mesMaterialKcLog:edit')){ %>
-<!--					<a href="${ctx}/mes/mesMaterialKcLog/form" class="btn btn-default btnTool" title="${text('新增来料库存记录表')}"><i class="fa fa-plus"></i> ${text('新增')}</a>-->
-<!--				-->
-				<% } %>
-				<a href="#" class="btn btn-default" id="btnSetting" title="${text('设置')}"><i class="fa fa-navicon"></i></a>
-			</div>
-		</div>
-		<div class="box-body">
-			<#form:form id="searchForm" model="${mesMaterialKcLog}" action="${ctx}/mes/mesMaterialKcLog/listData" method="post" class="form-inline hide"
-					data-page-no="${parameter.pageNo}" data-page-size="${parameter.pageSize}" data-order-by="${parameter.orderBy}">
-<!--				<div class="form-group">-->
-<!--					<label class="control-label">${text('编号')}:</label>-->
-<!--					<div class="control-inline">-->
-<!--						<#form:input path="id" maxlength="64" class="form-control width-120"/>-->
-<!--					</div>-->
-<!--				</div>-->
-				<div class="form-group">
-					<label class="control-label">${text('物料')}:</label>
-					<div class="control-inline">
-						<#form:input path="materialTitle" maxlength="64" class="form-control width-120"/>
-					</div>
-				</div>
-				<div class="form-group">
-					<label class="control-label">${text('批次码')}:</label>
-					<div class="control-inline">
-						<#form:input path="batchSn" maxlength="255" class="form-control width-120"/>
-					</div>
-				</div>
-				<div class="form-group">
-					<label class="control-label">${text('库位')}:</label>
-					<div class="control-inline">
-						<#form:input path="kwTitle" maxlength="64" class="form-control width-120"/>
-					</div>
-				</div>
-<!--				<div class="form-group">-->
-<!--					<label class="control-label">${text('数量')}:</label>-->
-<!--					<div class="control-inline">-->
-<!--						<#form:input path="nums" maxlength="9" class="form-control width-120"/>-->
-<!--					</div>-->
-<!--				</div>-->
-				<div class="form-group">
-					<label class="control-label">${text('入库单类型')}:</label>
-					<div class="control-inline width-120">
-						<#form:select path="rkType" dictType="mes_material_rk_type" blankOption="true" class="form-control"/>
-					</div>
-				</div>
-				<div class="form-group">
-					<label class="control-label">${text('入库时间')}:</label>
-					<div class="control-inline">
-						<#form:input path="createDate_gte" readonly="true" maxlength="20" class="form-control laydate width-datetime"
-							dataFormat="datetime" data-type="datetime" data-format="yyyy-MM-dd HH:mm" data-done="createDate_lte.click()"/>
-						&nbsp;-&nbsp;
-						<#form:input path="createDate_lte" readonly="true" maxlength="20" class="form-control laydate width-datetime"
-							dataFormat="datetime" data-type="datetime" data-format="yyyy-MM-dd HH:mm"/>
-					</div>
-				</div>
-				<div class="form-group">
-					<button type="submit" class="btn btn-primary btn-sm">${text('查询')}</button>
-					<button type="reset" class="btn btn-default btn-sm">${text('重置')}</button>
-				</div>
-			</#form:form>
-			<table id="dataGrid"></table>
-			<div id="dataGridPage"></div>
-		</div>
-	</div>
-</div>
-<% } %>
-<script>
-// 初始化DataGrid对象
-$('#dataGrid').dataGrid({
-	searchForm: $("#searchForm"),
-	columnModel: [
-		// {header:'${text("编号")}', name:'id', index:'a.id', width:150, align:"center"},
-		{header:'${text("物料")}', name:'materialTitle', index:'c.title', width:200, align:"center"},
-		{header:'${text("批次码")}', name:'batchSn', index:'a.batch_sn', width:220, align:"center"},
-		{header:'${text("库位")}', name:'kwTitle', index:'d.title', width:120, align:"center"},
-		{header:'${text("数量")}', name:'nums', index:'a.nums', width:100, align:"center"},
-	//	{header:'${text("备注")}', name:'remark', index:'a.remark', width:150, align:"center"},
-		{header:'${text("入库单类型")}', name:'rkType', index:'e.type', width:120, align:"center", formatter: function(val, obj, row, act){
-				return js.getDictLabel(${@DictUtils.getDictListJson('mes_material_rk_type')}, val, '${text("未知")}', true);
-			}},
-		{header:'${text("失效时间")}', name:'expireDate', index:'a.expire_date', width:180, align:"center"},
-		{header:'${text("入库时间")}', name:'createDate', index:'a.create_date', width:150, align:"center"},
-		{header:'${text("入库人")}', name:'createBy', index:'a.create_by', width:150, align:"center"},
-		{header:'${text("原批次码")}', name:'oldBatchSn', index:'a.old_batch_sn', width:220, align:"center"},
-		{header:'${text("操作")}', name:'actions', width:80, formatter: function(val, obj, row, act){
-			var actions = [];
-				actions.push('<a href="${ctx}/mes/mesMaterialKcLog/qrcode?id='+row.id+'" class="btnList" title="${text("二维码查看")}"><i class="glyphicon glyphicon-qrcode"></i></a>&nbsp;');
-			return actions.join('');
-		}}
-	],
-	// 加载成功后执行事件
-	ajaxSuccess: function(data){
-		
-	}
-});
-</script>
+<% layout('/layouts/default.html', {title: '来料库存管理', libs: ['dataGrid']}){ %>

+<div class="main-content">

+	<div class="box box-main">

+		<div class="box-header">

+			<div class="box-title">

+				<i class="fa icon-notebook"></i> ${text('来料库存管理')}

+			</div>

+			<div class="box-tools pull-right">

+				<a href="#" class="btn btn-default" id="btnSearch" title="${text('查询')}"><i class="fa fa-filter"></i> ${text('查询')}</a>

+				<% if(hasPermi('mes:mesMaterialKcLog:edit')){ %>

+<!--					<a href="${ctx}/mes/mesMaterialKcLog/form" class="btn btn-default btnTool" title="${text('新增来料库存记录表')}"><i class="fa fa-plus"></i> ${text('新增')}</a>-->

+<!--				-->

+				<% } %>

+				<a href="#" class="btn btn-default" id="btnSetting" title="${text('设置')}"><i class="fa fa-navicon"></i></a>

+			</div>

+		</div>

+		<div class="box-body">

+			<#form:form id="searchForm" model="${mesMaterialKcLog}" action="${ctx}/mes/mesMaterialKcLog/listData" method="post" class="form-inline hide"

+					data-page-no="${parameter.pageNo}" data-page-size="${parameter.pageSize}" data-order-by="${parameter.orderBy}">

+				<div class="form-group">

+					<label class="control-label">${text('物料')}:</label>

+					<div class="control-inline">

+						<#form:input path="materialTitle" maxlength="64" class="form-control width-120"/>

+					</div>

+				</div>

+				<div class="form-group">

+					<label class="control-label">${text('批次码')}:</label>

+					<div class="control-inline">

+						<#form:input path="batchSn" maxlength="255" class="form-control width-120"/>

+					</div>

+				</div>

+				<div class="form-group">

+					<label class="control-label">${text('库位')}:</label>

+					<div class="control-inline">

+						<#form:input path="kwTitle" maxlength="64" class="form-control width-120"/>

+					</div>

+				</div>

+				<div class="form-group">

+					<label class="control-label">${text('来源')}:</label>

+					<div class="control-inline width-120">

+						<select name="sourceType" class="form-control">

+							<option value="">${text('全部')}</option>

+							<option value="ERP_PICK">${text('ERP生产领料')}</option>

+							<option value="RK">${text('来料入库')}</option>

+						</select>

+					</div>

+				</div>

+				<div class="form-group">

+					<label class="control-label">${text('入库类型')}:</label>

+					<div class="control-inline width-120">

+						<#form:select path="rkType" dictType="mes_material_rk_type" blankOption="true" class="form-control"/>

+					</div>

+				</div>

+				<div class="form-group">

+					<label class="control-label">${text('ERP领料单号')}:</label>

+					<div class="control-inline">

+						<#form:input path="erpPickBillNo" maxlength="100" class="form-control width-140"/>

+					</div>

+				</div>

+				<div class="form-group">

+					<label class="control-label">${text('入库时间')}:</label>

+					<div class="control-inline">

+						<#form:input path="createDate_gte" readonly="true" maxlength="20" class="form-control laydate width-datetime"

+							dataFormat="datetime" data-type="datetime" data-format="yyyy-MM-dd HH:mm" data-done="createDate_lte.click()"/>

+						&nbsp;-&nbsp;

+						<#form:input path="createDate_lte" readonly="true" maxlength="20" class="form-control laydate width-datetime"

+							dataFormat="datetime" data-type="datetime" data-format="yyyy-MM-dd HH:mm"/>

+					</div>

+				</div>

+				<div class="form-group">

+					<button type="submit" class="btn btn-primary btn-sm">${text('查询')}</button>

+					<button type="reset" class="btn btn-default btn-sm">${text('重置')}</button>

+				</div>

+			</#form:form>

+			<table id="dataGrid"></table>

+			<div id="dataGridPage"></div>

+		</div>

+	</div>

+</div>

+<% } %>

+<script>

+var mesMaterialRkTypeDict = ${@DictUtils.getDictListJson('mes_material_rk_type')};

+

+// 来源/类型列展示

+function formatKcSourceType(row) {

+	if (row.sourceType === 'ERP_PICK') {

+		return '${text("ERP生产领料")}';

+	}

+	if (row.rkType) {

+		return js.getDictLabel(mesMaterialRkTypeDict, row.rkType, '${text("来料入库")}', true);

+	}

+	if (row.rkRecordId) {

+		return '${text("来料入库")}';

+	}

+	return '${text("未知")}';

+}

+

+// 初始化DataGrid对象

+$('#dataGrid').dataGrid({

+	searchForm: $("#searchForm"),

+	columnModel: [

+		{header:'${text("物料")}', name:'materialTitle', index:'c.title', width:180, align:"center"},

+		{header:'${text("批次码")}', name:'batchSn', index:'a.batch_sn', width:200, align:"center"},

+		{header:'${text("库位")}', name:'kwTitle', index:'d.title', width:100, align:"center"},

+		{header:'${text("数量")}', name:'nums', index:'a.nums', width:80, align:"center"},

+		{header:'${text("来源/类型")}', name:'rkType', index:'e.type', width:120, align:"center", formatter: function(val, obj, row, act){

+				return formatKcSourceType(row);

+			}},

+		{header:'${text("ERP领料单号")}', name:'erpPickBillNo', index:'a.erp_pick_bill_no', width:160, align:"center", formatter: function(val, obj, row, act){

+				return val || '-';

+			}},

+		{header:'${text("ERP生产订单")}', name:'erpMoBillNo', index:'a.erp_mo_bill_no', width:140, align:"center", formatter: function(val, obj, row, act){

+				return val || '-';

+			}},

+		{header:'${text("失效时间")}', name:'expireDate', index:'a.expire_date', width:150, align:"center"},

+		{header:'${text("入库时间")}', name:'createDate', index:'a.create_date', width:140, align:"center"},

+		{header:'${text("入库人")}', name:'createBy', index:'a.create_by', width:100, align:"center"},

+		{header:'${text("原批次码")}', name:'oldBatchSn', index:'a.old_batch_sn', width:180, align:"center"},

+		{header:'${text("操作")}', name:'actions', width:80, formatter: function(val, obj, row, act){

+			var actions = [];

+				actions.push('<a href="${ctx}/mes/mesMaterialKcLog/qrcode?id='+row.id+'" class="btnList" title="${text("二维码查看")}"><i class="glyphicon glyphicon-qrcode"></i></a>&nbsp;');

+			return actions.join('');

+		}}

+	],

+	// 加载成功后执行事件

+	ajaxSuccess: function(data){

+		

+	}

+});

+</script>


+ 0 - 43
src/main/resources/views/modules/mes/mesScreenLiuan.html

@@ -36,48 +36,6 @@
             background: url("${ctxStatic}/screen/imgs/liuan6.png") center / 100% 100% no-repeat;
             z-index: 3;
         }
-        .brand {
-            position: absolute;
-            left: 70px;
-            top: 22px;
-            display: flex;
-            align-items: center;
-            gap: 10px;
-            color: #fff;
-            font-size: 23px;
-            font-weight: 700;
-            letter-spacing: 1px;
-            z-index: 5;
-        }
-        .brand-mark {
-            width: 42px;
-            height: 24px;
-            border-radius: 50%;
-            border: 2px solid rgba(101, 222, 255, .8);
-            transform: rotate(-24deg);
-            position: relative;
-        }
-        .brand-mark::before,
-        .brand-mark::after {
-            content: "";
-            position: absolute;
-            border-radius: 50%;
-        }
-        .brand-mark::before {
-            width: 9px;
-            height: 9px;
-            left: 16px;
-            top: -4px;
-            background: #ffce35;
-            box-shadow: 13px 9px 0 #ff4058;
-        }
-        .brand-mark::after {
-            width: 32px;
-            height: 2px;
-            left: 5px;
-            top: 10px;
-            background: linear-gradient(90deg, transparent, #66ffe6, transparent);
-        }
         .title {
             position: absolute;
             left: 50%;
@@ -400,7 +358,6 @@
 <body>
 <div class="liuan-screen" id="screen">
     <div class="top-frame"></div>
-    <div class="brand"><span class="brand-mark"></span><span>大洋MES</span></div>
     <div class="title">MES生产管理模块数据大屏</div>
     <div class="clock" id="clock"></div>