Initial: Rust-Backend mit Clean Architecture (domain/application/infrastructure/api)
Vier-Crate-Workspace mit:
- Domain: Account, Car, Tour, Delivery, DeliveryItem, DeliveryNote, Customer,
Article, Warehouse, ScanState, AuditAction — alle mit serde + feature-gated
utoipa::ToSchema.
- Application: Ports (TourRepository, DeliveryRepository, ScanRepository,
DeliveryNoteRepository, CarRepository, AuthService) und Use Cases.
- Infrastructure: Postgres-Adapter via sqlx (PgTourRepository etc.) +
Keycloak-AuthService mit JWKS-Cache + OIDC-Discovery.
- API: Axum 0.8, utoipa-OpenAPI + Swagger-UI, JWT-Bearer-Middleware,
AuthenticatedUser-Extractor.
Endpoints:
- GET /me/tours/today, /tours/{id}, /accounts/{pn}, /me/cars, /health
- POST /sync/tour, /scans (bulk + idempotent via clientScanId),
/deliveries/{id}/{hold,resume,cancel,complete,notes}, /me/cars
- PUT /tours/{id}/delivery-order, /deliveries/{id}/assigned-car, /me/cars/{id}
- PATCH /me/cars/{id}
Datenmodell:
- 6 Migrationen (accounts, tours/deliveries/items + Stammdaten,
scan_audit mit clientScanId-UNIQUE, state_reason refactor,
delivery_notes, cars + FKs nachziehen).
- Business-stabile Beleg-Keys (belegart_id, belegnummer) für ERP-Sync.
- Append-only scan_audit + embedded scan_state als doppelte Wahrheit.
Dev-Setup:
- docker-compose mit Postgres 17 + Keycloak 26
- Keycloak-Realm 'holzleitner' mit Public-Client (PKCE), Testfahrer
(PN 1001) + Audience-/Personalnummer-Mapper
This commit is contained in:
32
crates/api/Cargo.toml
Normal file
32
crates/api/Cargo.toml
Normal file
@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "holzleitner-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "holzleitner-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
holzleitner-domain = { workspace = true, features = ["openapi"] }
|
||||
holzleitner-application = { workspace = true, features = ["openapi"] }
|
||||
holzleitner-infrastructure.workspace = true
|
||||
utoipa.workspace = true
|
||||
utoipa-swagger-ui.workspace = true
|
||||
axum.workspace = true
|
||||
tokio.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
envy.workspace = true
|
||||
dotenvy.workspace = true
|
||||
sqlx.workspace = true
|
||||
97
crates/api/src/config.rs
Normal file
97
crates/api/src/config.rs
Normal file
@ -0,0 +1,97 @@
|
||||
//! Konfiguration aus Umgebungsvariablen (12-Factor-Stil).
|
||||
//!
|
||||
//! Für lokale Entwicklung lädt [`load`] zunächst eine optionale `.env`
|
||||
//! Datei und parst dann pro Bereich (Server, Database, Keycloak) mit
|
||||
//! Prefix-Filter über `envy`. So bleiben die Strukturen klar getrennt
|
||||
//! und Fehlermeldungen verraten den genauen Bereich.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
pub database: DatabaseConfig,
|
||||
#[allow(dead_code)] // wird in der Keycloak-Phase verdrahtet
|
||||
pub keycloak: KeycloakConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DatabaseConfig {
|
||||
pub url: String,
|
||||
#[serde(default = "default_max_connections")]
|
||||
pub max_connections: u32,
|
||||
}
|
||||
|
||||
fn default_max_connections() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
/// Felder werden in der Keycloak-Phase angefasst — bis dahin Dead-Code-
|
||||
/// Warnings unterdrücken.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct KeycloakConfig {
|
||||
pub issuer_url: String,
|
||||
pub audience: String,
|
||||
#[serde(default = "default_jwks_cache_ttl")]
|
||||
pub jwks_cache_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
fn default_jwks_cache_ttl() -> u64 {
|
||||
3600
|
||||
}
|
||||
|
||||
/// Lädt die Konfiguration aus Umgebungsvariablen.
|
||||
///
|
||||
/// Reihenfolge:
|
||||
/// 1. Optionale `.env`-Datei im Arbeitsverzeichnis (für Local-Dev).
|
||||
/// 2. Pro Bereich werden Variablen mit passendem Prefix gelesen:
|
||||
/// * `SERVER_*` für [`ServerConfig`]
|
||||
/// * `DATABASE_*` für [`DatabaseConfig`]
|
||||
/// * `KEYCLOAK_*` für [`KeycloakConfig`]
|
||||
pub fn load() -> Result<Config, ConfigError> {
|
||||
// `.env` ist optional — in Produktion kommen die Werte aus dem
|
||||
// System-Environment.
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
let server = envy::prefixed("SERVER_")
|
||||
.from_env::<ServerConfig>()
|
||||
.map_err(|e| ConfigError::Section {
|
||||
section: "SERVER",
|
||||
source: e,
|
||||
})?;
|
||||
let database = envy::prefixed("DATABASE_")
|
||||
.from_env::<DatabaseConfig>()
|
||||
.map_err(|e| ConfigError::Section {
|
||||
section: "DATABASE",
|
||||
source: e,
|
||||
})?;
|
||||
let keycloak = envy::prefixed("KEYCLOAK_")
|
||||
.from_env::<KeycloakConfig>()
|
||||
.map_err(|e| ConfigError::Section {
|
||||
section: "KEYCLOAK",
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
Ok(Config {
|
||||
server,
|
||||
database,
|
||||
keycloak,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("missing or invalid env vars in section {section}: {source}")]
|
||||
Section {
|
||||
section: &'static str,
|
||||
#[source]
|
||||
source: envy::Error,
|
||||
},
|
||||
}
|
||||
51
crates/api/src/error.rs
Normal file
51
crates/api/src/error.rs
Normal file
@ -0,0 +1,51 @@
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::AuthError;
|
||||
use serde_json::json;
|
||||
|
||||
/// HTTP-Adapter für Application-Fehler. Mappt jede Variante auf
|
||||
/// einen Statuscode + JSON-Body. Verhindert, dass interne Details
|
||||
/// (z. B. SQL-Fehler) versehentlich in die Antwort durchschlagen —
|
||||
/// das eigentliche Detail wird über `tracing` geloggt.
|
||||
pub struct ApiError(pub ApplicationError);
|
||||
|
||||
impl From<ApplicationError> for ApiError {
|
||||
fn from(value: ApplicationError) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthError> for ApiError {
|
||||
fn from(err: AuthError) -> Self {
|
||||
// Auth-Detail für Debugging loggen, aber NICHT an den Client geben —
|
||||
// dort sehen wir nur „Unauthorized" / „internal error".
|
||||
tracing::debug!(error = %err, "auth verification failed");
|
||||
ApiError(err.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code) = match &self.0 {
|
||||
ApplicationError::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||
ApplicationError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
ApplicationError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
ApplicationError::Validation(_) => (StatusCode::BAD_REQUEST, "validation"),
|
||||
ApplicationError::Repository(_)
|
||||
| ApplicationError::External(_)
|
||||
| ApplicationError::Unexpected(_) => {
|
||||
tracing::error!(error = %self.0, "internal error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
||||
}
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
"error": code,
|
||||
"message": self.0.to_string(),
|
||||
}));
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
41
crates/api/src/extractors.rs
Normal file
41
crates/api/src/extractors.rs
Normal file
@ -0,0 +1,41 @@
|
||||
//! Eigene Axum-Extractoren für die API-Schicht.
|
||||
//!
|
||||
//! Da Axums Trait `FromRequestParts` und das fremde `Claims`-Struct
|
||||
//! aus `holzleitner_application` jeweils Fremd-Items sind (Rust-
|
||||
//! Orphan-Rule), wickeln wir `Claims` in einen lokalen Newtype
|
||||
//! `AuthenticatedUser`. Handler signaturen werden damit lesbarer als
|
||||
//! mit `axum::extract::Extension<Claims>` und liefern bei fehlender
|
||||
//! Authentifizierung einen konsistenten 401-Fehler.
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::Claims;
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
/// Wrapper-Extractor für `Claims` aus der JWT-Middleware.
|
||||
///
|
||||
/// Verwendung im Handler:
|
||||
/// ```ignore
|
||||
/// async fn handler(AuthenticatedUser(claims): AuthenticatedUser, ...) { ... }
|
||||
/// ```
|
||||
pub struct AuthenticatedUser(pub Claims);
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<Claims>()
|
||||
.cloned()
|
||||
.map(AuthenticatedUser)
|
||||
// Sollte nicht passieren wenn die Route hinter der Middleware
|
||||
// hängt — wir behandeln es als Programmier-/Konfig-Fehler.
|
||||
.ok_or(ApiError(ApplicationError::Unauthorized))
|
||||
}
|
||||
}
|
||||
158
crates/api/src/main.rs
Normal file
158
crates/api/src/main.rs
Normal file
@ -0,0 +1,158 @@
|
||||
//! Holzleitner-API — HTTP-Layer und Composition Root.
|
||||
//!
|
||||
//! Bootstrap-Reihenfolge:
|
||||
//! 1. Tracing/Logging initialisieren
|
||||
//! 2. Konfiguration aus Env-Variablen / `.env` laden
|
||||
//! 3. Postgres-Pool aufbauen und Migrations ausführen
|
||||
//! 4. Keycloak-AuthService instanziieren
|
||||
//! 5. Use Cases zusammenstellen und in `AppState` packen
|
||||
//! 6. Public + Protected Router komponieren und Server starten
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod extractors;
|
||||
mod middleware;
|
||||
mod openapi;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::Router;
|
||||
use axum::middleware::from_fn_with_state;
|
||||
use holzleitner_application::usecases::{
|
||||
ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase,
|
||||
CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase,
|
||||
ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase,
|
||||
UpdateMyCarUseCase,
|
||||
};
|
||||
use holzleitner_infrastructure::auth::{KeycloakAdapterConfig, KeycloakAuthService};
|
||||
use holzleitner_infrastructure::persistence::{
|
||||
PgAccountRepository, PgCarRepository, PgDeliveryNoteRepository, PgDeliveryRepository,
|
||||
PgScanRepository, PgTourRepository, PoolConfig, connect_and_migrate,
|
||||
};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
use crate::middleware::jwt_middleware;
|
||||
use crate::openapi::ApiDoc;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
"holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info".into()
|
||||
}),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cfg = config::load().context("config laden fehlgeschlagen")?;
|
||||
tracing::info!(host = %cfg.server.host, port = cfg.server.port, "starting up");
|
||||
|
||||
// --- Persistence ---------------------------------------------------
|
||||
let pool = connect_and_migrate(&PoolConfig {
|
||||
url: cfg.database.url.clone(),
|
||||
max_connections: cfg.database.max_connections,
|
||||
})
|
||||
.await
|
||||
.context("Postgres-Verbindung / Migrations fehlgeschlagen")?;
|
||||
tracing::info!("postgres connected, migrations applied");
|
||||
|
||||
let account_repository = Arc::new(PgAccountRepository::new(pool.clone()));
|
||||
let tour_repository = Arc::new(PgTourRepository::new(pool.clone()));
|
||||
let scan_repository = Arc::new(PgScanRepository::new(pool.clone()));
|
||||
let delivery_repository = Arc::new(PgDeliveryRepository::new(pool.clone()));
|
||||
let delivery_note_repository = Arc::new(PgDeliveryNoteRepository::new(pool.clone()));
|
||||
let car_repository = Arc::new(PgCarRepository::new(pool));
|
||||
|
||||
// --- Auth ----------------------------------------------------------
|
||||
let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig {
|
||||
issuer_url: cfg.keycloak.issuer_url.clone(),
|
||||
audience: cfg.keycloak.audience.clone(),
|
||||
jwks_cache_ttl: Duration::from_secs(cfg.keycloak.jwks_cache_ttl_seconds),
|
||||
}));
|
||||
tracing::info!(
|
||||
issuer = %cfg.keycloak.issuer_url,
|
||||
audience = %cfg.keycloak.audience,
|
||||
"keycloak adapter ready"
|
||||
);
|
||||
|
||||
// --- Use Cases -----------------------------------------------------
|
||||
let get_account = Arc::new(GetAccountUseCase::new(account_repository));
|
||||
let get_tour = Arc::new(GetTourUseCase::new(tour_repository.clone()));
|
||||
let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(tour_repository.clone()));
|
||||
let sync_tour = Arc::new(SyncTourUseCase::new(tour_repository.clone()));
|
||||
let set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository));
|
||||
let apply_scans = Arc::new(ApplyScansUseCase::new(
|
||||
scan_repository,
|
||||
car_repository.clone(),
|
||||
));
|
||||
let apply_delivery_action =
|
||||
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
|
||||
let create_delivery_note = Arc::new(CreateDeliveryNoteUseCase::new(
|
||||
delivery_note_repository,
|
||||
car_repository.clone(),
|
||||
));
|
||||
let list_my_cars = Arc::new(ListMyCarsUseCase::new(car_repository.clone()));
|
||||
let create_my_car = Arc::new(CreateMyCarUseCase::new(car_repository.clone()));
|
||||
let update_my_car = Arc::new(UpdateMyCarUseCase::new(car_repository.clone()));
|
||||
let assign_car_to_delivery = Arc::new(AssignCarToDeliveryUseCase::new(
|
||||
car_repository,
|
||||
delivery_repository,
|
||||
));
|
||||
|
||||
let state = AppState {
|
||||
get_account,
|
||||
get_tour,
|
||||
list_my_tours_today,
|
||||
sync_tour,
|
||||
set_delivery_order,
|
||||
apply_scans,
|
||||
apply_delivery_action,
|
||||
create_delivery_note,
|
||||
list_my_cars,
|
||||
create_my_car,
|
||||
update_my_car,
|
||||
assign_car_to_delivery,
|
||||
auth_service,
|
||||
};
|
||||
|
||||
// --- Router --------------------------------------------------------
|
||||
//
|
||||
// Public-Routen (Health, OpenAPI-Spec, Swagger-UI) und Protected-
|
||||
// Routen werden getrennt gebaut; die JWT-Middleware liegt **nur**
|
||||
// auf dem protected-Subtree. `route_layer` greift nur für die jetzt
|
||||
// definierten Routen — neue Routen darunter müssten explizit
|
||||
// nochmal angehängt werden.
|
||||
let public = Router::new()
|
||||
.merge(routes::health::router())
|
||||
.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi()));
|
||||
let protected = Router::new()
|
||||
.merge(routes::accounts::router())
|
||||
.merge(routes::tours::router())
|
||||
.merge(routes::scans::router())
|
||||
.merge(routes::deliveries::router())
|
||||
.merge(routes::cars::router())
|
||||
.route_layer(from_fn_with_state(state.clone(), jwt_middleware));
|
||||
|
||||
let app = Router::new()
|
||||
.merge(public)
|
||||
.merge(protected)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port)
|
||||
.parse()
|
||||
.with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?;
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
tracing::info!("server läuft auf http://{}", addr);
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
46
crates/api/src/middleware/jwt.rs
Normal file
46
crates/api/src/middleware/jwt.rs
Normal file
@ -0,0 +1,46 @@
|
||||
//! JWT-Middleware: extrahiert das Bearer-Token, ruft den `AuthService`
|
||||
//! aus dem `AppState` und hängt die verifizierten `Claims` als Request-
|
||||
//! Extension an. Nachgelagerte Handler greifen darüber per
|
||||
//! `AuthenticatedUser`-Extractor zu.
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use holzleitner_application::ports::AuthError;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn jwt_middleware(
|
||||
State(state): State<AppState>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let bearer = extract_bearer(req.headers()).ok_or(ApiError::from(AuthError::MissingToken))?;
|
||||
|
||||
let claims = state
|
||||
.auth_service
|
||||
.verify_token(bearer)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
tracing::debug!(
|
||||
personalnummer = claims.personalnummer,
|
||||
subject = %claims.subject,
|
||||
roles = ?claims.roles,
|
||||
"auth ok",
|
||||
);
|
||||
req.extensions_mut().insert(claims);
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
fn extract_bearer(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(AUTHORIZATION)?
|
||||
.to_str()
|
||||
.ok()?
|
||||
.strip_prefix("Bearer ")
|
||||
}
|
||||
6
crates/api/src/middleware/mod.rs
Normal file
6
crates/api/src/middleware/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
//! Axum-Middleware — z. B. JWT-Validierung gegen den
|
||||
//! `holzleitner_application::ports::AuthService`.
|
||||
|
||||
pub mod jwt;
|
||||
|
||||
pub use jwt::jwt_middleware;
|
||||
118
crates/api/src/openapi.rs
Normal file
118
crates/api/src/openapi.rs
Normal file
@ -0,0 +1,118 @@
|
||||
//! OpenAPI-Aggregation: zieht alle annotierten Handler und Schemata in
|
||||
//! ein einziges Dokument zusammen, das der `/openapi.json`-Endpoint
|
||||
//! serviert und Swagger-UI darstellt.
|
||||
//!
|
||||
//! Neue Endpoints werden hier in `paths(...)` registriert, neue Schemata
|
||||
//! in `components(schemas(...))`. Die Annotation am Handler (via
|
||||
//! `#[utoipa::path(...)]`) liefert die eigentliche Beschreibung.
|
||||
|
||||
use utoipa::Modify;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "Holzleitner Backend API",
|
||||
version = "0.1.0",
|
||||
description = "Backend für die Holzleitner-Lieferservice-App — Tour, Beladung, Ausführung."
|
||||
),
|
||||
paths(
|
||||
crate::routes::health::health,
|
||||
crate::routes::accounts::get_account,
|
||||
crate::routes::tours::list_my_tours_today,
|
||||
crate::routes::tours::get_tour,
|
||||
crate::routes::tours::set_delivery_order,
|
||||
crate::routes::tours::sync_tour,
|
||||
crate::routes::scans::apply_scans,
|
||||
crate::routes::deliveries::hold,
|
||||
crate::routes::deliveries::resume,
|
||||
crate::routes::deliveries::cancel,
|
||||
crate::routes::deliveries::complete,
|
||||
crate::routes::deliveries::create_note,
|
||||
crate::routes::deliveries::assign_car,
|
||||
crate::routes::cars::list_my_cars,
|
||||
crate::routes::cars::create_my_car,
|
||||
crate::routes::cars::update_my_car,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
holzleitner_domain::Account,
|
||||
holzleitner_domain::Address,
|
||||
holzleitner_domain::Article,
|
||||
holzleitner_domain::AuditAction,
|
||||
holzleitner_domain::Car,
|
||||
holzleitner_domain::Customer,
|
||||
holzleitner_domain::CustomerContact,
|
||||
holzleitner_domain::Delivery,
|
||||
holzleitner_domain::DeliveryItem,
|
||||
holzleitner_domain::DeliveryNote,
|
||||
holzleitner_domain::DeliveryState,
|
||||
holzleitner_domain::ScanState,
|
||||
holzleitner_domain::ScanStatus,
|
||||
holzleitner_domain::Tour,
|
||||
holzleitner_domain::Warehouse,
|
||||
holzleitner_application::dto::TourDetails,
|
||||
holzleitner_application::dto::DeliveryWithItems,
|
||||
holzleitner_application::dto::TourSummary,
|
||||
holzleitner_application::dto::SyncTourRequest,
|
||||
holzleitner_application::dto::SyncDelivery,
|
||||
holzleitner_application::dto::SyncDeliveryItem,
|
||||
holzleitner_application::dto::SetDeliveryOrderRequest,
|
||||
holzleitner_application::dto::SetDeliveryOrderResponse,
|
||||
holzleitner_application::dto::DeliveryOrderEntry,
|
||||
holzleitner_application::dto::ApplyScansRequest,
|
||||
holzleitner_application::dto::ApplyScansResponse,
|
||||
holzleitner_application::dto::ScanEvent,
|
||||
holzleitner_application::dto::ScanResult,
|
||||
holzleitner_application::dto::ScanResultStatus,
|
||||
holzleitner_application::dto::HoldDeliveryRequest,
|
||||
holzleitner_application::dto::CancelDeliveryRequest,
|
||||
holzleitner_application::dto::DeliveryResponse,
|
||||
holzleitner_application::dto::CreateDeliveryNoteRequest,
|
||||
holzleitner_application::dto::DeliveryNoteResponse,
|
||||
holzleitner_application::dto::CreateCarRequest,
|
||||
holzleitner_application::dto::UpdateCarRequest,
|
||||
holzleitner_application::dto::CarResponse,
|
||||
holzleitner_application::dto::CarsList,
|
||||
holzleitner_application::dto::AssignCarRequest,
|
||||
crate::routes::tours::TourSummaryList,
|
||||
crate::routes::tours::SyncTourResponse,
|
||||
)
|
||||
),
|
||||
modifiers(&SecurityAddon),
|
||||
tags(
|
||||
(name = "health", description = "Health- und Status-Endpoints"),
|
||||
(name = "accounts", description = "Account-Stammdaten"),
|
||||
(name = "tours", description = "Touren der Fahrer"),
|
||||
(name = "sync", description = "ERP-Sync-Endpunkte"),
|
||||
(name = "scans", description = "Scan-Events (Beladung & Auslieferung)"),
|
||||
(name = "deliveries", description = "Delivery-Lifecycle (hold / resume / cancel / complete)"),
|
||||
(name = "cars", description = "Fahrzeug-Stammdaten pro Fahrer"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
/// Hängt das `bearer_auth`-Security-Scheme nachträglich in die Spec ein —
|
||||
/// das geht nur über einen `Modify`-Hook, weil derive-Macros das nicht
|
||||
/// direkt erlauben.
|
||||
struct SecurityAddon;
|
||||
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(components) = openapi.components.as_mut() {
|
||||
components.add_security_scheme(
|
||||
"bearer_auth",
|
||||
SecurityScheme::Http(
|
||||
HttpBuilder::new()
|
||||
.scheme(HttpAuthScheme::Bearer)
|
||||
.bearer_format("JWT")
|
||||
.build(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
crates/api/src/routes/accounts.rs
Normal file
44
crates/api/src/routes/accounts.rs
Normal file
@ -0,0 +1,44 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::get;
|
||||
use holzleitner_domain::Account;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/accounts/{personalnummer}", get(get_account))
|
||||
}
|
||||
|
||||
/// Liest den Account zu einer Personalnummer.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/accounts/{personalnummer}",
|
||||
tag = "accounts",
|
||||
params(
|
||||
("personalnummer" = i64, Path, description = "Personalnummer des Accounts")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Account gefunden", body = Account),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Kein Account zu dieser Personalnummer")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_account(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(personalnummer): Path<i64>,
|
||||
) -> Result<Json<Account>, ApiError> {
|
||||
tracing::debug!(
|
||||
caller = claims.personalnummer,
|
||||
target = personalnummer,
|
||||
"get_account",
|
||||
);
|
||||
let account = state.get_account.execute(personalnummer).await?;
|
||||
Ok(Json(account))
|
||||
}
|
||||
108
crates/api/src/routes/cars.rs
Normal file
108
crates/api/src/routes/cars.rs
Normal file
@ -0,0 +1,108 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::routing::{get, patch};
|
||||
use holzleitner_application::dto::{
|
||||
CarResponse, CarsList, CreateCarRequest, UpdateCarRequest,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/me/cars", get(list_my_cars).post(create_my_car))
|
||||
.route("/me/cars/{car_id}", patch(update_my_car))
|
||||
}
|
||||
|
||||
/// Query-Parameter für `GET /me/cars`.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListCarsQuery {
|
||||
/// Default `false` — Endpoint liefert standardmäßig auch
|
||||
/// deaktivierte Fahrzeuge **nicht** mit.
|
||||
#[serde(default)]
|
||||
pub include_inactive: bool,
|
||||
}
|
||||
|
||||
/// Listet die Fahrzeuge des angemeldeten Fahrers.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/me/cars",
|
||||
tag = "cars",
|
||||
params(
|
||||
("includeInactive" = Option<bool>, Query, description = "Wenn true, werden inaktive Fahrzeuge mitgeliefert (default: false)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeuge des Fahrers", body = CarsList),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_my_cars(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Query(query): Query<ListCarsQuery>,
|
||||
) -> Result<Json<CarsList>, ApiError> {
|
||||
let cars = state
|
||||
.list_my_cars
|
||||
.execute(claims.personalnummer, query.include_inactive)
|
||||
.await?;
|
||||
Ok(Json(CarsList { cars }))
|
||||
}
|
||||
|
||||
/// Legt ein neues Fahrzeug für den angemeldeten Fahrer an.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/me/cars",
|
||||
tag = "cars",
|
||||
request_body = CreateCarRequest,
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeug angelegt", body = CarResponse),
|
||||
(status = 400, description = "Validierungsfehler (z. B. doppeltes Kennzeichen)"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_my_car(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Json(req): Json<CreateCarRequest>,
|
||||
) -> Result<Json<CarResponse>, ApiError> {
|
||||
let car = state
|
||||
.create_my_car
|
||||
.execute(claims.personalnummer, req)
|
||||
.await?;
|
||||
Ok(Json(CarResponse { car }))
|
||||
}
|
||||
|
||||
/// Aktualisiert ein Fahrzeug (Kennzeichen ändern / deaktivieren).
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/me/cars/{car_id}",
|
||||
tag = "cars",
|
||||
params(("car_id" = Uuid, Path)),
|
||||
request_body = UpdateCarRequest,
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeug aktualisiert", body = CarResponse),
|
||||
(status = 400, description = "Validierungsfehler"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Fahrzeug nicht gefunden oder gehört nicht zu diesem Account")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_my_car(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(car_id): Path<Uuid>,
|
||||
Json(req): Json<UpdateCarRequest>,
|
||||
) -> Result<Json<CarResponse>, ApiError> {
|
||||
let car = state
|
||||
.update_my_car
|
||||
.execute(car_id, claims.personalnummer, req)
|
||||
.await?;
|
||||
Ok(Json(CarResponse { car }))
|
||||
}
|
||||
207
crates/api/src/routes/deliveries.rs
Normal file
207
crates/api/src/routes/deliveries.rs
Normal file
@ -0,0 +1,207 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{post, put};
|
||||
use holzleitner_application::dto::{
|
||||
AssignCarRequest, CancelDeliveryRequest, CreateDeliveryNoteRequest, DeliveryNoteResponse,
|
||||
DeliveryResponse, HoldDeliveryRequest,
|
||||
};
|
||||
use holzleitner_application::ports::DeliveryAction;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/deliveries/{delivery_id}/hold", post(hold))
|
||||
.route("/deliveries/{delivery_id}/resume", post(resume))
|
||||
.route("/deliveries/{delivery_id}/cancel", post(cancel))
|
||||
.route("/deliveries/{delivery_id}/complete", post(complete))
|
||||
.route("/deliveries/{delivery_id}/notes", post(create_note))
|
||||
.route("/deliveries/{delivery_id}/assigned-car", put(assign_car))
|
||||
}
|
||||
|
||||
/// Setzt die Lieferung auf `held`. Nur aus `active` zulässig.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/hold",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = HoldDeliveryRequest,
|
||||
responses(
|
||||
(status = 200, description = "Lieferung geholdet", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang oder leerer Reason"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn hold(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<HoldDeliveryRequest>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.hold");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(delivery_id, DeliveryAction::Hold { reason: req.reason })
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Setzt die Lieferung zurück auf `active`. Nur aus `held` zulässig.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/resume",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
responses(
|
||||
(status = 200, description = "Lieferung wieder aktiv", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn resume(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.resume");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(delivery_id, DeliveryAction::Resume)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Setzt die Lieferung auf `canceled` — endgültig. Erlaubt aus
|
||||
/// `active` und `held`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/cancel",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = CancelDeliveryRequest,
|
||||
responses(
|
||||
(status = 200, description = "Lieferung storniert", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang oder leerer Reason"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn cancel(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<CancelDeliveryRequest>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.cancel");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(
|
||||
delivery_id,
|
||||
DeliveryAction::Cancel { reason: req.reason },
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Schließt die Lieferung ab — `state = completed`. Nur aus `active`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/complete",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
responses(
|
||||
(status = 200, description = "Lieferung abgeschlossen", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn complete(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.complete");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(delivery_id, DeliveryAction::Complete)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Legt eine neue Notiz an einer Lieferung an. Mindestens eines von
|
||||
/// `text` und `imageAttachment` muss inhaltlich gefüllt sein
|
||||
/// (Leerstrings werden serverseitig getrimmt und als leer behandelt).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/notes",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = CreateDeliveryNoteRequest,
|
||||
responses(
|
||||
(status = 200, description = "Notiz angelegt", body = DeliveryNoteResponse),
|
||||
(status = 400, description = "Notiz ohne Inhalt"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_note(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<CreateDeliveryNoteRequest>,
|
||||
) -> Result<Json<DeliveryNoteResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.create_note");
|
||||
let note = state
|
||||
.create_delivery_note
|
||||
.execute(delivery_id, claims.personalnummer, req)
|
||||
.await?;
|
||||
Ok(Json(DeliveryNoteResponse { note }))
|
||||
}
|
||||
|
||||
/// Setzt das `assigned_car_id` einer Lieferung. `carId: null` löst
|
||||
/// die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug
|
||||
/// zum angemeldeten Account gehört.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/deliveries/{delivery_id}/assigned-car",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = AssignCarRequest,
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeug zugewiesen / entfernt", body = DeliveryResponse),
|
||||
(status = 400, description = "Fahrzeug gehört nicht zum Account"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn assign_car(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<AssignCarRequest>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
actor = claims.personalnummer,
|
||||
%delivery_id,
|
||||
car_id = ?req.car_id,
|
||||
"delivery.assign_car",
|
||||
);
|
||||
let delivery = state
|
||||
.assign_car_to_delivery
|
||||
.execute(delivery_id, claims.personalnummer, req.car_id)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
23
crates/api/src/routes/health.rs
Normal file
23
crates/api/src/routes/health.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use axum::Router;
|
||||
use axum::routing::get;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/health", get(health))
|
||||
}
|
||||
|
||||
/// Health-Endpoint für Load-Balancer und Container-Probes. Bewusst
|
||||
/// kein Auth — eine `200 ok`-Antwort darf nicht von der Auth abhängen.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
tag = "health",
|
||||
responses(
|
||||
(status = 200, description = "Service ist erreichbar", body = String)
|
||||
),
|
||||
security()
|
||||
)]
|
||||
pub async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
9
crates/api/src/routes/mod.rs
Normal file
9
crates/api/src/routes/mod.rs
Normal file
@ -0,0 +1,9 @@
|
||||
//! HTTP-Routen, gruppiert nach Domäne. Wird vom `main.rs`-Router
|
||||
//! zusammengesetzt.
|
||||
|
||||
pub mod accounts;
|
||||
pub mod cars;
|
||||
pub mod deliveries;
|
||||
pub mod health;
|
||||
pub mod scans;
|
||||
pub mod tours;
|
||||
49
crates/api/src/routes/scans.rs
Normal file
49
crates/api/src/routes/scans.rs
Normal file
@ -0,0 +1,49 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::routing::post;
|
||||
use holzleitner_application::dto::{ApplyScansRequest, ApplyScansResponse};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/scans", post(apply_scans))
|
||||
}
|
||||
|
||||
/// Wendet eine Liste von Scan-Events idempotent an.
|
||||
///
|
||||
/// Pro Event ein eigenes Resultat. Status `applied` schreibt einen
|
||||
/// frischen Audit-Eintrag, `duplicate` liefert den aktuellen Stand am
|
||||
/// Server, `rejected` enthält die Begründung. Reihenfolge der `results`
|
||||
/// entspricht der Reihenfolge der `scans` im Request.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/scans",
|
||||
tag = "scans",
|
||||
request_body = ApplyScansRequest,
|
||||
responses(
|
||||
(status = 200, description = "Bulk-Ergebnis pro Event", body = ApplyScansResponse),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn apply_scans(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Json(request): Json<ApplyScansRequest>,
|
||||
) -> Result<Json<ApplyScansResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
actor = claims.personalnummer,
|
||||
count = request.scans.len(),
|
||||
"apply_scans",
|
||||
);
|
||||
let response = state
|
||||
.apply_scans
|
||||
.execute(request, claims.personalnummer)
|
||||
.await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
158
crates/api/src/routes/tours.rs
Normal file
158
crates/api/src/routes/tours.rs
Normal file
@ -0,0 +1,158 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{get, post, put};
|
||||
use holzleitner_application::dto::{
|
||||
SetDeliveryOrderRequest, SetDeliveryOrderResponse, SyncTourRequest, TourDetails, TourSummary,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/me/tours/today", get(list_my_tours_today))
|
||||
.route("/tours/{tour_id}", get(get_tour))
|
||||
.route("/tours/{tour_id}/delivery-order", put(set_delivery_order))
|
||||
.route("/sync/tour", post(sync_tour))
|
||||
}
|
||||
|
||||
/// Antwort-Hülle für `GET /me/tours/today`. Eigenes Struct, weil
|
||||
/// utoipa für `Vec<T>` als Top-Level-Response keinen sauberen
|
||||
/// Schemanamen vergibt — und ein Wrapper macht die Erweiterbarkeit
|
||||
/// (z. B. Paginierung in Zukunft) zur Nicht-Breaking-Change.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TourSummaryList {
|
||||
pub tours: Vec<TourSummary>,
|
||||
}
|
||||
|
||||
/// Antwort-Hülle für `POST /sync/tour`.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncTourResponse {
|
||||
pub tour_id: Uuid,
|
||||
}
|
||||
|
||||
/// Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/me/tours/today",
|
||||
tag = "tours",
|
||||
responses(
|
||||
(status = 200, description = "Liste der heutigen Touren", body = TourSummaryList),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn list_my_tours_today(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
) -> Result<Json<TourSummaryList>, ApiError> {
|
||||
tracing::debug!(personalnummer = claims.personalnummer, "list_my_tours_today");
|
||||
let tours = state
|
||||
.list_my_tours_today
|
||||
.execute(claims.personalnummer)
|
||||
.await?;
|
||||
Ok(Json(TourSummaryList { tours }))
|
||||
}
|
||||
|
||||
/// Lädt eine Tour mit allen Lieferungen, Positionen und referenzierten
|
||||
/// Stammdaten — die App nutzt das als einzigen großen Read.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/tours/{tour_id}",
|
||||
tag = "tours",
|
||||
params(
|
||||
("tour_id" = Uuid, Path, description = "Eindeutige Tour-Id (UUID)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Tour-Aggregat gefunden", body = TourDetails),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Keine Tour mit dieser Id")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_tour(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(tour_id): Path<Uuid>,
|
||||
) -> Result<Json<TourDetails>, ApiError> {
|
||||
tracing::debug!(personalnummer = claims.personalnummer, %tour_id, "get_tour");
|
||||
let details = state.get_tour.execute(tour_id).await?;
|
||||
Ok(Json(details))
|
||||
}
|
||||
|
||||
/// Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour neu.
|
||||
/// Der Client schickt die **vollständige** neue Reihenfolge; fehlende
|
||||
/// oder fremde Lieferungs-Ids werden mit `400 validation` abgelehnt.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/tours/{tour_id}/delivery-order",
|
||||
tag = "tours",
|
||||
params(("tour_id" = Uuid, Path)),
|
||||
request_body = SetDeliveryOrderRequest,
|
||||
responses(
|
||||
(status = 200, description = "Neue Reihenfolge gespeichert", body = SetDeliveryOrderResponse),
|
||||
(status = 400, description = "Mengen-Mismatch oder Duplikate"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Tour nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn set_delivery_order(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(tour_id): Path<Uuid>,
|
||||
Json(request): Json<SetDeliveryOrderRequest>,
|
||||
) -> Result<Json<SetDeliveryOrderResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
actor = claims.personalnummer,
|
||||
%tour_id,
|
||||
count = request.delivery_ids.len(),
|
||||
"set_delivery_order",
|
||||
);
|
||||
let response = state.set_delivery_order.execute(tour_id, request).await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// Sync-Endpoint für das ERP: legt eine Tagestour samt Lieferungen und
|
||||
/// Positionen idempotent an. Identität pro Tour
|
||||
/// `(driver_personalnummer, tour_date)`, pro Lieferung
|
||||
/// `(belegart_id, belegnummer)`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/sync/tour",
|
||||
tag = "sync",
|
||||
request_body = SyncTourRequest,
|
||||
responses(
|
||||
(status = 200, description = "Tour gespeichert", body = SyncTourResponse),
|
||||
(status = 400, description = "Validierungsfehler im Sync-Payload"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn sync_tour(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Json(request): Json<SyncTourRequest>,
|
||||
) -> Result<Json<SyncTourResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
caller = claims.personalnummer,
|
||||
driver = request.driver_personalnummer,
|
||||
date = %request.tour_date,
|
||||
deliveries = request.deliveries.len(),
|
||||
"sync_tour",
|
||||
);
|
||||
let tour_id = state.sync_tour.execute(request).await?;
|
||||
Ok(Json(SyncTourResponse { tour_id }))
|
||||
}
|
||||
33
crates/api/src/state.rs
Normal file
33
crates/api/src/state.rs
Normal file
@ -0,0 +1,33 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use holzleitner_application::ports::AuthService;
|
||||
use holzleitner_application::usecases::{
|
||||
ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase,
|
||||
CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase,
|
||||
ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase,
|
||||
UpdateMyCarUseCase,
|
||||
};
|
||||
|
||||
/// Shared application state, der per Axum's `State`-Extractor in alle
|
||||
/// Handler hineingegeben wird. Use Cases und Services liegen hinter
|
||||
/// `Arc`, damit `Clone` billig ist und Requests sich keine Locks teilen.
|
||||
///
|
||||
/// Das `AppState`-Klon wird zudem in die JWT-Middleware geschleust
|
||||
/// (`from_fn_with_state`), die den `auth_service` nutzt — derselbe
|
||||
/// Trait-Objekt-Arc wie die Handler.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub get_account: Arc<GetAccountUseCase>,
|
||||
pub get_tour: Arc<GetTourUseCase>,
|
||||
pub list_my_tours_today: Arc<ListMyToursTodayUseCase>,
|
||||
pub sync_tour: Arc<SyncTourUseCase>,
|
||||
pub set_delivery_order: Arc<SetDeliveryOrderUseCase>,
|
||||
pub apply_scans: Arc<ApplyScansUseCase>,
|
||||
pub apply_delivery_action: Arc<ApplyDeliveryActionUseCase>,
|
||||
pub create_delivery_note: Arc<CreateDeliveryNoteUseCase>,
|
||||
pub list_my_cars: Arc<ListMyCarsUseCase>,
|
||||
pub create_my_car: Arc<CreateMyCarUseCase>,
|
||||
pub update_my_car: Arc<UpdateMyCarUseCase>,
|
||||
pub assign_car_to_delivery: Arc<AssignCarToDeliveryUseCase>,
|
||||
pub auth_service: Arc<dyn AuthService>,
|
||||
}
|
||||
20
crates/application/Cargo.toml
Normal file
20
crates/application/Cargo.toml
Normal file
@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "holzleitner-application"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[features]
|
||||
# Aktiviert utoipa::ToSchema-Derives auf DTOs + propagiert das Feature in
|
||||
# das Domain-Crate, damit Antwort-Schemata vollständig generiert werden.
|
||||
openapi = ["dep:utoipa", "holzleitner-domain/openapi"]
|
||||
|
||||
[dependencies]
|
||||
holzleitner-domain.workspace = true
|
||||
serde.workspace = true
|
||||
async-trait.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
utoipa = { workspace = true, optional = true }
|
||||
50
crates/application/src/dto/car.rs
Normal file
50
crates/application/src/dto/car.rs
Normal file
@ -0,0 +1,50 @@
|
||||
//! Request- und Antwort-Typen für die Cars-Endpoints.
|
||||
//!
|
||||
//! Alle Cars-Endpoints sind aus Sicht des angemeldeten Fahrers ("/me/...").
|
||||
//! Der Account-Bezug kommt aus dem JWT, nicht aus dem Body.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::Car;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateCarRequest {
|
||||
pub plate: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateCarRequest {
|
||||
/// Wenn gesetzt: neues Kennzeichen.
|
||||
pub plate: Option<String>,
|
||||
/// Wenn gesetzt: aktiv/inaktiv. Inaktive Fahrzeuge tauchen in
|
||||
/// `GET /me/cars?activeOnly=true` (default) nicht auf.
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CarResponse {
|
||||
pub car: Car,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CarsList {
|
||||
pub cars: Vec<Car>,
|
||||
}
|
||||
|
||||
/// Setzt das `assigned_car_id` einer Lieferung. `None` (`carId: null`)
|
||||
/// entfernt die Zuordnung.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AssignCarRequest {
|
||||
pub car_id: Option<Uuid>,
|
||||
}
|
||||
33
crates/application/src/dto/delivery_action.rs
Normal file
33
crates/application/src/dto/delivery_action.rs
Normal file
@ -0,0 +1,33 @@
|
||||
//! Eingaben und Antwort für die vier Delivery-Lifecycle-Endpunkte.
|
||||
//!
|
||||
//! Pro Endpoint genau ein eigener Request-Typ:
|
||||
//! * Hold + Cancel brauchen einen Pflicht-Reason.
|
||||
//! * Resume + Complete sind body-frei (App schickt leeres `{}`).
|
||||
//!
|
||||
//! Die Antwort liefert immer die frisch aktualisierte `Delivery` zurück,
|
||||
//! damit die App ihre lokale Sicht in einem Schritt synchron halten kann.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use holzleitner_domain::Delivery;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HoldDeliveryRequest {
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelDeliveryRequest {
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryResponse {
|
||||
pub delivery: Delivery,
|
||||
}
|
||||
35
crates/application/src/dto/delivery_order.rs
Normal file
35
crates/application/src/dto/delivery_order.rs
Normal file
@ -0,0 +1,35 @@
|
||||
//! Request/Response für `PUT /tours/{id}/delivery-order`.
|
||||
//!
|
||||
//! Der Client schickt **die vollständige neue Reihenfolge** aller
|
||||
//! Lieferungen der Tour. Der Server validiert, dass die Menge der Ids
|
||||
//! exakt zur Tour passt — fehlende oder fremde Ids werden hart abgelehnt.
|
||||
//! Damit kann ein Client mit veralteten Daten nicht versehentlich
|
||||
//! Lieferungen "verlieren".
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetDeliveryOrderRequest {
|
||||
/// Reihenfolge: Position im Array (0-basiert) wird zu `sort_order`
|
||||
/// (1-basiert) gemappt.
|
||||
pub delivery_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetDeliveryOrderResponse {
|
||||
pub tour_id: Uuid,
|
||||
pub order: Vec<DeliveryOrderEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryOrderEntry {
|
||||
pub delivery_id: Uuid,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
35
crates/application/src/dto/mod.rs
Normal file
35
crates/application/src/dto/mod.rs
Normal file
@ -0,0 +1,35 @@
|
||||
//! Data Transfer Objects der Application-Schicht.
|
||||
//!
|
||||
//! DTOs aggregieren Domänenobjekte zu **Use-Case-Antworten** und
|
||||
//! beschreiben die Eingaben für Sync-Operationen. Sie bleiben bewusst
|
||||
//! im Application-Layer (nicht im Domain-Layer), weil sie eine
|
||||
//! Use-Case-Sicht auf die Daten sind, kein Stück Geschäftslogik —
|
||||
//! ein anderer Use-Case darf eine andere Projektion liefern.
|
||||
//!
|
||||
//! Serde- und (optional) utoipa-Annotationen erlauben, dieselbe
|
||||
//! Struktur direkt als HTTP-Antwort zu serialisieren — das spart eine
|
||||
//! zweite Schicht handgeschriebener API-DTOs.
|
||||
|
||||
pub mod car;
|
||||
pub mod delivery_action;
|
||||
pub mod delivery_order;
|
||||
pub mod note;
|
||||
pub mod scan;
|
||||
pub mod tour_details;
|
||||
pub mod tour_summary;
|
||||
pub mod tour_sync;
|
||||
|
||||
pub use car::{
|
||||
AssignCarRequest, CarResponse, CarsList, CreateCarRequest, UpdateCarRequest,
|
||||
};
|
||||
pub use delivery_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest};
|
||||
pub use delivery_order::{
|
||||
DeliveryOrderEntry, SetDeliveryOrderRequest, SetDeliveryOrderResponse,
|
||||
};
|
||||
pub use note::{CreateDeliveryNoteRequest, DeliveryNoteResponse};
|
||||
pub use scan::{
|
||||
ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus,
|
||||
};
|
||||
pub use tour_details::{DeliveryWithItems, TourDetails};
|
||||
pub use tour_summary::TourSummary;
|
||||
pub use tour_sync::{SyncDelivery, SyncDeliveryItem, SyncTourRequest};
|
||||
29
crates/application/src/dto/note.rs
Normal file
29
crates/application/src/dto/note.rs
Normal file
@ -0,0 +1,29 @@
|
||||
//! Request/Response für `POST /deliveries/{id}/notes`.
|
||||
//!
|
||||
//! Mindestens eines von `text` und `image_attachment` muss gesetzt
|
||||
//! sein — die Constraint wird im Use Case geprüft und steht zusätzlich
|
||||
//! als DB-CHECK.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::DeliveryNote;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDeliveryNoteRequest {
|
||||
pub text: Option<String>,
|
||||
/// Object-Storage-Key oder URL eines vorab hochgeladenen Bildes.
|
||||
pub image_attachment: Option<String>,
|
||||
/// Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten
|
||||
/// Account gehören. `None` ist erlaubt.
|
||||
pub author_car_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryNoteResponse {
|
||||
pub note: DeliveryNote,
|
||||
}
|
||||
73
crates/application/src/dto/scan.rs
Normal file
73
crates/application/src/dto/scan.rs
Normal file
@ -0,0 +1,73 @@
|
||||
//! Bulk-Scan-Endpoint: Request, Response, pro-Event-Result.
|
||||
//!
|
||||
//! Idempotenz läuft über `client_scan_id`: ein UUID, das die App pro
|
||||
//! erzeugtem Scan-Event genau einmal vergibt. Retry desselben Events
|
||||
//! liefert `Duplicate` zurück. Pro Event ein eigener kleiner Vorgang —
|
||||
//! ein einzelner Reject blockiert die anderen nicht.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::{AuditAction, ScanState};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyScansRequest {
|
||||
pub scans: Vec<ScanEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScanEvent {
|
||||
pub client_scan_id: Uuid,
|
||||
pub delivery_item_id: Uuid,
|
||||
pub action: AuditAction,
|
||||
/// Pflicht bei `Hold` und `Remove`. Sonst ignoriert.
|
||||
pub reason: Option<String>,
|
||||
pub client_scanned_at: DateTime<Utc>,
|
||||
/// Fahrzeug, in dem der Scan gemacht wurde. Muss zum
|
||||
/// angemeldeten Account gehören. `None` ist erlaubt, schwächt
|
||||
/// aber den Audit-Trail.
|
||||
pub actor_car_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyScansResponse {
|
||||
pub results: Vec<ScanResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScanResultStatus {
|
||||
/// Aktion wurde frisch angewendet; Audit-Eintrag geschrieben.
|
||||
Applied,
|
||||
/// `client_scan_id` war bereits bekannt. Item-State unverändert,
|
||||
/// `scan_state` zeigt den aktuellen Stand am Server.
|
||||
Duplicate,
|
||||
/// Aktion wurde abgelehnt (z. B. unbekanntes Item, invalider
|
||||
/// Statusübergang, fehlender Pflicht-Reason). `reason` füllt die
|
||||
/// Begründung; `scan_state` ist `None`.
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScanResult {
|
||||
pub client_scan_id: Uuid,
|
||||
pub status: ScanResultStatus,
|
||||
/// Bei `Rejected`: Begründung. Bei `Applied`/`Duplicate`: `None`.
|
||||
pub reason: Option<String>,
|
||||
/// Aktueller `scan_state` der Position nach der Verarbeitung —
|
||||
/// genau dann gesetzt, wenn der Server den Stand kennen konnte
|
||||
/// (`Applied` oder `Duplicate`). Erlaubt der App, die UI ohne
|
||||
/// Re-Fetch zu aktualisieren.
|
||||
pub delivery_item_id: Option<Uuid>,
|
||||
pub new_scan_state: Option<ScanState>,
|
||||
}
|
||||
41
crates/application/src/dto/tour_details.rs
Normal file
41
crates/application/src/dto/tour_details.rs
Normal file
@ -0,0 +1,41 @@
|
||||
//! Aggregat-Antwort für `GET /tours/{id}`.
|
||||
//!
|
||||
//! Vereint Tour, alle ihre Lieferungen mit Positionen sowie die
|
||||
//! referenzierten Stammdaten (Kunden, Artikel, Lager) in einem
|
||||
//! Payload. Die Stammdaten werden als deduplizierte Lookup-Listen
|
||||
//! mitgeliefert — die App joint clientseitig per Id. Das spart
|
||||
//! gegenüber vollständig denormalisierten Items spürbar Payload,
|
||||
//! wenn die gleichen Artikel/Lager mehrfach vorkommen.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use holzleitner_domain::{
|
||||
Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote, Tour, Warehouse,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TourDetails {
|
||||
pub tour: Tour,
|
||||
pub deliveries: Vec<DeliveryWithItems>,
|
||||
pub customers: Vec<Customer>,
|
||||
pub customer_contacts: Vec<CustomerContact>,
|
||||
pub articles: Vec<Article>,
|
||||
pub warehouses: Vec<Warehouse>,
|
||||
/// Alle Notizen aller Lieferungen dieser Tour, in einer Liste.
|
||||
/// Die App joint clientseitig per `delivery_id`. Reihenfolge:
|
||||
/// pro Lieferung aufsteigend nach `created_at`.
|
||||
pub notes: Vec<DeliveryNote>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryWithItems {
|
||||
#[serde(flatten)]
|
||||
pub delivery: Delivery,
|
||||
/// Sortier-Reihenfolge innerhalb der Tour (1-basiert).
|
||||
pub sort_order: i32,
|
||||
pub items: Vec<DeliveryItem>,
|
||||
}
|
||||
17
crates/application/src/dto/tour_summary.rs
Normal file
17
crates/application/src/dto/tour_summary.rs
Normal file
@ -0,0 +1,17 @@
|
||||
//! Liste-Antwort für `GET /me/tours/today` — leichtgewichtig, ohne
|
||||
//! Lieferpositionen. Die App nutzt diese Liste für die
|
||||
//! Fahrzeug/Tour-Auswahl-Page und lädt erst nach der Auswahl die
|
||||
//! vollständige Tour über `GET /tours/{id}`.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TourSummary {
|
||||
pub tour_id: Uuid,
|
||||
pub tour_date: NaiveDate,
|
||||
pub delivery_count: i64,
|
||||
}
|
||||
68
crates/application/src/dto/tour_sync.rs
Normal file
68
crates/application/src/dto/tour_sync.rs
Normal file
@ -0,0 +1,68 @@
|
||||
//! Request-Body für `POST /sync/tour`.
|
||||
//!
|
||||
//! Diese Struktur ist die Kontaktfläche zum ERP. Sie beschreibt eine
|
||||
//! Tagestour eines Fahrers inklusive aller Lieferungen und Positionen.
|
||||
//! Identität:
|
||||
//! * Tour: `(driver_personalnummer, tour_date)` — Upsert.
|
||||
//! * Delivery: `(belegart_id, belegnummer)` — Upsert.
|
||||
//! * DeliveryItem: `(delivery, belegzeilen_nr, komponenten_artikel_nr)`.
|
||||
//!
|
||||
//! Bewusst keine UUIDs vom Sender erwartet — die ERP-Welt arbeitet mit
|
||||
//! ihren eigenen business-stabilen Keys, wir generieren UUIDs intern.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use holzleitner_domain::Address;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncTourRequest {
|
||||
pub driver_personalnummer: i64,
|
||||
pub tour_date: NaiveDate,
|
||||
pub deliveries: Vec<SyncDelivery>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncDelivery {
|
||||
pub belegart_id: i64,
|
||||
pub belegnummer: String,
|
||||
|
||||
pub erp_customer_id: i64,
|
||||
pub customer_name: String,
|
||||
pub customer_address: Address,
|
||||
|
||||
/// Snapshot der Lieferadresse (kann von der Stammadresse abweichen).
|
||||
pub delivery_address: Address,
|
||||
|
||||
/// 1-basiert, definiert die initiale Reihenfolge in der App.
|
||||
pub sort_order: i32,
|
||||
|
||||
pub desired_time: Option<String>,
|
||||
pub special_agreements: Option<String>,
|
||||
|
||||
pub items: Vec<SyncDeliveryItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncDeliveryItem {
|
||||
pub belegzeilen_nr: i32,
|
||||
/// Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer.
|
||||
pub komponenten_artikel_nr: Option<String>,
|
||||
|
||||
pub article_number: String,
|
||||
pub article_name: String,
|
||||
/// Default-Lager-Code für den Artikel (Anlage neuer Artikel).
|
||||
pub article_default_warehouse_code: Option<String>,
|
||||
pub article_scannable: bool,
|
||||
|
||||
pub warehouse_code: String,
|
||||
pub warehouse_name: String,
|
||||
|
||||
pub required_quantity: i32,
|
||||
}
|
||||
31
crates/application/src/error.rs
Normal file
31
crates/application/src/error.rs
Normal file
@ -0,0 +1,31 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Fehler-Hierarchie der Application-Schicht.
|
||||
///
|
||||
/// Use Cases geben diesen Typ zurück. Die API-Schicht mappt ihn auf
|
||||
/// HTTP-Statuscodes (`NotFound` → 404, `Unauthorized` → 401, …),
|
||||
/// Persistence-Adapter mappen ihre konkreten Fehler nach
|
||||
/// `Repository(...)` hinein.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApplicationError {
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("validation: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("repository: {0}")]
|
||||
Repository(String),
|
||||
|
||||
#[error("external service: {0}")]
|
||||
External(String),
|
||||
|
||||
#[error("unexpected: {0}")]
|
||||
Unexpected(String),
|
||||
}
|
||||
17
crates/application/src/lib.rs
Normal file
17
crates/application/src/lib.rs
Normal file
@ -0,0 +1,17 @@
|
||||
//! Application Layer — Use Cases und Ports.
|
||||
//!
|
||||
//! Diese Crate kennt das [`holzleitner_domain`]-Modell und definiert
|
||||
//! gegen welche **Ports** (Repository-Traits, Service-Traits) die Use
|
||||
//! Cases programmieren. Konkrete Implementierungen leben in
|
||||
//! `holzleitner-infrastructure` (Postgres, Keycloak, …).
|
||||
//!
|
||||
//! Diese Crate hat bewusst **keine** Abhängigkeit auf eine konkrete
|
||||
//! Datenbank, ein HTTP-Framework oder einen Auth-Provider — sie soll
|
||||
//! testbar und framework-unabhängig bleiben.
|
||||
|
||||
pub mod dto;
|
||||
pub mod error;
|
||||
pub mod ports;
|
||||
pub mod usecases;
|
||||
|
||||
pub use error::ApplicationError;
|
||||
17
crates/application/src/ports/account_repository.rs
Normal file
17
crates/application/src/ports/account_repository.rs
Normal file
@ -0,0 +1,17 @@
|
||||
use async_trait::async_trait;
|
||||
use holzleitner_domain::Account;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
/// Repository für [`Account`]-Lesezugriffe. Schreibzugriffe folgen
|
||||
/// später, sobald sie fachlich gebraucht werden (Accounts werden in der
|
||||
/// Regel aus dem ERP gespiegelt, nicht durch die App erzeugt).
|
||||
#[async_trait]
|
||||
pub trait AccountRepository: Send + Sync {
|
||||
/// Liest einen Account anhand seiner Personalnummer.
|
||||
/// Liefert `None`, wenn kein Datensatz existiert.
|
||||
async fn find_by_personalnummer(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
) -> Result<Option<Account>, ApplicationError>;
|
||||
}
|
||||
80
crates/application/src/ports/auth_service.rs
Normal file
80
crates/application/src/ports/auth_service.rs
Normal file
@ -0,0 +1,80 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
/// Verifizierte Claims eines OAuth2/OIDC-Access-Tokens.
|
||||
///
|
||||
/// Bewusst sparsam — nur das, was die Anwendung wirklich braucht.
|
||||
/// Identität und Autorisierung leiten sich aus `personalnummer` +
|
||||
/// `roles` ab; `subject` ist die Keycloak-User-UUID für Audit-/Log-
|
||||
/// Zwecke.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Claims {
|
||||
/// Personalnummer aus dem Custom-Claim (User-Attribut). Identifiziert
|
||||
/// den Account in unserer Welt (siehe [`holzleitner_domain::Account`]).
|
||||
pub personalnummer: i64,
|
||||
|
||||
/// Keycloak-User-UUID (`sub`). Stabil, opak — eignet sich für Logs,
|
||||
/// nicht für fachliche Aussagen.
|
||||
pub subject: String,
|
||||
|
||||
/// Realm-Rollen (z. B. `driver`). Quelle für RBAC-Checks in Use Cases.
|
||||
pub roles: Vec<String>,
|
||||
|
||||
/// Ablaufzeitpunkt aus `exp`. Nur informativ — die Signatur-Validierung
|
||||
/// im Adapter prüft das bereits.
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
/// Convenience-Check, ob der Anwender eine bestimmte Rolle trägt.
|
||||
pub fn has_role(&self, role: &str) -> bool {
|
||||
self.roles.iter().any(|r| r == role)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fehler des [`AuthService`]. Adapter-spezifische Details bleiben in den
|
||||
/// Varianten, die API-Schicht mappt sie pauschal auf 401 (Unauthorized) —
|
||||
/// außer `External`, das auf 500/502 mappt.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("missing bearer token")]
|
||||
MissingToken,
|
||||
|
||||
#[error("token expired")]
|
||||
Expired,
|
||||
|
||||
#[error("invalid token: {0}")]
|
||||
Invalid(String),
|
||||
|
||||
#[error("audience mismatch")]
|
||||
InvalidAudience,
|
||||
|
||||
#[error("missing claim: {0}")]
|
||||
MissingClaim(&'static str),
|
||||
|
||||
/// Auth-Provider nicht erreichbar oder antwortet nicht plausibel.
|
||||
/// Aus Anwendersicht ein 5xx, nicht ein 401.
|
||||
#[error("external auth service: {0}")]
|
||||
External(String),
|
||||
}
|
||||
|
||||
impl From<AuthError> for ApplicationError {
|
||||
fn from(err: AuthError) -> Self {
|
||||
match err {
|
||||
AuthError::External(msg) => ApplicationError::External(msg),
|
||||
_ => ApplicationError::Unauthorized,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifiziert OAuth2/OIDC-Access-Tokens. Konkrete Implementierung in
|
||||
/// `holzleitner-infrastructure` (Keycloak via OIDC-Discovery + JWKS).
|
||||
#[async_trait]
|
||||
pub trait AuthService: Send + Sync {
|
||||
/// Validiert das übergebene Bearer-Token (ohne `Bearer `-Prefix) und
|
||||
/// gibt die extrahierten Claims zurück.
|
||||
async fn verify_token(&self, bearer_token: &str) -> Result<Claims, AuthError>;
|
||||
}
|
||||
58
crates/application/src/ports/car_repository.rs
Normal file
58
crates/application/src/ports/car_repository.rs
Normal file
@ -0,0 +1,58 @@
|
||||
//! Port für Fahrzeug-Stammdaten.
|
||||
//!
|
||||
//! Alle Lese- und Schreibzugriffe gehen über `account_id` (=
|
||||
//! Personalnummer aus dem JWT). Es gibt **keine** Methode, die
|
||||
//! Fahrzeuge ohne Account-Filter zurückgibt — so ist die Isolation
|
||||
//! zwischen Subunternehmer-Accounts strukturell garantiert.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::Car;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait CarRepository: Send + Sync {
|
||||
/// Liste der Fahrzeuge eines Accounts. `include_inactive = false`
|
||||
/// blendet deaktivierte Fahrzeuge aus (Default für die App).
|
||||
async fn find_by_account(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
include_inactive: bool,
|
||||
) -> Result<Vec<Car>, ApplicationError>;
|
||||
|
||||
/// Sucht ein Fahrzeug, das einem Account gehört. `None` wenn es
|
||||
/// das Fahrzeug nicht gibt **oder** zu einem anderen Account
|
||||
/// gehört — beides sieht für den Caller gleich aus, das ist
|
||||
/// gewollt (kein Account-Probing).
|
||||
async fn find_by_id_for_account(
|
||||
&self,
|
||||
car_id: Uuid,
|
||||
personalnummer: i64,
|
||||
) -> Result<Option<Car>, ApplicationError>;
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
plate: &str,
|
||||
) -> Result<Car, ApplicationError>;
|
||||
|
||||
/// Optional-Patch. `None` heißt "unverändert".
|
||||
async fn update(
|
||||
&self,
|
||||
car_id: Uuid,
|
||||
personalnummer: i64,
|
||||
plate: Option<&str>,
|
||||
active: Option<bool>,
|
||||
) -> Result<Car, ApplicationError>;
|
||||
|
||||
/// Validiert, dass alle übergebenen IDs zum Account gehören. Nutzt
|
||||
/// der Use-Case beim Bulk-Scan, um die `actor_car_id`-Werte
|
||||
/// einmalig vor dem Verarbeiten zu prüfen.
|
||||
async fn assert_owned_by_account(
|
||||
&self,
|
||||
car_ids: &[Uuid],
|
||||
personalnummer: i64,
|
||||
) -> Result<(), ApplicationError>;
|
||||
}
|
||||
24
crates/application/src/ports/delivery_note_repository.rs
Normal file
24
crates/application/src/ports/delivery_note_repository.rs
Normal file
@ -0,0 +1,24 @@
|
||||
//! Port für Delivery-Notizen.
|
||||
//!
|
||||
//! Aktuell nur das Anlegen — der Read-Pfad läuft als Teil des Tour-
|
||||
//! Aggregats (`TourDetails.notes`). Sollten irgendwann Listen-Reads
|
||||
//! oder Updates an einzelnen Notizen nötig werden, kommen die hier rein.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::DeliveryNote;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeliveryNoteRepository: Send + Sync {
|
||||
async fn create(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
text: Option<String>,
|
||||
image_attachment: Option<String>,
|
||||
) -> Result<DeliveryNote, ApplicationError>;
|
||||
}
|
||||
44
crates/application/src/ports/delivery_repository.rs
Normal file
44
crates/application/src/ports/delivery_repository.rs
Normal file
@ -0,0 +1,44 @@
|
||||
//! Port für Delivery-Lifecycle-Übergänge.
|
||||
//!
|
||||
//! Eine schmale, aktionsbasierte Schnittstelle: der Use Case übergibt
|
||||
//! eine [`DeliveryAction`], die Persistence führt SELECT-FOR-UPDATE,
|
||||
//! validiert den Statusübergang und schreibt fort — alles in einer
|
||||
//! Transaktion. Liefert die frisch aktualisierte [`Delivery`] zurück,
|
||||
//! damit die API direkt antworten kann.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::Delivery;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DeliveryAction {
|
||||
/// State → `Held`, `state_reason` = `reason`.
|
||||
Hold { reason: String },
|
||||
/// State → `Active`, `state_reason` = `NULL`. Nur aus `Held`.
|
||||
Resume,
|
||||
/// State → `Canceled`, `state_reason` = `reason`. Endgültig.
|
||||
Cancel { reason: String },
|
||||
/// State → `Completed`, `state_reason` = `NULL`. Nur aus `Active`.
|
||||
Complete,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeliveryRepository: Send + Sync {
|
||||
async fn apply_action(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
action: DeliveryAction,
|
||||
) -> Result<Delivery, ApplicationError>;
|
||||
|
||||
/// Setzt das `assigned_car_id` einer Lieferung. `None` löst die
|
||||
/// Zuordnung. Der Use Case muss vorher prüfen, dass das
|
||||
/// Fahrzeug zum angemeldeten Account gehört.
|
||||
async fn assign_car(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
car_id: Option<Uuid>,
|
||||
) -> Result<Delivery, ApplicationError>;
|
||||
}
|
||||
22
crates/application/src/ports/mod.rs
Normal file
22
crates/application/src/ports/mod.rs
Normal file
@ -0,0 +1,22 @@
|
||||
//! Ports — die Trait-Definitionen, gegen die Use Cases programmieren.
|
||||
//!
|
||||
//! Hier landen Repository-Traits (z. B. `TourRepository`,
|
||||
//! `DeliveryRepository`) sowie Service-Traits (z. B. `AuthService` für
|
||||
//! Keycloak). Konkrete Implementierungen leben in
|
||||
//! `holzleitner-infrastructure`.
|
||||
|
||||
pub mod account_repository;
|
||||
pub mod auth_service;
|
||||
pub mod car_repository;
|
||||
pub mod delivery_note_repository;
|
||||
pub mod delivery_repository;
|
||||
pub mod scan_repository;
|
||||
pub mod tour_repository;
|
||||
|
||||
pub use account_repository::AccountRepository;
|
||||
pub use auth_service::{AuthError, AuthService, Claims};
|
||||
pub use car_repository::CarRepository;
|
||||
pub use delivery_note_repository::DeliveryNoteRepository;
|
||||
pub use delivery_repository::{DeliveryAction, DeliveryRepository};
|
||||
pub use scan_repository::{ApplyScanOutcome, ScanRepository};
|
||||
pub use tour_repository::TourRepository;
|
||||
46
crates/application/src/ports/scan_repository.rs
Normal file
46
crates/application/src/ports/scan_repository.rs
Normal file
@ -0,0 +1,46 @@
|
||||
//! Port für das Anwenden von Scan-Events.
|
||||
//!
|
||||
//! Bewusst eine schmale Schnittstelle: ein einziger Aufruf
|
||||
//! `apply_one` pro Event. Die Bulk-Logik (Iteration, Sammlung der
|
||||
//! Ergebnisse) lebt im Use Case — die Persistence kennt nur die
|
||||
//! atomare Operation und kann sie isoliert testen.
|
||||
//!
|
||||
//! `apply_one` MUSS idempotent über `client_scan_id` sein und darf
|
||||
//! niemals den `scan_state` ändern, wenn der Audit-Eintrag schon
|
||||
//! existiert.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::ScanState;
|
||||
|
||||
use crate::dto::ScanEvent;
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
/// Ergebnis einer einzelnen Scan-Operation aus Sicht der Persistence.
|
||||
/// Der Use Case mappt das auf die `ScanResult`-Antwort der API.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ApplyScanOutcome {
|
||||
/// Frisch angewendet — neuer State der Position.
|
||||
Applied {
|
||||
delivery_item_id: Uuid,
|
||||
new_state: ScanState,
|
||||
},
|
||||
/// `client_scan_id` war bereits da. State unverändert, hier der
|
||||
/// aktuelle Stand am Server für die App.
|
||||
Duplicate {
|
||||
delivery_item_id: Uuid,
|
||||
current_state: ScanState,
|
||||
},
|
||||
/// Abgelehnt (unbekanntes Item, invalider Statusübergang, …).
|
||||
Rejected { reason: String },
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ScanRepository: Send + Sync {
|
||||
async fn apply_one(
|
||||
&self,
|
||||
event: &ScanEvent,
|
||||
actor_personalnummer: i64,
|
||||
) -> Result<ApplyScanOutcome, ApplicationError>;
|
||||
}
|
||||
52
crates/application/src/ports/tour_repository.rs
Normal file
52
crates/application/src/ports/tour_repository.rs
Normal file
@ -0,0 +1,52 @@
|
||||
//! Repository-Port für Touren.
|
||||
//!
|
||||
//! Drei Operationen, die direkt drei Use Cases bedienen:
|
||||
//! * `find_today_for_driver` — leichtgewichtige Liste (Auswahl-Page)
|
||||
//! * `find_details_by_id` — fettes Aggregat (Sortier-/Beladen-/Auslieferung)
|
||||
//! * `upsert_from_sync` — idempotente Übernahme aus dem ERP
|
||||
//!
|
||||
//! Das fette Aggregat hängen wir bewusst NICHT an die Sync-Operation,
|
||||
//! weil das ERP nicht den aktuellen Scan-Status kennt und nichts darüber
|
||||
//! aussagen darf.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{
|
||||
DeliveryOrderEntry, SyncTourRequest, TourDetails, TourSummary,
|
||||
};
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait TourRepository: Send + Sync {
|
||||
async fn find_today_for_driver(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
today: NaiveDate,
|
||||
) -> Result<Vec<TourSummary>, ApplicationError>;
|
||||
|
||||
async fn find_details_by_id(
|
||||
&self,
|
||||
tour_id: Uuid,
|
||||
) -> Result<Option<TourDetails>, ApplicationError>;
|
||||
|
||||
/// Legt eine Tour samt Lieferungen und Positionen idempotent an
|
||||
/// bzw. aktualisiert sie. Gibt die `tour_id` zurück.
|
||||
async fn upsert_from_sync(
|
||||
&self,
|
||||
request: &SyncTourRequest,
|
||||
) -> Result<Uuid, ApplicationError>;
|
||||
|
||||
/// Schreibt die `sort_order` aller Lieferungen einer Tour neu.
|
||||
///
|
||||
/// Validiert in einer Transaktion, dass die übergebene Id-Menge
|
||||
/// **exakt** der aktuellen Lieferungs-Menge der Tour entspricht.
|
||||
/// Fehlende, fremde oder doppelte Ids → `Validation`-Fehler.
|
||||
/// Gibt die finale Reihenfolge zurück.
|
||||
async fn set_delivery_order(
|
||||
&self,
|
||||
tour_id: Uuid,
|
||||
delivery_ids: &[Uuid],
|
||||
) -> Result<Vec<DeliveryOrderEntry>, ApplicationError>;
|
||||
}
|
||||
40
crates/application/src/usecases/apply_delivery_action.rs
Normal file
40
crates/application/src/usecases/apply_delivery_action.rs
Normal file
@ -0,0 +1,40 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::Delivery;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{DeliveryAction, DeliveryRepository};
|
||||
|
||||
/// Vier Lifecycle-Übergänge an einer Lieferung — `Hold`, `Resume`,
|
||||
/// `Cancel`, `Complete`. Die Statusmaschine läuft im Repository unter
|
||||
/// Lock; dieser Use Case validiert die Begründungs-Pflichten vorab.
|
||||
pub struct ApplyDeliveryActionUseCase {
|
||||
repository: Arc<dyn DeliveryRepository>,
|
||||
}
|
||||
|
||||
impl ApplyDeliveryActionUseCase {
|
||||
pub fn new(repository: Arc<dyn DeliveryRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
action: DeliveryAction,
|
||||
) -> Result<Delivery, ApplicationError> {
|
||||
match &action {
|
||||
DeliveryAction::Hold { reason } | DeliveryAction::Cancel { reason } => {
|
||||
if reason.trim().is_empty() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"reason darf nicht leer sein".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
DeliveryAction::Resume | DeliveryAction::Complete => {}
|
||||
}
|
||||
|
||||
self.repository.apply_action(delivery_id, action).await
|
||||
}
|
||||
}
|
||||
118
crates/application/src/usecases/apply_scans.rs
Normal file
118
crates/application/src/usecases/apply_scans.rs
Normal file
@ -0,0 +1,118 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use holzleitner_domain::AuditAction;
|
||||
|
||||
use crate::dto::{
|
||||
ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus,
|
||||
};
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{ApplyScanOutcome, CarRepository, ScanRepository};
|
||||
|
||||
/// Wendet eine Liste von Scan-Events idempotent an.
|
||||
///
|
||||
/// Pro Event ein eigener Vorgang in der Persistence — ein einzelner
|
||||
/// Reject blockiert die anderen nicht. Pflicht-Reasons (`Hold` /
|
||||
/// `Remove`) werden hier vorab geprüft, ebenso die Ownership aller
|
||||
/// in der Batch enthaltenen `actor_car_id`-Werte.
|
||||
pub struct ApplyScansUseCase {
|
||||
repository: Arc<dyn ScanRepository>,
|
||||
cars: Arc<dyn CarRepository>,
|
||||
}
|
||||
|
||||
impl ApplyScansUseCase {
|
||||
pub fn new(repository: Arc<dyn ScanRepository>, cars: Arc<dyn CarRepository>) -> Self {
|
||||
Self { repository, cars }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
request: ApplyScansRequest,
|
||||
actor_personalnummer: i64,
|
||||
) -> Result<ApplyScansResponse, ApplicationError> {
|
||||
// Distinct car_ids auf einmal validieren — eine Query statt
|
||||
// pro-Event-Roundtrip.
|
||||
let distinct_cars: BTreeSet<uuid::Uuid> = request
|
||||
.scans
|
||||
.iter()
|
||||
.filter_map(|e| e.actor_car_id)
|
||||
.collect();
|
||||
if !distinct_cars.is_empty() {
|
||||
let ids: Vec<uuid::Uuid> = distinct_cars.into_iter().collect();
|
||||
self.cars
|
||||
.assert_owned_by_account(&ids, actor_personalnummer)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut results = Vec::with_capacity(request.scans.len());
|
||||
|
||||
for event in &request.scans {
|
||||
if let Some(reason) = pre_validate(event) {
|
||||
results.push(ScanResult {
|
||||
client_scan_id: event.client_scan_id,
|
||||
status: ScanResultStatus::Rejected,
|
||||
reason: Some(reason),
|
||||
delivery_item_id: None,
|
||||
new_scan_state: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let outcome = self
|
||||
.repository
|
||||
.apply_one(event, actor_personalnummer)
|
||||
.await?;
|
||||
|
||||
results.push(match outcome {
|
||||
ApplyScanOutcome::Applied {
|
||||
delivery_item_id,
|
||||
new_state,
|
||||
} => ScanResult {
|
||||
client_scan_id: event.client_scan_id,
|
||||
status: ScanResultStatus::Applied,
|
||||
reason: None,
|
||||
delivery_item_id: Some(delivery_item_id),
|
||||
new_scan_state: Some(new_state),
|
||||
},
|
||||
ApplyScanOutcome::Duplicate {
|
||||
delivery_item_id,
|
||||
current_state,
|
||||
} => ScanResult {
|
||||
client_scan_id: event.client_scan_id,
|
||||
status: ScanResultStatus::Duplicate,
|
||||
reason: None,
|
||||
delivery_item_id: Some(delivery_item_id),
|
||||
new_scan_state: Some(current_state),
|
||||
},
|
||||
ApplyScanOutcome::Rejected { reason } => ScanResult {
|
||||
client_scan_id: event.client_scan_id,
|
||||
status: ScanResultStatus::Rejected,
|
||||
reason: Some(reason),
|
||||
delivery_item_id: None,
|
||||
new_scan_state: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ApplyScansResponse { results })
|
||||
}
|
||||
}
|
||||
|
||||
/// Validiert Pflichtfelder ohne DB-Aufruf. Liefert `Some(reason)`,
|
||||
/// wenn das Event verworfen werden soll.
|
||||
fn pre_validate(event: &ScanEvent) -> Option<String> {
|
||||
match event.action {
|
||||
AuditAction::Hold | AuditAction::Remove => {
|
||||
let trimmed = event.reason.as_deref().map(str::trim).unwrap_or("");
|
||||
if trimmed.is_empty() {
|
||||
Some(format!(
|
||||
"reason required for action {:?}",
|
||||
event.action
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
AuditAction::Scan | AuditAction::Unscan | AuditAction::Unhold => None,
|
||||
}
|
||||
}
|
||||
124
crates/application/src/usecases/cars.rs
Normal file
124
crates/application/src/usecases/cars.rs
Normal file
@ -0,0 +1,124 @@
|
||||
//! Use Cases rund um die Fahrzeug-Stammdaten eines Fahrers.
|
||||
//!
|
||||
//! Vier kleine Operationen — alle leichtgewichtig, weil die
|
||||
//! Account-Isolation strukturell im Repository sitzt.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::Car;
|
||||
|
||||
use crate::dto::{CreateCarRequest, UpdateCarRequest};
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::CarRepository;
|
||||
|
||||
pub struct ListMyCarsUseCase {
|
||||
repository: Arc<dyn CarRepository>,
|
||||
}
|
||||
|
||||
impl ListMyCarsUseCase {
|
||||
pub fn new(repository: Arc<dyn CarRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
include_inactive: bool,
|
||||
) -> Result<Vec<Car>, ApplicationError> {
|
||||
self.repository
|
||||
.find_by_account(personalnummer, include_inactive)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CreateMyCarUseCase {
|
||||
repository: Arc<dyn CarRepository>,
|
||||
}
|
||||
|
||||
impl CreateMyCarUseCase {
|
||||
pub fn new(repository: Arc<dyn CarRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
request: CreateCarRequest,
|
||||
) -> Result<Car, ApplicationError> {
|
||||
let plate = request.plate.trim();
|
||||
if plate.is_empty() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"plate darf nicht leer sein".into(),
|
||||
));
|
||||
}
|
||||
self.repository.create(personalnummer, plate).await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UpdateMyCarUseCase {
|
||||
repository: Arc<dyn CarRepository>,
|
||||
}
|
||||
|
||||
impl UpdateMyCarUseCase {
|
||||
pub fn new(repository: Arc<dyn CarRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
car_id: Uuid,
|
||||
personalnummer: i64,
|
||||
request: UpdateCarRequest,
|
||||
) -> Result<Car, ApplicationError> {
|
||||
// Leerer Body ist erlaubt (idempotenter PATCH), aber wenn
|
||||
// plate gesetzt ist, darf es nicht nur Whitespace sein.
|
||||
let plate = match request.plate {
|
||||
Some(p) => {
|
||||
let trimmed = p.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"plate darf nicht leer sein".into(),
|
||||
));
|
||||
}
|
||||
Some(trimmed.to_owned())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
self.repository
|
||||
.update(car_id, personalnummer, plate.as_deref(), request.active)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt das `assigned_car_id` einer Lieferung. Validiert die
|
||||
/// Fahrzeug-Ownership und delegiert dann an `DeliveryRepository`.
|
||||
pub struct AssignCarToDeliveryUseCase {
|
||||
cars: Arc<dyn CarRepository>,
|
||||
deliveries: Arc<dyn crate::ports::DeliveryRepository>,
|
||||
}
|
||||
|
||||
impl AssignCarToDeliveryUseCase {
|
||||
pub fn new(
|
||||
cars: Arc<dyn CarRepository>,
|
||||
deliveries: Arc<dyn crate::ports::DeliveryRepository>,
|
||||
) -> Self {
|
||||
Self { cars, deliveries }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
personalnummer: i64,
|
||||
car_id: Option<Uuid>,
|
||||
) -> Result<holzleitner_domain::Delivery, ApplicationError> {
|
||||
if let Some(id) = car_id {
|
||||
self.cars
|
||||
.assert_owned_by_account(&[id], personalnummer)
|
||||
.await?;
|
||||
}
|
||||
self.deliveries.assign_car(delivery_id, car_id).await
|
||||
}
|
||||
}
|
||||
73
crates/application/src/usecases/create_delivery_note.rs
Normal file
73
crates/application/src/usecases/create_delivery_note.rs
Normal file
@ -0,0 +1,73 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::DeliveryNote;
|
||||
|
||||
use crate::dto::CreateDeliveryNoteRequest;
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{CarRepository, DeliveryNoteRepository};
|
||||
|
||||
/// Legt eine neue Notiz an einer Lieferung an.
|
||||
///
|
||||
/// Validierung:
|
||||
/// * mindestens eines von `text` (nicht-leer nach trim) und
|
||||
/// `image_attachment` (nicht-leer nach trim) muss gesetzt sein.
|
||||
/// * `author_car_id` muss — falls gesetzt — zum angemeldeten Account gehören.
|
||||
pub struct CreateDeliveryNoteUseCase {
|
||||
repository: Arc<dyn DeliveryNoteRepository>,
|
||||
cars: Arc<dyn CarRepository>,
|
||||
}
|
||||
|
||||
impl CreateDeliveryNoteUseCase {
|
||||
pub fn new(
|
||||
repository: Arc<dyn DeliveryNoteRepository>,
|
||||
cars: Arc<dyn CarRepository>,
|
||||
) -> Self {
|
||||
Self { repository, cars }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
author_personalnummer: i64,
|
||||
request: CreateDeliveryNoteRequest,
|
||||
) -> Result<DeliveryNote, ApplicationError> {
|
||||
let text = clean(request.text);
|
||||
let image = clean(request.image_attachment);
|
||||
|
||||
if text.is_none() && image.is_none() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"notiz braucht text oder image_attachment".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(car_id) = request.author_car_id {
|
||||
self.cars
|
||||
.assert_owned_by_account(&[car_id], author_personalnummer)
|
||||
.await?;
|
||||
}
|
||||
|
||||
self.repository
|
||||
.create(
|
||||
delivery_id,
|
||||
author_personalnummer,
|
||||
request.author_car_id,
|
||||
text,
|
||||
image,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim + leerer-String → None.
|
||||
fn clean(input: Option<String>) -> Option<String> {
|
||||
input.and_then(|s| {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_owned())
|
||||
}
|
||||
})
|
||||
}
|
||||
28
crates/application/src/usecases/get_account.rs
Normal file
28
crates/application/src/usecases/get_account.rs
Normal file
@ -0,0 +1,28 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use holzleitner_domain::Account;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::AccountRepository;
|
||||
|
||||
/// Liefert den Account zu einer Personalnummer oder einen
|
||||
/// [`ApplicationError::NotFound`], wenn nichts gefunden wurde.
|
||||
///
|
||||
/// Bewusst trivial — dient erstmal als End-to-End-Smoke-Test, damit
|
||||
/// alle Schichten zusammen laufen.
|
||||
pub struct GetAccountUseCase {
|
||||
repository: Arc<dyn AccountRepository>,
|
||||
}
|
||||
|
||||
impl GetAccountUseCase {
|
||||
pub fn new(repository: Arc<dyn AccountRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, personalnummer: i64) -> Result<Account, ApplicationError> {
|
||||
self.repository
|
||||
.find_by_personalnummer(personalnummer)
|
||||
.await?
|
||||
.ok_or(ApplicationError::NotFound)
|
||||
}
|
||||
}
|
||||
26
crates/application/src/usecases/get_tour.rs
Normal file
26
crates/application/src/usecases/get_tour.rs
Normal file
@ -0,0 +1,26 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::TourDetails;
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::TourRepository;
|
||||
|
||||
/// Liefert das vollständige Tour-Aggregat (Lieferungen, Positionen,
|
||||
/// Stammdaten) für eine Tour-Id. Genau ein Round-Trip für die App.
|
||||
pub struct GetTourUseCase {
|
||||
repository: Arc<dyn TourRepository>,
|
||||
}
|
||||
|
||||
impl GetTourUseCase {
|
||||
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, tour_id: Uuid) -> Result<TourDetails, ApplicationError> {
|
||||
self.repository
|
||||
.find_details_by_id(tour_id)
|
||||
.await?
|
||||
.ok_or(ApplicationError::NotFound)
|
||||
}
|
||||
}
|
||||
27
crates/application/src/usecases/list_my_tours_today.rs
Normal file
27
crates/application/src/usecases/list_my_tours_today.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::dto::TourSummary;
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::TourRepository;
|
||||
|
||||
/// Liste der heutigen Touren des angemeldeten Fahrers. Das "heute"
|
||||
/// liegt **bewusst im Backend**: die App-Uhr ist nicht autoritativ
|
||||
/// (Zeitzone, Falsch-Stand, Manipulation).
|
||||
pub struct ListMyToursTodayUseCase {
|
||||
repository: Arc<dyn TourRepository>,
|
||||
}
|
||||
|
||||
impl ListMyToursTodayUseCase {
|
||||
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, personalnummer: i64) -> Result<Vec<TourSummary>, ApplicationError> {
|
||||
let today = Utc::now().date_naive();
|
||||
self.repository
|
||||
.find_today_for_driver(personalnummer, today)
|
||||
.await
|
||||
}
|
||||
}
|
||||
28
crates/application/src/usecases/mod.rs
Normal file
28
crates/application/src/usecases/mod.rs
Normal file
@ -0,0 +1,28 @@
|
||||
//! Use Cases — Geschäftslogik-Operationen.
|
||||
//!
|
||||
//! Jeder Use Case kapselt **eine** Operation aus Sicht des Anwenders
|
||||
//! (z. B. „Tour des Tages laden", „Artikel scannen", „Lieferung
|
||||
//! abbrechen"). Use Cases nehmen Ports (Trait-Objekte) per Konstruktor
|
||||
//! entgegen und orchestrieren damit das Domänenmodell.
|
||||
|
||||
pub mod apply_delivery_action;
|
||||
pub mod apply_scans;
|
||||
pub mod cars;
|
||||
pub mod create_delivery_note;
|
||||
pub mod get_account;
|
||||
pub mod get_tour;
|
||||
pub mod list_my_tours_today;
|
||||
pub mod set_delivery_order;
|
||||
pub mod sync_tour;
|
||||
|
||||
pub use apply_delivery_action::ApplyDeliveryActionUseCase;
|
||||
pub use apply_scans::ApplyScansUseCase;
|
||||
pub use cars::{
|
||||
AssignCarToDeliveryUseCase, CreateMyCarUseCase, ListMyCarsUseCase, UpdateMyCarUseCase,
|
||||
};
|
||||
pub use create_delivery_note::CreateDeliveryNoteUseCase;
|
||||
pub use get_account::GetAccountUseCase;
|
||||
pub use get_tour::GetTourUseCase;
|
||||
pub use list_my_tours_today::ListMyToursTodayUseCase;
|
||||
pub use set_delivery_order::SetDeliveryOrderUseCase;
|
||||
pub use sync_tour::SyncTourUseCase;
|
||||
53
crates/application/src/usecases/set_delivery_order.rs
Normal file
53
crates/application/src/usecases/set_delivery_order.rs
Normal file
@ -0,0 +1,53 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{SetDeliveryOrderRequest, SetDeliveryOrderResponse};
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::TourRepository;
|
||||
|
||||
/// Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour neu.
|
||||
///
|
||||
/// Eingabe-Validierung (vor DB-Aufruf):
|
||||
/// * mindestens eine Id
|
||||
/// * keine Duplikate
|
||||
///
|
||||
/// Die Mengen-Übereinstimmung mit der Tour wird in der Persistence
|
||||
/// geprüft (braucht DB-Kontext).
|
||||
pub struct SetDeliveryOrderUseCase {
|
||||
repository: Arc<dyn TourRepository>,
|
||||
}
|
||||
|
||||
impl SetDeliveryOrderUseCase {
|
||||
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
tour_id: Uuid,
|
||||
request: SetDeliveryOrderRequest,
|
||||
) -> Result<SetDeliveryOrderResponse, ApplicationError> {
|
||||
if request.delivery_ids.is_empty() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"delivery_ids darf nicht leer sein".into(),
|
||||
));
|
||||
}
|
||||
let mut seen = HashSet::with_capacity(request.delivery_ids.len());
|
||||
for id in &request.delivery_ids {
|
||||
if !seen.insert(*id) {
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"delivery_id {id} kommt mehrfach vor"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let order = self
|
||||
.repository
|
||||
.set_delivery_order(tour_id, &request.delivery_ids)
|
||||
.await?;
|
||||
|
||||
Ok(SetDeliveryOrderResponse { tour_id, order })
|
||||
}
|
||||
}
|
||||
54
crates/application/src/usecases/sync_tour.rs
Normal file
54
crates/application/src/usecases/sync_tour.rs
Normal file
@ -0,0 +1,54 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::SyncTourRequest;
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::TourRepository;
|
||||
|
||||
/// Übernimmt eine Tagestour aus dem ERP. Idempotent: wiederholte Aufrufe
|
||||
/// mit gleichem `(driver_personalnummer, tour_date)` aktualisieren die
|
||||
/// bestehende Tour, statt eine neue anzulegen.
|
||||
///
|
||||
/// Validierung läuft hier in der Application-Schicht — die Repository-
|
||||
/// Schicht darf einen sauberen Datensatz erwarten.
|
||||
pub struct SyncTourUseCase {
|
||||
repository: Arc<dyn TourRepository>,
|
||||
}
|
||||
|
||||
impl SyncTourUseCase {
|
||||
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, request: SyncTourRequest) -> Result<Uuid, ApplicationError> {
|
||||
if request.deliveries.is_empty() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"tour ohne lieferungen abgelehnt".into(),
|
||||
));
|
||||
}
|
||||
for delivery in &request.deliveries {
|
||||
if delivery.belegnummer.trim().is_empty() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"leere belegnummer".into(),
|
||||
));
|
||||
}
|
||||
if delivery.items.is_empty() {
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"lieferung {} ohne positionen",
|
||||
delivery.belegnummer
|
||||
)));
|
||||
}
|
||||
for item in &delivery.items {
|
||||
if item.required_quantity <= 0 {
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"lieferung {}, position {}: required_quantity muss > 0 sein",
|
||||
delivery.belegnummer, item.belegzeilen_nr
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.repository.upsert_from_sync(&request).await
|
||||
}
|
||||
}
|
||||
18
crates/domain/Cargo.toml
Normal file
18
crates/domain/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "holzleitner-domain"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[features]
|
||||
# Opt-in: schaltet `utoipa::ToSchema`-Ableitungen für die OpenAPI-
|
||||
# Generierung in der API-Schicht frei. Konsumenten ohne OpenAPI-Bedarf
|
||||
# (z. B. CLI-Tools, Tests) bleiben utoipa-frei.
|
||||
openapi = ["dep:utoipa"]
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
utoipa = { workspace = true, optional = true }
|
||||
19
crates/domain/src/account.rs
Normal file
19
crates/domain/src/account.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Account eines Liefer-Unternehmens oder Einzel-Lieferfahrers.
|
||||
///
|
||||
/// Die Personalnummer ist sowohl Primärschlüssel als auch Login-ID. Sie
|
||||
/// stammt aus dem ERP-Stamm — entweder ein Unternehmen (juristische
|
||||
/// Person, eigener Personalnummern-Kreis) oder eine natürliche Person.
|
||||
///
|
||||
/// Mehrere physische Fahrer können denselben Account benutzen; das Modell
|
||||
/// unterscheidet sie nicht, sondern loggt die Aktivität auf [`crate::domain::Car`]-
|
||||
/// Ebene (siehe Audit-Log).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Account {
|
||||
pub personalnummer: i64,
|
||||
pub name: String,
|
||||
pub active: bool,
|
||||
}
|
||||
19
crates/domain/src/article.rs
Normal file
19
crates/domain/src/article.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Artikel. ERP-Mirror; die `article_number` ist die business-stabile
|
||||
/// Artikelnummer aus dem ERP-Stamm und dient gleichzeitig als Brücke.
|
||||
///
|
||||
/// `scannable = false` markiert nicht-physische Positionen wie
|
||||
/// Dienstleistungen, Versandpauschalen o.ä. — sie tauchen zwar als
|
||||
/// `DeliveryItem` auf, blockieren aber den Beladen-Fortschritt nicht.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Article {
|
||||
pub id: Uuid,
|
||||
pub article_number: String,
|
||||
pub name: String,
|
||||
pub scannable: bool,
|
||||
pub default_warehouse_id: Option<Uuid>,
|
||||
}
|
||||
67
crates/domain/src/audit.rs
Normal file
67
crates/domain/src/audit.rs
Normal file
@ -0,0 +1,67 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::delivery::ScanStatus;
|
||||
|
||||
/// Aktion-Typen im Scan-Audit-Log.
|
||||
///
|
||||
/// * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1).
|
||||
/// * `Hold` / `Unhold` ändern nur den Status, keine Menge.
|
||||
/// * `Remove` markiert die Position als entfernt (Status `Removed`,
|
||||
/// z. B. weil der Kunde sie nicht annimmt).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuditAction {
|
||||
Scan,
|
||||
Unscan,
|
||||
Hold,
|
||||
Unhold,
|
||||
Remove,
|
||||
}
|
||||
|
||||
/// Append-only Audit-Log-Eintrag: jedes Ereignis am Scan-Zustand einer
|
||||
/// Position bekommt eine eigene Zeile. Nie geupdated, nie gelöscht.
|
||||
///
|
||||
/// Beleg-Bezug wird denormalisiert mitgeführt, damit der Audit-Trail
|
||||
/// auch dann auflösbar bleibt, wenn das zugehörige `DeliveryItem`
|
||||
/// irgendwann archiviert oder bereinigt wird.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScanAuditEntry {
|
||||
pub id: Uuid,
|
||||
/// Vom Client vergebene UUID — Idempotenz-Schlüssel beim Retry.
|
||||
pub client_scan_id: Uuid,
|
||||
pub delivery_item_id: Uuid,
|
||||
|
||||
pub action: AuditAction,
|
||||
|
||||
/// Signed Δ in `scanned_quantity`: +1 bei SCAN, -1 bei UNSCAN, 0 sonst.
|
||||
pub delta: i32,
|
||||
|
||||
/// Snapshot der `scanned_quantity` direkt NACH dieser Aktion.
|
||||
/// Vermeidet teure Aggregat-Queries bei Reports.
|
||||
pub resulting_quantity: i32,
|
||||
|
||||
/// Status der Position NACH dieser Aktion.
|
||||
pub resulting_status: ScanStatus,
|
||||
|
||||
/// Grund bei HOLD / REMOVE (jeweils Pflicht).
|
||||
pub reason: Option<String>,
|
||||
|
||||
/// Akteur — Personalnummer aus dem JWT.
|
||||
pub actor_personalnummer: i64,
|
||||
/// Akteur-Fahrzeug, sofern bekannt (cars werden später verwaltet).
|
||||
pub actor_car_id: Option<Uuid>,
|
||||
|
||||
pub client_scanned_at: DateTime<Utc>,
|
||||
pub server_recorded_at: DateTime<Utc>,
|
||||
|
||||
// ── Denormalisierter ERP-Beleg-Bezug (Archiv-stabil) ───────────────
|
||||
pub erp_belegart_id: i64,
|
||||
pub erp_belegnummer: String,
|
||||
pub erp_belegzeilen_nr: i32,
|
||||
pub erp_komponenten_artikel_nr: Option<String>,
|
||||
}
|
||||
19
crates/domain/src/car.rs
Normal file
19
crates/domain/src/car.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Fahrzeug eines [`crate::domain::Account`]. Wird in der App selbst
|
||||
/// gepflegt — kein ERP-Spiegel. Eindeutig per UUID.
|
||||
///
|
||||
/// Im Audit-Log ist der `Car` der „Akteur": die Personalnummer-Ebene
|
||||
/// (Account) ist gröber und unterscheidet nicht zwischen mehreren
|
||||
/// gleichzeitig aktiven Fahrern desselben Subunternehmens.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Car {
|
||||
pub id: Uuid,
|
||||
/// Verweis auf [`crate::domain::Account::personalnummer`].
|
||||
pub account_id: i64,
|
||||
pub plate: String,
|
||||
pub active: bool,
|
||||
}
|
||||
20
crates/domain/src/common.rs
Normal file
20
crates/domain/src/common.rs
Normal file
@ -0,0 +1,20 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Postanschrift — wird sowohl als aktuelle Kundenanschrift in [`Customer`]
|
||||
/// als auch als unveränderlicher Snapshot in [`crate::domain::Delivery`]
|
||||
/// verwendet (`delivery_address_snapshot`).
|
||||
///
|
||||
/// Bewusst als Value Object modelliert: gleiche Adresse = gleicher Wert.
|
||||
/// Strikte Equality erleichtert Sync-Diffs zwischen ERP und Backend.
|
||||
///
|
||||
/// [`Customer`]: crate::domain::Customer
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Address {
|
||||
pub street: String,
|
||||
pub house_number: String,
|
||||
pub postal_code: String,
|
||||
pub city: String,
|
||||
pub country: String,
|
||||
}
|
||||
37
crates/domain/src/customer.rs
Normal file
37
crates/domain/src/customer.rs
Normal file
@ -0,0 +1,37 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::common::Address;
|
||||
|
||||
/// Kunde. ERP-Mirror: die Stammdaten gehören dem ERP, wir spiegeln sie
|
||||
/// für die App. Die `erp_customer_id` ist die Brücke zurück (in der
|
||||
/// Regel die `Kunde.row_id` aus ERPframe).
|
||||
///
|
||||
/// Die `Customer.address` ist die *aktuelle* Anschrift. Für historische
|
||||
/// Stabilität führt [`crate::domain::Delivery`] zusätzlich einen
|
||||
/// `delivery_address_snapshot` — Adress-Änderungen wirken nicht
|
||||
/// rückwirkend auf bereits zugestellte oder geplante Lieferungen.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Customer {
|
||||
pub id: Uuid,
|
||||
pub erp_customer_id: i64,
|
||||
pub name: String,
|
||||
pub address: Address,
|
||||
}
|
||||
|
||||
/// Ansprechpartner eines Kunden. Ein Kunde kann mehrere Kontaktpersonen
|
||||
/// haben (z. B. Empfang vor Ort + Geschäftsführung). Eine Lieferung wählt
|
||||
/// 0..N davon als aktive Kontakte aus (siehe
|
||||
/// `Delivery::contact_person_ids`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomerContact {
|
||||
pub id: Uuid,
|
||||
pub customer_id: Uuid,
|
||||
pub name: String,
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
135
crates/domain/src/delivery.rs
Normal file
135
crates/domain/src/delivery.rs
Normal file
@ -0,0 +1,135 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::common::Address;
|
||||
|
||||
/// Lebenszyklus einer Lieferung.
|
||||
///
|
||||
/// `Held` ist für „heute nicht zustellbar, aber nicht endgültig abgesagt"
|
||||
/// reserviert; `Canceled` ist endgültig. `Completed` setzt der
|
||||
/// Abschluss-Flow am Ende der Auslieferung.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeliveryState {
|
||||
Active,
|
||||
Held,
|
||||
Canceled,
|
||||
Completed,
|
||||
}
|
||||
|
||||
/// Eine einzelne Lieferung an einen Kunden. Aggregat-Wurzel für die
|
||||
/// Liefer-Items, Notizen und das ggf. zugeordnete Fahrzeug.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Delivery {
|
||||
pub id: Uuid,
|
||||
pub tour_id: Uuid,
|
||||
|
||||
/// ERP-Beleg-Bezug: business-stabiles Paar `(Belegart, Belegnummer)`.
|
||||
/// Überlebt den Belegkopf-Archivübergang.
|
||||
pub erp_belegart_id: i64,
|
||||
pub erp_belegnummer: String,
|
||||
|
||||
pub customer_id: Uuid,
|
||||
|
||||
/// Eingefrorene Liefer-Adresse zum Zeitpunkt des Tour-Syncs.
|
||||
/// Schützt vor rückwirkenden Kunden-Adressänderungen.
|
||||
pub delivery_address_snapshot: Address,
|
||||
|
||||
/// Fahrzeug-Zuordnung, gesetzt in der Auswählen-Phase.
|
||||
/// Bei Ein-Auto-Teams beim Sync automatisch gefüllt.
|
||||
pub assigned_car_id: Option<Uuid>,
|
||||
|
||||
/// Ausgewählte Ansprechpartner für genau diese Lieferung (Auswahl
|
||||
/// aus `Customer.contacts`). Kann leer sein.
|
||||
pub contact_person_ids: Vec<Uuid>,
|
||||
|
||||
/// Wunsch-Lieferzeit als Freitext (z. B. "vormittags", "ab 14:00").
|
||||
pub desired_time: Option<String>,
|
||||
|
||||
/// Sondervereinbarungen (z. B. „Türklingel defekt, hintenrum klopfen").
|
||||
pub special_agreements: Option<String>,
|
||||
|
||||
pub state: DeliveryState,
|
||||
|
||||
/// Begründung bei `state == Held` oder `state == Canceled`. Beim
|
||||
/// Resume / Complete wieder `None`.
|
||||
pub state_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Status einer einzelnen Scan-Position innerhalb eines Items.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScanStatus {
|
||||
InProgress,
|
||||
Done,
|
||||
Held,
|
||||
Removed,
|
||||
}
|
||||
|
||||
/// Eingebetteter Scan-Zustand pro [`DeliveryItem`]. Wird durch
|
||||
/// `ScanAuditEntry`-Events fortgeschrieben — das Audit-Log ist die
|
||||
/// Wahrheit über das WIE und WANN, dieses Embedded-VO ist die schnelle
|
||||
/// Wahrheit über das WIEVIEL.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScanState {
|
||||
pub scanned_quantity: i32,
|
||||
pub status: ScanStatus,
|
||||
/// Grund bei `status == Held` oder `status == Removed`.
|
||||
pub held_reason: Option<String>,
|
||||
pub last_updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Einzelposition einer Lieferung. Vereint reguläre Belegzeilen und
|
||||
/// Stücklisten-Komponenten zu einer flachen Liste — die Stücklisten-
|
||||
/// Hierarchie ist ein ERP-Konstrukt und wird beim Sync aufgelöst.
|
||||
///
|
||||
/// Über die Felder `belegzeilen_nr` und `komponenten_artikel_nr` bleibt
|
||||
/// die ERP-Herkunft auflösbar.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryItem {
|
||||
pub id: Uuid,
|
||||
pub delivery_id: Uuid,
|
||||
|
||||
pub article_id: Uuid,
|
||||
pub required_quantity: i32,
|
||||
pub warehouse_id: Uuid,
|
||||
|
||||
/// ERP-Belegzeilen-Nr (Position innerhalb des Belegs).
|
||||
pub belegzeilen_nr: i32,
|
||||
|
||||
/// Bei Items aus einer Stückliste: Artikelnummer der Komponente.
|
||||
/// Bei regulären Belegzeilen: `None`.
|
||||
pub komponenten_artikel_nr: Option<String>,
|
||||
|
||||
pub scan_state: ScanState,
|
||||
}
|
||||
|
||||
/// Notiz an einer Lieferung — frei eingegeben durch den Fahrer.
|
||||
///
|
||||
/// Mindestens eines von `text` oder `image_attachment` muss gesetzt
|
||||
/// sein. Die Constraint sitzt sowohl im DB-Schema (CHECK) als auch
|
||||
/// in der Application-Schicht.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryNote {
|
||||
pub id: Uuid,
|
||||
pub delivery_id: Uuid,
|
||||
pub text: Option<String>,
|
||||
/// Referenz auf einen Bild-Anhang (z. B. Object-Storage-Key/URL).
|
||||
pub image_attachment: Option<String>,
|
||||
/// Personalnummer des Akteurs (aus dem JWT). Pflicht.
|
||||
pub author_personalnummer: i64,
|
||||
/// Fahrzeug, falls bekannt — nullable bis das Backend Cars verwaltet.
|
||||
pub author_car_id: Option<Uuid>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
38
crates/domain/src/lib.rs
Normal file
38
crates/domain/src/lib.rs
Normal file
@ -0,0 +1,38 @@
|
||||
//! Domänenmodell des Lieferservice-Backends.
|
||||
//!
|
||||
//! Aufbau pro Aggregat-Wurzel bzw. zusammengehöriger Begriffsgruppe.
|
||||
//! Reihenfolge der Module entspricht inhaltlich der Datenfluss-Logik:
|
||||
//! Stamm- und Strukturdaten zuerst, danach transaktionale Aggregate
|
||||
//! (Tour → Delivery → Audit), zuletzt der prozessuale Zustand.
|
||||
//!
|
||||
//! **Konventionen**
|
||||
//! * Rust-Felder in `snake_case`, JSON via `serde` in `camelCase`.
|
||||
//! * Enum-Varianten in `PascalCase`, JSON via `serde` in `snake_case`.
|
||||
//! * Identifier vom ERP (Personalnummer, Belegart/-nummer, Artikelnummer)
|
||||
//! behalten ihre fachlichen Namen, weil sie als Brücken im Datenmodell
|
||||
//! erkennbar bleiben sollen.
|
||||
//! * Eigene IDs sind UUIDs — entkoppelt vom ERP, generieren wir selbst.
|
||||
|
||||
#![allow(dead_code)] // Modelle werden später von Service-Schicht genutzt.
|
||||
|
||||
mod account;
|
||||
mod article;
|
||||
mod audit;
|
||||
mod car;
|
||||
mod common;
|
||||
mod customer;
|
||||
mod delivery;
|
||||
mod process_state;
|
||||
mod tour;
|
||||
mod warehouse;
|
||||
|
||||
pub use account::Account;
|
||||
pub use article::Article;
|
||||
pub use audit::{AuditAction, ScanAuditEntry};
|
||||
pub use car::Car;
|
||||
pub use common::Address;
|
||||
pub use customer::{Customer, CustomerContact};
|
||||
pub use delivery::{Delivery, DeliveryItem, DeliveryNote, DeliveryState, ScanState, ScanStatus};
|
||||
pub use process_state::{DeliveryPhase, DeliveryProcessState};
|
||||
pub use tour::Tour;
|
||||
pub use warehouse::Warehouse;
|
||||
50
crates/domain/src/process_state.rs
Normal file
50
crates/domain/src/process_state.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Phasen des geführten Tagesablaufs eines Fahrers.
|
||||
///
|
||||
/// Die Reihenfolge der Varianten entspricht dem natürlichen Fortschritt;
|
||||
/// `PartialOrd`/`Ord` werden derived, sodass `current < max_reached`
|
||||
/// als „besucht"-Check funktioniert.
|
||||
///
|
||||
/// `Auswaehlen` ist optional und nur sichtbar bei Accounts mit mehreren
|
||||
/// Fahrzeugen — die Eintrittsphase bei einem Ein-Auto-Account ist
|
||||
/// `Sortieren`.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord,
|
||||
)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeliveryPhase {
|
||||
Auswaehlen,
|
||||
Sortieren,
|
||||
Beladen,
|
||||
Ausliefern,
|
||||
}
|
||||
|
||||
/// Prozess-State pro Fahrzeug pro Tag. Trägt sowohl die aktuelle Phase
|
||||
/// als auch die höchste je erreichte Phase — damit Rück- und
|
||||
/// Vorwärtssprünge zwischen besuchten Phasen erlaubt sind.
|
||||
///
|
||||
/// `sorting_order` ist die in der Sortier-Phase festgelegte
|
||||
/// Auslieferungsreihenfolge (Liste von [`crate::domain::Delivery`]-IDs).
|
||||
/// Bei der Beladung wird sie umgekehrt benutzt (letzte Lieferung zuerst
|
||||
/// ins Auto).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryProcessState {
|
||||
pub id: Uuid,
|
||||
pub car_id: Uuid,
|
||||
pub date: NaiveDate,
|
||||
|
||||
pub current_phase: DeliveryPhase,
|
||||
pub max_reached_phase: DeliveryPhase,
|
||||
|
||||
pub sorting_order: Vec<Uuid>,
|
||||
|
||||
/// Gesetzt sobald die Sortierung bestätigt wurde — markiert den
|
||||
/// Übergang von `Sortieren` nach `Beladen` als „offiziell".
|
||||
pub confirmed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
20
crates/domain/src/tour.rs
Normal file
20
crates/domain/src/tour.rs
Normal file
@ -0,0 +1,20 @@
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Tour eines Tages, pro [`crate::domain::Account`]. Aggregat-Wurzel
|
||||
/// für die Lieferungen dieses Tages — die einzelnen [`crate::domain::Delivery`]
|
||||
/// referenzieren ihre Tour per FK.
|
||||
///
|
||||
/// Der Sync vom ERP läuft in der Regel einmal am Vortag und füllt eine
|
||||
/// neue Tour-Zeile inklusive Delivery- und DeliveryItem-Strukturen.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tour {
|
||||
pub id: Uuid,
|
||||
pub account_id: i64,
|
||||
pub date: NaiveDate,
|
||||
/// Zeitpunkt des letzten ERP-Sync — für Drift-Erkennung.
|
||||
pub synced_at: DateTime<Utc>,
|
||||
}
|
||||
15
crates/domain/src/warehouse.rs
Normal file
15
crates/domain/src/warehouse.rs
Normal file
@ -0,0 +1,15 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Lager. ERP-Mirror; `code` ist die ERP-Lager-Nr (z. B. `"0"` für das
|
||||
/// Standardlager). Das `is_standard`-Flag ist der schnelle Filter für
|
||||
/// die Beladen-Logik („nur Standardlager-Artikel zählen für Fertig").
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Warehouse {
|
||||
pub id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub is_standard: bool,
|
||||
}
|
||||
21
crates/infrastructure/Cargo.toml
Normal file
21
crates/infrastructure/Cargo.toml
Normal file
@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "holzleitner-infrastructure"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
holzleitner-domain.workspace = true
|
||||
holzleitner-application.workspace = true
|
||||
async-trait.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tokio.workspace = true
|
||||
sqlx.workspace = true
|
||||
reqwest.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
tracing.workspace = true
|
||||
241
crates/infrastructure/src/auth/keycloak.rs
Normal file
241
crates/infrastructure/src/auth/keycloak.rs
Normal file
@ -0,0 +1,241 @@
|
||||
//! Keycloak-Implementierung von [`AuthService`].
|
||||
//!
|
||||
//! ## Verifikations-Flow
|
||||
//!
|
||||
//! 1. OIDC-Discovery (`{issuer}/.well-known/openid-configuration`) holt
|
||||
//! einmalig den `jwks_uri`. Wird gecached.
|
||||
//! 2. JWKS (`jwks_uri`) liefert die aktuellen Signatur-Schlüssel. Cache
|
||||
//! läuft nach `jwks_cache_ttl` ab, oder wird beim ersten unbekannten
|
||||
//! `kid` proaktiv aktualisiert (Key-Rotation).
|
||||
//! 3. JWT wird mit `jsonwebtoken` validiert: Signatur, `exp`, `iss`,
|
||||
//! `aud`. Anschließend werden die fachlichen Claims extrahiert.
|
||||
//!
|
||||
//! ## Concurrency
|
||||
//!
|
||||
//! Der Cache hängt hinter `Arc<RwLock<...>>` (Tokio). Lese-Zugriffe
|
||||
//! laufen lock-free auf Snapshot-Daten, Schreibzugriffe sind selten
|
||||
//! (nur beim Refresh).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use holzleitner_application::ports::{AuthError, AuthService, Claims};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Konfiguration für den Keycloak-Adapter. Befüllt typischerweise aus
|
||||
/// der API-Konfig (`KEYCLOAK_*`-Env-Vars).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeycloakAdapterConfig {
|
||||
/// Voll-qualifizierter Issuer (Realm-URL), z. B.
|
||||
/// `http://localhost:8080/realms/holzleitner`. **Ohne** trailing slash.
|
||||
pub issuer_url: String,
|
||||
/// Erwartete Audience im Access-Token (= Client/Resource-Name).
|
||||
pub audience: String,
|
||||
/// Zeit, bevor der lokale JWKS-Cache zwangsweise neu gezogen wird.
|
||||
pub jwks_cache_ttl: Duration,
|
||||
}
|
||||
|
||||
pub struct KeycloakAuthService {
|
||||
config: KeycloakAdapterConfig,
|
||||
http: reqwest::Client,
|
||||
cache: Arc<RwLock<JwksCache>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct JwksCache {
|
||||
/// `kid` → fertige DecodingKey zur Verifikation.
|
||||
keys: HashMap<String, DecodingKey>,
|
||||
/// Zeitpunkt des letzten erfolgreichen Refresh.
|
||||
fetched_at: Option<Instant>,
|
||||
/// Aus OIDC-Discovery aufgelöste JWKS-URL — wird gemerkt.
|
||||
jwks_uri: Option<String>,
|
||||
}
|
||||
|
||||
impl KeycloakAuthService {
|
||||
pub fn new(config: KeycloakAdapterConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
http: reqwest::Client::new(),
|
||||
cache: Arc::new(RwLock::new(JwksCache::default())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover_jwks_uri(&self) -> Result<String, AuthError> {
|
||||
let url = format!(
|
||||
"{}/.well-known/openid-configuration",
|
||||
self.config.issuer_url.trim_end_matches('/')
|
||||
);
|
||||
let doc: OidcDiscovery = self
|
||||
.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AuthError::External(format!("OIDC discovery request: {e}")))?
|
||||
.error_for_status()
|
||||
.map_err(|e| AuthError::External(format!("OIDC discovery status: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AuthError::External(format!("OIDC discovery body: {e}")))?;
|
||||
Ok(doc.jwks_uri)
|
||||
}
|
||||
|
||||
async fn refresh_jwks(&self) -> Result<(), AuthError> {
|
||||
// jwks_uri einmal auflösen und merken.
|
||||
let jwks_uri = {
|
||||
let read = self.cache.read().await;
|
||||
read.jwks_uri.clone()
|
||||
};
|
||||
let jwks_uri = match jwks_uri {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
let u = self.discover_jwks_uri().await?;
|
||||
self.cache.write().await.jwks_uri = Some(u.clone());
|
||||
u
|
||||
}
|
||||
};
|
||||
|
||||
let jwks: Jwks = self
|
||||
.http
|
||||
.get(&jwks_uri)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AuthError::External(format!("JWKS request: {e}")))?
|
||||
.error_for_status()
|
||||
.map_err(|e| AuthError::External(format!("JWKS status: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AuthError::External(format!("JWKS body: {e}")))?;
|
||||
|
||||
let mut next = HashMap::new();
|
||||
for k in jwks.keys {
|
||||
// Wir unterstützen erstmal nur RSA (Keycloak-Default).
|
||||
if k.kty != "RSA" {
|
||||
continue;
|
||||
}
|
||||
let Some(kid) = k.kid else { continue };
|
||||
let key = DecodingKey::from_rsa_components(&k.n, &k.e)
|
||||
.map_err(|e| AuthError::External(format!("RSA key decode: {e}")))?;
|
||||
next.insert(kid, key);
|
||||
}
|
||||
|
||||
let mut write = self.cache.write().await;
|
||||
write.keys = next;
|
||||
write.fetched_at = Some(Instant::now());
|
||||
tracing::debug!(count = write.keys.len(), "jwks cache refreshed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn key_for_kid(&self, kid: &str) -> Result<DecodingKey, AuthError> {
|
||||
// 1) Lese-Pfad: wenn Cache frisch und kid bekannt → lock-free zurück.
|
||||
{
|
||||
let read = self.cache.read().await;
|
||||
let expired = match read.fetched_at {
|
||||
Some(t) => t.elapsed() > self.config.jwks_cache_ttl,
|
||||
None => true,
|
||||
};
|
||||
if !expired {
|
||||
if let Some(k) = read.keys.get(kid) {
|
||||
return Ok(k.clone());
|
||||
}
|
||||
// Cache frisch, aber kid nicht drin → Key-Rotation?
|
||||
// Trotzdem refreshen, könnte ein neuer Key sein.
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Refresh und nochmals nachsehen.
|
||||
self.refresh_jwks().await?;
|
||||
let read = self.cache.read().await;
|
||||
read.keys
|
||||
.get(kid)
|
||||
.cloned()
|
||||
.ok_or_else(|| AuthError::Invalid(format!("unknown kid: {kid}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthService for KeycloakAuthService {
|
||||
async fn verify_token(&self, bearer_token: &str) -> Result<Claims, AuthError> {
|
||||
let header = decode_header(bearer_token)
|
||||
.map_err(|e| AuthError::Invalid(format!("header parse: {e}")))?;
|
||||
let kid = header
|
||||
.kid
|
||||
.ok_or_else(|| AuthError::Invalid("missing kid".into()))?;
|
||||
|
||||
let key = self.key_for_kid(&kid).await?;
|
||||
|
||||
let mut validation = Validation::new(Algorithm::RS256);
|
||||
validation.set_audience(&[&self.config.audience]);
|
||||
validation.set_issuer(&[&self.config.issuer_url]);
|
||||
validation.validate_exp = true;
|
||||
|
||||
let token = decode::<RawClaims>(bearer_token, &key, &validation).map_err(|e| {
|
||||
match e.kind() {
|
||||
jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::Expired,
|
||||
jsonwebtoken::errors::ErrorKind::InvalidAudience => AuthError::InvalidAudience,
|
||||
_ => AuthError::Invalid(format!("verify: {e}")),
|
||||
}
|
||||
})?;
|
||||
|
||||
let raw = token.claims;
|
||||
let personalnummer = raw
|
||||
.personalnummer
|
||||
.ok_or(AuthError::MissingClaim("personalnummer"))?;
|
||||
let roles = raw
|
||||
.realm_access
|
||||
.map(|r| r.roles)
|
||||
.unwrap_or_default();
|
||||
let expires_at = Utc
|
||||
.timestamp_opt(raw.exp, 0)
|
||||
.single()
|
||||
.ok_or_else(|| AuthError::Invalid("invalid exp".into()))?;
|
||||
|
||||
Ok(Claims {
|
||||
personalnummer,
|
||||
subject: raw.sub,
|
||||
roles,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// JSON-Hilfstypen
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OidcDiscovery {
|
||||
jwks_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Jwks {
|
||||
keys: Vec<JwksKey>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JwksKey {
|
||||
kid: Option<String>,
|
||||
kty: String,
|
||||
n: String,
|
||||
e: String,
|
||||
}
|
||||
|
||||
/// Rohformat der Token-Claims direkt aus dem JWT-Body. Wird unmittelbar
|
||||
/// danach in das fachliche [`Claims`]-Struct übersetzt.
|
||||
#[derive(Deserialize)]
|
||||
struct RawClaims {
|
||||
sub: String,
|
||||
exp: i64,
|
||||
personalnummer: Option<i64>,
|
||||
realm_access: Option<RealmAccess>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RealmAccess {
|
||||
roles: Vec<String>,
|
||||
}
|
||||
10
crates/infrastructure/src/auth/mod.rs
Normal file
10
crates/infrastructure/src/auth/mod.rs
Normal file
@ -0,0 +1,10 @@
|
||||
//! Auth-Adapter: Keycloak OAuth2/OIDC.
|
||||
//!
|
||||
//! Implementiert den `AuthService`-Port aus
|
||||
//! `holzleitner_application::ports`. Discovery via OIDC-`/.well-known/`-
|
||||
//! Endpunkt, JWT-Verifikation gegen den JWKS-Endpunkt, Claims-Mapping
|
||||
//! auf das Domänenmodell (Personalnummer, Rollen).
|
||||
|
||||
pub mod keycloak;
|
||||
|
||||
pub use keycloak::{KeycloakAdapterConfig, KeycloakAuthService};
|
||||
13
crates/infrastructure/src/lib.rs
Normal file
13
crates/infrastructure/src/lib.rs
Normal file
@ -0,0 +1,13 @@
|
||||
//! Infrastructure Layer — konkrete Adapter zu externen Systemen.
|
||||
//!
|
||||
//! Implementiert die in `holzleitner_application::ports` definierten
|
||||
//! Traits gegen reale Technologien:
|
||||
//! * [`persistence`] — Postgres via sqlx
|
||||
//! * [`auth`] — Keycloak OAuth2/OIDC via reqwest + jsonwebtoken
|
||||
//!
|
||||
//! Wer hier was Neues anhängt (z. B. einen Object-Storage-Adapter für
|
||||
//! Note-Bilder), legt ein neues Submodul an und implementiert dort die
|
||||
//! passenden Application-Ports.
|
||||
|
||||
pub mod auth;
|
||||
pub mod persistence;
|
||||
54
crates/infrastructure/src/persistence/account_repository.rs
Normal file
54
crates/infrastructure/src/persistence/account_repository.rs
Normal file
@ -0,0 +1,54 @@
|
||||
use async_trait::async_trait;
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::AccountRepository;
|
||||
use holzleitner_domain::Account;
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// SQLx-Postgres-Implementierung von [`AccountRepository`].
|
||||
pub struct PgAccountRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgAccountRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
/// Tabellen-Zeile aus `accounts`. Eigener Typ in der Infrastructure-
|
||||
/// Schicht, damit der Domain-Typ [`Account`] frei von `sqlx`-Traits
|
||||
/// bleibt.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AccountRow {
|
||||
personalnummer: i64,
|
||||
name: String,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl From<AccountRow> for Account {
|
||||
fn from(row: AccountRow) -> Self {
|
||||
Account {
|
||||
personalnummer: row.personalnummer,
|
||||
name: row.name,
|
||||
active: row.active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountRepository for PgAccountRepository {
|
||||
async fn find_by_personalnummer(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
) -> Result<Option<Account>, ApplicationError> {
|
||||
let row = sqlx::query_as::<_, AccountRow>(
|
||||
"SELECT personalnummer, name, active FROM accounts WHERE personalnummer = $1",
|
||||
)
|
||||
.bind(personalnummer)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(e.to_string()))?;
|
||||
|
||||
Ok(row.map(Account::from))
|
||||
}
|
||||
}
|
||||
191
crates/infrastructure/src/persistence/car_repository.rs
Normal file
191
crates/infrastructure/src/persistence/car_repository.rs
Normal file
@ -0,0 +1,191 @@
|
||||
//! Postgres-Implementierung des `CarRepository`-Ports.
|
||||
//!
|
||||
//! Account-Isolation ist strukturell: jede SQL-Query filtert auf
|
||||
//! `account_id`. Ein Fahrzeug, das einem anderen Account gehört, ist
|
||||
//! aus Sicht dieses Repositories nicht existent (`NotFound`).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::CarRepository;
|
||||
use holzleitner_domain::Car;
|
||||
|
||||
pub struct PgCarRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgCarRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CarRow {
|
||||
id: Uuid,
|
||||
account_id: i64,
|
||||
plate: String,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl From<CarRow> for Car {
|
||||
fn from(r: CarRow) -> Self {
|
||||
Car {
|
||||
id: r.id,
|
||||
account_id: r.account_id,
|
||||
plate: r.plate,
|
||||
active: r.active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
/// Postgres-Fehlercode für UNIQUE-Verletzungen — wir mappen sie auf
|
||||
/// einen lesbaren `Validation`-Fehler.
|
||||
fn is_unique_violation(err: &sqlx::Error) -> bool {
|
||||
matches!(
|
||||
err,
|
||||
sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23505")
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CarRepository for PgCarRepository {
|
||||
async fn find_by_account(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
include_inactive: bool,
|
||||
) -> Result<Vec<Car>, ApplicationError> {
|
||||
let rows = sqlx::query_as::<_, CarRow>(
|
||||
r#"
|
||||
SELECT id, account_id, plate, active
|
||||
FROM cars
|
||||
WHERE account_id = $1
|
||||
AND (active = TRUE OR $2)
|
||||
ORDER BY plate
|
||||
"#,
|
||||
)
|
||||
.bind(personalnummer)
|
||||
.bind(include_inactive)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(rows.into_iter().map(Car::from).collect())
|
||||
}
|
||||
|
||||
async fn find_by_id_for_account(
|
||||
&self,
|
||||
car_id: Uuid,
|
||||
personalnummer: i64,
|
||||
) -> Result<Option<Car>, ApplicationError> {
|
||||
let row = sqlx::query_as::<_, CarRow>(
|
||||
"SELECT id, account_id, plate, active FROM cars WHERE id = $1 AND account_id = $2",
|
||||
)
|
||||
.bind(car_id)
|
||||
.bind(personalnummer)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(row.map(Car::from))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
plate: &str,
|
||||
) -> Result<Car, ApplicationError> {
|
||||
let result = sqlx::query_as::<_, CarRow>(
|
||||
r#"
|
||||
INSERT INTO cars (account_id, plate)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, account_id, plate, active
|
||||
"#,
|
||||
)
|
||||
.bind(personalnummer)
|
||||
.bind(plate)
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(row) => Ok(Car::from(row)),
|
||||
Err(err) if is_unique_violation(&err) => Err(ApplicationError::Validation(format!(
|
||||
"kennzeichen '{plate}' existiert bereits"
|
||||
))),
|
||||
Err(err) => Err(db(err)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
car_id: Uuid,
|
||||
personalnummer: i64,
|
||||
plate: Option<&str>,
|
||||
active: Option<bool>,
|
||||
) -> Result<Car, ApplicationError> {
|
||||
// COALESCE-Pattern: NULL-Argumente lassen die Spalte unverändert.
|
||||
let result = sqlx::query_as::<_, CarRow>(
|
||||
r#"
|
||||
UPDATE cars
|
||||
SET plate = COALESCE($3, plate),
|
||||
active = COALESCE($4, active)
|
||||
WHERE id = $1 AND account_id = $2
|
||||
RETURNING id, account_id, plate, active
|
||||
"#,
|
||||
)
|
||||
.bind(car_id)
|
||||
.bind(personalnummer)
|
||||
.bind(plate)
|
||||
.bind(active)
|
||||
.fetch_optional(&self.pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Some(row)) => Ok(Car::from(row)),
|
||||
Ok(None) => Err(ApplicationError::NotFound),
|
||||
Err(err) if is_unique_violation(&err) => Err(ApplicationError::Validation(
|
||||
"kennzeichen existiert bereits".into(),
|
||||
)),
|
||||
Err(err) => Err(db(err)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_owned_by_account(
|
||||
&self,
|
||||
car_ids: &[Uuid],
|
||||
personalnummer: i64,
|
||||
) -> Result<(), ApplicationError> {
|
||||
if car_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let owned: Vec<Uuid> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT id FROM cars
|
||||
WHERE id = ANY($1) AND account_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(car_ids)
|
||||
.bind(personalnummer)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
if owned.len() == car_ids.len() {
|
||||
Ok(())
|
||||
} else {
|
||||
let owned_set: std::collections::HashSet<Uuid> = owned.into_iter().collect();
|
||||
let foreign: Vec<Uuid> = car_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !owned_set.contains(id))
|
||||
.collect();
|
||||
Err(ApplicationError::Validation(format!(
|
||||
"fahrzeug(e) {foreign:?} gehören nicht zu diesem account"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
//! Postgres-Implementierung des `DeliveryNoteRepository`-Ports.
|
||||
//!
|
||||
//! Existenz-Check der Lieferung vor dem Insert: liefert einen sauberen
|
||||
//! `NotFound`, statt den FK-Verletzungs-Fehlertext durchzureichen.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::DeliveryNoteRepository;
|
||||
use holzleitner_domain::DeliveryNote;
|
||||
|
||||
pub struct PgDeliveryNoteRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryNoteRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||
async fn create(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
text: Option<String>,
|
||||
image_attachment: Option<String>,
|
||||
) -> Result<DeliveryNote, ApplicationError> {
|
||||
let exists: Option<Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM deliveries WHERE id = $1")
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
if exists.is_none() {
|
||||
return Err(ApplicationError::NotFound);
|
||||
}
|
||||
|
||||
let (id, created_at): (Uuid, DateTime<Utc>) = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO delivery_notes (
|
||||
delivery_id, text, image_attachment, author_personalnummer, author_car_id
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(text.as_deref())
|
||||
.bind(image_attachment.as_deref())
|
||||
.bind(author_personalnummer)
|
||||
.bind(author_car_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(DeliveryNote {
|
||||
id,
|
||||
delivery_id,
|
||||
text,
|
||||
image_attachment,
|
||||
author_personalnummer,
|
||||
author_car_id,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
250
crates/infrastructure/src/persistence/delivery_repository.rs
Normal file
250
crates/infrastructure/src/persistence/delivery_repository.rs
Normal file
@ -0,0 +1,250 @@
|
||||
//! Postgres-Implementierung des `DeliveryRepository`-Ports.
|
||||
//!
|
||||
//! Ein Aktionsaufruf entspricht genau einer Transaktion. Ablauf:
|
||||
//! 1. `SELECT … FOR UPDATE` auf die Lieferung (Lock + aktueller State).
|
||||
//! 2. Übergang validieren — bei invalider Quelle `Validation`-Fehler.
|
||||
//! 3. `UPDATE deliveries SET state = …, state_reason = …`.
|
||||
//! 4. Kontaktpersonen-Verknüpfungen laden, frische `Delivery` bauen.
|
||||
//!
|
||||
//! Die Validierung lebt **hier**, weil sie den aktuellen DB-State
|
||||
//! braucht. Im Use Case sitzt nur die Pflicht-Reason-Prüfung.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{DeliveryAction, DeliveryRepository};
|
||||
use holzleitner_domain::{Address, Delivery, DeliveryState};
|
||||
|
||||
pub struct PgDeliveryRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeliveryRow {
|
||||
id: Uuid,
|
||||
tour_id: Uuid,
|
||||
erp_belegart_id: i64,
|
||||
erp_belegnummer: String,
|
||||
customer_id: Uuid,
|
||||
snap_street: String,
|
||||
snap_house_number: String,
|
||||
snap_postal_code: String,
|
||||
snap_city: String,
|
||||
snap_country: String,
|
||||
assigned_car_id: Option<Uuid>,
|
||||
desired_time: Option<String>,
|
||||
special_agreements: Option<String>,
|
||||
state: String,
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
fn parse_state(value: &str) -> Result<DeliveryState, ApplicationError> {
|
||||
match value {
|
||||
"active" => Ok(DeliveryState::Active),
|
||||
"held" => Ok(DeliveryState::Held),
|
||||
"canceled" => Ok(DeliveryState::Canceled),
|
||||
"completed" => Ok(DeliveryState::Completed),
|
||||
other => Err(ApplicationError::Repository(format!(
|
||||
"unknown delivery state '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn state_str(s: DeliveryState) -> &'static str {
|
||||
match s {
|
||||
DeliveryState::Active => "active",
|
||||
DeliveryState::Held => "held",
|
||||
DeliveryState::Canceled => "canceled",
|
||||
DeliveryState::Completed => "completed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Berechnet Ziel-State + neuen Reason aus aktueller Quelle und Aktion.
|
||||
/// `Err(reason)` bei invalidem Übergang — wird vom Use Case als
|
||||
/// `ApplicationError::Validation` durchgereicht.
|
||||
fn next_state(
|
||||
current: DeliveryState,
|
||||
action: &DeliveryAction,
|
||||
) -> Result<(DeliveryState, Option<String>), String> {
|
||||
use DeliveryAction as A;
|
||||
use DeliveryState as S;
|
||||
match (current, action) {
|
||||
(S::Active, A::Hold { reason }) => Ok((S::Held, Some(reason.clone()))),
|
||||
(S::Held, A::Resume) => Ok((S::Active, None)),
|
||||
(S::Active | S::Held, A::Cancel { reason }) => Ok((S::Canceled, Some(reason.clone()))),
|
||||
(S::Active, A::Complete) => Ok((S::Completed, None)),
|
||||
|
||||
(S::Completed, _) => Err("delivery ist completed".into()),
|
||||
(S::Canceled, _) => Err("delivery ist canceled".into()),
|
||||
(s, a) => Err(format!(
|
||||
"invalider übergang {:?} aus state {:?}",
|
||||
a, s
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn lock_delivery(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Option<DeliveryRow>, ApplicationError> {
|
||||
sqlx::query_as::<_, DeliveryRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||
assigned_car_id, desired_time, special_agreements,
|
||||
state
|
||||
FROM deliveries
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(db)
|
||||
}
|
||||
|
||||
async fn load_contacts(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Vec<Uuid>, ApplicationError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT customer_contact_id FROM delivery_contact_persons WHERE delivery_id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryRepository for PgDeliveryRepository {
|
||||
async fn apply_action(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
action: DeliveryAction,
|
||||
) -> Result<Delivery, ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
|
||||
let Some(row) = lock_delivery(&mut tx, delivery_id).await? else {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::NotFound);
|
||||
};
|
||||
|
||||
let current = parse_state(&row.state)?;
|
||||
let (target, new_reason) = match next_state(current, &action) {
|
||||
Ok(v) => v,
|
||||
Err(reason) => {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(reason));
|
||||
}
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE deliveries
|
||||
SET state = $1,
|
||||
state_reason = $2
|
||||
WHERE id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(state_str(target))
|
||||
.bind(new_reason.as_deref())
|
||||
.bind(delivery_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let contact_person_ids = load_contacts(&mut tx, delivery_id).await?;
|
||||
tx.commit().await.map_err(db)?;
|
||||
|
||||
Ok(Delivery {
|
||||
id: row.id,
|
||||
tour_id: row.tour_id,
|
||||
erp_belegart_id: row.erp_belegart_id,
|
||||
erp_belegnummer: row.erp_belegnummer,
|
||||
customer_id: row.customer_id,
|
||||
delivery_address_snapshot: Address {
|
||||
street: row.snap_street,
|
||||
house_number: row.snap_house_number,
|
||||
postal_code: row.snap_postal_code,
|
||||
city: row.snap_city,
|
||||
country: row.snap_country,
|
||||
},
|
||||
assigned_car_id: row.assigned_car_id,
|
||||
contact_person_ids,
|
||||
desired_time: row.desired_time,
|
||||
special_agreements: row.special_agreements,
|
||||
state: target,
|
||||
state_reason: new_reason,
|
||||
})
|
||||
}
|
||||
|
||||
async fn assign_car(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
car_id: Option<Uuid>,
|
||||
) -> Result<Delivery, ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
|
||||
let Some(row) = lock_delivery(&mut tx, delivery_id).await? else {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::NotFound);
|
||||
};
|
||||
|
||||
let current = parse_state(&row.state)?;
|
||||
|
||||
sqlx::query("UPDATE deliveries SET assigned_car_id = $1 WHERE id = $2")
|
||||
.bind(car_id)
|
||||
.bind(delivery_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// state_reason holen wir aus dem Lock-Row — die Aktion ändert
|
||||
// nichts daran.
|
||||
let state_reason: Option<String> =
|
||||
sqlx::query_scalar("SELECT state_reason FROM deliveries WHERE id = $1")
|
||||
.bind(delivery_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let contact_person_ids = load_contacts(&mut tx, delivery_id).await?;
|
||||
tx.commit().await.map_err(db)?;
|
||||
|
||||
Ok(Delivery {
|
||||
id: row.id,
|
||||
tour_id: row.tour_id,
|
||||
erp_belegart_id: row.erp_belegart_id,
|
||||
erp_belegnummer: row.erp_belegnummer,
|
||||
customer_id: row.customer_id,
|
||||
delivery_address_snapshot: Address {
|
||||
street: row.snap_street,
|
||||
house_number: row.snap_house_number,
|
||||
postal_code: row.snap_postal_code,
|
||||
city: row.snap_city,
|
||||
country: row.snap_country,
|
||||
},
|
||||
assigned_car_id: car_id,
|
||||
contact_person_ids,
|
||||
desired_time: row.desired_time,
|
||||
special_agreements: row.special_agreements,
|
||||
state: current,
|
||||
state_reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
21
crates/infrastructure/src/persistence/mod.rs
Normal file
21
crates/infrastructure/src/persistence/mod.rs
Normal file
@ -0,0 +1,21 @@
|
||||
//! Persistence-Adapter: Postgres via sqlx.
|
||||
//!
|
||||
//! Jede Repository-Trait aus `holzleitner_application::ports` bekommt
|
||||
//! hier eine konkrete `Pg…Repository`-Implementierung. Connection-Pool
|
||||
//! und Migrations werden ebenfalls hier verwaltet.
|
||||
|
||||
pub mod account_repository;
|
||||
pub mod car_repository;
|
||||
pub mod delivery_note_repository;
|
||||
pub mod delivery_repository;
|
||||
pub mod pool;
|
||||
pub mod scan_repository;
|
||||
pub mod tour_repository;
|
||||
|
||||
pub use account_repository::PgAccountRepository;
|
||||
pub use car_repository::PgCarRepository;
|
||||
pub use delivery_note_repository::PgDeliveryNoteRepository;
|
||||
pub use delivery_repository::PgDeliveryRepository;
|
||||
pub use pool::{connect_and_migrate, PoolConfig};
|
||||
pub use scan_repository::PgScanRepository;
|
||||
pub use tour_repository::PgTourRepository;
|
||||
26
crates/infrastructure/src/persistence/pool.rs
Normal file
26
crates/infrastructure/src/persistence/pool.rs
Normal file
@ -0,0 +1,26 @@
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
/// Konfigurationsparameter für den Postgres-Connection-Pool. Wird
|
||||
/// typischerweise aus der API-Schicht (Composition Root) befüllt.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoolConfig {
|
||||
pub url: String,
|
||||
pub max_connections: u32,
|
||||
}
|
||||
|
||||
/// Baut den Postgres-Pool auf und führt alle ausstehenden Migrations
|
||||
/// aus dem Workspace-Migrations-Verzeichnis aus.
|
||||
///
|
||||
/// Die Migration-Definition ist über `sqlx::migrate!()` zur Compile-Zeit
|
||||
/// eingebettet — kein zusätzlicher Dateizugriff zur Laufzeit nötig.
|
||||
pub async fn connect_and_migrate(config: &PoolConfig) -> Result<PgPool, sqlx::Error> {
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(config.max_connections)
|
||||
.connect(&config.url)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!("../../migrations").run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
319
crates/infrastructure/src/persistence/scan_repository.rs
Normal file
319
crates/infrastructure/src/persistence/scan_repository.rs
Normal file
@ -0,0 +1,319 @@
|
||||
//! Postgres-Implementierung des `ScanRepository`-Ports.
|
||||
//!
|
||||
//! Pro Event eine eigene Transaktion. Ablauf:
|
||||
//! 1. `SELECT … FOR UPDATE` auf das Item (Lock + aktueller State).
|
||||
//! 2. Zustandsübergang berechnen oder mit `Rejected` aussteigen.
|
||||
//! 3. `INSERT INTO scan_audit … ON CONFLICT DO NOTHING RETURNING id`
|
||||
//! * Hat die Insert eine Zeile geliefert → Audit ist frisch,
|
||||
//! `UPDATE delivery_items` mit neuem State, commit, `Applied`.
|
||||
//! * Sonst → `client_scan_id` war schon da, rollback der Tx mit dem
|
||||
//! unveränderten Item, `Duplicate` mit dem unter (1) gelesenen State.
|
||||
//!
|
||||
//! Race-frei: zwei parallele Requests mit identischem `client_scan_id`
|
||||
//! kollidieren an der UNIQUE-Constraint und bekommen sauber genau einen
|
||||
//! `Applied` und einen `Duplicate`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::dto::ScanEvent;
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{ApplyScanOutcome, ScanRepository};
|
||||
use holzleitner_domain::{AuditAction, ScanState, ScanStatus};
|
||||
|
||||
pub struct PgScanRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgScanRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
/// Komplette Sicht auf eine Position inklusive ERP-Bezug — was wir
|
||||
/// für Zustandsübergang und Audit-Insert brauchen, in einer Query.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ItemLockRow {
|
||||
id: Uuid,
|
||||
required_quantity: i32,
|
||||
scanned_quantity: i32,
|
||||
scan_status: String,
|
||||
held_reason: Option<String>,
|
||||
scan_last_updated_at: DateTime<Utc>,
|
||||
belegzeilen_nr: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
erp_belegart_id: i64,
|
||||
erp_belegnummer: String,
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
fn parse_status(value: &str) -> Result<ScanStatus, ApplicationError> {
|
||||
match value {
|
||||
"in_progress" => Ok(ScanStatus::InProgress),
|
||||
"done" => Ok(ScanStatus::Done),
|
||||
"held" => Ok(ScanStatus::Held),
|
||||
"removed" => Ok(ScanStatus::Removed),
|
||||
other => Err(ApplicationError::Repository(format!(
|
||||
"unknown scan status '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_str(s: ScanStatus) -> &'static str {
|
||||
match s {
|
||||
ScanStatus::InProgress => "in_progress",
|
||||
ScanStatus::Done => "done",
|
||||
ScanStatus::Held => "held",
|
||||
ScanStatus::Removed => "removed",
|
||||
}
|
||||
}
|
||||
|
||||
fn action_str(a: AuditAction) -> &'static str {
|
||||
match a {
|
||||
AuditAction::Scan => "scan",
|
||||
AuditAction::Unscan => "unscan",
|
||||
AuditAction::Hold => "hold",
|
||||
AuditAction::Unhold => "unhold",
|
||||
AuditAction::Remove => "remove",
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergebnis einer reinen Zustandsübergangs-Rechnung (ohne DB).
|
||||
struct Transition {
|
||||
delta: i32,
|
||||
new_quantity: i32,
|
||||
new_status: ScanStatus,
|
||||
new_held_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Berechnet den nächsten Zustand. Bei `Err` enthält der String die
|
||||
/// fachliche Ablehnungs-Begründung, die 1:1 an die App geht.
|
||||
fn apply_transition(
|
||||
action: AuditAction,
|
||||
current_qty: i32,
|
||||
current_status: ScanStatus,
|
||||
required_qty: i32,
|
||||
reason: Option<&str>,
|
||||
) -> Result<Transition, String> {
|
||||
match action {
|
||||
AuditAction::Scan => match current_status {
|
||||
ScanStatus::InProgress | ScanStatus::Done => {
|
||||
let new_qty = current_qty + 1;
|
||||
let new_status = if new_qty >= required_qty {
|
||||
ScanStatus::Done
|
||||
} else {
|
||||
ScanStatus::InProgress
|
||||
};
|
||||
Ok(Transition {
|
||||
delta: 1,
|
||||
new_quantity: new_qty,
|
||||
new_status,
|
||||
new_held_reason: None,
|
||||
})
|
||||
}
|
||||
ScanStatus::Held => Err("item is on hold; unhold before scanning".into()),
|
||||
ScanStatus::Removed => Err("item is removed; cannot scan".into()),
|
||||
},
|
||||
AuditAction::Unscan => match current_status {
|
||||
ScanStatus::InProgress | ScanStatus::Done => {
|
||||
if current_qty == 0 {
|
||||
return Err("nothing scanned yet".into());
|
||||
}
|
||||
let new_qty = current_qty - 1;
|
||||
Ok(Transition {
|
||||
delta: -1,
|
||||
new_quantity: new_qty,
|
||||
new_status: ScanStatus::InProgress,
|
||||
new_held_reason: None,
|
||||
})
|
||||
}
|
||||
ScanStatus::Held => Err("item is on hold".into()),
|
||||
ScanStatus::Removed => Err("item is removed".into()),
|
||||
},
|
||||
AuditAction::Hold => match current_status {
|
||||
ScanStatus::InProgress | ScanStatus::Done => Ok(Transition {
|
||||
delta: 0,
|
||||
new_quantity: current_qty,
|
||||
new_status: ScanStatus::Held,
|
||||
new_held_reason: reason.map(str::to_owned),
|
||||
}),
|
||||
ScanStatus::Held => Err("item is already held".into()),
|
||||
ScanStatus::Removed => Err("item is removed".into()),
|
||||
},
|
||||
AuditAction::Unhold => match current_status {
|
||||
ScanStatus::Held => {
|
||||
let new_status = if current_qty >= required_qty {
|
||||
ScanStatus::Done
|
||||
} else {
|
||||
ScanStatus::InProgress
|
||||
};
|
||||
Ok(Transition {
|
||||
delta: 0,
|
||||
new_quantity: current_qty,
|
||||
new_status,
|
||||
new_held_reason: None,
|
||||
})
|
||||
}
|
||||
_ => Err("item is not held".into()),
|
||||
},
|
||||
AuditAction::Remove => match current_status {
|
||||
ScanStatus::Removed => Err("item is already removed".into()),
|
||||
_ => Ok(Transition {
|
||||
delta: 0,
|
||||
new_quantity: current_qty,
|
||||
new_status: ScanStatus::Removed,
|
||||
new_held_reason: reason.map(str::to_owned),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn lock_item(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_item_id: Uuid,
|
||||
) -> Result<Option<ItemLockRow>, ApplicationError> {
|
||||
sqlx::query_as::<_, ItemLockRow>(
|
||||
r#"
|
||||
SELECT
|
||||
di.id,
|
||||
di.required_quantity,
|
||||
di.scanned_quantity,
|
||||
di.scan_status,
|
||||
di.held_reason,
|
||||
di.scan_last_updated_at,
|
||||
di.belegzeilen_nr,
|
||||
di.komponenten_artikel_nr,
|
||||
d.erp_belegart_id,
|
||||
d.erp_belegnummer
|
||||
FROM delivery_items di
|
||||
JOIN deliveries d ON d.id = di.delivery_id
|
||||
WHERE di.id = $1
|
||||
FOR UPDATE OF di
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_item_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(db)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ScanRepository for PgScanRepository {
|
||||
async fn apply_one(
|
||||
&self,
|
||||
event: &ScanEvent,
|
||||
actor_personalnummer: i64,
|
||||
) -> Result<ApplyScanOutcome, ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
|
||||
let Some(item) = lock_item(&mut tx, event.delivery_item_id).await? else {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Ok(ApplyScanOutcome::Rejected {
|
||||
reason: format!("unknown delivery_item_id {}", event.delivery_item_id),
|
||||
});
|
||||
};
|
||||
|
||||
let current_status = parse_status(&item.scan_status)?;
|
||||
let current_state = ScanState {
|
||||
scanned_quantity: item.scanned_quantity,
|
||||
status: current_status,
|
||||
held_reason: item.held_reason.clone(),
|
||||
last_updated_at: item.scan_last_updated_at,
|
||||
};
|
||||
|
||||
let transition = match apply_transition(
|
||||
event.action,
|
||||
item.scanned_quantity,
|
||||
current_status,
|
||||
item.required_quantity,
|
||||
event.reason.as_deref(),
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(reason) => {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Ok(ApplyScanOutcome::Rejected { reason });
|
||||
}
|
||||
};
|
||||
|
||||
// Audit-Insert. ON CONFLICT DO NOTHING macht die Idempotenz: ist
|
||||
// die client_scan_id schon da, returnen wir keine Zeile.
|
||||
let inserted_id: Option<Uuid> = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO scan_audit (
|
||||
client_scan_id, delivery_item_id, action,
|
||||
delta, resulting_quantity, resulting_status,
|
||||
reason, actor_personalnummer, actor_car_id, client_scanned_at,
|
||||
erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr,
|
||||
erp_komponenten_artikel_nr
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (client_scan_id) DO NOTHING
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(event.client_scan_id)
|
||||
.bind(item.id)
|
||||
.bind(action_str(event.action))
|
||||
.bind(transition.delta)
|
||||
.bind(transition.new_quantity)
|
||||
.bind(status_str(transition.new_status))
|
||||
.bind(transition.new_held_reason.as_deref())
|
||||
.bind(actor_personalnummer)
|
||||
.bind(event.actor_car_id)
|
||||
.bind(event.client_scanned_at)
|
||||
.bind(item.erp_belegart_id)
|
||||
.bind(&item.erp_belegnummer)
|
||||
.bind(item.belegzeilen_nr)
|
||||
.bind(item.komponenten_artikel_nr.as_deref())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
if inserted_id.is_none() {
|
||||
// Duplicate: alles zurück, aktueller State (vor diesem Versuch)
|
||||
// an die App.
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Ok(ApplyScanOutcome::Duplicate {
|
||||
delivery_item_id: item.id,
|
||||
current_state,
|
||||
});
|
||||
}
|
||||
|
||||
// Applied: Item-State fortschreiben.
|
||||
let new_last_updated: DateTime<Utc> = sqlx::query_scalar(
|
||||
r#"
|
||||
UPDATE delivery_items
|
||||
SET scanned_quantity = $1,
|
||||
scan_status = $2,
|
||||
held_reason = $3,
|
||||
scan_last_updated_at = now()
|
||||
WHERE id = $4
|
||||
RETURNING scan_last_updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(transition.new_quantity)
|
||||
.bind(status_str(transition.new_status))
|
||||
.bind(transition.new_held_reason.as_deref())
|
||||
.bind(item.id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
tx.commit().await.map_err(db)?;
|
||||
|
||||
Ok(ApplyScanOutcome::Applied {
|
||||
delivery_item_id: item.id,
|
||||
new_state: ScanState {
|
||||
scanned_quantity: transition.new_quantity,
|
||||
status: transition.new_status,
|
||||
held_reason: transition.new_held_reason,
|
||||
last_updated_at: new_last_updated,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
815
crates/infrastructure/src/persistence/tour_repository.rs
Normal file
815
crates/infrastructure/src/persistence/tour_repository.rs
Normal file
@ -0,0 +1,815 @@
|
||||
//! Postgres-Implementierung des `TourRepository`-Ports.
|
||||
//!
|
||||
//! Drei Operationen, getrennt umgesetzt:
|
||||
//! * `find_today_for_driver` — eine Query (Tour + Lieferzahl pro Tour).
|
||||
//! * `find_details_by_id` — pro Aggregat ein paar gezielte Queries,
|
||||
//! anschließend in-memory zusammenbauen. Bewusst keine eine-Big-Join-
|
||||
//! Query: das vervielfacht Daten über die Leitung, die wir clientseitig
|
||||
//! wieder deduplizieren müssten — und Postgres handhabt 5–7 kleine
|
||||
//! Prepared Statements im selben Pool effizient genug.
|
||||
//! * `upsert_from_sync` — eine Transaktion, idempotent per UPSERT auf
|
||||
//! den fachlichen Keys. Bestehende `scan_state`-Werte bleiben
|
||||
//! unangetastet: das ERP weiß nichts davon und darf sie nicht
|
||||
//! überschreiben.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::dto::{
|
||||
DeliveryOrderEntry, DeliveryWithItems, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
|
||||
TourDetails, TourSummary,
|
||||
};
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::TourRepository;
|
||||
use holzleitner_domain::{
|
||||
Address, Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote,
|
||||
DeliveryState, ScanState, ScanStatus, Tour, Warehouse,
|
||||
};
|
||||
|
||||
pub struct PgTourRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgTourRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Row-Typen =========================================================
|
||||
//
|
||||
// Eigene FromRow-Strukturen in der Infrastructure halten die Domain-Typen
|
||||
// frei von `sqlx`-Traits. Mapping in private Helfer.
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TourRow {
|
||||
id: Uuid,
|
||||
account_id: i64,
|
||||
tour_date: NaiveDate,
|
||||
synced_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TourSummaryRow {
|
||||
id: Uuid,
|
||||
tour_date: NaiveDate,
|
||||
delivery_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeliveryRow {
|
||||
id: Uuid,
|
||||
tour_id: Uuid,
|
||||
erp_belegart_id: i64,
|
||||
erp_belegnummer: String,
|
||||
customer_id: Uuid,
|
||||
snap_street: String,
|
||||
snap_house_number: String,
|
||||
snap_postal_code: String,
|
||||
snap_city: String,
|
||||
snap_country: String,
|
||||
assigned_car_id: Option<Uuid>,
|
||||
desired_time: Option<String>,
|
||||
special_agreements: Option<String>,
|
||||
state: String,
|
||||
state_reason: Option<String>,
|
||||
sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeliveryItemRow {
|
||||
id: Uuid,
|
||||
delivery_id: Uuid,
|
||||
article_id: Uuid,
|
||||
required_quantity: i32,
|
||||
warehouse_id: Uuid,
|
||||
belegzeilen_nr: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
scanned_quantity: i32,
|
||||
scan_status: String,
|
||||
held_reason: Option<String>,
|
||||
scan_last_updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CustomerRow {
|
||||
id: Uuid,
|
||||
erp_customer_id: i64,
|
||||
name: String,
|
||||
street: String,
|
||||
house_number: String,
|
||||
postal_code: String,
|
||||
city: String,
|
||||
country: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CustomerContactRow {
|
||||
id: Uuid,
|
||||
customer_id: Uuid,
|
||||
name: String,
|
||||
phone: Option<String>,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ArticleRow {
|
||||
id: Uuid,
|
||||
article_number: String,
|
||||
name: String,
|
||||
scannable: bool,
|
||||
default_warehouse_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct WarehouseRow {
|
||||
id: Uuid,
|
||||
code: String,
|
||||
name: String,
|
||||
is_standard: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ContactLinkRow {
|
||||
delivery_id: Uuid,
|
||||
customer_contact_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeliveryNoteRow {
|
||||
id: Uuid,
|
||||
delivery_id: Uuid,
|
||||
text: Option<String>,
|
||||
image_attachment: Option<String>,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ===== Mapping Row -> Domain =============================================
|
||||
|
||||
fn map_tour(row: TourRow) -> Tour {
|
||||
Tour {
|
||||
id: row.id,
|
||||
account_id: row.account_id,
|
||||
date: row.tour_date,
|
||||
synced_at: row.synced_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_delivery_state(value: &str) -> Result<DeliveryState, ApplicationError> {
|
||||
match value {
|
||||
"active" => Ok(DeliveryState::Active),
|
||||
"held" => Ok(DeliveryState::Held),
|
||||
"canceled" => Ok(DeliveryState::Canceled),
|
||||
"completed" => Ok(DeliveryState::Completed),
|
||||
other => Err(ApplicationError::Repository(format!(
|
||||
"unknown delivery state '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_scan_status(value: &str) -> Result<ScanStatus, ApplicationError> {
|
||||
match value {
|
||||
"in_progress" => Ok(ScanStatus::InProgress),
|
||||
"done" => Ok(ScanStatus::Done),
|
||||
"held" => Ok(ScanStatus::Held),
|
||||
"removed" => Ok(ScanStatus::Removed),
|
||||
other => Err(ApplicationError::Repository(format!(
|
||||
"unknown scan status '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_item(row: DeliveryItemRow) -> Result<DeliveryItem, ApplicationError> {
|
||||
Ok(DeliveryItem {
|
||||
id: row.id,
|
||||
delivery_id: row.delivery_id,
|
||||
article_id: row.article_id,
|
||||
required_quantity: row.required_quantity,
|
||||
warehouse_id: row.warehouse_id,
|
||||
belegzeilen_nr: row.belegzeilen_nr,
|
||||
komponenten_artikel_nr: row.komponenten_artikel_nr,
|
||||
scan_state: ScanState {
|
||||
scanned_quantity: row.scanned_quantity,
|
||||
status: parse_scan_status(&row.scan_status)?,
|
||||
held_reason: row.held_reason,
|
||||
last_updated_at: row.scan_last_updated_at,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn map_customer(row: CustomerRow) -> Customer {
|
||||
Customer {
|
||||
id: row.id,
|
||||
erp_customer_id: row.erp_customer_id,
|
||||
name: row.name,
|
||||
address: Address {
|
||||
street: row.street,
|
||||
house_number: row.house_number,
|
||||
postal_code: row.postal_code,
|
||||
city: row.city,
|
||||
country: row.country,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn map_contact(row: CustomerContactRow) -> CustomerContact {
|
||||
CustomerContact {
|
||||
id: row.id,
|
||||
customer_id: row.customer_id,
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
email: row.email,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_article(row: ArticleRow) -> Article {
|
||||
Article {
|
||||
id: row.id,
|
||||
article_number: row.article_number,
|
||||
name: row.name,
|
||||
scannable: row.scannable,
|
||||
default_warehouse_id: row.default_warehouse_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_note(row: DeliveryNoteRow) -> DeliveryNote {
|
||||
DeliveryNote {
|
||||
id: row.id,
|
||||
delivery_id: row.delivery_id,
|
||||
text: row.text,
|
||||
image_attachment: row.image_attachment,
|
||||
author_personalnummer: row.author_personalnummer,
|
||||
author_car_id: row.author_car_id,
|
||||
created_at: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_warehouse(row: WarehouseRow) -> Warehouse {
|
||||
Warehouse {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
is_standard: row.is_standard,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_delivery(
|
||||
row: DeliveryRow,
|
||||
contact_person_ids: Vec<Uuid>,
|
||||
) -> Result<(Delivery, i32), ApplicationError> {
|
||||
let state = parse_delivery_state(&row.state)?;
|
||||
let delivery = Delivery {
|
||||
id: row.id,
|
||||
tour_id: row.tour_id,
|
||||
erp_belegart_id: row.erp_belegart_id,
|
||||
erp_belegnummer: row.erp_belegnummer,
|
||||
customer_id: row.customer_id,
|
||||
delivery_address_snapshot: Address {
|
||||
street: row.snap_street,
|
||||
house_number: row.snap_house_number,
|
||||
postal_code: row.snap_postal_code,
|
||||
city: row.snap_city,
|
||||
country: row.snap_country,
|
||||
},
|
||||
assigned_car_id: row.assigned_car_id,
|
||||
contact_person_ids,
|
||||
desired_time: row.desired_time,
|
||||
special_agreements: row.special_agreements,
|
||||
state,
|
||||
state_reason: row.state_reason,
|
||||
};
|
||||
Ok((delivery, row.sort_order))
|
||||
}
|
||||
|
||||
// ===== Helfer: Error-Mapping =============================================
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
// ===== Trait-Implementierung =============================================
|
||||
|
||||
#[async_trait]
|
||||
impl TourRepository for PgTourRepository {
|
||||
async fn find_today_for_driver(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
today: NaiveDate,
|
||||
) -> Result<Vec<TourSummary>, ApplicationError> {
|
||||
let rows = sqlx::query_as::<_, TourSummaryRow>(
|
||||
r#"
|
||||
SELECT t.id, t.tour_date, COUNT(d.id) AS delivery_count
|
||||
FROM tours t
|
||||
LEFT JOIN deliveries d ON d.tour_id = t.id
|
||||
WHERE t.account_id = $1 AND t.tour_date = $2
|
||||
GROUP BY t.id, t.tour_date
|
||||
ORDER BY t.tour_date
|
||||
"#,
|
||||
)
|
||||
.bind(personalnummer)
|
||||
.bind(today)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| TourSummary {
|
||||
tour_id: r.id,
|
||||
tour_date: r.tour_date,
|
||||
delivery_count: r.delivery_count,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_details_by_id(
|
||||
&self,
|
||||
tour_id: Uuid,
|
||||
) -> Result<Option<TourDetails>, ApplicationError> {
|
||||
// 1. Tour selbst
|
||||
let Some(tour_row) = sqlx::query_as::<_, TourRow>(
|
||||
"SELECT id, account_id, tour_date, synced_at FROM tours WHERE id = $1",
|
||||
)
|
||||
.bind(tour_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let tour = map_tour(tour_row);
|
||||
|
||||
// 2. Lieferungen
|
||||
let delivery_rows = sqlx::query_as::<_, DeliveryRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||
assigned_car_id, desired_time, special_agreements,
|
||||
state, state_reason, sort_order
|
||||
FROM deliveries
|
||||
WHERE tour_id = $1
|
||||
ORDER BY sort_order, erp_belegnummer
|
||||
"#,
|
||||
)
|
||||
.bind(tour_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let delivery_ids: Vec<Uuid> = delivery_rows.iter().map(|d| d.id).collect();
|
||||
|
||||
// 3. Kontakt-Person-Verknüpfungen
|
||||
let contact_links = sqlx::query_as::<_, ContactLinkRow>(
|
||||
r#"
|
||||
SELECT delivery_id, customer_contact_id
|
||||
FROM delivery_contact_persons
|
||||
WHERE delivery_id = ANY($1)
|
||||
"#,
|
||||
)
|
||||
.bind(&delivery_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let mut contacts_per_delivery: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
|
||||
for link in contact_links {
|
||||
contacts_per_delivery
|
||||
.entry(link.delivery_id)
|
||||
.or_default()
|
||||
.push(link.customer_contact_id);
|
||||
}
|
||||
|
||||
// 4. Positionen
|
||||
let item_rows = sqlx::query_as::<_, DeliveryItemRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id, delivery_id, article_id, required_quantity, warehouse_id,
|
||||
belegzeilen_nr, komponenten_artikel_nr,
|
||||
scanned_quantity, scan_status, held_reason, scan_last_updated_at
|
||||
FROM delivery_items
|
||||
WHERE delivery_id = ANY($1)
|
||||
ORDER BY delivery_id, belegzeilen_nr, komponenten_artikel_nr NULLS FIRST
|
||||
"#,
|
||||
)
|
||||
.bind(&delivery_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let mut items_per_delivery: HashMap<Uuid, Vec<DeliveryItem>> = HashMap::new();
|
||||
let mut article_ids = std::collections::BTreeSet::new();
|
||||
let mut warehouse_ids = std::collections::BTreeSet::new();
|
||||
for row in item_rows {
|
||||
article_ids.insert(row.article_id);
|
||||
warehouse_ids.insert(row.warehouse_id);
|
||||
let delivery_id = row.delivery_id;
|
||||
let item = map_item(row)?;
|
||||
items_per_delivery
|
||||
.entry(delivery_id)
|
||||
.or_default()
|
||||
.push(item);
|
||||
}
|
||||
|
||||
// 5. Lieferungen + Items kombinieren
|
||||
let mut customer_ids = std::collections::BTreeSet::new();
|
||||
let mut deliveries = Vec::with_capacity(delivery_rows.len());
|
||||
for row in delivery_rows {
|
||||
customer_ids.insert(row.customer_id);
|
||||
let delivery_id = row.id;
|
||||
let contact_ids = contacts_per_delivery.remove(&delivery_id).unwrap_or_default();
|
||||
let items = items_per_delivery.remove(&delivery_id).unwrap_or_default();
|
||||
let (delivery, sort_order) = map_delivery(row, contact_ids)?;
|
||||
deliveries.push(DeliveryWithItems {
|
||||
delivery,
|
||||
sort_order,
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Lookup-Stammdaten
|
||||
let customer_ids_vec: Vec<Uuid> = customer_ids.into_iter().collect();
|
||||
let customers = sqlx::query_as::<_, CustomerRow>(
|
||||
r#"
|
||||
SELECT id, erp_customer_id, name, street, house_number, postal_code, city, country
|
||||
FROM customers
|
||||
WHERE id = ANY($1)
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(&customer_ids_vec)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.into_iter()
|
||||
.map(map_customer)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let customer_contacts = sqlx::query_as::<_, CustomerContactRow>(
|
||||
r#"
|
||||
SELECT id, customer_id, name, phone, email
|
||||
FROM customer_contacts
|
||||
WHERE customer_id = ANY($1)
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(&customer_ids_vec)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.into_iter()
|
||||
.map(map_contact)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let article_ids_vec: Vec<Uuid> = article_ids.into_iter().collect();
|
||||
let articles = sqlx::query_as::<_, ArticleRow>(
|
||||
r#"
|
||||
SELECT id, article_number, name, scannable, default_warehouse_id
|
||||
FROM articles
|
||||
WHERE id = ANY($1)
|
||||
ORDER BY article_number
|
||||
"#,
|
||||
)
|
||||
.bind(&article_ids_vec)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.into_iter()
|
||||
.map(map_article)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let warehouse_ids_vec: Vec<Uuid> = warehouse_ids.into_iter().collect();
|
||||
let warehouses = sqlx::query_as::<_, WarehouseRow>(
|
||||
r#"
|
||||
SELECT id, code, name, is_standard
|
||||
FROM warehouses
|
||||
WHERE id = ANY($1)
|
||||
ORDER BY code
|
||||
"#,
|
||||
)
|
||||
.bind(&warehouse_ids_vec)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.into_iter()
|
||||
.map(map_warehouse)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 7. Notizen aller Lieferungen dieser Tour.
|
||||
let notes = sqlx::query_as::<_, DeliveryNoteRow>(
|
||||
r#"
|
||||
SELECT id, delivery_id, text, image_attachment,
|
||||
author_personalnummer, author_car_id, created_at
|
||||
FROM delivery_notes
|
||||
WHERE delivery_id = ANY($1)
|
||||
ORDER BY delivery_id, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(&delivery_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.into_iter()
|
||||
.map(map_note)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Some(TourDetails {
|
||||
tour,
|
||||
deliveries,
|
||||
customers,
|
||||
customer_contacts,
|
||||
articles,
|
||||
warehouses,
|
||||
notes,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn set_delivery_order(
|
||||
&self,
|
||||
tour_id: Uuid,
|
||||
delivery_ids: &[Uuid],
|
||||
) -> Result<Vec<DeliveryOrderEntry>, ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
|
||||
// 1. Lock alle Lieferungen dieser Tour. Liefert leer, wenn Tour
|
||||
// nicht existiert oder keine Lieferungen hat.
|
||||
let existing: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM deliveries WHERE tour_id = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(tour_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
if existing.is_empty() {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::NotFound);
|
||||
}
|
||||
|
||||
// 2. Mengen-Match: Input muss exakt der Tour entsprechen.
|
||||
let existing_set: std::collections::HashSet<Uuid> = existing.iter().copied().collect();
|
||||
let input_set: std::collections::HashSet<Uuid> = delivery_ids.iter().copied().collect();
|
||||
if existing_set != input_set {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
let fremde: Vec<Uuid> =
|
||||
input_set.difference(&existing_set).copied().collect();
|
||||
let fehlende: Vec<Uuid> =
|
||||
existing_set.difference(&input_set).copied().collect();
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"delivery_ids match nicht zur tour (fehlende: {fehlende:?}, fremde: {fremde:?})"
|
||||
)));
|
||||
}
|
||||
|
||||
// 3. Bulk-Update via UNNEST.
|
||||
let positions: Vec<i32> = (1..=delivery_ids.len() as i32).collect();
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE deliveries AS d
|
||||
SET sort_order = data.new_order
|
||||
FROM (
|
||||
SELECT UNNEST($1::uuid[]) AS id,
|
||||
UNNEST($2::int[]) AS new_order
|
||||
) AS data
|
||||
WHERE d.id = data.id
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_ids)
|
||||
.bind(&positions)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
tx.commit().await.map_err(db)?;
|
||||
|
||||
Ok(delivery_ids
|
||||
.iter()
|
||||
.zip(positions.iter())
|
||||
.map(|(id, pos)| DeliveryOrderEntry {
|
||||
delivery_id: *id,
|
||||
sort_order: *pos,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn upsert_from_sync(
|
||||
&self,
|
||||
request: &SyncTourRequest,
|
||||
) -> Result<Uuid, ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
|
||||
// 1. Tour upserten — Identität: (account_id, tour_date)
|
||||
let tour_id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO tours (account_id, tour_date)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (account_id, tour_date) DO UPDATE
|
||||
SET synced_at = now()
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(request.driver_personalnummer)
|
||||
.bind(request.tour_date)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
for delivery in &request.deliveries {
|
||||
// 2. Kunde upserten — Identität: erp_customer_id
|
||||
let customer_id = upsert_customer(&mut tx, delivery).await?;
|
||||
|
||||
// 3. Lieferung upserten — Identität: (belegart_id, belegnummer).
|
||||
// Bestehende Lieferung bleibt mit ihrem state/cancellation
|
||||
// erhalten; nur Stammdaten + sort_order werden refresht.
|
||||
let delivery_id = upsert_delivery(&mut tx, tour_id, customer_id, delivery).await?;
|
||||
|
||||
for item in &delivery.items {
|
||||
let warehouse_id = upsert_warehouse(&mut tx, item).await?;
|
||||
let article_id = upsert_article(&mut tx, item, warehouse_id).await?;
|
||||
upsert_delivery_item(&mut tx, delivery_id, article_id, warehouse_id, item).await?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(db)?;
|
||||
Ok(tour_id)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Upsert-Helfer =====================================================
|
||||
|
||||
async fn upsert_warehouse(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
item: &SyncDeliveryItem,
|
||||
) -> Result<Uuid, ApplicationError> {
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO warehouses (code, name)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET name = EXCLUDED.name
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(&item.warehouse_code)
|
||||
.bind(&item.warehouse_name)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn upsert_article(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
item: &SyncDeliveryItem,
|
||||
fallback_warehouse_id: Uuid,
|
||||
) -> Result<Uuid, ApplicationError> {
|
||||
// Optional: explizites Default-Lager aus dem ERP. Wenn nicht
|
||||
// geliefert, nehmen wir das Lager dieser Position als Default — das
|
||||
// ist eine pragmatische Wahl, die wir später korrigieren können.
|
||||
let default_warehouse_id = if let Some(code) = &item.article_default_warehouse_code {
|
||||
sqlx::query_scalar::<_, Uuid>("SELECT id FROM warehouses WHERE code = $1")
|
||||
.bind(code)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.unwrap_or(fallback_warehouse_id)
|
||||
} else {
|
||||
fallback_warehouse_id
|
||||
};
|
||||
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO articles (article_number, name, scannable, default_warehouse_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (article_number) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
scannable = EXCLUDED.scannable,
|
||||
default_warehouse_id = EXCLUDED.default_warehouse_id
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(&item.article_number)
|
||||
.bind(&item.article_name)
|
||||
.bind(item.article_scannable)
|
||||
.bind(default_warehouse_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn upsert_customer(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery: &SyncDelivery,
|
||||
) -> Result<Uuid, ApplicationError> {
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO customers (
|
||||
erp_customer_id, name, street, house_number, postal_code, city, country
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (erp_customer_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
street = EXCLUDED.street,
|
||||
house_number = EXCLUDED.house_number,
|
||||
postal_code = EXCLUDED.postal_code,
|
||||
city = EXCLUDED.city,
|
||||
country = EXCLUDED.country
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(delivery.erp_customer_id)
|
||||
.bind(&delivery.customer_name)
|
||||
.bind(&delivery.customer_address.street)
|
||||
.bind(&delivery.customer_address.house_number)
|
||||
.bind(&delivery.customer_address.postal_code)
|
||||
.bind(&delivery.customer_address.city)
|
||||
.bind(&delivery.customer_address.country)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn upsert_delivery(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
tour_id: Uuid,
|
||||
customer_id: Uuid,
|
||||
delivery: &SyncDelivery,
|
||||
) -> Result<Uuid, ApplicationError> {
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO deliveries (
|
||||
tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||
sort_order, desired_time, special_agreements
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (erp_belegart_id, erp_belegnummer) DO UPDATE SET
|
||||
tour_id = EXCLUDED.tour_id,
|
||||
customer_id = EXCLUDED.customer_id,
|
||||
snap_street = EXCLUDED.snap_street,
|
||||
snap_house_number = EXCLUDED.snap_house_number,
|
||||
snap_postal_code = EXCLUDED.snap_postal_code,
|
||||
snap_city = EXCLUDED.snap_city,
|
||||
snap_country = EXCLUDED.snap_country,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
desired_time = EXCLUDED.desired_time,
|
||||
special_agreements = EXCLUDED.special_agreements
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(tour_id)
|
||||
.bind(delivery.belegart_id)
|
||||
.bind(&delivery.belegnummer)
|
||||
.bind(customer_id)
|
||||
.bind(&delivery.delivery_address.street)
|
||||
.bind(&delivery.delivery_address.house_number)
|
||||
.bind(&delivery.delivery_address.postal_code)
|
||||
.bind(&delivery.delivery_address.city)
|
||||
.bind(&delivery.delivery_address.country)
|
||||
.bind(delivery.sort_order)
|
||||
.bind(delivery.desired_time.as_deref())
|
||||
.bind(delivery.special_agreements.as_deref())
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn upsert_delivery_item(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
article_id: Uuid,
|
||||
warehouse_id: Uuid,
|
||||
item: &SyncDeliveryItem,
|
||||
) -> Result<(), ApplicationError> {
|
||||
// Identität: (delivery_id, belegzeilen_nr, komponenten_artikel_nr).
|
||||
// scan_state-Felder bleiben beim UPDATE bewusst unberührt — das ERP
|
||||
// weiß nichts über Scans.
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO delivery_items (
|
||||
delivery_id, article_id, required_quantity, warehouse_id,
|
||||
belegzeilen_nr, komponenten_artikel_nr
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (delivery_id, belegzeilen_nr, komponenten_artikel_nr) DO UPDATE SET
|
||||
article_id = EXCLUDED.article_id,
|
||||
required_quantity = EXCLUDED.required_quantity,
|
||||
warehouse_id = EXCLUDED.warehouse_id
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(article_id)
|
||||
.bind(item.required_quantity)
|
||||
.bind(warehouse_id)
|
||||
.bind(item.belegzeilen_nr)
|
||||
.bind(item.komponenten_artikel_nr.as_deref())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user