Backend-Arbeitsstand: ERP-Sync, Lieferlebenszyklus, Reports + config.toml

Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand
(Clean Architecture: domain → application → infrastructure → api).

Wesentliche Bereiche:
- ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben)
- Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen,
  Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe
- Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services
- Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API
- Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte

Jüngste Änderungen dieser Session:
- Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/
  Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/
  E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails)
- Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde)
  umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG

Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/
Kundendaten), demo.mp4, .claude/, variocontrol-ai/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-01 17:52:58 +02:00
parent 438040acce
commit 6a9b5872e1
137 changed files with 13700 additions and 218 deletions

View File

@ -0,0 +1,107 @@
//! Postgres-Implementierung des `AttachmentRepository`-Ports.
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{
AttachmentLocalRef, AttachmentRef, AttachmentRepository, NewAttachment,
};
pub struct PgAttachmentRepository {
pool: PgPool,
}
impl PgAttachmentRepository {
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 AttachmentRepository for PgAttachmentRepository {
async fn create(&self, attachment: NewAttachment) -> Result<Uuid, ApplicationError> {
let id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO attachments (
docuframe_object_id, mime_type, size_bytes, filename,
checksum_sha256, width, height, uploaded_by, delivery_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
"#,
)
.bind(attachment.docuframe_object_id)
.bind(attachment.mime_type)
.bind(attachment.size_bytes)
.bind(attachment.filename)
.bind(attachment.checksum_sha256)
.bind(attachment.width)
.bind(attachment.height)
.bind(attachment.uploaded_by)
.bind(attachment.delivery_id)
.fetch_one(&self.pool)
.await
.map_err(db)?;
Ok(id)
}
async fn get(&self, id: Uuid) -> Result<Option<AttachmentRef>, ApplicationError> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT docuframe_object_id, mime_type FROM attachments WHERE id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
Ok(row.map(|(docuframe_object_id, mime_type)| AttachmentRef {
docuframe_object_id,
mime_type,
}))
}
async fn delivery_belegnummer(
&self,
delivery_id: Uuid,
) -> Result<Option<String>, ApplicationError> {
let belegnummer: Option<String> = sqlx::query_scalar(
"SELECT erp_belegnummer FROM deliveries WHERE id = $1",
)
.bind(delivery_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
Ok(belegnummer)
}
async fn list_active_for_delivery(
&self,
delivery_id: Uuid,
) -> Result<Vec<AttachmentLocalRef>, ApplicationError> {
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"SELECT id, docuframe_object_id FROM attachments \
WHERE delivery_id = $1 AND deleted_at IS NULL",
)
.bind(delivery_id)
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(rows
.into_iter()
.map(|(id, reference)| AttachmentLocalRef { id, reference })
.collect())
}
async fn mark_deleted(&self, id: Uuid) -> Result<(), ApplicationError> {
sqlx::query("UPDATE attachments SET deleted_at = now() WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await
.map_err(db)?;
Ok(())
}
}