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

@ -4,7 +4,9 @@ use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::error::ApplicationError;
use crate::ports::{DriverIdentityProvisioner, ErpDeliverySource};
use crate::ports::{
DriverIdentityProvisioner, ErpDeliverySource, SyncRunRecord, SyncRunRepository, SyncTrigger,
};
use crate::usecases::SyncTourUseCase;
/// Ergebnis eines Import-Laufs — pro Fahrer-Tour Erfolg/Fehler getrennt,
@ -45,6 +47,9 @@ pub struct ImportErpToursUseCase {
/// Optionaler Identity-Provisioner (Keycloak). `None` ⇒ Konto-Anlage
/// deaktiviert (`KEYCLOAK_PROVISIONING_ENABLED=false`).
provisioner: Option<Arc<dyn DriverIdentityProvisioner>>,
/// Dokumentiert jeden Lauf in `erp_sync_runs` (Audit + Basis für den
/// Startup-Catch-up „kein Sync für morgen ⇒ syncen").
sync_runs: Arc<dyn SyncRunRepository>,
}
impl ImportErpToursUseCase {
@ -52,15 +57,62 @@ impl ImportErpToursUseCase {
source: Arc<dyn ErpDeliverySource>,
sync_tour: Arc<SyncTourUseCase>,
provisioner: Option<Arc<dyn DriverIdentityProvisioner>>,
sync_runs: Arc<dyn SyncRunRepository>,
) -> Self {
Self {
source,
sync_tour,
provisioner,
sync_runs,
}
}
/// Import mit manuellem Auslöser (Admin-Endpunkt / Dev-Resync).
pub async fn execute(&self, date: NaiveDate) -> Result<ImportSummary, ApplicationError> {
self.execute_with(date, SyncTrigger::Manual).await
}
/// Import mit explizitem Auslöser. Dokumentiert den Lauf in JEDEM Fall
/// (Erfolg ODER kompletter Fehlschlag) in `erp_sync_runs`. Best-effort: ein
/// Schreibfehler bei der Dokumentation verändert den Import-Ausgang nicht.
pub async fn execute_with(
&self,
date: NaiveDate,
trigger: SyncTrigger,
) -> Result<ImportSummary, ApplicationError> {
let result = self.run(date).await;
let record = match &result {
Ok(s) => SyncRunRecord {
target_date: date,
trigger,
success: true,
tours_total: s.tours_total as i32,
tours_ok: s.tours_ok as i32,
tours_failed: s.tours_failed as i32,
drivers_provisioned: s.drivers_provisioned as i32,
error: None,
},
Err(e) => SyncRunRecord {
target_date: date,
trigger,
success: false,
tours_total: 0,
tours_ok: 0,
tours_failed: 0,
drivers_provisioned: 0,
error: Some(e.to_string()),
},
};
// `application` loggt bewusst nicht selbst → Doku-Schreibfehler still
// verschlucken (das eigentliche Import-Ergebnis hat Vorrang).
let _ = self.sync_runs.record(record).await;
result
}
/// Eigentlicher Import-Lauf (ohne Dokumentation).
async fn run(&self, date: NaiveDate) -> Result<ImportSummary, ApplicationError> {
let tours = self.source.fetch_tours_for_date(date).await?;
let tours_total = tours.len();
let mut tours_ok = 0usize;