Add operation-system config schema and CRUD API
Land design doc §12 P1 + P2: ops business tables plus station/segment configuration endpoints. The engine (P3) consumes these as its inputs. - Migration: six ops tables (station, station_signal, process_segment, segment_step, segment_interlock, segment_resource) plus event attribution columns (subject_type, subject_id). - model.rs: FromRow structs and string-backed enum helpers (StationType, StationSignalRole, SegmentMode, ActionKind, OnTimeout, InterlockAppliesTo, RuleKind). - service: station CRUD with signal-binding upsert; segment CRUD with nested step/interlock CRUD and transactional resource replacement. - handler: 13 endpoints covering design doc §9.1 config routes with validator-based input checks and enum allowlists. - router: wires the new routes; smoke tests cover station and segment collection routes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fd028b1320
commit
a33c013da5
|
|
@ -1 +1,3 @@
|
||||||
pub mod doc;
|
pub mod doc;
|
||||||
|
pub mod segment;
|
||||||
|
pub mod station;
|
||||||
|
|
|
||||||
|
|
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_segments(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ListSegmentQuery>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(segment_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(segment_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<String>,
|
||||||
|
pub priority: Option<i32>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub mode: Option<String>,
|
||||||
|
pub require_manual_ack_after_fault: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_segment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateSegmentReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<String>,
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub segment_type: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 50))]
|
||||||
|
pub line_code: Option<String>,
|
||||||
|
pub priority: Option<i32>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub mode: Option<String>,
|
||||||
|
pub require_manual_ack_after_fault: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_segment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(segment_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateSegmentReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(segment_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(segment_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<Uuid>,
|
||||||
|
pub target_station_id: Option<Uuid>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub confirm_signal_role: Option<String>,
|
||||||
|
pub confirm_point_id: Option<Uuid>,
|
||||||
|
pub expected_value: Option<bool>,
|
||||||
|
#[validate(range(min = 1))]
|
||||||
|
pub timeout_ms: Option<i32>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub command_role: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub stop_command_role: Option<String>,
|
||||||
|
#[validate(range(min = 1))]
|
||||||
|
pub pulse_ms: Option<i32>,
|
||||||
|
pub hold_until_confirm: Option<bool>,
|
||||||
|
pub cancel_on_fault: Option<bool>,
|
||||||
|
pub next_step_no_on_success: Option<i32>,
|
||||||
|
pub next_step_no_on_failure: Option<i32>,
|
||||||
|
#[validate(length(min = 1, max = 16))]
|
||||||
|
pub on_timeout: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_step(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(segment_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<CreateStepReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<String>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub action_kind: Option<String>,
|
||||||
|
pub target_equipment_id: Option<Uuid>,
|
||||||
|
pub target_station_id: Option<Uuid>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub confirm_signal_role: Option<String>,
|
||||||
|
pub confirm_point_id: Option<Uuid>,
|
||||||
|
pub expected_value: Option<bool>,
|
||||||
|
#[validate(range(min = 1))]
|
||||||
|
pub timeout_ms: Option<i32>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub command_role: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub stop_command_role: Option<String>,
|
||||||
|
#[validate(range(min = 1))]
|
||||||
|
pub pulse_ms: Option<i32>,
|
||||||
|
pub hold_until_confirm: Option<bool>,
|
||||||
|
pub cancel_on_fault: Option<bool>,
|
||||||
|
pub next_step_no_on_success: Option<i32>,
|
||||||
|
pub next_step_no_on_failure: Option<i32>,
|
||||||
|
#[validate(length(min = 1, max = 16))]
|
||||||
|
pub on_timeout: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_step(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((segment_id, step_no)): Path<(Uuid, i32)>,
|
||||||
|
Json(payload): Json<UpdateStepReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
Path((segment_id, step_no)): Path<(Uuid, i32)>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(segment_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<Uuid>,
|
||||||
|
pub station_id: Option<Uuid>,
|
||||||
|
pub equipment_id: Option<Uuid>,
|
||||||
|
pub expected_value: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_interlock(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(segment_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<CreateInterlockReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
Path((segment_id, interlock_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(segment_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn replace_resources(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(segment_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<ReplaceResourcesReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_stations(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ListStationQuery>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(station_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<String>,
|
||||||
|
#[validate(length(min = 1, max = 50))]
|
||||||
|
pub segment_code: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub station_type: String,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_station(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateStationReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<String>,
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 50))]
|
||||||
|
pub line_code: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 50))]
|
||||||
|
pub segment_code: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub station_type: Option<String>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_station(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(station_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateStationReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(station_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<Uuid>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub derived_from_role: Option<String>,
|
||||||
|
pub invert_value: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upsert_station_signal(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(station_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpsertStationSignalReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
Path((station_id, role)): Path<(Uuid, String)>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,9 @@ pub mod app;
|
||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod handler;
|
pub mod handler;
|
||||||
|
pub mod model;
|
||||||
pub mod router;
|
pub mod router;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
pub use app::{run, test_state, AppState};
|
pub use app::{run, test_state, AppState};
|
||||||
pub use router::build_router;
|
pub use router::build_router;
|
||||||
|
|
|
||||||
|
|
@ -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<String>,
|
||||||
|
pub segment_code: Option<String>,
|
||||||
|
pub station_type: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(serialize_with = "utc_to_local_str")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(serialize_with = "utc_to_local_str")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Uuid>,
|
||||||
|
pub derived_from_role: Option<String>,
|
||||||
|
pub invert_value: bool,
|
||||||
|
#[serde(serialize_with = "utc_to_local_str")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(serialize_with = "utc_to_local_str")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub priority: i32,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub mode: String,
|
||||||
|
pub require_manual_ack_after_fault: bool,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(serialize_with = "utc_to_local_str")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(serialize_with = "utc_to_local_str")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Uuid>,
|
||||||
|
pub target_station_id: Option<Uuid>,
|
||||||
|
pub confirm_signal_role: Option<String>,
|
||||||
|
pub confirm_point_id: Option<Uuid>,
|
||||||
|
pub expected_value: bool,
|
||||||
|
pub timeout_ms: i32,
|
||||||
|
pub command_role: Option<String>,
|
||||||
|
pub stop_command_role: Option<String>,
|
||||||
|
pub pulse_ms: Option<i32>,
|
||||||
|
pub hold_until_confirm: bool,
|
||||||
|
pub cancel_on_fault: bool,
|
||||||
|
pub next_step_no_on_success: Option<i32>,
|
||||||
|
pub next_step_no_on_failure: Option<i32>,
|
||||||
|
pub on_timeout: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(serialize_with = "utc_to_local_str")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(serialize_with = "utc_to_local_str")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Uuid>,
|
||||||
|
pub station_id: Option<Uuid>,
|
||||||
|
pub equipment_id: Option<Uuid>,
|
||||||
|
pub expected_value: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(serialize_with = "utc_to_local_str")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(serialize_with = "utc_to_local_str")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Utc>,
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
use axum::{extract::State, routing::get, Router};
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
routing::{get, post, put},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::app::AppState;
|
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.
|
// Platform routes (source, point, equipment, tag, page, logs) from core.
|
||||||
let platform = plc_platform_core::handler::platform_routes::<AppState>();
|
let platform = plc_platform_core::handler::platform_routes::<AppState>();
|
||||||
|
|
||||||
// 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()
|
let ops_routes = Router::new()
|
||||||
.route("/api/health", get(health_check))
|
.route("/api/health", get(health_check))
|
||||||
.route("/api/docs/api-md", get(crate::handler::doc::get_api_md))
|
.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()
|
Router::new()
|
||||||
.merge(platform)
|
.merge(platform)
|
||||||
|
.merge(config_routes)
|
||||||
.merge(ops_routes)
|
.merge(ops_routes)
|
||||||
.nest(
|
.nest(
|
||||||
"/ui",
|
"/ui",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod segment;
|
||||||
|
pub mod station;
|
||||||
|
|
@ -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<Vec<ProcessSegment>, 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<Option<ProcessSegment>, 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<Option<ProcessSegment>, 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<Uuid, sqlx::Error> {
|
||||||
|
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<i32>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub mode: Option<&'a str>,
|
||||||
|
pub require_manual_ack_after_fault: Option<bool>,
|
||||||
|
pub description: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_segment(
|
||||||
|
pool: &PgPool,
|
||||||
|
segment_id: Uuid,
|
||||||
|
params: UpdateSegmentParams<'_>,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
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<bool, sqlx::Error> {
|
||||||
|
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<Vec<SegmentStep>, 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<Option<SegmentStep>, 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<Uuid>,
|
||||||
|
pub target_station_id: Option<Uuid>,
|
||||||
|
pub confirm_signal_role: Option<&'a str>,
|
||||||
|
pub confirm_point_id: Option<Uuid>,
|
||||||
|
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<i32>,
|
||||||
|
pub hold_until_confirm: bool,
|
||||||
|
pub cancel_on_fault: bool,
|
||||||
|
pub next_step_no_on_success: Option<i32>,
|
||||||
|
pub next_step_no_on_failure: Option<i32>,
|
||||||
|
pub on_timeout: &'a str,
|
||||||
|
pub description: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_step(pool: &PgPool, params: CreateStepParams<'_>) -> Result<Uuid, sqlx::Error> {
|
||||||
|
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<Uuid>,
|
||||||
|
pub target_station_id: Option<Uuid>,
|
||||||
|
pub confirm_signal_role: Option<&'a str>,
|
||||||
|
pub confirm_point_id: Option<Uuid>,
|
||||||
|
pub expected_value: Option<bool>,
|
||||||
|
pub timeout_ms: Option<i32>,
|
||||||
|
pub command_role: Option<&'a str>,
|
||||||
|
pub stop_command_role: Option<&'a str>,
|
||||||
|
pub pulse_ms: Option<i32>,
|
||||||
|
pub hold_until_confirm: Option<bool>,
|
||||||
|
pub cancel_on_fault: Option<bool>,
|
||||||
|
pub next_step_no_on_success: Option<i32>,
|
||||||
|
pub next_step_no_on_failure: Option<i32>,
|
||||||
|
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<bool, sqlx::Error> {
|
||||||
|
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<bool, sqlx::Error> {
|
||||||
|
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<Vec<SegmentInterlock>, 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<Uuid>,
|
||||||
|
pub station_id: Option<Uuid>,
|
||||||
|
pub equipment_id: Option<Uuid>,
|
||||||
|
pub expected_value: Option<bool>,
|
||||||
|
pub description: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_interlock(
|
||||||
|
pool: &PgPool,
|
||||||
|
params: CreateInterlockParams<'_>,
|
||||||
|
) -> Result<Uuid, sqlx::Error> {
|
||||||
|
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<bool, sqlx::Error> {
|
||||||
|
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<Vec<SegmentResource>, 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
|
||||||
|
}
|
||||||
|
|
@ -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<Vec<Station>, 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<Option<Station>, 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<Option<Station>, 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<Uuid, sqlx::Error> {
|
||||||
|
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<bool>,
|
||||||
|
pub description: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_station(
|
||||||
|
pool: &PgPool,
|
||||||
|
station_id: Uuid,
|
||||||
|
params: UpdateStationParams<'_>,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
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<bool, sqlx::Error> {
|
||||||
|
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<Vec<StationSignal>, 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<Uuid>,
|
||||||
|
pub derived_from_role: Option<String>,
|
||||||
|
pub invert_value: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upsert_station_signal(
|
||||||
|
pool: &PgPool,
|
||||||
|
station_id: Uuid,
|
||||||
|
params: UpsertStationSignalParams,
|
||||||
|
) -> Result<StationSignal, sqlx::Error> {
|
||||||
|
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<bool, sqlx::Error> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,13 @@ use axum::{
|
||||||
};
|
};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn build_app() -> axum::Router {
|
||||||
|
app_operation_system::build_router(app_operation_system::app::test_state())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn operation_system_router_exposes_health_endpoint() {
|
async fn operation_system_router_exposes_health_endpoint() {
|
||||||
let app = app_operation_system::build_router(app_operation_system::app::test_state());
|
let response = build_app()
|
||||||
|
|
||||||
let response = app
|
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method(Method::GET)
|
.method(Method::GET)
|
||||||
|
|
@ -21,3 +23,37 @@ async fn operation_system_router_exposes_health_endpoint() {
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
Loading…
Reference in New Issue