feat(dev): /dev/reset-delivery — einzelne Lieferung per Belegnummer zurücksetzen

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) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-24 13:35:45 +02:00
parent fb5f43ed7a
commit 819005eaa5
7 changed files with 173 additions and 2 deletions

View File

@ -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,

View File

@ -24,6 +24,7 @@ use crate::state::AppState;
pub fn router() -> Router<AppState> {
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<AppState>,
Query(query): Query<DevResetDeliveryQuery>,
) -> Result<Json<DevResetDeliveryResponse>, 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.

View File

@ -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<ImportErpToursUseCase>,
/// DEV-ONLY: überschreibender Resync (löscht Postgres + importiert neu).
pub dev_resync_tours: Arc<DevResyncToursUseCase>,
/// DEV-ONLY: setzt eine einzelne Lieferung (per Belegnummer) zurück.
pub dev_reset_delivery: Arc<DevResetDeliveryUseCase>,
/// Erzeugt den PDF-Lieferreport (lokal — Dev-Endpoint + Fallback ohne Upload).
pub generate_delivery_report: Arc<GenerateDeliveryReportUseCase>,
/// Überträgt den Report an DOCUframe (Upload → Makro → Cleanup) — beim

View File

@ -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<u64, ApplicationError>;
/// **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<u64, ApplicationError>;
}

View File

@ -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<dyn TourRepository>,
}
impl DevResetDeliveryUseCase {
pub fn new(tours: Arc<dyn TourRepository>) -> Self {
Self { tours }
}
/// Liefert die Anzahl zurückgesetzter Lieferungen (0 = Belegnummer unbekannt).
pub async fn execute(&self, belegnummer: &str) -> Result<u64, ApplicationError> {
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
}
}

View File

@ -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;

View File

@ -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<u64, ApplicationError> {
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 =====================================================