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:
caoqianming 2026-05-19 08:17:34 +08:00
parent fd028b1320
commit a33c013da5
11 changed files with 2066 additions and 5 deletions

View File

@ -1 +1,3 @@
pub mod doc; pub mod doc;
pub mod segment;
pub mod station;

View File

@ -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());
}
}

View File

@ -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()));
}
}
}

View File

@ -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;

View File

@ -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>,
}

View File

@ -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",

View File

@ -0,0 +1,2 @@
pub mod segment;
pub mod station;

View File

@ -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
}

View File

@ -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(&params.signal_role)
.bind(params.point_id)
.bind(&params.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)
}

View File

@ -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);
}

View File

@ -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);