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
This commit is contained in:
Dennis Nemec
2026-05-14 22:28:31 +02:00
commit 438040acce
83 changed files with 8922 additions and 0 deletions

View File

@ -0,0 +1,54 @@
use async_trait::async_trait;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::AccountRepository;
use holzleitner_domain::Account;
use sqlx::PgPool;
/// SQLx-Postgres-Implementierung von [`AccountRepository`].
pub struct PgAccountRepository {
pool: PgPool,
}
impl PgAccountRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
/// Tabellen-Zeile aus `accounts`. Eigener Typ in der Infrastructure-
/// Schicht, damit der Domain-Typ [`Account`] frei von `sqlx`-Traits
/// bleibt.
#[derive(sqlx::FromRow)]
struct AccountRow {
personalnummer: i64,
name: String,
active: bool,
}
impl From<AccountRow> for Account {
fn from(row: AccountRow) -> Self {
Account {
personalnummer: row.personalnummer,
name: row.name,
active: row.active,
}
}
}
#[async_trait]
impl AccountRepository for PgAccountRepository {
async fn find_by_personalnummer(
&self,
personalnummer: i64,
) -> Result<Option<Account>, ApplicationError> {
let row = sqlx::query_as::<_, AccountRow>(
"SELECT personalnummer, name, active FROM accounts WHERE personalnummer = $1",
)
.bind(personalnummer)
.fetch_optional(&self.pool)
.await
.map_err(|e| ApplicationError::Repository(e.to_string()))?;
Ok(row.map(Account::from))
}
}

View File

@ -0,0 +1,191 @@
//! Postgres-Implementierung des `CarRepository`-Ports.
//!
//! Account-Isolation ist strukturell: jede SQL-Query filtert auf
//! `account_id`. Ein Fahrzeug, das einem anderen Account gehört, ist
//! aus Sicht dieses Repositories nicht existent (`NotFound`).
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::CarRepository;
use holzleitner_domain::Car;
pub struct PgCarRepository {
pool: PgPool,
}
impl PgCarRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct CarRow {
id: Uuid,
account_id: i64,
plate: String,
active: bool,
}
impl From<CarRow> for Car {
fn from(r: CarRow) -> Self {
Car {
id: r.id,
account_id: r.account_id,
plate: r.plate,
active: r.active,
}
}
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
/// Postgres-Fehlercode für UNIQUE-Verletzungen — wir mappen sie auf
/// einen lesbaren `Validation`-Fehler.
fn is_unique_violation(err: &sqlx::Error) -> bool {
matches!(
err,
sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23505")
)
}
#[async_trait]
impl CarRepository for PgCarRepository {
async fn find_by_account(
&self,
personalnummer: i64,
include_inactive: bool,
) -> Result<Vec<Car>, ApplicationError> {
let rows = sqlx::query_as::<_, CarRow>(
r#"
SELECT id, account_id, plate, active
FROM cars
WHERE account_id = $1
AND (active = TRUE OR $2)
ORDER BY plate
"#,
)
.bind(personalnummer)
.bind(include_inactive)
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(rows.into_iter().map(Car::from).collect())
}
async fn find_by_id_for_account(
&self,
car_id: Uuid,
personalnummer: i64,
) -> Result<Option<Car>, ApplicationError> {
let row = sqlx::query_as::<_, CarRow>(
"SELECT id, account_id, plate, active FROM cars WHERE id = $1 AND account_id = $2",
)
.bind(car_id)
.bind(personalnummer)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
Ok(row.map(Car::from))
}
async fn create(
&self,
personalnummer: i64,
plate: &str,
) -> Result<Car, ApplicationError> {
let result = sqlx::query_as::<_, CarRow>(
r#"
INSERT INTO cars (account_id, plate)
VALUES ($1, $2)
RETURNING id, account_id, plate, active
"#,
)
.bind(personalnummer)
.bind(plate)
.fetch_one(&self.pool)
.await;
match result {
Ok(row) => Ok(Car::from(row)),
Err(err) if is_unique_violation(&err) => Err(ApplicationError::Validation(format!(
"kennzeichen '{plate}' existiert bereits"
))),
Err(err) => Err(db(err)),
}
}
async fn update(
&self,
car_id: Uuid,
personalnummer: i64,
plate: Option<&str>,
active: Option<bool>,
) -> Result<Car, ApplicationError> {
// COALESCE-Pattern: NULL-Argumente lassen die Spalte unverändert.
let result = sqlx::query_as::<_, CarRow>(
r#"
UPDATE cars
SET plate = COALESCE($3, plate),
active = COALESCE($4, active)
WHERE id = $1 AND account_id = $2
RETURNING id, account_id, plate, active
"#,
)
.bind(car_id)
.bind(personalnummer)
.bind(plate)
.bind(active)
.fetch_optional(&self.pool)
.await;
match result {
Ok(Some(row)) => Ok(Car::from(row)),
Ok(None) => Err(ApplicationError::NotFound),
Err(err) if is_unique_violation(&err) => Err(ApplicationError::Validation(
"kennzeichen existiert bereits".into(),
)),
Err(err) => Err(db(err)),
}
}
async fn assert_owned_by_account(
&self,
car_ids: &[Uuid],
personalnummer: i64,
) -> Result<(), ApplicationError> {
if car_ids.is_empty() {
return Ok(());
}
let owned: Vec<Uuid> = sqlx::query_scalar(
r#"
SELECT id FROM cars
WHERE id = ANY($1) AND account_id = $2
"#,
)
.bind(car_ids)
.bind(personalnummer)
.fetch_all(&self.pool)
.await
.map_err(db)?;
if owned.len() == car_ids.len() {
Ok(())
} else {
let owned_set: std::collections::HashSet<Uuid> = owned.into_iter().collect();
let foreign: Vec<Uuid> = car_ids
.iter()
.copied()
.filter(|id| !owned_set.contains(id))
.collect();
Err(ApplicationError::Validation(format!(
"fahrzeug(e) {foreign:?} gehören nicht zu diesem account"
)))
}
}
}

View File

@ -0,0 +1,76 @@
//! Postgres-Implementierung des `DeliveryNoteRepository`-Ports.
//!
//! Existenz-Check der Lieferung vor dem Insert: liefert einen sauberen
//! `NotFound`, statt den FK-Verletzungs-Fehlertext durchzureichen.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::DeliveryNoteRepository;
use holzleitner_domain::DeliveryNote;
pub struct PgDeliveryNoteRepository {
pool: PgPool,
}
impl PgDeliveryNoteRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
#[async_trait]
impl DeliveryNoteRepository for PgDeliveryNoteRepository {
async fn create(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
text: Option<String>,
image_attachment: Option<String>,
) -> Result<DeliveryNote, ApplicationError> {
let exists: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM deliveries WHERE id = $1")
.bind(delivery_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
if exists.is_none() {
return Err(ApplicationError::NotFound);
}
let (id, created_at): (Uuid, DateTime<Utc>) = sqlx::query_as(
r#"
INSERT INTO delivery_notes (
delivery_id, text, image_attachment, author_personalnummer, author_car_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at
"#,
)
.bind(delivery_id)
.bind(text.as_deref())
.bind(image_attachment.as_deref())
.bind(author_personalnummer)
.bind(author_car_id)
.fetch_one(&self.pool)
.await
.map_err(db)?;
Ok(DeliveryNote {
id,
delivery_id,
text,
image_attachment,
author_personalnummer,
author_car_id,
created_at,
})
}
}

View File

@ -0,0 +1,250 @@
//! Postgres-Implementierung des `DeliveryRepository`-Ports.
//!
//! Ein Aktionsaufruf entspricht genau einer Transaktion. Ablauf:
//! 1. `SELECT … FOR UPDATE` auf die Lieferung (Lock + aktueller State).
//! 2. Übergang validieren — bei invalider Quelle `Validation`-Fehler.
//! 3. `UPDATE deliveries SET state = …, state_reason = …`.
//! 4. Kontaktpersonen-Verknüpfungen laden, frische `Delivery` bauen.
//!
//! Die Validierung lebt **hier**, weil sie den aktuellen DB-State
//! braucht. Im Use Case sitzt nur die Pflicht-Reason-Prüfung.
use async_trait::async_trait;
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{DeliveryAction, DeliveryRepository};
use holzleitner_domain::{Address, Delivery, DeliveryState};
pub struct PgDeliveryRepository {
pool: PgPool,
}
impl PgDeliveryRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[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,
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
fn parse_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 state_str(s: DeliveryState) -> &'static str {
match s {
DeliveryState::Active => "active",
DeliveryState::Held => "held",
DeliveryState::Canceled => "canceled",
DeliveryState::Completed => "completed",
}
}
/// Berechnet Ziel-State + neuen Reason aus aktueller Quelle und Aktion.
/// `Err(reason)` bei invalidem Übergang — wird vom Use Case als
/// `ApplicationError::Validation` durchgereicht.
fn next_state(
current: DeliveryState,
action: &DeliveryAction,
) -> Result<(DeliveryState, Option<String>), String> {
use DeliveryAction as A;
use DeliveryState as S;
match (current, action) {
(S::Active, A::Hold { reason }) => Ok((S::Held, Some(reason.clone()))),
(S::Held, A::Resume) => Ok((S::Active, None)),
(S::Active | S::Held, A::Cancel { reason }) => Ok((S::Canceled, Some(reason.clone()))),
(S::Active, A::Complete) => Ok((S::Completed, None)),
(S::Completed, _) => Err("delivery ist completed".into()),
(S::Canceled, _) => Err("delivery ist canceled".into()),
(s, a) => Err(format!(
"invalider übergang {:?} aus state {:?}",
a, s
)),
}
}
async fn lock_delivery(
tx: &mut Transaction<'_, Postgres>,
delivery_id: Uuid,
) -> Result<Option<DeliveryRow>, ApplicationError> {
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
FROM deliveries
WHERE id = $1
FOR UPDATE
"#,
)
.bind(delivery_id)
.fetch_optional(&mut **tx)
.await
.map_err(db)
}
async fn load_contacts(
tx: &mut Transaction<'_, Postgres>,
delivery_id: Uuid,
) -> Result<Vec<Uuid>, ApplicationError> {
let rows: Vec<(Uuid,)> = sqlx::query_as(
"SELECT customer_contact_id FROM delivery_contact_persons WHERE delivery_id = $1",
)
.bind(delivery_id)
.fetch_all(&mut **tx)
.await
.map_err(db)?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
#[async_trait]
impl DeliveryRepository for PgDeliveryRepository {
async fn apply_action(
&self,
delivery_id: Uuid,
action: DeliveryAction,
) -> Result<Delivery, ApplicationError> {
let mut tx = self.pool.begin().await.map_err(db)?;
let Some(row) = lock_delivery(&mut tx, delivery_id).await? else {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::NotFound);
};
let current = parse_state(&row.state)?;
let (target, new_reason) = match next_state(current, &action) {
Ok(v) => v,
Err(reason) => {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(reason));
}
};
sqlx::query(
r#"
UPDATE deliveries
SET state = $1,
state_reason = $2
WHERE id = $3
"#,
)
.bind(state_str(target))
.bind(new_reason.as_deref())
.bind(delivery_id)
.execute(&mut *tx)
.await
.map_err(db)?;
let contact_person_ids = load_contacts(&mut tx, delivery_id).await?;
tx.commit().await.map_err(db)?;
Ok(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: target,
state_reason: new_reason,
})
}
async fn assign_car(
&self,
delivery_id: Uuid,
car_id: Option<Uuid>,
) -> Result<Delivery, ApplicationError> {
let mut tx = self.pool.begin().await.map_err(db)?;
let Some(row) = lock_delivery(&mut tx, delivery_id).await? else {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::NotFound);
};
let current = parse_state(&row.state)?;
sqlx::query("UPDATE deliveries SET assigned_car_id = $1 WHERE id = $2")
.bind(car_id)
.bind(delivery_id)
.execute(&mut *tx)
.await
.map_err(db)?;
// state_reason holen wir aus dem Lock-Row — die Aktion ändert
// nichts daran.
let state_reason: Option<String> =
sqlx::query_scalar("SELECT state_reason FROM deliveries WHERE id = $1")
.bind(delivery_id)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
let contact_person_ids = load_contacts(&mut tx, delivery_id).await?;
tx.commit().await.map_err(db)?;
Ok(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: car_id,
contact_person_ids,
desired_time: row.desired_time,
special_agreements: row.special_agreements,
state: current,
state_reason,
})
}
}

View File

@ -0,0 +1,21 @@
//! Persistence-Adapter: Postgres via sqlx.
//!
//! Jede Repository-Trait aus `holzleitner_application::ports` bekommt
//! hier eine konkrete `Pg…Repository`-Implementierung. Connection-Pool
//! und Migrations werden ebenfalls hier verwaltet.
pub mod account_repository;
pub mod car_repository;
pub mod delivery_note_repository;
pub mod delivery_repository;
pub mod pool;
pub mod scan_repository;
pub mod tour_repository;
pub use account_repository::PgAccountRepository;
pub use car_repository::PgCarRepository;
pub use delivery_note_repository::PgDeliveryNoteRepository;
pub use delivery_repository::PgDeliveryRepository;
pub use pool::{connect_and_migrate, PoolConfig};
pub use scan_repository::PgScanRepository;
pub use tour_repository::PgTourRepository;

View File

@ -0,0 +1,26 @@
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
/// Konfigurationsparameter für den Postgres-Connection-Pool. Wird
/// typischerweise aus der API-Schicht (Composition Root) befüllt.
#[derive(Debug, Clone)]
pub struct PoolConfig {
pub url: String,
pub max_connections: u32,
}
/// Baut den Postgres-Pool auf und führt alle ausstehenden Migrations
/// aus dem Workspace-Migrations-Verzeichnis aus.
///
/// Die Migration-Definition ist über `sqlx::migrate!()` zur Compile-Zeit
/// eingebettet — kein zusätzlicher Dateizugriff zur Laufzeit nötig.
pub async fn connect_and_migrate(config: &PoolConfig) -> Result<PgPool, sqlx::Error> {
let pool = PgPoolOptions::new()
.max_connections(config.max_connections)
.connect(&config.url)
.await?;
sqlx::migrate!("../../migrations").run(&pool).await?;
Ok(pool)
}

View File

@ -0,0 +1,319 @@
//! Postgres-Implementierung des `ScanRepository`-Ports.
//!
//! Pro Event eine eigene Transaktion. Ablauf:
//! 1. `SELECT … FOR UPDATE` auf das Item (Lock + aktueller State).
//! 2. Zustandsübergang berechnen oder mit `Rejected` aussteigen.
//! 3. `INSERT INTO scan_audit … ON CONFLICT DO NOTHING RETURNING id`
//! * Hat die Insert eine Zeile geliefert → Audit ist frisch,
//! `UPDATE delivery_items` mit neuem State, commit, `Applied`.
//! * Sonst → `client_scan_id` war schon da, rollback der Tx mit dem
//! unveränderten Item, `Duplicate` mit dem unter (1) gelesenen State.
//!
//! Race-frei: zwei parallele Requests mit identischem `client_scan_id`
//! kollidieren an der UNIQUE-Constraint und bekommen sauber genau einen
//! `Applied` und einen `Duplicate`.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use holzleitner_application::dto::ScanEvent;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{ApplyScanOutcome, ScanRepository};
use holzleitner_domain::{AuditAction, ScanState, ScanStatus};
pub struct PgScanRepository {
pool: PgPool,
}
impl PgScanRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
/// Komplette Sicht auf eine Position inklusive ERP-Bezug — was wir
/// für Zustandsübergang und Audit-Insert brauchen, in einer Query.
#[derive(sqlx::FromRow)]
struct ItemLockRow {
id: Uuid,
required_quantity: i32,
scanned_quantity: i32,
scan_status: String,
held_reason: Option<String>,
scan_last_updated_at: DateTime<Utc>,
belegzeilen_nr: i32,
komponenten_artikel_nr: Option<String>,
erp_belegart_id: i64,
erp_belegnummer: String,
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
fn parse_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 status_str(s: ScanStatus) -> &'static str {
match s {
ScanStatus::InProgress => "in_progress",
ScanStatus::Done => "done",
ScanStatus::Held => "held",
ScanStatus::Removed => "removed",
}
}
fn action_str(a: AuditAction) -> &'static str {
match a {
AuditAction::Scan => "scan",
AuditAction::Unscan => "unscan",
AuditAction::Hold => "hold",
AuditAction::Unhold => "unhold",
AuditAction::Remove => "remove",
}
}
/// Ergebnis einer reinen Zustandsübergangs-Rechnung (ohne DB).
struct Transition {
delta: i32,
new_quantity: i32,
new_status: ScanStatus,
new_held_reason: Option<String>,
}
/// Berechnet den nächsten Zustand. Bei `Err` enthält der String die
/// fachliche Ablehnungs-Begründung, die 1:1 an die App geht.
fn apply_transition(
action: AuditAction,
current_qty: i32,
current_status: ScanStatus,
required_qty: i32,
reason: Option<&str>,
) -> Result<Transition, String> {
match action {
AuditAction::Scan => match current_status {
ScanStatus::InProgress | ScanStatus::Done => {
let new_qty = current_qty + 1;
let new_status = if new_qty >= required_qty {
ScanStatus::Done
} else {
ScanStatus::InProgress
};
Ok(Transition {
delta: 1,
new_quantity: new_qty,
new_status,
new_held_reason: None,
})
}
ScanStatus::Held => Err("item is on hold; unhold before scanning".into()),
ScanStatus::Removed => Err("item is removed; cannot scan".into()),
},
AuditAction::Unscan => match current_status {
ScanStatus::InProgress | ScanStatus::Done => {
if current_qty == 0 {
return Err("nothing scanned yet".into());
}
let new_qty = current_qty - 1;
Ok(Transition {
delta: -1,
new_quantity: new_qty,
new_status: ScanStatus::InProgress,
new_held_reason: None,
})
}
ScanStatus::Held => Err("item is on hold".into()),
ScanStatus::Removed => Err("item is removed".into()),
},
AuditAction::Hold => match current_status {
ScanStatus::InProgress | ScanStatus::Done => Ok(Transition {
delta: 0,
new_quantity: current_qty,
new_status: ScanStatus::Held,
new_held_reason: reason.map(str::to_owned),
}),
ScanStatus::Held => Err("item is already held".into()),
ScanStatus::Removed => Err("item is removed".into()),
},
AuditAction::Unhold => match current_status {
ScanStatus::Held => {
let new_status = if current_qty >= required_qty {
ScanStatus::Done
} else {
ScanStatus::InProgress
};
Ok(Transition {
delta: 0,
new_quantity: current_qty,
new_status,
new_held_reason: None,
})
}
_ => Err("item is not held".into()),
},
AuditAction::Remove => match current_status {
ScanStatus::Removed => Err("item is already removed".into()),
_ => Ok(Transition {
delta: 0,
new_quantity: current_qty,
new_status: ScanStatus::Removed,
new_held_reason: reason.map(str::to_owned),
}),
},
}
}
async fn lock_item(
tx: &mut Transaction<'_, Postgres>,
delivery_item_id: Uuid,
) -> Result<Option<ItemLockRow>, ApplicationError> {
sqlx::query_as::<_, ItemLockRow>(
r#"
SELECT
di.id,
di.required_quantity,
di.scanned_quantity,
di.scan_status,
di.held_reason,
di.scan_last_updated_at,
di.belegzeilen_nr,
di.komponenten_artikel_nr,
d.erp_belegart_id,
d.erp_belegnummer
FROM delivery_items di
JOIN deliveries d ON d.id = di.delivery_id
WHERE di.id = $1
FOR UPDATE OF di
"#,
)
.bind(delivery_item_id)
.fetch_optional(&mut **tx)
.await
.map_err(db)
}
#[async_trait]
impl ScanRepository for PgScanRepository {
async fn apply_one(
&self,
event: &ScanEvent,
actor_personalnummer: i64,
) -> Result<ApplyScanOutcome, ApplicationError> {
let mut tx = self.pool.begin().await.map_err(db)?;
let Some(item) = lock_item(&mut tx, event.delivery_item_id).await? else {
tx.rollback().await.map_err(db)?;
return Ok(ApplyScanOutcome::Rejected {
reason: format!("unknown delivery_item_id {}", event.delivery_item_id),
});
};
let current_status = parse_status(&item.scan_status)?;
let current_state = ScanState {
scanned_quantity: item.scanned_quantity,
status: current_status,
held_reason: item.held_reason.clone(),
last_updated_at: item.scan_last_updated_at,
};
let transition = match apply_transition(
event.action,
item.scanned_quantity,
current_status,
item.required_quantity,
event.reason.as_deref(),
) {
Ok(t) => t,
Err(reason) => {
tx.rollback().await.map_err(db)?;
return Ok(ApplyScanOutcome::Rejected { reason });
}
};
// Audit-Insert. ON CONFLICT DO NOTHING macht die Idempotenz: ist
// die client_scan_id schon da, returnen wir keine Zeile.
let inserted_id: Option<Uuid> = sqlx::query_scalar(
r#"
INSERT INTO scan_audit (
client_scan_id, delivery_item_id, action,
delta, resulting_quantity, resulting_status,
reason, actor_personalnummer, actor_car_id, client_scanned_at,
erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr,
erp_komponenten_artikel_nr
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (client_scan_id) DO NOTHING
RETURNING id
"#,
)
.bind(event.client_scan_id)
.bind(item.id)
.bind(action_str(event.action))
.bind(transition.delta)
.bind(transition.new_quantity)
.bind(status_str(transition.new_status))
.bind(transition.new_held_reason.as_deref())
.bind(actor_personalnummer)
.bind(event.actor_car_id)
.bind(event.client_scanned_at)
.bind(item.erp_belegart_id)
.bind(&item.erp_belegnummer)
.bind(item.belegzeilen_nr)
.bind(item.komponenten_artikel_nr.as_deref())
.fetch_optional(&mut *tx)
.await
.map_err(db)?;
if inserted_id.is_none() {
// Duplicate: alles zurück, aktueller State (vor diesem Versuch)
// an die App.
tx.rollback().await.map_err(db)?;
return Ok(ApplyScanOutcome::Duplicate {
delivery_item_id: item.id,
current_state,
});
}
// Applied: Item-State fortschreiben.
let new_last_updated: DateTime<Utc> = sqlx::query_scalar(
r#"
UPDATE delivery_items
SET scanned_quantity = $1,
scan_status = $2,
held_reason = $3,
scan_last_updated_at = now()
WHERE id = $4
RETURNING scan_last_updated_at
"#,
)
.bind(transition.new_quantity)
.bind(status_str(transition.new_status))
.bind(transition.new_held_reason.as_deref())
.bind(item.id)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
tx.commit().await.map_err(db)?;
Ok(ApplyScanOutcome::Applied {
delivery_item_id: item.id,
new_state: ScanState {
scanned_quantity: transition.new_quantity,
status: transition.new_status,
held_reason: transition.new_held_reason,
last_updated_at: new_last_updated,
},
})
}
}

View File

@ -0,0 +1,815 @@
//! 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(())
}