diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index abfe6a1..9bc6e22 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -38,7 +38,8 @@ use holzleitner_application::usecases::{ DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase, DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase, - GetAttachmentPreviewUseCase, GetPositionsModifiedUseCase, GetTourUseCase, + GetAttachmentPreviewUseCase, GetDeliveryDetailsUseCase, GetPositionsModifiedUseCase, + GetTourUseCase, ImportErpToursUseCase, ListCompletedDeliveriesUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase, @@ -294,6 +295,9 @@ pub(crate) async fn run_app( // --- Use Cases ----------------------------------------------------- let get_account = Arc::new(GetAccountUseCase::new(account_repository)); let get_tour = Arc::new(GetTourUseCase::new(tour_repository.clone())); + // Admin: volles Detail-Paket zu einer Belegnummer (nutzt das Tour-Aggregat). + let get_delivery_details = + Arc::new(GetDeliveryDetailsUseCase::new(tour_repository.clone())); let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new( tour_repository.clone(), cfg.dev.today_override, @@ -473,6 +477,7 @@ pub(crate) async fn run_app( list_delivered_belegnummern, list_completed_deliveries, get_positions_modified, + get_delivery_details, mark_mail_sent, list_pending_reviews, resolve_review, diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 4585299..5991da9 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -56,6 +56,7 @@ use utoipa::openapi::security::{ crate::routes::admin::delivered_belegnummern, crate::routes::admin::completed_deliveries, crate::routes::admin::positions_modified, + crate::routes::admin::belege_details, crate::routes::admin::mark_mail_sent, crate::routes::admin::list_reviews, crate::routes::admin::resolve_review, @@ -87,6 +88,7 @@ use utoipa::openapi::security::{ holzleitner_domain::DeliveryServiceValue, holzleitner_domain::Warehouse, holzleitner_application::dto::TourDetails, + holzleitner_application::dto::DeliveryDetails, holzleitner_application::dto::DeliveryWithItems, holzleitner_application::dto::TourSummary, holzleitner_application::dto::SyncTourRequest, diff --git a/crates/api/src/routes/admin.rs b/crates/api/src/routes/admin.rs index 6e69b81..459d590 100644 --- a/crates/api/src/routes/admin.rs +++ b/crates/api/src/routes/admin.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; +use holzleitner_application::dto::DeliveryDetails; use holzleitner_application::error::ApplicationError; use holzleitner_application::usecases::ImportSummary; @@ -37,6 +38,7 @@ pub fn router() -> Router { "/admin/belege/{belegnummer}/positions-modified", get(positions_modified), ) + .route("/admin/belege/{belegnummer}", get(belege_details)) .route("/admin/mark-mail-sent", post(mark_mail_sent)) .route("/admin/reviews", get(list_reviews)) .route("/admin/reviews/{delivery_id}/resolve", post(resolve_review)) @@ -348,6 +350,34 @@ pub async fn positions_modified( })) } +/// Liefert das **volle Detail-Paket** zu einer ERP-Belegnummer: die Lieferung +/// mit Positionen (inkl. Scan-Ständen), Kunde + Ansprechpartner, referenzierte +/// Artikel/Lager, Notizen, Geld-Gutschrift, Dienstleistungswerte und Kontakte — +/// destilliert aus dem Tour-Aggregat. Jede Lieferung, unabhängig vom Status. +/// `404`, wenn die Belegnummer unbekannt ist. +#[utoipa::path( + get, + path = "/admin/belege/{belegnummer}", + tag = "admin", + params( + ("belegnummer" = String, Path, description = "ERP-Belegnummer, z. B. V-30690291") + ), + responses( + (status = 200, description = "Volle Lieferdetails", body = DeliveryDetails), + (status = 401, description = "Admin-API-Key fehlt/ungültig"), + (status = 404, description = "Belegnummer unbekannt") + ), + security(("admin_api_key" = [])) +)] +pub async fn belege_details( + State(state): State, + Path(belegnummer): Path, +) -> Result, ApiError> { + tracing::info!(%belegnummer, "admin.belege_details"); + let details = state.get_delivery_details.execute(&belegnummer).await?; + Ok(Json(details)) +} + #[derive(Debug, Deserialize, ToSchema)] pub struct MarkMailSentRequest { /// Belegnummern, deren Liefermail erfolgreich versendet wurde und die als diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index 1140e65..9516900 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -8,7 +8,8 @@ use holzleitner_application::usecases::{ DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase, DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase, - GetAttachmentPreviewUseCase, GetPositionsModifiedUseCase, GetTourUseCase, + GetAttachmentPreviewUseCase, GetDeliveryDetailsUseCase, GetPositionsModifiedUseCase, + GetTourUseCase, ImportErpToursUseCase, ListCompletedDeliveriesUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase, @@ -56,6 +57,8 @@ pub struct AppState { pub list_completed_deliveries: Arc, /// Admin: Positions-Änderungs-Flag zu einer einzelnen Belegnummer. pub get_positions_modified: Arc, + /// Admin: volles Detail-Paket zu einer Belegnummer. + pub get_delivery_details: Arc, /// Admin: Liefermails von Belegnummern als versendet markieren (Dedup). pub mark_mail_sent: Arc, pub list_pending_reviews: Arc, diff --git a/crates/application/src/dto/delivery_details.rs b/crates/application/src/dto/delivery_details.rs new file mode 100644 index 0000000..07475b9 --- /dev/null +++ b/crates/application/src/dto/delivery_details.rs @@ -0,0 +1,46 @@ +//! Detail-Antwort für `GET /admin/belege/{belegnummer}` — das volle Paket zu +//! **einer** Lieferung, unabhängig vom Status. +//! +//! Gleiche Idee wie [`crate::dto::TourDetails`] (Stammdaten als deduplizierte +//! Lookup-Listen, Client joint per Id), aber auf genau die eine Lieferung +//! gefiltert. Wird im Use Case aus dem Tour-Aggregat destilliert. + +use serde::Serialize; + +use holzleitner_domain::{ + Article, ContactChannel, ContactSource, Customer, CustomerContact, DeliveryCredit, + DeliveryNote, DeliveryServiceValue, Service, Tour, Warehouse, +}; + +use super::tour_details::DeliveryWithItems; + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryDetails { + /// Die Tour, zu der die Lieferung gehört. + pub tour: Tour, + /// Die Lieferung selbst inkl. Positionen (mit Scan-Ständen) und `sortOrder`. + pub delivery: DeliveryWithItems, + /// Der Kunde der Lieferung (`null`, falls unauffindbar — sollte nicht + /// vorkommen). + pub customer: Option, + /// Ansprechpartner des Kunden. + pub customer_contacts: Vec, + /// Nur die von den Positionen referenzierten Artikel. + pub articles: Vec
, + /// Nur die von den Positionen referenzierten Lager. + pub warehouses: Vec, + /// Notizen der Lieferung, aufsteigend nach `createdAt`. + pub notes: Vec, + /// Aktuelle Betrags-Gutschrift (`null`, wenn keine aktiv). + pub credit: Option, + /// Aktive Service-Definitionen (Stammdaten). + pub services: Vec, + /// Für diese Lieferung gesetzte Service-Werte. + pub delivery_services: Vec, + /// Kontaktquellen der Lieferung (Liefer-/Rechnungsadresse, Ansprechpartner …). + pub contact_sources: Vec, + /// Die zu `contactSources` gehörenden Einzel-Kanäle. Join per `sourceId`. + pub contact_channels: Vec, +} diff --git a/crates/application/src/dto/mod.rs b/crates/application/src/dto/mod.rs index aa33eda..4b6d07d 100644 --- a/crates/application/src/dto/mod.rs +++ b/crates/application/src/dto/mod.rs @@ -14,6 +14,7 @@ pub mod car; pub mod complete; pub mod credit; pub mod delivery_action; +pub mod delivery_details; pub mod delivery_report; pub mod delivery_order; pub mod note; @@ -34,6 +35,7 @@ pub use delivery_report::{ ReportItem, ReportNote, ReportScanAudit, ReportService, }; pub use delivery_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest}; +pub use delivery_details::DeliveryDetails; pub use delivery_order::{ DeliveryOrderEntry, SetDeliveryOrderRequest, SetDeliveryOrderResponse, }; diff --git a/crates/application/src/ports/tour_repository.rs b/crates/application/src/ports/tour_repository.rs index d79c949..b2efd2e 100644 --- a/crates/application/src/ports/tour_repository.rs +++ b/crates/application/src/ports/tour_repository.rs @@ -31,6 +31,16 @@ pub trait TourRepository: Send + Sync { tour_id: Uuid, ) -> Result, ApplicationError>; + /// Löst eine ERP-Belegnummer auf die `tour_id` ihrer Lieferung auf. + /// `None`, wenn die Belegnummer keiner Lieferung zugeordnet ist. Bei + /// mehreren Lieferungen gleicher Belegnummer wird eine (die erste) + /// zurückgegeben. Dient dem Detail-Abruf per Belegnummer, der darüber das + /// Tour-Aggregat lädt und auf die eine Lieferung filtert. + async fn find_tour_id_by_belegnummer( + &self, + belegnummer: &str, + ) -> Result, ApplicationError>; + /// Legt eine Tour samt Lieferungen und Positionen idempotent an /// bzw. aktualisiert sie. Gibt die `tour_id` zurück. async fn upsert_from_sync( diff --git a/crates/application/src/usecases/get_delivery_details.rs b/crates/application/src/usecases/get_delivery_details.rs new file mode 100644 index 0000000..c6e8d75 --- /dev/null +++ b/crates/application/src/usecases/get_delivery_details.rs @@ -0,0 +1,117 @@ +//! Use Case: volles Detail-Paket zu einer Lieferung per ERP-Belegnummer. +//! +//! Löst die Belegnummer auf ihre `tour_id` auf, lädt das (getestete) +//! Tour-Aggregat und **destilliert** daraus genau die eine Lieferung samt der +//! von ihr referenzierten Stammdaten. Kein neuer großer Query — reine +//! In-Memory-Projektion des vorhandenen Aggregats. Status der Lieferung egal. + +use std::collections::HashSet; +use std::sync::Arc; + +use uuid::Uuid; + +use crate::dto::{DeliveryDetails, TourDetails}; +use crate::error::ApplicationError; +use crate::ports::TourRepository; + +pub struct GetDeliveryDetailsUseCase { + tours: Arc, +} + +impl GetDeliveryDetailsUseCase { + pub fn new(tours: Arc) -> Self { + Self { tours } + } + + /// `NotFound`, wenn die Belegnummer keiner Lieferung zugeordnet ist. + pub async fn execute( + &self, + belegnummer: &str, + ) -> Result { + let tour_id = self + .tours + .find_tour_id_by_belegnummer(belegnummer) + .await? + .ok_or(ApplicationError::NotFound)?; + + let TourDetails { + tour, + deliveries, + customers, + customer_contacts, + articles, + warehouses, + notes, + credits, + services, + delivery_services, + contact_sources, + contact_channels, + } = self + .tours + .find_details_by_id(tour_id) + .await? + .ok_or(ApplicationError::NotFound)?; + + // Die eine Lieferung aus dem Aggregat herauslösen. + let delivery = deliveries + .into_iter() + .find(|d| d.delivery.erp_belegnummer == belegnummer) + .ok_or(ApplicationError::NotFound)?; + + let delivery_id = delivery.delivery.id; + let customer_id = delivery.delivery.customer_id; + let article_ids: HashSet = + delivery.items.iter().map(|i| i.article_id).collect(); + let warehouse_ids: HashSet = + delivery.items.iter().map(|i| i.warehouse_id).collect(); + + // Stammdaten auf das Referenzierte, Kollektionen auf die Lieferung filtern. + let customer = customers.into_iter().find(|c| c.id == customer_id); + let customer_contacts = customer_contacts + .into_iter() + .filter(|c| c.customer_id == customer_id) + .collect(); + let articles = articles + .into_iter() + .filter(|a| article_ids.contains(&a.id)) + .collect(); + let warehouses = warehouses + .into_iter() + .filter(|w| warehouse_ids.contains(&w.id)) + .collect(); + let notes = notes + .into_iter() + .filter(|n| n.delivery_id == delivery_id) + .collect(); + let credit = credits.into_iter().find(|c| c.delivery_id == delivery_id); + let delivery_services = delivery_services + .into_iter() + .filter(|s| s.delivery_id == delivery_id) + .collect(); + let contact_sources: Vec<_> = contact_sources + .into_iter() + .filter(|s| s.delivery_id == delivery_id) + .collect(); + let source_ids: HashSet = contact_sources.iter().map(|s| s.id).collect(); + let contact_channels = contact_channels + .into_iter() + .filter(|c| source_ids.contains(&c.source_id)) + .collect(); + + Ok(DeliveryDetails { + tour, + delivery, + customer, + customer_contacts, + articles, + warehouses, + notes, + credit, + services, + delivery_services, + contact_sources, + contact_channels, + }) + } +} diff --git a/crates/application/src/usecases/mod.rs b/crates/application/src/usecases/mod.rs index 6758f18..1ecdf48 100644 --- a/crates/application/src/usecases/mod.rs +++ b/crates/application/src/usecases/mod.rs @@ -19,6 +19,7 @@ pub mod get_account; pub mod get_attachment_preview; pub mod get_tour; pub mod import_erp_tours; +pub mod get_delivery_details; pub mod get_positions_modified; pub mod list_completed_deliveries; pub mod list_delivered_belegnummern; @@ -48,6 +49,7 @@ pub use generate_delivery_report::GenerateDeliveryReportUseCase; pub use delete_delivery_note::DeleteDeliveryNoteUseCase; pub use get_account::GetAccountUseCase; pub use get_attachment_preview::GetAttachmentPreviewUseCase; +pub use get_delivery_details::GetDeliveryDetailsUseCase; pub use get_positions_modified::GetPositionsModifiedUseCase; pub use get_tour::GetTourUseCase; pub use import_erp_tours::{ImportErpToursUseCase, ImportSummary}; diff --git a/crates/infrastructure/src/persistence/tour_repository.rs b/crates/infrastructure/src/persistence/tour_repository.rs index 3200f76..21a1ceb 100644 --- a/crates/infrastructure/src/persistence/tour_repository.rs +++ b/crates/infrastructure/src/persistence/tour_repository.rs @@ -988,6 +988,21 @@ impl TourRepository for PgTourRepository { tx.commit().await.map_err(db)?; Ok(res.rows_affected()) } + + async fn find_tour_id_by_belegnummer( + &self, + belegnummer: &str, + ) -> Result, ApplicationError> { + let tour_id: Option = sqlx::query_scalar( + "SELECT tour_id FROM deliveries WHERE erp_belegnummer = $1 LIMIT 1", + ) + .bind(belegnummer) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + + Ok(tour_id) + } } // ===== Upsert-Helfer =====================================================