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