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

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

View File

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

View File

@ -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<AppState> {
"/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<AppState>,
Path(belegnummer): Path<String>,
) -> Result<Json<DeliveryDetails>, 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

View File

@ -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<ListCompletedDeliveriesUseCase>,
/// Admin: Positions-Änderungs-Flag zu einer einzelnen Belegnummer.
pub get_positions_modified: Arc<GetPositionsModifiedUseCase>,
/// Admin: volles Detail-Paket zu einer Belegnummer.
pub get_delivery_details: Arc<GetDeliveryDetailsUseCase>,
/// Admin: Liefermails von Belegnummern als versendet markieren (Dedup).
pub mark_mail_sent: Arc<MarkMailSentUseCase>,
pub list_pending_reviews: Arc<ListPendingReviewsUseCase>,

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

View File

@ -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<Option<Uuid>, ApplicationError> {
let tour_id: Option<Uuid> = 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 =====================================================