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, cars: Arc, } impl ApplyScansUseCase { pub fn new(repository: Arc, cars: Arc) -> Self { Self { repository, cars } } pub async fn execute( &self, request: ApplyScansRequest, actor_personalnummer: i64, ) -> Result { // Distinct car_ids auf einmal validieren — eine Query statt // pro-Event-Roundtrip. let distinct_cars: BTreeSet = request .scans .iter() .filter_map(|e| e.actor_car_id) .collect(); if !distinct_cars.is_empty() { let ids: Vec = 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 { // 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, } }