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