配方入库
"配方" 是设备侧可参数化的工艺集合 — 改一组参数就切换到另一种产品。本页讲如何把配方存到数据库, 让多个工位共享同一套配方, 并提供版本化和审计能力。
配方由工艺员/工程师维护, 面向"温度/压力/速度/停留时间"等物理参数, 不是订单/库存/客户等业务数据。 上位系统 (ERP/PLM 等) 可以把产品对应的工艺参数定期导出到配方表, 但配方表本身只服务于设备 — 读入 PLC 驱动实际工艺。 写回仅限换型审计一行, 不频繁写。
典型场景
- 一条饮料线生产多种口味, 每种口味一套温度/压力/灌装量配方
- 一台注塑机换模具时换工艺参数
- 一台焊接机按不同工件自动调节电流/时间/气压
- 多工位的同一产线共享一份全局配方库
场景需求分析
典型配方管理需求:
| 需求 | 实现 |
|---|---|
| 存一堆参数, 随时修改 | SQL 表 |
| 工程师在上位改, 现场 PLC 立即生效 | 数据库 + PLC 周期读 |
| 多个配方可切换 | 配方表 + 配方 ID 字段 |
| 改配方要留痕 | 版本表 + 审计表 |
| 换配方时停机 / 在线切换 | PLC 程序时序 |
| 给不同工位下发不同配方 | 配方按工位关联 |
表结构设计
基础配方表
CREATE TABLE recipes (
id INT PRIMARY KEY IDENTITY(1,1),
code VARCHAR(32) NOT NULL UNIQUE,
name NVARCHAR(64) NOT NULL,
description NVARCHAR(256),
category VARCHAR(32),
-- 工艺参数
temperature REAL,
pressure REAL,
speed REAL,
dwell_time INT, -- 保压时间, 秒
heating_time INT,
-- 版本与状态
version INT NOT NULL DEFAULT 1,
is_active BIT NOT NULL DEFAULT 0,
is_approved BIT NOT NULL DEFAULT 0,
-- 审计字段
created_by VARCHAR(32),
created_at DATETIME DEFAULT GETDATE(),
updated_by VARCHAR(32),
updated_at DATETIME,
approved_by VARCHAR(32),
approved_at DATETIME
);
CREATE INDEX idx_recipes_code ON recipes(code);
CREATE INDEX idx_recipes_active ON recipes(is_active) WHERE is_active = 1;
关键设计点:
code作为业务键, 便于跨库迁移is_active标记当前激活is_approved审批工作流- 审计三元组 (created/updated/approved)
版本历史表
每次修改都写入版本表, 便于回滚和审计:
CREATE TABLE recipe_versions (
version_id INT PRIMARY KEY IDENTITY(1,1),
recipe_id INT NOT NULL FOREIGN KEY REFERENCES recipes(id),
version INT NOT NULL,
-- 参数快照
temperature REAL,
pressure REAL,
speed REAL,
dwell_time INT,
heating_time INT,
-- 变更信息
changed_by VARCHAR(32),
changed_at DATETIME DEFAULT GETDATE(),
change_reason NVARCHAR(256),
UNIQUE (recipe_id, version)
);
修改 recipes 时触发器或应用层同步写入版本表:
CREATE TRIGGER tg_recipes_version
ON recipes
AFTER UPDATE AS
BEGIN
INSERT INTO recipe_versions(recipe_id, version, temperature, pressure,
speed, dwell_time, heating_time,
changed_by, change_reason)
SELECT i.id, i.version, i.temperature, i.pressure,
i.speed, i.dwell_time, i.heating_time,
i.updated_by, 'Auto-version on update'
FROM inserted i;
END;
切换审计表
每次配方切换都要留痕, 用于事后可追溯 (换型谁做的 / 何时 / 换前换后配方):
CREATE TABLE recipe_changeover_log (
log_id INT PRIMARY KEY IDENTITY(1,1),
station VARCHAR(32) NOT NULL,
from_code VARCHAR(32),
to_code VARCHAR(32) NOT NULL,
from_version INT,
to_version INT NOT NULL,
operator VARCHAR(32),
reason NVARCHAR(256),
ts DATETIME DEFAULT GETDATE(),
ok BIT DEFAULT 1, -- 切换是否成功
error_msg NVARCHAR(256)
);
CREATE INDEX idx_changeover_station_ts ON recipe_changeover_log(station, ts);
多工位关联表
多个 PLC 工位, 每个工位可能用不同配方:
CREATE TABLE stations (
id VARCHAR(32) PRIMARY KEY,
line VARCHAR(32),
name NVARCHAR(64),
description NVARCHAR(256)
);
CREATE TABLE station_recipe (
station_id VARCHAR(32) FOREIGN KEY REFERENCES stations(id),
recipe_id INT FOREIGN KEY REFERENCES recipes(id),
assigned_at DATETIME DEFAULT GETDATE(),
PRIMARY KEY (station_id)
);
每个工位在 station_recipe 里只有一条记录, 对应当前激活配方。
PLC 侧实现
数据结构
工程里建 UDT 对应配方表:
TYPE T_Recipe :
STRUCT
Id : DINT;
Code : STRING[32];
Name : STRING[64];
(* 工艺参数 *)
Temperature : REAL;
Pressure : REAL;
Speed : REAL;
DwellTime : INT;
HeatingTime : INT;
(* 元信息 *)
Version : INT;
IsActive : BOOL;
IsApproved : BOOL;
END_STRUCT
END_TYPE
以及一个全局 DB 存当前激活配方:
DATA_BLOCK DB_CurrentRecipe
VAR
Recipe : T_Recipe;
LastSyncTime : DT;
SyncError : STRING[128];
END_VAR
END_DATA_BLOCK
读取激活配方
周期性从数据库读当前激活配方:
FUNCTION_BLOCK FB_SyncCurrentRecipe
VAR_INPUT
StationId : STRING[32]; (* 本工位标识 *)
SyncIntervalMs : INT := 10000; (* 10 秒 *)
END_VAR
VAR_OUTPUT
RecipeChanged : BOOL; (* 配方变了吗 *)
Error : BOOL;
ErrorMessage : STRING[128];
END_VAR
VAR
SyncTimer : TON;
Select : DB_SELECT;
LastVersion : INT;
LastCode : STRING[32];
State : INT;
END_VAR
SyncTimer(IN := (State = 0), PT := T#10s);
CASE State OF
0:
IF SyncTimer.Q THEN State := 10; END_IF;
10:
Select(
ConnectionName := 'db_recipe',
SQL := 'SELECT r.id, r.code, r.name, r.temperature, r.pressure, ' +
'r.speed, r.dwell_time, r.heating_time, r.version, ' +
'r.is_active, r.is_approved ' +
'FROM recipes r ' +
'JOIN station_recipe sr ON r.id = sr.recipe_id ' +
'WHERE sr.station_id = :station AND r.is_approved = 1',
Execute := TRUE
);
Select.BindString('station', StationId);
State := 20;
20:
Select(Execute := FALSE);
IF Select.Done THEN
IF Select.RowCount = 1 THEN
(* 解析结果到 DB_CurrentRecipe *)
DB_CurrentRecipe.Recipe.Id := Select.Row[0].Cell[0].AsDInt;
DB_CurrentRecipe.Recipe.Code := Select.Row[0].Cell[1].AsString;
DB_CurrentRecipe.Recipe.Name := Select.Row[0].Cell[2].AsString;
DB_CurrentRecipe.Recipe.Temperature := Select.Row[0].Cell[3].AsReal;
DB_CurrentRecipe.Recipe.Pressure := Select.Row[0].Cell[4].AsReal;
DB_CurrentRecipe.Recipe.Speed := Select.Row[0].Cell[5].AsReal;
DB_CurrentRecipe.Recipe.DwellTime := Select.Row[0].Cell[6].AsInt;
DB_CurrentRecipe.Recipe.HeatingTime := Select.Row[0].Cell[7].AsInt;
DB_CurrentRecipe.Recipe.Version := Select.Row[0].Cell[8].AsInt;
(* 判断是否变化 *)
RecipeChanged := (DB_CurrentRecipe.Recipe.Code <> LastCode) OR
(DB_CurrentRecipe.Recipe.Version <> LastVersion);
LastCode := DB_CurrentRecipe.Recipe.Code;
LastVersion := DB_CurrentRecipe.Recipe.Version;
DB_CurrentRecipe.LastSyncTime := NOW();
END_IF;
State := 0;
ELSIF Select.Error THEN
Error := TRUE;
ErrorMessage := Select.ErrorMessage;
DB_CurrentRecipe.SyncError := Select.ErrorMessage;
State := 0;
END_IF;
END_CASE;
END_FUNCTION_BLOCK
配方切换时序
切换配方时必须走完整时序, 避免"配方 A 在用一半, 配方 B 强行覆盖"的危险:
核心原则:
- 切换期间 PLC 进入"切换中"状态, 禁止生产启动
- 先写数据库记录, 再切 PLC, 失败时能回滚并留痕
- 验证新参数生效 (读回比较) 后才算成功
切换代码示例
FUNCTION_BLOCK FB_ChangeRecipe
VAR_INPUT
NewCode : STRING[32];
Operator : STRING[32];
Reason : STRING[128];
Execute : BOOL;
END_VAR
VAR_OUTPUT
Done : BOOL;
Error : BOOL;
ErrorMessage : STRING[128];
END_VAR
VAR
State : INT;
LogInsert : DB_INSERT;
Update : DB_UPDATE;
Select : DB_SELECT;
StartEdge : R_TRIG;
LogId : DINT;
END_VAR
StartEdge(CLK := Execute);
CASE State OF
0: (* 空闲 *)
IF StartEdge.Q THEN
(* 检查是否允许切换 *)
IF IS_RUNNING THEN
Error := TRUE;
ErrorMessage := '生产中, 请先停机';
RETURN;
END_IF;
State := 10;
END_IF;
10: (* 插入 log *)
LogInsert(
ConnectionName := 'db_recipe',
SQL := 'INSERT INTO recipe_changeover_log(station, from_code, to_code, operator, reason) ' +
'VALUES(:st, :fc, :tc, :op, :re)',
Execute := TRUE
);
LogInsert.BindString('st', MyStationId);
LogInsert.BindString('fc', DB_CurrentRecipe.Recipe.Code);
LogInsert.BindString('tc', NewCode);
LogInsert.BindString('op', Operator);
LogInsert.BindString('re', Reason);
State := 20;
20:
LogInsert(Execute := FALSE);
IF LogInsert.Done THEN
LogId := LogInsert.LastInsertId;
State := 30;
ELSIF LogInsert.Error THEN
Error := TRUE;
ErrorMessage := LogInsert.ErrorMessage;
State := 0;
END_IF;
30: (* 更新 station_recipe *)
Update(
ConnectionName := 'db_recipe',
SQL := 'UPDATE station_recipe SET recipe_id = ' +
'(SELECT id FROM recipes WHERE code = :c), assigned_at = GETDATE() ' +
'WHERE station_id = :st',
Execute := TRUE
);
Update.BindString('c', NewCode);
Update.BindString('st', MyStationId);
State := 40;
40:
Update(Execute := FALSE);
IF Update.Done THEN
State := 50;
ELSIF Update.Error THEN
Error := TRUE;
ErrorMessage := Update.ErrorMessage;
State := 60; (* 错误分支, 更新日志为失败 *)
END_IF;
50: (* 读新配方 *)
(* 复用 FB_SyncCurrentRecipe 立即同步 *)
ForceSyncRecipe := TRUE;
(* ... 等待完成 ... *)
State := 70;
60: (* 失败: 更新 log *)
UpdateLog(
SQL := 'UPDATE recipe_changeover_log SET ok = 0, error_msg = :e WHERE log_id = :id',
Execute := TRUE
);
UpdateLog.BindString('e', ErrorMessage);
UpdateLog.BindInt('id', LogId);
State := 0;
70: (* 成功 *)
Done := TRUE;
State := 0;
END_CASE;
END_FUNCTION_BLOCK
多工位分发模式
模式 1: 工位主动拉取
每个 PLC 周期查自己的 station_recipe, 看是否更新。简单, 适合工位少 (< 100) 的场景。
模式 2: 中心推送
上位的配方管理服务通过 HTTP API 推给每个工位的 HMI/Service, 再由 Service 调用 PLC 的通讯 FB 执行切换:
POST http://plc-station01:18823/api/recipe/switch
{
"code": "RECIPE_B",
"operator": "张三",
"reason": "换产"
}
PLC 收到后执行切换。适合实时要求高的场景。
模式 3: MQTT 广播
配方管理服务发 MQTT 消息到 recipes/station/*/assign, 所有工位订阅自己的主题:
{
"station": "STATION_01",
"code": "RECIPE_B",
"version": 15,
"operator": "张三",
"timestamp": "2026-04-17T10:00:00Z"
}
无中心协调, 工位间解耦。
版本化策略
版本号递增
修改配方参数 → version += 1。简单, 历史版本在 recipe_versions 里完整保存。
草稿/发布 分离
draft表存工程师正在改的配方recipes表只存已发布的publish动作把 draft 复制到 recipes 并 version+1
-- 发布操作
INSERT INTO recipes (code, ..., version)
SELECT code, ..., COALESCE(MAX(version), 0) + 1 FROM recipes WHERE code = d.code
FROM draft d WHERE d.id = :draft_id;
DELETE FROM draft WHERE id = :draft_id;
批准流程
is_approved = 0表示未批准, PLC 不会读- 审批员改为
is_approved = 1+ 填approved_by+approved_at - 审批时自动锁定 (不能再改参数)
UPDATE recipes SET
is_approved = 1,
approved_by = :user,
approved_at = GETDATE()
WHERE id = :recipe_id AND is_approved = 0;
回滚
任意时刻可回滚到历史版本:
-- 从 recipe_versions 复原到 recipes
UPDATE recipes SET
temperature = (SELECT temperature FROM recipe_versions WHERE version_id = :v),
pressure = (SELECT pressure FROM recipe_versions WHERE version_id = :v),
...
version = (SELECT version FROM recipe_versions WHERE version_id = :v) + 1,
updated_by = :user,
updated_at = GETDATE(),
is_approved = 0 -- 回滚后需重新审批
WHERE code = (SELECT r.code FROM recipes r JOIN recipe_versions v ON r.id = v.recipe_id WHERE v.version_id = :v);
然后走正常审批流程。
离线场景
PLC 离线时 (数据库不可达) 配方切换怎么办?
选项:
- 完全拒绝切换: 必须联网, 简单安全
- 本地缓存: 最近 10 个配方缓存在 PLC, 离线时可切到缓存中的
- 先改本地, 联网后同步: 记录 pending 操作, 重连后推
大多数场景选 1 或 2。选 3 要小心同步冲突。
HMI 集成
HMI 配方界面典型功能:
- 配方列表: 表格显示所有
is_approved = 1的配方 - 详情查看: 选择一行看所有参数
- 切换按钮: 请求切换 (触发 FB_ChangeRecipe)
- 版本历史: 显示该配方的所有版本
- 差异对比: 当前版本 vs 上一版本, 变化的参数高亮
- 打印: 配方参数单, 给质量/工艺员存档
HMI 通过 WebSocket 订阅 DB_CurrentRecipe 等 PLC 变量, 通过 /api/hmi/db/recipes 读取配方列表的 schema。具体配方数据的 SELECT 由 PLC 侧 FB 执行, HMI 只展示 PLC 已填充的 DB 变量。
安全与权限
| 角色 | 查看 | 创建 | 修改 | 审批 | 切换 |
|---|---|---|---|---|---|
| 操作员 | Yes | No | No | No | Yes (已批准的) |
| 班长 | Yes | No | No | No | Yes |
| 工艺员 | Yes | Yes | Yes | No | No |
| 工艺经理 | Yes | Yes | Yes | Yes | No |
| 审计员 | Yes | No | No | No | No |
数据库侧的 SQL 权限只给 PLC 用的账号最小范围 (SELECT recipes, SELECT station_recipe, 以及写入 recipe_changeover_log 的有限 INSERT), 避免 PLC 被入侵后数据全失。
本文档描述的读操作 (SELECT recipe) 发生在换型时 (1 班 1-3 次), 写操作 (INSERT changeover_log) 同样发生在换型时, 都是低频事件。 这与扫描周期 (2-8ms) 完全解耦, 不会影响 PLC 实时性。