Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand (Clean Architecture: domain → application → infrastructure → api). Wesentliche Bereiche: - ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben) - Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen, Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe - Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services - Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API - Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte Jüngste Änderungen dieser Session: - Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/ Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/ E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails) - Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde) umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/ Kundendaten), demo.mp4, .claude/, variocontrol-ai/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
135 lines
4.7 KiB
Rust
135 lines
4.7 KiB
Rust
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. Mengen- und Status-abhängige
|
|
/// Bounds (z. B. `credited + quantity <= required`, scannbar ⇒ done,
|
|
/// Lieferung aktiv) prüft erst das Repository mit dem gelockten Item.
|
|
fn pre_validate(event: &ScanEvent) -> Option<String> {
|
|
// Eine gesetzte Menge muss positiv sein — und ist nur für die
|
|
// Mengen-Gutschrift (Remove/Unremove) überhaupt sinnvoll.
|
|
if let Some(q) = event.quantity {
|
|
match event.action {
|
|
AuditAction::Remove | AuditAction::Unremove if q <= 0 => {
|
|
return Some("quantity must be > 0".into());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
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
|
|
| AuditAction::Unremove => None,
|
|
}
|
|
}
|