diff --git a/crates/app_operation_system/src/handler.rs b/crates/app_operation_system/src/handler.rs index dc16bd5..524b6cc 100644 --- a/crates/app_operation_system/src/handler.rs +++ b/crates/app_operation_system/src/handler.rs @@ -1 +1,3 @@ pub mod doc; +pub mod segment; +pub mod station; diff --git a/crates/app_operation_system/src/handler/segment.rs b/crates/app_operation_system/src/handler/segment.rs new file mode 100644 index 0000000..af66783 --- /dev/null +++ b/crates/app_operation_system/src/handler/segment.rs @@ -0,0 +1,595 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; +use validator::Validate; + +use plc_platform_core::util::response::ApiErr; + +use crate::{ + service::segment as segment_service, + AppState, +}; + +const SEGMENT_TYPES: &[&str] = &[ + "front_load", + "robot", + "front_release", + "front_transfer", + "kiln_infeed", + "kiln_step", + "kiln_outfeed", + "tail_transfer", + "tail_step", + "unload", + "return", +]; + +const SEGMENT_MODES: &[&str] = &["auto", "remote_manual", "local_manual", "disabled"]; + +const ACTION_KINDS: &[&str] = &[ + "open_door", + "close_door", + "push_forward", + "push_retract", + "pull_run", + "pull_retract", + "transfer_move_to", + "step_once", + "robot_permit", + "robot_release", + "wait_signal", + "pulse_cmd", +]; + +const ON_TIMEOUT_VALUES: &[&str] = &["fault", "retry", "block"]; + +const INTERLOCK_APPLIES_TO: &[&str] = &["start_allow", "start_deny", "run_halt"]; + +const RULE_KINDS: &[&str] = &[ + "point_eq", + "station_vacant", + "station_occupied", + "equipment_origin", + "equipment_no_fault", + "equipment_remote", + "safety_chain_ok", +]; + +fn validate_enum(name: &'static str, value: &str, allowed: &[&str]) -> Result<(), ApiErr> { + if allowed.contains(&value) { + Ok(()) + } else { + Err(ApiErr::BadRequest( + format!("invalid {}: {}", name, value), + Some(json!({ "allowed": allowed })), + )) + } +} + +#[derive(Debug, Deserialize, Validate)] +pub struct ListSegmentQuery { + #[validate(length(min = 1, max = 50))] + pub line_code: Option, +} + +pub async fn list_segments( + State(state): State, + Query(query): Query, +) -> Result { + query.validate()?; + let segments = + segment_service::list_segments(&state.platform.pool, query.line_code.as_deref()).await?; + Ok(Json(segments)) +} + +pub async fn get_segment( + State(state): State, + Path(segment_id): Path, +) -> Result { + let segment = segment_service::get_segment_by_id(&state.platform.pool, segment_id) + .await? + .ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?; + Ok(Json(segment)) +} + +pub async fn get_segment_detail( + State(state): State, + Path(segment_id): Path, +) -> Result { + let segment = segment_service::get_segment_by_id(&state.platform.pool, segment_id) + .await? + .ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?; + let steps = segment_service::list_steps(&state.platform.pool, segment_id).await?; + let interlocks = + segment_service::list_interlocks(&state.platform.pool, segment_id).await?; + let resources = + segment_service::list_resources(&state.platform.pool, segment_id).await?; + + Ok(Json(json!({ + "segment": segment, + "steps": steps, + "interlocks": interlocks, + "resources": resources, + }))) +} + +#[derive(Debug, Deserialize, Validate)] +pub struct CreateSegmentReq { + #[validate(length(min = 1, max = 100))] + pub code: String, + #[validate(length(min = 1, max = 100))] + pub name: String, + #[validate(length(min = 1, max = 32))] + pub segment_type: String, + #[validate(length(min = 1, max = 50))] + pub line_code: Option, + pub priority: Option, + pub enabled: Option, + #[validate(length(min = 1, max = 32))] + pub mode: Option, + pub require_manual_ack_after_fault: Option, + pub description: Option, +} + +pub async fn create_segment( + State(state): State, + Json(payload): Json, +) -> Result { + payload.validate()?; + validate_enum("segment_type", &payload.segment_type, SEGMENT_TYPES)?; + let mode = payload.mode.as_deref().unwrap_or("disabled"); + validate_enum("mode", mode, SEGMENT_MODES)?; + + if segment_service::get_segment_by_code(&state.platform.pool, &payload.code) + .await? + .is_some() + { + return Err(ApiErr::BadRequest( + "Segment code already exists".to_string(), + None, + )); + } + + let segment_id = segment_service::create_segment( + &state.platform.pool, + segment_service::CreateSegmentParams { + code: &payload.code, + name: &payload.name, + segment_type: &payload.segment_type, + line_code: payload.line_code.as_deref(), + priority: payload.priority.unwrap_or(0), + enabled: payload.enabled.unwrap_or(true), + mode, + require_manual_ack_after_fault: payload.require_manual_ack_after_fault.unwrap_or(true), + description: payload.description.as_deref(), + }, + ) + .await?; + + Ok(( + StatusCode::CREATED, + Json(json!({ "id": segment_id, "ok_msg": "Segment created" })), + )) +} + +#[derive(Debug, Deserialize, Validate)] +pub struct UpdateSegmentReq { + #[validate(length(min = 1, max = 100))] + pub code: Option, + #[validate(length(min = 1, max = 100))] + pub name: Option, + #[validate(length(min = 1, max = 32))] + pub segment_type: Option, + #[validate(length(min = 1, max = 50))] + pub line_code: Option, + pub priority: Option, + pub enabled: Option, + #[validate(length(min = 1, max = 32))] + pub mode: Option, + pub require_manual_ack_after_fault: Option, + pub description: Option, +} + +pub async fn update_segment( + State(state): State, + Path(segment_id): Path, + Json(payload): Json, +) -> Result { + payload.validate()?; + if let Some(s) = payload.segment_type.as_deref() { + validate_enum("segment_type", s, SEGMENT_TYPES)?; + } + if let Some(m) = payload.mode.as_deref() { + validate_enum("mode", m, SEGMENT_MODES)?; + } + + let existing = segment_service::get_segment_by_id(&state.platform.pool, segment_id) + .await? + .ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?; + + if let Some(code) = payload.code.as_deref() { + if let Some(other) = + segment_service::get_segment_by_code(&state.platform.pool, code).await? + { + if other.id != existing.id { + return Err(ApiErr::BadRequest( + "Segment code already exists".to_string(), + None, + )); + } + } + } + + segment_service::update_segment( + &state.platform.pool, + segment_id, + segment_service::UpdateSegmentParams { + code: payload.code.as_deref(), + name: payload.name.as_deref(), + segment_type: payload.segment_type.as_deref(), + line_code: payload.line_code.as_deref(), + priority: payload.priority, + enabled: payload.enabled, + mode: payload.mode.as_deref(), + require_manual_ack_after_fault: payload.require_manual_ack_after_fault, + description: payload.description.as_deref(), + }, + ) + .await?; + + Ok(Json(json!({ "ok_msg": "Segment updated" }))) +} + +pub async fn delete_segment( + State(state): State, + Path(segment_id): Path, +) -> Result { + let deleted = segment_service::delete_segment(&state.platform.pool, segment_id).await?; + if !deleted { + return Err(ApiErr::NotFound("Segment not found".to_string(), None)); + } + Ok(StatusCode::NO_CONTENT) +} + +// Steps + +pub async fn list_steps( + State(state): State, + Path(segment_id): Path, +) -> Result { + let steps = segment_service::list_steps(&state.platform.pool, segment_id).await?; + Ok(Json(steps)) +} + +#[derive(Debug, Deserialize, Validate)] +pub struct CreateStepReq { + #[validate(range(min = 1))] + pub step_no: i32, + #[validate(length(min = 1, max = 64))] + pub step_code: String, + #[validate(length(min = 1, max = 32))] + pub action_kind: String, + pub target_equipment_id: Option, + pub target_station_id: Option, + #[validate(length(min = 1, max = 32))] + pub confirm_signal_role: Option, + pub confirm_point_id: Option, + pub expected_value: Option, + #[validate(range(min = 1))] + pub timeout_ms: Option, + #[validate(length(min = 1, max = 32))] + pub command_role: Option, + #[validate(length(min = 1, max = 32))] + pub stop_command_role: Option, + #[validate(range(min = 1))] + pub pulse_ms: Option, + pub hold_until_confirm: Option, + pub cancel_on_fault: Option, + pub next_step_no_on_success: Option, + pub next_step_no_on_failure: Option, + #[validate(length(min = 1, max = 16))] + pub on_timeout: Option, + pub description: Option, +} + +pub async fn create_step( + State(state): State, + Path(segment_id): Path, + Json(payload): Json, +) -> Result { + payload.validate()?; + validate_enum("action_kind", &payload.action_kind, ACTION_KINDS)?; + let on_timeout = payload.on_timeout.as_deref().unwrap_or("fault"); + validate_enum("on_timeout", on_timeout, ON_TIMEOUT_VALUES)?; + + segment_service::get_segment_by_id(&state.platform.pool, segment_id) + .await? + .ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?; + + let step_id = segment_service::create_step( + &state.platform.pool, + segment_service::CreateStepParams { + segment_id, + step_no: payload.step_no, + step_code: &payload.step_code, + action_kind: &payload.action_kind, + target_equipment_id: payload.target_equipment_id, + target_station_id: payload.target_station_id, + confirm_signal_role: payload.confirm_signal_role.as_deref(), + confirm_point_id: payload.confirm_point_id, + expected_value: payload.expected_value.unwrap_or(true), + timeout_ms: payload.timeout_ms.unwrap_or(30000), + command_role: payload.command_role.as_deref(), + stop_command_role: payload.stop_command_role.as_deref(), + pulse_ms: payload.pulse_ms, + hold_until_confirm: payload.hold_until_confirm.unwrap_or(false), + cancel_on_fault: payload.cancel_on_fault.unwrap_or(true), + next_step_no_on_success: payload.next_step_no_on_success, + next_step_no_on_failure: payload.next_step_no_on_failure, + on_timeout, + description: payload.description.as_deref(), + }, + ) + .await?; + + Ok(( + StatusCode::CREATED, + Json(json!({ "id": step_id, "ok_msg": "Step created" })), + )) +} + +#[derive(Debug, Deserialize, Validate)] +pub struct UpdateStepReq { + #[validate(length(min = 1, max = 64))] + pub step_code: Option, + #[validate(length(min = 1, max = 32))] + pub action_kind: Option, + pub target_equipment_id: Option, + pub target_station_id: Option, + #[validate(length(min = 1, max = 32))] + pub confirm_signal_role: Option, + pub confirm_point_id: Option, + pub expected_value: Option, + #[validate(range(min = 1))] + pub timeout_ms: Option, + #[validate(length(min = 1, max = 32))] + pub command_role: Option, + #[validate(length(min = 1, max = 32))] + pub stop_command_role: Option, + #[validate(range(min = 1))] + pub pulse_ms: Option, + pub hold_until_confirm: Option, + pub cancel_on_fault: Option, + pub next_step_no_on_success: Option, + pub next_step_no_on_failure: Option, + #[validate(length(min = 1, max = 16))] + pub on_timeout: Option, + pub description: Option, +} + +pub async fn update_step( + State(state): State, + Path((segment_id, step_no)): Path<(Uuid, i32)>, + Json(payload): Json, +) -> Result { + payload.validate()?; + if let Some(a) = payload.action_kind.as_deref() { + validate_enum("action_kind", a, ACTION_KINDS)?; + } + if let Some(t) = payload.on_timeout.as_deref() { + validate_enum("on_timeout", t, ON_TIMEOUT_VALUES)?; + } + + segment_service::get_step(&state.platform.pool, segment_id, step_no) + .await? + .ok_or_else(|| ApiErr::NotFound("Step not found".to_string(), None))?; + + segment_service::update_step( + &state.platform.pool, + segment_id, + step_no, + segment_service::UpdateStepParams { + step_code: payload.step_code.as_deref(), + action_kind: payload.action_kind.as_deref(), + target_equipment_id: payload.target_equipment_id, + target_station_id: payload.target_station_id, + confirm_signal_role: payload.confirm_signal_role.as_deref(), + confirm_point_id: payload.confirm_point_id, + expected_value: payload.expected_value, + timeout_ms: payload.timeout_ms, + command_role: payload.command_role.as_deref(), + stop_command_role: payload.stop_command_role.as_deref(), + pulse_ms: payload.pulse_ms, + hold_until_confirm: payload.hold_until_confirm, + cancel_on_fault: payload.cancel_on_fault, + next_step_no_on_success: payload.next_step_no_on_success, + next_step_no_on_failure: payload.next_step_no_on_failure, + on_timeout: payload.on_timeout.as_deref(), + description: payload.description.as_deref(), + }, + ) + .await?; + + Ok(Json(json!({ "ok_msg": "Step updated" }))) +} + +pub async fn delete_step( + State(state): State, + Path((segment_id, step_no)): Path<(Uuid, i32)>, +) -> Result { + let deleted = segment_service::delete_step(&state.platform.pool, segment_id, step_no).await?; + if !deleted { + return Err(ApiErr::NotFound("Step not found".to_string(), None)); + } + Ok(StatusCode::NO_CONTENT) +} + +// Interlocks + +pub async fn list_interlocks( + State(state): State, + Path(segment_id): Path, +) -> Result { + let interlocks = + segment_service::list_interlocks(&state.platform.pool, segment_id).await?; + Ok(Json(interlocks)) +} + +#[derive(Debug, Deserialize, Validate)] +pub struct CreateInterlockReq { + #[validate(length(min = 1, max = 32))] + pub applies_to: String, + #[validate(length(min = 1, max = 32))] + pub rule_kind: String, + pub point_id: Option, + pub station_id: Option, + pub equipment_id: Option, + pub expected_value: Option, + pub description: Option, +} + +pub async fn create_interlock( + State(state): State, + Path(segment_id): Path, + Json(payload): Json, +) -> Result { + payload.validate()?; + validate_enum("applies_to", &payload.applies_to, INTERLOCK_APPLIES_TO)?; + validate_enum("rule_kind", &payload.rule_kind, RULE_KINDS)?; + + segment_service::get_segment_by_id(&state.platform.pool, segment_id) + .await? + .ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?; + + let interlock_id = segment_service::create_interlock( + &state.platform.pool, + segment_service::CreateInterlockParams { + segment_id, + applies_to: &payload.applies_to, + rule_kind: &payload.rule_kind, + point_id: payload.point_id, + station_id: payload.station_id, + equipment_id: payload.equipment_id, + expected_value: payload.expected_value, + description: payload.description.as_deref(), + }, + ) + .await?; + + Ok(( + StatusCode::CREATED, + Json(json!({ "id": interlock_id, "ok_msg": "Interlock created" })), + )) +} + +pub async fn delete_interlock( + State(state): State, + Path((segment_id, interlock_id)): Path<(Uuid, Uuid)>, +) -> Result { + let deleted = + segment_service::delete_interlock(&state.platform.pool, segment_id, interlock_id).await?; + if !deleted { + return Err(ApiErr::NotFound("Interlock not found".to_string(), None)); + } + Ok(StatusCode::NO_CONTENT) +} + +// Resources (declared keys for a segment) + +pub async fn list_resources( + State(state): State, + Path(segment_id): Path, +) -> Result { + let resources = + segment_service::list_resources(&state.platform.pool, segment_id).await?; + Ok(Json(resources)) +} + +#[derive(Debug, Deserialize, Validate)] +pub struct ReplaceResourcesReq { + pub resource_keys: Vec, +} + +pub async fn replace_resources( + State(state): State, + Path(segment_id): Path, + Json(payload): Json, +) -> Result { + for key in &payload.resource_keys { + if key.is_empty() || key.len() > 64 { + return Err(ApiErr::BadRequest( + "resource_key must be 1..=64 chars".to_string(), + Some(json!({ "key": key })), + )); + } + } + + segment_service::get_segment_by_id(&state.platform.pool, segment_id) + .await? + .ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?; + + segment_service::replace_resources(&state.platform.pool, segment_id, &payload.resource_keys) + .await?; + + Ok(Json( + json!({ "ok_msg": "Resources replaced", "count": payload.resource_keys.len() }), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_enum_rejects_unknown() { + assert!(validate_enum("mode", "weird", SEGMENT_MODES).is_err()); + assert!(validate_enum("mode", "auto", SEGMENT_MODES).is_ok()); + } + + #[test] + fn create_segment_req_rejects_blank_code() { + let payload = CreateSegmentReq { + code: "".to_string(), + name: "Dry-1 infeed".to_string(), + segment_type: "kiln_infeed".to_string(), + line_code: None, + priority: None, + enabled: None, + mode: None, + require_manual_ack_after_fault: None, + description: None, + }; + assert!(payload.validate().is_err()); + } + + #[test] + fn create_step_req_rejects_zero_step_no() { + let payload = CreateStepReq { + step_no: 0, + step_code: "S1".to_string(), + action_kind: "open_door".to_string(), + target_equipment_id: None, + target_station_id: None, + confirm_signal_role: None, + confirm_point_id: None, + expected_value: None, + timeout_ms: None, + command_role: None, + stop_command_role: None, + pulse_ms: None, + hold_until_confirm: None, + cancel_on_fault: None, + next_step_no_on_success: None, + next_step_no_on_failure: None, + on_timeout: None, + description: None, + }; + assert!(payload.validate().is_err()); + } +} diff --git a/crates/app_operation_system/src/handler/station.rs b/crates/app_operation_system/src/handler/station.rs new file mode 100644 index 0000000..bdf201a --- /dev/null +++ b/crates/app_operation_system/src/handler/station.rs @@ -0,0 +1,317 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; +use validator::Validate; + +use plc_platform_core::util::response::ApiErr; + +use crate::{service::station as station_service, AppState}; + +const STATION_TYPES: &[&str] = &[ + "load", + "dry_in", + "dry_step", + "dry_out", + "fire_in", + "fire_step", + "fire_out", + "transfer", + "unload", + "return", +]; + +const SIGNAL_ROLES: &[&str] = &[ + "presence", + "vacancy", + "arrived", + "allow_in", + "done", + "fault", +]; + +fn validate_station_type(value: &str) -> Result<(), ApiErr> { + if STATION_TYPES.contains(&value) { + Ok(()) + } else { + Err(ApiErr::BadRequest( + format!("invalid station_type: {}", value), + Some(json!({ "allowed": STATION_TYPES })), + )) + } +} + +fn validate_signal_role(value: &str) -> Result<(), ApiErr> { + if SIGNAL_ROLES.contains(&value) { + Ok(()) + } else { + Err(ApiErr::BadRequest( + format!("invalid signal_role: {}", value), + Some(json!({ "allowed": SIGNAL_ROLES })), + )) + } +} + +#[derive(Debug, Deserialize, Validate)] +pub struct ListStationQuery { + #[validate(length(min = 1, max = 50))] + pub line_code: Option, +} + +pub async fn list_stations( + State(state): State, + Query(query): Query, +) -> Result { + query.validate()?; + let stations = + station_service::list_stations(&state.platform.pool, query.line_code.as_deref()).await?; + Ok(Json(stations)) +} + +pub async fn get_station( + State(state): State, + Path(station_id): Path, +) -> Result { + let station = station_service::get_station_by_id(&state.platform.pool, station_id) + .await? + .ok_or_else(|| ApiErr::NotFound("Station not found".to_string(), None))?; + let signals = + station_service::list_station_signals(&state.platform.pool, station_id).await?; + Ok(Json(json!({ + "station": station, + "signals": signals, + }))) +} + +#[derive(Debug, Deserialize, Validate)] +pub struct CreateStationReq { + #[validate(length(min = 1, max = 100))] + pub code: String, + #[validate(length(min = 1, max = 100))] + pub name: String, + #[validate(length(min = 1, max = 50))] + pub line_code: Option, + #[validate(length(min = 1, max = 50))] + pub segment_code: Option, + #[validate(length(min = 1, max = 32))] + pub station_type: String, + pub enabled: Option, + pub description: Option, +} + +pub async fn create_station( + State(state): State, + Json(payload): Json, +) -> Result { + payload.validate()?; + validate_station_type(&payload.station_type)?; + + if station_service::get_station_by_code(&state.platform.pool, &payload.code) + .await? + .is_some() + { + return Err(ApiErr::BadRequest( + "Station code already exists".to_string(), + None, + )); + } + + let station_id = station_service::create_station( + &state.platform.pool, + station_service::CreateStationParams { + code: &payload.code, + name: &payload.name, + line_code: payload.line_code.as_deref(), + segment_code: payload.segment_code.as_deref(), + station_type: &payload.station_type, + enabled: payload.enabled.unwrap_or(true), + description: payload.description.as_deref(), + }, + ) + .await?; + + Ok(( + StatusCode::CREATED, + Json(json!({ "id": station_id, "ok_msg": "Station created" })), + )) +} + +#[derive(Debug, Deserialize, Validate)] +pub struct UpdateStationReq { + #[validate(length(min = 1, max = 100))] + pub code: Option, + #[validate(length(min = 1, max = 100))] + pub name: Option, + #[validate(length(min = 1, max = 50))] + pub line_code: Option, + #[validate(length(min = 1, max = 50))] + pub segment_code: Option, + #[validate(length(min = 1, max = 32))] + pub station_type: Option, + pub enabled: Option, + pub description: Option, +} + +pub async fn update_station( + State(state): State, + Path(station_id): Path, + Json(payload): Json, +) -> Result { + payload.validate()?; + if let Some(t) = payload.station_type.as_deref() { + validate_station_type(t)?; + } + + let existing = station_service::get_station_by_id(&state.platform.pool, station_id) + .await? + .ok_or_else(|| ApiErr::NotFound("Station not found".to_string(), None))?; + + if let Some(code) = payload.code.as_deref() { + if let Some(other) = + station_service::get_station_by_code(&state.platform.pool, code).await? + { + if other.id != existing.id { + return Err(ApiErr::BadRequest( + "Station code already exists".to_string(), + None, + )); + } + } + } + + station_service::update_station( + &state.platform.pool, + station_id, + station_service::UpdateStationParams { + code: payload.code.as_deref(), + name: payload.name.as_deref(), + line_code: payload.line_code.as_deref(), + segment_code: payload.segment_code.as_deref(), + station_type: payload.station_type.as_deref(), + enabled: payload.enabled, + description: payload.description.as_deref(), + }, + ) + .await?; + + Ok(Json(json!({ "ok_msg": "Station updated" }))) +} + +pub async fn delete_station( + State(state): State, + Path(station_id): Path, +) -> Result { + let deleted = station_service::delete_station(&state.platform.pool, station_id).await?; + if !deleted { + return Err(ApiErr::NotFound("Station not found".to_string(), None)); + } + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize, Validate)] +pub struct UpsertStationSignalReq { + #[validate(length(min = 1, max = 32))] + pub signal_role: String, + pub point_id: Option, + #[validate(length(min = 1, max = 32))] + pub derived_from_role: Option, + pub invert_value: Option, +} + +pub async fn upsert_station_signal( + State(state): State, + Path(station_id): Path, + Json(payload): Json, +) -> Result { + payload.validate()?; + validate_signal_role(&payload.signal_role)?; + if let Some(role) = payload.derived_from_role.as_deref() { + validate_signal_role(role)?; + } + if payload.point_id.is_none() && payload.derived_from_role.is_none() { + return Err(ApiErr::BadRequest( + "either point_id or derived_from_role must be provided".to_string(), + None, + )); + } + + station_service::get_station_by_id(&state.platform.pool, station_id) + .await? + .ok_or_else(|| ApiErr::NotFound("Station not found".to_string(), None))?; + + let signal = station_service::upsert_station_signal( + &state.platform.pool, + station_id, + station_service::UpsertStationSignalParams { + signal_role: payload.signal_role, + point_id: payload.point_id, + derived_from_role: payload.derived_from_role, + invert_value: payload.invert_value.unwrap_or(false), + }, + ) + .await?; + + Ok(Json(signal)) +} + +pub async fn delete_station_signal( + State(state): State, + Path((station_id, role)): Path<(Uuid, String)>, +) -> Result { + validate_signal_role(&role)?; + let deleted = + station_service::delete_station_signal(&state.platform.pool, station_id, &role).await?; + if !deleted { + return Err(ApiErr::NotFound( + "Station signal binding not found".to_string(), + None, + )); + } + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::StationSignalRole; + + #[test] + fn create_station_req_rejects_blank_code() { + let payload = CreateStationReq { + code: "".to_string(), + name: "Dry-1 In".to_string(), + line_code: None, + segment_code: None, + station_type: "dry_in".to_string(), + enabled: None, + description: None, + }; + assert!(payload.validate().is_err()); + } + + #[test] + fn validate_station_type_rejects_unknown() { + assert!(validate_station_type("nope").is_err()); + assert!(validate_station_type("dry_in").is_ok()); + } + + #[test] + fn station_signal_role_enum_covers_handler_allowlist() { + let known = [ + StationSignalRole::Presence, + StationSignalRole::Vacancy, + StationSignalRole::Arrived, + StationSignalRole::AllowIn, + StationSignalRole::Done, + StationSignalRole::Fault, + ]; + for role in known { + assert!(SIGNAL_ROLES.contains(&role.as_str())); + } + } +} diff --git a/crates/app_operation_system/src/lib.rs b/crates/app_operation_system/src/lib.rs index f54e2f1..f5d717a 100644 --- a/crates/app_operation_system/src/lib.rs +++ b/crates/app_operation_system/src/lib.rs @@ -2,7 +2,9 @@ pub mod app; pub mod control; pub mod event; pub mod handler; +pub mod model; pub mod router; +pub mod service; pub use app::{run, test_state, AppState}; pub use router::build_router; diff --git a/crates/app_operation_system/src/model.rs b/crates/app_operation_system/src/model.rs new file mode 100644 index 0000000..21b715d --- /dev/null +++ b/crates/app_operation_system/src/model.rs @@ -0,0 +1,292 @@ +use chrono::{DateTime, Utc}; +use plc_platform_core::util::datetime::utc_to_local_str; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +// Station (design doc §4.2.1) + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Station { + pub id: Uuid, + pub code: String, + pub name: String, + pub line_code: Option, + pub segment_code: Option, + pub station_type: String, + pub enabled: bool, + pub description: Option, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StationType { + Load, + DryIn, + DryStep, + DryOut, + FireIn, + FireStep, + FireOut, + Transfer, + Unload, + Return, +} + +impl StationType { + pub fn as_str(&self) -> &'static str { + match self { + StationType::Load => "load", + StationType::DryIn => "dry_in", + StationType::DryStep => "dry_step", + StationType::DryOut => "dry_out", + StationType::FireIn => "fire_in", + StationType::FireStep => "fire_step", + StationType::FireOut => "fire_out", + StationType::Transfer => "transfer", + StationType::Unload => "unload", + StationType::Return => "return", + } + } +} + +// StationSignal (design doc §4.2.2) + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct StationSignal { + pub id: Uuid, + pub station_id: Uuid, + pub signal_role: String, + pub point_id: Option, + pub derived_from_role: Option, + pub invert_value: bool, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StationSignalRole { + Presence, + Vacancy, + Arrived, + AllowIn, + Done, + Fault, +} + +impl StationSignalRole { + pub fn as_str(&self) -> &'static str { + match self { + StationSignalRole::Presence => "presence", + StationSignalRole::Vacancy => "vacancy", + StationSignalRole::Arrived => "arrived", + StationSignalRole::AllowIn => "allow_in", + StationSignalRole::Done => "done", + StationSignalRole::Fault => "fault", + } + } +} + +// ProcessSegment (design doc §4.2.3) + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct ProcessSegment { + pub id: Uuid, + pub code: String, + pub name: String, + pub segment_type: String, + pub line_code: Option, + pub priority: i32, + pub enabled: bool, + pub mode: String, + pub require_manual_ack_after_fault: bool, + pub description: Option, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SegmentMode { + Auto, + RemoteManual, + LocalManual, + Disabled, +} + +impl SegmentMode { + pub fn as_str(&self) -> &'static str { + match self { + SegmentMode::Auto => "auto", + SegmentMode::RemoteManual => "remote_manual", + SegmentMode::LocalManual => "local_manual", + SegmentMode::Disabled => "disabled", + } + } +} + +// SegmentStep (design doc §4.2.4) + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct SegmentStep { + pub id: Uuid, + pub segment_id: Uuid, + pub step_no: i32, + pub step_code: String, + pub action_kind: String, + pub target_equipment_id: Option, + pub target_station_id: Option, + pub confirm_signal_role: Option, + pub confirm_point_id: Option, + pub expected_value: bool, + pub timeout_ms: i32, + pub command_role: Option, + pub stop_command_role: Option, + pub pulse_ms: Option, + pub hold_until_confirm: bool, + pub cancel_on_fault: bool, + pub next_step_no_on_success: Option, + pub next_step_no_on_failure: Option, + pub on_timeout: String, + pub description: Option, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ActionKind { + OpenDoor, + CloseDoor, + PushForward, + PushRetract, + PullRun, + PullRetract, + TransferMoveTo, + StepOnce, + RobotPermit, + RobotRelease, + WaitSignal, + PulseCmd, +} + +impl ActionKind { + pub fn as_str(&self) -> &'static str { + match self { + ActionKind::OpenDoor => "open_door", + ActionKind::CloseDoor => "close_door", + ActionKind::PushForward => "push_forward", + ActionKind::PushRetract => "push_retract", + ActionKind::PullRun => "pull_run", + ActionKind::PullRetract => "pull_retract", + ActionKind::TransferMoveTo => "transfer_move_to", + ActionKind::StepOnce => "step_once", + ActionKind::RobotPermit => "robot_permit", + ActionKind::RobotRelease => "robot_release", + ActionKind::WaitSignal => "wait_signal", + ActionKind::PulseCmd => "pulse_cmd", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OnTimeout { + Fault, + Retry, + Block, +} + +impl OnTimeout { + pub fn as_str(&self) -> &'static str { + match self { + OnTimeout::Fault => "fault", + OnTimeout::Retry => "retry", + OnTimeout::Block => "block", + } + } +} + +// SegmentInterlock (design doc §4.2.5) + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct SegmentInterlock { + pub id: Uuid, + pub segment_id: Uuid, + pub applies_to: String, + pub rule_kind: String, + pub point_id: Option, + pub station_id: Option, + pub equipment_id: Option, + pub expected_value: Option, + pub description: Option, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum InterlockAppliesTo { + StartAllow, + StartDeny, + RunHalt, +} + +impl InterlockAppliesTo { + pub fn as_str(&self) -> &'static str { + match self { + InterlockAppliesTo::StartAllow => "start_allow", + InterlockAppliesTo::StartDeny => "start_deny", + InterlockAppliesTo::RunHalt => "run_halt", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuleKind { + PointEq, + StationVacant, + StationOccupied, + EquipmentOrigin, + EquipmentNoFault, + EquipmentRemote, + SafetyChainOk, +} + +impl RuleKind { + pub fn as_str(&self) -> &'static str { + match self { + RuleKind::PointEq => "point_eq", + RuleKind::StationVacant => "station_vacant", + RuleKind::StationOccupied => "station_occupied", + RuleKind::EquipmentOrigin => "equipment_origin", + RuleKind::EquipmentNoFault => "equipment_no_fault", + RuleKind::EquipmentRemote => "equipment_remote", + RuleKind::SafetyChainOk => "safety_chain_ok", + } + } +} + +// SegmentResource (design doc §4.2.7) + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct SegmentResource { + pub segment_id: Uuid, + pub resource_key: String, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, +} diff --git a/crates/app_operation_system/src/router.rs b/crates/app_operation_system/src/router.rs index 28ed1f9..28fd2d6 100644 --- a/crates/app_operation_system/src/router.rs +++ b/crates/app_operation_system/src/router.rs @@ -1,4 +1,8 @@ -use axum::{extract::State, routing::get, Router}; +use axum::{ + extract::State, + routing::{get, post, put}, + Router, +}; use crate::app::AppState; @@ -6,7 +10,67 @@ pub fn build_router(state: AppState) -> Router { // Platform routes (source, point, equipment, tag, page, logs) from core. let platform = plc_platform_core::handler::platform_routes::(); - // Ops-specific routes. + // Ops configuration routes (design doc §9.1). + let config_routes = Router::new() + .route( + "/api/station", + get(crate::handler::station::list_stations) + .post(crate::handler::station::create_station), + ) + .route( + "/api/station/{station_id}", + get(crate::handler::station::get_station) + .put(crate::handler::station::update_station) + .delete(crate::handler::station::delete_station), + ) + .route( + "/api/station/{station_id}/signal", + post(crate::handler::station::upsert_station_signal), + ) + .route( + "/api/station/{station_id}/signal/{role}", + axum::routing::delete(crate::handler::station::delete_station_signal), + ) + .route( + "/api/segment", + get(crate::handler::segment::list_segments) + .post(crate::handler::segment::create_segment), + ) + .route( + "/api/segment/{segment_id}", + get(crate::handler::segment::get_segment) + .put(crate::handler::segment::update_segment) + .delete(crate::handler::segment::delete_segment), + ) + .route( + "/api/segment/{segment_id}/detail", + get(crate::handler::segment::get_segment_detail), + ) + .route( + "/api/segment/{segment_id}/step", + get(crate::handler::segment::list_steps) + .post(crate::handler::segment::create_step), + ) + .route( + "/api/segment/{segment_id}/step/{step_no}", + put(crate::handler::segment::update_step) + .delete(crate::handler::segment::delete_step), + ) + .route( + "/api/segment/{segment_id}/interlock", + get(crate::handler::segment::list_interlocks) + .post(crate::handler::segment::create_interlock), + ) + .route( + "/api/segment/{segment_id}/interlock/{interlock_id}", + axum::routing::delete(crate::handler::segment::delete_interlock), + ) + .route( + "/api/segment/{segment_id}/resource", + get(crate::handler::segment::list_resources) + .put(crate::handler::segment::replace_resources), + ); + let ops_routes = Router::new() .route("/api/health", get(health_check)) .route("/api/docs/api-md", get(crate::handler::doc::get_api_md)) @@ -17,6 +81,7 @@ pub fn build_router(state: AppState) -> Router { Router::new() .merge(platform) + .merge(config_routes) .merge(ops_routes) .nest( "/ui", diff --git a/crates/app_operation_system/src/service.rs b/crates/app_operation_system/src/service.rs new file mode 100644 index 0000000..c53bf6a --- /dev/null +++ b/crates/app_operation_system/src/service.rs @@ -0,0 +1,2 @@ +pub mod segment; +pub mod station; diff --git a/crates/app_operation_system/src/service/segment.rs b/crates/app_operation_system/src/service/segment.rs new file mode 100644 index 0000000..b83d605 --- /dev/null +++ b/crates/app_operation_system/src/service/segment.rs @@ -0,0 +1,443 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::model::{ProcessSegment, SegmentInterlock, SegmentResource, SegmentStep}; + +// process_segment + +pub async fn list_segments( + pool: &PgPool, + line_code: Option<&str>, +) -> Result, sqlx::Error> { + match line_code { + Some(line) => sqlx::query_as::<_, ProcessSegment>( + r#"SELECT * FROM process_segment WHERE line_code = $1 ORDER BY priority DESC, code"#, + ) + .bind(line) + .fetch_all(pool) + .await, + None => sqlx::query_as::<_, ProcessSegment>( + r#"SELECT * FROM process_segment ORDER BY priority DESC, code"#, + ) + .fetch_all(pool) + .await, + } +} + +pub async fn get_segment_by_id( + pool: &PgPool, + segment_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ProcessSegment>(r#"SELECT * FROM process_segment WHERE id = $1"#) + .bind(segment_id) + .fetch_optional(pool) + .await +} + +pub async fn get_segment_by_code( + pool: &PgPool, + code: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ProcessSegment>(r#"SELECT * FROM process_segment WHERE code = $1"#) + .bind(code) + .fetch_optional(pool) + .await +} + +pub struct CreateSegmentParams<'a> { + pub code: &'a str, + pub name: &'a str, + pub segment_type: &'a str, + pub line_code: Option<&'a str>, + pub priority: i32, + pub enabled: bool, + pub mode: &'a str, + pub require_manual_ack_after_fault: bool, + pub description: Option<&'a str>, +} + +pub async fn create_segment( + pool: &PgPool, + params: CreateSegmentParams<'_>, +) -> Result { + let segment_id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO process_segment ( + id, code, name, segment_type, line_code, priority, + enabled, mode, require_manual_ack_after_fault, description + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "#, + ) + .bind(segment_id) + .bind(params.code) + .bind(params.name) + .bind(params.segment_type) + .bind(params.line_code) + .bind(params.priority) + .bind(params.enabled) + .bind(params.mode) + .bind(params.require_manual_ack_after_fault) + .bind(params.description) + .execute(pool) + .await?; + Ok(segment_id) +} + +pub struct UpdateSegmentParams<'a> { + pub code: Option<&'a str>, + pub name: Option<&'a str>, + pub segment_type: Option<&'a str>, + pub line_code: Option<&'a str>, + pub priority: Option, + pub enabled: Option, + pub mode: Option<&'a str>, + pub require_manual_ack_after_fault: Option, + pub description: Option<&'a str>, +} + +pub async fn update_segment( + pool: &PgPool, + segment_id: Uuid, + params: UpdateSegmentParams<'_>, +) -> Result { + let result = sqlx::query( + r#" + UPDATE process_segment SET + code = COALESCE($2, code), + name = COALESCE($3, name), + segment_type = COALESCE($4, segment_type), + line_code = COALESCE($5, line_code), + priority = COALESCE($6, priority), + enabled = COALESCE($7, enabled), + mode = COALESCE($8, mode), + require_manual_ack_after_fault = COALESCE($9, require_manual_ack_after_fault), + description = COALESCE($10, description), + updated_at = NOW() + WHERE id = $1 + "#, + ) + .bind(segment_id) + .bind(params.code) + .bind(params.name) + .bind(params.segment_type) + .bind(params.line_code) + .bind(params.priority) + .bind(params.enabled) + .bind(params.mode) + .bind(params.require_manual_ack_after_fault) + .bind(params.description) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn delete_segment(pool: &PgPool, segment_id: Uuid) -> Result { + let result = sqlx::query(r#"DELETE FROM process_segment WHERE id = $1"#) + .bind(segment_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +// segment_step + +pub async fn list_steps( + pool: &PgPool, + segment_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, SegmentStep>( + r#"SELECT * FROM segment_step WHERE segment_id = $1 ORDER BY step_no"#, + ) + .bind(segment_id) + .fetch_all(pool) + .await +} + +pub async fn get_step( + pool: &PgPool, + segment_id: Uuid, + step_no: i32, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, SegmentStep>( + r#"SELECT * FROM segment_step WHERE segment_id = $1 AND step_no = $2"#, + ) + .bind(segment_id) + .bind(step_no) + .fetch_optional(pool) + .await +} + +pub struct CreateStepParams<'a> { + pub segment_id: Uuid, + pub step_no: i32, + pub step_code: &'a str, + pub action_kind: &'a str, + pub target_equipment_id: Option, + pub target_station_id: Option, + pub confirm_signal_role: Option<&'a str>, + pub confirm_point_id: Option, + pub expected_value: bool, + pub timeout_ms: i32, + pub command_role: Option<&'a str>, + pub stop_command_role: Option<&'a str>, + pub pulse_ms: Option, + pub hold_until_confirm: bool, + pub cancel_on_fault: bool, + pub next_step_no_on_success: Option, + pub next_step_no_on_failure: Option, + pub on_timeout: &'a str, + pub description: Option<&'a str>, +} + +pub async fn create_step(pool: &PgPool, params: CreateStepParams<'_>) -> Result { + let step_id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO segment_step ( + id, segment_id, step_no, step_code, action_kind, + target_equipment_id, target_station_id, + confirm_signal_role, confirm_point_id, expected_value, + timeout_ms, command_role, stop_command_role, pulse_ms, + hold_until_confirm, cancel_on_fault, + next_step_no_on_success, next_step_no_on_failure, + on_timeout, description + ) + VALUES ( + $1, $2, $3, $4, $5, + $6, $7, + $8, $9, $10, + $11, $12, $13, $14, + $15, $16, + $17, $18, + $19, $20 + ) + "#, + ) + .bind(step_id) + .bind(params.segment_id) + .bind(params.step_no) + .bind(params.step_code) + .bind(params.action_kind) + .bind(params.target_equipment_id) + .bind(params.target_station_id) + .bind(params.confirm_signal_role) + .bind(params.confirm_point_id) + .bind(params.expected_value) + .bind(params.timeout_ms) + .bind(params.command_role) + .bind(params.stop_command_role) + .bind(params.pulse_ms) + .bind(params.hold_until_confirm) + .bind(params.cancel_on_fault) + .bind(params.next_step_no_on_success) + .bind(params.next_step_no_on_failure) + .bind(params.on_timeout) + .bind(params.description) + .execute(pool) + .await?; + Ok(step_id) +} + +pub struct UpdateStepParams<'a> { + pub step_code: Option<&'a str>, + pub action_kind: Option<&'a str>, + pub target_equipment_id: Option, + pub target_station_id: Option, + pub confirm_signal_role: Option<&'a str>, + pub confirm_point_id: Option, + pub expected_value: Option, + pub timeout_ms: Option, + pub command_role: Option<&'a str>, + pub stop_command_role: Option<&'a str>, + pub pulse_ms: Option, + pub hold_until_confirm: Option, + pub cancel_on_fault: Option, + pub next_step_no_on_success: Option, + pub next_step_no_on_failure: Option, + pub on_timeout: Option<&'a str>, + pub description: Option<&'a str>, +} + +pub async fn update_step( + pool: &PgPool, + segment_id: Uuid, + step_no: i32, + params: UpdateStepParams<'_>, +) -> Result { + let result = sqlx::query( + r#" + UPDATE segment_step SET + step_code = COALESCE($3, step_code), + action_kind = COALESCE($4, action_kind), + target_equipment_id = COALESCE($5, target_equipment_id), + target_station_id = COALESCE($6, target_station_id), + confirm_signal_role = COALESCE($7, confirm_signal_role), + confirm_point_id = COALESCE($8, confirm_point_id), + expected_value = COALESCE($9, expected_value), + timeout_ms = COALESCE($10, timeout_ms), + command_role = COALESCE($11, command_role), + stop_command_role = COALESCE($12, stop_command_role), + pulse_ms = COALESCE($13, pulse_ms), + hold_until_confirm = COALESCE($14, hold_until_confirm), + cancel_on_fault = COALESCE($15, cancel_on_fault), + next_step_no_on_success = COALESCE($16, next_step_no_on_success), + next_step_no_on_failure = COALESCE($17, next_step_no_on_failure), + on_timeout = COALESCE($18, on_timeout), + description = COALESCE($19, description), + updated_at = NOW() + WHERE segment_id = $1 AND step_no = $2 + "#, + ) + .bind(segment_id) + .bind(step_no) + .bind(params.step_code) + .bind(params.action_kind) + .bind(params.target_equipment_id) + .bind(params.target_station_id) + .bind(params.confirm_signal_role) + .bind(params.confirm_point_id) + .bind(params.expected_value) + .bind(params.timeout_ms) + .bind(params.command_role) + .bind(params.stop_command_role) + .bind(params.pulse_ms) + .bind(params.hold_until_confirm) + .bind(params.cancel_on_fault) + .bind(params.next_step_no_on_success) + .bind(params.next_step_no_on_failure) + .bind(params.on_timeout) + .bind(params.description) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn delete_step( + pool: &PgPool, + segment_id: Uuid, + step_no: i32, +) -> Result { + let result = + sqlx::query(r#"DELETE FROM segment_step WHERE segment_id = $1 AND step_no = $2"#) + .bind(segment_id) + .bind(step_no) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +// segment_interlock + +pub async fn list_interlocks( + pool: &PgPool, + segment_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, SegmentInterlock>( + r#"SELECT * FROM segment_interlock WHERE segment_id = $1 ORDER BY applies_to, id"#, + ) + .bind(segment_id) + .fetch_all(pool) + .await +} + +pub struct CreateInterlockParams<'a> { + pub segment_id: Uuid, + pub applies_to: &'a str, + pub rule_kind: &'a str, + pub point_id: Option, + pub station_id: Option, + pub equipment_id: Option, + pub expected_value: Option, + pub description: Option<&'a str>, +} + +pub async fn create_interlock( + pool: &PgPool, + params: CreateInterlockParams<'_>, +) -> Result { + let interlock_id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO segment_interlock ( + id, segment_id, applies_to, rule_kind, + point_id, station_id, equipment_id, + expected_value, description + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + ) + .bind(interlock_id) + .bind(params.segment_id) + .bind(params.applies_to) + .bind(params.rule_kind) + .bind(params.point_id) + .bind(params.station_id) + .bind(params.equipment_id) + .bind(params.expected_value) + .bind(params.description) + .execute(pool) + .await?; + Ok(interlock_id) +} + +pub async fn delete_interlock( + pool: &PgPool, + segment_id: Uuid, + interlock_id: Uuid, +) -> Result { + let result = sqlx::query( + r#"DELETE FROM segment_interlock WHERE segment_id = $1 AND id = $2"#, + ) + .bind(segment_id) + .bind(interlock_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +// segment_resource + +pub async fn list_resources( + pool: &PgPool, + segment_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, SegmentResource>( + r#"SELECT * FROM segment_resource WHERE segment_id = $1 ORDER BY resource_key"#, + ) + .bind(segment_id) + .fetch_all(pool) + .await +} + +/// Replace the entire resource set for a segment. +/// Empty `keys` clears all resources for the segment. +pub async fn replace_resources( + pool: &PgPool, + segment_id: Uuid, + keys: &[String], +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + sqlx::query(r#"DELETE FROM segment_resource WHERE segment_id = $1"#) + .bind(segment_id) + .execute(&mut *tx) + .await?; + + for key in keys { + sqlx::query( + r#" + INSERT INTO segment_resource (segment_id, resource_key) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + "#, + ) + .bind(segment_id) + .bind(key) + .execute(&mut *tx) + .await?; + } + + tx.commit().await +} diff --git a/crates/app_operation_system/src/service/station.rs b/crates/app_operation_system/src/service/station.rs new file mode 100644 index 0000000..5a37ead --- /dev/null +++ b/crates/app_operation_system/src/service/station.rs @@ -0,0 +1,191 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::model::{Station, StationSignal}; + +pub async fn list_stations( + pool: &PgPool, + line_code: Option<&str>, +) -> Result, sqlx::Error> { + match line_code { + Some(line) => sqlx::query_as::<_, Station>( + r#"SELECT * FROM station WHERE line_code = $1 ORDER BY code"#, + ) + .bind(line) + .fetch_all(pool) + .await, + None => sqlx::query_as::<_, Station>(r#"SELECT * FROM station ORDER BY code"#) + .fetch_all(pool) + .await, + } +} + +pub async fn get_station_by_id( + pool: &PgPool, + station_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Station>(r#"SELECT * FROM station WHERE id = $1"#) + .bind(station_id) + .fetch_optional(pool) + .await +} + +pub async fn get_station_by_code( + pool: &PgPool, + code: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Station>(r#"SELECT * FROM station WHERE code = $1"#) + .bind(code) + .fetch_optional(pool) + .await +} + +pub struct CreateStationParams<'a> { + pub code: &'a str, + pub name: &'a str, + pub line_code: Option<&'a str>, + pub segment_code: Option<&'a str>, + pub station_type: &'a str, + pub enabled: bool, + pub description: Option<&'a str>, +} + +pub async fn create_station( + pool: &PgPool, + params: CreateStationParams<'_>, +) -> Result { + let station_id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO station ( + id, code, name, line_code, segment_code, station_type, enabled, description + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, + ) + .bind(station_id) + .bind(params.code) + .bind(params.name) + .bind(params.line_code) + .bind(params.segment_code) + .bind(params.station_type) + .bind(params.enabled) + .bind(params.description) + .execute(pool) + .await?; + + Ok(station_id) +} + +pub struct UpdateStationParams<'a> { + pub code: Option<&'a str>, + pub name: Option<&'a str>, + pub line_code: Option<&'a str>, + pub segment_code: Option<&'a str>, + pub station_type: Option<&'a str>, + pub enabled: Option, + pub description: Option<&'a str>, +} + +pub async fn update_station( + pool: &PgPool, + station_id: Uuid, + params: UpdateStationParams<'_>, +) -> Result { + let result = sqlx::query( + r#" + UPDATE station SET + code = COALESCE($2, code), + name = COALESCE($3, name), + line_code = COALESCE($4, line_code), + segment_code = COALESCE($5, segment_code), + station_type = COALESCE($6, station_type), + enabled = COALESCE($7, enabled), + description = COALESCE($8, description), + updated_at = NOW() + WHERE id = $1 + "#, + ) + .bind(station_id) + .bind(params.code) + .bind(params.name) + .bind(params.line_code) + .bind(params.segment_code) + .bind(params.station_type) + .bind(params.enabled) + .bind(params.description) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +pub async fn delete_station(pool: &PgPool, station_id: Uuid) -> Result { + let result = sqlx::query(r#"DELETE FROM station WHERE id = $1"#) + .bind(station_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn list_station_signals( + pool: &PgPool, + station_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, StationSignal>( + r#"SELECT * FROM station_signal WHERE station_id = $1 ORDER BY signal_role"#, + ) + .bind(station_id) + .fetch_all(pool) + .await +} + +pub struct UpsertStationSignalParams { + pub signal_role: String, + pub point_id: Option, + pub derived_from_role: Option, + pub invert_value: bool, +} + +pub async fn upsert_station_signal( + pool: &PgPool, + station_id: Uuid, + params: UpsertStationSignalParams, +) -> Result { + sqlx::query_as::<_, StationSignal>( + r#" + INSERT INTO station_signal ( + station_id, signal_role, point_id, derived_from_role, invert_value + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (station_id, signal_role) DO UPDATE SET + point_id = EXCLUDED.point_id, + derived_from_role = EXCLUDED.derived_from_role, + invert_value = EXCLUDED.invert_value, + updated_at = NOW() + RETURNING * + "#, + ) + .bind(station_id) + .bind(¶ms.signal_role) + .bind(params.point_id) + .bind(¶ms.derived_from_role) + .bind(params.invert_value) + .fetch_one(pool) + .await +} + +pub async fn delete_station_signal( + pool: &PgPool, + station_id: Uuid, + signal_role: &str, +) -> Result { + let result = sqlx::query( + r#"DELETE FROM station_signal WHERE station_id = $1 AND signal_role = $2"#, + ) + .bind(station_id) + .bind(signal_role) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} diff --git a/crates/app_operation_system/tests/router_smoke.rs b/crates/app_operation_system/tests/router_smoke.rs index d621193..fee76d3 100644 --- a/crates/app_operation_system/tests/router_smoke.rs +++ b/crates/app_operation_system/tests/router_smoke.rs @@ -4,11 +4,13 @@ use axum::{ }; use tower::ServiceExt; +fn build_app() -> axum::Router { + app_operation_system::build_router(app_operation_system::app::test_state()) +} + #[tokio::test] async fn operation_system_router_exposes_health_endpoint() { - let app = app_operation_system::build_router(app_operation_system::app::test_state()); - - let response = app + let response = build_app() .oneshot( Request::builder() .method(Method::GET) @@ -21,3 +23,37 @@ async fn operation_system_router_exposes_health_endpoint() { assert_eq!(response.status(), StatusCode::OK); } + +/// Verify the station collection route is registered (DELETE on the collection +/// isn't a real method, so axum should answer METHOD_NOT_ALLOWED, not 404). +#[tokio::test] +async fn operation_system_router_exposes_station_collection() { + let response = build_app() + .oneshot( + Request::builder() + .method(Method::DELETE) + .uri("/api/station") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("router should answer request"); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); +} + +#[tokio::test] +async fn operation_system_router_exposes_segment_collection() { + let response = build_app() + .oneshot( + Request::builder() + .method(Method::DELETE) + .uri("/api/segment") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("router should answer request"); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); +} diff --git a/migrations/20260518090000_create_operation_system.sql b/migrations/20260518090000_create_operation_system.sql new file mode 100644 index 0000000..8b6248d --- /dev/null +++ b/migrations/20260518090000_create_operation_system.sql @@ -0,0 +1,116 @@ +-- Operation-system business tables (design doc §4 / §12 P1). +-- Six ops configuration tables + event attribution columns. + +-- 1. station: 工位 +CREATE TABLE station ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL, + name TEXT NOT NULL, + line_code TEXT, + segment_code TEXT, + station_type TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (code) +); + +CREATE INDEX idx_station_line_code ON station(line_code); +CREATE INDEX idx_station_segment_code ON station(segment_code); + +-- 2. station_signal: 工位 ↔ 信号绑定 +CREATE TABLE station_signal ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + station_id UUID NOT NULL REFERENCES station(id) ON DELETE CASCADE, + signal_role TEXT NOT NULL, + point_id UUID REFERENCES point(id) ON DELETE SET NULL, + derived_from_role TEXT, + invert_value BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (station_id, signal_role) +); + +CREATE INDEX idx_station_signal_point_id ON station_signal(point_id); + +-- 3. process_segment: 流程段 +CREATE TABLE process_segment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL, + name TEXT NOT NULL, + segment_type TEXT NOT NULL, + line_code TEXT, + priority INTEGER NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + mode TEXT NOT NULL DEFAULT 'disabled', + require_manual_ack_after_fault BOOLEAN NOT NULL DEFAULT TRUE, + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (code) +); + +CREATE INDEX idx_process_segment_line_code ON process_segment(line_code); +CREATE INDEX idx_process_segment_enabled ON process_segment(enabled); + +-- 4. segment_step: 段步骤 +CREATE TABLE segment_step ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + segment_id UUID NOT NULL REFERENCES process_segment(id) ON DELETE CASCADE, + step_no INTEGER NOT NULL, + step_code TEXT NOT NULL, + action_kind TEXT NOT NULL, + target_equipment_id UUID REFERENCES equipment(id) ON DELETE SET NULL, + target_station_id UUID REFERENCES station(id) ON DELETE SET NULL, + confirm_signal_role TEXT, + confirm_point_id UUID REFERENCES point(id) ON DELETE SET NULL, + expected_value BOOLEAN NOT NULL DEFAULT TRUE, + timeout_ms INTEGER NOT NULL DEFAULT 30000 CHECK (timeout_ms > 0), + command_role TEXT, + stop_command_role TEXT, + pulse_ms INTEGER CHECK (pulse_ms IS NULL OR pulse_ms > 0), + hold_until_confirm BOOLEAN NOT NULL DEFAULT FALSE, + cancel_on_fault BOOLEAN NOT NULL DEFAULT TRUE, + next_step_no_on_success INTEGER, + next_step_no_on_failure INTEGER, + on_timeout TEXT NOT NULL DEFAULT 'fault', + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (segment_id, step_no) +); + +CREATE INDEX idx_segment_step_segment_id ON segment_step(segment_id); + +-- 5. segment_interlock: 段联锁 +CREATE TABLE segment_interlock ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + segment_id UUID NOT NULL REFERENCES process_segment(id) ON DELETE CASCADE, + applies_to TEXT NOT NULL, + rule_kind TEXT NOT NULL, + point_id UUID REFERENCES point(id) ON DELETE SET NULL, + station_id UUID REFERENCES station(id) ON DELETE SET NULL, + equipment_id UUID REFERENCES equipment(id) ON DELETE SET NULL, + expected_value BOOLEAN, + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_segment_interlock_segment_id ON segment_interlock(segment_id); + +-- 6. segment_resource: 段资源声明 +CREATE TABLE segment_resource ( + segment_id UUID NOT NULL REFERENCES process_segment(id) ON DELETE CASCADE, + resource_key TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (segment_id, resource_key) +); + +-- 7. event attribution: subject_type / subject_id (design doc §4.2.8) +ALTER TABLE event + ADD COLUMN subject_type TEXT, + ADD COLUMN subject_id UUID; + +CREATE INDEX idx_event_subject ON event(subject_type, subject_id);