feat(admin): GET /admin/belege/{belegnummer} - volle Lieferdetails

Neuer Admin-Endpunkt, der zu einer ERP-Belegnummer das komplette Detail-Paket
einer Lieferung liefert (Kopf, Positionen mit Scan-Ständen, Kunde +
Ansprechpartner, referenzierte Artikel/Lager, Notizen, Geld-Gutschrift,
Dienstleistungswerte, Kontakte). Jede Lieferung, unabhaengig vom Status;
404 bei unbekannter Belegnummer.

Umsetzung: TourRepository::find_tour_id_by_belegnummer loest die Belegnummer
auf ihre tour_id auf; der Use Case laedt das bestehende Tour-Aggregat und
destilliert daraus in-memory die eine Lieferung samt referenzierter Stammdaten
(neues DTO DeliveryDetails). Kein neuer grosser Query.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-07-09 23:42:57 +02:00
parent 7c6d883d44
commit af034250e7
10 changed files with 234 additions and 2 deletions

View File

@ -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<Customer>,
/// Ansprechpartner des Kunden.
pub customer_contacts: Vec<CustomerContact>,
/// Nur die von den Positionen referenzierten Artikel.
pub articles: Vec<Article>,
/// Nur die von den Positionen referenzierten Lager.
pub warehouses: Vec<Warehouse>,
/// Notizen der Lieferung, aufsteigend nach `createdAt`.
pub notes: Vec<DeliveryNote>,
/// Aktuelle Betrags-Gutschrift (`null`, wenn keine aktiv).
pub credit: Option<DeliveryCredit>,
/// Aktive Service-Definitionen (Stammdaten).
pub services: Vec<Service>,
/// Für diese Lieferung gesetzte Service-Werte.
pub delivery_services: Vec<DeliveryServiceValue>,
/// Kontaktquellen der Lieferung (Liefer-/Rechnungsadresse, Ansprechpartner …).
pub contact_sources: Vec<ContactSource>,
/// Die zu `contactSources` gehörenden Einzel-Kanäle. Join per `sourceId`.
pub contact_channels: Vec<ContactChannel>,
}

View File

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

View File

@ -31,6 +31,16 @@ pub trait TourRepository: Send + Sync {
tour_id: Uuid,
) -> Result<Option<TourDetails>, 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<Option<Uuid>, ApplicationError>;
/// Legt eine Tour samt Lieferungen und Positionen idempotent an
/// bzw. aktualisiert sie. Gibt die `tour_id` zurück.
async fn upsert_from_sync(

View File

@ -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<dyn TourRepository>,
}
impl GetDeliveryDetailsUseCase {
pub fn new(tours: Arc<dyn TourRepository>) -> Self {
Self { tours }
}
/// `NotFound`, wenn die Belegnummer keiner Lieferung zugeordnet ist.
pub async fn execute(
&self,
belegnummer: &str,
) -> Result<DeliveryDetails, ApplicationError> {
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<Uuid> =
delivery.items.iter().map(|i| i.article_id).collect();
let warehouse_ids: HashSet<Uuid> =
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<Uuid> = 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,
})
}
}

View File

@ -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};