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:
54
crates/infrastructure/src/persistence/account_repository.rs
Normal file
54
crates/infrastructure/src/persistence/account_repository.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
191
crates/infrastructure/src/persistence/car_repository.rs
Normal file
191
crates/infrastructure/src/persistence/car_repository.rs
Normal 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"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
250
crates/infrastructure/src/persistence/delivery_repository.rs
Normal file
250
crates/infrastructure/src/persistence/delivery_repository.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
21
crates/infrastructure/src/persistence/mod.rs
Normal file
21
crates/infrastructure/src/persistence/mod.rs
Normal 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;
|
||||
26
crates/infrastructure/src/persistence/pool.rs
Normal file
26
crates/infrastructure/src/persistence/pool.rs
Normal 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)
|
||||
}
|
||||
319
crates/infrastructure/src/persistence/scan_repository.rs
Normal file
319
crates/infrastructure/src/persistence/scan_repository.rs
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
815
crates/infrastructure/src/persistence/tour_repository.rs
Normal file
815
crates/infrastructure/src/persistence/tour_repository.rs
Normal 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 5–7 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user