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:
Dennis Nemec
2026-05-14 22:28:31 +02:00
commit 438040acce
83 changed files with 8922 additions and 0 deletions

24
.env.example Normal file
View File

@ -0,0 +1,24 @@
# Vorlage für lokale Entwicklung — kopieren nach `.env` und nach Bedarf anpassen.
# Die `.env` selbst gehört nicht in Git.
# --- HTTP-Server ----------------------------------------------------------
SERVER_HOST=127.0.0.1
SERVER_PORT=3000
# --- Postgres -------------------------------------------------------------
# Passt zur docker-compose.yml (Service `postgres`).
DATABASE_URL=postgres://holzleitner:holzleitner_dev@localhost:5432/holzleitner
DATABASE_MAX_CONNECTIONS=10
# --- Keycloak (OIDC) ------------------------------------------------------
# Passt zur docker-compose.yml (Service `keycloak`).
# Admin-UI: http://localhost:8080/admin/ (admin / admin)
# Realm: holzleitner
# Test-User: testfahrer / test (Personalnummer 1001, Rolle "driver")
KEYCLOAK_ISSUER_URL=http://localhost:8080/realms/holzleitner
KEYCLOAK_AUDIENCE=holzleitner-api
KEYCLOAK_JWKS_CACHE_TTL_SECONDS=3600
# --- Logging --------------------------------------------------------------
# Standard-Filter; siehe tracing_subscriber::EnvFilter-Doku.
RUST_LOG=holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
.env
.DS_Store

3258
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

42
Cargo.toml Normal file
View File

@ -0,0 +1,42 @@
[workspace]
resolver = "3"
members = [
"crates/domain",
"crates/application",
"crates/infrastructure",
"crates/api",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "Proprietary"
authors = ["Holzleitner GmbH"]
[workspace.dependencies]
# === Externe Crates (zentral gepinnt) =====================================
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["serde", "v4"] }
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
async-trait = "0.1"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
axum = "0.8"
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
jsonwebtoken = "9"
envy = "0.4"
dotenvy = "0.15"
anyhow = "1"
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
utoipa-swagger-ui = { version = "9", features = ["axum"] }
# === Interne Crates =======================================================
holzleitner-domain = { path = "crates/domain" }
holzleitner-application = { path = "crates/application" }
holzleitner-infrastructure = { path = "crates/infrastructure" }

99
README.md Normal file
View File

@ -0,0 +1,99 @@
# Holzleitner-Backend
Rust-Backend für die Holzleitner-Lieferservice-App. Workspace mit Clean
Architecture: `domain``application``infrastructure``api`.
## Schnellstart (lokale Entwicklung)
```bash
# 1) Postgres + Keycloak hochfahren
docker compose up -d
# Keycloak braucht ~30s bis "Listening on http://0.0.0.0:8080" im Log steht.
# 2) Env-Datei vorbereiten
cp .env.example .env
# 3) Backend starten
cargo run -p holzleitner-api
```
Migrations laufen beim Start automatisch über `sqlx::migrate!`.
Smoke-Test danach:
```bash
curl http://127.0.0.1:3000/health
# → ok
curl http://127.0.0.1:3000/accounts/1001
# → {"personalnummer":1001,"name":"Müller Logistik GmbH","active":true}
```
## Keycloak (Dev)
| Wo | URL / Credentials |
|---|---|
| Admin-Console | http://localhost:8080/admin/ (admin / admin) |
| Realm | `holzleitner` |
| Client | `holzleitner-app` (public, PKCE) |
| Test-User | `testfahrer` / `test` · Personalnummer 1001 · Rolle `driver` |
| Audience im Access-Token | `holzleitner-api` |
Der Realm wird bei jedem `docker compose up` aus
`keycloak/import/realm-holzleitner.json` frisch importiert. Wer in der
Admin-UI Änderungen macht, sollte sie **in die JSON zurückspielen**,
sonst sind sie beim nächsten `docker compose down` weg.
### Token für Dev-Tests holen
```bash
curl -s -X POST \
http://localhost:8080/realms/holzleitner/protocol/openid-connect/token \
-d 'grant_type=password' \
-d 'client_id=holzleitner-app' \
-d 'username=testfahrer' \
-d 'password=test' | jq -r .access_token
```
Den Token in den `Authorization`-Header packen, sobald die JWT-Middleware
in der API-Schicht aktiv ist:
```bash
TOKEN=$(curl -s -X POST .../token -d ... | jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3000/accounts/1001
```
## Crate-Layout
| Crate | Inhalt | Abhängigkeiten |
|---|---|---|
| `holzleitner-domain` | Reines Domänenmodell (serde + UUID + chrono) | — |
| `holzleitner-application` | Use Cases und Ports (Trait-Definitionen für Repository, AuthService) | `domain` |
| `holzleitner-infrastructure` | Konkrete Adapter (sqlx-Postgres, später Keycloak) | `domain`, `application` |
| `holzleitner-api` | Axum HTTP-Layer + Composition Root | alle |
## Konfiguration
Werte werden aus Umgebungsvariablen gelesen (siehe `.env.example`),
gruppiert nach Prefix:
| Prefix | Bereich |
|---|---|
| `SERVER_*` | Bind-Host/Port |
| `DATABASE_*` | Postgres-URL, Pool-Größe |
| `KEYCLOAK_*` | OIDC-Issuer, Audience, JWKS-Cache (greift erst in der Keycloak-Phase) |
## Migrations
Migrations liegen im Workspace-Root `migrations/`. Eingebettet via
`sqlx::migrate!()` — kein zusätzlicher Laufzeit-Dateizugriff nötig.
Neue Migration anlegen:
```bash
# Format: <epoch>_<beschreibung>.sql, z.B.:
touch migrations/0002_tour.sql
```
## Logging
`tracing` + `tracing-subscriber` mit `EnvFilter`. Default:
`holzleitner_api=info,tower_http=info`. Override via `RUST_LOG`.

32
crates/api/Cargo.toml Normal file
View 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
View 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
View 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()
}
}

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

View 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 ")
}

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

View 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))
}

View 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 }))
}

View 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 }))
}

View 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"
}

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

View 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))
}

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

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

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

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

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

View 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};

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

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

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

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

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

View 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),
}

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

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

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

View 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>;
}

View 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>;
}

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

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

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

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

View 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)
}
}

View 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)
}
}

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

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

View 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 })
}
}

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

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

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

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

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

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

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

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

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

View 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

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

View 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};

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

View 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))
}
}

View 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"
)))
}
}
}

View File

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

View 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,
})
}
}

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

View 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)
}

View 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,
},
})
}
}

View 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 57 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(())
}

47
docker-compose.yml Normal file
View File

@ -0,0 +1,47 @@
# Lokales Entwicklungs-Setup für das Holzleitner-Backend.
# Startbefehl: docker compose up -d
#
# Postgres-Daten landen im benannten Volume `postgres-data` und überleben
# Container-Neustarts. Keycloak nutzt bewusst keinen persistenten Volume —
# der Realm wird bei jedem Start frisch aus `keycloak/import/` importiert,
# damit die Quellen-of-truth Versionierte Dateien bleiben.
# Komplettes Reset: `docker compose down -v`.
services:
postgres:
image: postgres:17-alpine
container_name: holzleitner-postgres
restart: unless-stopped
environment:
POSTGRES_DB: holzleitner
POSTGRES_USER: holzleitner
POSTGRES_PASSWORD: holzleitner_dev
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U holzleitner -d holzleitner"]
interval: 5s
timeout: 5s
retries: 5
keycloak:
image: quay.io/keycloak/keycloak:26.0
container_name: holzleitner-keycloak
restart: unless-stopped
command: ["start-dev", "--import-realm"]
environment:
# Bootstrap-Admin (Keycloak 26+ neue Env-Vars).
# Admin-Console: http://localhost:8080/admin/ (admin / admin)
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
# Health-Endpoints für externe Checks aktivieren.
KC_HEALTH_ENABLED: "true"
ports:
- "8080:8080"
volumes:
- ./keycloak/import:/opt/keycloak/data/import:ro
volumes:
postgres-data:

View File

@ -0,0 +1,92 @@
{
"realm": "holzleitner",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"accessTokenLifespan": 1800,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"roles": {
"realm": [
{
"name": "driver",
"description": "Lieferfahrer — darf Touren laden, scannen und abschließen."
}
]
},
"users": [
{
"username": "testfahrer",
"enabled": true,
"emailVerified": true,
"firstName": "Test",
"lastName": "Fahrer",
"email": "test@example.com",
"credentials": [
{
"type": "password",
"value": "test",
"temporary": false
}
],
"realmRoles": ["driver"],
"attributes": {
"personalnummer": ["1001"]
}
}
],
"clients": [
{
"clientId": "holzleitner-app",
"name": "Holzleitner Mobile App",
"description": "Public Client für die Flutter-App (Authorization Code + PKCE und Direct Access Grants im Dev).",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"implicitFlowEnabled": false,
"redirectUris": [
"http://localhost:*",
"holzleitner://*"
],
"webOrigins": ["+"],
"attributes": {
"post.logout.redirect.uris": "+",
"pkce.code.challenge.method": "S256"
},
"protocolMappers": [
{
"name": "audience-holzleitner-api",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.client.audience": "holzleitner-api",
"id.token.claim": "false",
"access.token.claim": "true",
"introspection.token.claim": "true"
}
},
{
"name": "personalnummer",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "personalnummer",
"claim.name": "personalnummer",
"jsonType.label": "long",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"introspection.token.claim": "true"
}
}
]
}
]
}

View File

@ -0,0 +1,13 @@
-- Erste Tabelle: Account. Wird zunächst nur als Smoke-Test-Ziel für die
-- End-to-End-Achse benutzt; weitere Tabellen folgen pro Migration.
CREATE TABLE accounts (
personalnummer BIGINT PRIMARY KEY,
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE
);
-- Seed-Daten für Smoke-Tests.
INSERT INTO accounts (personalnummer, name, active) VALUES
(1001, 'Müller Logistik GmbH', TRUE),
(1002, 'Schmidt Transporte', TRUE);

185
migrations/0002_tours.sql Normal file
View File

@ -0,0 +1,185 @@
-- Tour-Domäne: Stammdaten (customers, customer_contacts, articles, warehouses)
-- plus die transaktionalen Tabellen tours, deliveries, delivery_items.
--
-- Datenfluss: das ERP pusht eine Tour via POST /sync/tour. Dieser Sync
-- legt fehlende Kunden/Artikel/Lager an oder aktualisiert sie und schreibt
-- danach die transaktionalen Zeilen.
-- ---------- Stamm: Lager -------------------------------------------------
CREATE TABLE warehouses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
is_standard BOOLEAN NOT NULL DEFAULT FALSE
);
-- Genau ein Lager darf als "Standardlager" markiert sein. Reduziert die
-- Fertig-Logik auf der App auf einen Bool-Check.
CREATE UNIQUE INDEX warehouses_one_standard
ON warehouses ((is_standard))
WHERE is_standard;
-- ---------- Stamm: Artikel ------------------------------------------------
CREATE TABLE articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_number TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
scannable BOOLEAN NOT NULL DEFAULT TRUE,
default_warehouse_id UUID REFERENCES warehouses(id)
);
-- ---------- Stamm: Kunde --------------------------------------------------
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
erp_customer_id BIGINT NOT NULL UNIQUE,
name TEXT NOT NULL,
-- Aktuelle Anschrift (für Snapshot in deliveries gesondert geführt)
street TEXT NOT NULL,
house_number TEXT NOT NULL,
postal_code TEXT NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL
);
CREATE TABLE customer_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
phone TEXT,
email TEXT
);
CREATE INDEX customer_contacts_customer ON customer_contacts(customer_id);
-- ---------- Transaktional: Tour ------------------------------------------
CREATE TABLE tours (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- account_id = Personalnummer des Subunternehmers
account_id BIGINT NOT NULL REFERENCES accounts(personalnummer),
tour_date DATE NOT NULL,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Eine Tour pro Account und Tag. Der Sync läuft als Upsert auf diese
-- Constraint.
UNIQUE (account_id, tour_date)
);
CREATE INDEX tours_account_date ON tours(account_id, tour_date);
-- ---------- Transaktional: Delivery --------------------------------------
CREATE TABLE deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tour_id UUID NOT NULL REFERENCES tours(id) ON DELETE CASCADE,
-- Business-stabiles Beleg-Paar aus dem ERP, überlebt Archivübergang
erp_belegart_id BIGINT NOT NULL,
erp_belegnummer TEXT NOT NULL,
customer_id UUID NOT NULL REFERENCES customers(id),
-- Snapshot der Adresse zum Zeitpunkt des Tour-Syncs
snap_street TEXT NOT NULL,
snap_house_number TEXT NOT NULL,
snap_postal_code TEXT NOT NULL,
snap_city TEXT NOT NULL,
snap_country TEXT NOT NULL,
assigned_car_id UUID, -- noch keine cars-Tabelle, FK später
desired_time TEXT,
special_agreements TEXT,
state TEXT NOT NULL DEFAULT 'active'
CHECK (state IN ('active', 'held', 'canceled', 'completed')),
cancellation_reason TEXT,
-- Sortier-Reihenfolge innerhalb der Tour. Beim Sync mit dichter
-- Reihenfolge initialisiert, später durch PUT /tours/{id}/delivery-order
-- überschrieben.
sort_order INT NOT NULL DEFAULT 0,
UNIQUE (erp_belegart_id, erp_belegnummer)
);
CREATE INDEX deliveries_tour ON deliveries(tour_id);
CREATE INDEX deliveries_customer ON deliveries(customer_id);
-- N:M-Tabelle Delivery → ausgewählte Ansprechpartner
CREATE TABLE delivery_contact_persons (
delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE,
customer_contact_id UUID NOT NULL REFERENCES customer_contacts(id) ON DELETE CASCADE,
PRIMARY KEY (delivery_id, customer_contact_id)
);
-- ---------- Transaktional: DeliveryItem ----------------------------------
CREATE TABLE delivery_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE,
article_id UUID NOT NULL REFERENCES articles(id),
required_quantity INT NOT NULL CHECK (required_quantity > 0),
warehouse_id UUID NOT NULL REFERENCES warehouses(id),
-- ERP-Position innerhalb des Belegs
belegzeilen_nr INT NOT NULL,
-- Stücklistenkomponente: ArtNr der Komponente, sonst NULL
komponenten_artikel_nr TEXT,
-- Embedded ScanState (siehe Domain::ScanState)
scanned_quantity INT NOT NULL DEFAULT 0 CHECK (scanned_quantity >= 0),
scan_status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (scan_status IN ('in_progress','done','held','removed')),
held_reason TEXT,
scan_last_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- NULLS NOT DISTINCT: zwei Items mit (delivery, belegzeilenNr) und
-- NULL-Komponente kollidieren — sonst würde der UPSERT eine zweite
-- Zeile anlegen statt zu aktualisieren. (Postgres 15+)
UNIQUE NULLS NOT DISTINCT (delivery_id, belegzeilen_nr, komponenten_artikel_nr)
);
CREATE INDEX delivery_items_delivery ON delivery_items(delivery_id);
-- ---------- Seed: Smoke-Test-Daten ----------------------------------------
INSERT INTO warehouses (id, code, name, is_standard) VALUES
('11111111-1111-1111-1111-111111111111', '0', 'Standardlager', TRUE),
('11111111-1111-1111-1111-111111111112', 'A', 'Außenlager A', FALSE);
INSERT INTO articles (id, article_number, name, scannable, default_warehouse_id) VALUES
('22222222-2222-2222-2222-222222222221', 'BRETT-200', 'Holzbrett 200cm', TRUE, '11111111-1111-1111-1111-111111111111'),
('22222222-2222-2222-2222-222222222222', 'PALETTE-EUR', 'Europalette', TRUE, '11111111-1111-1111-1111-111111111111'),
('22222222-2222-2222-2222-222222222223', 'FRACHT-PAUSCH', 'Fracht', FALSE, NULL);
INSERT INTO customers (id, erp_customer_id, name, street, house_number, postal_code, city, country) VALUES
('33333333-3333-3333-3333-333333333331', 4711, 'Bauernhof Huber', 'Dorfstraße', '12', '83410', 'Laufen', 'DE'),
('33333333-3333-3333-3333-333333333332', 4712, 'Sägewerk Müller', 'Industriering', '5', '83395', 'Freilassing', 'DE');
INSERT INTO customer_contacts (id, customer_id, name, phone, email) VALUES
('44444444-4444-4444-4444-444444444441', '33333333-3333-3333-3333-333333333331', 'Sepp Huber', '+49 8682 12345', NULL),
('44444444-4444-4444-4444-444444444442', '33333333-3333-3333-3333-333333333332', 'Anna Müller', NULL, 'anna@muellersaege.de');
-- Eine Beispiel-Tour für Personalnummer 1001 am heutigen Tag
INSERT INTO tours (id, account_id, tour_date) VALUES
('55555555-5555-5555-5555-555555555555', 1001, CURRENT_DATE);
INSERT INTO deliveries (
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
sort_order
) VALUES
('66666666-6666-6666-6666-666666666661', '55555555-5555-5555-5555-555555555555',
1, 'AB-2026-0001', '33333333-3333-3333-3333-333333333331',
'Dorfstraße', '12', '83410', 'Laufen', 'DE', 1),
('66666666-6666-6666-6666-666666666662', '55555555-5555-5555-5555-555555555555',
1, 'AB-2026-0002', '33333333-3333-3333-3333-333333333332',
'Industriering', '5', '83395', 'Freilassing', 'DE', 2);
INSERT INTO delivery_contact_persons (delivery_id, customer_contact_id) VALUES
('66666666-6666-6666-6666-666666666661', '44444444-4444-4444-4444-444444444441'),
('66666666-6666-6666-6666-666666666662', '44444444-4444-4444-4444-444444444442');
INSERT INTO delivery_items (
delivery_id, article_id, required_quantity, warehouse_id,
belegzeilen_nr, komponenten_artikel_nr
) VALUES
('66666666-6666-6666-6666-666666666661', '22222222-2222-2222-2222-222222222221',
20, '11111111-1111-1111-1111-111111111111', 1, NULL),
('66666666-6666-6666-6666-666666666661', '22222222-2222-2222-2222-222222222222',
2, '11111111-1111-1111-1111-111111111111', 2, NULL),
('66666666-6666-6666-6666-666666666662', '22222222-2222-2222-2222-222222222221',
10, '11111111-1111-1111-1111-111111111112', 1, NULL),
('66666666-6666-6666-6666-666666666662', '22222222-2222-2222-2222-222222222223',
1, '11111111-1111-1111-1111-111111111111', 2, NULL);

View File

@ -0,0 +1,52 @@
-- Append-only Audit-Log für jede Scan-Aktion an einer Lieferposition.
--
-- Doppelter Wahrheits-Ansatz:
-- * delivery_items.scan_* = "schnelle Wahrheit" über WIEVIEL (Embedded)
-- * scan_audit = "lange Wahrheit" über WIE und WANN
--
-- Idempotenz: client_scan_id ist UNIQUE. Schickt die App einen Scan
-- nach Netzfehler erneut, kollidiert sie auf diesem Index — der Server
-- liefert "duplicate" zurück, ohne den embedded scan_state zu verändern.
--
-- Beleg-Bezug wird denormalisiert mitgespeichert: bleibt nachvollziehbar,
-- auch wenn delivery_items irgendwann archiviert oder bereinigt wird.
CREATE TABLE scan_audit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Idempotenz-Schlüssel vom Client.
client_scan_id UUID NOT NULL UNIQUE,
delivery_item_id UUID NOT NULL REFERENCES delivery_items(id) ON DELETE CASCADE,
action TEXT NOT NULL
CHECK (action IN ('scan','unscan','hold','unhold','remove')),
-- Signed Delta auf scanned_quantity. +1 / -1 / 0 abhängig vom action.
delta INT NOT NULL,
-- Snapshot scanned_quantity nach dieser Aktion (Reports brauchen keinen Replay).
resulting_quantity INT NOT NULL CHECK (resulting_quantity >= 0),
resulting_status TEXT NOT NULL
CHECK (resulting_status IN ('in_progress','done','held','removed')),
reason TEXT,
-- Akteur: Personalnummer aus dem JWT (Pflicht).
-- car_id NULL bis Cars im Backend gemanagt werden.
actor_personalnummer BIGINT NOT NULL,
actor_car_id UUID,
-- Zeitpunkt am Client (offline-fähig) + Eingang am Server.
client_scanned_at TIMESTAMPTZ NOT NULL,
server_recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Denormalisierter ERP-Beleg-Bezug.
erp_belegart_id BIGINT NOT NULL,
erp_belegnummer TEXT NOT NULL,
erp_belegzeilen_nr INT NOT NULL,
erp_komponenten_artikel_nr TEXT
);
CREATE INDEX scan_audit_item ON scan_audit(delivery_item_id);
CREATE INDEX scan_audit_recorded ON scan_audit(server_recorded_at);
CREATE INDEX scan_audit_actor ON scan_audit(actor_personalnummer, server_recorded_at);

View File

@ -0,0 +1,10 @@
-- Ein Reason-Feld für beide Nicht-Active-Status (Hold + Cancel).
--
-- Bisher hieß die Spalte `cancellation_reason` und passte nur zum
-- Cancel-Pfad. Mit dem Hold-Flow brauchen wir ebenfalls einen
-- begründenden Text — semantisch ist es immer "warum ist die
-- Lieferung gerade nicht 'active'". `state_reason` ist der bessere
-- Name; beim Resume / Complete wird die Spalte auf NULL gesetzt.
ALTER TABLE deliveries
RENAME COLUMN cancellation_reason TO state_reason;

View File

@ -0,0 +1,21 @@
-- Notizen pro Lieferung. Eine Notiz ist entweder Text, ein Bild-Anhang
-- (Object-Storage-Key/URL) oder beides — aber nicht NULL/NULL.
--
-- Akteur: actor_personalnummer ist Pflicht (aus JWT). Das fachlich
-- gewünschte author_car_id bleibt optional, bis das Backend Fahrzeuge
-- selbst verwaltet.
CREATE TABLE delivery_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE,
text TEXT,
image_attachment TEXT,
author_personalnummer BIGINT NOT NULL,
author_car_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (text IS NOT NULL OR image_attachment IS NOT NULL)
);
CREATE INDEX delivery_notes_delivery
ON delivery_notes (delivery_id, created_at);

39
migrations/0006_cars.sql Normal file
View File

@ -0,0 +1,39 @@
-- Fahrzeuge eines Subunternehmer-Accounts. Kein ERP-Spiegel — die App
-- pflegt die Fahrzeuge selbst.
--
-- Im Audit-Log und an Lieferungen war `car_id` bislang ohne FK
-- geführt (Spalten existieren in 0002/0003/0005). Diese Migration
-- zieht die FKs nach. Bestehende NULL-Werte bleiben gültig.
--
-- `(account_id, plate)` ist fachlich eindeutig — pro Account keine
-- doppelten Kennzeichen.
CREATE TABLE cars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id BIGINT NOT NULL REFERENCES accounts(personalnummer),
plate TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE UNIQUE INDEX cars_account_plate ON cars(account_id, plate);
CREATE INDEX cars_account ON cars(account_id);
-- FKs nachziehen — alle Spalten bleiben NULLABLE, weil
-- Audit-Einträge der Vor-Cars-Phase keinen car_id haben.
ALTER TABLE deliveries
ADD CONSTRAINT deliveries_assigned_car_fk
FOREIGN KEY (assigned_car_id) REFERENCES cars(id);
ALTER TABLE delivery_notes
ADD CONSTRAINT delivery_notes_author_car_fk
FOREIGN KEY (author_car_id) REFERENCES cars(id);
ALTER TABLE scan_audit
ADD CONSTRAINT scan_audit_actor_car_fk
FOREIGN KEY (actor_car_id) REFERENCES cars(id);
-- Seed: zwei Fahrzeuge für Testfahrer (PN 1001) — bewusst zwei,
-- damit der Flow "Auswählen ab 2 Autos" testbar ist.
INSERT INTO cars (id, account_id, plate, active) VALUES
('77777777-7777-7777-7777-777777777771', 1001, 'BGL-HZ 100', TRUE),
('77777777-7777-7777-7777-777777777772', 1001, 'BGL-HZ 200', TRUE);