feat(import): ERP-Sync in DB dokumentieren + Startup-Catch-up

Jeder ERPframe-Import (Scheduler, Startup, manuell) wird in der neuen Tabelle
erp_sync_runs protokolliert (target_date, trigger, success, Zaehler, Fehler).
Beim Serverstart synct das Backend das Zieldatum (heute + offset, i.d.R. morgen)
nach, falls dafuer noch kein erfolgreicher Lauf dokumentiert ist — deckt
Erststart UND laengere Unterbrechungen ab, bei denen der Cron-Zeitpunkt verpasst
wurde. Gated ueber [import] enabled.

- Migration 0030_erp_sync_runs
- Port SyncRunRepository (+ SyncRunRecord, SyncTrigger)
- Adapter PgSyncRunRepository
- ImportErpToursUseCase dokumentiert jeden Lauf; neues execute_with(date, trigger)
- main.rs: Repo verdrahtet, Scheduler-Trigger, Startup-Catch-up (tokio::spawn)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-08 16:23:12 +02:00
parent c65e13485d
commit 2f4368ec52
7 changed files with 272 additions and 3 deletions

View File

@ -46,6 +46,7 @@ use holzleitner_application::usecases::{
UpdateServiceUseCase, UploadDeliveryNoteImageUseCase,
};
use holzleitner_application::ports::DeliveryReportJobRepository;
use holzleitner_application::ports::{SyncRunRepository, SyncTrigger};
use holzleitner_infrastructure::auth::{
KeycloakAdapterConfig, KeycloakAdminClient, KeycloakAdminConfig, KeycloakAuthService,
};
@ -60,7 +61,7 @@ use holzleitner_infrastructure::persistence::{
PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository,
PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryReportJobRepository,
PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgScanRepository,
PgServiceRepository, PgTourRepository, PoolConfig, connect_and_migrate,
PgServiceRepository, PgSyncRunRepository, PgTourRepository, PoolConfig, connect_and_migrate,
};
use holzleitner_infrastructure::storage::{LocalAttachmentStorage, LocalSignatureStorage};
use tokio_cron_scheduler::{Job, JobScheduler};
@ -214,6 +215,7 @@ pub(crate) async fn run_app(
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(
@ -333,6 +335,7 @@ pub(crate) async fn run_app(
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.
@ -522,7 +525,7 @@ pub(crate) async fn run_app(
let import = import.clone();
Box::pin(async move {
let date = (chrono::Utc::now() + chrono::Duration::days(offset)).date_naive();
match import.execute(date).await {
match import.execute_with(date, SyncTrigger::Scheduler).await {
Ok(summary) => tracing::info!(
date = %summary.date,
total = summary.tours_total,
@ -542,6 +545,48 @@ pub(crate) async fn run_app(
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;
tokio::spawn(async move {
let date = (chrono::Utc::now() + 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)");