diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 1302d0b..87f4cd3 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -39,7 +39,8 @@ use holzleitner_application::usecases::{ DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase, GetAttachmentPreviewUseCase, GetTourUseCase, - ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, + ImportErpToursUseCase, ListCompletedDeliveriesUseCase, ListDeliveredBelegnummernUseCase, + ListMyCarsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase, ListPendingReviewsUseCase, ListServicesUseCase, MarkMailSentUseCase, PushCompletionToErpUseCase, ResolveReviewUseCase, @@ -365,6 +366,11 @@ pub(crate) async fn run_app( let list_delivered_belegnummern = Arc::new(ListDeliveredBelegnummernUseCase::new( 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( delivery_completion_repository.clone(), )); @@ -460,6 +466,7 @@ pub(crate) async fn run_app( complete_delivery, push_completion_to_erp, list_delivered_belegnummern, + list_completed_deliveries, mark_mail_sent, list_pending_reviews, resolve_review, diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 45adfaf..96ec983 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -54,6 +54,7 @@ use utoipa::openapi::security::{ crate::routes::admin::import_erp, crate::routes::admin::push_completion, crate::routes::admin::delivered_belegnummern, + crate::routes::admin::completed_deliveries, crate::routes::admin::mark_mail_sent, crate::routes::admin::list_reviews, crate::routes::admin::resolve_review, @@ -127,6 +128,8 @@ use utoipa::openapi::security::{ holzleitner_application::dto::DeliveryServiceResponse, holzleitner_application::usecases::ImportSummary, crate::routes::admin::DeliveredBelegnummernResponse, + crate::routes::admin::CompletedDeliveriesResponse, + crate::routes::admin::CompletedDeliveryItem, crate::routes::admin::PendingReviewResponse, crate::routes::admin::ReviewedItemResponse, crate::routes::admin::ResolveReviewRequest, diff --git a/crates/api/src/routes/admin.rs b/crates/api/src/routes/admin.rs index e632e82..40e8a71 100644 --- a/crates/api/src/routes/admin.rs +++ b/crates/api/src/routes/admin.rs @@ -29,6 +29,10 @@ pub fn router() -> Router { "/admin/delivered-belegnummern", get(delivered_belegnummern), ) + .route( + "/admin/completed-deliveries", + get(completed_deliveries), + ) .route("/admin/mark-mail-sent", post(mark_mail_sent)) .route("/admin/reviews", get(list_reviews)) .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, +} + +/// 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, + Query(query): Query, +) -> Result, 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)] 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 14d8d8b..5a9ad18 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -9,7 +9,8 @@ use holzleitner_application::usecases::{ DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase, GetAttachmentPreviewUseCase, GetTourUseCase, - ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, + ImportErpToursUseCase, ListCompletedDeliveriesUseCase, ListDeliveredBelegnummernUseCase, + ListMyCarsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase, ListPendingReviewsUseCase, ListServicesUseCase, MarkMailSentUseCase, ProcessDeliveryReportUseCase, PushCompletionToErpUseCase, ResolveReviewUseCase, @@ -51,6 +52,8 @@ pub struct AppState { pub push_completion_to_erp: Arc, /// Admin: Belegnummern offener (noch nicht versendeter) Lieferungen. pub list_delivered_belegnummern: Arc, + /// Admin: alle an einem Tag abgeschlossenen Lieferungen (mit Änderungs-Flag). + pub list_completed_deliveries: 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/ports/delivery_completion_repository.rs b/crates/application/src/ports/delivery_completion_repository.rs index 83991bf..fe6ca6c 100644 --- a/crates/application/src/ports/delivery_completion_repository.rs +++ b/crates/application/src/ports/delivery_completion_repository.rs @@ -43,6 +43,17 @@ pub struct ErpWritebackData { pub payment_method_code: Option, } +/// 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 /// braucht plus die fachlichen Bestätigungs-Flags fürs Gate. pub struct CompleteDeliveryInput { @@ -121,6 +132,18 @@ pub trait DeliveryCompletionRepository: Send + Sync { day: Option, ) -> Result, 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, ApplicationError>; + /// Markiert die Liefermail der angegebenen Belegnummern als **versendet** /// (`mail_sent_at = now()`), aber nur dort, wo sie noch offen ist /// (`mail_sent_at IS NULL`) — bereits markierte bleiben unverändert diff --git a/crates/application/src/ports/mod.rs b/crates/application/src/ports/mod.rs index 0e3db6f..1b2c5f1 100644 --- a/crates/application/src/ports/mod.rs +++ b/crates/application/src/ports/mod.rs @@ -51,7 +51,8 @@ pub use delivery_service_repository::DeliveryServiceRepository; pub use docuframe_report_gateway::DocuframeReportGateway; pub use driver_identity_provisioner::{DriverIdentityProvisioner, ProvisionOutcome}; pub use delivery_completion_repository::{ - CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine, + CompleteDeliveryInput, CompletedDeliverySummary, DeliveryCompletionRepository, ErpWritebackData, + ErpWritebackLine, }; pub use erp_delivery_source::ErpDeliverySource; pub use erp_delivery_writeback::{ diff --git a/crates/application/src/usecases/list_completed_deliveries.rs b/crates/application/src/usecases/list_completed_deliveries.rs new file mode 100644 index 0000000..7c94c3f --- /dev/null +++ b/crates/application/src/usecases/list_completed_deliveries.rs @@ -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, +} + +impl ListCompletedDeliveriesUseCase { + pub fn new(completions: Arc) -> Self { + Self { completions } + } + + /// Liefert alle am `day` (Berliner Kalendertag) abgeschlossenen Lieferungen. + pub async fn execute( + &self, + day: NaiveDate, + ) -> Result, ApplicationError> { + self.completions.list_completed_deliveries_for_day(day).await + } +} diff --git a/crates/application/src/usecases/mod.rs b/crates/application/src/usecases/mod.rs index 9ddcc52..1229ccf 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 list_completed_deliveries; pub mod list_delivered_belegnummern; pub mod list_my_tours_today; pub mod mark_mail_sent; @@ -48,6 +49,7 @@ pub use get_account::GetAccountUseCase; pub use get_attachment_preview::GetAttachmentPreviewUseCase; pub use get_tour::GetTourUseCase; pub use import_erp_tours::{ImportErpToursUseCase, ImportSummary}; +pub use list_completed_deliveries::ListCompletedDeliveriesUseCase; pub use list_delivered_belegnummern::ListDeliveredBelegnummernUseCase; pub use list_my_tours_today::ListMyToursTodayUseCase; pub use mark_mail_sent::MarkMailSentUseCase; diff --git a/crates/infrastructure/src/persistence/delivery_completion_repository.rs b/crates/infrastructure/src/persistence/delivery_completion_repository.rs index 1bcfe91..2e730cc 100644 --- a/crates/infrastructure/src/persistence/delivery_completion_repository.rs +++ b/crates/infrastructure/src/persistence/delivery_completion_repository.rs @@ -14,7 +14,8 @@ use uuid::Uuid; use holzleitner_application::error::ApplicationError; use holzleitner_application::ports::{ - CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine, + CompleteDeliveryInput, CompletedDeliverySummary, DeliveryCompletionRepository, ErpWritebackData, + ErpWritebackLine, }; use holzleitner_domain::{Address, Delivery, DeliveryState}; @@ -472,6 +473,57 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository { Ok(belegnummern) } + async fn list_completed_deliveries_for_day( + &self, + day: NaiveDate, + ) -> Result, 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( &self, belegnummern: &[String],