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:
@ -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?;
|
||||
|
||||
@ -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;
|
||||
|
||||
199
crates/infrastructure/src/persistence/review_repository.rs
Normal file
199
crates/infrastructure/src/persistence/review_repository.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user