diff --git a/crates/app_operation_system/src/app.rs b/crates/app_operation_system/src/app.rs index 7b94b9e..881b616 100644 --- a/crates/app_operation_system/src/app.rs +++ b/crates/app_operation_system/src/app.rs @@ -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(), diff --git a/crates/app_operation_system/src/lib.rs b/crates/app_operation_system/src/lib.rs index f5d717a..9123116 100644 --- a/crates/app_operation_system/src/lib.rs +++ b/crates/app_operation_system/src/lib.rs @@ -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}; diff --git a/crates/app_operation_system/src/seed.rs b/crates/app_operation_system/src/seed.rs new file mode 100644 index 0000000..3b1366b --- /dev/null +++ b/crates/app_operation_system/src/seed.rs @@ -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 { + 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 = + 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 { + 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 { + 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 { + 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 = 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"), + } + } +} diff --git a/docs/api-ops.md b/docs/api-ops.md index c9cb9f5..a4f1b9f 100644 --- a/docs/api-ops.md +++ b/docs/api-ops.md @@ -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 完成。