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