Backend-Arbeitsstand: ERP-Sync, Lieferlebenszyklus, Reports + config.toml

Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand
(Clean Architecture: domain → application → infrastructure → api).

Wesentliche Bereiche:
- ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben)
- Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen,
  Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe
- Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services
- Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API
- Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte

Jüngste Änderungen dieser Session:
- Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/
  Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/
  E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails)
- Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde)
  umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG

Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/
Kundendaten), demo.mp4, .claude/, variocontrol-ai/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-01 17:52:58 +02:00
parent 438040acce
commit 6a9b5872e1
137 changed files with 13700 additions and 218 deletions

View File

@ -21,7 +21,10 @@ pub enum DeliveryState {
/// Eine einzelne Lieferung an einen Kunden. Aggregat-Wurzel für die
/// Liefer-Items, Notizen und das ggf. zugeordnete Fahrzeug.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
//
// Kein `Eq`-Derive, weil `prepaid_amount: f64` (Float kennt kein Eq —
// NaN-Verhalten). `PartialEq` reicht für unsere Vergleiche.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct Delivery {
@ -58,6 +61,16 @@ pub struct Delivery {
/// Begründung bei `state == Held` oder `state == Canceled`. Beim
/// Resume / Complete wieder `None`.
pub state_reason: Option<String>,
/// Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der
/// Kunde alles bei Lieferung zahlt. Wird vom ERP-Sync gefüllt.
pub prepaid_amount: f64,
/// Für den Restbetrag gewählte Zahlungsart — FK auf `payment_methods`.
/// Vom Kunden bei Bestellung festgelegt, der Fahrer übernimmt nur
/// die Abwicklung. Aktiv-Flag und Anzeige-Name werden über die
/// Stammdaten-Tabelle aufgelöst, nicht hier embeddet.
pub payment_method_id: Uuid,
}
/// Status einer einzelnen Scan-Position innerhalb eines Items.
@ -80,6 +93,11 @@ pub enum ScanStatus {
#[serde(rename_all = "camelCase")]
pub struct ScanState {
pub scanned_quantity: i32,
/// Als Gutschrift entfernte Menge (0..=required_quantity). Eigene
/// Dimension neben `scanned_quantity`: „wie viele Stück dieser Zeile hat
/// der Kunde nicht angenommen". `status == Removed` entspricht
/// `credited_quantity == required_quantity` (ganze Zeile gutgeschrieben).
pub credited_quantity: i32,
pub status: ScanStatus,
/// Grund bei `status == Held` oder `status == Removed`.
pub held_reason: Option<String>,
@ -92,7 +110,8 @@ pub struct ScanState {
///
/// Über die Felder `belegzeilen_nr` und `komponenten_artikel_nr` bleibt
/// die ERP-Herkunft auflösbar.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
// Kein `Eq`-Derive: `unit_price: f64` kennt kein `Eq`. `PartialEq` reicht.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryItem {
@ -103,6 +122,10 @@ pub struct DeliveryItem {
pub required_quantity: i32,
pub warehouse_id: Uuid,
/// Stückpreis (brutto, EUR) aus dem ERP-Sync. Der Warenwert einer
/// Lieferung = Σ `unit_price` × ausgelieferte Menge.
pub unit_price: f64,
/// ERP-Belegzeilen-Nr (Position innerhalb des Belegs).
pub belegzeilen_nr: i32,
@ -110,9 +133,27 @@ pub struct DeliveryItem {
/// Bei regulären Belegzeilen: `None`.
pub komponenten_artikel_nr: Option<String>,
/// Artikelnummer des Oberartikels, zu dem diese Komponente gehört.
/// `None` bei Oberartikeln/regulären Zeilen — die App rückt Komponenten
/// darüber unter ihrem Oberartikel ein.
pub parent_artikel_nr: Option<String>,
pub scan_state: ScanState,
}
impl DeliveryItem {
/// Tatsächlich auszuliefernde Menge = Soll minus Gutschrift. Nie negativ
/// (die Gutschrift ist per Constraint auf `required_quantity` gedeckelt).
pub fn delivered_quantity(&self) -> i32 {
(self.required_quantity - self.scan_state.credited_quantity).max(0)
}
/// Wert der ausgelieferten Menge dieser Position (brutto, EUR).
pub fn line_total(&self) -> f64 {
self.unit_price * self.delivered_quantity() as f64
}
}
/// Notiz an einer Lieferung — frei eingegeben durch den Fahrer.
///
/// Mindestens eines von `text` oder `image_attachment` muss gesetzt
@ -131,5 +172,35 @@ pub struct DeliveryNote {
pub author_personalnummer: i64,
/// Fahrzeug, falls bekannt — nullable bis das Backend Cars verwaltet.
pub author_car_id: Option<Uuid>,
/// Wenn die Notiz als Gutschrift-Grund zu einer Belegzeile angelegt
/// wurde: deren `DeliveryItem`-Id. Erlaubt dem Client, die Notiz beim
/// Zurücknehmen der Gutschrift (Unremove) gezielt wieder zu löschen.
/// `None` bei normalen Text-/Foto-Notizen.
pub credit_delivery_item_id: Option<Uuid>,
/// `true`, wenn die Notiz den Grund einer **Betrags-Gutschrift**
/// (Geld-Nachlass, Lieferungs-Ebene) dokumentiert. Erlaubt dem Client,
/// sie beim Entfernen der Gutschrift gezielt zu löschen.
pub is_amount_credit_note: bool,
/// `true`, wenn die lokale Bilddatei nach erfolgreichem Report-Upload
/// gelöscht wurde (das Bild steckt nun im Lieferbericht in DOCUframe).
/// Read-only; die App zeigt dann statt der Vorschau einen Hinweis.
/// Bei Text-Notizen / vorhandenem Bild: `false`.
#[serde(default)]
pub image_attachment_deleted: bool,
pub created_at: DateTime<Utc>,
}
/// Aktuelle Betrags-Gutschrift einer Lieferung (Geld-Nachlass, unabhängig von
/// Stückzahl). Abgeleitet aus dem jüngsten Ereignis im append-only
/// `delivery_credit_audit`; existiert nur, solange der letzte Stand `set`
/// (und nicht `remove`) ist. `delivery_id` macht den Eintrag — wie eine
/// Notiz — clientseitig per Lieferung join-bar.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryCredit {
pub delivery_id: Uuid,
/// Gutschrift-Betrag in Cent (> 0, ≤ 15000).
pub amount_cents: i64,
pub reason: String,
}