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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user