feat(review): Vier-Augen-Prüfung für geänderte Lieferscheine

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) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-23 18:02:27 +02:00
parent 1e6dfb10b0
commit 2d364f3fb7
12 changed files with 503 additions and 32 deletions

View File

@ -40,7 +40,8 @@ use holzleitner_application::usecases::{
GetAttachmentPreviewUseCase, GetTourUseCase, GetAttachmentPreviewUseCase, GetTourUseCase,
ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase,
ListMyToursTodayUseCase, ListPaymentMethodsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase,
ListServicesUseCase, MarkMailSentUseCase, PushCompletionToErpUseCase, ListPendingReviewsUseCase, ListServicesUseCase, MarkMailSentUseCase,
PushCompletionToErpUseCase, ResolveReviewUseCase,
SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase, SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase,
ProcessDeliveryReportUseCase, UpdateMyCarUseCase, UpdatePaymentMethodUseCase, ProcessDeliveryReportUseCase, UpdateMyCarUseCase, UpdatePaymentMethodUseCase,
UpdateServiceUseCase, UploadDeliveryNoteImageUseCase, UpdateServiceUseCase, UploadDeliveryNoteImageUseCase,
@ -61,7 +62,8 @@ use holzleitner_infrastructure::report::{
use holzleitner_infrastructure::persistence::{ use holzleitner_infrastructure::persistence::{
PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository, PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository,
PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryReportJobRepository, PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryReportJobRepository,
PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgScanRepository, PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgReviewRepository,
PgScanRepository,
PgServiceRepository, PgSyncRunRepository, PgTourRepository, PoolConfig, connect_and_migrate, PgServiceRepository, PgSyncRunRepository, PgTourRepository, PoolConfig, connect_and_migrate,
}; };
use holzleitner_infrastructure::storage::{LocalAttachmentStorage, LocalSignatureStorage}; 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 // Attachment-Upload/-Download über DOCUframe ist hier bewusst nicht mehr
// verdrahtet (Code in `gsd::GsdService` bleibt für später erhalten). // verdrahtet (Code in `gsd::GsdService` bleibt für später erhalten).
let gsd_service = Arc::new(GsdService::new( let gsd_service = Arc::new(GsdService::new(
pool, pool.clone(),
GsdConfig { GsdConfig {
rest_url: cfg.gsd.rest_url.clone(), rest_url: cfg.gsd.rest_url.clone(),
app_key: cfg.gsd.app_key.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( let mark_mail_sent = Arc::new(MarkMailSentUseCase::new(
delivery_completion_repository.clone(), 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 set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository));
let apply_scans = Arc::new(ApplyScansUseCase::new( let apply_scans = Arc::new(ApplyScansUseCase::new(
scan_repository, scan_repository,
@ -446,6 +453,8 @@ pub(crate) async fn run_app(
push_completion_to_erp, push_completion_to_erp,
list_delivered_belegnummern, list_delivered_belegnummern,
mark_mail_sent, mark_mail_sent,
list_pending_reviews,
resolve_review,
apply_delivery_credit_event, apply_delivery_credit_event,
create_delivery_note, create_delivery_note,
update_delivery_note, update_delivery_note,

View File

@ -55,6 +55,8 @@ use utoipa::openapi::security::{
crate::routes::admin::push_completion, crate::routes::admin::push_completion,
crate::routes::admin::delivered_belegnummern, crate::routes::admin::delivered_belegnummern,
crate::routes::admin::mark_mail_sent, crate::routes::admin::mark_mail_sent,
crate::routes::admin::list_reviews,
crate::routes::admin::resolve_review,
), ),
components( components(
schemas( schemas(
@ -125,6 +127,9 @@ use utoipa::openapi::security::{
holzleitner_application::dto::DeliveryServiceResponse, holzleitner_application::dto::DeliveryServiceResponse,
holzleitner_application::usecases::ImportSummary, holzleitner_application::usecases::ImportSummary,
crate::routes::admin::DeliveredBelegnummernResponse, crate::routes::admin::DeliveredBelegnummernResponse,
crate::routes::admin::PendingReviewResponse,
crate::routes::admin::ReviewedItemResponse,
crate::routes::admin::ResolveReviewRequest,
crate::routes::admin::MarkMailSentRequest, crate::routes::admin::MarkMailSentRequest,
crate::routes::admin::MarkMailSentResponse, crate::routes::admin::MarkMailSentResponse,
crate::routes::tours::TourSummaryList, crate::routes::tours::TourSummaryList,

View File

@ -7,7 +7,7 @@
use axum::Json; use axum::Json;
use axum::Router; use axum::Router;
use axum::extract::{Query, State}; use axum::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::routing::{get, post}; use axum::routing::{get, post};
use chrono::NaiveDate; use chrono::NaiveDate;
@ -30,6 +30,8 @@ pub fn router() -> Router<AppState> {
get(delivered_belegnummern), get(delivered_belegnummern),
) )
.route("/admin/mark-mail-sent", post(mark_mail_sent)) .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)] #[derive(Debug, Deserialize)]
@ -222,3 +224,121 @@ pub async fn mark_mail_sent(
tracing::info!(marked, "admin.mark_mail_sent.done"); tracing::info!(marked, "admin.mark_mail_sent.done");
Ok(Json(MarkMailSentResponse { marked })) 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<String>,
}
#[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<ReviewedItemResponse>,
/// Geld-Gutschrift in Cent (0 = keine).
pub money_credit_cents: i64,
pub money_credit_reason: Option<String>,
}
/// 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<AppState>,
) -> Result<Json<Vec<PendingReviewResponse>>, 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<String>,
}
/// 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<AppState>,
Path(delivery_id): Path<String>,
Json(body): Json<ResolveReviewRequest>,
) -> Result<StatusCode, ApiError> {
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)
}

View File

@ -10,8 +10,8 @@ use holzleitner_application::usecases::{
GetAttachmentPreviewUseCase, GetTourUseCase, GetAttachmentPreviewUseCase, GetTourUseCase,
ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase,
ListMyToursTodayUseCase, ListPaymentMethodsUseCase, ListMyToursTodayUseCase, ListPaymentMethodsUseCase,
ListServicesUseCase, MarkMailSentUseCase, ProcessDeliveryReportUseCase, ListPendingReviewsUseCase, ListServicesUseCase, MarkMailSentUseCase,
PushCompletionToErpUseCase, ProcessDeliveryReportUseCase, PushCompletionToErpUseCase, ResolveReviewUseCase,
SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase, SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase,
UpdateMyCarUseCase, UpdatePaymentMethodUseCase, UpdateServiceUseCase, UpdateMyCarUseCase, UpdatePaymentMethodUseCase, UpdateServiceUseCase,
UploadDeliveryNoteImageUseCase, UploadDeliveryNoteImageUseCase,
@ -50,6 +50,8 @@ pub struct AppState {
pub list_delivered_belegnummern: Arc<ListDeliveredBelegnummernUseCase>, pub list_delivered_belegnummern: Arc<ListDeliveredBelegnummernUseCase>,
/// Admin: Liefermails von Belegnummern als versendet markieren (Dedup). /// Admin: Liefermails von Belegnummern als versendet markieren (Dedup).
pub mark_mail_sent: Arc<MarkMailSentUseCase>, pub mark_mail_sent: Arc<MarkMailSentUseCase>,
pub list_pending_reviews: Arc<ListPendingReviewsUseCase>,
pub resolve_review: Arc<ResolveReviewUseCase>,
pub apply_delivery_credit_event: Arc<ApplyDeliveryCreditEventUseCase>, pub apply_delivery_credit_event: Arc<ApplyDeliveryCreditEventUseCase>,
pub create_delivery_note: Arc<CreateDeliveryNoteUseCase>, pub create_delivery_note: Arc<CreateDeliveryNoteUseCase>,
pub update_delivery_note: Arc<UpdateDeliveryNoteUseCase>, pub update_delivery_note: Arc<UpdateDeliveryNoteUseCase>,

View File

@ -21,6 +21,7 @@ pub mod delivery_service_repository;
pub mod docuframe_report_gateway; pub mod docuframe_report_gateway;
pub mod driver_identity_provisioner; pub mod driver_identity_provisioner;
pub mod payment_method_repository; pub mod payment_method_repository;
pub mod review_repository;
pub mod delivery_completion_repository; pub mod delivery_completion_repository;
pub mod erp_delivery_source; pub mod erp_delivery_source;
pub mod erp_delivery_writeback; pub mod erp_delivery_writeback;
@ -57,6 +58,7 @@ pub use erp_delivery_writeback::{
ErpDeliveryWriteback, ErpFinishDeliveryCommand, ErpLineQuantity, ErpDeliveryWriteback, ErpFinishDeliveryCommand, ErpLineQuantity,
}; };
pub use payment_method_repository::PaymentMethodRepository; pub use payment_method_repository::PaymentMethodRepository;
pub use review_repository::{PendingReview, ReviewRepository, ReviewedItem};
pub use scan_repository::{ApplyScanOutcome, ScanRepository}; pub use scan_repository::{ApplyScanOutcome, ScanRepository};
pub use service_repository::ServiceRepository; pub use service_repository::ServiceRepository;
pub use signature_storage::{SignatureRole, SignatureStorage}; pub use signature_storage::{SignatureRole, SignatureStorage};

View File

@ -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<String>,
}
/// 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<Utc>,
/// Entfernte/teil-gutgeschriebene Positionen.
pub credited_items: Vec<ReviewedItem>,
/// Geld-Gutschrift in Cent (0 = keine).
pub money_credit_cents: i64,
pub money_credit_reason: Option<String>,
}
#[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<Vec<PendingReview>, 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>;
}

View File

@ -24,6 +24,7 @@ pub mod mark_mail_sent;
pub mod payment_methods; pub mod payment_methods;
pub mod process_delivery_report; pub mod process_delivery_report;
pub mod push_completion_to_erp; pub mod push_completion_to_erp;
pub mod reviews;
pub mod services; pub mod services;
pub mod set_delivery_order; pub mod set_delivery_order;
pub mod sync_tour; pub mod sync_tour;
@ -54,6 +55,7 @@ pub use payment_methods::{
}; };
pub use process_delivery_report::ProcessDeliveryReportUseCase; pub use process_delivery_report::ProcessDeliveryReportUseCase;
pub use push_completion_to_erp::PushCompletionToErpUseCase; pub use push_completion_to_erp::PushCompletionToErpUseCase;
pub use reviews::{ListPendingReviewsUseCase, ResolveReviewUseCase};
pub use services::{ pub use services::{
CreateServiceUseCase, DeleteDeliveryServiceUseCase, DeleteServiceUseCase, CreateServiceUseCase, DeleteDeliveryServiceUseCase, DeleteServiceUseCase,
ListServicesUseCase, SetDeliveryServiceUseCase, UpdateServiceUseCase, ListServicesUseCase, SetDeliveryServiceUseCase, UpdateServiceUseCase,

View File

@ -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<dyn ReviewRepository>,
}
impl ListPendingReviewsUseCase {
pub fn new(repository: Arc<dyn ReviewRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self) -> Result<Vec<PendingReview>, ApplicationError> {
self.repository.list_pending().await
}
}
/// Löst die Prüfung einer Lieferung auf (Vier-Augen-Bestätigung).
pub struct ResolveReviewUseCase {
repository: Arc<dyn ReviewRepository>,
}
impl ResolveReviewUseCase {
pub fn new(repository: Arc<dyn ReviewRepository>) -> 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
}
}

View File

@ -8,10 +8,15 @@
//! //!
//! Reihenfolge: //! Reihenfolge:
//! 1. `Belegkopf.row_id` aus (BelegartId, Belegnummer) auflösen. //! 1. `Belegkopf.row_id` aus (BelegartId, Belegnummer) auflösen.
//! 2. Je Belegzeile die `Menge` auf die ausgelieferte Menge setzen. //! 2. Gutschrift-Zeile (`GUTSCHRIFT10`) anlegen/aktualisieren.
//! 3. Gutschrift-Zeile (`GUTSCHRIFT10`) anlegen/aktualisieren. //! 3. Belegsummen neu berechnen (`Σ Einzelpreis × Menge`, `Σ Brutto × Menge`).
//! 4. Belegsummen neu berechnen (`Σ Einzelpreis × Menge`, `Σ Brutto × Menge`). //! 4. `_SV_DELIVERY_DELIVERED_AT` + `_SV_DELIVERY_STATE='geliefert'`.
//! 5. `_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): //! TODO (bewusst hardcoded, später konfigurierbar/aus Stammdaten):
//! Gutschrift-Artikel `GUTSCHRIFT10`, Konto `8726`, Steuerschlüssel `M19`, //! 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** /// Gutschrift-Upsert als EINE Belegzeile mit dem **tatsächlichen Betrag**
/// (keine 10-€-Einheiten mehr → beliebige Beträge ≤ 150 € exakt abbildbar). /// (keine 10-€-Einheiten mehr → beliebige Beträge ≤ 150 € exakt abbildbar).
/// `amount_cents` = Geld-Gutschrift in Cent (Brutto; 0 = keine). /// `amount_cents` = Geld-Gutschrift in Cent (Brutto; 0 = keine).
@ -353,10 +340,13 @@ impl MssqlErpDeliveryWriteback {
) -> Result<(), ApplicationError> { ) -> Result<(), ApplicationError> {
let bk = Self::resolve_belegkopf(client, cmd.belegart_id, &cmd.belegnummer).await?; let bk = Self::resolve_belegkopf(client, cmd.belegart_id, &cmd.belegnummer).await?;
for line in &cmd.lines { // BEWUSST KEIN Setzen der Belegzeilen-Mengen mehr: eine reduzierte
Self::set_line_menge(client, bk, line.belegzeilen_nr, line.delivered_quantity).await?; // 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::upsert_gutschrift(client, bk, cmd.credit_amount_cents).await?;
Self::recalc_head(client, bk).await?; Self::recalc_head(client, bk).await?;

View File

@ -15,6 +15,7 @@ pub mod delivery_repository;
pub mod delivery_service_repository; pub mod delivery_service_repository;
pub mod payment_method_repository; pub mod payment_method_repository;
pub mod pool; pub mod pool;
pub mod review_repository;
pub mod scan_repository; pub mod scan_repository;
pub mod service_repository; pub mod service_repository;
pub mod sync_run_repository; pub mod sync_run_repository;
@ -31,6 +32,7 @@ pub use delivery_repository::PgDeliveryRepository;
pub use delivery_service_repository::PgDeliveryServiceRepository; pub use delivery_service_repository::PgDeliveryServiceRepository;
pub use payment_method_repository::PgPaymentMethodRepository; pub use payment_method_repository::PgPaymentMethodRepository;
pub use pool::{connect_and_migrate, PoolConfig}; pub use pool::{connect_and_migrate, PoolConfig};
pub use review_repository::PgReviewRepository;
pub use scan_repository::PgScanRepository; pub use scan_repository::PgScanRepository;
pub use service_repository::PgServiceRepository; pub use service_repository::PgServiceRepository;
pub use sync_run_repository::PgSyncRunRepository; pub use sync_run_repository::PgSyncRunRepository;

View File

@ -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: std::fmt::Display>(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<Vec<ReviewedItem>, ApplicationError> {
let rows: Vec<(i32, String, String, i32, i32, Option<String>)> = 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<Vec<PendingReview>, ApplicationError> {
// Delivery-Ebene: Abweichung + letzte Änderung + aktuelle Geld-Gutschrift.
let heads: Vec<(
Uuid,
i64,
String,
String,
NaiveDate,
DateTime<Utc>,
i64,
Option<String>,
)> = 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(())
}
}

View File

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