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