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:
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