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:
@ -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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
115
crates/application/src/usecases/complete_delivery.rs
Normal file
115
crates/application/src/usecases/complete_delivery.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -55,6 +55,8 @@ impl CreateDeliveryNoteUseCase {
|
||||
request.author_car_id,
|
||||
text,
|
||||
image,
|
||||
request.credit_delivery_item_id,
|
||||
request.is_amount_credit_note,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
24
crates/application/src/usecases/delete_delivery_note.rs
Normal file
24
crates/application/src/usecases/delete_delivery_note.rs
Normal 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
|
||||
}
|
||||
}
|
||||
37
crates/application/src/usecases/dev_resync_tours.rs
Normal file
37
crates/application/src/usecases/dev_resync_tours.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
92
crates/application/src/usecases/generate_delivery_report.rs
Normal file
92
crates/application/src/usecases/generate_delivery_report.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
43
crates/application/src/usecases/get_attachment_preview.rs
Normal file
43
crates/application/src/usecases/get_attachment_preview.rs
Normal 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, ¶meters, &page)
|
||||
.await
|
||||
}
|
||||
}
|
||||
110
crates/application/src/usecases/import_erp_tours.rs
Normal file
110
crates/application/src/usecases/import_erp_tours.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
41
crates/application/src/usecases/mark_mail_sent.rs
Normal file
41
crates/application/src/usecases/mark_mail_sent.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
106
crates/application/src/usecases/payment_methods.rs
Normal file
106
crates/application/src/usecases/payment_methods.rs
Normal 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
|
||||
}
|
||||
}
|
||||
122
crates/application/src/usecases/process_delivery_report.rs
Normal file
122
crates/application/src/usecases/process_delivery_report.rs
Normal 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 1–3 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
crates/application/src/usecases/push_completion_to_erp.rs
Normal file
59
crates/application/src/usecases/push_completion_to_erp.rs
Normal 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
|
||||
}
|
||||
}
|
||||
249
crates/application/src/usecases/services.rs
Normal file
249
crates/application/src/usecases/services.rs
Normal 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
|
||||
}
|
||||
}
|
||||
58
crates/application/src/usecases/update_delivery_note.rs
Normal file
58
crates/application/src/usecases/update_delivery_note.rs
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
129
crates/application/src/usecases/upload_delivery_note_image.rs
Normal file
129
crates/application/src/usecases/upload_delivery_note_image.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user