Jelajahi Sumber

ERP对接,采购订单抓取

jingbo 4 bulan lalu
induk
melakukan
f8a9524402
38 mengubah file dengan 3966 tambahan dan 577 penghapusan
  1. 20 0
      src/main/java/com/jeesite/modules/mes/dao/MesErpScheduleDao.java
  2. 225 0
      src/main/java/com/jeesite/modules/mes/entity/MesErpSchedule.java
  3. 232 0
      src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialBill.java
  4. 265 0
      src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialBillService.java
  5. 48 0
      src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialResult.java
  6. 286 0
      src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialService.java
  7. 66 0
      src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialSubmitRequest.java
  8. 135 0
      src/main/java/com/jeesite/modules/mes/erp/ErpProductionMaterial.java
  9. 70 6
      src/main/java/com/jeesite/modules/mes/erp/ErpProductionMaterialService.java
  10. 404 69
      src/main/java/com/jeesite/modules/mes/erp/ErpProductionOrderService.java
  11. 259 0
      src/main/java/com/jeesite/modules/mes/erp/ErpPurchaseOrder.java
  12. 369 0
      src/main/java/com/jeesite/modules/mes/erp/ErpPurchaseOrderService.java
  13. 9 0
      src/main/java/com/jeesite/modules/mes/erp/KingdeeErpClient.java
  14. 52 0
      src/main/java/com/jeesite/modules/mes/erp/KingdeeErpProperties.java
  15. 5 1
      src/main/java/com/jeesite/modules/mes/service/MesAdw300DataService.java
  16. 247 0
      src/main/java/com/jeesite/modules/mes/service/MesErpScheduleService.java
  17. 143 0
      src/main/java/com/jeesite/modules/mes/web/ErpPickMaterialBillController.java
  18. 1 26
      src/main/java/com/jeesite/modules/mes/web/ErpProductionOrderController.java
  19. 70 0
      src/main/java/com/jeesite/modules/mes/web/ErpPurchaseOrderController.java
  20. 127 0
      src/main/java/com/jeesite/modules/mes/web/MesErpScheduleController.java
  21. 17 2
      src/main/resources/config/application.yml
  22. 17 15
      src/main/resources/mappings/modules/mes/MesAdw300DataDao.xml
  23. 16 0
      src/main/resources/mappings/modules/mes/MesErpScheduleDao.xml
  24. 134 0
      src/main/resources/views/modules/mes/erpPickMaterialBillList.html
  25. 102 0
      src/main/resources/views/modules/mes/erpPickMaterialBillMaterial.html
  26. 45 0
      src/main/resources/views/modules/mes/erpPickMaterialBillSelectDetail.html
  27. 53 0
      src/main/resources/views/modules/mes/erpPickMaterialBillSelectOrder.html
  28. 0 46
      src/main/resources/views/modules/mes/erpProductionMaterialList.html
  29. 4 6
      src/main/resources/views/modules/mes/erpProductionOrderDetail.html
  30. 0 1
      src/main/resources/views/modules/mes/erpProductionOrderList.html
  31. 41 0
      src/main/resources/views/modules/mes/erpPurchaseOrderDetail.html
  32. 64 0
      src/main/resources/views/modules/mes/erpPurchaseOrderList.html
  33. 1 1
      src/main/resources/views/modules/mes/mesEnergyScreen.html
  34. 195 0
      src/main/resources/views/modules/mes/mesErpScheduleForm.html
  35. 75 0
      src/main/resources/views/modules/mes/mesErpScheduleList.html
  36. 161 130
      src/main/resources/views/modules/mes/mesProductArcWeldingInfo.html
  37. 8 8
      src/main/resources/views/modules/mes/mesProductArcWeldingList.html
  38. 0 266
      tools/build_screen_data_doc.py

+ 20 - 0
src/main/java/com/jeesite/modules/mes/dao/MesErpScheduleDao.java

@@ -0,0 +1,20 @@
+package com.jeesite.modules.mes.dao;
+
+import com.jeesite.common.dao.CrudDao;
+import com.jeesite.common.mybatis.annotation.MyBatisDao;
+import com.jeesite.modules.mes.entity.MesErpSchedule;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * ERP排产计划DAO接口
+ */
+@MyBatisDao
+public interface MesErpScheduleDao extends CrudDao<MesErpSchedule> {
+
+	/**
+	 * 统计同一ERP明细行已排产数量(不含删除状态)
+	 */
+	Integer sumScheduleQty(@Param("erpBillNo") String erpBillNo,
+			@Param("erpEntryId") String erpEntryId,
+			@Param("excludeId") String excludeId);
+}

+ 225 - 0
src/main/java/com/jeesite/modules/mes/entity/MesErpSchedule.java

@@ -0,0 +1,225 @@
+package com.jeesite.modules.mes.entity;
+
+import com.jeesite.common.entity.DataEntity;
+import com.jeesite.common.mybatis.annotation.Column;
+import com.jeesite.common.mybatis.annotation.Table;
+import com.jeesite.common.mybatis.mapper.query.QueryType;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+import java.math.BigDecimal;
+
+/**
+ * ERP排产计划Entity
+ */
+@Table(name = "mes_erp_schedule", alias = "a", label = "ERP排产计划信息", columns = {
+		@Column(name = "id", attrName = "id", label = "编号", isPK = true),
+		@Column(name = "erp_bill_no", attrName = "erpBillNo", label = "ERP生产订单号", queryType = QueryType.LIKE),
+		@Column(name = "erp_entry_id", attrName = "erpEntryId", label = "ERP分录内码"),
+		@Column(name = "erp_seq", attrName = "erpSeq", label = "ERP行号"),
+		@Column(name = "material_number", attrName = "materialNumber", label = "物料编码", queryType = QueryType.LIKE),
+		@Column(name = "material_name", attrName = "materialName", label = "物料名称", queryType = QueryType.LIKE),
+		@Column(name = "inventory_category", attrName = "inventoryCategory", label = "存货类别", queryType = QueryType.EQ),
+		@Column(name = "workshop_name", attrName = "workshopName", label = "生产车间", queryType = QueryType.LIKE),
+		@Column(name = "order_qty", attrName = "orderQty", label = "ERP订单数量"),
+		@Column(name = "erp_plan_start_date", attrName = "erpPlanStartDate", label = "ERP计划开工"),
+		@Column(name = "erp_plan_finish_date", attrName = "erpPlanFinishDate", label = "ERP计划完工"),
+		@Column(name = "line_id", attrName = "lineId", label = "产线"),
+		@Column(name = "line_sn", attrName = "lineSn", label = "产线编号", queryType = QueryType.EQ),
+		@Column(name = "line_title", attrName = "lineTitle", label = "产线名称", queryType = QueryType.LIKE),
+		@Column(name = "schedule_start_date", attrName = "scheduleStartDate", label = "排产开始日期", queryType = QueryType.GTE),
+		@Column(name = "schedule_end_date", attrName = "scheduleEndDate", label = "排产结束日期", queryType = QueryType.LTE),
+		@Column(name = "schedule_qty", attrName = "scheduleQty", label = "排产工件数量"),
+		@Column(name = "status", attrName = "status", label = "状态", comment = "状态(0正常 1删除 2停用)", isUpdate = false),
+		@Column(name = "create_by", attrName = "createBy", label = "create_by", isUpdate = false, isQuery = false),
+		@Column(name = "create_date", attrName = "createDate", label = "create_date", isUpdate = false, isQuery = false, isUpdateForce = true),
+		@Column(name = "update_by", attrName = "updateBy", label = "update_by", isQuery = false),
+		@Column(name = "update_date", attrName = "updateDate", label = "update_date", isQuery = false, isUpdateForce = true),
+		@Column(name = "remarks", attrName = "remarks", label = "备注", queryType = QueryType.LIKE),
+}, orderBy = "a.schedule_start_date DESC, a.update_date DESC")
+public class MesErpSchedule extends DataEntity<MesErpSchedule> {
+
+	private static final long serialVersionUID = 1L;
+
+	private String erpBillNo;
+	private String erpEntryId;
+	private String erpSeq;
+	private String materialNumber;
+	private String materialName;
+	private String inventoryCategory;
+	private String workshopName;
+	private BigDecimal orderQty;
+	private String erpPlanStartDate;
+	private String erpPlanFinishDate;
+	private String lineId;
+	private String lineSn;
+	private String lineTitle;
+	private String scheduleStartDate;
+	private String scheduleEndDate;
+	private Integer scheduleQty;
+	/** 已排产数量(查询辅助,非表字段) */
+	private Integer scheduledQtySum;
+	/** 剩余可排产数量(查询辅助,非表字段) */
+	private Integer remainQty;
+
+	public MesErpSchedule() {
+		this(null);
+	}
+
+	public MesErpSchedule(String id) {
+		super(id);
+	}
+
+	@NotBlank(message = "ERP生产订单号不能为空")
+	@Size(max = 100, message = "ERP生产订单号长度不能超过100个字符")
+	public String getErpBillNo() {
+		return erpBillNo;
+	}
+
+	public void setErpBillNo(String erpBillNo) {
+		this.erpBillNo = erpBillNo;
+	}
+
+	@Size(max = 64, message = "ERP分录内码长度不能超过64个字符")
+	public String getErpEntryId() {
+		return erpEntryId;
+	}
+
+	public void setErpEntryId(String erpEntryId) {
+		this.erpEntryId = erpEntryId;
+	}
+
+	public String getErpSeq() {
+		return erpSeq;
+	}
+
+	public void setErpSeq(String erpSeq) {
+		this.erpSeq = erpSeq;
+	}
+
+	public String getMaterialNumber() {
+		return materialNumber;
+	}
+
+	public void setMaterialNumber(String materialNumber) {
+		this.materialNumber = materialNumber;
+	}
+
+	public String getMaterialName() {
+		return materialName;
+	}
+
+	public void setMaterialName(String materialName) {
+		this.materialName = materialName;
+	}
+
+	public String getInventoryCategory() {
+		return inventoryCategory;
+	}
+
+	public void setInventoryCategory(String inventoryCategory) {
+		this.inventoryCategory = inventoryCategory;
+	}
+
+	public String getWorkshopName() {
+		return workshopName;
+	}
+
+	public void setWorkshopName(String workshopName) {
+		this.workshopName = workshopName;
+	}
+
+	public BigDecimal getOrderQty() {
+		return orderQty;
+	}
+
+	public void setOrderQty(BigDecimal orderQty) {
+		this.orderQty = orderQty;
+	}
+
+	public String getErpPlanStartDate() {
+		return erpPlanStartDate;
+	}
+
+	public void setErpPlanStartDate(String erpPlanStartDate) {
+		this.erpPlanStartDate = erpPlanStartDate;
+	}
+
+	public String getErpPlanFinishDate() {
+		return erpPlanFinishDate;
+	}
+
+	public void setErpPlanFinishDate(String erpPlanFinishDate) {
+		this.erpPlanFinishDate = erpPlanFinishDate;
+	}
+
+	@NotBlank(message = "产线不能为空")
+	@Size(max = 64, message = "产线长度不能超过64个字符")
+	public String getLineId() {
+		return lineId;
+	}
+
+	public void setLineId(String lineId) {
+		this.lineId = lineId;
+	}
+
+	public String getLineSn() {
+		return lineSn;
+	}
+
+	public void setLineSn(String lineSn) {
+		this.lineSn = lineSn;
+	}
+
+	public String getLineTitle() {
+		return lineTitle;
+	}
+
+	public void setLineTitle(String lineTitle) {
+		this.lineTitle = lineTitle;
+	}
+
+	@NotBlank(message = "排产开始日期不能为空")
+	public String getScheduleStartDate() {
+		return scheduleStartDate;
+	}
+
+	public void setScheduleStartDate(String scheduleStartDate) {
+		this.scheduleStartDate = scheduleStartDate;
+	}
+
+	@NotBlank(message = "排产结束日期不能为空")
+	public String getScheduleEndDate() {
+		return scheduleEndDate;
+	}
+
+	public void setScheduleEndDate(String scheduleEndDate) {
+		this.scheduleEndDate = scheduleEndDate;
+	}
+
+	@NotNull(message = "排产工件数量不能为空")
+	public Integer getScheduleQty() {
+		return scheduleQty;
+	}
+
+	public void setScheduleQty(Integer scheduleQty) {
+		this.scheduleQty = scheduleQty;
+	}
+
+	public Integer getScheduledQtySum() {
+		return scheduledQtySum;
+	}
+
+	public void setScheduledQtySum(Integer scheduledQtySum) {
+		this.scheduledQtySum = scheduledQtySum;
+	}
+
+	public Integer getRemainQty() {
+		return remainQty;
+	}
+
+	public void setRemainQty(Integer remainQty) {
+		this.remainQty = remainQty;
+	}
+}

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

@@ -0,0 +1,232 @@
+package com.jeesite.modules.mes.erp;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * ERP 生产领料单(PRD_PickMtrl)BillQuery 行数据。
+ */
+public class ErpPickMaterialBill implements Serializable {
+
+	private static final long serialVersionUID = 1L;
+
+	private String id;
+	private String fid;
+	private String billNo;
+	private String documentStatus;
+	private String documentStatusName;
+	private String date;
+	private String prdOrgNumber;
+	private String prdOrgName;
+	private String stockOrgNumber;
+	private String stockOrgName;
+	private String workshopNumber;
+	private String workshopName;
+	private String entryId;
+	private String seq;
+	private String materialNumber;
+	private String materialName;
+	private String unitName;
+	private BigDecimal appQty;
+	private BigDecimal actualQty;
+	private String moBillNo;
+	private String ppbomBillNo;
+	private String stockName;
+
+	/** 查询条件:生产订单号 */
+	private String moBillNoQuery;
+	/** 查询条件:物料编码(模糊) */
+	private String materialNumberQuery;
+
+	public String getId() {
+		return id;
+	}
+
+	public void setId(String id) {
+		this.id = id;
+	}
+
+	public String getFid() {
+		return fid;
+	}
+
+	public void setFid(String fid) {
+		this.fid = fid;
+	}
+
+	public String getBillNo() {
+		return billNo;
+	}
+
+	public void setBillNo(String billNo) {
+		this.billNo = billNo;
+	}
+
+	public String getDocumentStatus() {
+		return documentStatus;
+	}
+
+	public void setDocumentStatus(String documentStatus) {
+		this.documentStatus = documentStatus;
+	}
+
+	public String getDocumentStatusName() {
+		return documentStatusName;
+	}
+
+	public void setDocumentStatusName(String documentStatusName) {
+		this.documentStatusName = documentStatusName;
+	}
+
+	public String getDate() {
+		return date;
+	}
+
+	public void setDate(String date) {
+		this.date = date;
+	}
+
+	public String getPrdOrgNumber() {
+		return prdOrgNumber;
+	}
+
+	public void setPrdOrgNumber(String prdOrgNumber) {
+		this.prdOrgNumber = prdOrgNumber;
+	}
+
+	public String getPrdOrgName() {
+		return prdOrgName;
+	}
+
+	public void setPrdOrgName(String prdOrgName) {
+		this.prdOrgName = prdOrgName;
+	}
+
+	public String getStockOrgNumber() {
+		return stockOrgNumber;
+	}
+
+	public void setStockOrgNumber(String stockOrgNumber) {
+		this.stockOrgNumber = stockOrgNumber;
+	}
+
+	public String getStockOrgName() {
+		return stockOrgName;
+	}
+
+	public void setStockOrgName(String stockOrgName) {
+		this.stockOrgName = stockOrgName;
+	}
+
+	public String getWorkshopNumber() {
+		return workshopNumber;
+	}
+
+	public void setWorkshopNumber(String workshopNumber) {
+		this.workshopNumber = workshopNumber;
+	}
+
+	public String getWorkshopName() {
+		return workshopName;
+	}
+
+	public void setWorkshopName(String workshopName) {
+		this.workshopName = workshopName;
+	}
+
+	public String getEntryId() {
+		return entryId;
+	}
+
+	public void setEntryId(String entryId) {
+		this.entryId = entryId;
+	}
+
+	public String getSeq() {
+		return seq;
+	}
+
+	public void setSeq(String seq) {
+		this.seq = seq;
+	}
+
+	public String getMaterialNumber() {
+		return materialNumber;
+	}
+
+	public void setMaterialNumber(String materialNumber) {
+		this.materialNumber = materialNumber;
+	}
+
+	public String getMaterialName() {
+		return materialName;
+	}
+
+	public void setMaterialName(String materialName) {
+		this.materialName = materialName;
+	}
+
+	public String getUnitName() {
+		return unitName;
+	}
+
+	public void setUnitName(String unitName) {
+		this.unitName = unitName;
+	}
+
+	public BigDecimal getAppQty() {
+		return appQty;
+	}
+
+	public void setAppQty(BigDecimal appQty) {
+		this.appQty = appQty;
+	}
+
+	public BigDecimal getActualQty() {
+		return actualQty;
+	}
+
+	public void setActualQty(BigDecimal actualQty) {
+		this.actualQty = actualQty;
+	}
+
+	public String getMoBillNo() {
+		return moBillNo;
+	}
+
+	public void setMoBillNo(String moBillNo) {
+		this.moBillNo = moBillNo;
+	}
+
+	public String getPpbomBillNo() {
+		return ppbomBillNo;
+	}
+
+	public void setPpbomBillNo(String ppbomBillNo) {
+		this.ppbomBillNo = ppbomBillNo;
+	}
+
+	public String getStockName() {
+		return stockName;
+	}
+
+	public void setStockName(String stockName) {
+		this.stockName = stockName;
+	}
+
+	public String getMoBillNoQuery() {
+		return moBillNoQuery;
+	}
+
+	public void setMoBillNoQuery(String moBillNoQuery) {
+		this.moBillNoQuery = moBillNoQuery;
+	}
+
+	public String getMaterialNumberQuery() {
+		return materialNumberQuery;
+	}
+
+	public void setMaterialNumberQuery(String materialNumberQuery) {
+		this.materialNumberQuery = materialNumberQuery;
+	}
+}

+ 265 - 0
src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialBillService.java

@@ -0,0 +1,265 @@
+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.util.ArrayList;
+import java.util.List;
+
+/**
+ * ERP 生产领料单列表 BillQuery(PRD_PickMtrl)。
+ */
+@Service
+public class ErpPickMaterialBillService {
+
+	private static final Logger logger = LoggerFactory.getLogger(ErpPickMaterialBillService.class);
+	private static final String FORM_ID = "PRD_PickMtrl";
+	private static final int VISIBLE_MO_LIMIT = 500;
+	/** 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(",",
+			"FID",
+			"FBILLNO",
+			"FDate",
+			"FDocumentStatus",
+			"FPrdOrgId.FNUMBER",
+			"FPrdOrgId.FNAME",
+			"FStockOrgId.FNUMBER",
+			"FStockOrgId.FNAME",
+			"FEntryWorkShopId.FNUMBER",
+			"FEntryWorkShopId.FNAME",
+			"FEntity_FEntryID",
+			"FEntity_FSeq",
+			"FMoEntrySeq",
+			MATERIAL_NUMBER_FIELD,
+			"FMaterialName",
+			"FUnitID.FNAME",
+			"FAppQty",
+			"FActualQty",
+			MO_BILL_NO_FIELD,
+			"FPPBomBillNo",
+			"FStockId.FNUMBER",
+			"FStockId.FNAME");
+
+	private final KingdeeErpClient erpClient;
+	private final KingdeeErpProperties erpProperties;
+	private final ErpProductionOrderService erpProductionOrderService;
+
+	public ErpPickMaterialBillService(KingdeeErpClient erpClient, KingdeeErpProperties erpProperties,
+			ErpProductionOrderService erpProductionOrderService) {
+		this.erpClient = erpClient;
+		this.erpProperties = erpProperties;
+		this.erpProductionOrderService = erpProductionOrderService;
+	}
+
+	public List<ErpPickMaterialBill> findPageList(ErpPickMaterialBill query, int startRow, int limit) {
+		String moFilter = buildProductionOrderFilter(query);
+		if (moFilter == null) {
+			return new ArrayList<>();
+		}
+		JSONObject data = new JSONObject();
+		data.put("FormId", FORM_ID);
+		data.put("FieldKeys", FIELD_KEYS);
+		data.put("FilterString", buildFilterString(query, moFilter));
+		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());
+
+		JSONArray rows = erpClient.billQuery(data);
+		List<ErpPickMaterialBill> list = new ArrayList<>();
+		for (int i = 0; i < rows.size(); i++) {
+			ErpPickMaterialBill bill = toRow(rows.getJSONObject(i), i);
+			if (StringUtils.isBlank(bill.getMoBillNo())) {
+				continue;
+			}
+			list.add(bill);
+		}
+		return list;
+	}
+
+	private ErpPickMaterialBill toRow(JSONObject row, int index) {
+		ErpPickMaterialBill bill = new ErpPickMaterialBill();
+		bill.setFid(readString(row, "FID"));
+		bill.setBillNo(readString(row, "FBILLNO", "FBillNo"));
+		bill.setEntryId(readString(row, "FEntity_FEntryID", "FEntity_FEntryId"));
+		bill.setSeq(firstNonBlank(
+				readString(row, "FEntity_FSeq"),
+				readString(row, "FMoEntrySeq")));
+		String rowId = bill.getBillNo();
+		if (StringUtils.isNotBlank(bill.getEntryId())) {
+			rowId = rowId + "-" + bill.getEntryId();
+		} else if (index >= 0) {
+			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"));
+		bill.setWorkshopNumber(readString(row,
+				"FEntryWorkShopId.FNUMBER", "FEntryWorkShopId.FNumber",
+				"FWorkShopId.FNUMBER", "FWorkShopId.FNumber"));
+		bill.setWorkshopName(readString(row,
+				"FEntryWorkShopId.FNAME", "FEntryWorkShopId.FName",
+				"FWorkShopId.FNAME", "FWorkShopId.FName"));
+		bill.setMaterialNumber(readString(row,
+				"FMaterialId.FNUMBER", "FMaterialId.FNumber",
+				"FEntity_FMaterialId.FNUMBER", "FEntity_FMaterialId.FNumber"));
+		bill.setMaterialName(readString(row,
+				"FMaterialName",
+				"FMaterialId.FNAME", "FMaterialId.FName",
+				"FEntity_FMaterialId.FNAME", "FEntity_FMaterialId.FName"));
+		bill.setUnitName(readString(row,
+				"FUnitID.FNAME", "FUnitId.FNAME", "FUnitId.FName",
+				"FEntity_FUnitId.FNAME", "FEntity_FUnitId.FName"));
+		bill.setAppQty(firstBigDecimal(row, "FAppQty", "FEntity_FAppQty"));
+		bill.setActualQty(firstBigDecimal(row, "FActualQty", "FEntity_FActualQty"));
+		bill.setMoBillNo(readString(row, MO_BILL_NO_FIELD, "FEntity_FMoBillNo"));
+		bill.setPpbomBillNo(readString(row, "FPPBomBillNo", "FEntity_FPPBomBillNo"));
+		bill.setStockName(readString(row,
+				"FStockId.FNAME", "FStockId.FName",
+				"FEntity_FStockId.FNAME", "FEntity_FStockId.FName"));
+		return bill;
+	}
+
+	/**
+	 * 按 MES 可见生产订单范围筛选;无关联生产订单的领料行不查。
+	 *
+	 * @return null 表示当前条件下无需请求 ERP(结果为空)
+	 */
+	private String buildProductionOrderFilter(ErpPickMaterialBill query) {
+		String moBillNo = query == null ? "" : StringUtils.trim(query.getMoBillNoQuery());
+		if (StringUtils.isNotBlank(moBillNo)) {
+			if (!erpProductionOrderService.isVisibleProductionOrder(moBillNo)) {
+				logger.info("ERP pick material bill skip: production order not in MES scope, moBillNo={}", moBillNo);
+				return null;
+			}
+			return MO_BILL_NO_FIELD + "='" + escape(moBillNo) + "'";
+		}
+		List<String> visibleBillNos = erpProductionOrderService.findVisibleBillNos(VISIBLE_MO_LIMIT);
+		if (visibleBillNos.isEmpty()) {
+			logger.info("ERP pick material bill skip: no visible production orders");
+			return null;
+		}
+		return buildInFilter(MO_BILL_NO_FIELD, visibleBillNos);
+	}
+
+	private String buildFilterString(ErpPickMaterialBill query, String moFilter) {
+		String filter = appendAnd(buildCurrentOrgFilter(), moFilter);
+		// 排除未关联生产订单的分录
+		filter = appendAnd(filter, MO_BILL_NO_FIELD + "<>''");
+		if (query == null) {
+			return filter;
+		}
+		if (StringUtils.isNotBlank(query.getBillNo())) {
+			filter = appendAnd(filter, "FBILLNO='" + escape(query.getBillNo()) + "'");
+		}
+		if (StringUtils.isNotBlank(query.getMaterialNumberQuery())) {
+			filter = appendAnd(filter,
+					MATERIAL_NUMBER_FIELD + " like '%" + escape(query.getMaterialNumberQuery()) + "%'");
+		}
+		return filter;
+	}
+
+	private String buildInFilter(String fieldName, List<String> values) {
+		StringBuilder builder = new StringBuilder(fieldName).append(" IN(");
+		for (int i = 0; i < values.size(); i++) {
+			if (i > 0) {
+				builder.append(",");
+			}
+			builder.append("'").append(escape(values.get(i))).append("'");
+		}
+		builder.append(")");
+		return builder.toString();
+	}
+
+	private String buildCurrentOrgFilter() {
+		if (!erpProperties.isFilterCurrentOrg()) {
+			return "";
+		}
+		if (StringUtils.isNotBlank(erpProperties.getOrgNumber())) {
+			return "FPrdOrgId.FNUMBER='" + escape(erpProperties.getOrgNumber()) + "'";
+		}
+		Long currentOrganizationId = erpClient.getCurrentOrganizationId();
+		if (currentOrganizationId == null) {
+			return "1=1";
+		}
+		return "FPrdOrgId=" + currentOrganizationId;
+	}
+
+	private String appendAnd(String left, String right) {
+		if (StringUtils.isBlank(left)) {
+			return right;
+		}
+		if (StringUtils.isBlank(right)) {
+			return left;
+		}
+		return left + " AND " + right;
+	}
+
+	private String escape(String value) {
+		return value == null ? "" : value.trim().replace("'", "''");
+	}
+
+	private String readString(JSONObject row, String... keys) {
+		for (String key : keys) {
+			String value = row.getString(key);
+			if (StringUtils.isNotBlank(value)) {
+				return value.trim();
+			}
+		}
+		return "";
+	}
+
+	private String firstNonBlank(String... values) {
+		for (String value : values) {
+			if (StringUtils.isNotBlank(value)) {
+				return value.trim();
+			}
+		}
+		return "";
+	}
+
+	private java.math.BigDecimal firstBigDecimal(JSONObject row, String... keys) {
+		for (String key : keys) {
+			java.math.BigDecimal value = row.getBigDecimal(key);
+			if (value != null) {
+				return value;
+			}
+		}
+		return null;
+	}
+
+	private String formatErpDate(String rawValue) {
+		if (StringUtils.isBlank(rawValue)) {
+			return "";
+		}
+		String trimmed = rawValue.trim();
+		return trimmed.length() >= 10 ? trimmed.substring(0, 10) : trimmed;
+	}
+
+	private String documentStatusName(String status) {
+		if ("A".equals(status)) {
+			return "创建";
+		}
+		if ("B".equals(status)) {
+			return "审核中";
+		}
+		if ("C".equals(status)) {
+			return "已审核";
+		}
+		if ("D".equals(status)) {
+			return "重新审核";
+		}
+		return status;
+	}
+}

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

@@ -0,0 +1,48 @@
+package com.jeesite.modules.mes.erp;
+
+import java.io.Serializable;
+
+/**
+ * ERP 生产领料单 Save 结果
+ */
+public class ErpPickMaterialResult implements Serializable {
+
+	private static final long serialVersionUID = 1L;
+
+	private boolean success;
+	private String pickBillNo;
+	private String pickBillId;
+	private String message;
+
+	public static ErpPickMaterialResult success(String pickBillNo, String pickBillId) {
+		ErpPickMaterialResult result = new ErpPickMaterialResult();
+		result.success = true;
+		result.pickBillNo = pickBillNo;
+		result.pickBillId = pickBillId;
+		result.message = "领料单保存成功";
+		return result;
+	}
+
+	public static ErpPickMaterialResult fail(String message) {
+		ErpPickMaterialResult result = new ErpPickMaterialResult();
+		result.success = false;
+		result.message = message;
+		return result;
+	}
+
+	public boolean isSuccess() {
+		return success;
+	}
+
+	public String getPickBillNo() {
+		return pickBillNo;
+	}
+
+	public String getPickBillId() {
+		return pickBillId;
+	}
+
+	public String getMessage() {
+		return message;
+	}
+}

+ 286 - 0
src/main/java/com/jeesite/modules/mes/erp/ErpPickMaterialService.java

@@ -0,0 +1,286 @@
+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.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * ERP 生产领料单 Save(方案B:Save + FEntity_Link)
+ */
+@Service
+public class ErpPickMaterialService {
+
+	private static final Logger logger = LoggerFactory.getLogger(ErpPickMaterialService.class);
+	private static final String FORM_ID = "PRD_PickMtrl";
+	private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+	private final KingdeeErpClient erpClient;
+	private final KingdeeErpProperties erpProperties;
+	private final ErpProductionMaterialService erpProductionMaterialService;
+
+	public ErpPickMaterialService(KingdeeErpClient erpClient,
+			KingdeeErpProperties erpProperties,
+			ErpProductionMaterialService erpProductionMaterialService) {
+		this.erpClient = erpClient;
+		this.erpProperties = erpProperties;
+		this.erpProductionMaterialService = erpProductionMaterialService;
+	}
+
+	public ErpPickMaterialResult submit(ErpPickMaterialSubmitRequest request) {
+		if (request == null || StringUtils.isBlank(request.getBillNo()) || StringUtils.isBlank(request.getEntryId())) {
+			return ErpPickMaterialResult.fail("生产订单号或分录内码不能为空");
+		}
+		if (request.getLines() == null || request.getLines().isEmpty()) {
+			return ErpPickMaterialResult.fail("请至少选择一行领料明细");
+		}
+
+		Map<String, ErpProductionMaterial> materialMap = erpProductionMaterialService.findMaterialMap(
+				request.getBillNo(), request.getEntryId());
+		if (materialMap.isEmpty()) {
+			return ErpPickMaterialResult.fail("未找到对应的生产用料清单");
+		}
+
+		List<PickLineContext> pickLines = new ArrayList<>();
+		for (ErpPickMaterialSubmitRequest.Line line : request.getLines()) {
+			if (line == null || StringUtils.isBlank(line.getPpbomEntryId())) {
+				continue;
+			}
+			BigDecimal pickQty = line.getPickQty();
+			if (pickQty == null || pickQty.compareTo(BigDecimal.ZERO) <= 0) {
+				continue;
+			}
+			ErpProductionMaterial material = materialMap.get(line.getPpbomEntryId());
+			if (material == null) {
+				return ErpPickMaterialResult.fail("用料清单分录不存在:" + line.getPpbomEntryId());
+			}
+			BigDecimal noPickedQty = material.getNoPickedQty() == null ? BigDecimal.ZERO : material.getNoPickedQty();
+			if (pickQty.compareTo(noPickedQty) > 0) {
+				return ErpPickMaterialResult.fail("物料 " + material.getMaterialNumber()
+						+ " 领料数量不能超过未领数量 " + noPickedQty);
+			}
+			String validateMsg = validateMaterial(material);
+			if (StringUtils.isNotBlank(validateMsg)) {
+				return ErpPickMaterialResult.fail(validateMsg);
+			}
+			pickLines.add(new PickLineContext(material, pickQty));
+		}
+
+		if (pickLines.isEmpty()) {
+			return ErpPickMaterialResult.fail("请填写大于 0 的领料数量");
+		}
+
+		JSONObject saveData = buildSaveData(pickLines);
+		logger.info("ERP pick material save request: {}", saveData.toJSONString());
+		JSONObject response = erpClient.save(FORM_ID, saveData);
+		return parseSaveResponse(response);
+	}
+
+	private String validateMaterial(ErpProductionMaterial material) {
+		if (StringUtils.isBlank(material.getMaterialNumber())) {
+			return "子项物料编码为空,无法领料";
+		}
+		if (StringUtils.isBlank(material.getStockNumber())) {
+			return "物料 " + material.getMaterialNumber() + " 发料仓库为空,无法领料";
+		}
+		if (StringUtils.isBlank(material.getUnitNumber())) {
+			return "物料 " + material.getMaterialNumber() + " 单位编码为空,无法领料";
+		}
+		return "";
+	}
+
+	private JSONObject buildSaveData(List<PickLineContext> pickLines) {
+		ErpProductionMaterial sample = pickLines.get(0).material;
+		String orgNumber = firstNonBlank(sample.getPrdOrgNumber(), erpProperties.getOrgNumber());
+		String stockOrgNumber = firstNonBlank(sample.getStockOrgNumber(), orgNumber);
+		String ownerNumber = firstNonBlank(sample.getOwnerNumber(), orgNumber);
+		String keeperNumber = firstNonBlank(sample.getKeeperNumber(), orgNumber);
+		String ownerType = firstNonBlank(sample.getOwnerTypeId(), erpProperties.getPickMtrlOwnerType());
+		String keeperType = firstNonBlank(sample.getKeeperTypeId(), erpProperties.getPickMtrlKeeperType());
+		String stockStatus = firstNonBlank(sample.getStockStatusNumber(), erpProperties.getPickMtrlStockStatus());
+
+		JSONObject model = new JSONObject(true);
+		model.put("FID", 0);
+		model.put("FBillType", numberObject(erpProperties.getPickMtrlBillType(), true));
+		model.put("FDate", LocalDate.now().format(DATE_FORMAT));
+		model.put("FPrdOrgId", numberObject(orgNumber, false));
+		model.put("FStockOrgId", numberObject(stockOrgNumber, false));
+		if (StringUtils.isNotBlank(sample.getWorkshopNumber())) {
+			model.put("FWorkShopId", numberObject(sample.getWorkshopNumber(), false));
+		}
+		model.put("FOwnerTypeId0", ownerType);
+		model.put("FOwnerId0", numberObject(ownerNumber, false));
+
+		JSONArray entityList = new JSONArray();
+		for (PickLineContext pickLine : pickLines) {
+			entityList.add(buildEntity(pickLine, orgNumber, stockOrgNumber, ownerType, ownerNumber,
+					keeperType, keeperNumber, stockStatus));
+		}
+		model.put("FEntity", entityList);
+
+		JSONObject saveData = new JSONObject(true);
+		saveData.put("NeedUpDateFields", new JSONArray());
+		saveData.put("NeedReturnFields", JSONArray.parseArray("[\"FBillNo\"]"));
+		saveData.put("IsDeleteEntry", "true");
+		saveData.put("IsVerifyBaseDataField", "false");
+		saveData.put("IsEntryBatchFill", "true");
+		saveData.put("ValidateFlag", "true");
+		saveData.put("NumberSearch", "true");
+		saveData.put("IsAutoAdjustField", "true");
+		saveData.put("IgnoreInterationFlag", "true");
+		saveData.put("Model", model);
+		return saveData;
+	}
+
+	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());
+
+		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("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);
+		entity.put("FStockId", numberObject(material.getStockNumber(), false));
+		entity.put("FStockStatusId", numberObject(stockStatus, false));
+		entity.put("FMoBillNo", material.getBillNo());
+		if (StringUtils.isNotBlank(material.getMoId())) {
+			entity.put("FMoId", parseLong(material.getMoId()));
+		}
+		entity.put("FMoEntryId", parseLong(material.getMoEntryId()));
+		if (StringUtils.isNotBlank(material.getMoEntrySeq())) {
+			entity.put("FMoEntrySeq", material.getMoEntrySeq());
+		}
+		entity.put("FPPBomBillNo", material.getPpbomNo());
+		entity.put("FPPBomEntryId", parseLong(material.getId()));
+		entity.put("FSrcBillType", "PRD_PPBOM");
+		entity.put("FSrcBillNo", material.getPpbomNo());
+		entity.put("FEntrySrcInterId", parseLong(material.getPpbomId()));
+		entity.put("FEntrySrcEnteryId", parseLong(material.getId()));
+		if (StringUtils.isNotBlank(material.getSeq())) {
+			entity.put("FEntrySrcEntrySeq", material.getSeq());
+		}
+		entity.put("FOwnerTypeId", ownerType);
+		entity.put("FOwnerId", numberObject(ownerNumber, false));
+		entity.put("FKeeperTypeId", keeperType);
+		entity.put("FKeeperId", numberObject(keeperNumber, false));
+		entity.put("FParentOwnerTypeId", ownerType);
+		entity.put("FParentOwnerId", numberObject(ownerNumber, false));
+		if (StringUtils.isNotBlank(material.getParentMaterialNumber())) {
+			entity.put("FParentMaterialId", numberObject(material.getParentMaterialNumber(), false));
+		}
+		if (StringUtils.isNotBlank(material.getWorkshopNumber())) {
+			entity.put("FEntryWorkShopId", numberObject(material.getWorkshopNumber(), false));
+		}
+		if (StringUtils.isNotBlank(material.getLotNumber())) {
+			entity.put("FLot", numberObject(material.getLotNumber(), false));
+		}
+		entity.put("FEntity_Link", buildEntityLink(material, qty));
+		return entity;
+	}
+
+	private JSONArray buildEntityLink(ErpProductionMaterial material, BigDecimal 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);
+		JSONArray links = new JSONArray();
+		links.add(link);
+		return links;
+	}
+
+	private JSONObject numberObject(String number, boolean useFNumberKey) {
+		JSONObject object = new JSONObject();
+		object.put(useFNumberKey ? "FNUMBER" : "FNumber", number);
+		return object;
+	}
+
+	private long parseLong(String value) {
+		if (StringUtils.isBlank(value)) {
+			return 0L;
+		}
+		return Long.parseLong(value.trim());
+	}
+
+	private String firstNonBlank(String... values) {
+		for (String value : values) {
+			if (StringUtils.isNotBlank(value)) {
+				return value.trim();
+			}
+		}
+		return "";
+	}
+
+	private ErpPickMaterialResult parseSaveResponse(JSONObject response) {
+		if (response == null) {
+			return ErpPickMaterialResult.fail("ERP 返回为空");
+		}
+		JSONObject resultWrapper = response.getJSONObject("Result");
+		JSONObject status = resultWrapper == null ? null : resultWrapper.getJSONObject("ResponseStatus");
+		if (status != null && Boolean.TRUE.equals(status.getBoolean("IsSuccess"))) {
+			JSONObject result = resultWrapper.getJSONObject("Result");
+			String billNo = result == null ? "" : result.getString("Number");
+			if (StringUtils.isBlank(billNo)) {
+				billNo = resultWrapper.getString("Number");
+			}
+			String billId = result == null ? "" : result.getString("Id");
+			if (StringUtils.isBlank(billId)) {
+				billId = resultWrapper.getString("Id");
+			}
+			return ErpPickMaterialResult.success(billNo, billId);
+		}
+		return ErpPickMaterialResult.fail(extractErrorMessage(status, response));
+	}
+
+	private String extractErrorMessage(JSONObject status, JSONObject response) {
+		if (status != null) {
+			JSONArray errors = status.getJSONArray("Errors");
+			if (errors != null && !errors.isEmpty()) {
+				StringBuilder builder = new StringBuilder();
+				for (int i = 0; i < errors.size(); i++) {
+					JSONObject error = errors.getJSONObject(i);
+					if (builder.length() > 0) {
+						builder.append(";");
+					}
+					builder.append(error.getString("Message"));
+				}
+				if (builder.length() > 0) {
+					return builder.toString();
+				}
+			}
+		}
+		return "ERP 领料单保存失败:" + response.toJSONString();
+	}
+
+	private static class PickLineContext {
+		private final ErpProductionMaterial material;
+		private final BigDecimal pickQty;
+
+		private PickLineContext(ErpProductionMaterial material, BigDecimal pickQty) {
+			this.material = material;
+			this.pickQty = pickQty;
+		}
+	}
+}

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

@@ -0,0 +1,66 @@
+package com.jeesite.modules.mes.erp;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 提交 ERP 生产领料单请求
+ */
+public class ErpPickMaterialSubmitRequest implements Serializable {
+
+	private static final long serialVersionUID = 1L;
+
+	private String billNo;
+	private String entryId;
+	private List<Line> lines = new ArrayList<>();
+
+	public String getBillNo() {
+		return billNo;
+	}
+
+	public void setBillNo(String billNo) {
+		this.billNo = billNo;
+	}
+
+	public String getEntryId() {
+		return entryId;
+	}
+
+	public void setEntryId(String entryId) {
+		this.entryId = entryId;
+	}
+
+	public List<Line> getLines() {
+		return lines;
+	}
+
+	public void setLines(List<Line> lines) {
+		this.lines = lines;
+	}
+
+	public static class Line implements Serializable {
+
+		private static final long serialVersionUID = 1L;
+
+		private String ppbomEntryId;
+		private BigDecimal pickQty;
+
+		public String getPpbomEntryId() {
+			return ppbomEntryId;
+		}
+
+		public void setPpbomEntryId(String ppbomEntryId) {
+			this.ppbomEntryId = ppbomEntryId;
+		}
+
+		public BigDecimal getPickQty() {
+			return pickQty;
+		}
+
+		public void setPickQty(BigDecimal pickQty) {
+			this.pickQty = pickQty;
+		}
+	}
+}

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

@@ -27,6 +27,21 @@ public class ErpProductionMaterial implements Serializable {
 	private String sourceStockName;
 	private String stockStatusName;
 	private String lotText;
+	private String unitNumber;
+	private String stockStatusNumber;
+	private String baseUnitNumber;
+	private String stockUnitNumber;
+	private String moId;
+	private String moEntrySeq;
+	private String ownerTypeId;
+	private String ownerNumber;
+	private String keeperTypeId;
+	private String keeperNumber;
+	private String parentMaterialNumber;
+	private String workshopNumber;
+	private String prdOrgNumber;
+	private String stockOrgNumber;
+	private String lotNumber;
 
 	public String getId() {
 		return id;
@@ -187,4 +202,124 @@ public class ErpProductionMaterial implements Serializable {
 	public void setLotText(String lotText) {
 		this.lotText = lotText;
 	}
+
+	public String getUnitNumber() {
+		return unitNumber;
+	}
+
+	public void setUnitNumber(String unitNumber) {
+		this.unitNumber = unitNumber;
+	}
+
+	public String getStockStatusNumber() {
+		return stockStatusNumber;
+	}
+
+	public void setStockStatusNumber(String stockStatusNumber) {
+		this.stockStatusNumber = stockStatusNumber;
+	}
+
+	public String getBaseUnitNumber() {
+		return baseUnitNumber;
+	}
+
+	public void setBaseUnitNumber(String baseUnitNumber) {
+		this.baseUnitNumber = baseUnitNumber;
+	}
+
+	public String getStockUnitNumber() {
+		return stockUnitNumber;
+	}
+
+	public void setStockUnitNumber(String stockUnitNumber) {
+		this.stockUnitNumber = stockUnitNumber;
+	}
+
+	public String getMoId() {
+		return moId;
+	}
+
+	public void setMoId(String moId) {
+		this.moId = moId;
+	}
+
+	public String getMoEntrySeq() {
+		return moEntrySeq;
+	}
+
+	public void setMoEntrySeq(String moEntrySeq) {
+		this.moEntrySeq = moEntrySeq;
+	}
+
+	public String getOwnerTypeId() {
+		return ownerTypeId;
+	}
+
+	public void setOwnerTypeId(String ownerTypeId) {
+		this.ownerTypeId = ownerTypeId;
+	}
+
+	public String getOwnerNumber() {
+		return ownerNumber;
+	}
+
+	public void setOwnerNumber(String ownerNumber) {
+		this.ownerNumber = ownerNumber;
+	}
+
+	public String getKeeperTypeId() {
+		return keeperTypeId;
+	}
+
+	public void setKeeperTypeId(String keeperTypeId) {
+		this.keeperTypeId = keeperTypeId;
+	}
+
+	public String getKeeperNumber() {
+		return keeperNumber;
+	}
+
+	public void setKeeperNumber(String keeperNumber) {
+		this.keeperNumber = keeperNumber;
+	}
+
+	public String getParentMaterialNumber() {
+		return parentMaterialNumber;
+	}
+
+	public void setParentMaterialNumber(String parentMaterialNumber) {
+		this.parentMaterialNumber = parentMaterialNumber;
+	}
+
+	public String getWorkshopNumber() {
+		return workshopNumber;
+	}
+
+	public void setWorkshopNumber(String workshopNumber) {
+		this.workshopNumber = workshopNumber;
+	}
+
+	public String getPrdOrgNumber() {
+		return prdOrgNumber;
+	}
+
+	public void setPrdOrgNumber(String prdOrgNumber) {
+		this.prdOrgNumber = prdOrgNumber;
+	}
+
+	public String getStockOrgNumber() {
+		return stockOrgNumber;
+	}
+
+	public void setStockOrgNumber(String stockOrgNumber) {
+		this.stockOrgNumber = stockOrgNumber;
+	}
+
+	public String getLotNumber() {
+		return lotNumber;
+	}
+
+	public void setLotNumber(String lotNumber) {
+		this.lotNumber = lotNumber;
+	}
 }

+ 70 - 6
src/main/java/com/jeesite/modules/mes/erp/ErpProductionMaterialService.java

@@ -8,7 +8,9 @@ import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
 @Service
 public class ErpProductionMaterialService {
@@ -36,6 +38,16 @@ public class ErpProductionMaterialService {
 		return findDetailByNumber(ppbom.getString("FBILLNO"), billNo, entryId);
 	}
 
+	public Map<String, ErpProductionMaterial> findMaterialMap(String billNo, String entryId) {
+		Map<String, ErpProductionMaterial> map = new LinkedHashMap<>();
+		for (ErpProductionMaterial material : findByProductionOrderEntry(billNo, entryId)) {
+			if (StringUtils.isNotBlank(material.getId())) {
+				map.put(material.getId(), material);
+			}
+		}
+		return map;
+	}
+
 	private JSONObject findPpbom(String billNo, String entryId) {
 		JSONObject direct = findPpbomByCandidateFields(billNo, entryId);
 		if (direct != null) {
@@ -119,7 +131,7 @@ public class ErpProductionMaterialService {
 			if (!entryId.equals(entry.getString("MoEntryId"))) {
 				continue;
 			}
-			list.add(toMaterial(entry, ppbomId, ppbomNo, billNo, entryId));
+			list.add(toMaterial(entry, result, ppbomId, ppbomNo, billNo, entryId));
 		}
 		return list;
 	}
@@ -160,7 +172,8 @@ public class ErpProductionMaterialService {
 		return false;
 	}
 
-	private ErpProductionMaterial toMaterial(JSONObject row, String ppbomId, String ppbomNo, String billNo, String entryId) {
+	private ErpProductionMaterial toMaterial(JSONObject row, JSONObject header, String ppbomId, String ppbomNo,
+			String billNo, String entryId) {
 		ErpProductionMaterial material = new ErpProductionMaterial();
 		material.setId(row.getString("Id"));
 		material.setPpbomId(ppbomId);
@@ -169,7 +182,7 @@ public class ErpProductionMaterialService {
 		material.setMoEntryId(entryId);
 		material.setSeq(row.getString("Seq"));
 		JSONObject materialObj = row.getJSONObject("MaterialID");
-		material.setMaterialNumber(materialObj == null ? "" : materialObj.getString("Number"));
+		material.setMaterialNumber(getNumber(materialObj));
 		material.setMaterialName(getNameValue(materialObj));
 		material.setSpecification(getSpecification(materialObj));
 		material.setMustQty(row.getBigDecimal("MustQty"));
@@ -177,17 +190,68 @@ public class ErpProductionMaterialService {
 		material.setNoPickedQty(row.getBigDecimal("NoPickedQty"));
 		material.setIssueQty(row.getBigDecimal("IssueQty"));
 		material.setUnitName(getNameValue(row.getJSONObject("UnitID")));
+		material.setUnitNumber(getNumber(row.getJSONObject("UnitID")));
+		material.setBaseUnitNumber(getNumber(firstObject(row, "BaseUnitID", "BaseUnitId")));
+		material.setStockUnitNumber(getNumber(firstObject(row, "StockUnitID", "StockUnitId")));
 		JSONObject stock = row.getJSONObject("StockID");
-		material.setStockNumber(stock == null ? "" : stock.getString("Number"));
+		material.setStockNumber(getNumber(stock));
 		material.setStockName(getNameValue(stock));
 		JSONObject sourceStock = row.getJSONObject("SrcTransStockId");
-		material.setSourceStockNumber(sourceStock == null ? "" : sourceStock.getString("Number"));
+		material.setSourceStockNumber(getNumber(sourceStock));
 		material.setSourceStockName(getNameValue(sourceStock));
-		material.setStockStatusName(getNameValue(row.getJSONObject("StockStatusId")));
+		JSONObject stockStatus = row.getJSONObject("StockStatusId");
+		material.setStockStatusName(getNameValue(stockStatus));
+		material.setStockStatusNumber(getNumber(stockStatus));
 		material.setLotText(row.getString("Lot_Text"));
+		JSONObject lot = row.getJSONObject("Lot");
+		material.setLotNumber(lot == null ? row.getString("Lot_Text") : lot.getString("Number"));
+		material.setMoId(firstNonBlank(row.getString("MoId"), header.getString("MoId")));
+		material.setMoEntrySeq(row.getString("MoEntrySeq"));
+		material.setOwnerTypeId(firstNonBlank(row.getString("OwnerTypeId"), header.getString("OwnerTypeId")));
+		material.setOwnerNumber(getNumber(firstObject(row, "OwnerID", "OwnerId")));
+		material.setKeeperTypeId(firstNonBlank(row.getString("KeeperTypeId"), header.getString("KeeperTypeId")));
+		material.setKeeperNumber(getNumber(firstObject(row, "KeeperID", "KeeperId")));
+		material.setParentMaterialNumber(getNumber(header.getJSONObject("MaterialID")));
+		material.setWorkshopNumber(getNumber(firstObject(header, "WorkShopID", "WorkshopID")));
+		material.setPrdOrgNumber(getNumber(header.getJSONObject("PrdOrgId")));
+		material.setStockOrgNumber(firstNonBlank(
+				getNumber(header.getJSONObject("SupplyOrg")),
+				getNumber(header.getJSONObject("StockOrgId")),
+				material.getPrdOrgNumber()));
 		return material;
 	}
 
+	private JSONObject firstObject(JSONObject row, String... keys) {
+		for (String key : keys) {
+			JSONObject object = row.getJSONObject(key);
+			if (object != null) {
+				return object;
+			}
+		}
+		return null;
+	}
+
+	private String getNumber(JSONObject object) {
+		if (object == null) {
+			return "";
+		}
+		String number = object.getString("Number");
+		if (StringUtils.isNotBlank(number)) {
+			return number.trim();
+		}
+		number = object.getString("FNumber");
+		return number == null ? "" : number.trim();
+	}
+
+	private String firstNonBlank(String... values) {
+		for (String value : values) {
+			if (StringUtils.isNotBlank(value)) {
+				return value.trim();
+			}
+		}
+		return "";
+	}
+
 	private String getSpecification(JSONObject object) {
 		if (object == null) {
 			return "";

+ 404 - 69
src/main/java/com/jeesite/modules/mes/erp/ErpProductionOrderService.java

@@ -16,6 +16,8 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
 
 @Service
 public class ErpProductionOrderService {
@@ -24,6 +26,14 @@ public class ErpProductionOrderService {
 	private static final String FORM_ID = "PRD_MO";
 	private static final Set<String> ALLOWED_INVENTORY_CATEGORIES =
 			new HashSet<>(Arrays.asList("HEV", "\u6b23\u65fa\u8fbe115", "115"));
+	private static final long VIEW_CATEGORY_CACHE_TTL_MS = TimeUnit.MINUTES.toMillis(10);
+	private static final int VIEW_PREFETCH_BATCH_SIZE = 8;
+	/** PRD_MO 明细「存货类别」View 返回字段 */
+	private static final String INVENTORY_CATEGORY_VIEW_FIELD = "F_XYLX_BaseProperty_jv4";
+
+	private final ConcurrentHashMap<String, ViewDetailCacheEntry> viewCategoryCache = new ConcurrentHashMap<>();
+	/** BillQuery 存货类别字段不可用时会自动降级为 View 兜底 */
+	private volatile boolean billQueryCategoryFieldDisabled = false;
 	private static final String HEADER_FIELD_KEYS = String.join(",",
 			"FID",
 			"FBILLNO",
@@ -40,12 +50,14 @@ public class ErpProductionOrderService {
 			"FDate",
 			"FPrdOrgId.FNAME",
 			"FWorkShopID0.FNAME",
+			"FWorkShopID.FNAME",
 			"FPlannerID.FNAME",
 			"FTreeEntity_FEntryId",
 			"FTreeEntity_FSeq",
 			"FMATERIALID.FNUMBER",
 			"FMATERIALID.FNAME",
 			"FSpecification",
+			"FMATERIALID.FSpecification",
 			"FQTY",
 			"FUnitId.FNAME",
 			"FPlanStartDate",
@@ -65,8 +77,102 @@ public class ErpProductionOrderService {
 		this.erpProperties = erpProperties;
 	}
 
+	/** 领料单列表等场景:取 MES 可见范围内的生产订单号 */
+	private static final int VISIBLE_BILL_NO_BATCH = 500;
+
+	public List<String> findVisibleBillNos(int limit) {
+		int fetchLimit = limit > 0 ? limit : VISIBLE_BILL_NO_BATCH;
+		List<ErpProductionOrder> orders = findPageList("", 0, fetchLimit);
+		List<String> billNos = new ArrayList<>();
+		for (ErpProductionOrder order : orders) {
+			if (order != null && StringUtils.isNotBlank(order.getBillNo())) {
+				billNos.add(order.getBillNo().trim());
+			}
+		}
+		return billNos;
+	}
+
+	public boolean isVisibleProductionOrder(String billNo) {
+		if (StringUtils.isBlank(billNo)) {
+			return false;
+		}
+		return !findPageList(billNo.trim(), 0, 1).isEmpty();
+	}
+
 	public List<ErpProductionOrder> findPageList(String billNo, int startRow, int limit) {
-		List<ErpProductionOrder> headers = findFilteredHeaderPageList(billNo, startRow, limit);
+		if (!erpProperties.isInventoryCategoryFilterEnabled()) {
+			return findPageListWithDetailSummary(billNo, startRow, limit);
+		}
+		if (useBillQueryCategoryField()) {
+			try {
+				return findAggregatedPageListByBillQuery(billNo, startRow, limit);
+			} catch (IllegalStateException ex) {
+				if (disableBillQueryCategoryFieldIfInvalid(ex)) {
+					return findAggregatedPageListByView(billNo, startRow, limit);
+				}
+				throw ex;
+			}
+		}
+		return findAggregatedPageListByView(billNo, startRow, limit);
+	}
+
+	private List<ErpProductionOrder> findAggregatedPageListByBillQuery(String billNo, 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, ErpProductionOrder> orderMap = new LinkedHashMap<>();
+
+		while (orderMap.size() < targetSize && fetchStart < maxFetchRows) {
+			List<ErpProductionOrder> chunk = findDetailCandidateList(billNo, fetchStart, fetchLimit);
+			if (chunk.isEmpty()) {
+				break;
+			}
+			billQueryCategoryFieldDisabled = false;
+			for (ErpProductionOrder detail : chunk) {
+				if (StringUtils.isBlank(detail.getInventoryCategory())) {
+					continue;
+				}
+				ErpProductionOrder order = orderMap.get(detail.getBillNo());
+				if (order == null) {
+					order = toHeaderFromDetail(detail);
+					order.setEntryCount(0);
+					order.setTotalQty(BigDecimal.ZERO);
+					orderMap.put(detail.getBillNo(), order);
+				}
+				order.setEntryCount(order.getEntryCount() + 1);
+				if (detail.getQty() != null) {
+					order.setTotalQty(order.getTotalQty().add(detail.getQty()));
+				}
+				mergeHeaderFieldsFromDetail(order, detail);
+			}
+			if (chunk.size() < fetchLimit) {
+				break;
+			}
+			fetchStart += fetchLimit;
+		}
+
+		List<ErpProductionOrder> allOrders = new ArrayList<>(orderMap.values());
+		if (startRow >= allOrders.size()) {
+			return new ArrayList<>();
+		}
+		return new ArrayList<>(allOrders.subList(startRow, Math.min(startRow + limit, allOrders.size())));
+	}
+
+	private List<ErpProductionOrder> findAggregatedPageListByView(String billNo, int startRow, int limit) {
+		Map<String, ViewDetailSummary> summaryCache = new LinkedHashMap<>();
+		List<ErpProductionOrder> headers = findFilteredHeaderPageListByView(billNo, startRow, limit, summaryCache);
+		for (ErpProductionOrder header : headers) {
+			ViewDetailSummary summary = getViewDetailSummary(header.getBillNo(), summaryCache);
+			header.setEntryCount(summary.entryCount);
+			header.setTotalQty(summary.totalQty);
+			mergeHeaderFieldsFromSummary(header, summary);
+		}
+		return headers;
+	}
+
+	private List<ErpProductionOrder> findPageListWithDetailSummary(String billNo, int startRow, int limit) {
+		List<ErpProductionOrder> headers = findHeaderPageList(billNo, startRow, limit);
 		if (headers.isEmpty()) {
 			return headers;
 		}
@@ -83,25 +189,74 @@ public class ErpProductionOrderService {
 			if (order == null) {
 				continue;
 			}
-			if (StringUtils.isBlank(order.getInventoryCategory())) {
-				order.setInventoryCategory(detail.getInventoryCategory());
-			}
 			order.setEntryCount(order.getEntryCount() + 1);
 			if (detail.getQty() != null) {
 				order.setTotalQty(order.getTotalQty().add(detail.getQty()));
 			}
+			mergeHeaderFieldsFromDetail(order, detail);
 		}
 		return new ArrayList<>(orderMap.values());
 	}
 
-	private List<ErpProductionOrder> findFilteredHeaderPageList(String billNo, int startRow, int limit) {
-		if (!erpProperties.isInventoryCategoryFilterEnabled()) {
-			return findHeaderPageList(billNo, startRow, limit);
+	private boolean useBillQueryCategoryField() {
+		return !billQueryCategoryFieldDisabled
+				&& erpProperties.isInventoryCategoryFilterEnabled()
+				&& StringUtils.isNotBlank(erpProperties.getInventoryCategoryField());
+	}
+
+	private boolean disableBillQueryCategoryFieldIfInvalid(IllegalStateException ex) {
+		if (!isBillQueryCategoryFieldError(ex)) {
+			return false;
+		}
+		billQueryCategoryFieldDisabled = true;
+		logger.warn("ERP BillQuery 存货类别字段 {} 不可用,已切换为 View 兜底:{}",
+				erpProperties.getInventoryCategoryField(), ex.getMessage());
+		return true;
+	}
+
+	private boolean isBillQueryCategoryFieldError(IllegalStateException ex) {
+		String message = ex.getMessage();
+		return message != null && message.contains("不存在");
+	}
+
+	private JSONArray executeDetailBillQuery(JSONObject data, boolean strictCategoryField) {
+		try {
+			return erpClient.billQuery(data);
+		} catch (IllegalStateException ex) {
+			String categoryField = erpProperties.getInventoryCategoryField();
+			if (disableBillQueryCategoryFieldIfInvalid(ex)) {
+				if (strictCategoryField) {
+					throw ex;
+				}
+				data.put("FieldKeys", DETAIL_FIELD_KEYS);
+				data.put("FilterString", stripCategoryFilter(data.getString("FilterString"), categoryField));
+				return erpClient.billQuery(data);
+			}
+			throw ex;
+		}
+	}
+
+	private String stripCategoryFilter(String filterString, String categoryField) {
+		if (StringUtils.isBlank(filterString) || StringUtils.isBlank(categoryField)) {
+			return filterString;
 		}
+		String escaped = java.util.regex.Pattern.quote(categoryField.trim());
+		String rebuilt = filterString.replaceAll("\\s*AND\\s*\\(" + escaped + "=[^)]*\\)", "")
+				.replaceAll("\\(" + escaped + "=[^)]*\\)\\s*AND\\s*", "")
+				.replaceAll("\\(" + escaped + "=[^)]*\\)", "")
+				.trim();
+		if (rebuilt.startsWith("AND ")) {
+			rebuilt = rebuilt.substring(4).trim();
+		}
+		return StringUtils.isBlank(rebuilt) ? buildCurrentOrgFilter() : rebuilt;
+	}
+
+	private List<ErpProductionOrder> findFilteredHeaderPageListByView(String billNo, int startRow, int limit,
+			Map<String, ViewDetailSummary> summaryCache) {
 		int targetSize = startRow + limit;
 		int fetchStart = 0;
 		int fetchLimit = Math.max(limit * 5, 50);
-		int maxFetchRows = Math.max(targetSize * 30, 500);
+		int maxFetchRows = Math.max(targetSize * 20, 300);
 		List<ErpProductionOrder> matchedHeaders = new ArrayList<>();
 
 		while (matchedHeaders.size() < targetSize && fetchStart < maxFetchRows) {
@@ -109,12 +264,16 @@ public class ErpProductionOrderService {
 			if (candidates.isEmpty()) {
 				break;
 			}
+			prefetchViewDetailSummaries(candidates, summaryCache);
 			for (ErpProductionOrder header : candidates) {
-				String category = findAllowedInventoryCategoryByView(header.getBillNo());
-				if (StringUtils.isBlank(category)) {
+				ViewDetailSummary summary = getViewDetailSummary(header.getBillNo(), summaryCache);
+				if (summary.entryCount <= 0) {
 					continue;
 				}
-				header.setInventoryCategory(category);
+				if (!summary.categories.isEmpty()) {
+					header.setInventoryCategory(summary.categories.values().iterator().next());
+				}
+				mergeHeaderFieldsFromSummary(header, summary);
 				matchedHeaders.add(header);
 				if (matchedHeaders.size() >= targetSize) {
 					break;
@@ -133,31 +292,7 @@ public class ErpProductionOrderService {
 	}
 
 	private List<ErpProductionOrder> findHeaderPageList(String billNo, int startRow, int limit) {
-		int targetSize = startRow + limit;
-		int fetchStart = 0;
-		int fetchLimit = Math.max(limit * 5, 100);
-		int maxFetchRows = Math.max(targetSize * 10, 500);
-		Map<String, ErpProductionOrder> uniqueHeaders = new LinkedHashMap<>();
-
-		while (uniqueHeaders.size() < targetSize && fetchStart < maxFetchRows) {
-			List<ErpProductionOrder> chunk = findDetailCandidateList(billNo, fetchStart, fetchLimit);
-			if (chunk.isEmpty()) {
-				break;
-			}
-			for (ErpProductionOrder detail : chunk) {
-				uniqueHeaders.putIfAbsent(detail.getBillNo(), toHeaderFromDetail(detail));
-			}
-			if (chunk.size() < fetchLimit) {
-				break;
-			}
-			fetchStart += fetchLimit;
-		}
-
-		List<ErpProductionOrder> allHeaders = new ArrayList<>(uniqueHeaders.values());
-		if (startRow >= allHeaders.size()) {
-			return new ArrayList<>();
-		}
-		return new ArrayList<>(allHeaders.subList(startRow, Math.min(startRow + limit, allHeaders.size())));
+		return findHeaderList(billNo, startRow, limit);
 	}
 
 	private List<ErpProductionOrder> findDetailCandidateList(String billNo, int startRow, int limit) {
@@ -170,7 +305,7 @@ public class ErpProductionOrderService {
 		data.put("Limit", Math.max(limit, 1));
 		logger.info("ERP production order candidate query: {}", data.toJSONString());
 
-		JSONArray rows = erpClient.billQuery(data);
+		JSONArray rows = executeDetailBillQuery(data, useBillQueryCategoryField());
 		List<ErpProductionOrder> list = new ArrayList<>();
 		for (int i = 0; i < rows.size(); i++) {
 			list.add(toDetail(rows.getJSONObject(i), i));
@@ -212,14 +347,20 @@ public class ErpProductionOrderService {
 		data.put("Limit", 2000);
 		logger.info("ERP production order detail query: {}", data.toJSONString());
 
-		JSONArray rows = erpClient.billQuery(data);
+		JSONArray rows = executeDetailBillQuery(data, false);
 		List<ErpProductionOrder> list = new ArrayList<>();
-		Map<String, Map<String, String>> categoryCache = new LinkedHashMap<>();
+		boolean useBillQueryCategory = useBillQueryCategoryField();
+		boolean useViewCategory = erpProperties.isInventoryCategoryFilterEnabled() && !useBillQueryCategory;
+		Map<String, ViewDetailSummary> summaryCache = new LinkedHashMap<>();
+		if (useViewCategory) {
+			prefetchViewDetailSummariesByBillNos(billNos, summaryCache);
+		}
 		for (int i = 0; i < rows.size(); i++) {
 			ErpProductionOrder detail = toDetail(rows.getJSONObject(i), i);
-			Map<String, String> categories = categoryCache.computeIfAbsent(detail.getBillNo(),
-					this::findAllowedInventoryCategoriesByView);
-			detail.setInventoryCategory(categories.get(detail.getEntryId()));
+			if (useViewCategory) {
+				detail.setInventoryCategory(getViewDetailSummary(detail.getBillNo(), summaryCache)
+						.categories.get(detail.getEntryId()));
+			}
 			if (!erpProperties.isInventoryCategoryFilterEnabled()
 					|| StringUtils.isNotBlank(detail.getInventoryCategory())) {
 				list.add(detail);
@@ -235,9 +376,9 @@ public class ErpProductionOrderService {
 		order.setBillNo(row.getString("FBILLNO"));
 		order.setDocumentStatus(row.getString("FDocumentStatus"));
 		order.setDocumentStatusName(documentStatusName(order.getDocumentStatus()));
-		order.setDate(row.getString("FDate"));
+		order.setDate(formatErpDate(row.getString("FDate")));
 		order.setPrdOrgName(row.getString("FPrdOrgId.FNAME"));
-		order.setWorkshopName(row.getString("FWorkShopID0.FNAME"));
+		order.setWorkshopName(readWorkshopName(row));
 		order.setPlannerName(row.getString("FPlannerID.FNAME"));
 		return order;
 	}
@@ -266,17 +407,17 @@ public class ErpProductionOrderService {
 		order.setId(order.getBillNo() + "-" + (StringUtils.isBlank(order.getEntryId()) ? index : order.getEntryId()));
 		order.setDocumentStatus(row.getString("FDocumentStatus"));
 		order.setDocumentStatusName(documentStatusName(order.getDocumentStatus()));
-		order.setDate(row.getString("FDate"));
+		order.setDate(formatErpDate(row.getString("FDate")));
 		order.setPrdOrgName(row.getString("FPrdOrgId.FNAME"));
-		order.setWorkshopName(row.getString("FWorkShopID0.FNAME"));
+		order.setWorkshopName(readWorkshopName(row));
 		order.setPlannerName(row.getString("FPlannerID.FNAME"));
 		order.setMaterialNumber(row.getString("FMATERIALID.FNUMBER"));
 		order.setMaterialName(row.getString("FMATERIALID.FNAME"));
-		order.setSpecification(row.getString("FSpecification"));
+		order.setSpecification(readSpecification(row));
 		order.setQty(row.getBigDecimal("FQTY"));
 		order.setUnitName(row.getString("FUnitId.FNAME"));
-		order.setPlanStartDate(row.getString("FPlanStartDate"));
-		order.setPlanFinishDate(row.getString("FPlanFinishDate"));
+		order.setPlanStartDate(formatErpDate(row.getString("FPlanStartDate")));
+		order.setPlanFinishDate(formatErpDate(row.getString("FPlanFinishDate")));
 		order.setBusinessStatus(row.getString("FStatus"));
 		order.setBusinessStatusName(businessStatusName(order.getBusinessStatus()));
 		order.setStockName(row.getString("FStockId.FNAME"));
@@ -284,20 +425,79 @@ public class ErpProductionOrderService {
 		order.setRoutingNumber(row.getString("FRoutingId.FNUMBER"));
 		order.setProjectNo(row.getString("FProjectNo"));
 		order.setMtoNo(row.getString("FMTONO"));
+		String categoryField = erpProperties.getInventoryCategoryField();
+		if (StringUtils.isNotBlank(categoryField)) {
+			order.setInventoryCategory(normalizeInventoryCategory(row.getString(categoryField.trim())));
+		}
 		return order;
 	}
 
-	private String findAllowedInventoryCategoryByView(String billNo) {
-		Map<String, String> categories = findAllowedInventoryCategoriesByView(billNo);
-		if (categories.isEmpty()) {
+	private String normalizeInventoryCategory(String rawValue) {
+		if (StringUtils.isBlank(rawValue)) {
 			return "";
 		}
-		return categories.values().iterator().next();
+		String trimmed = rawValue.trim();
+		if (isAllowedCategory(trimmed)) {
+			return trimmed;
+		}
+		return "";
+	}
+
+	private void prefetchViewDetailSummaries(List<ErpProductionOrder> headers,
+			Map<String, ViewDetailSummary> summaryCache) {
+		List<String> billNos = new ArrayList<>();
+		for (ErpProductionOrder header : headers) {
+			if (header != null && StringUtils.isNotBlank(header.getBillNo())) {
+				billNos.add(header.getBillNo());
+			}
+		}
+		prefetchViewDetailSummariesByBillNos(billNos, summaryCache);
+	}
+
+	private void prefetchViewDetailSummariesByBillNos(List<String> billNos, Map<String, ViewDetailSummary> summaryCache) {
+		List<String> pendingBillNos = new ArrayList<>();
+		for (String billNo : billNos) {
+			if (StringUtils.isBlank(billNo) || summaryCache.containsKey(billNo)
+					|| hasValidViewDetailCache(billNo)) {
+				continue;
+			}
+			pendingBillNos.add(billNo);
+		}
+		for (int i = 0; i < pendingBillNos.size(); i += VIEW_PREFETCH_BATCH_SIZE) {
+			int end = Math.min(i + VIEW_PREFETCH_BATCH_SIZE, pendingBillNos.size());
+			List<String> batch = pendingBillNos.subList(i, end);
+			batch.parallelStream().forEach(billNo -> getViewDetailSummary(billNo, summaryCache));
+		}
 	}
 
-	private Map<String, String> findAllowedInventoryCategoriesByView(String billNo) {
+	private ViewDetailSummary getViewDetailSummary(String billNo, Map<String, ViewDetailSummary> summaryCache) {
 		if (StringUtils.isBlank(billNo)) {
-			return Collections.emptyMap();
+			return ViewDetailSummary.empty();
+		}
+		ViewDetailSummary cachedInRequest = summaryCache.get(billNo);
+		if (cachedInRequest != null) {
+			return cachedInRequest;
+		}
+		ViewDetailCacheEntry cachedEntry = viewCategoryCache.get(billNo);
+		if (cachedEntry != null && cachedEntry.expireAt > System.currentTimeMillis()) {
+			summaryCache.put(billNo, cachedEntry.summary);
+			return cachedEntry.summary;
+		}
+		ViewDetailSummary summary = loadViewDetailSummary(billNo);
+		viewCategoryCache.put(billNo, new ViewDetailCacheEntry(
+				System.currentTimeMillis() + VIEW_CATEGORY_CACHE_TTL_MS, summary));
+		summaryCache.put(billNo, summary);
+		return summary;
+	}
+
+	private boolean hasValidViewDetailCache(String billNo) {
+		ViewDetailCacheEntry cachedEntry = viewCategoryCache.get(billNo);
+		return cachedEntry != null && cachedEntry.expireAt > System.currentTimeMillis();
+	}
+
+	private ViewDetailSummary loadViewDetailSummary(String billNo) {
+		if (StringUtils.isBlank(billNo)) {
+			return ViewDetailSummary.empty();
 		}
 		JSONObject data = new JSONObject();
 		data.put("CreateOrgId", 0);
@@ -309,40 +509,58 @@ public class ErpProductionOrderService {
 		JSONObject status = resultWrapper == null ? null : resultWrapper.getJSONObject("ResponseStatus");
 		if (status == null || !Boolean.TRUE.equals(status.getBoolean("IsSuccess"))) {
 			logger.warn("ERP production order view failed, billNo={}, response={}", billNo, response.toJSONString());
-			return Collections.emptyMap();
+			return ViewDetailSummary.empty();
 		}
 		JSONObject result = resultWrapper.getJSONObject("Result");
 		if (result == null) {
-			return Collections.emptyMap();
+			return ViewDetailSummary.empty();
 		}
 		JSONArray treeEntity = result.getJSONArray("TreeEntity");
 		if (treeEntity == null) {
-			return Collections.emptyMap();
+			return ViewDetailSummary.empty();
 		}
-		Map<String, String> categories = new LinkedHashMap<>();
+		ViewDetailSummary summary = new ViewDetailSummary();
+		summary.plannerName = getNameValue(result.getJSONObject("PlannerId"));
+		summary.workshopName = getNameValue(result.getJSONObject("WorkShopId0"));
 		for (int i = 0; i < treeEntity.size(); i++) {
 			JSONObject entry = treeEntity.getJSONObject(i);
+			if (StringUtils.isBlank(summary.workshopName)) {
+				summary.workshopName = getNameValue(entry.getJSONObject("WorkShopId"));
+			}
 			String category = extractAllowedCategory(entry);
-			if (StringUtils.isNotBlank(category)) {
-				categories.put(entry.getString("Id"), category);
+			if (StringUtils.isBlank(category)) {
+				continue;
+			}
+			summary.categories.put(entry.getString("Id"), category);
+			summary.entryCount++;
+			BigDecimal qty = entry.getBigDecimal("Qty");
+			if (qty == null) {
+				qty = entry.getBigDecimal("FQty");
+			}
+			if (qty != null) {
+				summary.totalQty = summary.totalQty.add(qty);
 			}
 		}
-		return categories;
+		return summary;
 	}
 
 	private String extractAllowedCategory(JSONObject entry) {
 		if (entry == null) {
 			return "";
 		}
+		String category = extractAllowedCategoryFromBaseProperty(entry.get(INVENTORY_CATEGORY_VIEW_FIELD));
+		if (StringUtils.isNotBlank(category)) {
+			return category;
+		}
 		JSONObject material = entry.getJSONObject("MaterialId");
 		JSONArray materialBase = material == null ? null : material.getJSONArray("MaterialBase");
 		if (materialBase == null) {
 			return "";
 		}
 		for (int i = 0; i < materialBase.size(); i++) {
-			JSONObject category = materialBase.getJSONObject(i).getJSONObject("CategoryID");
-			String number = category == null ? "" : category.getString("Number");
-			String name = getNameValue(category);
+			JSONObject categoryObj = materialBase.getJSONObject(i).getJSONObject("CategoryID");
+			String number = categoryObj == null ? "" : categoryObj.getString("Number");
+			String name = getNameValue(categoryObj);
 			if (isAllowedCategory(number)) {
 				return number;
 			}
@@ -353,6 +571,29 @@ public class ErpProductionOrderService {
 		return "";
 	}
 
+	private String extractAllowedCategoryFromBaseProperty(Object propertyValue) {
+		if (propertyValue == null) {
+			return "";
+		}
+		if (propertyValue instanceof String) {
+			String text = ((String) propertyValue).trim();
+			return isAllowedCategory(text) ? text : "";
+		}
+		if (!(propertyValue instanceof JSONObject)) {
+			return "";
+		}
+		JSONObject baseProperty = (JSONObject) propertyValue;
+		String number = StringUtils.defaultIfBlank(baseProperty.getString("Number"), baseProperty.getString("FNumber"));
+		if (isAllowedCategory(number)) {
+			return number.trim();
+		}
+		String name = getNameValue(baseProperty);
+		if (isAllowedCategory(name)) {
+			return name;
+		}
+		return "";
+	}
+
 	private String getNameValue(JSONObject object) {
 		if (object == null) {
 			return "";
@@ -364,6 +605,56 @@ public class ErpProductionOrderService {
 		return nameArray.getJSONObject(0).getString("Value");
 	}
 
+	private String firstNonBlank(String... values) {
+		if (values == null) {
+			return "";
+		}
+		for (String value : values) {
+			if (StringUtils.isNotBlank(value)) {
+				return value.trim();
+			}
+		}
+		return "";
+	}
+
+	/** BillQuery 明细行优先取 FWorkShopID,表头 FWorkShopID0 兜底 */
+	private String readWorkshopName(JSONObject row) {
+		return firstNonBlank(
+				row.getString("FWorkShopID.FNAME"),
+				row.getString("FWorkShopID0.FNAME"));
+	}
+
+	/** 明细规格优先取行字段,物料主数据规格兜底 */
+	private String readSpecification(JSONObject row) {
+		return firstNonBlank(
+				row.getString("FSpecification"),
+				row.getString("FMATERIALID.FSpecification"));
+	}
+
+	private void mergeHeaderFieldsFromDetail(ErpProductionOrder header, ErpProductionOrder detail) {
+		if (header == null || detail == null) {
+			return;
+		}
+		if (StringUtils.isBlank(header.getWorkshopName())) {
+			header.setWorkshopName(detail.getWorkshopName());
+		}
+		if (StringUtils.isBlank(header.getPlannerName())) {
+			header.setPlannerName(detail.getPlannerName());
+		}
+	}
+
+	private void mergeHeaderFieldsFromSummary(ErpProductionOrder header, ViewDetailSummary summary) {
+		if (header == null || summary == null) {
+			return;
+		}
+		if (StringUtils.isBlank(header.getWorkshopName())) {
+			header.setWorkshopName(summary.workshopName);
+		}
+		if (StringUtils.isBlank(header.getPlannerName())) {
+			header.setPlannerName(summary.plannerName);
+		}
+	}
+
 	private boolean isAllowedCategory(String value) {
 		if (StringUtils.isBlank(value)) {
 			return false;
@@ -372,16 +663,27 @@ public class ErpProductionOrderService {
 	}
 
 	private String buildFilterString(String billNo) {
-		String orgFilter = buildCurrentOrgFilter();
-		String baseFilter = orgFilter;
+		String baseFilter = appendAnd(buildCurrentOrgFilter(), buildInventoryCategoryFilter());
 		if (StringUtils.isBlank(billNo)) {
 			return baseFilter;
 		}
 		return appendAnd(baseFilter, "FBILLNO='" + billNo.trim().replace("'", "''") + "'");
 	}
 
+	private String buildInventoryCategoryFilter() {
+		if (!useBillQueryCategoryField()) {
+			return "";
+		}
+		String categoryField = erpProperties.getInventoryCategoryField();
+		List<String> parts = new ArrayList<>();
+		for (String category : ALLOWED_INVENTORY_CATEGORIES) {
+			parts.add(categoryField.trim() + "='" + category.replace("'", "''") + "'");
+		}
+		return "(" + String.join(" OR ", parts) + ")";
+	}
+
 	private String buildInFilterString(List<String> billNos) {
-		String baseFilter = buildCurrentOrgFilter();
+		String baseFilter = appendAnd(buildCurrentOrgFilter(), buildInventoryCategoryFilter());
 		StringBuilder builder = new StringBuilder(baseFilter);
 		if (builder.length() > 0) {
 			builder.append(" AND ");
@@ -428,7 +730,40 @@ public class ErpProductionOrderService {
 	}
 
 	private String buildDetailFieldKeys() {
-		return DETAIL_FIELD_KEYS;
+		if (!useBillQueryCategoryField()) {
+			return DETAIL_FIELD_KEYS;
+		}
+		return DETAIL_FIELD_KEYS + "," + erpProperties.getInventoryCategoryField().trim();
+	}
+
+	private static class ViewDetailSummary {
+		private final Map<String, String> categories = new LinkedHashMap<>();
+		private int entryCount;
+		private BigDecimal totalQty = BigDecimal.ZERO;
+		private String workshopName = "";
+		private String plannerName = "";
+
+		private static ViewDetailSummary empty() {
+			return new ViewDetailSummary();
+		}
+	}
+
+	private static class ViewDetailCacheEntry {
+		private final long expireAt;
+		private final ViewDetailSummary summary;
+
+		private ViewDetailCacheEntry(long expireAt, ViewDetailSummary summary) {
+			this.expireAt = expireAt;
+			this.summary = summary;
+		}
+	}
+
+	private String formatErpDate(String rawValue) {
+		if (StringUtils.isBlank(rawValue)) {
+			return "";
+		}
+		String trimmed = rawValue.trim();
+		return trimmed.length() >= 10 ? trimmed.substring(0, 10) : trimmed;
 	}
 
 	private String documentStatusName(String status) {

+ 259 - 0
src/main/java/com/jeesite/modules/mes/erp/ErpPurchaseOrder.java

@@ -0,0 +1,259 @@
+package com.jeesite.modules.mes.erp;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * ERP 采购订单(PUR_PurchaseOrder)BillQuery 数据。
+ */
+public class ErpPurchaseOrder implements Serializable {
+
+	private static final long serialVersionUID = 1L;
+
+	private String id;
+	private String fid;
+	private String billNo;
+	private String documentStatus;
+	private String documentStatusName;
+	private String date;
+	private String billTypeNumber;
+	private String billTypeName;
+	private String businessType;
+	private String businessTypeName;
+	private String purchaseOrgName;
+	private String supplierNumber;
+	private String supplierName;
+	private Integer entryCount;
+	private BigDecimal totalQty;
+	private String entryId;
+	private String seq;
+	private String materialNumber;
+	private String materialName;
+	private String unitName;
+	private BigDecimal qty;
+	private String deliveryDate;
+	private BigDecimal price;
+	private BigDecimal amount;
+	private String sourceBillNo;
+
+	/** 查询条件:供应商编码(模糊) */
+	private String supplierNumberQuery;
+	/** 查询条件:物料编码(模糊) */
+	private String materialNumberQuery;
+
+	public String getId() {
+		return id;
+	}
+
+	public void setId(String id) {
+		this.id = id;
+	}
+
+	public String getFid() {
+		return fid;
+	}
+
+	public void setFid(String fid) {
+		this.fid = fid;
+	}
+
+	public String getBillNo() {
+		return billNo;
+	}
+
+	public void setBillNo(String billNo) {
+		this.billNo = billNo;
+	}
+
+	public String getDocumentStatus() {
+		return documentStatus;
+	}
+
+	public void setDocumentStatus(String documentStatus) {
+		this.documentStatus = documentStatus;
+	}
+
+	public String getDocumentStatusName() {
+		return documentStatusName;
+	}
+
+	public void setDocumentStatusName(String documentStatusName) {
+		this.documentStatusName = documentStatusName;
+	}
+
+	public String getDate() {
+		return date;
+	}
+
+	public void setDate(String date) {
+		this.date = date;
+	}
+
+	public String getBillTypeNumber() {
+		return billTypeNumber;
+	}
+
+	public void setBillTypeNumber(String billTypeNumber) {
+		this.billTypeNumber = billTypeNumber;
+	}
+
+	public String getBillTypeName() {
+		return billTypeName;
+	}
+
+	public void setBillTypeName(String billTypeName) {
+		this.billTypeName = billTypeName;
+	}
+
+	public String getBusinessType() {
+		return businessType;
+	}
+
+	public void setBusinessType(String businessType) {
+		this.businessType = businessType;
+	}
+
+	public String getBusinessTypeName() {
+		return businessTypeName;
+	}
+
+	public void setBusinessTypeName(String businessTypeName) {
+		this.businessTypeName = businessTypeName;
+	}
+
+	public String getPurchaseOrgName() {
+		return purchaseOrgName;
+	}
+
+	public void setPurchaseOrgName(String purchaseOrgName) {
+		this.purchaseOrgName = purchaseOrgName;
+	}
+
+	public String getSupplierNumber() {
+		return supplierNumber;
+	}
+
+	public void setSupplierNumber(String supplierNumber) {
+		this.supplierNumber = supplierNumber;
+	}
+
+	public String getSupplierName() {
+		return supplierName;
+	}
+
+	public void setSupplierName(String supplierName) {
+		this.supplierName = supplierName;
+	}
+
+	public Integer getEntryCount() {
+		return entryCount;
+	}
+
+	public void setEntryCount(Integer entryCount) {
+		this.entryCount = entryCount;
+	}
+
+	public BigDecimal getTotalQty() {
+		return totalQty;
+	}
+
+	public void setTotalQty(BigDecimal totalQty) {
+		this.totalQty = totalQty;
+	}
+
+	public String getEntryId() {
+		return entryId;
+	}
+
+	public void setEntryId(String entryId) {
+		this.entryId = entryId;
+	}
+
+	public String getSeq() {
+		return seq;
+	}
+
+	public void setSeq(String seq) {
+		this.seq = seq;
+	}
+
+	public String getMaterialNumber() {
+		return materialNumber;
+	}
+
+	public void setMaterialNumber(String materialNumber) {
+		this.materialNumber = materialNumber;
+	}
+
+	public String getMaterialName() {
+		return materialName;
+	}
+
+	public void setMaterialName(String materialName) {
+		this.materialName = materialName;
+	}
+
+	public String getUnitName() {
+		return unitName;
+	}
+
+	public void setUnitName(String unitName) {
+		this.unitName = unitName;
+	}
+
+	public BigDecimal getQty() {
+		return qty;
+	}
+
+	public void setQty(BigDecimal qty) {
+		this.qty = qty;
+	}
+
+	public String getDeliveryDate() {
+		return deliveryDate;
+	}
+
+	public void setDeliveryDate(String deliveryDate) {
+		this.deliveryDate = deliveryDate;
+	}
+
+	public BigDecimal getPrice() {
+		return price;
+	}
+
+	public void setPrice(BigDecimal price) {
+		this.price = price;
+	}
+
+	public BigDecimal getAmount() {
+		return amount;
+	}
+
+	public void setAmount(BigDecimal amount) {
+		this.amount = amount;
+	}
+
+	public String getSourceBillNo() {
+		return sourceBillNo;
+	}
+
+	public void setSourceBillNo(String sourceBillNo) {
+		this.sourceBillNo = sourceBillNo;
+	}
+
+	public String getSupplierNumberQuery() {
+		return supplierNumberQuery;
+	}
+
+	public void setSupplierNumberQuery(String supplierNumberQuery) {
+		this.supplierNumberQuery = supplierNumberQuery;
+	}
+
+	public String getMaterialNumberQuery() {
+		return materialNumberQuery;
+	}
+
+	public void setMaterialNumberQuery(String materialNumberQuery) {
+		this.materialNumberQuery = materialNumberQuery;
+	}
+}

+ 369 - 0
src/main/java/com/jeesite/modules/mes/erp/ErpPurchaseOrderService.java

@@ -0,0 +1,369 @@
+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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * ERP 采购订单 BillQuery(PUR_PurchaseOrder),默认仅查标准采购订单。
+ */
+@Service
+public class ErpPurchaseOrderService {
+
+	private static final Logger logger = LoggerFactory.getLogger(ErpPurchaseOrderService.class);
+	private static final String FORM_ID = "PUR_PurchaseOrder";
+	private static final String BILL_TYPE_FIELD = "FBillTypeID.FNUMBER";
+	private static final String MATERIAL_NUMBER_FIELD = "FMaterialId.FNUMBER";
+	private static final String SUPPLIER_NUMBER_FIELD = "FSupplierId.FNUMBER";
+	private static final String HEADER_FIELD_KEYS = String.join(",",
+			"FID",
+			"FBILLNO",
+			"FDate",
+			"FDocumentStatus",
+			BILL_TYPE_FIELD,
+			"FBillTypeID.FNAME",
+			"FBusinessType",
+			"FPurchaseOrgId.FNAME",
+			SUPPLIER_NUMBER_FIELD,
+			"FSupplierId.FNAME");
+	private static final String DETAIL_FIELD_KEYS = String.join(",",
+			"FID",
+			"FBILLNO",
+			"FDate",
+			"FDocumentStatus",
+			BILL_TYPE_FIELD,
+			"FBillTypeID.FNAME",
+			"FBusinessType",
+			"FPurchaseOrgId.FNAME",
+			SUPPLIER_NUMBER_FIELD,
+			"FSupplierId.FNAME",
+			"FSourceBillNo",
+			"FPOOrderEntry_FEntryID",
+			"FPOOrderEntry_FSeq",
+			MATERIAL_NUMBER_FIELD,
+			"FMaterialName",
+			"FUnitID.FNAME",
+			"FQty",
+			"FDeliveryDate",
+			"FPrice",
+			"FAmount");
+
+	private final KingdeeErpClient erpClient;
+	private final KingdeeErpProperties erpProperties;
+
+	public ErpPurchaseOrderService(KingdeeErpClient erpClient, KingdeeErpProperties erpProperties) {
+		this.erpClient = erpClient;
+		this.erpProperties = erpProperties;
+	}
+
+	public List<ErpPurchaseOrder> findPageList(ErpPurchaseOrder query, int startRow, int limit) {
+		if (StringUtils.isNotBlank(query == null ? "" : query.getMaterialNumberQuery())) {
+			return findAggregatedPageList(query, startRow, limit);
+		}
+		List<ErpPurchaseOrder> headers = findHeaderList(query, startRow, limit);
+		if (headers.isEmpty()) {
+			return headers;
+		}
+		List<String> billNos = new ArrayList<>();
+		Map<String, ErpPurchaseOrder> orderMap = new LinkedHashMap<>();
+		for (ErpPurchaseOrder header : headers) {
+			header.setEntryCount(0);
+			header.setTotalQty(BigDecimal.ZERO);
+			orderMap.put(header.getBillNo(), header);
+			billNos.add(header.getBillNo());
+		}
+		for (ErpPurchaseOrder detail : findDetailListByBillNos(billNos)) {
+			ErpPurchaseOrder header = orderMap.get(detail.getBillNo());
+			if (header == null) {
+				continue;
+			}
+			header.setEntryCount(header.getEntryCount() + 1);
+			if (detail.getQty() != null) {
+				header.setTotalQty(header.getTotalQty().add(detail.getQty()));
+			}
+		}
+		return new ArrayList<>(orderMap.values());
+	}
+
+	public List<ErpPurchaseOrder> findDetailList(String billNo) {
+		if (StringUtils.isBlank(billNo)) {
+			return new ArrayList<>();
+		}
+		List<String> billNos = new ArrayList<>();
+		billNos.add(billNo.trim());
+		return findDetailListByBillNos(billNos);
+	}
+
+	private List<ErpPurchaseOrder> findAggregatedPageList(ErpPurchaseOrder query, 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, ErpPurchaseOrder> orderMap = new LinkedHashMap<>();
+
+		while (orderMap.size() < targetSize && fetchStart < maxFetchRows) {
+			List<ErpPurchaseOrder> chunk = findDetailList(query, fetchStart, fetchLimit);
+			if (chunk.isEmpty()) {
+				break;
+			}
+			for (ErpPurchaseOrder detail : chunk) {
+				ErpPurchaseOrder header = orderMap.get(detail.getBillNo());
+				if (header == null) {
+					header = toHeaderFromDetail(detail);
+					header.setEntryCount(0);
+					header.setTotalQty(BigDecimal.ZERO);
+					orderMap.put(detail.getBillNo(), header);
+				}
+				header.setEntryCount(header.getEntryCount() + 1);
+				if (detail.getQty() != null) {
+					header.setTotalQty(header.getTotalQty().add(detail.getQty()));
+				}
+			}
+			if (chunk.size() < fetchLimit) {
+				break;
+			}
+			fetchStart += fetchLimit;
+		}
+		List<ErpPurchaseOrder> allOrders = new ArrayList<>(orderMap.values());
+		if (startRow >= allOrders.size()) {
+			return new ArrayList<>();
+		}
+		return new ArrayList<>(allOrders.subList(startRow, Math.min(startRow + limit, allOrders.size())));
+	}
+
+	private List<ErpPurchaseOrder> findHeaderList(ErpPurchaseOrder query, int startRow, int limit) {
+		JSONObject data = new JSONObject();
+		data.put("FormId", FORM_ID);
+		data.put("FieldKeys", HEADER_FIELD_KEYS);
+		data.put("FilterString", buildFilterString(query, 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 purchase order header query: {}", data.toJSONString());
+		return mapRows(erpClient.billQuery(data), false);
+	}
+
+	private List<ErpPurchaseOrder> findDetailList(ErpPurchaseOrder query, int startRow, int limit) {
+		JSONObject data = new JSONObject();
+		data.put("FormId", FORM_ID);
+		data.put("FieldKeys", DETAIL_FIELD_KEYS);
+		data.put("FilterString", buildFilterString(query, true));
+		data.put("OrderString", "FDate DESC,FBILLNO DESC,FPOOrderEntry_FSeq");
+		data.put("StartRow", Math.max(startRow, 0));
+		data.put("Limit", Math.max(limit, 1));
+		logger.info("ERP purchase order detail query: {}", data.toJSONString());
+		return mapRows(erpClient.billQuery(data), true);
+	}
+
+	private List<ErpPurchaseOrder> findDetailListByBillNos(List<String> billNos) {
+		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));
+		data.put("OrderString", "FBILLNO DESC,FPOOrderEntry_FSeq");
+		data.put("StartRow", 0);
+		data.put("Limit", 2000);
+		logger.info("ERP purchase order detail by billNos query: {}", data.toJSONString());
+		return mapRows(erpClient.billQuery(data), true);
+	}
+
+	private List<ErpPurchaseOrder> mapRows(JSONArray rows, boolean detail) {
+		List<ErpPurchaseOrder> list = new ArrayList<>();
+		for (int i = 0; i < rows.size(); i++) {
+			list.add(detail ? toDetail(rows.getJSONObject(i), i) : toHeader(rows.getJSONObject(i)));
+		}
+		return list;
+	}
+
+	private ErpPurchaseOrder toHeader(JSONObject row) {
+		ErpPurchaseOrder order = new ErpPurchaseOrder();
+		order.setFid(readString(row, "FID"));
+		order.setBillNo(readString(row, "FBILLNO", "FBillNo"));
+		order.setId(order.getBillNo());
+		fillHeaderFields(order, row);
+		return order;
+	}
+
+	private ErpPurchaseOrder toHeaderFromDetail(ErpPurchaseOrder detail) {
+		ErpPurchaseOrder order = new ErpPurchaseOrder();
+		order.setFid(detail.getFid());
+		order.setBillNo(detail.getBillNo());
+		order.setId(detail.getBillNo());
+		order.setDocumentStatus(detail.getDocumentStatus());
+		order.setDocumentStatusName(detail.getDocumentStatusName());
+		order.setDate(detail.getDate());
+		order.setBillTypeNumber(detail.getBillTypeNumber());
+		order.setBillTypeName(detail.getBillTypeName());
+		order.setBusinessType(detail.getBusinessType());
+		order.setBusinessTypeName(detail.getBusinessTypeName());
+		order.setPurchaseOrgName(detail.getPurchaseOrgName());
+		order.setSupplierNumber(detail.getSupplierNumber());
+		order.setSupplierName(detail.getSupplierName());
+		return order;
+	}
+
+	private ErpPurchaseOrder toDetail(JSONObject row, int index) {
+		ErpPurchaseOrder order = new ErpPurchaseOrder();
+		order.setFid(readString(row, "FID"));
+		order.setBillNo(readString(row, "FBILLNO", "FBillNo"));
+		order.setEntryId(readString(row, "FPOOrderEntry_FEntryID", "FPOOrderEntry_FEntryId"));
+		order.setSeq(readString(row, "FPOOrderEntry_FSeq"));
+		String rowId = order.getBillNo();
+		if (StringUtils.isNotBlank(order.getEntryId())) {
+			rowId = rowId + "-" + order.getEntryId();
+		} else if (index >= 0) {
+			rowId = rowId + "-" + index;
+		}
+		order.setId(rowId);
+		fillHeaderFields(order, row);
+		order.setSourceBillNo(readString(row, "FSourceBillNo"));
+		order.setMaterialNumber(readString(row, MATERIAL_NUMBER_FIELD, "FMaterialId.FNumber"));
+		order.setMaterialName(readString(row, "FMaterialName", "FMaterialId.FNAME", "FMaterialId.FName"));
+		order.setUnitName(readString(row, "FUnitID.FNAME", "FUnitId.FNAME", "FUnitId.FName"));
+		order.setQty(row.getBigDecimal("FQty"));
+		order.setDeliveryDate(formatErpDate(readString(row, "FDeliveryDate")));
+		order.setPrice(row.getBigDecimal("FPrice"));
+		order.setAmount(row.getBigDecimal("FAmount"));
+		return order;
+	}
+
+	private void fillHeaderFields(ErpPurchaseOrder order, JSONObject row) {
+		order.setDocumentStatus(readString(row, "FDocumentStatus"));
+		order.setDocumentStatusName(documentStatusName(order.getDocumentStatus()));
+		order.setDate(formatErpDate(readString(row, "FDate")));
+		order.setBillTypeNumber(readString(row, BILL_TYPE_FIELD, "FBillTypeID.FNumber"));
+		order.setBillTypeName(readString(row, "FBillTypeID.FNAME", "FBillTypeID.FName"));
+		order.setBusinessType(readString(row, "FBusinessType"));
+		order.setBusinessTypeName(businessTypeName(order.getBusinessType()));
+		order.setPurchaseOrgName(readString(row, "FPurchaseOrgId.FNAME", "FPurchaseOrgId.FName"));
+		order.setSupplierNumber(readString(row, SUPPLIER_NUMBER_FIELD, "FSupplierId.FNumber"));
+		order.setSupplierName(readString(row, "FSupplierId.FNAME", "FSupplierId.FName"));
+	}
+
+	private String buildFilterString(ErpPurchaseOrder query, boolean includeMaterialFilter) {
+		String filter = buildBaseFilter();
+		if (query == null) {
+			return filter;
+		}
+		if (StringUtils.isNotBlank(query.getBillNo())) {
+			filter = appendAnd(filter, "FBILLNO='" + escape(query.getBillNo()) + "'");
+		}
+		if (StringUtils.isNotBlank(query.getSupplierNumberQuery())) {
+			filter = appendAnd(filter,
+					SUPPLIER_NUMBER_FIELD + " like '%" + escape(query.getSupplierNumberQuery()) + "%'");
+		}
+		if (includeMaterialFilter && StringUtils.isNotBlank(query.getMaterialNumberQuery())) {
+			filter = appendAnd(filter,
+					MATERIAL_NUMBER_FIELD + " like '%" + escape(query.getMaterialNumberQuery()) + "%'");
+		}
+		return filter;
+	}
+
+	private String buildInFilterString(List<String> billNos) {
+		StringBuilder builder = new StringBuilder(buildBaseFilter());
+		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 buildBaseFilter() {
+		String filter = BILL_TYPE_FIELD + "='" + escape(erpProperties.getPurchaseOrderBillType()) + "'";
+		return appendAnd(filter, buildPurchaseOrgFilter());
+	}
+
+	private String buildPurchaseOrgFilter() {
+		if (!erpProperties.isFilterCurrentOrg()) {
+			return "";
+		}
+		if (StringUtils.isNotBlank(erpProperties.getOrgNumber())) {
+			return "FPurchaseOrgId.FNUMBER='" + escape(erpProperties.getOrgNumber()) + "'";
+		}
+		Long currentOrganizationId = erpClient.getCurrentOrganizationId();
+		if (currentOrganizationId == null) {
+			return "1=1";
+		}
+		return "FPurchaseOrgId=" + currentOrganizationId;
+	}
+
+	private String appendAnd(String left, String right) {
+		if (StringUtils.isBlank(left)) {
+			return right;
+		}
+		if (StringUtils.isBlank(right)) {
+			return left;
+		}
+		return left + " AND " + right;
+	}
+
+	private String escape(String value) {
+		return value == null ? "" : value.trim().replace("'", "''");
+	}
+
+	private String readString(JSONObject row, String... keys) {
+		for (String key : keys) {
+			String value = row.getString(key);
+			if (StringUtils.isNotBlank(value)) {
+				return value.trim();
+			}
+		}
+		return "";
+	}
+
+	private String formatErpDate(String rawValue) {
+		if (StringUtils.isBlank(rawValue)) {
+			return "";
+		}
+		String trimmed = rawValue.trim();
+		return trimmed.length() >= 10 ? trimmed.substring(0, 10) : trimmed;
+	}
+
+	private String documentStatusName(String status) {
+		if ("A".equals(status)) {
+			return "创建";
+		}
+		if ("B".equals(status)) {
+			return "审核中";
+		}
+		if ("C".equals(status)) {
+			return "已审核";
+		}
+		if ("D".equals(status)) {
+			return "重新审核";
+		}
+		return status;
+	}
+
+	private String businessTypeName(String businessType) {
+		if ("CG".equals(businessType)) {
+			return "标准采购";
+		}
+		if ("WW".equals(businessType)) {
+			return "标准委外";
+		}
+		if ("ZCCG".equals(businessType)) {
+			return "资产采购";
+		}
+		if ("ZYCG".equals(businessType)) {
+			return "直运采购";
+		}
+		return businessType;
+	}
+}

+ 9 - 0
src/main/java/com/jeesite/modules/mes/erp/KingdeeErpClient.java

@@ -28,6 +28,7 @@ public class KingdeeErpClient {
 	private static final String LOGIN_PATH = "/Kingdee.BOS.WebApi.ServicesStub.AuthService.ValidateUser.common.kdsvc";
 	private static final String BILL_QUERY_PATH = "/Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.BillQuery.common.kdsvc";
 	private static final String VIEW_PATH = "/Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.View.common.kdsvc";
+	private static final String SAVE_PATH = "/Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.Save.common.kdsvc";
 
 	private final KingdeeErpProperties properties;
 	private final MemoryCookieJar cookieJar = new MemoryCookieJar();
@@ -89,6 +90,14 @@ public class KingdeeErpClient {
 		return postObject(VIEW_PATH, body.toJSONString());
 	}
 
+	public JSONObject save(String formId, JSONObject data) {
+		loginIfNecessary();
+		JSONObject body = new JSONObject();
+		body.put("formid", formId);
+		body.put("data", data.toJSONString());
+		return postObject(SAVE_PATH, body.toJSONString());
+	}
+
 	private JSONObject postObject(String path, String json) {
 		return JSON.parseObject(post(path, json));
 	}

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

@@ -33,6 +33,30 @@ public class KingdeeErpProperties {
 	@Value("${erp.k3cloud.lcid:2052}")
 	private int lcid;
 
+	/** 生产领料单单据类型编码,Apifox 验证后配置 */
+	@Value("${erp.k3cloud.pick-mtrl.bill-type:SCLLD01_SYS}")
+	private String pickMtrlBillType;
+
+	/** 库存状态编码(如「可用」) */
+	@Value("${erp.k3cloud.pick-mtrl.stock-status:KCZT01_SYS}")
+	private String pickMtrlStockStatus;
+
+	@Value("${erp.k3cloud.pick-mtrl.owner-type:BD_OwnerOrg}")
+	private String pickMtrlOwnerType;
+
+	@Value("${erp.k3cloud.pick-mtrl.keeper-type:BD_KeeperOrg}")
+	private String pickMtrlKeeperType;
+
+	@Value("${erp.k3cloud.pick-mtrl.link-rule:PRD_PPBOM2PICKMTRL_NORMAL}")
+	private String pickMtrlLinkRule;
+
+	@Value("${erp.k3cloud.pick-mtrl.link-table:T_PRD_PPBOMENTRY}")
+	private String pickMtrlLinkTable;
+
+	/** 标准采购订单单据类型编码 */
+	@Value("${erp.k3cloud.purchase-order.bill-type:CGDD01_SYS}")
+	private String purchaseOrderBillType;
+
 	public String getBaseUrl() {
 		if (baseUrl.endsWith("/")) {
 			return baseUrl.substring(0, baseUrl.length() - 1);
@@ -71,4 +95,32 @@ public class KingdeeErpProperties {
 	public int getLcid() {
 		return lcid;
 	}
+
+	public String getPickMtrlBillType() {
+		return pickMtrlBillType;
+	}
+
+	public String getPickMtrlStockStatus() {
+		return pickMtrlStockStatus;
+	}
+
+	public String getPickMtrlOwnerType() {
+		return pickMtrlOwnerType;
+	}
+
+	public String getPickMtrlKeeperType() {
+		return pickMtrlKeeperType;
+	}
+
+	public String getPickMtrlLinkRule() {
+		return pickMtrlLinkRule;
+	}
+
+	public String getPickMtrlLinkTable() {
+		return pickMtrlLinkTable;
+	}
+
+	public String getPurchaseOrderBillType() {
+		return purchaseOrderBillType;
+	}
 }

+ 5 - 1
src/main/java/com/jeesite/modules/mes/service/MesAdw300DataService.java

@@ -225,8 +225,12 @@ public class MesAdw300DataService extends CrudService<MesAdw300DataDao, MesAdw30
 		for (Map.Entry<String, Map<String, Double>> entry : dataMap.entrySet()) {
 			Map<String, Object> item = MapUtils.newHashMap();
 			List<Double> values = ListUtils.newArrayList();
+			double latestValue = 0D;
 			for (String hour : hours) {
-				values.add(round(entry.getValue().getOrDefault(hour, 0D)));
+				if (entry.getValue().containsKey(hour)) {
+					latestValue = entry.getValue().get(hour);
+				}
+				values.add(round(latestValue));
 			}
 			item.put("name", getDeviceName(entry.getKey()));
 			item.put("data", values);

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

@@ -0,0 +1,247 @@
+package com.jeesite.modules.mes.service;
+
+import com.jeesite.common.entity.Page;
+import com.jeesite.common.lang.StringUtils;
+import com.jeesite.common.service.CrudService;
+import com.jeesite.common.service.ServiceException;
+import com.jeesite.modules.mes.dao.MesErpScheduleDao;
+import com.jeesite.modules.mes.entity.MesErpSchedule;
+import com.jeesite.modules.mes.entity.MesLine;
+import com.jeesite.modules.mes.erp.ErpProductionOrder;
+import com.jeesite.modules.mes.erp.ErpProductionOrderService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.ObjectUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * ERP排产计划Service
+ */
+@Service
+public class MesErpScheduleService extends CrudService<MesErpScheduleDao, MesErpSchedule> {
+
+	private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+	private static final Map<String, String> CATEGORY_LINE_SN_MAP;
+
+	static {
+		Map<String, String> map = new HashMap<>();
+		map.put("HEV", "HEVXT");
+		map.put("115", "115XT");
+		map.put("\u6b23\u65fa\u8fbe115", "115XT");
+		CATEGORY_LINE_SN_MAP = Collections.unmodifiableMap(map);
+	}
+
+	private final MesErpScheduleDao mesErpScheduleDao;
+	private final MesLineService mesLineService;
+	private final ErpProductionOrderService erpProductionOrderService;
+
+	public MesErpScheduleService(MesErpScheduleDao mesErpScheduleDao,
+			MesLineService mesLineService,
+			ErpProductionOrderService erpProductionOrderService) {
+		this.mesErpScheduleDao = mesErpScheduleDao;
+		this.mesLineService = mesLineService;
+		this.erpProductionOrderService = erpProductionOrderService;
+	}
+
+	@Override
+	public MesErpSchedule get(MesErpSchedule mesErpSchedule) {
+		return super.get(mesErpSchedule);
+	}
+
+	@Override
+	public Page<MesErpSchedule> findPage(MesErpSchedule mesErpSchedule) {
+		return super.findPage(mesErpSchedule);
+	}
+
+	@Override
+	public List<MesErpSchedule> findList(MesErpSchedule mesErpSchedule) {
+		return super.findList(mesErpSchedule);
+	}
+
+	/**
+	 * 根据存货类别匹配产线:HEV→HEVXT,115/欣旺达115→115XT
+	 */
+	public MesLine resolveLineByCategory(String inventoryCategory) {
+		if (StringUtils.isBlank(inventoryCategory)) {
+			return null;
+		}
+		String lineSn = CATEGORY_LINE_SN_MAP.get(inventoryCategory.trim());
+		if (StringUtils.isBlank(lineSn)) {
+			return null;
+		}
+		MesLine query = new MesLine();
+		query.setSn(lineSn);
+		query.setStatus(MesLine.STATUS_NORMAL);
+		return mesLineService.findInfo(query);
+	}
+
+	/**
+	 * 加载 ERP 明细行并计算可排产数量
+	 */
+	public MesErpSchedule buildFromErpEntry(String erpBillNo, String erpEntryId) {
+		if (StringUtils.isBlank(erpBillNo)) {
+			throw new ServiceException(text("ERP生产订单号不能为空"));
+		}
+		ErpProductionOrder erpLine = findErpDetailLine(erpBillNo, erpEntryId);
+		if (erpLine == null) {
+			throw new ServiceException(text("未找到对应的ERP生产订单明细"));
+		}
+
+		MesErpSchedule schedule = new MesErpSchedule();
+		schedule.setErpBillNo(erpLine.getBillNo());
+		schedule.setErpEntryId(erpLine.getEntryId());
+		schedule.setErpSeq(erpLine.getSeq());
+		schedule.setMaterialNumber(erpLine.getMaterialNumber());
+		schedule.setMaterialName(erpLine.getMaterialName());
+		schedule.setInventoryCategory(erpLine.getInventoryCategory());
+		schedule.setWorkshopName(erpLine.getWorkshopName());
+		schedule.setOrderQty(erpLine.getQty());
+		schedule.setErpPlanStartDate(normalizeDate(erpLine.getPlanStartDate()));
+		schedule.setErpPlanFinishDate(normalizeDate(erpLine.getPlanFinishDate()));
+
+		MesLine line = resolveLineByCategory(erpLine.getInventoryCategory());
+		if (line != null) {
+			schedule.setLineId(line.getId());
+			schedule.setLineSn(line.getSn());
+			schedule.setLineTitle(line.getTitle());
+		}
+
+		int orderQty = toIntQty(erpLine.getQty());
+		int scheduledQty = sumScheduledQty(erpBillNo, erpEntryId, null);
+		schedule.setScheduledQtySum(scheduledQty);
+		schedule.setRemainQty(Math.max(orderQty - scheduledQty, 0));
+
+		if (StringUtils.isBlank(schedule.getScheduleStartDate())) {
+			schedule.setScheduleStartDate(schedule.getErpPlanStartDate());
+		}
+		if (StringUtils.isBlank(schedule.getScheduleEndDate())) {
+			schedule.setScheduleEndDate(schedule.getErpPlanFinishDate());
+		}
+		return schedule;
+	}
+
+	public int sumScheduledQty(String erpBillNo, String erpEntryId, String excludeId) {
+		if (StringUtils.isBlank(erpBillNo) || StringUtils.isBlank(erpEntryId)) {
+			return 0;
+		}
+		Integer sum = mesErpScheduleDao.sumScheduleQty(erpBillNo, erpEntryId, excludeId);
+		return sum == null ? 0 : sum;
+	}
+
+	@Override
+	@Transactional
+	public void save(MesErpSchedule mesErpSchedule) {
+		validateBeforeSave(mesErpSchedule);
+		fillLineSnapshot(mesErpSchedule);
+		super.save(mesErpSchedule);
+	}
+
+	private void validateBeforeSave(MesErpSchedule mesErpSchedule) {
+		if (StringUtils.isBlank(mesErpSchedule.getErpBillNo())) {
+			throw new ServiceException(text("ERP生产订单号不能为空"));
+		}
+		if (mesErpSchedule.getScheduleQty() == null || mesErpSchedule.getScheduleQty() <= 0) {
+			throw new ServiceException(text("排产工件数量必须大于0"));
+		}
+
+		MesErpSchedule erpInfo = buildFromErpEntry(mesErpSchedule.getErpBillNo(), mesErpSchedule.getErpEntryId());
+		if (StringUtils.isBlank(mesErpSchedule.getMaterialNumber())) {
+			mesErpSchedule.setMaterialNumber(erpInfo.getMaterialNumber());
+			mesErpSchedule.setMaterialName(erpInfo.getMaterialName());
+			mesErpSchedule.setInventoryCategory(erpInfo.getInventoryCategory());
+			mesErpSchedule.setWorkshopName(erpInfo.getWorkshopName());
+			mesErpSchedule.setOrderQty(erpInfo.getOrderQty());
+			mesErpSchedule.setErpPlanStartDate(erpInfo.getErpPlanStartDate());
+			mesErpSchedule.setErpPlanFinishDate(erpInfo.getErpPlanFinishDate());
+			mesErpSchedule.setErpSeq(erpInfo.getErpSeq());
+		}
+
+		if (StringUtils.isBlank(mesErpSchedule.getLineId()) && StringUtils.isNotBlank(erpInfo.getLineId())) {
+			mesErpSchedule.setLineId(erpInfo.getLineId());
+			mesErpSchedule.setLineSn(erpInfo.getLineSn());
+			mesErpSchedule.setLineTitle(erpInfo.getLineTitle());
+		}
+		if (StringUtils.isBlank(mesErpSchedule.getLineId())) {
+			throw new ServiceException(text("未找到存货类别对应的产线,请检查产线配置"));
+		}
+
+		LocalDate scheduleStart = parseDate(mesErpSchedule.getScheduleStartDate(), "排产开始日期");
+		LocalDate scheduleEnd = parseDate(mesErpSchedule.getScheduleEndDate(), "排产结束日期");
+		if (scheduleEnd.isBefore(scheduleStart)) {
+			throw new ServiceException(text("排产结束日期不能早于开始日期"));
+		}
+
+		LocalDate erpStart = parseDate(mesErpSchedule.getErpPlanStartDate(), "ERP计划开工");
+		LocalDate erpEnd = parseDate(mesErpSchedule.getErpPlanFinishDate(), "ERP计划完工");
+		if (scheduleStart.isBefore(erpStart) || scheduleEnd.isAfter(erpEnd)) {
+			throw new ServiceException(text("排产日期必须在ERP订单计划时间范围内({0} ~ {1})",
+					mesErpSchedule.getErpPlanStartDate(), mesErpSchedule.getErpPlanFinishDate()));
+		}
+
+		int orderQty = toIntQty(mesErpSchedule.getOrderQty());
+		int scheduledQty = sumScheduledQty(mesErpSchedule.getErpBillNo(), mesErpSchedule.getErpEntryId(),
+				mesErpSchedule.getId());
+		int remainQty = orderQty - scheduledQty;
+		if (mesErpSchedule.getScheduleQty() > remainQty) {
+			throw new ServiceException(text("排产数量不能超过剩余可排产量(剩余 {0})", String.valueOf(remainQty)));
+		}
+	}
+
+	private void fillLineSnapshot(MesErpSchedule mesErpSchedule) {
+		MesLine line = mesLineService.get(new MesLine(mesErpSchedule.getLineId()));
+		if (ObjectUtils.isEmpty(line)) {
+			throw new ServiceException(text("产线不存在"));
+		}
+		mesErpSchedule.setLineSn(line.getSn());
+		mesErpSchedule.setLineTitle(line.getTitle());
+	}
+
+	private ErpProductionOrder findErpDetailLine(String erpBillNo, String erpEntryId) {
+		List<ErpProductionOrder> details = erpProductionOrderService.findDetailList(erpBillNo);
+		for (ErpProductionOrder detail : details) {
+			if (StringUtils.isBlank(erpEntryId) || erpEntryId.equals(detail.getEntryId())) {
+				return detail;
+			}
+		}
+		return null;
+	}
+
+	private int toIntQty(BigDecimal qty) {
+		if (qty == null) {
+			return 0;
+		}
+		return qty.intValue();
+	}
+
+	private LocalDate parseDate(String value, String label) {
+		String normalized = normalizeDate(value);
+		if (StringUtils.isBlank(normalized)) {
+			throw new ServiceException(text("{0}不能为空", label));
+		}
+		try {
+			return LocalDate.parse(normalized, DATE_FORMAT);
+		} catch (DateTimeParseException ex) {
+			throw new ServiceException(text("{0}格式不正确", label));
+		}
+	}
+
+	/** 将 ERP 返回的日期时间格式化为 yyyy-MM-dd */
+	public static String normalizeDate(String rawValue) {
+		if (StringUtils.isBlank(rawValue)) {
+			return "";
+		}
+		String trimmed = rawValue.trim();
+		if (trimmed.length() >= 10) {
+			return trimmed.substring(0, 10);
+		}
+		return trimmed;
+	}
+}

+ 143 - 0
src/main/java/com/jeesite/modules/mes/web/ErpPickMaterialBillController.java

@@ -0,0 +1,143 @@
+package com.jeesite.modules.mes.web;
+
+import com.jeesite.common.config.Global;
+import com.jeesite.common.entity.Page;
+import com.jeesite.common.web.BaseController;
+import com.jeesite.modules.mes.erp.ErpPickMaterialBill;
+import com.jeesite.modules.mes.erp.ErpPickMaterialBillService;
+import com.jeesite.modules.mes.erp.ErpPickMaterialResult;
+import com.jeesite.modules.mes.erp.ErpPickMaterialService;
+import com.jeesite.modules.mes.erp.ErpPickMaterialSubmitRequest;
+import com.jeesite.modules.mes.erp.ErpProductionMaterial;
+import com.jeesite.modules.mes.erp.ErpProductionMaterialService;
+import com.jeesite.modules.mes.erp.ErpProductionOrder;
+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.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.List;
+
+/**
+ * ERP 生产领料单列表与新增领料。
+ */
+@Controller
+@RequestMapping(value = "${adminPath}/mes/erpPickMaterialBill")
+public class ErpPickMaterialBillController extends BaseController {
+
+	private final ErpPickMaterialBillService erpPickMaterialBillService;
+	private final ErpProductionOrderService erpProductionOrderService;
+	private final ErpProductionMaterialService erpProductionMaterialService;
+	private final ErpPickMaterialService erpPickMaterialService;
+
+	public ErpPickMaterialBillController(ErpPickMaterialBillService erpPickMaterialBillService,
+			ErpProductionOrderService erpProductionOrderService,
+			ErpProductionMaterialService erpProductionMaterialService,
+			ErpPickMaterialService erpPickMaterialService) {
+		this.erpPickMaterialBillService = erpPickMaterialBillService;
+		this.erpProductionOrderService = erpProductionOrderService;
+		this.erpProductionMaterialService = erpProductionMaterialService;
+		this.erpPickMaterialService = erpPickMaterialService;
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@RequestMapping(value = {"list", ""})
+	public String list(ErpPickMaterialBill erpPickMaterialBill, Model model) {
+		model.addAttribute("erpPickMaterialBill", erpPickMaterialBill);
+		return "modules/mes/erpPickMaterialBillList";
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@RequestMapping(value = "listData")
+	@ResponseBody
+	public Page<ErpPickMaterialBill> listData(ErpPickMaterialBill erpPickMaterialBill,
+			HttpServletRequest request, HttpServletResponse response) {
+		Page<ErpPickMaterialBill> page = new Page<>(request, response);
+		int pageSize = page.getPageSize() > 0 ? page.getPageSize() : 20;
+		int pageNo = page.getPageNo() > 0 ? page.getPageNo() : 1;
+		int startRow = (pageNo - 1) * pageSize;
+		List<ErpPickMaterialBill> list = erpPickMaterialBillService.findPageList(erpPickMaterialBill, startRow, pageSize);
+		page.setList(list);
+		page.setCount(startRow + list.size() + (list.size() == pageSize ? 1 : 0));
+		return page;
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@RequestMapping(value = "selectOrder")
+	public String selectOrder(ErpProductionOrder erpProductionOrder, Model model) {
+		model.addAttribute("erpProductionOrder", erpProductionOrder);
+		return "modules/mes/erpPickMaterialBillSelectOrder";
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@RequestMapping(value = "selectOrderData")
+	@ResponseBody
+	public Page<ErpProductionOrder> selectOrderData(ErpProductionOrder erpProductionOrder,
+			HttpServletRequest request, HttpServletResponse response) {
+		Page<ErpProductionOrder> page = new Page<>(request, response);
+		int pageSize = page.getPageSize() > 0 ? page.getPageSize() : 20;
+		int pageNo = page.getPageNo() > 0 ? page.getPageNo() : 1;
+		int startRow = (pageNo - 1) * pageSize;
+		List<ErpProductionOrder> list = erpProductionOrderService.findPageList(
+				erpProductionOrder.getBillNo(), startRow, pageSize);
+		page.setList(list);
+		page.setCount(startRow + list.size() + (list.size() == pageSize ? 1 : 0));
+		return page;
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@RequestMapping(value = "selectDetail")
+	public String selectDetail(ErpProductionOrder erpProductionOrder, Model model) {
+		model.addAttribute("erpProductionOrder", erpProductionOrder);
+		return "modules/mes/erpPickMaterialBillSelectDetail";
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@RequestMapping(value = "selectDetailData")
+	@ResponseBody
+	public Page<ErpProductionOrder> selectDetailData(ErpProductionOrder erpProductionOrder,
+			HttpServletRequest request, HttpServletResponse response) {
+		Page<ErpProductionOrder> page = new Page<>(request, response);
+		List<ErpProductionOrder> list = erpProductionOrderService.findDetailList(erpProductionOrder.getBillNo());
+		page.setList(list);
+		page.setCount(list.size());
+		return page;
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@RequestMapping(value = "material")
+	public String material(ErpProductionOrder erpProductionOrder, Model model) {
+		model.addAttribute("erpProductionOrder", erpProductionOrder);
+		return "modules/mes/erpPickMaterialBillMaterial";
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@RequestMapping(value = "materialData")
+	@ResponseBody
+	public Page<ErpProductionMaterial> materialData(ErpProductionOrder erpProductionOrder,
+			HttpServletRequest request, HttpServletResponse response) {
+		Page<ErpProductionMaterial> page = new Page<>(request, response);
+		List<ErpProductionMaterial> list = erpProductionMaterialService.findByProductionOrderEntry(
+				erpProductionOrder.getBillNo(), erpProductionOrder.getEntryId());
+		page.setList(list);
+		page.setCount(list.size());
+		return page;
+	}
+
+	@RequiresPermissions("mes:erpPickMaterialBill:view")
+	@PostMapping(value = "submitPickMaterial")
+	@ResponseBody
+	public String submitPickMaterial(@RequestBody ErpPickMaterialSubmitRequest request) {
+		ErpPickMaterialResult result = erpPickMaterialService.submit(request);
+		if (result.isSuccess()) {
+			return renderResult(Global.TRUE, text("领料单保存成功,单号:{0}", result.getPickBillNo()));
+		}
+		return renderResult(Global.FALSE, result.getMessage());
+	}
+}

+ 1 - 26
src/main/java/com/jeesite/modules/mes/web/ErpProductionOrderController.java

@@ -2,8 +2,6 @@ package com.jeesite.modules.mes.web;
 
 import com.jeesite.common.entity.Page;
 import com.jeesite.common.web.BaseController;
-import com.jeesite.modules.mes.erp.ErpProductionMaterial;
-import com.jeesite.modules.mes.erp.ErpProductionMaterialService;
 import com.jeesite.modules.mes.erp.ErpProductionOrder;
 import com.jeesite.modules.mes.erp.ErpProductionOrderService;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
@@ -21,12 +19,9 @@ import java.util.List;
 public class ErpProductionOrderController extends BaseController {
 
 	private final ErpProductionOrderService erpProductionOrderService;
-	private final ErpProductionMaterialService erpProductionMaterialService;
 
-	public ErpProductionOrderController(ErpProductionOrderService erpProductionOrderService,
-			ErpProductionMaterialService erpProductionMaterialService) {
+	public ErpProductionOrderController(ErpProductionOrderService erpProductionOrderService) {
 		this.erpProductionOrderService = erpProductionOrderService;
-		this.erpProductionMaterialService = erpProductionMaterialService;
 	}
 
 	@RequiresPermissions("mes:erpProductionOrder:view")
@@ -69,24 +64,4 @@ public class ErpProductionOrderController extends BaseController {
 		page.setCount(list.size());
 		return page;
 	}
-
-	@RequiresPermissions("mes:erpProductionOrder:view")
-	@RequestMapping(value = "material")
-	public String material(ErpProductionOrder erpProductionOrder, Model model) {
-		model.addAttribute("erpProductionOrder", erpProductionOrder);
-		return "modules/mes/erpProductionMaterialList";
-	}
-
-	@RequiresPermissions("mes:erpProductionOrder:view")
-	@RequestMapping(value = "materialData")
-	@ResponseBody
-	public Page<ErpProductionMaterial> materialData(ErpProductionOrder erpProductionOrder,
-			HttpServletRequest request, HttpServletResponse response) {
-		Page<ErpProductionMaterial> page = new Page<>(request, response);
-		List<ErpProductionMaterial> list = erpProductionMaterialService.findByProductionOrderEntry(
-				erpProductionOrder.getBillNo(), erpProductionOrder.getEntryId());
-		page.setList(list);
-		page.setCount(list.size());
-		return page;
-	}
 }

+ 70 - 0
src/main/java/com/jeesite/modules/mes/web/ErpPurchaseOrderController.java

@@ -0,0 +1,70 @@
+package com.jeesite.modules.mes.web;
+
+import com.jeesite.common.entity.Page;
+import com.jeesite.common.web.BaseController;
+import com.jeesite.modules.mes.erp.ErpPurchaseOrder;
+import com.jeesite.modules.mes.erp.ErpPurchaseOrderService;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.List;
+
+/**
+ * ERP 采购订单查询。
+ */
+@Controller
+@RequestMapping(value = "${adminPath}/mes/erpPurchaseOrder")
+public class ErpPurchaseOrderController extends BaseController {
+
+	private final ErpPurchaseOrderService erpPurchaseOrderService;
+
+	public ErpPurchaseOrderController(ErpPurchaseOrderService erpPurchaseOrderService) {
+		this.erpPurchaseOrderService = erpPurchaseOrderService;
+	}
+
+	@RequiresPermissions("mes:erpPurchaseOrder:view")
+	@RequestMapping(value = {"list", ""})
+	public String list(ErpPurchaseOrder erpPurchaseOrder, Model model) {
+		model.addAttribute("erpPurchaseOrder", erpPurchaseOrder);
+		return "modules/mes/erpPurchaseOrderList";
+	}
+
+	@RequiresPermissions("mes:erpPurchaseOrder:view")
+	@RequestMapping(value = "listData")
+	@ResponseBody
+	public Page<ErpPurchaseOrder> listData(ErpPurchaseOrder erpPurchaseOrder,
+			HttpServletRequest request, HttpServletResponse response) {
+		Page<ErpPurchaseOrder> page = new Page<>(request, response);
+		int pageSize = page.getPageSize() > 0 ? page.getPageSize() : 20;
+		int pageNo = page.getPageNo() > 0 ? page.getPageNo() : 1;
+		int startRow = (pageNo - 1) * pageSize;
+		List<ErpPurchaseOrder> list = erpPurchaseOrderService.findPageList(erpPurchaseOrder, startRow, pageSize);
+		page.setList(list);
+		page.setCount(startRow + list.size() + (list.size() == pageSize ? 1 : 0));
+		return page;
+	}
+
+	@RequiresPermissions("mes:erpPurchaseOrder:view")
+	@RequestMapping(value = "detail")
+	public String detail(ErpPurchaseOrder erpPurchaseOrder, Model model) {
+		model.addAttribute("erpPurchaseOrder", erpPurchaseOrder);
+		return "modules/mes/erpPurchaseOrderDetail";
+	}
+
+	@RequiresPermissions("mes:erpPurchaseOrder:view")
+	@RequestMapping(value = "detailData")
+	@ResponseBody
+	public Page<ErpPurchaseOrder> detailData(ErpPurchaseOrder erpPurchaseOrder,
+			HttpServletRequest request, HttpServletResponse response) {
+		Page<ErpPurchaseOrder> page = new Page<>(request, response);
+		List<ErpPurchaseOrder> list = erpPurchaseOrderService.findDetailList(erpPurchaseOrder.getBillNo());
+		page.setList(list);
+		page.setCount(list.size());
+		return page;
+	}
+}

+ 127 - 0
src/main/java/com/jeesite/modules/mes/web/MesErpScheduleController.java

@@ -0,0 +1,127 @@
+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.entity.MesErpSchedule;
+import com.jeesite.modules.mes.entity.MesLine;
+import com.jeesite.modules.mes.service.MesErpScheduleService;
+import com.jeesite.modules.mes.service.MesLineService;
+import org.apache.shiro.authz.annotation.Logical;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * ERP排产计划Controller
+ */
+@Controller
+@RequestMapping(value = "${adminPath}/mes/mesErpSchedule")
+public class MesErpScheduleController extends BaseController {
+
+	private final MesErpScheduleService mesErpScheduleService;
+	private final MesLineService mesLineService;
+
+	public MesErpScheduleController(MesErpScheduleService mesErpScheduleService,
+			MesLineService mesLineService) {
+		this.mesErpScheduleService = mesErpScheduleService;
+		this.mesLineService = mesLineService;
+	}
+
+	@ModelAttribute
+	public MesErpSchedule get(String id, boolean isNewRecord) {
+		return mesErpScheduleService.get(id, isNewRecord);
+	}
+
+	@RequiresPermissions("mes:mesErpSchedule:view")
+	@RequestMapping(value = {"list", ""})
+	public String list(MesErpSchedule mesErpSchedule, Model model) {
+		model.addAttribute("mesErpSchedule", mesErpSchedule);
+		return "modules/mes/mesErpScheduleList";
+	}
+
+	@RequiresPermissions("mes:mesErpSchedule:view")
+	@RequestMapping(value = "listData")
+	@ResponseBody
+	public Page<MesErpSchedule> listData(MesErpSchedule mesErpSchedule,
+			HttpServletRequest request, HttpServletResponse response) {
+		mesErpSchedule.setPage(new Page<>(request, response));
+		return mesErpScheduleService.findPage(mesErpSchedule);
+	}
+
+	@RequiresPermissions(value = {"mes:mesErpSchedule:view", "mes:erpProductionOrder:view"}, logical = Logical.OR)
+	@RequestMapping(value = "form")
+	public String form(MesErpSchedule mesErpSchedule, Model model) {
+		if (mesErpSchedule.getIsNewRecord()
+				&& StringUtils.isNotBlank(mesErpSchedule.getErpBillNo())) {
+			MesErpSchedule fromErp = mesErpScheduleService.buildFromErpEntry(
+					mesErpSchedule.getErpBillNo(), mesErpSchedule.getErpEntryId());
+			copyErpFields(mesErpSchedule, fromErp);
+		}
+		MesLine lineQuery = new MesLine();
+		lineQuery.setStatus(MesLine.STATUS_NORMAL);
+		List<MesLine> lines = mesLineService.findList(lineQuery);
+		model.addAttribute("lines", lines);
+		model.addAttribute("mesErpSchedule", mesErpSchedule);
+		return "modules/mes/mesErpScheduleForm";
+	}
+
+	@RequiresPermissions("mes:mesErpSchedule:view")
+	@RequestMapping(value = "erpEntryData")
+	@ResponseBody
+	public Map<String, Object> erpEntryData(String erpBillNo, String erpEntryId) {
+		MesErpSchedule schedule = mesErpScheduleService.buildFromErpEntry(erpBillNo, erpEntryId);
+		Map<String, Object> map = new HashMap<>();
+		map.put("result", Global.TRUE);
+		map.put("data", schedule);
+		return map;
+	}
+
+	@RequiresPermissions(value = {"mes:mesErpSchedule:view", "mes:erpProductionOrder:view"}, logical = Logical.OR)
+	@PostMapping(value = "save")
+	@ResponseBody
+	public String save(@Validated MesErpSchedule mesErpSchedule) {
+		mesErpScheduleService.save(mesErpSchedule);
+		return renderResult(Global.TRUE, text("保存ERP排产计划成功!"));
+	}
+
+	@RequiresPermissions(value = {"mes:mesErpSchedule:view", "mes:erpProductionOrder:view"}, logical = Logical.OR)
+	@RequestMapping(value = "delete")
+	@ResponseBody
+	public String delete(MesErpSchedule mesErpSchedule) {
+		mesErpScheduleService.delete(mesErpSchedule);
+		return renderResult(Global.TRUE, text("删除ERP排产计划成功!"));
+	}
+
+	private void copyErpFields(MesErpSchedule target, MesErpSchedule source) {
+		target.setErpBillNo(source.getErpBillNo());
+		target.setErpEntryId(source.getErpEntryId());
+		target.setErpSeq(source.getErpSeq());
+		target.setMaterialNumber(source.getMaterialNumber());
+		target.setMaterialName(source.getMaterialName());
+		target.setInventoryCategory(source.getInventoryCategory());
+		target.setWorkshopName(source.getWorkshopName());
+		target.setOrderQty(source.getOrderQty());
+		target.setErpPlanStartDate(source.getErpPlanStartDate());
+		target.setErpPlanFinishDate(source.getErpPlanFinishDate());
+		target.setLineId(source.getLineId());
+		target.setLineSn(source.getLineSn());
+		target.setLineTitle(source.getLineTitle());
+		target.setScheduledQtySum(source.getScheduledQtySum());
+		target.setRemainQty(source.getRemainQty());
+		target.setScheduleStartDate(source.getScheduleStartDate());
+		target.setScheduleEndDate(source.getScheduleEndDate());
+	}
+}

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

@@ -1,4 +1,4 @@
-#======================================#
+#======================================#
 #========== Project settings ==========#
 #======================================#
 
@@ -43,8 +43,20 @@ erp:
     org-number: 110
     filter-current-org: true
     inventory-category-filter-enabled: true
-    inventory-category-field:
+    # PRD_MO BillQuery 存货类别字段(Apifox 已验证:用 F_XYLX_BaseProperty_jv4,不要加 FTreeEntity_ 前缀)
+    inventory-category-field: F_XYLX_BaseProperty_jv4
     lcid: 2052
+    # 生产领料单 PRD_PickMtrl Save(方案B),编码需与 Apifox 实测一致
+    pick-mtrl:
+      bill-type: SCLLD01_SYS
+      stock-status: KCZT01_SYS
+      owner-type: BD_OwnerOrg
+      keeper-type: BD_KeeperOrg
+      link-rule: PRD_PPBOM2PICKMTRL_NORMAL
+      link-table: T_PRD_PPBOMENTRY
+    # 采购订单 PUR_PurchaseOrder,标准采购订单单据类型
+    purchase-order:
+      bill-type: CGDD01_SYS
 
 #======================================#
 #========== Server settings ===========#
@@ -653,6 +665,8 @@ shiro:
     ${adminPath}/mes/mesProductRecord/qmcheck = anon
     ${adminPath}/mes/mesProductRecord/qmresult = anon
     ${adminPath}/mes/mesProductRecord/device = anon
+    ${adminPath}/mes/mesProductArcWelding/batchsave = anon
+    ${adminPath}/mes/mesProductCmt/batchsave = anon
     ${adminPath}/mes/mesProduct/screen = anon
     ${adminPath}/mes/mesProduct/screen2 = anon
     ${adminPath}/mes/mesProduct/screen3 = anon
@@ -1073,3 +1087,4 @@ msg:
 #======================================#
 #========== Project settings ==========#
 #======================================#
+

+ 17 - 15
src/main/resources/mappings/modules/mes/MesAdw300DataDao.xml

@@ -79,12 +79,12 @@
 
 	<select id="findHourlyEnergy" resultType="java.util.Map">
 		SELECT
-			first_t.device_sn AS deviceSn,
-			DATE_FORMAT(first_t.report_time, '%H:00') AS hourLabel,
-			ROUND(CAST(last_t.point_value AS DECIMAL(18, 4)) - CAST(first_t.point_value AS DECIMAL(18, 4)), 3) AS energy
-		FROM mes_adw300_data first_t
+			last_t.device_sn AS deviceSn,
+			DATE_FORMAT(last_t.report_time, '%H:00') AS hourLabel,
+			ROUND(GREATEST(CAST(last_t.point_value AS DECIMAL(18, 4)) - CAST(first_t.point_value AS DECIMAL(18, 4)), 0), 3) AS energy
+		FROM mes_adw300_data last_t
 		INNER JOIN (
-			SELECT device_sn, DATE_FORMAT(report_time, '%H:00') AS hour_label, MIN(report_time) AS report_time
+			SELECT device_sn, DATE_FORMAT(report_time, '%H:00') AS hour_label, MAX(report_time) AS report_time
 			FROM mes_adw300_data
 			WHERE status = '0'
 			  AND point_id = 28
@@ -92,23 +92,25 @@
 			  AND report_time &lt; #{endTime}
 			  AND point_value REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
 			GROUP BY device_sn, DATE_FORMAT(report_time, '%H:00')
-		) first_x ON first_x.device_sn = first_t.device_sn AND first_x.report_time = first_t.report_time
+		) last_x ON last_x.device_sn = last_t.device_sn AND last_x.report_time = last_t.report_time
 		INNER JOIN (
-			SELECT device_sn, DATE_FORMAT(report_time, '%H:00') AS hour_label, MAX(report_time) AS report_time
+			SELECT device_sn, MIN(report_time) AS report_time
 			FROM mes_adw300_data
 			WHERE status = '0'
 			  AND point_id = 28
 			  AND report_time &gt;= #{startTime}
 			  AND report_time &lt; #{endTime}
 			  AND point_value REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
-			GROUP BY device_sn, DATE_FORMAT(report_time, '%H:00')
-		) last_x ON last_x.device_sn = first_t.device_sn AND last_x.hour_label = first_x.hour_label
-		INNER JOIN mes_adw300_data last_t
-		  ON last_t.device_sn = last_x.device_sn
-		 AND last_t.report_time = last_x.report_time
-		 AND last_t.point_id = 28
-		WHERE first_t.status = '0'
-		  AND first_t.point_id = 28
+			GROUP BY device_sn
+		) first_x ON first_x.device_sn = last_t.device_sn
+		INNER JOIN mes_adw300_data first_t
+		  ON first_t.device_sn = first_x.device_sn
+		 AND first_t.report_time = first_x.report_time
+		 AND first_t.point_id = 28
+		WHERE last_t.status = '0'
+		  AND last_t.point_id = 28
+		  AND last_t.point_value REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
+		  AND first_t.status = '0'
 		  AND first_t.point_value REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
 		ORDER BY hourLabel, deviceSn
 	</select>

+ 16 - 0
src/main/resources/mappings/modules/mes/MesErpScheduleDao.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.jeesite.modules.mes.dao.MesErpScheduleDao">
+
+	<select id="sumScheduleQty" resultType="java.lang.Integer">
+		SELECT IFNULL(SUM(schedule_qty), 0)
+		FROM mes_erp_schedule
+		WHERE status = '0'
+		  AND erp_bill_no = #{erpBillNo}
+		  AND erp_entry_id = #{erpEntryId}
+		<if test="excludeId != null and excludeId != ''">
+			AND id != #{excludeId}
+		</if>
+	</select>
+
+</mapper>

+ 134 - 0
src/main/resources/views/modules/mes/erpPickMaterialBillList.html

@@ -0,0 +1,134 @@
+<% 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-ul"></i> ${text('ERP生产领料单查询')}
+
+			</div>
+
+			<div class="box-tools pull-right">
+
+				<a href="${ctx}/mes/erpPickMaterialBill/selectOrder" class="btn btn-primary btnTool" title="${text('增加领料单')}"><i class="fa fa-plus"></i> ${text('增加')}</a>
+
+				<a href="#" class="btn btn-default" id="btnSearch" title="${text('查询')}"><i class="fa fa-filter"></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/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="moBillNoQuery" maxlength="100" class="form-control width-180" placeholder="${text('留空则显示MES可见订单的全部领料')}"/>
+
+					</div>
+
+				</div>
+
+				<div class="form-group">
+
+					<label class="control-label">${text('领料单号')}:</label>
+
+					<div class="control-inline">
+
+						<#form:input path="billNo" maxlength="100" class="form-control width-180"/>
+
+					</div>
+
+				</div>
+
+				<div class="form-group">
+
+					<label class="control-label">${text('物料编码')}:</label>
+
+					<div class="control-inline">
+
+						<#form:input path="materialNumberQuery" maxlength="100" class="form-control width-180"/>
+
+					</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({
+
+	searchForm: $("#searchForm"),
+
+	columnModel: [
+
+		{header:'${text("领料单号")}', name:'billNo', index:'billNo', width:170, align:"center", frozen:true},
+
+		{header:'${text("生产订单")}', name:'moBillNo', index:'moBillNo', width:160, align:"center", frozen:true},
+
+		{header:'${text("日期")}', name:'date', index:'date', width:110, align:"center"},
+
+		{header:'${text("单据状态")}', name:'documentStatusName', index:'documentStatusName', width:90, align:"center"},
+
+		{header:'${text("发料组织")}', name:'stockOrgName', index:'stockOrgName', width:160, align:"left"},
+
+		{header:'${text("生产组织")}', name:'prdOrgName', index:'prdOrgName', width:160, align:"left"},
+
+		{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:'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:'stockName', index:'stockName', width:120, align:"left"}
+
+	],
+
+	ajaxSuccess: function(){}
+
+});
+
+</script>
+

+ 102 - 0
src/main/resources/views/modules/mes/erpPickMaterialBillMaterial.html

@@ -0,0 +1,102 @@
+<% 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">
+			<#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>
+<% } %>
+<script>
+var billNo = '${erpProductionOrder.billNo!}';
+var entryId = '${erpProductionOrder.entryId!}';
+
+function formatPickQty(val, obj, row){
+	var defaultQty = row.noPickedQty == null ? 0 : row.noPickedQty;
+	var inputVal = val == null || val === '' ? defaultQty : val;
+	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+'"/>';
+}
+
+$('#dataGrid').dataGrid({
+	searchForm: $("#searchForm"),
+	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"/>';
+		}},
+		{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:'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"}
+	],
+	ajaxSuccess: function(){}
+});
+
+$('#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;
+		}
+		lines.push({ ppbomEntryId: String(id), pickQty: pickQty });
+	});
+	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>

+ 45 - 0
src/main/resources/views/modules/mes/erpPickMaterialBillSelectDetail.html

@@ -0,0 +1,45 @@
+<% 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"></i> ${text('增加领料单 - 选择订单明细')}:${erpProductionOrder.billNo}
+			</div>
+			<div class="box-tools pull-right">
+				<a href="${ctx}/mes/erpPickMaterialBill/selectOrder" 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">
+			<#form:form id="searchForm" model="${erpProductionOrder}" action="${ctx}/mes/erpPickMaterialBill/selectDetailData" 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:'actions', width:120, align:"center", frozen:true, formatter: function(val, obj, row){
+			return '<a href="${ctx}/mes/erpPickMaterialBill/material?billNo='+encodeURIComponent(row.billNo)+'&entryId='+encodeURIComponent(row.entryId)+'" class="btnList addTabPage" data-title="${text("生产用料清单")}">'+
+				'<i class="fa fa-list-ul"></i> ${text("选择清单")}</a>';
+		}},
+		{header:'${text("行号")}', name:'seq', index:'seq', width:80, align:"center", frozen:true},
+		{header:'${text("物料编码")}', name:'materialNumber', index:'materialNumber', width:220, align:"left"},
+		{header:'${text("物料名称")}', name:'materialName', index:'materialName', width:260, align:"left"},
+		{header:'${text("存货类别")}', name:'inventoryCategory', index:'inventoryCategory', width:120, align:"center"},
+		{header:'${text("生产车间")}', name:'workshopName', index:'workshopName', width:140, align:"left"},
+		{header:'${text("数量")}', name:'qty', index:'qty', width:110, align:"right"},
+		{header:'${text("单位")}', name:'unitName', index:'unitName', width:90, align:"center"},
+		{header:'${text("业务状态")}', name:'businessStatusName', index:'businessStatusName', width:100, align:"center"},
+		{header:'${text("计划开工")}', name:'planStartDate', index:'planStartDate', width:150, align:"center"},
+		{header:'${text("计划完工")}', name:'planFinishDate', index:'planFinishDate', width:150, align:"center"}
+	],
+	ajaxSuccess: function(){}
+});
+</script>

+ 53 - 0
src/main/resources/views/modules/mes/erpPickMaterialBillSelectOrder.html

@@ -0,0 +1,53 @@
+<% 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-alt"></i> ${text('增加领料单 - 选择生产订单')}
+			</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="btnSearch" title="${text('查询')}"><i class="fa fa-filter"></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="${erpProductionOrder}" action="${ctx}/mes/erpPickMaterialBill/selectOrderData" 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="billNo" maxlength="100" class="form-control width-180"/>
+					</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({
+	searchForm: $("#searchForm"),
+	columnModel: [
+		{header:'${text("操作")}', name:'actions', width:100, align:"center", frozen:true, formatter: function(val, obj, row){
+			return '<a href="${ctx}/mes/erpPickMaterialBill/selectDetail?billNo='+encodeURIComponent(row.billNo)+'" class="btnList addTabPage" data-title="${text("选择订单明细")}">'+
+				'<i class="fa fa-check"></i> ${text("选择")}</a>';
+		}},
+		{header:'${text("生产订单号")}', name:'billNo', index:'billNo', width:180, align:"center", frozen:true},
+		{header:'${text("单据日期")}', name:'date', index:'date', width:120, align:"center"},
+		{header:'${text("单据状态")}', name:'documentStatusName', index:'documentStatusName', width:100, align:"center"},
+		{header:'${text("生产组织")}', name:'prdOrgName', index:'prdOrgName', width:180, align:"left"},
+		{header:'${text("存货类别")}', name:'inventoryCategory', index:'inventoryCategory', width:120, align:"center"},
+		{header:'${text("生产车间")}', name:'workshopName', index:'workshopName', width:140, align:"left"},
+		{header:'${text("明细行数")}', name:'entryCount', index:'entryCount', width:100, align:"right"},
+		{header:'${text("总数量")}', name:'totalQty', index:'totalQty', width:120, align:"right"}
+	],
+	ajaxSuccess: function(){}
+});
+</script>

+ 0 - 46
src/main/resources/views/modules/mes/erpProductionMaterialList.html

@@ -1,46 +0,0 @@
-<% 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="${ctx}/mes/erpProductionOrder/detail?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">
-			<#form:form id="searchForm" model="${erpProductionOrder}" action="${ctx}/mes/erpProductionOrder/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>
-<% } %>
-<script>
-$('#dataGrid').dataGrid({
-	searchForm: $("#searchForm"),
-	columnModel: [
-		{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:'specification', index:'specification', width:220, align:"left"},
-		{header:'${text("应发数量")}', name:'mustQty', index:'mustQty', width:120, align:"right"},
-		{header:'${text("已领数量")}', name:'pickedQty', index:'pickedQty', width:120, align:"right"},
-		{header:'${text("未领数量")}', name:'noPickedQty', index:'noPickedQty', width:120, align:"right"},
-		{header:'${text("领料数量")}', name:'issueQty', index:'issueQty', width:120, align:"right"},
-		{header:'${text("单位")}', name:'unitName', index:'unitName', width:90, align:"center"},
-		{header:'${text("发料仓库")}', name:'stockName', index:'stockName', width:180, align:"left"},
-		{header:'${text("调出仓库")}', name:'sourceStockName', index:'sourceStockName', width:180, align:"left"},
-		{header:'${text("库存状态")}', name:'stockStatusName', index:'stockStatusName', width:100, align:"center"},
-		{header:'${text("批号")}', name:'lotText', index:'lotText', width:120, align:"center"}
-	],
-	ajaxSuccess: function(){}
-});
-</script>

+ 4 - 6
src/main/resources/views/modules/mes/erpProductionOrderDetail.html

@@ -26,14 +26,14 @@ $('#dataGrid').dataGrid({
 	searchForm: $("#searchForm"),
 	columnModel: [
 		{header:'${text("操作")}', name:'actions', width:100, align:"center", frozen:true, formatter: function(val, obj, row){
-			return '<a href="${ctx}/mes/erpProductionOrder/material?billNo='+encodeURIComponent(row.billNo)+'&entryId='+encodeURIComponent(row.entryId)+'" class="btnList addTabPage" data-title="${text("生产用料清单")}">'+
-				'<i class="fa fa-list-ul"></i> ${text("用料")}</a>';
+			return '<a href="${ctx}/mes/mesErpSchedule/form?erpBillNo='+encodeURIComponent(row.billNo)+'&erpEntryId='+encodeURIComponent(row.entryId)+'" class="btnList addTabPage" data-title="${text("ERP排产")}">'+
+				'<i class="fa fa-calendar-plus-o"></i> ${text("排产")}</a>';
 		}},
 		{header:'${text("行号")}', name:'seq', index:'seq', width:80, align:"center", frozen:true},
 		{header:'${text("物料编码")}', name:'materialNumber', index:'materialNumber', width:220, align:"left"},
 		{header:'${text("物料名称")}', name:'materialName', index:'materialName', width:260, align:"left"},
 		{header:'${text("存货类别")}', name:'inventoryCategory', index:'inventoryCategory', width:120, align:"center"},
-		{header:'${text("规格型号")}', name:'specification', index:'specification', width:180, align:"left"},
+		{header:'${text("生产车间")}', name:'workshopName', index:'workshopName', width:140, align:"left"},
 		{header:'${text("数量")}', name:'qty', index:'qty', width:110, align:"right"},
 		{header:'${text("单位")}', name:'unitName', index:'unitName', width:90, align:"center"},
 		{header:'${text("业务状态")}', name:'businessStatusName', index:'businessStatusName', width:100, align:"center"},
@@ -41,9 +41,7 @@ $('#dataGrid').dataGrid({
 		{header:'${text("计划完工")}', name:'planFinishDate', index:'planFinishDate', width:150, align:"center"},
 		{header:'${text("入库仓库")}', name:'stockName', index:'stockName', width:140, align:"left"},
 		{header:'${text("BOM版本")}', name:'bomNumber', index:'bomNumber', width:140, align:"left"},
-		{header:'${text("工艺路线")}', name:'routingNumber', index:'routingNumber', width:140, align:"left"},
-		{header:'${text("项目编号")}', name:'projectNo', index:'projectNo', width:130, align:"left"},
-		{header:'${text("计划跟踪号")}', name:'mtoNo', index:'mtoNo', width:130, align:"left"}
+		{header:'${text("工艺路线")}', name:'routingNumber', index:'routingNumber', width:140, align:"left"}
 	],
 	ajaxSuccess: function(){}
 });

+ 0 - 1
src/main/resources/views/modules/mes/erpProductionOrderList.html

@@ -42,7 +42,6 @@ $('#dataGrid').dataGrid({
 		{header:'${text("生产组织")}', name:'prdOrgName', index:'prdOrgName', width:180, align:"left"},
 		{header:'${text("存货类别")}', name:'inventoryCategory', index:'inventoryCategory', width:120, align:"center"},
 		{header:'${text("生产车间")}', name:'workshopName', index:'workshopName', width:140, align:"left"},
-		{header:'${text("计划员")}', name:'plannerName', index:'plannerName', width:120, align:"left"},
 		{header:'${text("明细行数")}', name:'entryCount', index:'entryCount', width:100, align:"right"},
 		{header:'${text("总数量")}', name:'totalQty', index:'totalQty', width:120, align:"right"}
 	],

+ 41 - 0
src/main/resources/views/modules/mes/erpPurchaseOrderDetail.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采购订单明细')}:${erpPurchaseOrder.billNo}
+			</div>
+			<div class="box-tools pull-right">
+				<a href="${ctx}/mes/erpPurchaseOrder/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="${erpPurchaseOrder}" action="${ctx}/mes/erpPurchaseOrder/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:'materialNumber', index:'materialNumber', width:180, align:"left", frozen:true},
+		{header:'${text("物料名称")}', name:'materialName', index:'materialName', width:220, align:"left"},
+		{header:'${text("采购数量")}', name:'qty', index:'qty', width:100, align:"right"},
+		{header:'${text("单位")}', name:'unitName', index:'unitName', width:80, align:"center"},
+		{header:'${text("交货日期")}', name:'deliveryDate', index:'deliveryDate', width:110, align:"center"},
+		{header:'${text("单价")}', name:'price', index:'price', width:100, align:"right"},
+		{header:'${text("金额")}', name:'amount', index:'amount', width:110, align:"right"},
+		{header:'${text("源单编号")}', name:'sourceBillNo', index:'sourceBillNo', width:150, align:"center"},
+		{header:'${text("供应商")}', name:'supplierName', index:'supplierName', width:160, align:"left"}
+	],
+	ajaxSuccess: function(){}
+});
+</script>

+ 64 - 0
src/main/resources/views/modules/mes/erpPurchaseOrderList.html

@@ -0,0 +1,64 @@
+<% 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-shopping-cart"></i> ${text('ERP采购订单查询')}
+			</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>
+				<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="${erpPurchaseOrder}" action="${ctx}/mes/erpPurchaseOrder/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="billNo" maxlength="100" class="form-control width-180"/>
+					</div>
+				</div>
+				<div class="form-group">
+					<label class="control-label">${text('供应商编码')}:</label>
+					<div class="control-inline">
+						<#form:input path="supplierNumberQuery" maxlength="100" class="form-control width-180"/>
+					</div>
+				</div>
+				<div class="form-group">
+					<label class="control-label">${text('物料编码')}:</label>
+					<div class="control-inline">
+						<#form:input path="materialNumberQuery" maxlength="100" class="form-control width-180"/>
+					</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({
+	searchForm: $("#searchForm"),
+	columnModel: [
+		{header:'${text("采购订单号")}', name:'billNo', index:'billNo', width:180, align:"center", frozen:true, formatter: function(val){
+			return '<a href="${ctx}/mes/erpPurchaseOrder/detail?billNo='+encodeURIComponent(val)+'" class="addTabPage" data-title="${text("ERP采购订单明细")}">'+val+'</a>';
+		}},
+		{header:'${text("采购日期")}', name:'date', index:'date', width:110, align:"center"},
+		{header:'${text("单据状态")}', name:'documentStatusName', index:'documentStatusName', width:90, align:"center"},
+		{header:'${text("单据类型")}', name:'billTypeName', index:'billTypeName', width:120, align:"center"},
+		{header:'${text("业务类型")}', name:'businessTypeName', index:'businessTypeName', width:100, align:"center"},
+		{header:'${text("采购组织")}', name:'purchaseOrgName', index:'purchaseOrgName', width:160, align:"left"},
+		{header:'${text("供应商编码")}', name:'supplierNumber', index:'supplierNumber', width:130, align:"left"},
+		{header:'${text("供应商名称")}', name:'supplierName', index:'supplierName', width:180, align:"left"},
+		{header:'${text("明细行数")}', name:'entryCount', index:'entryCount', width:90, align:"right"},
+		{header:'${text("总数量")}', name:'totalQty', index:'totalQty', width:110, align:"right"}
+	],
+	ajaxSuccess: function(){}
+});
+</script>

+ 1 - 1
src/main/resources/views/modules/mes/mesEnergyScreen.html

@@ -86,7 +86,7 @@
         <div class="device-list" id="deviceList"></div>
     </div>
     <div class="chart-wrap">
-        <div class="panel"><div class="panel-title">今日小时能耗趋势</div><div id="trendChart" class="chart"></div></div>
+        <div class="panel"><div class="panel-title">今日累计用电趋势</div><div id="trendChart" class="chart"></div></div>
         <div class="panel"><div class="panel-title">实时功率对比</div><div id="powerChart" class="chart"></div></div>
     </div>
 </div>

+ 195 - 0
src/main/resources/views/modules/mes/mesErpScheduleForm.html

@@ -0,0 +1,195 @@
+<% layout('/layouts/default.html', {title: 'ERP排产计划', libs: ['validate']}){ %>
+<div class="main-content">
+	<div class="box box-main">
+		<div class="box-header with-border">
+			<div class="box-title">
+				<i class="fa icon-note"></i> ${text(mesErpSchedule.isNewRecord ? '新增ERP排产计划' : '编辑ERP排产计划')}
+			</div>
+			<div class="box-tools pull-right">
+				<button type="button" class="btn btn-box-tool" data-widget="collapse"><i class="fa fa-minus"></i></button>
+			</div>
+		</div>
+		<#form:form id="inputForm" model="${mesErpSchedule}" action="${ctx}/mes/mesErpSchedule/save" method="post" class="form-horizontal">
+			<div class="box-body">
+				<div class="form-unit">${text('ERP订单信息')}</div>
+				<#form:hidden path="id"/>
+				<#form:hidden path="erpBillNo"/>
+				<#form:hidden path="erpEntryId"/>
+				<#form:hidden path="erpSeq"/>
+				<#form:hidden path="materialNumber"/>
+				<#form:hidden path="materialName"/>
+				<#form:hidden path="inventoryCategory"/>
+				<#form:hidden path="workshopName"/>
+				<#form:hidden path="orderQty"/>
+				<#form:hidden path="erpPlanStartDate"/>
+				<#form:hidden path="erpPlanFinishDate"/>
+				<div class="row">
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4">${text('生产订单号')}:</label>
+							<div class="col-sm-8">
+								<p class="form-control-static" id="viewBillNo">${mesErpSchedule.erpBillNo!}</p>
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4">${text('行号')}:</label>
+							<div class="col-sm-8">
+								<p class="form-control-static" id="viewSeq">${mesErpSchedule.erpSeq!}</p>
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4">${text('物料名称')}:</label>
+							<div class="col-sm-8">
+								<p class="form-control-static" id="viewMaterialName">${mesErpSchedule.materialName!}</p>
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4">${text('存货类别')}:</label>
+							<div class="col-sm-8">
+								<p class="form-control-static" id="viewCategory">${mesErpSchedule.inventoryCategory!}</p>
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4">${text('生产车间')}:</label>
+							<div class="col-sm-8">
+								<p class="form-control-static" id="viewWorkshop">${mesErpSchedule.workshopName!}</p>
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4">${text('订单数量')}:</label>
+							<div class="col-sm-8">
+								<p class="form-control-static" id="viewOrderQty">${mesErpSchedule.orderQty!}</p>
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-12">
+						<div class="form-group">
+							<label class="control-label col-sm-2">${text('ERP计划时间')}:</label>
+							<div class="col-sm-10">
+								<p class="form-control-static" id="viewErpPlan">
+									${mesErpSchedule.erpPlanStartDate!} ~ ${mesErpSchedule.erpPlanFinishDate!}
+								</p>
+								<p class="help-block">${text('排产日期必须在此范围内')}</p>
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-12">
+						<div class="form-group">
+							<label class="control-label col-sm-2">${text('可排产数量')}:</label>
+							<div class="col-sm-10">
+								<p class="form-control-static text-primary" id="viewRemainQty">${mesErpSchedule.remainQty!0}</p>
+							</div>
+						</div>
+					</div>
+				</div>
+
+				<div class="form-unit">${text('排产信息')}</div>
+				<div class="row">
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4" title="">
+								<span class="required">*</span> ${text('产线')}:</label>
+							<div class="col-sm-8">
+								<#form:select path="lineId" items="${lines}" itemLabel="title" itemValue="id" blankOption="true" class="form-control required"/>
+								<p class="help-block">${text('HEV→HEV产线,115/欣旺达115→115产线')}</p>
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4" title="">
+								<span class="required">*</span> ${text('排产数量')}:</label>
+							<div class="col-sm-8">
+								<#form:input path="scheduleQty" maxlength="8" class="form-control required digits"/>
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4" title="">
+								<span class="required">*</span> ${text('排产开始')}:</label>
+							<div class="col-sm-8">
+								<input type="text" id="scheduleStartDate" name="scheduleStartDate" class="form-control required" readonly
+									value="${mesErpSchedule.scheduleStartDate!}">
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4" title="">
+								<span class="required">*</span> ${text('排产结束')}:</label>
+							<div class="col-sm-8">
+								<input type="text" id="scheduleEndDate" name="scheduleEndDate" class="form-control required" readonly
+									value="${mesErpSchedule.scheduleEndDate!}">
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-12">
+						<div class="form-group">
+							<label class="control-label col-sm-2">${text('备注')}:</label>
+							<div class="col-sm-10">
+								<#form:textarea path="remarks" rows="2" maxlength="500" class="form-control"/>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+			<div class="box-footer">
+				<div class="row">
+					<div class="col-sm-offset-2 col-sm-10">
+						<% if (hasPermi('mes:mesErpSchedule:view') || hasPermi('mes:erpProductionOrder:view')){ %>
+							<button type="submit" class="btn btn-sm btn-primary" id="btnSubmit"><i class="fa fa-check"></i> ${text('保 存')}</button>&nbsp;
+						<% } %>
+						<button type="button" class="btn btn-sm btn-default" id="btnCancel" onclick="js.closeCurrentTabPage()"><i class="fa fa-reply-all"></i> ${text('关 闭')}</button>
+					</div>
+				</div>
+			</div>
+		</#form:form>
+	</div>
+</div>
+<% } %>
+<script src="${ctxStatic}/layDate-v5.0.9/laydate.js"></script>
+<script>
+var erpMinDate = '${mesErpSchedule.erpPlanStartDate!}';
+var erpMaxDate = '${mesErpSchedule.erpPlanFinishDate!}';
+
+laydate.render({
+	elem: '#scheduleStartDate',
+	trigger: 'click',
+	type: 'date',
+	format: 'yyyy-MM-dd',
+	min: erpMinDate || '1900-01-01',
+	max: erpMaxDate || '2099-12-31'
+});
+laydate.render({
+	elem: '#scheduleEndDate',
+	trigger: 'click',
+	type: 'date',
+	format: 'yyyy-MM-dd',
+	min: erpMinDate || '1900-01-01',
+	max: erpMaxDate || '2099-12-31'
+});
+
+$("#inputForm").validate({
+	submitHandler: function(form){
+		js.ajaxSubmitForm($(form), function(data){
+			js.showMessage(data.message);
+			if(data.result == Global.TRUE){
+				js.closeCurrentTabPage(function(contentWindow){
+					contentWindow.page();
+				});
+			}
+		}, "json");
+	}
+});
+</script>

+ 75 - 0
src/main/resources/views/modules/mes/mesErpScheduleList.html

@@ -0,0 +1,75 @@
+<% 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-calendar-check-o"></i> ${text('ERP排产计划')}
+			</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>
+				<a href="${ctx}/mes/erpProductionOrder/list" class="btn btn-default btnTool" title="${text('从ERP订单排产')}"><i class="fa fa-list-alt"></i> ${text('ERP订单')}</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="${mesErpSchedule}" action="${ctx}/mes/mesErpSchedule/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="erpBillNo" maxlength="100" class="form-control width-160"/>
+					</div>
+				</div>
+				<div class="form-group">
+					<label class="control-label">${text('产线')}:</label>
+					<div class="control-inline">
+						<#form:input path="lineTitle" maxlength="100" class="form-control width-120"/>
+					</div>
+				</div>
+				<div class="form-group">
+					<label class="control-label">${text('存货类别')}:</label>
+					<div class="control-inline">
+						<#form:input path="inventoryCategory" maxlength="64" class="form-control width-100"/>
+					</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({
+	searchForm: $("#searchForm"),
+	columnModel: [
+		{header:'${text("生产订单号")}', name:'erpBillNo', index:'a.erp_bill_no', width:170, align:"center", frozen:true, formatter: function(val, obj, row){
+			return '<a href="${ctx}/mes/mesErpSchedule/form?id='+row.id+'" class="btnList" data-title="${text("编辑ERP排产计划")}">'+val+'</a>';
+		}},
+		{header:'${text("行号")}', name:'erpSeq', index:'a.erp_seq', width:60, align:"center", frozen:true},
+		{header:'${text("物料名称")}', name:'materialName', index:'a.material_name', width:180, align:"left"},
+		{header:'${text("存货类别")}', name:'inventoryCategory', index:'a.inventory_category', width:90, align:"center"},
+		{header:'${text("产线")}', name:'lineTitle', index:'a.line_title', width:100, align:"center"},
+		{header:'${text("排产开始")}', name:'scheduleStartDate', index:'a.schedule_start_date', width:110, align:"center"},
+		{header:'${text("排产结束")}', name:'scheduleEndDate', index:'a.schedule_end_date', width:110, align:"center"},
+		{header:'${text("排产数量")}', name:'scheduleQty', index:'a.schedule_qty', width:90, align:"right"},
+		{header:'${text("订单数量")}', name:'orderQty', index:'a.order_qty', width:90, align:"right"},
+		{header:'${text("ERP计划")}', name:'erpPlanStartDate', index:'a.erp_plan_start_date', width:180, align:"center", formatter: function(val, obj, row){
+			return (row.erpPlanStartDate||'') + ' ~ ' + (row.erpPlanFinishDate||'');
+		}},
+		{header:'${text("操作")}', name:'actions', width:80, align:"center", formatter: function(val, obj, row){
+			var actions = [];
+			//<% if(hasPermi('mes:mesErpSchedule:view') || hasPermi('mes:erpProductionOrder:view')){ %>
+				actions.push('<a href="${ctx}/mes/mesErpSchedule/form?id='+row.id+'" class="btnList" title="${text("编辑")}"><i class="fa fa-pencil"></i></a>&nbsp;');
+				actions.push('<a href="${ctx}/mes/mesErpSchedule/delete?id='+row.id+'" class="btnList" title="${text("删除")}" data-confirm="${text("确认要删除该排产计划吗?")}"><i class="fa fa-trash-o"></i></a>&nbsp;');
+			//<% } %>
+			return actions.join('');
+		}}
+	],
+	ajaxSuccess: function(){}
+});
+</script>

+ 161 - 130
src/main/resources/views/modules/mes/mesProductArcWeldingInfo.html

@@ -1,9 +1,9 @@
-<% layout('/layouts/default.html', {title: '弧焊过程参数管理', libs: ['validate']}){ %>
+<% layout('/layouts/default.html', {title: '焊接过程参数管理', libs: ['validate']}){ %>
 <div class="main-content">
     <div class="box box-main">
         <div class="box-header with-border">
             <div class="box-title">
-                <i class="fa icon-note"></i> ${text(mesProductArcWelding.isNewRecord ? '新增弧焊过程参数管理' : '弧焊过程参数管理详情')}
+                <i class="fa icon-note"></i> ${text(mesProductArcWelding.isNewRecord ? '新增焊接过程参数管理' : '焊接过程参数管理详情')}
             </div>
             <div class="box-tools pull-right">
                 <button type="button" class="btn btn-box-tool" data-widget="collapse"><i class="fa fa-minus"></i>
@@ -13,6 +13,7 @@
 
 
         <div id="app">
+            <div class="alert alert-info" style="margin-bottom: 10px;">{{ weldingTypeText }},已解析 {{ myData.length }} 条参数记录</div>
 
             <div ref="vChart" style="width: 100%;height: 500px;"></div>
             <br>
@@ -20,7 +21,7 @@
             <div ref="iChart" style="width: 100%;height:500px;"></div>
             <br>
             <br>
-            <div ref="speedChart" style="width: 100%;height:500px;"></div>
+            <div v-if="showThirdChart" ref="speedChart" style="width: 100%;height:500px;"></div>
             <br>
             <br>
             <div class="col-sm-12">
@@ -28,11 +29,11 @@
                     <thead>
                         <tr>
                             <template v-for="n in welderCount">
-                                <th>电压{{n}}</th>
-                                <th>电流{{n}}</th>
-                                <th>送丝速度{{n}}</th>
-                                <th>错误码{{n}}</th>
-                                <th>JOB号{{n}}</th>
+                                <th>{{ firstMetricName }}{{n}}</th>
+                                <th>{{ secondMetricName }}{{n}}</th>
+                                <th v-if="showThirdChart">{{ thirdMetricName }}{{n}}</th>
+                                <th v-if="!isNewJson">错误码{{n}}</th>
+                                <th v-if="!isNewJson">JOB号{{n}}</th>
                             </template>
                             <th>记录时间</th>
                         </tr>
@@ -42,9 +43,9 @@
                             <template v-for="n in welderCount">
                                 <td>{{ item['V' + n] }}</td>
                                 <td>{{ item['I' + n] }}</td>
-                                <td>{{ item['speed' + n] }}</td>
-                                <td>{{ item['code' + n] }}</td>
-                                <td>{{ item['JOB' + n] }}</td>
+                                <td v-if="showThirdChart">{{ item['speed' + n] }}</td>
+                                <td v-if="!isNewJson">{{ item['code' + n] }}</td>
+                                <td v-if="!isNewJson">{{ item['JOB' + n] }}</td>
                             </template>
                             <td>{{ item.createDate }}</td>
                         </tr>
@@ -113,10 +114,49 @@
                  .replace(/&nbsp;/g, ' ');
     };
 
+    const normalizeContent = (content) => {
+        if (!content) return '';
+        let text = unescapeHtml(content).trim();
+        for (let i = 0; i < 2; i++) {
+            if (!text.startsWith('"')) break;
+            try {
+                const parsed = JSON.parse(text);
+                if (typeof parsed !== 'string') break;
+                text = parsed.trim();
+            } catch (e) {
+                break;
+            }
+        }
+        if (text.startsWith('{\\') || text.startsWith('[\\')) {
+            text = text.replace(/\\"/g, '"')
+                       .replace(/\\\\/g, '\\');
+        }
+        return text;
+    };
+
+    const parseJsonContent = (content) => {
+        const text = normalizeContent(content);
+        if (!text.startsWith('{')) return null;
+        try {
+            return JSON.parse(text);
+        } catch (e) {
+            console.error('解析JSON格式content出错:', e, text);
+            return null;
+        }
+    };
+
     const getWelderCount = (content) => {
         if (!content || content === '' || content === '[]') return 2;
         try {
-            content = unescapeHtml(content);
+            content = normalizeContent(content);
+            const json = parseJsonContent(content);
+            if (json) {
+                const first = json.samples && json.samples.length > 0 ? json.samples[0] : {};
+                let count = 0;
+                if (first.robot1Current !== undefined || first.robot1Voltage !== undefined || first.robot1Pressure !== undefined) count++;
+                if (first.robot2Current !== undefined || first.robot2Voltage !== undefined || first.robot2Pressure !== undefined) count++;
+                return count || 2;
+            }
             let strArr;
             if (content.startsWith('[')) {
                 strArr = JSON.parse(content);
@@ -136,10 +176,34 @@
         }
     };
 
+    const convertNewJsonContent = (content) => {
+        const json = parseJsonContent(content);
+        if (!json) return [];
+        const samples = json.samples || [];
+        return samples.map(sample => {
+            const obj = {};
+            obj.V1 = parseFloat(sample.robot1Voltage || 0) || 0;
+            obj.I1 = parseFloat(sample.robot1Current || 0) || 0;
+            obj.speed1 = parseFloat(sample.robot1Pressure || sample.robot1WeldTime || 0) || 0;
+            obj.code1 = 0;
+            obj.JOB1 = '';
+            obj.V2 = parseFloat(sample.robot2Voltage || 0) || 0;
+            obj.I2 = parseFloat(sample.robot2Current || 0) || 0;
+            obj.speed2 = parseFloat(sample.robot2Pressure || sample.robot2WeldTime || 0) || 0;
+            obj.code2 = 0;
+            obj.JOB2 = '';
+            obj.createDate = sample.time || '';
+            return obj;
+        });
+    };
+
     const parseContent = (content) => {
         if (!content || content === '' || content === '[]') return [];
         try {
-            content = unescapeHtml(content);
+            content = normalizeContent(content);
+            if (parseJsonContent(content)) {
+                return convertNewJsonContent(content);
+            }
             let strArr;
             if (content.startsWith('[')) {
                 strArr = JSON.parse(content);
@@ -160,7 +224,7 @@
     new Vue({
         el: '#app',
         data: {
-            content: '${mesProductArcWelding.content!\'\'}',
+            content: ${toJson(mesProductArcWelding.content!'')},
             welderCount: 2,
             vChart: null,
             iChart: null,
@@ -170,174 +234,131 @@
             myData() {
                 return parseContent(this.content);
             },
+            contentJson() {
+                return parseJsonContent(this.content);
+            },
+            isNewJson() {
+                return !!this.contentJson;
+            },
+            weldingType() {
+                return this.contentJson && this.contentJson.weldingType ? this.contentJson.weldingType : 'old';
+            },
+            weldingTypeText() {
+                if (this.weldingType === 'spot') return '点焊过程参数';
+                if (this.weldingType === 'arc') return '弧焊过程参数';
+                return '焊接过程参数';
+            },
+            firstMetricName() {
+                return this.weldingType === 'spot' ? '电流' : '电压';
+            },
+            secondMetricName() {
+                return this.weldingType === 'spot' ? '压力' : '电流';
+            },
+            thirdMetricName() {
+                return this.weldingType === 'spot' ? '焊接时间' : '送丝速度';
+            },
+            showThirdChart() {
+                return this.weldingType === 'spot';
+            },
             timeArr() {
                 if (!this.myData || this.myData.length === 0) return [];
-                arr = []
+                const arr = [];
                 for (let i = 0; i < this.myData.length; i++) {
-                    const {createDate} = this.myData[i]
+                    const createDate = this.myData[i].createDate;
                     if (createDate) {
-                        arr.push(createDate.slice(14))
+                        arr.push(createDate.length > 14 ? createDate.slice(11) : createDate);
                     }
                 }
-                return arr
+                return arr;
             },
             vOption() {
                 const series = [];
                 const legendData = [];
                 for (let i = 1; i <= this.welderCount; i++) {
-                    legendData.push('电压' + i);
+                    legendData.push(this.firstMetricName + i);
                     const arr = [];
                     for (let j = 0; j < this.myData.length; j++) {
                         arr.push(this.myData[j]['V' + i]);
                     }
                     series.push({
-                        name: '电压' + i,
+                        name: this.firstMetricName + i,
                         type: 'line',
                         data: arr,
-                        smooth: true
+                        smooth: true,
+                        showSymbol: true,
+                        symbolSize: 8
                     });
                 }
-                let option = {
-                    title: {
-                        text: '电压变化折线图'
-                    },
-                    tooltip: {
-                        trigger: 'axis'
-                    },
-                    legend: {
-                        data: legendData
-                    },
-                    grid: {
-                        left: '3%',
-                        right: '4%',
-                        bottom: '3%',
-                        containLabel: true
-                    },
-                    toolbox: {
-                        feature: {
-                            saveAsImage: {}
-                        }
-                    },
-                    xAxis: {
-                        type: 'category',
-                        name: '时间',
-                        boundaryGap: false,
-                        data: this.timeArr
-                    },
-                    yAxis: {
-                        name: "电压",
-                        type: 'value'
-                    },
+                return {
+                    title: { text: this.firstMetricName + '变化折线图' },
+                    tooltip: { trigger: 'axis' },
+                    legend: { data: legendData },
+                    grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
+                    toolbox: { feature: { saveAsImage: {} } },
+                    xAxis: { type: 'category', name: '时间', boundaryGap: false, data: this.timeArr },
+                    yAxis: { name: this.firstMetricName, type: 'value' },
                     series: series
                 };
-                return option
             },
             iOption() {
                 const series = [];
                 const legendData = [];
                 for (let i = 1; i <= this.welderCount; i++) {
-                    legendData.push('电流' + i);
+                    legendData.push(this.secondMetricName + i);
                     const arr = [];
                     for (let j = 0; j < this.myData.length; j++) {
                         arr.push(this.myData[j]['I' + i]);
                     }
                     series.push({
-                        name: '电流' + i,
+                        name: this.secondMetricName + i,
                         type: 'line',
                         data: arr,
-                        smooth: true
+                        smooth: true,
+                        showSymbol: true,
+                        symbolSize: 8
                     });
                 }
-                let option = {
-                    title: {
-                        text: '电流变化折线图'
-                    },
-                    tooltip: {
-                        trigger: 'axis'
-                    },
-                    legend: {
-                        data: legendData
-                    },
-                    grid: {
-                        left: '3%',
-                        right: '4%',
-                        bottom: '3%',
-                        containLabel: true
-                    },
-                    toolbox: {
-                        feature: {
-                            saveAsImage: {}
-                        }
-                    },
-                    xAxis: {
-                        type: 'category',
-                        name: '时间',
-                        boundaryGap: false,
-                        data: this.timeArr,
-
-                    },
-                    yAxis: {
-                        name: "电流",
-                        type: 'value'
-                    },
+                return {
+                    title: { text: this.secondMetricName + '变化折线图' },
+                    tooltip: { trigger: 'axis' },
+                    legend: { data: legendData },
+                    grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
+                    toolbox: { feature: { saveAsImage: {} } },
+                    xAxis: { type: 'category', name: '时间', boundaryGap: false, data: this.timeArr },
+                    yAxis: { name: this.secondMetricName, type: 'value' },
                     series: series
                 };
-                return option
             },
             speedOption() {
                 const series = [];
                 const legendData = [];
                 for (let i = 1; i <= this.welderCount; i++) {
-                    legendData.push('送丝速度' + i);
+                    legendData.push(this.thirdMetricName + i);
                     const arr = [];
                     for (let j = 0; j < this.myData.length; j++) {
                         arr.push(this.myData[j]['speed' + i]);
                     }
                     series.push({
-                        name: '送丝速度' + i,
+                        name: this.thirdMetricName + i,
                         type: 'line',
                         data: arr,
-                        smooth: true
+                        smooth: true,
+                        showSymbol: true,
+                        symbolSize: 8
                     });
                 }
-                let option = {
-                    title: {
-                        text: '送丝速度变化折线图'
-                    },
-                    tooltip: {
-                        trigger: 'axis'
-                    },
-                    legend: {
-                        data: legendData
-                    },
-                    grid: {
-                        left: '3%',
-                        right: '4%',
-                        bottom: '3%',
-                        containLabel: true
-                    },
-                    toolbox: {
-                        feature: {
-                            saveAsImage: {}
-                        }
-                    },
-                    xAxis: {
-                        type: 'category',
-                        name: '时间',
-                        boundaryGap: false,
-                        data: this.timeArr,
-
-                    },
-                    yAxis: {
-                        name: "速度",
-                        type: 'value'
-                    },
+                return {
+                    title: { text: this.thirdMetricName + '变化折线图' },
+                    tooltip: { trigger: 'axis' },
+                    legend: { data: legendData },
+                    grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
+                    toolbox: { feature: { saveAsImage: {} } },
+                    xAxis: { type: 'category', name: '时间', boundaryGap: false, data: this.timeArr },
+                    yAxis: { name: this.thirdMetricName, type: 'value' },
                     series: series
                 };
-                return option
             }
         },
-
         watch: {
             myData: {
                 handler() {
@@ -345,7 +366,7 @@
                         this.welderCount = getWelderCount(this.content);
                         this.vChart.setOption(this.vOption, true);
                         this.iChart.setOption(this.iOption, true);
-                        this.speedChart.setOption(this.speedOption, true);
+                        if (this.speedChart) { this.speedChart.setOption(this.speedOption, true); }
                     }
                 },
                 deep: true
@@ -359,11 +380,21 @@
             this.$nextTick(() => {
                 this.vChart = echarts.init(this.$refs.vChart)
                 this.iChart = echarts.init(this.$refs.iChart)
-                this.speedChart = echarts.init(this.$refs.speedChart)
+                if (this.$refs.speedChart) {
+                    this.speedChart = echarts.init(this.$refs.speedChart)
+                }
                 this.vChart.setOption(this.vOption);
                 this.iChart.setOption(this.iOption);
-                this.speedChart.setOption(this.speedOption);
+                if (this.speedChart) {
+                    this.speedChart.setOption(this.speedOption);
+                }
             });
         }
     })
-</script>
+</script>
+
+
+
+
+
+

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

@@ -1,16 +1,16 @@
-<% layout('/layouts/default.html', {title: '弧焊参数管理', libs: ['dataGrid']}){ %>
+<% 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('弧焊参数管理')}
+				<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>
 				<a href="#" class="btn btn-default" id="btnExport"><i class="glyphicon glyphicon-export"></i> 导出</a>
 <!--				<% if(hasPermi('mes:mesProductArcWelding:edit')){ %>-->
 <!--						<a href="#" class="btn btn-default" id="btnImport"><i class="glyphicon glyphicon-import"></i> 导入</a>-->
-<!--					<a href="${ctx}/mes/mesProductArcWelding/form" class="btn btn-default btnTool" title="${text('新增弧焊参数')}"><i class="fa fa-plus"></i> ${text('新增')}</a>-->
+<!--					<a href="${ctx}/mes/mesProductArcWelding/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>
@@ -53,17 +53,17 @@ $('#dataGrid').dataGrid({
 	searchForm: $("#searchForm"),
 	columnModel: [
 		{header:'${text("产线编号")}', name:'lineSn', index:'a.line_sn', width:150, align:"left", frozen:true, formatter: function(val, obj, row, act){
-			return '<a href="${ctx}/mes/mesProductArcWelding/form?id='+row.id+'&createDate='+row.createDate+'" class="btnList" data-title="${text("编辑弧焊参数")}">'+(val||row.id)+'</a>';
+			return '<a href="${ctx}/mes/mesProductArcWelding/form?id='+row.id+'&createDate='+encodeURIComponent(row.createDate)+'" class="btnList" data-title="${text("编辑点焊参数")}">'+(val||row.id)+'</a>';
 		}},
 		{header:'${text("工位号")}', name:'oprno', index:'a.oprno', width:150, align:"left"},
 		{header:'${text("二维码")}', name:'sn', index:'a.sn', width:150, align:"left"},
 		{header:'${text("操作")}', name:'actions', width:120, formatter: function(val, obj, row, act){
 			var actions = [];
 			//<% if(hasPermi('mes:mesProductArcWelding:edit')){ %>
-			// 	actions.push('<a href="${ctx}/mes/mesProductArcWelding/form?id='+row.id+'&createDate='+row.createDate+'" class="btnList" title="${text("编辑弧焊参数")}"><i class="fa fa-pencil"></i></a>&nbsp;');
-				// actions.push('<a href="${ctx}/mes/mesProductArcWelding/delete?id='+row.id+'&createDate='+row.createDate+'" class="btnList" title="${text("删除弧焊参数")}" data-confirm="${text("确认要删除该弧焊参数吗?")}"><i class="fa fa-trash-o"></i></a>&nbsp;');
+			// 	actions.push('<a href="${ctx}/mes/mesProductArcWelding/form?id='+row.id+'&createDate='+row.createDate+'" class="btnList" title="${text("编辑点焊参数")}"><i class="fa fa-pencil"></i></a>&nbsp;');
+				// actions.push('<a href="${ctx}/mes/mesProductArcWelding/delete?id='+row.id+'&createDate='+row.createDate+'" class="btnList" title="${text("删除点焊参数")}" data-confirm="${text("确认要删除该点焊参数吗?")}"><i class="fa fa-trash-o"></i></a>&nbsp;');
 			//<% } %>
-				actions.push('<a href="${ctx}/mes/mesProductCmt/info?id='+row.id+'" class="btnList" title="${text("详情")}"><i class="fa fa-fw fa-eye"></i></a>&nbsp;');
+				actions.push('<a href="${ctx}/mes/mesProductArcWelding/info?id='+row.id+'&createDate='+encodeURIComponent(row.createDate)+'" class="btnList" title="${text("详情")}"><i class="fa fa-fw fa-eye"></i></a>&nbsp;');
 				return actions.join('');
 		}}
 	],
@@ -83,7 +83,7 @@ $('#btnImport').click(function(){
 	js.layer.open({
 		type: 1,
 		area: ['400px'],
-		title: '${text("导入弧焊参数")}',
+		title: '${text("导入点焊参数")}',
 		resize: false,
 		scrollbar: true,
 		content: js.template('importTpl'),

+ 0 - 266
tools/build_screen_data_doc.py

@@ -1,266 +0,0 @@
-from docx import Document
-from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
-from docx.enum.text import WD_ALIGN_PARAGRAPH
-from docx.oxml import OxmlElement
-from docx.oxml.ns import qn
-from docx.shared import Inches, Pt, RGBColor
-
-
-OUT = "大屏数据来源说明-客户版.docx"
-
-
-def shade(cell, fill):
-    tc_pr = cell._tc.get_or_add_tcPr()
-    shd = tc_pr.find(qn("w:shd"))
-    if shd is None:
-        shd = OxmlElement("w:shd")
-        tc_pr.append(shd)
-    shd.set(qn("w:fill"), fill)
-
-
-def set_east_asia_font(run, font_name="Microsoft YaHei"):
-    run.font.name = "Calibri"
-    r_pr = run._element.get_or_add_rPr()
-    r_fonts = r_pr.rFonts
-    if r_fonts is None:
-        r_fonts = OxmlElement("w:rFonts")
-        r_pr.append(r_fonts)
-    r_fonts.set(qn("w:eastAsia"), font_name)
-
-
-def set_table_geometry(table, widths):
-    table.autofit = False
-    table.alignment = WD_TABLE_ALIGNMENT.CENTER
-    tbl_pr = table._tbl.tblPr
-    tbl_w = tbl_pr.find(qn("w:tblW"))
-    if tbl_w is None:
-        tbl_w = OxmlElement("w:tblW")
-        tbl_pr.append(tbl_w)
-    tbl_w.set(qn("w:w"), str(sum(int(w.inches * 1440) for w in widths)))
-    tbl_w.set(qn("w:type"), "dxa")
-
-    margins = tbl_pr.find(qn("w:tblCellMar"))
-    if margins is None:
-        margins = OxmlElement("w:tblCellMar")
-        tbl_pr.append(margins)
-    for name, value in (("top", 80), ("bottom", 80), ("start", 120), ("end", 120)):
-        node = margins.find(qn(f"w:{name}"))
-        if node is None:
-            node = OxmlElement(f"w:{name}")
-            margins.append(node)
-        node.set(qn("w:w"), str(value))
-        node.set(qn("w:type"), "dxa")
-
-    for row in table.rows:
-        for i, width in enumerate(widths):
-            cell = row.cells[i]
-            cell.width = width
-            cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
-            tc_pr = cell._tc.get_or_add_tcPr()
-            tc_w = tc_pr.find(qn("w:tcW"))
-            if tc_w is None:
-                tc_w = OxmlElement("w:tcW")
-                tc_pr.append(tc_w)
-            tc_w.set(qn("w:w"), str(int(width.inches * 1440)))
-            tc_w.set(qn("w:type"), "dxa")
-
-
-def repeat_header(row):
-    tr_pr = row._tr.get_or_add_trPr()
-    node = OxmlElement("w:tblHeader")
-    node.set(qn("w:val"), "true")
-    tr_pr.append(node)
-
-
-def setup_doc(doc):
-    sec = doc.sections[0]
-    sec.top_margin = Inches(1)
-    sec.bottom_margin = Inches(1)
-    sec.left_margin = Inches(1)
-    sec.right_margin = Inches(1)
-    sec.header_distance = Inches(0.492)
-    sec.footer_distance = Inches(0.492)
-
-    normal = doc.styles["Normal"]
-    normal.font.name = "Calibri"
-    normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
-    normal.font.size = Pt(11)
-    normal.paragraph_format.space_after = Pt(6)
-    normal.paragraph_format.line_spacing = 1.10
-
-    for name, size, color, before, after in [
-        ("Heading 1", 16, "2E74B5", 16, 8),
-        ("Heading 2", 13, "2E74B5", 12, 6),
-    ]:
-        style = doc.styles[name]
-        style.font.name = "Calibri"
-        style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
-        style.font.size = Pt(size)
-        style.font.bold = True
-        style.font.color.rgb = RGBColor.from_string(color)
-        style.paragraph_format.space_before = Pt(before)
-        style.paragraph_format.space_after = Pt(after)
-
-
-def add_title(doc):
-    p = doc.add_paragraph()
-    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
-    r = p.add_run("MES 大屏数据来源说明")
-    r.bold = True
-    r.font.size = Pt(22)
-    r.font.color.rgb = RGBColor.from_string("0B2545")
-    set_east_asia_font(r)
-
-    sub = doc.add_paragraph()
-    sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
-    r2 = sub.add_run("客户版:按大屏页面说明每项数据来自哪个业务模块")
-    r2.font.size = Pt(10)
-    r2.font.color.rgb = RGBColor.from_string("5B677A")
-    set_east_asia_font(r2)
-
-
-def add_note(doc, text):
-    table = doc.add_table(rows=1, cols=1)
-    table.style = "Table Grid"
-    cell = table.rows[0].cells[0]
-    cell.text = text
-    shade(cell, "F4F6F9")
-    set_table_geometry(table, [Inches(6.5)])
-    doc.add_paragraph()
-
-
-def add_table(doc, rows):
-    table = doc.add_table(rows=1, cols=3)
-    table.style = "Table Grid"
-    headers = ["大屏展示内容", "数据来源", "统计说明"]
-    for i, header in enumerate(headers):
-        cell = table.rows[0].cells[i]
-        cell.text = header
-        shade(cell, "F2F4F7")
-        for p in cell.paragraphs:
-            for run in p.runs:
-                run.bold = True
-    for item, source, rule in rows:
-        cells = table.add_row().cells
-        cells[0].text = item
-        cells[1].text = source
-        cells[2].text = rule
-    repeat_header(table.rows[0])
-    set_table_geometry(table, [Inches(1.75), Inches(2.15), Inches(2.60)])
-    doc.add_paragraph()
-
-
-def bullet(doc, text):
-    p = doc.add_paragraph(style="List Bullet")
-    p.paragraph_format.left_indent = Inches(0.5)
-    p.paragraph_format.first_line_indent = Inches(-0.25)
-    p.paragraph_format.space_after = Pt(8)
-    p.paragraph_format.line_spacing = 1.167
-    p.add_run(text)
-
-
-def main():
-    doc = Document()
-    setup_doc(doc)
-    add_title(doc)
-    add_note(doc, "说明:本文档只说明大屏上各类数据从系统哪个业务模块取得,以及目前页面采用的大致统计口径,不涉及代码、接口和数据库字段。")
-
-    doc.add_heading("一、MES 综合数据大屏", level=1)
-    add_table(doc, [
-        ("人员数量", "用户管理 / 员工账号信息", "统计系统中已维护的员工用户数量。"),
-        ("设备数量", "设备管理", "统计系统中已维护的设备总数。"),
-        ("计划产量", "系统配置的大屏计划值", "读取系统配置中的计划产量;部分工位计划会优先读取大屏计划管理中的配置。"),
-        ("实际产量", "产品生产记录", "统计系统中已生成的生产产品数量。页面部分位置展示累计产量,部分趋势图按日期统计。"),
-        ("今日 / 近七日产量", "产品生产记录", "按生产记录的生成时间,分别统计当天、近七天或每小时产量。"),
-        ("小时产量", "产品生产记录", "按小时统计当天产量;综合图表中主要展示 8 点到 18 点的小时产量。"),
-        ("返修数量", "返修管理", "按当天返修记录统计,部分地方按产线和工件编号去重后计算。"),
-        ("报废数量", "报废管理", "统计系统中的报废记录数量。"),
-        ("产线不良率报警", "生产记录 + 返修记录", "按产线比较当天生产数量和返修数量,超过设定比例后在大屏显示报警。"),
-        ("工具剩余寿命", "工具寿命管理", "读取正在使用的工具及其当前剩余寿命。"),
-        ("出库数量", "发运 / 出库记录", "按出库记录中的数量汇总,展示今日、本月或近七日出库情况。"),
-        ("当前工位工件", "工位在制信息", "展示当前各工位正在加工或流转的工件编号。"),
-        ("不良问题统计", "返修问题清单", "按返修问题类型汇总数量,用于显示问题类型分布。"),
-        ("设备报警排行", "设备报警记录", "按报警类型汇总数量,展示报警较多的类型。"),
-        ("设备故障率趋势", "设备管理 + 设备报警记录", "按天统计发生过报警的设备数量,再与设备总数比较得到故障率。"),
-        ("NG 超时提醒", "生产过站 / 检测记录", "查找长时间未处理的 NG 记录,在大屏上做提醒。"),
-        ("工位产能列表", "工艺工位 + 生产过站记录 + 大屏计划", "按工位统计今日完成数量、近 12 小时完成数量,并展示对应计划产能。"),
-    ])
-
-    doc.add_heading("二、小时产量看板", level=1)
-    add_table(doc, [
-        ("HEVXT 上线数量", "HEVXT 产线生产过站记录", "统计 HEVXT 产线当天每小时上线工位的记录数量。"),
-        ("HEVXT 下线数量", "HEVXT 产线生产过站记录", "统计 HEVXT 产线当天每小时下线工位的记录数量。"),
-        ("B13XT 上线数量", "B13XT 产线生产过站记录", "统计 B13XT 产线当天每小时上线工位的记录数量。"),
-        ("B13XT 下线数量", "B13XT 产线生产过站记录", "统计 B13XT 产线当天每小时下线工位的记录数量。"),
-    ])
-
-    doc.add_heading("三、质量管理大屏", level=1)
-    add_table(doc, [
-        ("今日检查数量", "产品检验记录", "统计当天产生的产品检验记录数量。"),
-        ("今日首检数量", "首件检验记录", "统计当天产生的首件检验记录数量。"),
-        ("NG 数量", "NG 记录", "统计当天或近七天的 NG 记录数量。"),
-        ("返修数量", "返修管理", "统计当天或近七天的返修记录数量。"),
-        ("报废数量", "报废管理", "统计当天或近七天的报废记录数量。"),
-        ("待返修数量", "返修管理", "统计当前仍处于待处理状态的返修记录。"),
-        ("质量趋势", "检验、NG、返修、报废记录", "按最近七天分别汇总检查、NG、返修和报废数量。"),
-        ("检查状态分布", "产品检验记录", "按检验状态汇总数量。"),
-        ("检查类型分布", "产品检验记录", "按检验类型汇总数量。"),
-        ("首检状态分布", "首件检验记录", "按首件检验状态汇总数量。"),
-        ("NG 类型分布", "NG 记录", "按 NG 类型汇总数量。"),
-        ("返修状态 / 结果分布", "返修管理", "按返修状态和返修结果汇总数量。"),
-        ("报废原因排行", "报废管理", "按报废原因汇总,展示数量较多的原因。"),
-        ("责任工位排行", "检验、NG、返修记录", "按相关工位汇总质量问题数量。"),
-        ("最新质量问题", "检验、NG、返修、报废记录", "展示最近产生的质量异常或处理记录。"),
-    ])
-
-    doc.add_heading("四、物料管理看板", level=1)
-    add_table(doc, [
-        ("物料总数", "物料基础资料", "统计系统中已维护的物料数量。"),
-        ("库存批次数", "库存台账", "统计当前仍有库存数量的批次。"),
-        ("库存总量", "库存台账", "汇总当前正库存数量。"),
-        ("今日入库数量", "入库记录", "汇总当天入库数量。"),
-        ("今日出库数量", "出库记录", "汇总当天出库数量。"),
-        ("今日报检数量", "物料报检记录", "统计当天产生的报检记录数量。"),
-        ("待处理报检", "物料报检记录", "统计当前仍未完成处理的报检记录。"),
-        ("今日异常数量", "不良品、退货、报废记录", "汇总当天物料异常相关记录数量。"),
-        ("效期预警", "库存台账", "统计未来 30 天内即将到期的库存批次。"),
-        ("物料趋势", "入库、出库、报检和异常记录", "按最近七天汇总业务发生数量。"),
-        ("报检 / 检查 / 不良状态", "物料报检、检查、不良品记录", "按状态汇总数量,用于饼图展示。"),
-        ("入库类型分布", "入库单据", "按入库类型汇总数量。"),
-        ("领料状态分布", "领料单据", "按领料状态汇总数量。"),
-        ("库位库存排行", "库存台账", "按库位汇总库存数量,展示库存较多的库位。"),
-        ("物料库存排行", "库存台账", "按物料汇总库存数量,展示库存较多的物料。"),
-        ("库存明细 / 到期清单", "库存台账", "展示当前库存和即将到期的重点批次。"),
-        ("最新业务动态", "报检、检查、入库、出库及异常记录", "展示最近发生的物料业务记录。"),
-    ])
-
-    doc.add_heading("五、设备 OEE 大屏", level=1)
-    add_table(doc, [
-        ("设备总数", "设备管理", "统计系统中已维护的设备数量。"),
-        ("运行 / 故障 / 维修 / 报警 / 离线数量", "设备实时状态记录 + 设备管理", "读取每台设备最新状态后按状态分类汇总。"),
-        ("设备开动率", "设备状态记录", "按当天设备处于运行状态的时长计算,运行时长占当天已过时间的比例。"),
-        ("平均开动率", "设备状态记录", "把所有设备当天运行时长汇总后,计算整体平均开动率。"),
-        ("设备卡片", "设备管理 + 设备状态记录", "展示设备名称、编号、所属产线、当前状态、最近上报时间和开动率。"),
-        ("开动率排行", "设备状态记录", "按设备当天开动率排序,展示开动率较高的设备。"),
-    ])
-
-    add_note(doc, "特别说明:当前设备 OEE 大屏展示的是“设备开动率”,不是完整 OEE。完整 OEE 通常还需要计划运行时间、理想节拍、总产量、良品数量等数据。")
-
-    doc.add_heading("六、客户确认建议", level=1)
-    for text in [
-        "确认综合大屏顶部统计希望展示“今日数据”“本月数据”还是“历史累计数据”。",
-        "确认生产日期口径使用自然日 00:00-24:00,还是生产班次日 08:00-次日 08:00。",
-        "确认质量大屏中良品、不良是否必须全部来自真实检验数据。",
-        "确认设备 OEE 是否只展示开动率,还是后续需要扩展为完整 OEE。",
-        "确认报警、排行类数据是否需要限定为今日、近七天或本月,避免历史累计影响展示判断。",
-    ]:
-        bullet(doc, text)
-
-    footer = doc.sections[0].footer.paragraphs[0]
-    footer.alignment = WD_ALIGN_PARAGRAPH.RIGHT
-    footer.add_run("MES 大屏数据来源说明")
-    doc.save(OUT)
-
-
-if __name__ == "__main__":
-    main()