Backend-Arbeitsstand: ERP-Sync, Lieferlebenszyklus, Reports + config.toml

Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand
(Clean Architecture: domain → application → infrastructure → api).

Wesentliche Bereiche:
- ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben)
- Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen,
  Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe
- Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services
- Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API
- Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte

Jüngste Änderungen dieser Session:
- Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/
  Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/
  E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails)
- Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde)
  umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG

Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/
Kundendaten), demo.mp4, .claude/, variocontrol-ai/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-01 17:52:58 +02:00
parent 438040acce
commit 6a9b5872e1
137 changed files with 13700 additions and 218 deletions

View File

@ -0,0 +1,224 @@
//! 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::{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<AppState> {
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/mark-mail-sent", post(mark_mail_sent))
}
#[derive(Debug, Deserialize)]
pub struct ImportErpQuery {
/// Ziel-Tourdatum `YYYY-MM-DD`. Fehlt der Parameter, wird **heute**
/// verwendet.
#[serde(default)]
pub date: Option<String>,
}
/// 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<String>, 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<AppState>,
Query(query): Query<ImportErpQuery>,
) -> Result<Json<ImportSummary>, 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<AppState>,
Query(query): Query<PushCompletionQuery>,
) -> Result<StatusCode, ApiError> {
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<String>,
}
#[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<String>,
}
/// 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<String>, 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<AppState>,
Query(query): Query<DeliveredBelegnummernQuery>,
) -> Result<Json<DeliveredBelegnummernResponse>, ApiError> {
let day: Option<NaiveDate> = 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, ToSchema)]
pub struct MarkMailSentRequest {
/// Belegnummern, deren Liefermail erfolgreich versendet wurde und die als
/// versendet markiert werden sollen.
pub belegnummern: Vec<String>,
}
#[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<AppState>,
Json(body): Json<MarkMailSentRequest>,
) -> Result<Json<MarkMailSentResponse>, 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 }))
}