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:
@ -10,6 +10,11 @@ use super::delivery::ScanStatus;
|
||||
/// * `Hold` / `Unhold` ändern nur den Status, keine Menge.
|
||||
/// * `Remove` markiert die Position als entfernt (Status `Removed`,
|
||||
/// z. B. weil der Kunde sie nicht annimmt).
|
||||
/// * `Unremove` hebt ein `Remove` wieder auf — die Position landet
|
||||
/// zurück in `InProgress` (oder `Done`, falls die `scanned_quantity`
|
||||
/// schon `required_quantity` erreicht hatte). Der ursprüngliche
|
||||
/// `Remove`-Audit-Eintrag bleibt unangetastet; das `Unremove` erzeugt
|
||||
/// einen eigenen Audit-Eintrag — die Historie bleibt vollständig.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@ -19,6 +24,7 @@ pub enum AuditAction {
|
||||
Hold,
|
||||
Unhold,
|
||||
Remove,
|
||||
Unremove,
|
||||
}
|
||||
|
||||
/// Append-only Audit-Log-Eintrag: jedes Ereignis am Scan-Zustand einer
|
||||
|
||||
66
crates/domain/src/contact.rs
Normal file
66
crates/domain/src/contact.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Rolle, mit der ein Kontakt-Datensatz an einer Lieferung hängt. Spiegelt
|
||||
/// die fünf Adress-FKs von `Belegkopf` (bzw. den Umweg über den Kunden):
|
||||
/// `header` = Belegadresse, `delivery` = Lieferadresse, `billing` =
|
||||
/// Rechnungsadresse, `contact_person` = Ansprechpartner, `customer_master`
|
||||
/// = Stammadresse des Kunden über `Kunden.AdressId`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContactRole {
|
||||
Header,
|
||||
Delivery,
|
||||
Billing,
|
||||
ContactPerson,
|
||||
CustomerMaster,
|
||||
}
|
||||
|
||||
/// Art eines Kommunikationskanals. `fax` bewusst nicht mitgeführt — in der
|
||||
/// App nicht verwendet.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContactKind {
|
||||
Phone,
|
||||
Mobile,
|
||||
Email,
|
||||
Web,
|
||||
}
|
||||
|
||||
/// Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs
|
||||
/// an einer Lieferung hing — Namensblock ohne Anschrift, weil die Adresse
|
||||
/// ihrerseits schon im Lieferungs-Snapshot steckt (`snap_*`-Spalten). Die
|
||||
/// eigentlichen Telefonnummern, E-Mails etc. liegen in den
|
||||
/// zugehörigen [`ContactChannel`]s.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContactSource {
|
||||
pub id: Uuid,
|
||||
pub delivery_id: Uuid,
|
||||
pub role: ContactRole,
|
||||
pub anrede: Option<String>,
|
||||
pub titel: Option<String>,
|
||||
pub name1: Option<String>,
|
||||
pub name2: Option<String>,
|
||||
pub name3: Option<String>,
|
||||
pub abteilung: Option<String>,
|
||||
pub funktion: Option<String>,
|
||||
}
|
||||
|
||||
/// Ein einzelner Kontaktkanal (Telefonnummer / Mobil / E-Mail / Web).
|
||||
/// Mehrere pro [`ContactSource`] möglich, die `position` hält die
|
||||
/// 1-basierte ERP-Reihenfolge (`Telefon` → 1, `Telefon2` → 2 usw.) fest,
|
||||
/// damit der „primäre" Kanal je Art stabil identifizierbar bleibt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContactChannel {
|
||||
pub id: Uuid,
|
||||
pub source_id: Uuid,
|
||||
pub kind: ContactKind,
|
||||
pub position: i16,
|
||||
pub value: String,
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -20,9 +20,12 @@ mod article;
|
||||
mod audit;
|
||||
mod car;
|
||||
mod common;
|
||||
mod contact;
|
||||
mod customer;
|
||||
mod delivery;
|
||||
mod payment;
|
||||
mod process_state;
|
||||
mod service;
|
||||
mod tour;
|
||||
mod warehouse;
|
||||
|
||||
@ -31,8 +34,13 @@ pub use article::Article;
|
||||
pub use audit::{AuditAction, ScanAuditEntry};
|
||||
pub use car::Car;
|
||||
pub use common::Address;
|
||||
pub use contact::{ContactChannel, ContactKind, ContactRole, ContactSource};
|
||||
pub use customer::{Customer, CustomerContact};
|
||||
pub use delivery::{Delivery, DeliveryItem, DeliveryNote, DeliveryState, ScanState, ScanStatus};
|
||||
pub use delivery::{
|
||||
Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryState, ScanState, ScanStatus,
|
||||
};
|
||||
pub use payment::PaymentMethod;
|
||||
pub use process_state::{DeliveryPhase, DeliveryProcessState};
|
||||
pub use service::{DeliveryServiceValue, Service, ServiceKind};
|
||||
pub use tour::Tour;
|
||||
pub use warehouse::Warehouse;
|
||||
|
||||
30
crates/domain/src/payment.rs
Normal file
30
crates/domain/src/payment.rs
Normal file
@ -0,0 +1,30 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Zahlungs-Stammdatensatz.
|
||||
///
|
||||
/// Bewusst eine Tabelle und kein Enum: neue Anbieter (PayPal, Klarna, …)
|
||||
/// kommen über den `POST /payment-methods`-Endpoint hinzu. Domain-Code
|
||||
/// kann trotzdem fachliche Sonderfälle über den stabilen `code` (z. B.
|
||||
/// `"invoice"` braucht Bonitätsprüfung) referenzieren — die UUID dient
|
||||
/// nur als FK in `deliveries`.
|
||||
///
|
||||
/// `active = false` ist Soft-Delete: die Methode bleibt referenzierbar
|
||||
/// für historische Lieferungen, taucht aber in der UI-Auswahl nicht
|
||||
/// mehr auf. Echtes Löschen ist nur möglich, wenn keine Lieferung sie
|
||||
/// referenziert — Datenbank-Constraint regelt das via
|
||||
/// `ON DELETE RESTRICT`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaymentMethod {
|
||||
pub id: Uuid,
|
||||
/// Stabiler Programm-Identifier — z. B. `"cash"`, `"ec_card"`.
|
||||
/// Eindeutig pro Eintrag. Wird vom Aufrufer beim Anlegen gesetzt.
|
||||
pub code: String,
|
||||
/// Display-Name in der UI — frei via PATCH änderbar.
|
||||
pub name: String,
|
||||
pub active: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
45
crates/domain/src/service.rs
Normal file
45
crates/domain/src/service.rs
Normal file
@ -0,0 +1,45 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Eingabetyp eines Service (früher „Lieferoption"). `Boolean` rendert als
|
||||
/// Checkbox, `Numeric` als Zahlenfeld mit optionalen Grenzen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ServiceKind {
|
||||
Boolean,
|
||||
Numeric,
|
||||
}
|
||||
|
||||
/// Service-Stammdatensatz — admin-konfigurierbar (Muster wie `PaymentMethod`).
|
||||
///
|
||||
/// `key` ist der stabile Programm-Identifier (eindeutig), `name` der
|
||||
/// Anzeige-Name. `min_value`/`max_value` sind nur für `Numeric` relevant.
|
||||
/// `active = false` ist Soft-Delete (bleibt für historische Lieferungen
|
||||
/// referenzierbar, fällt aus dem Default-Listing).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Service {
|
||||
pub id: Uuid,
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub kind: ServiceKind,
|
||||
pub min_value: Option<i32>,
|
||||
pub max_value: Option<i32>,
|
||||
pub active: bool,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
/// Pro-Lieferung gewählter Wert eines Service. Genau einer der beiden
|
||||
/// Wert-Slots ist je nach `ServiceKind` gesetzt; per `service_id`/`delivery_id`
|
||||
/// clientseitig join-bar (wie Notizen/Gutschriften).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryServiceValue {
|
||||
pub delivery_id: Uuid,
|
||||
pub service_id: Uuid,
|
||||
pub bool_value: Option<bool>,
|
||||
pub numeric_value: Option<i32>,
|
||||
}
|
||||
Reference in New Issue
Block a user