配方管理(MES 场景)
大约 9 分钟约 2782 字
配方管理(MES 场景)
简介
配方管理是 MES(制造执行系统)的核心功能,管理不同产品的工艺参数、生产步骤和质量标准。WPF 上位机通过配方管理模块,实现配方的创建、编辑、导入导出和下发到设备,支持生产换型和工艺追溯。
特点
数据模型
配方结构
/// <summary>
/// 配方数据模型
/// </summary>
public class Recipe : ObservableObject
{
public int Id { get; set; }
public string Code { get; set; } = "";
[ObservableProperty]
private string _name = "";
[ObservableProperty]
private string _productType = "";
[ObservableProperty]
private string _description = "";
[ObservableProperty]
private int _version;
[ObservableProperty]
private RecipeStatus _status;
public DateTime CreatedAt { get; set; }
public string CreatedBy { get; set; } = "";
public DateTime UpdatedAt { get; set; }
public string UpdatedBy { get; set; } = "";
// 配方参数组
public ObservableCollection<RecipeParameterGroup> ParameterGroups { get; set; } = new();
// 获取所有参数(扁平化)
public List<RecipeParameter> GetAllParameters()
{
return ParameterGroups.SelectMany(g => g.Parameters).ToList();
}
// 克隆配方
public Recipe Clone()
{
var json = JsonSerializer.Serialize(this);
var clone = JsonSerializer.Deserialize<Recipe>(json)!;
clone.Id = 0;
clone.Version = 0;
clone.Status = RecipeStatus.Draft;
clone.CreatedAt = DateTime.Now;
return clone;
}
}
public class RecipeParameterGroup
{
public string GroupName { get; set; } = "";
public int Order { get; set; }
public ObservableCollection<RecipeParameter> Parameters { get; set; } = new();
}
public class RecipeParameter : ObservableObject
{
public string Name { get; set; } = "";
public string Address { get; set; } = "" // PLC 地址(如 D100)
{
get; set;
}
public string DataType { get; set; } = "Float"; // Float/Int/Bool/String
[ObservableProperty]
private double _value;
public double DefaultValue { get; set; }
public double MinValue { get; set; } = double.MinValue;
public double MaxValue { get; set; } = double.MaxValue;
public string Unit { get; set; } = "";
public string Description { get; set; } = "";
}
public enum RecipeStatus
{
Draft, // 草稿
Approved, // 已审批
Active, // 使用中
Archived // 已归档
}配方服务
CRUD 和版本管理
/// <summary>
/// 配方管理服务
/// </summary>
public class RecipeService
{
private readonly IRecipeRepository _repository;
private readonly ILogger<RecipeService> _logger;
public RecipeService(IRecipeRepository repository, ILogger<RecipeService> logger)
{
_repository = repository;
_logger = logger;
}
// 获取所有配方
public async Task<List<Recipe>> GetAllAsync()
{
return await _repository.GetAllAsync();
}
// 创建配方
public async Task<Recipe> CreateAsync(Recipe recipe, string createdBy)
{
recipe.CreatedAt = DateTime.Now;
recipe.UpdatedAt = DateTime.Now;
recipe.CreatedBy = createdBy;
recipe.UpdatedBy = createdBy;
recipe.Version = 1;
recipe.Status = RecipeStatus.Draft;
return await _repository.AddAsync(recipe);
}
// 更新配方(创建新版本)
public async Task<Recipe> UpdateAsync(Recipe recipe, string updatedBy)
{
recipe.Version++;
recipe.UpdatedAt = DateTime.Now;
recipe.UpdatedBy = updatedBy;
recipe.Status = RecipeStatus.Draft;
return await _repository.AddAsync(recipe);
}
// 审批配方
public async Task ApproveAsync(int recipeId)
{
var recipe = await _repository.GetByIdAsync(recipeId);
if (recipe != null)
{
recipe.Status = RecipeStatus.Approved;
await _repository.UpdateAsync(recipe);
}
}
// 导入配方
public async Task<Recipe> ImportAsync(string filePath)
{
var json = await File.ReadAllTextAsync(filePath);
var recipe = JsonSerializer.Deserialize<Recipe>(json);
if (recipe == null) throw new Exception("无效的配方文件");
return await _repository.AddAsync(recipe);
}
// 导出配方
public async Task ExportAsync(int recipeId, string filePath)
{
var recipe = await _repository.GetByIdAsync(recipeId);
if (recipe == null) throw new Exception("配方不存在");
var json = JsonSerializer.Serialize(recipe, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(filePath, json);
}
}配方差异对比
版本对比功能
/// <summary>
/// 配方版本对比服务
/// </summary>
public class RecipeCompareService
{
// 对比两个版本的配方差异
public List<RecipeDiffItem> Compare(Recipe oldRecipe, Recipe newRecipe)
{
var diffs = new List<RecipeDiffItem>();
var oldParams = oldRecipe.GetAllParameters()
.ToDictionary(p => p.Name);
var newParams = newRecipe.GetAllParameters()
.ToDictionary(p => p.Name);
// 检查新增和修改的参数
foreach (var param in newParams.Values)
{
if (!oldParams.ContainsKey(param.Name))
{
diffs.Add(new RecipeDiffItem
{
ParameterName = param.Name,
ChangeType = DiffChangeType.Added,
NewValue = param.Value,
Unit = param.Unit
});
}
else if (Math.Abs(oldParams[param.Name].Value - param.Value) > 0.0001)
{
diffs.Add(new RecipeDiffItem
{
ParameterName = param.Name,
ChangeType = DiffChangeType.Modified,
OldValue = oldParams[param.Name].Value,
NewValue = param.Value,
Unit = param.Unit
});
}
}
// 检查删除的参数
foreach (var param in oldParams.Values)
{
if (!newParams.ContainsKey(param.Name))
{
diffs.Add(new RecipeDiffItem
{
ParameterName = param.Name,
ChangeType = DiffChangeType.Removed,
OldValue = param.Value,
Unit = param.Unit
});
}
}
return diffs;
}
}
public class RecipeDiffItem
{
public string ParameterName { get; set; } = "";
public DiffChangeType ChangeType { get; set; }
public double OldValue { get; set; }
public double NewValue { get; set; }
public string Unit { get; set; } = "";
}
public enum DiffChangeType
{
Added,
Modified,
Removed
}配方审批流程
/// <summary>
/// 配方审批工作流
/// </summary>
public class RecipeApprovalWorkflow
{
private readonly IRecipeRepository _repository;
private readonly ILogger<RecipeApprovalWorkflow> _logger;
public event Action<Recipe, string, string>? ApprovalStatusChanged;
public RecipeApprovalWorkflow(IRecipeRepository repository, ILogger<RecipeApprovalWorkflow> logger)
{
_repository = repository;
_logger = logger;
}
// 提交审批
public async Task SubmitForApprovalAsync(int recipeId, string submitter)
{
var recipe = await _repository.GetByIdAsync(recipeId);
if (recipe == null) throw new Exception("配方不存在");
if (recipe.Status != RecipeStatus.Draft) throw new Exception("只有草稿状态的配方才能提交审批");
recipe.Status = RecipeStatus.PendingApproval;
recipe.UpdatedBy = submitter;
recipe.UpdatedAt = DateTime.Now;
await _repository.UpdateAsync(recipe);
ApprovalStatusChanged?.Invoke(recipe, "待审批", submitter);
_logger.LogInformation("配方 {Name} V{Version} 已提交审批", recipe.Name, recipe.Version);
}
// 审批通过
public async Task ApproveAsync(int recipeId, string approver, string comment = "")
{
var recipe = await _repository.GetByIdAsync(recipeId);
if (recipe == null) throw new Exception("配方不存在");
recipe.Status = RecipeStatus.Approved;
recipe.ApprovedBy = approver;
recipe.ApprovedAt = DateTime.Now;
recipe.ApprovalComment = comment;
recipe.UpdatedBy = approver;
recipe.UpdatedAt = DateTime.Now;
await _repository.UpdateAsync(recipe);
ApprovalStatusChanged?.Invoke(recipe, "已审批", approver);
}
// 审批拒绝
public async Task RejectAsync(int recipeId, string approver, string reason)
{
var recipe = await _repository.GetByIdAsync(recipeId);
if (recipe == null) throw new Exception("配方不存在");
recipe.Status = RecipeStatus.Rejected;
recipe.ApprovedBy = approver;
recipe.ApprovedAt = DateTime.Now;
recipe.ApprovalComment = reason;
recipe.UpdatedBy = approver;
recipe.UpdatedAt = DateTime.Now;
await _repository.UpdateAsync(recipe);
ApprovalStatusChanged?.Invoke(recipe, "已拒绝", approver);
}
// 获取审批历史
public async Task<List<ApprovalRecord>> GetApprovalHistoryAsync(int recipeId)
{
return await _repository.GetApprovalHistoryAsync(recipeId);
}
}
public class ApprovalRecord
{
public string Action { get; set; } = "";
public string Operator { get; set; } = "";
public DateTime Timestamp { get; set; }
public string Comment { get; set; } = "";
}配方参数校验
/// <summary>
/// 配方下发前的参数校验
/// </summary>
public class RecipeValidator
{
// 校验配方完整性
public List<string> Validate(Recipe recipe)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(recipe.Name))
errors.Add("配方名称不能为空");
if (recipe.ParameterGroups.Count == 0)
errors.Add("配方至少需要一个参数组");
foreach (var group in recipe.ParameterGroups)
{
if (string.IsNullOrWhiteSpace(group.GroupName))
errors.Add("参数组名称不能为空");
foreach (var param in group.Parameters)
{
if (string.IsNullOrWhiteSpace(param.Name))
errors.Add("参数名称不能为空");
if (string.IsNullOrWhiteSpace(param.Address))
errors.Add($"参数 {param.Name} 缺少 PLC 地址");
if (param.Value < param.MinValue || param.Value > param.MaxValue)
errors.Add($"参数 {param.Name} 值 {param.Value} 超出范围 [{param.MinValue}, {param.MaxValue}]{param.Unit}");
if (param.DataType == "Int" && param.Value != Math.Floor(param.Value))
errors.Add($"参数 {param.Name} 类型为 Int,但值为浮点数");
}
}
return errors;
}
// 检查配方与设备的兼容性
public bool IsCompatibleWithDevice(Recipe recipe, DeviceProfile device)
{
var requiredAddresses = recipe.GetAllParameters()
.Select(p => p.Address)
.Where(a => !string.IsNullOrEmpty(a))
.ToHashSet();
var deviceAddresses = device.SupportedAddresses;
return requiredAddresses.All(deviceAddresses.Contains);
}
}
public class DeviceProfile
{
public string DeviceName { get; set; } = "";
public HashSet<string> SupportedAddresses { get; set; } = new();
public int MaxParameters { get; set; } = 500;
}配方下发
参数下发到设备
/// <summary>
/// 配方下发服务
/// </summary>
public class RecipeDownloadService
{
private readonly IPlcService _plcService;
private readonly ILogger<RecipeDownloadService> _logger;
public event Action<int, string>? DownloadProgress;
public event Action<bool, string>? DownloadCompleted;
public RecipeDownloadService(IPlcService plcService, ILogger<RecipeDownloadService> logger)
{
_plcService = plcService;
_logger = logger;
}
// 下发配方到设备
public async Task DownloadAsync(string deviceName, Recipe recipe)
{
try
{
var parameters = recipe.GetAllParameters();
int total = parameters.Count;
// 先下发配方头信息
await _plcService.WriteAsync("D9000", recipe.Id);
await _plcService.WriteAsync("D9002", recipe.Version);
// 逐个下发参数
for (int i = 0; i < parameters.Count; i++)
{
var param = parameters[i];
if (!string.IsNullOrEmpty(param.Address))
{
await _plcService.WriteAsync(param.Address, param.Value);
DownloadProgress?.Invoke((i + 1) * 100 / total, $"下发 {param.Name} = {param.Value}{param.Unit}");
}
await Task.Delay(50); // 间隔避免通信过快
}
// 下发完成标志
await _plcService.WriteAsync("D9998", 1);
recipe.Status = RecipeStatus.Active;
DownloadCompleted?.Invoke(true, $"配方 {recipe.Name} V{recipe.Version} 下发完成");
_logger.LogInformation("配方 {Name} V{Version} 已下发到 {Device}",
recipe.Name, recipe.Version, deviceName);
}
catch (Exception ex)
{
DownloadCompleted?.Invoke(false, $"下发失败:{ex.Message}");
_logger.LogError(ex, "配方下发失败");
}
}
// 从设备读取配方
public async Task<Recipe> UploadFromDeviceAsync(string deviceName)
{
var recipe = new Recipe { Name = $"从设备 {deviceName} 上传" };
// 读取配方参数
var groups = new List<RecipeParameterGroup>
{
new()
{
GroupName = "温度参数",
Parameters = new ObservableCollection<RecipeParameter>
{
new() { Name = "加热温度", Address = "D100", Value = await _plcService.ReadFloatAsync("D100"), Unit = "℃" },
new() { Name = "保温温度", Address = "D102", Value = await _plcService.ReadFloatAsync("D102"), Unit = "℃" },
new() { Name = "冷却温度", Address = "D104", Value = await _plcService.ReadFloatAsync("D104"), Unit = "℃" }
}
},
new()
{
GroupName = "时间参数",
Parameters = new ObservableCollection<RecipeParameter>
{
new() { Name = "加热时间", Address = "D200", Value = await _plcService.ReadFloatAsync("D200"), Unit = "s" },
new() { Name = "保温时间", Address = "D202", Value = await _plcService.ReadFloatAsync("D202"), Unit = "s" },
new() { Name = "冷却时间", Address = "D204", Value = await _plcService.ReadFloatAsync("D204"), Unit = "s" }
}
},
new()
{
GroupName = "速度参数",
Parameters = new ObservableCollection<RecipeParameter>
{
new() { Name = "主轴转速", Address = "D300", Value = await _plcService.ReadFloatAsync("D300"), Unit = "rpm" },
new() { Name = "进给速度", Address = "D302", Value = await _plcService.ReadFloatAsync("D302"), Unit = "mm/s" }
}
}
};
recipe.ParameterGroups = new ObservableCollection<RecipeParameterGroup>(groups);
return recipe;
}
}配方编辑器
参数编辑界面
<!-- 配方参数编辑 -->
<TabControl ItemsSource="{Binding SelectedRecipe.ParameterGroups}">
<TabControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding GroupName}"/>
</DataTemplate>
</TabControl.ItemTemplate>
<TabControl.ContentTemplate>
<DataTemplate>
<DataGrid ItemsSource="{Binding Parameters}" AutoGenerateColumns="False"
CanUserAddRows="False">
<DataGridTextColumn Header="参数名" Binding="{Binding Name}" IsReadOnly="True"/>
<DataGridTextColumn Header="地址" Binding="{Binding Address}" IsReadOnly="True"/>
<DataGridTextColumn Header="设定值" Binding="{Binding Value, UpdateSourceTrigger=PropertyChanged}" Width="100"/>
<DataGridTextColumn Header="默认值" Binding="{Binding DefaultValue}" IsReadOnly="True"/>
<DataGridTextColumn Header="最小值" Binding="{Binding MinValue}" IsReadOnly="True"/>
<DataGridTextColumn Header="最大值" Binding="{Binding MaxValue}" IsReadOnly="True"/>
<DataGridTextColumn Header="单位" Binding="{Binding Unit}" IsReadOnly="True" Width="60"/>
</DataGrid>
</DataTemplate>
</TabControl.ContentTemplate>
</TabControl>优点
缺点
总结
配方管理核心:Recipe 模型包含参数组 → 参数组包含具体参数(名称、地址、值、范围)→ 下发时按地址写入 PLC。版本管理通过 Version 字段记录。下发流程:参数校验 → 逐个写入 PLC → 写完成标志。建议添加审批流程,确保配方修改经过审核才能下发。
关键知识点
- 先分清主题属于界面层、ViewModel 层、线程模型还是设备接入层。
- WPF 文章真正的价值在于把 UI、数据、命令、线程和资源关系讲清楚。
- 上位机场景里,稳定性和异常恢复常常比界面花哨更重要。
- WPF 主题往往要同时理解依赖属性、绑定、可视树和命令系统。
项目落地视角
- 优先明确 DataContext、绑定路径、命令源和线程切换位置。
- 涉及设备时,补齐超时、重连、日志、告警和资源释放策略。
- 复杂控件最好提供最小可运行页面,便于后续复用和排障。
- 优先确认 DataContext、绑定路径、命令触发点和资源引用来源。
常见误区
- 把大量逻辑堆进 code-behind,导致页面难测难维护。
- 在后台线程直接操作 UI,或忽略 Dispatcher 切换。
- 只验证正常路径,不验证设备掉线、权限缺失和资源泄漏。
- 在 code-behind 塞太多状态与业务逻辑。
进阶路线
- 继续向 MVVM 工具链、控件可复用性、性能优化和视觉树诊断深入。
- 把主题放回真实设备流程,思考启动、连接、采集、显示、告警和恢复。
- 沉淀成控件库、通信层和诊断工具,提高整套客户端的复用度。
- 继续补齐控件库、主题系统、诊断工具和启动性能优化。
适用场景
- 当你准备把《配方管理(MES 场景)》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合桌面业务系统、监控看板、工控上位机和设备交互界面。
- 当系统需要同时处理复杂 UI、后台任务和硬件通信时,这类主题尤为关键。
落地建议
- 优先明确 View、ViewModel、服务层和设备层边界,避免代码隐藏过重。
- 涉及实时刷新时,提前设计 UI 线程切换、节流和资源释放。
- 对硬件通信、日志、告警和异常恢复建立标准流程。
排错清单
- 先检查 DataContext、绑定路径、INotifyPropertyChanged 和命令状态是否正常。
- 排查 Dispatcher 调用、死锁、后台线程直接操作 UI 的问题。
- 出现内存增长时,优先检查事件订阅、图像资源和窗口生命周期。
复盘问题
- 如果把《配方管理(MES 场景)》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《配方管理(MES 场景)》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《配方管理(MES 场景)》最大的收益和代价分别是什么?
