From 2d364f3fb7767505df70188b8bc4633c9224ec73 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Tue, 23 Jun 2026 18:02:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(review):=20Vier-Augen-Pr=C3=BCfung=20f?= =?UTF-8?q?=C3=BCr=20ge=C3=A4nderte=20Lieferscheine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geänderte Lieferscheine (Artikel entfernt/teil-gutgeschrieben ODER Geld-Gutschrift) sollen von der Fakturierung gegengeprüft werden, statt die Menge im ERP zu reduzieren (was den Lagerbestand inkonsistent machte, weil der Raw-Writeback die ERPframe-Engine umgeht). - ERP-Writeback: kein Setzen der Belegzeilen-Menge mehr → ERP-Beleg bleibt im Original (kein Bestandskonflikt). Geld-Gutschrift wird weiter geschrieben (unkritisch, nur Vier-Augen). delivered-at/State/Zahlbed bleiben. - Migration 0031: review_resolved_at/by/note auf deliveries. Der Status wird ABGELEITET (Abweichung via credited_quantity / aktueller Gutschrift vs. resolved_at), daher: Originalzustand wiederhergestellt ⇒ Flag weg; nach Bestätigung erneut geändert ⇒ wieder offen. - ReviewRepository (Port + Pg-Impl) + Use Cases ListPendingReviews/ ResolveReview. - Endpoints: GET /admin/reviews (offene Prüfungen inkl. Änderungsdetails aus scan_audit/credit_audit) + POST /admin/reviews/{delivery_id}/resolve (Admin-Key). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/api/src/main.rs | 15 +- crates/api/src/openapi.rs | 5 + crates/api/src/routes/admin.rs | 122 ++++++++++- crates/api/src/state.rs | 6 +- crates/application/src/ports/mod.rs | 2 + .../src/ports/review_repository.rs | 61 ++++++ crates/application/src/usecases/mod.rs | 2 + crates/application/src/usecases/reviews.rs | 54 +++++ .../src/erp/mssql_delivery_writeback.rs | 42 ++-- crates/infrastructure/src/persistence/mod.rs | 2 + .../src/persistence/review_repository.rs | 199 ++++++++++++++++++ migrations/0031_delivery_review.sql | 25 +++ 12 files changed, 503 insertions(+), 32 deletions(-) create mode 100644 crates/application/src/ports/review_repository.rs create mode 100644 crates/application/src/usecases/reviews.rs create mode 100644 crates/infrastructure/src/persistence/review_repository.rs create mode 100644 migrations/0031_delivery_review.sql diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index b434e15..e8afa16 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -40,7 +40,8 @@ use holzleitner_application::usecases::{ GetAttachmentPreviewUseCase, GetTourUseCase, ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase, - ListServicesUseCase, MarkMailSentUseCase, PushCompletionToErpUseCase, + ListPendingReviewsUseCase, ListServicesUseCase, MarkMailSentUseCase, + PushCompletionToErpUseCase, ResolveReviewUseCase, SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase, ProcessDeliveryReportUseCase, UpdateMyCarUseCase, UpdatePaymentMethodUseCase, UpdateServiceUseCase, UploadDeliveryNoteImageUseCase, @@ -61,7 +62,8 @@ use holzleitner_infrastructure::report::{ use holzleitner_infrastructure::persistence::{ PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository, PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryReportJobRepository, - PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgScanRepository, + PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgReviewRepository, + PgScanRepository, PgServiceRepository, PgSyncRunRepository, PgTourRepository, PoolConfig, connect_and_migrate, }; use holzleitner_infrastructure::storage::{LocalAttachmentStorage, LocalSignatureStorage}; @@ -253,7 +255,7 @@ pub(crate) async fn run_app( // Attachment-Upload/-Download über DOCUframe ist hier bewusst nicht mehr // verdrahtet (Code in `gsd::GsdService` bleibt für später erhalten). let gsd_service = Arc::new(GsdService::new( - pool, + pool.clone(), GsdConfig { rest_url: cfg.gsd.rest_url.clone(), app_key: cfg.gsd.app_key.clone(), @@ -359,6 +361,11 @@ pub(crate) async fn run_app( let mark_mail_sent = Arc::new(MarkMailSentUseCase::new( delivery_completion_repository.clone(), )); + // Vier-Augen-Prüfung geänderter Lieferscheine. + let review_repository = Arc::new(PgReviewRepository::new(pool.clone())); + let list_pending_reviews = + Arc::new(ListPendingReviewsUseCase::new(review_repository.clone())); + let resolve_review = Arc::new(ResolveReviewUseCase::new(review_repository)); let set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository)); let apply_scans = Arc::new(ApplyScansUseCase::new( scan_repository, @@ -446,6 +453,8 @@ pub(crate) async fn run_app( push_completion_to_erp, list_delivered_belegnummern, mark_mail_sent, + list_pending_reviews, + resolve_review, apply_delivery_credit_event, create_delivery_note, update_delivery_note, diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 55996e2..45adfaf 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -55,6 +55,8 @@ use utoipa::openapi::security::{ crate::routes::admin::push_completion, crate::routes::admin::delivered_belegnummern, crate::routes::admin::mark_mail_sent, + crate::routes::admin::list_reviews, + crate::routes::admin::resolve_review, ), components( schemas( @@ -125,6 +127,9 @@ use utoipa::openapi::security::{ holzleitner_application::dto::DeliveryServiceResponse, holzleitner_application::usecases::ImportSummary, crate::routes::admin::DeliveredBelegnummernResponse, + crate::routes::admin::PendingReviewResponse, + crate::routes::admin::ReviewedItemResponse, + crate::routes::admin::ResolveReviewRequest, crate::routes::admin::MarkMailSentRequest, crate::routes::admin::MarkMailSentResponse, crate::routes::tours::TourSummaryList, diff --git a/crates/api/src/routes/admin.rs b/crates/api/src/routes/admin.rs index c719335..e632e82 100644 --- a/crates/api/src/routes/admin.rs +++ b/crates/api/src/routes/admin.rs @@ -7,7 +7,7 @@ use axum::Json; use axum::Router; -use axum::extract::{Query, State}; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::routing::{get, post}; use chrono::NaiveDate; @@ -30,6 +30,8 @@ pub fn router() -> Router { get(delivered_belegnummern), ) .route("/admin/mark-mail-sent", post(mark_mail_sent)) + .route("/admin/reviews", get(list_reviews)) + .route("/admin/reviews/{delivery_id}/resolve", post(resolve_review)) } #[derive(Debug, Deserialize)] @@ -222,3 +224,121 @@ pub async fn mark_mail_sent( tracing::info!(marked, "admin.mark_mail_sent.done"); Ok(Json(MarkMailSentResponse { marked })) } + +// ─── Vier-Augen-Prüfung geänderter Lieferscheine ──────────────────────── + +#[derive(Debug, Serialize, ToSchema)] +pub struct ReviewedItemResponse { + pub belegzeilen_nr: i32, + pub artikel_nr: String, + pub article_name: String, + pub required_quantity: i32, + pub credited_quantity: i32, + pub reason: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct PendingReviewResponse { + pub delivery_id: String, + pub erp_belegart_id: i64, + pub erp_belegnummer: String, + pub customer_name: String, + /// Tourdatum (ISO `YYYY-MM-DD`). + pub tour_date: String, + /// Zeitpunkt der letzten beleg-ändernden Aktion (RFC 3339). + pub last_change_at: String, + /// Entfernte/teil-gutgeschriebene Positionen. + pub credited_items: Vec, + /// Geld-Gutschrift in Cent (0 = keine). + pub money_credit_cents: i64, + pub money_credit_reason: Option, +} + +/// Listet alle geänderten Lieferscheine, die noch auf eine manuelle +/// Bestätigung (Vier-Augen) warten — Entfernungen und/oder Geld-Gutschriften. +#[utoipa::path( + get, + path = "/admin/reviews", + tag = "admin", + responses( + (status = 200, description = "Offene Prüfungen", body = [PendingReviewResponse]), + (status = 401, description = "Admin-API-Key fehlt/ungültig") + ), + security(("admin_api_key" = [])) +)] +pub async fn list_reviews( + State(state): State, +) -> Result>, ApiError> { + let items = state.list_pending_reviews.execute().await?; + tracing::info!(count = items.len(), "admin.reviews.list"); + let out = items + .into_iter() + .map(|r| PendingReviewResponse { + delivery_id: r.delivery_id.to_string(), + erp_belegart_id: r.erp_belegart_id, + erp_belegnummer: r.erp_belegnummer, + customer_name: r.customer_name, + tour_date: r.tour_date.format("%Y-%m-%d").to_string(), + last_change_at: r.last_change_at.to_rfc3339(), + credited_items: r + .credited_items + .into_iter() + .map(|i| ReviewedItemResponse { + belegzeilen_nr: i.belegzeilen_nr, + artikel_nr: i.artikel_nr, + article_name: i.article_name, + required_quantity: i.required_quantity, + credited_quantity: i.credited_quantity, + reason: i.reason, + }) + .collect(), + money_credit_cents: r.money_credit_cents, + money_credit_reason: r.money_credit_reason, + }) + .collect(); + Ok(Json(out)) +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ResolveReviewRequest { + /// Bearbeiter (Name/Kürzel), der die Prüfung bestätigt. + pub resolved_by: String, + /// Optionale Notiz zur getroffenen Entscheidung. + #[serde(default)] + pub note: Option, +} + +/// Bestätigt die Prüfung einer geänderten Lieferung (Vier-Augen) — die +/// Lieferung verschwindet danach aus `GET /admin/reviews` (sofern nicht +/// erneut geändert). +#[utoipa::path( + post, + path = "/admin/reviews/{delivery_id}/resolve", + tag = "admin", + params(("delivery_id" = String, Path, description = "UUID der Lieferung")), + request_body = ResolveReviewRequest, + responses( + (status = 204, description = "Prüfung bestätigt"), + (status = 400, description = "Ungültige delivery_id / Bearbeiter leer"), + (status = 401, description = "Admin-API-Key fehlt/ungültig"), + (status = 404, description = "Lieferung nicht gefunden") + ), + security(("admin_api_key" = [])) +)] +pub async fn resolve_review( + State(state): State, + Path(delivery_id): Path, + Json(body): Json, +) -> Result { + let id = Uuid::parse_str(delivery_id.trim()).map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "ungültige delivery_id '{delivery_id}': {e}" + ))) + })?; + tracing::info!(%id, by = %body.resolved_by, "admin.reviews.resolve"); + state + .resolve_review + .execute(id, &body.resolved_by, body.note.as_deref()) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index b3f4587..52fcd8f 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -10,8 +10,8 @@ use holzleitner_application::usecases::{ GetAttachmentPreviewUseCase, GetTourUseCase, ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase, - ListServicesUseCase, MarkMailSentUseCase, ProcessDeliveryReportUseCase, - PushCompletionToErpUseCase, + ListPendingReviewsUseCase, ListServicesUseCase, MarkMailSentUseCase, + ProcessDeliveryReportUseCase, PushCompletionToErpUseCase, ResolveReviewUseCase, SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase, UpdateMyCarUseCase, UpdatePaymentMethodUseCase, UpdateServiceUseCase, UploadDeliveryNoteImageUseCase, @@ -50,6 +50,8 @@ pub struct AppState { pub list_delivered_belegnummern: Arc, /// Admin: Liefermails von Belegnummern als versendet markieren (Dedup). pub mark_mail_sent: Arc, + pub list_pending_reviews: Arc, + pub resolve_review: Arc, pub apply_delivery_credit_event: Arc, pub create_delivery_note: Arc, pub update_delivery_note: Arc, diff --git a/crates/application/src/ports/mod.rs b/crates/application/src/ports/mod.rs index 8296da6..0e3db6f 100644 --- a/crates/application/src/ports/mod.rs +++ b/crates/application/src/ports/mod.rs @@ -21,6 +21,7 @@ pub mod delivery_service_repository; pub mod docuframe_report_gateway; pub mod driver_identity_provisioner; pub mod payment_method_repository; +pub mod review_repository; pub mod delivery_completion_repository; pub mod erp_delivery_source; pub mod erp_delivery_writeback; @@ -57,6 +58,7 @@ pub use erp_delivery_writeback::{ ErpDeliveryWriteback, ErpFinishDeliveryCommand, ErpLineQuantity, }; pub use payment_method_repository::PaymentMethodRepository; +pub use review_repository::{PendingReview, ReviewRepository, ReviewedItem}; pub use scan_repository::{ApplyScanOutcome, ScanRepository}; pub use service_repository::ServiceRepository; pub use signature_storage::{SignatureRole, SignatureStorage}; diff --git a/crates/application/src/ports/review_repository.rs b/crates/application/src/ports/review_repository.rs new file mode 100644 index 0000000..03ba2ea --- /dev/null +++ b/crates/application/src/ports/review_repository.rs @@ -0,0 +1,61 @@ +//! Port für die Vier-Augen-Prüfung geänderter Lieferscheine. +//! +//! Eine Lieferung gilt als „prüfbedürftig", sobald sie vom ursprünglichen +//! ERP-Beleg abweicht (Artikel entfernt/teil-gutgeschrieben ODER Geld- +//! Gutschrift). Der Status wird **abgeleitet** (siehe Migration 0031); hier +//! kommen nur die Lese-Liste der offenen Prüfungen und das manuelle Auflösen +//! an. + +use async_trait::async_trait; +use chrono::{DateTime, NaiveDate, Utc}; +use uuid::Uuid; + +use crate::error::ApplicationError; + +/// Eine vom Original abweichende Position (entfernt/teil-gutgeschrieben). +#[derive(Debug, Clone)] +pub struct ReviewedItem { + pub belegzeilen_nr: i32, + pub artikel_nr: String, + pub article_name: String, + pub required_quantity: i32, + pub credited_quantity: i32, + /// Jüngster Entfern-Grund aus dem Audit (falls vorhanden). + pub reason: Option, +} + +/// Eine offene (noch nicht bestätigte) Prüfung eines geänderten Lieferscheins. +#[derive(Debug, Clone)] +pub struct PendingReview { + pub delivery_id: Uuid, + pub erp_belegart_id: i64, + pub erp_belegnummer: String, + pub customer_name: String, + pub tour_date: NaiveDate, + /// Zeitpunkt der letzten beleg-ändernden Aktion (Entfernen/Gutschrift). + pub last_change_at: DateTime, + /// Entfernte/teil-gutgeschriebene Positionen. + pub credited_items: Vec, + /// Geld-Gutschrift in Cent (0 = keine). + pub money_credit_cents: i64, + pub money_credit_reason: Option, +} + +#[async_trait] +pub trait ReviewRepository: Send + Sync { + /// Alle Lieferungen, die vom Original abweichen UND seit der letzten + /// Änderung noch nicht bestätigt wurden (= Status `pending`). Sortiert + /// nach jüngster Änderung zuerst. + async fn list_pending(&self) -> Result, ApplicationError>; + + /// Markiert die Prüfung einer Lieferung als erledigt (Vier-Augen): + /// speichert Zeitpunkt + Bearbeiter + optionale Notiz. Idempotent + /// (erneutes Auflösen überschreibt). `NotFound`, wenn die Lieferung + /// nicht existiert. + async fn resolve( + &self, + delivery_id: Uuid, + resolved_by: &str, + note: Option<&str>, + ) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/usecases/mod.rs b/crates/application/src/usecases/mod.rs index 086ebe3..d16ecb9 100644 --- a/crates/application/src/usecases/mod.rs +++ b/crates/application/src/usecases/mod.rs @@ -24,6 +24,7 @@ pub mod mark_mail_sent; pub mod payment_methods; pub mod process_delivery_report; pub mod push_completion_to_erp; +pub mod reviews; pub mod services; pub mod set_delivery_order; pub mod sync_tour; @@ -54,6 +55,7 @@ pub use payment_methods::{ }; pub use process_delivery_report::ProcessDeliveryReportUseCase; pub use push_completion_to_erp::PushCompletionToErpUseCase; +pub use reviews::{ListPendingReviewsUseCase, ResolveReviewUseCase}; pub use services::{ CreateServiceUseCase, DeleteDeliveryServiceUseCase, DeleteServiceUseCase, ListServicesUseCase, SetDeliveryServiceUseCase, UpdateServiceUseCase, diff --git a/crates/application/src/usecases/reviews.rs b/crates/application/src/usecases/reviews.rs new file mode 100644 index 0000000..a74f424 --- /dev/null +++ b/crates/application/src/usecases/reviews.rs @@ -0,0 +1,54 @@ +//! Use Cases für die Vier-Augen-Prüfung geänderter Lieferscheine. +//! +//! Dünne Hüllen über [`ReviewRepository`]: die Fakturierung listet offene +//! Prüfungen und löst sie nach manueller Entscheidung auf. + +use std::sync::Arc; + +use uuid::Uuid; + +use crate::error::ApplicationError; +use crate::ports::{PendingReview, ReviewRepository}; + +/// Listet alle offenen (noch nicht bestätigten) Prüfungen geänderter +/// Lieferscheine. +pub struct ListPendingReviewsUseCase { + repository: Arc, +} + +impl ListPendingReviewsUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute(&self) -> Result, ApplicationError> { + self.repository.list_pending().await + } +} + +/// Löst die Prüfung einer Lieferung auf (Vier-Augen-Bestätigung). +pub struct ResolveReviewUseCase { + repository: Arc, +} + +impl ResolveReviewUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + delivery_id: Uuid, + resolved_by: &str, + note: Option<&str>, + ) -> Result<(), ApplicationError> { + let by = resolved_by.trim(); + if by.is_empty() { + return Err(ApplicationError::Validation( + "resolved_by darf nicht leer sein".into(), + )); + } + let note = note.map(str::trim).filter(|s| !s.is_empty()); + self.repository.resolve(delivery_id, by, note).await + } +} diff --git a/crates/infrastructure/src/erp/mssql_delivery_writeback.rs b/crates/infrastructure/src/erp/mssql_delivery_writeback.rs index f34d17d..a908df7 100644 --- a/crates/infrastructure/src/erp/mssql_delivery_writeback.rs +++ b/crates/infrastructure/src/erp/mssql_delivery_writeback.rs @@ -8,10 +8,15 @@ //! //! Reihenfolge: //! 1. `Belegkopf.row_id` aus (BelegartId, Belegnummer) auflösen. -//! 2. Je Belegzeile die `Menge` auf die ausgelieferte Menge setzen. -//! 3. Gutschrift-Zeile (`GUTSCHRIFT10`) anlegen/aktualisieren. -//! 4. Belegsummen neu berechnen (`Σ Einzelpreis × Menge`, `Σ Brutto × Menge`). -//! 5. `_SV_DELIVERY_DELIVERED_AT` + `_SV_DELIVERY_STATE='geliefert'`. +//! 2. Gutschrift-Zeile (`GUTSCHRIFT10`) anlegen/aktualisieren. +//! 3. Belegsummen neu berechnen (`Σ Einzelpreis × Menge`, `Σ Brutto × Menge`). +//! 4. `_SV_DELIVERY_DELIVERED_AT` + `_SV_DELIVERY_STATE='geliefert'`. +//! +//! **Bewusst NICHT mehr:** das Setzen der Belegzeilen-`Menge`. Eine reduzierte +//! Liefermenge (entfernter Artikel) per Raw-`UPDATE` würde die ERPframe-Engine +//! umgehen und den Lagerbestand inkonsistent machen (keine Gegenbuchung). Der +//! ERP-Beleg bleibt daher im Original; geänderte Lieferungen werden im Backend +//! zur Vier-Augen-Prüfung markiert, die Fakturierung entscheidet manuell. //! //! TODO (bewusst hardcoded, später konfigurierbar/aus Stammdaten): //! Gutschrift-Artikel `GUTSCHRIFT10`, Konto `8726`, Steuerschlüssel `M19`, @@ -114,24 +119,6 @@ impl MssqlErpDeliveryWriteback { }) } - /// Setzt die Menge einer Belegzeile (absolut, idempotent). - async fn set_line_menge( - client: &mut TiberiusClient, - bk_row_id: i64, - belegzeilen_nr: i32, - menge: i32, - ) -> Result<(), ApplicationError> { - client - .execute( - r#"UPDATE Belegzeilen SET Menge = @P1 - WHERE ParentID = @P2 AND BelegzeilenNr = @P3"#, - &[&menge, &bk_row_id, &belegzeilen_nr], - ) - .await - .map_err(repo)?; - Ok(()) - } - /// Gutschrift-Upsert als EINE Belegzeile mit dem **tatsächlichen Betrag** /// (keine 10-€-Einheiten mehr → beliebige Beträge ≤ 150 € exakt abbildbar). /// `amount_cents` = Geld-Gutschrift in Cent (Brutto; 0 = keine). @@ -353,10 +340,13 @@ impl MssqlErpDeliveryWriteback { ) -> Result<(), ApplicationError> { let bk = Self::resolve_belegkopf(client, cmd.belegart_id, &cmd.belegnummer).await?; - for line in &cmd.lines { - Self::set_line_menge(client, bk, line.belegzeilen_nr, line.delivered_quantity).await?; - } - + // BEWUSST KEIN Setzen der Belegzeilen-Mengen mehr: eine reduzierte + // Liefermenge (entfernter Artikel) würde im ERP eine Bestands- + // Inkonsistenz erzeugen (die Engine bucht beim Raw-UPDATE nicht mit). + // Stattdessen bleibt der ERP-Beleg im Original; die Lieferung wird im + // Backend zur Vier-Augen-Prüfung markiert (siehe ReviewRepository) und + // die Fakturierung entscheidet manuell. Die Geld-Gutschrift wird + // weiterhin geschrieben (unkritisch, nur Vier-Augen-Bestätigung). Self::upsert_gutschrift(client, bk, cmd.credit_amount_cents).await?; Self::recalc_head(client, bk).await?; diff --git a/crates/infrastructure/src/persistence/mod.rs b/crates/infrastructure/src/persistence/mod.rs index 69005c8..78c0a8f 100644 --- a/crates/infrastructure/src/persistence/mod.rs +++ b/crates/infrastructure/src/persistence/mod.rs @@ -15,6 +15,7 @@ pub mod delivery_repository; pub mod delivery_service_repository; pub mod payment_method_repository; pub mod pool; +pub mod review_repository; pub mod scan_repository; pub mod service_repository; pub mod sync_run_repository; @@ -31,6 +32,7 @@ pub use delivery_repository::PgDeliveryRepository; pub use delivery_service_repository::PgDeliveryServiceRepository; pub use payment_method_repository::PgPaymentMethodRepository; pub use pool::{connect_and_migrate, PoolConfig}; +pub use review_repository::PgReviewRepository; pub use scan_repository::PgScanRepository; pub use service_repository::PgServiceRepository; pub use sync_run_repository::PgSyncRunRepository; diff --git a/crates/infrastructure/src/persistence/review_repository.rs b/crates/infrastructure/src/persistence/review_repository.rs new file mode 100644 index 0000000..aec2f2b --- /dev/null +++ b/crates/infrastructure/src/persistence/review_repository.rs @@ -0,0 +1,199 @@ +//! Postgres-Adapter für `ReviewRepository` (Vier-Augen-Prüfung). +//! +//! Der Prüf-Status wird **abgeleitet** (keine eigene Status-Spalte): +//! Eine Lieferung ist „pending", wenn sie vom Original abweicht +//! (`delivery_items.credited_quantity > 0` ODER neueste Geld-Gutschrift +//! `action='set'` mit Betrag > 0) UND seit der letzten beleg-ändernden +//! Aktion noch nicht bestätigt wurde (`review_resolved_at` NULL oder älter +//! als die letzte Änderung). + +use async_trait::async_trait; +use chrono::{DateTime, NaiveDate, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::{PendingReview, ReviewRepository, ReviewedItem}; + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +pub struct PgReviewRepository { + pool: PgPool, +} + +impl PgReviewRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + /// Entfernte/teil-gutgeschriebene Positionen einer Lieferung samt jüngstem + /// Entfern-Grund. + async fn credited_items( + &self, + delivery_id: Uuid, + ) -> Result, ApplicationError> { + let rows: Vec<(i32, String, String, i32, i32, Option)> = sqlx::query_as( + r#" + SELECT di.belegzeilen_nr, + a.article_number, + a.name, + di.required_quantity, + di.credited_quantity, + (SELECT sa.reason + FROM scan_audit sa + WHERE sa.delivery_item_id = di.id + AND sa.action = 'remove' + AND sa.reason IS NOT NULL + ORDER BY sa.server_recorded_at DESC + LIMIT 1) AS reason + FROM delivery_items di + JOIN articles a ON a.id = di.article_id + WHERE di.delivery_id = $1 AND di.credited_quantity > 0 + ORDER BY di.belegzeilen_nr + "#, + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + Ok(rows + .into_iter() + .map( + |(belegzeilen_nr, artikel_nr, article_name, required, credited, reason)| { + ReviewedItem { + belegzeilen_nr, + artikel_nr, + article_name, + required_quantity: required, + credited_quantity: credited, + reason, + } + }, + ) + .collect()) + } +} + +#[async_trait] +impl ReviewRepository for PgReviewRepository { + async fn list_pending(&self) -> Result, ApplicationError> { + // Delivery-Ebene: Abweichung + letzte Änderung + aktuelle Geld-Gutschrift. + let heads: Vec<( + Uuid, + i64, + String, + String, + NaiveDate, + DateTime, + i64, + Option, + )> = sqlx::query_as( + r#" + WITH chg AS ( + SELECT d.id AS delivery_id, + GREATEST( + COALESCE((SELECT max(sa.server_recorded_at) + FROM scan_audit sa + JOIN delivery_items di ON di.id = sa.delivery_item_id + WHERE di.delivery_id = d.id + AND sa.action IN ('remove','unremove')), to_timestamp(0)), + COALESCE((SELECT max(ca.recorded_at) + FROM delivery_credit_audit ca + WHERE ca.delivery_id = d.id), to_timestamp(0)) + ) AS last_change_at, + EXISTS(SELECT 1 FROM delivery_items di2 + WHERE di2.delivery_id = d.id + AND di2.credited_quantity > 0) AS has_credit_qty + FROM deliveries d + ), + cur_credit AS ( + SELECT DISTINCT ON (ca.delivery_id) + ca.delivery_id, ca.action, ca.amount_cents, ca.reason + FROM delivery_credit_audit ca + ORDER BY ca.delivery_id, ca.recorded_at DESC + ) + SELECT d.id, + d.erp_belegart_id, + d.erp_belegnummer, + c.name AS customer_name, + t.tour_date, + chg.last_change_at, + CASE WHEN cc.action = 'set' THEN COALESCE(cc.amount_cents, 0) ELSE 0 END + AS money_credit_cents, + CASE WHEN cc.action = 'set' THEN cc.reason ELSE NULL END + AS money_credit_reason + FROM deliveries d + JOIN chg ON chg.delivery_id = d.id + JOIN tours t ON t.id = d.tour_id + JOIN customers c ON c.id = d.customer_id + LEFT JOIN cur_credit cc ON cc.delivery_id = d.id + WHERE (chg.has_credit_qty + OR (cc.action = 'set' AND COALESCE(cc.amount_cents, 0) > 0)) + AND (d.review_resolved_at IS NULL + OR d.review_resolved_at < chg.last_change_at) + ORDER BY chg.last_change_at DESC + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + let mut out = Vec::with_capacity(heads.len()); + for ( + delivery_id, + erp_belegart_id, + erp_belegnummer, + customer_name, + tour_date, + last_change_at, + money_credit_cents, + money_credit_reason, + ) in heads + { + let credited_items = self.credited_items(delivery_id).await?; + out.push(PendingReview { + delivery_id, + erp_belegart_id, + erp_belegnummer, + customer_name, + tour_date, + last_change_at, + credited_items, + money_credit_cents, + money_credit_reason, + }); + } + Ok(out) + } + + async fn resolve( + &self, + delivery_id: Uuid, + resolved_by: &str, + note: Option<&str>, + ) -> Result<(), ApplicationError> { + let res = sqlx::query( + r#" + UPDATE deliveries + SET review_resolved_at = now(), + review_resolved_by = $2, + review_note = $3 + WHERE id = $1 + "#, + ) + .bind(delivery_id) + .bind(resolved_by) + .bind(note) + .execute(&self.pool) + .await + .map_err(db)?; + + if res.rows_affected() == 0 { + return Err(ApplicationError::NotFound); + } + Ok(()) + } +} diff --git a/migrations/0031_delivery_review.sql b/migrations/0031_delivery_review.sql new file mode 100644 index 0000000..169ba7c --- /dev/null +++ b/migrations/0031_delivery_review.sql @@ -0,0 +1,25 @@ +-- 0031_delivery_review.sql +-- +-- Vier-Augen-Prüfung für geänderte Lieferscheine. +-- +-- Sobald an einer Lieferung etwas vom ursprünglichen ERP-Beleg abweicht +-- (Artikel entfernt/teil-gutgeschrieben ODER Geld-Gutschrift gesetzt), soll +-- ein Mitarbeiter in der Fakturierung das gegenprüfen. +-- +-- Bewusst KEINE eigene Status-Spalte: der Status wird ABGELEITET aus dem +-- vorhandenen Stand (`delivery_items.credited_quantity`, neueste +-- `delivery_credit_audit`-Zeile) versus dem hier gespeicherten Bestätigungs- +-- Zeitpunkt. Das erfüllt automatisch „Originalzustand wieder erreicht ⇒ Flag +-- weg" (keine Abweichung mehr) UND „nach Bestätigung erneut geändert ⇒ wieder +-- offen" (resolved_at < letzte Änderung), ohne Logik in die Scan-/Gutschrift- +-- Transaktionen zu hängen. +-- +-- Abgeleiteter Status (im Read berechnet): +-- keine Abweichung -> none +-- Abweichung, resolved_at NULL oder < letzte Änderung -> pending +-- Abweichung, resolved_at >= letzte Änderung -> resolved + +ALTER TABLE deliveries + ADD COLUMN review_resolved_at TIMESTAMPTZ, + ADD COLUMN review_resolved_by TEXT, + ADD COLUMN review_note TEXT;