From 6378b1101b7d39213a968aa6c567c60c9f1eb86e Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Thu, 9 Jul 2026 17:35:11 +0200 Subject: [PATCH] feat(admin): completed-deliveries um from/to-Range erweitern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /admin/completed-deliveries akzeptiert jetzt zusätzlich zu `day` (Kurzform from=to) optionale `from`/`to` (DD-MM-YYYY, inklusive, je offen). Damit lassen sich Halb-Grenzen abfragen: `?from=` bzw. `?to=` liefern je eine Belegnummern- Menge, deren SQL-AND-Schnitt einen Zeitraum ergibt (Range-Filter über zwei unabhängige ERPframe-Filter). Response gibt statt `day` nun `from`/`to` zurück. Mindestens eine Grenze ist Pflicht (kein Full-Table-Dump). Co-Authored-By: Claude Opus 4.8 --- crates/api/src/routes/admin.rs | 89 +++++++++++++------ .../ports/delivery_completion_repository.rs | 20 +++-- .../src/usecases/list_completed_deliveries.rs | 8 +- .../delivery_completion_repository.rs | 18 ++-- 4 files changed, 93 insertions(+), 42 deletions(-) diff --git a/crates/api/src/routes/admin.rs b/crates/api/src/routes/admin.rs index 40e8a71..fe1aad7 100644 --- a/crates/api/src/routes/admin.rs +++ b/crates/api/src/routes/admin.rs @@ -192,9 +192,16 @@ 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, + /// Einzeltag `DD-MM-YYYY` — Kurzform für `from == to == day`. Hat Vorrang + /// vor `from`/`to`, wenn gesetzt. + #[serde(default)] + pub day: Option, + /// Untere Bereichsgrenze `DD-MM-YYYY` (inklusive). Ohne Angabe offen. + #[serde(default)] + pub from: Option, + /// Obere Bereichsgrenze `DD-MM-YYYY` (inklusive). Ohne Angabe offen. + #[serde(default)] + pub to: Option, } /// Eine abgeschlossene Lieferung im Tagesabruf. @@ -210,30 +217,48 @@ pub struct CompletedDeliveryItem { #[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. + /// Wirksame untere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen). + pub from: Option, + /// Wirksame obere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen). + pub to: Option, + /// Anzahl der abgeschlossenen Lieferungen im Bereich. 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). +/// Parst einen `DD-MM-YYYY`-Tag in ein `NaiveDate` oder liefert `400`. +fn parse_ddmmyyyy(s: &str) -> Result { + NaiveDate::parse_from_str(s.trim(), "%d-%m-%Y").map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "ungültiges Datum '{s}' (erwartet DD-MM-YYYY): {e}" + ))) + }) +} + +/// Liefert **alle** abgeschlossenen (ausgelieferten) Lieferungen in einem +/// Datumsbereich — unabhängig vom Mail-Versand-Status. Gefiltert wird über den +/// **Berliner** Kalendertag des Abschluss-Zeitpunkts (`completed_at`). +/// +/// Parameter (alle `DD-MM-YYYY`): `day` = Einzeltag (Kurzform `from=to=day`), +/// sonst `from`/`to` als **inklusive** Bereichsgrenzen (je optional/offen). +/// Mindestens einer von `day`/`from`/`to` ist erforderlich. Pro Lieferung: +/// Belegnummer + `positions_modified` (Menge reduziert/Zeile entfernt oder +/// Geld-Gutschrift). Die Halb-Grenzen-Variante ist für Range-Filter gedacht: +/// `?from=…` und `?to=…` liefern je eine Menge, deren SQL-`AND`-Schnitt den +/// Zeitraum ergibt. #[utoipa::path( get, path = "/admin/completed-deliveries", tag = "admin", params( - ("day" = String, Query, description = "Tag DD-MM-YYYY (Pflicht)") + ("day" = Option, Query, description = "Einzeltag DD-MM-YYYY (Kurzform from=to)"), + ("from" = Option, Query, description = "Untere Grenze DD-MM-YYYY (inklusive)"), + ("to" = Option, Query, description = "Obere Grenze DD-MM-YYYY (inklusive)") ), responses( - (status = 200, description = "Abgeschlossene Lieferungen des Tages", body = CompletedDeliveriesResponse), - (status = 400, description = "Ungültiger/fehlender Tag"), + (status = 200, description = "Abgeschlossene Lieferungen im Bereich", body = CompletedDeliveriesResponse), + (status = 400, description = "Ungültiges Datum oder keine Grenze angegeben"), (status = 401, description = "Admin-API-Key fehlt/ungültig") ), security(("admin_api_key" = [])) @@ -242,17 +267,31 @@ 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"); + // `day` ist die Kurzform und hat Vorrang; sonst freie Halb-/Vollgrenzen. + let (from, to) = match query.day.as_deref() { + Some(day) => { + let d = parse_ddmmyyyy(day)?; + (Some(d), Some(d)) + } + None => ( + query.from.as_deref().map(parse_ddmmyyyy).transpose()?, + query.to.as_deref().map(parse_ddmmyyyy).transpose()?, + ), + }; + + // Unbegrenzt (alles) wäre ein versehentlicher Full-Table-Dump → ablehnen. + if from.is_none() && to.is_none() { + return Err(ApiError(ApplicationError::Validation( + "mindestens einer von `day`, `from`, `to` (DD-MM-YYYY) ist erforderlich".into(), + ))); + } + + tracing::info!(?from, ?to, "admin.completed_deliveries"); + let summaries = state.list_completed_deliveries.execute(from, to).await?; + tracing::info!(?from, ?to, count = summaries.len(), "admin.completed_deliveries.done"); Ok(Json(CompletedDeliveriesResponse { - day: day.format("%Y-%m-%d").to_string(), + from: from.map(|d| d.format("%Y-%m-%d").to_string()), + to: to.map(|d| d.format("%Y-%m-%d").to_string()), count: summaries.len(), deliveries: summaries .into_iter() diff --git a/crates/application/src/ports/delivery_completion_repository.rs b/crates/application/src/ports/delivery_completion_repository.rs index fe6ca6c..bac328e 100644 --- a/crates/application/src/ports/delivery_completion_repository.rs +++ b/crates/application/src/ports/delivery_completion_repository.rs @@ -132,16 +132,20 @@ 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`]). + /// Liefert alle **abgeschlossenen** (ausgelieferten) Lieferungen im + /// angegebenen Datumsbereich — gefiltert über den Berliner Kalendertag von + /// `completed_at`, 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( + /// `from`/`to` sind **inklusive** Grenzen; `None` lässt die jeweilige Seite + /// offen (`from == to` ⇒ genau ein Tag, `None`/`None` ⇒ alle). `completed_at` + /// ist ein UTC-Zeitstempel; der Kalendertag wird in **Europe/Berlin** + /// bestimmt. Sortierung: aufsteigend nach Abschluss-Zeit. + async fn list_completed_deliveries( &self, - day: NaiveDate, + from: Option, + to: Option, ) -> Result, ApplicationError>; /// Markiert die Liefermail der angegebenen Belegnummern als **versendet** diff --git a/crates/application/src/usecases/list_completed_deliveries.rs b/crates/application/src/usecases/list_completed_deliveries.rs index 7c94c3f..234620d 100644 --- a/crates/application/src/usecases/list_completed_deliveries.rs +++ b/crates/application/src/usecases/list_completed_deliveries.rs @@ -23,11 +23,13 @@ impl ListCompletedDeliveriesUseCase { Self { completions } } - /// Liefert alle am `day` (Berliner Kalendertag) abgeschlossenen Lieferungen. + /// Liefert alle im Bereich `[from, to]` (Berliner Kalendertag, inklusive, + /// `None` = offene Seite) abgeschlossenen Lieferungen. pub async fn execute( &self, - day: NaiveDate, + from: Option, + to: Option, ) -> Result, ApplicationError> { - self.completions.list_completed_deliveries_for_day(day).await + self.completions.list_completed_deliveries(from, to).await } } diff --git a/crates/infrastructure/src/persistence/delivery_completion_repository.rs b/crates/infrastructure/src/persistence/delivery_completion_repository.rs index 2e730cc..73ed355 100644 --- a/crates/infrastructure/src/persistence/delivery_completion_repository.rs +++ b/crates/infrastructure/src/persistence/delivery_completion_repository.rs @@ -473,12 +473,14 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository { Ok(belegnummern) } - async fn list_completed_deliveries_for_day( + async fn list_completed_deliveries( &self, - day: NaiveDate, + from: Option, + to: Option, ) -> Result, ApplicationError> { - // Alle an `day` (Berliner Kalendertag von completed_at) abgeschlossenen - // Lieferungen — ohne Mail-Filter. `positions_modified` ist true, wenn + // Alle im Bereich [from, to] (Berliner Kalendertag von completed_at) + // abgeschlossenen Lieferungen — ohne Mail-Filter. `from`/`to` inklusive, + // NULL = offene Seite. `positions_modified` ist true, wenn // (a) irgendeine Belegzeile eine Stück-Gutschrift trägt // (credited_quantity > 0 ⇒ Zeile ganz entfernt oder Menge reduziert), // ODER @@ -506,11 +508,15 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository { ) 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 + WHERE ( $1::date IS NULL + OR (c.completed_at AT TIME ZONE 'Europe/Berlin')::date >= $1 ) + AND ( $2::date IS NULL + OR (c.completed_at AT TIME ZONE 'Europe/Berlin')::date <= $2 ) ORDER BY c.completed_at "#, ) - .bind(day) + .bind(from) + .bind(to) .fetch_all(&self.pool) .await .map_err(db)?;