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:
@ -27,6 +27,7 @@ pub mod erp_delivery_writeback;
|
||||
pub mod scan_repository;
|
||||
pub mod service_repository;
|
||||
pub mod signature_storage;
|
||||
pub mod sync_run_repository;
|
||||
pub mod tour_repository;
|
||||
|
||||
pub use account_repository::AccountRepository;
|
||||
@ -59,4 +60,5 @@ pub use payment_method_repository::PaymentMethodRepository;
|
||||
pub use scan_repository::{ApplyScanOutcome, ScanRepository};
|
||||
pub use service_repository::ServiceRepository;
|
||||
pub use signature_storage::{SignatureRole, SignatureStorage};
|
||||
pub use sync_run_repository::{SyncRunRecord, SyncRunRepository, SyncTrigger};
|
||||
pub use tour_repository::TourRepository;
|
||||
|
||||
68
crates/application/src/ports/sync_run_repository.rs
Normal file
68
crates/application/src/ports/sync_run_repository.rs
Normal file
@ -0,0 +1,68 @@
|
||||
//! Port: Dokumentation der ERP-Import-Läufe (Sync mit ERPframe).
|
||||
//!
|
||||
//! Spiegelt die Tabelle `erp_sync_runs`. Jeder Import — vom täglichen
|
||||
//! Scheduler, vom Startup-Catch-up oder manuell (Admin-Endpunkt) — wird hier
|
||||
//! protokolliert. Zusätzlich Grundlage für den Startup-Catch-up: prüft, ob für
|
||||
//! ein Zieldatum bereits ein erfolgreicher Lauf existiert.
|
||||
//!
|
||||
//! Konkrete Impl (Postgres via sqlx) lebt in `holzleitner-infrastructure`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
/// Auslöser eines Import-Laufs — fürs Audit in der DB hinterlegt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncTrigger {
|
||||
/// Manuell über den Admin-Endpunkt (oder Dev-Resync).
|
||||
Manual,
|
||||
/// Täglicher Cron-Scheduler.
|
||||
Scheduler,
|
||||
/// Catch-up beim Serverstart (Erststart / nach längerer Unterbrechung).
|
||||
Startup,
|
||||
}
|
||||
|
||||
impl SyncTrigger {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
SyncTrigger::Manual => "manual",
|
||||
SyncTrigger::Scheduler => "scheduler",
|
||||
SyncTrigger::Startup => "startup",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein abgeschlossener Import-Lauf, wie er dokumentiert wird (eine Zeile in
|
||||
/// `erp_sync_runs`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncRunRecord {
|
||||
/// Tourdatum, für das gesynct wurde.
|
||||
pub target_date: NaiveDate,
|
||||
pub trigger: SyncTrigger,
|
||||
/// `true` = der Lauf lief durch (ERP gelesen + verarbeitet). Ein einzelner
|
||||
/// fehlerhafter Beleg (`tours_failed > 0`) zählt weiterhin als erfolgreicher
|
||||
/// Lauf. `false` = der Lauf scheiterte komplett (z. B. ERP nicht
|
||||
/// erreichbar), dann ist `error` gesetzt.
|
||||
pub success: bool,
|
||||
pub tours_total: i32,
|
||||
pub tours_ok: i32,
|
||||
pub tours_failed: i32,
|
||||
pub drivers_provisioned: i32,
|
||||
/// Fehlertext bei `success = false`.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SyncRunRepository: Send + Sync {
|
||||
/// Schreibt die Dokumentation eines (abgeschlossenen oder gescheiterten)
|
||||
/// Laufs.
|
||||
async fn record(&self, run: SyncRunRecord) -> Result<(), ApplicationError>;
|
||||
|
||||
/// `true`, wenn für `date` bereits ein **erfolgreicher** Lauf dokumentiert
|
||||
/// ist. Basis für den Startup-Catch-up ("kein Sync für morgen ⇒ syncen").
|
||||
async fn has_successful_run_for(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
) -> Result<bool, ApplicationError>;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user