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 }))
}

View File

@ -0,0 +1,88 @@
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use serde::Deserialize;
use uuid::Uuid;
use crate::error::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new().route("/attachments/{id}", get(get_attachment))
}
/// Größen-/Format-Parameter für das gerenderte Vorschaubild. Alle optional
/// mit sinnvollen Defaults — die App kann pro Anwendungsfall (Thumbnail vs.
/// Vollbild) abweichende Werte anfragen.
#[derive(Debug, Deserialize)]
pub struct PreviewQuery {
#[serde(default = "default_dimension")]
pub w: u32,
#[serde(default = "default_dimension")]
pub h: u32,
#[serde(default = "default_quality")]
pub q: u32,
#[serde(default = "default_ext")]
pub ext: String,
#[serde(default = "default_page")]
pub page: String,
}
fn default_dimension() -> u32 {
1024
}
fn default_quality() -> u32 {
85
}
fn default_ext() -> String {
"jpeg".to_string()
}
fn default_page() -> String {
"1".to_string()
}
/// Liefert ein gerendertes Vorschaubild des Attachments (Bytes), geladen
/// aus DOCUframe. Auflösung/Format über Query-Parameter steuerbar
/// (`?w=&h=&q=&ext=&page=`).
#[utoipa::path(
get,
path = "/attachments/{id}",
tag = "attachments",
params(
("id" = Uuid, Path, description = "Attachment-Id (unsere UUID)"),
("w" = Option<u32>, Query, description = "Breite in Pixeln (Default 1024)"),
("h" = Option<u32>, Query, description = "Höhe in Pixeln (Default 1024)"),
("q" = Option<u32>, Query, description = "Qualität 0100 (Default 85)"),
("ext" = Option<String>, Query, description = "png|jpeg|jpg|webp|tiff (Default jpeg)"),
("page" = Option<String>, Query, description = "Seitennummer (Default 1)"),
),
responses(
(status = 200, description = "Vorschaubild (Bytes)", content_type = "image/jpeg"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Attachment nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn get_attachment(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(id): Path<Uuid>,
Query(query): Query<PreviewQuery>,
) -> Result<Response, ApiError> {
tracing::info!(actor = claims.personalnummer, %id, "attachment.preview");
// DOCUframe-Parameterschema: width_height_quality_extension.
let parameters = format!("{}_{}_{}_{}", query.w, query.h, query.q, query.ext);
let preview = state
.get_attachment_preview
.execute(id, parameters, query.page)
.await?;
Ok((
[(header::CONTENT_TYPE, preview.content_type)],
preview.bytes,
)
.into_response())
}

View File

@ -1,11 +1,15 @@
use axum::Json;
use axum::Router;
use axum::extract::{Path, State};
use axum::routing::{post, put};
use axum::extract::{DefaultBodyLimit, Multipart, Path, State};
use axum::http::StatusCode;
use axum::routing::{patch, post, put};
use holzleitner_application::dto::{
AssignCarRequest, CancelDeliveryRequest, CreateDeliveryNoteRequest, DeliveryNoteResponse,
DeliveryResponse, HoldDeliveryRequest,
AssignCarRequest, CancelDeliveryRequest, CompleteDeliveryAcknowledgements,
CreateDeliveryNoteRequest, DeliveryCreditEventRequest, DeliveryCreditResponse,
DeliveryNoteResponse, DeliveryResponse, DeliveryServiceResponse, HoldDeliveryRequest,
SetDeliveryServiceRequest, UpdateDeliveryNoteRequest,
};
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::DeliveryAction;
use uuid::Uuid;
@ -13,14 +17,40 @@ use crate::error::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
/// Maximale Größe eines multipart-Uploads. Axums Default-Body-Limit liegt
/// bei 2 MiB — Handy-Kamerafotos sprengen das regelmäßig, der
/// multipart-Stream wird dann abgeschnitten und multer wirft „Error parsing
/// multipart…". Daher heben wir das Limit **nur** für die multipart-Routen
/// an (Bild-Upload + Abschluss mit Signaturen); die JSON-Routen behalten ihr
/// sicheres Default.
const MULTIPART_BODY_LIMIT: usize = 25 * 1024 * 1024;
pub fn router() -> Router<AppState> {
// Eigener Sub-Router für multipart-Uploads mit angehobenem Body-Limit.
let multipart = Router::new()
.route(
"/deliveries/{delivery_id}/notes/image",
post(upload_note_image),
)
.route("/deliveries/{delivery_id}/complete", post(complete))
.layer(DefaultBodyLimit::max(MULTIPART_BODY_LIMIT));
Router::new()
.route("/deliveries/{delivery_id}/hold", post(hold))
.route("/deliveries/{delivery_id}/resume", post(resume))
.route("/deliveries/{delivery_id}/cancel", post(cancel))
.route("/deliveries/{delivery_id}/complete", post(complete))
.route("/deliveries/{delivery_id}/notes", post(create_note))
.route(
"/deliveries/{delivery_id}/notes/{note_id}",
patch(update_note).delete(delete_note),
)
.route("/deliveries/{delivery_id}/credit", post(apply_credit))
.route(
"/deliveries/{delivery_id}/services/{service_id}",
put(set_service).delete(delete_service_value),
)
.route("/deliveries/{delivery_id}/assigned-car", put(assign_car))
.merge(multipart)
}
/// Setzt die Lieferung auf `held`. Nur aus `active` zulässig.
@ -113,14 +143,30 @@ pub async fn cancel(
}
/// Schließt die Lieferung ab — `state = completed`. Nur aus `active`.
///
/// `multipart/form-data` mit drei Feldern:
/// * `customer_signature` — PNG der Kunden-Unterschrift (Pflicht)
/// * `driver_signature` — PNG der Fahrer-Unterschrift (Pflicht)
/// * `acknowledgements` — JSON (`CompleteDeliveryAcknowledgements`):
/// `receiptConfirmed` (Pflicht true), `notesAcknowledged`,
/// `acknowledgedNoteIds`, `authorCarId`.
///
/// Atomar: Signaturen werden lokal gespeichert, die Abschluss-Zeile
/// geschrieben und der Status auf `completed` gesetzt — alles oder nichts.
/// Gates: Lieferung aktiv, alle scanbaren Positionen fertig, Notizen
/// bestätigt (falls vorhanden).
#[utoipa::path(
post,
path = "/deliveries/{delivery_id}/complete",
tag = "deliveries",
params(("delivery_id" = Uuid, Path)),
request_body(
content_type = "multipart/form-data",
description = "Felder `customer_signature`, `driver_signature` (PNG) + `acknowledgements` (JSON)"
),
responses(
(status = 200, description = "Lieferung abgeschlossen", body = DeliveryResponse),
(status = 400, description = "Invalider Statusübergang"),
(status = 400, description = "Invalider Statusübergang / fehlende Signatur / offene Scans / Notizen unbestätigt"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden")
),
@ -130,15 +176,107 @@ pub async fn complete(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(delivery_id): Path<Uuid>,
mut multipart: Multipart,
) -> Result<Json<DeliveryResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.complete");
let mut customer_png: Option<Vec<u8>> = None;
let mut driver_png: Option<Vec<u8>> = None;
let mut acknowledgements: Option<CompleteDeliveryAcknowledgements> = None;
while let Some(field) = multipart.next_field().await.map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"multipart konnte nicht gelesen werden: {e}"
)))
})? {
match field.name() {
Some("customer_signature") => {
let data = field.bytes().await.map_err(read_err)?;
customer_png = Some(data.to_vec());
}
Some("driver_signature") => {
let data = field.bytes().await.map_err(read_err)?;
driver_png = Some(data.to_vec());
}
Some("acknowledgements") => {
let text = field.text().await.map_err(read_err)?;
let parsed: CompleteDeliveryAcknowledgements =
serde_json::from_str(&text).map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"`acknowledgements` ist kein gültiges JSON: {e}"
)))
})?;
acknowledgements = Some(parsed);
}
_ => {}
}
}
let customer_png = customer_png.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"Feld `customer_signature` fehlt".into(),
))
})?;
let driver_png = driver_png.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"Feld `driver_signature` fehlt".into(),
))
})?;
let acknowledgements = acknowledgements.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"Feld `acknowledgements` fehlt".into(),
))
})?;
let delivery = state
.apply_delivery_action
.execute(delivery_id, DeliveryAction::Complete)
.complete_delivery
.execute(
delivery_id,
claims.personalnummer,
acknowledgements,
customer_png,
driver_png,
)
.await?;
// PDF-Report (best-effort, NACH erfolgreichem Abschluss): ein Fehler hier
// darf die Abschluss-Antwort NIE kippen.
if state.report_upload_enabled {
// An DOCUframe übertragen — im Hintergrund, damit die Antwort schnell
// bleibt. Schlägt etwas fehl, bleibt ein Job in PG offen und der
// Retry-Cron versucht es erneut.
let process = state.process_delivery_report.clone();
tokio::spawn(async move {
match process.execute(delivery_id).await {
Ok(()) => tracing::info!(%delivery_id, "delivery.complete.report_uploaded"),
Err(e) => tracing::warn!(
%delivery_id, error = %e,
"delivery.complete.report_upload_failed (Retry-Cron übernimmt)"
),
}
});
} else {
// DOCUframe-Upload aus (Dev): Report nur lokal erzeugen.
match state.generate_delivery_report.execute(delivery_id).await {
Ok(reference) => {
tracing::info!(%delivery_id, reference, "delivery.complete.report_generated_local")
}
Err(e) => {
tracing::error!(%delivery_id, error = %e, "delivery.complete.report_failed")
}
}
}
Ok(Json(DeliveryResponse { delivery }))
}
/// Helfer: multipart-Feld-Lesefehler → `Validation`.
fn read_err(e: axum::extract::multipart::MultipartError) -> ApiError {
ApiError(ApplicationError::Validation(format!(
"feld konnte nicht gelesen werden: {e}"
)))
}
/// Legt eine neue Notiz an einer Lieferung an. Mindestens eines von
/// `text` und `imageAttachment` muss inhaltlich gefüllt sein
/// (Leerstrings werden serverseitig getrimmt und als leer behandelt).
@ -170,6 +308,238 @@ pub async fn create_note(
Ok(Json(DeliveryNoteResponse { note }))
}
/// Ändert Text/Bild einer Notiz. Innerhalb des (geteilten) Accounts darf
/// jeder Fahrer Notizen pflegen — kein Autor-Check. `delivery_id` ist Teil
/// des Pfads (REST-Konsistenz), die Notiz wird über `note_id` adressiert.
#[utoipa::path(
patch,
path = "/deliveries/{delivery_id}/notes/{note_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("note_id" = Uuid, Path),
),
request_body = UpdateDeliveryNoteRequest,
responses(
(status = 200, description = "Notiz aktualisiert", body = DeliveryNoteResponse),
(status = 400, description = "Notiz ohne Inhalt"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Notiz nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn update_note(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, note_id)): Path<(Uuid, Uuid)>,
Json(req): Json<UpdateDeliveryNoteRequest>,
) -> Result<Json<DeliveryNoteResponse>, ApiError> {
tracing::info!(
actor = claims.personalnummer,
%delivery_id,
%note_id,
"delivery.update_note"
);
let note = state.update_delivery_note.execute(note_id, req).await?;
Ok(Json(DeliveryNoteResponse { note }))
}
/// Löscht eine Notiz. Antwortet mit `204 No Content`.
#[utoipa::path(
delete,
path = "/deliveries/{delivery_id}/notes/{note_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("note_id" = Uuid, Path),
),
responses(
(status = 204, description = "Notiz gelöscht"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Notiz nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn delete_note(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, note_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, ApiError> {
tracing::info!(
actor = claims.personalnummer,
%delivery_id,
%note_id,
"delivery.delete_note"
);
state.delete_delivery_note.execute(note_id).await?;
Ok(StatusCode::NO_CONTENT)
}
/// Lädt ein Bild zu einer Lieferung hoch (multipart/form-data, Feld `file`)
/// und legt dafür eine Bild-Notiz an. Das Bild geht in den
/// DOCUframe-Dokumentenspeicher; gespeichert wird die zurückgelieferte
/// Referenz (`~ObjectID`) als `image_attachment` der Notiz.
#[utoipa::path(
post,
path = "/deliveries/{delivery_id}/notes/image",
tag = "deliveries",
params(("delivery_id" = Uuid, Path)),
request_body(
content_type = "multipart/form-data",
description = "Formularfeld `file` mit den Bilddaten"
),
responses(
(status = 200, description = "Bild hochgeladen, Notiz angelegt", body = DeliveryNoteResponse),
(status = 400, description = "Kein/leeres Datei-Feld"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden"),
(status = 500, description = "Upload zu DOCUframe fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn upload_note_image(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(delivery_id): Path<Uuid>,
mut multipart: Multipart,
) -> Result<Json<DeliveryNoteResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.upload_note_image");
let mut bytes: Option<Vec<u8>> = None;
let mut filename = String::from("upload");
let mut mime = String::from("application/octet-stream");
while let Some(field) = multipart.next_field().await.map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"multipart konnte nicht gelesen werden: {e}"
)))
})? {
if field.name() == Some("file") {
if let Some(fname) = field.file_name() {
filename = fname.to_owned();
}
if let Some(ct) = field.content_type() {
mime = ct.to_owned();
}
let data = field.bytes().await.map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"datei konnte nicht gelesen werden: {e}"
)))
})?;
bytes = Some(data.to_vec());
}
}
let bytes = bytes.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"kein `file`-Feld im multipart-Body".into(),
))
})?;
let note = state
.upload_delivery_note_image
.execute(delivery_id, claims.personalnummer, None, filename, mime, bytes)
.await?;
Ok(Json(DeliveryNoteResponse { note }))
}
/// Wendet ein Betrags-Gutschrift-Ereignis an (`set`/`remove`). Append-only,
/// idempotent über `clientEventId`. Nur bei aktiver Lieferung; bei `set` sind
/// Betrag (0 < x ≤ 150 €, 10-€-Schritte) und Grund Pflicht. Antwort: der
/// aktuelle Gutschrift-Stand (`null`, wenn entfernt).
#[utoipa::path(
post,
path = "/deliveries/{delivery_id}/credit",
tag = "deliveries",
params(("delivery_id" = Uuid, Path)),
request_body = DeliveryCreditEventRequest,
responses(
(status = 200, description = "Gutschrift gesetzt/entfernt", body = DeliveryCreditResponse),
(status = 400, description = "Ungültiger Betrag/Grund oder Lieferung nicht aktiv"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn apply_credit(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(delivery_id): Path<Uuid>,
Json(req): Json<DeliveryCreditEventRequest>,
) -> Result<Json<DeliveryCreditResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.apply_credit");
let credit = state
.apply_delivery_credit_event
.execute(delivery_id, claims.personalnummer, req)
.await?;
Ok(Json(DeliveryCreditResponse { credit }))
}
/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum
/// Service-Typ passende Feld (`boolValue`/`numericValue`) muss gesetzt sein;
/// numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung.
#[utoipa::path(
put,
path = "/deliveries/{delivery_id}/services/{service_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("service_id" = Uuid, Path),
),
request_body = SetDeliveryServiceRequest,
responses(
(status = 200, description = "Wert gesetzt", body = DeliveryServiceResponse),
(status = 400, description = "Wert passt nicht zum Service-Typ / außerhalb min-max / Lieferung nicht aktiv"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Service oder Lieferung nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn set_service(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, service_id)): Path<(Uuid, Uuid)>,
Json(req): Json<SetDeliveryServiceRequest>,
) -> Result<Json<DeliveryServiceResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, %service_id, "delivery.set_service");
let value = state
.set_delivery_service
.execute(delivery_id, service_id, claims.personalnummer, req)
.await?;
Ok(Json(DeliveryServiceResponse { value }))
}
/// Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt").
/// Nur bei aktiver Lieferung. Antwort `204`.
#[utoipa::path(
delete,
path = "/deliveries/{delivery_id}/services/{service_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("service_id" = Uuid, Path),
),
responses(
(status = 204, description = "Wert entfernt"),
(status = 400, description = "Lieferung nicht aktiv"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn delete_service_value(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, service_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, %service_id, "delivery.delete_service_value");
state
.delete_delivery_service
.execute(delivery_id, service_id)
.await?;
Ok(StatusCode::NO_CONTENT)
}
/// Setzt das `assigned_car_id` einer Lieferung. `carId: null` löst
/// die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug
/// zum angemeldeten Account gehört.

View File

@ -0,0 +1,160 @@
//! DEV-ONLY Endpunkte. Werden in `main.rs` **nur** gemountet, wenn
//! `dev.sync_enabled = true` (config.toml) — in Produktion existieren sie nicht.
//!
//! `POST /dev/resync` ist bewusst **unauthentifiziert** (liegt auf dem
//! public Router), damit man ihn ohne JWT per `curl` triggern kann. Er macht
//! die Postgres-Tourdaten platt und importiert frisch aus dem ERP — der
//! „überschreibende" Sync für die lokale Entwicklung. NIEMALS in Produktion
//! aktivieren.
use axum::Json;
use axum::Router;
use axum::extract::{Query, State};
use axum::routing::post;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
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("/dev/resync", post(dev_resync))
.route("/dev/generate-report", post(dev_generate_report))
.route("/dev/process-report", post(dev_process_report))
.route("/dev/unmark-mail-sent", post(dev_unmark_mail_sent))
}
#[derive(Debug, Deserialize)]
pub struct DevResyncQuery {
/// Ziel-Tourdatum `YYYY-MM-DD`. Fehlt der Parameter, wird **heute**
/// (echte Uhr) verwendet.
#[serde(default)]
pub date: Option<String>,
}
/// DEV-ONLY, UNAUTHENTIFIZIERT: löscht alle Postgres-Tourdaten und importiert
/// das Datum frisch aus dem ERP. Liefert die Import-Zusammenfassung.
pub async fn dev_resync(
State(state): State<AppState>,
Query(query): Query<DevResyncQuery>,
) -> 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::warn!(%date, "dev.resync: Postgres wird überschrieben + neu importiert");
let summary = state.dev_resync_tours.execute(date).await?;
tracing::info!(
%date,
total = summary.tours_total,
ok = summary.tours_ok,
failed = summary.tours_failed,
provisioned = summary.drivers_provisioned,
"dev.resync.done"
);
Ok(Json(summary))
}
#[derive(Debug, Deserialize)]
pub struct DevReportQuery {
/// UUID der Lieferung, für die der Report erzeugt werden soll.
pub delivery_id: String,
}
#[derive(Debug, Serialize)]
pub struct DevReportResponse {
pub reference: String,
}
/// DEV-ONLY: erzeugt den PDF-Report für eine Lieferung (ohne echten Abschluss)
/// und gibt die Sink-Referenz (Dateipfad) zurück. Zum Iterieren am Layout.
pub async fn dev_generate_report(
State(state): State<AppState>,
Query(query): Query<DevReportQuery>,
) -> Result<Json<DevReportResponse>, 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::warn!(%delivery_id, "dev.generate_report angestoßen");
let reference = state.generate_delivery_report.execute(delivery_id).await?;
tracing::info!(%delivery_id, reference, "dev.generate_report.done");
Ok(Json(DevReportResponse { reference }))
}
/// DEV-ONLY: stößt die volle DOCUframe-Übertragungs-Pipeline für eine Lieferung
/// an (Render → Upload → Makro → Cleanup). Solange das Makro fehlt, schlägt der
/// Makro-Schritt erwartungsgemäß fehl — der Job bleibt dann in PG offen und der
/// Retry-Cron versucht es erneut. Liefert eine kurze Status-Meldung.
pub async fn dev_process_report(
State(state): State<AppState>,
Query(query): Query<DevReportQuery>,
) -> Result<Json<DevProcessResponse>, 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::warn!(%delivery_id, "dev.process_report angestoßen");
match state.process_delivery_report.execute(delivery_id).await {
Ok(()) => {
tracing::info!(%delivery_id, "dev.process_report.done");
Ok(Json(DevProcessResponse {
ok: true,
message: "Report an DOCUframe übertragen + lokale Dateien aufgeräumt".into(),
}))
}
Err(e) => {
tracing::warn!(%delivery_id, error = %e, "dev.process_report.failed (Job bleibt offen)");
Ok(Json(DevProcessResponse {
ok: false,
message: format!("fehlgeschlagen (Job in PG offen, Cron retried): {e}"),
}))
}
}
}
#[derive(Debug, Serialize)]
pub struct DevProcessResponse {
pub ok: bool,
pub message: String,
}
#[derive(Debug, Deserialize)]
pub struct DevUnmarkRequest {
/// Belegnummern, deren Mail-Versendet-Markierung wieder aufgehoben werden
/// soll (für erneutes Testen).
pub belegnummern: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct DevUnmarkResponse {
/// Anzahl tatsächlich zurückgesetzter (vorher markierter) Belege.
pub unmarked: u64,
}
/// DEV-ONLY, UNAUTHENTIFIZIERT: setzt `mail_sent_at` der angegebenen
/// Belegnummern wieder auf NULL, sodass sie erneut als offen in
/// `GET /admin/delivered-belegnummern` erscheinen. Zum wiederholten Testen
/// des Mailclients.
pub async fn dev_unmark_mail_sent(
State(state): State<AppState>,
Json(body): Json<DevUnmarkRequest>,
) -> Result<Json<DevUnmarkResponse>, ApiError> {
tracing::warn!(count = body.belegnummern.len(), "dev.unmark_mail_sent");
let unmarked = state.mark_mail_sent.unmark(body.belegnummern).await?;
tracing::info!(unmarked, "dev.unmark_mail_sent.done");
Ok(Json(DevUnmarkResponse { unmarked }))
}

View File

@ -2,8 +2,13 @@
//! zusammengesetzt.
pub mod accounts;
pub mod admin;
pub mod attachments;
pub mod cars;
pub mod deliveries;
pub mod dev;
pub mod health;
pub mod payment_methods;
pub mod scans;
pub mod services;
pub mod tours;

View File

@ -0,0 +1,142 @@
//! `/payment-methods` — globale Zahlungs-Stammdaten.
//!
//! Lese-Endpoint ist von der App frei nutzbar (Liste für die Auswahl in
//! der Auslieferungs-Phase). Schreib-Endpoints (POST/PATCH/DELETE) sind
//! Admin-Operationen — Authentifizierung schützt sie über die globale
//! Middleware, eine Rollen-Trennung kommt später (Phase H).
use axum::Json;
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
// `Path<Uuid>` für PATCH/DELETE — direkt aus axum::extract verwendet.
use holzleitner_application::dto::{
CreatePaymentMethodRequest, PaymentMethodResponse, PaymentMethodsList,
UpdatePaymentMethodRequest,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::error::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/payment-methods",
get(list_payment_methods).post(create_payment_method),
)
.route(
"/payment-methods/{id}",
axum::routing::patch(update_payment_method).delete(delete_payment_method),
)
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ListPaymentMethodsQuery {
/// Default `false` — Endpoint liefert nur aktive Methoden.
#[serde(default)]
pub include_inactive: bool,
}
/// Listet die Zahlungsmethoden.
#[utoipa::path(
get,
path = "/payment-methods",
tag = "payment-methods",
params(
("includeInactive" = Option<bool>, Query,
description = "Wenn true, werden inaktive Methoden mitgeliefert (default: false)")
),
responses(
(status = 200, description = "Zahlungsmethoden", body = PaymentMethodsList),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn list_payment_methods(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Query(query): Query<ListPaymentMethodsQuery>,
) -> Result<Json<PaymentMethodsList>, ApiError> {
let methods = state
.list_payment_methods
.execute(query.include_inactive)
.await?;
Ok(Json(PaymentMethodsList { methods }))
}
/// Legt eine neue Zahlungsmethode an.
#[utoipa::path(
post,
path = "/payment-methods",
tag = "payment-methods",
request_body = CreatePaymentMethodRequest,
responses(
(status = 200, body = PaymentMethodResponse),
(status = 400, description = "Validierungsfehler (z. B. doppelter code)"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn create_payment_method(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Json(req): Json<CreatePaymentMethodRequest>,
) -> Result<Json<PaymentMethodResponse>, ApiError> {
let method = state.create_payment_method.execute(req).await?;
Ok(Json(PaymentMethodResponse { method }))
}
/// Patcht Anzeige-Name und/oder Aktiv-Flag.
#[utoipa::path(
patch,
path = "/payment-methods/{id}",
tag = "payment-methods",
params(("id" = Uuid, Path, description = "Zahlungsmethoden-Id")),
request_body = UpdatePaymentMethodRequest,
responses(
(status = 200, body = PaymentMethodResponse),
(status = 404, description = "Methode nicht gefunden"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn update_payment_method(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdatePaymentMethodRequest>,
) -> Result<Json<PaymentMethodResponse>, ApiError> {
let method = state.update_payment_method.execute(id, req).await?;
Ok(Json(PaymentMethodResponse { method }))
}
/// Hartes Löschen. `409 Conflict`, wenn die Methode von einer Lieferung
/// referenziert wird — der Admin soll dann den `active = false`-Pfad
/// nutzen.
#[utoipa::path(
delete,
path = "/payment-methods/{id}",
tag = "payment-methods",
params(("id" = Uuid, Path, description = "Zahlungsmethoden-Id")),
responses(
(status = 204, description = "Methode gelöscht"),
(status = 404, description = "Methode nicht gefunden"),
(status = 409, description = "Methode ist noch von Lieferungen referenziert"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn delete_payment_method(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
state.delete_payment_method.execute(id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@ -0,0 +1,133 @@
//! `/services` — admin-konfigurierbare Service-Stammdaten (früher
//! „Lieferoptionen").
//!
//! Lese-Endpoint nutzt die App (Phase 4). Schreib-Endpoints (POST/PATCH/
//! DELETE) sind Admin-Operationen — geschützt durch die globale JWT-
//! Middleware; Rollen-Trennung kommt später (Phase H).
use axum::Json;
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
use holzleitner_application::dto::{
CreateServiceRequest, ServiceResponse, ServicesList, UpdateServiceRequest,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::error::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/services", get(list_services).post(create_service))
.route(
"/services/{id}",
axum::routing::patch(update_service).delete(delete_service),
)
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ListServicesQuery {
#[serde(default)]
pub include_inactive: bool,
}
/// Listet die Services (sortiert nach `sortOrder`).
#[utoipa::path(
get,
path = "/services",
tag = "services",
params(
("includeInactive" = Option<bool>, Query,
description = "Wenn true, werden inaktive Services mitgeliefert (default: false)")
),
responses(
(status = 200, description = "Services", body = ServicesList),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn list_services(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Query(query): Query<ListServicesQuery>,
) -> Result<Json<ServicesList>, ApiError> {
let services = state.list_services.execute(query.include_inactive).await?;
Ok(Json(ServicesList { services }))
}
/// Legt einen neuen Service an.
#[utoipa::path(
post,
path = "/services",
tag = "services",
request_body = CreateServiceRequest,
responses(
(status = 200, body = ServiceResponse),
(status = 400, description = "Validierungsfehler (z. B. kind/min/max inkonsistent)"),
(status = 409, description = "key existiert bereits"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn create_service(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Json(req): Json<CreateServiceRequest>,
) -> Result<Json<ServiceResponse>, ApiError> {
let service = state.create_service.execute(req).await?;
Ok(Json(ServiceResponse { service }))
}
/// Patcht Name/Grenzen/Aktiv-Flag/Sortierung. `kind` ist nicht änderbar.
#[utoipa::path(
patch,
path = "/services/{id}",
tag = "services",
params(("id" = Uuid, Path, description = "Service-Id")),
request_body = UpdateServiceRequest,
responses(
(status = 200, body = ServiceResponse),
(status = 404, description = "Service nicht gefunden"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn update_service(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateServiceRequest>,
) -> Result<Json<ServiceResponse>, ApiError> {
let service = state.update_service.execute(id, req).await?;
Ok(Json(ServiceResponse { service }))
}
/// Hartes Löschen. `409 Conflict`, wenn der Service noch von einer Lieferung
/// referenziert wird — dann stattdessen deaktivieren.
#[utoipa::path(
delete,
path = "/services/{id}",
tag = "services",
params(("id" = Uuid, Path, description = "Service-Id")),
responses(
(status = 204, description = "Service gelöscht"),
(status = 404, description = "Service nicht gefunden"),
(status = 409, description = "Service ist noch referenziert"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn delete_service(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
state.delete_service.execute(id).await?;
Ok(StatusCode::NO_CONTENT)
}