Pārlūkot izejas kodu

feat: 生产记录关联生产计划,提交工位结果无计划拦截

- mes_product_record 新增 plan_id 字段,实体关联生产计划/生产订单/班次
- 提交工位结果前校验工件是否关联有效生产计划:无计划、计划失效、订单未下发一律拦截
- 计划来源三级兜底:记录级 -> 工件级 -> 当前班次自动匹配(自愈回写)
- 新增拦截总开关 js_sys_config: mes.plan.intercept(0=关闭,便于灰度/应急)
- 生产记录列表/导出/NG 支持按生产订单号、计划日期查询;计划列表支持订单号查询、完成率
- 生产订单列表新增已排产/未排产数量
- 顺带修复:pcresult 返修标记判断错误(拦截被绕过)、updateDate 原始 SQL 在有 join 的实体上字段二义性

需先执行 db/mes_product_record_plan.sql
hbx 5 dienas atpakaļ
vecāks
revīzija
0fdfaa4ea9

+ 73 - 0
db/mes_product_record_plan.sql

@@ -0,0 +1,73 @@
+-- 生产记录关联生产计划(订单-计划-产品-记录 全链路 / 无计划拦截)
+-- 背景:
+--   1) mes_product_record(生产记录/工位结果)没有计划字段,工位结果层面无法追溯订单与计划;
+--   2) 提交工位结果时没有"无计划"校验,无计划工件照样可以过站;
+--   3) 生产记录列表/导出无法按订单、计划查询。
+-- 本脚本负责:加字段 + 加索引 + 存量回填 + 拦截总开关。
+-- 说明:结构补齐脚本,字段必须先执行本脚本再发布应用,否则生产记录相关查询会报错。
+
+-- ---------- 1. 生产记录表增加生产计划字段 ----------
+SET @col_exists = (SELECT COUNT(1) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mes_product_record' AND COLUMN_NAME = 'plan_id');
+SET @ddl = IF(@col_exists = 0,
+  'ALTER TABLE `mes_product_record` ADD COLUMN `plan_id` VARCHAR(64) DEFAULT NULL COMMENT ''生产计划'' AFTER `cate_sn`',
+  'SELECT ''mes_product_record.plan_id 已存在'' AS message');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ---------- 2. 索引:按计划/订单查记录 ----------
+SET @idx_exists = (SELECT COUNT(1) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mes_product_record' AND INDEX_NAME = 'idx_mpr_plan_id');
+SET @ddl = IF(@idx_exists = 0,
+  'CREATE INDEX `idx_mpr_plan_id` ON `mes_product_record` (`plan_id`)',
+  'SELECT ''idx_mpr_plan_id 已存在'' AS message');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ---------- 2.1 索引:按工件码查记录(无计划拦截回退查询用)----------
+-- 注意:mes_product_record 数据量较大时建索引耗时较长,可在低峰期执行
+SET @idx_exists = (SELECT COUNT(1) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mes_product_record' AND COLUMN_NAME = 'sn');
+SET @ddl = IF(@idx_exists = 0,
+  'CREATE INDEX `idx_mpr_sn` ON `mes_product_record` (`sn`)',
+  'SELECT ''mes_product_record.sn 已有索引,跳过'' AS message');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ---------- 3. 存量数据回填:工件有计划 -> 回填到该工件的所有记录 ----------
+UPDATE `mes_product_record` r
+  JOIN `mes_product` p ON p.`sn` = r.`sn`
+  SET r.`plan_id` = p.`plan_id`
+ WHERE (r.`plan_id` IS NULL OR r.`plan_id` = '')
+   AND p.`plan_id` IS NOT NULL AND p.`plan_id` <> '';
+
+-- ---------- 4. 拦截总开关 ----------
+-- 0=不拦截(应急/灰度),1=拦截(提交工位结果时工件必须关联有效生产计划)
+-- 注意:直插 js_sys_config 后需重启服务,或在"系统配置"页面重新保存一次触发缓存刷新
+DELETE FROM `js_sys_config` WHERE `config_key` = 'mes.plan.intercept';
+INSERT INTO `js_sys_config`
+  (`id`, `config_name`, `config_key`, `config_value`, `is_sys`, `create_by`, `create_date`, `update_by`, `update_date`, `remarks`)
+VALUES
+  ('2026102110000001','生产计划无计划拦截','mes.plan.intercept','1','0','system',NOW(),'system',NOW(),
+   '提交工位结果时校验工件是否关联有效生产计划;0=关闭拦截,1=开启拦截');
+
+-- ---------- 5. 验证 ----------
+-- 5.1 字段与索引
+SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mes_product_record' AND COLUMN_NAME = 'plan_id';
+-- 5.2 仍有记录未回填(这些工件在生产但没有计划,发布后会拦截)
+SELECT r.sn, r.cate_sn, p.plan_id AS product_plan_id
+  FROM mes_product_record r
+  LEFT JOIN mes_product p ON p.sn = r.sn
+ WHERE (r.plan_id IS NULL OR r.plan_id = '') AND r.content <> 'UDF'
+ LIMIT 100;
+-- 5.3 记录与工件计划不一致(应为 0 行)
+SELECT r.id, r.sn, r.plan_id AS record_plan, p.plan_id AS product_plan
+  FROM mes_product_record r
+  JOIN mes_product p ON p.sn = r.sn
+ WHERE IFNULL(r.plan_id,'') <> IFNULL(p.plan_id,'')
+ LIMIT 100;
+-- 5.4 未关联订单的计划(应为 0 行)
+SELECT id, order_id, day, shift_id, nums FROM mes_product_plan
+ WHERE order_id IS NULL OR order_id = '';
+-- 5.5 开关
+SELECT config_key, config_value FROM js_sys_config WHERE config_key = 'mes.plan.intercept';
+
+SELECT '生产记录关联生产计划脚本执行完成' AS message;

+ 297 - 0
docs/生产计划-无计划拦截-实施方案.md

@@ -0,0 +1,297 @@
+# 订单-计划-产品-记录 全链路 & 无计划拦截 实施方案
+
+> 关联提交:`a7167fc fix: 打通生产订单-生产计划-工件关联链路`(已完成 1/3:订单→计划→产品)
+> 本次做 2/3:**工位结果层面的无计划拦截** + **按订单/计划查询**
+
+---
+
+## 一、现状分析
+
+### 1.1 已有的链路(上次提交已完成)
+
+```
+mes_product_order (订单)  --order_id-->  mes_product_plan (计划: order_id/day/shift_id/nums)
+                                                        |
+                                                     plan_id
+                                                        v
+                                             mes_product (产品/工件)
+```
+
+| 环节 | 代码位置 | 说明 |
+|---|---|---|
+| 排产 | `MesProductPlanService.save()` | 校验订单已下发(`state=1`)、BOM 的 `product_cate_id`、同日期+班次+产品唯一、累计不超订单数量 |
+| 创建工件时匹配计划 | `MesProductPlanService.getCurrentPlan(cateSn)` | 按 **当前班次的开始日期 + 班次 + 工件类型** 唯一匹配;`c.state='1'`(订单已下发) |
+| 工件绑定计划 | `MesProductRecordService.resolvePlan()` → `mes_product.plan_id` | 创建工件时无计划直接抛 `当前班次没有有效的生产计划,请先排产!` |
+| 超产校验 | `MesProductPlanService.validatePlanCapacity()` | 按 `mes_product.plan_id` 计数 |
+
+### 1.2 缺口(本次要补的)
+
+| # | 缺口 | 现状 |
+|---|---|---|
+| 1 | `mes_product_record`(生产记录/工位结果)**没有计划字段** | 记录层面看不到属于哪个计划,履历无法按订单/计划追溯 |
+| 2 | **提交工位结果不校验计划** | `updateRecordResult()` / `updateRecord()` 等直接写库;历史件、返修件、非正常创建路径产生的"无计划件"照样能过站 |
+| 3 | **查询侧没有订单/计划维度** | 生产记录列表/导出只能按工件码、工位号查;看不出哪些件"无计划";计划页只能按 orderId 精确查 |
+| 4 | 计划实际产出统计不完整 | `MesProductPlanController.listData` 只按 `mes_product` 计数,返修件/无计划件统计不到 |
+
+---
+
+## 二、拦截规则(业务口径)
+
+**拦截主体 = 工件(sn)+ 计划(plan)**,在 **提交工位结果** 时校验。
+
+### 2.1 计划来源(三级兜底,逐级回写)
+
+```
+1) mes_product_record.plan_id     ← 新增字段,本次加
+2) mes_product.plan_id            ← 已有字段
+3) getCurrentPlan(cateSn)         ← 按 当前班次开始日期+班次+工件类型 自动匹配(自愈),匹配到即回写 1)/2)
+```
+
+三级都取不到 → **拦截**。
+
+### 2.2 拦截条件(任一命中即拒绝)
+
+| 条件 | 提示语 |
+|---|---|
+| 找不到有效计划 | `该工件未关联生产计划(订单-计划-工件),当前班次无有效生产计划,不能作业!` |
+| 计划已删除/停用 (`status != '0'`) | `该工件关联的生产计划已失效,不能作业!` |
+| 计划对应订单未下发 (`order.state != '1'`) | `该工件关联的生产订单未下发,不能作业!` |
+
+### 2.3 放行例外
+
+| 例外 | 处理 |
+|---|---|
+| 返修提交 (`isRepair = '1'`) | 放行,沿用原记录 plan(返修件不占用计划产量) |
+| 全局开关 `mes.plan.intercept = '0'` | 全量关闭拦截(应急/灰度),拦截代码保留 |
+| 计划超产 | **提交工位结果时不再校验数量**(数量在创建工件时已扣),只校验计划有效性 |
+
+---
+
+## 三、数据库改动
+
+新增脚本:`db/mes_product_record_plan.sql`
+
+```sql
+-- 1) 生产记录表增加生产计划字段
+ALTER TABLE `mes_product_record`
+  ADD COLUMN `plan_id` VARCHAR(64) NULL COMMENT '生产计划' AFTER `cate_sn`;
+
+-- 2) 索引(按计划/订单查记录)
+CREATE INDEX `idx_mpr_plan_id` ON `mes_product_record` (`plan_id`);
+
+-- 3) 历史数据回填:工件有计划 -> 回填到记录
+UPDATE `mes_product_record` r
+  JOIN `mes_product` p ON p.`sn` = r.`sn`
+  SET r.`plan_id` = p.`plan_id`
+ WHERE (r.`plan_id` IS NULL OR r.`plan_id` = '')
+   AND p.`plan_id` IS NOT NULL AND p.`plan_id` <> '';
+
+-- 4) 拦截总开关(0=不拦截,1=拦截)
+DELETE FROM `js_sys_config` WHERE `config_key` = 'mes.plan.intercept';
+INSERT INTO `js_sys_config`
+  (id, config_name, config_key, config_value, is_sys, create_by, create_date, update_by, update_date, remarks)
+VALUES
+  ('2026102018000001','生产计划无计划拦截','mes.plan.intercept','1','0','system',NOW(),'system',NOW(),
+   '提交工位结果时校验工件是否关联有效生产计划;0=关闭拦截');
+```
+
+> 注:`js_sys_config` 直插数据库后需重启服务或在"系统配置"页面重新保存一次触发缓存刷新(沿用 `cz060_cz061_routing_align.sql` 的做法)。
+
+---
+
+## 四、代码改动清单
+
+### 4.1 实体 `MesProductRecord.java`
+
+1. 列定义新增:
+   ```java
+   @Column(name="plan_id", attrName="planId", label="生产计划"),
+   ```
+2. 字段新增:`planId`、`planDay`(计划日期)、`planOrderId`(计划所属订单)、`orderSn`(生产订单号)、`shiftTitle`(班次)
+3. 恢复并启用 `joinTable`(当前是注释状态),做三级左连接:
+   ```java
+   joinTable={
+     @JoinTable(type=JoinTable.Type.LEFT_JOIN, entity=MesProductPlan.class, attrName="this", alias="b",
+         on="b.id = a.plan_id",
+         columns={ @Column(name="day", attrName="planDay", label="计划日期", queryType=QueryType.EQ) }),
+     @JoinTable(type=JoinTable.Type.LEFT_JOIN, entity=MesProductOrder.class, attrName="this", alias="c",
+         on="c.id = b.order_id",
+         columns={ @Column(name="sn", attrName="orderSn", label="生产订单号", queryType=QueryType.LIKE) }),
+     @JoinTable(type=JoinTable.Type.LEFT_JOIN, entity=MesShift.class, attrName="this", alias="d",
+         on="d.id = b.shift_id",
+         columns={ @Column(name="title", attrName="shiftTitle", label="班次", queryType=QueryType.LIKE) })
+   }
+   ```
+   > 注意:JeeSite 的 join 不会带出被 join 实体自身的 join 字段,所以 `orderSn` / `shiftTitle` 必须在 `MesProductRecord` 上重新声明(别名 b/c/d 之前被注释占用,现已释放)。
+4. `ExcelField` 增列:`生产订单号`、`计划日期`(导出用)
+
+### 4.2 `MesProductPlanService.java` 新增方法
+
+```java
+// 按 id 取计划并校验有效性(计划存在 + status='0' + 订单已下发)
+public MesProductPlan getValidPlan(String planId)
+
+// 取工件的 plan_id(来自 mes_product)
+public String getPlanIdByProductSn(String sn)
+
+// 把计划回写到工件
+public void bindPlanToProduct(String sn, String planId)
+```
+
+### 4.3 `MesProductRecordService.java`(核心)
+
+**新增统一校验方法**(放在 service 层,controller 只做参数解析,所有通道自动覆盖):
+
+```java
+/**
+ * 提交工位结果前校验:工件必须关联有效生产计划,否则拦截。
+ * @return 命中的计划;开关关闭时返回 null
+ */
+private MesProductPlan requirePlan(String sn, String cateSn, String isRepair)
+```
+执行顺序:
+1. 开关:`"0".equals(Global.getConfig("mes.plan.intercept"))` → 直接返回 null(不拦截)
+2. 返修:`"1".equals(isRepair)` → 跳过拦截(只做查询,不强制)
+3. 依次尝试 2.1 的三级来源;三级都取不到 → 抛 `ServiceException`
+4. 校验计划有效性(2.2 的 2、3 条)→ 不合法抛 `ServiceException`
+5. 命中第 3 级(自动匹配)时,回写 `mes_product.plan_id` 和 `mes_product_record.plan_id`(自愈)
+
+**接入点**(覆盖所有工位结果提交通道):
+
+| 接入方法 | 说明 |
+|---|---|
+| `updateRecordResult()` | **主路径**(PC 结果上传 / PDA / CCD 质量上传 / 返修 / `apiUpdateRecordResult` / `updateMultipleRecords` 内部都走这里)→ 在方法最前面校验 |
+| `updateRecord()` | 单工位结果回写(`update` 接口、解绑、终检等) |
+| `updateRecordParam()` | 带参数的结果提交 |
+| `updateUnbindRecord()` | 解绑回写 |
+
+**记录写入时回填 plan_id**:
+- `pcadd()` / `add()` / `sdadd()`:创建 `mpr1` 时 `mpr1.setPlanId(mesProductPlan.getId())`(这 3 处已调用 `resolvePlan()`,直接复用)
+- `save()` 兜底:`planId` 为空时按 `mes_product.plan_id` 补齐
+
+### 4.4 `MesProductRecordController.java`
+
+- `listData` / `listData2` / `exportData`:支持按 **生产订单号**、**计划日期** 过滤
+  ```java
+  if (StringUtils.isNotBlank(mesProductRecord.getOrderSn())) {
+      mesProductRecord.getSqlMap().getWhere().and("c.sn", QueryType.LIKE, mesProductRecord.getOrderSn());
+  }
+  ```
+- `listData`:批量回填「无计划」标识(`planId` 为空的记录标红)
+
+### 4.5 视图
+
+| 文件 | 改动 |
+|---|---|
+| `mesProductRecordList.html` | 搜索项加「生产订单号」「计划日期」;列表加列「生产订单」「计划日期」「班次」「计划状态」(无计划标红) |
+| `mesProductPlanList.html` | 搜索项「生产订单」由 orderId 精确下拉改为 **订单号模糊查询**;列表加「完成率」(nums2/nums) |
+| `mesProductOrderList.html` | 订单列表加「已排产 / 未排产」数量列(`mes_product_order_plan.html` 已有排产入口,可复用) |
+
+---
+
+## 五、实施步骤(按序执行,每步可独立验证)
+
+| 步骤 | 内容 | 产出 |
+|---|---|---|
+| 1 | 执行 `db/mes_product_record_plan.sql`(加字段+索引+回填+开关) | DB 就绪 |
+| 2 | `MesProductRecord` 实体加 `plan_id` + 三级 join + 导出列 | 可查订单/计划 |
+| 3 | `MesProductPlanService` 加 `getValidPlan` / `getPlanIdByProductSn` / `bindPlanToProduct` | 复用能力 |
+| 4 | `MesProductRecordService` 加 `requirePlan()` + 4 个接入点 + 记录回填 | **拦截生效** |
+| 5 | 列表/导出/计划页/订单页加订单、计划维度 | 查询闭环 |
+| 6 | 回归测试(见下) | 验收 |
+
+---
+
+## 六、验证用例
+
+| # | 场景 | 预期 |
+|---|---|---|
+| 1 | 白班排了计划(订单已下发、BOM 维护了工件类型),创建 1 件 | 工件 `plan_id` 有值;记录 `plan_id` 有值 |
+| 2 | 当前班次**无计划**,创建工件 | `当前班次没有有效的生产计划,请先排产!`(已有逻辑保持) |
+| 3 | 当前班次无计划,用历史件(`mes_product.plan_id` 为空)提交工位结果 | 拦截:`该工件未关联生产计划...不能作业!` |
+| 4 | 工件有计划但计划被删除/停用 | 拦截:`该工件关联的生产计划已失效,不能作业!` |
+| 5 | 工件有计划但订单改成未下发 | 拦截:`该工件关联的生产订单未下发,不能作业!` |
+| 6 | 返修件提交(isRepair=1) | 放行,不影响计划产量 |
+| 7 | `mes.plan.intercept='0'` | 全部放行(应急) |
+| 8 | 生产记录列表按订单号过滤 | 只返回该订单下的记录;无计划件标红 |
+| 9 | 计划页「生产数量」统计 | 与 `mes_product` 中该计划下的件数一致 |
+
+**核查 SQL**
+```sql
+-- 无计划仍在生产的工件
+SELECT p.sn, p.cate, p.plan_id FROM mes_product p
+ WHERE (p.plan_id IS NULL OR p.plan_id='')
+   AND EXISTS (SELECT 1 FROM mes_product_record r WHERE r.sn = p.sn AND r.content <> 'UDF');
+
+-- 记录与工件计划不一致(应为 0)
+SELECT r.id, r.sn, r.plan_id, p.plan_id FROM mes_product_record r
+  JOIN mes_product p ON p.sn = r.sn
+ WHERE IFNULL(r.plan_id,'') <> IFNULL(p.plan_id,'');
+
+-- 计划未关联订单
+SELECT id, order_id, day, shift_id, nums FROM mes_product_plan
+ WHERE order_id IS NULL OR order_id = '';
+```
+
+---
+
+## 七、风险与兼容
+
+| 风险 | 处理 |
+|---|---|
+| 存量工件无 `plan_id`,上线后立即被拦 | 步骤 1 的回填 SQL 解决大部分;剩余用开关 `mes.plan.intercept='0'` 过渡,排产后逐步收紧 |
+| 返修/补录/换件等非标准流程被误拦 | `isRepair='1'` 放行;必要时再加 `mes.plan.intercept.exempt.oprno` 白名单 |
+| join 增加列表查询成本 | 走 `plan_id` 索引 + 左连接;列表页本身已分页,影响可控;必要时改为按需批量回填(现有 listData 已是批量 Map 回填模式) |
+| `js_sys_config` 缓存 | 直插后需重启服务或后台重新保存一次 |
+| 多产线/多班次 | 计划取自**工件创建时**的班次,跨零点夜班沿用"班次开始日期"口径(`getCurrentPlan` 已处理) |
+
+---
+
+## 八、待确认事项
+
+1. **拦截范围**:只在"提交工位结果"拦截,还是"创建工件"也要再次强校验(现在创建已强校验)?建议两处都保留。
+2. **返修是否放行**:建议放行(返修不占产量),若管理上要求返修也必须挂在有效计划下,去掉例外即可。
+3. **无计划件是否允许补录**:建议提供"按 日期+班次+工件类型 批量补关联计划"的后台工具/SQL,用于历史数据归集。
+4. **计划超产是否在提交时拦截**:建议否(数量在创建时已扣),如需则按 `mes_product.plan_id` 计数再校验一次。
+
+---
+
+## 九、实施结果(已落地)
+
+### 9.1 改动清单
+
+| 文件 | 改动 |
+|---|---|
+| `db/mes_product_record_plan.sql`(新增) | `mes_product_record.plan_id` 字段 + `plan_id`/`sn` 索引 + 存量回填 + `mes.plan.intercept` 开关 + 验证 SQL |
+| `entity/MesProductRecord.java` | 新增 `plan_id` 列;启用 joinTable(b=计划 / c=订单 / d=班次);新增 `planId`/`planDay`/`orderSn`/`shiftTitle` 属性;导出加「生产订单号」「计划日期」 |
+| `entity/MesProductOrder.java` | 新增 `planNums`(已排产)/`unPlanNums`(未排产)展示字段 |
+| `dao/MesProductRecordDao.java` + `MesProductRecordDao.xml` | 新增 `findPlanIdBySn`(取工件已关联计划)、`updatePlanIdBySn`(回填记录计划);NG 查询 join 计划/订单/班次并支持订单号过滤;`updateDate` 去掉 `sqlMap.table.toSql()` |
+| `service/MesProductPlanService.java` | 新增 `getValidPlan`(计划有效+订单已下发校验)、`getPlanIdByProductSn`、`bindPlanToProduct` |
+| `service/MesProductRecordService.java` | 新增 `requirePlan()`(三级计划来源 + 拦截 + 自愈回写)、`isPlanInterceptEnabled()`(开关);接入 `updateRecordResult`/`updateRecord`/`updateRecordParam`/`updateUnbindRecord`;`pcadd`/`add`/`sdadd` 写入记录计划;`save`/`insertBatch` 兜底补计划 |
+| `web/MesProductRecordController.java` | 新增 `applyPlanFilter()`(订单号/计划日期过滤),应用于 `listData`/`listData2`/`exportData`;后面板新记录继承计划 |
+| `web/MesProductPlanController.java` | 计划列表支持按订单号模糊查询 |
+| `web/MesProductOrderController.java` | 订单列表统计已排产/未排产数量 |
+| 视图 `mesProductRecordList/List2/ListNg.html` | 搜索项加「生产订单号」,列表加「生产订单号」「计划日期」「班次」「计划状态(无计划标红)」 |
+| 视图 `mesProductPlanList.html` | 搜索项加「订单号」,列表加「完成率」 |
+| 视图 `mesProductOrderList.html` | 列表加「已排产」「未排产」 |
+
+### 9.2 顺带修复的两个问题
+
+| 问题 | 原因 | 处理 |
+|---|---|---|
+| `pcresult` 接口返修标记判断错误 | `isRepair = StringUtils.isEmpty(isRepair)?"0":"1"` → 客户端传 `isRepair=0`(正常)被当成返修,导致拦截被绕过 | 改为 `StringUtils.isEmpty(isRepair)?"0":isRepair`;服务层 `isRepair.equals("1")` 改为 `"1".equals(isRepair)` 防 NPE |
+| `updateDate` 原始 SQL 在有 join 的实体上报错 | `update ${sqlMap.table.toSql()}` 会带上 LEFT JOIN,`update_date`/`id` 出现二义性 | 改为写死表名 `update mes_product_record ...` / `update mes_product ...` |
+
+### 9.3 发布步骤
+
+1. **先执行** `db/mes_product_record_plan.sql`(必须先加字段,否则生产记录相关查询直接报错)
+2. 再发布应用
+3. `js_sys_config` 直插后需重启服务,或在"系统配置"页面重新保存一次触发缓存刷新
+4. 灰度建议:先把 `mes.plan.intercept` 置为 `0` 上线,确认存量数据回填正常后再置 `1` 开启拦截
+
+### 9.4 关键实现说明
+
+- **热路径无额外查询**:新建工件的记录在创建时就写入 `plan_id`,提交工位结果时直接从已加载的记录上取计划,不产生额外 SQL;只有历史无计划件才会走回退查询并自动回写(自愈)。
+- **关联字段查询**:JeeSite 会把 join 上的字段自动绑定为查询条件(`orderSn` 声明为 LIKE、`planDay` 声明为 EQ),控制器里另加了兜底条件,值一致不会冲突。
+- **拦截范围**:返修(`isRepair=1`)放行;开关关闭时全部放行;提交时**不再**校验超产数量(数量在创建工件时已扣)。
+

+ 6 - 0
src/main/java/com/jeesite/modules/mes/dao/MesProductRecordDao.java

@@ -27,4 +27,10 @@ public interface MesProductRecordDao extends CrudDao<MesProductRecord> {
 
     // 新增特定查询接口
     List<MesProductRecord> findNgList(MesProductRecord mesProductRecord);
+
+    // 查询工件已关联的生产计划(记录级)
+    String findPlanIdBySn(@Param("sn") String sn);
+
+    // 回填工件所有记录的生产计划
+    int updatePlanIdBySn(@Param("sn") String sn, @Param("planId") String planId);
 }

+ 18 - 0
src/main/java/com/jeesite/modules/mes/entity/MesProductOrder.java

@@ -56,6 +56,8 @@ public class MesProductOrder extends DataEntity<MesProductOrder> {
 	private String bomTitle;
 	private String wmsOrderId;
 	private String wmsOrderSn;
+	private Integer planNums;		// 已排产数量
+	private Integer unPlanNums;		// 未排产数量
 
 	public MesProductOrder() {
 		this(null);
@@ -141,4 +143,20 @@ public class MesProductOrder extends DataEntity<MesProductOrder> {
 	public void setWmsOrderId(String wmsOrderId) {
 		this.wmsOrderId = wmsOrderId;
 	}
+
+	public Integer getPlanNums() {
+		return planNums;
+	}
+
+	public void setPlanNums(Integer planNums) {
+		this.planNums = planNums;
+	}
+
+	public Integer getUnPlanNums() {
+		return unPlanNums;
+	}
+
+	public void setUnPlanNums(Integer unPlanNums) {
+		this.unPlanNums = unPlanNums;
+	}
 }

+ 59 - 10
src/main/java/com/jeesite/modules/mes/entity/MesProductRecord.java

@@ -28,6 +28,7 @@ import java.util.List;
 		@Column(name="craft", attrName="craft", label="工艺号", queryType=QueryType.EQ),
 		@Column(name="serial", attrName="serial", label="排序", isUpdateForce=true),
 		@Column(name="cate_sn", attrName="cateSn", label="工件类型", queryType=QueryType.LIKE),
+		@Column(name="plan_id", attrName="planId", label="生产计划"),
 		@Column(name="line_sn", attrName="lineSn", label="产线编号", queryType=QueryType.LIKE),
 		@Column(name="source", attrName="source", label="来源", queryType=QueryType.EQ),
 		@Column(name="materiel_sn", attrName="materielSn", label="物料批次二维码", queryType=QueryType.LIKE),
@@ -40,17 +41,26 @@ import java.util.List;
 		@Column(name="create_date", attrName="createDate", label="create_date", isUpdate=false,  isUpdateForce=true),
 		@Column(name="update_by", attrName="updateBy", label="update_by", isQuery=false),
 		@Column(name="update_date", attrName="updateDate", label="update_date", isUpdateForce=true),
+}, joinTable={
+		// 1. 关联生产计划(别名 b):记录 -> 计划
+		@JoinTable(type= JoinTable.Type.LEFT_JOIN, entity= MesProductPlan.class, attrName="this", alias="b",
+				on="b.id = a.plan_id",
+				columns={
+						@Column(name="day", attrName="planDay", label="计划日期", queryType=QueryType.EQ),
+				}),
+		// 2. 关联生产订单(别名 c):计划 -> 订单
+		@JoinTable(type= JoinTable.Type.LEFT_JOIN, entity= MesProductOrder.class, attrName="this", alias="c",
+				on="c.id = b.order_id",
+				columns={
+						@Column(name="sn", attrName="orderSn", label="生产订单号", queryType=QueryType.LIKE),
+				}),
+		// 3. 关联班次(别名 d):计划 -> 班次
+		@JoinTable(type= JoinTable.Type.LEFT_JOIN, entity= MesShift.class, attrName="this", alias="d",
+				on="d.id = b.shift_id",
+				columns={
+						@Column(name="title", attrName="shiftTitle", label="班次", queryType=QueryType.LIKE),
+				}),
 }
-//		joinTable={
-				// 1. 关联 MesTemplateItems 模板表 (别名 b)
-				// 注意:这里强烈建议用 LEFT_JOIN,如果用 JOIN(内连接),那么没有关联模板的记录也会查不出来
-//				@JoinTable(type= JoinTable.Type.LEFT_JOIN, entity=MesTemplateItems.class, attrName="this", alias="b",
-//						on="b.id = a.tid",
-//						columns={
-//								@Column(name="cate", attrName="messageType", label="报文类型"),
-//								@Column(name="title", attrName="title", label="报文名称"),
-//								@Column(name="oprno", attrName="oprno2", label="模板工位号"),
-//						}),
 )
 public class MesProductRecord extends DataEntity<MesProductRecord> {
 
@@ -86,6 +96,10 @@ public class MesProductRecord extends DataEntity<MesProductRecord> {
 	private String auto;
 	private String oprnoName; //工位名称
 	private String isRepair;//是否返修;1=是,2=否
+	private String planId;		// 生产计划
+	private Date planDay;		// 计划日期(关联生产计划)
+	private String orderSn;		// 生产订单号(关联生产订单)
+	private String shiftTitle;		// 班次(关联班次)
 
 
 
@@ -101,6 +115,8 @@ public class MesProductRecord extends DataEntity<MesProductRecord> {
 			@ExcelField(title="绑定内容", attrName="materielSn", align= ExcelField.Align.CENTER, sort=60),
 			@ExcelField(title="工件类型", attrName="cateSn", align= ExcelField.Align.CENTER, sort=70),
 			@ExcelField(title="产线编号", attrName="lineSn", align= ExcelField.Align.CENTER, sort=80),
+			@ExcelField(title="生产订单号", attrName="orderSn", align= ExcelField.Align.CENTER, sort=85),
+			@ExcelField(title="计划日期", attrName="planDay", align= ExcelField.Align.CENTER, sort=86, dataFormat="yyyy-MM-dd"),
 			@ExcelField(title="备注", attrName="remark", align= ExcelField.Align.CENTER, sort=90),
 			@ExcelField(title="更新日期", attrName="updateDate", align= ExcelField.Align.CENTER, sort=150, dataFormat="yyyy-MM-dd hh:mm:ss"),
 	})
@@ -384,4 +400,37 @@ public class MesProductRecord extends DataEntity<MesProductRecord> {
 	public void setOprnoName(String oprnoName) {
 		this.oprnoName = oprnoName;
 	}
+
+	public String getPlanId() {
+		return planId;
+	}
+
+	public void setPlanId(String planId) {
+		this.planId = planId;
+	}
+
+	@JsonFormat(pattern = "yyyy-MM-dd")
+	public Date getPlanDay() {
+		return planDay;
+	}
+
+	public void setPlanDay(Date planDay) {
+		this.planDay = planDay;
+	}
+
+	public String getOrderSn() {
+		return orderSn;
+	}
+
+	public void setOrderSn(String orderSn) {
+		this.orderSn = orderSn;
+	}
+
+	public String getShiftTitle() {
+		return shiftTitle;
+	}
+
+	public void setShiftTitle(String shiftTitle) {
+		this.shiftTitle = shiftTitle;
+	}
 }

+ 67 - 0
src/main/java/com/jeesite/modules/mes/service/MesProductPlanService.java

@@ -233,5 +233,72 @@ public class MesProductPlanService extends CrudService<MesProductPlanDao, MesPro
 			throw new ServiceException(text("生产计划已完成,不能继续生产"));
 		}
 	}
+
+	/**
+	 * 获取有效的生产计划:计划存在、未删除停用、且关联的生产订单已下发。
+	 * 校验不通过直接抛出业务异常,阻断工位结果提交。
+	 *
+	 * @param planId 生产计划id
+	 * @return 有效的生产计划
+	 */
+	public MesProductPlan getValidPlan(String planId){
+		if(StringUtils.isBlank(planId)){
+			throw new ServiceException(text("该工件未关联生产计划(订单-计划-工件),不能作业!"));
+		}
+		MesProductPlan plan = get(new MesProductPlan(planId));
+		if(plan == null){
+			throw new ServiceException(text("该工件关联的生产计划不存在或已删除,不能作业!"));
+		}
+		if(!"0".equals(plan.getStatus())){
+			throw new ServiceException(text("该工件关联的生产计划已失效,不能作业!"));
+		}
+		MesProductOrder order = mesProductOrderService.get(new MesProductOrder(plan.getOrderId()));
+		if(order == null){
+			throw new ServiceException(text("该工件关联的生产订单不存在,不能作业!"));
+		}
+		if(!"1".equals(order.getState())){
+			throw new ServiceException(text("该工件关联的生产订单未下发,不能作业!"));
+		}
+		return plan;
+	}
+
+	/**
+	 * 查询工件(mes_product)已关联的生产计划id。
+	 *
+	 * @param sn 工件码
+	 * @return 生产计划id,未关联时返回 null
+	 */
+	public String getPlanIdByProductSn(String sn){
+		if(StringUtils.isBlank(sn)){
+			return null;
+		}
+		MesProduct query = new MesProduct();
+		query.setSn(sn);
+		MesProduct product = mesProductService.findInfo(query);
+		if(product == null){
+			return null;
+		}
+		return product.getPlanId();
+	}
+
+	/**
+	 * 把生产计划回写到工件上(只补空,不覆盖工件已有计划)。
+	 *
+	 * @param sn 工件码
+	 * @param planId 生产计划id
+	 */
+	public void bindPlanToProduct(String sn, String planId){
+		if(StringUtils.isBlank(sn) || StringUtils.isBlank(planId)){
+			return;
+		}
+		MesProduct query = new MesProduct();
+		query.setSn(sn);
+		MesProduct product = mesProductService.findInfo(query);
+		if(product == null || StringUtils.isNotBlank(product.getPlanId())){
+			return;
+		}
+		product.setPlanId(planId);
+		mesProductService.save(product);
+	}
 	
 }

+ 132 - 1
src/main/java/com/jeesite/modules/mes/service/MesProductRecordService.java

@@ -46,6 +46,11 @@ import javax.servlet.http.HttpServletRequest;
 @Service
 public class MesProductRecordService extends CrudService<MesProductRecordDao, MesProductRecord> {
 
+	/**
+	 * 无计划拦截总开关配置key(js_sys_config.config_key)
+	 */
+	private static final String PLAN_INTERCEPT_CONFIG = "mes.plan.intercept";
+
 	@Autowired
 	private MesProductRecordDao mesProductRecordDao;
 
@@ -160,13 +165,38 @@ public class MesProductRecordService extends CrudService<MesProductRecordDao, Me
 		if (StringUtils.isEmpty(mesProductRecord.getSource())) {
 			mesProductRecord.setSource("0");
 		}
+		// 兜底补计划:记录未带计划时,按工件已关联的计划补齐(只补空,不覆盖已有计划)
+		if (StringUtils.isBlank(mesProductRecord.getPlanId())
+				&& StringUtils.isNotBlank(mesProductRecord.getSn())) {
+			String planId = mesProductPlanService.getPlanIdByProductSn(mesProductRecord.getSn());
+			if (StringUtils.isNotBlank(planId)) {
+				mesProductRecord.setPlanId(planId);
+			}
+		}
 		super.save(mesProductRecord);
 	}
 	public Long insertBatch(List<MesProductRecord> mesProductRecordList,int num) {
+		boolean needPlan = false;
+		String sn = null;
 		for (MesProductRecord record : mesProductRecordList) {
 			if (StringUtils.isEmpty(record.getSource())) {
 				record.setSource("0");
 			}
+			if (StringUtils.isBlank(record.getPlanId()) && StringUtils.isNotBlank(record.getSn())) {
+				needPlan = true;
+				sn = record.getSn();
+			}
+		}
+		// 兜底补计划:整批记录未带计划时,按工件已关联的计划一次性补齐(只补空,不覆盖已有计划)
+		if (needPlan) {
+			String planId = mesProductPlanService.getPlanIdByProductSn(sn);
+			if (StringUtils.isNotBlank(planId)) {
+				for (MesProductRecord record : mesProductRecordList) {
+					if (StringUtils.isBlank(record.getPlanId()) && sn.equals(record.getSn())) {
+						record.setPlanId(planId);
+					}
+				}
+			}
 		}
 		return mesProductRecordDao.insertBatch(mesProductRecordList,num);
 	}
@@ -274,6 +304,88 @@ public class MesProductRecordService extends CrudService<MesProductRecordDao, Me
 		return mesProductPlan;
 	}
 
+	/**
+	 * 无计划拦截总开关(js_sys_config:mes.plan.intercept)。
+	 * 0=关闭拦截(应急/灰度),其它值(含未配置)=开启拦截。
+	 */
+	private boolean isPlanInterceptEnabled() {
+		String cfg = Global.getConfig(PLAN_INTERCEPT_CONFIG);
+		if (cfg == null) {
+			return true;
+		}
+		return !"0".equals(cfg.trim());
+	}
+
+	/**
+	 * 提交工位结果前校验生产计划:工件必须关联有效的生产计划,否则拦截作业。
+	 *
+	 * 计划来源依次为:
+	 *   1) 当前记录上的 plan_id(新建工件时已写入)
+	 *   2) 工件级 mes_product.plan_id
+	 *   3) 该工件其它记录上的 plan_id
+	 *   4) 按当前班次(班次开始日期+班次+工件类型)自动匹配,匹配到即回写自愈
+	 * 都取不到则拦截。
+	 *
+	 * @param sn 工件码
+	 * @param cateSn 工件类型
+	 * @param isRepair 是否返修(1=返修,返修不拦截,沿用原记录计划)
+	 * @param recordPlanId 当前记录已带的生产计划id(可为空)
+	 * @return 命中的生产计划;拦截关闭或返修时返回 null
+	 */
+	private MesProductPlan requirePlan(String sn, String cateSn, String isRepair, String recordPlanId) {
+		if (!isPlanInterceptEnabled()) {
+			return null;
+		}
+		if ("1".equals(isRepair)) { // 返修不拦截,返修件不占用计划产量
+			return null;
+		}
+		String planId = recordPlanId;
+		if (StringUtils.isBlank(planId)) {
+			planId = mesProductPlanService.getPlanIdByProductSn(sn);
+		}
+		if (StringUtils.isBlank(planId)) {
+			planId = mesProductRecordDao.findPlanIdBySn(sn);
+		}
+		if (StringUtils.isNotBlank(planId)) {
+			// 已有计划:校验计划有效性(计划失效、订单未下发在此抛出)
+			return mesProductPlanService.getValidPlan(planId);
+		}
+		// 无计划:按当前班次自动匹配并回写(自愈)
+		MesProductPlan plan = autoBindCurrentPlan(sn, cateSn);
+		if (plan == null) {
+			throw new ServiceException(text("该工件未关联生产计划(订单-计划-工件),当前班次无有效生产计划,不能作业!"));
+		}
+		return plan;
+	}
+
+	/**
+	 * 无计划工件按当前班次自动匹配生产计划,并把计划回写到工件与生产记录上。
+	 *
+	 * @param sn 工件码
+	 * @param cateSn 工件类型
+	 * @return 匹配到的生产计划;无匹配时返回 null
+	 */
+	private MesProductPlan autoBindCurrentPlan(String sn, String cateSn) {
+		if (StringUtils.isBlank(cateSn)) {
+			return null;
+		}
+		MesProductPlan plan = null;
+		try {
+			plan = mesProductPlanService.getCurrentPlan(cateSn);
+		} catch (ServiceException e) {
+			// 未匹配到班次、计划重复等异常,交由上层给出明确提示
+			throw e;
+		} catch (RuntimeException e) {
+			return null;
+		}
+		if (plan == null) {
+			return null;
+		}
+		mesProductPlanService.bindPlanToProduct(sn, plan.getId());
+		mesProductRecordDao.updatePlanIdBySn(sn, plan.getId());
+		return plan;
+	}
+
 
 	/**
 	 * 保存数据(插入或更新)
@@ -327,6 +439,7 @@ public class MesProductRecordService extends CrudService<MesProductRecordDao, Me
 				mpr1.setTid(mesTemplateItems.getId());
 				mpr1.setCdate(cdate);
 				mpr1.setContent("UDF");
+				mpr1.setPlanId(mesProductPlan.getId());
 				if(mesTemplateItems.getOprno().equals(oprno)){
 					mpr1.setOprno(mesProductRecord.getOprno());
 					mpr1.setContent("OK");
@@ -387,6 +500,7 @@ public class MesProductRecordService extends CrudService<MesProductRecordDao, Me
 				mpr1.setCdate(cdate);
 				mpr1.setContent("UDF");
 				mpr1.setSource(source);
+				mpr1.setPlanId(mesProductPlan.getId());
 				if(mesTemplateItems.getOprno().equals(oprno)){
 					mpr1.setOprno(mesProductRecord.getOprno());
 					mpr1.setContent(mesProductRecord.getContent());
@@ -520,6 +634,7 @@ public class MesProductRecordService extends CrudService<MesProductRecordDao, Me
 				mpr1.setTreeLevel(0);
 				mpr1.setCdate(cdate);
 				mpr1.setContent("UDF");
+				mpr1.setPlanId(mesProductPlan.getId());
 				if(mesTemplateItems.getOprno().equals(oprno)){
 					mpr1.setOprno(mesProductRecord.getOprno());
 					mpr1.setContent(mesProductRecord.getContent());
@@ -616,11 +731,18 @@ public class MesProductRecordService extends CrudService<MesProductRecordDao, Me
 		content = content.equals("OK")?"OK":"NG";
 
 		String cateSn = "";
+		String recordPlanId = null;
 		for (MesProductRecord mesProductRecord : lists){
 			cateSn = mesProductRecord.getCateSn();
+			if (StringUtils.isBlank(recordPlanId)) {
+				recordPlanId = mesProductRecord.getPlanId();
+			}
 			break;
 		}
 
+		// 无计划拦截:提交工位结果前,工件必须关联有效的生产计划(订单-计划-工件)
+		requirePlan(sn, cateSn, isRepair, recordPlanId);
+
 		if(bmlists!=null&&bmlists.size() > 0){
 			for (BindMaterialResp bindMaterialResp:bmlists){
 				ParamsResp paramsResp = new ParamsResp();
@@ -719,7 +841,7 @@ public class MesProductRecordService extends CrudService<MesProductRecordDao, Me
 				mesProductModifyRecord1.setLineSn(mesProductRecord.getLineSn());
 				mesProductModifyRecord1.setContent(content);
 				mesProductModifyRecord1.setContentOld(oldcontent);
-				if(isRepair.equals("1")){
+				if("1".equals(isRepair)){
 					mesProductModifyRecord1.setSource("3"); // 来自返修
 				}else{
 					mesProductModifyRecord1.setSource("0");
@@ -1016,6 +1138,9 @@ public class MesProductRecordService extends CrudService<MesProductRecordDao, Me
 			throw new ServiceException(text("记录不存在!"));
 		}
 
+		// 无计划拦截:提交工位结果前,工件必须关联有效的生产计划(订单-计划-工件)
+		requirePlan(sn, mesProductRecord.getCateSn(), null, mesProductRecord.getPlanId());
+
 		if(oldOprno.length() == 6){
 			mesProductRecord.setOprno(oldOprno);
 		}
@@ -1065,6 +1190,9 @@ public class MesProductRecordService extends CrudService<MesProductRecordDao, Me
 			throw new ServiceException(text("记录不存在!"));
 		}
 
+		// 无计划拦截:提交工位结果前,工件必须关联有效的生产计划(订单-计划-工件)
+		requirePlan(sn, mesProductRecord.getCateSn(), null, mesProductRecord.getPlanId());
+
 		if(oldOprno.length() == 6){
 			mesProductRecord.setOprno(oldOprno);
 		}
@@ -1096,6 +1224,9 @@ public class MesProductRecordService extends CrudService<MesProductRecordDao, Me
 			throw new ServiceException(text("记录不存在!"));
 		}
 
+		// 无计划拦截:提交工位结果前,工件必须关联有效的生产计划(订单-计划-工件)
+		requirePlan(sn, mesProductRecord.getCateSn(), null, mesProductRecord.getPlanId());
+
 		if(oldOprno.length() == 6){
 			mesProductRecord.setOprno(oldOprno);
 		}

+ 18 - 0
src/main/java/com/jeesite/modules/mes/web/MesProductOrderController.java

@@ -36,8 +36,10 @@ import org.springframework.transaction.annotation.Transactional;
 
 import com.jeesite.common.config.Global;
 import com.jeesite.common.entity.Page;
+import com.jeesite.common.mybatis.mapper.query.QueryType;
 import com.jeesite.common.web.BaseController;
 import com.jeesite.modules.mes.entity.MesProductOrder;
+import com.jeesite.modules.mes.entity.MesProductPlan;
 import com.jeesite.modules.mes.service.MesProductOrderService;
 
 /**
@@ -63,6 +65,8 @@ public class MesProductOrderController extends BaseController {
     private WmsPickRecordService wmsPickRecordService;
     @Autowired private com.jeesite.modules.wms.service.WmsPickService wmsPickService;
     @Autowired private com.jeesite.modules.wms.service.WmsPickMaterialService wmsPickMaterialService;
+    @Autowired
+    private com.jeesite.modules.mes.service.MesProductPlanService mesProductPlanService;
 
 	/**
 	 * 获取数据
@@ -91,6 +95,20 @@ public class MesProductOrderController extends BaseController {
 	public Page<MesProductOrder> listData(MesProductOrder mesProductOrder, HttpServletRequest request, HttpServletResponse response) {
 		mesProductOrder.setPage(new Page<>(request, response));
 		Page<MesProductOrder> page = mesProductOrderService.findPage(mesProductOrder);
+		// 排产情况:已排产数量 / 未排产数量
+		for (MesProductOrder order : page.getList()) {
+			int planned = 0;
+			MesProductPlan planQuery = new MesProductPlan();
+			planQuery.setOrderId(order.getId());
+			planQuery.getSqlMap().getWhere().and("a.status", QueryType.EQ, "0");
+			for (MesProductPlan plan : mesProductPlanService.findList(planQuery)) {
+				if (plan.getNums() != null) {
+					planned += plan.getNums();
+				}
+			}
+			order.setPlanNums(planned);
+			order.setUnPlanNums(order.getNums() == null ? 0 : Math.max(0, order.getNums() - planned));
+		}
 		return page;
 	}
 

+ 5 - 0
src/main/java/com/jeesite/modules/mes/web/MesProductPlanController.java

@@ -4,6 +4,7 @@ import java.util.List;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
+import com.jeesite.common.lang.StringUtils;
 import com.jeesite.common.mybatis.mapper.query.QueryType;
 import com.jeesite.modules.mes.entity.MesProduct;
 import com.jeesite.modules.mes.entity.MesProductOrder;
@@ -76,6 +77,10 @@ public class MesProductPlanController extends BaseController {
 	@ResponseBody
 	public Page<MesProductPlan> listData(MesProductPlan mesProductPlan, HttpServletRequest request, HttpServletResponse response) {
 		mesProductPlan.getSqlMap().getWhere().and("a.status", QueryType.EQ, "0");
+		// 按生产订单号模糊查询(计划 -> 订单);关联字段默认已由框架自动绑定(c.sn LIKE)
+		if (StringUtils.isNotBlank(mesProductPlan.getOrderSn())) {
+			mesProductPlan.getSqlMap().getWhere().and("c.sn", QueryType.LIKE, mesProductPlan.getOrderSn());
+		}
 		mesProductPlan.setPage(new Page<>(request, response));
 		Page<MesProductPlan> page = mesProductPlanService.findPage(mesProductPlan);
 		for (MesProductPlan mesProductPlan1 : page.getList()) {

+ 31 - 1
src/main/java/com/jeesite/modules/mes/web/MesProductRecordController.java

@@ -312,6 +312,26 @@ public class MesProductRecordController extends BaseController {
 		return renderResult(Global.TRUE, text("操作成功!"));
 	}
 	/**
+	 * 生产记录按订单/计划维度过滤。
+	 * 关联关系:mes_product_record.plan_id -> mes_product_plan -> mes_product_order
+	 *
+	 * @param mesProductRecord 查询条件
+	 */
+	private void applyPlanFilter(MesProductRecord mesProductRecord) {
+		if (mesProductRecord == null || mesProductRecord.getSqlMap() == null) {
+			return;
+		}
+		String orderSn = mesProductRecord.getOrderSn();
+		if (StringUtils.isNotBlank(orderSn)) {
+			// 关联字段默认已由框架自动绑定(c.sn LIKE),此处兜底保证过滤生效
+			mesProductRecord.getSqlMap().getWhere().and("c.sn", QueryType.LIKE, orderSn);
+		}
+		if (mesProductRecord.getPlanDay() != null) {
+			mesProductRecord.getSqlMap().getWhere().and("b.day", QueryType.EQ, mesProductRecord.getPlanDay());
+		}
+	}
+
+	/**
 	 * 查询列表数据
 	 */
 	@RequiresPermissions("mes:mesProductRecord:view")
@@ -328,6 +348,9 @@ public class MesProductRecordController extends BaseController {
 			mesProductRecord.getSqlMap().getOrder().setOrderBy("a.id DESC");
 			mesProductRecord.getSqlMap().getWhere().and("content", QueryType.NE, "UDF");
 		}
+
+		// 订单/计划维度过滤:记录 -> 计划 -> 订单
+		applyPlanFilter(mesProductRecord);
 		String searchHmb = mesProductRecord.getHmb();
 
 		// 1. 判断前端是否传入了 "后面板码" 作为搜索条件
@@ -466,6 +489,9 @@ public class MesProductRecordController extends BaseController {
 		// 如果查询状态的值为 0, 表示用户选择查询 未加工
 		else if ("0".equals(mesProductRecord.getProcessState()) && "".equals(mesProductRecord.getContent()))
 			mesProductRecord.getSqlMap().getWhere().and("content",QueryType.EQ, "UDF");
+
+		// 订单/计划维度过滤:记录 -> 计划 -> 订单
+		applyPlanFilter(mesProductRecord);
 		Page<MesProductRecord> page = mesProductRecordService.findPage(mesProductRecord);
 		for (int i=0;i<page.getList().size();i++){
 			MesTemplateItems mesTemplateItems = new MesTemplateItems();
@@ -599,6 +625,9 @@ public class MesProductRecordController extends BaseController {
 
 		mesProductRecord.setCraft("100000");    // 只查工艺号10000的
 
+		// 订单/计划维度过滤:记录 -> 计划 -> 订单
+		applyPlanFilter(mesProductRecord);
+
 		// 确保 sqlMap 被初始化,并添加默认排序和过滤条件
 		if (mesProductRecord.getSqlMap() != null) {
 			mesProductRecord.getSqlMap().getOrder().setOrderBy("a.id DESC");
@@ -2680,7 +2709,7 @@ public class MesProductRecordController extends BaseController {
 		String result = request.getParameter("result");
 		String userCode = request.getParameter("ucode");
 		String isRepair = request.getParameter("isRepair");
-		isRepair = StringUtils.isEmpty(isRepair)?"0":"1";
+		isRepair = StringUtils.isEmpty(isRepair)?"0":isRepair;
 		String craft = "100000";
 		String remark = request.getParameter("remark");
 		String ps = request.getParameter("ps"); // 参数,多个以"|"分割
@@ -4156,6 +4185,7 @@ public class MesProductRecordController extends BaseController {
 				mesProductRecord4.setSource("0");
 				mesProductRecord4.setCdate(mesProductRecord1.getCdate());
 				mesProductRecord4.setCreateBy(mesProductRecord1.getCreateBy());
+				mesProductRecord4.setPlanId(mesProductRecord1.getPlanId());
 				mesProductRecordService.save(mesProductRecord4);
 			}else{
 				mesProductRecord3.setMaterielSn(batchSn);

+ 1 - 1
src/main/resources/mappings/modules/mes/MesProductDao.xml

@@ -2,7 +2,7 @@
 <!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.MesProductDao">
 	<update id="updateDate">
-		update ${sqlMap.table.toSql()}
+		update mes_product
 		set update_date=#{updateDate}
 		WHERE id = #{id}
 	</update>

+ 26 - 3
src/main/resources/mappings/modules/mes/MesProductRecordDao.xml

@@ -42,8 +42,11 @@
     </select>
     <select id="findNgList" resultType="com.jeesite.modules.mes.entity.MesProductRecord">
         SELECT
-       a.*
+       a.*, a.plan_id AS planId, b.day AS planDay, c.sn AS orderSn, d.title AS shiftTitle
         FROM mes_product_record a
+        LEFT JOIN mes_product_plan b ON a.plan_id = b.id
+        LEFT JOIN mes_product_order c ON b.order_id = c.id
+        LEFT JOIN mes_shift d ON b.shift_id = d.id
         <where>
             a.content = 'NG'
             AND a.update_date &gt;= DATE_SUB(NOW(), INTERVAL 3 DAY)
@@ -52,7 +55,14 @@
             AND NOT EXISTS (
             SELECT 1 FROM mes_product_repair r
             WHERE r.sn = a.sn
-            ) </where>
+            )
+            <if test="orderSn != null and orderSn != ''">
+            AND c.sn LIKE CONCAT('%', #{orderSn}, '%')
+            </if>
+            <if test="planDay != null">
+            AND b.day = #{planDay}
+            </if>
+        </where>
         <choose>
             <when test="page !=null and page.orderBy != null and page.orderBy != ''">
                 ORDER BY ${page.orderBy}
@@ -74,8 +84,21 @@
         where sn = #{sn} and oprno = #{oprno} and craft = #{craft}
     </update>
     <update id="updateDate">
-        update ${sqlMap.table.toSql()}
+        update mes_product_record
         set update_date=#{updateDate}
         WHERE id = #{id}
     </update>
+    <!-- 查询工件已关联的生产计划(记录级),取第一条非空 -->
+    <select id="findPlanIdBySn" resultType="java.lang.String">
+        SELECT plan_id
+        FROM mes_product_record
+        WHERE sn = #{sn} AND plan_id IS NOT NULL AND plan_id &lt;&gt; ''
+        LIMIT 1
+    </select>
+    <!-- 回填工件所有记录的生产计划(只补空,不覆盖已有计划) -->
+    <update id="updatePlanIdBySn">
+        UPDATE mes_product_record
+        SET plan_id = #{planId}
+        WHERE sn = #{sn} AND (plan_id IS NULL OR plan_id = '')
+    </update>
 </mapper>

+ 2 - 0
src/main/resources/views/modules/mes/mesProductOrderList.html

@@ -54,6 +54,8 @@ $('#dataGrid').dataGrid({
 		{header:'${text("订单编号")}', name:'sn', index:'a.sn', width:150, align:"center", frozen:true},
 		{header:'${text("销售订单")}', name:'wmsOrderSn', index:'c.sn', width:150, align:"center"},
 		{header:'${text("生产数量")}', name:'nums', index:'a.nums', width:150, align:"center"},
+		{header:'${text("已排产")}', name:'planNums', index:'a.nums', width:150, align:"center", sortable:false},
+		{header:'${text("未排产")}', name:'unPlanNums', index:'a.nums', width:150, align:"center", sortable:false},
 		{header:'${text("交货日期")}', name:'deliveryDate', index:'a.delivery_date', width:150, align:"center"},
 		{header:'${text("BOM")}', name:'bomTitle', index:'b.title', width:150, align:"center"},
 		{header:'${text("备注")}', name:'remark', index:'a.remark', width:150, align:"center"},

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

@@ -23,6 +23,12 @@
 					</div>
 				</div>
 				<div class="form-group">
+					<label class="control-label">${text('订单号')}:</label>
+					<div class="control-inline">
+						<#form:input path="orderSn" maxlength="64" class="form-control width-120"/>
+					</div>
+				</div>
+				<div class="form-group">
 					<label class="control-label">${text('日期')}:</label>
 					<div class="control-inline">
 						<#form:input path="day" readonly="true" maxlength="20" class="form-control laydate width-datetime"
@@ -56,7 +62,15 @@ $('#dataGrid').dataGrid({
 		{header:'${text("BOM")}', name:'bomTitle', index:'d.title', width:150, align:"center"},
 		{header:'${text("班次")}', name:'shiftTitle', index:'a.shift_id', width:150, align:"center"},
 		{header:'${text("计划数量")}', name:'nums', index:'a.nums', width:150, align:"center"},
-		{header:'${text("生产数量")}', name:'nums2', index:'a.nums', width:150, align:"center"},
+		{header:'${text("生产数量")}', name:'nums2', index:'a.nums', width:150, align:"center", sortable:false},
+		{header:'${text("完成率")}', name:'nums', index:'a.nums', width:150, align:"center", sortable:false, formatter: function(val, obj, row, act){
+			var plan = parseInt(val) || 0;
+			var done = parseInt(row.nums2) || 0;
+			if (plan <= 0) {
+				return '';
+			}
+			return (done * 100 / plan).toFixed(1) + '%(' + done + '/' + plan + ')';
+		}},
 		{header:'${text("更新时间")}', name:'updateDate', index:'a.update_date', width:150, align:"center"},
 		{header:'${text("操作")}', name:'actions', width:120, formatter: function(val, obj, row, act){
 			var actions = [];

+ 24 - 0
src/main/resources/views/modules/mes/mesProductRecordList.html

@@ -45,6 +45,19 @@
 						<#form:input path="lineSn" maxlength="10" class="form-control width-120"/>
 					</div>
 				</div>
+				<div class="form-group">
+					<label class="control-label">${text('生产订单号')}:</label>
+					<div class="control-inline">
+						<#form:input path="orderSn" maxlength="64" class="form-control width-120"/>
+					</div>
+				</div>
+				<div class="form-group">
+					<label class="control-label">${text('计划日期')}:</label>
+					<div class="control-inline">
+						<#form:input path="planDay" readonly="true" maxlength="20" class="form-control laydate width-datetime"
+							dataFormat="date" data-type="date" data-format="yyyy-MM-dd"/>
+					</div>
+				</div>
 
 <!--				<div class="form-group">-->
 <!--					<label class="control-label">${text('工艺号')}:</label>-->
@@ -179,6 +192,17 @@ $('#dataGrid').dataGrid({
 		{header:'${text("绑定内容")}', name:'materielSn', index:'a.materiel_sn', width:180, align:"center"},
 		// {header:'${text("工件类型")}', name:'cateSn', index:'a.cate_sn', width:90, align:"center"},
 		{header:'${text("产线编号")}', name:'lineSn', index:'a.line_sn', width:90, align:"center"},
+		{header:'${text("生产订单号")}', name:'orderSn', index:'c.sn', width:150, align:"center", sortable:false},
+		{header:'${text("计划日期")}', name:'planDay', index:'b.day', width:110, align:"center", sortable:false, formatter: function(val){
+				return val || '';
+		}},
+		{header:'${text("班次")}', name:'shiftTitle', index:'d.title', width:90, align:"center", sortable:false},
+		{header:'${text("计划状态")}', name:'planId', index:'a.plan_id', width:90, align:"center", sortable:false, formatter: function(val, obj, row, act){
+				if (val) {
+					return '已关联';
+				}
+				return '<span style="color:#ff0000;">无计划</span>';
+		}},
 		// {header:'${text("工位结果序号")}', name:'sort', index:'b.sort', width:120, align:"center",sortable:true},
 		// {header:'${text("报文类型")}', name:'messageType', index:'b.cate', width:100, align:"center"},
 

+ 6 - 0
src/main/resources/views/modules/mes/mesProductRecordList2.html

@@ -47,6 +47,12 @@
 					</div>
 				</div>
 				<div class="form-group">
+					<label class="control-label">${text('生产订单号')}:</label>
+					<div class="control-inline">
+						<#form:input path="orderSn" maxlength="64" class="form-control width-120"/>
+					</div>
+				</div>
+				<div class="form-group">
 					<label class="control-label">${text('绑定内容')}:</label>
 					<div class="control-inline">
 						<#form:input path="materielSn" maxlength="50" class="form-control width-120"/>

+ 7 - 0
src/main/resources/views/modules/mes/mesProductRecordListNg.html

@@ -58,6 +58,12 @@
 					</div>
 				</div>
 				<div class="form-group">
+					<label class="control-label">${text('生产订单号')}:</label>
+					<div class="control-inline">
+						<#form:input path="orderSn" maxlength="64" class="form-control width-120"/>
+					</div>
+				</div>
+				<div class="form-group">
 					<label class="control-label">${text('绑定内容')}:</label>
 					<div class="control-inline">
 						<#form:input path="materielSn" maxlength="50" class="form-control width-120"/>
@@ -110,6 +116,7 @@ $('#dataGrid').dataGrid({
 		{header:'${text("绑定内容")}', name:'materielSn', index:'a.materiel_sn', width:180, align:"center"},
 		{header:'${text("工件类型")}', name:'cateSn', index:'a.cate_sn', width:90, align:"center"},
 		{header:'${text("产线编号")}', name:'lineSn', index:'a.line_sn', width:90, align:"center"},
+		{header:'${text("生产订单号")}', name:'orderSn', index:'c.sn', width:150, align:"center", sortable:false},
 		// {header:'${text("工位结果序号")}', name:'sort', index:'b.sort', width:120, align:"center",sortable:true},
 		// {header:'${text("报文类型")}', name:'messageType', index:'b.cate', width:100, align:"center"},
 		{header:'${text("备注")}', name:'remark', index:'a.remark', width:150, align:"center"},