plc_control/crates/app_operation_system/src/handler/station.rs

312 lines
9.0 KiB
Rust

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