跳到主要内容

配方入库

"配方" 是设备侧可参数化的工艺集合 — 改一组参数就切换到另一种产品。本页讲如何把配方存到数据库, 让多个工位共享同一套配方, 并提供版本化和审计能力。

配方是设备参数, 不是业务数据

配方由工艺员/工程师维护, 面向"温度/压力/速度/停留时间"等物理参数, 不是订单/库存/客户等业务数据。 上位系统 (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 离线时 (数据库不可达) 配方切换怎么办?

选项:

  1. 完全拒绝切换: 必须联网, 简单安全
  2. 本地缓存: 最近 10 个配方缓存在 PLC, 离线时可切到缓存中的
  3. 先改本地, 联网后同步: 记录 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 变量。

安全与权限

角色查看创建修改审批切换
操作员YesNoNoNoYes (已批准的)
班长YesNoNoNoYes
工艺员YesYesYesNoNo
工艺经理YesYesYesYesNo
审计员YesNoNoNoNo

数据库侧的 SQL 权限只给 PLC 用的账号最小范围 (SELECT recipes, SELECT station_recipe, 以及写入 recipe_changeover_log 的有限 INSERT), 避免 PLC 被入侵后数据全失。

频率强调

本文档描述的读操作 (SELECT recipe) 发生在换型时 (1 班 1-3 次), 写操作 (INSERT changeover_log) 同样发生在换型时, 都是低频事件。 这与扫描周期 (2-8ms) 完全解耦, 不会影响 PLC 实时性。

相关文档