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:
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
32
crates/application/src/usecases/dev_reset_delivery.rs
Normal file
32
crates/application/src/usecases/dev_reset_delivery.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 =====================================================
|
||||
|
||||
Reference in New Issue
Block a user