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:
224
crates/api/src/routes/admin.rs
Normal file
224
crates/api/src/routes/admin.rs
Normal 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 }))
|
||||
}
|
||||
Reference in New Issue
Block a user