//! Admin-/Betriebs-Endpunkte. //! //! Aktuell: manueller ERP-Import-Trigger. Derselbe Use Case, den auch der //! tägliche Scheduler ruft — hier on-demand für ein konkretes Datum //! (Testen + manuelle Nachläufe im Betrieb). JWT-geschützt wie alle //! protected Routen. use axum::Json; use axum::Router; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::routing::{get, post}; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; use holzleitner_application::error::ApplicationError; use holzleitner_application::usecases::ImportSummary; use crate::error::ApiError; use crate::state::AppState; pub fn router() -> Router { Router::new() .route("/admin/import-erp", post(import_erp)) .route("/admin/push-completion", post(push_completion)) .route( "/admin/delivered-belegnummern", get(delivered_belegnummern), ) .route( "/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)) } #[derive(Debug, Deserialize)] pub struct ImportErpQuery { /// Ziel-Tourdatum `YYYY-MM-DD`. Fehlt der Parameter, wird **heute** /// verwendet. #[serde(default)] pub date: Option, } /// Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung. #[utoipa::path( post, path = "/admin/import-erp", tag = "admin", params( ("date" = Option, Query, description = "Ziel-Tourdatum YYYY-MM-DD (Default: heute)") ), responses( (status = 200, description = "Import durchgeführt", body = ImportSummary), (status = 400, description = "Ungültiges Datum"), (status = 401, description = "Admin-API-Key fehlt/ungültig"), (status = 502, description = "ERP nicht erreichbar / Lesefehler") ), security(("admin_api_key" = [])) )] pub async fn import_erp( State(state): State, Query(query): Query, ) -> Result, ApiError> { let date = match query.date { Some(s) => NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").map_err(|e| { ApiError(ApplicationError::Validation(format!( "ungültiges Datum '{s}' (erwartet YYYY-MM-DD): {e}" ))) })?, None => chrono::Utc::now().date_naive(), }; tracing::info!(%date, "admin.import_erp"); let summary = state.import_erp_tours.execute(date).await?; tracing::info!( %date, total = summary.tours_total, ok = summary.tours_ok, failed = summary.tours_failed, "admin.import_erp.done" ); Ok(Json(summary)) } #[derive(Debug, Deserialize)] pub struct PushCompletionQuery { /// UUID der bereits abgeschlossenen Lieferung. pub delivery_id: String, } /// Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen /// Lieferabschlusses erneut an (idempotenter Retry, falls der automatische /// Push beim Abschluss fehlschlug). #[utoipa::path( post, path = "/admin/push-completion", tag = "admin", params( ("delivery_id" = String, Query, description = "UUID der abgeschlossenen Lieferung") ), responses( (status = 204, description = "Rückschreiben erfolgreich"), (status = 400, description = "Ungültige delivery_id"), (status = 401, description = "Admin-API-Key fehlt/ungültig"), (status = 404, description = "Lieferung nicht gefunden / nicht abgeschlossen"), (status = 502, description = "ERP nicht erreichbar / Schreibfehler") ), security(("admin_api_key" = [])) )] pub async fn push_completion( State(state): State, Query(query): Query, ) -> Result { let delivery_id = Uuid::parse_str(query.delivery_id.trim()).map_err(|e| { ApiError(ApplicationError::Validation(format!( "ungültige delivery_id '{}': {e}", query.delivery_id ))) })?; tracing::info!(%delivery_id, "admin.push_completion"); state.push_completion_to_erp.execute(delivery_id).await?; tracing::info!(%delivery_id, "admin.push_completion.done"); Ok(StatusCode::NO_CONTENT) } #[derive(Debug, Deserialize)] pub struct DeliveredBelegnummernQuery { /// Ziel-Tag im Format `DD-MM-YYYY`. **Fehlt der Parameter, werden ALLE** /// offenen (noch nicht versendeten) Belege über alle Tage geliefert — das /// ist der Modus des Mailclients. #[serde(default)] pub day: Option, } #[derive(Debug, Serialize, ToSchema)] pub struct DeliveredBelegnummernResponse { /// Tag, nach dem gefiltert wurde (ISO `YYYY-MM-DD`), oder `"all"` wenn kein /// `day` angegeben war. pub day: String, /// Anzahl der offenen (noch nicht versendeten) Belege. pub count: usize, /// Belegnummern aller **ausgelieferten** (abgeschlossenen) Lieferungen, /// deren Liefermail noch **nicht versendet** wurde, aufsteigend nach /// Abschluss-Zeitpunkt. pub belegnummern: Vec, } /// Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, /// **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`). /// „Ausgeliefert" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur /// Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über /// alle Tage) — so bleiben Belege über Mitternacht nicht hängen. #[utoipa::path( get, path = "/admin/delivered-belegnummern", tag = "admin", params( ("day" = Option, Query, description = "Tag DD-MM-YYYY; ohne Angabe ALLE offenen Belege") ), responses( (status = 200, description = "Offene (nicht versendete) Belegnummern", body = DeliveredBelegnummernResponse), (status = 400, description = "Ungültiger Tag"), (status = 401, description = "Admin-API-Key fehlt/ungültig") ), security(("admin_api_key" = [])) )] pub async fn delivered_belegnummern( State(state): State, Query(query): Query, ) -> Result, ApiError> { let day: Option = match query.day { Some(s) => Some(NaiveDate::parse_from_str(s.trim(), "%d-%m-%Y").map_err(|e| { ApiError(ApplicationError::Validation(format!( "ungültiger Tag '{s}' (erwartet DD-MM-YYYY): {e}" ))) })?), None => None, }; tracing::info!(?day, "admin.delivered_belegnummern"); let belegnummern = state.list_delivered_belegnummern.execute(day).await?; tracing::info!(?day, count = belegnummern.len(), "admin.delivered_belegnummern.done"); Ok(Json(DeliveredBelegnummernResponse { day: day.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_else(|| "all".into()), count: belegnummern.len(), belegnummern, })) } #[derive(Debug, Deserialize)] pub struct CompletedDeliveriesQuery { /// 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. #[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 { /// 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, } /// 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" = 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 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" = [])) )] pub async fn completed_deliveries( State(state): State, Query(query): Query, ) -> Result, ApiError> { // `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 { 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() .map(|s| CompletedDeliveryItem { belegnummer: s.belegnummer, positions_modified: s.positions_modified, }) .collect(), })) } #[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 /// versendet markiert werden sollen. pub belegnummern: Vec, } #[derive(Debug, Serialize, ToSchema)] pub struct MarkMailSentResponse { /// Anzahl frisch markierter (vorher offener) Belege. Bereits markierte /// zählen nicht mit (idempotent). pub marked: u64, } /// Markiert die Liefermails der angegebenen Belegnummern als **versendet** /// (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, /// NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen /// die Belege nicht mehr in `GET /admin/delivered-belegnummern`. #[utoipa::path( post, path = "/admin/mark-mail-sent", tag = "admin", request_body = MarkMailSentRequest, responses( (status = 200, description = "Markierung durchgeführt", body = MarkMailSentResponse), (status = 401, description = "Admin-API-Key fehlt/ungültig") ), security(("admin_api_key" = [])) )] pub async fn mark_mail_sent( State(state): State, Json(body): Json, ) -> Result, ApiError> { tracing::info!(count = body.belegnummern.len(), "admin.mark_mail_sent"); let marked = state.mark_mail_sent.execute(body.belegnummern).await?; tracing::info!(marked, "admin.mark_mail_sent.done"); Ok(Json(MarkMailSentResponse { marked })) } // ─── Vier-Augen-Prüfung geänderter Lieferscheine ──────────────────────── #[derive(Debug, Serialize, ToSchema)] pub struct ReviewedItemResponse { pub belegzeilen_nr: i32, pub artikel_nr: String, pub article_name: String, pub required_quantity: i32, pub credited_quantity: i32, pub reason: Option, } #[derive(Debug, Serialize, ToSchema)] pub struct PendingReviewResponse { pub delivery_id: String, pub erp_belegart_id: i64, pub erp_belegnummer: String, pub customer_name: String, /// Tourdatum (ISO `YYYY-MM-DD`). pub tour_date: String, /// Zeitpunkt der letzten beleg-ändernden Aktion (RFC 3339). pub last_change_at: String, /// Entfernte/teil-gutgeschriebene Positionen. pub credited_items: Vec, /// Geld-Gutschrift in Cent (0 = keine). pub money_credit_cents: i64, pub money_credit_reason: Option, } /// Listet alle geänderten Lieferscheine, die noch auf eine manuelle /// Bestätigung (Vier-Augen) warten — Entfernungen und/oder Geld-Gutschriften. #[utoipa::path( get, path = "/admin/reviews", tag = "admin", responses( (status = 200, description = "Offene Prüfungen", body = [PendingReviewResponse]), (status = 401, description = "Admin-API-Key fehlt/ungültig") ), security(("admin_api_key" = [])) )] pub async fn list_reviews( State(state): State, ) -> Result>, ApiError> { let items = state.list_pending_reviews.execute().await?; tracing::info!(count = items.len(), "admin.reviews.list"); let out = items .into_iter() .map(|r| PendingReviewResponse { delivery_id: r.delivery_id.to_string(), erp_belegart_id: r.erp_belegart_id, erp_belegnummer: r.erp_belegnummer, customer_name: r.customer_name, tour_date: r.tour_date.format("%Y-%m-%d").to_string(), last_change_at: r.last_change_at.to_rfc3339(), credited_items: r .credited_items .into_iter() .map(|i| ReviewedItemResponse { belegzeilen_nr: i.belegzeilen_nr, artikel_nr: i.artikel_nr, article_name: i.article_name, required_quantity: i.required_quantity, credited_quantity: i.credited_quantity, reason: i.reason, }) .collect(), money_credit_cents: r.money_credit_cents, money_credit_reason: r.money_credit_reason, }) .collect(); Ok(Json(out)) } #[derive(Debug, Deserialize, ToSchema)] pub struct ResolveReviewRequest { /// Bearbeiter (Name/Kürzel), der die Prüfung bestätigt. pub resolved_by: String, /// Optionale Notiz zur getroffenen Entscheidung. #[serde(default)] pub note: Option, } /// Bestätigt die Prüfung einer geänderten Lieferung (Vier-Augen) — die /// Lieferung verschwindet danach aus `GET /admin/reviews` (sofern nicht /// erneut geändert). #[utoipa::path( post, path = "/admin/reviews/{delivery_id}/resolve", tag = "admin", params(("delivery_id" = String, Path, description = "UUID der Lieferung")), request_body = ResolveReviewRequest, responses( (status = 204, description = "Prüfung bestätigt"), (status = 400, description = "Ungültige delivery_id / Bearbeiter leer"), (status = 401, description = "Admin-API-Key fehlt/ungültig"), (status = 404, description = "Lieferung nicht gefunden") ), security(("admin_api_key" = [])) )] pub async fn resolve_review( State(state): State, Path(delivery_id): Path, Json(body): Json, ) -> Result { let id = Uuid::parse_str(delivery_id.trim()).map_err(|e| { ApiError(ApplicationError::Validation(format!( "ungültige delivery_id '{delivery_id}': {e}" ))) })?; tracing::info!(%id, by = %body.resolved_by, "admin.reviews.resolve"); state .resolve_review .execute(id, &body.resolved_by, body.note.as_deref()) .await?; Ok(StatusCode::NO_CONTENT) }