From 865fcfcd2390f24bd7ee824d47eda11205d15371 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Thu, 9 Jul 2026 21:32:04 +0200 Subject: [PATCH] feat(admin): GET /admin/belege/{belegnummer}/positions-modified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Admin-Endpunkt, der zu EINER ERP-Belegnummer das positions_modified-Flag liefert (Stück-Gutschrift auf einer Belegzeile ODER aktive Geld-Gutschrift) — gleiche Definition wie in completed-deliveries, aber pro Beleg und ohne Abschluss-Voraussetzung. 404 bei unbekannter Belegnummer; bei mehreren Lieferungen gleicher Belegnummer true, sobald eine verändert ist (bool_or). Co-Authored-By: Claude Opus 4.8 --- crates/api/src/main.rs | 8 ++- crates/api/src/openapi.rs | 2 + crates/api/src/routes/admin.rs | 50 +++++++++++++++++++ crates/api/src/state.rs | 4 +- .../ports/delivery_completion_repository.rs | 14 ++++++ .../src/usecases/get_positions_modified.rs | 30 +++++++++++ crates/application/src/usecases/mod.rs | 2 + .../delivery_completion_repository.rs | 38 ++++++++++++++ 8 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 crates/application/src/usecases/get_positions_modified.rs diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 87f4cd3..abfe6a1 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -38,7 +38,7 @@ use holzleitner_application::usecases::{ DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase, DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase, - GetAttachmentPreviewUseCase, GetTourUseCase, + GetAttachmentPreviewUseCase, GetPositionsModifiedUseCase, GetTourUseCase, ImportErpToursUseCase, ListCompletedDeliveriesUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase, @@ -371,6 +371,11 @@ pub(crate) async fn run_app( let list_completed_deliveries = Arc::new(ListCompletedDeliveriesUseCase::new( delivery_completion_repository.clone(), )); + // Admin-Lese-Use-Case: Positions-Änderungs-Flag zu einer Belegnummer. + // `.clone()` VOR dem Move in `complete_delivery`. + let get_positions_modified = Arc::new(GetPositionsModifiedUseCase::new( + delivery_completion_repository.clone(), + )); let mark_mail_sent = Arc::new(MarkMailSentUseCase::new( delivery_completion_repository.clone(), )); @@ -467,6 +472,7 @@ pub(crate) async fn run_app( push_completion_to_erp, list_delivered_belegnummern, list_completed_deliveries, + get_positions_modified, mark_mail_sent, list_pending_reviews, resolve_review, diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 96ec983..4585299 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -55,6 +55,7 @@ use utoipa::openapi::security::{ crate::routes::admin::push_completion, crate::routes::admin::delivered_belegnummern, crate::routes::admin::completed_deliveries, + crate::routes::admin::positions_modified, crate::routes::admin::mark_mail_sent, crate::routes::admin::list_reviews, crate::routes::admin::resolve_review, @@ -130,6 +131,7 @@ use utoipa::openapi::security::{ crate::routes::admin::DeliveredBelegnummernResponse, crate::routes::admin::CompletedDeliveriesResponse, crate::routes::admin::CompletedDeliveryItem, + crate::routes::admin::PositionsModifiedResponse, 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 fe1aad7..06572b3 100644 --- a/crates/api/src/routes/admin.rs +++ b/crates/api/src/routes/admin.rs @@ -33,6 +33,10 @@ pub fn router() -> Router { "/admin/completed-deliveries", get(completed_deliveries), ) + .route( + "/admin/belege/{belegnummer}/positions-modified", + get(positions_modified), + ) .route("/admin/mark-mail-sent", post(mark_mail_sent)) .route("/admin/reviews", get(list_reviews)) .route("/admin/reviews/{delivery_id}/resolve", post(resolve_review)) @@ -303,6 +307,52 @@ pub async fn completed_deliveries( })) } +#[derive(Debug, Serialize, ToSchema)] +pub struct PositionsModifiedResponse { + /// ERP-Belegnummer, nach der gefragt wurde. + 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, +} + +/// Liefert zu **einer** ERP-Belegnummer, ob an der Lieferung Positionen +/// verändert wurden (Menge reduziert/Zeile entfernt oder Geld-Gutschrift) — +/// unabhängig vom Zustand der Lieferung. `404`, wenn die Belegnummer unbekannt +/// ist. Gibt es mehrere Lieferungen mit derselben Belegnummer, ist das Flag +/// `true`, sobald **eine** davon verändert ist. +#[utoipa::path( + get, + path = "/admin/belege/{belegnummer}/positions-modified", + tag = "admin", + params( + ("belegnummer" = String, Path, description = "ERP-Belegnummer, z. B. V-30690291") + ), + responses( + (status = 200, description = "Änderungs-Flag der Belegpositionen", body = PositionsModifiedResponse), + (status = 401, description = "Admin-API-Key fehlt/ungültig"), + (status = 404, description = "Belegnummer unbekannt") + ), + security(("admin_api_key" = [])) +)] +pub async fn positions_modified( + State(state): State, + Path(belegnummer): Path, +) -> Result, ApiError> { + tracing::info!(%belegnummer, "admin.positions_modified"); + let flag = state + .get_positions_modified + .execute(&belegnummer) + .await? + .ok_or(ApiError(ApplicationError::NotFound))?; + tracing::info!(%belegnummer, positions_modified = flag, "admin.positions_modified.done"); + Ok(Json(PositionsModifiedResponse { + belegnummer, + positions_modified: flag, + })) +} + #[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 5a9ad18..1140e65 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -8,7 +8,7 @@ use holzleitner_application::usecases::{ DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase, DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase, - GetAttachmentPreviewUseCase, GetTourUseCase, + GetAttachmentPreviewUseCase, GetPositionsModifiedUseCase, GetTourUseCase, ImportErpToursUseCase, ListCompletedDeliveriesUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase, @@ -54,6 +54,8 @@ pub struct AppState { pub list_delivered_belegnummern: Arc, /// Admin: alle an einem Tag abgeschlossenen Lieferungen (mit Änderungs-Flag). pub list_completed_deliveries: Arc, + /// Admin: Positions-Änderungs-Flag zu einer einzelnen Belegnummer. + pub get_positions_modified: 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 bac328e..c6947fc 100644 --- a/crates/application/src/ports/delivery_completion_repository.rs +++ b/crates/application/src/ports/delivery_completion_repository.rs @@ -148,6 +148,20 @@ pub trait DeliveryCompletionRepository: Send + Sync { to: Option, ) -> Result, ApplicationError>; + /// Liefert zu einer ERP-Belegnummer, ob an der Lieferung **Positionen + /// verändert** wurden — dieselbe Definition wie `positions_modified` in + /// [`CompletedDeliverySummary`] (Stück-Gutschrift auf einer Belegzeile + /// **oder** aktive Geld-Gutschrift). Zustand der Lieferung egal (kein + /// Abschluss vorausgesetzt). + /// + /// `Ok(None)` ⇒ Belegnummer unbekannt (keine Lieferung). Bei mehreren + /// Lieferungen mit derselben Belegnummer gilt `true`, sobald **eine** davon + /// verändert ist. + async fn positions_modified_for_belegnummer( + &self, + belegnummer: &str, + ) -> 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/usecases/get_positions_modified.rs b/crates/application/src/usecases/get_positions_modified.rs new file mode 100644 index 0000000..ec2ec8a --- /dev/null +++ b/crates/application/src/usecases/get_positions_modified.rs @@ -0,0 +1,30 @@ +//! Use Case: zu einer ERP-Belegnummer ermitteln, ob an der Lieferung +//! Positionen verändert wurden (Stück-Gutschrift oder aktive Geld-Gutschrift). +//! +//! Reine Lese-Operation für den Admin-Endpunkt. `Ok(None)` ⇒ Belegnummer +//! unbekannt (der Handler macht daraus ein `404`). + +use std::sync::Arc; + +use crate::error::ApplicationError; +use crate::ports::DeliveryCompletionRepository; + +pub struct GetPositionsModifiedUseCase { + completions: Arc, +} + +impl GetPositionsModifiedUseCase { + pub fn new(completions: Arc) -> Self { + Self { completions } + } + + /// `Some(true/false)` = Beleg gefunden, Flag; `None` = Belegnummer unbekannt. + pub async fn execute( + &self, + belegnummer: &str, + ) -> Result, ApplicationError> { + self.completions + .positions_modified_for_belegnummer(belegnummer) + .await + } +} diff --git a/crates/application/src/usecases/mod.rs b/crates/application/src/usecases/mod.rs index 1229ccf..6758f18 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_positions_modified; pub mod list_completed_deliveries; pub mod list_delivered_belegnummern; pub mod list_my_tours_today; @@ -47,6 +48,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_positions_modified::GetPositionsModifiedUseCase; pub use get_tour::GetTourUseCase; pub use import_erp_tours::{ImportErpToursUseCase, ImportSummary}; pub use list_completed_deliveries::ListCompletedDeliveriesUseCase; diff --git a/crates/infrastructure/src/persistence/delivery_completion_repository.rs b/crates/infrastructure/src/persistence/delivery_completion_repository.rs index 73ed355..9b69de9 100644 --- a/crates/infrastructure/src/persistence/delivery_completion_repository.rs +++ b/crates/infrastructure/src/persistence/delivery_completion_repository.rs @@ -530,6 +530,44 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository { .collect()) } + async fn positions_modified_for_belegnummer( + &self, + belegnummer: &str, + ) -> Result, ApplicationError> { + // `positions_modified`-Logik identisch zu list_completed_deliveries, + // aber pro Belegnummer und ohne Abschluss-Join. `bool_or` aggregiert + // über evtl. mehrere Lieferungen gleicher Belegnummer (true, sobald eine + // verändert ist) und liefert NULL, wenn es die Belegnummer nicht gibt + // ⇒ das mappt sqlx auf `None` (⇒ 404 im Handler). + let modified: Option = sqlx::query_scalar( + r#" + SELECT bool_or( + EXISTS ( + SELECT 1 FROM delivery_items di + WHERE di.delivery_id = d.id + AND di.credited_quantity > 0 + ) + OR COALESCE( + ( 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 + ) + ) + FROM deliveries d + WHERE d.erp_belegnummer = $1 + "#, + ) + .bind(belegnummer) + .fetch_one(&self.pool) + .await + .map_err(db)?; + + Ok(modified) + } + async fn mark_mail_sent( &self, belegnummern: &[String],