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

@ -20,14 +20,15 @@ use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use holzleitner_application::dto::{
DeliveryOrderEntry, DeliveryWithItems, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
TourDetails, TourSummary,
DeliveryOrderEntry, DeliveryWithItems, SyncContactSource, SyncDelivery, SyncDeliveryItem,
SyncTourRequest, TourDetails, TourSummary,
};
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::TourRepository;
use holzleitner_domain::{
Address, Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote,
DeliveryState, ScanState, ScanStatus, Tour, Warehouse,
Address, Article, ContactChannel, ContactKind, ContactRole, ContactSource, Customer,
CustomerContact, Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryServiceValue,
DeliveryState, ScanState, ScanStatus, Service, ServiceKind, Tour, Warehouse,
};
pub struct PgTourRepository {
@ -78,6 +79,8 @@ struct DeliveryRow {
state: String,
state_reason: Option<String>,
sort_order: i32,
prepaid_amount: f64,
payment_method_id: Uuid,
}
#[derive(sqlx::FromRow)]
@ -87,9 +90,12 @@ struct DeliveryItemRow {
article_id: Uuid,
required_quantity: i32,
warehouse_id: Uuid,
unit_price: f64,
belegzeilen_nr: i32,
komponenten_artikel_nr: Option<String>,
parent_artikel_nr: Option<String>,
scanned_quantity: i32,
credited_quantity: i32,
scan_status: String,
held_reason: Option<String>,
scan_last_updated_at: DateTime<Utc>,
@ -139,6 +145,29 @@ struct ContactLinkRow {
customer_contact_id: Uuid,
}
#[derive(sqlx::FromRow)]
struct ContactSourceRow {
id: Uuid,
delivery_id: Uuid,
role: String,
anrede: Option<String>,
titel: Option<String>,
name1: Option<String>,
name2: Option<String>,
name3: Option<String>,
abteilung: Option<String>,
funktion: Option<String>,
}
#[derive(sqlx::FromRow)]
struct ContactChannelRow {
id: Uuid,
source_id: Uuid,
kind: String,
position: i16,
value: String,
}
#[derive(sqlx::FromRow)]
struct DeliveryNoteRow {
id: Uuid,
@ -147,6 +176,9 @@ struct DeliveryNoteRow {
image_attachment: Option<String>,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
credit_delivery_item_id: Option<Uuid>,
is_amount_credit_note: bool,
image_attachment_deleted: bool,
created_at: DateTime<Utc>,
}
@ -192,10 +224,13 @@ fn map_item(row: DeliveryItemRow) -> Result<DeliveryItem, ApplicationError> {
article_id: row.article_id,
required_quantity: row.required_quantity,
warehouse_id: row.warehouse_id,
unit_price: row.unit_price,
belegzeilen_nr: row.belegzeilen_nr,
komponenten_artikel_nr: row.komponenten_artikel_nr,
parent_artikel_nr: row.parent_artikel_nr,
scan_state: ScanState {
scanned_quantity: row.scanned_quantity,
credited_quantity: row.credited_quantity,
status: parse_scan_status(&row.scan_status)?,
held_reason: row.held_reason,
last_updated_at: row.scan_last_updated_at,
@ -228,6 +263,31 @@ fn map_contact(row: CustomerContactRow) -> CustomerContact {
}
}
fn map_contact_source(row: ContactSourceRow) -> Result<ContactSource, ApplicationError> {
Ok(ContactSource {
id: row.id,
delivery_id: row.delivery_id,
role: role_from_db(&row.role)?,
anrede: row.anrede,
titel: row.titel,
name1: row.name1,
name2: row.name2,
name3: row.name3,
abteilung: row.abteilung,
funktion: row.funktion,
})
}
fn map_contact_channel(row: ContactChannelRow) -> Result<ContactChannel, ApplicationError> {
Ok(ContactChannel {
id: row.id,
source_id: row.source_id,
kind: kind_from_db(&row.kind)?,
position: row.position,
value: row.value,
})
}
fn map_article(row: ArticleRow) -> Article {
Article {
id: row.id,
@ -246,10 +306,85 @@ fn map_note(row: DeliveryNoteRow) -> DeliveryNote {
image_attachment: row.image_attachment,
author_personalnummer: row.author_personalnummer,
author_car_id: row.author_car_id,
credit_delivery_item_id: row.credit_delivery_item_id,
is_amount_credit_note: row.is_amount_credit_note,
image_attachment_deleted: row.image_attachment_deleted,
created_at: row.created_at,
}
}
#[derive(sqlx::FromRow)]
struct CreditRow {
delivery_id: Uuid,
action: String,
amount_cents: i64,
reason: Option<String>,
}
/// Aktuelles Gutschrift-Ereignis → Domänenobjekt. `remove` (oder unbekannte
/// Action) liefert `None`, sodass entfernte Gutschriften nicht erscheinen.
fn map_credit(row: CreditRow) -> Option<DeliveryCredit> {
if row.action != "set" {
return None;
}
Some(DeliveryCredit {
delivery_id: row.delivery_id,
amount_cents: row.amount_cents,
reason: row.reason.unwrap_or_default(),
})
}
#[derive(sqlx::FromRow)]
struct ServiceRow {
id: Uuid,
key: String,
name: String,
kind: String,
min_value: Option<i32>,
max_value: Option<i32>,
active: bool,
sort_order: i32,
}
fn map_service(row: ServiceRow) -> Result<Service, ApplicationError> {
let kind = match row.kind.as_str() {
"boolean" => ServiceKind::Boolean,
"numeric" => ServiceKind::Numeric,
other => {
return Err(ApplicationError::Repository(format!(
"unknown service kind '{other}'"
)));
}
};
Ok(Service {
id: row.id,
key: row.key,
name: row.name,
kind,
min_value: row.min_value,
max_value: row.max_value,
active: row.active,
sort_order: row.sort_order,
})
}
#[derive(sqlx::FromRow)]
struct DeliveryServiceRow {
delivery_id: Uuid,
service_id: Uuid,
bool_value: Option<bool>,
numeric_value: Option<i32>,
}
fn map_delivery_service(row: DeliveryServiceRow) -> DeliveryServiceValue {
DeliveryServiceValue {
delivery_id: row.delivery_id,
service_id: row.service_id,
bool_value: row.bool_value,
numeric_value: row.numeric_value,
}
}
fn map_warehouse(row: WarehouseRow) -> Warehouse {
Warehouse {
id: row.id,
@ -283,6 +418,8 @@ fn map_delivery(
special_agreements: row.special_agreements,
state,
state_reason: row.state_reason,
prepaid_amount: row.prepaid_amount,
payment_method_id: row.payment_method_id,
};
Ok((delivery, row.sort_order))
}
@ -352,7 +489,8 @@ impl TourRepository for PgTourRepository {
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
assigned_car_id, desired_time, special_agreements,
state, state_reason, sort_order
state, state_reason, sort_order,
prepaid_amount, payment_method_id
FROM deliveries
WHERE tour_id = $1
ORDER BY sort_order, erp_belegnummer
@ -391,8 +529,9 @@ impl TourRepository for PgTourRepository {
r#"
SELECT
id, delivery_id, article_id, required_quantity, warehouse_id,
belegzeilen_nr, komponenten_artikel_nr,
scanned_quantity, scan_status, held_reason, scan_last_updated_at
unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr,
scanned_quantity, credited_quantity, scan_status, held_reason,
scan_last_updated_at
FROM delivery_items
WHERE delivery_id = ANY($1)
ORDER BY delivery_id, belegzeilen_nr, komponenten_artikel_nr NULLS FIRST
@ -504,11 +643,15 @@ impl TourRepository for PgTourRepository {
// 7. Notizen aller Lieferungen dieser Tour.
let notes = sqlx::query_as::<_, DeliveryNoteRow>(
r#"
SELECT id, delivery_id, text, image_attachment,
author_personalnummer, author_car_id, created_at
FROM delivery_notes
WHERE delivery_id = ANY($1)
ORDER BY delivery_id, created_at
SELECT dn.id, dn.delivery_id, dn.text, dn.image_attachment,
dn.author_personalnummer, dn.author_car_id,
dn.credit_delivery_item_id, dn.is_amount_credit_note,
(att.deleted_at IS NOT NULL) AS image_attachment_deleted,
dn.created_at
FROM delivery_notes dn
LEFT JOIN attachments att ON att.id = dn.image_attachment::uuid
WHERE dn.delivery_id = ANY($1)
ORDER BY dn.delivery_id, dn.created_at
"#,
)
.bind(&delivery_ids)
@ -519,6 +662,99 @@ impl TourRepository for PgTourRepository {
.map(map_note)
.collect::<Vec<_>>();
// 8. Aktuelle Betrags-Gutschriften: jüngstes Ereignis pro Lieferung,
// nur solange der letzte Stand `set` ist.
let credits = sqlx::query_as::<_, CreditRow>(
r#"
SELECT DISTINCT ON (delivery_id)
delivery_id, action, amount_cents, reason
FROM delivery_credit_audit
WHERE delivery_id = ANY($1)
ORDER BY delivery_id, recorded_at DESC, id DESC
"#,
)
.bind(&delivery_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.filter_map(map_credit)
.collect::<Vec<_>>();
// 9. Aktive Service-Definitionen (Stammdaten) — die App rendert daraus
// Phase 4.
let services = sqlx::query_as::<_, ServiceRow>(
r#"
SELECT id, key, name, kind, min_value, max_value, active, sort_order
FROM services
WHERE active = TRUE
ORDER BY sort_order, name
"#,
)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.map(map_service)
.collect::<Result<Vec<_>, _>>()?;
// 10. Pro-Lieferung gesetzte Service-Werte.
let delivery_services = sqlx::query_as::<_, DeliveryServiceRow>(
r#"
SELECT delivery_id, service_id, bool_value, numeric_value
FROM delivery_services
WHERE delivery_id = ANY($1)
"#,
)
.bind(&delivery_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.map(map_delivery_service)
.collect::<Vec<_>>();
// 11. Kontaktdaten-Snapshots aller Lieferungen + ihre Kanäle.
// Reihenfolge: Quellen pro Lieferung nach Rolle, Kanäle pro
// Quelle nach Art und ERP-Position — so kommt „Telefon"
// vor „Telefon2", die App muss nicht extra sortieren.
let source_rows = sqlx::query_as::<_, ContactSourceRow>(
r#"
SELECT id, delivery_id, role,
anrede, titel, name1, name2, name3, abteilung, funktion
FROM delivery_contact_sources
WHERE delivery_id = ANY($1)
ORDER BY delivery_id, role
"#,
)
.bind(&delivery_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?;
let source_ids: Vec<Uuid> = source_rows.iter().map(|r| r.id).collect();
let contact_sources = source_rows
.into_iter()
.map(map_contact_source)
.collect::<Result<Vec<_>, _>>()?;
let channel_rows = sqlx::query_as::<_, ContactChannelRow>(
r#"
SELECT id, source_id, kind, position, value
FROM delivery_contact_channels
WHERE source_id = ANY($1)
ORDER BY source_id, kind, position
"#,
)
.bind(&source_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?;
let contact_channels = channel_rows
.into_iter()
.map(map_contact_channel)
.collect::<Result<Vec<_>, _>>()?;
Ok(Some(TourDetails {
tour,
deliveries,
@ -527,6 +763,11 @@ impl TourRepository for PgTourRepository {
articles,
warehouses,
notes,
credits,
services,
delivery_services,
contact_sources,
contact_channels,
}))
}
@ -603,6 +844,23 @@ impl TourRepository for PgTourRepository {
) -> Result<Uuid, ApplicationError> {
let mut tx = self.pool.begin().await.map_err(db)?;
// 0. Fahrer-/Account-Konto sicherstellen — der ERP-`Vertreter` muss als
// `accounts`-Zeile existieren (FK von `tours`). Auto-Provisionierung:
// fehlende Konten werden mit Default-Namen angelegt; bestehende
// bleiben unangetastet (DO NOTHING überschreibt keinen Namen).
sqlx::query(
r#"
INSERT INTO accounts (personalnummer, name)
VALUES ($1, $2)
ON CONFLICT (personalnummer) DO NOTHING
"#,
)
.bind(request.driver_personalnummer)
.bind(format!("Fahrer {}", request.driver_personalnummer))
.execute(&mut *tx)
.await
.map_err(db)?;
// 1. Tour upserten — Identität: (account_id, tour_date)
let tour_id: Uuid = sqlx::query_scalar(
r#"
@ -628,6 +886,11 @@ impl TourRepository for PgTourRepository {
// erhalten; nur Stammdaten + sort_order werden refresht.
let delivery_id = upsert_delivery(&mut tx, tour_id, customer_id, delivery).await?;
// 3a. Kontaktdaten-Snapshot neu schreiben. Snapshot-Semantik:
// beim Sync wird der Stand vom ERP übernommen, ältere Stände
// verworfen. Der CASCADE-DELETE räumt auch die Channels mit.
replace_contact_sources(&mut tx, delivery_id, &delivery.contact_sources).await?;
for item in &delivery.items {
let warehouse_id = upsert_warehouse(&mut tx, item).await?;
let article_id = upsert_article(&mut tx, item, warehouse_id).await?;
@ -638,6 +901,17 @@ impl TourRepository for PgTourRepository {
tx.commit().await.map_err(db)?;
Ok(tour_id)
}
async fn delete_all_tours(&self) -> Result<u64, ApplicationError> {
// DELETE FROM tours cascadet per FK auf deliveries → delivery_items →
// scan_audit, delivery_notes, delivery_credit_audit, delivery_services,
// delivery_completions, attachments, delivery_contact_persons.
let res = sqlx::query("DELETE FROM tours")
.execute(&self.pool)
.await
.map_err(db)?;
Ok(res.rows_affected())
}
}
// ===== Upsert-Helfer =====================================================
@ -741,15 +1015,39 @@ async fn upsert_delivery(
customer_id: Uuid,
delivery: &SyncDelivery,
) -> Result<Uuid, ApplicationError> {
// Payment-Method-Code → UUID auflösen. Fallback `"cash"` falls vom
// ERP nichts gekommen ist — `"cash"` ist Default-Stamm aus
// Migration 0008 und damit garantiert vorhanden.
let payment_code = delivery
.payment_method_code
.as_deref()
.unwrap_or("cash");
let payment_method_id: Uuid = sqlx::query_scalar(
"SELECT id FROM payment_methods WHERE code = $1",
)
.bind(payment_code)
.fetch_optional(&mut **tx)
.await
.map_err(db)?
.ok_or_else(|| {
ApplicationError::Validation(format!(
"unknown payment method code '{payment_code}'"
))
})?;
let id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO deliveries (
tour_id, erp_belegart_id, erp_belegnummer, customer_id,
tour_id, erp_belegart_id, erp_belegart_code, erp_belegart_name,
erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
sort_order, desired_time, special_agreements
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
sort_order, desired_time, special_agreements,
prepaid_amount, payment_method_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
ON CONFLICT (erp_belegart_id, erp_belegnummer) DO UPDATE SET
tour_id = EXCLUDED.tour_id,
erp_belegart_code = EXCLUDED.erp_belegart_code,
erp_belegart_name = EXCLUDED.erp_belegart_name,
customer_id = EXCLUDED.customer_id,
snap_street = EXCLUDED.snap_street,
snap_house_number = EXCLUDED.snap_house_number,
@ -758,12 +1056,16 @@ async fn upsert_delivery(
snap_country = EXCLUDED.snap_country,
sort_order = EXCLUDED.sort_order,
desired_time = EXCLUDED.desired_time,
special_agreements = EXCLUDED.special_agreements
special_agreements = EXCLUDED.special_agreements,
prepaid_amount = EXCLUDED.prepaid_amount,
payment_method_id = EXCLUDED.payment_method_id
RETURNING id
"#,
)
.bind(tour_id)
.bind(delivery.belegart_id)
.bind(delivery.belegart_code.as_deref())
.bind(delivery.belegart_name.as_deref())
.bind(&delivery.belegnummer)
.bind(customer_id)
.bind(&delivery.delivery_address.street)
@ -774,6 +1076,8 @@ async fn upsert_delivery(
.bind(delivery.sort_order)
.bind(delivery.desired_time.as_deref())
.bind(delivery.special_agreements.as_deref())
.bind(delivery.prepaid_amount)
.bind(payment_method_id)
.fetch_one(&mut **tx)
.await
.map_err(db)?;
@ -794,22 +1098,129 @@ async fn upsert_delivery_item(
r#"
INSERT INTO delivery_items (
delivery_id, article_id, required_quantity, warehouse_id,
belegzeilen_nr, komponenten_artikel_nr
) VALUES ($1, $2, $3, $4, $5, $6)
unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (delivery_id, belegzeilen_nr, komponenten_artikel_nr) DO UPDATE SET
article_id = EXCLUDED.article_id,
required_quantity = EXCLUDED.required_quantity,
warehouse_id = EXCLUDED.warehouse_id
warehouse_id = EXCLUDED.warehouse_id,
unit_price = EXCLUDED.unit_price,
parent_artikel_nr = EXCLUDED.parent_artikel_nr
"#,
)
.bind(delivery_id)
.bind(article_id)
.bind(item.required_quantity)
.bind(warehouse_id)
.bind(item.unit_price)
.bind(item.belegzeilen_nr)
.bind(item.komponenten_artikel_nr.as_deref())
.bind(item.parent_artikel_nr.as_deref())
.execute(&mut **tx)
.await
.map_err(db)?;
Ok(())
}
// ===== Kontaktdaten ======================================================
fn role_to_db(role: ContactRole) -> &'static str {
match role {
ContactRole::Header => "header",
ContactRole::Delivery => "delivery",
ContactRole::Billing => "billing",
ContactRole::ContactPerson => "contact_person",
ContactRole::CustomerMaster => "customer_master",
}
}
fn role_from_db(value: &str) -> Result<ContactRole, ApplicationError> {
match value {
"header" => Ok(ContactRole::Header),
"delivery" => Ok(ContactRole::Delivery),
"billing" => Ok(ContactRole::Billing),
"contact_person" => Ok(ContactRole::ContactPerson),
"customer_master" => Ok(ContactRole::CustomerMaster),
other => Err(ApplicationError::Repository(format!(
"unknown contact role in DB: {other}"
))),
}
}
fn kind_to_db(kind: ContactKind) -> &'static str {
match kind {
ContactKind::Phone => "phone",
ContactKind::Mobile => "mobile",
ContactKind::Email => "email",
ContactKind::Web => "web",
}
}
fn kind_from_db(value: &str) -> Result<ContactKind, ApplicationError> {
match value {
"phone" => Ok(ContactKind::Phone),
"mobile" => Ok(ContactKind::Mobile),
"email" => Ok(ContactKind::Email),
"web" => Ok(ContactKind::Web),
other => Err(ApplicationError::Repository(format!(
"unknown contact kind in DB: {other}"
))),
}
}
/// Snapshot-Refresh: vorhandene Sources der Lieferung löschen (Channels
/// fliegen per ON DELETE CASCADE mit), neue einfügen. Idempotent: leerer
/// Input ⇒ Lieferung hat nach dem Aufruf 0 Sources.
async fn replace_contact_sources(
tx: &mut Transaction<'_, Postgres>,
delivery_id: Uuid,
sources: &[SyncContactSource],
) -> Result<(), ApplicationError> {
sqlx::query("DELETE FROM delivery_contact_sources WHERE delivery_id = $1")
.bind(delivery_id)
.execute(&mut **tx)
.await
.map_err(db)?;
for src in sources {
let source_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO delivery_contact_sources (
delivery_id, role, anrede, titel, name1, name2, name3,
abteilung, funktion
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
"#,
)
.bind(delivery_id)
.bind(role_to_db(src.role))
.bind(src.anrede.as_deref())
.bind(src.titel.as_deref())
.bind(src.name1.as_deref())
.bind(src.name2.as_deref())
.bind(src.name3.as_deref())
.bind(src.abteilung.as_deref())
.bind(src.funktion.as_deref())
.fetch_one(&mut **tx)
.await
.map_err(db)?;
for ch in &src.channels {
sqlx::query(
r#"
INSERT INTO delivery_contact_channels (
source_id, kind, position, value
) VALUES ($1, $2, $3, $4)
"#,
)
.bind(source_id)
.bind(kind_to_db(ch.kind))
.bind(ch.position)
.bind(&ch.value)
.execute(&mut **tx)
.await
.map_err(db)?;
}
}
Ok(())
}