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:
@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user