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