312 lines
9.0 KiB
Rust
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()));
|
|
}
|
|
}
|
|
}
|