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:
parent
aaf48a336d
commit
e3e7917078
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 完成。
|
||||
|
|
|
|||
Loading…
Reference in New Issue