//! 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 + 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; 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, > = 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(()) }