Initial: Rust-Backend mit Clean Architecture (domain/application/infrastructure/api)

Vier-Crate-Workspace mit:
- Domain: Account, Car, Tour, Delivery, DeliveryItem, DeliveryNote, Customer,
  Article, Warehouse, ScanState, AuditAction — alle mit serde + feature-gated
  utoipa::ToSchema.
- Application: Ports (TourRepository, DeliveryRepository, ScanRepository,
  DeliveryNoteRepository, CarRepository, AuthService) und Use Cases.
- Infrastructure: Postgres-Adapter via sqlx (PgTourRepository etc.) +
  Keycloak-AuthService mit JWKS-Cache + OIDC-Discovery.
- API: Axum 0.8, utoipa-OpenAPI + Swagger-UI, JWT-Bearer-Middleware,
  AuthenticatedUser-Extractor.

Endpoints:
- GET /me/tours/today, /tours/{id}, /accounts/{pn}, /me/cars, /health
- POST /sync/tour, /scans (bulk + idempotent via clientScanId),
  /deliveries/{id}/{hold,resume,cancel,complete,notes}, /me/cars
- PUT /tours/{id}/delivery-order, /deliveries/{id}/assigned-car, /me/cars/{id}
- PATCH /me/cars/{id}

Datenmodell:
- 6 Migrationen (accounts, tours/deliveries/items + Stammdaten,
  scan_audit mit clientScanId-UNIQUE, state_reason refactor,
  delivery_notes, cars + FKs nachziehen).
- Business-stabile Beleg-Keys (belegart_id, belegnummer) für ERP-Sync.
- Append-only scan_audit + embedded scan_state als doppelte Wahrheit.

Dev-Setup:
- docker-compose mit Postgres 17 + Keycloak 26
- Keycloak-Realm 'holzleitner' mit Public-Client (PKCE), Testfahrer
  (PN 1001) + Audience-/Personalnummer-Mapper
This commit is contained in:
Dennis Nemec
2026-05-14 22:28:31 +02:00
commit 438040acce
83 changed files with 8922 additions and 0 deletions

View File

@ -0,0 +1,50 @@
//! Request- und Antwort-Typen für die Cars-Endpoints.
//!
//! Alle Cars-Endpoints sind aus Sicht des angemeldeten Fahrers ("/me/...").
//! Der Account-Bezug kommt aus dem JWT, nicht aus dem Body.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use holzleitner_domain::Car;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CreateCarRequest {
pub plate: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct UpdateCarRequest {
/// Wenn gesetzt: neues Kennzeichen.
pub plate: Option<String>,
/// Wenn gesetzt: aktiv/inaktiv. Inaktive Fahrzeuge tauchen in
/// `GET /me/cars?activeOnly=true` (default) nicht auf.
pub active: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CarResponse {
pub car: Car,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CarsList {
pub cars: Vec<Car>,
}
/// Setzt das `assigned_car_id` einer Lieferung. `None` (`carId: null`)
/// entfernt die Zuordnung.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct AssignCarRequest {
pub car_id: Option<Uuid>,
}

View File

@ -0,0 +1,33 @@
//! Eingaben und Antwort für die vier Delivery-Lifecycle-Endpunkte.
//!
//! Pro Endpoint genau ein eigener Request-Typ:
//! * Hold + Cancel brauchen einen Pflicht-Reason.
//! * Resume + Complete sind body-frei (App schickt leeres `{}`).
//!
//! Die Antwort liefert immer die frisch aktualisierte `Delivery` zurück,
//! damit die App ihre lokale Sicht in einem Schritt synchron halten kann.
use serde::{Deserialize, Serialize};
use holzleitner_domain::Delivery;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct HoldDeliveryRequest {
pub reason: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CancelDeliveryRequest {
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryResponse {
pub delivery: Delivery,
}

View File

@ -0,0 +1,35 @@
//! Request/Response für `PUT /tours/{id}/delivery-order`.
//!
//! Der Client schickt **die vollständige neue Reihenfolge** aller
//! Lieferungen der Tour. Der Server validiert, dass die Menge der Ids
//! exakt zur Tour passt — fehlende oder fremde Ids werden hart abgelehnt.
//! Damit kann ein Client mit veralteten Daten nicht versehentlich
//! Lieferungen "verlieren".
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SetDeliveryOrderRequest {
/// Reihenfolge: Position im Array (0-basiert) wird zu `sort_order`
/// (1-basiert) gemappt.
pub delivery_ids: Vec<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SetDeliveryOrderResponse {
pub tour_id: Uuid,
pub order: Vec<DeliveryOrderEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryOrderEntry {
pub delivery_id: Uuid,
pub sort_order: i32,
}

View File

@ -0,0 +1,35 @@
//! Data Transfer Objects der Application-Schicht.
//!
//! DTOs aggregieren Domänenobjekte zu **Use-Case-Antworten** und
//! beschreiben die Eingaben für Sync-Operationen. Sie bleiben bewusst
//! im Application-Layer (nicht im Domain-Layer), weil sie eine
//! Use-Case-Sicht auf die Daten sind, kein Stück Geschäftslogik —
//! ein anderer Use-Case darf eine andere Projektion liefern.
//!
//! Serde- und (optional) utoipa-Annotationen erlauben, dieselbe
//! Struktur direkt als HTTP-Antwort zu serialisieren — das spart eine
//! zweite Schicht handgeschriebener API-DTOs.
pub mod car;
pub mod delivery_action;
pub mod delivery_order;
pub mod note;
pub mod scan;
pub mod tour_details;
pub mod tour_summary;
pub mod tour_sync;
pub use car::{
AssignCarRequest, CarResponse, CarsList, CreateCarRequest, UpdateCarRequest,
};
pub use delivery_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest};
pub use delivery_order::{
DeliveryOrderEntry, SetDeliveryOrderRequest, SetDeliveryOrderResponse,
};
pub use note::{CreateDeliveryNoteRequest, DeliveryNoteResponse};
pub use scan::{
ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus,
};
pub use tour_details::{DeliveryWithItems, TourDetails};
pub use tour_summary::TourSummary;
pub use tour_sync::{SyncDelivery, SyncDeliveryItem, SyncTourRequest};

View File

@ -0,0 +1,29 @@
//! Request/Response für `POST /deliveries/{id}/notes`.
//!
//! Mindestens eines von `text` und `image_attachment` muss gesetzt
//! sein — die Constraint wird im Use Case geprüft und steht zusätzlich
//! als DB-CHECK.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use holzleitner_domain::DeliveryNote;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CreateDeliveryNoteRequest {
pub text: Option<String>,
/// Object-Storage-Key oder URL eines vorab hochgeladenen Bildes.
pub image_attachment: Option<String>,
/// Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten
/// Account gehören. `None` ist erlaubt.
pub author_car_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryNoteResponse {
pub note: DeliveryNote,
}

View File

@ -0,0 +1,73 @@
//! Bulk-Scan-Endpoint: Request, Response, pro-Event-Result.
//!
//! Idempotenz läuft über `client_scan_id`: ein UUID, das die App pro
//! erzeugtem Scan-Event genau einmal vergibt. Retry desselben Events
//! liefert `Duplicate` zurück. Pro Event ein eigener kleiner Vorgang —
//! ein einzelner Reject blockiert die anderen nicht.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use holzleitner_domain::{AuditAction, ScanState};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ApplyScansRequest {
pub scans: Vec<ScanEvent>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ScanEvent {
pub client_scan_id: Uuid,
pub delivery_item_id: Uuid,
pub action: AuditAction,
/// Pflicht bei `Hold` und `Remove`. Sonst ignoriert.
pub reason: Option<String>,
pub client_scanned_at: DateTime<Utc>,
/// Fahrzeug, in dem der Scan gemacht wurde. Muss zum
/// angemeldeten Account gehören. `None` ist erlaubt, schwächt
/// aber den Audit-Trail.
pub actor_car_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ApplyScansResponse {
pub results: Vec<ScanResult>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ScanResultStatus {
/// Aktion wurde frisch angewendet; Audit-Eintrag geschrieben.
Applied,
/// `client_scan_id` war bereits bekannt. Item-State unverändert,
/// `scan_state` zeigt den aktuellen Stand am Server.
Duplicate,
/// Aktion wurde abgelehnt (z. B. unbekanntes Item, invalider
/// Statusübergang, fehlender Pflicht-Reason). `reason` füllt die
/// Begründung; `scan_state` ist `None`.
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ScanResult {
pub client_scan_id: Uuid,
pub status: ScanResultStatus,
/// Bei `Rejected`: Begründung. Bei `Applied`/`Duplicate`: `None`.
pub reason: Option<String>,
/// Aktueller `scan_state` der Position nach der Verarbeitung —
/// genau dann gesetzt, wenn der Server den Stand kennen konnte
/// (`Applied` oder `Duplicate`). Erlaubt der App, die UI ohne
/// Re-Fetch zu aktualisieren.
pub delivery_item_id: Option<Uuid>,
pub new_scan_state: Option<ScanState>,
}

View File

@ -0,0 +1,41 @@
//! Aggregat-Antwort für `GET /tours/{id}`.
//!
//! Vereint Tour, alle ihre Lieferungen mit Positionen sowie die
//! referenzierten Stammdaten (Kunden, Artikel, Lager) in einem
//! Payload. Die Stammdaten werden als deduplizierte Lookup-Listen
//! mitgeliefert — die App joint clientseitig per Id. Das spart
//! gegenüber vollständig denormalisierten Items spürbar Payload,
//! wenn die gleichen Artikel/Lager mehrfach vorkommen.
use serde::Serialize;
use holzleitner_domain::{
Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote, Tour, Warehouse,
};
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct TourDetails {
pub tour: Tour,
pub deliveries: Vec<DeliveryWithItems>,
pub customers: Vec<Customer>,
pub customer_contacts: Vec<CustomerContact>,
pub articles: Vec<Article>,
pub warehouses: Vec<Warehouse>,
/// Alle Notizen aller Lieferungen dieser Tour, in einer Liste.
/// Die App joint clientseitig per `delivery_id`. Reihenfolge:
/// pro Lieferung aufsteigend nach `created_at`.
pub notes: Vec<DeliveryNote>,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryWithItems {
#[serde(flatten)]
pub delivery: Delivery,
/// Sortier-Reihenfolge innerhalb der Tour (1-basiert).
pub sort_order: i32,
pub items: Vec<DeliveryItem>,
}

View File

@ -0,0 +1,17 @@
//! Liste-Antwort für `GET /me/tours/today` — leichtgewichtig, ohne
//! Lieferpositionen. Die App nutzt diese Liste für die
//! Fahrzeug/Tour-Auswahl-Page und lädt erst nach der Auswahl die
//! vollständige Tour über `GET /tours/{id}`.
use chrono::NaiveDate;
use serde::Serialize;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct TourSummary {
pub tour_id: Uuid,
pub tour_date: NaiveDate,
pub delivery_count: i64,
}

View File

@ -0,0 +1,68 @@
//! Request-Body für `POST /sync/tour`.
//!
//! Diese Struktur ist die Kontaktfläche zum ERP. Sie beschreibt eine
//! Tagestour eines Fahrers inklusive aller Lieferungen und Positionen.
//! Identität:
//! * Tour: `(driver_personalnummer, tour_date)` — Upsert.
//! * Delivery: `(belegart_id, belegnummer)` — Upsert.
//! * DeliveryItem: `(delivery, belegzeilen_nr, komponenten_artikel_nr)`.
//!
//! Bewusst keine UUIDs vom Sender erwartet — die ERP-Welt arbeitet mit
//! ihren eigenen business-stabilen Keys, wir generieren UUIDs intern.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use holzleitner_domain::Address;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SyncTourRequest {
pub driver_personalnummer: i64,
pub tour_date: NaiveDate,
pub deliveries: Vec<SyncDelivery>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SyncDelivery {
pub belegart_id: i64,
pub belegnummer: String,
pub erp_customer_id: i64,
pub customer_name: String,
pub customer_address: Address,
/// Snapshot der Lieferadresse (kann von der Stammadresse abweichen).
pub delivery_address: Address,
/// 1-basiert, definiert die initiale Reihenfolge in der App.
pub sort_order: i32,
pub desired_time: Option<String>,
pub special_agreements: Option<String>,
pub items: Vec<SyncDeliveryItem>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SyncDeliveryItem {
pub belegzeilen_nr: i32,
/// Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer.
pub komponenten_artikel_nr: Option<String>,
pub article_number: String,
pub article_name: String,
/// Default-Lager-Code für den Artikel (Anlage neuer Artikel).
pub article_default_warehouse_code: Option<String>,
pub article_scannable: bool,
pub warehouse_code: String,
pub warehouse_name: String,
pub required_quantity: i32,
}

View File

@ -0,0 +1,31 @@
use thiserror::Error;
/// Fehler-Hierarchie der Application-Schicht.
///
/// Use Cases geben diesen Typ zurück. Die API-Schicht mappt ihn auf
/// HTTP-Statuscodes (`NotFound` → 404, `Unauthorized` → 401, …),
/// Persistence-Adapter mappen ihre konkreten Fehler nach
/// `Repository(...)` hinein.
#[derive(Debug, Error)]
pub enum ApplicationError {
#[error("not found")]
NotFound,
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("validation: {0}")]
Validation(String),
#[error("repository: {0}")]
Repository(String),
#[error("external service: {0}")]
External(String),
#[error("unexpected: {0}")]
Unexpected(String),
}

View File

@ -0,0 +1,17 @@
//! Application Layer — Use Cases und Ports.
//!
//! Diese Crate kennt das [`holzleitner_domain`]-Modell und definiert
//! gegen welche **Ports** (Repository-Traits, Service-Traits) die Use
//! Cases programmieren. Konkrete Implementierungen leben in
//! `holzleitner-infrastructure` (Postgres, Keycloak, …).
//!
//! Diese Crate hat bewusst **keine** Abhängigkeit auf eine konkrete
//! Datenbank, ein HTTP-Framework oder einen Auth-Provider — sie soll
//! testbar und framework-unabhängig bleiben.
pub mod dto;
pub mod error;
pub mod ports;
pub mod usecases;
pub use error::ApplicationError;

View File

@ -0,0 +1,17 @@
use async_trait::async_trait;
use holzleitner_domain::Account;
use crate::error::ApplicationError;
/// Repository für [`Account`]-Lesezugriffe. Schreibzugriffe folgen
/// später, sobald sie fachlich gebraucht werden (Accounts werden in der
/// Regel aus dem ERP gespiegelt, nicht durch die App erzeugt).
#[async_trait]
pub trait AccountRepository: Send + Sync {
/// Liest einen Account anhand seiner Personalnummer.
/// Liefert `None`, wenn kein Datensatz existiert.
async fn find_by_personalnummer(
&self,
personalnummer: i64,
) -> Result<Option<Account>, ApplicationError>;
}

View File

@ -0,0 +1,80 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use thiserror::Error;
use crate::error::ApplicationError;
/// Verifizierte Claims eines OAuth2/OIDC-Access-Tokens.
///
/// Bewusst sparsam — nur das, was die Anwendung wirklich braucht.
/// Identität und Autorisierung leiten sich aus `personalnummer` +
/// `roles` ab; `subject` ist die Keycloak-User-UUID für Audit-/Log-
/// Zwecke.
#[derive(Debug, Clone)]
pub struct Claims {
/// Personalnummer aus dem Custom-Claim (User-Attribut). Identifiziert
/// den Account in unserer Welt (siehe [`holzleitner_domain::Account`]).
pub personalnummer: i64,
/// Keycloak-User-UUID (`sub`). Stabil, opak — eignet sich für Logs,
/// nicht für fachliche Aussagen.
pub subject: String,
/// Realm-Rollen (z. B. `driver`). Quelle für RBAC-Checks in Use Cases.
pub roles: Vec<String>,
/// Ablaufzeitpunkt aus `exp`. Nur informativ — die Signatur-Validierung
/// im Adapter prüft das bereits.
pub expires_at: DateTime<Utc>,
}
impl Claims {
/// Convenience-Check, ob der Anwender eine bestimmte Rolle trägt.
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|r| r == role)
}
}
/// Fehler des [`AuthService`]. Adapter-spezifische Details bleiben in den
/// Varianten, die API-Schicht mappt sie pauschal auf 401 (Unauthorized) —
/// außer `External`, das auf 500/502 mappt.
#[derive(Debug, Error)]
pub enum AuthError {
#[error("missing bearer token")]
MissingToken,
#[error("token expired")]
Expired,
#[error("invalid token: {0}")]
Invalid(String),
#[error("audience mismatch")]
InvalidAudience,
#[error("missing claim: {0}")]
MissingClaim(&'static str),
/// Auth-Provider nicht erreichbar oder antwortet nicht plausibel.
/// Aus Anwendersicht ein 5xx, nicht ein 401.
#[error("external auth service: {0}")]
External(String),
}
impl From<AuthError> for ApplicationError {
fn from(err: AuthError) -> Self {
match err {
AuthError::External(msg) => ApplicationError::External(msg),
_ => ApplicationError::Unauthorized,
}
}
}
/// Verifiziert OAuth2/OIDC-Access-Tokens. Konkrete Implementierung in
/// `holzleitner-infrastructure` (Keycloak via OIDC-Discovery + JWKS).
#[async_trait]
pub trait AuthService: Send + Sync {
/// Validiert das übergebene Bearer-Token (ohne `Bearer `-Prefix) und
/// gibt die extrahierten Claims zurück.
async fn verify_token(&self, bearer_token: &str) -> Result<Claims, AuthError>;
}

View File

@ -0,0 +1,58 @@
//! Port für Fahrzeug-Stammdaten.
//!
//! Alle Lese- und Schreibzugriffe gehen über `account_id` (=
//! Personalnummer aus dem JWT). Es gibt **keine** Methode, die
//! Fahrzeuge ohne Account-Filter zurückgibt — so ist die Isolation
//! zwischen Subunternehmer-Accounts strukturell garantiert.
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::Car;
use crate::error::ApplicationError;
#[async_trait]
pub trait CarRepository: Send + Sync {
/// Liste der Fahrzeuge eines Accounts. `include_inactive = false`
/// blendet deaktivierte Fahrzeuge aus (Default für die App).
async fn find_by_account(
&self,
personalnummer: i64,
include_inactive: bool,
) -> Result<Vec<Car>, ApplicationError>;
/// Sucht ein Fahrzeug, das einem Account gehört. `None` wenn es
/// das Fahrzeug nicht gibt **oder** zu einem anderen Account
/// gehört — beides sieht für den Caller gleich aus, das ist
/// gewollt (kein Account-Probing).
async fn find_by_id_for_account(
&self,
car_id: Uuid,
personalnummer: i64,
) -> Result<Option<Car>, ApplicationError>;
async fn create(
&self,
personalnummer: i64,
plate: &str,
) -> Result<Car, ApplicationError>;
/// Optional-Patch. `None` heißt "unverändert".
async fn update(
&self,
car_id: Uuid,
personalnummer: i64,
plate: Option<&str>,
active: Option<bool>,
) -> Result<Car, ApplicationError>;
/// Validiert, dass alle übergebenen IDs zum Account gehören. Nutzt
/// der Use-Case beim Bulk-Scan, um die `actor_car_id`-Werte
/// einmalig vor dem Verarbeiten zu prüfen.
async fn assert_owned_by_account(
&self,
car_ids: &[Uuid],
personalnummer: i64,
) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,24 @@
//! Port für Delivery-Notizen.
//!
//! Aktuell nur das Anlegen — der Read-Pfad läuft als Teil des Tour-
//! Aggregats (`TourDetails.notes`). Sollten irgendwann Listen-Reads
//! oder Updates an einzelnen Notizen nötig werden, kommen die hier rein.
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::DeliveryNote;
use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryNoteRepository: Send + Sync {
async fn create(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
text: Option<String>,
image_attachment: Option<String>,
) -> Result<DeliveryNote, ApplicationError>;
}

View File

@ -0,0 +1,44 @@
//! Port für Delivery-Lifecycle-Übergänge.
//!
//! Eine schmale, aktionsbasierte Schnittstelle: der Use Case übergibt
//! eine [`DeliveryAction`], die Persistence führt SELECT-FOR-UPDATE,
//! validiert den Statusübergang und schreibt fort — alles in einer
//! Transaktion. Liefert die frisch aktualisierte [`Delivery`] zurück,
//! damit die API direkt antworten kann.
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::Delivery;
use crate::error::ApplicationError;
#[derive(Debug, Clone)]
pub enum DeliveryAction {
/// State → `Held`, `state_reason` = `reason`.
Hold { reason: String },
/// State → `Active`, `state_reason` = `NULL`. Nur aus `Held`.
Resume,
/// State → `Canceled`, `state_reason` = `reason`. Endgültig.
Cancel { reason: String },
/// State → `Completed`, `state_reason` = `NULL`. Nur aus `Active`.
Complete,
}
#[async_trait]
pub trait DeliveryRepository: Send + Sync {
async fn apply_action(
&self,
delivery_id: Uuid,
action: DeliveryAction,
) -> Result<Delivery, ApplicationError>;
/// Setzt das `assigned_car_id` einer Lieferung. `None` löst die
/// Zuordnung. Der Use Case muss vorher prüfen, dass das
/// Fahrzeug zum angemeldeten Account gehört.
async fn assign_car(
&self,
delivery_id: Uuid,
car_id: Option<Uuid>,
) -> Result<Delivery, ApplicationError>;
}

View File

@ -0,0 +1,22 @@
//! Ports — die Trait-Definitionen, gegen die Use Cases programmieren.
//!
//! Hier landen Repository-Traits (z. B. `TourRepository`,
//! `DeliveryRepository`) sowie Service-Traits (z. B. `AuthService` für
//! Keycloak). Konkrete Implementierungen leben in
//! `holzleitner-infrastructure`.
pub mod account_repository;
pub mod auth_service;
pub mod car_repository;
pub mod delivery_note_repository;
pub mod delivery_repository;
pub mod scan_repository;
pub mod tour_repository;
pub use account_repository::AccountRepository;
pub use auth_service::{AuthError, AuthService, Claims};
pub use car_repository::CarRepository;
pub use delivery_note_repository::DeliveryNoteRepository;
pub use delivery_repository::{DeliveryAction, DeliveryRepository};
pub use scan_repository::{ApplyScanOutcome, ScanRepository};
pub use tour_repository::TourRepository;

View File

@ -0,0 +1,46 @@
//! Port für das Anwenden von Scan-Events.
//!
//! Bewusst eine schmale Schnittstelle: ein einziger Aufruf
//! `apply_one` pro Event. Die Bulk-Logik (Iteration, Sammlung der
//! Ergebnisse) lebt im Use Case — die Persistence kennt nur die
//! atomare Operation und kann sie isoliert testen.
//!
//! `apply_one` MUSS idempotent über `client_scan_id` sein und darf
//! niemals den `scan_state` ändern, wenn der Audit-Eintrag schon
//! existiert.
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::ScanState;
use crate::dto::ScanEvent;
use crate::error::ApplicationError;
/// Ergebnis einer einzelnen Scan-Operation aus Sicht der Persistence.
/// Der Use Case mappt das auf die `ScanResult`-Antwort der API.
#[derive(Debug, Clone)]
pub enum ApplyScanOutcome {
/// Frisch angewendet — neuer State der Position.
Applied {
delivery_item_id: Uuid,
new_state: ScanState,
},
/// `client_scan_id` war bereits da. State unverändert, hier der
/// aktuelle Stand am Server für die App.
Duplicate {
delivery_item_id: Uuid,
current_state: ScanState,
},
/// Abgelehnt (unbekanntes Item, invalider Statusübergang, …).
Rejected { reason: String },
}
#[async_trait]
pub trait ScanRepository: Send + Sync {
async fn apply_one(
&self,
event: &ScanEvent,
actor_personalnummer: i64,
) -> Result<ApplyScanOutcome, ApplicationError>;
}

View File

@ -0,0 +1,52 @@
//! Repository-Port für Touren.
//!
//! Drei Operationen, die direkt drei Use Cases bedienen:
//! * `find_today_for_driver` — leichtgewichtige Liste (Auswahl-Page)
//! * `find_details_by_id` — fettes Aggregat (Sortier-/Beladen-/Auslieferung)
//! * `upsert_from_sync` — idempotente Übernahme aus dem ERP
//!
//! Das fette Aggregat hängen wir bewusst NICHT an die Sync-Operation,
//! weil das ERP nicht den aktuellen Scan-Status kennt und nichts darüber
//! aussagen darf.
use async_trait::async_trait;
use chrono::NaiveDate;
use uuid::Uuid;
use crate::dto::{
DeliveryOrderEntry, SyncTourRequest, TourDetails, TourSummary,
};
use crate::error::ApplicationError;
#[async_trait]
pub trait TourRepository: Send + Sync {
async fn find_today_for_driver(
&self,
personalnummer: i64,
today: NaiveDate,
) -> Result<Vec<TourSummary>, ApplicationError>;
async fn find_details_by_id(
&self,
tour_id: Uuid,
) -> Result<Option<TourDetails>, ApplicationError>;
/// Legt eine Tour samt Lieferungen und Positionen idempotent an
/// bzw. aktualisiert sie. Gibt die `tour_id` zurück.
async fn upsert_from_sync(
&self,
request: &SyncTourRequest,
) -> Result<Uuid, ApplicationError>;
/// Schreibt die `sort_order` aller Lieferungen einer Tour neu.
///
/// Validiert in einer Transaktion, dass die übergebene Id-Menge
/// **exakt** der aktuellen Lieferungs-Menge der Tour entspricht.
/// Fehlende, fremde oder doppelte Ids → `Validation`-Fehler.
/// Gibt die finale Reihenfolge zurück.
async fn set_delivery_order(
&self,
tour_id: Uuid,
delivery_ids: &[Uuid],
) -> Result<Vec<DeliveryOrderEntry>, ApplicationError>;
}

View File

@ -0,0 +1,40 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::Delivery;
use crate::error::ApplicationError;
use crate::ports::{DeliveryAction, DeliveryRepository};
/// Vier Lifecycle-Übergänge an einer Lieferung — `Hold`, `Resume`,
/// `Cancel`, `Complete`. Die Statusmaschine läuft im Repository unter
/// Lock; dieser Use Case validiert die Begründungs-Pflichten vorab.
pub struct ApplyDeliveryActionUseCase {
repository: Arc<dyn DeliveryRepository>,
}
impl ApplyDeliveryActionUseCase {
pub fn new(repository: Arc<dyn DeliveryRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
delivery_id: Uuid,
action: DeliveryAction,
) -> Result<Delivery, ApplicationError> {
match &action {
DeliveryAction::Hold { reason } | DeliveryAction::Cancel { reason } => {
if reason.trim().is_empty() {
return Err(ApplicationError::Validation(
"reason darf nicht leer sein".into(),
));
}
}
DeliveryAction::Resume | DeliveryAction::Complete => {}
}
self.repository.apply_action(delivery_id, action).await
}
}

View File

@ -0,0 +1,118 @@
use std::collections::BTreeSet;
use std::sync::Arc;
use holzleitner_domain::AuditAction;
use crate::dto::{
ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus,
};
use crate::error::ApplicationError;
use crate::ports::{ApplyScanOutcome, CarRepository, ScanRepository};
/// Wendet eine Liste von Scan-Events idempotent an.
///
/// Pro Event ein eigener Vorgang in der Persistence — ein einzelner
/// Reject blockiert die anderen nicht. Pflicht-Reasons (`Hold` /
/// `Remove`) werden hier vorab geprüft, ebenso die Ownership aller
/// in der Batch enthaltenen `actor_car_id`-Werte.
pub struct ApplyScansUseCase {
repository: Arc<dyn ScanRepository>,
cars: Arc<dyn CarRepository>,
}
impl ApplyScansUseCase {
pub fn new(repository: Arc<dyn ScanRepository>, cars: Arc<dyn CarRepository>) -> Self {
Self { repository, cars }
}
pub async fn execute(
&self,
request: ApplyScansRequest,
actor_personalnummer: i64,
) -> Result<ApplyScansResponse, ApplicationError> {
// Distinct car_ids auf einmal validieren — eine Query statt
// pro-Event-Roundtrip.
let distinct_cars: BTreeSet<uuid::Uuid> = request
.scans
.iter()
.filter_map(|e| e.actor_car_id)
.collect();
if !distinct_cars.is_empty() {
let ids: Vec<uuid::Uuid> = distinct_cars.into_iter().collect();
self.cars
.assert_owned_by_account(&ids, actor_personalnummer)
.await?;
}
let mut results = Vec::with_capacity(request.scans.len());
for event in &request.scans {
if let Some(reason) = pre_validate(event) {
results.push(ScanResult {
client_scan_id: event.client_scan_id,
status: ScanResultStatus::Rejected,
reason: Some(reason),
delivery_item_id: None,
new_scan_state: None,
});
continue;
}
let outcome = self
.repository
.apply_one(event, actor_personalnummer)
.await?;
results.push(match outcome {
ApplyScanOutcome::Applied {
delivery_item_id,
new_state,
} => ScanResult {
client_scan_id: event.client_scan_id,
status: ScanResultStatus::Applied,
reason: None,
delivery_item_id: Some(delivery_item_id),
new_scan_state: Some(new_state),
},
ApplyScanOutcome::Duplicate {
delivery_item_id,
current_state,
} => ScanResult {
client_scan_id: event.client_scan_id,
status: ScanResultStatus::Duplicate,
reason: None,
delivery_item_id: Some(delivery_item_id),
new_scan_state: Some(current_state),
},
ApplyScanOutcome::Rejected { reason } => ScanResult {
client_scan_id: event.client_scan_id,
status: ScanResultStatus::Rejected,
reason: Some(reason),
delivery_item_id: None,
new_scan_state: None,
},
});
}
Ok(ApplyScansResponse { results })
}
}
/// Validiert Pflichtfelder ohne DB-Aufruf. Liefert `Some(reason)`,
/// wenn das Event verworfen werden soll.
fn pre_validate(event: &ScanEvent) -> Option<String> {
match event.action {
AuditAction::Hold | AuditAction::Remove => {
let trimmed = event.reason.as_deref().map(str::trim).unwrap_or("");
if trimmed.is_empty() {
Some(format!(
"reason required for action {:?}",
event.action
))
} else {
None
}
}
AuditAction::Scan | AuditAction::Unscan | AuditAction::Unhold => None,
}
}

View File

@ -0,0 +1,124 @@
//! Use Cases rund um die Fahrzeug-Stammdaten eines Fahrers.
//!
//! Vier kleine Operationen — alle leichtgewichtig, weil die
//! Account-Isolation strukturell im Repository sitzt.
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::Car;
use crate::dto::{CreateCarRequest, UpdateCarRequest};
use crate::error::ApplicationError;
use crate::ports::CarRepository;
pub struct ListMyCarsUseCase {
repository: Arc<dyn CarRepository>,
}
impl ListMyCarsUseCase {
pub fn new(repository: Arc<dyn CarRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
personalnummer: i64,
include_inactive: bool,
) -> Result<Vec<Car>, ApplicationError> {
self.repository
.find_by_account(personalnummer, include_inactive)
.await
}
}
pub struct CreateMyCarUseCase {
repository: Arc<dyn CarRepository>,
}
impl CreateMyCarUseCase {
pub fn new(repository: Arc<dyn CarRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
personalnummer: i64,
request: CreateCarRequest,
) -> Result<Car, ApplicationError> {
let plate = request.plate.trim();
if plate.is_empty() {
return Err(ApplicationError::Validation(
"plate darf nicht leer sein".into(),
));
}
self.repository.create(personalnummer, plate).await
}
}
pub struct UpdateMyCarUseCase {
repository: Arc<dyn CarRepository>,
}
impl UpdateMyCarUseCase {
pub fn new(repository: Arc<dyn CarRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
car_id: Uuid,
personalnummer: i64,
request: UpdateCarRequest,
) -> Result<Car, ApplicationError> {
// Leerer Body ist erlaubt (idempotenter PATCH), aber wenn
// plate gesetzt ist, darf es nicht nur Whitespace sein.
let plate = match request.plate {
Some(p) => {
let trimmed = p.trim();
if trimmed.is_empty() {
return Err(ApplicationError::Validation(
"plate darf nicht leer sein".into(),
));
}
Some(trimmed.to_owned())
}
None => None,
};
self.repository
.update(car_id, personalnummer, plate.as_deref(), request.active)
.await
}
}
/// Setzt das `assigned_car_id` einer Lieferung. Validiert die
/// Fahrzeug-Ownership und delegiert dann an `DeliveryRepository`.
pub struct AssignCarToDeliveryUseCase {
cars: Arc<dyn CarRepository>,
deliveries: Arc<dyn crate::ports::DeliveryRepository>,
}
impl AssignCarToDeliveryUseCase {
pub fn new(
cars: Arc<dyn CarRepository>,
deliveries: Arc<dyn crate::ports::DeliveryRepository>,
) -> Self {
Self { cars, deliveries }
}
pub async fn execute(
&self,
delivery_id: Uuid,
personalnummer: i64,
car_id: Option<Uuid>,
) -> Result<holzleitner_domain::Delivery, ApplicationError> {
if let Some(id) = car_id {
self.cars
.assert_owned_by_account(&[id], personalnummer)
.await?;
}
self.deliveries.assign_car(delivery_id, car_id).await
}
}

View File

@ -0,0 +1,73 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::DeliveryNote;
use crate::dto::CreateDeliveryNoteRequest;
use crate::error::ApplicationError;
use crate::ports::{CarRepository, DeliveryNoteRepository};
/// Legt eine neue Notiz an einer Lieferung an.
///
/// Validierung:
/// * mindestens eines von `text` (nicht-leer nach trim) und
/// `image_attachment` (nicht-leer nach trim) muss gesetzt sein.
/// * `author_car_id` muss — falls gesetzt — zum angemeldeten Account gehören.
pub struct CreateDeliveryNoteUseCase {
repository: Arc<dyn DeliveryNoteRepository>,
cars: Arc<dyn CarRepository>,
}
impl CreateDeliveryNoteUseCase {
pub fn new(
repository: Arc<dyn DeliveryNoteRepository>,
cars: Arc<dyn CarRepository>,
) -> Self {
Self { repository, cars }
}
pub async fn execute(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
request: CreateDeliveryNoteRequest,
) -> Result<DeliveryNote, ApplicationError> {
let text = clean(request.text);
let image = clean(request.image_attachment);
if text.is_none() && image.is_none() {
return Err(ApplicationError::Validation(
"notiz braucht text oder image_attachment".into(),
));
}
if let Some(car_id) = request.author_car_id {
self.cars
.assert_owned_by_account(&[car_id], author_personalnummer)
.await?;
}
self.repository
.create(
delivery_id,
author_personalnummer,
request.author_car_id,
text,
image,
)
.await
}
}
/// Trim + leerer-String → None.
fn clean(input: Option<String>) -> Option<String> {
input.and_then(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
})
}

View File

@ -0,0 +1,28 @@
use std::sync::Arc;
use holzleitner_domain::Account;
use crate::error::ApplicationError;
use crate::ports::AccountRepository;
/// Liefert den Account zu einer Personalnummer oder einen
/// [`ApplicationError::NotFound`], wenn nichts gefunden wurde.
///
/// Bewusst trivial — dient erstmal als End-to-End-Smoke-Test, damit
/// alle Schichten zusammen laufen.
pub struct GetAccountUseCase {
repository: Arc<dyn AccountRepository>,
}
impl GetAccountUseCase {
pub fn new(repository: Arc<dyn AccountRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, personalnummer: i64) -> Result<Account, ApplicationError> {
self.repository
.find_by_personalnummer(personalnummer)
.await?
.ok_or(ApplicationError::NotFound)
}
}

View File

@ -0,0 +1,26 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::dto::TourDetails;
use crate::error::ApplicationError;
use crate::ports::TourRepository;
/// Liefert das vollständige Tour-Aggregat (Lieferungen, Positionen,
/// Stammdaten) für eine Tour-Id. Genau ein Round-Trip für die App.
pub struct GetTourUseCase {
repository: Arc<dyn TourRepository>,
}
impl GetTourUseCase {
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, tour_id: Uuid) -> Result<TourDetails, ApplicationError> {
self.repository
.find_details_by_id(tour_id)
.await?
.ok_or(ApplicationError::NotFound)
}
}

View File

@ -0,0 +1,27 @@
use std::sync::Arc;
use chrono::Utc;
use crate::dto::TourSummary;
use crate::error::ApplicationError;
use crate::ports::TourRepository;
/// Liste der heutigen Touren des angemeldeten Fahrers. Das "heute"
/// liegt **bewusst im Backend**: die App-Uhr ist nicht autoritativ
/// (Zeitzone, Falsch-Stand, Manipulation).
pub struct ListMyToursTodayUseCase {
repository: Arc<dyn TourRepository>,
}
impl ListMyToursTodayUseCase {
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, personalnummer: i64) -> Result<Vec<TourSummary>, ApplicationError> {
let today = Utc::now().date_naive();
self.repository
.find_today_for_driver(personalnummer, today)
.await
}
}

View File

@ -0,0 +1,28 @@
//! Use Cases — Geschäftslogik-Operationen.
//!
//! Jeder Use Case kapselt **eine** Operation aus Sicht des Anwenders
//! (z. B. „Tour des Tages laden", „Artikel scannen", „Lieferung
//! abbrechen"). Use Cases nehmen Ports (Trait-Objekte) per Konstruktor
//! entgegen und orchestrieren damit das Domänenmodell.
pub mod apply_delivery_action;
pub mod apply_scans;
pub mod cars;
pub mod create_delivery_note;
pub mod get_account;
pub mod get_tour;
pub mod list_my_tours_today;
pub mod set_delivery_order;
pub mod sync_tour;
pub use apply_delivery_action::ApplyDeliveryActionUseCase;
pub use apply_scans::ApplyScansUseCase;
pub use cars::{
AssignCarToDeliveryUseCase, CreateMyCarUseCase, ListMyCarsUseCase, UpdateMyCarUseCase,
};
pub use create_delivery_note::CreateDeliveryNoteUseCase;
pub use get_account::GetAccountUseCase;
pub use get_tour::GetTourUseCase;
pub use list_my_tours_today::ListMyToursTodayUseCase;
pub use set_delivery_order::SetDeliveryOrderUseCase;
pub use sync_tour::SyncTourUseCase;

View File

@ -0,0 +1,53 @@
use std::collections::HashSet;
use std::sync::Arc;
use uuid::Uuid;
use crate::dto::{SetDeliveryOrderRequest, SetDeliveryOrderResponse};
use crate::error::ApplicationError;
use crate::ports::TourRepository;
/// Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour neu.
///
/// Eingabe-Validierung (vor DB-Aufruf):
/// * mindestens eine Id
/// * keine Duplikate
///
/// Die Mengen-Übereinstimmung mit der Tour wird in der Persistence
/// geprüft (braucht DB-Kontext).
pub struct SetDeliveryOrderUseCase {
repository: Arc<dyn TourRepository>,
}
impl SetDeliveryOrderUseCase {
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
tour_id: Uuid,
request: SetDeliveryOrderRequest,
) -> Result<SetDeliveryOrderResponse, ApplicationError> {
if request.delivery_ids.is_empty() {
return Err(ApplicationError::Validation(
"delivery_ids darf nicht leer sein".into(),
));
}
let mut seen = HashSet::with_capacity(request.delivery_ids.len());
for id in &request.delivery_ids {
if !seen.insert(*id) {
return Err(ApplicationError::Validation(format!(
"delivery_id {id} kommt mehrfach vor"
)));
}
}
let order = self
.repository
.set_delivery_order(tour_id, &request.delivery_ids)
.await?;
Ok(SetDeliveryOrderResponse { tour_id, order })
}
}

View File

@ -0,0 +1,54 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::dto::SyncTourRequest;
use crate::error::ApplicationError;
use crate::ports::TourRepository;
/// Übernimmt eine Tagestour aus dem ERP. Idempotent: wiederholte Aufrufe
/// mit gleichem `(driver_personalnummer, tour_date)` aktualisieren die
/// bestehende Tour, statt eine neue anzulegen.
///
/// Validierung läuft hier in der Application-Schicht — die Repository-
/// Schicht darf einen sauberen Datensatz erwarten.
pub struct SyncTourUseCase {
repository: Arc<dyn TourRepository>,
}
impl SyncTourUseCase {
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, request: SyncTourRequest) -> Result<Uuid, ApplicationError> {
if request.deliveries.is_empty() {
return Err(ApplicationError::Validation(
"tour ohne lieferungen abgelehnt".into(),
));
}
for delivery in &request.deliveries {
if delivery.belegnummer.trim().is_empty() {
return Err(ApplicationError::Validation(
"leere belegnummer".into(),
));
}
if delivery.items.is_empty() {
return Err(ApplicationError::Validation(format!(
"lieferung {} ohne positionen",
delivery.belegnummer
)));
}
for item in &delivery.items {
if item.required_quantity <= 0 {
return Err(ApplicationError::Validation(format!(
"lieferung {}, position {}: required_quantity muss > 0 sein",
delivery.belegnummer, item.belegzeilen_nr
)));
}
}
}
self.repository.upsert_from_sync(&request).await
}
}