Files
Holzleitner---Backend--aktu…/crates/api/src/routes/admin.rs
Dennis Nemec 865fcfcd23 feat(admin): GET /admin/belege/{belegnummer}/positions-modified
Neuer Admin-Endpunkt, der zu EINER ERP-Belegnummer das positions_modified-Flag
liefert (Stück-Gutschrift auf einer Belegzeile ODER aktive Geld-Gutschrift) —
gleiche Definition wie in completed-deliveries, aber pro Beleg und ohne
Abschluss-Voraussetzung. 404 bei unbekannter Belegnummer; bei mehreren
Lieferungen gleicher Belegnummer true, sobald eine verändert ist (bool_or).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:32:04 +02:00

512 lines
19 KiB
Rust

//! Admin-/Betriebs-Endpunkte.
//!
//! Aktuell: manueller ERP-Import-Trigger. Derselbe Use Case, den auch der
//! tägliche Scheduler ruft — hier on-demand für ein konkretes Datum
//! (Testen + manuelle Nachläufe im Betrieb). JWT-geschützt wie alle
//! protected Routen.
use axum::Json;
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::usecases::ImportSummary;
use crate::error::ApiError;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/admin/import-erp", post(import_erp))
.route("/admin/push-completion", post(push_completion))
.route(
"/admin/delivered-belegnummern",
get(delivered_belegnummern),
)
.route(
"/admin/completed-deliveries",
get(completed_deliveries),
)
.route(
"/admin/belege/{belegnummer}/positions-modified",
get(positions_modified),
)
.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)]
pub struct ImportErpQuery {
/// Ziel-Tourdatum `YYYY-MM-DD`. Fehlt der Parameter, wird **heute**
/// verwendet.
#[serde(default)]
pub date: Option<String>,
}
/// Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung.
#[utoipa::path(
post,
path = "/admin/import-erp",
tag = "admin",
params(
("date" = Option<String>, Query, description = "Ziel-Tourdatum YYYY-MM-DD (Default: heute)")
),
responses(
(status = 200, description = "Import durchgeführt", body = ImportSummary),
(status = 400, description = "Ungültiges Datum"),
(status = 401, description = "Admin-API-Key fehlt/ungültig"),
(status = 502, description = "ERP nicht erreichbar / Lesefehler")
),
security(("admin_api_key" = []))
)]
pub async fn import_erp(
State(state): State<AppState>,
Query(query): Query<ImportErpQuery>,
) -> Result<Json<ImportSummary>, ApiError> {
let date = match query.date {
Some(s) => NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültiges Datum '{s}' (erwartet YYYY-MM-DD): {e}"
)))
})?,
None => chrono::Utc::now().date_naive(),
};
tracing::info!(%date, "admin.import_erp");
let summary = state.import_erp_tours.execute(date).await?;
tracing::info!(
%date,
total = summary.tours_total,
ok = summary.tours_ok,
failed = summary.tours_failed,
"admin.import_erp.done"
);
Ok(Json(summary))
}
#[derive(Debug, Deserialize)]
pub struct PushCompletionQuery {
/// UUID der bereits abgeschlossenen Lieferung.
pub delivery_id: String,
}
/// Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen
/// Lieferabschlusses erneut an (idempotenter Retry, falls der automatische
/// Push beim Abschluss fehlschlug).
#[utoipa::path(
post,
path = "/admin/push-completion",
tag = "admin",
params(
("delivery_id" = String, Query, description = "UUID der abgeschlossenen Lieferung")
),
responses(
(status = 204, description = "Rückschreiben erfolgreich"),
(status = 400, description = "Ungültige delivery_id"),
(status = 401, description = "Admin-API-Key fehlt/ungültig"),
(status = 404, description = "Lieferung nicht gefunden / nicht abgeschlossen"),
(status = 502, description = "ERP nicht erreichbar / Schreibfehler")
),
security(("admin_api_key" = []))
)]
pub async fn push_completion(
State(state): State<AppState>,
Query(query): Query<PushCompletionQuery>,
) -> Result<StatusCode, ApiError> {
let delivery_id = Uuid::parse_str(query.delivery_id.trim()).map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültige delivery_id '{}': {e}",
query.delivery_id
)))
})?;
tracing::info!(%delivery_id, "admin.push_completion");
state.push_completion_to_erp.execute(delivery_id).await?;
tracing::info!(%delivery_id, "admin.push_completion.done");
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
pub struct DeliveredBelegnummernQuery {
/// Ziel-Tag im Format `DD-MM-YYYY`. **Fehlt der Parameter, werden ALLE**
/// offenen (noch nicht versendeten) Belege über alle Tage geliefert — das
/// ist der Modus des Mailclients.
#[serde(default)]
pub day: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct DeliveredBelegnummernResponse {
/// Tag, nach dem gefiltert wurde (ISO `YYYY-MM-DD`), oder `"all"` wenn kein
/// `day` angegeben war.
pub day: String,
/// Anzahl der offenen (noch nicht versendeten) Belege.
pub count: usize,
/// Belegnummern aller **ausgelieferten** (abgeschlossenen) Lieferungen,
/// deren Liefermail noch **nicht versendet** wurde, aufsteigend nach
/// Abschluss-Zeitpunkt.
pub belegnummern: Vec<String>,
}
/// Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen,
/// **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`).
/// „Ausgeliefert" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur
/// Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über
/// alle Tage) — so bleiben Belege über Mitternacht nicht hängen.
#[utoipa::path(
get,
path = "/admin/delivered-belegnummern",
tag = "admin",
params(
("day" = Option<String>, Query, description = "Tag DD-MM-YYYY; ohne Angabe ALLE offenen Belege")
),
responses(
(status = 200, description = "Offene (nicht versendete) Belegnummern", body = DeliveredBelegnummernResponse),
(status = 400, description = "Ungültiger Tag"),
(status = 401, description = "Admin-API-Key fehlt/ungültig")
),
security(("admin_api_key" = []))
)]
pub async fn delivered_belegnummern(
State(state): State<AppState>,
Query(query): Query<DeliveredBelegnummernQuery>,
) -> Result<Json<DeliveredBelegnummernResponse>, ApiError> {
let day: Option<NaiveDate> = match query.day {
Some(s) => Some(NaiveDate::parse_from_str(s.trim(), "%d-%m-%Y").map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültiger Tag '{s}' (erwartet DD-MM-YYYY): {e}"
)))
})?),
None => None,
};
tracing::info!(?day, "admin.delivered_belegnummern");
let belegnummern = state.list_delivered_belegnummern.execute(day).await?;
tracing::info!(?day, count = belegnummern.len(), "admin.delivered_belegnummern.done");
Ok(Json(DeliveredBelegnummernResponse {
day: day.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_else(|| "all".into()),
count: belegnummern.len(),
belegnummern,
}))
}
#[derive(Debug, Deserialize)]
pub struct CompletedDeliveriesQuery {
/// Einzeltag `DD-MM-YYYY` — Kurzform für `from == to == day`. Hat Vorrang
/// vor `from`/`to`, wenn gesetzt.
#[serde(default)]
pub day: Option<String>,
/// Untere Bereichsgrenze `DD-MM-YYYY` (inklusive). Ohne Angabe offen.
#[serde(default)]
pub from: Option<String>,
/// Obere Bereichsgrenze `DD-MM-YYYY` (inklusive). Ohne Angabe offen.
#[serde(default)]
pub to: Option<String>,
}
/// Eine abgeschlossene Lieferung im Tagesabruf.
#[derive(Debug, Serialize, ToSchema)]
pub struct CompletedDeliveryItem {
/// ERP-Belegnummer der Lieferung.
pub belegnummer: String,
/// `true`, wenn an der Lieferung Positionen verändert wurden — eine Zeile
/// wurde entfernt oder in der Menge reduziert (Stück-Gutschrift) **oder** es
/// liegt eine aktive Geld-Gutschrift vor.
pub positions_modified: bool,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct CompletedDeliveriesResponse {
/// Wirksame untere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen).
pub from: Option<String>,
/// Wirksame obere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen).
pub to: Option<String>,
/// Anzahl der abgeschlossenen Lieferungen im Bereich.
pub count: usize,
/// Die abgeschlossenen Lieferungen, aufsteigend nach Abschluss-Zeitpunkt.
pub deliveries: Vec<CompletedDeliveryItem>,
}
/// Parst einen `DD-MM-YYYY`-Tag in ein `NaiveDate` oder liefert `400`.
fn parse_ddmmyyyy(s: &str) -> Result<NaiveDate, ApiError> {
NaiveDate::parse_from_str(s.trim(), "%d-%m-%Y").map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültiges Datum '{s}' (erwartet DD-MM-YYYY): {e}"
)))
})
}
/// Liefert **alle** abgeschlossenen (ausgelieferten) Lieferungen in einem
/// Datumsbereich — unabhängig vom Mail-Versand-Status. Gefiltert wird über den
/// **Berliner** Kalendertag des Abschluss-Zeitpunkts (`completed_at`).
///
/// Parameter (alle `DD-MM-YYYY`): `day` = Einzeltag (Kurzform `from=to=day`),
/// sonst `from`/`to` als **inklusive** Bereichsgrenzen (je optional/offen).
/// Mindestens einer von `day`/`from`/`to` ist erforderlich. Pro Lieferung:
/// Belegnummer + `positions_modified` (Menge reduziert/Zeile entfernt oder
/// Geld-Gutschrift). Die Halb-Grenzen-Variante ist für Range-Filter gedacht:
/// `?from=…` und `?to=…` liefern je eine Menge, deren SQL-`AND`-Schnitt den
/// Zeitraum ergibt.
#[utoipa::path(
get,
path = "/admin/completed-deliveries",
tag = "admin",
params(
("day" = Option<String>, Query, description = "Einzeltag DD-MM-YYYY (Kurzform from=to)"),
("from" = Option<String>, Query, description = "Untere Grenze DD-MM-YYYY (inklusive)"),
("to" = Option<String>, Query, description = "Obere Grenze DD-MM-YYYY (inklusive)")
),
responses(
(status = 200, description = "Abgeschlossene Lieferungen im Bereich", body = CompletedDeliveriesResponse),
(status = 400, description = "Ungültiges Datum oder keine Grenze angegeben"),
(status = 401, description = "Admin-API-Key fehlt/ungültig")
),
security(("admin_api_key" = []))
)]
pub async fn completed_deliveries(
State(state): State<AppState>,
Query(query): Query<CompletedDeliveriesQuery>,
) -> Result<Json<CompletedDeliveriesResponse>, ApiError> {
// `day` ist die Kurzform und hat Vorrang; sonst freie Halb-/Vollgrenzen.
let (from, to) = match query.day.as_deref() {
Some(day) => {
let d = parse_ddmmyyyy(day)?;
(Some(d), Some(d))
}
None => (
query.from.as_deref().map(parse_ddmmyyyy).transpose()?,
query.to.as_deref().map(parse_ddmmyyyy).transpose()?,
),
};
// Unbegrenzt (alles) wäre ein versehentlicher Full-Table-Dump → ablehnen.
if from.is_none() && to.is_none() {
return Err(ApiError(ApplicationError::Validation(
"mindestens einer von `day`, `from`, `to` (DD-MM-YYYY) ist erforderlich".into(),
)));
}
tracing::info!(?from, ?to, "admin.completed_deliveries");
let summaries = state.list_completed_deliveries.execute(from, to).await?;
tracing::info!(?from, ?to, count = summaries.len(), "admin.completed_deliveries.done");
Ok(Json(CompletedDeliveriesResponse {
from: from.map(|d| d.format("%Y-%m-%d").to_string()),
to: to.map(|d| d.format("%Y-%m-%d").to_string()),
count: summaries.len(),
deliveries: summaries
.into_iter()
.map(|s| CompletedDeliveryItem {
belegnummer: s.belegnummer,
positions_modified: s.positions_modified,
})
.collect(),
}))
}
#[derive(Debug, Serialize, ToSchema)]
pub struct PositionsModifiedResponse {
/// ERP-Belegnummer, nach der gefragt wurde.
pub belegnummer: String,
/// `true`, wenn an der Lieferung Positionen verändert wurden — eine Zeile
/// wurde entfernt oder in der Menge reduziert (Stück-Gutschrift) **oder** es
/// liegt eine aktive Geld-Gutschrift vor.
pub positions_modified: bool,
}
/// Liefert zu **einer** ERP-Belegnummer, ob an der Lieferung Positionen
/// verändert wurden (Menge reduziert/Zeile entfernt oder Geld-Gutschrift) —
/// unabhängig vom Zustand der Lieferung. `404`, wenn die Belegnummer unbekannt
/// ist. Gibt es mehrere Lieferungen mit derselben Belegnummer, ist das Flag
/// `true`, sobald **eine** davon verändert ist.
#[utoipa::path(
get,
path = "/admin/belege/{belegnummer}/positions-modified",
tag = "admin",
params(
("belegnummer" = String, Path, description = "ERP-Belegnummer, z. B. V-30690291")
),
responses(
(status = 200, description = "Änderungs-Flag der Belegpositionen", body = PositionsModifiedResponse),
(status = 401, description = "Admin-API-Key fehlt/ungültig"),
(status = 404, description = "Belegnummer unbekannt")
),
security(("admin_api_key" = []))
)]
pub async fn positions_modified(
State(state): State<AppState>,
Path(belegnummer): Path<String>,
) -> Result<Json<PositionsModifiedResponse>, ApiError> {
tracing::info!(%belegnummer, "admin.positions_modified");
let flag = state
.get_positions_modified
.execute(&belegnummer)
.await?
.ok_or(ApiError(ApplicationError::NotFound))?;
tracing::info!(%belegnummer, positions_modified = flag, "admin.positions_modified.done");
Ok(Json(PositionsModifiedResponse {
belegnummer,
positions_modified: flag,
}))
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct MarkMailSentRequest {
/// Belegnummern, deren Liefermail erfolgreich versendet wurde und die als
/// versendet markiert werden sollen.
pub belegnummern: Vec<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct MarkMailSentResponse {
/// Anzahl frisch markierter (vorher offener) Belege. Bereits markierte
/// zählen nicht mit (idempotent).
pub marked: u64,
}
/// Markiert die Liefermails der angegebenen Belegnummern als **versendet**
/// (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen,
/// NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen
/// die Belege nicht mehr in `GET /admin/delivered-belegnummern`.
#[utoipa::path(
post,
path = "/admin/mark-mail-sent",
tag = "admin",
request_body = MarkMailSentRequest,
responses(
(status = 200, description = "Markierung durchgeführt", body = MarkMailSentResponse),
(status = 401, description = "Admin-API-Key fehlt/ungültig")
),
security(("admin_api_key" = []))
)]
pub async fn mark_mail_sent(
State(state): State<AppState>,
Json(body): Json<MarkMailSentRequest>,
) -> Result<Json<MarkMailSentResponse>, ApiError> {
tracing::info!(count = body.belegnummern.len(), "admin.mark_mail_sent");
let marked = state.mark_mail_sent.execute(body.belegnummern).await?;
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)
}