- 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>
761 lines
33 KiB
Rust
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(())
|
|
}
|