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

@ -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<AppState> {
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<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)
}