From 819005eaa5f7c31c9a2275ff2c965f85b30b5cdb Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Wed, 24 Jun 2026 13:35:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(dev):=20/dev/reset-delivery=20=E2=80=94=20?= =?UTF-8?q?einzelne=20Lieferung=20per=20Belegnummer=20zur=C3=BCcksetzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setzt EINE Lieferung zurück, ohne die Tour zu löschen (Alternative zum destruktiven /dev/resync): Abschluss (delivery_completions), Scan-/ Gutschrift-Audit + offener Report-Job weg, Positionen zurück (scanned/ credited = 0, Status in_progress), Lieferung wieder active (state_reason/ assigned_car/review_* geleert). Notizen/Dienstleistungen/Anhänge bleiben. Vervollständigt den bisher nur als Port-Stub vorhandenen reset_delivery_by_belegnummer (Build war dadurch kaputt) + Use Case + DEV-Route (nur bei dev.sync_enabled gemountet, unauthentifiziert). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/api/src/main.rs | 7 +- crates/api/src/routes/dev.rs | 38 ++++++++++ crates/api/src/state.rs | 5 +- .../application/src/ports/tour_repository.rs | 15 ++++ .../src/usecases/dev_reset_delivery.rs | 32 ++++++++ crates/application/src/usecases/mod.rs | 2 + .../src/persistence/tour_repository.rs | 76 +++++++++++++++++++ 7 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 crates/application/src/usecases/dev_reset_delivery.rs diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index c66bd7e..1302d0b 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -36,7 +36,8 @@ use holzleitner_application::usecases::{ AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase, CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase, DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase, - DeleteServiceUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase, + DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase, + GenerateDeliveryReportUseCase, GetAccountUseCase, GetAttachmentPreviewUseCase, GetTourUseCase, ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase, @@ -345,6 +346,9 @@ pub(crate) async fn run_app( 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`. @@ -447,6 +451,7 @@ pub(crate) async fn run_app( set_delivery_order, import_erp_tours: import_erp_tours.clone(), dev_resync_tours, + dev_reset_delivery, generate_delivery_report, process_delivery_report: process_delivery_report.clone(), report_upload_enabled: cfg.report.upload_enabled, diff --git a/crates/api/src/routes/dev.rs b/crates/api/src/routes/dev.rs index 2e18fd7..94a7c39 100644 --- a/crates/api/src/routes/dev.rs +++ b/crates/api/src/routes/dev.rs @@ -24,6 +24,7 @@ use crate::state::AppState; pub fn router() -> Router { Router::new() .route("/dev/resync", post(dev_resync)) + .route("/dev/reset-delivery", post(dev_reset_delivery)) .route("/dev/generate-report", post(dev_generate_report)) .route("/dev/process-report", post(dev_process_report)) .route("/dev/unmark-mail-sent", post(dev_unmark_mail_sent)) @@ -64,6 +65,43 @@ pub async fn dev_resync( Ok(Json(summary)) } +#[derive(Debug, Deserialize)] +pub struct DevResetDeliveryQuery { + /// ERP-Belegnummer der zurückzusetzenden Lieferung. + pub belegnummer: String, +} + +#[derive(Debug, Serialize)] +pub struct DevResetDeliveryResponse { + pub belegnummer: String, + /// Anzahl zurückgesetzter Lieferungen (1 bei Erfolg). + pub reset: u64, +} + +/// DEV-ONLY, UNAUTHENTIFIZIERT: setzt EINE Lieferung (per Belegnummer) zurück — +/// Abschluss, Scan-/Gutschrift-Audit + Report-Job weg, Positionen + Lieferung +/// zurück auf `active`. Notizen/Dienstleistungen/Anhänge bleiben. 404, wenn die +/// Belegnummer unbekannt ist. +pub async fn dev_reset_delivery( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + tracing::warn!(belegnummer = %query.belegnummer, "dev.reset_delivery"); + let reset = state.dev_reset_delivery.execute(&query.belegnummer).await?; + if reset == 0 { + return Err(ApiError(ApplicationError::NotFound)); + } + tracing::info!( + belegnummer = %query.belegnummer, + reset, + "dev.reset_delivery.done" + ); + Ok(Json(DevResetDeliveryResponse { + belegnummer: query.belegnummer, + reset, + })) +} + #[derive(Debug, Deserialize)] pub struct DevReportQuery { /// UUID der Lieferung, für die der Report erzeugt werden soll. diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index 52fcd8f..14d8d8b 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -6,7 +6,8 @@ use holzleitner_application::usecases::{ AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase, CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase, DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase, - DeleteServiceUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase, + DeleteServiceUseCase, DevResetDeliveryUseCase, DevResyncToursUseCase, + GenerateDeliveryReportUseCase, GetAccountUseCase, GetAttachmentPreviewUseCase, GetTourUseCase, ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase, @@ -33,6 +34,8 @@ pub struct AppState { pub import_erp_tours: Arc, /// DEV-ONLY: überschreibender Resync (löscht Postgres + importiert neu). pub dev_resync_tours: Arc, + /// DEV-ONLY: setzt eine einzelne Lieferung (per Belegnummer) zurück. + pub dev_reset_delivery: Arc, /// Erzeugt den PDF-Lieferreport (lokal — Dev-Endpoint + Fallback ohne Upload). pub generate_delivery_report: Arc, /// Überträgt den Report an DOCUframe (Upload → Makro → Cleanup) — beim diff --git a/crates/application/src/ports/tour_repository.rs b/crates/application/src/ports/tour_repository.rs index a7c1602..d79c949 100644 --- a/crates/application/src/ports/tour_repository.rs +++ b/crates/application/src/ports/tour_repository.rs @@ -55,4 +55,19 @@ pub trait TourRepository: Send + Sync { /// Dev-Resync, der die Postgres-Daten vor einem frischen Import platt /// macht. Gibt die Anzahl gelöschter Touren zurück. async fn delete_all_tours(&self) -> Result; + + /// **DEV-ONLY**: Setzt eine **einzelne** Lieferung (über ihre ERP- + /// Belegnummer) zurück, ohne die Tour anzufassen — damit man den + /// Auslieferungs-Flow neu durchspielen kann: + /// * Abschluss (`delivery_completions`) gelöscht, + /// * Scan-/Gutschrift-Audit (`scan_audit`, `delivery_credit_audit`) gelöscht, + /// * Positionen zurück (scanned/credited = 0, Status `in_progress`), + /// * Lieferung wieder `active` (state_reason/assigned_car/review_* geleert). + /// + /// Notizen, Dienstleistungen und Anhänge bleiben unangetastet. Gibt die + /// Anzahl zurückgesetzter Lieferungen zurück (0 = Belegnummer unbekannt). + async fn reset_delivery_by_belegnummer( + &self, + belegnummer: &str, + ) -> Result; } diff --git a/crates/application/src/usecases/dev_reset_delivery.rs b/crates/application/src/usecases/dev_reset_delivery.rs new file mode 100644 index 0000000..f34c05e --- /dev/null +++ b/crates/application/src/usecases/dev_reset_delivery.rs @@ -0,0 +1,32 @@ +//! DEV-ONLY: Einzelne Lieferung zurücksetzen (per Belegnummer). +//! +//! Erlaubt, den Auslieferungs-Flow einer Lieferung erneut durchzuspielen, +//! ohne die ganze Tour (`/dev/resync`) zu löschen: Abschluss, Scan-/ +//! Gutschrift-Audit und Report-Job weg, Positionen + Lieferung zurück auf +//! `active`. Notizen/Dienstleistungen/Anhänge bleiben unangetastet. + +use std::sync::Arc; + +use crate::error::ApplicationError; +use crate::ports::TourRepository; + +pub struct DevResetDeliveryUseCase { + tours: Arc, +} + +impl DevResetDeliveryUseCase { + pub fn new(tours: Arc) -> Self { + Self { tours } + } + + /// Liefert die Anzahl zurückgesetzter Lieferungen (0 = Belegnummer unbekannt). + pub async fn execute(&self, belegnummer: &str) -> Result { + let bn = belegnummer.trim(); + if bn.is_empty() { + return Err(ApplicationError::Validation( + "belegnummer darf nicht leer sein".into(), + )); + } + self.tours.reset_delivery_by_belegnummer(bn).await + } +} diff --git a/crates/application/src/usecases/mod.rs b/crates/application/src/usecases/mod.rs index d16ecb9..9ddcc52 100644 --- a/crates/application/src/usecases/mod.rs +++ b/crates/application/src/usecases/mod.rs @@ -12,6 +12,7 @@ pub mod cars; pub mod complete_delivery; pub mod create_delivery_note; pub mod delete_delivery_note; +pub mod dev_reset_delivery; pub mod dev_resync_tours; pub mod generate_delivery_report; pub mod get_account; @@ -39,6 +40,7 @@ pub use cars::{ }; pub use complete_delivery::CompleteDeliveryUseCase; pub use create_delivery_note::CreateDeliveryNoteUseCase; +pub use dev_reset_delivery::DevResetDeliveryUseCase; pub use dev_resync_tours::DevResyncToursUseCase; pub use generate_delivery_report::GenerateDeliveryReportUseCase; pub use delete_delivery_note::DeleteDeliveryNoteUseCase; diff --git a/crates/infrastructure/src/persistence/tour_repository.rs b/crates/infrastructure/src/persistence/tour_repository.rs index 4e32869..3200f76 100644 --- a/crates/infrastructure/src/persistence/tour_repository.rs +++ b/crates/infrastructure/src/persistence/tour_repository.rs @@ -912,6 +912,82 @@ impl TourRepository for PgTourRepository { .map_err(db)?; Ok(res.rows_affected()) } + + async fn reset_delivery_by_belegnummer( + &self, + belegnummer: &str, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(db)?; + + // Subquery-Filter überall identisch: Lieferung(en) mit dieser Belegnr. + const BY_BELEG: &str = + "SELECT id FROM deliveries WHERE erp_belegnummer = $1"; + + // Abschluss + (evtl.) offener Report-Job + Scan-/Gutschrift-Audit löschen. + sqlx::query(&format!( + "DELETE FROM delivery_report_jobs WHERE delivery_id IN ({BY_BELEG})" + )) + .bind(belegnummer) + .execute(&mut *tx) + .await + .map_err(db)?; + + sqlx::query(&format!( + "DELETE FROM delivery_completions WHERE delivery_id IN ({BY_BELEG})" + )) + .bind(belegnummer) + .execute(&mut *tx) + .await + .map_err(db)?; + + sqlx::query( + "DELETE FROM scan_audit WHERE delivery_item_id IN (\ + SELECT di.id FROM delivery_items di \ + JOIN deliveries d ON d.id = di.delivery_id \ + WHERE d.erp_belegnummer = $1)", + ) + .bind(belegnummer) + .execute(&mut *tx) + .await + .map_err(db)?; + + sqlx::query(&format!( + "DELETE FROM delivery_credit_audit WHERE delivery_id IN ({BY_BELEG})" + )) + .bind(belegnummer) + .execute(&mut *tx) + .await + .map_err(db)?; + + // Positionen zurück (verladen + gutgeschrieben = 0, Status offen). + sqlx::query(&format!( + "UPDATE delivery_items \ + SET scanned_quantity = 0, scan_status = 'in_progress', \ + held_reason = NULL, credited_quantity = 0, \ + scan_last_updated_at = now() \ + WHERE delivery_id IN ({BY_BELEG})" + )) + .bind(belegnummer) + .execute(&mut *tx) + .await + .map_err(db)?; + + // Lieferung wieder aktiv; Status-/Auto-/Review-Felder leeren. + let res = sqlx::query( + "UPDATE deliveries \ + SET state = 'active', state_reason = NULL, assigned_car_id = NULL, \ + review_resolved_at = NULL, review_resolved_by = NULL, \ + review_note = NULL \ + WHERE erp_belegnummer = $1", + ) + .bind(belegnummer) + .execute(&mut *tx) + .await + .map_err(db)?; + + tx.commit().await.map_err(db)?; + Ok(res.rows_affected()) + } } // ===== Upsert-Helfer =====================================================