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:
@ -1,8 +1,8 @@
|
||||
//! Holzleitner-API — HTTP-Layer und Composition Root.
|
||||
//!
|
||||
//! Bootstrap-Reihenfolge:
|
||||
//! 1. Tracing/Logging initialisieren
|
||||
//! 2. Konfiguration aus Env-Variablen / `.env` laden
|
||||
//! 1. Konfiguration aus `config.toml` laden (liefert u. a. den Log-Filter)
|
||||
//! 2. Tracing/Logging initialisieren
|
||||
//! 3. Postgres-Pool aufbauen und Migrations ausführen
|
||||
//! 4. Keycloak-AuthService instanziieren
|
||||
//! 5. Use Cases zusammenstellen und in `AppState` packen
|
||||
@ -24,36 +24,73 @@ use anyhow::Context;
|
||||
use axum::Router;
|
||||
use axum::middleware::from_fn_with_state;
|
||||
use holzleitner_application::usecases::{
|
||||
ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase,
|
||||
CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase,
|
||||
ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase,
|
||||
UpdateMyCarUseCase,
|
||||
ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase,
|
||||
AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase,
|
||||
CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase,
|
||||
DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase,
|
||||
DeleteServiceUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase,
|
||||
GetAttachmentPreviewUseCase, GetTourUseCase,
|
||||
ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase,
|
||||
ListMyToursTodayUseCase, ListPaymentMethodsUseCase,
|
||||
ListServicesUseCase, MarkMailSentUseCase, PushCompletionToErpUseCase,
|
||||
SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase,
|
||||
ProcessDeliveryReportUseCase, UpdateMyCarUseCase, UpdatePaymentMethodUseCase,
|
||||
UpdateServiceUseCase, UploadDeliveryNoteImageUseCase,
|
||||
};
|
||||
use holzleitner_application::ports::DeliveryReportJobRepository;
|
||||
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::auth::{KeycloakAdapterConfig, KeycloakAuthService};
|
||||
use holzleitner_infrastructure::persistence::{
|
||||
PgAccountRepository, PgCarRepository, PgDeliveryNoteRepository, PgDeliveryRepository,
|
||||
PgScanRepository, PgTourRepository, PoolConfig, connect_and_migrate,
|
||||
PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository,
|
||||
PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryReportJobRepository,
|
||||
PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgScanRepository,
|
||||
PgServiceRepository, 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::jwt_middleware;
|
||||
use crate::middleware::{admin_api_key_middleware, jwt_middleware};
|
||||
use crate::openapi::ApiDoc;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Config ZUERST laden — der Log-Filter steht jetzt in `config.toml`
|
||||
// (`[logging] filter`), also brauchen wir die Config, bevor der
|
||||
// Subscriber initialisiert wird. Fehler hier werden von anyhow auch
|
||||
// ohne aktiven Subscriber sichtbar (stderr).
|
||||
let cfg = config::load().context("config laden fehlgeschlagen")?;
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
// Auf stderr loggen statt stdout: stderr ist unbuffered und erscheint
|
||||
// damit sofort — auch wenn die Ausgabe in eine Datei/Pipe/ein
|
||||
// IDE-Terminal läuft (stdout wäre dort blockgepuffert → Logs würden
|
||||
// erst verspätet/gar nicht sichtbar).
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
"holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info".into()
|
||||
}),
|
||||
// `RUST_LOG`-Env hat Vorrang (Ad-hoc-Debugging ohne Datei-Edit),
|
||||
// sonst der Filter aus `config.toml` (`[logging] filter`).
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| cfg.logging.filter.clone().into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cfg = config::load().context("config laden fehlgeschlagen")?;
|
||||
tracing::info!(host = %cfg.server.host, port = cfg.server.port, "starting up");
|
||||
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 {
|
||||
@ -69,7 +106,74 @@ async fn main() -> anyhow::Result<()> {
|
||||
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 car_repository = Arc::new(PgCarRepository::new(pool));
|
||||
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_completion_repository =
|
||||
Arc::new(PgDeliveryCompletionRepository::new(pool.clone()));
|
||||
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()));
|
||||
|
||||
// --- 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),
|
||||
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,
|
||||
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(),
|
||||
signature_storage.clone(),
|
||||
report_sink.clone(),
|
||||
));
|
||||
|
||||
// --- Auth ----------------------------------------------------------
|
||||
let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig {
|
||||
@ -86,8 +190,74 @@ async fn main() -> anyhow::Result<()> {
|
||||
// --- Use Cases -----------------------------------------------------
|
||||
let get_account = Arc::new(GetAccountUseCase::new(account_repository));
|
||||
let get_tour = Arc::new(GetTourUseCase::new(tour_repository.clone()));
|
||||
let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(tour_repository.clone()));
|
||||
let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(
|
||||
tour_repository.clone(),
|
||||
cfg.dev.today_override,
|
||||
));
|
||||
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,
|
||||
));
|
||||
// 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(),
|
||||
));
|
||||
// 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));
|
||||
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(),
|
||||
));
|
||||
let mark_mail_sent = Arc::new(MarkMailSentUseCase::new(
|
||||
delivery_completion_repository.clone(),
|
||||
));
|
||||
let set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository));
|
||||
let apply_scans = Arc::new(ApplyScansUseCase::new(
|
||||
scan_repository,
|
||||
@ -95,10 +265,39 @@ async fn main() -> anyhow::Result<()> {
|
||||
));
|
||||
let apply_delivery_action =
|
||||
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
|
||||
let complete_delivery = Arc::new(CompleteDeliveryUseCase::new(
|
||||
delivery_completion_repository,
|
||||
signature_storage,
|
||||
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 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()));
|
||||
@ -106,6 +305,28 @@ async fn main() -> anyhow::Result<()> {
|
||||
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,
|
||||
@ -113,14 +334,39 @@ async fn main() -> anyhow::Result<()> {
|
||||
list_my_tours_today,
|
||||
sync_tour,
|
||||
set_delivery_order,
|
||||
import_erp_tours: import_erp_tours.clone(),
|
||||
dev_resync_tours,
|
||||
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,
|
||||
mark_mail_sent,
|
||||
apply_delivery_credit_event,
|
||||
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 --------------------------------------------------------
|
||||
@ -130,29 +376,156 @@ async fn main() -> anyhow::Result<()> {
|
||||
// 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 public = Router::new()
|
||||
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 job = Job::new_async(cfg.import.cron.as_str(), move |_uuid, _lock| {
|
||||
let import = import.clone();
|
||||
Box::pin(async move {
|
||||
let date = (chrono::Utc::now() + chrono::Duration::days(offset)).date_naive();
|
||||
match import.execute(date).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"
|
||||
);
|
||||
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(cfg.report.retry_cron.as_str(), 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
|
||||
};
|
||||
|
||||
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);
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
// Graceful Shutdown: bei Ctrl-C / SIGTERM die GSD-Lizenz aktiv freigeben,
|
||||
// damit der Seat nicht bis zum Session-Ablauf geblockt bleibt.
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal(gsd_service))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wartet auf Ctrl-C bzw. SIGTERM und gibt davor die GSD-Lizenz frei.
|
||||
async fn shutdown_signal(gsd_service: Arc<GsdService>) {
|
||||
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 => {},
|
||||
}
|
||||
|
||||
tracing::info!("shutdown signal empfangen — gebe GSD-Lizenz frei");
|
||||
gsd_service.release_license().await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user