Files
Holzleitner---Backend--aktu…/crates/api/src/main.rs
Dennis Nemec 954c5f52b2 feat(zahlung): Zahlungsabwicklung als eigenes Protokoll (POST /deliveries/{id}/payment)
- Neue Tabelle delivery_payments (append-only, idempotent ueber
  client_event_id): Methode + Code-Snapshot, server-seitig berechneter
  offener Betrag, Fahrer, Fahrzeug, Zeitpunkt
- Endpoint prueft unter Zeilen-Lock: Lieferung aktiv, Methode aktiv,
  offener Betrag > 0 und identisch mit dem vom Fahrer bestaetigten Betrag
- Offener Betrag als gemeinsamer Helper (open_amount_cents) fuer
  Zahlungsprotokoll und Abschluss
- Abschluss-Gate: gueltige protokollierte Zahlung erfuellt die
  Inkasso-Pflicht und liefert die Methode; delivery_completions.payment_id
  verknuepft den Abschluss mit der Zahlung. Altes payment_collected-Flag
  bleibt fuer aeltere App-Versionen gueltig
- Tour-Aggregat und Admin-Belegdetails liefern die juengste Zahlung
- Einzel-Reset loescht auch das Zahlungsprotokoll
- Integrationstest (ignored, braucht Wegwerf-DB) fuer Protokoll + Gate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:04:35 +02:00

761 lines
33 KiB
Rust

//! Holzleitner-API — HTTP-Layer und Composition Root.
//!
//! Läuft entweder als **Windows-Dienst** (vom SCM gestartet) oder im
//! **Konsolenmodus** (interaktiv / Linux / Mac). Die eigentliche App steckt
//! in [`run_app`], die ein `shutdown`-Future als Stop-Trigger bekommt
//! (Ctrl-C/SIGTERM im Konsolenmodus, SCM-Stop im Dienst).
//!
//! Bootstrap-Reihenfolge (in [`run_app`]):
//! 1. Konfiguration aus `config.toml` laden (liefert u. a. den Log-Filter)
//! 2. Tracing/Logging initialisieren (Konsole → stderr, Dienst → Datei)
//! 3. Postgres-Pool aufbauen und Migrations ausführen
//! 4. Keycloak-AuthService instanziieren
//! 5. Use Cases zusammenstellen und in `AppState` packen
//! 6. Public + Protected Router komponieren und Server starten
mod config;
mod error;
mod extractors;
mod middleware;
mod openapi;
mod routes;
#[cfg(windows)]
mod service;
mod state;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use axum::Router;
use axum::middleware::from_fn_with_state;
use holzleitner_application::usecases::{
ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase,
RecordDeliveryPaymentUseCase,
AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase,
CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase,
DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase,
DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase,
GenerateDeliveryReportUseCase, GetAccountUseCase,
GetAttachmentPreviewUseCase, GetDeliveryDetailsUseCase, GetPositionsModifiedUseCase,
GetTourUseCase,
ImportErpToursUseCase, ListCompletedDeliveriesUseCase, ListDeliveredBelegnummernUseCase,
ListMyCarsUseCase,
ListMyToursTodayUseCase, ListPaymentMethodsUseCase,
ListPendingReviewsUseCase, ListServicesUseCase, MarkMailSentUseCase,
PushCompletionToErpUseCase, ResolveReviewUseCase,
SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase,
ProcessDeliveryReportUseCase, UpdateMyCarUseCase, UpdatePaymentMethodUseCase,
UpdateServiceUseCase, UploadDeliveryNoteImageUseCase,
};
use holzleitner_application::ports::DeliveryReportJobRepository;
use holzleitner_application::ports::SignatureStorage;
use holzleitner_application::ports::{SyncRunRepository, SyncTrigger};
use holzleitner_infrastructure::auth::{
KeycloakAdapterConfig, KeycloakAdminClient, KeycloakAdminConfig, KeycloakAuthService,
};
use holzleitner_infrastructure::erp::{
MssqlErpConfig, MssqlErpDeliverySource, MssqlErpDeliveryWriteback,
};
use holzleitner_infrastructure::gsd::{GsdConfig, GsdService};
use holzleitner_infrastructure::report::{
LocalReportSink, PdfDeliveryReportRenderer, PgDeliveryReportRepository,
};
use holzleitner_infrastructure::persistence::{
PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository,
PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryPaymentRepository, PgDeliveryReportJobRepository,
PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgReviewRepository,
PgScanRepository,
PgServiceRepository, PgSyncRunRepository, PgTourRepository, PoolConfig, connect_and_migrate,
};
use holzleitner_infrastructure::storage::{LocalAttachmentStorage, LocalSignatureStorage};
use tokio_cron_scheduler::{Job, JobScheduler};
use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::middleware::{admin_api_key_middleware, jwt_middleware};
use crate::openapi::ApiDoc;
use crate::state::AppState;
/// Verzeichnis der laufenden EXE. Wichtig im Dienst-Modus: ein Windows-Dienst
/// startet mit Arbeitsverzeichnis `C:\Windows\System32`; damit `config.toml`,
/// `data/` und `logs/` neben der EXE liegen, setzt der Dienst sein CWD auf
/// dieses Verzeichnis (siehe `service::run`). Nur vom Windows-Dienst-Modul
/// genutzt — auf anderen Plattformen daher als dead-code erlaubt.
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn exe_dir() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_else(|| PathBuf::from("."))
}
fn main() -> anyhow::Result<()> {
// Unter Windows: standardmäßig versuchen, als Dienst zu starten. Wird die
// EXE interaktiv gestartet (Konsole/Doppelklick), scheitert der
// SCM-Dispatcher (nicht vom SCM gestartet) → Konsolenmodus. `--console`
// erzwingt den Konsolenmodus direkt.
#[cfg(windows)]
{
let console = std::env::args().any(|a| a == "--console" || a == "-c");
if !console {
match service::run() {
Ok(()) => return Ok(()), // lief als Dienst, sauber beendet
Err(_e) => { /* interaktiv gestartet → Konsole */ }
}
}
}
run_console()
}
/// Konsolenmodus: eigene tokio-Runtime, Stop via Ctrl-C/SIGTERM. Logs → stderr.
fn run_console() -> anyhow::Result<()> {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("tokio runtime")?;
rt.block_on(run_app(console_shutdown(), false))
}
/// Stop-Signal für den Konsolenmodus: Ctrl-C oder (auf Unix) SIGTERM.
async fn console_shutdown() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("Ctrl-C-Handler konnte nicht installiert werden");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM-Handler konnte nicht installiert werden")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
/// Die eigentliche App. `shutdown` ist der Stop-Trigger (Ctrl-C im Konsolen-,
/// SCM-Stop im Dienst-Modus); `service_mode` steuert das Log-Ziel
/// (Konsole → stderr, Dienst → rollende Tagesdatei in `[logging] dir`).
///
/// Gibt `Err` zurück, wenn der Bootstrap fehlschlägt — im Dienst-Modus fängt
/// der Aufrufer (`service::run_service`) das ab und schreibt ein Fallback-Log,
/// weil ein Boot-Fehler vor der Logger-Initialisierung sonst spurlos wäre.
pub(crate) async fn run_app(
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
service_mode: bool,
) -> anyhow::Result<()> {
// Config ZUERST laden — der Log-Filter steht in `config.toml`
// (`[logging] filter`), also brauchen wir die Config, bevor der
// Subscriber initialisiert wird.
let cfg = config::load().context("config laden fehlgeschlagen")?;
// `RUST_LOG`-Env hat Vorrang (Ad-hoc-Debugging ohne Datei-Edit), sonst der
// Filter aus `config.toml`.
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| cfg.logging.filter.clone().into());
// Hält den non-blocking-Writer am Leben (flush beim Drop). Nur im
// Dienst-Modus belegt; im Konsolenmodus `None` (stderr braucht keinen Guard).
let _log_guard: Option<tracing_appender::non_blocking::WorkerGuard>;
if service_mode {
// Dienst hat keine Konsole → in eine rollende Tagesdatei loggen, damit
// die Logs nicht verloren gehen. Verzeichnis relativ zum CWD (= EXE-Dir,
// vom Dienst gesetzt) bzw. absolut, falls so konfiguriert.
let appender = tracing_appender::rolling::daily(&cfg.logging.dir, "holzleitner-backend.log");
let (writer, guard) = tracing_appender::non_blocking(appender);
_log_guard = Some(guard);
tracing_subscriber::fmt()
.with_writer(writer)
.with_ansi(false) // keine Farbcodes in der Logdatei
.with_env_filter(env_filter)
.init();
} else {
// Konsole: stderr ist unbuffered und erscheint sofort — auch wenn die
// Ausgabe in eine Datei/Pipe/ein IDE-Terminal läuft.
_log_guard = None;
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(env_filter)
.init();
}
tracing::info!("starting up");
// Vollständige (secret-maskierte) Konfig-Übersicht beim Start — damit
// Fehlkonfigurationen (z. B. ein aktiver dev.today_override) sofort
// im Log sichtbar sind.
cfg.log_summary();
// --- Persistence ---------------------------------------------------
let pool = connect_and_migrate(&PoolConfig {
url: cfg.database.url.clone(),
max_connections: cfg.database.max_connections,
})
.await
.context("Postgres-Verbindung / Migrations fehlgeschlagen")?;
tracing::info!("postgres connected, migrations applied");
let account_repository = Arc::new(PgAccountRepository::new(pool.clone()));
let tour_repository = Arc::new(PgTourRepository::new(pool.clone()));
let scan_repository = Arc::new(PgScanRepository::new(pool.clone()));
let delivery_repository = Arc::new(PgDeliveryRepository::new(pool.clone()));
let delivery_note_repository = Arc::new(PgDeliveryNoteRepository::new(pool.clone()));
let attachment_repository = Arc::new(PgAttachmentRepository::new(pool.clone()));
let car_repository = Arc::new(PgCarRepository::new(pool.clone()));
let payment_method_repository = Arc::new(PgPaymentMethodRepository::new(pool.clone()));
let delivery_credit_repository = Arc::new(PgDeliveryCreditRepository::new(pool.clone()));
let delivery_payment_repository = Arc::new(PgDeliveryPaymentRepository::new(pool.clone()));
let delivery_completion_repository = Arc::new(PgDeliveryCompletionRepository::new(
pool.clone(),
cfg.server.timezone,
));
let service_repository = Arc::new(PgServiceRepository::new(pool.clone()));
let delivery_service_repository = Arc::new(PgDeliveryServiceRepository::new(pool.clone()));
let report_repository = Arc::new(PgDeliveryReportRepository::new(pool.clone()));
let report_job_repository = Arc::new(PgDeliveryReportJobRepository::new(pool.clone()));
let sync_run_repository = Arc::new(PgSyncRunRepository::new(pool.clone()));
// --- Lokaler Unterschriften-Speicher (Dateisystem) -----------------
let signature_storage = Arc::new(
LocalSignatureStorage::new(&cfg.signature.storage_dir)
.context("Signatur-Verzeichnis konnte nicht angelegt werden")?,
);
tracing::info!(dir = %cfg.signature.storage_dir, "signature storage ready");
// --- Lokaler Bild-Speicher (Bild-Notizen, Ordner = Belegnummer) ----
// Ersetzt den DOCUframe-Upload für Bild-Notizen. Der GSD-Service bleibt
// erhalten (s. u.), wird aber nicht mehr für Attachments verdrahtet.
let attachment_storage = Arc::new(
LocalAttachmentStorage::new(&cfg.attachment.storage_dir)
.context("Attachment-Verzeichnis konnte nicht angelegt werden")?,
);
tracing::info!(dir = %cfg.attachment.storage_dir, "attachment storage ready (lokal)");
// --- PDF-Lieferreport (lokaler/temporärer Sink; DOCUframe später) --
let report_sink = Arc::new(
LocalReportSink::new(&cfg.report.storage_dir)
.context("Report-Verzeichnis konnte nicht angelegt werden")?,
);
let generate_delivery_report = Arc::new(GenerateDeliveryReportUseCase::new(
report_repository,
Arc::new(PdfDeliveryReportRenderer::new(cfg.server.timezone)),
report_sink.clone(),
signature_storage.clone(),
attachment_storage.clone(),
));
tracing::info!(dir = %cfg.report.storage_dir, "delivery report renderer ready (printpdf, lokal)");
// --- GSD/DOCUframe (Datei-Upload) ----------------------------------
// Bleibt für den Lizenz-Release beim Shutdown konstruiert. Der frühere
// Attachment-Upload/-Download über DOCUframe ist hier bewusst nicht mehr
// verdrahtet (Code in `gsd::GsdService` bleibt für später erhalten).
let gsd_service = Arc::new(GsdService::new(
pool.clone(),
GsdConfig {
rest_url: cfg.gsd.rest_url.clone(),
app_key: cfg.gsd.app_key.clone(),
user: cfg.gsd.user.clone(),
password_md5: cfg.gsd.password_md5.clone(),
app_names: cfg.gsd.app_names.clone(),
},
));
// --- Report-Upload-Pipeline (Report → DOCUframe, mit Retry-Job) ----
// Nutzt GsdService als DocuframeReportGateway (Upload + Makro). Räumt
// nach Erfolg lokale Dateien (Report, Unterschriften, Bild-Notizen) auf.
let process_delivery_report = Arc::new(ProcessDeliveryReportUseCase::new(
generate_delivery_report.clone(),
report_job_repository.clone(),
gsd_service.clone(),
attachment_repository.clone(),
attachment_storage.clone(),
report_sink.clone(),
));
// --- Auth ----------------------------------------------------------
let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig {
issuer_url: cfg.keycloak.issuer_url.clone(),
audience: cfg.keycloak.audience.clone(),
jwks_cache_ttl: Duration::from_secs(cfg.keycloak.jwks_cache_ttl_seconds),
}));
tracing::info!(
issuer = %cfg.keycloak.issuer_url,
audience = %cfg.keycloak.audience,
"keycloak adapter ready"
);
// --- Use Cases -----------------------------------------------------
let get_account = Arc::new(GetAccountUseCase::new(account_repository));
let get_tour = Arc::new(GetTourUseCase::new(tour_repository.clone()));
// Admin: volles Detail-Paket zu einer Belegnummer (nutzt das Tour-Aggregat).
let get_delivery_details =
Arc::new(GetDeliveryDetailsUseCase::new(tour_repository.clone()));
let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(
tour_repository.clone(),
cfg.dev.today_override,
cfg.server.timezone,
));
let sync_tour = Arc::new(SyncTourUseCase::new(tour_repository.clone()));
// ERP-Import (täglicher Pull aus ERPframe-MSSQL) — nutzt denselben
// Sync-Pfad wie der HTTP-Endpoint. Geht NICHT in den AppState (kein
// HTTP-Endpoint), sondern nur in den Scheduler.
let erp_mssql_config = MssqlErpConfig {
host: cfg.erp.host.clone(),
port: cfg.erp.port,
database: cfg.erp.database.clone(),
user: cfg.erp.user.clone(),
password: cfg.erp.password.clone(),
trust_cert: cfg.erp.trust_cert,
};
let erp_source = Arc::new(MssqlErpDeliverySource::new(erp_mssql_config.clone()));
// Optionaler Keycloak-Provisioner: legt beim Sync fehlende Fahrer-Konten
// an (Username = Fahrer-/Account-Nummer, temporäres Passwort). Nur aktiv,
// wenn `KEYCLOAK_PROVISIONING_ENABLED=true`.
let driver_provisioner: Option<
Arc<dyn holzleitner_application::ports::DriverIdentityProvisioner>,
> = if cfg.keycloak.provisioning_enabled {
tracing::info!(
admin_url = %cfg.keycloak.admin_url,
realm = %cfg.keycloak.realm,
role = %cfg.keycloak.driver_role,
"keycloak driver provisioning ENABLED"
);
Some(Arc::new(KeycloakAdminClient::new(KeycloakAdminConfig {
base_url: cfg.keycloak.admin_url.clone(),
realm: cfg.keycloak.realm.clone(),
client_id: cfg.keycloak.provisioner_client_id.clone(),
client_secret: cfg.keycloak.provisioner_client_secret.clone(),
default_password: cfg.keycloak.driver_default_password.clone(),
driver_role: cfg.keycloak.driver_role.clone(),
})))
} else {
tracing::info!("keycloak driver provisioning disabled");
None
};
let import_erp_tours = Arc::new(ImportErpToursUseCase::new(
erp_source,
sync_tour.clone(),
driver_provisioner,
sync_run_repository.clone(),
));
// DEV-ONLY: überschreibender Resync (löscht Postgres-Tourdaten + Import).
// Wird immer gebaut, der Endpoint aber nur bei dev.sync_enabled gemountet.
let dev_resync_tours = Arc::new(DevResyncToursUseCase::new(
tour_repository.clone(),
import_erp_tours.clone(),
));
// DEV-ONLY: einzelne Lieferung zurücksetzen (per Belegnummer).
let dev_reset_delivery =
Arc::new(DevResetDeliveryUseCase::new(tour_repository.clone()));
// ERP-Rückschreiben beim Lieferabschluss. Der Push-Use-Case wird IMMER
// gebaut (Admin-Retry-Endpunkt nutzt ihn manuell). Ob der normale
// Abschluss-Pfad automatisch pusht, steuert `ERP_WRITEBACK_ENABLED`.
let erp_writeback = Arc::new(MssqlErpDeliveryWriteback::new(
erp_mssql_config,
cfg.erp.gutschrift_writeback_enabled,
));
let push_completion_to_erp = Arc::new(PushCompletionToErpUseCase::new(
delivery_completion_repository.clone(),
erp_writeback,
));
// Admin-Lese-Use-Case: Belegnummern der an einem Tag ausgelieferten
// Lieferungen. `.clone()` VOR dem späteren Move in `complete_delivery`.
let list_delivered_belegnummern = Arc::new(ListDeliveredBelegnummernUseCase::new(
delivery_completion_repository.clone(),
));
// Admin-Lese-Use-Case: alle an einem Tag abgeschlossenen Lieferungen samt
// Positions-Änderungs-Flag. `.clone()` VOR dem Move in `complete_delivery`.
let list_completed_deliveries = Arc::new(ListCompletedDeliveriesUseCase::new(
delivery_completion_repository.clone(),
));
// Admin-Lese-Use-Case: Positions-Änderungs-Flag zu einer Belegnummer.
// `.clone()` VOR dem Move in `complete_delivery`.
let get_positions_modified = Arc::new(GetPositionsModifiedUseCase::new(
delivery_completion_repository.clone(),
));
let mark_mail_sent = Arc::new(MarkMailSentUseCase::new(
delivery_completion_repository.clone(),
));
// Vier-Augen-Prüfung geänderter Lieferscheine.
let review_repository = Arc::new(PgReviewRepository::new(pool.clone()));
let list_pending_reviews =
Arc::new(ListPendingReviewsUseCase::new(review_repository.clone()));
let resolve_review = Arc::new(ResolveReviewUseCase::new(review_repository));
let set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository));
let apply_scans = Arc::new(ApplyScansUseCase::new(
scan_repository,
car_repository.clone(),
));
let apply_delivery_action =
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
let complete_delivery = Arc::new(CompleteDeliveryUseCase::new(
delivery_completion_repository,
signature_storage.clone(),
car_repository.clone(),
if cfg.erp.writeback_enabled {
Some(push_completion_to_erp.clone())
} else {
None
},
));
let apply_delivery_credit_event = Arc::new(ApplyDeliveryCreditEventUseCase::new(
delivery_credit_repository,
car_repository.clone(),
));
let record_delivery_payment = Arc::new(RecordDeliveryPaymentUseCase::new(
delivery_payment_repository,
car_repository.clone(),
));
let create_delivery_note = Arc::new(CreateDeliveryNoteUseCase::new(
delivery_note_repository.clone(),
car_repository.clone(),
));
let update_delivery_note = Arc::new(UpdateDeliveryNoteUseCase::new(
delivery_note_repository.clone(),
));
let delete_delivery_note =
Arc::new(DeleteDeliveryNoteUseCase::new(delivery_note_repository.clone()));
let upload_delivery_note_image = Arc::new(UploadDeliveryNoteImageUseCase::new(
attachment_storage.clone(),
attachment_repository.clone(),
delivery_note_repository,
car_repository.clone(),
));
let get_attachment_preview = Arc::new(GetAttachmentPreviewUseCase::new(
attachment_repository,
attachment_storage.clone(),
));
let list_my_cars = Arc::new(ListMyCarsUseCase::new(car_repository.clone()));
let create_my_car = Arc::new(CreateMyCarUseCase::new(car_repository.clone()));
let update_my_car = Arc::new(UpdateMyCarUseCase::new(car_repository.clone()));
let assign_car_to_delivery = Arc::new(AssignCarToDeliveryUseCase::new(
car_repository,
delivery_repository,
));
let list_payment_methods = Arc::new(ListPaymentMethodsUseCase::new(
payment_method_repository.clone(),
));
let create_payment_method = Arc::new(CreatePaymentMethodUseCase::new(
payment_method_repository.clone(),
));
let update_payment_method = Arc::new(UpdatePaymentMethodUseCase::new(
payment_method_repository.clone(),
));
let delete_payment_method =
Arc::new(DeletePaymentMethodUseCase::new(payment_method_repository));
let list_services = Arc::new(ListServicesUseCase::new(service_repository.clone()));
let create_service = Arc::new(CreateServiceUseCase::new(service_repository.clone()));
let update_service = Arc::new(UpdateServiceUseCase::new(service_repository.clone()));
let delete_service = Arc::new(DeleteServiceUseCase::new(service_repository.clone()));
let set_delivery_service = Arc::new(SetDeliveryServiceUseCase::new(
service_repository,
delivery_service_repository.clone(),
));
let delete_delivery_service = Arc::new(DeleteDeliveryServiceUseCase::new(
delivery_service_repository,
));
let state = AppState {
get_account,
get_tour,
list_my_tours_today,
sync_tour,
set_delivery_order,
import_erp_tours: import_erp_tours.clone(),
timezone: cfg.server.timezone,
dev_resync_tours,
dev_reset_delivery,
generate_delivery_report,
process_delivery_report: process_delivery_report.clone(),
report_upload_enabled: cfg.report.upload_enabled,
apply_scans,
apply_delivery_action,
complete_delivery,
push_completion_to_erp,
list_delivered_belegnummern,
list_completed_deliveries,
get_positions_modified,
get_delivery_details,
mark_mail_sent,
list_pending_reviews,
resolve_review,
apply_delivery_credit_event,
record_delivery_payment,
create_delivery_note,
update_delivery_note,
delete_delivery_note,
upload_delivery_note_image,
get_attachment_preview,
list_my_cars,
create_my_car,
update_my_car,
assign_car_to_delivery,
list_payment_methods,
create_payment_method,
update_payment_method,
delete_payment_method,
list_services,
create_service,
update_service,
delete_service,
set_delivery_service,
delete_delivery_service,
auth_service,
admin_api_key: cfg.admin.api_key.clone().into(),
};
// --- Router --------------------------------------------------------
//
// Public-Routen (Health, OpenAPI-Spec, Swagger-UI) und Protected-
// Routen werden getrennt gebaut; die JWT-Middleware liegt **nur**
// auf dem protected-Subtree. `route_layer` greift nur für die jetzt
// definierten Routen — neue Routen darunter müssten explizit
// nochmal angehängt werden.
let mut public = Router::new()
.merge(routes::health::router())
.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi()));
// DEV-ONLY: ungeschützten Resync-Endpoint NUR bei explizit gesetztem Flag
// mounten — in Produktion existiert er nicht.
if cfg.dev.sync_enabled {
public = public.merge(routes::dev::router());
tracing::warn!("DEV: POST /dev/resync gemountet (unauthentifiziert, dev.sync_enabled=true)");
}
let protected = Router::new()
.merge(routes::accounts::router())
.merge(routes::tours::router())
.merge(routes::scans::router())
.merge(routes::deliveries::router())
.merge(routes::attachments::router())
.merge(routes::cars::router())
.merge(routes::payment_methods::router())
.merge(routes::services::router())
.route_layer(from_fn_with_state(state.clone(), jwt_middleware));
// `/admin`-Routen sind Maschinen-Endpunkte (Cron/ERPframe/Ops) und laufen
// bewusst NICHT über die JWT-Middleware, sondern über ein eigenes
// statisches-API-Key-Gate (Header `X-Admin-Api-Key`). Eigener Subtree mit
// eigener `route_layer`.
let admin = routes::admin::router()
.route_layer(from_fn_with_state(state.clone(), admin_api_key_middleware));
let app = Router::new()
.merge(public)
.merge(protected)
.merge(admin)
.layer(TraceLayer::new_for_http())
.with_state(state);
// --- ERP-Import-Scheduler (optional) -------------------------------
// Läuft im selben Prozess. Nur wenn IMPORT_ENABLED=true. Hält den
// JobScheduler am Leben, indem die Variable bis Programmende gebunden
// bleibt (Drop würde die Jobs stoppen).
let _erp_scheduler = if cfg.import.enabled {
let scheduler = JobScheduler::new()
.await
.context("JobScheduler konnte nicht erstellt werden")?;
let import = import_erp_tours.clone();
let offset = cfg.import.date_offset_days;
let tz = cfg.server.timezone;
// `new_async_tz`: Cron-Ausdruck in der konfigurierten Zone auswerten.
// `new_async` würde ihn in UTC interpretieren — "0 0 17 * * *" feuerte
// dann erst um 19:00 deutscher Zeit.
let job = Job::new_async_tz(cfg.import.cron.as_str(), tz, move |_uuid, _lock| {
let import = import.clone();
Box::pin(async move {
// Kalendertag in der konfigurierten Zone (nicht OS-Local).
let date = (chrono::Utc::now().with_timezone(&tz)
+ chrono::Duration::days(offset))
.date_naive();
match import.execute_with(date, SyncTrigger::Scheduler).await {
Ok(summary) => tracing::info!(
date = %summary.date,
total = summary.tours_total,
ok = summary.tours_ok,
failed = summary.tours_failed,
"erp_import.done"
),
Err(e) => tracing::error!(%date, error = %e, "erp_import.failed"),
}
})
})
.context("ERP-Import-Job konnte nicht erstellt werden")?;
scheduler.add(job).await.context("ERP-Import-Job add fehlgeschlagen")?;
scheduler.start().await.context("Scheduler-Start fehlgeschlagen")?;
tracing::info!(
cron = %cfg.import.cron,
offset_days = cfg.import.date_offset_days,
"erp_import scheduler gestartet"
);
// Startup-Catch-up: beim Serverstart nachsynchronisieren, falls für das
// Zieldatum (heute + offset, i. d. R. morgen) noch KEIN erfolgreicher
// Lauf dokumentiert ist. Deckt Erststart UND längere Unterbrechungen ab,
// bei denen der Cron-Zeitpunkt (03:00) verpasst wurde. Läuft im
// Hintergrund, damit der Serverstart nicht am (evtl. langsamen oder
// abwesenden) ERP hängt.
{
let import = import_erp_tours.clone();
let sync_runs = sync_run_repository.clone();
let offset = cfg.import.date_offset_days;
let tz = cfg.server.timezone;
tokio::spawn(async move {
let date = (chrono::Utc::now().with_timezone(&tz)
+ chrono::Duration::days(offset))
.date_naive();
match sync_runs.has_successful_run_for(date).await {
Ok(true) => tracing::info!(
%date,
"erp_import.catchup: bereits erfolgreich gesynct — übersprungen"
),
Ok(false) => {
tracing::info!(%date, "erp_import.catchup: kein Sync vorhanden — synchronisiere nach");
match import.execute_with(date, SyncTrigger::Startup).await {
Ok(s) => tracing::info!(
date = %s.date,
total = s.tours_total,
ok = s.tours_ok,
failed = s.tours_failed,
"erp_import.catchup.done"
),
Err(e) => {
tracing::error!(%date, error = %e, "erp_import.catchup.failed")
}
}
}
Err(e) => tracing::error!(
%date,
error = %e,
"erp_import.catchup: Statusabfrage fehlgeschlagen"
),
}
});
}
Some(scheduler)
} else {
tracing::info!("erp_import deaktiviert (IMPORT_ENABLED!=true)");
None
};
// --- Report-Retry-Scheduler (offene DOCUframe-Übertragungen) -------
// Greift alle offenen `delivery_report_jobs` periodisch erneut auf
// (Upload/Makro, die zuvor fehlschlugen — z. B. DOCUframe nicht erreichbar).
let _report_scheduler = if cfg.report.upload_enabled {
let scheduler = JobScheduler::new()
.await
.context("Report-JobScheduler konnte nicht erstellt werden")?;
let process = process_delivery_report.clone();
let jobs = report_job_repository.clone();
let job = Job::new_async_tz(
cfg.report.retry_cron.as_str(),
cfg.server.timezone,
move |_uuid, _lock| {
let process = process.clone();
let jobs = jobs.clone();
Box::pin(async move {
match jobs.list_open().await {
Ok(open) => {
if !open.is_empty() {
tracing::info!(count = open.len(), "report_retry: offene Jobs werden erneut versucht");
}
for j in open {
if let Err(e) = process.execute(j.delivery_id).await {
tracing::warn!(delivery_id = %j.delivery_id, error = %e, "report_retry: Job weiterhin offen");
}
}
}
Err(e) => tracing::error!(error = %e, "report_retry: offene Jobs konnten nicht geladen werden"),
}
})
})
.context("Report-Retry-Job konnte nicht erstellt werden")?;
scheduler.add(job).await.context("Report-Retry-Job add fehlgeschlagen")?;
scheduler.start().await.context("Report-Scheduler-Start fehlgeschlagen")?;
tracing::info!(cron = %cfg.report.retry_cron, "report_retry scheduler gestartet");
Some(scheduler)
} else {
tracing::info!("report_upload deaktiviert (REPORT_UPLOAD_ENABLED!=true) — Reports nur lokal");
None
};
// --- Signatur-Cleanup-Scheduler ------------------------------------
// Unterschriften bleiben nach dem Report-Upload bewusst erhalten (wir
// brauchen die Dateien) und werden hier periodisch gelöscht, sobald sie
// älter als die Aufbewahrungsfrist sind. retention_days = 0 ⇒ deaktiviert.
let _signature_cleanup_scheduler = if cfg.signature.retention_days > 0 {
let scheduler = JobScheduler::new()
.await
.context("Signatur-Cleanup-JobScheduler konnte nicht erstellt werden")?;
let signatures = signature_storage.clone();
let max_age = Duration::from_secs(cfg.signature.retention_days as u64 * 86_400);
let job = Job::new_async_tz(
cfg.signature.cleanup_cron.as_str(),
cfg.server.timezone,
move |_uuid, _lock| {
let signatures = signatures.clone();
Box::pin(async move {
match signatures.delete_older_than(max_age).await {
Ok(0) => {}
Ok(n) => tracing::info!(
deleted = n,
"signature_cleanup: abgelaufene Unterschriften gelöscht"
),
Err(e) => tracing::error!(error = %e, "signature_cleanup fehlgeschlagen"),
}
})
})
.context("Signatur-Cleanup-Job konnte nicht erstellt werden")?;
scheduler
.add(job)
.await
.context("Signatur-Cleanup-Job add fehlgeschlagen")?;
scheduler
.start()
.await
.context("Signatur-Cleanup-Scheduler-Start fehlgeschlagen")?;
tracing::info!(
cron = %cfg.signature.cleanup_cron,
retention_days = cfg.signature.retention_days,
"signature_cleanup scheduler gestartet"
);
Some(scheduler)
} else {
tracing::info!(
"signature_cleanup deaktiviert (signature.retention_days = 0) — Unterschriften werden nie automatisch gelöscht"
);
None
};
let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port)
.parse()
.with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!("server läuft auf http://{}", addr);
// Graceful Shutdown: auf den `shutdown`-Trigger warten (Ctrl-C/SIGTERM im
// Konsolen-, SCM-Stop im Dienst-Modus), dann die GSD-Lizenz aktiv
// freigeben, damit der Seat nicht bis zum Session-Ablauf geblockt bleibt.
let graceful = async move {
shutdown.await;
tracing::info!("shutdown signal empfangen — gebe GSD-Lizenz frei");
gsd_service.release_license().await;
};
axum::serve(listener, app)
.with_graceful_shutdown(graceful)
.await?;
Ok(())
}