Seed dual-kiln segment templates behind OPS_SEED_TEMPLATES

ensure_kiln_templates idempotently inserts 6 dry-kiln stations and 6
segments (infeed/step/outfeed × 2 kilns) with their canonical step
sequences from §10.1. Equipment and station-signal bindings stay
operator-owned through the CRUD APIs. Startup runs the seed only when
OPS_SEED_TEMPLATES=true|1, so production deployments don't accidentally
mutate config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-19 08:47:18 +08:00
parent aaf48a336d
commit e3e7917078
4 changed files with 422 additions and 0 deletions

View File

@ -71,6 +71,18 @@ pub async fn run() {
.await
.expect("Failed to connect enabled sources");
if crate::seed::enabled_via_env() {
match crate::seed::ensure_kiln_templates(&platform.pool).await {
Ok(report) => tracing::info!(
"Seeded kiln templates (stations={}, segments={}, steps={})",
report.stations_inserted,
report.segments_inserted,
report.steps_inserted
),
Err(err) => tracing::error!("Seed kiln templates failed: {}", err),
}
}
let state = AppState {
app_name: "operation-system",
config: config.clone(),

View File

@ -4,6 +4,7 @@ pub mod event;
pub mod handler;
pub mod model;
pub mod router;
pub mod seed;
pub mod service;
pub use app::{run, test_state, AppState};

View File

@ -0,0 +1,404 @@
//! Kiln template seed (design doc §12 P5).
//!
//! Inserts skeleton stations + segments + steps for 1 号 / 2 号 dry-kiln lines
//! so operators can start binding equipment / signals through the CRUD APIs
//! instead of authoring every row by hand. Idempotent: re-running is a no-op
//! when the codes already exist.
//!
//! Only the *control flow* is seeded; equipment / station-signal bindings stay
//! operator-managed because the field config differs per site.
use sqlx::PgPool;
/// Insert kiln-1 / kiln-2 templates. Returns the number of rows freshly
/// inserted (segments + stations + steps).
pub async fn ensure_kiln_templates(pool: &PgPool) -> Result<TemplateReport, sqlx::Error> {
let mut report = TemplateReport::default();
let mut tx = pool.begin().await?;
for station in kiln_template_stations() {
let inserted = sqlx::query(
r#"
INSERT INTO station (code, name, line_code, segment_code, station_type, enabled, description)
VALUES ($1, $2, $3, $4, $5, TRUE, $6)
ON CONFLICT (code) DO NOTHING
"#,
)
.bind(station.code)
.bind(station.name)
.bind(station.line_code)
.bind(station.segment_code)
.bind(station.station_type)
.bind(station.description)
.execute(&mut *tx)
.await?;
report.stations_inserted += inserted.rows_affected();
}
for segment in kiln_template_segments() {
let inserted = sqlx::query(
r#"
INSERT INTO process_segment (
code, name, segment_type, line_code, priority,
enabled, mode, require_manual_ack_after_fault, description
)
VALUES ($1, $2, $3, $4, $5, TRUE, $6, TRUE, $7)
ON CONFLICT (code) DO NOTHING
"#,
)
.bind(segment.code)
.bind(segment.name)
.bind(segment.segment_type)
.bind(segment.line_code)
.bind(segment.priority)
.bind(segment.mode)
.bind(segment.description)
.execute(&mut *tx)
.await?;
report.segments_inserted += inserted.rows_affected();
let segment_id: Option<uuid::Uuid> =
sqlx::query_scalar(r#"SELECT id FROM process_segment WHERE code = $1"#)
.bind(segment.code)
.fetch_optional(&mut *tx)
.await?;
let Some(segment_id) = segment_id else { continue };
// Only seed steps for an empty segment. Once an operator owns the
// step list, the seed mustn't trample it.
let existing_step_count: i64 =
sqlx::query_scalar(r#"SELECT COUNT(*) FROM segment_step WHERE segment_id = $1"#)
.bind(segment_id)
.fetch_one(&mut *tx)
.await?;
if existing_step_count > 0 {
continue;
}
for step in build_step_template(segment.segment_type) {
sqlx::query(
r#"
INSERT INTO segment_step (
segment_id, step_no, step_code, action_kind,
confirm_signal_role, expected_value, timeout_ms,
hold_until_confirm, cancel_on_fault, on_timeout, description
)
VALUES ($1, $2, $3, $4, $5, TRUE, $6, FALSE, TRUE, 'fault', $7)
"#,
)
.bind(segment_id)
.bind(step.step_no)
.bind(step.step_code)
.bind(step.action_kind)
.bind(step.confirm_signal_role)
.bind(step.timeout_ms)
.bind(step.description)
.execute(&mut *tx)
.await?;
report.steps_inserted += 1;
}
}
tx.commit().await?;
Ok(report)
}
#[derive(Debug, Default)]
pub struct TemplateReport {
pub stations_inserted: u64,
pub segments_inserted: u64,
pub steps_inserted: u64,
}
struct StationTemplate {
code: &'static str,
name: &'static str,
line_code: &'static str,
segment_code: &'static str,
station_type: &'static str,
description: &'static str,
}
fn kiln_template_stations() -> Vec<StationTemplate> {
let lines = [("KILN_1", "1 号"), ("KILN_2", "2 号")];
let kinds: &[(&str, &str, &str)] = &[
("dry_in", "干燥窑进口", "DRY_INFEED"),
("dry_step", "干燥窑内", "DRY_STEP"),
("dry_out", "干燥窑出口", "DRY_OUTFEED"),
];
let mut out = Vec::with_capacity(lines.len() * kinds.len());
for (line_code, line_name) in lines {
for (kind, kind_name, segment_code) in kinds {
let code = match (line_code, *kind) {
("KILN_1", "dry_in") => "ST-DRY1-IN",
("KILN_1", "dry_step") => "ST-DRY1-STEP",
("KILN_1", "dry_out") => "ST-DRY1-OUT",
("KILN_2", "dry_in") => "ST-DRY2-IN",
("KILN_2", "dry_step") => "ST-DRY2-STEP",
("KILN_2", "dry_out") => "ST-DRY2-OUT",
_ => continue,
};
let name: &'static str = match (line_code, *kind) {
("KILN_1", "dry_in") => "1 号干燥窑进口位",
("KILN_1", "dry_step") => "1 号干燥窑内位",
("KILN_1", "dry_out") => "1 号干燥窑出口位",
("KILN_2", "dry_in") => "2 号干燥窑进口位",
("KILN_2", "dry_step") => "2 号干燥窑内位",
("KILN_2", "dry_out") => "2 号干燥窑出口位",
_ => continue,
};
// Description is interned via &'static str by matching above; no need to format.
let _ = (line_name, kind_name); // documentation-only context
out.push(StationTemplate {
code,
name,
line_code,
segment_code,
station_type: kind,
description: "Seeded by ensure_kiln_templates",
});
}
}
out
}
struct SegmentTemplate {
code: &'static str,
name: &'static str,
segment_type: &'static str,
line_code: &'static str,
priority: i32,
mode: &'static str,
description: &'static str,
}
fn kiln_template_segments() -> Vec<SegmentTemplate> {
let mut out = Vec::new();
for (line_code, label) in [("KILN_1", "1 号"), ("KILN_2", "2 号")] {
out.push(SegmentTemplate {
code: if line_code == "KILN_1" {
"SEG-DRY1-INFEED"
} else {
"SEG-DRY2-INFEED"
},
name: if line_code == "KILN_1" {
"1 号干燥窑进口段"
} else {
"2 号干燥窑进口段"
},
segment_type: "kiln_infeed",
line_code,
priority: 10,
mode: "disabled",
description: "Seeded skeleton; bind equipment + station signals to enable.",
});
out.push(SegmentTemplate {
code: if line_code == "KILN_1" {
"SEG-DRY1-STEP"
} else {
"SEG-DRY2-STEP"
},
name: if line_code == "KILN_1" {
"1 号干燥窑内前移段"
} else {
"2 号干燥窑内前移段"
},
segment_type: "kiln_step",
line_code,
priority: 5,
mode: "disabled",
description: "Seeded skeleton; bind equipment + station signals to enable.",
});
out.push(SegmentTemplate {
code: if line_code == "KILN_1" {
"SEG-DRY1-OUTFEED"
} else {
"SEG-DRY2-OUTFEED"
},
name: if line_code == "KILN_1" {
"1 号干燥窑出口段"
} else {
"2 号干燥窑出口段"
},
segment_type: "kiln_outfeed",
line_code,
priority: 10,
mode: "disabled",
description: "Seeded skeleton; bind equipment + station signals to enable.",
});
let _ = label;
}
out
}
#[derive(Debug, Clone)]
pub struct StepTemplate {
pub step_no: i32,
pub step_code: &'static str,
pub action_kind: &'static str,
pub confirm_signal_role: &'static str,
pub timeout_ms: i32,
pub description: &'static str,
}
/// Per-segment-type canonical step list (design doc §7.4 / §10.1).
pub fn build_step_template(segment_type: &str) -> Vec<StepTemplate> {
match segment_type {
"kiln_infeed" => vec![
StepTemplate {
step_no: 1,
step_code: "OPEN_DOOR",
action_kind: "open_door",
confirm_signal_role: "allow_in",
timeout_ms: 15_000,
description: "开门并等待门开到位",
},
StepTemplate {
step_no: 2,
step_code: "PUSH_FORWARD",
action_kind: "push_forward",
confirm_signal_role: "arrived",
timeout_ms: 30_000,
description: "顶车进窑并等待到位",
},
StepTemplate {
step_no: 3,
step_code: "PUSH_RETRACT",
action_kind: "push_retract",
confirm_signal_role: "done",
timeout_ms: 30_000,
description: "顶车后退复位",
},
StepTemplate {
step_no: 4,
step_code: "CLOSE_DOOR",
action_kind: "close_door",
confirm_signal_role: "done",
timeout_ms: 15_000,
description: "关门并等待门关到位",
},
],
"kiln_step" => vec![StepTemplate {
step_no: 1,
step_code: "STEP_ONCE",
action_kind: "step_once",
confirm_signal_role: "arrived",
timeout_ms: 30_000,
description: "步进一格并等待到位",
}],
"kiln_outfeed" => vec![
StepTemplate {
step_no: 1,
step_code: "OPEN_DOOR",
action_kind: "open_door",
confirm_signal_role: "allow_in",
timeout_ms: 15_000,
description: "开门并等待门开到位",
},
StepTemplate {
step_no: 2,
step_code: "PULL_RUN",
action_kind: "pull_run",
confirm_signal_role: "arrived",
timeout_ms: 30_000,
description: "拉引出窑并等待到位",
},
StepTemplate {
step_no: 3,
step_code: "PULL_RETRACT",
action_kind: "pull_retract",
confirm_signal_role: "done",
timeout_ms: 30_000,
description: "拉引复位",
},
StepTemplate {
step_no: 4,
step_code: "CLOSE_DOOR",
action_kind: "close_door",
confirm_signal_role: "done",
timeout_ms: 15_000,
description: "关门并等待门关到位",
},
],
_ => Vec::new(),
}
}
/// Whether the OPS_SEED_TEMPLATES env opts the deployment into automatic seeding.
pub fn enabled_via_env() -> bool {
matches!(
std::env::var("OPS_SEED_TEMPLATES").ok().as_deref(),
Some("true") | Some("1")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn infeed_template_has_four_sequential_steps() {
let steps = build_step_template("kiln_infeed");
assert_eq!(steps.len(), 4);
let nos: Vec<i32> = steps.iter().map(|s| s.step_no).collect();
assert_eq!(nos, vec![1, 2, 3, 4]);
assert_eq!(steps[0].action_kind, "open_door");
assert_eq!(steps[3].action_kind, "close_door");
}
#[test]
fn step_template_has_single_step() {
let steps = build_step_template("kiln_step");
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].action_kind, "step_once");
}
#[test]
fn outfeed_template_uses_pull_actions() {
let steps = build_step_template("kiln_outfeed");
assert!(steps.iter().any(|s| s.action_kind == "pull_run"));
assert!(steps.iter().any(|s| s.action_kind == "pull_retract"));
}
#[test]
fn unknown_segment_type_yields_empty_template() {
assert!(build_step_template("unknown").is_empty());
}
#[test]
fn station_template_covers_both_kilns() {
let stations = kiln_template_stations();
assert_eq!(stations.len(), 6);
assert!(stations.iter().any(|s| s.code == "ST-DRY1-IN"));
assert!(stations.iter().any(|s| s.code == "ST-DRY2-OUT"));
}
#[test]
fn segment_template_covers_three_per_kiln() {
let segments = kiln_template_segments();
assert_eq!(segments.len(), 6);
let kiln_1 = segments.iter().filter(|s| s.line_code == "KILN_1").count();
let kiln_2 = segments.iter().filter(|s| s.line_code == "KILN_2").count();
assert_eq!(kiln_1, 3);
assert_eq!(kiln_2, 3);
}
#[test]
fn enabled_via_env_respects_flag() {
let prev = std::env::var("OPS_SEED_TEMPLATES").ok();
std::env::remove_var("OPS_SEED_TEMPLATES");
assert!(!enabled_via_env());
std::env::set_var("OPS_SEED_TEMPLATES", "1");
assert!(enabled_via_env());
std::env::set_var("OPS_SEED_TEMPLATES", "no");
assert!(!enabled_via_env());
match prev {
Some(v) => std::env::set_var("OPS_SEED_TEMPLATES", v),
None => std::env::remove_var("OPS_SEED_TEMPLATES"),
}
}
}

View File

@ -69,3 +69,8 @@
- `point_new_value`(核心)
- `event_created`(核心)
- `app_event``{ app: "operation-system", event_type: "segment_runtime_changed", data: SegmentRuntime }`
## 环境变量
- `SIMULATE_PLC=1` — 调试模式,引擎发出命令后通过模拟器把确认信号写回监控缓存,使段流程可在无 PLC 现场时端到端运行。
- `OPS_SEED_TEMPLATES=1` — 应用启动时自动写入 1 号 / 2 号干燥窑 6 段infeed / step / outfeed × 2的段 + 步骤骨架,仅插入缺失的记录,不覆盖已有配置。设备与工位信号绑定仍需通过 CRUD API 完成。