feat(admin): GET /admin/completed-deliveries — abgeschlossene Lieferungen eines Tages

Neuer Admin-Endpunkt, der ALLE an einem Tag (Berliner Kalendertag von
completed_at) abgeschlossenen Lieferungen liefert — unabhängig vom
Mail-Versand-Status. Pro Lieferung: Belegnummer und ein Flag
`positions_modified`, das true ist, wenn eine Belegzeile in der Menge
reduziert/entfernt wurde (delivery_items.credited_quantity > 0) oder eine
aktive Geld-Gutschrift vorliegt (jüngstes delivery_credit_audit-Event 'set').

Pflichtparameter `day` (DD-MM-YYYY). Geschützt per admin_api_key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-07-09 16:06:01 +02:00
parent 819005eaa5
commit 748df577ef
9 changed files with 206 additions and 4 deletions

View File

@ -39,7 +39,8 @@ use holzleitner_application::usecases::{
DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase, DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase,
GenerateDeliveryReportUseCase, GetAccountUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase,
GetAttachmentPreviewUseCase, GetTourUseCase, GetAttachmentPreviewUseCase, GetTourUseCase,
ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ImportErpToursUseCase, ListCompletedDeliveriesUseCase, ListDeliveredBelegnummernUseCase,
ListMyCarsUseCase,
ListMyToursTodayUseCase, ListPaymentMethodsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase,
ListPendingReviewsUseCase, ListServicesUseCase, MarkMailSentUseCase, ListPendingReviewsUseCase, ListServicesUseCase, MarkMailSentUseCase,
PushCompletionToErpUseCase, ResolveReviewUseCase, PushCompletionToErpUseCase, ResolveReviewUseCase,
@ -365,6 +366,11 @@ pub(crate) async fn run_app(
let list_delivered_belegnummern = Arc::new(ListDeliveredBelegnummernUseCase::new( let list_delivered_belegnummern = Arc::new(ListDeliveredBelegnummernUseCase::new(
delivery_completion_repository.clone(), delivery_completion_repository.clone(),
)); ));
// Admin-Lese-Use-Case: alle an einem Tag abgeschlossenen Lieferungen samt
// Positions-Änderungs-Flag. `.clone()` VOR dem Move in `complete_delivery`.
let list_completed_deliveries = Arc::new(ListCompletedDeliveriesUseCase::new(
delivery_completion_repository.clone(),
));
let mark_mail_sent = Arc::new(MarkMailSentUseCase::new( let mark_mail_sent = Arc::new(MarkMailSentUseCase::new(
delivery_completion_repository.clone(), delivery_completion_repository.clone(),
)); ));
@ -460,6 +466,7 @@ pub(crate) async fn run_app(
complete_delivery, complete_delivery,
push_completion_to_erp, push_completion_to_erp,
list_delivered_belegnummern, list_delivered_belegnummern,
list_completed_deliveries,
mark_mail_sent, mark_mail_sent,
list_pending_reviews, list_pending_reviews,
resolve_review, resolve_review,

View File

@ -54,6 +54,7 @@ use utoipa::openapi::security::{
crate::routes::admin::import_erp, crate::routes::admin::import_erp,
crate::routes::admin::push_completion, crate::routes::admin::push_completion,
crate::routes::admin::delivered_belegnummern, crate::routes::admin::delivered_belegnummern,
crate::routes::admin::completed_deliveries,
crate::routes::admin::mark_mail_sent, crate::routes::admin::mark_mail_sent,
crate::routes::admin::list_reviews, crate::routes::admin::list_reviews,
crate::routes::admin::resolve_review, crate::routes::admin::resolve_review,
@ -127,6 +128,8 @@ use utoipa::openapi::security::{
holzleitner_application::dto::DeliveryServiceResponse, holzleitner_application::dto::DeliveryServiceResponse,
holzleitner_application::usecases::ImportSummary, holzleitner_application::usecases::ImportSummary,
crate::routes::admin::DeliveredBelegnummernResponse, crate::routes::admin::DeliveredBelegnummernResponse,
crate::routes::admin::CompletedDeliveriesResponse,
crate::routes::admin::CompletedDeliveryItem,
crate::routes::admin::PendingReviewResponse, crate::routes::admin::PendingReviewResponse,
crate::routes::admin::ReviewedItemResponse, crate::routes::admin::ReviewedItemResponse,
crate::routes::admin::ResolveReviewRequest, crate::routes::admin::ResolveReviewRequest,

View File

@ -29,6 +29,10 @@ pub fn router() -> Router<AppState> {
"/admin/delivered-belegnummern", "/admin/delivered-belegnummern",
get(delivered_belegnummern), get(delivered_belegnummern),
) )
.route(
"/admin/completed-deliveries",
get(completed_deliveries),
)
.route("/admin/mark-mail-sent", post(mark_mail_sent)) .route("/admin/mark-mail-sent", post(mark_mail_sent))
.route("/admin/reviews", get(list_reviews)) .route("/admin/reviews", get(list_reviews))
.route("/admin/reviews/{delivery_id}/resolve", post(resolve_review)) .route("/admin/reviews/{delivery_id}/resolve", post(resolve_review))
@ -186,6 +190,80 @@ pub async fn delivered_belegnummern(
})) }))
} }
#[derive(Debug, Deserialize)]
pub struct CompletedDeliveriesQuery {
/// Ziel-Tag im Format `DD-MM-YYYY`. **Pflicht** — der Endpunkt liefert nur
/// Abschlüsse genau dieses (Berliner) Kalendertages.
pub day: String,
}
/// Eine abgeschlossene Lieferung im Tagesabruf.
#[derive(Debug, Serialize, ToSchema)]
pub struct CompletedDeliveryItem {
/// ERP-Belegnummer der Lieferung.
pub belegnummer: String,
/// `true`, wenn an der Lieferung Positionen verändert wurden — eine Zeile
/// wurde entfernt oder in der Menge reduziert (Stück-Gutschrift) **oder** es
/// liegt eine aktive Geld-Gutschrift vor.
pub positions_modified: bool,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct CompletedDeliveriesResponse {
/// Tag, nach dem gefiltert wurde (ISO `YYYY-MM-DD`).
pub day: String,
/// Anzahl der abgeschlossenen Lieferungen an diesem Tag.
pub count: usize,
/// Die abgeschlossenen Lieferungen, aufsteigend nach Abschluss-Zeitpunkt.
pub deliveries: Vec<CompletedDeliveryItem>,
}
/// Liefert **alle** an einem Tag abgeschlossenen (ausgelieferten) Lieferungen —
/// unabhängig vom Mail-Versand-Status. Der Tag (`day`, `DD-MM-YYYY`) ist Pflicht
/// und wird als Berliner Kalendertag auf den Abschluss-Zeitpunkt
/// (`completed_at`) angewandt. Pro Lieferung: Belegnummer und ein Flag, ob
/// Positionen verändert wurden (Menge reduziert/Zeile entfernt oder
/// Geld-Gutschrift).
#[utoipa::path(
get,
path = "/admin/completed-deliveries",
tag = "admin",
params(
("day" = String, Query, description = "Tag DD-MM-YYYY (Pflicht)")
),
responses(
(status = 200, description = "Abgeschlossene Lieferungen des Tages", body = CompletedDeliveriesResponse),
(status = 400, description = "Ungültiger/fehlender Tag"),
(status = 401, description = "Admin-API-Key fehlt/ungültig")
),
security(("admin_api_key" = []))
)]
pub async fn completed_deliveries(
State(state): State<AppState>,
Query(query): Query<CompletedDeliveriesQuery>,
) -> Result<Json<CompletedDeliveriesResponse>, ApiError> {
let day = NaiveDate::parse_from_str(query.day.trim(), "%d-%m-%Y").map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültiger Tag '{}' (erwartet DD-MM-YYYY): {e}",
query.day
)))
})?;
tracing::info!(?day, "admin.completed_deliveries");
let summaries = state.list_completed_deliveries.execute(day).await?;
tracing::info!(?day, count = summaries.len(), "admin.completed_deliveries.done");
Ok(Json(CompletedDeliveriesResponse {
day: day.format("%Y-%m-%d").to_string(),
count: summaries.len(),
deliveries: summaries
.into_iter()
.map(|s| CompletedDeliveryItem {
belegnummer: s.belegnummer,
positions_modified: s.positions_modified,
})
.collect(),
}))
}
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
pub struct MarkMailSentRequest { pub struct MarkMailSentRequest {
/// Belegnummern, deren Liefermail erfolgreich versendet wurde und die als /// Belegnummern, deren Liefermail erfolgreich versendet wurde und die als

View File

@ -9,7 +9,8 @@ use holzleitner_application::usecases::{
DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase, DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase,
GenerateDeliveryReportUseCase, GetAccountUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase,
GetAttachmentPreviewUseCase, GetTourUseCase, GetAttachmentPreviewUseCase, GetTourUseCase,
ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ImportErpToursUseCase, ListCompletedDeliveriesUseCase, ListDeliveredBelegnummernUseCase,
ListMyCarsUseCase,
ListMyToursTodayUseCase, ListPaymentMethodsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase,
ListPendingReviewsUseCase, ListServicesUseCase, MarkMailSentUseCase, ListPendingReviewsUseCase, ListServicesUseCase, MarkMailSentUseCase,
ProcessDeliveryReportUseCase, PushCompletionToErpUseCase, ResolveReviewUseCase, ProcessDeliveryReportUseCase, PushCompletionToErpUseCase, ResolveReviewUseCase,
@ -51,6 +52,8 @@ pub struct AppState {
pub push_completion_to_erp: Arc<PushCompletionToErpUseCase>, pub push_completion_to_erp: Arc<PushCompletionToErpUseCase>,
/// Admin: Belegnummern offener (noch nicht versendeter) Lieferungen. /// Admin: Belegnummern offener (noch nicht versendeter) Lieferungen.
pub list_delivered_belegnummern: Arc<ListDeliveredBelegnummernUseCase>, pub list_delivered_belegnummern: Arc<ListDeliveredBelegnummernUseCase>,
/// Admin: alle an einem Tag abgeschlossenen Lieferungen (mit Änderungs-Flag).
pub list_completed_deliveries: Arc<ListCompletedDeliveriesUseCase>,
/// Admin: Liefermails von Belegnummern als versendet markieren (Dedup). /// Admin: Liefermails von Belegnummern als versendet markieren (Dedup).
pub mark_mail_sent: Arc<MarkMailSentUseCase>, pub mark_mail_sent: Arc<MarkMailSentUseCase>,
pub list_pending_reviews: Arc<ListPendingReviewsUseCase>, pub list_pending_reviews: Arc<ListPendingReviewsUseCase>,

View File

@ -43,6 +43,17 @@ pub struct ErpWritebackData {
pub payment_method_code: Option<String>, pub payment_method_code: Option<String>,
} }
/// Zusammenfassung einer abgeschlossenen Lieferung für den Admin-Tagesabruf.
/// Belegnummer plus ein Flag, ob an der Lieferung Positionen verändert wurden.
pub struct CompletedDeliverySummary {
pub belegnummer: String,
/// `true`, wenn an der Lieferung fachlich etwas von der ursprünglichen
/// Bestellung abweicht: eine Position wurde ganz entfernt oder in der Menge
/// reduziert (`delivery_items.credited_quantity > 0`) **oder** es liegt eine
/// aktive Geld-Gutschrift vor (jüngstes `delivery_credit_audit`-Event `set`).
pub positions_modified: bool,
}
/// Vollständige Eingabe für den Abschluss — alles, was die Abschluss-Zeile /// Vollständige Eingabe für den Abschluss — alles, was die Abschluss-Zeile
/// braucht plus die fachlichen Bestätigungs-Flags fürs Gate. /// braucht plus die fachlichen Bestätigungs-Flags fürs Gate.
pub struct CompleteDeliveryInput { pub struct CompleteDeliveryInput {
@ -121,6 +132,18 @@ pub trait DeliveryCompletionRepository: Send + Sync {
day: Option<NaiveDate>, day: Option<NaiveDate>,
) -> Result<Vec<String>, ApplicationError>; ) -> Result<Vec<String>, ApplicationError>;
/// Liefert alle **abgeschlossenen** (ausgelieferten) Lieferungen, deren
/// Abschluss-Zeitpunkt (`completed_at`) auf den Berliner Kalendertag `day`
/// fällt — unabhängig vom Mail-Versand-Status. Pro Lieferung Belegnummer
/// und das `positions_modified`-Flag (siehe [`CompletedDeliverySummary`]).
///
/// `completed_at` ist ein UTC-Zeitstempel; der Kalendertag wird in
/// **Europe/Berlin** bestimmt. Sortierung: aufsteigend nach Abschluss-Zeit.
async fn list_completed_deliveries_for_day(
&self,
day: NaiveDate,
) -> Result<Vec<CompletedDeliverySummary>, ApplicationError>;
/// Markiert die Liefermail der angegebenen Belegnummern als **versendet** /// Markiert die Liefermail der angegebenen Belegnummern als **versendet**
/// (`mail_sent_at = now()`), aber nur dort, wo sie noch offen ist /// (`mail_sent_at = now()`), aber nur dort, wo sie noch offen ist
/// (`mail_sent_at IS NULL`) — bereits markierte bleiben unverändert /// (`mail_sent_at IS NULL`) — bereits markierte bleiben unverändert

View File

@ -51,7 +51,8 @@ pub use delivery_service_repository::DeliveryServiceRepository;
pub use docuframe_report_gateway::DocuframeReportGateway; pub use docuframe_report_gateway::DocuframeReportGateway;
pub use driver_identity_provisioner::{DriverIdentityProvisioner, ProvisionOutcome}; pub use driver_identity_provisioner::{DriverIdentityProvisioner, ProvisionOutcome};
pub use delivery_completion_repository::{ pub use delivery_completion_repository::{
CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine, CompleteDeliveryInput, CompletedDeliverySummary, DeliveryCompletionRepository, ErpWritebackData,
ErpWritebackLine,
}; };
pub use erp_delivery_source::ErpDeliverySource; pub use erp_delivery_source::ErpDeliverySource;
pub use erp_delivery_writeback::{ pub use erp_delivery_writeback::{

View File

@ -0,0 +1,33 @@
//! Use Case: abgeschlossene (ausgelieferte) Lieferungen eines Tages auflisten.
//!
//! Reine Lese-Operation für den Admin-Endpunkt. Im Gegensatz zu
//! [`crate::usecases::ListDeliveredBelegnummernUseCase`] (Mailclient-Sicht,
//! nur noch nicht versendete Belege) liefert dieser Use Case **alle**
//! Abschlüsse des Tages — unabhängig vom Mail-Status — samt einem Flag, ob an
//! der Lieferung Positionen verändert wurden (Menge reduziert/entfernt oder
//! Geld-Gutschrift). Tages-/TZ-Logik liegt im Repository (Europe/Berlin).
use std::sync::Arc;
use chrono::NaiveDate;
use crate::error::ApplicationError;
use crate::ports::{CompletedDeliverySummary, DeliveryCompletionRepository};
pub struct ListCompletedDeliveriesUseCase {
completions: Arc<dyn DeliveryCompletionRepository>,
}
impl ListCompletedDeliveriesUseCase {
pub fn new(completions: Arc<dyn DeliveryCompletionRepository>) -> Self {
Self { completions }
}
/// Liefert alle am `day` (Berliner Kalendertag) abgeschlossenen Lieferungen.
pub async fn execute(
&self,
day: NaiveDate,
) -> Result<Vec<CompletedDeliverySummary>, ApplicationError> {
self.completions.list_completed_deliveries_for_day(day).await
}
}

View File

@ -19,6 +19,7 @@ pub mod get_account;
pub mod get_attachment_preview; pub mod get_attachment_preview;
pub mod get_tour; pub mod get_tour;
pub mod import_erp_tours; pub mod import_erp_tours;
pub mod list_completed_deliveries;
pub mod list_delivered_belegnummern; pub mod list_delivered_belegnummern;
pub mod list_my_tours_today; pub mod list_my_tours_today;
pub mod mark_mail_sent; pub mod mark_mail_sent;
@ -48,6 +49,7 @@ pub use get_account::GetAccountUseCase;
pub use get_attachment_preview::GetAttachmentPreviewUseCase; pub use get_attachment_preview::GetAttachmentPreviewUseCase;
pub use get_tour::GetTourUseCase; pub use get_tour::GetTourUseCase;
pub use import_erp_tours::{ImportErpToursUseCase, ImportSummary}; pub use import_erp_tours::{ImportErpToursUseCase, ImportSummary};
pub use list_completed_deliveries::ListCompletedDeliveriesUseCase;
pub use list_delivered_belegnummern::ListDeliveredBelegnummernUseCase; pub use list_delivered_belegnummern::ListDeliveredBelegnummernUseCase;
pub use list_my_tours_today::ListMyToursTodayUseCase; pub use list_my_tours_today::ListMyToursTodayUseCase;
pub use mark_mail_sent::MarkMailSentUseCase; pub use mark_mail_sent::MarkMailSentUseCase;

View File

@ -14,7 +14,8 @@ use uuid::Uuid;
use holzleitner_application::error::ApplicationError; use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{ use holzleitner_application::ports::{
CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine, CompleteDeliveryInput, CompletedDeliverySummary, DeliveryCompletionRepository, ErpWritebackData,
ErpWritebackLine,
}; };
use holzleitner_domain::{Address, Delivery, DeliveryState}; use holzleitner_domain::{Address, Delivery, DeliveryState};
@ -472,6 +473,57 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
Ok(belegnummern) Ok(belegnummern)
} }
async fn list_completed_deliveries_for_day(
&self,
day: NaiveDate,
) -> Result<Vec<CompletedDeliverySummary>, ApplicationError> {
// Alle an `day` (Berliner Kalendertag von completed_at) abgeschlossenen
// Lieferungen — ohne Mail-Filter. `positions_modified` ist true, wenn
// (a) irgendeine Belegzeile eine Stück-Gutschrift trägt
// (credited_quantity > 0 ⇒ Zeile ganz entfernt oder Menge reduziert),
// ODER
// (b) eine aktive Geld-Gutschrift vorliegt: das jüngste Audit-Event
// (recorded_at, id als Tie-Break — identisch zur Credit-Repo-Logik)
// ist 'set'.
let rows: Vec<(String, bool)> = sqlx::query_as(
r#"
SELECT
d.erp_belegnummer,
COALESCE(
EXISTS (
SELECT 1 FROM delivery_items di
WHERE di.delivery_id = d.id
AND di.credited_quantity > 0
)
OR (
SELECT dca.action
FROM delivery_credit_audit dca
WHERE dca.delivery_id = d.id
ORDER BY dca.recorded_at DESC, dca.id DESC
LIMIT 1
) = 'set',
false
) AS positions_modified
FROM deliveries d
JOIN delivery_completions c ON c.delivery_id = d.id
WHERE (c.completed_at AT TIME ZONE 'Europe/Berlin')::date = $1
ORDER BY c.completed_at
"#,
)
.bind(day)
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(rows
.into_iter()
.map(|(belegnummer, positions_modified)| CompletedDeliverySummary {
belegnummer,
positions_modified,
})
.collect())
}
async fn mark_mail_sent( async fn mark_mail_sent(
&self, &self,
belegnummern: &[String], belegnummern: &[String],