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 }))
|
||||
}
|
||||
88
crates/api/src/routes/attachments.rs
Normal file
88
crates/api/src/routes/attachments.rs
Normal 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 0–100 (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())
|
||||
}
|
||||
@ -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.
|
||||
|
||||
160
crates/api/src/routes/dev.rs
Normal file
160
crates/api/src/routes/dev.rs
Normal 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 }))
|
||||
}
|
||||
@ -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;
|
||||
|
||||
142
crates/api/src/routes/payment_methods.rs
Normal file
142
crates/api/src/routes/payment_methods.rs
Normal 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)
|
||||
}
|
||||
133
crates/api/src/routes/services.rs
Normal file
133
crates/api/src/routes/services.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user