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,85 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::DeliveryCredit;
use crate::dto::{CreditAction, DeliveryCreditEventRequest};
use crate::error::ApplicationError;
use crate::ports::{CarRepository, DeliveryCreditRepository};
/// Obergrenze der Betrags-Gutschrift in Cent (150 €).
const MAX_CREDIT_CENTS: i64 = 15_000;
/// Wendet ein Gutschrift-Ereignis (`set`/`remove`) auf eine Lieferung an.
///
/// Validierung (fachlich, ohne DB):
/// * `Set`: Betrag Pflicht, `0 < amount ≤ 150 €` (beliebiger Betrag, keine
/// Schrittweite); Begründung Pflicht (nicht leer).
/// * `author_car_id` muss — falls gesetzt — zum Account gehören.
///
/// Den `active`-Check der Lieferung und die Idempotenz (`client_event_id`)
/// übernimmt das Repository mit der gelockten Zeile.
pub struct ApplyDeliveryCreditEventUseCase {
repository: Arc<dyn DeliveryCreditRepository>,
cars: Arc<dyn CarRepository>,
}
impl ApplyDeliveryCreditEventUseCase {
pub fn new(
repository: Arc<dyn DeliveryCreditRepository>,
cars: Arc<dyn CarRepository>,
) -> Self {
Self { repository, cars }
}
pub async fn execute(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
request: DeliveryCreditEventRequest,
) -> Result<Option<DeliveryCredit>, ApplicationError> {
if let Some(car_id) = request.author_car_id {
self.cars
.assert_owned_by_account(&[car_id], author_personalnummer)
.await?;
}
let (amount_cents, reason) = match request.action {
CreditAction::Set => {
let amount = request.amount_cents.ok_or_else(|| {
ApplicationError::Validation("amount_cents required for set".into())
})?;
if amount <= 0 || amount > MAX_CREDIT_CENTS {
return Err(ApplicationError::Validation(format!(
"amount_cents must be in (0, {MAX_CREDIT_CENTS}]"
)));
}
let reason = request
.reason
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
ApplicationError::Validation("reason required for set".into())
})?
.to_owned();
(amount, Some(reason))
}
// Remove: Betrag/Grund irrelevant.
CreditAction::Remove => (0, None),
};
self.repository
.apply_event(
delivery_id,
request.client_event_id,
request.action,
amount_cents,
reason,
author_personalnummer,
request.author_car_id,
)
.await
}
}

View File

@ -99,8 +99,21 @@ impl ApplyScansUseCase {
}
/// Validiert Pflichtfelder ohne DB-Aufruf. Liefert `Some(reason)`,
/// wenn das Event verworfen werden soll.
/// wenn das Event verworfen werden soll. Mengen- und Status-abhängige
/// Bounds (z. B. `credited + quantity <= required`, scannbar ⇒ done,
/// Lieferung aktiv) prüft erst das Repository mit dem gelockten Item.
fn pre_validate(event: &ScanEvent) -> Option<String> {
// Eine gesetzte Menge muss positiv sein — und ist nur für die
// Mengen-Gutschrift (Remove/Unremove) überhaupt sinnvoll.
if let Some(q) = event.quantity {
match event.action {
AuditAction::Remove | AuditAction::Unremove if q <= 0 => {
return Some("quantity must be > 0".into());
}
_ => {}
}
}
match event.action {
AuditAction::Hold | AuditAction::Remove => {
let trimmed = event.reason.as_deref().map(str::trim).unwrap_or("");
@ -113,6 +126,9 @@ fn pre_validate(event: &ScanEvent) -> Option<String> {
None
}
}
AuditAction::Scan | AuditAction::Unscan | AuditAction::Unhold => None,
AuditAction::Scan
| AuditAction::Unscan
| AuditAction::Unhold
| AuditAction::Unremove => None,
}
}

View File

@ -0,0 +1,115 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::Delivery;
use crate::dto::CompleteDeliveryAcknowledgements;
use crate::error::ApplicationError;
use crate::ports::{
CarRepository, CompleteDeliveryInput, DeliveryCompletionRepository, SignatureRole,
SignatureStorage,
};
use crate::usecases::PushCompletionToErpUseCase;
/// Schließt eine Lieferung ab: speichert beide Unterschriften lokal und
/// schreibt — atomar im Repository — die Abschluss-Zeile + den Statuswechsel
/// auf `completed`.
///
/// Reihenfolge bewusst: erst die fachlichen Vor-Prüfungen ohne DB, dann die
/// Dateien schreiben, dann das Repository (das die DB-abhängigen Gates unter
/// Lock prüft). Schlägt das Repo-Gate fehl, bleiben höchstens die beiden
/// deterministisch benannten PNG-Dateien liegen — ein erneuter Versuch
/// überschreibt sie, es entsteht kein Müll.
pub struct CompleteDeliveryUseCase {
repository: Arc<dyn DeliveryCompletionRepository>,
signatures: Arc<dyn SignatureStorage>,
cars: Arc<dyn CarRepository>,
/// Optionales ERP-Rückschreiben. `None` ⇒ rein lokaler Abschluss
/// (ERP_WRITEBACK_ENABLED=false / Dev / Seed-Daten ohne ERP-Beleg).
erp_push: Option<Arc<PushCompletionToErpUseCase>>,
}
impl CompleteDeliveryUseCase {
pub fn new(
repository: Arc<dyn DeliveryCompletionRepository>,
signatures: Arc<dyn SignatureStorage>,
cars: Arc<dyn CarRepository>,
erp_push: Option<Arc<PushCompletionToErpUseCase>>,
) -> Self {
Self {
repository,
signatures,
cars,
erp_push,
}
}
pub async fn execute(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
acknowledgements: CompleteDeliveryAcknowledgements,
customer_signature_png: Vec<u8>,
driver_signature_png: Vec<u8>,
) -> Result<Delivery, ApplicationError> {
// --- Vor-Prüfungen ohne DB ----------------------------------------
if !acknowledgements.receipt_confirmed {
return Err(ApplicationError::Validation(
"receipt must be confirmed before completion".into(),
));
}
if customer_signature_png.is_empty() {
return Err(ApplicationError::Validation(
"customer signature is required".into(),
));
}
if driver_signature_png.is_empty() {
return Err(ApplicationError::Validation(
"driver signature is required".into(),
));
}
if let Some(car_id) = acknowledgements.author_car_id {
self.cars
.assert_owned_by_account(&[car_id], author_personalnummer)
.await?;
}
// --- Signaturen lokal speichern -----------------------------------
let customer_signature_path = self
.signatures
.save(delivery_id, SignatureRole::Customer, customer_signature_png)
.await?;
let driver_signature_path = self
.signatures
.save(delivery_id, SignatureRole::Driver, driver_signature_png)
.await?;
// --- Atomarer Abschluss im Repository -----------------------------
let delivery = self
.repository
.complete(CompleteDeliveryInput {
delivery_id,
customer_signature_path,
driver_signature_path,
receipt_confirmed: acknowledgements.receipt_confirmed,
notes_acknowledged: acknowledgements.notes_acknowledged,
acknowledged_note_ids: acknowledgements.acknowledged_note_ids,
payment_collected: acknowledgements.payment_collected,
payment_method_id: acknowledgements.payment_method_id,
completed_by_personalnummer: author_personalnummer,
completed_by_car_id: acknowledgements.author_car_id,
})
.await?;
// --- ERP-Rückschreiben (optional, nach lokalem Commit) ------------
// Idempotent → ein Fehler hier lässt den lokalen Abschluss bestehen;
// der Aufrufer bekommt den Fehler (502) und kann via Admin-Endpunkt
// `POST /admin/push-completion` erneut pushen.
if let Some(push) = &self.erp_push {
push.execute(delivery_id).await?;
}
Ok(delivery)
}
}

View File

@ -55,6 +55,8 @@ impl CreateDeliveryNoteUseCase {
request.author_car_id,
text,
image,
request.credit_delivery_item_id,
request.is_amount_credit_note,
)
.await
}

View File

@ -0,0 +1,24 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::DeliveryNoteRepository;
/// Löscht eine Notiz. `NotFound`, wenn keine Zeile betroffen war.
///
/// Berechtigung: keine Autor-Prüfung (geteilter Account) — analog zu
/// [`super::update_delivery_note::UpdateDeliveryNoteUseCase`].
pub struct DeleteDeliveryNoteUseCase {
repository: Arc<dyn DeliveryNoteRepository>,
}
impl DeleteDeliveryNoteUseCase {
pub fn new(repository: Arc<dyn DeliveryNoteRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, note_id: Uuid) -> Result<(), ApplicationError> {
self.repository.delete(note_id).await
}
}

View File

@ -0,0 +1,37 @@
use std::sync::Arc;
use chrono::NaiveDate;
use crate::error::ApplicationError;
use crate::ports::TourRepository;
use crate::usecases::{ImportErpToursUseCase, ImportSummary};
/// **DEV-ONLY**: „Überschreibender" Sync für die lokale Entwicklung.
///
/// Anders als der produktive Import (idempotenter Upsert, der den Scan-/
/// Abschluss-Status bewusst erhält) macht dieser Use Case die Postgres-
/// Tourdaten zuerst **platt** (`delete_all_tours` → FK-Cascade) und importiert
/// dann frisch aus dem ERP. So liefert ein wiederholter Sync desselben Tages in
/// Dev garantiert einen sauberen Stand — ohne Reste aus vorherigen
/// Abschluss-Tests (Status `completed`, Gutschrift-Zeilen, Scans …).
///
/// In Produktion wird das **nicht** verwendet: dort läuft der Sync einmal
/// täglich für den Folgetag (zentral geplante, frische Belege).
pub struct DevResyncToursUseCase {
tours: Arc<dyn TourRepository>,
import: Arc<ImportErpToursUseCase>,
}
impl DevResyncToursUseCase {
pub fn new(tours: Arc<dyn TourRepository>, import: Arc<ImportErpToursUseCase>) -> Self {
Self { tours, import }
}
/// Wischt alle Tourdaten und importiert das Datum neu. Gibt die
/// Import-Zusammenfassung zurück. (Logging übernimmt die API-Schicht.)
pub async fn execute(&self, date: NaiveDate) -> Result<ImportSummary, ApplicationError> {
let _deleted = self.tours.delete_all_tours().await?;
let summary = self.import.execute(date).await?;
Ok(summary)
}
}

View File

@ -0,0 +1,92 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{
AttachmentStorage, DeliveryReportRenderer, DeliveryReportRepository, DeliveryReportSink,
SignatureStorage,
};
/// Erzeugt den PDF-Lieferreport: lädt alle Daten + Audit-Trails, hängt die
/// Bild-Bytes (Unterschriften, Foto-Notizen) aus dem lokalen Speicher an,
/// rendert das PDF und übergibt es dem Sink (lokal ablegen / später DOCUframe).
///
/// Wird sowohl beim Lieferabschluss (best-effort) als auch vom Dev-Endpoint
/// genutzt. Gibt die Sink-Referenz (z. B. den Dateipfad) zurück.
pub struct GenerateDeliveryReportUseCase {
repo: Arc<dyn DeliveryReportRepository>,
renderer: Arc<dyn DeliveryReportRenderer>,
sink: Arc<dyn DeliveryReportSink>,
signatures: Arc<dyn SignatureStorage>,
attachments: Arc<dyn AttachmentStorage>,
}
impl GenerateDeliveryReportUseCase {
pub fn new(
repo: Arc<dyn DeliveryReportRepository>,
renderer: Arc<dyn DeliveryReportRenderer>,
sink: Arc<dyn DeliveryReportSink>,
signatures: Arc<dyn SignatureStorage>,
attachments: Arc<dyn AttachmentStorage>,
) -> Self {
Self {
repo,
renderer,
sink,
signatures,
attachments,
}
}
/// Lädt die Daten, bettet die lokalen Bild-/Signatur-Bytes ein und rendert
/// das PDF **in-memory**. Liefert `(Belegnummer, PDF)`. Wird vom Dev-Sink
/// und von der DOCUframe-Upload-Pipeline genutzt.
pub async fn render_pdf(
&self,
delivery_id: Uuid,
) -> Result<(String, Vec<u8>), ApplicationError> {
let mut data = self
.repo
.load(delivery_id)
.await?
.ok_or(ApplicationError::NotFound)?;
// Unterschriften-Bytes anhängen (best-effort — fehlt eine Datei,
// bleibt das Bild im Report einfach weg).
if let Some(completion) = &data.completion {
data.customer_signature_png = self
.signatures
.load(&completion.customer_signature_path)
.await
.ok()
.flatten();
data.driver_signature_png = self
.signatures
.load(&completion.driver_signature_path)
.await
.ok()
.flatten();
}
// Anhang-Bytes anhängen (best-effort).
for att in data.attachments.iter_mut() {
if let Ok(img) = self
.attachments
.download_preview(&att.reference, "", "1")
.await
{
att.bytes = Some(img.bytes);
}
}
let pdf = self.renderer.render(&data)?;
Ok((data.belegnummer, pdf))
}
pub async fn execute(&self, delivery_id: Uuid) -> Result<String, ApplicationError> {
let (belegnummer, pdf) = self.render_pdf(delivery_id).await?;
let reference = self.sink.deliver(&belegnummer, pdf).await?;
Ok(reference)
}
}

View File

@ -0,0 +1,43 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{AttachmentRepository, AttachmentStorage, PreviewImage};
/// Lädt ein gerendertes Vorschaubild zu einem Attachment.
///
/// Löst unsere Attachment-Id zur DOCUframe-`~ObjectID` auf und holt darüber
/// die Bytes aus dem Speicher. `NotFound`, wenn die Id unbekannt ist.
pub struct GetAttachmentPreviewUseCase {
attachments: Arc<dyn AttachmentRepository>,
storage: Arc<dyn AttachmentStorage>,
}
impl GetAttachmentPreviewUseCase {
pub fn new(
attachments: Arc<dyn AttachmentRepository>,
storage: Arc<dyn AttachmentStorage>,
) -> Self {
Self {
attachments,
storage,
}
}
pub async fn execute(
&self,
id: Uuid,
parameters: String,
page: String,
) -> Result<PreviewImage, ApplicationError> {
let attachment = self
.attachments
.get(id)
.await?
.ok_or(ApplicationError::NotFound)?;
self.storage
.download_preview(&attachment.docuframe_object_id, &parameters, &page)
.await
}
}

View File

@ -0,0 +1,110 @@
use std::sync::Arc;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::error::ApplicationError;
use crate::ports::{DriverIdentityProvisioner, ErpDeliverySource};
use crate::usecases::SyncTourUseCase;
/// Ergebnis eines Import-Laufs — pro Fahrer-Tour Erfolg/Fehler getrennt,
/// damit ein einzelner kaputter Beleg nicht den ganzen Tag blockiert.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ImportSummary {
pub date: NaiveDate,
pub tours_total: usize,
pub tours_ok: usize,
pub tours_failed: usize,
/// Fehlertexte je fehlgeschlagener Fahrer-Tour (z. B. unbekannter Fahrer
/// → FK auf `accounts`, oder Validierungsfehler).
pub errors: Vec<String>,
/// Anzahl der **neu** im Identity-Provider (Keycloak) angelegten
/// Fahrer-Konten in diesem Lauf (0, wenn Provisionierung deaktiviert ist
/// oder alle Konten bereits existierten).
#[serde(default)]
pub drivers_provisioned: usize,
/// Fehlertexte der Konto-Provisionierung (Keycloak). Best-effort: ein
/// Fehler hier blockiert den Touren-Import **nicht**.
#[serde(default)]
pub provisioning_errors: Vec<String>,
}
/// Zieht die Tagestouren eines Datums aus dem ERP und schreibt sie über den
/// **bestehenden** Sync-Pfad (`SyncTourUseCase` → `upsert_from_sync`) in unser
/// Postgres. Damit teilt der Import dieselbe Validierung + Upsert-Logik wie der
/// HTTP-Endpoint `POST /sync/tour` — eine Wahrheit, kein zweiter Schreibweg.
///
/// Fehlertoleranz: jede Fahrer-Tour wird einzeln verarbeitet. Schlägt eine fehl
/// (häufigster Fall: `Vertreter` ist kein angelegter Account → FK-Fehler), wird
/// sie geloggt + übersprungen, der Rest läuft weiter.
pub struct ImportErpToursUseCase {
source: Arc<dyn ErpDeliverySource>,
sync_tour: Arc<SyncTourUseCase>,
/// Optionaler Identity-Provisioner (Keycloak). `None` ⇒ Konto-Anlage
/// deaktiviert (`KEYCLOAK_PROVISIONING_ENABLED=false`).
provisioner: Option<Arc<dyn DriverIdentityProvisioner>>,
}
impl ImportErpToursUseCase {
pub fn new(
source: Arc<dyn ErpDeliverySource>,
sync_tour: Arc<SyncTourUseCase>,
provisioner: Option<Arc<dyn DriverIdentityProvisioner>>,
) -> Self {
Self {
source,
sync_tour,
provisioner,
}
}
pub async fn execute(&self, date: NaiveDate) -> Result<ImportSummary, ApplicationError> {
let tours = self.source.fetch_tours_for_date(date).await?;
let tours_total = tours.len();
let mut tours_ok = 0usize;
let mut errors: Vec<String> = Vec::new();
let mut drivers_provisioned = 0usize;
let mut provisioning_errors: Vec<String> = Vec::new();
for request in tours {
let driver = request.driver_personalnummer;
let deliveries = request.deliveries.len();
match self.sync_tour.execute(request).await {
Ok(_) => {
tours_ok += 1;
// Fahrer-Konto im IdP sicherstellen (best-effort): ein
// Fehler hier wird protokolliert, blockiert aber den Import
// nicht — Logistik geht vor.
if let Some(provisioner) = &self.provisioner {
let name = format!("Fahrer {driver}");
match provisioner.ensure_driver(driver, Some(&name)).await {
Ok(outcome) => {
if outcome.created {
drivers_provisioned += 1;
}
}
Err(e) => {
provisioning_errors.push(format!("driver {driver}: {e}"));
}
}
}
}
Err(e) => {
errors.push(format!("driver {driver} ({deliveries} Lieferungen): {e}"));
}
}
}
Ok(ImportSummary {
date,
tours_total,
tours_ok,
tours_failed: errors.len(),
errors,
drivers_provisioned,
provisioning_errors,
})
}
}

View File

@ -0,0 +1,34 @@
//! Use Case: Belegnummern ausgelieferter (abgeschlossener) Lieferungen
//! auflisten, **deren Liefermail noch nicht versendet wurde**.
//!
//! Reine Lese-Operation für den Admin-/Betriebs-Endpunkt + den externen
//! Mailclient. „Ausgeliefert" = es existiert eine Abschluss-Zeile
//! (`delivery_completions`); „offen" = `mail_sent_at IS NULL`. Optionaler
//! Tagesfilter über den Abschluss-Zeitpunkt (`completed_at`, Zeitzone
//! Europe/Berlin); `None` ⇒ alle offenen Belege. TZ-/Filter-Logik im Repository.
use std::sync::Arc;
use chrono::NaiveDate;
use crate::error::ApplicationError;
use crate::ports::DeliveryCompletionRepository;
pub struct ListDeliveredBelegnummernUseCase {
completions: Arc<dyn DeliveryCompletionRepository>,
}
impl ListDeliveredBelegnummernUseCase {
pub fn new(completions: Arc<dyn DeliveryCompletionRepository>) -> Self {
Self { completions }
}
/// Liefert die Belegnummern offener (noch nicht versendeter) Lieferungen.
/// `Some(day)` ⇒ nur Abschlüsse dieses Tages, `None` ⇒ alle offenen.
pub async fn execute(
&self,
day: Option<NaiveDate>,
) -> Result<Vec<String>, ApplicationError> {
self.completions.list_delivered_belegnummern(day).await
}
}

View File

@ -1,6 +1,6 @@
use std::sync::Arc;
use chrono::Utc;
use chrono::{NaiveDate, Utc};
use crate::dto::TourSummary;
use crate::error::ApplicationError;
@ -9,17 +9,27 @@ use crate::ports::TourRepository;
/// Liste der heutigen Touren des angemeldeten Fahrers. Das "heute"
/// liegt **bewusst im Backend**: die App-Uhr ist nicht autoritativ
/// (Zeitzone, Falsch-Stand, Manipulation).
///
/// `today_override` ist eine **DEV-ONLY**-Hintertür zum Testen mit
/// historischen/importierten Touren: ist sie gesetzt, wird statt der echten
/// Uhr dieses Datum verwendet. In Produktion `None`.
pub struct ListMyToursTodayUseCase {
repository: Arc<dyn TourRepository>,
today_override: Option<NaiveDate>,
}
impl ListMyToursTodayUseCase {
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
Self { repository }
pub fn new(repository: Arc<dyn TourRepository>, today_override: Option<NaiveDate>) -> Self {
Self {
repository,
today_override,
}
}
pub async fn execute(&self, personalnummer: i64) -> Result<Vec<TourSummary>, ApplicationError> {
let today = Utc::now().date_naive();
let today = self
.today_override
.unwrap_or_else(|| Utc::now().date_naive());
self.repository
.find_today_for_driver(personalnummer, today)
.await

View File

@ -0,0 +1,41 @@
//! Use Case: Liefermails von Belegnummern als **versendet** markieren.
//!
//! Wird vom externen Mailclient aufgerufen, NACHDEM ERPframe die Mails für die
//! Belege erfolgreich verschickt hat. Setzt `delivery_completions.mail_sent_at`
//! (nur wo noch NULL → idempotent) und sorgt damit dafür, dass dieselben Belege
//! beim nächsten Poll nicht erneut zurückgegeben werden (server-seitiges Dedup).
use std::sync::Arc;
use crate::error::ApplicationError;
use crate::ports::DeliveryCompletionRepository;
pub struct MarkMailSentUseCase {
completions: Arc<dyn DeliveryCompletionRepository>,
}
impl MarkMailSentUseCase {
pub fn new(completions: Arc<dyn DeliveryCompletionRepository>) -> Self {
Self { completions }
}
/// Markiert die angegebenen Belegnummern als mail-versendet und liefert die
/// Anzahl frisch markierter (vorher offener) Belege zurück. Leere Eingabe
/// ⇒ 0, ohne DB-Zugriff.
pub async fn execute(
&self,
belegnummern: Vec<String>,
) -> Result<u64, ApplicationError> {
self.completions.mark_mail_sent(&belegnummern).await
}
/// **DEV-ONLY**: hebt die Markierung wieder auf (`mail_sent_at = NULL`),
/// sodass die Belege erneut als offen erscheinen. Anzahl zurückgesetzter
/// Belege als Rückgabe.
pub async fn unmark(
&self,
belegnummern: Vec<String>,
) -> Result<u64, ApplicationError> {
self.completions.unmark_mail_sent(&belegnummern).await
}
}

View File

@ -6,23 +6,59 @@
//! entgegen und orchestrieren damit das Domänenmodell.
pub mod apply_delivery_action;
pub mod apply_delivery_credit_event;
pub mod apply_scans;
pub mod cars;
pub mod complete_delivery;
pub mod create_delivery_note;
pub mod delete_delivery_note;
pub mod dev_resync_tours;
pub mod generate_delivery_report;
pub mod get_account;
pub mod get_attachment_preview;
pub mod get_tour;
pub mod import_erp_tours;
pub mod list_delivered_belegnummern;
pub mod list_my_tours_today;
pub mod mark_mail_sent;
pub mod payment_methods;
pub mod process_delivery_report;
pub mod push_completion_to_erp;
pub mod services;
pub mod set_delivery_order;
pub mod sync_tour;
pub mod update_delivery_note;
pub mod upload_delivery_note_image;
pub use apply_delivery_action::ApplyDeliveryActionUseCase;
pub use apply_delivery_credit_event::ApplyDeliveryCreditEventUseCase;
pub use apply_scans::ApplyScansUseCase;
pub use cars::{
AssignCarToDeliveryUseCase, CreateMyCarUseCase, ListMyCarsUseCase, UpdateMyCarUseCase,
};
pub use complete_delivery::CompleteDeliveryUseCase;
pub use create_delivery_note::CreateDeliveryNoteUseCase;
pub use dev_resync_tours::DevResyncToursUseCase;
pub use generate_delivery_report::GenerateDeliveryReportUseCase;
pub use delete_delivery_note::DeleteDeliveryNoteUseCase;
pub use get_account::GetAccountUseCase;
pub use get_attachment_preview::GetAttachmentPreviewUseCase;
pub use get_tour::GetTourUseCase;
pub use import_erp_tours::{ImportErpToursUseCase, ImportSummary};
pub use list_delivered_belegnummern::ListDeliveredBelegnummernUseCase;
pub use list_my_tours_today::ListMyToursTodayUseCase;
pub use mark_mail_sent::MarkMailSentUseCase;
pub use payment_methods::{
CreatePaymentMethodUseCase, DeletePaymentMethodUseCase, ListPaymentMethodsUseCase,
UpdatePaymentMethodUseCase,
};
pub use process_delivery_report::ProcessDeliveryReportUseCase;
pub use push_completion_to_erp::PushCompletionToErpUseCase;
pub use services::{
CreateServiceUseCase, DeleteDeliveryServiceUseCase, DeleteServiceUseCase,
ListServicesUseCase, SetDeliveryServiceUseCase, UpdateServiceUseCase,
};
pub use set_delivery_order::SetDeliveryOrderUseCase;
pub use sync_tour::SyncTourUseCase;
pub use update_delivery_note::UpdateDeliveryNoteUseCase;
pub use upload_delivery_note_image::UploadDeliveryNoteImageUseCase;

View File

@ -0,0 +1,106 @@
//! Use Cases rund um Zahlungs-Stammdaten.
//!
//! Global — keine Account-Isolation, weil Methoden firmenweit gelten.
//! Validierung beschränkt sich auf nicht-leere Strings; Eindeutigkeit
//! des `code` ist DB-Constraint, nicht hier dupliziert.
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::PaymentMethod;
use crate::dto::{CreatePaymentMethodRequest, UpdatePaymentMethodRequest};
use crate::error::ApplicationError;
use crate::ports::PaymentMethodRepository;
pub struct ListPaymentMethodsUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl ListPaymentMethodsUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
include_inactive: bool,
) -> Result<Vec<PaymentMethod>, ApplicationError> {
self.repository.list(include_inactive).await
}
}
pub struct CreatePaymentMethodUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl CreatePaymentMethodUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
request: CreatePaymentMethodRequest,
) -> Result<PaymentMethod, ApplicationError> {
let code = request.code.trim();
let name = request.name.trim();
if code.is_empty() {
return Err(ApplicationError::Validation(
"code darf nicht leer sein".into(),
));
}
if name.is_empty() {
return Err(ApplicationError::Validation(
"name darf nicht leer sein".into(),
));
}
self.repository.create(code, name).await
}
}
pub struct UpdatePaymentMethodUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl UpdatePaymentMethodUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
id: Uuid,
request: UpdatePaymentMethodRequest,
) -> Result<PaymentMethod, ApplicationError> {
if let Some(name) = request.name.as_deref() {
if name.trim().is_empty() {
return Err(ApplicationError::Validation(
"name darf nicht leer sein".into(),
));
}
}
self.repository
.update(
id,
request.name.as_deref().map(str::trim),
request.active,
)
.await
}
}
pub struct DeletePaymentMethodUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl DeletePaymentMethodUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, id: Uuid) -> Result<(), ApplicationError> {
self.repository.delete(id).await
}
}

View File

@ -0,0 +1,122 @@
//! Überträgt den PDF-Lieferreport an DOCUframe — idempotent & resume-fähig.
//!
//! Schritte (Fortschritt nach jedem Schritt hart in `delivery_report_jobs`):
//! 1+2. PDF in-memory rendern → nach DOCUframe hochladen → `~ObjectID` hart
//! speichern (`status = 'uploaded'`). Bei Retry übersprungen, wenn die
//! ObjectId schon vorliegt (kein Doppel-Upload).
//! 3. Makro `_SV_assignDeliveryReport` aufrufen (ordnet Report dem Beleg zu).
//! 4. Erfolg → lokale Dateien aufräumen (Report-PDF, Unterschriften,
//! Bild-Notizen + `deleted_at`), dann `status = 'done'`.
//!
//! Reihenfolge bei Schritt 4: erst aufräumen, dann `done`. Ein Crash dazwischen
//! lässt den Job auf `uploaded` → der Cron ruft das (idempotente) Makro erneut
//! und räumt erneut auf. So bleiben keine verwaisten lokalen Dateien zurück.
//!
//! Fehler in 13 werden im Job vermerkt (`attempts`/`last_error`) und der
//! Status bleibt auf der erreichten Stufe — der Retry-Cron nimmt offene Jobs
//! erneut auf.
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{
AttachmentRepository, AttachmentStorage, DeliveryReportJobRepository, DeliveryReportSink,
DocuframeReportGateway, ReportJobStatus, SignatureStorage,
};
use crate::usecases::GenerateDeliveryReportUseCase;
pub struct ProcessDeliveryReportUseCase {
generate: Arc<GenerateDeliveryReportUseCase>,
jobs: Arc<dyn DeliveryReportJobRepository>,
gateway: Arc<dyn DocuframeReportGateway>,
attachment_repo: Arc<dyn AttachmentRepository>,
attachment_storage: Arc<dyn AttachmentStorage>,
signatures: Arc<dyn SignatureStorage>,
report_sink: Arc<dyn DeliveryReportSink>,
}
impl ProcessDeliveryReportUseCase {
#[allow(clippy::too_many_arguments)]
pub fn new(
generate: Arc<GenerateDeliveryReportUseCase>,
jobs: Arc<dyn DeliveryReportJobRepository>,
gateway: Arc<dyn DocuframeReportGateway>,
attachment_repo: Arc<dyn AttachmentRepository>,
attachment_storage: Arc<dyn AttachmentStorage>,
signatures: Arc<dyn SignatureStorage>,
report_sink: Arc<dyn DeliveryReportSink>,
) -> Self {
Self {
generate,
jobs,
gateway,
attachment_repo,
attachment_storage,
signatures,
report_sink,
}
}
/// Verarbeitet einen Job (anlegen, falls nötig). Fehler werden im Job
/// vermerkt und zusätzlich propagiert (der Aufrufer loggt).
pub async fn execute(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
match self.run(delivery_id).await {
Ok(()) => Ok(()),
Err(e) => {
// Best-effort: Fehler im Job festhalten (für Cron-Retry/Sicht).
let _ = self.jobs.record_error(delivery_id, &e.to_string()).await;
Err(e)
}
}
}
async fn run(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
let belegnummer = self
.attachment_repo
.delivery_belegnummer(delivery_id)
.await?
.ok_or(ApplicationError::NotFound)?;
let job = self.jobs.ensure(delivery_id, &belegnummer).await?;
if matches!(job.status, ReportJobStatus::Done) {
return Ok(());
}
// Schritt 1+2: rendern + hochladen (überspringen, wenn schon erledigt).
let object_id = match job.docuframe_object_id {
Some(oid) => oid,
None => {
let (_beleg, pdf) = self.generate.render_pdf(delivery_id).await?;
let oid = self.gateway.upload_report_pdf(&belegnummer, pdf).await?;
self.jobs.set_uploaded(delivery_id, &oid).await?;
oid
}
};
// Schritt 3: Makro-Zuordnung (muss succeeded == true liefern).
self.gateway.assign_report(&object_id, &belegnummer).await?;
// Schritt 4: erst aufräumen, dann als erledigt markieren.
self.cleanup_local(delivery_id, &belegnummer).await;
self.jobs.mark_done(delivery_id).await?;
Ok(())
}
/// Aufräumen nach erfolgreichem Upload — best-effort (Fehler werden
/// geschluckt; der Report liegt bereits sicher in DOCUframe):
/// * lokale Report-PDFs
/// * Unterschriften (Kunde + Fahrer)
/// * Bild-Notizen (Datei löschen + `deleted_at` setzen, Metadaten bleiben)
async fn cleanup_local(&self, delivery_id: Uuid, belegnummer: &str) {
let _ = self.report_sink.delete(belegnummer).await;
let _ = self.signatures.delete_for_delivery(delivery_id).await;
if let Ok(refs) = self.attachment_repo.list_active_for_delivery(delivery_id).await {
for r in refs {
let _ = self.attachment_storage.delete(&r.reference).await;
let _ = self.attachment_repo.mark_deleted(r.id).await;
}
}
}
}

View File

@ -0,0 +1,59 @@
//! Use Case: einen **bereits lokal abgeschlossenen** Lieferabschluss ins ERP
//! zurückschreiben.
//!
//! Liest den aktuellen Postgres-Stand (ausgelieferte Mengen, Geld-Gutschrift,
//! Abschluss-Zeitpunkt) und spiegelt ihn über den `ErpDeliveryWriteback`-Port
//! in die ERPframe-MSSQL-DB. Bewusst **getrennt** vom lokalen Abschluss:
//!
//! * Der normale Pfad ruft diesen Use Case direkt nach erfolgreichem
//! `complete()` auf (Fehler ⇒ 502, lokal bleibt `completed`).
//! * Der Admin-Retry-Endpunkt ruft denselben Use Case erneut — da das
//! Rückschreiben idempotent ist, ist das gefahrlos.
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{
DeliveryCompletionRepository, ErpDeliveryWriteback, ErpFinishDeliveryCommand, ErpLineQuantity,
};
pub struct PushCompletionToErpUseCase {
completions: Arc<dyn DeliveryCompletionRepository>,
erp: Arc<dyn ErpDeliveryWriteback>,
}
impl PushCompletionToErpUseCase {
pub fn new(
completions: Arc<dyn DeliveryCompletionRepository>,
erp: Arc<dyn ErpDeliveryWriteback>,
) -> Self {
Self { completions, erp }
}
/// Schreibt den Abschluss der Lieferung ins ERP zurück. `NotFound`, wenn
/// die Lieferung nicht abgeschlossen ist; sonstige Fehler reichen den
/// MSSQL-/Repository-Fehler durch.
pub async fn execute(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
let data = self.completions.load_erp_writeback(delivery_id).await?;
let cmd = ErpFinishDeliveryCommand {
belegart_id: data.belegart_id,
belegnummer: data.belegnummer,
delivered_at: data.delivered_at,
lines: data
.lines
.into_iter()
.map(|l| ErpLineQuantity {
belegzeilen_nr: l.belegzeilen_nr,
delivered_quantity: l.delivered_quantity,
})
.collect(),
credit_amount_cents: data.credit_amount_cents,
payment_method_code: data.payment_method_code,
};
self.erp.finish_delivery(cmd).await
}
}

View File

@ -0,0 +1,249 @@
//! Use Cases rund um Services (Stammdaten-CRUD + Pro-Lieferung-Wert).
//!
//! Global — keine Account-Isolation. Eindeutigkeit des `key` ist
//! DB-Constraint; hier nur fachliche Validierung (nicht-leer, kind↔min/max,
//! Wert passend zum Typ + in Grenzen).
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::{DeliveryServiceValue, Service, ServiceKind};
use crate::dto::{CreateServiceRequest, SetDeliveryServiceRequest, UpdateServiceRequest};
use crate::error::ApplicationError;
use crate::ports::{DeliveryServiceRepository, ServiceRepository};
// ─── Stammdaten-CRUD ──────────────────────────────────────────────────────
pub struct ListServicesUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl ListServicesUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
include_inactive: bool,
) -> Result<Vec<Service>, ApplicationError> {
self.repository.list(include_inactive).await
}
}
pub struct CreateServiceUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl CreateServiceUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
request: CreateServiceRequest,
) -> Result<Service, ApplicationError> {
let key = request.key.trim();
let name = request.name.trim();
if key.is_empty() {
return Err(ApplicationError::Validation("key darf nicht leer sein".into()));
}
if name.is_empty() {
return Err(ApplicationError::Validation("name darf nicht leer sein".into()));
}
// boolean trägt keine Grenzen.
let (min_value, max_value) = match request.kind {
ServiceKind::Boolean => {
if request.min_value.is_some() || request.max_value.is_some() {
return Err(ApplicationError::Validation(
"boolean-Service darf keine min/max-Werte haben".into(),
));
}
(None, None)
}
ServiceKind::Numeric => {
if let (Some(min), Some(max)) = (request.min_value, request.max_value) {
if min > max {
return Err(ApplicationError::Validation(
"min_value darf nicht größer als max_value sein".into(),
));
}
}
(request.min_value, request.max_value)
}
};
self.repository
.create(
key,
name,
request.kind,
min_value,
max_value,
request.sort_order.unwrap_or(0),
)
.await
}
}
pub struct UpdateServiceUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl UpdateServiceUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
id: Uuid,
request: UpdateServiceRequest,
) -> Result<Service, ApplicationError> {
if let Some(name) = request.name.as_deref() {
if name.trim().is_empty() {
return Err(ApplicationError::Validation("name darf nicht leer sein".into()));
}
}
if let (Some(min), Some(max)) = (request.min_value, request.max_value) {
if min > max {
return Err(ApplicationError::Validation(
"min_value darf nicht größer als max_value sein".into(),
));
}
}
self.repository
.update(
id,
request.name.as_deref().map(str::trim),
request.min_value,
request.max_value,
request.active,
request.sort_order,
)
.await
}
}
pub struct DeleteServiceUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl DeleteServiceUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, id: Uuid) -> Result<(), ApplicationError> {
self.repository.delete(id).await
}
}
// ─── Pro-Lieferung-Wert ───────────────────────────────────────────────────
pub struct SetDeliveryServiceUseCase {
services: Arc<dyn ServiceRepository>,
delivery_services: Arc<dyn DeliveryServiceRepository>,
}
impl SetDeliveryServiceUseCase {
pub fn new(
services: Arc<dyn ServiceRepository>,
delivery_services: Arc<dyn DeliveryServiceRepository>,
) -> Self {
Self {
services,
delivery_services,
}
}
pub async fn execute(
&self,
delivery_id: Uuid,
service_id: Uuid,
author_personalnummer: i64,
request: SetDeliveryServiceRequest,
) -> Result<DeliveryServiceValue, ApplicationError> {
let service = self
.services
.find_by_id(service_id)
.await?
.ok_or(ApplicationError::NotFound)?;
if !service.active {
return Err(ApplicationError::Validation(
"service is inactive".into(),
));
}
// Wert muss zum Typ passen.
let (bool_value, numeric_value) = match service.kind {
ServiceKind::Boolean => {
let b = request.bool_value.ok_or_else(|| {
ApplicationError::Validation("boolValue required for boolean service".into())
})?;
if request.numeric_value.is_some() {
return Err(ApplicationError::Validation(
"numericValue not allowed for boolean service".into(),
));
}
(Some(b), None)
}
ServiceKind::Numeric => {
let n = request.numeric_value.ok_or_else(|| {
ApplicationError::Validation("numericValue required for numeric service".into())
})?;
if request.bool_value.is_some() {
return Err(ApplicationError::Validation(
"boolValue not allowed for numeric service".into(),
));
}
if let Some(min) = service.min_value {
if n < min {
return Err(ApplicationError::Validation(format!(
"numericValue {n} below min {min}"
)));
}
}
if let Some(max) = service.max_value {
if n > max {
return Err(ApplicationError::Validation(format!(
"numericValue {n} above max {max}"
)));
}
}
(None, Some(n))
}
};
self.delivery_services
.set(
delivery_id,
service_id,
bool_value,
numeric_value,
author_personalnummer,
request.author_car_id,
)
.await
}
}
pub struct DeleteDeliveryServiceUseCase {
delivery_services: Arc<dyn DeliveryServiceRepository>,
}
impl DeleteDeliveryServiceUseCase {
pub fn new(delivery_services: Arc<dyn DeliveryServiceRepository>) -> Self {
Self { delivery_services }
}
pub async fn execute(
&self,
delivery_id: Uuid,
service_id: Uuid,
) -> Result<(), ApplicationError> {
self.delivery_services.delete(delivery_id, service_id).await
}
}

View File

@ -0,0 +1,58 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::DeliveryNote;
use crate::dto::UpdateDeliveryNoteRequest;
use crate::error::ApplicationError;
use crate::ports::DeliveryNoteRepository;
/// Ändert `text` / `image_attachment` einer bestehenden Notiz.
///
/// Validierung wie beim Anlegen: mindestens eines von `text` (nicht-leer
/// nach trim) und `image_attachment` muss gesetzt sein. Autor und
/// `created_at` bleiben unverändert.
///
/// Berechtigung: keine Autor-Prüfung — innerhalb eines (geteilten) Accounts
/// darf jeder Fahrer Notizen pflegen. Das entspricht dem Modell der übrigen
/// Delivery-Aktionen (hold/cancel/complete), die ebenfalls keinen
/// Autor-Bezug erzwingen.
pub struct UpdateDeliveryNoteUseCase {
repository: Arc<dyn DeliveryNoteRepository>,
}
impl UpdateDeliveryNoteUseCase {
pub fn new(repository: Arc<dyn DeliveryNoteRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
note_id: Uuid,
request: UpdateDeliveryNoteRequest,
) -> Result<DeliveryNote, ApplicationError> {
let text = clean(request.text);
let image = clean(request.image_attachment);
if text.is_none() && image.is_none() {
return Err(ApplicationError::Validation(
"notiz braucht text oder image_attachment".into(),
));
}
self.repository.update(note_id, text, image).await
}
}
/// Trim + leerer-String → None.
fn clean(input: Option<String>) -> Option<String> {
input.and_then(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
})
}

View File

@ -0,0 +1,129 @@
use std::sync::Arc;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use holzleitner_domain::DeliveryNote;
use crate::error::ApplicationError;
use crate::ports::{
AttachmentRepository, AttachmentStorage, CarRepository, DeliveryNoteRepository, NewAttachment,
};
/// Lädt ein Bild zu einer Lieferung hoch, registriert dessen Metadaten und
/// legt dafür eine Bild-Notiz an.
///
/// Ablauf:
/// 1. Bytes analysieren (Größe, SHA-256, Bildabmessungen).
/// 2. Belegnummer der Lieferung auflösen (= Ordnername im Speicher).
/// 3. Datei lokal ablegen (`<dir>/<Belegnummer>/<datei>`) → Speicher-Referenz.
/// 4. Metadatensatz in `attachments` anlegen → unsere Attachment-Id.
/// 5. Notiz mit `image_attachment = <attachment_id>` anlegen (kein Text).
///
/// Die App referenziert nur die Attachment-Id; der Download-Endpoint löst sie
/// zur Speicher-Referenz auf. (Der DOCUframe-Upload bleibt im `GsdService`
/// erhalten, ist hier aber nicht mehr verdrahtet — Bilder gehen lokal.)
pub struct UploadDeliveryNoteImageUseCase {
storage: Arc<dyn AttachmentStorage>,
attachments: Arc<dyn AttachmentRepository>,
notes: Arc<dyn DeliveryNoteRepository>,
cars: Arc<dyn CarRepository>,
}
impl UploadDeliveryNoteImageUseCase {
pub fn new(
storage: Arc<dyn AttachmentStorage>,
attachments: Arc<dyn AttachmentRepository>,
notes: Arc<dyn DeliveryNoteRepository>,
cars: Arc<dyn CarRepository>,
) -> Self {
Self {
storage,
attachments,
notes,
cars,
}
}
pub async fn execute(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
filename: String,
mime: String,
bytes: Vec<u8>,
) -> Result<DeliveryNote, ApplicationError> {
if bytes.is_empty() {
return Err(ApplicationError::Validation("leere datei".into()));
}
if let Some(car_id) = author_car_id {
self.cars
.assert_owned_by_account(&[car_id], author_personalnummer)
.await?;
}
// 1. Metadaten aus den Bytes ableiten.
let size_bytes = bytes.len() as i64;
let checksum_sha256 = sha256_hex(&bytes);
let (width, height) = match imagesize::blob_size(&bytes) {
Ok(dim) => (Some(dim.width as i32), Some(dim.height as i32)),
Err(_) => (None, None),
};
// 2. Belegnummer der Lieferung auflösen (= Ordnername im Speicher).
let belegnummer = self
.attachments
.delivery_belegnummer(delivery_id)
.await?
.ok_or(ApplicationError::NotFound)?;
// 3. Bytes lokal ablegen (Ordner = Belegnummer) → Speicher-Referenz.
let storage_reference = self
.storage
.upload(&belegnummer, &filename, &mime, bytes)
.await?;
// 4. Metadatensatz anlegen. `docuframe_object_id` trägt jetzt die
// lokale relative Speicher-Referenz (Spaltenname bleibt vorerst).
let attachment_id = self
.attachments
.create(NewAttachment {
docuframe_object_id: storage_reference,
mime_type: mime,
size_bytes,
filename: Some(filename),
checksum_sha256,
width,
height,
uploaded_by: author_personalnummer,
delivery_id,
})
.await?;
// 5. Bild-Notiz mit Verweis auf den Metadatensatz.
self.notes
.create(
delivery_id,
author_personalnummer,
author_car_id,
None,
Some(attachment_id.to_string()),
None, // Bild-Notiz hat keinen Mengen-Gutschrift-Bezug
false, // und ist keine Betrags-Gutschrift-Notiz
)
.await
}
}
/// SHA-256 der Bytes als Hex-String.
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}