hbx 5 ngày trước cách đây
mục cha
commit
b8f7820672

+ 63 - 0
db/mes_product_create_195_196_198.sql

@@ -0,0 +1,63 @@
+-- 产品创建:195 / 196 / 198
+-- 背景:
+--   下单页「产品类型」下拉读取 mes_product_cate(status='0')。
+--   目前主数据只有 CZ060 / CZ061,缺少 195 / 196 / 198 三种产品,
+--   下单时无法选择,订单-计划-产品链路无法按产品区分。
+-- 本脚本:
+--   1) 以 CZ060 的模板作为 195/196/198 的默认生产模板(取不到时用任一启用模板兜底);
+--   2) 幂等创建 195/196/198 三条产品类型:已存在则更新名称/模板/状态,不存在则插入;
+--   3) 输出校验结果。
+-- 说明:
+--   - sn 与 title 均为 195/196/198,下单下拉直接显示产品编号;
+--   - cate=0 表示产品(1=零件);
+--   - 可重复执行,不会产生重复数据;
+--   - 执行后无需重启,下单页刷新即可看到三种产品。
+
+-- ---------- 1. 取默认模板 ----------
+SET @template_id = (SELECT template_id FROM mes_product_cate WHERE sn = 'CZ060' LIMIT 1);
+SET @template_id = IFNULL(NULLIF(@template_id, ''),
+  (SELECT id FROM mes_template WHERE status = '0' ORDER BY update_date DESC LIMIT 1));
+
+-- ---------- 2. 幂等创建三种产品 ----------
+INSERT INTO `mes_product_cate`
+  (`id`, `title`, `sn`, `status`, `template_id`, `create_by`, `create_date`, `update_by`, `update_date`, `cate`)
+SELECT '2026102100000195', '195', '195', '0', @template_id, 'system', NOW(), 'system', NOW(), 0
+  FROM DUAL
+ WHERE NOT EXISTS (SELECT 1 FROM `mes_product_cate` WHERE `sn` = '195');
+
+INSERT INTO `mes_product_cate`
+  (`id`, `title`, `sn`, `status`, `template_id`, `create_by`, `create_date`, `update_by`, `update_date`, `cate`)
+SELECT '2026102100000196', '196', '196', '0', @template_id, 'system', NOW(), 'system', NOW(), 0
+  FROM DUAL
+ WHERE NOT EXISTS (SELECT 1 FROM `mes_product_cate` WHERE `sn` = '196');
+
+INSERT INTO `mes_product_cate`
+  (`id`, `title`, `sn`, `status`, `template_id`, `create_by`, `create_date`, `update_by`, `update_date`, `cate`)
+SELECT '2026102100000198', '198', '198', '0', @template_id, 'system', NOW(), 'system', NOW(), 0
+  FROM DUAL
+ WHERE NOT EXISTS (SELECT 1 FROM `mes_product_cate` WHERE `sn` = '198');
+
+-- 已存在时校正名称、模板、状态,保证脚本可重复执行
+UPDATE `mes_product_cate`
+   SET `title` = `sn`,
+       `status` = '0',
+       `template_id` = IFNULL(NULLIF(@template_id, ''), `template_id`),
+       `cate` = 0,
+       `update_by` = 'system',
+       `update_date` = NOW()
+ WHERE `sn` IN ('195', '196', '198');
+
+-- ---------- 3. 校验 ----------
+-- 3.1 三种产品应各有一条,且 status=0、template_id 非空
+SELECT `id`, `title`, `sn`, `status`, `template_id`, `cate`
+  FROM `mes_product_cate`
+ WHERE `sn` IN ('195', '196', '198')
+ ORDER BY `sn`;
+
+-- 3.2 模板缺失检查(应为 0 行)
+SELECT `sn`, `title`, `template_id`
+  FROM `mes_product_cate`
+ WHERE `sn` IN ('195', '196', '198')
+   AND (`template_id` IS NULL OR `template_id` = '');
+
+SELECT '产品 195/196/198 创建完成' AS message;

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

@@ -412,4 +412,40 @@ POST /mes/mesProductPlan/currentPlan
 > 剩余 2 条未回填的计划是历史脏数据(2026-04-16/17,关联订单已不存在、`shift_id='1'` 占位值),
 > 产品类型无从推导,且匹配不到真实班次,不影响排产与拦截,建议人工清理。
 
+---
+
+## 十一、产品创建 195 / 196 / 198(已落地)
+
+### 11.1 目标
+
+在工件/产品类型主数据 `mes_product_cate` 中创建 `195`、`196`、`198` 三种产品,
+使下单页「产品类型」下拉可以直接选择,订单-计划-工件链路按产品区分。
+
+### 11.2 脚本
+
+`db/mes_product_create_195_196_198.sql`(可重复执行):
+
+- 默认模板取 `CZ060` 的 `template_id`,取不到时用任一启用模板兜底;
+- 按 `sn` 幂等创建 `195`/`196`/`198`,`title` 与 `sn` 一致、`status='0'`、`cate=0`(产品);
+- 已存在时自动校正名称、模板、状态,不会产生重复数据。
+
+### 11.3 验证
+
+```sql
+SELECT id, title, sn, status, template_id, cate
+  FROM mes_product_cate
+ WHERE sn IN ('195', '196', '198')
+ ORDER BY sn;
+```
+
+本地库(localhost:3307 / mes_cloud_xz)执行结果:
+
+| sn | title | status | template_id | cate |
+|---|---|---|---|---|
+| 195 | 195 | 0 | 2102266346164490240(CZ060 模板) | 0 |
+| 196 | 196 | 0 | 2102266346164490240(CZ060 模板) | 0 |
+| 198 | 198 | 0 | 2102266346164490240(CZ060 模板) | 0 |
+
+> 执行后刷新下单页即可看到三种产品;如需为三种产品配置独立生产模板,可在「工件类型管理」中编辑 `template_id`。
+
 

+ 30 - 0
src/main/java/com/jeesite/modules/mes/service/MesProductOrderService.java

@@ -80,6 +80,14 @@ public class MesProductOrderService extends CrudService<MesProductOrderDao, MesP
 	@Transactional
 	public void save(MesProductOrder mesProductOrder) {
 
+		// 生产数量和BOM是后续下发、领料、排产的必要数据,保存时必须校验,避免生成空数据订单
+		if (mesProductOrder.getNums() == null || mesProductOrder.getNums() <= 0) {
+			throw new ServiceException(text("生产数量不能为空,且必须大于0!"));
+		}
+		if (StringUtils.isBlank(mesProductOrder.getBomId())) {
+			throw new ServiceException(text("请选择BOM!"));
+		}
+
 		if(mesProductOrder.getIsNewRecord()){
 			String orderNo = "";
 			while (true){
@@ -103,6 +111,12 @@ public class MesProductOrderService extends CrudService<MesProductOrderDao, MesP
 					wmsOrderService.save(wmsOrder);
 				}
 			}
+		} else {
+			// 订单状态只能通过“下发/完成”操作流转,编辑保存时不允许直接修改状态
+			MesProductOrder oldOrder = super.get(new MesProductOrder(mesProductOrder.getId()));
+			if (oldOrder != null) {
+				mesProductOrder.setState(oldOrder.getState());
+			}
 		}
 
 		// 产品类型(下单时选择):未选择时按 BOM 的工件类型补齐,与 BOM 不一致时报错
@@ -143,6 +157,22 @@ public class MesProductOrderService extends CrudService<MesProductOrderDao, MesP
 	}
 	
 	/**
+	 * 订单状态流转(下发、完成)。
+	 * 仅供明确的状态操作调用,保存编辑表单时不要使用,避免绕过下发/完成流程。
+	 * @param id 订单id
+	 * @param state 目标状态:0草稿 1下发 2完成
+	 */
+	@Transactional
+	public void updateOrderState(String id, String state) {
+		MesProductOrder order = super.get(new MesProductOrder(id));
+		if (order == null) {
+			throw new ServiceException(text("生产订单不存在或已删除!"));
+		}
+		order.setState(state);
+		super.save(order);
+	}
+
+	/**
 	 * 更新状态
 	 * @param mesProductOrder
 	 */

+ 66 - 5
src/main/java/com/jeesite/modules/mes/web/MesProductOrderController.java

@@ -5,6 +5,7 @@ import java.util.ArrayList;
 import java.util.Map;
 import java.util.HashMap;
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.text.SimpleDateFormat;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -12,6 +13,7 @@ import javax.servlet.http.HttpServletResponse;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONValidator;
 import com.jeesite.common.codec.EncodeUtils;
+import com.jeesite.common.lang.StringUtils;
 import com.jeesite.modules.wms.entity.WmsBom;
 import com.jeesite.modules.wms.entity.WmsMaterial;
 import com.jeesite.modules.wms.entity.WmsOrder;
@@ -38,6 +40,7 @@ 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.service.ServiceException;
 import com.jeesite.common.web.BaseController;
 import com.jeesite.modules.mes.entity.MesProductCate;
 import com.jeesite.modules.mes.entity.MesProductOrder;
@@ -269,10 +272,13 @@ public class MesProductOrderController extends BaseController {
 		if (orderId == null || materialId == null || nums == null || nums <= 0) return renderResult(Global.FALSE, text("请输入有效的取料数量!"));
 		MesProductOrder order = mesProductOrderService.get(orderId);
 		if (order == null) return renderResult(Global.FALSE, text("生产订单不存在!"));
+		if (StringUtils.isBlank(order.getBomId())) return renderResult(Global.FALSE, text("生产订单未维护BOM,无法取料!"));
 		WmsBomMaterial q = new WmsBomMaterial(); q.setBomId(order.getBomId()); q.setMaterialId(materialId); q.setStatus("0");
 		List<WmsBomMaterial> items = wmsBomMaterialService.findList(q);
 		if (items.isEmpty()) return renderResult(Global.FALSE, text("物料不在订单BOM中!"));
-		int demand = Integer.parseInt(items.get(0).getNums()) * order.getNums();
+		BigDecimal bomNums = parseNums(items.get(0).getNums());
+		if (bomNums == null || order.getNums() == null) return renderResult(Global.FALSE, text("BOM物料需求数量无效,请先维护BOM物料数量!"));
+		int demand = bomNums.multiply(BigDecimal.valueOf(order.getNums())).setScale(0, RoundingMode.CEILING).intValue();
 		WmsPickRecord rq = new WmsPickRecord(); rq.setOrderId(orderId); rq.setMaterialId(materialId);
 		int picked = 0; for (WmsPickRecord pr : wmsPickRecordService.findList(rq)) if (pr.getNums()!=null) picked += pr.getNums();
 		if (picked + nums > demand) return renderResult(Global.FALSE, text("取料数量不能超过订单需求!"));
@@ -288,7 +294,11 @@ public class MesProductOrderController extends BaseController {
 	@PostMapping(value = "save")
 	@ResponseBody
 	public String save(@Validated MesProductOrder mesProductOrder) {
-		mesProductOrderService.save(mesProductOrder);
+		try {
+			mesProductOrderService.save(mesProductOrder);
+		} catch (ServiceException e) {
+			return renderResult(Global.FALSE, e.getMessage());
+		}
 		return renderResult(Global.TRUE, text("保存生产订单成功!"));
 	}
 
@@ -303,12 +313,36 @@ public class MesProductOrderController extends BaseController {
 		if (!"0".equals(mesProductOrder.getState()))
 			return renderResult(Global.FALSE, text("当前订单不能下发!"));
 
+		if (mesProductOrder.getNums() == null || mesProductOrder.getNums() <= 0)
+			return renderResult(Global.FALSE, text("订单生产数量不能为空,请先编辑订单维护生产数量!"));
+		if (StringUtils.isBlank(mesProductOrder.getBomId()))
+			return renderResult(Global.FALSE, text("订单未维护BOM,不能下发!"));
+
 		WmsBomMaterial query = new WmsBomMaterial();
 		query.setBomId(mesProductOrder.getBomId());
 		query.setStatus("0");
 		List<WmsBomMaterial> bomItems = wmsBomMaterialService.findList(query);
 		if (bomItems.isEmpty())
 			return renderResult(Global.FALSE, text("订单BOM未配置有效物料!"));
+
+		// 先校验BOM物料数量,避免生成领料单后才发现数据异常
+		Map<String, Integer> planNumsMap = new HashMap<>();
+		for (WmsBomMaterial bomItem : bomItems) {
+			BigDecimal bomNums = parseNums(bomItem.getNums());
+			if (bomNums == null) {
+				return renderResult(Global.FALSE, text("BOM物料需求数量无效,请先维护BOM物料数量!"));
+			}
+			planNumsMap.put(bomItem.getId(), bomNums
+					.multiply(BigDecimal.valueOf(mesProductOrder.getNums()))
+					.setScale(0, RoundingMode.CEILING).intValue());
+		}
+
+		// 防止重复下发时生成多张领料单
+		WmsPick pickQuery = new WmsPick();
+		pickQuery.setOrderId(mesProductOrder.getId());
+		if (!wmsPickService.findList(pickQuery).isEmpty())
+			return renderResult(Global.FALSE, text("该订单已生成领料单,请勿重复下发!"));
+
 		WmsPick pick = new WmsPick();
 		pick.setOrderId(mesProductOrder.getId());
 		pick.setState("0");
@@ -319,18 +353,45 @@ public class MesProductOrderController extends BaseController {
 			WmsPickMaterial item = new WmsPickMaterial();
 			item.setPickId(pick.getId());
 			item.setMaterialId(bomItem.getMaterialId());
-			item.setPlanNums(Integer.valueOf(bomItem.getNums()) * mesProductOrder.getNums());
+			item.setPlanNums(planNumsMap.get(bomItem.getId()));
 			item.setPickNums(0);
 			item.setState("0");
 			item.setStatus("0");
 			wmsPickMaterialService.save(item);
 		}
-		mesProductOrder.setState("1");
-		mesProductOrderService.save(mesProductOrder);
+		mesProductOrderService.updateOrderState(mesProductOrder.getId(), "1");
 		return renderResult(Global.TRUE, text("操作成功!"));
 	}
+
+	/**
+	 * 解析BOM数量,支持小数;为空或格式错误时返回null。
+	 */
+	private BigDecimal parseNums(String value) {
+		if (StringUtils.isBlank(value)) {
+			return null;
+		}
+		try {
+			return new BigDecimal(value.trim());
+		} catch (NumberFormatException e) {
+			return null;
+		}
+	}
 	
 	/**
+	 * 完成订单
+	 */
+	@RequiresPermissions("mes:mesProductOrder:edit")
+	@RequestMapping(value = "complete")
+	@ResponseBody
+	@Transactional
+	public String complete(MesProductOrder mesProductOrder) {
+		if (!"1".equals(mesProductOrder.getState()))
+			return renderResult(Global.FALSE, text("只有已下发的订单才能完成!"));
+		mesProductOrderService.updateOrderState(mesProductOrder.getId(), "2");
+		return renderResult(Global.TRUE, text("订单已完成!"));
+	}
+
+	/**
 	 * 删除数据
 	 */
 	@RequiresPermissions("mes:mesProductOrder:edit")

+ 16 - 7
src/main/resources/views/modules/mes/mesProductOrderForm.html

@@ -33,7 +33,7 @@
 							<label class="control-label col-sm-2" title="">
 								<span class="required hide">*</span> ${text('生产数量')}:<i class="fa icon-question hide"></i></label>
 							<div class="col-sm-4">
-								<#form:input path="nums" maxlength="8" class="form-control digits"/>
+								<#form:input path="nums" maxlength="8" class="form-control required digits"/>
 							</div>
 						</div>
 					</div>
@@ -90,7 +90,8 @@
 							<label class="control-label col-sm-2" title="">
 								<span class="required hide">*</span> ${text('状态')}:<i class="fa icon-question hide"></i></label>
 							<div class="col-sm-4">
-								<#form:select path="state" dictType="wms_order_state" blankOption="true" class="form-control" />
+								<#form:hidden path="state"/>
+								<p class="form-control-static">${@DictUtils.getDictLabel('wms_order_state', mesProductOrder.state, '草稿')}</p>
 							</div>
 						</div>
 					</div>
@@ -125,22 +126,30 @@ $("#inputForm").validate({
 });
 
 // BOM -> 工件类型 映射(选择BOM时自动带出产品类型)
-var bomCateMap = ${bomCateMap!'{}'};
+var bomCateMap = ${bomCateMap!"null"} || {};
 // 当前计划的产品类型(新建订单时默认带出)
 var curCateSn = '${curCateSn!}';
 
 $(function(){
 	// 新建订单且未选产品类型时,按当前计划的产品类型带出
 	if(!$('#productCateId').val() && curCateSn){
-		$('#productCateId').val(curCateSn);
-		showCateTip('已按当前计划的产品类型带出,可手动调整');
+		if($('#productCateId option[value="'+curCateSn+'"]').length){
+			$('#productCateId').val(curCateSn);
+			showCateTip('已按当前计划的产品类型带出,可手动调整');
+		}else{
+			showCateTip('当前计划的产品类型已停用,请手动选择产品类型');
+		}
 	}
 	// 切换BOM时自动带出对应的工件类型
 	$('#bomId').on('change', function(){
 		var cate = bomCateMap[$('#bomId').val()];
 		if(cate){
-			$('#productCateId').val(cate);
-			showCateTip('已按BOM的工件类型自动带出');
+			if($('#productCateId option[value="'+cate+'"]').length){
+				$('#productCateId').val(cate);
+				showCateTip('已按BOM的工件类型自动带出');
+			}else{
+				showCateTip('该BOM的工件类型已停用,请手动选择产品类型');
+			}
 		}else{
 			showCateTip('该BOM未维护工件类型,请手动选择产品类型');
 		}

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

@@ -65,7 +65,7 @@ $('#dataGrid').dataGrid({
 			return js.getDictLabel(${@DictUtils.getDictListJson('wms_order_state')}, val, '${text("未知")}', true);
 		}},
 		{header:'${text("更新时间")}', name:'updateDate', index:'a.update_date', width:150, align:"center"},
-		{header:'${text("操作")}', name:'actions', width:120, formatter: function(val, obj, row, act){
+		{header:'${text("操作")}', name:'actions', width:190, formatter: function(val, obj, row, act){
 			var actions = [];
 			//<% if(hasPermi('mes:mesProductOrder:edit')){ %>
 				if(row.state == "0"){
@@ -78,6 +78,7 @@ $('#dataGrid').dataGrid({
 					actions.push('</div>');
 				}else if(row.state == "1"){
 					actions.push('<a href="${ctx}/mes/mesProductPlan/list?orderId='+row.id+'" class="btnList" title="${text("生产计划")}">计划</a>&nbsp;');
+					actions.push('<a href="${ctx}/mes/mesProductOrder/complete?id='+row.id+'" class="btnList" title="${text("完成订单")}" data-confirm="${text("确认要完成该生产订单吗?")}"><i class="fa fa-check"></i> ${text("完成")}</a>&nbsp;');
 				}
 				// actions.push('<a href="${ctx}/mes/mesProductOrder/pickDetail?id='+row.id+'" class="btnList" title="${text("查看订单详情")}"><i class="fa fa-fw fa-eye"></i></a>&nbsp;');
 								//<% } %>

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

@@ -64,7 +64,8 @@
 							<label class="control-label col-sm-2" title="">
 								<span class="required hide">*</span> ${text('状态')}:<i class="fa icon-question hide"></i></label>
 							<div class="col-sm-4">
-								<#form:select path="state" dictType="wms_order_state" blankOption="true" class="form-control" />
+								<#form:hidden path="state"/>
+								<p class="form-control-static">${@DictUtils.getDictLabel('wms_order_state', mesProductOrder.state, '草稿')}</p>
 							</div>
 						</div>
 					</div>