Files
Holzleitner---Backend--aktu…/crates/infrastructure/src/persistence/tour_repository.rs
Dennis Nemec 438040acce Initial: Rust-Backend mit Clean Architecture (domain/application/infrastructure/api)
Vier-Crate-Workspace mit:
- Domain: Account, Car, Tour, Delivery, DeliveryItem, DeliveryNote, Customer,
  Article, Warehouse, ScanState, AuditAction — alle mit serde + feature-gated
  utoipa::ToSchema.
- Application: Ports (TourRepository, DeliveryRepository, ScanRepository,
  DeliveryNoteRepository, CarRepository, AuthService) und Use Cases.
- Infrastructure: Postgres-Adapter via sqlx (PgTourRepository etc.) +
  Keycloak-AuthService mit JWKS-Cache + OIDC-Discovery.
- API: Axum 0.8, utoipa-OpenAPI + Swagger-UI, JWT-Bearer-Middleware,
  AuthenticatedUser-Extractor.

Endpoints:
- GET /me/tours/today, /tours/{id}, /accounts/{pn}, /me/cars, /health
- POST /sync/tour, /scans (bulk + idempotent via clientScanId),
  /deliveries/{id}/{hold,resume,cancel,complete,notes}, /me/cars
- PUT /tours/{id}/delivery-order, /deliveries/{id}/assigned-car, /me/cars/{id}
- PATCH /me/cars/{id}

Datenmodell:
- 6 Migrationen (accounts, tours/deliveries/items + Stammdaten,
  scan_audit mit clientScanId-UNIQUE, state_reason refactor,
  delivery_notes, cars + FKs nachziehen).
- Business-stabile Beleg-Keys (belegart_id, belegnummer) für ERP-Sync.
- Append-only scan_audit + embedded scan_state als doppelte Wahrheit.

Dev-Setup:
- docker-compose mit Postgres 17 + Keycloak 26
- Keycloak-Realm 'holzleitner' mit Public-Client (PKCE), Testfahrer
  (PN 1001) + Audience-/Personalnummer-Mapper
2026-05-14 22:28:31 +02:00

816 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Postgres-Implementierung des `TourRepository`-Ports.
//!
//! Drei Operationen, getrennt umgesetzt:
//! * `find_today_for_driver` — eine Query (Tour + Lieferzahl pro Tour).
//! * `find_details_by_id` — pro Aggregat ein paar gezielte Queries,
//! anschließend in-memory zusammenbauen. Bewusst keine eine-Big-Join-
//! Query: das vervielfacht Daten über die Leitung, die wir clientseitig
//! wieder deduplizieren müssten — und Postgres handhabt 57 kleine
//! Prepared Statements im selben Pool effizient genug.
//! * `upsert_from_sync` — eine Transaktion, idempotent per UPSERT auf
//! den fachlichen Keys. Bestehende `scan_state`-Werte bleiben
//! unangetastet: das ERP weiß nichts davon und darf sie nicht
//! überschreiben.
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::{DateTime, NaiveDate, Utc};
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use holzleitner_application::dto::{
DeliveryOrderEntry, DeliveryWithItems, 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,
};
pub struct PgTourRepository {
pool: PgPool,
}
impl PgTourRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
// ===== Row-Typen =========================================================
//
// Eigene FromRow-Strukturen in der Infrastructure halten die Domain-Typen
// frei von `sqlx`-Traits. Mapping in private Helfer.
#[derive(sqlx::FromRow)]
struct TourRow {
id: Uuid,
account_id: i64,
tour_date: NaiveDate,
synced_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct TourSummaryRow {
id: Uuid,
tour_date: NaiveDate,
delivery_count: i64,
}
#[derive(sqlx::FromRow)]
struct DeliveryRow {
id: Uuid,
tour_id: Uuid,
erp_belegart_id: i64,
erp_belegnummer: String,
customer_id: Uuid,
snap_street: String,
snap_house_number: String,
snap_postal_code: String,
snap_city: String,
snap_country: String,
assigned_car_id: Option<Uuid>,
desired_time: Option<String>,
special_agreements: Option<String>,
state: String,
state_reason: Option<String>,
sort_order: i32,
}
#[derive(sqlx::FromRow)]
struct DeliveryItemRow {
id: Uuid,
delivery_id: Uuid,
article_id: Uuid,
required_quantity: i32,
warehouse_id: Uuid,
belegzeilen_nr: i32,
komponenten_artikel_nr: Option<String>,
scanned_quantity: i32,
scan_status: String,
held_reason: Option<String>,
scan_last_updated_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct CustomerRow {
id: Uuid,
erp_customer_id: i64,
name: String,
street: String,
house_number: String,
postal_code: String,
city: String,
country: String,
}
#[derive(sqlx::FromRow)]
struct CustomerContactRow {
id: Uuid,
customer_id: Uuid,
name: String,
phone: Option<String>,
email: Option<String>,
}
#[derive(sqlx::FromRow)]
struct ArticleRow {
id: Uuid,
article_number: String,
name: String,
scannable: bool,
default_warehouse_id: Option<Uuid>,
}
#[derive(sqlx::FromRow)]
struct WarehouseRow {
id: Uuid,
code: String,
name: String,
is_standard: bool,
}
#[derive(sqlx::FromRow)]
struct ContactLinkRow {
delivery_id: Uuid,
customer_contact_id: Uuid,
}
#[derive(sqlx::FromRow)]
struct DeliveryNoteRow {
id: Uuid,
delivery_id: Uuid,
text: Option<String>,
image_attachment: Option<String>,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
created_at: DateTime<Utc>,
}
// ===== Mapping Row -> Domain =============================================
fn map_tour(row: TourRow) -> Tour {
Tour {
id: row.id,
account_id: row.account_id,
date: row.tour_date,
synced_at: row.synced_at,
}
}
fn parse_delivery_state(value: &str) -> Result<DeliveryState, ApplicationError> {
match value {
"active" => Ok(DeliveryState::Active),
"held" => Ok(DeliveryState::Held),
"canceled" => Ok(DeliveryState::Canceled),
"completed" => Ok(DeliveryState::Completed),
other => Err(ApplicationError::Repository(format!(
"unknown delivery state '{other}'"
))),
}
}
fn parse_scan_status(value: &str) -> Result<ScanStatus, ApplicationError> {
match value {
"in_progress" => Ok(ScanStatus::InProgress),
"done" => Ok(ScanStatus::Done),
"held" => Ok(ScanStatus::Held),
"removed" => Ok(ScanStatus::Removed),
other => Err(ApplicationError::Repository(format!(
"unknown scan status '{other}'"
))),
}
}
fn map_item(row: DeliveryItemRow) -> Result<DeliveryItem, ApplicationError> {
Ok(DeliveryItem {
id: row.id,
delivery_id: row.delivery_id,
article_id: row.article_id,
required_quantity: row.required_quantity,
warehouse_id: row.warehouse_id,
belegzeilen_nr: row.belegzeilen_nr,
komponenten_artikel_nr: row.komponenten_artikel_nr,
scan_state: ScanState {
scanned_quantity: row.scanned_quantity,
status: parse_scan_status(&row.scan_status)?,
held_reason: row.held_reason,
last_updated_at: row.scan_last_updated_at,
},
})
}
fn map_customer(row: CustomerRow) -> Customer {
Customer {
id: row.id,
erp_customer_id: row.erp_customer_id,
name: row.name,
address: Address {
street: row.street,
house_number: row.house_number,
postal_code: row.postal_code,
city: row.city,
country: row.country,
},
}
}
fn map_contact(row: CustomerContactRow) -> CustomerContact {
CustomerContact {
id: row.id,
customer_id: row.customer_id,
name: row.name,
phone: row.phone,
email: row.email,
}
}
fn map_article(row: ArticleRow) -> Article {
Article {
id: row.id,
article_number: row.article_number,
name: row.name,
scannable: row.scannable,
default_warehouse_id: row.default_warehouse_id,
}
}
fn map_note(row: DeliveryNoteRow) -> DeliveryNote {
DeliveryNote {
id: row.id,
delivery_id: row.delivery_id,
text: row.text,
image_attachment: row.image_attachment,
author_personalnummer: row.author_personalnummer,
author_car_id: row.author_car_id,
created_at: row.created_at,
}
}
fn map_warehouse(row: WarehouseRow) -> Warehouse {
Warehouse {
id: row.id,
code: row.code,
name: row.name,
is_standard: row.is_standard,
}
}
fn map_delivery(
row: DeliveryRow,
contact_person_ids: Vec<Uuid>,
) -> Result<(Delivery, i32), ApplicationError> {
let state = parse_delivery_state(&row.state)?;
let delivery = Delivery {
id: row.id,
tour_id: row.tour_id,
erp_belegart_id: row.erp_belegart_id,
erp_belegnummer: row.erp_belegnummer,
customer_id: row.customer_id,
delivery_address_snapshot: Address {
street: row.snap_street,
house_number: row.snap_house_number,
postal_code: row.snap_postal_code,
city: row.snap_city,
country: row.snap_country,
},
assigned_car_id: row.assigned_car_id,
contact_person_ids,
desired_time: row.desired_time,
special_agreements: row.special_agreements,
state,
state_reason: row.state_reason,
};
Ok((delivery, row.sort_order))
}
// ===== Helfer: Error-Mapping =============================================
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
// ===== Trait-Implementierung =============================================
#[async_trait]
impl TourRepository for PgTourRepository {
async fn find_today_for_driver(
&self,
personalnummer: i64,
today: NaiveDate,
) -> Result<Vec<TourSummary>, ApplicationError> {
let rows = sqlx::query_as::<_, TourSummaryRow>(
r#"
SELECT t.id, t.tour_date, COUNT(d.id) AS delivery_count
FROM tours t
LEFT JOIN deliveries d ON d.tour_id = t.id
WHERE t.account_id = $1 AND t.tour_date = $2
GROUP BY t.id, t.tour_date
ORDER BY t.tour_date
"#,
)
.bind(personalnummer)
.bind(today)
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(rows
.into_iter()
.map(|r| TourSummary {
tour_id: r.id,
tour_date: r.tour_date,
delivery_count: r.delivery_count,
})
.collect())
}
async fn find_details_by_id(
&self,
tour_id: Uuid,
) -> Result<Option<TourDetails>, ApplicationError> {
// 1. Tour selbst
let Some(tour_row) = sqlx::query_as::<_, TourRow>(
"SELECT id, account_id, tour_date, synced_at FROM tours WHERE id = $1",
)
.bind(tour_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?
else {
return Ok(None);
};
let tour = map_tour(tour_row);
// 2. Lieferungen
let delivery_rows = sqlx::query_as::<_, DeliveryRow>(
r#"
SELECT
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
FROM deliveries
WHERE tour_id = $1
ORDER BY sort_order, erp_belegnummer
"#,
)
.bind(tour_id)
.fetch_all(&self.pool)
.await
.map_err(db)?;
let delivery_ids: Vec<Uuid> = delivery_rows.iter().map(|d| d.id).collect();
// 3. Kontakt-Person-Verknüpfungen
let contact_links = sqlx::query_as::<_, ContactLinkRow>(
r#"
SELECT delivery_id, customer_contact_id
FROM delivery_contact_persons
WHERE delivery_id = ANY($1)
"#,
)
.bind(&delivery_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?;
let mut contacts_per_delivery: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
for link in contact_links {
contacts_per_delivery
.entry(link.delivery_id)
.or_default()
.push(link.customer_contact_id);
}
// 4. Positionen
let item_rows = sqlx::query_as::<_, DeliveryItemRow>(
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
FROM delivery_items
WHERE delivery_id = ANY($1)
ORDER BY delivery_id, belegzeilen_nr, komponenten_artikel_nr NULLS FIRST
"#,
)
.bind(&delivery_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?;
let mut items_per_delivery: HashMap<Uuid, Vec<DeliveryItem>> = HashMap::new();
let mut article_ids = std::collections::BTreeSet::new();
let mut warehouse_ids = std::collections::BTreeSet::new();
for row in item_rows {
article_ids.insert(row.article_id);
warehouse_ids.insert(row.warehouse_id);
let delivery_id = row.delivery_id;
let item = map_item(row)?;
items_per_delivery
.entry(delivery_id)
.or_default()
.push(item);
}
// 5. Lieferungen + Items kombinieren
let mut customer_ids = std::collections::BTreeSet::new();
let mut deliveries = Vec::with_capacity(delivery_rows.len());
for row in delivery_rows {
customer_ids.insert(row.customer_id);
let delivery_id = row.id;
let contact_ids = contacts_per_delivery.remove(&delivery_id).unwrap_or_default();
let items = items_per_delivery.remove(&delivery_id).unwrap_or_default();
let (delivery, sort_order) = map_delivery(row, contact_ids)?;
deliveries.push(DeliveryWithItems {
delivery,
sort_order,
items,
});
}
// 6. Lookup-Stammdaten
let customer_ids_vec: Vec<Uuid> = customer_ids.into_iter().collect();
let customers = sqlx::query_as::<_, CustomerRow>(
r#"
SELECT id, erp_customer_id, name, street, house_number, postal_code, city, country
FROM customers
WHERE id = ANY($1)
ORDER BY name
"#,
)
.bind(&customer_ids_vec)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.map(map_customer)
.collect::<Vec<_>>();
let customer_contacts = sqlx::query_as::<_, CustomerContactRow>(
r#"
SELECT id, customer_id, name, phone, email
FROM customer_contacts
WHERE customer_id = ANY($1)
ORDER BY name
"#,
)
.bind(&customer_ids_vec)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.map(map_contact)
.collect::<Vec<_>>();
let article_ids_vec: Vec<Uuid> = article_ids.into_iter().collect();
let articles = sqlx::query_as::<_, ArticleRow>(
r#"
SELECT id, article_number, name, scannable, default_warehouse_id
FROM articles
WHERE id = ANY($1)
ORDER BY article_number
"#,
)
.bind(&article_ids_vec)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.map(map_article)
.collect::<Vec<_>>();
let warehouse_ids_vec: Vec<Uuid> = warehouse_ids.into_iter().collect();
let warehouses = sqlx::query_as::<_, WarehouseRow>(
r#"
SELECT id, code, name, is_standard
FROM warehouses
WHERE id = ANY($1)
ORDER BY code
"#,
)
.bind(&warehouse_ids_vec)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.map(map_warehouse)
.collect::<Vec<_>>();
// 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
"#,
)
.bind(&delivery_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.map(map_note)
.collect::<Vec<_>>();
Ok(Some(TourDetails {
tour,
deliveries,
customers,
customer_contacts,
articles,
warehouses,
notes,
}))
}
async fn set_delivery_order(
&self,
tour_id: Uuid,
delivery_ids: &[Uuid],
) -> Result<Vec<DeliveryOrderEntry>, ApplicationError> {
let mut tx = self.pool.begin().await.map_err(db)?;
// 1. Lock alle Lieferungen dieser Tour. Liefert leer, wenn Tour
// nicht existiert oder keine Lieferungen hat.
let existing: Vec<Uuid> = sqlx::query_scalar(
"SELECT id FROM deliveries WHERE tour_id = $1 FOR UPDATE",
)
.bind(tour_id)
.fetch_all(&mut *tx)
.await
.map_err(db)?;
if existing.is_empty() {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::NotFound);
}
// 2. Mengen-Match: Input muss exakt der Tour entsprechen.
let existing_set: std::collections::HashSet<Uuid> = existing.iter().copied().collect();
let input_set: std::collections::HashSet<Uuid> = delivery_ids.iter().copied().collect();
if existing_set != input_set {
tx.rollback().await.map_err(db)?;
let fremde: Vec<Uuid> =
input_set.difference(&existing_set).copied().collect();
let fehlende: Vec<Uuid> =
existing_set.difference(&input_set).copied().collect();
return Err(ApplicationError::Validation(format!(
"delivery_ids match nicht zur tour (fehlende: {fehlende:?}, fremde: {fremde:?})"
)));
}
// 3. Bulk-Update via UNNEST.
let positions: Vec<i32> = (1..=delivery_ids.len() as i32).collect();
sqlx::query(
r#"
UPDATE deliveries AS d
SET sort_order = data.new_order
FROM (
SELECT UNNEST($1::uuid[]) AS id,
UNNEST($2::int[]) AS new_order
) AS data
WHERE d.id = data.id
"#,
)
.bind(delivery_ids)
.bind(&positions)
.execute(&mut *tx)
.await
.map_err(db)?;
tx.commit().await.map_err(db)?;
Ok(delivery_ids
.iter()
.zip(positions.iter())
.map(|(id, pos)| DeliveryOrderEntry {
delivery_id: *id,
sort_order: *pos,
})
.collect())
}
async fn upsert_from_sync(
&self,
request: &SyncTourRequest,
) -> Result<Uuid, ApplicationError> {
let mut tx = self.pool.begin().await.map_err(db)?;
// 1. Tour upserten — Identität: (account_id, tour_date)
let tour_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO tours (account_id, tour_date)
VALUES ($1, $2)
ON CONFLICT (account_id, tour_date) DO UPDATE
SET synced_at = now()
RETURNING id
"#,
)
.bind(request.driver_personalnummer)
.bind(request.tour_date)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
for delivery in &request.deliveries {
// 2. Kunde upserten — Identität: erp_customer_id
let customer_id = upsert_customer(&mut tx, delivery).await?;
// 3. Lieferung upserten — Identität: (belegart_id, belegnummer).
// Bestehende Lieferung bleibt mit ihrem state/cancellation
// erhalten; nur Stammdaten + sort_order werden refresht.
let delivery_id = upsert_delivery(&mut tx, tour_id, customer_id, delivery).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?;
upsert_delivery_item(&mut tx, delivery_id, article_id, warehouse_id, item).await?;
}
}
tx.commit().await.map_err(db)?;
Ok(tour_id)
}
}
// ===== Upsert-Helfer =====================================================
async fn upsert_warehouse(
tx: &mut Transaction<'_, Postgres>,
item: &SyncDeliveryItem,
) -> Result<Uuid, ApplicationError> {
let id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO warehouses (code, name)
VALUES ($1, $2)
ON CONFLICT (code) DO UPDATE
SET name = EXCLUDED.name
RETURNING id
"#,
)
.bind(&item.warehouse_code)
.bind(&item.warehouse_name)
.fetch_one(&mut **tx)
.await
.map_err(db)?;
Ok(id)
}
async fn upsert_article(
tx: &mut Transaction<'_, Postgres>,
item: &SyncDeliveryItem,
fallback_warehouse_id: Uuid,
) -> Result<Uuid, ApplicationError> {
// Optional: explizites Default-Lager aus dem ERP. Wenn nicht
// geliefert, nehmen wir das Lager dieser Position als Default — das
// ist eine pragmatische Wahl, die wir später korrigieren können.
let default_warehouse_id = if let Some(code) = &item.article_default_warehouse_code {
sqlx::query_scalar::<_, Uuid>("SELECT id FROM warehouses WHERE code = $1")
.bind(code)
.fetch_optional(&mut **tx)
.await
.map_err(db)?
.unwrap_or(fallback_warehouse_id)
} else {
fallback_warehouse_id
};
let id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO articles (article_number, name, scannable, default_warehouse_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (article_number) DO UPDATE
SET name = EXCLUDED.name,
scannable = EXCLUDED.scannable,
default_warehouse_id = EXCLUDED.default_warehouse_id
RETURNING id
"#,
)
.bind(&item.article_number)
.bind(&item.article_name)
.bind(item.article_scannable)
.bind(default_warehouse_id)
.fetch_one(&mut **tx)
.await
.map_err(db)?;
Ok(id)
}
async fn upsert_customer(
tx: &mut Transaction<'_, Postgres>,
delivery: &SyncDelivery,
) -> Result<Uuid, ApplicationError> {
let id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO customers (
erp_customer_id, name, street, house_number, postal_code, city, country
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (erp_customer_id) DO UPDATE SET
name = EXCLUDED.name,
street = EXCLUDED.street,
house_number = EXCLUDED.house_number,
postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city,
country = EXCLUDED.country
RETURNING id
"#,
)
.bind(delivery.erp_customer_id)
.bind(&delivery.customer_name)
.bind(&delivery.customer_address.street)
.bind(&delivery.customer_address.house_number)
.bind(&delivery.customer_address.postal_code)
.bind(&delivery.customer_address.city)
.bind(&delivery.customer_address.country)
.fetch_one(&mut **tx)
.await
.map_err(db)?;
Ok(id)
}
async fn upsert_delivery(
tx: &mut Transaction<'_, Postgres>,
tour_id: Uuid,
customer_id: Uuid,
delivery: &SyncDelivery,
) -> Result<Uuid, ApplicationError> {
let id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO deliveries (
tour_id, erp_belegart_id, 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)
ON CONFLICT (erp_belegart_id, erp_belegnummer) DO UPDATE SET
tour_id = EXCLUDED.tour_id,
customer_id = EXCLUDED.customer_id,
snap_street = EXCLUDED.snap_street,
snap_house_number = EXCLUDED.snap_house_number,
snap_postal_code = EXCLUDED.snap_postal_code,
snap_city = EXCLUDED.snap_city,
snap_country = EXCLUDED.snap_country,
sort_order = EXCLUDED.sort_order,
desired_time = EXCLUDED.desired_time,
special_agreements = EXCLUDED.special_agreements
RETURNING id
"#,
)
.bind(tour_id)
.bind(delivery.belegart_id)
.bind(&delivery.belegnummer)
.bind(customer_id)
.bind(&delivery.delivery_address.street)
.bind(&delivery.delivery_address.house_number)
.bind(&delivery.delivery_address.postal_code)
.bind(&delivery.delivery_address.city)
.bind(&delivery.delivery_address.country)
.bind(delivery.sort_order)
.bind(delivery.desired_time.as_deref())
.bind(delivery.special_agreements.as_deref())
.fetch_one(&mut **tx)
.await
.map_err(db)?;
Ok(id)
}
async fn upsert_delivery_item(
tx: &mut Transaction<'_, Postgres>,
delivery_id: Uuid,
article_id: Uuid,
warehouse_id: Uuid,
item: &SyncDeliveryItem,
) -> Result<(), ApplicationError> {
// Identität: (delivery_id, belegzeilen_nr, komponenten_artikel_nr).
// scan_state-Felder bleiben beim UPDATE bewusst unberührt — das ERP
// weiß nichts über Scans.
sqlx::query(
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)
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
"#,
)
.bind(delivery_id)
.bind(article_id)
.bind(item.required_quantity)
.bind(warehouse_id)
.bind(item.belegzeilen_nr)
.bind(item.komponenten_artikel_nr.as_deref())
.execute(&mut **tx)
.await
.map_err(db)?;
Ok(())
}