From 6a9b5872e179ed23bfb9d97fd6b01dbbd2e9f003 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Mon, 1 Jun 2026 17:52:58 +0200 Subject: [PATCH] Backend-Arbeitsstand: ERP-Sync, Lieferlebenszyklus, Reports + config.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 24 - .gitignore | 17 +- Cargo.lock | 1098 ++++++++++++++++- Cargo.toml | 14 +- README.md | 37 +- config.example.toml | 92 ++ crates/api/Cargo.toml | 4 +- crates/api/src/config.rs | 617 ++++++++- crates/api/src/error.rs | 1 + crates/api/src/main.rs | 411 +++++- crates/api/src/middleware/admin_key.rs | 102 ++ crates/api/src/middleware/mod.rs | 2 + crates/api/src/openapi.rs | 61 +- crates/api/src/routes/admin.rs | 224 ++++ crates/api/src/routes/attachments.rs | 88 ++ crates/api/src/routes/deliveries.rs | 386 +++++- crates/api/src/routes/dev.rs | 160 +++ crates/api/src/routes/mod.rs | 5 + crates/api/src/routes/payment_methods.rs | 142 +++ crates/api/src/routes/services.rs | 133 ++ crates/api/src/state.rs | 53 +- crates/application/Cargo.toml | 2 + crates/application/src/dto/complete.rs | 40 + crates/application/src/dto/credit.rs | 45 + crates/application/src/dto/delivery_report.rs | 149 +++ crates/application/src/dto/mod.rs | 27 +- crates/application/src/dto/note.rs | 21 + crates/application/src/dto/payment_method.rs | 41 + crates/application/src/dto/scan.rs | 13 + crates/application/src/dto/service.rs | 73 ++ crates/application/src/dto/tour_details.rs | 21 +- crates/application/src/dto/tour_sync.rs | 74 +- crates/application/src/error.rs | 5 + .../src/ports/attachment_repository.rs | 71 ++ .../src/ports/attachment_storage.rs | 54 + .../ports/delivery_completion_repository.rs | 131 ++ .../src/ports/delivery_credit_repository.rs | 36 + .../src/ports/delivery_note_repository.rs | 21 +- .../ports/delivery_report_job_repository.rs | 89 ++ .../src/ports/delivery_report_renderer.rs | 10 + .../src/ports/delivery_report_repository.rs | 19 + .../src/ports/delivery_report_sink.rs | 19 + .../src/ports/delivery_service_repository.rs | 36 + .../src/ports/docuframe_report_gateway.rs | 33 + .../src/ports/driver_identity_provisioner.rs | 36 + .../src/ports/erp_delivery_source.rs | 24 + .../src/ports/erp_delivery_writeback.rs | 64 + crates/application/src/ports/mod.rs | 40 + .../src/ports/payment_method_repository.rs | 54 + .../src/ports/service_repository.rs | 44 + .../src/ports/signature_storage.rs | 54 + .../application/src/ports/tour_repository.rs | 6 + .../usecases/apply_delivery_credit_event.rs | 85 ++ .../application/src/usecases/apply_scans.rs | 20 +- .../src/usecases/complete_delivery.rs | 115 ++ .../src/usecases/create_delivery_note.rs | 2 + .../src/usecases/delete_delivery_note.rs | 24 + .../src/usecases/dev_resync_tours.rs | 37 + .../src/usecases/generate_delivery_report.rs | 92 ++ .../src/usecases/get_attachment_preview.rs | 43 + .../src/usecases/import_erp_tours.rs | 110 ++ .../usecases/list_delivered_belegnummern.rs | 34 + .../src/usecases/list_my_tours_today.rs | 18 +- .../src/usecases/mark_mail_sent.rs | 41 + crates/application/src/usecases/mod.rs | 36 + .../src/usecases/payment_methods.rs | 106 ++ .../src/usecases/process_delivery_report.rs | 122 ++ .../src/usecases/push_completion_to_erp.rs | 59 + crates/application/src/usecases/services.rs | 249 ++++ .../src/usecases/update_delivery_note.rs | 58 + .../usecases/upload_delivery_note_image.rs | 129 ++ crates/domain/src/audit.rs | 6 + crates/domain/src/contact.rs | 66 + crates/domain/src/delivery.rs | 75 +- crates/domain/src/lib.rs | 10 +- crates/domain/src/payment.rs | 30 + crates/domain/src/service.rs | 45 + crates/infrastructure/Cargo.toml | 7 + .../infrastructure/src/auth/keycloak_admin.rs | 298 +++++ crates/infrastructure/src/auth/mod.rs | 2 + crates/infrastructure/src/erp/mod.rs | 11 + .../src/erp/mssql_delivery_source.rs | 586 +++++++++ .../src/erp/mssql_delivery_writeback.rs | 419 +++++++ crates/infrastructure/src/gsd/dto.rs | 100 ++ crates/infrastructure/src/gsd/mod.rs | 6 + crates/infrastructure/src/gsd/service.rs | 539 ++++++++ crates/infrastructure/src/lib.rs | 4 + .../src/persistence/attachment_repository.rs | 107 ++ .../delivery_completion_repository.rs | 515 ++++++++ .../persistence/delivery_credit_repository.rs | 139 +++ .../persistence/delivery_note_repository.rs | 89 +- .../delivery_report_job_repository.rs | 150 +++ .../src/persistence/delivery_repository.rs | 18 +- .../delivery_service_repository.rs | 111 ++ crates/infrastructure/src/persistence/mod.rs | 14 + .../persistence/payment_method_repository.rs | 174 +++ .../src/persistence/scan_repository.rs | 336 ++++- .../src/persistence/service_repository.rs | 196 +++ .../src/persistence/tour_repository.rs | 449 ++++++- crates/infrastructure/src/report/mod.rs | 10 + crates/infrastructure/src/report/renderer.rs | 699 +++++++++++ .../infrastructure/src/report/repository.rs | 485 ++++++++ crates/infrastructure/src/report/sink.rs | 102 ++ .../src/storage/local_attachment_storage.rs | 169 +++ crates/infrastructure/src/storage/mod.rs | 11 + .../src/storage/signature_storage.rs | 99 ++ docker-compose.yml | 27 +- keycloak/import/realm-holzleitner.json | 51 +- migrations/0007_scan_audit_unremove.sql | 17 + migrations/0008_payment_methods.sql | 57 + migrations/0009_warehouse_filiale_rename.sql | 16 + migrations/0010_app_state.sql | 18 + migrations/0011_attachments.sql | 34 + migrations/0012_credit_quantity.sql | 35 + migrations/0013_note_credit_link.sql | 15 + migrations/0014_delivery_credit_audit.sql | 43 + migrations/0015_note_amount_credit_flag.sql | 11 + migrations/0016_services.sql | 65 + migrations/0017_seed_real_services.sql | 32 + migrations/0018_delivery_total_amount.sql | 11 + migrations/0019_item_unit_price.sql | 16 + migrations/0020_delivery_completions.sql | 40 + migrations/0021_remove_credit_card.sql | 18 + migrations/0022_scan_audit_manual.sql | 12 + migrations/0023_item_parent_artikel_nr.sql | 15 + .../0024_completion_payment_collected.sql | 20 + migrations/0025_delivery_belegart_code.sql | 18 + migrations/0026_delivery_report_jobs.sql | 40 + migrations/0027_attachment_deleted.sql | 14 + migrations/0028_completion_mail_sent.sql | 20 + migrations/0029_delivery_contact_sources.sql | 62 + tool/dev_usb.sh | 79 ++ tool/mark_all_scanned.sh | 97 ++ tool/mark_standard_scanned.sh | 101 ++ tool/reseed_today.sh | 70 ++ tool/reset_test_tour.sh | 85 ++ tool/seed_demo_data.sh | 299 +++++ 137 files changed, 13700 insertions(+), 218 deletions(-) delete mode 100644 .env.example create mode 100644 config.example.toml create mode 100644 crates/api/src/middleware/admin_key.rs create mode 100644 crates/api/src/routes/admin.rs create mode 100644 crates/api/src/routes/attachments.rs create mode 100644 crates/api/src/routes/dev.rs create mode 100644 crates/api/src/routes/payment_methods.rs create mode 100644 crates/api/src/routes/services.rs create mode 100644 crates/application/src/dto/complete.rs create mode 100644 crates/application/src/dto/credit.rs create mode 100644 crates/application/src/dto/delivery_report.rs create mode 100644 crates/application/src/dto/payment_method.rs create mode 100644 crates/application/src/dto/service.rs create mode 100644 crates/application/src/ports/attachment_repository.rs create mode 100644 crates/application/src/ports/attachment_storage.rs create mode 100644 crates/application/src/ports/delivery_completion_repository.rs create mode 100644 crates/application/src/ports/delivery_credit_repository.rs create mode 100644 crates/application/src/ports/delivery_report_job_repository.rs create mode 100644 crates/application/src/ports/delivery_report_renderer.rs create mode 100644 crates/application/src/ports/delivery_report_repository.rs create mode 100644 crates/application/src/ports/delivery_report_sink.rs create mode 100644 crates/application/src/ports/delivery_service_repository.rs create mode 100644 crates/application/src/ports/docuframe_report_gateway.rs create mode 100644 crates/application/src/ports/driver_identity_provisioner.rs create mode 100644 crates/application/src/ports/erp_delivery_source.rs create mode 100644 crates/application/src/ports/erp_delivery_writeback.rs create mode 100644 crates/application/src/ports/payment_method_repository.rs create mode 100644 crates/application/src/ports/service_repository.rs create mode 100644 crates/application/src/ports/signature_storage.rs create mode 100644 crates/application/src/usecases/apply_delivery_credit_event.rs create mode 100644 crates/application/src/usecases/complete_delivery.rs create mode 100644 crates/application/src/usecases/delete_delivery_note.rs create mode 100644 crates/application/src/usecases/dev_resync_tours.rs create mode 100644 crates/application/src/usecases/generate_delivery_report.rs create mode 100644 crates/application/src/usecases/get_attachment_preview.rs create mode 100644 crates/application/src/usecases/import_erp_tours.rs create mode 100644 crates/application/src/usecases/list_delivered_belegnummern.rs create mode 100644 crates/application/src/usecases/mark_mail_sent.rs create mode 100644 crates/application/src/usecases/payment_methods.rs create mode 100644 crates/application/src/usecases/process_delivery_report.rs create mode 100644 crates/application/src/usecases/push_completion_to_erp.rs create mode 100644 crates/application/src/usecases/services.rs create mode 100644 crates/application/src/usecases/update_delivery_note.rs create mode 100644 crates/application/src/usecases/upload_delivery_note_image.rs create mode 100644 crates/domain/src/contact.rs create mode 100644 crates/domain/src/payment.rs create mode 100644 crates/domain/src/service.rs create mode 100644 crates/infrastructure/src/auth/keycloak_admin.rs create mode 100644 crates/infrastructure/src/erp/mod.rs create mode 100644 crates/infrastructure/src/erp/mssql_delivery_source.rs create mode 100644 crates/infrastructure/src/erp/mssql_delivery_writeback.rs create mode 100644 crates/infrastructure/src/gsd/dto.rs create mode 100644 crates/infrastructure/src/gsd/mod.rs create mode 100644 crates/infrastructure/src/gsd/service.rs create mode 100644 crates/infrastructure/src/persistence/attachment_repository.rs create mode 100644 crates/infrastructure/src/persistence/delivery_completion_repository.rs create mode 100644 crates/infrastructure/src/persistence/delivery_credit_repository.rs create mode 100644 crates/infrastructure/src/persistence/delivery_report_job_repository.rs create mode 100644 crates/infrastructure/src/persistence/delivery_service_repository.rs create mode 100644 crates/infrastructure/src/persistence/payment_method_repository.rs create mode 100644 crates/infrastructure/src/persistence/service_repository.rs create mode 100644 crates/infrastructure/src/report/mod.rs create mode 100644 crates/infrastructure/src/report/renderer.rs create mode 100644 crates/infrastructure/src/report/repository.rs create mode 100644 crates/infrastructure/src/report/sink.rs create mode 100644 crates/infrastructure/src/storage/local_attachment_storage.rs create mode 100644 crates/infrastructure/src/storage/mod.rs create mode 100644 crates/infrastructure/src/storage/signature_storage.rs create mode 100644 migrations/0007_scan_audit_unremove.sql create mode 100644 migrations/0008_payment_methods.sql create mode 100644 migrations/0009_warehouse_filiale_rename.sql create mode 100644 migrations/0010_app_state.sql create mode 100644 migrations/0011_attachments.sql create mode 100644 migrations/0012_credit_quantity.sql create mode 100644 migrations/0013_note_credit_link.sql create mode 100644 migrations/0014_delivery_credit_audit.sql create mode 100644 migrations/0015_note_amount_credit_flag.sql create mode 100644 migrations/0016_services.sql create mode 100644 migrations/0017_seed_real_services.sql create mode 100644 migrations/0018_delivery_total_amount.sql create mode 100644 migrations/0019_item_unit_price.sql create mode 100644 migrations/0020_delivery_completions.sql create mode 100644 migrations/0021_remove_credit_card.sql create mode 100644 migrations/0022_scan_audit_manual.sql create mode 100644 migrations/0023_item_parent_artikel_nr.sql create mode 100644 migrations/0024_completion_payment_collected.sql create mode 100644 migrations/0025_delivery_belegart_code.sql create mode 100644 migrations/0026_delivery_report_jobs.sql create mode 100644 migrations/0027_attachment_deleted.sql create mode 100644 migrations/0028_completion_mail_sent.sql create mode 100644 migrations/0029_delivery_contact_sources.sql create mode 100755 tool/dev_usb.sh create mode 100755 tool/mark_all_scanned.sh create mode 100755 tool/mark_standard_scanned.sh create mode 100755 tool/reseed_today.sh create mode 100755 tool/reset_test_tour.sh create mode 100755 tool/seed_demo_data.sh diff --git a/.env.example b/.env.example deleted file mode 100644 index cdbcf26..0000000 --- a/.env.example +++ /dev/null @@ -1,24 +0,0 @@ -# Vorlage für lokale Entwicklung — kopieren nach `.env` und nach Bedarf anpassen. -# Die `.env` selbst gehört nicht in Git. - -# --- HTTP-Server ---------------------------------------------------------- -SERVER_HOST=127.0.0.1 -SERVER_PORT=3000 - -# --- Postgres ------------------------------------------------------------- -# Passt zur docker-compose.yml (Service `postgres`). -DATABASE_URL=postgres://holzleitner:holzleitner_dev@localhost:5432/holzleitner -DATABASE_MAX_CONNECTIONS=10 - -# --- Keycloak (OIDC) ------------------------------------------------------ -# Passt zur docker-compose.yml (Service `keycloak`). -# Admin-UI: http://localhost:8080/admin/ (admin / admin) -# Realm: holzleitner -# Test-User: testfahrer / test (Personalnummer 1001, Rolle "driver") -KEYCLOAK_ISSUER_URL=http://localhost:8080/realms/holzleitner -KEYCLOAK_AUDIENCE=holzleitner-api -KEYCLOAK_JWKS_CACHE_TTL_SECONDS=3600 - -# --- Logging -------------------------------------------------------------- -# Standard-Filter; siehe tracing_subscriber::EnvFilter-Doku. -RUST_LOG=holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info diff --git a/.gitignore b/.gitignore index 10a362e..6d67170 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,18 @@ /target -.env +# Lokale Konfiguration mit Secrets — Vorlage: config.example.toml +config.toml .DS_Store + +# Laufzeit-Daten (Unterschriften, Bild-Notizen, Reports) — enthält +# Kundendaten, gehört NICHT ins Repo. +/data + +# Lokale Tool-/Agent-Artefakte +/.claude + +# Große Demo-/Build-Artefakte +demo.mp4 +output.png + +# ERP-Schema-/AI-Wissensablage (separates Konzern-Wissen, kein Backend-Source) +/variocontrol-ai diff --git a/Cargo.lock b/Cargo.lock index 5130d83..c98da33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -47,6 +65,32 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -58,6 +102,19 @@ dependencies = [ "syn", ] +[[package]] +name = "asynchronous-codec" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057f2c32adbb2fc158e22fb38433c8e9bbf76b75a4732c7c0cbaf695fb65568" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + [[package]] name = "atoi" version = "2.0.0" @@ -79,6 +136,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "axum" version = "0.8.9" @@ -98,6 +198,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -131,6 +232,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -143,6 +250,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "2.11.1" @@ -152,6 +265,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -161,18 +283,47 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -186,6 +337,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -208,11 +361,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -222,12 +383,28 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "connection-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -267,6 +444,34 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "croner" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c344b0690c1ad1c7176fe18eb173e0c927008fdaaa256e40dfd43ddd149c0843" +dependencies = [ + "chrono", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -282,6 +487,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -362,12 +573,52 @@ dependencies = [ ] [[package]] -name = "envy" +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equator" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" dependencies = [ - "serde", + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -408,6 +659,36 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -420,6 +701,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ + "crc32fast", "miniz_oxide", "zlib-rs", ] @@ -494,6 +776,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -514,6 +807,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -571,6 +865,27 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -634,16 +949,16 @@ dependencies = [ "anyhow", "axum", "chrono", - "dotenvy", - "envy", "holzleitner-application", "holzleitner-domain", "holzleitner-infrastructure", "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 2.0.18", "tokio", + "tokio-cron-scheduler", + "toml", "tower", "tower-http", "tracing", @@ -660,8 +975,10 @@ dependencies = [ "async-trait", "chrono", "holzleitner-domain", + "imagesize", "serde", - "thiserror", + "sha2", + "thiserror 2.0.18", "utoipa", "uuid", ] @@ -684,13 +1001,17 @@ dependencies = [ "chrono", "holzleitner-application", "holzleitner-domain", + "image", "jsonwebtoken", + "printpdf", "reqwest", "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 2.0.18", + "tiberius", "tokio", + "tokio-util", "tracing", "uuid", ] @@ -779,9 +1100,9 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.40", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots 1.0.7", ] @@ -792,7 +1113,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -942,6 +1263,52 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + [[package]] name = "indexmap" version = "2.14.0" @@ -954,18 +1321,48 @@ dependencies = [ "serde_core", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.98" @@ -984,7 +1381,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64", + "base64 0.22.1", "js-sys", "pem", "ring", @@ -1008,12 +1405,28 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libm" version = "0.2.16" @@ -1042,6 +1455,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "litemap" version = "0.8.2" @@ -1063,6 +1482,32 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lopdf" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" +dependencies = [ + "encoding_rs", + "flate2", + "itoa", + "linked-hash-map", + "log", + "md5", + "pom", + "time", + "weezl", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1084,6 +1529,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1094,6 +1549,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -1137,6 +1598,63 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1178,6 +1696,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1198,6 +1727,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1214,6 +1754,21 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "owned_ttf_parser" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking" version = "2.2.1" @@ -1243,13 +1798,25 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -1307,6 +1874,28 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "pom" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" +dependencies = [ + "bstr", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1331,6 +1920,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty-hex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1341,6 +1936,18 @@ dependencies = [ "syn", ] +[[package]] +name = "printpdf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d" +dependencies = [ + "js-sys", + "lopdf", + "owned_ttf_parser", + "time", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1350,6 +1957,46 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -1362,9 +2009,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.40", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1382,10 +2029,10 @@ dependencies = [ "rand 0.9.4", "ring", "rustc-hash", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1485,6 +2132,76 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1538,9 +2255,10 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1549,17 +2267,18 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -1570,6 +2289,12 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -1644,6 +2369,18 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1653,11 +2390,32 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -1668,6 +2426,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -1700,12 +2468,54 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1766,6 +2576,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1841,6 +2660,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simple_asn1" version = "0.6.4" @@ -1849,7 +2677,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -1916,7 +2744,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -1934,12 +2762,12 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls", + "rustls 0.23.40", "serde", "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -1993,7 +2821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "bytes", @@ -2024,7 +2852,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2037,7 +2865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "chrono", @@ -2063,7 +2891,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2089,7 +2917,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -2149,13 +2977,33 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2178,6 +3026,48 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiberius" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" +dependencies = [ + "async-trait", + "asynchronous-codec", + "byteorder", + "bytes", + "chrono", + "connection-string", + "encoding_rs", + "enumflags2", + "futures-util", + "num-traits", + "once_cell", + "pin-project-lite", + "pretty-hex", + "rustls-native-certs", + "rustls-pemfile", + "thiserror 1.0.69", + "tokio-rustls 0.24.1", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -2251,6 +3141,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-cron-scheduler" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5597b569b4712cf78aa0c9ae29742461b7bda1e49c2a5fdad1d79bf022f8f0" +dependencies = [ + "chrono", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -2262,13 +3167,23 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.40", "tokio", ] @@ -2283,6 +3198,61 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -2398,6 +3368,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" + [[package]] name = "typenum" version = "1.20.0" @@ -2499,7 +3475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" dependencies = [ "axum", - "base64", + "base64 0.22.1", "mime_guess", "regex", "rust-embed", @@ -2522,6 +3498,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2716,6 +3703,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -3016,6 +4009,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3116,6 +4118,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.2" @@ -3256,3 +4264,27 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index c23e1f9..263f5a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,17 +22,23 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "cloc async-trait = "0.1" thiserror = "2" tokio = { version = "1", features = ["full"] } -axum = "0.8" +axum = { version = "0.8", features = ["multipart"] } tower = "0.5" tower-http = { version = "0.6", features = ["trace", "cors"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] } +# MSSQL (ERPframe) — nativer async-Treiber. Kein Pool: der ERP-Pull läuft +# einmal täglich, eine frische Verbindung pro Lauf reicht. +tiberius = { version = "0.12", default-features = false, features = ["chrono", "rustls", "tds73"] } +tokio-util = { version = "0.7", features = ["compat"] } +tokio-cron-scheduler = "0.13" jsonwebtoken = "9" -envy = "0.4" -dotenvy = "0.15" +toml = "0.8" anyhow = "1" +sha2 = "0.10" +imagesize = "0.13" utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] } utoipa-swagger-ui = { version = "9", features = ["axum"] } diff --git a/README.md b/README.md index 64fba77..8a1d9a5 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ Architecture: `domain` → `application` → `infrastructure` → `api`. docker compose up -d # Keycloak braucht ~30s bis "Listening on http://0.0.0.0:8080" im Log steht. -# 2) Env-Datei vorbereiten -cp .env.example .env +# 2) Konfiguration vorbereiten +cp config.example.toml config.toml +# Werte in config.toml anpassen (DB-URL, Keycloak-Issuer, ERP-Zugang, …). # 3) Backend starten cargo run -p holzleitner-api @@ -73,14 +74,27 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3000/accounts/1001 ## Konfiguration -Werte werden aus Umgebungsvariablen gelesen (siehe `.env.example`), -gruppiert nach Prefix: +Werte werden aus `config.toml` gelesen (Vorlage: `config.example.toml`), +gruppiert in TOML-Sections. Der Dateipfad ist über die Env-Variable +`HOLZLEITNER_CONFIG` überschreibbar (z. B. im Deployment); Default ist +`config.toml` im Arbeitsverzeichnis. Die echte `config.toml` enthält +Secrets und ist `.gitignore`-t. -| Prefix | Bereich | -|---|---| -| `SERVER_*` | Bind-Host/Port | -| `DATABASE_*` | Postgres-URL, Pool-Größe | -| `KEYCLOAK_*` | OIDC-Issuer, Audience, JWKS-Cache (greift erst in der Keycloak-Phase) | +| Section | Bereich | Pflicht? | +|---|---|---| +| `[server]` | Bind-Host/Port | ja | +| `[database]` | Postgres-URL, Pool-Größe | ja | +| `[keycloak]` | OIDC-Issuer, Audience, JWKS-Cache, Provisioning | ja | +| `[gsd]` | DOCUframe-REST (Datei-Upload) | ja | +| `[erp]` | ERPframe-MSSQL (Touren-Pull) | optional | +| `[import]` | Import-Scheduler (Cron, Offset) | optional | +| `[report]` / `[signature]` / `[attachment]` | Lokale Speicher-Pfade | optional | +| `[dev]` | `today_override`, `sync_enabled` (DEV-ONLY) | optional | +| `[admin]` | `api_key` für das `/admin`-Gate | optional | +| `[logging]` | Log-Filter (Default; `RUST_LOG`-Env hat Vorrang) | optional | + +Unbekannte Schlüssel werden beim Laden abgewiesen (`deny_unknown_fields`), +sodass Tippfehler sofort als Startfehler auffallen. ## Migrations @@ -95,5 +109,6 @@ touch migrations/0002_tour.sql ## Logging -`tracing` + `tracing-subscriber` mit `EnvFilter`. Default: -`holzleitner_api=info,tower_http=info`. Override via `RUST_LOG`. +`tracing` + `tracing-subscriber` mit `EnvFilter`. Der Default-Filter steht +in `config.toml` unter `[logging] filter`; die Env-Variable `RUST_LOG` hat +Vorrang (Ad-hoc-Debugging ohne Datei-Edit, z. B. `RUST_LOG=debug cargo run`). diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..fc0bce3 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,92 @@ +# Vorlage für die lokale Backend-Konfiguration. +# Kopieren nach `config.toml` und Werte anpassen: +# +# cp config.example.toml config.toml +# +# Die `config.toml` selbst enthält Secrets und gehört NICHT in Git. +# Pfad überschreibbar via Env `HOLZLEITNER_CONFIG=/pfad/zu/config.toml`. +# +# Pflicht-Sections: [server], [database], [keycloak], [gsd]. +# Alle anderen Sections/Felder sind optional und fallen auf Defaults zurück. + +# --- HTTP-Server ---------------------------------------------------------- +[server] +host = "127.0.0.1" +port = 3000 + +# --- Postgres ------------------------------------------------------------- +# Passt zur docker-compose.yml (Service `postgres`). +[database] +url = "postgres://holzleitner:holzleitner_dev@localhost:5432/holzleitner" +max_connections = 10 + +# --- Keycloak (OIDC) ------------------------------------------------------ +# issuer_url muss EXAKT dem `iss`-Claim entsprechen, das Keycloak ausstellt. +[keycloak] +issuer_url = "http://localhost:8080/realms/holzleitner" +audience = "holzleitner-api" +jwks_cache_ttl_seconds = 3600 +admin_url = "http://localhost:8080" +realm = "holzleitner" +provisioner_client_id = "holzleitner-provisioner" +provisioner_client_secret = "provisioner-dev-secret" +driver_default_password = "Holzleitner-Start1!" +driver_role = "driver" +# Default false → Sync legt keine Fahrer-Konten an. +provisioning_enabled = false + +# --- GSD / DOCUframe (Datei-Upload) --------------------------------------- +# password_md5 ist der MD5-Hash des Service-Account-Passworts (kein Klartext). +[gsd] +rest_url = "http://192.168.1.9:8334" +app_key = "GSD-RestApi" +user = "GSDWebServiceTmp" +password_md5 = "" +app_names = ["GSD-RestApi"] + +# --- ERPframe MSSQL (täglicher Touren-Pull) ------------------------------- +[erp] +host = "192.168.1.7" +port = 61279 +database = "HOLZ_SQL" +user = "sa" +password = "" +trust_cert = true +writeback_enabled = false + +# --- ERP-Import-Scheduler ------------------------------------------------- +[import] +enabled = false +cron = "0 0 3 * * *" +date_offset_days = 1 + +# --- PDF-Lieferreport → DOCUframe ---------------------------------------- +[report] +storage_dir = "./data/reports" +upload_enabled = false +retry_cron = "0 */5 * * * *" + +# --- Lokale Speicher (Signaturen / Bild-Notizen) -------------------------- +[signature] +storage_dir = "./data/signatures" + +[attachment] +storage_dir = "./data/attachments" + +# --- DEV-ONLY-Schalter ---------------------------------------------------- +[dev] +# today_override weglassen = echte Uhr. Zum Testen mit importierten Touren +# den quotierten String setzen, z. B.: +# today_override = "2026-06-01" +sync_enabled = false + +# --- Admin-API-Key (Maschinen-Zugang zu /admin) --------------------------- +# Leer ⇒ alle /admin-Endpunkte gesperrt (fail-closed). In Produktion einen +# hochentropischen Zufallswert setzen (`openssl rand -hex 32`). +[admin] +api_key = "" + +# --- Logging -------------------------------------------------------------- +# RUST_LOG-Env hat Vorrang. Binary-Crate heißt `holzleitner_server`. +[logging] +filter = "holzleitner_server=info,holzleitner_api=info,holzleitner_application=info,holzleitner_infrastructure=info,tower_http=info" diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 972edc1..ae05953 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -27,6 +27,6 @@ uuid.workspace = true chrono.workspace = true thiserror.workspace = true anyhow.workspace = true -envy.workspace = true -dotenvy.workspace = true +toml.workspace = true sqlx.workspace = true +tokio-cron-scheduler.workspace = true diff --git a/crates/api/src/config.rs b/crates/api/src/config.rs index 391dd79..a94a74f 100644 --- a/crates/api/src/config.rs +++ b/crates/api/src/config.rs @@ -1,27 +1,351 @@ -//! Konfiguration aus Umgebungsvariablen (12-Factor-Stil). +//! Konfiguration aus einer `config.toml`-Datei. //! -//! Für lokale Entwicklung lädt [`load`] zunächst eine optionale `.env` -//! Datei und parst dann pro Bereich (Server, Database, Keycloak) mit -//! Prefix-Filter über `envy`. So bleiben die Strukturen klar getrennt -//! und Fehlermeldungen verraten den genauen Bereich. +//! [`load`] liest die TOML-Datei (Pfad via `HOLZLEITNER_CONFIG`-Env +//! überschreibbar, sonst `config.toml` im Arbeitsverzeichnis) und +//! deserialisiert sie über `serde` in [`Config`]. Die Struktur ist nach +//! Bereichen in TOML-Sections gruppiert (`[server]`, `[database]`, +//! `[keycloak]`, …); fehlende optionale Sections/Felder fallen auf die +//! `#[serde(default = …)]`-Werte zurück. +//! +//! Die Vorlage liegt in `config.example.toml`; für lokale Entwicklung +//! `cp config.example.toml config.toml` und Werte anpassen. Die echte +//! `config.toml` enthält Secrets und gehört nicht in Git. +use std::path::PathBuf; + +use chrono::NaiveDate; use serde::Deserialize; -#[derive(Debug, Clone)] +/// Env-Variable, mit der der Pfad zur Config-Datei überschrieben werden +/// kann (z. B. im Deployment). Ohne sie wird `config.toml` im +/// Arbeitsverzeichnis erwartet. +const CONFIG_PATH_ENV: &str = "HOLZLEITNER_CONFIG"; +const DEFAULT_CONFIG_PATH: &str = "config.toml"; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Config { pub server: ServerConfig, pub database: DatabaseConfig, #[allow(dead_code)] // wird in der Keycloak-Phase verdrahtet pub keycloak: KeycloakConfig, + pub gsd: GsdConfig, + #[serde(default)] + pub signature: SignatureConfig, + #[serde(default)] + pub attachment: AttachmentConfig, + #[serde(default)] + pub report: ReportConfig, + #[serde(default)] + pub erp: ErpConfig, + #[serde(default)] + pub import: ImportConfig, + /// DEV-ONLY-Schalter (`today_override`, `sync_enabled`). + #[serde(default)] + pub dev: DevConfig, + /// Admin-API-Key-Gate auf `/admin`. + #[serde(default)] + pub admin: AdminConfig, + /// Log-Filter (Override via `RUST_LOG`-Env hat Vorrang, siehe main.rs). + #[serde(default)] + pub logging: LoggingConfig, +} + +impl Config { + /// Loggt eine Zusammenfassung der geladenen Konfiguration beim Start. + /// Secrets (Passwörter, Client-Secrets, Hashes) werden **maskiert** — + /// nur „gesetzt/leer" ist sichtbar, nie der Klartext. Hilft, beim + /// Binary-Start auf einen Blick Fehlkonfigurationen zu erkennen (z. B. + /// einen aktiven `today_override`). + pub fn log_summary(&self) { + tracing::info!("════════════ Konfiguration (Start) ════════════"); + tracing::info!(host = %self.server.host, port = self.server.port, "cfg.server"); + tracing::info!( + url = %mask_db_url(&self.database.url), + max_connections = self.database.max_connections, + "cfg.database" + ); + tracing::info!( + issuer_url = %self.keycloak.issuer_url, + audience = %self.keycloak.audience, + realm = %self.keycloak.realm, + admin_url = %display_or_unset(&self.keycloak.admin_url), + provisioning_enabled = self.keycloak.provisioning_enabled, + provisioner_client_id = %display_or_unset(&self.keycloak.provisioner_client_id), + provisioner_client_secret = %mask(&self.keycloak.provisioner_client_secret), + driver_role = %self.keycloak.driver_role, + driver_default_password = %mask(&self.keycloak.driver_default_password), + jwks_cache_ttl_seconds = self.keycloak.jwks_cache_ttl_seconds, + "cfg.keycloak" + ); + tracing::info!( + host = %display_or_unset(&self.erp.host), + port = self.erp.port, + database = %display_or_unset(&self.erp.database), + user = %display_or_unset(&self.erp.user), + password = %mask(&self.erp.password), + trust_cert = self.erp.trust_cert, + writeback_enabled = self.erp.writeback_enabled, + "cfg.erp" + ); + tracing::info!( + enabled = self.import.enabled, + cron = %self.import.cron, + date_offset_days = self.import.date_offset_days, + "cfg.import (scheduler)" + ); + tracing::info!( + rest_url = %self.gsd.rest_url, + user = %self.gsd.user, + app_key = %mask(&self.gsd.app_key), + password_md5 = %mask(&self.gsd.password_md5), + app_names = ?self.gsd.app_names, + "cfg.gsd" + ); + tracing::info!(storage_dir = %self.signature.storage_dir, "cfg.signature"); + tracing::info!(storage_dir = %self.attachment.storage_dir, "cfg.attachment (Bild-Notizen lokal)"); + tracing::info!( + storage_dir = %self.report.storage_dir, + upload_enabled = self.report.upload_enabled, + retry_cron = %self.report.retry_cron, + "cfg.report (PDF-Report → DOCUframe)" + ); + if self.report.upload_enabled { + tracing::warn!("report.upload_enabled=true: Reports werden an DOCUframe übertragen + lokale Dateien danach gelöscht"); + } + match &self.dev.today_override { + Some(date) => tracing::warn!( + today_override = %date, + "cfg.DEV: dev.today_override AKTIV — 'heute' ist überschrieben! \ + In Produktion entfernen." + ), + None => tracing::info!("cfg.dev.today_override = (echte Uhr)"), + } + if self.dev.sync_enabled { + tracing::warn!( + "cfg.DEV: dev.sync_enabled AKTIV — ungeschützter Endpoint \ + POST /dev/resync löscht Postgres-Tourdaten! In Produktion aus." + ); + } + tracing::info!( + admin_api_key = %mask(&self.admin.api_key), + "cfg.admin (X-Admin-Api-Key Gate auf /admin)" + ); + if self.admin.api_key.is_empty() { + tracing::warn!( + "cfg.admin: admin.api_key LEER — alle /admin-Endpunkte sind \ + GESPERRT (fail-closed). Zum Aktivieren admin.api_key setzen." + ); + } + tracing::info!("═══════════════════════════════════════════════"); + } +} + +/// Maskiert ein Secret: zeigt nur, ob gesetzt — nie den Klartext. +fn mask(secret: &str) -> &'static str { + if secret.is_empty() { + "(leer)" + } else { + "***gesetzt***" + } +} + +/// Zeigt den Wert oder `(leer)` für optionale, nicht-geheime Felder. +fn display_or_unset(value: &str) -> String { + if value.is_empty() { + "(leer)".to_string() + } else { + value.to_string() + } +} + +/// Maskiert das Passwort in einer DB-URL: `scheme://user:pass@host` → +/// `scheme://user:***@host`. Lässt alles andere unverändert. +fn mask_db_url(url: &str) -> String { + let Some(scheme_end) = url.find("://") else { + return url.to_string(); + }; + let (scheme, rest) = url.split_at(scheme_end + 3); + let Some(at) = rest.find('@') else { + return url.to_string(); + }; + let (creds, host) = rest.split_at(at); // host beginnt mit '@' + let Some(colon) = creds.find(':') else { + return url.to_string(); + }; + let user = &creds[..colon]; + format!("{scheme}{user}:***{host}") +} + +/// Lese-Zugang zur ERPframe-MS-SQL-DB (für den täglichen Touren-Pull). +/// Alle Felder optional defaultet — der Server bootet auch ohne ERP-Config, +/// solange `import.enabled = false` (Default) ist. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ErpConfig { + #[serde(default)] + pub host: String, + #[serde(default = "default_mssql_port")] + pub port: u16, + #[serde(default)] + pub database: String, + #[serde(default)] + pub user: String, + #[serde(default)] + pub password: String, + /// Selbstsigniertes Server-Zertifikat akzeptieren (Intranet-DB). + #[serde(default = "default_true")] + pub trust_cert: bool, + /// ERP-**Rückschreiben** beim Lieferabschluss aktiv? Default `false` — + /// dann bleibt der Abschluss rein lokal (Dev/Seed ohne ERP-Beleg). + #[serde(default)] + pub writeback_enabled: bool, +} + +impl Default for ErpConfig { + fn default() -> Self { + Self { + host: String::new(), + port: default_mssql_port(), + database: String::new(), + user: String::new(), + password: String::new(), + trust_cert: default_true(), + writeback_enabled: false, + } + } +} + +fn default_mssql_port() -> u16 { + 1433 +} +fn default_true() -> bool { + true +} + +/// Steuert den geplanten ERP-Import. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImportConfig { + /// Default `false` — der Scheduler startet nur, wenn explizit aktiviert. + #[serde(default)] + pub enabled: bool, + /// Cron-Ausdruck (tokio-cron-scheduler, 6-stellig inkl. Sekunden). + /// Default: täglich 03:00:00. + #[serde(default = "default_import_cron")] + pub cron: String, + /// Versatz in Tagen relativ zu „heute" für das Ziel-Tourdatum. + /// Default `1` = morgen. + #[serde(default = "default_import_offset")] + pub date_offset_days: i64, +} + +impl Default for ImportConfig { + fn default() -> Self { + Self { + enabled: false, + cron: default_import_cron(), + date_offset_days: default_import_offset(), + } + } +} + +fn default_import_cron() -> String { + "0 0 3 * * *".to_string() +} +fn default_import_offset() -> i64 { + 1 +} + +/// Lokaler Speicher für Unterschriften-PNGs (Kunde + Fahrer). Bewusst NICHT +/// DOCUframe — die Bilder bleiben auf der Backend-Maschine. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SignatureConfig { + /// Basis-Verzeichnis für die PNG-Dateien (wird beim Start angelegt). + #[serde(default = "default_signature_dir")] + pub storage_dir: String, +} + +impl Default for SignatureConfig { + fn default() -> Self { + Self { + storage_dir: default_signature_dir(), + } + } +} + +fn default_signature_dir() -> String { + "./data/signatures".to_string() +} + +/// Lokaler Speicher für hochgeladene Bild-Notizen. Statt DOCUframe landen die +/// Bilder pro Belegnummer in einem Unterordner (`//…`). +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AttachmentConfig { + /// Basis-Verzeichnis (wird beim Start angelegt). + #[serde(default = "default_attachment_dir")] + pub storage_dir: String, +} + +impl Default for AttachmentConfig { + fn default() -> Self { + Self { + storage_dir: default_attachment_dir(), + } + } +} + +fn default_attachment_dir() -> String { + "./data/attachments".to_string() +} + +/// Lokaler (temporärer) Speicher für die PDF-Lieferreports. Später wird der +/// Blob direkt an ein DOCUframe-Makro gesendet (Sink-Stub vorhanden). +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReportConfig { + /// Basis-Verzeichnis (wird beim Start angelegt). + #[serde(default = "default_report_dir")] + pub storage_dir: String, + /// Schaltet die DOCUframe-Übertragung des Reports beim Abschluss + den + /// Retry-Cron frei. Aus = nur lokale Ablage (Dev / DOCUframe nicht erreichbar). + #[serde(default)] + pub upload_enabled: bool, + /// Cron-Ausdruck (6-stellig, mit Sekunden) für den Retry der offenen + /// Report-Jobs. Default: alle 5 Minuten. + #[serde(default = "default_report_retry_cron")] + pub retry_cron: String, +} + +impl Default for ReportConfig { + fn default() -> Self { + Self { + storage_dir: default_report_dir(), + upload_enabled: false, + retry_cron: default_report_retry_cron(), + } + } +} + +fn default_report_dir() -> String { + "./data/reports".to_string() +} + +fn default_report_retry_cron() -> String { + "0 */5 * * * *".to_string() } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ServerConfig { pub host: String, pub port: u16, } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DatabaseConfig { pub url: String, #[serde(default = "default_max_connections")] @@ -36,62 +360,269 @@ fn default_max_connections() -> u32 { /// Warnings unterdrücken. #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct KeycloakConfig { pub issuer_url: String, pub audience: String, #[serde(default = "default_jwks_cache_ttl")] pub jwks_cache_ttl_seconds: u64, + + // --- Provisioning via Admin-API (ERP-Sync legt Fahrer-Konten an) ------ + /// Basis-URL der Keycloak-Instanz **ohne** `/realms/...`, z. B. + /// `http://localhost:8080`. Leer ⇒ Provisionierung nicht möglich. + #[serde(default)] + pub admin_url: String, + /// Realm-Name. Default `holzleitner`. + #[serde(default = "default_realm")] + pub realm: String, + /// Service-Account-Client (confidential) für die Admin-API. + #[serde(default)] + pub provisioner_client_id: String, + #[serde(default)] + pub provisioner_client_secret: String, + /// Temporäres Default-Passwort für neu angelegte Fahrer-Konten (muss beim + /// ersten Login geändert werden). + #[serde(default = "default_driver_password")] + pub driver_default_password: String, + /// Realm-Rolle, die jedem Fahrer zugewiesen wird. Default `driver`. + #[serde(default = "default_driver_role")] + pub driver_role: String, + /// Konto-Provisionierung beim Sync aktiv? Default `false`. + #[serde(default)] + pub provisioning_enabled: bool, } fn default_jwks_cache_ttl() -> u64 { 3600 } +fn default_realm() -> String { + "holzleitner".to_string() +} +fn default_driver_password() -> String { + "Holzleitner-Start1!".to_string() +} +fn default_driver_role() -> String { + "driver".to_string() +} -/// Lädt die Konfiguration aus Umgebungsvariablen. +/// Anbindung an die GSD/DOCUframe-REST-API (für Datei-Uploads). +/// +/// Login läuft über einen technischen Service-Account; `password_md5` ist +/// bereits der MD5-Hash des Passworts (GSD erwartet `pass` als MD5) — so +/// liegt kein Klartext-Secret in der Konfiguration. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GsdConfig { + /// Basis-URL der GSD-REST-API (ohne abschließenden Slash), z. B. + /// `http://192.168.1.9:8334`. + pub rest_url: String, + pub app_key: String, + pub user: String, + /// MD5-Hash des Service-Account-Passworts. + pub password_md5: String, + /// App-Namen als TOML-Array, z. B. `["GSD-RestApi"]`. + pub app_names: Vec, +} + +/// DEV-ONLY-Schalter. Beide Felder defaulten auf „aus". +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct DevConfig { + /// Überschreibt das serverseitige „heute" für `GET /me/tours/today`. + /// Als **quotierter** String `"YYYY-MM-DD"` angeben — ein bloßes + /// `2026-06-01` würde TOML als nativen Datums-Typ lesen, den chrono + /// nicht direkt akzeptiert. `None`/weglassen = echte Uhr. Nur zum + /// Testen mit historischen/importierten Touren. + #[serde(default)] + pub today_override: Option, + /// Schaltet den ungeschützten Dev-Resync-Endpoint (`POST /dev/resync`) + /// frei, der Postgres platt macht und neu importiert. Default `false` + /// → Endpoint wird gar nicht erst gemountet. + #[serde(default)] + pub sync_enabled: bool, +} + +/// Admin-API-Key-Gate auf `/admin`. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct AdminConfig { + /// Statischer API-Key für die `/admin`-Endpunkte (Maschinen-Zugang per + /// Header `X-Admin-Api-Key`, KEIN Keycloak/JWT). **Leer ⇒ alle + /// Admin-Routen sind gesperrt** (fail-closed). In Produktion einen + /// hochentropischen Zufallswert setzen, nie committen. + #[serde(default)] + pub api_key: String, +} + +/// Logging-Filter (tracing-subscriber `EnvFilter`-Syntax). +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LoggingConfig { + /// Default-Filter. Die `RUST_LOG`-Env-Variable hat Vorrang (siehe + /// `main.rs`), damit Ad-hoc-Debugging ohne Datei-Edit möglich bleibt. + #[serde(default = "default_log_filter")] + pub filter: String, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + filter: default_log_filter(), + } + } +} + +fn default_log_filter() -> String { + // WICHTIG: Der Binary-Crate heißt `holzleitner_server` (vom `[[bin]] + // name`), NICHT `holzleitner_api` — sonst werden alle Logs aus + // main.rs/config.rs (Startup + Config-Übersicht) herausgefiltert. + "holzleitner_server=info,holzleitner_api=info,\ + holzleitner_application=info,holzleitner_infrastructure=info,\ + tower_http=info" + .to_string() +} + +/// Pfad zur Config-Datei: `HOLZLEITNER_CONFIG`-Env, sonst `config.toml`. +fn config_path() -> PathBuf { + match std::env::var(CONFIG_PATH_ENV) { + Ok(p) if !p.trim().is_empty() => PathBuf::from(p.trim()), + _ => PathBuf::from(DEFAULT_CONFIG_PATH), + } +} + +/// Lädt die Konfiguration aus der TOML-Datei. /// /// Reihenfolge: -/// 1. Optionale `.env`-Datei im Arbeitsverzeichnis (für Local-Dev). -/// 2. Pro Bereich werden Variablen mit passendem Prefix gelesen: -/// * `SERVER_*` für [`ServerConfig`] -/// * `DATABASE_*` für [`DatabaseConfig`] -/// * `KEYCLOAK_*` für [`KeycloakConfig`] +/// 1. Pfad bestimmen (`HOLZLEITNER_CONFIG`-Env oder `config.toml`). +/// 2. Datei lesen und mit `toml`/`serde` in [`Config`] deserialisieren. +/// Fehlende optionale Sections/Felder fallen auf die `serde`-Defaults. pub fn load() -> Result { - // `.env` ist optional — in Produktion kommen die Werte aus dem - // System-Environment. - let _ = dotenvy::dotenv(); + let path = config_path(); + let raw = std::fs::read_to_string(&path).map_err(|source| ConfigError::Read { + path: path.clone(), + source, + })?; + parse(&raw).map_err(|source| ConfigError::Parse { path, source }) +} - let server = envy::prefixed("SERVER_") - .from_env::() - .map_err(|e| ConfigError::Section { - section: "SERVER", - source: e, - })?; - let database = envy::prefixed("DATABASE_") - .from_env::() - .map_err(|e| ConfigError::Section { - section: "DATABASE", - source: e, - })?; - let keycloak = envy::prefixed("KEYCLOAK_") - .from_env::() - .map_err(|e| ConfigError::Section { - section: "KEYCLOAK", - source: e, - })?; - - Ok(Config { - server, - database, - keycloak, - }) +/// Deserialisiert TOML-Text in [`Config`]. Ausgelagert, damit er in Tests +/// ohne Dateizugriff aufgerufen werden kann. +fn parse(raw: &str) -> Result { + toml::from_str::(raw) } #[derive(Debug, thiserror::Error)] pub enum ConfigError { - #[error("missing or invalid env vars in section {section}: {source}")] - Section { - section: &'static str, + // PathBuf implementiert kein Display → Debug-Format `{path:?}`. + #[error("config-Datei {path:?} konnte nicht gelesen werden: {source}")] + Read { + path: PathBuf, #[source] - source: envy::Error, + source: std::io::Error, + }, + #[error("config-Datei {path:?} ist ungültig: {source}")] + Parse { + path: PathBuf, + #[source] + source: toml::de::Error, }, } + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal-Config mit nur den Pflicht-Sections — optionale Sections + /// fallen auf Defaults zurück. + #[test] + fn parses_minimal_config() { + let raw = r#" + [server] + host = "0.0.0.0" + port = 3000 + + [database] + url = "postgres://u:p@localhost/db" + + [keycloak] + issuer_url = "http://localhost:8080/realms/holzleitner" + audience = "holzleitner-api" + + [gsd] + rest_url = "http://localhost:8334" + app_key = "k" + user = "u" + password_md5 = "abc" + app_names = ["GSD-RestApi"] + "#; + let cfg = parse(raw).expect("minimal config should parse"); + assert_eq!(cfg.server.port, 3000); + assert_eq!(cfg.database.max_connections, 10); // default + assert_eq!(cfg.report.storage_dir, "./data/reports"); // default section + assert!(!cfg.dev.sync_enabled); // default + assert!(cfg.dev.today_override.is_none()); + assert!(cfg.admin.api_key.is_empty()); + assert_eq!(cfg.erp.port, 1433); // default + } + + /// `today_override` wird als `YYYY-MM-DD` geparst. + #[test] + fn parses_today_override_and_arrays() { + let raw = r#" + [server] + host = "0.0.0.0" + port = 3000 + + [database] + url = "postgres://u:p@localhost/db" + + [keycloak] + issuer_url = "iss" + audience = "aud" + + [gsd] + rest_url = "http://localhost:8334" + app_key = "k" + user = "u" + password_md5 = "abc" + app_names = ["A", "B"] + + [dev] + today_override = "2026-06-01" + sync_enabled = true + "#; + let cfg = parse(raw).expect("config should parse"); + assert_eq!( + cfg.dev.today_override, + Some(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap()) + ); + assert!(cfg.dev.sync_enabled); + assert_eq!(cfg.gsd.app_names, vec!["A".to_string(), "B".to_string()]); + } + + /// Unbekannte Schlüssel werden abgewiesen — schützt vor Tippfehlern. + #[test] + fn rejects_unknown_keys() { + let raw = r#" + [server] + host = "0.0.0.0" + port = 3000 + bogus = true + + [database] + url = "postgres://u:p@localhost/db" + + [keycloak] + issuer_url = "iss" + audience = "aud" + + [gsd] + rest_url = "r" + app_key = "k" + user = "u" + password_md5 = "abc" + app_names = [] + "#; + assert!(parse(raw).is_err()); + } +} diff --git a/crates/api/src/error.rs b/crates/api/src/error.rs index ae2a87b..b22da43 100644 --- a/crates/api/src/error.rs +++ b/crates/api/src/error.rs @@ -33,6 +33,7 @@ impl IntoResponse for ApiError { ApplicationError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), ApplicationError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), ApplicationError::Validation(_) => (StatusCode::BAD_REQUEST, "validation"), + ApplicationError::Conflict(_) => (StatusCode::CONFLICT, "conflict"), ApplicationError::Repository(_) | ApplicationError::External(_) | ApplicationError::Unexpected(_) => { diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 0e7281e..f08c01e 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -1,8 +1,8 @@ //! Holzleitner-API — HTTP-Layer und Composition Root. //! //! Bootstrap-Reihenfolge: -//! 1. Tracing/Logging initialisieren -//! 2. Konfiguration aus Env-Variablen / `.env` laden +//! 1. Konfiguration aus `config.toml` laden (liefert u. a. den Log-Filter) +//! 2. Tracing/Logging initialisieren //! 3. Postgres-Pool aufbauen und Migrations ausführen //! 4. Keycloak-AuthService instanziieren //! 5. Use Cases zusammenstellen und in `AppState` packen @@ -24,36 +24,73 @@ use anyhow::Context; use axum::Router; use axum::middleware::from_fn_with_state; use holzleitner_application::usecases::{ - ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase, - CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase, - ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase, - UpdateMyCarUseCase, + ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase, + AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase, + CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase, + DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase, + DeleteServiceUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase, + GetAttachmentPreviewUseCase, GetTourUseCase, + ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, + ListMyToursTodayUseCase, ListPaymentMethodsUseCase, + ListServicesUseCase, MarkMailSentUseCase, PushCompletionToErpUseCase, + SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase, + ProcessDeliveryReportUseCase, UpdateMyCarUseCase, UpdatePaymentMethodUseCase, + UpdateServiceUseCase, UploadDeliveryNoteImageUseCase, +}; +use holzleitner_application::ports::DeliveryReportJobRepository; +use holzleitner_infrastructure::auth::{ + KeycloakAdapterConfig, KeycloakAdminClient, KeycloakAdminConfig, KeycloakAuthService, +}; +use holzleitner_infrastructure::erp::{ + MssqlErpConfig, MssqlErpDeliverySource, MssqlErpDeliveryWriteback, +}; +use holzleitner_infrastructure::gsd::{GsdConfig, GsdService}; +use holzleitner_infrastructure::report::{ + LocalReportSink, PdfDeliveryReportRenderer, PgDeliveryReportRepository, }; -use holzleitner_infrastructure::auth::{KeycloakAdapterConfig, KeycloakAuthService}; use holzleitner_infrastructure::persistence::{ - PgAccountRepository, PgCarRepository, PgDeliveryNoteRepository, PgDeliveryRepository, - PgScanRepository, PgTourRepository, PoolConfig, connect_and_migrate, + PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository, + PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryReportJobRepository, + PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgScanRepository, + PgServiceRepository, PgTourRepository, PoolConfig, connect_and_migrate, }; +use holzleitner_infrastructure::storage::{LocalAttachmentStorage, LocalSignatureStorage}; +use tokio_cron_scheduler::{Job, JobScheduler}; use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::middleware::jwt_middleware; +use crate::middleware::{admin_api_key_middleware, jwt_middleware}; use crate::openapi::ApiDoc; use crate::state::AppState; #[tokio::main] async fn main() -> anyhow::Result<()> { + // Config ZUERST laden — der Log-Filter steht jetzt in `config.toml` + // (`[logging] filter`), also brauchen wir die Config, bevor der + // Subscriber initialisiert wird. Fehler hier werden von anyhow auch + // ohne aktiven Subscriber sichtbar (stderr). + let cfg = config::load().context("config laden fehlgeschlagen")?; + tracing_subscriber::fmt() + // Auf stderr loggen statt stdout: stderr ist unbuffered und erscheint + // damit sofort — auch wenn die Ausgabe in eine Datei/Pipe/ein + // IDE-Terminal läuft (stdout wäre dort blockgepuffert → Logs würden + // erst verspätet/gar nicht sichtbar). + .with_writer(std::io::stderr) .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { - "holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info".into() - }), + // `RUST_LOG`-Env hat Vorrang (Ad-hoc-Debugging ohne Datei-Edit), + // sonst der Filter aus `config.toml` (`[logging] filter`). + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| cfg.logging.filter.clone().into()), ) .init(); - let cfg = config::load().context("config laden fehlgeschlagen")?; - tracing::info!(host = %cfg.server.host, port = cfg.server.port, "starting up"); + tracing::info!("starting up"); + // Vollständige (secret-maskierte) Konfig-Übersicht beim Start — damit + // Fehlkonfigurationen (z. B. ein aktiver dev.today_override) sofort + // im Log sichtbar sind. + cfg.log_summary(); // --- Persistence --------------------------------------------------- let pool = connect_and_migrate(&PoolConfig { @@ -69,7 +106,74 @@ async fn main() -> anyhow::Result<()> { let scan_repository = Arc::new(PgScanRepository::new(pool.clone())); let delivery_repository = Arc::new(PgDeliveryRepository::new(pool.clone())); let delivery_note_repository = Arc::new(PgDeliveryNoteRepository::new(pool.clone())); - let car_repository = Arc::new(PgCarRepository::new(pool)); + let attachment_repository = Arc::new(PgAttachmentRepository::new(pool.clone())); + let car_repository = Arc::new(PgCarRepository::new(pool.clone())); + let payment_method_repository = Arc::new(PgPaymentMethodRepository::new(pool.clone())); + let delivery_credit_repository = Arc::new(PgDeliveryCreditRepository::new(pool.clone())); + let delivery_completion_repository = + Arc::new(PgDeliveryCompletionRepository::new(pool.clone())); + let service_repository = Arc::new(PgServiceRepository::new(pool.clone())); + let delivery_service_repository = Arc::new(PgDeliveryServiceRepository::new(pool.clone())); + let report_repository = Arc::new(PgDeliveryReportRepository::new(pool.clone())); + let report_job_repository = Arc::new(PgDeliveryReportJobRepository::new(pool.clone())); + + // --- Lokaler Unterschriften-Speicher (Dateisystem) ----------------- + let signature_storage = Arc::new( + LocalSignatureStorage::new(&cfg.signature.storage_dir) + .context("Signatur-Verzeichnis konnte nicht angelegt werden")?, + ); + tracing::info!(dir = %cfg.signature.storage_dir, "signature storage ready"); + + // --- Lokaler Bild-Speicher (Bild-Notizen, Ordner = Belegnummer) ---- + // Ersetzt den DOCUframe-Upload für Bild-Notizen. Der GSD-Service bleibt + // erhalten (s. u.), wird aber nicht mehr für Attachments verdrahtet. + let attachment_storage = Arc::new( + LocalAttachmentStorage::new(&cfg.attachment.storage_dir) + .context("Attachment-Verzeichnis konnte nicht angelegt werden")?, + ); + tracing::info!(dir = %cfg.attachment.storage_dir, "attachment storage ready (lokal)"); + + // --- PDF-Lieferreport (lokaler/temporärer Sink; DOCUframe später) -- + let report_sink = Arc::new( + LocalReportSink::new(&cfg.report.storage_dir) + .context("Report-Verzeichnis konnte nicht angelegt werden")?, + ); + let generate_delivery_report = Arc::new(GenerateDeliveryReportUseCase::new( + report_repository, + Arc::new(PdfDeliveryReportRenderer), + report_sink.clone(), + signature_storage.clone(), + attachment_storage.clone(), + )); + tracing::info!(dir = %cfg.report.storage_dir, "delivery report renderer ready (printpdf, lokal)"); + + // --- GSD/DOCUframe (Datei-Upload) ---------------------------------- + // Bleibt für den Lizenz-Release beim Shutdown konstruiert. Der frühere + // Attachment-Upload/-Download über DOCUframe ist hier bewusst nicht mehr + // verdrahtet (Code in `gsd::GsdService` bleibt für später erhalten). + let gsd_service = Arc::new(GsdService::new( + pool, + GsdConfig { + rest_url: cfg.gsd.rest_url.clone(), + app_key: cfg.gsd.app_key.clone(), + user: cfg.gsd.user.clone(), + password_md5: cfg.gsd.password_md5.clone(), + app_names: cfg.gsd.app_names.clone(), + }, + )); + + // --- Report-Upload-Pipeline (Report → DOCUframe, mit Retry-Job) ---- + // Nutzt GsdService als DocuframeReportGateway (Upload + Makro). Räumt + // nach Erfolg lokale Dateien (Report, Unterschriften, Bild-Notizen) auf. + let process_delivery_report = Arc::new(ProcessDeliveryReportUseCase::new( + generate_delivery_report.clone(), + report_job_repository.clone(), + gsd_service.clone(), + attachment_repository.clone(), + attachment_storage.clone(), + signature_storage.clone(), + report_sink.clone(), + )); // --- Auth ---------------------------------------------------------- let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig { @@ -86,8 +190,74 @@ async fn main() -> anyhow::Result<()> { // --- Use Cases ----------------------------------------------------- let get_account = Arc::new(GetAccountUseCase::new(account_repository)); let get_tour = Arc::new(GetTourUseCase::new(tour_repository.clone())); - let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(tour_repository.clone())); + let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new( + tour_repository.clone(), + cfg.dev.today_override, + )); let sync_tour = Arc::new(SyncTourUseCase::new(tour_repository.clone())); + // ERP-Import (täglicher Pull aus ERPframe-MSSQL) — nutzt denselben + // Sync-Pfad wie der HTTP-Endpoint. Geht NICHT in den AppState (kein + // HTTP-Endpoint), sondern nur in den Scheduler. + let erp_mssql_config = MssqlErpConfig { + host: cfg.erp.host.clone(), + port: cfg.erp.port, + database: cfg.erp.database.clone(), + user: cfg.erp.user.clone(), + password: cfg.erp.password.clone(), + trust_cert: cfg.erp.trust_cert, + }; + let erp_source = Arc::new(MssqlErpDeliverySource::new(erp_mssql_config.clone())); + // Optionaler Keycloak-Provisioner: legt beim Sync fehlende Fahrer-Konten + // an (Username = Fahrer-/Account-Nummer, temporäres Passwort). Nur aktiv, + // wenn `KEYCLOAK_PROVISIONING_ENABLED=true`. + let driver_provisioner: Option< + Arc, + > = if cfg.keycloak.provisioning_enabled { + tracing::info!( + admin_url = %cfg.keycloak.admin_url, + realm = %cfg.keycloak.realm, + role = %cfg.keycloak.driver_role, + "keycloak driver provisioning ENABLED" + ); + Some(Arc::new(KeycloakAdminClient::new(KeycloakAdminConfig { + base_url: cfg.keycloak.admin_url.clone(), + realm: cfg.keycloak.realm.clone(), + client_id: cfg.keycloak.provisioner_client_id.clone(), + client_secret: cfg.keycloak.provisioner_client_secret.clone(), + default_password: cfg.keycloak.driver_default_password.clone(), + driver_role: cfg.keycloak.driver_role.clone(), + }))) + } else { + tracing::info!("keycloak driver provisioning disabled"); + None + }; + let import_erp_tours = Arc::new(ImportErpToursUseCase::new( + erp_source, + sync_tour.clone(), + driver_provisioner, + )); + // DEV-ONLY: überschreibender Resync (löscht Postgres-Tourdaten + Import). + // Wird immer gebaut, der Endpoint aber nur bei dev.sync_enabled gemountet. + let dev_resync_tours = Arc::new(DevResyncToursUseCase::new( + tour_repository.clone(), + import_erp_tours.clone(), + )); + // ERP-Rückschreiben beim Lieferabschluss. Der Push-Use-Case wird IMMER + // gebaut (Admin-Retry-Endpunkt nutzt ihn manuell). Ob der normale + // Abschluss-Pfad automatisch pusht, steuert `ERP_WRITEBACK_ENABLED`. + let erp_writeback = Arc::new(MssqlErpDeliveryWriteback::new(erp_mssql_config)); + let push_completion_to_erp = Arc::new(PushCompletionToErpUseCase::new( + delivery_completion_repository.clone(), + erp_writeback, + )); + // Admin-Lese-Use-Case: Belegnummern der an einem Tag ausgelieferten + // Lieferungen. `.clone()` VOR dem späteren Move in `complete_delivery`. + let list_delivered_belegnummern = Arc::new(ListDeliveredBelegnummernUseCase::new( + delivery_completion_repository.clone(), + )); + let mark_mail_sent = Arc::new(MarkMailSentUseCase::new( + delivery_completion_repository.clone(), + )); let set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository)); let apply_scans = Arc::new(ApplyScansUseCase::new( scan_repository, @@ -95,10 +265,39 @@ async fn main() -> anyhow::Result<()> { )); let apply_delivery_action = Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone())); + let complete_delivery = Arc::new(CompleteDeliveryUseCase::new( + delivery_completion_repository, + signature_storage, + car_repository.clone(), + if cfg.erp.writeback_enabled { + Some(push_completion_to_erp.clone()) + } else { + None + }, + )); + let apply_delivery_credit_event = Arc::new(ApplyDeliveryCreditEventUseCase::new( + delivery_credit_repository, + car_repository.clone(), + )); let create_delivery_note = Arc::new(CreateDeliveryNoteUseCase::new( + delivery_note_repository.clone(), + car_repository.clone(), + )); + let update_delivery_note = Arc::new(UpdateDeliveryNoteUseCase::new( + delivery_note_repository.clone(), + )); + let delete_delivery_note = + Arc::new(DeleteDeliveryNoteUseCase::new(delivery_note_repository.clone())); + let upload_delivery_note_image = Arc::new(UploadDeliveryNoteImageUseCase::new( + attachment_storage.clone(), + attachment_repository.clone(), delivery_note_repository, car_repository.clone(), )); + let get_attachment_preview = Arc::new(GetAttachmentPreviewUseCase::new( + attachment_repository, + attachment_storage.clone(), + )); let list_my_cars = Arc::new(ListMyCarsUseCase::new(car_repository.clone())); let create_my_car = Arc::new(CreateMyCarUseCase::new(car_repository.clone())); let update_my_car = Arc::new(UpdateMyCarUseCase::new(car_repository.clone())); @@ -106,6 +305,28 @@ async fn main() -> anyhow::Result<()> { car_repository, delivery_repository, )); + let list_payment_methods = Arc::new(ListPaymentMethodsUseCase::new( + payment_method_repository.clone(), + )); + let create_payment_method = Arc::new(CreatePaymentMethodUseCase::new( + payment_method_repository.clone(), + )); + let update_payment_method = Arc::new(UpdatePaymentMethodUseCase::new( + payment_method_repository.clone(), + )); + let delete_payment_method = + Arc::new(DeletePaymentMethodUseCase::new(payment_method_repository)); + let list_services = Arc::new(ListServicesUseCase::new(service_repository.clone())); + let create_service = Arc::new(CreateServiceUseCase::new(service_repository.clone())); + let update_service = Arc::new(UpdateServiceUseCase::new(service_repository.clone())); + let delete_service = Arc::new(DeleteServiceUseCase::new(service_repository.clone())); + let set_delivery_service = Arc::new(SetDeliveryServiceUseCase::new( + service_repository, + delivery_service_repository.clone(), + )); + let delete_delivery_service = Arc::new(DeleteDeliveryServiceUseCase::new( + delivery_service_repository, + )); let state = AppState { get_account, @@ -113,14 +334,39 @@ async fn main() -> anyhow::Result<()> { list_my_tours_today, sync_tour, set_delivery_order, + import_erp_tours: import_erp_tours.clone(), + dev_resync_tours, + generate_delivery_report, + process_delivery_report: process_delivery_report.clone(), + report_upload_enabled: cfg.report.upload_enabled, apply_scans, apply_delivery_action, + complete_delivery, + push_completion_to_erp, + list_delivered_belegnummern, + mark_mail_sent, + apply_delivery_credit_event, create_delivery_note, + update_delivery_note, + delete_delivery_note, + upload_delivery_note_image, + get_attachment_preview, list_my_cars, create_my_car, update_my_car, assign_car_to_delivery, + list_payment_methods, + create_payment_method, + update_payment_method, + delete_payment_method, + list_services, + create_service, + update_service, + delete_service, + set_delivery_service, + delete_delivery_service, auth_service, + admin_api_key: cfg.admin.api_key.clone().into(), }; // --- Router -------------------------------------------------------- @@ -130,29 +376,156 @@ async fn main() -> anyhow::Result<()> { // auf dem protected-Subtree. `route_layer` greift nur für die jetzt // definierten Routen — neue Routen darunter müssten explizit // nochmal angehängt werden. - let public = Router::new() + let mut public = Router::new() .merge(routes::health::router()) .merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi())); + // DEV-ONLY: ungeschützten Resync-Endpoint NUR bei explizit gesetztem Flag + // mounten — in Produktion existiert er nicht. + if cfg.dev.sync_enabled { + public = public.merge(routes::dev::router()); + tracing::warn!("DEV: POST /dev/resync gemountet (unauthentifiziert, dev.sync_enabled=true)"); + } let protected = Router::new() .merge(routes::accounts::router()) .merge(routes::tours::router()) .merge(routes::scans::router()) .merge(routes::deliveries::router()) + .merge(routes::attachments::router()) .merge(routes::cars::router()) + .merge(routes::payment_methods::router()) + .merge(routes::services::router()) .route_layer(from_fn_with_state(state.clone(), jwt_middleware)); + // `/admin`-Routen sind Maschinen-Endpunkte (Cron/ERPframe/Ops) und laufen + // bewusst NICHT über die JWT-Middleware, sondern über ein eigenes + // statisches-API-Key-Gate (Header `X-Admin-Api-Key`). Eigener Subtree mit + // eigener `route_layer`. + let admin = routes::admin::router() + .route_layer(from_fn_with_state(state.clone(), admin_api_key_middleware)); + let app = Router::new() .merge(public) .merge(protected) + .merge(admin) .layer(TraceLayer::new_for_http()) .with_state(state); + // --- ERP-Import-Scheduler (optional) ------------------------------- + // Läuft im selben Prozess. Nur wenn IMPORT_ENABLED=true. Hält den + // JobScheduler am Leben, indem die Variable bis Programmende gebunden + // bleibt (Drop würde die Jobs stoppen). + let _erp_scheduler = if cfg.import.enabled { + let scheduler = JobScheduler::new() + .await + .context("JobScheduler konnte nicht erstellt werden")?; + let import = import_erp_tours.clone(); + let offset = cfg.import.date_offset_days; + let job = Job::new_async(cfg.import.cron.as_str(), move |_uuid, _lock| { + let import = import.clone(); + Box::pin(async move { + let date = (chrono::Utc::now() + chrono::Duration::days(offset)).date_naive(); + match import.execute(date).await { + Ok(summary) => tracing::info!( + date = %summary.date, + total = summary.tours_total, + ok = summary.tours_ok, + failed = summary.tours_failed, + "erp_import.done" + ), + Err(e) => tracing::error!(%date, error = %e, "erp_import.failed"), + } + }) + }) + .context("ERP-Import-Job konnte nicht erstellt werden")?; + scheduler.add(job).await.context("ERP-Import-Job add fehlgeschlagen")?; + scheduler.start().await.context("Scheduler-Start fehlgeschlagen")?; + tracing::info!( + cron = %cfg.import.cron, + offset_days = cfg.import.date_offset_days, + "erp_import scheduler gestartet" + ); + Some(scheduler) + } else { + tracing::info!("erp_import deaktiviert (IMPORT_ENABLED!=true)"); + None + }; + + // --- Report-Retry-Scheduler (offene DOCUframe-Übertragungen) ------- + // Greift alle offenen `delivery_report_jobs` periodisch erneut auf + // (Upload/Makro, die zuvor fehlschlugen — z. B. DOCUframe nicht erreichbar). + let _report_scheduler = if cfg.report.upload_enabled { + let scheduler = JobScheduler::new() + .await + .context("Report-JobScheduler konnte nicht erstellt werden")?; + let process = process_delivery_report.clone(); + let jobs = report_job_repository.clone(); + let job = Job::new_async(cfg.report.retry_cron.as_str(), move |_uuid, _lock| { + let process = process.clone(); + let jobs = jobs.clone(); + Box::pin(async move { + match jobs.list_open().await { + Ok(open) => { + if !open.is_empty() { + tracing::info!(count = open.len(), "report_retry: offene Jobs werden erneut versucht"); + } + for j in open { + if let Err(e) = process.execute(j.delivery_id).await { + tracing::warn!(delivery_id = %j.delivery_id, error = %e, "report_retry: Job weiterhin offen"); + } + } + } + Err(e) => tracing::error!(error = %e, "report_retry: offene Jobs konnten nicht geladen werden"), + } + }) + }) + .context("Report-Retry-Job konnte nicht erstellt werden")?; + scheduler.add(job).await.context("Report-Retry-Job add fehlgeschlagen")?; + scheduler.start().await.context("Report-Scheduler-Start fehlgeschlagen")?; + tracing::info!(cron = %cfg.report.retry_cron, "report_retry scheduler gestartet"); + Some(scheduler) + } else { + tracing::info!("report_upload deaktiviert (REPORT_UPLOAD_ENABLED!=true) — Reports nur lokal"); + None + }; + let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port) .parse() .with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?; let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!("server läuft auf http://{}", addr); - axum::serve(listener, app).await?; + + // Graceful Shutdown: bei Ctrl-C / SIGTERM die GSD-Lizenz aktiv freigeben, + // damit der Seat nicht bis zum Session-Ablauf geblockt bleibt. + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal(gsd_service)) + .await?; Ok(()) } + +/// Wartet auf Ctrl-C bzw. SIGTERM und gibt davor die GSD-Lizenz frei. +async fn shutdown_signal(gsd_service: Arc) { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("Ctrl-C-Handler konnte nicht installiert werden"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("SIGTERM-Handler konnte nicht installiert werden") + .recv() + .await; + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + tracing::info!("shutdown signal empfangen — gebe GSD-Lizenz frei"); + gsd_service.release_license().await; +} diff --git a/crates/api/src/middleware/admin_key.rs b/crates/api/src/middleware/admin_key.rs new file mode 100644 index 0000000..b47dad2 --- /dev/null +++ b/crates/api/src/middleware/admin_key.rs @@ -0,0 +1,102 @@ +//! Admin-API-Key-Middleware: schützt die `/admin`-Routen mit einem +//! statischen Schlüssel (Maschinen-Zugang aus Cron/ERPframe-Makro/Skripten), +//! **ohne** Keycloak/JWT. +//! +//! Der Aufrufer schickt den Schlüssel im Header `X-Admin-Api-Key`. Verglichen +//! wird gegen `AppState::admin_api_key` (aus `[admin] api_key` in config.toml). +//! +//! Sicherheits-Eigenschaften: +//! * **Fail-closed**: ist kein Schlüssel konfiguriert (leer), wird *jede* +//! Admin-Anfrage abgelehnt — niemals „offen, weil unkonfiguriert". +//! * **Konstant-zeitlicher Vergleich**: kein früher Abbruch beim ersten +//! abweichenden Byte (reduziert Timing-Seitenkanäle). +//! * Bei fehlendem/falschem Schlüssel: `401 Unauthorized`, ohne den Grund +//! an den Client zu verraten (Detail nur ins Log). + +use axum::extract::{Request, State}; +use axum::http::HeaderMap; +use axum::middleware::Next; +use axum::response::Response; +use holzleitner_application::error::ApplicationError; + +use crate::error::ApiError; +use crate::state::AppState; + +/// Header-Name für den Admin-API-Key. +pub const ADMIN_API_KEY_HEADER: &str = "x-admin-api-key"; + +pub async fn admin_api_key_middleware( + State(state): State, + req: Request, + next: Next, +) -> Result { + let configured = state.admin_api_key.as_ref(); + + // Fail-closed: ohne konfigurierten Schlüssel ist /admin komplett dicht. + if configured.is_empty() { + tracing::warn!( + "admin-Zugriff abgelehnt: admin.api_key ist nicht gesetzt (fail-closed)" + ); + return Err(ApiError(ApplicationError::Unauthorized)); + } + + let presented = extract_key(req.headers()); + + match presented { + Some(key) if constant_time_eq(key.as_bytes(), configured.as_bytes()) => { + tracing::debug!("admin-api-key ok"); + Ok(next.run(req).await) + } + Some(_) => { + tracing::warn!("admin-Zugriff abgelehnt: falscher X-Admin-Api-Key"); + Err(ApiError(ApplicationError::Unauthorized)) + } + None => { + tracing::warn!("admin-Zugriff abgelehnt: Header X-Admin-Api-Key fehlt"); + Err(ApiError(ApplicationError::Unauthorized)) + } + } +} + +fn extract_key(headers: &HeaderMap) -> Option<&str> { + headers.get(ADMIN_API_KEY_HEADER)?.to_str().ok() +} + +/// Konstant-zeitlicher Byte-Vergleich. Die Längen-Vorprüfung verrät nur die +/// Schlüssellänge (unkritisch); der Inhalt wird ohne früh­zeitigen Abbruch +/// verglichen. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff: u8 = 0; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +mod tests { + use super::constant_time_eq; + + #[test] + fn eq_matches_identical() { + assert!(constant_time_eq(b"secret-key", b"secret-key")); + } + + #[test] + fn eq_rejects_different_content_same_len() { + assert!(!constant_time_eq(b"secret-key", b"secret-kex")); + } + + #[test] + fn eq_rejects_different_len() { + assert!(!constant_time_eq(b"short", b"longer-key")); + } + + #[test] + fn eq_rejects_empty_vs_nonempty() { + assert!(!constant_time_eq(b"", b"x")); + } +} diff --git a/crates/api/src/middleware/mod.rs b/crates/api/src/middleware/mod.rs index 8eaff25..28b4921 100644 --- a/crates/api/src/middleware/mod.rs +++ b/crates/api/src/middleware/mod.rs @@ -1,6 +1,8 @@ //! Axum-Middleware — z. B. JWT-Validierung gegen den //! `holzleitner_application::ports::AuthService`. +pub mod admin_key; pub mod jwt; +pub use admin_key::admin_api_key_middleware; pub use jwt::jwt_middleware; diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 4163bf6..55996e2 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -8,7 +8,9 @@ use utoipa::Modify; use utoipa::OpenApi; -use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; +use utoipa::openapi::security::{ + ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme, +}; #[derive(OpenApi)] #[openapi( @@ -30,10 +32,29 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; crate::routes::deliveries::cancel, crate::routes::deliveries::complete, crate::routes::deliveries::create_note, + crate::routes::deliveries::update_note, + crate::routes::deliveries::delete_note, + crate::routes::deliveries::upload_note_image, + crate::routes::deliveries::apply_credit, + crate::routes::attachments::get_attachment, crate::routes::deliveries::assign_car, + crate::routes::deliveries::set_service, + crate::routes::deliveries::delete_service_value, + crate::routes::services::list_services, + crate::routes::services::create_service, + crate::routes::services::update_service, + crate::routes::services::delete_service, crate::routes::cars::list_my_cars, crate::routes::cars::create_my_car, crate::routes::cars::update_my_car, + crate::routes::payment_methods::list_payment_methods, + crate::routes::payment_methods::create_payment_method, + crate::routes::payment_methods::update_payment_method, + crate::routes::payment_methods::delete_payment_method, + crate::routes::admin::import_erp, + crate::routes::admin::push_completion, + crate::routes::admin::delivered_belegnummern, + crate::routes::admin::mark_mail_sent, ), components( schemas( @@ -42,20 +63,31 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; holzleitner_domain::Article, holzleitner_domain::AuditAction, holzleitner_domain::Car, + holzleitner_domain::ContactChannel, + holzleitner_domain::ContactKind, + holzleitner_domain::ContactRole, + holzleitner_domain::ContactSource, holzleitner_domain::Customer, holzleitner_domain::CustomerContact, holzleitner_domain::Delivery, holzleitner_domain::DeliveryItem, holzleitner_domain::DeliveryNote, + holzleitner_domain::DeliveryCredit, holzleitner_domain::DeliveryState, holzleitner_domain::ScanState, holzleitner_domain::ScanStatus, holzleitner_domain::Tour, + holzleitner_domain::PaymentMethod, + holzleitner_domain::Service, + holzleitner_domain::ServiceKind, + holzleitner_domain::DeliveryServiceValue, holzleitner_domain::Warehouse, holzleitner_application::dto::TourDetails, holzleitner_application::dto::DeliveryWithItems, holzleitner_application::dto::TourSummary, holzleitner_application::dto::SyncTourRequest, + holzleitner_application::dto::SyncContactChannel, + holzleitner_application::dto::SyncContactSource, holzleitner_application::dto::SyncDelivery, holzleitner_application::dto::SyncDeliveryItem, holzleitner_application::dto::SetDeliveryOrderRequest, @@ -68,14 +100,33 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; holzleitner_application::dto::ScanResultStatus, holzleitner_application::dto::HoldDeliveryRequest, holzleitner_application::dto::CancelDeliveryRequest, + holzleitner_application::dto::CompleteDeliveryAcknowledgements, holzleitner_application::dto::DeliveryResponse, holzleitner_application::dto::CreateDeliveryNoteRequest, + holzleitner_application::dto::UpdateDeliveryNoteRequest, holzleitner_application::dto::DeliveryNoteResponse, + holzleitner_application::dto::CreditAction, + holzleitner_application::dto::DeliveryCreditEventRequest, + holzleitner_application::dto::DeliveryCreditResponse, holzleitner_application::dto::CreateCarRequest, holzleitner_application::dto::UpdateCarRequest, holzleitner_application::dto::CarResponse, holzleitner_application::dto::CarsList, holzleitner_application::dto::AssignCarRequest, + holzleitner_application::dto::CreatePaymentMethodRequest, + holzleitner_application::dto::UpdatePaymentMethodRequest, + holzleitner_application::dto::PaymentMethodResponse, + holzleitner_application::dto::PaymentMethodsList, + holzleitner_application::dto::CreateServiceRequest, + holzleitner_application::dto::UpdateServiceRequest, + holzleitner_application::dto::ServiceResponse, + holzleitner_application::dto::ServicesList, + holzleitner_application::dto::SetDeliveryServiceRequest, + holzleitner_application::dto::DeliveryServiceResponse, + holzleitner_application::usecases::ImportSummary, + crate::routes::admin::DeliveredBelegnummernResponse, + crate::routes::admin::MarkMailSentRequest, + crate::routes::admin::MarkMailSentResponse, crate::routes::tours::TourSummaryList, crate::routes::tours::SyncTourResponse, ) @@ -89,6 +140,9 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; (name = "scans", description = "Scan-Events (Beladung & Auslieferung)"), (name = "deliveries", description = "Delivery-Lifecycle (hold / resume / cancel / complete)"), (name = "cars", description = "Fahrzeug-Stammdaten pro Fahrer"), + (name = "payment-methods", description = "Zahlungsmethoden — globale Stammdaten"), + (name = "services", description = "Services / Lieferoptionen — globale Stammdaten"), + (name = "admin", description = "Betriebs-/Admin-Endpunkte (Maschinen-Zugang via X-Admin-Api-Key, kein JWT)"), ), security( ("bearer_auth" = []) @@ -113,6 +167,11 @@ impl Modify for SecurityAddon { .build(), ), ); + // Statischer API-Key für die /admin-Routen (Maschinen-Zugang). + components.add_security_scheme( + "admin_api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Admin-Api-Key"))), + ); } } } diff --git a/crates/api/src/routes/admin.rs b/crates/api/src/routes/admin.rs new file mode 100644 index 0000000..c719335 --- /dev/null +++ b/crates/api/src/routes/admin.rs @@ -0,0 +1,224 @@ +//! Admin-/Betriebs-Endpunkte. +//! +//! Aktuell: manueller ERP-Import-Trigger. Derselbe Use Case, den auch der +//! tägliche Scheduler ruft — hier on-demand für ein konkretes Datum +//! (Testen + manuelle Nachläufe im Betrieb). JWT-geschützt wie alle +//! protected Routen. + +use axum::Json; +use axum::Router; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::usecases::ImportSummary; + +use crate::error::ApiError; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/admin/import-erp", post(import_erp)) + .route("/admin/push-completion", post(push_completion)) + .route( + "/admin/delivered-belegnummern", + get(delivered_belegnummern), + ) + .route("/admin/mark-mail-sent", post(mark_mail_sent)) +} + +#[derive(Debug, Deserialize)] +pub struct ImportErpQuery { + /// Ziel-Tourdatum `YYYY-MM-DD`. Fehlt der Parameter, wird **heute** + /// verwendet. + #[serde(default)] + pub date: Option, +} + +/// Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung. +#[utoipa::path( + post, + path = "/admin/import-erp", + tag = "admin", + params( + ("date" = Option, Query, description = "Ziel-Tourdatum YYYY-MM-DD (Default: heute)") + ), + responses( + (status = 200, description = "Import durchgeführt", body = ImportSummary), + (status = 400, description = "Ungültiges Datum"), + (status = 401, description = "Admin-API-Key fehlt/ungültig"), + (status = 502, description = "ERP nicht erreichbar / Lesefehler") + ), + security(("admin_api_key" = [])) +)] +pub async fn import_erp( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let date = match query.date { + Some(s) => NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "ungültiges Datum '{s}' (erwartet YYYY-MM-DD): {e}" + ))) + })?, + None => chrono::Utc::now().date_naive(), + }; + tracing::info!(%date, "admin.import_erp"); + let summary = state.import_erp_tours.execute(date).await?; + tracing::info!( + %date, + total = summary.tours_total, + ok = summary.tours_ok, + failed = summary.tours_failed, + "admin.import_erp.done" + ); + Ok(Json(summary)) +} + +#[derive(Debug, Deserialize)] +pub struct PushCompletionQuery { + /// UUID der bereits abgeschlossenen Lieferung. + pub delivery_id: String, +} + +/// Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen +/// Lieferabschlusses erneut an (idempotenter Retry, falls der automatische +/// Push beim Abschluss fehlschlug). +#[utoipa::path( + post, + path = "/admin/push-completion", + tag = "admin", + params( + ("delivery_id" = String, Query, description = "UUID der abgeschlossenen Lieferung") + ), + responses( + (status = 204, description = "Rückschreiben erfolgreich"), + (status = 400, description = "Ungültige delivery_id"), + (status = 401, description = "Admin-API-Key fehlt/ungültig"), + (status = 404, description = "Lieferung nicht gefunden / nicht abgeschlossen"), + (status = 502, description = "ERP nicht erreichbar / Schreibfehler") + ), + security(("admin_api_key" = [])) +)] +pub async fn push_completion( + State(state): State, + Query(query): Query, +) -> Result { + let delivery_id = Uuid::parse_str(query.delivery_id.trim()).map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "ungültige delivery_id '{}': {e}", + query.delivery_id + ))) + })?; + tracing::info!(%delivery_id, "admin.push_completion"); + state.push_completion_to_erp.execute(delivery_id).await?; + tracing::info!(%delivery_id, "admin.push_completion.done"); + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +pub struct DeliveredBelegnummernQuery { + /// Ziel-Tag im Format `DD-MM-YYYY`. **Fehlt der Parameter, werden ALLE** + /// offenen (noch nicht versendeten) Belege über alle Tage geliefert — das + /// ist der Modus des Mailclients. + #[serde(default)] + pub day: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct DeliveredBelegnummernResponse { + /// Tag, nach dem gefiltert wurde (ISO `YYYY-MM-DD`), oder `"all"` wenn kein + /// `day` angegeben war. + pub day: String, + /// Anzahl der offenen (noch nicht versendeten) Belege. + pub count: usize, + /// Belegnummern aller **ausgelieferten** (abgeschlossenen) Lieferungen, + /// deren Liefermail noch **nicht versendet** wurde, aufsteigend nach + /// Abschluss-Zeitpunkt. + pub belegnummern: Vec, +} + +/// Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, +/// **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`). +/// „Ausgeliefert" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur +/// Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über +/// alle Tage) — so bleiben Belege über Mitternacht nicht hängen. +#[utoipa::path( + get, + path = "/admin/delivered-belegnummern", + tag = "admin", + params( + ("day" = Option, Query, description = "Tag DD-MM-YYYY; ohne Angabe ALLE offenen Belege") + ), + responses( + (status = 200, description = "Offene (nicht versendete) Belegnummern", body = DeliveredBelegnummernResponse), + (status = 400, description = "Ungültiger Tag"), + (status = 401, description = "Admin-API-Key fehlt/ungültig") + ), + security(("admin_api_key" = [])) +)] +pub async fn delivered_belegnummern( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let day: Option = match query.day { + Some(s) => Some(NaiveDate::parse_from_str(s.trim(), "%d-%m-%Y").map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "ungültiger Tag '{s}' (erwartet DD-MM-YYYY): {e}" + ))) + })?), + None => None, + }; + tracing::info!(?day, "admin.delivered_belegnummern"); + let belegnummern = state.list_delivered_belegnummern.execute(day).await?; + tracing::info!(?day, count = belegnummern.len(), "admin.delivered_belegnummern.done"); + Ok(Json(DeliveredBelegnummernResponse { + day: day.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_else(|| "all".into()), + count: belegnummern.len(), + belegnummern, + })) +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct MarkMailSentRequest { + /// Belegnummern, deren Liefermail erfolgreich versendet wurde und die als + /// versendet markiert werden sollen. + pub belegnummern: Vec, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct MarkMailSentResponse { + /// Anzahl frisch markierter (vorher offener) Belege. Bereits markierte + /// zählen nicht mit (idempotent). + pub marked: u64, +} + +/// Markiert die Liefermails der angegebenen Belegnummern als **versendet** +/// (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, +/// NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen +/// die Belege nicht mehr in `GET /admin/delivered-belegnummern`. +#[utoipa::path( + post, + path = "/admin/mark-mail-sent", + tag = "admin", + request_body = MarkMailSentRequest, + responses( + (status = 200, description = "Markierung durchgeführt", body = MarkMailSentResponse), + (status = 401, description = "Admin-API-Key fehlt/ungültig") + ), + security(("admin_api_key" = [])) +)] +pub async fn mark_mail_sent( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + tracing::info!(count = body.belegnummern.len(), "admin.mark_mail_sent"); + let marked = state.mark_mail_sent.execute(body.belegnummern).await?; + tracing::info!(marked, "admin.mark_mail_sent.done"); + Ok(Json(MarkMailSentResponse { marked })) +} diff --git a/crates/api/src/routes/attachments.rs b/crates/api/src/routes/attachments.rs new file mode 100644 index 0000000..c06635b --- /dev/null +++ b/crates/api/src/routes/attachments.rs @@ -0,0 +1,88 @@ +use axum::Router; +use axum::extract::{Path, Query, State}; +use axum::http::header; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use serde::Deserialize; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::extractors::AuthenticatedUser; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new().route("/attachments/{id}", get(get_attachment)) +} + +/// Größen-/Format-Parameter für das gerenderte Vorschaubild. Alle optional +/// mit sinnvollen Defaults — die App kann pro Anwendungsfall (Thumbnail vs. +/// Vollbild) abweichende Werte anfragen. +#[derive(Debug, Deserialize)] +pub struct PreviewQuery { + #[serde(default = "default_dimension")] + pub w: u32, + #[serde(default = "default_dimension")] + pub h: u32, + #[serde(default = "default_quality")] + pub q: u32, + #[serde(default = "default_ext")] + pub ext: String, + #[serde(default = "default_page")] + pub page: String, +} + +fn default_dimension() -> u32 { + 1024 +} +fn default_quality() -> u32 { + 85 +} +fn default_ext() -> String { + "jpeg".to_string() +} +fn default_page() -> String { + "1".to_string() +} + +/// Liefert ein gerendertes Vorschaubild des Attachments (Bytes), geladen +/// aus DOCUframe. Auflösung/Format über Query-Parameter steuerbar +/// (`?w=&h=&q=&ext=&page=`). +#[utoipa::path( + get, + path = "/attachments/{id}", + tag = "attachments", + params( + ("id" = Uuid, Path, description = "Attachment-Id (unsere UUID)"), + ("w" = Option, Query, description = "Breite in Pixeln (Default 1024)"), + ("h" = Option, Query, description = "Höhe in Pixeln (Default 1024)"), + ("q" = Option, Query, description = "Qualität 0–100 (Default 85)"), + ("ext" = Option, Query, description = "png|jpeg|jpg|webp|tiff (Default jpeg)"), + ("page" = Option, Query, description = "Seitennummer (Default 1)"), + ), + responses( + (status = 200, description = "Vorschaubild (Bytes)", content_type = "image/jpeg"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Attachment nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn get_attachment( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(id): Path, + Query(query): Query, +) -> Result { + tracing::info!(actor = claims.personalnummer, %id, "attachment.preview"); + // DOCUframe-Parameterschema: width_height_quality_extension. + let parameters = format!("{}_{}_{}_{}", query.w, query.h, query.q, query.ext); + let preview = state + .get_attachment_preview + .execute(id, parameters, query.page) + .await?; + + Ok(( + [(header::CONTENT_TYPE, preview.content_type)], + preview.bytes, + ) + .into_response()) +} diff --git a/crates/api/src/routes/deliveries.rs b/crates/api/src/routes/deliveries.rs index d4bf8e5..d2ff0ed 100644 --- a/crates/api/src/routes/deliveries.rs +++ b/crates/api/src/routes/deliveries.rs @@ -1,11 +1,15 @@ use axum::Json; use axum::Router; -use axum::extract::{Path, State}; -use axum::routing::{post, put}; +use axum::extract::{DefaultBodyLimit, Multipart, Path, State}; +use axum::http::StatusCode; +use axum::routing::{patch, post, put}; use holzleitner_application::dto::{ - AssignCarRequest, CancelDeliveryRequest, CreateDeliveryNoteRequest, DeliveryNoteResponse, - DeliveryResponse, HoldDeliveryRequest, + AssignCarRequest, CancelDeliveryRequest, CompleteDeliveryAcknowledgements, + CreateDeliveryNoteRequest, DeliveryCreditEventRequest, DeliveryCreditResponse, + DeliveryNoteResponse, DeliveryResponse, DeliveryServiceResponse, HoldDeliveryRequest, + SetDeliveryServiceRequest, UpdateDeliveryNoteRequest, }; +use holzleitner_application::error::ApplicationError; use holzleitner_application::ports::DeliveryAction; use uuid::Uuid; @@ -13,14 +17,40 @@ use crate::error::ApiError; use crate::extractors::AuthenticatedUser; use crate::state::AppState; +/// Maximale Größe eines multipart-Uploads. Axums Default-Body-Limit liegt +/// bei 2 MiB — Handy-Kamerafotos sprengen das regelmäßig, der +/// multipart-Stream wird dann abgeschnitten und multer wirft „Error parsing +/// multipart…". Daher heben wir das Limit **nur** für die multipart-Routen +/// an (Bild-Upload + Abschluss mit Signaturen); die JSON-Routen behalten ihr +/// sicheres Default. +const MULTIPART_BODY_LIMIT: usize = 25 * 1024 * 1024; + pub fn router() -> Router { + // Eigener Sub-Router für multipart-Uploads mit angehobenem Body-Limit. + let multipart = Router::new() + .route( + "/deliveries/{delivery_id}/notes/image", + post(upload_note_image), + ) + .route("/deliveries/{delivery_id}/complete", post(complete)) + .layer(DefaultBodyLimit::max(MULTIPART_BODY_LIMIT)); + Router::new() .route("/deliveries/{delivery_id}/hold", post(hold)) .route("/deliveries/{delivery_id}/resume", post(resume)) .route("/deliveries/{delivery_id}/cancel", post(cancel)) - .route("/deliveries/{delivery_id}/complete", post(complete)) .route("/deliveries/{delivery_id}/notes", post(create_note)) + .route( + "/deliveries/{delivery_id}/notes/{note_id}", + patch(update_note).delete(delete_note), + ) + .route("/deliveries/{delivery_id}/credit", post(apply_credit)) + .route( + "/deliveries/{delivery_id}/services/{service_id}", + put(set_service).delete(delete_service_value), + ) .route("/deliveries/{delivery_id}/assigned-car", put(assign_car)) + .merge(multipart) } /// Setzt die Lieferung auf `held`. Nur aus `active` zulässig. @@ -113,14 +143,30 @@ pub async fn cancel( } /// Schließt die Lieferung ab — `state = completed`. Nur aus `active`. +/// +/// `multipart/form-data` mit drei Feldern: +/// * `customer_signature` — PNG der Kunden-Unterschrift (Pflicht) +/// * `driver_signature` — PNG der Fahrer-Unterschrift (Pflicht) +/// * `acknowledgements` — JSON (`CompleteDeliveryAcknowledgements`): +/// `receiptConfirmed` (Pflicht true), `notesAcknowledged`, +/// `acknowledgedNoteIds`, `authorCarId`. +/// +/// Atomar: Signaturen werden lokal gespeichert, die Abschluss-Zeile +/// geschrieben und der Status auf `completed` gesetzt — alles oder nichts. +/// Gates: Lieferung aktiv, alle scanbaren Positionen fertig, Notizen +/// bestätigt (falls vorhanden). #[utoipa::path( post, path = "/deliveries/{delivery_id}/complete", tag = "deliveries", params(("delivery_id" = Uuid, Path)), + request_body( + content_type = "multipart/form-data", + description = "Felder `customer_signature`, `driver_signature` (PNG) + `acknowledgements` (JSON)" + ), responses( (status = 200, description = "Lieferung abgeschlossen", body = DeliveryResponse), - (status = 400, description = "Invalider Statusübergang"), + (status = 400, description = "Invalider Statusübergang / fehlende Signatur / offene Scans / Notizen unbestätigt"), (status = 401, description = "Authentifizierung fehlgeschlagen"), (status = 404, description = "Lieferung nicht gefunden") ), @@ -130,15 +176,107 @@ pub async fn complete( State(state): State, AuthenticatedUser(claims): AuthenticatedUser, Path(delivery_id): Path, + mut multipart: Multipart, ) -> Result, ApiError> { tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.complete"); + + let mut customer_png: Option> = None; + let mut driver_png: Option> = None; + let mut acknowledgements: Option = None; + + while let Some(field) = multipart.next_field().await.map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "multipart konnte nicht gelesen werden: {e}" + ))) + })? { + match field.name() { + Some("customer_signature") => { + let data = field.bytes().await.map_err(read_err)?; + customer_png = Some(data.to_vec()); + } + Some("driver_signature") => { + let data = field.bytes().await.map_err(read_err)?; + driver_png = Some(data.to_vec()); + } + Some("acknowledgements") => { + let text = field.text().await.map_err(read_err)?; + let parsed: CompleteDeliveryAcknowledgements = + serde_json::from_str(&text).map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "`acknowledgements` ist kein gültiges JSON: {e}" + ))) + })?; + acknowledgements = Some(parsed); + } + _ => {} + } + } + + let customer_png = customer_png.ok_or_else(|| { + ApiError(ApplicationError::Validation( + "Feld `customer_signature` fehlt".into(), + )) + })?; + let driver_png = driver_png.ok_or_else(|| { + ApiError(ApplicationError::Validation( + "Feld `driver_signature` fehlt".into(), + )) + })?; + let acknowledgements = acknowledgements.ok_or_else(|| { + ApiError(ApplicationError::Validation( + "Feld `acknowledgements` fehlt".into(), + )) + })?; + let delivery = state - .apply_delivery_action - .execute(delivery_id, DeliveryAction::Complete) + .complete_delivery + .execute( + delivery_id, + claims.personalnummer, + acknowledgements, + customer_png, + driver_png, + ) .await?; + + // PDF-Report (best-effort, NACH erfolgreichem Abschluss): ein Fehler hier + // darf die Abschluss-Antwort NIE kippen. + if state.report_upload_enabled { + // An DOCUframe übertragen — im Hintergrund, damit die Antwort schnell + // bleibt. Schlägt etwas fehl, bleibt ein Job in PG offen und der + // Retry-Cron versucht es erneut. + let process = state.process_delivery_report.clone(); + tokio::spawn(async move { + match process.execute(delivery_id).await { + Ok(()) => tracing::info!(%delivery_id, "delivery.complete.report_uploaded"), + Err(e) => tracing::warn!( + %delivery_id, error = %e, + "delivery.complete.report_upload_failed (Retry-Cron übernimmt)" + ), + } + }); + } else { + // DOCUframe-Upload aus (Dev): Report nur lokal erzeugen. + match state.generate_delivery_report.execute(delivery_id).await { + Ok(reference) => { + tracing::info!(%delivery_id, reference, "delivery.complete.report_generated_local") + } + Err(e) => { + tracing::error!(%delivery_id, error = %e, "delivery.complete.report_failed") + } + } + } + Ok(Json(DeliveryResponse { delivery })) } +/// Helfer: multipart-Feld-Lesefehler → `Validation`. +fn read_err(e: axum::extract::multipart::MultipartError) -> ApiError { + ApiError(ApplicationError::Validation(format!( + "feld konnte nicht gelesen werden: {e}" + ))) +} + /// Legt eine neue Notiz an einer Lieferung an. Mindestens eines von /// `text` und `imageAttachment` muss inhaltlich gefüllt sein /// (Leerstrings werden serverseitig getrimmt und als leer behandelt). @@ -170,6 +308,238 @@ pub async fn create_note( Ok(Json(DeliveryNoteResponse { note })) } +/// Ändert Text/Bild einer Notiz. Innerhalb des (geteilten) Accounts darf +/// jeder Fahrer Notizen pflegen — kein Autor-Check. `delivery_id` ist Teil +/// des Pfads (REST-Konsistenz), die Notiz wird über `note_id` adressiert. +#[utoipa::path( + patch, + path = "/deliveries/{delivery_id}/notes/{note_id}", + tag = "deliveries", + params( + ("delivery_id" = Uuid, Path), + ("note_id" = Uuid, Path), + ), + request_body = UpdateDeliveryNoteRequest, + responses( + (status = 200, description = "Notiz aktualisiert", body = DeliveryNoteResponse), + (status = 400, description = "Notiz ohne Inhalt"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Notiz nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn update_note( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path((delivery_id, note_id)): Path<(Uuid, Uuid)>, + Json(req): Json, +) -> Result, ApiError> { + tracing::info!( + actor = claims.personalnummer, + %delivery_id, + %note_id, + "delivery.update_note" + ); + let note = state.update_delivery_note.execute(note_id, req).await?; + Ok(Json(DeliveryNoteResponse { note })) +} + +/// Löscht eine Notiz. Antwortet mit `204 No Content`. +#[utoipa::path( + delete, + path = "/deliveries/{delivery_id}/notes/{note_id}", + tag = "deliveries", + params( + ("delivery_id" = Uuid, Path), + ("note_id" = Uuid, Path), + ), + responses( + (status = 204, description = "Notiz gelöscht"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Notiz nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_note( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path((delivery_id, note_id)): Path<(Uuid, Uuid)>, +) -> Result { + tracing::info!( + actor = claims.personalnummer, + %delivery_id, + %note_id, + "delivery.delete_note" + ); + state.delete_delivery_note.execute(note_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// Lädt ein Bild zu einer Lieferung hoch (multipart/form-data, Feld `file`) +/// und legt dafür eine Bild-Notiz an. Das Bild geht in den +/// DOCUframe-Dokumentenspeicher; gespeichert wird die zurückgelieferte +/// Referenz (`~ObjectID`) als `image_attachment` der Notiz. +#[utoipa::path( + post, + path = "/deliveries/{delivery_id}/notes/image", + tag = "deliveries", + params(("delivery_id" = Uuid, Path)), + request_body( + content_type = "multipart/form-data", + description = "Formularfeld `file` mit den Bilddaten" + ), + responses( + (status = 200, description = "Bild hochgeladen, Notiz angelegt", body = DeliveryNoteResponse), + (status = 400, description = "Kein/leeres Datei-Feld"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Lieferung nicht gefunden"), + (status = 500, description = "Upload zu DOCUframe fehlgeschlagen") + ), + security(("bearer_auth" = [])) +)] +pub async fn upload_note_image( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(delivery_id): Path, + mut multipart: Multipart, +) -> Result, ApiError> { + tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.upload_note_image"); + + let mut bytes: Option> = None; + let mut filename = String::from("upload"); + let mut mime = String::from("application/octet-stream"); + + while let Some(field) = multipart.next_field().await.map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "multipart konnte nicht gelesen werden: {e}" + ))) + })? { + if field.name() == Some("file") { + if let Some(fname) = field.file_name() { + filename = fname.to_owned(); + } + if let Some(ct) = field.content_type() { + mime = ct.to_owned(); + } + let data = field.bytes().await.map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "datei konnte nicht gelesen werden: {e}" + ))) + })?; + bytes = Some(data.to_vec()); + } + } + + let bytes = bytes.ok_or_else(|| { + ApiError(ApplicationError::Validation( + "kein `file`-Feld im multipart-Body".into(), + )) + })?; + + let note = state + .upload_delivery_note_image + .execute(delivery_id, claims.personalnummer, None, filename, mime, bytes) + .await?; + Ok(Json(DeliveryNoteResponse { note })) +} + +/// Wendet ein Betrags-Gutschrift-Ereignis an (`set`/`remove`). Append-only, +/// idempotent über `clientEventId`. Nur bei aktiver Lieferung; bei `set` sind +/// Betrag (0 < x ≤ 150 €, 10-€-Schritte) und Grund Pflicht. Antwort: der +/// aktuelle Gutschrift-Stand (`null`, wenn entfernt). +#[utoipa::path( + post, + path = "/deliveries/{delivery_id}/credit", + tag = "deliveries", + params(("delivery_id" = Uuid, Path)), + request_body = DeliveryCreditEventRequest, + responses( + (status = 200, description = "Gutschrift gesetzt/entfernt", body = DeliveryCreditResponse), + (status = 400, description = "Ungültiger Betrag/Grund oder Lieferung nicht aktiv"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Lieferung nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn apply_credit( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(delivery_id): Path, + Json(req): Json, +) -> Result, ApiError> { + tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.apply_credit"); + let credit = state + .apply_delivery_credit_event + .execute(delivery_id, claims.personalnummer, req) + .await?; + Ok(Json(DeliveryCreditResponse { credit })) +} + +/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum +/// Service-Typ passende Feld (`boolValue`/`numericValue`) muss gesetzt sein; +/// numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung. +#[utoipa::path( + put, + path = "/deliveries/{delivery_id}/services/{service_id}", + tag = "deliveries", + params( + ("delivery_id" = Uuid, Path), + ("service_id" = Uuid, Path), + ), + request_body = SetDeliveryServiceRequest, + responses( + (status = 200, description = "Wert gesetzt", body = DeliveryServiceResponse), + (status = 400, description = "Wert passt nicht zum Service-Typ / außerhalb min-max / Lieferung nicht aktiv"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Service oder Lieferung nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn set_service( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path((delivery_id, service_id)): Path<(Uuid, Uuid)>, + Json(req): Json, +) -> Result, ApiError> { + tracing::info!(actor = claims.personalnummer, %delivery_id, %service_id, "delivery.set_service"); + let value = state + .set_delivery_service + .execute(delivery_id, service_id, claims.personalnummer, req) + .await?; + Ok(Json(DeliveryServiceResponse { value })) +} + +/// Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt"). +/// Nur bei aktiver Lieferung. Antwort `204`. +#[utoipa::path( + delete, + path = "/deliveries/{delivery_id}/services/{service_id}", + tag = "deliveries", + params( + ("delivery_id" = Uuid, Path), + ("service_id" = Uuid, Path), + ), + responses( + (status = 204, description = "Wert entfernt"), + (status = 400, description = "Lieferung nicht aktiv"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Lieferung nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_service_value( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path((delivery_id, service_id)): Path<(Uuid, Uuid)>, +) -> Result { + tracing::info!(actor = claims.personalnummer, %delivery_id, %service_id, "delivery.delete_service_value"); + state + .delete_delivery_service + .execute(delivery_id, service_id) + .await?; + Ok(StatusCode::NO_CONTENT) +} + /// Setzt das `assigned_car_id` einer Lieferung. `carId: null` löst /// die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug /// zum angemeldeten Account gehört. diff --git a/crates/api/src/routes/dev.rs b/crates/api/src/routes/dev.rs new file mode 100644 index 0000000..2e18fd7 --- /dev/null +++ b/crates/api/src/routes/dev.rs @@ -0,0 +1,160 @@ +//! DEV-ONLY Endpunkte. Werden in `main.rs` **nur** gemountet, wenn +//! `dev.sync_enabled = true` (config.toml) — in Produktion existieren sie nicht. +//! +//! `POST /dev/resync` ist bewusst **unauthentifiziert** (liegt auf dem +//! public Router), damit man ihn ohne JWT per `curl` triggern kann. Er macht +//! die Postgres-Tourdaten platt und importiert frisch aus dem ERP — der +//! „überschreibende" Sync für die lokale Entwicklung. NIEMALS in Produktion +//! aktivieren. + +use axum::Json; +use axum::Router; +use axum::extract::{Query, State}; +use axum::routing::post; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::usecases::ImportSummary; + +use crate::error::ApiError; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/dev/resync", post(dev_resync)) + .route("/dev/generate-report", post(dev_generate_report)) + .route("/dev/process-report", post(dev_process_report)) + .route("/dev/unmark-mail-sent", post(dev_unmark_mail_sent)) +} + +#[derive(Debug, Deserialize)] +pub struct DevResyncQuery { + /// Ziel-Tourdatum `YYYY-MM-DD`. Fehlt der Parameter, wird **heute** + /// (echte Uhr) verwendet. + #[serde(default)] + pub date: Option, +} + +/// DEV-ONLY, UNAUTHENTIFIZIERT: löscht alle Postgres-Tourdaten und importiert +/// das Datum frisch aus dem ERP. Liefert die Import-Zusammenfassung. +pub async fn dev_resync( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let date = match query.date { + Some(s) => NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "ungültiges Datum '{s}' (erwartet YYYY-MM-DD): {e}" + ))) + })?, + None => chrono::Utc::now().date_naive(), + }; + tracing::warn!(%date, "dev.resync: Postgres wird überschrieben + neu importiert"); + let summary = state.dev_resync_tours.execute(date).await?; + tracing::info!( + %date, + total = summary.tours_total, + ok = summary.tours_ok, + failed = summary.tours_failed, + provisioned = summary.drivers_provisioned, + "dev.resync.done" + ); + Ok(Json(summary)) +} + +#[derive(Debug, Deserialize)] +pub struct DevReportQuery { + /// UUID der Lieferung, für die der Report erzeugt werden soll. + pub delivery_id: String, +} + +#[derive(Debug, Serialize)] +pub struct DevReportResponse { + pub reference: String, +} + +/// DEV-ONLY: erzeugt den PDF-Report für eine Lieferung (ohne echten Abschluss) +/// und gibt die Sink-Referenz (Dateipfad) zurück. Zum Iterieren am Layout. +pub async fn dev_generate_report( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let delivery_id = Uuid::parse_str(query.delivery_id.trim()).map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "ungültige delivery_id '{}': {e}", + query.delivery_id + ))) + })?; + tracing::warn!(%delivery_id, "dev.generate_report angestoßen"); + let reference = state.generate_delivery_report.execute(delivery_id).await?; + tracing::info!(%delivery_id, reference, "dev.generate_report.done"); + Ok(Json(DevReportResponse { reference })) +} + +/// DEV-ONLY: stößt die volle DOCUframe-Übertragungs-Pipeline für eine Lieferung +/// an (Render → Upload → Makro → Cleanup). Solange das Makro fehlt, schlägt der +/// Makro-Schritt erwartungsgemäß fehl — der Job bleibt dann in PG offen und der +/// Retry-Cron versucht es erneut. Liefert eine kurze Status-Meldung. +pub async fn dev_process_report( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let delivery_id = Uuid::parse_str(query.delivery_id.trim()).map_err(|e| { + ApiError(ApplicationError::Validation(format!( + "ungültige delivery_id '{}': {e}", + query.delivery_id + ))) + })?; + tracing::warn!(%delivery_id, "dev.process_report angestoßen"); + match state.process_delivery_report.execute(delivery_id).await { + Ok(()) => { + tracing::info!(%delivery_id, "dev.process_report.done"); + Ok(Json(DevProcessResponse { + ok: true, + message: "Report an DOCUframe übertragen + lokale Dateien aufgeräumt".into(), + })) + } + Err(e) => { + tracing::warn!(%delivery_id, error = %e, "dev.process_report.failed (Job bleibt offen)"); + Ok(Json(DevProcessResponse { + ok: false, + message: format!("fehlgeschlagen (Job in PG offen, Cron retried): {e}"), + })) + } + } +} + +#[derive(Debug, Serialize)] +pub struct DevProcessResponse { + pub ok: bool, + pub message: String, +} + +#[derive(Debug, Deserialize)] +pub struct DevUnmarkRequest { + /// Belegnummern, deren Mail-Versendet-Markierung wieder aufgehoben werden + /// soll (für erneutes Testen). + pub belegnummern: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DevUnmarkResponse { + /// Anzahl tatsächlich zurückgesetzter (vorher markierter) Belege. + pub unmarked: u64, +} + +/// DEV-ONLY, UNAUTHENTIFIZIERT: setzt `mail_sent_at` der angegebenen +/// Belegnummern wieder auf NULL, sodass sie erneut als offen in +/// `GET /admin/delivered-belegnummern` erscheinen. Zum wiederholten Testen +/// des Mailclients. +pub async fn dev_unmark_mail_sent( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + tracing::warn!(count = body.belegnummern.len(), "dev.unmark_mail_sent"); + let unmarked = state.mark_mail_sent.unmark(body.belegnummern).await?; + tracing::info!(unmarked, "dev.unmark_mail_sent.done"); + Ok(Json(DevUnmarkResponse { unmarked })) +} diff --git a/crates/api/src/routes/mod.rs b/crates/api/src/routes/mod.rs index 95d5264..a697efa 100644 --- a/crates/api/src/routes/mod.rs +++ b/crates/api/src/routes/mod.rs @@ -2,8 +2,13 @@ //! zusammengesetzt. pub mod accounts; +pub mod admin; +pub mod attachments; pub mod cars; pub mod deliveries; +pub mod dev; pub mod health; +pub mod payment_methods; pub mod scans; +pub mod services; pub mod tours; diff --git a/crates/api/src/routes/payment_methods.rs b/crates/api/src/routes/payment_methods.rs new file mode 100644 index 0000000..6182822 --- /dev/null +++ b/crates/api/src/routes/payment_methods.rs @@ -0,0 +1,142 @@ +//! `/payment-methods` — globale Zahlungs-Stammdaten. +//! +//! Lese-Endpoint ist von der App frei nutzbar (Liste für die Auswahl in +//! der Auslieferungs-Phase). Schreib-Endpoints (POST/PATCH/DELETE) sind +//! Admin-Operationen — Authentifizierung schützt sie über die globale +//! Middleware, eine Rollen-Trennung kommt später (Phase H). + +use axum::Json; +use axum::Router; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::get; +// `Path` für PATCH/DELETE — direkt aus axum::extract verwendet. +use holzleitner_application::dto::{ + CreatePaymentMethodRequest, PaymentMethodResponse, PaymentMethodsList, + UpdatePaymentMethodRequest, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::extractors::AuthenticatedUser; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route( + "/payment-methods", + get(list_payment_methods).post(create_payment_method), + ) + .route( + "/payment-methods/{id}", + axum::routing::patch(update_payment_method).delete(delete_payment_method), + ) +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ListPaymentMethodsQuery { + /// Default `false` — Endpoint liefert nur aktive Methoden. + #[serde(default)] + pub include_inactive: bool, +} + +/// Listet die Zahlungsmethoden. +#[utoipa::path( + get, + path = "/payment-methods", + tag = "payment-methods", + params( + ("includeInactive" = Option, Query, + description = "Wenn true, werden inaktive Methoden mitgeliefert (default: false)") + ), + responses( + (status = 200, description = "Zahlungsmethoden", body = PaymentMethodsList), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security(("bearer_auth" = [])) +)] +pub async fn list_payment_methods( + State(state): State, + AuthenticatedUser(_claims): AuthenticatedUser, + Query(query): Query, +) -> Result, ApiError> { + let methods = state + .list_payment_methods + .execute(query.include_inactive) + .await?; + Ok(Json(PaymentMethodsList { methods })) +} + +/// Legt eine neue Zahlungsmethode an. +#[utoipa::path( + post, + path = "/payment-methods", + tag = "payment-methods", + request_body = CreatePaymentMethodRequest, + responses( + (status = 200, body = PaymentMethodResponse), + (status = 400, description = "Validierungsfehler (z. B. doppelter code)"), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security(("bearer_auth" = [])) +)] +pub async fn create_payment_method( + State(state): State, + AuthenticatedUser(_claims): AuthenticatedUser, + Json(req): Json, +) -> Result, ApiError> { + let method = state.create_payment_method.execute(req).await?; + Ok(Json(PaymentMethodResponse { method })) +} + +/// Patcht Anzeige-Name und/oder Aktiv-Flag. +#[utoipa::path( + patch, + path = "/payment-methods/{id}", + tag = "payment-methods", + params(("id" = Uuid, Path, description = "Zahlungsmethoden-Id")), + request_body = UpdatePaymentMethodRequest, + responses( + (status = 200, body = PaymentMethodResponse), + (status = 404, description = "Methode nicht gefunden"), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security(("bearer_auth" = [])) +)] +pub async fn update_payment_method( + State(state): State, + AuthenticatedUser(_claims): AuthenticatedUser, + Path(id): Path, + Json(req): Json, +) -> Result, ApiError> { + let method = state.update_payment_method.execute(id, req).await?; + Ok(Json(PaymentMethodResponse { method })) +} + +/// Hartes Löschen. `409 Conflict`, wenn die Methode von einer Lieferung +/// referenziert wird — der Admin soll dann den `active = false`-Pfad +/// nutzen. +#[utoipa::path( + delete, + path = "/payment-methods/{id}", + tag = "payment-methods", + params(("id" = Uuid, Path, description = "Zahlungsmethoden-Id")), + responses( + (status = 204, description = "Methode gelöscht"), + (status = 404, description = "Methode nicht gefunden"), + (status = 409, description = "Methode ist noch von Lieferungen referenziert"), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_payment_method( + State(state): State, + AuthenticatedUser(_claims): AuthenticatedUser, + Path(id): Path, +) -> Result { + state.delete_payment_method.execute(id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/api/src/routes/services.rs b/crates/api/src/routes/services.rs new file mode 100644 index 0000000..8bd6537 --- /dev/null +++ b/crates/api/src/routes/services.rs @@ -0,0 +1,133 @@ +//! `/services` — admin-konfigurierbare Service-Stammdaten (früher +//! „Lieferoptionen"). +//! +//! Lese-Endpoint nutzt die App (Phase 4). Schreib-Endpoints (POST/PATCH/ +//! DELETE) sind Admin-Operationen — geschützt durch die globale JWT- +//! Middleware; Rollen-Trennung kommt später (Phase H). + +use axum::Json; +use axum::Router; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::get; +use holzleitner_application::dto::{ + CreateServiceRequest, ServiceResponse, ServicesList, UpdateServiceRequest, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::extractors::AuthenticatedUser; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/services", get(list_services).post(create_service)) + .route( + "/services/{id}", + axum::routing::patch(update_service).delete(delete_service), + ) +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ListServicesQuery { + #[serde(default)] + pub include_inactive: bool, +} + +/// Listet die Services (sortiert nach `sortOrder`). +#[utoipa::path( + get, + path = "/services", + tag = "services", + params( + ("includeInactive" = Option, Query, + description = "Wenn true, werden inaktive Services mitgeliefert (default: false)") + ), + responses( + (status = 200, description = "Services", body = ServicesList), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security(("bearer_auth" = [])) +)] +pub async fn list_services( + State(state): State, + AuthenticatedUser(_claims): AuthenticatedUser, + Query(query): Query, +) -> Result, ApiError> { + let services = state.list_services.execute(query.include_inactive).await?; + Ok(Json(ServicesList { services })) +} + +/// Legt einen neuen Service an. +#[utoipa::path( + post, + path = "/services", + tag = "services", + request_body = CreateServiceRequest, + responses( + (status = 200, body = ServiceResponse), + (status = 400, description = "Validierungsfehler (z. B. kind/min/max inkonsistent)"), + (status = 409, description = "key existiert bereits"), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security(("bearer_auth" = [])) +)] +pub async fn create_service( + State(state): State, + AuthenticatedUser(_claims): AuthenticatedUser, + Json(req): Json, +) -> Result, ApiError> { + let service = state.create_service.execute(req).await?; + Ok(Json(ServiceResponse { service })) +} + +/// Patcht Name/Grenzen/Aktiv-Flag/Sortierung. `kind` ist nicht änderbar. +#[utoipa::path( + patch, + path = "/services/{id}", + tag = "services", + params(("id" = Uuid, Path, description = "Service-Id")), + request_body = UpdateServiceRequest, + responses( + (status = 200, body = ServiceResponse), + (status = 404, description = "Service nicht gefunden"), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security(("bearer_auth" = [])) +)] +pub async fn update_service( + State(state): State, + AuthenticatedUser(_claims): AuthenticatedUser, + Path(id): Path, + Json(req): Json, +) -> Result, ApiError> { + let service = state.update_service.execute(id, req).await?; + Ok(Json(ServiceResponse { service })) +} + +/// Hartes Löschen. `409 Conflict`, wenn der Service noch von einer Lieferung +/// referenziert wird — dann stattdessen deaktivieren. +#[utoipa::path( + delete, + path = "/services/{id}", + tag = "services", + params(("id" = Uuid, Path, description = "Service-Id")), + responses( + (status = 204, description = "Service gelöscht"), + (status = 404, description = "Service nicht gefunden"), + (status = 409, description = "Service ist noch referenziert"), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_service( + State(state): State, + AuthenticatedUser(_claims): AuthenticatedUser, + Path(id): Path, +) -> Result { + state.delete_service.execute(id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index b6da606..b3f4587 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -2,10 +2,19 @@ use std::sync::Arc; use holzleitner_application::ports::AuthService; use holzleitner_application::usecases::{ - ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase, - CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase, - ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase, - UpdateMyCarUseCase, + ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase, + AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase, + CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase, + DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase, + DeleteServiceUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase, + GetAttachmentPreviewUseCase, GetTourUseCase, + ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase, + ListMyToursTodayUseCase, ListPaymentMethodsUseCase, + ListServicesUseCase, MarkMailSentUseCase, ProcessDeliveryReportUseCase, + PushCompletionToErpUseCase, + SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase, + UpdateMyCarUseCase, UpdatePaymentMethodUseCase, UpdateServiceUseCase, + UploadDeliveryNoteImageUseCase, }; /// Shared application state, der per Axum's `State`-Extractor in alle @@ -21,13 +30,49 @@ pub struct AppState { pub get_tour: Arc, pub list_my_tours_today: Arc, pub sync_tour: Arc, + pub import_erp_tours: Arc, + /// DEV-ONLY: überschreibender Resync (löscht Postgres + importiert neu). + pub dev_resync_tours: Arc, + /// Erzeugt den PDF-Lieferreport (lokal — Dev-Endpoint + Fallback ohne Upload). + pub generate_delivery_report: Arc, + /// Überträgt den Report an DOCUframe (Upload → Makro → Cleanup) — beim + /// Abschluss (Hintergrund) + Retry-Cron + Dev-Endpoint. + pub process_delivery_report: Arc, + /// Spiegelt `REPORT_UPLOAD_ENABLED`: steuert, ob beim Abschluss die + /// DOCUframe-Übertragung läuft (an) oder nur lokal erzeugt wird (aus). + pub report_upload_enabled: bool, pub set_delivery_order: Arc, pub apply_scans: Arc, pub apply_delivery_action: Arc, + pub complete_delivery: Arc, + pub push_completion_to_erp: Arc, + /// Admin: Belegnummern offener (noch nicht versendeter) Lieferungen. + pub list_delivered_belegnummern: Arc, + /// Admin: Liefermails von Belegnummern als versendet markieren (Dedup). + pub mark_mail_sent: Arc, + pub apply_delivery_credit_event: Arc, pub create_delivery_note: Arc, + pub update_delivery_note: Arc, + pub delete_delivery_note: Arc, + pub upload_delivery_note_image: Arc, + pub get_attachment_preview: Arc, pub list_my_cars: Arc, pub create_my_car: Arc, pub update_my_car: Arc, pub assign_car_to_delivery: Arc, + pub list_payment_methods: Arc, + pub create_payment_method: Arc, + pub update_payment_method: Arc, + pub delete_payment_method: Arc, + pub list_services: Arc, + pub create_service: Arc, + pub update_service: Arc, + pub delete_service: Arc, + pub set_delivery_service: Arc, + pub delete_delivery_service: Arc, pub auth_service: Arc, + /// Statischer API-Key-Gate für die `/admin`-Routen (Header + /// `X-Admin-Api-Key`). Leer ⇒ alle Admin-Routen gesperrt (fail-closed). + /// Wird von der `admin_api_key`-Middleware konstant-zeitlich verglichen. + pub admin_api_key: Arc, } diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index c76ead0..2fb301c 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -17,4 +17,6 @@ async-trait.workspace = true thiserror.workspace = true uuid.workspace = true chrono.workspace = true +sha2.workspace = true +imagesize.workspace = true utoipa = { workspace = true, optional = true } diff --git a/crates/application/src/dto/complete.rs b/crates/application/src/dto/complete.rs new file mode 100644 index 0000000..e39bdda --- /dev/null +++ b/crates/application/src/dto/complete.rs @@ -0,0 +1,40 @@ +//! Eingabe für den Lieferungs-Abschluss (`POST /deliveries/{id}/complete`). +//! +//! Der Endpoint nimmt `multipart/form-data` entgegen — zwei Signatur-PNGs +//! plus dieses JSON-Feld mit den Checkbox-Bestätigungen des Kunden. Die +//! Antwort ist die frisch abgeschlossene `Delivery` (`DeliveryResponse`). + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Dokumentierte Bestätigungen des Kunden zum Abschlusszeitpunkt. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CompleteDeliveryAcknowledgements { + /// „Ware im ordnungsgemäßen Zustand erhalten / Aufbau korrekt." — Pflicht. + pub receipt_confirmed: bool, + /// „Anmerkungen zur Lieferung zur Kenntnis genommen." — Pflicht nur, wenn + /// Notizen existieren (das prüft der Server). + #[serde(default)] + pub notes_acknowledged: bool, + /// Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt + /// wurden (Audit-Robustheit). + #[serde(default)] + pub acknowledged_note_ids: Vec, + /// Inkasso-Bestätigung des Fahrers: „der offene Betrag wurde erhalten + /// (bar) bzw. über das EC-Gerät abgerechnet." Pflicht nur, wenn beim + /// Abschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort- + /// Inkasso ist (Bar/EC) — das prüft der Server. Der kassierte Betrag wird + /// server-seitig autoritativ berechnet (nicht vom Client übernommen). + #[serde(default)] + pub payment_collected: bool, + /// Optionale Zahlungsmethode, die der Fahrer beim Abschluss gewählt hat. + /// `None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss + /// sie existieren **und** aktiv sein (vom Server geprüft). + #[serde(default)] + pub payment_method_id: Option, + /// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. + #[serde(default)] + pub author_car_id: Option, +} diff --git a/crates/application/src/dto/credit.rs b/crates/application/src/dto/credit.rs new file mode 100644 index 0000000..d58b198 --- /dev/null +++ b/crates/application/src/dto/credit.rs @@ -0,0 +1,45 @@ +//! Request/Response für `POST /deliveries/{id}/credit` — die +//! Betrags-Gutschrift (append-only, idempotent über `client_event_id`). + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use holzleitner_domain::DeliveryCredit; + +/// Art des Gutschrift-Ereignisses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum CreditAction { + /// Gutschrift setzen/ändern — `amount_cents` und `reason` Pflicht. + Set, + /// Gutschrift entfernen — `amount_cents`/`reason` werden ignoriert. + Remove, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryCreditEventRequest { + /// Idempotenz-Schlüssel — pro erzeugtem Ereignis genau einmal vergeben. + /// Ein Retry mit derselben Id wendet nichts erneut an. + pub client_event_id: Uuid, + pub action: CreditAction, + /// Bei `Set` Pflicht: Betrag in Cent (> 0, ≤ 15000, Vielfaches von 1000). + #[serde(default)] + pub amount_cents: Option, + /// Bei `Set` Pflicht: Begründung. + #[serde(default)] + pub reason: Option, + /// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. + #[serde(default)] + pub author_car_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryCreditResponse { + /// Aktueller Stand nach dem Ereignis — `None`, wenn (zuletzt) entfernt. + pub credit: Option, +} diff --git a/crates/application/src/dto/delivery_report.rs b/crates/application/src/dto/delivery_report.rs new file mode 100644 index 0000000..08436f4 --- /dev/null +++ b/crates/application/src/dto/delivery_report.rs @@ -0,0 +1,149 @@ +//! DTO-Aggregat für den PDF-Lieferreport. +//! +//! Bündelt **alle** Informationen zu einer Lieferung inkl. der beiden +//! Audit-Trails (`scan_audit`, `delivery_credit_audit`). Wird vom +//! `DeliveryReportRepository` (DB) befüllt; die Bild-Bytes (Unterschriften, +//! Anhänge) hängt der Use Case nachträglich aus dem lokalen Speicher an, +//! damit der Renderer rein (ohne IO) bleibt. + +use chrono::{DateTime, NaiveDate, Utc}; + +#[derive(Debug, Clone)] +pub struct DeliveryReportData { + pub generated_at: DateTime, + // Kopf + pub belegart_id: i64, + /// Belegart-Kurzcode (z. B. „VL5"), falls vom Sync befüllt. + pub belegart_code: Option, + /// Belegart-Klartext (z. B. „Lieferschein EH"). + pub belegart_name: Option, + pub belegnummer: String, + pub state: String, + pub tour_date: NaiveDate, + pub driver_personalnummer: i64, + pub driver_name: String, + pub car_plate: Option, + pub payment_method: Option, + // Kunde + Adresse + pub customer_number: i64, + pub customer_name: String, + pub address: String, + pub desired_time: Option, + pub special_agreements: Option, + pub prepaid_amount: f64, + pub current_credit_cents: i64, + pub contacts: Vec, + pub items: Vec, + pub services: Vec, + pub notes: Vec, + pub completion: Option, + pub scan_audit: Vec, + pub credit_audit: Vec, + pub attachments: Vec, + // Bild-Bytes (vom Use Case aus dem lokalen Speicher nachgeladen): + pub customer_signature_png: Option>, + pub driver_signature_png: Option>, +} + +#[derive(Debug, Clone)] +pub struct ReportContact { + pub name: String, + pub detail: Option, +} + +#[derive(Debug, Clone)] +pub struct ReportItem { + pub belegzeilen_nr: i32, + pub komponenten_artikel_nr: Option, + pub parent_artikel_nr: Option, + pub article_number: String, + pub name: String, + pub required_quantity: i32, + pub credited_quantity: i32, + pub scanned_quantity: i32, + pub scan_status: String, + pub unit_price: f64, + pub warehouse_code: Option, + pub warehouse_name: Option, +} + +impl ReportItem { + pub fn is_component(&self) -> bool { + self.komponenten_artikel_nr.is_some() + } + /// Tatsächlich ausgeliefert = Soll − Gutschrift. + pub fn delivered(&self) -> i32 { + (self.required_quantity - self.credited_quantity).max(0) + } +} + +#[derive(Debug, Clone)] +pub struct ReportService { + pub name: String, + pub bool_value: Option, + pub numeric_value: Option, +} + +#[derive(Debug, Clone)] +pub struct ReportNote { + pub created_at: DateTime, + pub author_personalnummer: i64, + pub text: Option, + pub image_attachment: Option, + pub is_amount_credit_note: bool, +} + +#[derive(Debug, Clone)] +pub struct ReportCompletion { + pub completed_at: DateTime, + pub completed_by_personalnummer: i64, + pub receipt_confirmed: bool, + pub notes_acknowledged: bool, + pub customer_signature_path: String, + pub driver_signature_path: String, + /// Fahrer hat das Inkasso (Bar/EC) bestätigt. + pub payment_collected: bool, + /// Snapshot des kassierten offenen Betrags in Cent (None = kein Inkasso). + pub collected_amount_cents: Option, +} + +#[derive(Debug, Clone)] +pub struct ReportScanAudit { + pub server_recorded_at: DateTime, + pub client_scanned_at: DateTime, + pub action: String, + pub delta: i32, + pub resulting_quantity: i32, + pub resulting_status: String, + pub reason: Option, + pub manual: bool, + pub credit_delta: Option, + pub actor_personalnummer: i64, + pub belegzeilen_nr: i32, + pub komponenten_artikel_nr: Option, + pub article_name: Option, +} + +#[derive(Debug, Clone)] +pub struct ReportCreditAudit { + pub recorded_at: DateTime, + pub action: String, + pub amount_cents: i64, + pub reason: Option, + pub author_personalnummer: i64, +} + +#[derive(Debug, Clone)] +pub struct ReportAttachment { + pub filename: Option, + /// Speicher-Referenz (lokaler relativer Pfad) — zum Nachladen der Bytes. + pub reference: String, + pub mime_type: String, + pub size_bytes: i64, + pub width: Option, + pub height: Option, + pub uploaded_at: DateTime, + pub uploaded_by: i64, + /// Vom Use Case aus dem lokalen Speicher nachgeladen (fürs Einbetten). + pub bytes: Option>, +} diff --git a/crates/application/src/dto/mod.rs b/crates/application/src/dto/mod.rs index 6be5f72..aa33eda 100644 --- a/crates/application/src/dto/mod.rs +++ b/crates/application/src/dto/mod.rs @@ -11,10 +11,15 @@ //! zweite Schicht handgeschriebener API-DTOs. pub mod car; +pub mod complete; +pub mod credit; pub mod delivery_action; +pub mod delivery_report; pub mod delivery_order; pub mod note; +pub mod payment_method; pub mod scan; +pub mod service; pub mod tour_details; pub mod tour_summary; pub mod tour_sync; @@ -22,14 +27,32 @@ pub mod tour_sync; pub use car::{ AssignCarRequest, CarResponse, CarsList, CreateCarRequest, UpdateCarRequest, }; +pub use complete::CompleteDeliveryAcknowledgements; +pub use credit::{CreditAction, DeliveryCreditEventRequest, DeliveryCreditResponse}; +pub use delivery_report::{ + DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit, + ReportItem, ReportNote, ReportScanAudit, ReportService, +}; pub use delivery_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest}; pub use delivery_order::{ DeliveryOrderEntry, SetDeliveryOrderRequest, SetDeliveryOrderResponse, }; -pub use note::{CreateDeliveryNoteRequest, DeliveryNoteResponse}; +pub use note::{ + CreateDeliveryNoteRequest, DeliveryNoteResponse, UpdateDeliveryNoteRequest, +}; +pub use payment_method::{ + CreatePaymentMethodRequest, PaymentMethodResponse, PaymentMethodsList, + UpdatePaymentMethodRequest, +}; pub use scan::{ ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus, }; +pub use service::{ + CreateServiceRequest, DeliveryServiceResponse, ServiceResponse, ServicesList, + SetDeliveryServiceRequest, UpdateServiceRequest, +}; pub use tour_details::{DeliveryWithItems, TourDetails}; pub use tour_summary::TourSummary; -pub use tour_sync::{SyncDelivery, SyncDeliveryItem, SyncTourRequest}; +pub use tour_sync::{ + SyncContactChannel, SyncContactSource, SyncDelivery, SyncDeliveryItem, SyncTourRequest, +}; diff --git a/crates/application/src/dto/note.rs b/crates/application/src/dto/note.rs index 4b7cab8..3f569cb 100644 --- a/crates/application/src/dto/note.rs +++ b/crates/application/src/dto/note.rs @@ -19,6 +19,27 @@ pub struct CreateDeliveryNoteRequest { /// Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten /// Account gehören. `None` ist erlaubt. pub author_car_id: Option, + /// Optionaler Gutschrift-Bezug: die Belegzeile, für die diese Notiz als + /// Gutschrift-Grund angelegt wird. Ermöglicht das gezielte Löschen beim + /// Unremove. `None` für normale Notizen. + #[serde(default)] + pub credit_delivery_item_id: Option, + /// `true` markiert die Notiz als Grund einer Betrags-Gutschrift + /// (Lieferungs-Ebene). Default `false`. + #[serde(default)] + pub is_amount_credit_note: bool, +} + +/// Request für `PATCH /deliveries/{id}/notes/{note_id}`. Wie beim Create +/// muss mindestens eines von `text` / `image_attachment` inhaltlich gefüllt +/// sein — geprüft im Use Case. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct UpdateDeliveryNoteRequest { + pub text: Option, + /// Object-Storage-Key oder URL eines vorab hochgeladenen Bildes. + pub image_attachment: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/application/src/dto/payment_method.rs b/crates/application/src/dto/payment_method.rs new file mode 100644 index 0000000..b24d785 --- /dev/null +++ b/crates/application/src/dto/payment_method.rs @@ -0,0 +1,41 @@ +//! Request- und Antwort-Typen für die Payment-Methods-Endpoints. + +use serde::{Deserialize, Serialize}; + +use holzleitner_domain::PaymentMethod; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CreatePaymentMethodRequest { + /// Eindeutiger Programm-Identifier (z. B. `"paypal"`, `"klarna"`). + pub code: String, + /// Anzeige-Name in der UI. + pub name: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct UpdatePaymentMethodRequest { + /// Wenn gesetzt: neuer Anzeige-Name. + pub name: Option, + /// Wenn gesetzt: aktiv/inaktiv. Inaktive Methoden bleiben für + /// historische Lieferungen referenzierbar, tauchen aber im + /// Default-Listing nicht auf. + pub active: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct PaymentMethodResponse { + pub method: PaymentMethod, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct PaymentMethodsList { + pub methods: Vec, +} diff --git a/crates/application/src/dto/scan.rs b/crates/application/src/dto/scan.rs index 9c3a85a..358845b 100644 --- a/crates/application/src/dto/scan.rs +++ b/crates/application/src/dto/scan.rs @@ -27,6 +27,19 @@ pub struct ScanEvent { pub action: AuditAction, /// Pflicht bei `Hold` und `Remove`. Sonst ignoriert. pub reason: Option, + /// Menge für `Remove` / `Unremove` (Mengen-Gutschrift): wie viele Stück + /// der Belegzeile gutgeschrieben bzw. wieder hergestellt werden. + /// `None` = ganze Restmenge (abwärtskompatibel zum bisherigen + /// „ganze Zeile entfernen"). Bei `Scan`/`Unscan`/`Hold`/`Unhold` + /// ignoriert. Muss, wenn gesetzt, `> 0` sein. + #[serde(default)] + pub quantity: Option, + /// `true`, wenn der Fahrer die Position **manuell** als geladen bestätigt + /// hat (Fallback ohne Barcode-Scan). Wird nur im Audit (`scan_audit.manual`) + /// festgehalten; an der Mengen-/Status-Logik ändert es nichts. Default + /// `false` (regulärer Barcode-Scan). + #[serde(default)] + pub manual: bool, pub client_scanned_at: DateTime, /// Fahrzeug, in dem der Scan gemacht wurde. Muss zum /// angemeldeten Account gehören. `None` ist erlaubt, schwächt diff --git a/crates/application/src/dto/service.rs b/crates/application/src/dto/service.rs new file mode 100644 index 0000000..0bd14e1 --- /dev/null +++ b/crates/application/src/dto/service.rs @@ -0,0 +1,73 @@ +//! Request-/Antwort-Typen für die Services-Endpoints (Stammdaten-CRUD + +//! Pro-Lieferung-Wert). + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use holzleitner_domain::{DeliveryServiceValue, Service, ServiceKind}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CreateServiceRequest { + /// Eindeutiger Programm-Identifier (z. B. `"podium_setup"`). + pub key: String, + pub name: String, + pub kind: ServiceKind, + /// Nur bei `Numeric` sinnvoll. + #[serde(default)] + pub min_value: Option, + #[serde(default)] + pub max_value: Option, + #[serde(default)] + pub sort_order: Option, +} + +/// Teil-Update eines Service. `kind` ist bewusst **nicht** änderbar — ein +/// Wechsel boolean↔numeric würde bestehende Pro-Lieferung-Werte ungültig +/// machen (dann lieber deaktivieren + neu anlegen). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct UpdateServiceRequest { + pub name: Option, + pub min_value: Option, + pub max_value: Option, + pub active: Option, + pub sort_order: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ServiceResponse { + pub service: Service, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ServicesList { + pub services: Vec, +} + +/// Setzt den Wert eines Service für eine Lieferung (Upsert). Es muss genau +/// das zum `ServiceKind` passende Feld gesetzt sein (Use Case prüft das). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct SetDeliveryServiceRequest { + #[serde(default)] + pub bool_value: Option, + #[serde(default)] + pub numeric_value: Option, + #[serde(default)] + pub author_car_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryServiceResponse { + pub value: DeliveryServiceValue, +} diff --git a/crates/application/src/dto/tour_details.rs b/crates/application/src/dto/tour_details.rs index 79ff8f2..2849d0d 100644 --- a/crates/application/src/dto/tour_details.rs +++ b/crates/application/src/dto/tour_details.rs @@ -10,7 +10,8 @@ use serde::Serialize; use holzleitner_domain::{ - Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote, Tour, Warehouse, + Article, ContactChannel, ContactSource, Customer, CustomerContact, Delivery, DeliveryCredit, + DeliveryItem, DeliveryNote, DeliveryServiceValue, Service, Tour, Warehouse, }; #[derive(Debug, Clone, Serialize)] @@ -27,6 +28,24 @@ pub struct TourDetails { /// Die App joint clientseitig per `delivery_id`. Reihenfolge: /// pro Lieferung aufsteigend nach `created_at`. pub notes: Vec, + /// Aktuelle Betrags-Gutschriften (jüngster Stand pro Lieferung), nur für + /// Lieferungen, deren letztes Ereignis `set` war. Join per `delivery_id`. + pub credits: Vec, + /// Aktive Service-Definitionen (Stammdaten) — die App rendert daraus + /// Phase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus + /// dem Tour-Aggregat hat. + pub services: Vec, + /// Pro-Lieferung gesetzte Service-Werte. Join per `delivery_id` + + /// `service_id`. + pub delivery_services: Vec, + /// Alle vom ERP gespiegelten Kontaktquellen aller Lieferungen dieser + /// Tour. Die App joint clientseitig per `delivery_id` und gruppiert + /// nach `role` (Lieferadresse / Rechnungsadresse / Ansprechpartner / + /// Kundenstamm / Belegadresse). + pub contact_sources: Vec, + /// Die zu `contact_sources` gehörenden Einzel-Kanäle (Telefon, Mobil, + /// E-Mail, Web). Join per `source_id`. + pub contact_channels: Vec, } #[derive(Debug, Clone, Serialize)] diff --git a/crates/application/src/dto/tour_sync.rs b/crates/application/src/dto/tour_sync.rs index b4a1827..20a03d2 100644 --- a/crates/application/src/dto/tour_sync.rs +++ b/crates/application/src/dto/tour_sync.rs @@ -13,7 +13,7 @@ use chrono::NaiveDate; use serde::{Deserialize, Serialize}; -use holzleitner_domain::Address; +use holzleitner_domain::{Address, ContactKind, ContactRole}; #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] @@ -29,6 +29,12 @@ pub struct SyncTourRequest { #[serde(rename_all = "camelCase")] pub struct SyncDelivery { pub belegart_id: i64, + /// Belegart-Kurzcode (z. B. „VL5"), aus `Belegarten.Belegart` (getrimmt). + #[serde(default)] + pub belegart_code: Option, + /// Belegart-Klartext (z. B. „Lieferschein EH"), aus `Belegarten.Bezeichnung`. + #[serde(default)] + pub belegart_name: Option, pub belegnummer: String, pub erp_customer_id: i64, @@ -44,7 +50,62 @@ pub struct SyncDelivery { pub desired_time: Option, pub special_agreements: Option, + /// Bei Bestellung schon bezahlter Betrag in EUR. Default `0.0`, + /// wenn der Kunde alles bei Lieferung zahlt. Der ERP-Sync liefert + /// den Wert mit. + #[serde(default)] + pub prepaid_amount: f64, + + /// Für den Restbetrag gewählte Zahlungsart — Referenz per + /// `code` (z. B. `"cash"`, `"invoice"`). Das ERP kennt seine + /// Standard-Codes, der Sync-Code resolvet sie zur UUID. Wenn + /// `None`, fällt der Backend-Code auf `"cash"` zurück. + #[serde(default)] + pub payment_method_code: Option, + pub items: Vec, + + /// Alle vom ERP an diesem Beleg hängenden Kontakt-Adressen (Beleg-/ + /// Liefer-/Rechnungsadresse, Ansprechpartner, Kundenstamm). Leere + /// Quellen (kein einziger ausgefüllter Kanal *und* kein Name) lässt + /// der Sync weg. + #[serde(default)] + pub contact_sources: Vec, +} + +/// Eine Adress-Rolle eines Belegs mit Namensblock und allen ausgefüllten +/// Telefon-/Mobil-/E-Mail-/Web-Einträgen. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct SyncContactSource { + pub role: ContactRole, + #[serde(default)] + pub anrede: Option, + #[serde(default)] + pub titel: Option, + #[serde(default)] + pub name1: Option, + #[serde(default)] + pub name2: Option, + #[serde(default)] + pub name3: Option, + #[serde(default)] + pub abteilung: Option, + #[serde(default)] + pub funktion: Option, + #[serde(default)] + pub channels: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct SyncContactChannel { + pub kind: ContactKind, + /// 1-basiert: spiegelt ERP-Spaltenposition (Telefon → 1, Telefon2 → 2, …). + pub position: i16, + pub value: String, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -53,7 +114,13 @@ pub struct SyncDelivery { pub struct SyncDeliveryItem { pub belegzeilen_nr: i32, /// Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer. + /// Trägt die **eigene** Nummer der Komponente (eindeutig je Belegzeile). pub komponenten_artikel_nr: Option, + /// Artikelnummer des **Oberartikels**, zu dem diese Komponente gehört. + /// `None` bei Oberartikeln/regulären Zeilen. Erlaubt der App, Komponenten + /// unter ihrem Oberartikel einzurücken. + #[serde(default)] + pub parent_artikel_nr: Option, pub article_number: String, pub article_name: String, @@ -65,4 +132,9 @@ pub struct SyncDeliveryItem { pub warehouse_name: String, pub required_quantity: i32, + + /// Stückpreis (brutto, EUR). Default `0.0`. Liefert der ERP-Sync mit; + /// die App rechnet daraus den Warenwert. + #[serde(default)] + pub unit_price: f64, } diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 67fa540..34c0054 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -20,6 +20,11 @@ pub enum ApplicationError { #[error("validation: {0}")] Validation(String), + /// Operation würde einen Daten-Konflikt erzeugen (z. B. FK-RESTRICT + /// beim Löschen, UNIQUE-Verletzung). Mappt auf HTTP `409`. + #[error("conflict: {0}")] + Conflict(String), + #[error("repository: {0}")] Repository(String), diff --git a/crates/application/src/ports/attachment_repository.rs b/crates/application/src/ports/attachment_repository.rs new file mode 100644 index 0000000..f0b9729 --- /dev/null +++ b/crates/application/src/ports/attachment_repository.rs @@ -0,0 +1,71 @@ +//! Port für die Attachment-Metadaten-Registry (Postgres). +//! +//! Hält Metadaten zu hochgeladenen Dateien + die DOCUframe-Referenz. Die +//! Bytes selbst liegen extern (DOCUframe, siehe [`super::AttachmentStorage`]). + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::error::ApplicationError; + +/// Eingabe zum Anlegen eines Attachment-Metadatensatzes. `id` und +/// `uploaded_at` werden von der DB vergeben. +pub struct NewAttachment { + /// DOCUframe `~ObjectID` — Referenz zum Abruf der Bytes. + pub docuframe_object_id: String, + pub mime_type: String, + pub size_bytes: i64, + pub filename: Option, + /// SHA-256 der Bytes als Hex. + pub checksum_sha256: String, + pub width: Option, + pub height: Option, + pub uploaded_by: i64, + pub delivery_id: Uuid, +} + +/// Für den Download relevante Felder eines Attachments. +pub struct AttachmentRef { + /// DOCUframe `~ObjectID` zum Laden der Bytes. + pub docuframe_object_id: String, + /// Ursprünglicher Upload-MIME-Typ (nur informativ — das Vorschau- + /// Rendering bestimmt das tatsächliche Ausgabeformat). + pub mime_type: String, +} + +/// Lokale Datei-Referenz eines (noch nicht gelöschten) Attachments — fürs +/// Aufräumen nach erfolgreichem Report-Upload. +pub struct AttachmentLocalRef { + pub id: Uuid, + /// Speicher-Referenz (lokal = relativer Pfad in `docuframe_object_id`). + pub reference: String, +} + +#[async_trait] +pub trait AttachmentRepository: Send + Sync { + /// Legt einen Metadatensatz an und liefert dessen neue Id zurück. + async fn create(&self, attachment: NewAttachment) -> Result; + + /// Lädt die Download-relevanten Felder eines Attachments. `None`, wenn + /// kein Attachment mit dieser Id existiert. + async fn get(&self, id: Uuid) -> Result, ApplicationError>; + + /// Liefert die Belegnummer (`deliveries.erp_belegnummer`) zur Lieferung — + /// der lokale Speicher nutzt sie als Ordnernamen. `None`, wenn die + /// Lieferung nicht (mehr) existiert. + async fn delivery_belegnummer( + &self, + delivery_id: Uuid, + ) -> Result, ApplicationError>; + + /// Listet alle noch nicht gelöschten Attachments einer Lieferung + /// (`deleted_at IS NULL`) mit ihrer lokalen Referenz — fürs Aufräumen. + async fn list_active_for_delivery( + &self, + delivery_id: Uuid, + ) -> Result, ApplicationError>; + + /// Markiert ein Attachment als gelöscht (`deleted_at = now()`). Die + /// Metadaten-Zeile bleibt — so ist ersichtlich, dass es ein Bild gab. + async fn mark_deleted(&self, id: Uuid) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/ports/attachment_storage.rs b/crates/application/src/ports/attachment_storage.rs new file mode 100644 index 0000000..111b710 --- /dev/null +++ b/crates/application/src/ports/attachment_storage.rs @@ -0,0 +1,54 @@ +//! Port für den externen Dokumenten-/Datei-Speicher. +//! +//! Konkrete Impl ist der DOCUframe-Adapter (GSD-REST-API). Der Use Case +//! lädt eine Datei hoch und erhält eine persistente Referenz zurück, die +//! als `image_attachment` an einer Notiz gespeichert wird. + +use async_trait::async_trait; + +use crate::error::ApplicationError; + +/// Heruntergeladenes Vorschaubild: rohe Bytes + der vom Speicher gemeldete +/// Content-Type (das Vorschau-Rendering bestimmt das Ausgabeformat, nicht +/// der ursprüngliche Upload-MIME-Typ). +pub struct PreviewImage { + pub bytes: Vec, + pub content_type: String, +} + +#[async_trait] +pub trait AttachmentStorage: Send + Sync { + /// Lädt eine Datei hoch und liefert die persistente Referenz. + /// + /// `folder` ist ein logischer Ablage-Schlüssel (bei uns die Belegnummer): + /// der lokale Adapter legt die Datei in einem gleichnamigen Unterordner ab. + /// Der DOCUframe-Adapter ignoriert ihn (könnte ihn später als Kategorie + /// nutzen). Rückgabe: die Referenz für den späteren Abruf, die in + /// `attachments.docuframe_object_id` gespeichert wird (lokal = rel. Pfad, + /// DOCUframe = `~ObjectID`). + async fn upload( + &self, + folder: &str, + filename: &str, + mime: &str, + bytes: Vec, + ) -> Result; + + /// Lädt ein gerendertes Vorschaubild zur Referenz `object_id`. + /// + /// `parameters` folgt dem DOCUframe-Schema + /// `width_height_quality_extension` (z. B. `1024_1024_85_jpeg`), + /// `page` ist die Seitennummer (für Bilder i. d. R. `1`). + async fn download_preview( + &self, + object_id: &str, + parameters: &str, + page: &str, + ) -> Result; + + /// Löscht die Datei hinter `reference` (lokaler Adapter: die lokale Datei). + /// Wird beim Aufräumen nach erfolgreichem Report-Upload genutzt. Idempotent: + /// eine bereits fehlende Datei ist kein Fehler. Der DOCUframe-Adapter + /// implementiert das als No-Op (wir löschen dort nichts). + async fn delete(&self, reference: &str) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/ports/delivery_completion_repository.rs b/crates/application/src/ports/delivery_completion_repository.rs new file mode 100644 index 0000000..e398a6b --- /dev/null +++ b/crates/application/src/ports/delivery_completion_repository.rs @@ -0,0 +1,131 @@ +//! Port für den Lieferungs-Abschluss (Unterschriften + Bestätigungen). +//! +//! Der Abschluss ist eine **atomare** Operation: Gate-Prüfungen (Lieferung +//! aktiv, alle scanbaren Positionen fertig, ggf. Notizen bestätigt), +//! Persistenz der Abschluss-Zeile und der Statuswechsel auf `completed` +//! laufen in genau einer Transaktion. Schlägt etwas fehl, bleibt nichts +//! halb-fertig zurück. +//! +//! Die Signatur-Dateien werden VOR dem Repository-Aufruf vom Use Case über +//! `SignatureStorage` geschrieben; hier kommen nur noch deren Referenzen an. + +use async_trait::async_trait; +use chrono::{NaiveDate, NaiveDateTime}; +use uuid::Uuid; + +use holzleitner_domain::Delivery; + +use crate::error::ApplicationError; + +/// Eine Belegzeile für das ERP-Rückschreiben: Position + ausgelieferte Menge. +pub struct ErpWritebackLine { + pub belegzeilen_nr: i32, + /// `required_quantity − credited_quantity` (Postgres-Stand). + pub delivered_quantity: i32, +} + +/// Alles, was der ERP-Rückschreib-Use-Case aus Postgres braucht, um den +/// Abschluss ins ERP zu spiegeln. Liest den **aktuellen** lokalen Stand +/// (Mengen, Geld-Gutschrift, Abschluss-Zeitpunkt) — daher idempotent +/// wiederholbar. +pub struct ErpWritebackData { + pub belegart_id: i64, + pub belegnummer: String, + /// Abschluss-Zeitpunkt (lokale Zeit) aus `delivery_completions.completed_at`. + pub delivered_at: NaiveDateTime, + pub lines: Vec, + /// Aktuelle Geld-Gutschrift in Cent (0 = keine). + pub credit_amount_cents: i64, + /// Code der beim Abschluss gewählten Zahlungsmethode + /// (`payment_methods.code`, z. B. `cash`/`ec_card`/`invoice`). `None`, + /// wenn keine zugeordnet ist. Der Adapter mappt das auf die ERP- + /// Zahlungsbedingung und setzt `Belegkopf.ZahlungsbedingungId`. + pub payment_method_code: Option, +} + +/// Vollständige Eingabe für den Abschluss — alles, was die Abschluss-Zeile +/// braucht plus die fachlichen Bestätigungs-Flags fürs Gate. +pub struct CompleteDeliveryInput { + pub delivery_id: Uuid, + pub customer_signature_path: String, + pub driver_signature_path: String, + /// Empfangsbestätigung des Kunden (immer Pflicht == true). + pub receipt_confirmed: bool, + /// Kenntnisnahme der Anmerkungen — Pflicht nur, wenn Notizen existieren + /// (das prüft das Repository gegen den DB-Stand). + pub notes_acknowledged: bool, + /// Notiz-IDs, die zum Abschlusszeitpunkt sichtbar/mit-bestätigt waren. + pub acknowledged_note_ids: Vec, + /// Inkasso-Bestätigung des Fahrers. Das Repository prüft gegen den + /// DB-Stand, ob sie nötig war (offener Betrag > 0 UND Methode Bar/EC), und + /// friert den kassierten Betrag als Snapshot ein. + pub payment_collected: bool, + /// Optionale Zahlungsmethode-Override. `None` = Methode am Beleg bleibt. + /// Falls gesetzt, prüft das Repository Existenz + Aktiv-Status. + pub payment_method_id: Option, + pub completed_by_personalnummer: i64, + pub completed_by_car_id: Option, +} + +#[async_trait] +pub trait DeliveryCompletionRepository: Send + Sync { + /// Schließt eine Lieferung ab und liefert die frische `Delivery` + /// (`state == completed`) zurück. + /// + /// Gates (alle in der Transaktion unter Lock): + /// * Lieferung existiert (`NotFound`). + /// * Bereits `completed` **mit** Abschluss-Zeile → idempotenter Erfolg. + /// * Sonst muss `state == active` sein (`Validation`). + /// * Alle scanbaren, nicht entfernten Positionen müssen fertig sein. + /// * Existieren Notizen, muss `notes_acknowledged == true` sein. + async fn complete( + &self, + input: CompleteDeliveryInput, + ) -> Result; + + /// Lädt die für das ERP-Rückschreiben nötigen Daten einer **bereits + /// abgeschlossenen** Lieferung (Beleg-Key, ausgelieferte Mengen, + /// Geld-Gutschrift, Abschluss-Zeitpunkt). + /// + /// `NotFound`, wenn die Lieferung oder ihre Abschluss-Zeile fehlt. + async fn load_erp_writeback( + &self, + delivery_id: Uuid, + ) -> Result; + + /// Liefert die Belegnummern aller **ausgelieferten** (abgeschlossenen) + /// Lieferungen, **deren Liefermail noch NICHT versendet wurde** + /// (`mail_sent_at IS NULL`). + /// + /// * `day = Some(d)` → nur Belege, deren Abschluss-Zeitpunkt + /// (`completed_at`) auf den Kalendertag `d` fällt. `completed_at` ist ein + /// UTC-Zeitstempel; der Kalendertag wird in der Zeitzone **Europe/Berlin** + /// bestimmt (Geschäftszeit), nicht in UTC. + /// * `day = None` → **alle** offenen (noch nicht versendeten) Belege über + /// alle Tage. Das ist der Modus des Mailclients, damit Belege, die über + /// Mitternacht nicht versendet wurden, nicht hängen bleiben. + /// + /// Sortierung: aufsteigend nach Abschluss-Zeitpunkt. + async fn list_delivered_belegnummern( + &self, + day: Option, + ) -> Result, ApplicationError>; + + /// Markiert die Liefermail der angegebenen Belegnummern als **versendet** + /// (`mail_sent_at = now()`), aber nur dort, wo sie noch offen ist + /// (`mail_sent_at IS NULL`) — bereits markierte bleiben unverändert + /// (idempotent, erster Versand-Zeitpunkt bleibt erhalten). Liefert die + /// Anzahl tatsächlich frisch markierter Belege zurück. + async fn mark_mail_sent( + &self, + belegnummern: &[String], + ) -> Result; + + /// **DEV-ONLY**: Hebt die Mail-Versendet-Markierung der angegebenen + /// Belegnummern wieder auf (`mail_sent_at = NULL`), sodass sie erneut als + /// offen erscheinen. Liefert die Anzahl tatsächlich zurückgesetzter Belege. + async fn unmark_mail_sent( + &self, + belegnummern: &[String], + ) -> Result; +} diff --git a/crates/application/src/ports/delivery_credit_repository.rs b/crates/application/src/ports/delivery_credit_repository.rs new file mode 100644 index 0000000..ee4c927 --- /dev/null +++ b/crates/application/src/ports/delivery_credit_repository.rs @@ -0,0 +1,36 @@ +//! Port für die Betrags-Gutschrift (append-only). +//! +//! Schreibseite: jedes `set`/`remove` hängt eine Zeile ans Audit-Log. Die +//! Leseseite (aktueller Stand pro Lieferung) läuft als Teil des +//! Tour-Aggregats (`TourDetails.credits`), nicht über diesen Port. + +use async_trait::async_trait; +use uuid::Uuid; + +use holzleitner_domain::DeliveryCredit; + +use crate::dto::CreditAction; +use crate::error::ApplicationError; + +#[async_trait] +pub trait DeliveryCreditRepository: Send + Sync { + /// Hängt ein Gutschrift-Ereignis ans Log und liefert den **aktuellen + /// Stand** der Lieferung danach zurück (`None`, wenn zuletzt entfernt). + /// + /// Idempotent über `client_event_id`: ist die Id bereits bekannt, wird + /// nichts erneut angehängt und der aktuelle Stand unverändert geliefert. + /// + /// `NotFound`, wenn die Lieferung nicht existiert; `Validation`, wenn die + /// Lieferung nicht `active` ist (nur bei frischem Ereignis geprüft). + /// `amount_cents` ist bei `Set` der zu setzende Betrag, bei `Remove` `0`. + async fn apply_event( + &self, + delivery_id: Uuid, + client_event_id: Uuid, + action: CreditAction, + amount_cents: i64, + reason: Option, + author_personalnummer: i64, + author_car_id: Option, + ) -> Result, ApplicationError>; +} diff --git a/crates/application/src/ports/delivery_note_repository.rs b/crates/application/src/ports/delivery_note_repository.rs index 084fc73..0a764a4 100644 --- a/crates/application/src/ports/delivery_note_repository.rs +++ b/crates/application/src/ports/delivery_note_repository.rs @@ -1,8 +1,7 @@ //! Port für Delivery-Notizen. //! -//! Aktuell nur das Anlegen — der Read-Pfad läuft als Teil des Tour- -//! Aggregats (`TourDetails.notes`). Sollten irgendwann Listen-Reads -//! oder Updates an einzelnen Notizen nötig werden, kommen die hier rein. +//! Anlegen, Ändern, Löschen einzelner Notizen. Der Read-Pfad läuft als +//! Teil des Tour-Aggregats (`TourDetails.notes`). use async_trait::async_trait; use uuid::Uuid; @@ -13,6 +12,7 @@ use crate::error::ApplicationError; #[async_trait] pub trait DeliveryNoteRepository: Send + Sync { + #[allow(clippy::too_many_arguments)] async fn create( &self, delivery_id: Uuid, @@ -20,5 +20,20 @@ pub trait DeliveryNoteRepository: Send + Sync { author_car_id: Option, text: Option, image_attachment: Option, + credit_delivery_item_id: Option, + is_amount_credit_note: bool, ) -> Result; + + /// Aktualisiert `text` / `image_attachment` einer bestehenden Notiz. + /// Autor und `created_at` bleiben unverändert (historische Metadaten). + /// `NotFound`, wenn die Notiz nicht existiert. + async fn update( + &self, + note_id: Uuid, + text: Option, + image_attachment: Option, + ) -> Result; + + /// Löscht eine Notiz. `NotFound`, wenn keine Zeile betroffen war. + async fn delete(&self, note_id: Uuid) -> Result<(), ApplicationError>; } diff --git a/crates/application/src/ports/delivery_report_job_repository.rs b/crates/application/src/ports/delivery_report_job_repository.rs new file mode 100644 index 0000000..25df188 --- /dev/null +++ b/crates/application/src/ports/delivery_report_job_repository.rs @@ -0,0 +1,89 @@ +//! Port: persistenter Zustand der Report-Übertragung an DOCUframe. +//! +//! Spiegelt die Tabelle `delivery_report_jobs`. Hält den Fortschritt der +//! mehrstufigen Übertragung (Upload → ~ObjectID → Makro) hart in Postgres, +//! damit fehlgeschlagene Schritte per Cron wiederholt werden können. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::error::ApplicationError; + +/// Status der Übertragung — Resume-Marke für Retries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReportJobStatus { + /// Angelegt, noch nichts übertragen. + Pending, + /// PDF liegt in DOCUframe (`docuframe_object_id` gesetzt), Makro offen. + Uploaded, + /// Makro erfolgreich, lokale Dateien aufgeräumt — terminal. + Done, +} + +impl ReportJobStatus { + pub fn as_str(self) -> &'static str { + match self { + ReportJobStatus::Pending => "pending", + ReportJobStatus::Uploaded => "uploaded", + ReportJobStatus::Done => "done", + } + } + + pub fn parse(s: &str) -> Self { + match s { + "uploaded" => ReportJobStatus::Uploaded, + "done" => ReportJobStatus::Done, + _ => ReportJobStatus::Pending, + } + } +} + +/// Ein Report-Übertragungs-Job (eine Zeile aus `delivery_report_jobs`). +#[derive(Debug, Clone)] +pub struct ReportJob { + pub delivery_id: Uuid, + pub belegnummer: String, + pub status: ReportJobStatus, + pub docuframe_object_id: Option, + pub report_uploaded_at: Option>, + pub attempts: i32, + pub last_error: Option, +} + +#[async_trait] +pub trait DeliveryReportJobRepository: Send + Sync { + /// Legt einen Job (`pending`) an, falls noch keiner existiert, und liefert + /// den aktuellen Stand. Idempotent — ein erneuter Abschluss-Versuch + /// überschreibt einen vorhandenen Job nicht. + async fn ensure( + &self, + delivery_id: Uuid, + belegnummer: &str, + ) -> Result; + + /// Lädt einen Job. `None`, wenn keiner existiert. + async fn get(&self, delivery_id: Uuid) -> Result, ApplicationError>; + + /// Alle offenen Jobs (`status <> 'done'`) — für den Retry-Cron. + async fn list_open(&self) -> Result, ApplicationError>; + + /// Setzt die ~ObjectID nach erfolgreichem Upload und `status = 'uploaded'`. + async fn set_uploaded( + &self, + delivery_id: Uuid, + object_id: &str, + ) -> Result<(), ApplicationError>; + + /// Markiert den Job als `done` und setzt `report_uploaded_at` (Zeitpunkt + /// der erfolgreichen Makro-Zuordnung). + async fn mark_done(&self, delivery_id: Uuid) -> Result<(), ApplicationError>; + + /// Vermerkt einen fehlgeschlagenen Versuch (`attempts++`, `last_error`, + /// `last_attempt_at = now()`). Lässt `status` unverändert (Resume-Marke). + async fn record_error( + &self, + delivery_id: Uuid, + error: &str, + ) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/ports/delivery_report_renderer.rs b/crates/application/src/ports/delivery_report_renderer.rs new file mode 100644 index 0000000..41fad5e --- /dev/null +++ b/crates/application/src/ports/delivery_report_renderer.rs @@ -0,0 +1,10 @@ +//! Port: rendert die Reportdaten zu einem PDF. + +use crate::dto::DeliveryReportData; +use crate::error::ApplicationError; + +pub trait DeliveryReportRenderer: Send + Sync { + /// Rendert den vollständigen Lieferreport als PDF-Bytes. Rein (kein IO): + /// alle Bild-Bytes liegen bereits im [`DeliveryReportData`]. + fn render(&self, data: &DeliveryReportData) -> Result, ApplicationError>; +} diff --git a/crates/application/src/ports/delivery_report_repository.rs b/crates/application/src/ports/delivery_report_repository.rs new file mode 100644 index 0000000..ee32308 --- /dev/null +++ b/crates/application/src/ports/delivery_report_repository.rs @@ -0,0 +1,19 @@ +//! Port: lädt alle DB-Daten für den PDF-Lieferreport. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::dto::DeliveryReportData; +use crate::error::ApplicationError; + +#[async_trait] +pub trait DeliveryReportRepository: Send + Sync { + /// Sammelt sämtliche Lieferungs-Daten inkl. beider Audit-Trails + /// (`scan_audit`, `delivery_credit_audit`) — **ohne** Bild-Bytes (die + /// hängt der Use Case aus dem lokalen Speicher an). `None`, wenn die + /// Lieferung nicht existiert. + async fn load( + &self, + delivery_id: Uuid, + ) -> Result, ApplicationError>; +} diff --git a/crates/application/src/ports/delivery_report_sink.rs b/crates/application/src/ports/delivery_report_sink.rs new file mode 100644 index 0000000..1fc9d31 --- /dev/null +++ b/crates/application/src/ports/delivery_report_sink.rs @@ -0,0 +1,19 @@ +//! Port: nimmt das fertige Report-PDF entgegen. +//! +//! Heute: lokaler Datei-Sink (temporäre Ablage). Später: DOCUframe-Sink, der +//! den Blob an ein Makro sendet (Stub vorhanden). + +use async_trait::async_trait; + +use crate::error::ApplicationError; + +#[async_trait] +pub trait DeliveryReportSink: Send + Sync { + /// Übernimmt das fertige PDF. `folder` = Belegnummer (für die Ablage). + /// Gibt eine Referenz zurück (lokal: Dateipfad; DOCUframe: später). + async fn deliver(&self, folder: &str, pdf: Vec) -> Result; + + /// Räumt alle lokal abgelegten Report-Dateien zu `folder` (Belegnummer) + /// auf — aufgerufen nach erfolgreichem DOCUframe-Upload. Idempotent. + async fn delete(&self, folder: &str) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/ports/delivery_service_repository.rs b/crates/application/src/ports/delivery_service_repository.rs new file mode 100644 index 0000000..878cdd4 --- /dev/null +++ b/crates/application/src/ports/delivery_service_repository.rs @@ -0,0 +1,36 @@ +//! Port für die Pro-Lieferung-Service-Werte (Upsert). +//! +//! Die Leseseite (aktuelle Werte pro Lieferung) läuft als Teil des +//! Tour-Aggregats (`TourDetails.delivery_services`), nicht über diesen Port. + +use async_trait::async_trait; +use uuid::Uuid; + +use holzleitner_domain::DeliveryServiceValue; + +use crate::error::ApplicationError; + +#[async_trait] +pub trait DeliveryServiceRepository: Send + Sync { + /// Setzt (Upsert) den Wert eines Service für eine Lieferung. Prüft, dass + /// die Lieferung existiert (`NotFound`) und `active` ist (`Validation`). + /// Genau eines von `bool_value`/`numeric_value` ist gesetzt (Use Case + /// stellt das passend zum Service-Typ sicher). + async fn set( + &self, + delivery_id: Uuid, + service_id: Uuid, + bool_value: Option, + numeric_value: Option, + author_personalnummer: i64, + author_car_id: Option, + ) -> Result; + + /// Entfernt den Wert (Service für diese Lieferung „nicht gesetzt"). + /// Nur bei aktiver Lieferung. + async fn delete( + &self, + delivery_id: Uuid, + service_id: Uuid, + ) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/ports/docuframe_report_gateway.rs b/crates/application/src/ports/docuframe_report_gateway.rs new file mode 100644 index 0000000..5041d39 --- /dev/null +++ b/crates/application/src/ports/docuframe_report_gateway.rs @@ -0,0 +1,33 @@ +//! Port: Übertragung des Report-PDFs nach DOCUframe. +//! +//! Zwei Schritte, getrennt, damit die Pipeline ihren Fortschritt nach jedem +//! Schritt persistieren kann (und ein Retry den schon erledigten Upload +//! überspringt): +//! 1. [`upload_report_pdf`] — PDF hochladen → `~ObjectID`. +//! 2. [`assign_report`] — DOCUframe-Makro `_SV_assignDeliveryReport` +//! aufrufen, das den Report dem Beleg/Vorgang zuordnet. +//! +//! Konkrete Impl: der GSD-/DOCUframe-Adapter (`GsdService`). + +use async_trait::async_trait; + +use crate::error::ApplicationError; + +#[async_trait] +pub trait DocuframeReportGateway: Send + Sync { + /// Lädt das Report-PDF nach DOCUframe und liefert die `~ObjectID`. + async fn upload_report_pdf( + &self, + belegnummer: &str, + pdf: Vec, + ) -> Result; + + /// Ruft das Makro `_SV_assignDeliveryReport` mit `{objectId, belegnummer}` + /// auf und ordnet den hochgeladenen Report dem Beleg zu. Fehler, wenn das + /// Makro `succeeded != true` liefert oder DOCUframe nicht erreichbar ist. + async fn assign_report( + &self, + object_id: &str, + belegnummer: &str, + ) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/ports/driver_identity_provisioner.rs b/crates/application/src/ports/driver_identity_provisioner.rs new file mode 100644 index 0000000..184ba44 --- /dev/null +++ b/crates/application/src/ports/driver_identity_provisioner.rs @@ -0,0 +1,36 @@ +//! Port für das **Provisionieren** von Fahrer-Konten im Identity-Provider +//! (Keycloak) beim ERP-Sync. +//! +//! Wenn der tägliche Touren-Import einen Fahrer (ERP-`Vertreter`, fachlich die +//! Account-/Vertragspartner-Nummer) sieht, soll im Realm ein Login-Konto +//! existieren: Benutzername = Fahrer-/Account-Nummer, ein **temporäres** +//! Passwort, das beim ersten Login zwingend geändert werden muss +//! (Keycloak-Required-Action `UPDATE_PASSWORD`), und die Rolle `driver`. +//! +//! Die konkrete Impl (Keycloak Admin-REST via reqwest) lebt in +//! `holzleitner-infrastructure` und MUSS **idempotent** sein: existiert der +//! User bereits, passiert nichts (kein Passwort-Reset, keine Doppelanlage). + +use async_trait::async_trait; + +use crate::error::ApplicationError; + +/// Ergebnis einer Provisionierung — ob ein Konto **neu** angelegt wurde. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProvisionOutcome { + /// `true` ⇒ Konto wurde in diesem Aufruf erstellt; `false` ⇒ existierte + /// bereits (idempotenter No-Op). + pub created: bool, +} + +#[async_trait] +pub trait DriverIdentityProvisioner: Send + Sync { + /// Stellt sicher, dass für `personalnummer` ein Login-Konto existiert. + /// Idempotent. `display_name` ist ein optionaler Anzeigename (z. B. + /// „Fahrer 423") — nur kosmetisch im IdP. + async fn ensure_driver( + &self, + personalnummer: i64, + display_name: Option<&str>, + ) -> Result; +} diff --git a/crates/application/src/ports/erp_delivery_source.rs b/crates/application/src/ports/erp_delivery_source.rs new file mode 100644 index 0000000..93c2aef --- /dev/null +++ b/crates/application/src/ports/erp_delivery_source.rs @@ -0,0 +1,24 @@ +//! Port für die Lese-Anbindung an das ERP (ERPframe). +//! +//! Liefert die Tagestouren eines Datums als `SyncTourRequest`-DTOs — also +//! genau die Repräsentation, die auch der HTTP-Sync (`POST /sync/tour`) +//! nutzt. Eine `SyncTourRequest` = eine Fahrer-Tour. Die konkrete Impl +//! (MSSQL via tiberius) lebt in `holzleitner-infrastructure`; die +//! Application bleibt frei von DB-Treiber-Details. + +use async_trait::async_trait; +use chrono::NaiveDate; + +use crate::dto::SyncTourRequest; +use crate::error::ApplicationError; + +#[async_trait] +pub trait ErpDeliverySource: Send + Sync { + /// Liest alle Lieferungen des gegebenen Tages aus dem ERP und gruppiert + /// sie zu **einer `SyncTourRequest` pro Fahrer** (driverPersonalnummer). + /// Reine Lese-Operation; schreibt nichts ins ERP zurück. + async fn fetch_tours_for_date( + &self, + date: NaiveDate, + ) -> Result, ApplicationError>; +} diff --git a/crates/application/src/ports/erp_delivery_writeback.rs b/crates/application/src/ports/erp_delivery_writeback.rs new file mode 100644 index 0000000..59d707d --- /dev/null +++ b/crates/application/src/ports/erp_delivery_writeback.rs @@ -0,0 +1,64 @@ +//! Port für das **Zurückschreiben** eines Lieferabschlusses ins ERP (ERPframe). +//! +//! Gegenstück zum lesenden [`ErpDeliverySource`](super::ErpDeliverySource): +//! wenn eine Lieferung lokal (Postgres) abgeschlossen wurde, spiegelt dieser +//! Port das Ergebnis direkt in die ERPframe-MSSQL-DB. Drei fachliche Effekte +//! (vgl. die Alt-Makros `_web_finishDelivery` / `_removeArticles` / +//! `_addDiscount`): +//! +//! 1. **Entfernte Artikel** — je Belegzeile die Menge auf die tatsächlich +//! ausgelieferte Menge setzen (`required − credited`). +//! 2. **Gutschrift** — eine Belegzeile für den Gutschrift-Artikel +//! (`GUTSCHRIFT10`) hinzufügen/aktualisieren. +//! 3. **Liefer-Zeitpunkt** — `_SV_DELIVERY_DELIVERED_AT` + `_SV_DELIVERY_STATE` +//! setzen. +//! +//! Die konkrete Impl (MSSQL via tiberius) lebt in `holzleitner-infrastructure` +//! und MUSS **idempotent** sein: alle Mengen werden absolut gesetzt, die +//! Gutschrift als Upsert geführt — ein erneuter Aufruf (Admin-Retry) verändert +//! das Ergebnis nicht. + +use async_trait::async_trait; +use chrono::NaiveDateTime; + +use crate::error::ApplicationError; + +/// Eine Belegzeile mit ihrer **neuen** (absoluten) Menge. +#[derive(Debug, Clone)] +pub struct ErpLineQuantity { + /// ERP-Position innerhalb des Belegs (`Belegzeilen.BelegzeilenNr`). + pub belegzeilen_nr: i32, + /// Tatsächlich ausgelieferte Menge = `required_quantity − credited_quantity`. + /// Wird absolut gesetzt (nicht subtrahiert) → idempotent. + pub delivered_quantity: i32, +} + +/// Vollständige Eingabe für das ERP-Rückschreiben eines Abschlusses. +#[derive(Debug, Clone)] +pub struct ErpFinishDeliveryCommand { + /// Beleg-Natural-Key (aus `deliveries.erp_belegart_id`/`erp_belegnummer`). + /// Der Adapter resolved daraus die `Belegkopf.row_id`. + pub belegart_id: i64, + pub belegnummer: String, + /// Liefer-Zeitpunkt (lokale Zeit), wird als ISO-8601 mit `T` geschrieben. + pub delivered_at: NaiveDateTime, + /// Belegzeilen mit ausgelieferten Mengen. + pub lines: Vec, + /// Geld-Gutschrift in **Cent** (0 = keine). Der Adapter rechnet daraus die + /// Menge der 10-€-Gutschrift-Einheiten. + pub credit_amount_cents: i64, + /// Code der gewählten Zahlungsmethode (`cash`/`ec_card`/`invoice`). Der + /// Adapter mappt das auf die ERP-Zahlungsbedingung (D16/D53/D10) und setzt + /// `Belegkopf.ZahlungsbedingungId`. `None` ⇒ Zahlungsbedingung bleibt. + pub payment_method_code: Option, +} + +#[async_trait] +pub trait ErpDeliveryWriteback: Send + Sync { + /// Schreibt den Lieferabschluss ins ERP zurück (eine MSSQL-Transaktion). + /// Idempotent: erneuter Aufruf mit gleichem Command ⇒ gleicher Endzustand. + async fn finish_delivery( + &self, + cmd: ErpFinishDeliveryCommand, + ) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/ports/mod.rs b/crates/application/src/ports/mod.rs index 0334e45..43a2195 100644 --- a/crates/application/src/ports/mod.rs +++ b/crates/application/src/ports/mod.rs @@ -6,17 +6,57 @@ //! `holzleitner-infrastructure`. pub mod account_repository; +pub mod attachment_repository; +pub mod attachment_storage; pub mod auth_service; pub mod car_repository; +pub mod delivery_credit_repository; pub mod delivery_note_repository; +pub mod delivery_report_job_repository; +pub mod delivery_report_renderer; +pub mod delivery_report_repository; +pub mod delivery_report_sink; pub mod delivery_repository; +pub mod delivery_service_repository; +pub mod docuframe_report_gateway; +pub mod driver_identity_provisioner; +pub mod payment_method_repository; +pub mod delivery_completion_repository; +pub mod erp_delivery_source; +pub mod erp_delivery_writeback; pub mod scan_repository; +pub mod service_repository; +pub mod signature_storage; pub mod tour_repository; pub use account_repository::AccountRepository; +pub use attachment_repository::{ + AttachmentLocalRef, AttachmentRef, AttachmentRepository, NewAttachment, +}; +pub use attachment_storage::{AttachmentStorage, PreviewImage}; pub use auth_service::{AuthError, AuthService, Claims}; pub use car_repository::CarRepository; +pub use delivery_credit_repository::DeliveryCreditRepository; pub use delivery_note_repository::DeliveryNoteRepository; +pub use delivery_report_job_repository::{ + DeliveryReportJobRepository, ReportJob, ReportJobStatus, +}; +pub use delivery_report_renderer::DeliveryReportRenderer; +pub use delivery_report_repository::DeliveryReportRepository; +pub use delivery_report_sink::DeliveryReportSink; pub use delivery_repository::{DeliveryAction, DeliveryRepository}; +pub use delivery_service_repository::DeliveryServiceRepository; +pub use docuframe_report_gateway::DocuframeReportGateway; +pub use driver_identity_provisioner::{DriverIdentityProvisioner, ProvisionOutcome}; +pub use delivery_completion_repository::{ + CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine, +}; +pub use erp_delivery_source::ErpDeliverySource; +pub use erp_delivery_writeback::{ + ErpDeliveryWriteback, ErpFinishDeliveryCommand, ErpLineQuantity, +}; +pub use payment_method_repository::PaymentMethodRepository; pub use scan_repository::{ApplyScanOutcome, ScanRepository}; +pub use service_repository::ServiceRepository; +pub use signature_storage::{SignatureRole, SignatureStorage}; pub use tour_repository::TourRepository; diff --git a/crates/application/src/ports/payment_method_repository.rs b/crates/application/src/ports/payment_method_repository.rs new file mode 100644 index 0000000..7cbbc43 --- /dev/null +++ b/crates/application/src/ports/payment_method_repository.rs @@ -0,0 +1,54 @@ +//! Port für Zahlungs-Stammdaten. +//! +//! Im Gegensatz zu `cars` sind Zahlungsmethoden **global** — sie hängen +//! nicht an einem Account, sondern gelten für die ganze App. Daher +//! keine `account_id`-Parameter. +//! +//! Lösch-Verhalten: `delete` wirft `ApplicationError::Validation`, wenn +//! eine Lieferung die Methode noch referenziert (entsprechend dem +//! Datenbank-`ON DELETE RESTRICT`). Für „weiches Entfernen" gibt es +//! das `active`-Flag — wird per `update(active: Some(false))` gesetzt. + +use async_trait::async_trait; +use uuid::Uuid; + +use holzleitner_domain::PaymentMethod; + +use crate::error::ApplicationError; + +#[async_trait] +pub trait PaymentMethodRepository: Send + Sync { + /// Listet alle Methoden. `include_inactive = false` filtert + /// deaktivierte raus (Default für die App-UI). + async fn list( + &self, + include_inactive: bool, + ) -> Result, ApplicationError>; + + async fn find_by_id( + &self, + id: Uuid, + ) -> Result, ApplicationError>; + + /// Legt eine neue Methode an. `code` muss eindeutig sein — + /// Duplikat → `Conflict("…already exists")` (HTTP 409). + async fn create( + &self, + code: &str, + name: &str, + ) -> Result; + + /// Optional-Patch. Beide `None`s = no-op. + async fn update( + &self, + id: Uuid, + name: Option<&str>, + active: Option, + ) -> Result; + + /// Hart löschen. Wirft `Conflict("payment method is in use")` + /// (→ HTTP 409), wenn noch Lieferungen darauf zeigen — der + /// FK-RESTRICT regelt das auf DB-Ebene, der Adapter übersetzt den + /// Pg-Fehler. + async fn delete(&self, id: Uuid) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/ports/service_repository.rs b/crates/application/src/ports/service_repository.rs new file mode 100644 index 0000000..a0e4832 --- /dev/null +++ b/crates/application/src/ports/service_repository.rs @@ -0,0 +1,44 @@ +//! Port für Service-Stammdaten (admin-konfigurierbar, global — keine +//! Account-Isolation, Muster wie `PaymentMethodRepository`). + +use async_trait::async_trait; +use uuid::Uuid; + +use holzleitner_domain::{Service, ServiceKind}; + +use crate::error::ApplicationError; + +#[async_trait] +pub trait ServiceRepository: Send + Sync { + /// Listet Services, sortiert nach `sort_order`. `include_inactive = false` + /// filtert deaktivierte raus (Default für die App). + async fn list(&self, include_inactive: bool) -> Result, ApplicationError>; + + async fn find_by_id(&self, id: Uuid) -> Result, ApplicationError>; + + /// Legt einen Service an. `key`-Duplikat → `Conflict`. + async fn create( + &self, + key: &str, + name: &str, + kind: ServiceKind, + min_value: Option, + max_value: Option, + sort_order: i32, + ) -> Result; + + /// Teil-Update. `kind` ist nicht änderbar. + async fn update( + &self, + id: Uuid, + name: Option<&str>, + min_value: Option, + max_value: Option, + active: Option, + sort_order: Option, + ) -> Result; + + /// Hart löschen. `Conflict`, wenn noch eine Lieferung den Service + /// referenziert (FK `ON DELETE RESTRICT`) — dann lieber deaktivieren. + async fn delete(&self, id: Uuid) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/ports/signature_storage.rs b/crates/application/src/ports/signature_storage.rs new file mode 100644 index 0000000..770c863 --- /dev/null +++ b/crates/application/src/ports/signature_storage.rs @@ -0,0 +1,54 @@ +//! Port für den Unterschriften-Speicher. +//! +//! Im Gegensatz zu Notiz-Bildern (die nach DOCUframe gehen) liegen +//! Unterschriften bewusst **lokal im Backend-Server** — ein einfacher +//! Datei-Speicher reicht, und die Daten verlassen die Maschine nicht. +//! +//! Die konkrete Impl (lokales Dateisystem) lebt in +//! `holzleitner-infrastructure`. Der Use Case erhält eine relative +//! Referenz zurück, die in `delivery_completions` persistiert wird. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::error::ApplicationError; + +/// Wer hat unterschrieben — bestimmt den Dateinamen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignatureRole { + Customer, + Driver, +} + +impl SignatureRole { + pub fn as_str(self) -> &'static str { + match self { + SignatureRole::Customer => "customer", + SignatureRole::Driver => "driver", + } + } +} + +#[async_trait] +pub trait SignatureStorage: Send + Sync { + /// Speichert eine Unterschrift (PNG-Bytes) für eine Lieferung+Rolle und + /// liefert die persistente, relative Referenz (Dateiname) zurück. + /// Deterministisch über `delivery_id`+`role` — ein Retry überschreibt + /// dieselbe Datei statt Müll anzuhäufen. + async fn save( + &self, + delivery_id: Uuid, + role: SignatureRole, + bytes: Vec, + ) -> Result; + + /// Lädt die gespeicherten PNG-Bytes einer Unterschrift über ihre relative + /// Referenz (Dateiname, wie von [`save`](Self::save) geliefert) — fürs + /// Einbetten in den PDF-Report. `None`, wenn die Datei fehlt. + async fn load(&self, reference: &str) -> Result>, ApplicationError>; + + /// Löscht beide Unterschriften (Kunde + Fahrer) einer Lieferung — Aufräumen + /// nach erfolgreichem Report-Upload (die Unterschriften stecken dann + /// eingebettet im PDF in DOCUframe). Idempotent (fehlende Datei = ok). + async fn delete_for_delivery(&self, delivery_id: Uuid) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/ports/tour_repository.rs b/crates/application/src/ports/tour_repository.rs index 22fe377..a7c1602 100644 --- a/crates/application/src/ports/tour_repository.rs +++ b/crates/application/src/ports/tour_repository.rs @@ -49,4 +49,10 @@ pub trait TourRepository: Send + Sync { tour_id: Uuid, delivery_ids: &[Uuid], ) -> Result, ApplicationError>; + + /// **DEV-ONLY**: Löscht alle Touren (und per FK-Cascade alle Lieferungen, + /// Positionen, Scans, Abschlüsse, Gutschriften, Notizen). Dient dem + /// Dev-Resync, der die Postgres-Daten vor einem frischen Import platt + /// macht. Gibt die Anzahl gelöschter Touren zurück. + async fn delete_all_tours(&self) -> Result; } diff --git a/crates/application/src/usecases/apply_delivery_credit_event.rs b/crates/application/src/usecases/apply_delivery_credit_event.rs new file mode 100644 index 0000000..3124299 --- /dev/null +++ b/crates/application/src/usecases/apply_delivery_credit_event.rs @@ -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, + cars: Arc, +} + +impl ApplyDeliveryCreditEventUseCase { + pub fn new( + repository: Arc, + cars: Arc, + ) -> Self { + Self { repository, cars } + } + + pub async fn execute( + &self, + delivery_id: Uuid, + author_personalnummer: i64, + request: DeliveryCreditEventRequest, + ) -> Result, 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 + } +} diff --git a/crates/application/src/usecases/apply_scans.rs b/crates/application/src/usecases/apply_scans.rs index 0e8ab62..7d1e889 100644 --- a/crates/application/src/usecases/apply_scans.rs +++ b/crates/application/src/usecases/apply_scans.rs @@ -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 { + // 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 { None } } - AuditAction::Scan | AuditAction::Unscan | AuditAction::Unhold => None, + AuditAction::Scan + | AuditAction::Unscan + | AuditAction::Unhold + | AuditAction::Unremove => None, } } diff --git a/crates/application/src/usecases/complete_delivery.rs b/crates/application/src/usecases/complete_delivery.rs new file mode 100644 index 0000000..531550f --- /dev/null +++ b/crates/application/src/usecases/complete_delivery.rs @@ -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, + signatures: Arc, + cars: Arc, + /// Optionales ERP-Rückschreiben. `None` ⇒ rein lokaler Abschluss + /// (ERP_WRITEBACK_ENABLED=false / Dev / Seed-Daten ohne ERP-Beleg). + erp_push: Option>, +} + +impl CompleteDeliveryUseCase { + pub fn new( + repository: Arc, + signatures: Arc, + cars: Arc, + erp_push: Option>, + ) -> Self { + Self { + repository, + signatures, + cars, + erp_push, + } + } + + pub async fn execute( + &self, + delivery_id: Uuid, + author_personalnummer: i64, + acknowledgements: CompleteDeliveryAcknowledgements, + customer_signature_png: Vec, + driver_signature_png: Vec, + ) -> Result { + // --- 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) + } +} diff --git a/crates/application/src/usecases/create_delivery_note.rs b/crates/application/src/usecases/create_delivery_note.rs index b805ee4..3dc0c8b 100644 --- a/crates/application/src/usecases/create_delivery_note.rs +++ b/crates/application/src/usecases/create_delivery_note.rs @@ -55,6 +55,8 @@ impl CreateDeliveryNoteUseCase { request.author_car_id, text, image, + request.credit_delivery_item_id, + request.is_amount_credit_note, ) .await } diff --git a/crates/application/src/usecases/delete_delivery_note.rs b/crates/application/src/usecases/delete_delivery_note.rs new file mode 100644 index 0000000..9d2c0ce --- /dev/null +++ b/crates/application/src/usecases/delete_delivery_note.rs @@ -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, +} + +impl DeleteDeliveryNoteUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute(&self, note_id: Uuid) -> Result<(), ApplicationError> { + self.repository.delete(note_id).await + } +} diff --git a/crates/application/src/usecases/dev_resync_tours.rs b/crates/application/src/usecases/dev_resync_tours.rs new file mode 100644 index 0000000..04ffe3e --- /dev/null +++ b/crates/application/src/usecases/dev_resync_tours.rs @@ -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, + import: Arc, +} + +impl DevResyncToursUseCase { + pub fn new(tours: Arc, import: Arc) -> 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 { + let _deleted = self.tours.delete_all_tours().await?; + let summary = self.import.execute(date).await?; + Ok(summary) + } +} diff --git a/crates/application/src/usecases/generate_delivery_report.rs b/crates/application/src/usecases/generate_delivery_report.rs new file mode 100644 index 0000000..d336786 --- /dev/null +++ b/crates/application/src/usecases/generate_delivery_report.rs @@ -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, + renderer: Arc, + sink: Arc, + signatures: Arc, + attachments: Arc, +} + +impl GenerateDeliveryReportUseCase { + pub fn new( + repo: Arc, + renderer: Arc, + sink: Arc, + signatures: Arc, + attachments: Arc, + ) -> 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), 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 { + let (belegnummer, pdf) = self.render_pdf(delivery_id).await?; + let reference = self.sink.deliver(&belegnummer, pdf).await?; + Ok(reference) + } +} diff --git a/crates/application/src/usecases/get_attachment_preview.rs b/crates/application/src/usecases/get_attachment_preview.rs new file mode 100644 index 0000000..74971cb --- /dev/null +++ b/crates/application/src/usecases/get_attachment_preview.rs @@ -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, + storage: Arc, +} + +impl GetAttachmentPreviewUseCase { + pub fn new( + attachments: Arc, + storage: Arc, + ) -> Self { + Self { + attachments, + storage, + } + } + + pub async fn execute( + &self, + id: Uuid, + parameters: String, + page: String, + ) -> Result { + let attachment = self + .attachments + .get(id) + .await? + .ok_or(ApplicationError::NotFound)?; + self.storage + .download_preview(&attachment.docuframe_object_id, ¶meters, &page) + .await + } +} diff --git a/crates/application/src/usecases/import_erp_tours.rs b/crates/application/src/usecases/import_erp_tours.rs new file mode 100644 index 0000000..0b198ec --- /dev/null +++ b/crates/application/src/usecases/import_erp_tours.rs @@ -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, + /// 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, +} + +/// 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, + sync_tour: Arc, + /// Optionaler Identity-Provisioner (Keycloak). `None` ⇒ Konto-Anlage + /// deaktiviert (`KEYCLOAK_PROVISIONING_ENABLED=false`). + provisioner: Option>, +} + +impl ImportErpToursUseCase { + pub fn new( + source: Arc, + sync_tour: Arc, + provisioner: Option>, + ) -> Self { + Self { + source, + sync_tour, + provisioner, + } + } + + pub async fn execute(&self, date: NaiveDate) -> Result { + let tours = self.source.fetch_tours_for_date(date).await?; + let tours_total = tours.len(); + let mut tours_ok = 0usize; + let mut errors: Vec = Vec::new(); + let mut drivers_provisioned = 0usize; + let mut provisioning_errors: Vec = 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, + }) + } +} diff --git a/crates/application/src/usecases/list_delivered_belegnummern.rs b/crates/application/src/usecases/list_delivered_belegnummern.rs new file mode 100644 index 0000000..16a23d5 --- /dev/null +++ b/crates/application/src/usecases/list_delivered_belegnummern.rs @@ -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, +} + +impl ListDeliveredBelegnummernUseCase { + pub fn new(completions: Arc) -> 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, + ) -> Result, ApplicationError> { + self.completions.list_delivered_belegnummern(day).await + } +} diff --git a/crates/application/src/usecases/list_my_tours_today.rs b/crates/application/src/usecases/list_my_tours_today.rs index e656757..29f8900 100644 --- a/crates/application/src/usecases/list_my_tours_today.rs +++ b/crates/application/src/usecases/list_my_tours_today.rs @@ -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, + today_override: Option, } impl ListMyToursTodayUseCase { - pub fn new(repository: Arc) -> Self { - Self { repository } + pub fn new(repository: Arc, today_override: Option) -> Self { + Self { + repository, + today_override, + } } pub async fn execute(&self, personalnummer: i64) -> Result, 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 diff --git a/crates/application/src/usecases/mark_mail_sent.rs b/crates/application/src/usecases/mark_mail_sent.rs new file mode 100644 index 0000000..fc51dac --- /dev/null +++ b/crates/application/src/usecases/mark_mail_sent.rs @@ -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, +} + +impl MarkMailSentUseCase { + pub fn new(completions: Arc) -> 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, + ) -> Result { + 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, + ) -> Result { + self.completions.unmark_mail_sent(&belegnummern).await + } +} diff --git a/crates/application/src/usecases/mod.rs b/crates/application/src/usecases/mod.rs index a37ec82..086ebe3 100644 --- a/crates/application/src/usecases/mod.rs +++ b/crates/application/src/usecases/mod.rs @@ -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; diff --git a/crates/application/src/usecases/payment_methods.rs b/crates/application/src/usecases/payment_methods.rs new file mode 100644 index 0000000..e02314b --- /dev/null +++ b/crates/application/src/usecases/payment_methods.rs @@ -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, +} + +impl ListPaymentMethodsUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + include_inactive: bool, + ) -> Result, ApplicationError> { + self.repository.list(include_inactive).await + } +} + +pub struct CreatePaymentMethodUseCase { + repository: Arc, +} + +impl CreatePaymentMethodUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + request: CreatePaymentMethodRequest, + ) -> Result { + 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, +} + +impl UpdatePaymentMethodUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + id: Uuid, + request: UpdatePaymentMethodRequest, + ) -> Result { + 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, +} + +impl DeletePaymentMethodUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute(&self, id: Uuid) -> Result<(), ApplicationError> { + self.repository.delete(id).await + } +} diff --git a/crates/application/src/usecases/process_delivery_report.rs b/crates/application/src/usecases/process_delivery_report.rs new file mode 100644 index 0000000..37d6d1f --- /dev/null +++ b/crates/application/src/usecases/process_delivery_report.rs @@ -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, + jobs: Arc, + gateway: Arc, + attachment_repo: Arc, + attachment_storage: Arc, + signatures: Arc, + report_sink: Arc, +} + +impl ProcessDeliveryReportUseCase { + #[allow(clippy::too_many_arguments)] + pub fn new( + generate: Arc, + jobs: Arc, + gateway: Arc, + attachment_repo: Arc, + attachment_storage: Arc, + signatures: Arc, + report_sink: Arc, + ) -> 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; + } + } + } +} diff --git a/crates/application/src/usecases/push_completion_to_erp.rs b/crates/application/src/usecases/push_completion_to_erp.rs new file mode 100644 index 0000000..1d1d10d --- /dev/null +++ b/crates/application/src/usecases/push_completion_to_erp.rs @@ -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, + erp: Arc, +} + +impl PushCompletionToErpUseCase { + pub fn new( + completions: Arc, + erp: Arc, + ) -> 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 + } +} diff --git a/crates/application/src/usecases/services.rs b/crates/application/src/usecases/services.rs new file mode 100644 index 0000000..e0183d8 --- /dev/null +++ b/crates/application/src/usecases/services.rs @@ -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, +} + +impl ListServicesUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + include_inactive: bool, + ) -> Result, ApplicationError> { + self.repository.list(include_inactive).await + } +} + +pub struct CreateServiceUseCase { + repository: Arc, +} + +impl CreateServiceUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + request: CreateServiceRequest, + ) -> Result { + 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, +} + +impl UpdateServiceUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + id: Uuid, + request: UpdateServiceRequest, + ) -> Result { + 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, +} + +impl DeleteServiceUseCase { + pub fn new(repository: Arc) -> 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, + delivery_services: Arc, +} + +impl SetDeliveryServiceUseCase { + pub fn new( + services: Arc, + delivery_services: Arc, + ) -> Self { + Self { + services, + delivery_services, + } + } + + pub async fn execute( + &self, + delivery_id: Uuid, + service_id: Uuid, + author_personalnummer: i64, + request: SetDeliveryServiceRequest, + ) -> Result { + 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, +} + +impl DeleteDeliveryServiceUseCase { + pub fn new(delivery_services: Arc) -> 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 + } +} diff --git a/crates/application/src/usecases/update_delivery_note.rs b/crates/application/src/usecases/update_delivery_note.rs new file mode 100644 index 0000000..7b16971 --- /dev/null +++ b/crates/application/src/usecases/update_delivery_note.rs @@ -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, +} + +impl UpdateDeliveryNoteUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + note_id: Uuid, + request: UpdateDeliveryNoteRequest, + ) -> Result { + 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) -> Option { + input.and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } + }) +} diff --git a/crates/application/src/usecases/upload_delivery_note_image.rs b/crates/application/src/usecases/upload_delivery_note_image.rs new file mode 100644 index 0000000..a3d6476 --- /dev/null +++ b/crates/application/src/usecases/upload_delivery_note_image.rs @@ -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 (`//`) → Speicher-Referenz. +/// 4. Metadatensatz in `attachments` anlegen → unsere Attachment-Id. +/// 5. Notiz mit `image_attachment = ` 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, + attachments: Arc, + notes: Arc, + cars: Arc, +} + +impl UploadDeliveryNoteImageUseCase { + pub fn new( + storage: Arc, + attachments: Arc, + notes: Arc, + cars: Arc, + ) -> Self { + Self { + storage, + attachments, + notes, + cars, + } + } + + pub async fn execute( + &self, + delivery_id: Uuid, + author_personalnummer: i64, + author_car_id: Option, + filename: String, + mime: String, + bytes: Vec, + ) -> Result { + 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() +} diff --git a/crates/domain/src/audit.rs b/crates/domain/src/audit.rs index 1d9f986..413043a 100644 --- a/crates/domain/src/audit.rs +++ b/crates/domain/src/audit.rs @@ -10,6 +10,11 @@ use super::delivery::ScanStatus; /// * `Hold` / `Unhold` ändern nur den Status, keine Menge. /// * `Remove` markiert die Position als entfernt (Status `Removed`, /// z. B. weil der Kunde sie nicht annimmt). +/// * `Unremove` hebt ein `Remove` wieder auf — die Position landet +/// zurück in `InProgress` (oder `Done`, falls die `scanned_quantity` +/// schon `required_quantity` erreicht hatte). Der ursprüngliche +/// `Remove`-Audit-Eintrag bleibt unangetastet; das `Unremove` erzeugt +/// einen eigenen Audit-Eintrag — die Historie bleibt vollständig. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[serde(rename_all = "snake_case")] @@ -19,6 +24,7 @@ pub enum AuditAction { Hold, Unhold, Remove, + Unremove, } /// Append-only Audit-Log-Eintrag: jedes Ereignis am Scan-Zustand einer diff --git a/crates/domain/src/contact.rs b/crates/domain/src/contact.rs new file mode 100644 index 0000000..cad5d22 --- /dev/null +++ b/crates/domain/src/contact.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Rolle, mit der ein Kontakt-Datensatz an einer Lieferung hängt. Spiegelt +/// die fünf Adress-FKs von `Belegkopf` (bzw. den Umweg über den Kunden): +/// `header` = Belegadresse, `delivery` = Lieferadresse, `billing` = +/// Rechnungsadresse, `contact_person` = Ansprechpartner, `customer_master` +/// = Stammadresse des Kunden über `Kunden.AdressId`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum ContactRole { + Header, + Delivery, + Billing, + ContactPerson, + CustomerMaster, +} + +/// Art eines Kommunikationskanals. `fax` bewusst nicht mitgeführt — in der +/// App nicht verwendet. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum ContactKind { + Phone, + Mobile, + Email, + Web, +} + +/// Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs +/// an einer Lieferung hing — Namensblock ohne Anschrift, weil die Adresse +/// ihrerseits schon im Lieferungs-Snapshot steckt (`snap_*`-Spalten). Die +/// eigentlichen Telefonnummern, E-Mails etc. liegen in den +/// zugehörigen [`ContactChannel`]s. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ContactSource { + pub id: Uuid, + pub delivery_id: Uuid, + pub role: ContactRole, + pub anrede: Option, + pub titel: Option, + pub name1: Option, + pub name2: Option, + pub name3: Option, + pub abteilung: Option, + pub funktion: Option, +} + +/// Ein einzelner Kontaktkanal (Telefonnummer / Mobil / E-Mail / Web). +/// Mehrere pro [`ContactSource`] möglich, die `position` hält die +/// 1-basierte ERP-Reihenfolge (`Telefon` → 1, `Telefon2` → 2 usw.) fest, +/// damit der „primäre" Kanal je Art stabil identifizierbar bleibt. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ContactChannel { + pub id: Uuid, + pub source_id: Uuid, + pub kind: ContactKind, + pub position: i16, + pub value: String, +} diff --git a/crates/domain/src/delivery.rs b/crates/domain/src/delivery.rs index 0b7b0d5..39090c1 100644 --- a/crates/domain/src/delivery.rs +++ b/crates/domain/src/delivery.rs @@ -21,7 +21,10 @@ pub enum DeliveryState { /// Eine einzelne Lieferung an einen Kunden. Aggregat-Wurzel für die /// Liefer-Items, Notizen und das ggf. zugeordnete Fahrzeug. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +// +// Kein `Eq`-Derive, weil `prepaid_amount: f64` (Float kennt kein Eq — +// NaN-Verhalten). `PartialEq` reicht für unsere Vergleiche. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct Delivery { @@ -58,6 +61,16 @@ pub struct Delivery { /// Begründung bei `state == Held` oder `state == Canceled`. Beim /// Resume / Complete wieder `None`. pub state_reason: Option, + + /// Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der + /// Kunde alles bei Lieferung zahlt. Wird vom ERP-Sync gefüllt. + pub prepaid_amount: f64, + + /// Für den Restbetrag gewählte Zahlungsart — FK auf `payment_methods`. + /// Vom Kunden bei Bestellung festgelegt, der Fahrer übernimmt nur + /// die Abwicklung. Aktiv-Flag und Anzeige-Name werden über die + /// Stammdaten-Tabelle aufgelöst, nicht hier embeddet. + pub payment_method_id: Uuid, } /// Status einer einzelnen Scan-Position innerhalb eines Items. @@ -80,6 +93,11 @@ pub enum ScanStatus { #[serde(rename_all = "camelCase")] pub struct ScanState { pub scanned_quantity: i32, + /// Als Gutschrift entfernte Menge (0..=required_quantity). Eigene + /// Dimension neben `scanned_quantity`: „wie viele Stück dieser Zeile hat + /// der Kunde nicht angenommen". `status == Removed` entspricht + /// `credited_quantity == required_quantity` (ganze Zeile gutgeschrieben). + pub credited_quantity: i32, pub status: ScanStatus, /// Grund bei `status == Held` oder `status == Removed`. pub held_reason: Option, @@ -92,7 +110,8 @@ pub struct ScanState { /// /// Über die Felder `belegzeilen_nr` und `komponenten_artikel_nr` bleibt /// die ERP-Herkunft auflösbar. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +// Kein `Eq`-Derive: `unit_price: f64` kennt kein `Eq`. `PartialEq` reicht. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct DeliveryItem { @@ -103,6 +122,10 @@ pub struct DeliveryItem { pub required_quantity: i32, pub warehouse_id: Uuid, + /// Stückpreis (brutto, EUR) aus dem ERP-Sync. Der Warenwert einer + /// Lieferung = Σ `unit_price` × ausgelieferte Menge. + pub unit_price: f64, + /// ERP-Belegzeilen-Nr (Position innerhalb des Belegs). pub belegzeilen_nr: i32, @@ -110,9 +133,27 @@ pub struct DeliveryItem { /// Bei regulären Belegzeilen: `None`. pub komponenten_artikel_nr: Option, + /// Artikelnummer des Oberartikels, zu dem diese Komponente gehört. + /// `None` bei Oberartikeln/regulären Zeilen — die App rückt Komponenten + /// darüber unter ihrem Oberartikel ein. + pub parent_artikel_nr: Option, + pub scan_state: ScanState, } +impl DeliveryItem { + /// Tatsächlich auszuliefernde Menge = Soll minus Gutschrift. Nie negativ + /// (die Gutschrift ist per Constraint auf `required_quantity` gedeckelt). + pub fn delivered_quantity(&self) -> i32 { + (self.required_quantity - self.scan_state.credited_quantity).max(0) + } + + /// Wert der ausgelieferten Menge dieser Position (brutto, EUR). + pub fn line_total(&self) -> f64 { + self.unit_price * self.delivered_quantity() as f64 + } +} + /// Notiz an einer Lieferung — frei eingegeben durch den Fahrer. /// /// Mindestens eines von `text` oder `image_attachment` muss gesetzt @@ -131,5 +172,35 @@ pub struct DeliveryNote { pub author_personalnummer: i64, /// Fahrzeug, falls bekannt — nullable bis das Backend Cars verwaltet. pub author_car_id: Option, + /// Wenn die Notiz als Gutschrift-Grund zu einer Belegzeile angelegt + /// wurde: deren `DeliveryItem`-Id. Erlaubt dem Client, die Notiz beim + /// Zurücknehmen der Gutschrift (Unremove) gezielt wieder zu löschen. + /// `None` bei normalen Text-/Foto-Notizen. + pub credit_delivery_item_id: Option, + /// `true`, wenn die Notiz den Grund einer **Betrags-Gutschrift** + /// (Geld-Nachlass, Lieferungs-Ebene) dokumentiert. Erlaubt dem Client, + /// sie beim Entfernen der Gutschrift gezielt zu löschen. + pub is_amount_credit_note: bool, + /// `true`, wenn die lokale Bilddatei nach erfolgreichem Report-Upload + /// gelöscht wurde (das Bild steckt nun im Lieferbericht in DOCUframe). + /// Read-only; die App zeigt dann statt der Vorschau einen Hinweis. + /// Bei Text-Notizen / vorhandenem Bild: `false`. + #[serde(default)] + pub image_attachment_deleted: bool, pub created_at: DateTime, } + +/// Aktuelle Betrags-Gutschrift einer Lieferung (Geld-Nachlass, unabhängig von +/// Stückzahl). Abgeleitet aus dem jüngsten Ereignis im append-only +/// `delivery_credit_audit`; existiert nur, solange der letzte Stand `set` +/// (und nicht `remove`) ist. `delivery_id` macht den Eintrag — wie eine +/// Notiz — clientseitig per Lieferung join-bar. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryCredit { + pub delivery_id: Uuid, + /// Gutschrift-Betrag in Cent (> 0, ≤ 15000). + pub amount_cents: i64, + pub reason: String, +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 4ccbe33..e4628a8 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -20,9 +20,12 @@ mod article; mod audit; mod car; mod common; +mod contact; mod customer; mod delivery; +mod payment; mod process_state; +mod service; mod tour; mod warehouse; @@ -31,8 +34,13 @@ pub use article::Article; pub use audit::{AuditAction, ScanAuditEntry}; pub use car::Car; pub use common::Address; +pub use contact::{ContactChannel, ContactKind, ContactRole, ContactSource}; pub use customer::{Customer, CustomerContact}; -pub use delivery::{Delivery, DeliveryItem, DeliveryNote, DeliveryState, ScanState, ScanStatus}; +pub use delivery::{ + Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryState, ScanState, ScanStatus, +}; +pub use payment::PaymentMethod; pub use process_state::{DeliveryPhase, DeliveryProcessState}; +pub use service::{DeliveryServiceValue, Service, ServiceKind}; pub use tour::Tour; pub use warehouse::Warehouse; diff --git a/crates/domain/src/payment.rs b/crates/domain/src/payment.rs new file mode 100644 index 0000000..7228791 --- /dev/null +++ b/crates/domain/src/payment.rs @@ -0,0 +1,30 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Zahlungs-Stammdatensatz. +/// +/// Bewusst eine Tabelle und kein Enum: neue Anbieter (PayPal, Klarna, …) +/// kommen über den `POST /payment-methods`-Endpoint hinzu. Domain-Code +/// kann trotzdem fachliche Sonderfälle über den stabilen `code` (z. B. +/// `"invoice"` braucht Bonitätsprüfung) referenzieren — die UUID dient +/// nur als FK in `deliveries`. +/// +/// `active = false` ist Soft-Delete: die Methode bleibt referenzierbar +/// für historische Lieferungen, taucht aber in der UI-Auswahl nicht +/// mehr auf. Echtes Löschen ist nur möglich, wenn keine Lieferung sie +/// referenziert — Datenbank-Constraint regelt das via +/// `ON DELETE RESTRICT`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct PaymentMethod { + pub id: Uuid, + /// Stabiler Programm-Identifier — z. B. `"cash"`, `"ec_card"`. + /// Eindeutig pro Eintrag. Wird vom Aufrufer beim Anlegen gesetzt. + pub code: String, + /// Display-Name in der UI — frei via PATCH änderbar. + pub name: String, + pub active: bool, + pub created_at: DateTime, +} diff --git a/crates/domain/src/service.rs b/crates/domain/src/service.rs new file mode 100644 index 0000000..01e595a --- /dev/null +++ b/crates/domain/src/service.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Eingabetyp eines Service (früher „Lieferoption"). `Boolean` rendert als +/// Checkbox, `Numeric` als Zahlenfeld mit optionalen Grenzen. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum ServiceKind { + Boolean, + Numeric, +} + +/// Service-Stammdatensatz — admin-konfigurierbar (Muster wie `PaymentMethod`). +/// +/// `key` ist der stabile Programm-Identifier (eindeutig), `name` der +/// Anzeige-Name. `min_value`/`max_value` sind nur für `Numeric` relevant. +/// `active = false` ist Soft-Delete (bleibt für historische Lieferungen +/// referenzierbar, fällt aus dem Default-Listing). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Service { + pub id: Uuid, + pub key: String, + pub name: String, + pub kind: ServiceKind, + pub min_value: Option, + pub max_value: Option, + pub active: bool, + pub sort_order: i32, +} + +/// Pro-Lieferung gewählter Wert eines Service. Genau einer der beiden +/// Wert-Slots ist je nach `ServiceKind` gesetzt; per `service_id`/`delivery_id` +/// clientseitig join-bar (wie Notizen/Gutschriften). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryServiceValue { + pub delivery_id: Uuid, + pub service_id: Uuid, + pub bool_value: Option, + pub numeric_value: Option, +} diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index 3faa587..e50aebe 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -15,7 +15,14 @@ serde_json.workspace = true uuid.workspace = true chrono.workspace = true tokio.workspace = true +tokio-util.workspace = true sqlx.workspace = true reqwest.workspace = true +tiberius.workspace = true jsonwebtoken.workspace = true tracing.workspace = true +# PDF-Report-Generierung (Lieferabschluss). printpdf nutzt eingebaute +# Standard-Fonts (Helvetica, WinAnsi → deutsche Umlaute) → kein Font-Asset. +# `image` dekodiert Unterschriften/Foto-Notizen zu Roh-RGB fürs Einbetten. +printpdf = "0.7" +image = "0.25" diff --git a/crates/infrastructure/src/auth/keycloak_admin.rs b/crates/infrastructure/src/auth/keycloak_admin.rs new file mode 100644 index 0000000..12ae3e1 --- /dev/null +++ b/crates/infrastructure/src/auth/keycloak_admin.rs @@ -0,0 +1,298 @@ +//! Keycloak-Admin-Adapter — Implementierung von [`DriverIdentityProvisioner`]. +//! +//! Legt beim ERP-Sync Fahrer-Konten im Realm an. Authentifiziert sich als +//! **Service-Account** (confidential Client `holzleitner-provisioner`, +//! `client_credentials`) mit der `realm-management`-Rolle `manage-users`. +//! +//! Ablauf je Fahrer (idempotent): +//! 1. Admin-Token holen (`client_credentials`). +//! 2. User per `?username=&exact=true` suchen → existiert ⇒ No-Op. +//! 3. `POST users` mit `username=`, Attribut `personalnummer=[]`, +//! temporärem Passwort (`temporary:true`) und Required-Action +//! `UPDATE_PASSWORD` (Zwangsänderung beim ersten Login). +//! 4. Realm-Rolle `driver` zuweisen (`role-mappings/realm`). +//! +//! Bewusst **kein** Passwort-Reset für bestehende User — wer sein Passwort +//! schon gesetzt hat, behält es. + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::{DriverIdentityProvisioner, ProvisionOutcome}; + +/// Konfiguration des Keycloak-Admin-Adapters (aus `KEYCLOAK_*`-Env). +#[derive(Debug, Clone)] +pub struct KeycloakAdminConfig { + /// Basis-URL der Keycloak-Instanz **ohne** `/realms/...`, z. B. + /// `http://localhost:8080`. + pub base_url: String, + /// Realm-Name, z. B. `holzleitner`. + pub realm: String, + /// Service-Account-Client (confidential) für die Admin-API. + pub client_id: String, + pub client_secret: String, + /// Default-Passwort, das neuen Konten als **temporär** gesetzt wird + /// (muss beim ersten Login geändert werden). + pub default_password: String, + /// Realm-Rolle, die jedem Fahrer zugewiesen wird (z. B. `driver`). + pub driver_role: String, +} + +pub struct KeycloakAdminClient { + config: KeycloakAdminConfig, + http: reqwest::Client, +} + +impl KeycloakAdminClient { + pub fn new(config: KeycloakAdminConfig) -> Self { + Self { + config, + http: reqwest::Client::new(), + } + } + + fn base(&self) -> &str { + self.config.base_url.trim_end_matches('/') + } + + /// Holt ein Admin-Access-Token via `client_credentials`. + async fn admin_token(&self) -> Result { + let url = format!( + "{}/realms/{}/protocol/openid-connect/token", + self.base(), + self.config.realm + ); + let resp = self + .http + .post(&url) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", self.config.client_id.as_str()), + ("client_secret", self.config.client_secret.as_str()), + ]) + .send() + .await + .map_err(ext)?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(ApplicationError::External(format!( + "keycloak token ({status}): {body}" + ))); + } + let token: TokenResponse = resp.json().await.map_err(ext)?; + Ok(token.access_token) + } + + /// Sucht einen User per exaktem Benutzernamen. `Some(id)` ⇒ existiert. + async fn find_user_id( + &self, + token: &str, + username: &str, + ) -> Result, ApplicationError> { + let url = format!("{}/admin/realms/{}/users", self.base(), self.config.realm); + let resp = self + .http + .get(&url) + .bearer_auth(token) + .query(&[("username", username), ("exact", "true")]) + .send() + .await + .map_err(ext)?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(ApplicationError::External(format!( + "keycloak user lookup ({status}): {body}" + ))); + } + let users: Vec = resp.json().await.map_err(ext)?; + Ok(users.into_iter().find_map(|u| u.id)) + } + + /// Legt einen neuen User an und gibt dessen ID zurück. + async fn create_user( + &self, + token: &str, + username: &str, + display_name: Option<&str>, + ) -> Result { + let url = format!("{}/admin/realms/{}/users", self.base(), self.config.realm); + let body = json!({ + "username": username, + "enabled": true, + "firstName": display_name.unwrap_or(username), + "attributes": { "personalnummer": [username] }, + "requiredActions": ["UPDATE_PASSWORD"], + "credentials": [{ + "type": "password", + "value": self.config.default_password, + "temporary": true + }] + }); + + let resp = self + .http + .post(&url) + .bearer_auth(token) + .json(&body) + .send() + .await + .map_err(ext)?; + + let status = resp.status(); + if status.as_u16() == 409 { + // Race: zwischen Lookup und Create angelegt — als „existiert" + // behandeln und ID nachschlagen. + return self + .find_user_id(token, username) + .await? + .ok_or_else(|| ApplicationError::External("keycloak 409 ohne User".into())); + } + if !status.is_success() { + let txt = resp.text().await.unwrap_or_default(); + return Err(ApplicationError::External(format!( + "keycloak create user ({status}): {txt}" + ))); + } + + // Keycloak liefert die ID im `Location`-Header (.../users/{id}). + if let Some(loc) = resp.headers().get(reqwest::header::LOCATION) { + if let Ok(s) = loc.to_str() { + if let Some(id) = s.rsplit('/').next() { + if !id.is_empty() { + return Ok(id.to_string()); + } + } + } + } + // Fallback: erneut suchen. + self.find_user_id(token, username) + .await? + .ok_or_else(|| ApplicationError::External("keycloak: User nach Create nicht gefunden".into())) + } + + /// Weist dem User die Realm-Rolle `driver_role` zu (idempotent). + /// + /// Bewusst über den **user-scoped** Endpoint `role-mappings/realm/available` + /// statt `GET /roles/{name}` — letzterer bräuchte `view-realm`; ersterer + /// kommt mit `manage-users` aus (least privilege). Ist die Rolle nicht mehr + /// „available", ist sie bereits zugewiesen (oder existiert nicht → wir + /// prüfen die effektiven Zuweisungen und liefern sonst einen Fehler). + async fn assign_driver_role( + &self, + token: &str, + user_id: &str, + ) -> Result<(), ApplicationError> { + let role_name = self.config.driver_role.as_str(); + let base_map = format!( + "{}/admin/realms/{}/users/{}/role-mappings/realm", + self.base(), + self.config.realm, + user_id + ); + + // 1. Zuweisbare Realm-Rollen des Users holen, `driver` suchen. + let available: Vec = self + .http + .get(format!("{base_map}/available")) + .bearer_auth(token) + .send() + .await + .map_err(ext)? + .error_for_status() + .map_err(ext)? + .json() + .await + .map_err(ext)?; + + let role = available + .into_iter() + .find(|r| r.get("name").and_then(Value::as_str) == Some(role_name)); + + let Some(role) = role else { + // Nicht „available" → entweder schon zugewiesen (idempotenter + // No-Op) oder Rolle existiert nicht. Effektive Zuweisungen prüfen. + let assigned: Vec = self + .http + .get(&base_map) + .bearer_auth(token) + .send() + .await + .map_err(ext)? + .error_for_status() + .map_err(ext)? + .json() + .await + .map_err(ext)?; + let has = assigned + .iter() + .any(|r| r.get("name").and_then(Value::as_str) == Some(role_name)); + return if has { + Ok(()) + } else { + Err(ApplicationError::External(format!( + "keycloak: Realm-Rolle '{role_name}' existiert nicht" + ))) + }; + }; + + // 2. Rolle zuweisen. + let resp = self + .http + .post(&base_map) + .bearer_auth(token) + .json(&json!([role])) + .send() + .await + .map_err(ext)?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(ApplicationError::External(format!( + "keycloak assign role ({status}): {body}" + ))); + } + Ok(()) + } +} + +#[async_trait] +impl DriverIdentityProvisioner for KeycloakAdminClient { + async fn ensure_driver( + &self, + personalnummer: i64, + display_name: Option<&str>, + ) -> Result { + let username = personalnummer.to_string(); + let token = self.admin_token().await?; + + if self.find_user_id(&token, &username).await?.is_some() { + tracing::debug!(personalnummer, "keycloak_provision: user existiert bereits"); + return Ok(ProvisionOutcome { created: false }); + } + + let user_id = self.create_user(&token, &username, display_name).await?; + self.assign_driver_role(&token, &user_id).await?; + tracing::info!(personalnummer, user_id, "keycloak_provision: Fahrer-Konto angelegt"); + Ok(ProvisionOutcome { created: true }) + } +} + +fn ext(e: E) -> ApplicationError { + ApplicationError::External(e.to_string()) +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, +} + +#[derive(Deserialize)] +struct UserRep { + id: Option, +} diff --git a/crates/infrastructure/src/auth/mod.rs b/crates/infrastructure/src/auth/mod.rs index d6296bd..42eb90f 100644 --- a/crates/infrastructure/src/auth/mod.rs +++ b/crates/infrastructure/src/auth/mod.rs @@ -6,5 +6,7 @@ //! auf das Domänenmodell (Personalnummer, Rollen). pub mod keycloak; +pub mod keycloak_admin; pub use keycloak::{KeycloakAdapterConfig, KeycloakAuthService}; +pub use keycloak_admin::{KeycloakAdminClient, KeycloakAdminConfig}; diff --git a/crates/infrastructure/src/erp/mod.rs b/crates/infrastructure/src/erp/mod.rs new file mode 100644 index 0000000..775f678 --- /dev/null +++ b/crates/infrastructure/src/erp/mod.rs @@ -0,0 +1,11 @@ +//! ERP-Lese-Adapter (ERPframe / MS SQL Server). +//! +//! Implementiert den `ErpDeliverySource`-Port gegen die ERPframe-Datenbank +//! via `tiberius` (nativer async MSSQL-Treiber). Reine Lese-Operation; das +//! ERP wird nicht zurückgeschrieben. + +pub mod mssql_delivery_source; +pub mod mssql_delivery_writeback; + +pub use mssql_delivery_source::{MssqlErpConfig, MssqlErpDeliverySource}; +pub use mssql_delivery_writeback::MssqlErpDeliveryWriteback; diff --git a/crates/infrastructure/src/erp/mssql_delivery_source.rs b/crates/infrastructure/src/erp/mssql_delivery_source.rs new file mode 100644 index 0000000..b64f3af --- /dev/null +++ b/crates/infrastructure/src/erp/mssql_delivery_source.rs @@ -0,0 +1,586 @@ +//! MSSQL-Adapter für den `ErpDeliverySource`-Port. +//! +//! Liest die Lieferungen eines Tages direkt aus den ERPframe-Basistabellen +//! (Belegart `VL5` / Lieferschein) und gruppiert sie zu `SyncTourRequest`- +//! DTOs (eine pro Fahrer). Kein Connection-Pool: der Pull läuft einmal +//! täglich, eine frische Verbindung pro Lauf genügt. +//! +//! Die SELECT-Query löst **Stücklisten** auf (`UNION ALL`, analog zur Alt-View +//! `_SV_APP_DELIVERIES_TODAY`): Oberartikel werden non-scannable als Preis-/ +//! Gruppenträger geführt, jede Komponente als eigener (scanbarer) Artikel. Das +//! Lager kommt pro Zeile aus `bz.Lagerverteilung` (XML), Fallback `bk.Lager`. +//! Numerische Spalten werden serverseitig auf feste Typen gecastet +//! (BIGINT/INT/FLOAT/BIT), damit die tiberius-Reads deterministisch sind. + +use async_trait::async_trait; +use chrono::NaiveDate; +use tiberius::{AuthMethod, Client, Config}; +use tokio::net::TcpStream; +use tokio_util::compat::TokioAsyncWriteCompatExt; + +use holzleitner_application::dto::{ + SyncContactChannel, SyncContactSource, SyncDelivery, SyncDeliveryItem, SyncTourRequest, +}; +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::ErpDeliverySource; +use holzleitner_domain::{Address, ContactKind, ContactRole}; + +/// Verbindungsparameter zur ERPframe-MSSQL. +#[derive(Debug, Clone)] +pub struct MssqlErpConfig { + pub host: String, + pub port: u16, + pub database: String, + pub user: String, + pub password: String, + /// Selbstsigniertes Server-Zertifikat akzeptieren (lokale/Intranet-DB). + pub trust_cert: bool, +} + +pub struct MssqlErpDeliverySource { + config: MssqlErpConfig, +} + +impl MssqlErpDeliverySource { + pub fn new(config: MssqlErpConfig) -> Self { + Self { config } + } + + fn tiberius_config(&self) -> Config { + let mut cfg = Config::new(); + cfg.host(&self.config.host); + cfg.port(self.config.port); + cfg.database(&self.config.database); + cfg.authentication(AuthMethod::sql_server( + &self.config.user, + &self.config.password, + )); + if self.config.trust_cert { + cfg.trust_cert(); + } + cfg + } +} + +fn repo(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +/// Adress-Aliases, ihre FK-Quelle am Belegkopf und der Präfix, mit dem alle +/// Kontaktspalten der jeweiligen Adresse in der SELECT-Liste verbiegt werden. +/// `adr` ist die Belegadresse (auch heute schon Pflicht-JOIN für die +/// Anschrift); die übrigen vier sind LEFT-JOINs. Reihenfolge entspricht der +/// `ContactRole`-Enum. +const ADDRESS_ALIASES: &[(&str, &str)] = &[ + ("adr", "hdr"), // header – bk.AdressId + ("dadr", "dlv"), // delivery – bk.LieferAdressId + ("radr", "bll"), // billing – bk.RechnungsAdressId + ("kadr", "ctp"), // contact_person – bk.AnsprechpartnerId + ("sadr", "cms"), // customer_master – Kunden.AdressId (über bk.KundenId) +]; + +/// Spalten, die wir pro Adresse selektieren. Reihenfolge irrelevant; Aliase +/// werden im Mapper über `_` aufgelöst. +const ADDRESS_CONTACT_COLUMNS: &[&str] = &[ + "Anrede", "Titel", "Name1", "Name2", "Name3", "Abteilung", "Funktion", "Telefon", "Telefon2", + "Telefon3", "Telefon4", "Mobiltel", "Mobiltel2", "EMail", "EMail2", "EMail3", "InternetAdresse", +]; + +/// Erzeugt den SELECT-Block für alle 5 Adressen — eine Zeile pro Spalte mit +/// `. AS _`. Wird in beide UNION-Hälften identisch +/// einkopiert. +fn address_select_block() -> String { + let mut out = String::new(); + for (alias, prefix) in ADDRESS_ALIASES { + for col in ADDRESS_CONTACT_COLUMNS { + out.push_str(&format!(" {alias}.{col} AS {prefix}_{col},\n")); + } + } + out +} + +/// SELECT gegen die Basistabellen, **mit Stücklisten-Auflösung** (analog zur +/// Alt-View `_SV_APP_DELIVERIES_TODAY`). Datum als positionaler Parameter +/// `@P1`. Zwei `UNION ALL`-Teile: +/// +/// * **Teil 1** — alle Belegzeilen als Items. Ein Oberartikel (= Artikel mit +/// Stücklisten-Kopf `Stuecklisten.StueckListenId=0`) wird **non-scannable** +/// (`articleScannable=0`) ausgegeben: er ist nur Preis-/Gruppen-Träger, die +/// physisch zu scannenden Einheiten sind seine Komponenten. `komponenten- +/// ArtikelNr = NULL`. +/// * **Teil 2** — die Stücklisten-Komponenten je Oberartikel, **jede als +/// eigener Artikel** (eigene Artikelnummer/-bezeichnung/-scanbarkeit), Menge +/// = Zeilenmenge × Komponentenmenge, `unitPrice=0` (der Preis liegt im ERP +/// nur auf der Oberartikel-Zeile). `komponentenArtikelNr` = eigene Nummer der +/// Komponente (eindeutig je `belegzeilenNr`; markiert die Zeile als +/// Komponente — der Parent ist die `NULL`-Zeile gleicher `belegzeilenNr`). +/// +/// **Lager pro Zeile** aus `bz.Lagerverteilung` (XML `(/Root/Row/Lager)[1]`), +/// Fallback `bk.Lager`; Name aus `Lagerstammdaten`. Komponenten erben das +/// Lager ihrer Oberartikel-Zeile. Das Lager wird je Zeile einmal per +/// `CROSS APPLY lv` berechnet. +/// +/// **Adressen für Kontaktdaten:** Wir joinen zusätzlich zur Belegadresse +/// (`adr`) und der Lieferadresse (`dadr`) auch Rechnungsadresse (`radr`), +/// Ansprechpartner (`kadr`) und die Stamm-Adresse des Kunden (`sadr` über +/// `Kunden.AdressId`). Pro Adresse werden Name/Anrede/Titel und alle +/// Telefon-/Mobil-/E-Mail-/Web-Spalten selektiert (siehe +/// [`address_select_block`]); der Mapper baut daraus die `contact_sources` +/// der Lieferung. +const SQL_TEMPLATE: &str = r#" +SELECT + TRY_CAST(LTRIM(RTRIM(bk.Vertreter)) AS BIGINT) AS driverPersonalnummer, + CAST(bk.BelegartId AS BIGINT) AS belegartId, + LTRIM(RTRIM(ba.Belegart)) AS belegartCode, + LTRIM(RTRIM(ba.Bezeichnung)) AS belegartName, + LTRIM(RTRIM(bk.Belegnummer)) AS belegnummer, + COALESCE(TRY_CAST(LTRIM(RTRIM(k.Kundennummer)) AS BIGINT), + CAST(bk.KundenId AS BIGINT)) AS erpCustomerId, + adr.Name1 AS customerName, + adr.Strasse AS custStreet, + adr.Hausnummer AS custHouseNumber, + LTRIM(RTRIM(adr.PLZ)) AS custPostalCode, + adr.Ort AS custCity, + adr.Land AS custCountry, + COALESCE(dadr.Strasse, adr.Strasse) AS delivStreet, + COALESCE(dadr.Hausnummer, adr.Hausnummer) AS delivHouseNumber, + LTRIM(RTRIM(COALESCE(dadr.PLZ, adr.PLZ))) AS delivPostalCode, + COALESCE(dadr.Ort, adr.Ort) AS delivCity, + COALESCE(dadr.Land, adr.Land) AS delivCountry, + LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime, + LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements, + CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount, + LTRIM(RTRIM(z.Zahlungsbedingung)) AS paymentZahlbed, + z.Bezeichnung AS paymentZahlbedText, + CAST(bz.BelegzeilenNr AS INT) AS belegzeilenNr, + CAST(NULL AS varchar(50)) AS komponentenArtikelNr, + CAST(NULL AS varchar(50)) AS parentArtikelNr, + a.Artikelnummer AS articleNumber, + COALESCE(NULLIF(LTRIM(RTRIM(bz.ArtikelBezeichnung)), ''), a.Artikelbezeichnung) + AS articleName, + CASE WHEN EXISTS (SELECT 1 FROM Stuecklisten sk + WHERE sk.ArtikelID = a.row_id AND sk.StueckListenId = 0) + THEN CAST(0 AS BIT) + WHEN UPPER(LTRIM(RTRIM(ag.[Bestandsführung]))) IN ('1','J','Y','T','X') + THEN CAST(1 AS BIT) + ELSE CAST(0 AS BIT) END AS articleScannable, + lv.code AS warehouseCode, + COALESCE(ls.lagerbezeichnung, lv.code) AS warehouseName, + CAST(bz.Menge AS INT) AS requiredQuantity, + CAST(bz.EinzelPreisBrutto AS FLOAT) AS unitPrice, +{ADDR} + -- Trailing-Anker: damit der letzte `,` im Adressblock syntaktisch ok ist. + CAST(1 AS BIT) AS _addrBlockMarker +FROM Belegkopf bk +JOIN Adressen adr ON bk.AdressId = adr.ROW_ID +LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID +LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID +LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID +JOIN Belegzeilen bz ON bk.row_id = bz.ParentID +JOIN Personalstamm ps ON ps.Personalnummer = bk.Vertreter +JOIN Artikel a ON a.row_id = bz.ArtikelId +JOIN Artikelgruppen ag ON a.ArtikelGruppenId = ag.ROW_ID +JOIN Zahlungsbedingungen z ON z.ROW_ID = bk.ZahlungsbedingungId +JOIN Belegarten ba ON ba.row_id = bk.BelegartId +LEFT JOIN Kunden k ON k.row_id = bk.KundenId +LEFT JOIN Adressen sadr ON k.AdressId = sadr.ROW_ID +CROSS APPLY (SELECT COALESCE( + NULLIF(LTRIM(RTRIM( + CAST(CAST(bz.Lagerverteilung AS varchar(max)) AS XML) + .value('(/Root/Row/Lager)[1]', 'varchar(20)'))), ''), + LTRIM(RTRIM(bk.Lager))) AS code) lv +LEFT JOIN Lagerstammdaten ls ON ls.lagernummer = lv.code +WHERE ba.Belegart = 'VL5' + AND bz.ArtikelId IS NOT NULL + AND CAST(bk.Termin AS DATE) = @P1 + -- Abschluss-Artefakte ausschließen, damit ein erneuter Sync auch nach + -- Rückschreiben eines Abschlusses robust bleibt: Gutschrift-/Storno- + -- Zeilen (negativer Bruttopreis) und vollständig entfernte Positionen + -- (Menge 0). Sonst Crash gegen unit_price>=0 / required_quantity>0. + AND CAST(ISNULL(bz.EinzelPreisBrutto, 0) AS FLOAT) >= 0 + AND ISNULL(bz.Menge, 0) > 0 + +UNION ALL + +SELECT + TRY_CAST(LTRIM(RTRIM(bk.Vertreter)) AS BIGINT) AS driverPersonalnummer, + CAST(bk.BelegartId AS BIGINT) AS belegartId, + LTRIM(RTRIM(ba.Belegart)) AS belegartCode, + LTRIM(RTRIM(ba.Bezeichnung)) AS belegartName, + LTRIM(RTRIM(bk.Belegnummer)) AS belegnummer, + COALESCE(TRY_CAST(LTRIM(RTRIM(k.Kundennummer)) AS BIGINT), + CAST(bk.KundenId AS BIGINT)) AS erpCustomerId, + adr.Name1 AS customerName, + adr.Strasse AS custStreet, + adr.Hausnummer AS custHouseNumber, + LTRIM(RTRIM(adr.PLZ)) AS custPostalCode, + adr.Ort AS custCity, + adr.Land AS custCountry, + COALESCE(dadr.Strasse, adr.Strasse) AS delivStreet, + COALESCE(dadr.Hausnummer, adr.Hausnummer) AS delivHouseNumber, + LTRIM(RTRIM(COALESCE(dadr.PLZ, adr.PLZ))) AS delivPostalCode, + COALESCE(dadr.Ort, adr.Ort) AS delivCity, + COALESCE(dadr.Land, adr.Land) AS delivCountry, + LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime, + LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements, + CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount, + LTRIM(RTRIM(z.Zahlungsbedingung)) AS paymentZahlbed, + z.Bezeichnung AS paymentZahlbedText, + CAST(bz.BelegzeilenNr AS INT) AS belegzeilenNr, + LTRIM(RTRIM(ka.Artikelnummer)) AS komponentenArtikelNr, + LTRIM(RTRIM(a.Artikelnummer)) AS parentArtikelNr, + ka.Artikelnummer AS articleNumber, + COALESCE(NULLIF(LTRIM(RTRIM(ka.Artikelbezeichnung)), ''), ka.Artikelnummer) + AS articleName, + CASE WHEN UPPER(LTRIM(RTRIM(kag.[Bestandsführung]))) IN ('1','J','Y','T','X') + THEN CAST(1 AS BIT) ELSE CAST(0 AS BIT) END AS articleScannable, + lv.code AS warehouseCode, + COALESCE(ls.lagerbezeichnung, lv.code) AS warehouseName, + CAST(bz.Menge * stl_pos.Menge AS INT) AS requiredQuantity, + CAST(0 AS FLOAT) AS unitPrice, +{ADDR} + CAST(1 AS BIT) AS _addrBlockMarker +FROM Belegkopf bk +JOIN Adressen adr ON bk.AdressId = adr.ROW_ID +LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID +LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID +LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID +JOIN Belegzeilen bz ON bk.row_id = bz.ParentID +JOIN Personalstamm ps ON ps.Personalnummer = bk.Vertreter +JOIN Artikel a ON a.row_id = bz.ArtikelId +JOIN Zahlungsbedingungen z ON z.ROW_ID = bk.ZahlungsbedingungId +JOIN Belegarten ba ON ba.row_id = bk.BelegartId +LEFT JOIN Kunden k ON k.row_id = bk.KundenId +LEFT JOIN Adressen sadr ON k.AdressId = sadr.ROW_ID +JOIN Stuecklisten stl_kopf ON stl_kopf.ArtikelID = a.row_id + AND stl_kopf.StueckListenId = 0 +JOIN Stuecklisten stl_pos ON stl_pos.StueckListenId = stl_kopf.ROW_ID +JOIN Artikel ka ON ka.row_id = stl_pos.ArtikelID +LEFT JOIN Artikelgruppen kag ON ka.ArtikelGruppenId = kag.ROW_ID +CROSS APPLY (SELECT COALESCE( + NULLIF(LTRIM(RTRIM( + CAST(CAST(bz.Lagerverteilung AS varchar(max)) AS XML) + .value('(/Root/Row/Lager)[1]', 'varchar(20)'))), ''), + LTRIM(RTRIM(bk.Lager))) AS code) lv +LEFT JOIN Lagerstammdaten ls ON ls.lagernummer = lv.code +WHERE ba.Belegart = 'VL5' + AND bz.ArtikelId IS NOT NULL + AND CAST(bk.Termin AS DATE) = @P1 + -- Abschluss-Artefakte ausschließen, damit ein erneuter Sync auch nach + -- Rückschreiben eines Abschlusses robust bleibt: Gutschrift-/Storno- + -- Zeilen (negativer Bruttopreis) und vollständig entfernte Positionen + -- (Menge 0). Sonst Crash gegen unit_price>=0 / required_quantity>0. + AND CAST(ISNULL(bz.EinzelPreisBrutto, 0) AS FLOAT) >= 0 + AND ISNULL(bz.Menge, 0) > 0 + +ORDER BY driverPersonalnummer, belegartId, belegnummer, belegzeilenNr, komponentenArtikelNr +"#; + +/// Eine flache Belegzeile, wie sie aus der Query kommt. +struct ErpRow { + driver_personalnummer: i64, + belegart_id: i64, + belegart_code: Option, + belegart_name: Option, + belegnummer: String, + erp_customer_id: i64, + customer_name: String, + cust: Address, + deliv: Address, + desired_time: Option, + special_agreements: Option, + prepaid_amount: f64, + payment_zahlbed: String, + payment_zahlbed_text: String, + belegzeilen_nr: i32, + komponenten_artikel_nr: Option, + parent_artikel_nr: Option, + article_number: String, + article_name: String, + article_scannable: bool, + warehouse_code: String, + warehouse_name: String, + required_quantity: i32, + unit_price: f64, + /// Pro-Beleg Kontaktquellen (aus allen 5 Adress-FKs). Auf jeder Item-Zeile + /// derselben Lieferung identisch; der Grouper übernimmt sie aus der + /// ersten Zeile und ignoriert die der Folgezeilen. + contact_sources: Vec, +} + +fn s(row: &tiberius::Row, col: &str) -> String { + row.get::<&str, _>(col).unwrap_or("").trim().to_string() +} +fn opt_s(row: &tiberius::Row, col: &str) -> Option { + match row.get::<&str, _>(col) { + Some(v) if !v.trim().is_empty() => Some(v.trim().to_string()), + _ => None, + } +} +fn i64c(row: &tiberius::Row, col: &str) -> i64 { + row.get::(col).unwrap_or(0) +} +fn i32c(row: &tiberius::Row, col: &str) -> i32 { + row.get::(col).unwrap_or(0) +} +fn f64c(row: &tiberius::Row, col: &str) -> f64 { + row.get::(col).unwrap_or(0.0) +} +fn boolc(row: &tiberius::Row, col: &str) -> bool { + row.get::(col).unwrap_or(false) +} + +/// Liest aus einem Tiberius-Row die Kontaktdaten **einer** Adress-Rolle aus +/// (alle Spalten heißen `_`, siehe [`address_select_block`]). +/// Gibt `None` zurück, wenn weder ein Namensfeld noch ein Kontaktkanal +/// belegt ist — leere Adressen werden so vom Sync nicht mitgeschleppt. +fn read_contact_source( + row: &tiberius::Row, + prefix: &str, + role: ContactRole, +) -> Option { + let get = |col: &str| opt_s(row, &format!("{prefix}_{col}")); + let anrede = get("Anrede"); + let titel = get("Titel"); + let name1 = get("Name1"); + let name2 = get("Name2"); + let name3 = get("Name3"); + let abteilung = get("Abteilung"); + let funktion = get("Funktion"); + + let mut channels: Vec = Vec::new(); + // 1-basierte Position spiegelt die ERP-Spaltennummerierung + // (`Telefon` → 1, `Telefon2` → 2, …). Spalten-Reihenfolge identisch zu + // [`ADDRESS_CONTACT_COLUMNS`]. + let push = |chans: &mut Vec, + kind: ContactKind, + pos: i16, + value: Option| { + if let Some(v) = value { + chans.push(SyncContactChannel { + kind, + position: pos, + value: v, + }); + } + }; + push(&mut channels, ContactKind::Phone, 1, get("Telefon")); + push(&mut channels, ContactKind::Phone, 2, get("Telefon2")); + push(&mut channels, ContactKind::Phone, 3, get("Telefon3")); + push(&mut channels, ContactKind::Phone, 4, get("Telefon4")); + push(&mut channels, ContactKind::Mobile, 1, get("Mobiltel")); + push(&mut channels, ContactKind::Mobile, 2, get("Mobiltel2")); + push(&mut channels, ContactKind::Email, 1, get("EMail")); + push(&mut channels, ContactKind::Email, 2, get("EMail2")); + push(&mut channels, ContactKind::Email, 3, get("EMail3")); + push(&mut channels, ContactKind::Web, 1, get("InternetAdresse")); + + let has_name = anrede.is_some() + || titel.is_some() + || name1.is_some() + || name2.is_some() + || name3.is_some() + || abteilung.is_some() + || funktion.is_some(); + if channels.is_empty() && !has_name { + return None; + } + + Some(SyncContactSource { + role, + anrede, + titel, + name1, + name2, + name3, + abteilung, + funktion, + channels, + }) +} + +fn read_all_contact_sources(row: &tiberius::Row) -> Vec { + [ + ("hdr", ContactRole::Header), + ("dlv", ContactRole::Delivery), + ("bll", ContactRole::Billing), + ("ctp", ContactRole::ContactPerson), + ("cms", ContactRole::CustomerMaster), + ] + .into_iter() + .filter_map(|(prefix, role)| read_contact_source(row, prefix, role)) + .collect() +} + +fn map_row(row: &tiberius::Row) -> ErpRow { + ErpRow { + driver_personalnummer: i64c(row, "driverPersonalnummer"), + belegart_id: i64c(row, "belegartId"), + belegart_code: opt_s(row, "belegartCode"), + belegart_name: opt_s(row, "belegartName"), + belegnummer: s(row, "belegnummer"), + erp_customer_id: i64c(row, "erpCustomerId"), + customer_name: s(row, "customerName"), + cust: Address { + street: s(row, "custStreet"), + house_number: s(row, "custHouseNumber"), + postal_code: s(row, "custPostalCode"), + city: s(row, "custCity"), + country: s(row, "custCountry"), + }, + deliv: Address { + street: s(row, "delivStreet"), + house_number: s(row, "delivHouseNumber"), + postal_code: s(row, "delivPostalCode"), + city: s(row, "delivCity"), + country: s(row, "delivCountry"), + }, + desired_time: opt_s(row, "desiredTime"), + special_agreements: opt_s(row, "specialAgreements"), + prepaid_amount: f64c(row, "prepaidAmount"), + payment_zahlbed: s(row, "paymentZahlbed"), + payment_zahlbed_text: s(row, "paymentZahlbedText"), + belegzeilen_nr: i32c(row, "belegzeilenNr"), + komponenten_artikel_nr: opt_s(row, "komponentenArtikelNr"), + parent_artikel_nr: opt_s(row, "parentArtikelNr"), + article_number: s(row, "articleNumber"), + article_name: s(row, "articleName"), + article_scannable: boolc(row, "articleScannable"), + warehouse_code: s(row, "warehouseCode"), + warehouse_name: s(row, "warehouseName"), + required_quantity: i32c(row, "requiredQuantity"), + unit_price: f64c(row, "unitPrice"), + contact_sources: read_all_contact_sources(row), + } +} + +/// Mapping ERP-Zahlungsbedingung-Code → Backend-Code. +/// +/// Holzleitner bietet im Liefer-Flow exakt drei Zahlungsbedingungen an +/// (Allowlist in DOCUframe-Makro `_web_getPaymentMethods.dfm`); ihre +/// Bedeutung stammt aus der ERP-Tabelle `Zahlungsbedingungen` (live verifiziert): +/// * `D16` = „Zahlung bei Lieferung (Barzahlung)" → cash +/// * `d53` = „Zahlung bei Lieferung mit EC-Karte" → ec_card +/// * `D10` = „14 Tage netto" → invoice +/// `credit_card` kommt im Liefer-Flow nicht vor. Unbekannte Codes → `None` +/// (Backend defaultet dann auf `cash`). +fn map_payment_code(code: &str, _text: &str) -> Option { + match code.trim().to_uppercase().as_str() { + "D16" => Some("cash".to_string()), + "D53" => Some("ec_card".to_string()), + "D10" => Some("invoice".to_string()), + _ => None, + } +} + +fn item_from(row: &ErpRow) -> SyncDeliveryItem { + SyncDeliveryItem { + belegzeilen_nr: row.belegzeilen_nr, + komponenten_artikel_nr: row.komponenten_artikel_nr.clone(), + parent_artikel_nr: row.parent_artikel_nr.clone(), + article_number: row.article_number.clone(), + article_name: row.article_name.clone(), + article_default_warehouse_code: None, + article_scannable: row.article_scannable, + warehouse_code: row.warehouse_code.clone(), + warehouse_name: row.warehouse_name.clone(), + required_quantity: row.required_quantity, + unit_price: row.unit_price, + } +} + +fn delivery_from(row: &ErpRow, sort_order: i32) -> SyncDelivery { + SyncDelivery { + belegart_id: row.belegart_id, + belegart_code: row.belegart_code.clone(), + belegart_name: row.belegart_name.clone(), + belegnummer: row.belegnummer.clone(), + erp_customer_id: row.erp_customer_id, + customer_name: row.customer_name.clone(), + customer_address: row.cust.clone(), + delivery_address: row.deliv.clone(), + sort_order, + desired_time: row.desired_time.clone(), + special_agreements: row.special_agreements.clone(), + prepaid_amount: row.prepaid_amount, + payment_method_code: map_payment_code(&row.payment_zahlbed, &row.payment_zahlbed_text), + items: vec![item_from(row)], + contact_sources: row.contact_sources.clone(), + } +} + +/// Gruppiert die (nach Fahrer/Beleg/Zeile sortierten) Zeilen zu +/// `SyncTourRequest` pro Fahrer. Nutzt die Sortierung aus dem ORDER BY: +/// gleiche Fahrer/Belege liegen kontiguös. +fn group(rows: Vec, date: NaiveDate) -> Vec { + let mut tours: Vec = Vec::new(); + + for row in &rows { + // Tour (Fahrer) finden/anlegen. + let need_new_tour = tours + .last() + .map(|t| t.driver_personalnummer != row.driver_personalnummer) + .unwrap_or(true); + if need_new_tour { + tours.push(SyncTourRequest { + driver_personalnummer: row.driver_personalnummer, + tour_date: date, + deliveries: Vec::new(), + }); + } + let tour = tours.last_mut().unwrap(); + + // Lieferung (Beleg) finden/anlegen. + let need_new_delivery = tour + .deliveries + .last() + .map(|d| d.belegart_id != row.belegart_id || d.belegnummer != row.belegnummer) + .unwrap_or(true); + if need_new_delivery { + let sort_order = tour.deliveries.len() as i32 + 1; + tour.deliveries.push(delivery_from(row, sort_order)); + } else { + tour.deliveries.last_mut().unwrap().items.push(item_from(row)); + } + } + + tours +} + +#[async_trait] +impl ErpDeliverySource for MssqlErpDeliverySource { + async fn fetch_tours_for_date( + &self, + date: NaiveDate, + ) -> Result, ApplicationError> { + let cfg = self.tiberius_config(); + let addr = format!("{}:{}", self.config.host, self.config.port); + + let tcp = TcpStream::connect(&addr).await.map_err(repo)?; + tcp.set_nodelay(true).ok(); + + let mut client = Client::connect(cfg, tcp.compat_write()) + .await + .map_err(repo)?; + + let sql = SQL_TEMPLATE.replace("{ADDR}", &address_select_block()); + let rows = client + .query(&sql, &[&date]) + .await + .map_err(repo)? + .into_first_result() + .await + .map_err(repo)?; + + let flat: Vec = rows.iter().map(map_row).collect(); + let tours = group(flat, date); + + tracing::info!( + %date, + rows = rows.len(), + tours = tours.len(), + "erp_source.fetched" + ); + Ok(tours) + } +} diff --git a/crates/infrastructure/src/erp/mssql_delivery_writeback.rs b/crates/infrastructure/src/erp/mssql_delivery_writeback.rs new file mode 100644 index 0000000..f34d17d --- /dev/null +++ b/crates/infrastructure/src/erp/mssql_delivery_writeback.rs @@ -0,0 +1,419 @@ +//! MSSQL-Adapter für den `ErpDeliveryWriteback`-Port. +//! +//! Schreibt einen Lieferabschluss **direkt** in die ERPframe-Basistabellen +//! zurück — das Rust-Pendant zu den Alt-Makros `_web_finishDelivery`, +//! `_removeArticles` und `_addDiscount`. Alles läuft in **einer** MSSQL- +//! Transaktion und ist **idempotent** (Mengen werden absolut gesetzt, die +//! Gutschrift als Upsert geführt). +//! +//! Reihenfolge: +//! 1. `Belegkopf.row_id` aus (BelegartId, Belegnummer) auflösen. +//! 2. Je Belegzeile die `Menge` auf die ausgelieferte Menge setzen. +//! 3. Gutschrift-Zeile (`GUTSCHRIFT10`) anlegen/aktualisieren. +//! 4. Belegsummen neu berechnen (`Σ Einzelpreis × Menge`, `Σ Brutto × Menge`). +//! 5. `_SV_DELIVERY_DELIVERED_AT` + `_SV_DELIVERY_STATE='geliefert'`. +//! +//! TODO (bewusst hardcoded, später konfigurierbar/aus Stammdaten): +//! Gutschrift-Artikel `GUTSCHRIFT10`, Konto `8726`, Steuerschlüssel `M19`, +//! Steuersatz `19`. Die 10-€-Einheit steckt im Artikelpreis (`Preise`). + +use async_trait::async_trait; +use tiberius::{Client, Config}; +use tokio::net::TcpStream; +use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::{ErpDeliveryWriteback, ErpFinishDeliveryCommand}; + +use super::mssql_delivery_source::MssqlErpConfig; + +/// Hardcodierte Gutschrift-Stammdaten (TODO: später konfigurierbar). +const GUTSCHRIFT_ARTICLE_NUMBER: &str = "GUTSCHRIFT10"; +const GUTSCHRIFT_KONTO: &str = "8726"; +const GUTSCHRIFT_STEUERSCHLUESSEL: &str = "M19"; +const GUTSCHRIFT_STEUERSATZ: i32 = 19; +/// Brutto-Faktor (1 + Steuersatz/100) — zum Ableiten des Netto-Einzelpreises +/// aus dem Brutto-Gutschriftbetrag. +const GUTSCHRIFT_GROSS_FACTOR: f64 = 1.0 + (GUTSCHRIFT_STEUERSATZ as f64) / 100.0; +/// Belegzeilentyp (additive Bitmaske) der Gutschrift-Position: +/// Artikel (1) + Andruck (32768) + keine Provision (262144) = 294913. +/// Ermittelt aus einer manuell in ERPframe angelegten Gutschrift (goldene +/// Referenz) — exakt der Typ, den native Gutschrift-Zeilen tragen. +const GUTSCHRIFT_BELEGZEILENTYP: i32 = 294_913; + +/// PG-Zahlungsmethode-Code → ERP-`Zahlungsbedingung`-Code. Spiegelt +/// `map_payment_code` im Lese-Adapter (Rückrichtung). TODO: später aus +/// Stammdaten/Config statt hardcoded. +fn erp_zahlbed_code(pg_code: &str) -> Option<&'static str> { + match pg_code.trim().to_lowercase().as_str() { + "cash" => Some("D16"), + "ec_card" => Some("D53"), + "invoice" => Some("D10"), + _ => None, + } +} + +pub struct MssqlErpDeliveryWriteback { + config: MssqlErpConfig, +} + +impl MssqlErpDeliveryWriteback { + pub fn new(config: MssqlErpConfig) -> Self { + Self { config } + } + + fn tiberius_config(&self) -> Config { + let mut cfg = Config::new(); + cfg.host(&self.config.host); + cfg.port(self.config.port); + cfg.database(&self.config.database); + cfg.authentication(tiberius::AuthMethod::sql_server( + &self.config.user, + &self.config.password, + )); + if self.config.trust_cert { + cfg.trust_cert(); + } + cfg + } +} + +fn repo(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +type TiberiusClient = Client>; + +impl MssqlErpDeliveryWriteback { + /// Resolved die `Belegkopf.row_id` über den Beleg-Natural-Key. + async fn resolve_belegkopf( + client: &mut TiberiusClient, + belegart_id: i64, + belegnummer: &str, + ) -> Result { + let rows = client + .query( + r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rowId + FROM Belegkopf + WHERE BelegartId = @P1 AND LTRIM(RTRIM(Belegnummer)) = @P2"#, + &[&belegart_id, &belegnummer], + ) + .await + .map_err(repo)? + .into_first_result() + .await + .map_err(repo)?; + + let row = rows.first().ok_or_else(|| { + ApplicationError::Repository(format!( + "Belegkopf nicht gefunden (BelegartId={belegart_id}, Belegnummer='{belegnummer}')" + )) + })?; + row.get::("rowId").ok_or_else(|| { + ApplicationError::Repository("Belegkopf.row_id ist NULL".to_string()) + }) + } + + /// Setzt die Menge einer Belegzeile (absolut, idempotent). + async fn set_line_menge( + client: &mut TiberiusClient, + bk_row_id: i64, + belegzeilen_nr: i32, + menge: i32, + ) -> Result<(), ApplicationError> { + client + .execute( + r#"UPDATE Belegzeilen SET Menge = @P1 + WHERE ParentID = @P2 AND BelegzeilenNr = @P3"#, + &[&menge, &bk_row_id, &belegzeilen_nr], + ) + .await + .map_err(repo)?; + Ok(()) + } + + /// Gutschrift-Upsert als EINE Belegzeile mit dem **tatsächlichen Betrag** + /// (keine 10-€-Einheiten mehr → beliebige Beträge ≤ 150 € exakt abbildbar). + /// `amount_cents` = Geld-Gutschrift in Cent (Brutto; 0 = keine). + /// + /// Die Zeile bekommt `Menge = 1` und `Einzelpreis` = -(Brutto / 1,19) + /// (negativ → senkt den Belegwert). `EinzelPreisBrutto` ist eine berechnete + /// Spalte (Netto × (1+Steuersatz/100)) → ergibt automatisch -(Brutto). + async fn upsert_gutschrift( + client: &mut TiberiusClient, + bk_row_id: i64, + amount_cents: i64, + ) -> Result<(), ApplicationError> { + // Netto-Einzelpreis aus dem Brutto-Betrag (negativ). 0 € → leere Zeile. + let menge: i32 = if amount_cents > 0 { 1 } else { 0 }; + let net: f64 = if amount_cents > 0 { + -((amount_cents as f64 / 100.0) / GUTSCHRIFT_GROSS_FACTOR) + } else { + 0.0 + }; + + // Existiert bereits eine Gutschrift-Zeile (GUTSCHRIFT10) am Beleg? + let existing = client + .query( + r#"SELECT TOP 1 CAST(bz.row_id AS BIGINT) AS rowId + FROM Belegzeilen bz + JOIN Artikel a ON a.row_id = bz.ArtikelId + WHERE bz.ParentID = @P1 AND a.Artikelnummer = @P2"#, + &[&bk_row_id, &GUTSCHRIFT_ARTICLE_NUMBER], + ) + .await + .map_err(repo)? + .into_first_result() + .await + .map_err(repo)?; + + if let Some(row) = existing.first() { + // Vorhandene Zeile: Menge UND Einzelpreis aktualisieren (Betrag + // variabel). Menge 0 ⇒ Zeile zählt im Recalc nicht mehr. + let line_row_id = row.get::("rowId").ok_or_else(|| { + ApplicationError::Repository("Gutschrift-Belegzeile.row_id ist NULL".to_string()) + })?; + client + .execute( + "UPDATE Belegzeilen SET Menge = @P1, Einzelpreis = @P2, \ + Belegzeilentyp = @P3 WHERE row_id = @P4", + &[&menge, &net, &GUTSCHRIFT_BELEGZEILENTYP, &line_row_id], + ) + .await + .map_err(repo)?; + return Ok(()); + } + + // Keine Gutschrift nötig und keine vorhandene Zeile → nichts tun. + if amount_cents <= 0 { + return Ok(()); + } + + // Neue Gutschrift-Zeile: Artikel-row_id + Name aus dem Stamm holen. + let art = client + .query( + r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rowId, + CAST(Artikelbezeichnung AS NVARCHAR(400)) AS name + FROM Artikel WHERE Artikelnummer = @P1"#, + &[&GUTSCHRIFT_ARTICLE_NUMBER], + ) + .await + .map_err(repo)? + .into_first_result() + .await + .map_err(repo)?; + let art = art.first().ok_or_else(|| { + ApplicationError::Repository(format!( + "Gutschrift-Artikel '{GUTSCHRIFT_ARTICLE_NUMBER}' nicht im ERP gefunden" + )) + })?; + let article_row_id = art + .get::("rowId") + .ok_or_else(|| ApplicationError::Repository("Artikel.row_id ist NULL".into()))?; + let article_name = art.get::<&str, _>("name").unwrap_or("Gutschrift").to_string(); + + client + .execute( + r#"INSERT INTO Belegzeilen + (ArtikelId, Artikelbezeichnung, Menge, ParentID, + Einzelpreis, Steuersatz, Steuerschluessel, + Konto, ParentBelegzeilenId, PositionNr, Belegzeilentext, + Belegzeilentyp) + VALUES (@P1, @P2, @P3, @P4, @P5, @P6, @P7, @P8, 0, 0, @P9, @P10)"#, + &[ + &article_row_id, + &article_name, + &menge, + &bk_row_id, + &net, + &GUTSCHRIFT_STEUERSATZ, + &GUTSCHRIFT_STEUERSCHLUESSEL, + &GUTSCHRIFT_KONTO, + &"Gutschrift", + &GUTSCHRIFT_BELEGZEILENTYP, + ], + ) + .await + .map_err(repo)?; + Ok(()) + } + + /// Summiert alle Belegzeilen neu und schreibt die Kopf-Summen. + async fn recalc_head( + client: &mut TiberiusClient, + bk_row_id: i64, + ) -> Result<(), ApplicationError> { + let rows = client + .query( + r#"SELECT CAST(ISNULL(Einzelpreis,0) AS FLOAT) AS net, + CAST(ISNULL(EinzelPreisBrutto,0) AS FLOAT) AS gross, + CAST(ISNULL(Menge,0) AS FLOAT) AS menge + FROM Belegzeilen WHERE ParentID = @P1"#, + &[&bk_row_id], + ) + .await + .map_err(repo)? + .into_first_result() + .await + .map_err(repo)?; + + let mut net_sum = 0.0_f64; + let mut gross_sum = 0.0_f64; + for r in &rows { + let net = r.get::("net").unwrap_or(0.0); + let gross = r.get::("gross").unwrap_or(0.0); + let menge = r.get::("menge").unwrap_or(0.0); + net_sum += net * menge; + gross_sum += gross * menge; + } + + client + .execute( + r#"UPDATE Belegkopf + SET WarenwertNetto = @P1, WarenwertBrutto = @P2, PosSummeNetto = @P3 + WHERE row_id = @P4"#, + &[&net_sum, &gross_sum, &net_sum, &bk_row_id], + ) + .await + .map_err(repo)?; + Ok(()) + } + + /// Setzt Liefer-Zeitpunkt + Status. ISO-8601 mit `T` als String + /// (impliziter SQL-Cast), exakt wie das Alt-Makro. + async fn mark_delivered( + client: &mut TiberiusClient, + bk_row_id: i64, + delivered_at_iso: &str, + ) -> Result<(), ApplicationError> { + client + .execute( + r#"UPDATE Belegkopf + SET _SV_DELIVERY_DELIVERED_AT = @P1, _SV_DELIVERY_STATE = 'geliefert' + WHERE row_id = @P2"#, + &[&delivered_at_iso, &bk_row_id], + ) + .await + .map_err(repo)?; + Ok(()) + } + + /// Setzt `Belegkopf.ZahlungsbedingungId` anhand der gewählten Zahlungs- + /// methode (Code → ERP-Zahlungsbedingung → deren `row_id`). Idempotent + /// (absolutes Setzen). Kein Mapping bzw. keine passende Zahlungsbedingung + /// im ERP → überspringen (kein Fehler), der Beleg behält seine bisherige. + async fn set_payment_condition( + client: &mut TiberiusClient, + bk_row_id: i64, + pg_code: &str, + ) -> Result<(), ApplicationError> { + let Some(erp_code) = erp_zahlbed_code(pg_code) else { + tracing::warn!( + pg_code, + "erp_writeback: kein Zahlungsbedingung-Mapping — übersprungen" + ); + return Ok(()); + }; + + let rows = client + .query( + r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rid + FROM Zahlungsbedingungen + WHERE LTRIM(RTRIM(Zahlungsbedingung)) = @P1"#, + &[&erp_code], + ) + .await + .map_err(repo)? + .into_first_result() + .await + .map_err(repo)?; + + let Some(rid) = rows.first().and_then(|r| r.get::("rid")) else { + tracing::warn!( + erp_code, + "erp_writeback: Zahlungsbedingung nicht im ERP gefunden — übersprungen" + ); + return Ok(()); + }; + + client + .execute( + "UPDATE Belegkopf SET ZahlungsbedingungId = @P1 WHERE row_id = @P2", + &[&rid, &bk_row_id], + ) + .await + .map_err(repo)?; + Ok(()) + } + + /// Führt alle Schritte aus (innerhalb der bereits geöffneten Transaktion). + async fn run( + client: &mut TiberiusClient, + cmd: &ErpFinishDeliveryCommand, + ) -> Result<(), ApplicationError> { + let bk = Self::resolve_belegkopf(client, cmd.belegart_id, &cmd.belegnummer).await?; + + for line in &cmd.lines { + Self::set_line_menge(client, bk, line.belegzeilen_nr, line.delivered_quantity).await?; + } + + Self::upsert_gutschrift(client, bk, cmd.credit_amount_cents).await?; + + Self::recalc_head(client, bk).await?; + + // Gewählte Zahlungsmethode → Zahlungsbedingung im Belegkopf. + if let Some(code) = &cmd.payment_method_code { + Self::set_payment_condition(client, bk, code).await?; + } + + let iso = cmd.delivered_at.format("%Y-%m-%dT%H:%M:%S").to_string(); + Self::mark_delivered(client, bk, &iso).await?; + + Ok(()) + } +} + +#[async_trait] +impl ErpDeliveryWriteback for MssqlErpDeliveryWriteback { + async fn finish_delivery( + &self, + cmd: ErpFinishDeliveryCommand, + ) -> Result<(), ApplicationError> { + let cfg = self.tiberius_config(); + let addr = format!("{}:{}", self.config.host, self.config.port); + + let tcp = TcpStream::connect(&addr).await.map_err(repo)?; + tcp.set_nodelay(true).ok(); + let mut client = Client::connect(cfg, tcp.compat_write()).await.map_err(repo)?; + + client + .simple_query("BEGIN TRANSACTION") + .await + .map_err(repo)?; + + match Self::run(&mut client, &cmd).await { + Ok(()) => { + client.simple_query("COMMIT").await.map_err(repo)?; + tracing::info!( + belegart = cmd.belegart_id, + belegnummer = %cmd.belegnummer, + lines = cmd.lines.len(), + credit_cents = cmd.credit_amount_cents, + "erp_writeback.committed" + ); + Ok(()) + } + Err(e) => { + // Best-effort Rollback; der ursprüngliche Fehler bleibt maßgeblich. + let _ = client.simple_query("ROLLBACK").await; + tracing::error!( + belegart = cmd.belegart_id, + belegnummer = %cmd.belegnummer, + error = %e, + "erp_writeback.rolled_back" + ); + Err(e) + } + } + } +} diff --git a/crates/infrastructure/src/gsd/dto.rs b/crates/infrastructure/src/gsd/dto.rs new file mode 100644 index 0000000..6db65e9 --- /dev/null +++ b/crates/infrastructure/src/gsd/dto.rs @@ -0,0 +1,100 @@ +//! Wire-DTOs der GSD/DOCUframe-REST-API. +//! +//! Alle Antworten sind in einen Umschlag `{ status, data }` verpackt. +//! `status.internalStatus` ist der fachliche Status: `"0"` = ok, +//! `"201"` = Session ungültig/abgelaufen (→ Re-Login). + +use serde::{Deserialize, Serialize}; + +pub const STATUS_OK: &str = "0"; +pub const STATUS_INVALID_SESSION: &str = "201"; + +#[derive(Debug, Deserialize)] +pub struct GsdStatus { + #[serde(rename = "internalStatus")] + pub internal_status: String, + #[serde(rename = "statusMessage", default)] + pub status_message: String, +} + +#[derive(Debug, Deserialize)] +pub struct GsdEnvelope { + pub status: Option, + pub data: Option, +} + +impl GsdEnvelope { + /// `true`, wenn der Server eine ungültige Session signalisiert. + pub fn is_invalid_session(&self) -> bool { + self.status + .as_ref() + .map(|s| s.internal_status == STATUS_INVALID_SESSION) + .unwrap_or(false) + } + + /// `true`, wenn der fachliche Status ok ist (oder gar kein Status kam). + pub fn is_ok(&self) -> bool { + match &self.status { + Some(s) => s.internal_status == STATUS_OK, + None => true, + } + } + + pub fn status_message(&self) -> String { + self.status + .as_ref() + .map(|s| s.status_message.clone()) + .unwrap_or_default() + } +} + +// ─── Login ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct LoginRequest<'a> { + pub user: &'a str, + /// MD5-Hash des Passworts (GSD-Vorgabe). + pub pass: &'a str, + #[serde(rename = "appNames")] + pub app_names: &'a [String], +} + +#[derive(Debug, Deserialize)] +pub struct LoginData { + #[serde(rename = "sessionId")] + pub session_id: Option, +} + +// ─── License release ──────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct ReleaseRequest<'a> { + #[serde(rename = "appNames")] + pub app_names: &'a [String], +} + +// ─── Upload (3-stufig) ──────────────────────────────────────────────────── + +/// Antwort von `GET /v1/uploadFile` — reservierter Upload-Slot. +#[derive(Debug, Deserialize)] +pub struct UploadIdData { + #[serde(rename = "uploadId")] + pub upload_id: Option, +} + +/// Antwort von `PATCH /v1/uploadFile/{uploadId}` — committetes Attachment. +#[derive(Debug, Deserialize)] +pub struct CommitData { + #[serde(rename = "~ObjectID")] + pub object_id: Option, +} + +// ─── Makro-Aufruf (`/v1/execute/`) ──────────────────────────────────── + +/// Request-Body für das Makro `_SV_assignDeliveryReport`. +#[derive(Debug, Serialize)] +pub struct AssignReportRequest<'a> { + #[serde(rename = "objectId")] + pub object_id: &'a str, + pub belegnummer: &'a str, +} diff --git a/crates/infrastructure/src/gsd/mod.rs b/crates/infrastructure/src/gsd/mod.rs new file mode 100644 index 0000000..493d2e2 --- /dev/null +++ b/crates/infrastructure/src/gsd/mod.rs @@ -0,0 +1,6 @@ +//! DOCUframe/GSD-Adapter — Datei-Upload gegen die GSD-REST-API. + +mod dto; +pub mod service; + +pub use service::{GsdConfig, GsdService}; diff --git a/crates/infrastructure/src/gsd/service.rs b/crates/infrastructure/src/gsd/service.rs new file mode 100644 index 0000000..fc39be3 --- /dev/null +++ b/crates/infrastructure/src/gsd/service.rs @@ -0,0 +1,539 @@ +//! DOCUframe-Anbindung (GSD-REST-API) — Datei-Upload + Session-Verwaltung. +//! +//! Recycelt aus dem alten Proxy-Backend, aber als **typisierter Adapter** +//! statt transparentem Proxy. Eine einzige technische Service-Account-Session +//! wird wiederverwendet und **durabel in Postgres** (`app_state`) gehalten — +//! der GSD-Server blockt pro Session einen Lizenz-Seat bis Ablauf/Release, +//! darum darf die Id einen Backend-Neustart nicht verlieren (sonst verwaiste +//! Blocks → Lizenz-Lockout). +//! +//! Session-Lebenszyklus: +//! * Erstzugriff: Id aus `app_state` lesen, sonst einloggen. +//! * `internalStatus == "201"` (Session tot): Single-Flight-Re-Login, +//! neue Id überschreiben. +//! * Graceful Shutdown: [`GsdService::release_license`] gibt den Seat frei. + +use async_trait::async_trait; +use sqlx::PgPool; +use tokio::sync::{Mutex, RwLock}; +use tracing::{error, info, warn}; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::{AttachmentStorage, DocuframeReportGateway, PreviewImage}; + +use super::dto::*; + +const SESSION_KEY: &str = "gsd_session_id"; + +/// Interner Fehlertyp, der „Session ungültig" von echten Fehlern trennt, +/// damit der Aufrufer gezielt einen Re-Login auslösen kann. +enum GsdError { + InvalidSession, + Other(ApplicationError), +} + +impl From for GsdError { + fn from(e: ApplicationError) -> Self { + GsdError::Other(e) + } +} + +pub struct GsdConfig { + pub rest_url: String, + pub app_key: String, + pub user: String, + pub password_md5: String, + pub app_names: Vec, +} + +pub struct GsdService { + pool: PgPool, + http: reqwest::Client, + config: GsdConfig, + /// Heißer Cache der Session-Id; Quelle der Wahrheit ist `app_state`. + cache: RwLock>, + /// Single-Flight-Guard: nie zwei parallele Logins (= zwei Lizenz-Seats). + login_lock: Mutex<()>, +} + +impl GsdService { + pub fn new(pool: PgPool, config: GsdConfig) -> Self { + Self { + pool, + http: reqwest::Client::new(), + config, + cache: RwLock::new(None), + login_lock: Mutex::new(()), + } + } + + fn ext(e: E) -> ApplicationError { + ApplicationError::External(e.to_string()) + } + + fn repo(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) + } + + // ─── Session-Store (Postgres-KV) ───────────────────────────────────── + + async fn load_session_from_db(&self) -> Result, ApplicationError> { + sqlx::query_scalar("SELECT value FROM app_state WHERE key = $1") + .bind(SESSION_KEY) + .fetch_optional(&self.pool) + .await + .map_err(Self::repo) + } + + async fn store_session_in_db(&self, session: &str) -> Result<(), ApplicationError> { + sqlx::query( + r#" + INSERT INTO app_state (key, value, updated_at) + VALUES ($1, $2, now()) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, updated_at = now() + "#, + ) + .bind(SESSION_KEY) + .bind(session) + .execute(&self.pool) + .await + .map_err(Self::repo)?; + Ok(()) + } + + async fn clear_session_in_db(&self) -> Result<(), ApplicationError> { + sqlx::query("DELETE FROM app_state WHERE key = $1") + .bind(SESSION_KEY) + .execute(&self.pool) + .await + .map_err(Self::repo)?; + Ok(()) + } + + // ─── Session-Beschaffung ───────────────────────────────────────────── + + /// Liefert die aktuelle Session-Id — aus dem Cache, sonst aus der DB, + /// sonst per Login (Single-Flight). + async fn current_session(&self) -> Result { + if let Some(s) = self.cache.read().await.clone() { + return Ok(s); + } + if let Some(s) = self.load_session_from_db().await? { + *self.cache.write().await = Some(s.clone()); + return Ok(s); + } + // Keine Session bekannt → einloggen (unter Lock, mit Double-Check). + let _guard = self.login_lock.lock().await; + if let Some(s) = self.cache.read().await.clone() { + return Ok(s); + } + self.do_login().await + } + + /// Re-Login nach einer als ungültig erkannten Session. Prüft unter Lock, + /// ob inzwischen schon ein anderer Task eine neue Session geholt hat + /// (dann wird die genutzt — kein zweiter Seat). + async fn relogin(&self, stale: &str) -> Result { + let _guard = self.login_lock.lock().await; + if let Some(current) = self.cache.read().await.clone() { + if current != stale { + return Ok(current); + } + } + self.do_login().await + } + + /// Führt den eigentlichen Login aus (Aufrufer hält `login_lock`). + async fn do_login(&self) -> Result { + info!("GSD: Login gegen {}", self.config.rest_url); + let body = LoginRequest { + user: &self.config.user, + pass: &self.config.password_md5, + app_names: &self.config.app_names, + }; + let resp = self + .http + .post(format!("{}/v1/login", self.config.rest_url)) + .header("appKey", &self.config.app_key) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(Self::ext)?; + + let env: GsdEnvelope = resp.json().await.map_err(Self::ext)?; + if !env.is_ok() { + error!("GSD: Login fehlgeschlagen: {}", env.status_message()); + return Err(ApplicationError::External(format!( + "GSD-Login fehlgeschlagen: {}", + env.status_message() + ))); + } + let session = env + .data + .and_then(|d| d.session_id) + .ok_or_else(|| ApplicationError::External("GSD-Login ohne sessionId".into()))?; + + *self.cache.write().await = Some(session.clone()); + self.store_session_in_db(&session).await?; + info!("GSD: Neue Session etabliert"); + Ok(session) + } + + // ─── Lizenz freigeben (Graceful Shutdown) ──────────────────────────── + + /// Gibt den Lizenz-Seat der aktuellen Session via `/v1/license/release` + /// frei und vergisst die Session (Cache + DB). Best-Effort: Fehler + /// werden geloggt, aber nicht propagiert — beim Shutdown soll nichts + /// hängenbleiben. + pub async fn release_license(&self) { + let session = match self.cache.read().await.clone() { + Some(s) => Some(s), + None => self.load_session_from_db().await.ok().flatten(), + }; + let Some(session) = session else { + return; + }; + + let body = ReleaseRequest { + app_names: &self.config.app_names, + }; + let result = self + .http + .post(format!("{}/v1/license/release", self.config.rest_url)) + .header("appKey", &self.config.app_key) + .header("sessionId", &session) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await; + + match result { + Ok(_) => info!("GSD: Lizenz freigegeben"), + Err(e) => warn!("GSD: Lizenz-Freigabe fehlgeschlagen (ignoriert): {}", e), + } + + *self.cache.write().await = None; + if let Err(e) = self.clear_session_in_db().await { + warn!("GSD: Session konnte nicht aus DB gelöscht werden: {}", e); + } + } + + // ─── Datei-Upload (3-stufig) ───────────────────────────────────────── + + /// Ein vollständiger Upload-Versuch mit einer festen Session. Bei + /// `201` in irgendeinem Schritt → `GsdError::InvalidSession`, damit der + /// Aufrufer den ganzen Vorgang nach Re-Login wiederholen kann (die + /// `uploadId` ist an die Session gebunden). + async fn upload_once( + &self, + session: &str, + filename: &str, + mime: &str, + bytes: &[u8], + ) -> Result { + // Schritt 1: Slot anfordern. + let url = format!("{}/v1/uploadFile", self.config.rest_url); + let resp = self + .http + .get(&url) + .header("appkey", &self.config.app_key) + .header("sessionId", session) + .send() + .await + .map_err(|e| GsdError::Other(Self::ext(e)))?; + let env: GsdEnvelope = + resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?; + if env.is_invalid_session() { + return Err(GsdError::InvalidSession); + } + let upload_id = env + .data + .and_then(|d| d.upload_id) + .ok_or_else(|| GsdError::Other(ApplicationError::External("GSD: keine uploadId".into())))?; + + // Schritt 2: Bytes als multipart/form-data senden (Feld `file`). + let part = reqwest::multipart::Part::bytes(bytes.to_vec()) + .file_name(filename.to_owned()) + .mime_str(mime) + .map_err(|e| GsdError::Other(Self::ext(e)))?; + let form = reqwest::multipart::Form::new().part("file", part); + let upload_url = format!("{}/v1/uploadFile/{}", self.config.rest_url, upload_id); + let resp = self + .http + .post(&upload_url) + .header("appkey", &self.config.app_key) + .header("sessionId", session) + .multipart(form) + .send() + .await + .map_err(|e| GsdError::Other(Self::ext(e)))?; + let env: GsdEnvelope = + resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?; + if env.is_invalid_session() { + return Err(GsdError::InvalidSession); + } + if !env.is_ok() { + return Err(GsdError::Other(ApplicationError::External(format!( + "GSD-Upload fehlgeschlagen: {}", + env.status_message() + )))); + } + + // Schritt 3: Commit → liefert ~ObjectID. + let resp = self + .http + .patch(&upload_url) + .header("appkey", &self.config.app_key) + .header("sessionId", session) + .send() + .await + .map_err(|e| GsdError::Other(Self::ext(e)))?; + let env: GsdEnvelope = + resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?; + if env.is_invalid_session() { + return Err(GsdError::InvalidSession); + } + env.data + .and_then(|d| d.object_id) + .ok_or_else(|| GsdError::Other(ApplicationError::External("GSD: kein ~ObjectID".into()))) + } + + /// Ein Vorschau-Download-Versuch mit fester Session. Liefert bei einem + /// Bild-Content-Type die Bytes; bei JSON-Antwort (typisch für eine + /// ungültige Session) → `GsdError::InvalidSession` bzw. ein Fehler. + async fn download_preview_once( + &self, + session: &str, + object_id: &str, + parameters: &str, + page: &str, + ) -> Result { + let url = format!( + "{}/v1/preview/{}/{}/{}", + self.config.rest_url, parameters, object_id, page + ); + let resp = self + .http + .get(&url) + .header("appkey", &self.config.app_key) + .header("sessionId", session) + .send() + .await + .map_err(|e| GsdError::Other(Self::ext(e)))?; + + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + + if content_type.starts_with("image/") { + let bytes = resp + .bytes() + .await + .map_err(|e| GsdError::Other(Self::ext(e)))? + .to_vec(); + return Ok(PreviewImage { + bytes, + content_type, + }); + } + + // Kein Bild → vermutlich JSON-Fehlerumschlag (z. B. Session ungültig). + let env: GsdEnvelope = + resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?; + if env.is_invalid_session() { + return Err(GsdError::InvalidSession); + } + Err(GsdError::Other(ApplicationError::External(format!( + "GSD-Preview fehlgeschlagen: {}", + env.status_message() + )))) + } + + // ─── Makro-Aufruf (`POST /v1/execute/`) ──────────────────────── + + /// Ein Makro-Aufruf mit fester Session. Liefert die rohe JSON-Antwort + /// (Envelope ODER flach — der Aufrufer extrahiert die Felder). Bei + /// `internalStatus == "201"` → `GsdError::InvalidSession` (Re-Login). + async fn execute_macro_once( + &self, + session: &str, + macro_name: &str, + body: &serde_json::Value, + ) -> Result { + let url = format!("{}/v1/execute/{}", self.config.rest_url, macro_name); + let resp = self + .http + .post(&url) + .header("appkey", &self.config.app_key) + .header("sessionId", session) + .header("Content-Type", "application/json") + .json(body) + .send() + .await + .map_err(|e| GsdError::Other(Self::ext(e)))?; + let val: serde_json::Value = + resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?; + let invalid = val + .get("status") + .and_then(|s| s.get("internalStatus")) + .and_then(|v| v.as_str()) + == Some(super::dto::STATUS_INVALID_SESSION); + if invalid { + return Err(GsdError::InvalidSession); + } + Ok(val) + } + + /// Ruft ein DOCUframe-Makro auf (mit Session + Re-Login-Retry). Liefert die + /// rohe JSON-Antwort. + pub async fn execute_macro( + &self, + macro_name: &str, + body: &serde_json::Value, + ) -> Result { + let session = self.current_session().await?; + match self.execute_macro_once(&session, macro_name, body).await { + Ok(v) => Ok(v), + Err(GsdError::InvalidSession) => { + info!("GSD: Session ungültig, Re-Login und erneuter Makro-Aufruf"); + let fresh = self.relogin(&session).await?; + self.execute_macro_once(&fresh, macro_name, body) + .await + .map_err(|e| match e { + GsdError::InvalidSession => ApplicationError::External( + "GSD: Session nach Re-Login weiterhin ungültig".into(), + ), + GsdError::Other(inner) => inner, + }) + } + Err(GsdError::Other(inner)) => Err(inner), + } + } +} + +#[async_trait] +impl AttachmentStorage for GsdService { + async fn upload( + &self, + // DOCUframe legt keine Ordner nach Belegnummer an — der `folder`- + // Schlüssel wird hier (vorerst) ignoriert; später ggf. als Kategorie. + _folder: &str, + filename: &str, + mime: &str, + bytes: Vec, + ) -> Result { + let session = self.current_session().await?; + match self.upload_once(&session, filename, mime, &bytes).await { + Ok(oid) => Ok(oid), + Err(GsdError::InvalidSession) => { + info!("GSD: Session ungültig, Re-Login und erneuter Upload"); + let fresh = self.relogin(&session).await?; + self.upload_once(&fresh, filename, mime, &bytes) + .await + .map_err(|e| match e { + GsdError::InvalidSession => ApplicationError::External( + "GSD: Session nach Re-Login weiterhin ungültig".into(), + ), + GsdError::Other(inner) => inner, + }) + } + Err(GsdError::Other(inner)) => Err(inner), + } + } + + async fn download_preview( + &self, + object_id: &str, + parameters: &str, + page: &str, + ) -> Result { + let session = self.current_session().await?; + match self + .download_preview_once(&session, object_id, parameters, page) + .await + { + Ok(img) => Ok(img), + Err(GsdError::InvalidSession) => { + info!("GSD: Session ungültig, Re-Login und erneuter Preview-Download"); + let fresh = self.relogin(&session).await?; + self.download_preview_once(&fresh, object_id, parameters, page) + .await + .map_err(|e| match e { + GsdError::InvalidSession => ApplicationError::External( + "GSD: Session nach Re-Login weiterhin ungültig".into(), + ), + GsdError::Other(inner) => inner, + }) + } + Err(GsdError::Other(inner)) => Err(inner), + } + } + + /// No-Op: In DOCUframe löschen wir nichts (der Report bleibt dort liegen). + async fn delete(&self, _reference: &str) -> Result<(), ApplicationError> { + Ok(()) + } +} + +#[async_trait] +impl DocuframeReportGateway for GsdService { + async fn upload_report_pdf( + &self, + belegnummer: &str, + pdf: Vec, + ) -> Result { + // Report wie ein Bild hochladen — der 3-stufige Upload liefert die + // ~ObjectID. `folder` ist für DOCUframe irrelevant. + let filename = format!("Lieferbericht-{belegnummer}.pdf"); + AttachmentStorage::upload(self, belegnummer, &filename, "application/pdf", pdf).await + } + + async fn assign_report( + &self, + object_id: &str, + belegnummer: &str, + ) -> Result<(), ApplicationError> { + let body = serde_json::to_value(super::dto::AssignReportRequest { + object_id, + belegnummer, + }) + .map_err(Self::ext)?; + let val = self + .execute_macro("_SV_assignDeliveryReport", &body) + .await?; + // Das Makro liefert `{succeeded, message}` per RETURN(STRING). Wie + // `/v1/execute` das verpackt, ist nicht garantiert — daher robust gegen + // alle drei Formen: flach, Envelope mit `data`-Objekt, oder `data` als + // (escaptem) JSON-String. Wir prüfen die Kandidaten der Reihe nach. + let mut candidates: Vec = vec![val.clone()]; + if let Some(d) = val.get("data") { + if d.is_object() { + candidates.push(d.clone()); + } else if let Some(s) = d.as_str() { + if let Ok(parsed) = serde_json::from_str::(s) { + candidates.push(parsed); + } + } + } + let succeeded = candidates + .iter() + .find_map(|c| c.get("succeeded").and_then(|v| v.as_bool())) + .unwrap_or(false); + if succeeded { + Ok(()) + } else { + let msg = candidates + .iter() + .find_map(|c| c.get("message").and_then(|v| v.as_str())) + .unwrap_or("unbekannte/keine Antwort (Makro evtl. nicht vorhanden)"); + Err(ApplicationError::External(format!( + "DOCUframe-Makro _SV_assignDeliveryReport succeeded=false: {msg}" + ))) + } + } +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 10727c9..8b6ea6a 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -10,4 +10,8 @@ //! passenden Application-Ports. pub mod auth; +pub mod erp; +pub mod gsd; pub mod persistence; +pub mod report; +pub mod storage; diff --git a/crates/infrastructure/src/persistence/attachment_repository.rs b/crates/infrastructure/src/persistence/attachment_repository.rs new file mode 100644 index 0000000..b2dda55 --- /dev/null +++ b/crates/infrastructure/src/persistence/attachment_repository.rs @@ -0,0 +1,107 @@ +//! Postgres-Implementierung des `AttachmentRepository`-Ports. + +use async_trait::async_trait; +use sqlx::PgPool; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::{ + AttachmentLocalRef, AttachmentRef, AttachmentRepository, NewAttachment, +}; + +pub struct PgAttachmentRepository { + pool: PgPool, +} + +impl PgAttachmentRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +#[async_trait] +impl AttachmentRepository for PgAttachmentRepository { + async fn create(&self, attachment: NewAttachment) -> Result { + let id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO attachments ( + docuframe_object_id, mime_type, size_bytes, filename, + checksum_sha256, width, height, uploaded_by, delivery_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id + "#, + ) + .bind(attachment.docuframe_object_id) + .bind(attachment.mime_type) + .bind(attachment.size_bytes) + .bind(attachment.filename) + .bind(attachment.checksum_sha256) + .bind(attachment.width) + .bind(attachment.height) + .bind(attachment.uploaded_by) + .bind(attachment.delivery_id) + .fetch_one(&self.pool) + .await + .map_err(db)?; + Ok(id) + } + + async fn get(&self, id: Uuid) -> Result, ApplicationError> { + let row: Option<(String, String)> = sqlx::query_as( + "SELECT docuframe_object_id, mime_type FROM attachments WHERE id = $1", + ) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + Ok(row.map(|(docuframe_object_id, mime_type)| AttachmentRef { + docuframe_object_id, + mime_type, + })) + } + + async fn delivery_belegnummer( + &self, + delivery_id: Uuid, + ) -> Result, ApplicationError> { + let belegnummer: Option = sqlx::query_scalar( + "SELECT erp_belegnummer FROM deliveries WHERE id = $1", + ) + .bind(delivery_id) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + Ok(belegnummer) + } + + async fn list_active_for_delivery( + &self, + delivery_id: Uuid, + ) -> Result, ApplicationError> { + let rows: Vec<(Uuid, String)> = sqlx::query_as( + "SELECT id, docuframe_object_id FROM attachments \ + WHERE delivery_id = $1 AND deleted_at IS NULL", + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + Ok(rows + .into_iter() + .map(|(id, reference)| AttachmentLocalRef { id, reference }) + .collect()) + } + + async fn mark_deleted(&self, id: Uuid) -> Result<(), ApplicationError> { + sqlx::query("UPDATE attachments SET deleted_at = now() WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await + .map_err(db)?; + Ok(()) + } +} diff --git a/crates/infrastructure/src/persistence/delivery_completion_repository.rs b/crates/infrastructure/src/persistence/delivery_completion_repository.rs new file mode 100644 index 0000000..48f4510 --- /dev/null +++ b/crates/infrastructure/src/persistence/delivery_completion_repository.rs @@ -0,0 +1,515 @@ +//! Postgres-Implementierung des `DeliveryCompletionRepository`-Ports. +//! +//! Eine Transaktion, ein Abschluss. Ablauf: +//! 1. `SELECT … FOR UPDATE` auf die Lieferung (Lock + aktueller State). +//! 2. Idempotenz: schon `completed` mit Abschluss-Zeile → Erfolg zurück. +//! 3. Gates: `active`, alle scanbaren Positionen fertig, Notizen bestätigt. +//! 4. `INSERT INTO delivery_completions` + `UPDATE deliveries SET state`. +//! 5. Frische `Delivery` bauen. + +use async_trait::async_trait; +use chrono::NaiveDate; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::{ + CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine, +}; +use holzleitner_domain::{Address, Delivery, DeliveryState}; + +pub struct PgDeliveryCompletionRepository { + pool: PgPool, +} + +impl PgDeliveryCompletionRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[derive(sqlx::FromRow)] +struct DeliveryRow { + id: Uuid, + tour_id: Uuid, + erp_belegart_id: i64, + erp_belegnummer: String, + customer_id: Uuid, + snap_street: String, + snap_house_number: String, + snap_postal_code: String, + snap_city: String, + snap_country: String, + assigned_car_id: Option, + desired_time: Option, + special_agreements: Option, + state: String, + prepaid_amount: f64, + payment_method_id: Uuid, +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +async fn lock_delivery( + tx: &mut Transaction<'_, Postgres>, + delivery_id: Uuid, +) -> Result, ApplicationError> { + sqlx::query_as::<_, DeliveryRow>( + r#" + SELECT + id, tour_id, erp_belegart_id, erp_belegnummer, customer_id, + snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, + assigned_car_id, desired_time, special_agreements, + state, prepaid_amount, payment_method_id + FROM deliveries + WHERE id = $1 + FOR UPDATE + "#, + ) + .bind(delivery_id) + .fetch_optional(&mut **tx) + .await + .map_err(db) +} + +async fn load_contacts( + tx: &mut Transaction<'_, Postgres>, + delivery_id: Uuid, +) -> Result, ApplicationError> { + let rows: Vec<(Uuid,)> = sqlx::query_as( + "SELECT customer_contact_id FROM delivery_contact_persons WHERE delivery_id = $1", + ) + .bind(delivery_id) + .fetch_all(&mut **tx) + .await + .map_err(db)?; + Ok(rows.into_iter().map(|(id,)| id).collect()) +} + +fn build_delivery(row: DeliveryRow, state: DeliveryState, state_reason: Option, contacts: Vec) -> Delivery { + Delivery { + id: row.id, + tour_id: row.tour_id, + erp_belegart_id: row.erp_belegart_id, + erp_belegnummer: row.erp_belegnummer, + customer_id: row.customer_id, + delivery_address_snapshot: Address { + street: row.snap_street, + house_number: row.snap_house_number, + postal_code: row.snap_postal_code, + city: row.snap_city, + country: row.snap_country, + }, + assigned_car_id: row.assigned_car_id, + contact_person_ids: contacts, + desired_time: row.desired_time, + special_agreements: row.special_agreements, + state, + state_reason, + prepaid_amount: row.prepaid_amount, + payment_method_id: row.payment_method_id, + } +} + +#[async_trait] +impl DeliveryCompletionRepository for PgDeliveryCompletionRepository { + async fn complete( + &self, + input: CompleteDeliveryInput, + ) -> Result { + let delivery_id = input.delivery_id; + let mut tx = self.pool.begin().await.map_err(db)?; + + let Some(mut row) = lock_delivery(&mut tx, delivery_id).await? else { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::NotFound); + }; + + // Idempotenz: bereits abgeschlossen + Abschluss-Zeile vorhanden → + // unveränderten Erfolg liefern (Netz-Retry nach erfolgreichem Commit). + if row.state == "completed" { + let exists: Option = sqlx::query_scalar( + "SELECT delivery_id FROM delivery_completions WHERE delivery_id = $1", + ) + .bind(delivery_id) + .fetch_optional(&mut *tx) + .await + .map_err(db)?; + if exists.is_some() { + let contacts = load_contacts(&mut tx, delivery_id).await?; + tx.commit().await.map_err(db)?; + return Ok(build_delivery(row, DeliveryState::Completed, None, contacts)); + } + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::Validation( + "delivery already completed".into(), + )); + } + + if row.state != "active" { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::Validation( + "delivery is not active; cannot complete".into(), + )); + } + + // Empfangsbestätigung ist immer Pflicht (Doppel-Guard zum Use Case). + if !input.receipt_confirmed { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::Validation( + "receipt must be confirmed before completion".into(), + )); + } + + // Gate 1: alle scanbaren, nicht entfernten Positionen müssen fertig + // sein (`done`). Entfernte (`removed`) zählen nicht. + let open_scannables: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM delivery_items di + JOIN articles a ON a.id = di.article_id + WHERE di.delivery_id = $1 + AND a.scannable = true + AND di.scan_status NOT IN ('done', 'removed') + "#, + ) + .bind(delivery_id) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + if open_scannables > 0 { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::Validation(format!( + "{open_scannables} scannable item(s) not yet done; cannot complete" + ))); + } + + // Gate 2: existieren Notizen, muss der Kunde sie bestätigt haben. + let note_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM delivery_notes WHERE delivery_id = $1") + .bind(delivery_id) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + if note_count > 0 && !input.notes_acknowledged { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::Validation( + "notes must be acknowledged before completion".into(), + )); + } + + // Gate 3: Zahlungsmethode-Override (falls gesetzt) muss existieren UND + // aktiv sein. `None` lässt die am Beleg hinterlegte Methode unangetastet. + let effective_payment_method_id = match input.payment_method_id { + Some(pm_id) => { + let active: Option = + sqlx::query_scalar("SELECT active FROM payment_methods WHERE id = $1") + .bind(pm_id) + .fetch_optional(&mut *tx) + .await + .map_err(db)?; + match active { + None => { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::Validation( + "unknown payment method".into(), + )); + } + Some(false) => { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::Validation( + "payment method is not active".into(), + )); + } + Some(true) => pm_id, + } + } + None => row.payment_method_id, + }; + + // Gate 4: Inkasso-Bestätigung. Besteht beim Abschluss ein offener + // Betrag (> 0) UND ist die Methode ein Vor-Ort-Inkasso (Bar/EC), muss + // der Fahrer bestätigt haben, dass kassiert wurde. „Auf Rechnung" + // (oder offen == 0) ⇒ kein Inkasso, keine Pflicht. + // + // Offener Betrag = Σ unit_price·(required − credited) − Anzahlung − + // Gutschrift — exakt dieselbe Formel wie App-Übersicht & PDF-Report. + let warenwert: f64 = sqlx::query_scalar( + r#" + SELECT COALESCE( + SUM(unit_price * GREATEST(required_quantity - credited_quantity, 0)), + 0 + )::float8 + FROM delivery_items + WHERE delivery_id = $1 + "#, + ) + .bind(delivery_id) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + + // Aktuelle Geld-Gutschrift: jüngstes Audit-Event ('set' → Betrag, sonst 0). + let credit_cents: i64 = sqlx::query_scalar( + r#" + SELECT COALESCE(( + SELECT CASE WHEN action = 'set' THEN amount_cents ELSE 0 END + FROM delivery_credit_audit + WHERE delivery_id = $1 + ORDER BY recorded_at DESC + LIMIT 1 + ), 0) + "#, + ) + .bind(delivery_id) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + + let method_code: String = + sqlx::query_scalar("SELECT code FROM payment_methods WHERE id = $1") + .bind(effective_payment_method_id) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + + let open_euros = + (warenwert - row.prepaid_amount - (credit_cents as f64) / 100.0).max(0.0); + let open_cents = (open_euros * 100.0).round() as i64; + let requires_collection = + open_cents > 0 && (method_code == "cash" || method_code == "ec_card"); + if requires_collection && !input.payment_collected { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::Validation( + "offener Betrag nicht als kassiert bestätigt; Abschluss nicht möglich".into(), + )); + } + // Snapshot des kassierten Betrags nur, wenn tatsächlich Inkasso anfiel. + let collected_amount_cents: Option = + if requires_collection { Some(open_cents) } else { None }; + + sqlx::query( + r#" + INSERT INTO delivery_completions ( + delivery_id, customer_signature_path, driver_signature_path, + receipt_confirmed, notes_acknowledged, acknowledged_note_ids, + completed_by_personalnummer, completed_by_car_id, + payment_collected, collected_amount_cents + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "#, + ) + .bind(delivery_id) + .bind(&input.customer_signature_path) + .bind(&input.driver_signature_path) + .bind(input.receipt_confirmed) + .bind(input.notes_acknowledged) + .bind(&input.acknowledged_note_ids) + .bind(input.completed_by_personalnummer) + .bind(input.completed_by_car_id) + .bind(requires_collection && input.payment_collected) + .bind(collected_amount_cents) + .execute(&mut *tx) + .await + .map_err(db)?; + + sqlx::query( + "UPDATE deliveries SET state = 'completed', state_reason = NULL, payment_method_id = $2 WHERE id = $1", + ) + .bind(delivery_id) + .bind(effective_payment_method_id) + .execute(&mut *tx) + .await + .map_err(db)?; + + let contacts = load_contacts(&mut tx, delivery_id).await?; + tx.commit().await.map_err(db)?; + + // Rückgabe spiegelt den (ggf. überschriebenen) Stand. + row.payment_method_id = effective_payment_method_id; + Ok(build_delivery(row, DeliveryState::Completed, None, contacts)) + } + + async fn load_erp_writeback( + &self, + delivery_id: Uuid, + ) -> Result { + // Beleg-Key + Abschluss-Zeitpunkt in einem JOIN. INNER JOIN auf + // delivery_completions ⇒ ohne Abschluss-Zeile keine Zeile (NotFound). + let head: Option<(i64, String, chrono::DateTime)> = sqlx::query_as( + r#" + SELECT d.erp_belegart_id, d.erp_belegnummer, c.completed_at + FROM deliveries d + JOIN delivery_completions c ON c.delivery_id = d.id + WHERE d.id = $1 + "#, + ) + .bind(delivery_id) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + + let Some((belegart_id, belegnummer, completed_at)) = head else { + return Err(ApplicationError::NotFound); + }; + + // Ausgelieferte Menge je Belegzeile = required − credited. + // NUR Oberartikel/Normalzeilen (komponenten_artikel_nr IS NULL) — sie + // entsprechen 1:1 einer ERP-Belegzeile. Stücklisten-Komponenten teilen + // sich die belegzeilen_nr des Oberartikels und haben KEINE eigene + // ERP-Belegzeile; sie würden sonst mehrfache, widersprüchliche + // Mengen-Updates auf dieselbe Zeile auslösen. + let line_rows: Vec<(i32, i32)> = sqlx::query_as( + r#" + SELECT belegzeilen_nr, (required_quantity - credited_quantity)::int + FROM delivery_items + WHERE delivery_id = $1 + AND komponenten_artikel_nr IS NULL + ORDER BY belegzeilen_nr + "#, + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + let lines = line_rows + .into_iter() + .map(|(belegzeilen_nr, delivered_quantity)| ErpWritebackLine { + belegzeilen_nr, + delivered_quantity, + }) + .collect(); + + // Aktuelle Geld-Gutschrift: jüngstes Audit-Event. 'set' → Betrag, + // 'remove' (oder keine Zeile) → 0. + let credit: Option<(String, i64)> = sqlx::query_as( + r#" + SELECT action, amount_cents + FROM delivery_credit_audit + WHERE delivery_id = $1 + ORDER BY recorded_at DESC + LIMIT 1 + "#, + ) + .bind(delivery_id) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + + let credit_amount_cents = match credit { + Some((action, cents)) if action == "set" => cents, + _ => 0, + }; + + // Beim Abschluss gewählte Zahlungsmethode → Code (cash/ec_card/invoice). + let payment_method_code: Option = sqlx::query_scalar( + r#" + SELECT pm.code + FROM deliveries d + JOIN payment_methods pm ON pm.id = d.payment_method_id + WHERE d.id = $1 + "#, + ) + .bind(delivery_id) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + + Ok(ErpWritebackData { + belegart_id, + belegnummer, + // ERP erwartet lokale Zeit; completed_at ist UTC → in lokale + // Wanduhrzeit umrechnen und die TZ-Info fallenlassen. + delivered_at: completed_at.with_timezone(&chrono::Local).naive_local(), + lines, + credit_amount_cents, + payment_method_code, + }) + } + + async fn list_delivered_belegnummern( + &self, + day: Option, + ) -> Result, ApplicationError> { + // INNER JOIN auf delivery_completions ⇒ nur ausgelieferte (abgeschlossene) + // Lieferungen. `mail_sent_at IS NULL` ⇒ nur noch nicht versendete + // (server-seitiges Dedup für den Mailclient). Der optionale Tagesfilter: + // bei NULL ($1) ⇒ ALLE offenen über alle Tage; sonst der Berliner + // Kalendertag von completed_at (TIMESTAMPTZ = UTC-Instant → AT TIME ZONE + // 'Europe/Berlin' → ::date), damit ein Abschluss um 23:30 Ortszeit nicht + // fälschlich dem UTC-Folgetag zugeordnet wird. + let belegnummern: Vec = sqlx::query_scalar( + r#" + SELECT d.erp_belegnummer + FROM deliveries d + JOIN delivery_completions c ON c.delivery_id = d.id + WHERE c.mail_sent_at IS NULL + AND ( $1::date IS NULL + OR (c.completed_at AT TIME ZONE 'Europe/Berlin')::date = $1 ) + ORDER BY c.completed_at + "#, + ) + .bind(day) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + Ok(belegnummern) + } + + async fn mark_mail_sent( + &self, + belegnummern: &[String], + ) -> Result { + if belegnummern.is_empty() { + return Ok(0); + } + // Setzt mail_sent_at nur dort, wo noch NULL (idempotent — erster + // Versand-Zeitpunkt bleibt erhalten, mehrfaches Markieren ist harmlos). + // Match über die Belegnummer (was der GET-Endpoint zurückgibt). + let result = sqlx::query( + r#" + UPDATE delivery_completions c + SET mail_sent_at = now() + FROM deliveries d + WHERE c.delivery_id = d.id + AND d.erp_belegnummer = ANY($1) + AND c.mail_sent_at IS NULL + "#, + ) + .bind(belegnummern) + .execute(&self.pool) + .await + .map_err(db)?; + + Ok(result.rows_affected()) + } + + async fn unmark_mail_sent( + &self, + belegnummern: &[String], + ) -> Result { + if belegnummern.is_empty() { + return Ok(0); + } + // DEV: mail_sent_at zurück auf NULL, nur wo aktuell gesetzt. + let result = sqlx::query( + r#" + UPDATE delivery_completions c + SET mail_sent_at = NULL + FROM deliveries d + WHERE c.delivery_id = d.id + AND d.erp_belegnummer = ANY($1) + AND c.mail_sent_at IS NOT NULL + "#, + ) + .bind(belegnummern) + .execute(&self.pool) + .await + .map_err(db)?; + + Ok(result.rows_affected()) + } +} diff --git a/crates/infrastructure/src/persistence/delivery_credit_repository.rs b/crates/infrastructure/src/persistence/delivery_credit_repository.rs new file mode 100644 index 0000000..343eeb0 --- /dev/null +++ b/crates/infrastructure/src/persistence/delivery_credit_repository.rs @@ -0,0 +1,139 @@ +//! Postgres-Implementierung des `DeliveryCreditRepository`-Ports. +//! +//! Append-only: `apply_event` hängt eine Zeile ans `delivery_credit_audit` +//! und liest danach den aktuellen Stand (jüngstes Ereignis). Idempotent über +//! `client_event_id`; `set`/`remove` nur bei `active`er Lieferung. + +use async_trait::async_trait; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +use holzleitner_application::dto::CreditAction; +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::DeliveryCreditRepository; +use holzleitner_domain::DeliveryCredit; + +pub struct PgDeliveryCreditRepository { + pool: PgPool, +} + +impl PgDeliveryCreditRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +fn action_str(a: CreditAction) -> &'static str { + match a { + CreditAction::Set => "set", + CreditAction::Remove => "remove", + } +} + +/// Liest den aktuellen Gutschrift-Stand einer Lieferung = jüngstes Ereignis. +/// `set` → `Some(..)`, `remove` (oder kein Ereignis) → `None`. +async fn current_credit( + tx: &mut Transaction<'_, Postgres>, + delivery_id: Uuid, +) -> Result, ApplicationError> { + let row: Option<(String, i64, Option)> = sqlx::query_as( + r#" + SELECT action, amount_cents, reason + FROM delivery_credit_audit + WHERE delivery_id = $1 + ORDER BY recorded_at DESC, id DESC + LIMIT 1 + "#, + ) + .bind(delivery_id) + .fetch_optional(&mut **tx) + .await + .map_err(db)?; + + Ok(match row { + Some((action, amount_cents, reason)) if action == "set" => Some(DeliveryCredit { + delivery_id, + amount_cents, + reason: reason.unwrap_or_default(), + }), + _ => None, + }) +} + +#[async_trait] +impl DeliveryCreditRepository for PgDeliveryCreditRepository { + async fn apply_event( + &self, + delivery_id: Uuid, + client_event_id: Uuid, + action: CreditAction, + amount_cents: i64, + reason: Option, + author_personalnummer: i64, + author_car_id: Option, + ) -> Result, ApplicationError> { + let mut tx = self.pool.begin().await.map_err(db)?; + + // Idempotenz: ist die client_event_id schon bekannt, nichts erneut + // anwenden — nur den aktuellen Stand liefern (ohne active-Check, das + // Ereignis wurde ja bereits akzeptiert). + let already: Option = sqlx::query_scalar( + "SELECT id FROM delivery_credit_audit WHERE client_event_id = $1", + ) + .bind(client_event_id) + .fetch_optional(&mut *tx) + .await + .map_err(db)?; + + if already.is_some() { + let current = current_credit(&mut tx, delivery_id).await?; + tx.rollback().await.map_err(db)?; + return Ok(current); + } + + // Frisches Ereignis: Lieferung muss existieren und aktiv sein. + let state: Option = + sqlx::query_scalar("SELECT state FROM deliveries WHERE id = $1 FOR UPDATE") + .bind(delivery_id) + .fetch_optional(&mut *tx) + .await + .map_err(db)?; + let Some(state) = state else { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::NotFound); + }; + if state != "active" { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::Validation( + "delivery is not active; cannot change credit".into(), + )); + } + + sqlx::query( + r#" + INSERT INTO delivery_credit_audit ( + client_event_id, delivery_id, action, amount_cents, reason, + author_personalnummer, author_car_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + ) + .bind(client_event_id) + .bind(delivery_id) + .bind(action_str(action)) + .bind(amount_cents) + .bind(reason.as_deref()) + .bind(author_personalnummer) + .bind(author_car_id) + .execute(&mut *tx) + .await + .map_err(db)?; + + let current = current_credit(&mut tx, delivery_id).await?; + tx.commit().await.map_err(db)?; + Ok(current) + } +} diff --git a/crates/infrastructure/src/persistence/delivery_note_repository.rs b/crates/infrastructure/src/persistence/delivery_note_repository.rs index 4a94f42..4f845cc 100644 --- a/crates/infrastructure/src/persistence/delivery_note_repository.rs +++ b/crates/infrastructure/src/persistence/delivery_note_repository.rs @@ -28,6 +28,7 @@ fn db(e: E) -> ApplicationError { #[async_trait] impl DeliveryNoteRepository for PgDeliveryNoteRepository { + #[allow(clippy::too_many_arguments)] async fn create( &self, delivery_id: Uuid, @@ -35,6 +36,8 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository { author_car_id: Option, text: Option, image_attachment: Option, + credit_delivery_item_id: Option, + is_amount_credit_note: bool, ) -> Result { let exists: Option = sqlx::query_scalar("SELECT id FROM deliveries WHERE id = $1") @@ -49,8 +52,9 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository { let (id, created_at): (Uuid, DateTime) = sqlx::query_as( r#" INSERT INTO delivery_notes ( - delivery_id, text, image_attachment, author_personalnummer, author_car_id - ) VALUES ($1, $2, $3, $4, $5) + delivery_id, text, image_attachment, author_personalnummer, + author_car_id, credit_delivery_item_id, is_amount_credit_note + ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at "#, ) @@ -59,6 +63,8 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository { .bind(image_attachment.as_deref()) .bind(author_personalnummer) .bind(author_car_id) + .bind(credit_delivery_item_id) + .bind(is_amount_credit_note) .fetch_one(&self.pool) .await .map_err(db)?; @@ -70,7 +76,86 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository { image_attachment, author_personalnummer, author_car_id, + credit_delivery_item_id, + is_amount_credit_note, + // Frisch angelegt → Bild (falls vorhanden) liegt lokal vor. + image_attachment_deleted: false, created_at, }) } + + async fn update( + &self, + note_id: Uuid, + text: Option, + image_attachment: Option, + ) -> Result { + // RETURNING liefert die vollständige Zeile zurück — kein zweiter + // Read nötig. `fetch_optional` unterscheidet „nicht gefunden" sauber + // von DB-Fehlern. + let row: Option<( + Uuid, + Uuid, + Option, + Option, + i64, + Option, + Option, + bool, + DateTime, + )> = sqlx::query_as( + r#" + UPDATE delivery_notes + SET text = $2, image_attachment = $3 + WHERE id = $1 + RETURNING id, delivery_id, text, image_attachment, + author_personalnummer, author_car_id, + credit_delivery_item_id, is_amount_credit_note, created_at + "#, + ) + .bind(note_id) + .bind(text.as_deref()) + .bind(image_attachment.as_deref()) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + + match row { + None => Err(ApplicationError::NotFound), + Some(( + id, + delivery_id, + text, + image_attachment, + author_personalnummer, + author_car_id, + credit_delivery_item_id, + is_amount_credit_note, + created_at, + )) => Ok(DeliveryNote { + id, + delivery_id, + text, + image_attachment, + author_personalnummer, + author_car_id, + credit_delivery_item_id, + is_amount_credit_note, + image_attachment_deleted: false, + created_at, + }), + } + } + + async fn delete(&self, note_id: Uuid) -> Result<(), ApplicationError> { + let result = sqlx::query("DELETE FROM delivery_notes WHERE id = $1") + .bind(note_id) + .execute(&self.pool) + .await + .map_err(db)?; + if result.rows_affected() == 0 { + return Err(ApplicationError::NotFound); + } + Ok(()) + } } diff --git a/crates/infrastructure/src/persistence/delivery_report_job_repository.rs b/crates/infrastructure/src/persistence/delivery_report_job_repository.rs new file mode 100644 index 0000000..6f6034f --- /dev/null +++ b/crates/infrastructure/src/persistence/delivery_report_job_repository.rs @@ -0,0 +1,150 @@ +//! Postgres-Implementierung von `DeliveryReportJobRepository`. +//! +//! Spiegelt `delivery_report_jobs` — der harte Zustandsanker der +//! Report-Übertragung an DOCUframe (für Resume + Cron-Retry). + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::{DeliveryReportJobRepository, ReportJob, ReportJobStatus}; + +pub struct PgDeliveryReportJobRepository { + pool: PgPool, +} + +impl PgDeliveryReportJobRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +#[derive(sqlx::FromRow)] +struct JobRow { + delivery_id: Uuid, + belegnummer: String, + status: String, + docuframe_object_id: Option, + report_uploaded_at: Option>, + attempts: i32, + last_error: Option, +} + +impl From for ReportJob { + fn from(r: JobRow) -> Self { + ReportJob { + delivery_id: r.delivery_id, + belegnummer: r.belegnummer, + status: ReportJobStatus::parse(&r.status), + docuframe_object_id: r.docuframe_object_id, + report_uploaded_at: r.report_uploaded_at, + attempts: r.attempts, + last_error: r.last_error, + } + } +} + +const SELECT: &str = "SELECT delivery_id, belegnummer, status, docuframe_object_id, \ + report_uploaded_at, attempts, last_error FROM delivery_report_jobs"; + +#[async_trait] +impl DeliveryReportJobRepository for PgDeliveryReportJobRepository { + async fn ensure( + &self, + delivery_id: Uuid, + belegnummer: &str, + ) -> Result { + // Idempotent: vorhandenen Job NICHT zurücksetzen. DO UPDATE (no-op auf + // belegnummer) nur, damit RETURNING auch bei Konflikt die Zeile liefert. + let row: JobRow = sqlx::query_as( + r#" + INSERT INTO delivery_report_jobs (delivery_id, belegnummer) + VALUES ($1, $2) + ON CONFLICT (delivery_id) + DO UPDATE SET belegnummer = EXCLUDED.belegnummer + RETURNING delivery_id, belegnummer, status, docuframe_object_id, + report_uploaded_at, attempts, last_error + "#, + ) + .bind(delivery_id) + .bind(belegnummer) + .fetch_one(&self.pool) + .await + .map_err(db)?; + Ok(row.into()) + } + + async fn get(&self, delivery_id: Uuid) -> Result, ApplicationError> { + let row: Option = sqlx::query_as(&format!("{SELECT} WHERE delivery_id = $1")) + .bind(delivery_id) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + Ok(row.map(Into::into)) + } + + async fn list_open(&self) -> Result, ApplicationError> { + let rows: Vec = + sqlx::query_as(&format!("{SELECT} WHERE status <> 'done' ORDER BY created_at")) + .fetch_all(&self.pool) + .await + .map_err(db)?; + Ok(rows.into_iter().map(Into::into).collect()) + } + + async fn set_uploaded( + &self, + delivery_id: Uuid, + object_id: &str, + ) -> Result<(), ApplicationError> { + sqlx::query( + "UPDATE delivery_report_jobs \ + SET status = 'uploaded', docuframe_object_id = $2, updated_at = now() \ + WHERE delivery_id = $1", + ) + .bind(delivery_id) + .bind(object_id) + .execute(&self.pool) + .await + .map_err(db)?; + Ok(()) + } + + async fn mark_done(&self, delivery_id: Uuid) -> Result<(), ApplicationError> { + sqlx::query( + "UPDATE delivery_report_jobs \ + SET status = 'done', report_uploaded_at = now(), updated_at = now() \ + WHERE delivery_id = $1", + ) + .bind(delivery_id) + .execute(&self.pool) + .await + .map_err(db)?; + Ok(()) + } + + async fn record_error( + &self, + delivery_id: Uuid, + error: &str, + ) -> Result<(), ApplicationError> { + sqlx::query( + "UPDATE delivery_report_jobs \ + SET attempts = attempts + 1, last_error = $2, last_attempt_at = now(), \ + updated_at = now() \ + WHERE delivery_id = $1", + ) + .bind(delivery_id) + .bind(error) + .execute(&self.pool) + .await + .map_err(db)?; + Ok(()) + } +} diff --git a/crates/infrastructure/src/persistence/delivery_repository.rs b/crates/infrastructure/src/persistence/delivery_repository.rs index 50c01ee..f53f182 100644 --- a/crates/infrastructure/src/persistence/delivery_repository.rs +++ b/crates/infrastructure/src/persistence/delivery_repository.rs @@ -43,6 +43,8 @@ struct DeliveryRow { desired_time: Option, special_agreements: Option, state: String, + prepaid_amount: f64, + payment_method_id: Uuid, } fn db(e: E) -> ApplicationError { @@ -81,7 +83,15 @@ fn next_state( use DeliveryState as S; match (current, action) { (S::Active, A::Hold { reason }) => Ok((S::Held, Some(reason.clone()))), - (S::Held, A::Resume) => Ok((S::Active, None)), + // `Resume` führt sowohl aus `Held` als auch aus `Canceled` + // zurück auf `Active`. Die App erzwingt vor Cancel-Recovery + // einen extra Bestätigungsdialog; technisch sind beide Pfade + // identisch. Der `state_reason` wird in beiden Fällen + // gelöscht — der Audit-Trail dazu lebt aktuell nur am Reason + // selbst und geht damit verloren. Schließt sich in Phase G + // (siehe `docs/BACKEND_MIGRATION.md`): eigenes + // `delivery_audit`-Log analog zu `scan_audit`. + (S::Held | S::Canceled, A::Resume) => Ok((S::Active, None)), (S::Active | S::Held, A::Cancel { reason }) => Ok((S::Canceled, Some(reason.clone()))), (S::Active, A::Complete) => Ok((S::Completed, None)), @@ -104,7 +114,7 @@ async fn lock_delivery( id, tour_id, erp_belegart_id, erp_belegnummer, customer_id, snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, assigned_car_id, desired_time, special_agreements, - state + state, prepaid_amount, payment_method_id FROM deliveries WHERE id = $1 FOR UPDATE @@ -190,6 +200,8 @@ impl DeliveryRepository for PgDeliveryRepository { special_agreements: row.special_agreements, state: target, state_reason: new_reason, + prepaid_amount: row.prepaid_amount, + payment_method_id: row.payment_method_id, }) } @@ -245,6 +257,8 @@ impl DeliveryRepository for PgDeliveryRepository { special_agreements: row.special_agreements, state: current, state_reason, + prepaid_amount: row.prepaid_amount, + payment_method_id: row.payment_method_id, }) } } diff --git a/crates/infrastructure/src/persistence/delivery_service_repository.rs b/crates/infrastructure/src/persistence/delivery_service_repository.rs new file mode 100644 index 0000000..2d1632c --- /dev/null +++ b/crates/infrastructure/src/persistence/delivery_service_repository.rs @@ -0,0 +1,111 @@ +//! Postgres-Implementierung des `DeliveryServiceRepository`-Ports (Upsert). + +use async_trait::async_trait; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::DeliveryServiceRepository; +use holzleitner_domain::DeliveryServiceValue; + +pub struct PgDeliveryServiceRepository { + pool: PgPool, +} + +impl PgDeliveryServiceRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +/// Lieferung muss existieren und `active` sein, sonst Reject. Lock auf der +/// deliveries-Zeile innerhalb der Transaktion. +async fn assert_delivery_active( + tx: &mut Transaction<'_, Postgres>, + delivery_id: Uuid, +) -> Result<(), ApplicationError> { + let state: Option = + sqlx::query_scalar("SELECT state FROM deliveries WHERE id = $1 FOR UPDATE") + .bind(delivery_id) + .fetch_optional(&mut **tx) + .await + .map_err(db)?; + match state { + None => Err(ApplicationError::NotFound), + Some(s) if s != "active" => Err(ApplicationError::Validation( + "delivery is not active; cannot change services".into(), + )), + Some(_) => Ok(()), + } +} + +#[async_trait] +impl DeliveryServiceRepository for PgDeliveryServiceRepository { + async fn set( + &self, + delivery_id: Uuid, + service_id: Uuid, + bool_value: Option, + numeric_value: Option, + author_personalnummer: i64, + author_car_id: Option, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(db)?; + assert_delivery_active(&mut tx, delivery_id).await?; + + sqlx::query( + r#" + INSERT INTO delivery_services ( + delivery_id, service_id, bool_value, numeric_value, + author_personalnummer, author_car_id, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, now()) + ON CONFLICT (delivery_id, service_id) DO UPDATE SET + bool_value = EXCLUDED.bool_value, + numeric_value = EXCLUDED.numeric_value, + author_personalnummer = EXCLUDED.author_personalnummer, + author_car_id = EXCLUDED.author_car_id, + updated_at = now() + "#, + ) + .bind(delivery_id) + .bind(service_id) + .bind(bool_value) + .bind(numeric_value) + .bind(author_personalnummer) + .bind(author_car_id) + .execute(&mut *tx) + .await + .map_err(db)?; + + tx.commit().await.map_err(db)?; + Ok(DeliveryServiceValue { + delivery_id, + service_id, + bool_value, + numeric_value, + }) + } + + async fn delete( + &self, + delivery_id: Uuid, + service_id: Uuid, + ) -> Result<(), ApplicationError> { + let mut tx = self.pool.begin().await.map_err(db)?; + assert_delivery_active(&mut tx, delivery_id).await?; + sqlx::query( + "DELETE FROM delivery_services WHERE delivery_id = $1 AND service_id = $2", + ) + .bind(delivery_id) + .bind(service_id) + .execute(&mut *tx) + .await + .map_err(db)?; + tx.commit().await.map_err(db)?; + Ok(()) + } +} diff --git a/crates/infrastructure/src/persistence/mod.rs b/crates/infrastructure/src/persistence/mod.rs index 45660bd..8a5f39b 100644 --- a/crates/infrastructure/src/persistence/mod.rs +++ b/crates/infrastructure/src/persistence/mod.rs @@ -5,17 +5,31 @@ //! und Migrations werden ebenfalls hier verwaltet. pub mod account_repository; +pub mod attachment_repository; pub mod car_repository; +pub mod delivery_completion_repository; +pub mod delivery_credit_repository; pub mod delivery_note_repository; +pub mod delivery_report_job_repository; pub mod delivery_repository; +pub mod delivery_service_repository; +pub mod payment_method_repository; pub mod pool; pub mod scan_repository; +pub mod service_repository; pub mod tour_repository; pub use account_repository::PgAccountRepository; +pub use attachment_repository::PgAttachmentRepository; pub use car_repository::PgCarRepository; +pub use delivery_completion_repository::PgDeliveryCompletionRepository; +pub use delivery_credit_repository::PgDeliveryCreditRepository; pub use delivery_note_repository::PgDeliveryNoteRepository; +pub use delivery_report_job_repository::PgDeliveryReportJobRepository; pub use delivery_repository::PgDeliveryRepository; +pub use delivery_service_repository::PgDeliveryServiceRepository; +pub use payment_method_repository::PgPaymentMethodRepository; pub use pool::{connect_and_migrate, PoolConfig}; pub use scan_repository::PgScanRepository; +pub use service_repository::PgServiceRepository; pub use tour_repository::PgTourRepository; diff --git a/crates/infrastructure/src/persistence/payment_method_repository.rs b/crates/infrastructure/src/persistence/payment_method_repository.rs new file mode 100644 index 0000000..bd0a554 --- /dev/null +++ b/crates/infrastructure/src/persistence/payment_method_repository.rs @@ -0,0 +1,174 @@ +//! Postgres-Implementierung des `PaymentMethodRepository`-Ports. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::PaymentMethodRepository; +use holzleitner_domain::PaymentMethod; + +pub struct PgPaymentMethodRepository { + pool: PgPool, +} + +impl PgPaymentMethodRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[derive(sqlx::FromRow)] +struct PaymentMethodRow { + id: Uuid, + code: String, + name: String, + active: bool, + created_at: DateTime, +} + +impl From for PaymentMethod { + fn from(r: PaymentMethodRow) -> Self { + PaymentMethod { + id: r.id, + code: r.code, + name: r.name, + active: r.active, + created_at: r.created_at, + } + } +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +/// Postgres-SQLSTATE-Codes — wir interessieren uns für zwei: +/// +/// * `23505 unique_violation` — `code`-Duplikat beim INSERT +/// * `23503 foreign_key_violation` — Lieferungen zeigen noch auf die +/// Methode, die gerade gelöscht werden soll (RESTRICT) +fn pg_sqlstate(err: &sqlx::Error) -> Option { + if let sqlx::Error::Database(db_err) = err { + return db_err.code().map(|c| c.into_owned()); + } + None +} + +#[async_trait] +impl PaymentMethodRepository for PgPaymentMethodRepository { + async fn list( + &self, + include_inactive: bool, + ) -> Result, ApplicationError> { + let rows = sqlx::query_as::<_, PaymentMethodRow>( + r#" + SELECT id, code, name, active, created_at + FROM payment_methods + WHERE (active = TRUE OR $1) + ORDER BY name + "#, + ) + .bind(include_inactive) + .fetch_all(&self.pool) + .await + .map_err(db)?; + Ok(rows.into_iter().map(PaymentMethod::from).collect()) + } + + async fn find_by_id( + &self, + id: Uuid, + ) -> Result, ApplicationError> { + let row = sqlx::query_as::<_, PaymentMethodRow>( + r#" + SELECT id, code, name, active, created_at + FROM payment_methods + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + Ok(row.map(PaymentMethod::from)) + } + + async fn create( + &self, + code: &str, + name: &str, + ) -> Result { + match sqlx::query_as::<_, PaymentMethodRow>( + r#" + INSERT INTO payment_methods (code, name) + VALUES ($1, $2) + RETURNING id, code, name, active, created_at + "#, + ) + .bind(code) + .bind(name) + .fetch_one(&self.pool) + .await + { + Ok(row) => Ok(row.into()), + Err(e) if pg_sqlstate(&e).as_deref() == Some("23505") => Err( + ApplicationError::Conflict(format!( + "payment method with code '{code}' already exists" + )), + ), + Err(e) => Err(db(e)), + } + } + + async fn update( + &self, + id: Uuid, + name: Option<&str>, + active: Option, + ) -> Result { + // COALESCE-Pattern lässt unveränderte Felder durch die DB-Spalte + // fließen — wir senden für „nicht ändern" einfach NULL. + let row = sqlx::query_as::<_, PaymentMethodRow>( + r#" + UPDATE payment_methods + SET name = COALESCE($2, name), + active = COALESCE($3, active) + WHERE id = $1 + RETURNING id, code, name, active, created_at + "#, + ) + .bind(id) + .bind(name) + .bind(active) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + row.map(PaymentMethod::from) + .ok_or(ApplicationError::NotFound) + } + + async fn delete(&self, id: Uuid) -> Result<(), ApplicationError> { + match sqlx::query("DELETE FROM payment_methods WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await + { + Ok(result) => { + if result.rows_affected() == 0 { + Err(ApplicationError::NotFound) + } else { + Ok(()) + } + } + Err(e) if pg_sqlstate(&e).as_deref() == Some("23503") => Err( + ApplicationError::Conflict( + "payment method is in use by at least one delivery" + .to_string(), + ), + ), + Err(e) => Err(db(e)), + } + } +} diff --git a/crates/infrastructure/src/persistence/scan_repository.rs b/crates/infrastructure/src/persistence/scan_repository.rs index 8427df7..9a4f39d 100644 --- a/crates/infrastructure/src/persistence/scan_repository.rs +++ b/crates/infrastructure/src/persistence/scan_repository.rs @@ -38,8 +38,10 @@ impl PgScanRepository { #[derive(sqlx::FromRow)] struct ItemLockRow { id: Uuid, + delivery_id: Uuid, required_quantity: i32, scanned_quantity: i32, + credited_quantity: i32, scan_status: String, held_reason: Option, scan_last_updated_at: DateTime, @@ -47,6 +49,11 @@ struct ItemLockRow { komponenten_artikel_nr: Option, erp_belegart_id: i64, erp_belegnummer: String, + /// `articles.scannable` der Position — entscheidet, ob für eine + /// Gutschrift erst gescannt (`Done`) sein muss. + scannable: bool, + /// `deliveries.state` — Gutschriften nur bei `active`. + delivery_state: String, } fn db(e: E) -> ApplicationError { @@ -81,40 +88,86 @@ fn action_str(a: AuditAction) -> &'static str { AuditAction::Hold => "hold", AuditAction::Unhold => "unhold", AuditAction::Remove => "remove", + AuditAction::Unremove => "unremove", } } /// Ergebnis einer reinen Zustandsübergangs-Rechnung (ohne DB). struct Transition { + /// Signed Δ der SCAN-Menge (+1/-1 bei Scan/Unscan, sonst 0). delta: i32, new_quantity: i32, new_status: ScanStatus, new_held_reason: Option, + /// Signed Δ der GUTSCHRIFT-Menge: `Some(+n)` bei Remove, `Some(-n)` bei + /// Unremove, sonst `None` (Audit-Spalte bleibt dann NULL). + credit_delta: Option, + /// Neuer Stand `credited_quantity` nach der Aktion (für das Item-Update). + new_credited_quantity: i32, +} + +/// Schnappschuss des relevanten Item-Zustands für die reine +/// Übergangs-Rechnung — bündelt die vielen Parameter, die Remove/Unremove +/// jetzt brauchen. +struct ItemSnapshot<'a> { + current_qty: i32, + current_credited: i32, + current_status: ScanStatus, + required_qty: i32, + scannable: bool, + /// `deliveries.state` als String ('active' / 'held' / …). + delivery_state: &'a str, } /// Berechnet den nächsten Zustand. Bei `Err` enthält der String die /// fachliche Ablehnungs-Begründung, die 1:1 an die App geht. +/// +/// `quantity` ist nur für Remove/Unremove relevant (Mengen-Gutschrift); +/// `None` heißt dort „ganze Restmenge". fn apply_transition( action: AuditAction, - current_qty: i32, - current_status: ScanStatus, - required_qty: i32, + item: &ItemSnapshot<'_>, + quantity: Option, reason: Option<&str>, ) -> Result { + let current_qty = item.current_qty; + let current_status = item.current_status; + let required_qty = item.required_qty; + let current_credited = item.current_credited; + match action { AuditAction::Scan => match current_status { ScanStatus::InProgress | ScanStatus::Done => { - let new_qty = current_qty + 1; + // `quantity = None` → +1 (regulärer Einzel-Barcode-Scan). + // `quantity = Some(n)` → n Stück auf einmal (manuelle + // Zeilen-Bestätigung der Restmenge). n muss in + // [1 .. required − scanned] liegen, damit nicht über das Soll + // hinaus „gescannt" wird. + let n = match quantity { + None => 1, + Some(n) => { + let remaining = required_qty - current_qty; + if n <= 0 || n > remaining { + return Err(format!( + "invalid scan quantity {n}; remaining scannable {remaining}" + )); + } + n + } + }; + let new_qty = current_qty + n; let new_status = if new_qty >= required_qty { ScanStatus::Done } else { ScanStatus::InProgress }; Ok(Transition { - delta: 1, + delta: n, new_quantity: new_qty, new_status, new_held_reason: None, + credit_delta: None, + new_credited_quantity: current_credited, }) } ScanStatus::Held => Err("item is on hold; unhold before scanning".into()), @@ -131,6 +184,8 @@ fn apply_transition( new_quantity: new_qty, new_status: ScanStatus::InProgress, new_held_reason: None, + credit_delta: None, + new_credited_quantity: current_credited, }) } ScanStatus::Held => Err("item is on hold".into()), @@ -142,6 +197,8 @@ fn apply_transition( new_quantity: current_qty, new_status: ScanStatus::Held, new_held_reason: reason.map(str::to_owned), + credit_delta: None, + new_credited_quantity: current_credited, }), ScanStatus::Held => Err("item is already held".into()), ScanStatus::Removed => Err("item is removed".into()), @@ -158,19 +215,101 @@ fn apply_transition( new_quantity: current_qty, new_status, new_held_reason: None, + credit_delta: None, + new_credited_quantity: current_credited, }) } _ => Err("item is not held".into()), }, - AuditAction::Remove => match current_status { - ScanStatus::Removed => Err("item is already removed".into()), - _ => Ok(Transition { + + // ── Mengen-Gutschrift ─────────────────────────────────────────── + // Entscheidungstabelle (siehe Design): + // * Lieferung muss `active` sein + // * scannbare Position muss `Done` sein (erst verladen) + // * Menge muss in [1 .. Restmenge] liegen + // * Status → `Removed` erst wenn voll gutgeschrieben + AuditAction::Remove => { + if item.delivery_state != "active" { + return Err("delivery is not active; cannot credit".into()); + } + if item.scannable && current_status != ScanStatus::Done { + return Err( + "scannable item must be scanned (done) before it can be credited".into(), + ); + } + if current_status == ScanStatus::Held { + return Err("item is on hold; resume before crediting".into()); + } + let remaining = required_qty - current_credited; + let n = quantity.unwrap_or(remaining); + if n <= 0 || n > remaining { + return Err(format!( + "invalid credit quantity {n}; remaining creditable {remaining}" + )); + } + let new_credited = current_credited + n; + // Teil-Gutschrift lässt den Status unangetastet (Zeile wird + // weiter teilweise ausgeliefert); erst die volle Menge macht + // die Zeile zu `Removed`. `held_reason` trägt den Grund nur im + // Removed-Fall (Embed); die volle Historie steht ohnehin im Audit. + let fully = new_credited >= required_qty; + let new_status = if fully { + ScanStatus::Removed + } else { + current_status + }; + Ok(Transition { delta: 0, new_quantity: current_qty, - new_status: ScanStatus::Removed, - new_held_reason: reason.map(str::to_owned), - }), - }, + new_status, + new_held_reason: if fully { + reason.map(str::to_owned) + } else { + None + }, + credit_delta: Some(n), + new_credited_quantity: new_credited, + }) + } + + // Gutschrift (teilweise) zurücknehmen. Greift jetzt mengenbasiert: + // solange `credited_quantity > 0`, lässt sich etwas wiederherstellen + // — unabhängig davon, ob die Zeile schon ganz auf `Removed` stand. + AuditAction::Unremove => { + if item.delivery_state != "active" { + return Err("delivery is not active; cannot restore".into()); + } + if current_credited <= 0 { + return Err("nothing credited; nothing to restore".into()); + } + let n = quantity.unwrap_or(current_credited); + if n <= 0 || n > current_credited { + return Err(format!( + "invalid restore quantity {n}; credited {current_credited}" + )); + } + let new_credited = current_credited - n; + // Aus `Removed` zurück: Status nach Scan-Menge bestimmen. + // War die Zeile nur teil-gutgeschrieben (Status z. B. `Done`), + // bleibt er, was er war. + let new_status = if current_status == ScanStatus::Removed { + if current_qty >= required_qty { + ScanStatus::Done + } else { + ScanStatus::InProgress + } + } else { + current_status + }; + Ok(Transition { + delta: 0, + new_quantity: current_qty, + new_status, + new_held_reason: None, + credit_delta: Some(-n), + new_credited_quantity: new_credited, + }) + } } } @@ -182,17 +321,22 @@ async fn lock_item( r#" SELECT di.id, + di.delivery_id, di.required_quantity, di.scanned_quantity, + di.credited_quantity, di.scan_status, di.held_reason, di.scan_last_updated_at, di.belegzeilen_nr, di.komponenten_artikel_nr, d.erp_belegart_id, - d.erp_belegnummer + d.erp_belegnummer, + a.scannable, + d.state AS delivery_state FROM delivery_items di JOIN deliveries d ON d.id = di.delivery_id + JOIN articles a ON a.id = di.article_id WHERE di.id = $1 FOR UPDATE OF di "#, @@ -203,6 +347,104 @@ async fn lock_item( .map_err(db) } +/// Markiert alle (noch nicht entfernten) Komponenten eines Oberartikels als +/// entfernt — gleiche Belegzeile, `komponenten_artikel_nr IS NOT NULL`. Setzt +/// volle Gutschrift (credited = required, Status `removed`) und schreibt je +/// Komponente einen Audit-Eintrag. Läuft in der Transaktion des auslösenden +/// Oberartikel-Removes (durch dessen Idempotenz genau einmal). Komponenten +/// haben keine eigene ERP-Belegzeile → kein Einfluss aufs ERP-Rückschreiben. +async fn cascade_remove_components( + tx: &mut Transaction<'_, Postgres>, + parent: &ItemLockRow, + event: &ScanEvent, + actor_personalnummer: i64, +) -> Result<(), ApplicationError> { + #[derive(sqlx::FromRow)] + struct CompRow { + id: Uuid, + required_quantity: i32, + credited_quantity: i32, + scanned_quantity: i32, + komponenten_artikel_nr: Option, + } + + let components = sqlx::query_as::<_, CompRow>( + r#" + SELECT id, required_quantity, credited_quantity, scanned_quantity, + komponenten_artikel_nr + FROM delivery_items + WHERE delivery_id = $1 + AND belegzeilen_nr = $2 + AND komponenten_artikel_nr IS NOT NULL + AND scan_status <> 'removed' + FOR UPDATE + "#, + ) + .bind(parent.delivery_id) + .bind(parent.belegzeilen_nr) + .fetch_all(&mut **tx) + .await + .map_err(db)?; + + let reason = event + .reason + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| "Oberartikel entfernt".to_string()); + + for comp in components { + let credit_delta = comp.required_quantity - comp.credited_quantity; + + sqlx::query( + r#" + UPDATE delivery_items + SET credited_quantity = required_quantity, + scan_status = 'removed', + scan_last_updated_at = now() + WHERE id = $1 + "#, + ) + .bind(comp.id) + .execute(&mut **tx) + .await + .map_err(db)?; + + sqlx::query( + r#" + INSERT INTO scan_audit ( + client_scan_id, delivery_item_id, action, + delta, resulting_quantity, resulting_status, + reason, actor_personalnummer, actor_car_id, client_scanned_at, + erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr, + erp_komponenten_artikel_nr, + credit_delta, resulting_credited_quantity, manual + ) VALUES ($1, $2, 'remove', 0, $3, 'removed', $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, false) + "#, + ) + .bind(Uuid::new_v4()) + .bind(comp.id) + .bind(comp.scanned_quantity) + .bind(&reason) + .bind(actor_personalnummer) + .bind(event.actor_car_id) + .bind(event.client_scanned_at) + .bind(parent.erp_belegart_id) + .bind(&parent.erp_belegnummer) + .bind(parent.belegzeilen_nr) + .bind(comp.komponenten_artikel_nr.as_deref()) + .bind(credit_delta) + .bind(comp.required_quantity) + .execute(&mut **tx) + .await + .map_err(db)?; + } + + Ok(()) +} + #[async_trait] impl ScanRepository for PgScanRepository { async fn apply_one( @@ -222,16 +464,46 @@ impl ScanRepository for PgScanRepository { let current_status = parse_status(&item.scan_status)?; let current_state = ScanState { scanned_quantity: item.scanned_quantity, + credited_quantity: item.credited_quantity, status: current_status, held_reason: item.held_reason.clone(), last_updated_at: item.scan_last_updated_at, }; + // Idempotenz ZUERST — vor der Transition. Ein Netz-Retry desselben + // `client_scan_id` muss „Duplicate" liefern, auch wenn die + // (mengenabhängige) Transition inzwischen ablehnen würde (z. B. Item + // bereits `done`, Restmenge 0 bei manueller Bestätigung oder + // Remove/Unremove). Sonst würde die App ihr optimistisches Update + // fälschlich zurückrollen. + let already_applied: Option = sqlx::query_scalar( + "SELECT id FROM scan_audit WHERE client_scan_id = $1", + ) + .bind(event.client_scan_id) + .fetch_optional(&mut *tx) + .await + .map_err(db)?; + if already_applied.is_some() { + tx.rollback().await.map_err(db)?; + return Ok(ApplyScanOutcome::Duplicate { + delivery_item_id: item.id, + current_state, + }); + } + + let snapshot = ItemSnapshot { + current_qty: item.scanned_quantity, + current_credited: item.credited_quantity, + current_status, + required_qty: item.required_quantity, + scannable: item.scannable, + delivery_state: &item.delivery_state, + }; + let transition = match apply_transition( event.action, - item.scanned_quantity, - current_status, - item.required_quantity, + &snapshot, + event.quantity, event.reason.as_deref(), ) { Ok(t) => t, @@ -250,8 +522,9 @@ impl ScanRepository for PgScanRepository { delta, resulting_quantity, resulting_status, reason, actor_personalnummer, actor_car_id, client_scanned_at, erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr, - erp_komponenten_artikel_nr - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + erp_komponenten_artikel_nr, + credit_delta, resulting_credited_quantity, manual + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) ON CONFLICT (client_scan_id) DO NOTHING RETURNING id "#, @@ -270,6 +543,11 @@ impl ScanRepository for PgScanRepository { .bind(&item.erp_belegnummer) .bind(item.belegzeilen_nr) .bind(item.komponenten_artikel_nr.as_deref()) + // credit_delta / resulting_credited_quantity: nur bei Remove/Unremove + // gesetzt, sonst NULL (credit_delta == None). + .bind(transition.credit_delta) + .bind(transition.credit_delta.map(|_| transition.new_credited_quantity)) + .bind(event.manual) .fetch_optional(&mut *tx) .await .map_err(db)?; @@ -289,14 +567,16 @@ impl ScanRepository for PgScanRepository { r#" UPDATE delivery_items SET scanned_quantity = $1, - scan_status = $2, - held_reason = $3, + credited_quantity = $2, + scan_status = $3, + held_reason = $4, scan_last_updated_at = now() - WHERE id = $4 + WHERE id = $5 RETURNING scan_last_updated_at "#, ) .bind(transition.new_quantity) + .bind(transition.new_credited_quantity) .bind(status_str(transition.new_status)) .bind(transition.new_held_reason.as_deref()) .bind(item.id) @@ -304,12 +584,26 @@ impl ScanRepository for PgScanRepository { .await .map_err(db)?; + // Cascade: wird ein **Oberartikel** entfernt (Position ohne eigene + // Komponenten-Nr, die selbst Komponenten unter derselben Belegzeile + // hat) und ist dadurch voll `removed`, werden ALLE seine Komponenten + // ebenfalls als entfernt markiert — auch wenn sie noch nicht gescannt + // sind. Die Set-Entscheidung überschreibt das „scannbar muss done"-Gate. + if event.action == AuditAction::Remove + && transition.new_status == ScanStatus::Removed + && item.komponenten_artikel_nr.is_none() + { + cascade_remove_components(&mut tx, &item, event, actor_personalnummer) + .await?; + } + tx.commit().await.map_err(db)?; Ok(ApplyScanOutcome::Applied { delivery_item_id: item.id, new_state: ScanState { scanned_quantity: transition.new_quantity, + credited_quantity: transition.new_credited_quantity, status: transition.new_status, held_reason: transition.new_held_reason, last_updated_at: new_last_updated, diff --git a/crates/infrastructure/src/persistence/service_repository.rs b/crates/infrastructure/src/persistence/service_repository.rs new file mode 100644 index 0000000..f09ce80 --- /dev/null +++ b/crates/infrastructure/src/persistence/service_repository.rs @@ -0,0 +1,196 @@ +//! Postgres-Implementierung des `ServiceRepository`-Ports. + +use async_trait::async_trait; +use sqlx::PgPool; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::ServiceRepository; +use holzleitner_domain::{Service, ServiceKind}; + +pub struct PgServiceRepository { + pool: PgPool, +} + +impl PgServiceRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[derive(sqlx::FromRow)] +struct ServiceRow { + id: Uuid, + key: String, + name: String, + kind: String, + min_value: Option, + max_value: Option, + active: bool, + sort_order: i32, +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +fn pg_sqlstate(err: &sqlx::Error) -> Option { + if let sqlx::Error::Database(db_err) = err { + return db_err.code().map(|c| c.into_owned()); + } + None +} + +fn kind_str(k: ServiceKind) -> &'static str { + match k { + ServiceKind::Boolean => "boolean", + ServiceKind::Numeric => "numeric", + } +} + +fn parse_kind(s: &str) -> Result { + match s { + "boolean" => Ok(ServiceKind::Boolean), + "numeric" => Ok(ServiceKind::Numeric), + other => Err(ApplicationError::Repository(format!( + "unknown service kind '{other}'" + ))), + } +} + +fn map_service(r: ServiceRow) -> Result { + Ok(Service { + id: r.id, + key: r.key, + name: r.name, + kind: parse_kind(&r.kind)?, + min_value: r.min_value, + max_value: r.max_value, + active: r.active, + sort_order: r.sort_order, + }) +} + +const COLS: &str = + "id, key, name, kind, min_value, max_value, active, sort_order"; + +#[async_trait] +impl ServiceRepository for PgServiceRepository { + async fn list(&self, include_inactive: bool) -> Result, ApplicationError> { + let rows = sqlx::query_as::<_, ServiceRow>( + r#" + SELECT id, key, name, kind, min_value, max_value, active, sort_order + FROM services + WHERE (active = TRUE OR $1) + ORDER BY sort_order, name + "#, + ) + .bind(include_inactive) + .fetch_all(&self.pool) + .await + .map_err(db)?; + rows.into_iter().map(map_service).collect() + } + + async fn find_by_id(&self, id: Uuid) -> Result, ApplicationError> { + let row = sqlx::query_as::<_, ServiceRow>(&format!( + "SELECT {COLS} FROM services WHERE id = $1" + )) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + row.map(map_service).transpose() + } + + async fn create( + &self, + key: &str, + name: &str, + kind: ServiceKind, + min_value: Option, + max_value: Option, + sort_order: i32, + ) -> Result { + match sqlx::query_as::<_, ServiceRow>( + r#" + INSERT INTO services (key, name, kind, min_value, max_value, sort_order) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, key, name, kind, min_value, max_value, active, sort_order + "#, + ) + .bind(key) + .bind(name) + .bind(kind_str(kind)) + .bind(min_value) + .bind(max_value) + .bind(sort_order) + .fetch_one(&self.pool) + .await + { + Ok(row) => map_service(row), + Err(e) if pg_sqlstate(&e).as_deref() == Some("23505") => Err( + ApplicationError::Conflict(format!("service with key '{key}' already exists")), + ), + Err(e) => Err(db(e)), + } + } + + async fn update( + &self, + id: Uuid, + name: Option<&str>, + min_value: Option, + max_value: Option, + active: Option, + sort_order: Option, + ) -> Result { + let row = sqlx::query_as::<_, ServiceRow>( + r#" + UPDATE services + SET name = COALESCE($2, name), + min_value = COALESCE($3, min_value), + max_value = COALESCE($4, max_value), + active = COALESCE($5, active), + sort_order = COALESCE($6, sort_order) + WHERE id = $1 + RETURNING id, key, name, kind, min_value, max_value, active, sort_order + "#, + ) + .bind(id) + .bind(name) + .bind(min_value) + .bind(max_value) + .bind(active) + .bind(sort_order) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + match row { + Some(r) => map_service(r), + None => Err(ApplicationError::NotFound), + } + } + + async fn delete(&self, id: Uuid) -> Result<(), ApplicationError> { + match sqlx::query("DELETE FROM services WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await + { + Ok(result) => { + if result.rows_affected() == 0 { + Err(ApplicationError::NotFound) + } else { + Ok(()) + } + } + Err(e) if pg_sqlstate(&e).as_deref() == Some("23503") => Err( + ApplicationError::Conflict( + "service is in use by at least one delivery — deactivate instead".to_string(), + ), + ), + Err(e) => Err(db(e)), + } + } +} diff --git a/crates/infrastructure/src/persistence/tour_repository.rs b/crates/infrastructure/src/persistence/tour_repository.rs index b68a755..4e32869 100644 --- a/crates/infrastructure/src/persistence/tour_repository.rs +++ b/crates/infrastructure/src/persistence/tour_repository.rs @@ -20,14 +20,15 @@ use sqlx::{PgPool, Postgres, Transaction}; use uuid::Uuid; use holzleitner_application::dto::{ - DeliveryOrderEntry, DeliveryWithItems, SyncDelivery, SyncDeliveryItem, SyncTourRequest, - TourDetails, TourSummary, + DeliveryOrderEntry, DeliveryWithItems, SyncContactSource, SyncDelivery, SyncDeliveryItem, + SyncTourRequest, TourDetails, TourSummary, }; use holzleitner_application::error::ApplicationError; use holzleitner_application::ports::TourRepository; use holzleitner_domain::{ - Address, Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote, - DeliveryState, ScanState, ScanStatus, Tour, Warehouse, + Address, Article, ContactChannel, ContactKind, ContactRole, ContactSource, Customer, + CustomerContact, Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryServiceValue, + DeliveryState, ScanState, ScanStatus, Service, ServiceKind, Tour, Warehouse, }; pub struct PgTourRepository { @@ -78,6 +79,8 @@ struct DeliveryRow { state: String, state_reason: Option, sort_order: i32, + prepaid_amount: f64, + payment_method_id: Uuid, } #[derive(sqlx::FromRow)] @@ -87,9 +90,12 @@ struct DeliveryItemRow { article_id: Uuid, required_quantity: i32, warehouse_id: Uuid, + unit_price: f64, belegzeilen_nr: i32, komponenten_artikel_nr: Option, + parent_artikel_nr: Option, scanned_quantity: i32, + credited_quantity: i32, scan_status: String, held_reason: Option, scan_last_updated_at: DateTime, @@ -139,6 +145,29 @@ struct ContactLinkRow { customer_contact_id: Uuid, } +#[derive(sqlx::FromRow)] +struct ContactSourceRow { + id: Uuid, + delivery_id: Uuid, + role: String, + anrede: Option, + titel: Option, + name1: Option, + name2: Option, + name3: Option, + abteilung: Option, + funktion: Option, +} + +#[derive(sqlx::FromRow)] +struct ContactChannelRow { + id: Uuid, + source_id: Uuid, + kind: String, + position: i16, + value: String, +} + #[derive(sqlx::FromRow)] struct DeliveryNoteRow { id: Uuid, @@ -147,6 +176,9 @@ struct DeliveryNoteRow { image_attachment: Option, author_personalnummer: i64, author_car_id: Option, + credit_delivery_item_id: Option, + is_amount_credit_note: bool, + image_attachment_deleted: bool, created_at: DateTime, } @@ -192,10 +224,13 @@ fn map_item(row: DeliveryItemRow) -> Result { article_id: row.article_id, required_quantity: row.required_quantity, warehouse_id: row.warehouse_id, + unit_price: row.unit_price, belegzeilen_nr: row.belegzeilen_nr, komponenten_artikel_nr: row.komponenten_artikel_nr, + parent_artikel_nr: row.parent_artikel_nr, scan_state: ScanState { scanned_quantity: row.scanned_quantity, + credited_quantity: row.credited_quantity, status: parse_scan_status(&row.scan_status)?, held_reason: row.held_reason, last_updated_at: row.scan_last_updated_at, @@ -228,6 +263,31 @@ fn map_contact(row: CustomerContactRow) -> CustomerContact { } } +fn map_contact_source(row: ContactSourceRow) -> Result { + Ok(ContactSource { + id: row.id, + delivery_id: row.delivery_id, + role: role_from_db(&row.role)?, + anrede: row.anrede, + titel: row.titel, + name1: row.name1, + name2: row.name2, + name3: row.name3, + abteilung: row.abteilung, + funktion: row.funktion, + }) +} + +fn map_contact_channel(row: ContactChannelRow) -> Result { + Ok(ContactChannel { + id: row.id, + source_id: row.source_id, + kind: kind_from_db(&row.kind)?, + position: row.position, + value: row.value, + }) +} + fn map_article(row: ArticleRow) -> Article { Article { id: row.id, @@ -246,10 +306,85 @@ fn map_note(row: DeliveryNoteRow) -> DeliveryNote { image_attachment: row.image_attachment, author_personalnummer: row.author_personalnummer, author_car_id: row.author_car_id, + credit_delivery_item_id: row.credit_delivery_item_id, + is_amount_credit_note: row.is_amount_credit_note, + image_attachment_deleted: row.image_attachment_deleted, created_at: row.created_at, } } +#[derive(sqlx::FromRow)] +struct CreditRow { + delivery_id: Uuid, + action: String, + amount_cents: i64, + reason: Option, +} + +/// Aktuelles Gutschrift-Ereignis → Domänenobjekt. `remove` (oder unbekannte +/// Action) liefert `None`, sodass entfernte Gutschriften nicht erscheinen. +fn map_credit(row: CreditRow) -> Option { + if row.action != "set" { + return None; + } + Some(DeliveryCredit { + delivery_id: row.delivery_id, + amount_cents: row.amount_cents, + reason: row.reason.unwrap_or_default(), + }) +} + +#[derive(sqlx::FromRow)] +struct ServiceRow { + id: Uuid, + key: String, + name: String, + kind: String, + min_value: Option, + max_value: Option, + active: bool, + sort_order: i32, +} + +fn map_service(row: ServiceRow) -> Result { + let kind = match row.kind.as_str() { + "boolean" => ServiceKind::Boolean, + "numeric" => ServiceKind::Numeric, + other => { + return Err(ApplicationError::Repository(format!( + "unknown service kind '{other}'" + ))); + } + }; + Ok(Service { + id: row.id, + key: row.key, + name: row.name, + kind, + min_value: row.min_value, + max_value: row.max_value, + active: row.active, + sort_order: row.sort_order, + }) +} + +#[derive(sqlx::FromRow)] +struct DeliveryServiceRow { + delivery_id: Uuid, + service_id: Uuid, + bool_value: Option, + numeric_value: Option, +} + +fn map_delivery_service(row: DeliveryServiceRow) -> DeliveryServiceValue { + DeliveryServiceValue { + delivery_id: row.delivery_id, + service_id: row.service_id, + bool_value: row.bool_value, + numeric_value: row.numeric_value, + } +} + fn map_warehouse(row: WarehouseRow) -> Warehouse { Warehouse { id: row.id, @@ -283,6 +418,8 @@ fn map_delivery( special_agreements: row.special_agreements, state, state_reason: row.state_reason, + prepaid_amount: row.prepaid_amount, + payment_method_id: row.payment_method_id, }; Ok((delivery, row.sort_order)) } @@ -352,7 +489,8 @@ impl TourRepository for PgTourRepository { id, tour_id, erp_belegart_id, erp_belegnummer, customer_id, snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, assigned_car_id, desired_time, special_agreements, - state, state_reason, sort_order + state, state_reason, sort_order, + prepaid_amount, payment_method_id FROM deliveries WHERE tour_id = $1 ORDER BY sort_order, erp_belegnummer @@ -391,8 +529,9 @@ impl TourRepository for PgTourRepository { r#" SELECT id, delivery_id, article_id, required_quantity, warehouse_id, - belegzeilen_nr, komponenten_artikel_nr, - scanned_quantity, scan_status, held_reason, scan_last_updated_at + unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr, + scanned_quantity, credited_quantity, scan_status, held_reason, + scan_last_updated_at FROM delivery_items WHERE delivery_id = ANY($1) ORDER BY delivery_id, belegzeilen_nr, komponenten_artikel_nr NULLS FIRST @@ -504,11 +643,15 @@ impl TourRepository for PgTourRepository { // 7. Notizen aller Lieferungen dieser Tour. let notes = sqlx::query_as::<_, DeliveryNoteRow>( r#" - SELECT id, delivery_id, text, image_attachment, - author_personalnummer, author_car_id, created_at - FROM delivery_notes - WHERE delivery_id = ANY($1) - ORDER BY delivery_id, created_at + SELECT dn.id, dn.delivery_id, dn.text, dn.image_attachment, + dn.author_personalnummer, dn.author_car_id, + dn.credit_delivery_item_id, dn.is_amount_credit_note, + (att.deleted_at IS NOT NULL) AS image_attachment_deleted, + dn.created_at + FROM delivery_notes dn + LEFT JOIN attachments att ON att.id = dn.image_attachment::uuid + WHERE dn.delivery_id = ANY($1) + ORDER BY dn.delivery_id, dn.created_at "#, ) .bind(&delivery_ids) @@ -519,6 +662,99 @@ impl TourRepository for PgTourRepository { .map(map_note) .collect::>(); + // 8. Aktuelle Betrags-Gutschriften: jüngstes Ereignis pro Lieferung, + // nur solange der letzte Stand `set` ist. + let credits = sqlx::query_as::<_, CreditRow>( + r#" + SELECT DISTINCT ON (delivery_id) + delivery_id, action, amount_cents, reason + FROM delivery_credit_audit + WHERE delivery_id = ANY($1) + ORDER BY delivery_id, recorded_at DESC, id DESC + "#, + ) + .bind(&delivery_ids) + .fetch_all(&self.pool) + .await + .map_err(db)? + .into_iter() + .filter_map(map_credit) + .collect::>(); + + // 9. Aktive Service-Definitionen (Stammdaten) — die App rendert daraus + // Phase 4. + let services = sqlx::query_as::<_, ServiceRow>( + r#" + SELECT id, key, name, kind, min_value, max_value, active, sort_order + FROM services + WHERE active = TRUE + ORDER BY sort_order, name + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(db)? + .into_iter() + .map(map_service) + .collect::, _>>()?; + + // 10. Pro-Lieferung gesetzte Service-Werte. + let delivery_services = sqlx::query_as::<_, DeliveryServiceRow>( + r#" + SELECT delivery_id, service_id, bool_value, numeric_value + FROM delivery_services + WHERE delivery_id = ANY($1) + "#, + ) + .bind(&delivery_ids) + .fetch_all(&self.pool) + .await + .map_err(db)? + .into_iter() + .map(map_delivery_service) + .collect::>(); + + // 11. Kontaktdaten-Snapshots aller Lieferungen + ihre Kanäle. + // Reihenfolge: Quellen pro Lieferung nach Rolle, Kanäle pro + // Quelle nach Art und ERP-Position — so kommt „Telefon" + // vor „Telefon2", die App muss nicht extra sortieren. + let source_rows = sqlx::query_as::<_, ContactSourceRow>( + r#" + SELECT id, delivery_id, role, + anrede, titel, name1, name2, name3, abteilung, funktion + FROM delivery_contact_sources + WHERE delivery_id = ANY($1) + ORDER BY delivery_id, role + "#, + ) + .bind(&delivery_ids) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + let source_ids: Vec = source_rows.iter().map(|r| r.id).collect(); + let contact_sources = source_rows + .into_iter() + .map(map_contact_source) + .collect::, _>>()?; + + let channel_rows = sqlx::query_as::<_, ContactChannelRow>( + r#" + SELECT id, source_id, kind, position, value + FROM delivery_contact_channels + WHERE source_id = ANY($1) + ORDER BY source_id, kind, position + "#, + ) + .bind(&source_ids) + .fetch_all(&self.pool) + .await + .map_err(db)?; + let contact_channels = channel_rows + .into_iter() + .map(map_contact_channel) + .collect::, _>>()?; + Ok(Some(TourDetails { tour, deliveries, @@ -527,6 +763,11 @@ impl TourRepository for PgTourRepository { articles, warehouses, notes, + credits, + services, + delivery_services, + contact_sources, + contact_channels, })) } @@ -603,6 +844,23 @@ impl TourRepository for PgTourRepository { ) -> Result { let mut tx = self.pool.begin().await.map_err(db)?; + // 0. Fahrer-/Account-Konto sicherstellen — der ERP-`Vertreter` muss als + // `accounts`-Zeile existieren (FK von `tours`). Auto-Provisionierung: + // fehlende Konten werden mit Default-Namen angelegt; bestehende + // bleiben unangetastet (DO NOTHING überschreibt keinen Namen). + sqlx::query( + r#" + INSERT INTO accounts (personalnummer, name) + VALUES ($1, $2) + ON CONFLICT (personalnummer) DO NOTHING + "#, + ) + .bind(request.driver_personalnummer) + .bind(format!("Fahrer {}", request.driver_personalnummer)) + .execute(&mut *tx) + .await + .map_err(db)?; + // 1. Tour upserten — Identität: (account_id, tour_date) let tour_id: Uuid = sqlx::query_scalar( r#" @@ -628,6 +886,11 @@ impl TourRepository for PgTourRepository { // erhalten; nur Stammdaten + sort_order werden refresht. let delivery_id = upsert_delivery(&mut tx, tour_id, customer_id, delivery).await?; + // 3a. Kontaktdaten-Snapshot neu schreiben. Snapshot-Semantik: + // beim Sync wird der Stand vom ERP übernommen, ältere Stände + // verworfen. Der CASCADE-DELETE räumt auch die Channels mit. + replace_contact_sources(&mut tx, delivery_id, &delivery.contact_sources).await?; + for item in &delivery.items { let warehouse_id = upsert_warehouse(&mut tx, item).await?; let article_id = upsert_article(&mut tx, item, warehouse_id).await?; @@ -638,6 +901,17 @@ impl TourRepository for PgTourRepository { tx.commit().await.map_err(db)?; Ok(tour_id) } + + async fn delete_all_tours(&self) -> Result { + // DELETE FROM tours cascadet per FK auf deliveries → delivery_items → + // scan_audit, delivery_notes, delivery_credit_audit, delivery_services, + // delivery_completions, attachments, delivery_contact_persons. + let res = sqlx::query("DELETE FROM tours") + .execute(&self.pool) + .await + .map_err(db)?; + Ok(res.rows_affected()) + } } // ===== Upsert-Helfer ===================================================== @@ -741,15 +1015,39 @@ async fn upsert_delivery( customer_id: Uuid, delivery: &SyncDelivery, ) -> Result { + // Payment-Method-Code → UUID auflösen. Fallback `"cash"` falls vom + // ERP nichts gekommen ist — `"cash"` ist Default-Stamm aus + // Migration 0008 und damit garantiert vorhanden. + let payment_code = delivery + .payment_method_code + .as_deref() + .unwrap_or("cash"); + let payment_method_id: Uuid = sqlx::query_scalar( + "SELECT id FROM payment_methods WHERE code = $1", + ) + .bind(payment_code) + .fetch_optional(&mut **tx) + .await + .map_err(db)? + .ok_or_else(|| { + ApplicationError::Validation(format!( + "unknown payment method code '{payment_code}'" + )) + })?; + let id: Uuid = sqlx::query_scalar( r#" INSERT INTO deliveries ( - tour_id, erp_belegart_id, erp_belegnummer, customer_id, + tour_id, erp_belegart_id, erp_belegart_code, erp_belegart_name, + erp_belegnummer, customer_id, snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, - sort_order, desired_time, special_agreements - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + sort_order, desired_time, special_agreements, + prepaid_amount, payment_method_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ON CONFLICT (erp_belegart_id, erp_belegnummer) DO UPDATE SET tour_id = EXCLUDED.tour_id, + erp_belegart_code = EXCLUDED.erp_belegart_code, + erp_belegart_name = EXCLUDED.erp_belegart_name, customer_id = EXCLUDED.customer_id, snap_street = EXCLUDED.snap_street, snap_house_number = EXCLUDED.snap_house_number, @@ -758,12 +1056,16 @@ async fn upsert_delivery( snap_country = EXCLUDED.snap_country, sort_order = EXCLUDED.sort_order, desired_time = EXCLUDED.desired_time, - special_agreements = EXCLUDED.special_agreements + special_agreements = EXCLUDED.special_agreements, + prepaid_amount = EXCLUDED.prepaid_amount, + payment_method_id = EXCLUDED.payment_method_id RETURNING id "#, ) .bind(tour_id) .bind(delivery.belegart_id) + .bind(delivery.belegart_code.as_deref()) + .bind(delivery.belegart_name.as_deref()) .bind(&delivery.belegnummer) .bind(customer_id) .bind(&delivery.delivery_address.street) @@ -774,6 +1076,8 @@ async fn upsert_delivery( .bind(delivery.sort_order) .bind(delivery.desired_time.as_deref()) .bind(delivery.special_agreements.as_deref()) + .bind(delivery.prepaid_amount) + .bind(payment_method_id) .fetch_one(&mut **tx) .await .map_err(db)?; @@ -794,22 +1098,129 @@ async fn upsert_delivery_item( r#" INSERT INTO delivery_items ( delivery_id, article_id, required_quantity, warehouse_id, - belegzeilen_nr, komponenten_artikel_nr - ) VALUES ($1, $2, $3, $4, $5, $6) + unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (delivery_id, belegzeilen_nr, komponenten_artikel_nr) DO UPDATE SET article_id = EXCLUDED.article_id, required_quantity = EXCLUDED.required_quantity, - warehouse_id = EXCLUDED.warehouse_id + warehouse_id = EXCLUDED.warehouse_id, + unit_price = EXCLUDED.unit_price, + parent_artikel_nr = EXCLUDED.parent_artikel_nr "#, ) .bind(delivery_id) .bind(article_id) .bind(item.required_quantity) .bind(warehouse_id) + .bind(item.unit_price) .bind(item.belegzeilen_nr) .bind(item.komponenten_artikel_nr.as_deref()) + .bind(item.parent_artikel_nr.as_deref()) .execute(&mut **tx) .await .map_err(db)?; Ok(()) } + +// ===== Kontaktdaten ====================================================== + +fn role_to_db(role: ContactRole) -> &'static str { + match role { + ContactRole::Header => "header", + ContactRole::Delivery => "delivery", + ContactRole::Billing => "billing", + ContactRole::ContactPerson => "contact_person", + ContactRole::CustomerMaster => "customer_master", + } +} + +fn role_from_db(value: &str) -> Result { + match value { + "header" => Ok(ContactRole::Header), + "delivery" => Ok(ContactRole::Delivery), + "billing" => Ok(ContactRole::Billing), + "contact_person" => Ok(ContactRole::ContactPerson), + "customer_master" => Ok(ContactRole::CustomerMaster), + other => Err(ApplicationError::Repository(format!( + "unknown contact role in DB: {other}" + ))), + } +} + +fn kind_to_db(kind: ContactKind) -> &'static str { + match kind { + ContactKind::Phone => "phone", + ContactKind::Mobile => "mobile", + ContactKind::Email => "email", + ContactKind::Web => "web", + } +} + +fn kind_from_db(value: &str) -> Result { + match value { + "phone" => Ok(ContactKind::Phone), + "mobile" => Ok(ContactKind::Mobile), + "email" => Ok(ContactKind::Email), + "web" => Ok(ContactKind::Web), + other => Err(ApplicationError::Repository(format!( + "unknown contact kind in DB: {other}" + ))), + } +} + +/// Snapshot-Refresh: vorhandene Sources der Lieferung löschen (Channels +/// fliegen per ON DELETE CASCADE mit), neue einfügen. Idempotent: leerer +/// Input ⇒ Lieferung hat nach dem Aufruf 0 Sources. +async fn replace_contact_sources( + tx: &mut Transaction<'_, Postgres>, + delivery_id: Uuid, + sources: &[SyncContactSource], +) -> Result<(), ApplicationError> { + sqlx::query("DELETE FROM delivery_contact_sources WHERE delivery_id = $1") + .bind(delivery_id) + .execute(&mut **tx) + .await + .map_err(db)?; + + for src in sources { + let source_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO delivery_contact_sources ( + delivery_id, role, anrede, titel, name1, name2, name3, + abteilung, funktion + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id + "#, + ) + .bind(delivery_id) + .bind(role_to_db(src.role)) + .bind(src.anrede.as_deref()) + .bind(src.titel.as_deref()) + .bind(src.name1.as_deref()) + .bind(src.name2.as_deref()) + .bind(src.name3.as_deref()) + .bind(src.abteilung.as_deref()) + .bind(src.funktion.as_deref()) + .fetch_one(&mut **tx) + .await + .map_err(db)?; + + for ch in &src.channels { + sqlx::query( + r#" + INSERT INTO delivery_contact_channels ( + source_id, kind, position, value + ) VALUES ($1, $2, $3, $4) + "#, + ) + .bind(source_id) + .bind(kind_to_db(ch.kind)) + .bind(ch.position) + .bind(&ch.value) + .execute(&mut **tx) + .await + .map_err(db)?; + } + } + Ok(()) +} diff --git a/crates/infrastructure/src/report/mod.rs b/crates/infrastructure/src/report/mod.rs new file mode 100644 index 0000000..3ae2b0d --- /dev/null +++ b/crates/infrastructure/src/report/mod.rs @@ -0,0 +1,10 @@ +//! PDF-Lieferreport: Daten sammeln (PG) → rendern (printpdf) → ablegen/senden +//! (Sink). Adapter zu den `DeliveryReport*`-Ports der Application-Schicht. + +pub mod renderer; +pub mod repository; +pub mod sink; + +pub use renderer::PdfDeliveryReportRenderer; +pub use repository::PgDeliveryReportRepository; +pub use sink::{DocuframeReportSink, LocalReportSink}; diff --git a/crates/infrastructure/src/report/renderer.rs b/crates/infrastructure/src/report/renderer.rs new file mode 100644 index 0000000..6578d31 --- /dev/null +++ b/crates/infrastructure/src/report/renderer.rs @@ -0,0 +1,699 @@ +//! printpdf-Renderer für den Lieferreport. +//! +//! Nutzt die eingebaute **Helvetica** (WinAnsi → deutsche Umlaute) — kein +//! Font-Asset. Einfache, eigene Layout-Engine: Cursor `y` von oben nach unten, +//! automatischer Seitenumbruch, klippende Tabellen, eingebettete Bilder +//! (Unterschriften + Foto-Notizen) via roher RGB-Daten. + +use printpdf::{ + BuiltinFont, Color, ColorBits, ColorSpace, Image, ImageFilter, ImageTransform, ImageXObject, + IndirectFontRef, Line, Mm, PdfDocument, PdfDocumentReference, PdfLayerReference, Point, Px, Rgb, +}; + +use holzleitner_application::dto::DeliveryReportData; +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::DeliveryReportRenderer; + +const PAGE_W: f32 = 210.0; +const PAGE_H: f32 = 297.0; +const MARGIN_L: f32 = 15.0; +const MARGIN_R: f32 = 15.0; +const MARGIN_TOP: f32 = 15.0; +const MARGIN_BOTTOM: f32 = 16.0; +const CONTENT_W: f32 = PAGE_W - MARGIN_L - MARGIN_R; // 180 + +fn pt2mm(pt: f32) -> f32 { + pt * 0.352_778 +} +/// Grobe Helvetica-Zeichenbreite (≈0.5em) — reicht für Umbruch/Klippen. +fn char_w(size: f32) -> f32 { + pt2mm(size) * 0.5 +} +/// Geschätzte Breite (mm) der fetten Schlüssel-Spalte inkl. 4mm Luft. +/// Konservative Obergrenze für Helvetica-Bold (~0.6em → hier 0.66em). +fn kv_col(key: &str) -> f32 { + pt2mm(9.0) * 0.66 * key.chars().count() as f32 + 4.0 +} +fn money(v: f64) -> String { + format!("{:.2} €", v).replace('.', ",") +} +fn cents(c: i64) -> String { + money(c as f64 / 100.0) +} + +pub struct PdfDeliveryReportRenderer; + +struct Pdf { + doc: PdfDocumentReference, + font: IndirectFontRef, + bold: IndirectFontRef, + layer: PdfLayerReference, + y: f32, +} + +impl Pdf { + fn new(title: &str) -> Result { + let (doc, page, layer) = PdfDocument::new(title, Mm(PAGE_W), Mm(PAGE_H), "Layer 1"); + let font = doc + .add_builtin_font(BuiltinFont::Helvetica) + .map_err(ext)?; + let bold = doc + .add_builtin_font(BuiltinFont::HelveticaBold) + .map_err(ext)?; + let layer = doc.get_page(page).get_layer(layer); + Ok(Self { + doc, + font, + bold, + layer, + y: PAGE_H - MARGIN_TOP, + }) + } + + fn new_page(&mut self) { + let (page, layer) = self.doc.add_page(Mm(PAGE_W), Mm(PAGE_H), "Layer 1"); + self.layer = self.doc.get_page(page).get_layer(layer); + self.y = PAGE_H - MARGIN_TOP; + } + + fn ensure(&mut self, needed: f32) { + if self.y - needed < MARGIN_BOTTOM { + self.new_page(); + } + } + + /// Schreibt eine Zeile ab x (mm von links innerhalb des Inhalts). + fn put(&mut self, text: &str, size: f32, bold: bool, x: f32, baseline_y: f32) { + let font = if bold { &self.bold } else { &self.font }; + self.layer + .use_text(text, size, Mm(MARGIN_L + x), Mm(baseline_y), font); + } + + fn max_chars(&self, width: f32, size: f32) -> usize { + ((width / char_w(size)).floor() as usize).max(1) + } + + fn clip(&self, text: &str, width: f32, size: f32) -> String { + let max = self.max_chars(width, size); + if text.chars().count() <= max { + text.to_string() + } else if max <= 1 { + "…".to_string() + } else { + let truncated: String = text.chars().take(max - 1).collect(); + format!("{truncated}…") + } + } + + /// Mehrzeiliger, umbrechender Text. `indent` in mm. + fn text(&mut self, text: &str, size: f32, bold: bool, indent: f32) { + let avail = CONTENT_W - indent; + let max = self.max_chars(avail, size); + for raw_line in text.split('\n') { + if raw_line.is_empty() { + self.y -= pt2mm(size) * 1.3; + continue; + } + let mut current = String::new(); + for word in raw_line.split_whitespace() { + let candidate = if current.is_empty() { + word.to_string() + } else { + format!("{current} {word}") + }; + if candidate.chars().count() > max && !current.is_empty() { + self.write_line(¤t, size, bold, indent); + current = word.to_string(); + } else { + current = candidate; + } + } + if !current.is_empty() { + self.write_line(¤t, size, bold, indent); + } + } + } + + fn write_line(&mut self, text: &str, size: f32, bold: bool, indent: f32) { + let lh = pt2mm(size) * 1.35; + self.ensure(lh); + let baseline = self.y - pt2mm(size); + self.put(text, size, bold, indent, baseline); + self.y -= lh; + } + + fn gap(&mut self, mm: f32) { + self.y -= mm; + } + + fn title(&mut self, text: &str) { + self.write_line(text, 18.0, true, 0.0); + self.gap(2.0); + } + + fn heading(&mut self, text: &str) { + self.gap(4.0); + self.ensure(8.0); + self.write_line(text, 12.0, true, 0.0); + self.gap(1.0); + } + + fn kv(&mut self, key: &str, value: &str) { + // Wert-Spalte normal bei 42mm; ist der (fette) Schlüssel breiter, + // wird der Wert nach rechts geschoben, damit nichts überlappt. + let val_x = 42.0_f32.max(kv_col(key)); + self.kv_at(key, value, val_x); + } + + /// Wie `kv`, aber mit fest vorgegebener Wert-Spalte `val_x` (mm) — für + /// bündig ausgerichtete Blöcke, in denen alle Werte an derselben Stelle + /// beginnen (Spaltenbreite = breitester Schlüssel des Blocks). + fn kv_at(&mut self, key: &str, value: &str, val_x: f32) { + // Key in der ersten Spalte (fett), Wert daneben. + let lh = pt2mm(9.0) * 1.35; + self.ensure(lh); + let baseline = self.y - pt2mm(9.0); + self.put(key, 9.0, true, 0.0, baseline); + let val = self.clip(value, CONTENT_W - val_x, 9.0); + self.put(&val, 9.0, false, val_x, baseline); + self.y -= lh; + } + + /// Tabellenzeile: (Text, Spaltenbreite mm, fett). Klippt je Zelle. + fn row(&mut self, cells: &[(String, f32, bool)], size: f32) { + let lh = pt2mm(size) * 1.5; + self.ensure(lh); + let baseline = self.y - pt2mm(size); + let mut x = 0.0; + for (text, width, bold) in cells { + let clipped = self.clip(text, *width - 1.5, size); + self.put(&clipped, size, *bold, x, baseline); + x += width; + } + self.y -= lh; + } + + /// Bettet ein Bild ein (Bytes → RGB). `max_w`/`max_h` in mm. Bewegt den + /// Cursor (für Anhänge/Foto-Notizen im Textfluss). + fn image(&mut self, bytes: &[u8], max_w: f32, max_h: f32) { + // `max_h` ist die obere Schranke der Zeichenhöhe → konservativ reservieren. + self.ensure(max_h + 2.0); + match self.draw_image_at(bytes, MARGIN_L, self.y, max_w, max_h) { + Some(h) => self.y -= h + 2.0, + None => self.text("[Bild konnte nicht gelesen werden]", 8.0, false, 0.0), + } + } + + /// Zeichnet ein Bild an fester Position (x links, `top_y` Oberkante, mm), + /// **ohne** den Cursor zu verändern. Liefert die gezeichnete Höhe in mm + /// (oder `None`, wenn das Bild nicht dekodiert werden konnte). + fn draw_image_at(&self, bytes: &[u8], x_mm: f32, top_y: f32, max_w: f32, max_h: f32) -> Option { + let dynimg = image::load_from_memory(bytes).ok()?; + // Auf eine sinnvolle Maximal-Kantenlänge runterskalieren — ein Report + // braucht keine 12-MP-Fotos. Begrenzt die Pixelmenge VOR der Kompression. + const MAX_EDGE: u32 = 1600; + let dynimg = if dynimg.width().max(dynimg.height()) > MAX_EDGE { + dynimg.resize(MAX_EDGE, MAX_EDGE, image::imageops::FilterType::Triangle) + } else { + dynimg + }; + let rgb = dynimg.to_rgb8(); + let (w_px, h_px) = (rgb.width(), rgb.height()); + if w_px == 0 || h_px == 0 { + return None; + } + // JPEG-komprimieren und als DCTDecode-Stream ins PDF legen (statt rohem + // RGB) → drastisch kleinere PDFs (12-MP-Foto: 37 MB roh → ~200 KB). + let mut jpeg: Vec = Vec::new(); + { + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 82); + enc.encode(rgb.as_raw(), w_px, h_px, image::ExtendedColorType::Rgb8) + .ok()?; + } + // Zielgröße unter Wahrung des Seitenverhältnisses. + let aspect = h_px as f32 / w_px as f32; + let mut draw_w = max_w; + let mut draw_h = draw_w * aspect; + if draw_h > max_h { + draw_h = max_h; + draw_w = draw_h / aspect; + } + let bottom = top_y - draw_h; + + // Scale relativ zur Default-DPI (300): natürliche Breite in mm. + let natural_w_mm = (w_px as f32) / 300.0 * 25.4; + let scale = if natural_w_mm > 0.0 { + draw_w / natural_w_mm + } else { + 1.0 + }; + + let xobject = ImageXObject { + width: Px(w_px as usize), + height: Px(h_px as usize), + color_space: ColorSpace::Rgb, + bits_per_component: ColorBits::Bit8, + interpolate: false, + image_data: jpeg, + image_filter: Some(ImageFilter::DCT), + smask: None, + clipping_bbox: None, + }; + Image::from(xobject).add_to_layer( + self.layer.clone(), + ImageTransform { + translate_x: Some(Mm(x_mm)), + translate_y: Some(Mm(bottom)), + scale_x: Some(scale), + scale_y: Some(scale), + ..Default::default() + }, + ); + Some(draw_h) + } + + /// Zeichnet einen Rechteck-Rahmen (nur Kontur, grau). `bottom_y` = Unterkante. + fn rect(&self, x_mm: f32, bottom_y: f32, w: f32, h: f32) { + self.layer + .set_outline_color(Color::Rgb(Rgb::new(0.6, 0.6, 0.6, None))); + self.layer.set_outline_thickness(0.5); // pt + let (x0, x1) = (x_mm, x_mm + w); + let (y0, y1) = (bottom_y, bottom_y + h); + let line = Line { + points: vec![ + (Point::new(Mm(x0), Mm(y0)), false), + (Point::new(Mm(x1), Mm(y0)), false), + (Point::new(Mm(x1), Mm(y1)), false), + (Point::new(Mm(x0), Mm(y1)), false), + ], + is_closed: true, + }; + self.layer.add_line(line); + } + + /// Unterschriftsfeld: Beschriftung + umrahmter Kasten mit dem (kleinen) Bild. + fn signature_box(&mut self, label: &str, png: &[u8]) { + const BOX_W: f32 = 75.0; + const BOX_H: f32 = 22.0; + const PAD: f32 = 2.5; + let label_lh = pt2mm(9.0) * 1.35; + // Beschriftung + Kasten zusammenhalten (kein Umbruch mittendrin). + self.ensure(label_lh + BOX_H + 3.0); + self.write_line(label, 9.0, true, 0.0); + let top = self.y; + let bottom = top - BOX_H; + self.rect(MARGIN_L, bottom, BOX_W, BOX_H); + self.draw_image_at( + png, + MARGIN_L + PAD, + top - PAD, + BOX_W - 2.0 * PAD, + BOX_H - 2.0 * PAD, + ); + self.y = bottom - 3.0; + } + + fn finish(self) -> Result, ApplicationError> { + self.doc.save_to_bytes().map_err(ext) + } +} + +fn ext(e: E) -> ApplicationError { + ApplicationError::Repository(format!("pdf: {e}")) +} + +fn dt(d: &chrono::DateTime) -> String { + d.format("%d.%m.%Y %H:%M").to_string() +} +fn opt(s: &Option) -> &str { + s.as_deref().unwrap_or("—") +} + +/// Lesbare Belegart: „VL5 — Lieferschein EH", sonst nur Code/Name, sonst die +/// nackte ERP-ID („Nr. 24") als Fallback für noch nicht nachsynchronisierte +/// Altbestände. +fn belegart_label(d: &DeliveryReportData) -> String { + let code = d + .belegart_code + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + let name = d + .belegart_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + match (code, name) { + (Some(c), Some(n)) => format!("{c} — {n}"), + (Some(c), None) => c.to_string(), + (None, Some(n)) => n.to_string(), + (None, None) => format!("Nr. {}", d.belegart_id), + } +} + +/// Deutsche Beschriftung des Lieferungs-Status (DB-Werte sind technisch/englisch). +fn state_de(s: &str) -> &str { + match s { + "active" => "Aktiv", + "held" => "Zurückgestellt", + "canceled" => "Storniert", + "completed" => "Abgeschlossen", + other => other, + } +} + +/// Deutsche Beschriftung des Positions-/Scanstatus. +fn status_de(s: &str) -> &str { + match s { + "in_progress" => "In Arbeit", + "done" => "Erledigt", + "held" => "Zurückgestellt", + "removed" => "Entfernt", + other => other, + } +} + +/// Deutsche Beschriftung der Belade-/Scanaktion. +fn scan_action_de(s: &str) -> &str { + match s { + "scan" => "Erfasst", + "unscan" => "Zurückgesetzt", + "hold" => "Zurückgestellt", + "unhold" => "Freigegeben", + "remove" => "Entfernt", + other => other, + } +} + +/// Deutsche Beschriftung der Gutschriftaktion. +fn credit_action_de(s: &str) -> &str { + match s { + "set" => "Gesetzt", + "remove" => "Entfernt", + other => other, + } +} + +impl DeliveryReportRenderer for PdfDeliveryReportRenderer { + fn render(&self, d: &DeliveryReportData) -> Result, ApplicationError> { + let mut p = Pdf::new(&format!("Lieferbericht {}", d.belegnummer))?; + + // 1. Kopf + p.title(&format!("Lieferbericht {}", d.belegnummer)); + p.kv("Belegnummer", &d.belegnummer); + p.kv("Belegart", &belegart_label(d)); + p.kv("Status", state_de(&d.state)); + p.kv("Tourdatum", &d.tour_date.format("%d.%m.%Y").to_string()); + match &d.completion { + Some(c) => p.kv("Abgeschlossen", &dt(&c.completed_at)), + None => p.kv("Abgeschlossen", "— (nicht abgeschlossen)"), + } + p.kv("Fahrer", &format!("{} ({})", d.driver_name, d.driver_personalnummer)); + p.kv("Fahrzeug", opt(&d.car_plate)); + p.kv("Erstellt am", &dt(&d.generated_at)); + + // 2. Kunde & Lieferadresse + p.heading("Kunde & Lieferadresse"); + p.kv("Kunde", &format!("{} (Nr. {})", d.customer_name, d.customer_number)); + p.kv("Adresse", &d.address); + p.kv("Wunschzeit", opt(&d.desired_time)); + if let Some(sa) = &d.special_agreements { + if !sa.trim().is_empty() { + p.text(&format!("Sonderwünsche: {sa}"), 9.0, false, 0.0); + } + } + for c in &d.contacts { + let line = match &c.detail { + Some(det) => format!("Ansprechpartner: {} ({det})", c.name), + None => format!("Ansprechpartner: {}", c.name), + }; + p.text(&line, 9.0, false, 0.0); + } + + // 3. Positionen + p.heading("Positionen"); + let cols: [(&str, f32); 6] = [ + ("Artikel", 78.0), + ("Soll", 16.0), + ("Gel.", 16.0), + ("Gutschr.", 22.0), + ("Einzel", 26.0), + ("Lager", 22.0), + ]; + p.row( + &cols.iter().map(|(t, w)| (t.to_string(), *w, true)).collect::>(), + 8.0, + ); + let mut warenwert = 0.0_f64; + for it in &d.items { + warenwert += it.unit_price * it.delivered() as f64; + let label = if it.is_component() { + format!(" ↳ {} ({})", it.name, it.article_number) + } else { + format!("{} ({})", it.name, it.article_number) + }; + p.row( + &[ + (label, 78.0, false), + (it.required_quantity.to_string(), 16.0, false), + (it.delivered().to_string(), 16.0, false), + (it.credited_quantity.to_string(), 22.0, false), + (money(it.unit_price), 26.0, false), + (it.warehouse_code.clone().unwrap_or_default(), 22.0, false), + ], + 8.0, + ); + } + // Lager-Legende: Nummer → Name (einmalig, alphabetisch nach Nummer). + let mut lager: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for it in &d.items { + if let Some(code) = &it.warehouse_code { + if !code.trim().is_empty() { + lager + .entry(code.clone()) + .or_insert_with(|| it.warehouse_name.clone().unwrap_or_default()); + } + } + } + if !lager.is_empty() { + let legend = lager + .iter() + .map(|(code, name)| { + if name.trim().is_empty() { + code.clone() + } else { + format!("{code} = {name}") + } + }) + .collect::>() + .join(" "); + p.gap(1.0); + p.text(&format!("Lager: {legend}"), 7.5, false, 0.0); + } + + // 4. Zahlung + p.heading("Zahlung"); + let credit = d.current_credit_cents as f64 / 100.0; + let open = (warenwert - d.prepaid_amount - credit).max(0.0); + p.kv("Warenwert", &money(warenwert)); + p.kv("Anzahlung", &money(d.prepaid_amount)); + p.kv("Gutschrift", ¢s(d.current_credit_cents)); + p.kv("Offener Betrag", &money(open)); + p.kv("Zahlungsmethode", opt(&d.payment_method)); + // Inkasso-Bestätigung: nur wenn beim Abschluss tatsächlich kassiert + // wurde (Bar/EC bei offenem Betrag). „Auf Rechnung"/voll bezahlt → keine. + if let Some(c) = &d.completion { + if let Some(collected) = c.collected_amount_cents { + p.kv( + "Betrag erhalten", + &format!("Ja — {} (am {})", cents(collected), dt(&c.completed_at)), + ); + } + } + + // 5. Dienstleistungen + p.heading("Dienstleistungen"); + if d.services.is_empty() { + p.text("— keine —", 9.0, false, 0.0); + } else { + // Gemeinsame Wert-Spalte = Breite des längsten Dienstleistungs- + // Namens (mind. 42mm), damit die zweite Spalte bündig ausgerichtet + // ist statt pro Zeile zu springen. + let col = d + .services + .iter() + .map(|s| kv_col(&s.name)) + .fold(42.0_f32, f32::max); + for s in &d.services { + let val = if let Some(b) = s.bool_value { + if b { "Ja".to_string() } else { "Nein".to_string() } + } else if let Some(n) = s.numeric_value { + n.to_string() + } else { + "—".to_string() + }; + p.kv_at(&s.name, &val, col); + } + } + + // 6. Notizen + p.heading("Notizen"); + if d.notes.is_empty() { + p.text("— keine —", 9.0, false, 0.0); + } else { + for n in &d.notes { + let mut head = format!("{} · Fahrer {}", dt(&n.created_at), n.author_personalnummer); + if n.is_amount_credit_note { + head.push_str(" · [Gutschrift-Notiz]"); + } + if n.image_attachment.is_some() { + head.push_str(" · [Bild]"); + } + p.text(&head, 8.0, true, 0.0); + if let Some(t) = &n.text { + if !t.trim().is_empty() { + p.text(t, 9.0, false, 4.0); + } + } + } + } + + // 7. Unterschriften + p.heading("Unterschriften"); + match &d.completion { + Some(c) => { + // Es gibt nur EINEN Abschlusszeitpunkt (completed_at) — er gilt + // für beide Bestätigungen und wird hier neben den Häkchen gezeigt. + let bestätigt_am = dt(&c.completed_at); + let receipt = if c.receipt_confirmed { + format!("Ja (am {bestätigt_am})") + } else { + "Nein".to_string() + }; + let notes = if c.notes_acknowledged { + format!("Ja (am {bestätigt_am})") + } else { + "Nein".to_string() + }; + p.kv("Erhalt bestätigt", &receipt); + p.kv("Notizen quittiert", ¬es); + if let Some(png) = &d.customer_signature_png { + p.signature_box("Unterschrift Kunde", png); + } + if let Some(png) = &d.driver_signature_png { + p.signature_box("Unterschrift Fahrer", png); + } + } + None => p.text("— Lieferung nicht abgeschlossen —", 9.0, false, 0.0), + } + + // 8. Protokoll: Belade-/Scanverlauf + p.heading("Protokoll — Belade- und Scanverlauf"); + if d.scan_audit.is_empty() { + p.text("— keine Einträge —", 9.0, false, 0.0); + } else { + p.row( + &[ + ("Zeit".to_string(), 30.0, true), + ("Aktion".to_string(), 20.0, true), + ("Artikel".to_string(), 58.0, true), + ("Δ".to_string(), 10.0, true), + ("Menge".to_string(), 16.0, true), + ("Status".to_string(), 22.0, true), + ("Fahrer".to_string(), 18.0, true), + ], + 7.5, + ); + for s in &d.scan_audit { + let art = s + .article_name + .clone() + .unwrap_or_else(|| format!("Pos {}", s.belegzeilen_nr)); + let mut action = scan_action_de(&s.action).to_string(); + if s.manual { + action.push('*'); + } + p.row( + &[ + (dt(&s.server_recorded_at), 30.0, false), + (action, 20.0, false), + (art, 58.0, false), + (format!("{:+}", s.delta), 10.0, false), + (s.resulting_quantity.to_string(), 16.0, false), + (status_de(&s.resulting_status).to_string(), 22.0, false), + (s.actor_personalnummer.to_string(), 18.0, false), + ], + 7.5, + ); + if let Some(r) = &s.reason { + if !r.trim().is_empty() { + p.text(&format!(" Grund: {r}"), 7.0, false, 0.0); + } + } + } + p.text("* = manuell bestätigt", 7.0, false, 0.0); + } + + // 9. Protokoll: Gutschriftverlauf + p.heading("Protokoll — Gutschriftverlauf"); + if d.credit_audit.is_empty() { + p.text("— keine Einträge —", 9.0, false, 0.0); + } else { + p.row( + &[ + ("Zeit".to_string(), 34.0, true), + ("Aktion".to_string(), 22.0, true), + ("Betrag".to_string(), 26.0, true), + ("Fahrer".to_string(), 20.0, true), + ("Grund".to_string(), 70.0, true), + ], + 7.5, + ); + for c in &d.credit_audit { + p.row( + &[ + (dt(&c.recorded_at), 34.0, false), + (credit_action_de(&c.action).to_string(), 22.0, false), + (cents(c.amount_cents), 26.0, false), + (c.author_personalnummer.to_string(), 20.0, false), + (c.reason.clone().unwrap_or_default(), 70.0, false), + ], + 7.5, + ); + } + } + + // 10. Anhänge + p.heading("Anhänge"); + if d.attachments.is_empty() { + p.text("— keine —", 9.0, false, 0.0); + } else { + for a in &d.attachments { + let meta = format!( + "{} · {} · {} KB · hochgeladen {} von Fahrer {}", + a.filename.clone().unwrap_or_else(|| "(ohne Name)".into()), + a.mime_type, + a.size_bytes / 1024, + dt(&a.uploaded_at), + a.uploaded_by + ); + p.text(&meta, 8.0, true, 0.0); + match (&a.bytes, a.mime_type.starts_with("image/")) { + (Some(bytes), true) => p.image(bytes, 90.0, 90.0), + _ => p.text(" (Vorschau nicht verfügbar)", 8.0, false, 0.0), + } + } + } + + // 11. Footer + p.gap(4.0); + p.text( + &format!("Automatisch erzeugt am {} — Holzleitner Auslieferung", dt(&d.generated_at)), + 7.0, + false, + 0.0, + ); + + p.finish() + } +} diff --git a/crates/infrastructure/src/report/repository.rs b/crates/infrastructure/src/report/repository.rs new file mode 100644 index 0000000..d6d84a6 --- /dev/null +++ b/crates/infrastructure/src/report/repository.rs @@ -0,0 +1,485 @@ +//! Postgres-Implementierung von `DeliveryReportRepository`. +//! +//! Sammelt mit mehreren SELECTs alle Daten einer Lieferung inkl. beider +//! Audit-Trails (`scan_audit`, `delivery_credit_audit`) zum `DeliveryReportData`. +//! Bild-Bytes werden hier NICHT geladen (macht der Use Case). + +use async_trait::async_trait; +use chrono::{DateTime, NaiveDate, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use holzleitner_application::dto::{ + DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit, + ReportItem, ReportNote, ReportScanAudit, ReportService, +}; +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::DeliveryReportRepository; + +pub struct PgDeliveryReportRepository { + pool: PgPool, +} + +impl PgDeliveryReportRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +#[derive(sqlx::FromRow)] +struct HeadRow { + erp_belegart_id: i64, + erp_belegart_code: Option, + erp_belegart_name: Option, + erp_belegnummer: String, + state: String, + tour_date: NaiveDate, + account_id: i64, + driver_name: String, + car_plate: Option, + payment_method: Option, + erp_customer_id: i64, + customer_name: String, + snap_street: Option, + snap_house_number: Option, + snap_postal_code: Option, + snap_city: Option, + snap_country: Option, + desired_time: Option, + special_agreements: Option, + prepaid_amount: f64, +} + +#[derive(sqlx::FromRow)] +struct ItemRow { + belegzeilen_nr: i32, + komponenten_artikel_nr: Option, + parent_artikel_nr: Option, + article_number: String, + name: String, + required_quantity: i32, + credited_quantity: i32, + scanned_quantity: i32, + scan_status: String, + unit_price: f64, + warehouse_code: Option, + warehouse_name: Option, +} + +#[derive(sqlx::FromRow)] +struct ServiceRow { + name: String, + bool_value: Option, + numeric_value: Option, +} + +#[derive(sqlx::FromRow)] +struct NoteRow { + created_at: DateTime, + author_personalnummer: i64, + text: Option, + image_attachment: Option, + is_amount_credit_note: bool, +} + +#[derive(sqlx::FromRow)] +struct ContactRow { + name: String, + phone: Option, + email: Option, +} + +#[derive(sqlx::FromRow)] +struct CompletionRow { + completed_at: DateTime, + completed_by_personalnummer: i64, + receipt_confirmed: bool, + notes_acknowledged: bool, + customer_signature_path: String, + driver_signature_path: String, + payment_collected: bool, + collected_amount_cents: Option, +} + +#[derive(sqlx::FromRow)] +struct ScanAuditRow { + server_recorded_at: DateTime, + client_scanned_at: DateTime, + action: String, + delta: i32, + resulting_quantity: i32, + resulting_status: String, + reason: Option, + manual: bool, + credit_delta: Option, + actor_personalnummer: i64, + belegzeilen_nr: i32, + komponenten_artikel_nr: Option, + article_name: Option, +} + +#[derive(sqlx::FromRow)] +struct CreditAuditRow { + recorded_at: DateTime, + action: String, + amount_cents: i64, + reason: Option, + author_personalnummer: i64, +} + +#[derive(sqlx::FromRow)] +struct AttachmentRow { + filename: Option, + reference: String, + mime_type: String, + size_bytes: i64, + width: Option, + height: Option, + uploaded_at: DateTime, + uploaded_by: i64, +} + +fn one_line_address( + street: Option, + house: Option, + plz: Option, + city: Option, + country: Option, +) -> String { + let line1 = [street, house] + .into_iter() + .flatten() + .filter(|s| !s.trim().is_empty()) + .collect::>() + .join(" "); + let line2 = [plz, city] + .into_iter() + .flatten() + .filter(|s| !s.trim().is_empty()) + .collect::>() + .join(" "); + [line1, line2, country.unwrap_or_default()] + .into_iter() + .filter(|s| !s.trim().is_empty()) + .collect::>() + .join(", ") +} + +#[async_trait] +impl DeliveryReportRepository for PgDeliveryReportRepository { + async fn load( + &self, + delivery_id: Uuid, + ) -> Result, ApplicationError> { + // --- Kopf --- + let head: Option = sqlx::query_as( + r#" + SELECT d.erp_belegart_id, d.erp_belegart_code, d.erp_belegart_name, + d.erp_belegnummer, d.state, + t.tour_date, t.account_id, + acc.name AS driver_name, + car.plate AS car_plate, + pm.name AS payment_method, + c.erp_customer_id, c.name AS customer_name, + d.snap_street, d.snap_house_number, d.snap_postal_code, + d.snap_city, d.snap_country, + d.desired_time, d.special_agreements, d.prepaid_amount + FROM deliveries d + JOIN tours t ON t.id = d.tour_id + JOIN accounts acc ON acc.personalnummer = t.account_id + LEFT JOIN cars car ON car.id = d.assigned_car_id + LEFT JOIN payment_methods pm ON pm.id = d.payment_method_id + JOIN customers c ON c.id = d.customer_id + WHERE d.id = $1 + "#, + ) + .bind(delivery_id) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + + let Some(head) = head else { + return Ok(None); + }; + + // --- Positionen --- + let items: Vec = sqlx::query_as( + r#" + SELECT di.belegzeilen_nr, di.komponenten_artikel_nr, di.parent_artikel_nr, + a.article_number, a.name, + di.required_quantity, di.credited_quantity, di.scanned_quantity, + di.scan_status, di.unit_price, + w.code AS warehouse_code, w.name AS warehouse_name + FROM delivery_items di + JOIN articles a ON a.id = di.article_id + LEFT JOIN warehouses w ON w.id = di.warehouse_id + WHERE di.delivery_id = $1 + ORDER BY di.belegzeilen_nr, + di.komponenten_artikel_nr NULLS FIRST + "#, + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + // --- Dienstleistungen --- + let services: Vec = sqlx::query_as( + r#" + SELECT s.name, ds.bool_value, ds.numeric_value + FROM delivery_services ds + JOIN services s ON s.id = ds.service_id + WHERE ds.delivery_id = $1 + ORDER BY s.sort_order, s.name + "#, + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + // --- Notizen --- + let notes: Vec = sqlx::query_as( + r#" + SELECT created_at, author_personalnummer, text, image_attachment, + is_amount_credit_note + FROM delivery_notes + WHERE delivery_id = $1 + ORDER BY created_at + "#, + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + // --- Ansprechpartner --- + let contacts: Vec = sqlx::query_as( + r#" + SELECT cc.name, cc.phone, cc.email + FROM delivery_contact_persons dcp + JOIN customer_contacts cc ON cc.id = dcp.customer_contact_id + WHERE dcp.delivery_id = $1 + ORDER BY cc.name + "#, + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + // --- Abschluss --- + let completion: Option = sqlx::query_as( + r#" + SELECT completed_at, completed_by_personalnummer, receipt_confirmed, + notes_acknowledged, customer_signature_path, driver_signature_path, + payment_collected, collected_amount_cents + FROM delivery_completions + WHERE delivery_id = $1 + "#, + ) + .bind(delivery_id) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + + // --- Audit: Scan/Belade-Verlauf --- + let scan_audit: Vec = sqlx::query_as( + r#" + SELECT sa.server_recorded_at, sa.client_scanned_at, sa.action, sa.delta, + sa.resulting_quantity, sa.resulting_status, sa.reason, sa.manual, + sa.credit_delta, sa.actor_personalnummer, + sa.erp_belegzeilen_nr AS belegzeilen_nr, + sa.erp_komponenten_artikel_nr AS komponenten_artikel_nr, + a.name AS article_name + FROM scan_audit sa + JOIN delivery_items di ON di.id = sa.delivery_item_id + LEFT JOIN articles a ON a.id = di.article_id + WHERE di.delivery_id = $1 + ORDER BY sa.server_recorded_at + "#, + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + // --- Audit: Gutschrift-Verlauf --- + let credit_audit: Vec = sqlx::query_as( + r#" + SELECT recorded_at, action, amount_cents, reason, author_personalnummer + FROM delivery_credit_audit + WHERE delivery_id = $1 + ORDER BY recorded_at + "#, + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + // --- Anhänge --- + let attachments: Vec = sqlx::query_as( + r#" + SELECT filename, docuframe_object_id AS reference, mime_type, size_bytes, + width, height, uploaded_at, uploaded_by + FROM attachments + WHERE delivery_id = $1 + ORDER BY uploaded_at + "#, + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + // Aktuelle Gutschrift = Wirkung des letzten Events (append-only). + let current_credit_cents = credit_audit + .last() + .map(|e| { + if e.action.eq_ignore_ascii_case("set") { + e.amount_cents + } else { + 0 + } + }) + .unwrap_or(0); + + let address = one_line_address( + head.snap_street, + head.snap_house_number, + head.snap_postal_code, + head.snap_city, + head.snap_country, + ); + + Ok(Some(DeliveryReportData { + generated_at: Utc::now(), + belegart_id: head.erp_belegart_id, + belegart_code: head.erp_belegart_code, + belegart_name: head.erp_belegart_name, + belegnummer: head.erp_belegnummer, + state: head.state, + tour_date: head.tour_date, + driver_personalnummer: head.account_id, + driver_name: head.driver_name, + car_plate: head.car_plate, + payment_method: head.payment_method, + customer_number: head.erp_customer_id, + customer_name: head.customer_name, + address, + desired_time: head.desired_time, + special_agreements: head.special_agreements, + prepaid_amount: head.prepaid_amount, + current_credit_cents, + contacts: contacts + .into_iter() + .map(|c| { + let detail = [c.phone, c.email] + .into_iter() + .flatten() + .filter(|s| !s.trim().is_empty()) + .collect::>() + .join(" · "); + ReportContact { + name: c.name, + detail: if detail.is_empty() { None } else { Some(detail) }, + } + }) + .collect(), + items: items + .into_iter() + .map(|i| ReportItem { + belegzeilen_nr: i.belegzeilen_nr, + komponenten_artikel_nr: i.komponenten_artikel_nr, + parent_artikel_nr: i.parent_artikel_nr, + article_number: i.article_number, + name: i.name, + required_quantity: i.required_quantity, + credited_quantity: i.credited_quantity, + scanned_quantity: i.scanned_quantity, + scan_status: i.scan_status, + unit_price: i.unit_price, + warehouse_code: i.warehouse_code, + warehouse_name: i.warehouse_name, + }) + .collect(), + services: services + .into_iter() + .map(|s| ReportService { + name: s.name, + bool_value: s.bool_value, + numeric_value: s.numeric_value, + }) + .collect(), + notes: notes + .into_iter() + .map(|n| ReportNote { + created_at: n.created_at, + author_personalnummer: n.author_personalnummer, + text: n.text, + image_attachment: n.image_attachment, + is_amount_credit_note: n.is_amount_credit_note, + }) + .collect(), + completion: completion.map(|c| ReportCompletion { + completed_at: c.completed_at, + completed_by_personalnummer: c.completed_by_personalnummer, + receipt_confirmed: c.receipt_confirmed, + notes_acknowledged: c.notes_acknowledged, + customer_signature_path: c.customer_signature_path, + driver_signature_path: c.driver_signature_path, + payment_collected: c.payment_collected, + collected_amount_cents: c.collected_amount_cents, + }), + scan_audit: scan_audit + .into_iter() + .map(|s| ReportScanAudit { + server_recorded_at: s.server_recorded_at, + client_scanned_at: s.client_scanned_at, + action: s.action, + delta: s.delta, + resulting_quantity: s.resulting_quantity, + resulting_status: s.resulting_status, + reason: s.reason, + manual: s.manual, + credit_delta: s.credit_delta, + actor_personalnummer: s.actor_personalnummer, + belegzeilen_nr: s.belegzeilen_nr, + komponenten_artikel_nr: s.komponenten_artikel_nr, + article_name: s.article_name, + }) + .collect(), + credit_audit: credit_audit + .into_iter() + .map(|c| ReportCreditAudit { + recorded_at: c.recorded_at, + action: c.action, + amount_cents: c.amount_cents, + reason: c.reason, + author_personalnummer: c.author_personalnummer, + }) + .collect(), + attachments: attachments + .into_iter() + .map(|a| ReportAttachment { + filename: a.filename, + reference: a.reference, + mime_type: a.mime_type, + size_bytes: a.size_bytes, + width: a.width, + height: a.height, + uploaded_at: a.uploaded_at, + uploaded_by: a.uploaded_by, + bytes: None, + }) + .collect(), + customer_signature_png: None, + driver_signature_png: None, + })) + } +} diff --git a/crates/infrastructure/src/report/sink.rs b/crates/infrastructure/src/report/sink.rs new file mode 100644 index 0000000..012d973 --- /dev/null +++ b/crates/infrastructure/src/report/sink.rs @@ -0,0 +1,102 @@ +//! Sinks für das fertige Report-PDF. +//! +//! * [`LocalReportSink`] — legt das PDF **temporär** lokal ab +//! (`//report-.pdf`). Aktiver Sink. +//! * [`DocuframeReportSink`] — **Stub** für später: soll den PDF-Blob an ein +//! DOCUframe-Makro senden. Aktuell nur Logging, keine echte Übertragung. + +use std::path::PathBuf; + +use async_trait::async_trait; +use chrono::Utc; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::DeliveryReportSink; + +/// Dateisystem-sicheres Segment (alnum/._-, Rest → `_`). +fn sanitize(input: &str) -> String { + let s: String = input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '_' + } + }) + .collect(); + let t = s.trim_matches('.').trim().to_string(); + if t.is_empty() { "unbenannt".to_string() } else { t } +} + +// ===== Local ============================================================= + +pub struct LocalReportSink { + base_dir: PathBuf, +} + +impl LocalReportSink { + pub fn new(base_dir: impl Into) -> std::io::Result { + let base_dir = base_dir.into(); + std::fs::create_dir_all(&base_dir)?; + Ok(Self { base_dir }) + } +} + +#[async_trait] +impl DeliveryReportSink for LocalReportSink { + async fn deliver(&self, folder: &str, pdf: Vec) -> Result { + let folder = sanitize(folder); + let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string(); + let dir = self.base_dir.join(&folder); + let name = format!("report-{stamp}.pdf"); + let path = dir.join(&name); + let path_str = path.to_string_lossy().to_string(); + + tokio::task::spawn_blocking(move || -> std::io::Result<()> { + std::fs::create_dir_all(&dir)?; + std::fs::write(&path, &pdf) + }) + .await + .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))? + .map_err(|e| ApplicationError::Repository(format!("report speichern fehlgeschlagen: {e}")))?; + + tracing::info!(path = %path_str, "report.sink.local: PDF abgelegt"); + Ok(path_str) + } + + async fn delete(&self, folder: &str) -> Result<(), ApplicationError> { + // Löscht das gesamte Belegnummer-Verzeichnis (alle abgelegten Reports). + let dir = self.base_dir.join(sanitize(folder)); + tokio::task::spawn_blocking(move || match std::fs::remove_dir_all(&dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + }) + .await + .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))? + .map_err(|e| ApplicationError::Repository(format!("report löschen fehlgeschlagen: {e}"))) + } +} + +// ===== DOCUframe (Stub) ================================================== + +/// Platzhalter — später: PDF-Blob an ein DOCUframe-Makro senden. Aktuell nur +/// Logging, keine echte Übertragung. +pub struct DocuframeReportSink; + +#[async_trait] +impl DeliveryReportSink for DocuframeReportSink { + async fn deliver(&self, folder: &str, pdf: Vec) -> Result { + tracing::warn!( + belegnummer = folder, + pdf_bytes = pdf.len(), + "report.sink.docuframe: STUB — PDF würde an DOCUframe-Makro gesendet (noch nicht implementiert)" + ); + Ok(format!("docuframe-stub://{folder}")) + } + + async fn delete(&self, _folder: &str) -> Result<(), ApplicationError> { + Ok(()) + } +} diff --git a/crates/infrastructure/src/storage/local_attachment_storage.rs b/crates/infrastructure/src/storage/local_attachment_storage.rs new file mode 100644 index 0000000..8c6558d --- /dev/null +++ b/crates/infrastructure/src/storage/local_attachment_storage.rs @@ -0,0 +1,169 @@ +//! Lokaler Dateisystem-Adapter für `AttachmentStorage`. +//! +//! Ersetzt (vorerst) den DOCUframe-Upload: hochgeladene Bild-Notizen landen +//! als Dateien auf der Backend-Maschine — gruppiert pro **Belegnummer** in +//! einem eigenen Unterordner: +//! +//! ```text +//! //_ +//! ``` +//! +//! Die in der DB (`attachments.docuframe_object_id`) gespeicherte Referenz ist +//! der **relative** Pfad `/` — so bleibt ein Umzug des +//! Verzeichnisses unkompliziert und der Download liest dieselbe Referenz +//! wieder ein. +//! +//! Das DOCUframe-Pendant (`gsd::GsdService`) bleibt im Code erhalten und kann +//! später wieder verdrahtet werden. + +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::{AttachmentStorage, PreviewImage}; + +pub struct LocalAttachmentStorage { + base_dir: PathBuf, +} + +impl LocalAttachmentStorage { + /// Legt das Basis-Verzeichnis (rekursiv) an, falls nötig. Ein + /// nicht-anlegbares Verzeichnis ist ein Boot-Fehler. + pub fn new(base_dir: impl Into) -> std::io::Result { + let base_dir = base_dir.into(); + std::fs::create_dir_all(&base_dir)?; + Ok(Self { base_dir }) + } +} + +/// Macht aus einem beliebigen Segment einen dateisystem-sicheren Namen: +/// erlaubt sind `[A-Za-z0-9._-]`, alles andere wird zu `_`. Verhindert damit +/// auch Path-Traversal (`/`, `\`, `..` → `_`). Leeres Ergebnis → `unbenannt`. +fn sanitize_segment(input: &str) -> String { + let cleaned: String = input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '_' + } + }) + .collect(); + let trimmed = cleaned.trim_matches('.').trim(); + if trimmed.is_empty() { + "unbenannt".to_string() + } else { + trimmed.to_string() + } +} + +/// Content-Type aus der Dateiendung (das lokale Storage rendert nicht, es +/// liefert die Originalbytes — der Typ kommt aus der Endung). +fn content_type_for(path: &Path) -> String { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + match ext.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "heic" | "heif" => "image/heic", + "bmp" => "image/bmp", + "pdf" => "application/pdf", + _ => "application/octet-stream", + } + .to_string() +} + +#[async_trait] +impl AttachmentStorage for LocalAttachmentStorage { + async fn upload( + &self, + folder: &str, + filename: &str, + _mime: &str, + bytes: Vec, + ) -> Result { + let folder = sanitize_segment(folder); + // Nur den reinen Dateinamen verwenden (etwaige Pfadanteile abschneiden). + let raw_name = Path::new(filename) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(filename); + let safe_name = sanitize_segment(raw_name); + // UUID-Präfix → mehrere Bilder pro Beleg kollidieren nicht. + let stored_name = format!("{}_{}", Uuid::new_v4(), safe_name); + + let dir = self.base_dir.join(&folder); + let path = dir.join(&stored_name); + let reference = format!("{folder}/{stored_name}"); + + tokio::task::spawn_blocking(move || -> std::io::Result<()> { + std::fs::create_dir_all(&dir)?; + std::fs::write(&path, &bytes) + }) + .await + .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))? + .map_err(|e| { + ApplicationError::Repository(format!("bild speichern fehlgeschlagen: {e}")) + })?; + + Ok(reference) + } + + async fn download_preview( + &self, + object_id: &str, + _parameters: &str, + _page: &str, + ) -> Result { + // `object_id` ist unsere relative Referenz `/`. + // Defense-in-depth gegen Traversal: keine absoluten Pfade / `..`. + if object_id.contains("..") + || object_id.starts_with('/') + || object_id.starts_with('\\') + { + return Err(ApplicationError::Validation( + "ungültige Attachment-Referenz".into(), + )); + } + let path = self.base_dir.join(object_id); + let content_type = content_type_for(&path); + + let read_path = path.clone(); + let bytes = tokio::task::spawn_blocking(move || std::fs::read(&read_path)) + .await + .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))? + .map_err(|_| ApplicationError::NotFound)?; + + Ok(PreviewImage { + bytes, + content_type, + }) + } + + async fn delete(&self, reference: &str) -> Result<(), ApplicationError> { + // `reference` = relative Referenz `/`. Traversal-Guard. + if reference.contains("..") || reference.starts_with('/') || reference.starts_with('\\') { + return Err(ApplicationError::Validation( + "ungültige Attachment-Referenz".into(), + )); + } + let path = self.base_dir.join(reference); + tokio::task::spawn_blocking(move || match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + // Fehlende Datei ist kein Fehler (idempotent). + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + }) + .await + .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))? + .map_err(|e| ApplicationError::Repository(format!("bild löschen fehlgeschlagen: {e}"))) + } +} diff --git a/crates/infrastructure/src/storage/mod.rs b/crates/infrastructure/src/storage/mod.rs new file mode 100644 index 0000000..d430e19 --- /dev/null +++ b/crates/infrastructure/src/storage/mod.rs @@ -0,0 +1,11 @@ +//! Lokale Datei-Adapter. +//! +//! Im Gegensatz zu `gsd` (DOCUframe-Dokumentenspeicher) bleiben diese +//! Daten auf der Backend-Maschine: Unterschriften-PNGs und (neu) die +//! hochgeladenen Bild-Notizen pro Belegnummer. + +pub mod local_attachment_storage; +pub mod signature_storage; + +pub use local_attachment_storage::LocalAttachmentStorage; +pub use signature_storage::LocalSignatureStorage; diff --git a/crates/infrastructure/src/storage/signature_storage.rs b/crates/infrastructure/src/storage/signature_storage.rs new file mode 100644 index 0000000..24741c9 --- /dev/null +++ b/crates/infrastructure/src/storage/signature_storage.rs @@ -0,0 +1,99 @@ +//! Lokaler Dateisystem-Adapter für `SignatureStorage`. +//! +//! Schreibt jede Unterschrift als PNG unter +//! `/_.png`. Der Pfad ist deterministisch: +//! ein Retry überschreibt dieselbe Datei, statt Müll anzuhäufen. Persistiert +//! wird in der DB nur die relative Referenz (Dateiname), nicht der absolute +//! Pfad — so bleibt ein Umzug des Verzeichnisses unkompliziert. + +use std::path::PathBuf; + +use async_trait::async_trait; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::{SignatureRole, SignatureStorage}; + +pub struct LocalSignatureStorage { + base_dir: PathBuf, +} + +impl LocalSignatureStorage { + /// Legt das Basis-Verzeichnis (rekursiv) an, falls es noch nicht + /// existiert. Ein nicht-anlegbares Verzeichnis ist ein Boot-Fehler. + pub fn new(base_dir: impl Into) -> std::io::Result { + let base_dir = base_dir.into(); + std::fs::create_dir_all(&base_dir)?; + Ok(Self { base_dir }) + } + + fn file_name(delivery_id: Uuid, role: SignatureRole) -> String { + format!("{delivery_id}_{}.png", role.as_str()) + } +} + +#[async_trait] +impl SignatureStorage for LocalSignatureStorage { + async fn save( + &self, + delivery_id: Uuid, + role: SignatureRole, + bytes: Vec, + ) -> Result { + let name = Self::file_name(delivery_id, role); + let path = self.base_dir.join(&name); + // Blocking-IO bewusst auf einen Blocking-Thread auslagern. + tokio::task::spawn_blocking(move || std::fs::write(&path, &bytes)) + .await + .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))? + .map_err(|e| { + ApplicationError::Repository(format!("signatur speichern fehlgeschlagen: {e}")) + })?; + Ok(name) + } + + async fn load(&self, reference: &str) -> Result>, ApplicationError> { + // Traversal-Schutz: Referenz ist ein einfacher Dateiname. + if reference.contains('/') || reference.contains('\\') || reference.contains("..") { + return Err(ApplicationError::Validation( + "ungültige Signatur-Referenz".into(), + )); + } + let path = self.base_dir.join(reference); + let bytes = tokio::task::spawn_blocking(move || std::fs::read(&path)) + .await + .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?; + match bytes { + Ok(b) => Ok(Some(b)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(ApplicationError::Repository(format!( + "signatur laden fehlgeschlagen: {e}" + ))), + } + } + + async fn delete_for_delivery(&self, delivery_id: Uuid) -> Result<(), ApplicationError> { + // Beide deterministisch benannten Dateien (Kunde + Fahrer) entfernen. + let paths = [ + self.base_dir + .join(Self::file_name(delivery_id, SignatureRole::Customer)), + self.base_dir + .join(Self::file_name(delivery_id, SignatureRole::Driver)), + ]; + tokio::task::spawn_blocking(move || -> std::io::Result<()> { + for path in paths { + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + Ok(()) + }) + .await + .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))? + .map_err(|e| { + ApplicationError::Repository(format!("signatur löschen fehlgeschlagen: {e}")) + }) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index c3c698c..9d42d43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,9 +2,18 @@ # Startbefehl: docker compose up -d # # Postgres-Daten landen im benannten Volume `postgres-data` und überleben -# Container-Neustarts. Keycloak nutzt bewusst keinen persistenten Volume — -# der Realm wird bei jedem Start frisch aus `keycloak/import/` importiert, -# damit die Quellen-of-truth Versionierte Dateien bleiben. +# Container-Neustarts. +# +# Keycloak hat seit der Fahrer-Provisionierung EBENFALLS ein persistentes +# Volume (`keycloak-data` → /opt/keycloak/data): per Sync angelegte Fahrer- +# Konten (Username = Fahrernummer, temporäres Passwort) überleben damit +# Neustarts. ACHTUNG: `--import-realm` importiert nur, wenn der Realm noch +# NICHT existiert — nach dem ersten Start werden Änderungen an +# `keycloak/import/realm-holzleitner.json` also NICHT mehr automatisch +# übernommen. Realm bewusst neu importieren / frische Demo-Daten: +# docker compose down -v && docker compose up -d (löscht ALLE Volumes) +# oder gezielt nur Keycloak: +# docker volume rm holzleitner-backend_keycloak-data (Container vorher stoppen) # Komplettes Reset: `docker compose down -v`. services: @@ -38,10 +47,22 @@ services: KC_BOOTSTRAP_ADMIN_PASSWORD: admin # Health-Endpoints für externe Checks aktivieren. KC_HEALTH_ENABLED: "true" + # Hostname, den Keycloak ins `iss`-Claim und in die Discovery-URLs + # schreibt. Per Default die LAN-IP (Android-Test über WLAN). Für den + # USB-Tunnel-Modus (`adb reverse`) mit + # KC_HOSTNAME=localhost docker compose up -d + # überschreiben — dann muss auch das Backend mit + # KEYCLOAK_ISSUER_URL=http://localhost:8080/realms/holzleitner laufen + # (siehe tool/dev_usb.sh, das beides zusammen hochfährt). + KC_HOSTNAME: "${KC_HOSTNAME:-192.168.0.138}" ports: - "8080:8080" volumes: + # Persistente H2-DB: provisionierte Fahrer-Konten überleben Neustarts. + - keycloak-data:/opt/keycloak/data + # Realm-Import (nur beim ersten Start wirksam, s. Kopf-Kommentar). - ./keycloak/import:/opt/keycloak/data/import:ro volumes: postgres-data: + keycloak-data: diff --git a/keycloak/import/realm-holzleitner.json b/keycloak/import/realm-holzleitner.json index 50ce6df..34c4102 100644 --- a/keycloak/import/realm-holzleitner.json +++ b/keycloak/import/realm-holzleitner.json @@ -34,9 +34,23 @@ "temporary": false } ], - "realmRoles": ["driver"], + "realmRoles": [ + "driver" + ], "attributes": { - "personalnummer": ["1001"] + "personalnummer": [ + "1001" + ] + } + }, + { + "username": "service-account-holzleitner-provisioner", + "enabled": true, + "serviceAccountClientId": "holzleitner-provisioner", + "clientRoles": { + "realm-management": [ + "manage-users" + ] } } ], @@ -55,7 +69,9 @@ "http://localhost:*", "holzleitner://*" ], - "webOrigins": ["+"], + "webOrigins": [ + "+" + ], "attributes": { "post.logout.redirect.uris": "+", "pkce.code.challenge.method": "S256" @@ -87,6 +103,31 @@ } } ] + }, + { + "clientId": "holzleitner-provisioner", + "name": "Holzleitner Provisioner (Service Account)", + "description": "Confidential Client: legt beim ERP-Sync Fahrer-Konten im Realm an (manage-users).", + "enabled": true, + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "provisioner-dev-secret", + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "implicitFlowEnabled": false } - ] -} + ], + "components": { + "org.keycloak.userprofile.UserProfileProvider": [ + { + "providerId": "declarative-user-profile", + "config": { + "kc.user.profile.config": [ + "{\"attributes\": [{\"name\": \"username\", \"displayName\": \"${username}\", \"validations\": {\"length\": {\"min\": 3, \"max\": 255}, \"username-prohibited-characters\": {}, \"up-username-not-idn-homograph\": {}}, \"permissions\": {\"view\": [\"admin\", \"user\"], \"edit\": [\"admin\", \"user\"]}, \"multivalued\": false}, {\"name\": \"email\", \"displayName\": \"${email}\", \"validations\": {\"email\": {}, \"length\": {\"max\": 255}}, \"permissions\": {\"view\": [\"admin\", \"user\"], \"edit\": [\"admin\", \"user\"]}, \"multivalued\": false}, {\"name\": \"firstName\", \"displayName\": \"${firstName}\", \"validations\": {\"length\": {\"max\": 255}, \"person-name-prohibited-characters\": {}}, \"required\": {\"roles\": [\"user\"]}, \"permissions\": {\"view\": [\"admin\", \"user\"], \"edit\": [\"admin\", \"user\"]}, \"multivalued\": false}, {\"name\": \"lastName\", \"displayName\": \"${lastName}\", \"validations\": {\"length\": {\"max\": 255}, \"person-name-prohibited-characters\": {}}, \"permissions\": {\"view\": [\"admin\", \"user\"], \"edit\": [\"admin\", \"user\"]}, \"multivalued\": false}], \"groups\": [{\"name\": \"user-metadata\", \"displayHeader\": \"User metadata\", \"displayDescription\": \"Attributes, which refer to user metadata\"}], \"unmanagedAttributePolicy\": \"ENABLED\"}" + ] + } + } + ] + } +} \ No newline at end of file diff --git a/migrations/0007_scan_audit_unremove.sql b/migrations/0007_scan_audit_unremove.sql new file mode 100644 index 0000000..3244625 --- /dev/null +++ b/migrations/0007_scan_audit_unremove.sql @@ -0,0 +1,17 @@ +-- 0007_scan_audit_unremove.sql +-- +-- Erweitert den CHECK-Constraint auf scan_audit.action um den Wert +-- 'unremove'. Hintergrund: Phase C+D-4 erlaubt das Wiederherstellen +-- entfernter Items — jedes Apply landet als eigene Audit-Zeile, und +-- ohne diesen Constraint-Update bricht die Insert-Query mit 500. +-- +-- Reversibel: ältere Server-Versionen, die 'unremove' nicht kennen, +-- können nach diesem Constraint-Update weiterhin die anderen Actions +-- normal schreiben — die Erweiterung ist additiv. + +ALTER TABLE scan_audit + DROP CONSTRAINT IF EXISTS scan_audit_action_check; + +ALTER TABLE scan_audit + ADD CONSTRAINT scan_audit_action_check + CHECK (action IN ('scan','unscan','hold','unhold','remove','unremove')); diff --git a/migrations/0008_payment_methods.sql b/migrations/0008_payment_methods.sql new file mode 100644 index 0000000..2c65ab8 --- /dev/null +++ b/migrations/0008_payment_methods.sql @@ -0,0 +1,57 @@ +-- 0008_payment_methods.sql +-- +-- Zahlungs-Stammdaten + Zahlungs-Felder an Lieferungen. +-- +-- Designentscheidung: eigene Tabelle statt Enum, damit Methoden über +-- einen API-Endpoint zur Laufzeit angelegt/deaktiviert werden können +-- (z. B. neue Anbieter wie PayPal oder Klarna). FK auf `deliveries` +-- mit `ON DELETE RESTRICT` — historische Lieferungen sollen ihren +-- Methoden-Bezug niemals verlieren. Soft-Delete ist als +-- `active`-Flag möglich; Hard-Delete bleibt für nie-genutzte Methoden. + +CREATE TABLE payment_methods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- `code` ist der stabile Programm-Identifier (z. B. „cash"). Wird vom + -- Aufrufer vergeben, muss eindeutig sein. App und Berichts-Code + -- können darüber spezielle Methoden referenzieren, ohne die UUID + -- kennen zu müssen. + code TEXT NOT NULL UNIQUE, + -- Display-Name in der UI — frei änderbar via PATCH, z. B. + -- „EC-Karte" → „Girocard". + name TEXT NOT NULL, + -- Soft-Delete: deaktivierte Methoden bleiben im DB-Stamm und + -- bleiben damit für historische Lieferungen referenzierbar. + -- Bei neuen Lieferungen filtert die App standardmäßig auf + -- `active = true`. + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Initialer Stamm: die vier vom CEO definierten Methoden mit +-- deterministischen UUIDs, damit Demo-Seeds und Tests stabile Keys +-- haben. +INSERT INTO payment_methods (id, code, name) VALUES + ('99999999-9999-9999-9999-999999999901', 'cash', 'Bar'), + ('99999999-9999-9999-9999-999999999902', 'ec_card', 'EC-Karte'), + ('99999999-9999-9999-9999-999999999903', 'credit_card', 'Kreditkarte'), + ('99999999-9999-9999-9999-999999999904', 'invoice', 'Auf Rechnung (14 Tage netto)'); + +-- Lieferungen: Vorauszahlung + gewählte Zahlungsmethode für den Rest. +-- `prepaid_amount` ist `DOUBLE PRECISION` (≈ f64), kein NUMERIC — +-- für 2 Nachkommastellen reicht das locker und die sqlx-Default- +-- Features unterstützen es nativ ohne extra Crates. +ALTER TABLE deliveries + ADD COLUMN prepaid_amount DOUBLE PRECISION NOT NULL DEFAULT 0 + CHECK (prepaid_amount >= 0), + -- Default beim Migrieren auf „cash" — die häufigste Methode. Die + -- Default-Klausel räumen wir gleich wieder ab, damit zukünftige + -- INSERTs den FK explizit setzen müssen. + ADD COLUMN payment_method_id UUID NOT NULL + DEFAULT '99999999-9999-9999-9999-999999999901' + REFERENCES payment_methods(id) ON DELETE RESTRICT; + +ALTER TABLE deliveries ALTER COLUMN payment_method_id DROP DEFAULT; + +-- Lookup-Index: Reports oder Filter „alle Lieferungen mit Rechnung" +-- profitieren davon. Ohne Index wäre das ein voller Tabelle-Scan. +CREATE INDEX deliveries_payment_method ON deliveries(payment_method_id); diff --git a/migrations/0009_warehouse_filiale_rename.sql b/migrations/0009_warehouse_filiale_rename.sql new file mode 100644 index 0000000..73290d7 --- /dev/null +++ b/migrations/0009_warehouse_filiale_rename.sql @@ -0,0 +1,16 @@ +-- 0009_warehouse_filiale_rename.sql +-- +-- Wording-Angleichung: Die App spricht durchgängig von „Filiale" statt +-- „Außenlager" (Nicht-Standard-Lager sind faktisch andere Filialen, aus +-- denen sperrige Geräte separat geholt werden). Der Stammdaten-Name wird +-- entsprechend angepasst. +-- +-- Bewusst als eigene Migration statt Edit an 0002: 0002 ist bereits +-- angewendet, eine nachträgliche Änderung würde die sqlx-Checksum brechen. +-- Dieses idempotente UPDATE wirkt sowohl auf bestehende DBs als auch auf +-- frische Setups (dort seedet 0002 zuerst „Außenlager A", danach benennt +-- diese Migration um). +UPDATE warehouses + SET name = 'Filiale Freilassing' + WHERE code = 'A' + AND name = 'Außenlager A'; diff --git a/migrations/0010_app_state.sql b/migrations/0010_app_state.sql new file mode 100644 index 0000000..e630cf3 --- /dev/null +++ b/migrations/0010_app_state.sql @@ -0,0 +1,18 @@ +-- 0010_app_state.sql +-- +-- Generischer Key-Value-Store für kleine, langlebige Backend-Zustände, die +-- Prozess-Neustarts überleben müssen, aber kein eigenes Schema rechtfertigen. +-- +-- Erster Nutzer: die GSD/DOCUframe-Session-Id. Der GSD-Server „blockt" einen +-- Lizenz-Seat pro Session, bis die Session abläuft oder via +-- `/v1/license/release` freigegeben wird. Ginge die Session-Id bei einem +-- Backend-Neustart verloren (reiner In-Memory-Cache), bliebe der Seat +-- verwaist geblockt — bei wiederholten Neustarts droht ein Lizenz-Lockout. +-- Deshalb persistieren wir die Id hier durabel (überlebt Restart UND +-- Redeploy, da im DB-Volume) und können sie wiederverwenden bzw. gezielt +-- freigeben. +CREATE TABLE app_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/migrations/0011_attachments.sql b/migrations/0011_attachments.sql new file mode 100644 index 0000000..64ddf1e --- /dev/null +++ b/migrations/0011_attachments.sql @@ -0,0 +1,34 @@ +-- 0011_attachments.sql +-- +-- Metadaten-Registry für hochgeladene Dateien (aktuell Bild-Notizen). +-- +-- Die eigentlichen Bytes liegen in DOCUframe; hier halten wir die +-- Metadaten und die DOCUframe-Referenz (`docuframe_object_id` = ~ObjectID). +-- Die App referenziert ausschließlich `attachments.id` (unsere UUID) — +-- DOCUframe bleibt damit ein austauschbares Implementierungsdetail +-- (z. B. später Object-Storage statt DOCUframe). +-- +-- Verknüpfung zur Notiz: `delivery_notes.image_attachment` enthält die +-- `attachments.id` (als Text). Über `delivery_id` hängt der Anhang +-- zusätzlich direkt an der Lieferung (CASCADE-Cleanup). +CREATE TABLE attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- DOCUframe ~ObjectID — für den späteren Download der Bytes. + docuframe_object_id TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0), + -- Originaldateiname beim Upload (optional). + filename TEXT, + -- SHA-256 der Bytes (Hex) — Integrität / Dedup. + checksum_sha256 TEXT NOT NULL, + -- Bildabmessungen in Pixeln (nullable: nicht jede Datei ist ein Bild + -- bzw. das Format ist evtl. nicht erkennbar). + width INT, + height INT, + -- Personalnummer des Hochladenden (aus dem JWT). + uploaded_by BIGINT NOT NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE +); + +CREATE INDEX attachments_delivery ON attachments(delivery_id); diff --git a/migrations/0012_credit_quantity.sql b/migrations/0012_credit_quantity.sql new file mode 100644 index 0000000..68071e5 --- /dev/null +++ b/migrations/0012_credit_quantity.sql @@ -0,0 +1,35 @@ +-- 0012_credit_quantity.sql +-- +-- Mengen-Gutschrift ("Belegzeile ganz oder teilweise entfernen"). +-- +-- Bis hierher war "entfernen" binär: scan_status kippte auf 'removed' und +-- die ganze Position galt als zurückgegeben. Für Teilmengen +-- ("Kunde nimmt 1 von 3 Stück nicht an") braucht es eine eigene +-- Mengen-Dimension, getrennt von scanned_quantity (= verladen): +-- +-- required_quantity = ERP-Soll (unverändert) +-- scanned_quantity = verladen / Beladen (unverändert) +-- credited_quantity = Gutschrift / zurück (NEU) +-- ausgeliefert = required - credited (abgeleitet) +-- +-- scan_status = 'removed' bedeutet künftig "voll gutgeschrieben" +-- (credited == required). Teil-Gutschrift = 0 < credited < required; +-- der Status bleibt dann 'done' (bzw. was er vorher war). + +ALTER TABLE delivery_items + ADD COLUMN credited_quantity INT NOT NULL DEFAULT 0 + CHECK (credited_quantity >= 0 AND credited_quantity <= required_quantity); + +-- Audit der Gutschrift läuft über dieselbe append-only scan_audit-Tabelle +-- wie Scan/Hold. Die bestehenden delta/resulting_quantity beschreiben die +-- SCAN-Dimension; für remove/unremove kommen zwei eigene, nur dort gefüllte +-- Spalten dazu, damit die beiden Dimensionen sauber getrennt bleiben. +-- +-- credit_delta = +n bei remove, -n bei unremove +-- resulting_credited_quantity = Snapshot von credited_quantity danach +-- +-- Beide nullable: bei scan/unscan/hold/unhold/etc. bleiben sie NULL. +ALTER TABLE scan_audit + ADD COLUMN credit_delta INT, + ADD COLUMN resulting_credited_quantity INT + CHECK (resulting_credited_quantity IS NULL OR resulting_credited_quantity >= 0); diff --git a/migrations/0013_note_credit_link.sql b/migrations/0013_note_credit_link.sql new file mode 100644 index 0000000..d1bb1bd --- /dev/null +++ b/migrations/0013_note_credit_link.sql @@ -0,0 +1,15 @@ +-- 0013_note_credit_link.sql +-- +-- Verknüpft eine Notiz mit der Belegzeile, für die sie als Gutschrift-Grund +-- angelegt wurde. Damit lässt sich die Notiz beim Zurücknehmen der Gutschrift +-- (Unremove) gezielt wieder löschen. +-- +-- Nullable: normale Notizen (Text/Foto ohne Gutschrift-Bezug) haben hier NULL. +-- ON DELETE SET NULL: verschwindet die Belegzeile mal (z. B. ERP-Resync), bleibt +-- die Notiz als reine Doku erhalten und wird nur „entkoppelt". +ALTER TABLE delivery_notes + ADD COLUMN credit_delivery_item_id UUID + REFERENCES delivery_items(id) ON DELETE SET NULL; + +CREATE INDEX delivery_notes_credit_item + ON delivery_notes (credit_delivery_item_id); diff --git a/migrations/0014_delivery_credit_audit.sql b/migrations/0014_delivery_credit_audit.sql new file mode 100644 index 0000000..c8a8766 --- /dev/null +++ b/migrations/0014_delivery_credit_audit.sql @@ -0,0 +1,43 @@ +-- 0014_delivery_credit_audit.sql +-- +-- Betrags-Gutschrift pro Lieferung (Geld-Nachlass, unabhängig von Stückzahl; +-- ≤150 €, in 10-€-Schritten, Pflichtgrund). +-- +-- Append-only Audit-Log — analog scan_audit: jede Änderung (set/remove) ist +-- eine eigene Zeile, nichts wird geupdated/gelöscht. Der "aktuelle" Stand +-- einer Lieferung ist das jüngste Ereignis (DISTINCT ON delivery_id … +-- ORDER BY recorded_at DESC): action='set' → Betrag+Grund, action='remove' +-- → keine Gutschrift. +-- +-- Idempotenz: client_event_id ist UNIQUE. Ein Netz-Retry desselben Events +-- kollidiert auf dem Index (INSERT … ON CONFLICT DO NOTHING) — der Server +-- liefert den aktuellen Stand zurück, ohne eine Dublette anzuhängen. + +CREATE TABLE delivery_credit_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Idempotenz-Schlüssel vom Client. + client_event_id UUID NOT NULL UNIQUE, + + delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, + + action TEXT NOT NULL CHECK (action IN ('set', 'remove')), + + -- Resultierender Betrag NACH diesem Ereignis (in Cent). 0 bei 'remove'. + -- Harte Obergrenze 150 € als CHECK; die 10-€-Schritt-Regel prüft der + -- Use Case (fachlich, leichter änderbar als ein DB-CHECK). + amount_cents BIGINT NOT NULL DEFAULT 0 + CHECK (amount_cents >= 0 AND amount_cents <= 15000), + + -- Pflicht bei 'set' (im Use Case erzwungen), bei 'remove' optional. + reason TEXT, + + -- Akteur: Personalnummer aus dem JWT (Pflicht), Fahrzeug optional. + author_personalnummer BIGINT NOT NULL, + author_car_id UUID, + + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX delivery_credit_audit_delivery + ON delivery_credit_audit (delivery_id, recorded_at DESC); diff --git a/migrations/0015_note_amount_credit_flag.sql b/migrations/0015_note_amount_credit_flag.sql new file mode 100644 index 0000000..25ac447 --- /dev/null +++ b/migrations/0015_note_amount_credit_flag.sql @@ -0,0 +1,11 @@ +-- 0015_note_amount_credit_flag.sql +-- +-- Markiert eine Notiz als Grund-Notiz einer Betrags-Gutschrift (Geld-Nachlass +-- auf Lieferungs-Ebene). Anders als die Mengen-Gutschrift hängt der Nachlass +-- nicht an einer Belegzeile, daher kein credit_delivery_item_id, sondern ein +-- einfaches Flag. +-- +-- Damit kann der Client beim Entfernen der Gutschrift die zugehörige(n) +-- Grund-Notiz(en) der Lieferung gezielt wieder löschen. +ALTER TABLE delivery_notes + ADD COLUMN is_amount_credit_note BOOLEAN NOT NULL DEFAULT false; diff --git a/migrations/0016_services.sql b/migrations/0016_services.sql new file mode 100644 index 0000000..32ffd45 --- /dev/null +++ b/migrations/0016_services.sql @@ -0,0 +1,65 @@ +-- 0016_services.sql +-- +-- Services (früher „Lieferoptionen") — admin-konfigurierbare Stammdaten plus +-- Pro-Lieferung-Werte. +-- +-- Im alten ERPframe-Stand kamen die Optionen datengetrieben pro Lieferung +-- (key/display/numerical/value). Neu: der Administrator pflegt die Definitionen +-- frei (anlegen/ändern/löschen, Muster wie payment_methods), der Fahrer wählt +-- sie in Phase 4 pro Lieferung aus (Checkbox bzw. Zahl mit min/max). + +CREATE TABLE services ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Stabiler Programm-Identifier (z. B. "podium_setup"). Eindeutig. + key TEXT NOT NULL UNIQUE, + -- Anzeige-Name in der UI (z. B. "Podest aufgestellt"). Frei änderbar. + name TEXT NOT NULL, + -- 'boolean' → Checkbox, 'numeric' → Zahlenfeld mit min/max. + kind TEXT NOT NULL CHECK (kind IN ('boolean', 'numeric')), + -- Nur für numeric relevant; bei boolean NULL. + min_value INT, + max_value INT, + -- Soft-Delete: deaktivierte Services bleiben für historische Lieferungen + -- referenzierbar, tauchen aber im Default-Listing nicht auf. + active BOOLEAN NOT NULL DEFAULT TRUE, + -- Anzeige-Reihenfolge in Phase 4. + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- boolean trägt keine Grenzen; numeric darf welche haben. + CONSTRAINT services_bounds_kind CHECK ( + (kind = 'boolean' AND min_value IS NULL AND max_value IS NULL) + OR kind = 'numeric' + ), + -- Falls beide gesetzt: min ≤ max. + CONSTRAINT services_min_le_max CHECK ( + min_value IS NULL OR max_value IS NULL OR min_value <= max_value + ) +); + +-- Initialer Stamm (deterministische UUIDs für stabile Seeds/Tests). Der +-- Administrator kann sie jederzeit ändern/deaktivieren/ergänzen. +INSERT INTO services (id, key, name, kind, min_value, max_value, sort_order) VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa01', 'podium_setup', 'Podest aufgestellt', 'boolean', NULL, NULL, 1), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa02', 'old_appliance_taken', 'Altgerät mitgenommen', 'boolean', NULL, NULL, 2), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa03', 'commissioning', 'Inbetriebnahme', 'boolean', NULL, NULL, 3), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa04', 'floor', 'Stockwerk', 'numeric', 0, 20, 4); + +-- Pro-Lieferung gewählter Wert eines Service. Genau eine Zeile je +-- (Lieferung, Service) — Upsert. `bool_value` für boolean, `numeric_value` +-- für numeric (Use Case stellt sicher, dass nur das passende Feld gesetzt ist). +CREATE TABLE delivery_services ( + delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, + -- RESTRICT: ein referenzierter Service darf nicht hart gelöscht werden + -- (sonst verlöre die Lieferung ihren Wert) — stattdessen deaktivieren. + service_id UUID NOT NULL REFERENCES services(id) ON DELETE RESTRICT, + bool_value BOOLEAN, + numeric_value INT, + author_personalnummer BIGINT, + author_car_id UUID, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + PRIMARY KEY (delivery_id, service_id) +); + +CREATE INDEX delivery_services_delivery ON delivery_services (delivery_id); diff --git a/migrations/0017_seed_real_services.sql b/migrations/0017_seed_real_services.sql new file mode 100644 index 0000000..9a0a6c2 --- /dev/null +++ b/migrations/0017_seed_real_services.sql @@ -0,0 +1,32 @@ +-- 0017_seed_real_services.sql +-- +-- Ersetzt den Platzhalter-Seed aus 0016 durch die **echten** Lieferoptionen +-- aus dem Altsystem. Quelle: DOCUframe-Makro +-- `_SV/_DF_APP/_initDelivery.dfm` (gegengeprüft in `_getOptionColumnByKey.dfm`) +-- — 8 Optionen, 2 numerisch (Stückzahlen), 6 boolean. Im Makro gibt es keine +-- min/max-Werte; für die Stückzahlen setzen wir fachlich `min = 0`, kein max. +-- +-- (Migration 0016 wurde bereits angewandt und bleibt unangetastet — daher die +-- Korrektur in einer eigenen Migration statt eines Edits.) + +-- Platzhalter-Seeds entfernen. Erst etwaige Pro-Lieferung-Werte lösen +-- (FK ON DELETE RESTRICT), dann die Definitionen. +DELETE FROM delivery_services + WHERE service_id IN ( + SELECT id FROM services + WHERE key IN ('podium_setup', 'old_appliance_taken', 'commissioning', 'floor') + ); +DELETE FROM services + WHERE key IN ('podium_setup', 'old_appliance_taken', 'commissioning', 'floor'); + +-- Echte Optionen (deterministische UUIDs für stabile Seeds/Tests). +-- sort_order = Reihenfolge wie in der alten App (numerische zuerst). +INSERT INTO services (id, key, name, kind, min_value, max_value, sort_order) VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa01', 'AMOUNT_OLD_DEVICES', 'Anzahl alter Geräte', 'numeric', 0, NULL, 1), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa02', 'AMOUNT_MOUNTED_DEVICES', 'Anzahl montierter Geräte', 'numeric', 0, NULL, 2), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa03', 'DEVICE_ALIGNED', 'Gerät ausgerichtet', 'boolean', NULL, NULL, 3), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa04', 'ON_PLATFORM', 'Auf Podest gestellt', 'boolean', NULL, NULL, 4), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa05', 'REVISIT_REQUIRED', 'Erneute Anfahrt notwendig', 'boolean', NULL, NULL, 5), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa06', 'TEST_RUN_COMPLETE', 'Testdurchlauf durchgeführt', 'boolean', NULL, NULL, 6), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa07', 'TIGHTNESS_TEST_DONE', 'Dichtigkeitstest durchgeführt','boolean', NULL, NULL, 7), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa08', 'TUBE_EXTENSION_REQUIRED', 'Schlaucherweiterung notwendig','boolean', NULL, NULL, 8); diff --git a/migrations/0018_delivery_total_amount.sql b/migrations/0018_delivery_total_amount.sql new file mode 100644 index 0000000..ab731d7 --- /dev/null +++ b/migrations/0018_delivery_total_amount.sql @@ -0,0 +1,11 @@ +-- 0018_delivery_total_amount.sql +-- +-- Gesamt-/Rechnungsbetrag (brutto, EUR) einer Lieferung. Zusammen mit +-- `prepaid_amount` (bei Bestellung bezahlt) und der Betrags-Gutschrift rechnet +-- die App daraus den offenen Betrag: +-- offen = max(0, total_amount - prepaid_amount - gutschrift) +-- +-- Wird vom ERP-Sync geliefert. Default 0, bis das Sync-Makro den Wert mitsendet. +ALTER TABLE deliveries + ADD COLUMN total_amount DOUBLE PRECISION NOT NULL DEFAULT 0 + CHECK (total_amount >= 0); diff --git a/migrations/0019_item_unit_price.sql b/migrations/0019_item_unit_price.sql new file mode 100644 index 0000000..183bfb8 --- /dev/null +++ b/migrations/0019_item_unit_price.sql @@ -0,0 +1,16 @@ +-- 0019_item_unit_price.sql +-- +-- Preis pro Belegzeile/Artikel + Entfernen des statischen Liefer-Totals. +-- +-- Hintergrund: Der Gesamt-/Warenwert einer Lieferung wird jetzt **aus den +-- Artikeln berechnet** (Σ Stückpreis × ausgelieferte Menge), nicht mehr als +-- statischer Betrag geführt. Ausgelieferte Menge = required - credited, d. h. +-- entfernte/teil-entfernte Positionen reduzieren den Wert automatisch. Damit +-- ist `deliveries.total_amount` (Migration 0018) überflüssig und wird wieder +-- entfernt — eine Quelle der Wahrheit (der Stückpreis). + +ALTER TABLE deliveries DROP COLUMN total_amount; + +ALTER TABLE delivery_items + ADD COLUMN unit_price DOUBLE PRECISION NOT NULL DEFAULT 0 + CHECK (unit_price >= 0); diff --git a/migrations/0020_delivery_completions.sql b/migrations/0020_delivery_completions.sql new file mode 100644 index 0000000..4394b5f --- /dev/null +++ b/migrations/0020_delivery_completions.sql @@ -0,0 +1,40 @@ +-- 0020_delivery_completions.sql +-- +-- Abschluss-Beleg einer Lieferung: dokumentiert den Moment, in dem Kunde +-- und Fahrer unterschreiben und die Lieferung auf `completed` geht. +-- +-- Genau EINE Zeile pro Lieferung (PK = delivery_id). Der Abschluss ist +-- terminal — ein zweiter Versuch wird vom Use Case über das +-- `state == active`-Gate abgelehnt; existiert die Zeile schon und steht +-- die Lieferung auf `completed`, liefert der Server idempotent denselben +-- Stand zurück. +-- +-- Die beiden Unterschriften liegen NICHT in der DB, sondern als PNG-Dateien +-- lokal im Backend-Server (Pfad konfigurierbar). Hier stehen nur die +-- relativen Datei-Referenzen. +-- +-- Die Checkbox-Bestätigungen des Kunden werden hier dauerhaft dokumentiert: +-- * receipt_confirmed — „Ware ordnungsgemäß erhalten / Aufbau korrekt" +-- * notes_acknowledged — „Anmerkungen zur Lieferung zur Kenntnis genommen" +-- `acknowledged_note_ids` hält die konkreten Notiz-IDs fest, die zum +-- Zeitpunkt des Abschlusses sichtbar waren und mit-bestätigt wurden +-- (Audit-Robustheit, falls später Notizen dazukommen/wegfallen). + +CREATE TABLE delivery_completions ( + delivery_id UUID PRIMARY KEY REFERENCES deliveries(id) ON DELETE CASCADE, + + -- Relative Pfade/Referenzen der lokal gespeicherten Signatur-PNGs. + customer_signature_path TEXT NOT NULL, + driver_signature_path TEXT NOT NULL, + + -- Dokumentierte Checkbox-Bestätigungen des Kunden. + receipt_confirmed BOOLEAN NOT NULL, + notes_acknowledged BOOLEAN NOT NULL, + acknowledged_note_ids UUID[] NOT NULL DEFAULT '{}', + + -- Akteur: Personalnummer aus dem JWT (Pflicht), Fahrzeug optional. + completed_by_personalnummer BIGINT NOT NULL, + completed_by_car_id UUID, + + completed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/migrations/0021_remove_credit_card.sql b/migrations/0021_remove_credit_card.sql new file mode 100644 index 0000000..2e4ba2b --- /dev/null +++ b/migrations/0021_remove_credit_card.sql @@ -0,0 +1,18 @@ +-- 0021_remove_credit_card.sql +-- +-- Entfernt die Zahlungsmethode `credit_card` (Kreditkarte) aus den +-- Stammdaten. Sie wurde in Migration 0008 geseedet, kommt aber im +-- ERP-Liefer-Flow nicht vor (Allowlist der ERP-Zahlungsbedingungen: +-- D16/d53/D10 = cash/ec_card/invoice). +-- +-- 0008 selbst bleibt unangetastet (angewandte Migration → Checksum). Daher +-- diese Folge-Migration: erst etwaige Referenzen umhängen (FK +-- ON DELETE RESTRICT auf deliveries.payment_method_id), dann löschen. + +-- Bestehende Lieferungen, die noch auf Kreditkarte zeigen, auf `cash` +-- (ERP-Default) umstellen — verhindert die FK-Verletzung beim DELETE. +UPDATE deliveries +SET payment_method_id = (SELECT id FROM payment_methods WHERE code = 'cash') +WHERE payment_method_id = (SELECT id FROM payment_methods WHERE code = 'credit_card'); + +DELETE FROM payment_methods WHERE code = 'credit_card'; diff --git a/migrations/0022_scan_audit_manual.sql b/migrations/0022_scan_audit_manual.sql new file mode 100644 index 0000000..b6268e4 --- /dev/null +++ b/migrations/0022_scan_audit_manual.sql @@ -0,0 +1,12 @@ +-- 0022_scan_audit_manual.sql +-- +-- Fallback-Scan: markiert einen Scan-Eintrag als **manuell bestätigt** +-- ("Ich habe den Artikel geladen"), wenn der Barcode-/QR-Scan nicht klappt. +-- +-- Reine Audit-Information: WIE der Scan zustande kam. Die Mengen-/Status- +-- Wahrheit (scanned_quantity, scan_status) bleibt unverändert — ein manueller +-- Scan ist fachlich ein vollwertiger Scan, nur ohne Barcode-Lesung. +-- Default false: alle bestehenden Einträge gelten als regulär gescannt. + +ALTER TABLE scan_audit + ADD COLUMN manual BOOLEAN NOT NULL DEFAULT false; diff --git a/migrations/0023_item_parent_artikel_nr.sql b/migrations/0023_item_parent_artikel_nr.sql new file mode 100644 index 0000000..1fd2773 --- /dev/null +++ b/migrations/0023_item_parent_artikel_nr.sql @@ -0,0 +1,15 @@ +-- 0023_item_parent_artikel_nr.sql +-- +-- Stücklisten-Hierarchie sichtbar machen: Artikelnummer des Oberartikels, +-- zu dem eine Komponente gehört. +-- +-- Bisher teilten sich Oberartikel und seine Komponenten nur die +-- `belegzeilen_nr`; die `komponenten_artikel_nr` trägt die EIGENE Nummer der +-- Komponente (muss je belegzeilen_nr eindeutig sein) und taugt daher nicht +-- als Parent-Referenz. `parent_artikel_nr` schließt diese Lücke: +-- * Oberartikel / reguläre Belegzeile → NULL +-- * Komponente → Artikelnummer des Oberartikels +-- +-- Damit kann die App Komponenten unter ihrem Oberartikel einrücken. +ALTER TABLE delivery_items + ADD COLUMN parent_artikel_nr TEXT; diff --git a/migrations/0024_completion_payment_collected.sql b/migrations/0024_completion_payment_collected.sql new file mode 100644 index 0000000..c3a2911 --- /dev/null +++ b/migrations/0024_completion_payment_collected.sql @@ -0,0 +1,20 @@ +-- 0024_completion_payment_collected.sql +-- +-- Inkasso-Bestätigung beim Abschluss: Wenn beim Abschluss noch ein offener +-- Betrag besteht UND die Zahlungsmethode ein Vor-Ort-Inkasso ist (Bar oder +-- EC-Karte), muss der Fahrer vor den Unterschriften bestätigen, dass das Geld +-- erhalten/abgerechnet wurde. „Auf Rechnung" (invoice) bleibt bewusst offen → +-- kein Inkasso, keine Bestätigung. +-- +-- * payment_collected — Fahrer hat das Inkasso bestätigt. Bei +-- Lieferungen ohne Inkasso (offen == 0 oder +-- Methode = invoice) bleibt es `false`. +-- * collected_amount_cents — Snapshot des tatsächlich kassierten offenen +-- Betrags in Cent (server-seitig autoritativ +-- berechnet). NULL = kein Inkasso erforderlich. +-- Eingefroren, damit der Report-/Audit-Wert +-- unabhängig von späteren Daten-Resyncs bleibt. + +ALTER TABLE delivery_completions + ADD COLUMN payment_collected BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN collected_amount_cents BIGINT; diff --git a/migrations/0025_delivery_belegart_code.sql b/migrations/0025_delivery_belegart_code.sql new file mode 100644 index 0000000..4241528 --- /dev/null +++ b/migrations/0025_delivery_belegart_code.sql @@ -0,0 +1,18 @@ +-- 0025_delivery_belegart_code.sql +-- +-- Belegart lesbar machen: bisher steht nur die `erp_belegart_id` (die ERP- +-- `row_id` der Belegart, z. B. 24) in `deliveries` — eine nackte Nummer ohne +-- Aussagekraft. Der Sync zieht jetzt zusätzlich den Belegart-Code und die +-- Bezeichnung aus ERPframe (`Belegarten.Belegart` / `.Bezeichnung`, +-- z. B. „VL5" / „Lieferschein EH") mit, damit der Report sie statt der +-- nackten ID anzeigen kann. +-- +-- * erp_belegart_code — Kurzcode der Belegart (z. B. „VL5"). +-- * erp_belegart_name — Klartext-Bezeichnung (z. B. „Lieferschein EH"). +-- +-- Beide nullable: Altbestand vor diesem Sync hat sie nicht; der nächste +-- (Re-)Sync füllt sie idempotent nach. + +ALTER TABLE deliveries + ADD COLUMN erp_belegart_code TEXT, + ADD COLUMN erp_belegart_name TEXT; diff --git a/migrations/0026_delivery_report_jobs.sql b/migrations/0026_delivery_report_jobs.sql new file mode 100644 index 0000000..c073a2a --- /dev/null +++ b/migrations/0026_delivery_report_jobs.sql @@ -0,0 +1,40 @@ +-- 0026_delivery_report_jobs.sql +-- +-- Zustandsanker für die Übertragung des PDF-Lieferreports an DOCUframe. +-- Beim Abschluss wird der Report erzeugt und in DOCUframe abgelegt; das ist +-- ein mehrstufiger, netzabhängiger Vorgang (Upload → ~ObjectID → Makro +-- `_SV_assignDeliveryReport`). Damit nichts verloren geht und fehlgeschlagene +-- Übertragungen per Cron wiederholt werden können, hält diese Tabelle den +-- Fortschritt **hart** in Postgres. +-- +-- Genau eine Zeile pro Lieferung (PK = delivery_id). `status` ist die +-- Resume-Marke; ein Retry führt nur die noch offenen Schritte aus: +-- * 'pending' — angelegt, noch nichts übertragen +-- * 'uploaded' — PDF liegt in DOCUframe, `docuframe_object_id` gesetzt, +-- Makro-Zuordnung steht noch aus +-- * 'done' — Makro erfolgreich; lokale Dateien wurden aufgeräumt +-- +-- Fehlersemantik: schlägt ein Schritt fehl, bleibt `status` auf der erreichten +-- Stufe, `last_error`/`attempts`/`last_attempt_at` werden gesetzt. Der Cron +-- greift alle Zeilen mit status <> 'done' erneut auf. + +CREATE TABLE delivery_report_jobs ( + delivery_id UUID PRIMARY KEY REFERENCES deliveries(id) ON DELETE CASCADE, + belegnummer TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'uploaded', 'done')), + -- DOCUframe ~ObjectID des hochgeladenen Reports — hart gespeichert nach + -- dem Upload, damit ein Retry nicht doppelt hochlädt. + docuframe_object_id TEXT, + -- Zeitpunkt der erfolgreichen Makro-Zuordnung (= „Report hochgeladen"). + report_uploaded_at TIMESTAMPTZ, + attempts INT NOT NULL DEFAULT 0, + last_error TEXT, + last_attempt_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Schneller Zugriff des Retry-Cron auf offene Jobs. +CREATE INDEX delivery_report_jobs_open ON delivery_report_jobs (status) + WHERE status <> 'done'; diff --git a/migrations/0027_attachment_deleted.sql b/migrations/0027_attachment_deleted.sql new file mode 100644 index 0000000..3b06ea0 --- /dev/null +++ b/migrations/0027_attachment_deleted.sql @@ -0,0 +1,14 @@ +-- 0027_attachment_deleted.sql +-- +-- „Soft-Delete" für Bild-Notizen: Sobald der PDF-Lieferreport (in dem die +-- Bilder eingebettet sind) erfolgreich in DOCUframe liegt, wird die lokale +-- Bilddatei gelöscht, um keinen Datenmüll zu halten. Die Metadaten-Zeile +-- bleibt jedoch erhalten — so ist weiterhin ersichtlich, dass es ein Bild +-- gab. Die App zeigt für solche Anhänge keinen Vorschau-Download mehr, +-- sondern den Hinweis, dass das Bild im Lieferbericht enthalten ist. +-- +-- `deleted_at` doppelt als Flag (NULL = lokale Datei vorhanden) UND als +-- Audit-Zeitstempel (wann aufgeräumt). + +ALTER TABLE attachments + ADD COLUMN deleted_at TIMESTAMPTZ; diff --git a/migrations/0028_completion_mail_sent.sql b/migrations/0028_completion_mail_sent.sql new file mode 100644 index 0000000..0d4a695 --- /dev/null +++ b/migrations/0028_completion_mail_sent.sql @@ -0,0 +1,20 @@ +-- 0028_completion_mail_sent.sql +-- +-- Markierung, ob für eine ausgelieferte (abgeschlossene) Lieferung die +-- Liefer-/Belegmail bereits versendet wurde. Der externe Mailclient pollt die +-- noch NICHT versendeten Belege (GET /admin/delivered-belegnummern liefert nur +-- `mail_sent_at IS NULL`), stößt ERPframe an (das die Mails verschickt) und +-- markiert sie anschließend über POST /admin/mark-mail-sent als versendet. +-- +-- NULL = noch nicht versendet (offen). +-- Wert = Zeitstempel, wann markiert wurde (Audit + server-seitiges Dedup, +-- damit derselbe Beleg nicht alle 5 Minuten erneut eine Mail auslöst). + +ALTER TABLE delivery_completions + ADD COLUMN mail_sent_at TIMESTAMPTZ; + +-- Partieller Index: der Mailclient fragt praktisch immer nur die offenen +-- (mail_sent_at IS NULL) Belege ab. +CREATE INDEX delivery_completions_mail_unsent + ON delivery_completions (completed_at) + WHERE mail_sent_at IS NULL; diff --git a/migrations/0029_delivery_contact_sources.sql b/migrations/0029_delivery_contact_sources.sql new file mode 100644 index 0000000..eff0ac3 --- /dev/null +++ b/migrations/0029_delivery_contact_sources.sql @@ -0,0 +1,62 @@ +-- 0029_delivery_contact_sources.sql +-- +-- Belegspezifische Kontaktdaten-Snapshots. ERPframe modelliert pro Beleg bis +-- zu fünf Adressen (Belegadresse `Belegkopf.AdressId`, Lieferadresse +-- `LieferAdressId`, Rechnungsadresse `RechnungsAdressId`, Ansprechpartner +-- `AnsprechpartnerId`, plus die Kundenstamm-Adresse über `Kunden.AdressId`). +-- Jeder dieser Adress-Datensätze trägt seinerseits mehrere Telefon-, +-- Mobil- und E-Mail-Spalten (`Telefon..Telefon4`, `Mobiltel..Mobiltel2`, +-- `EMail..EMail3`, `InternetAdresse`) sowie einen Namensblock +-- (`Anrede`/`Titel`/`Name1..3`/`Abteilung`/`Funktion`). +-- +-- Wir spiegeln das als zwei Tabellen: pro Lieferung 0..n +-- `delivery_contact_sources` (eine Zeile je Rolle), und pro Source 0..n +-- `delivery_contact_channels` (eine Zeile je Telefon-/Mobil-/E-Mail-/Web- +-- Eintrag). Snapshot-Semantik wie bei `deliveries.snap_*`: beim Sync werden +-- alle Sources einer Lieferung gelöscht und neu geschrieben — die Lieferung +-- behält damit den Stand vom letzten Sync, unabhängig von späteren ERP- +-- Adressänderungen. +-- +-- `Fax` wird bewusst nicht gespiegelt (in der App nicht benutzt). + +CREATE TABLE delivery_contact_sources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, + -- Mapping auf die ERP-FKs am Belegkopf bzw. den Kunden-Umweg: + -- header → Belegkopf.AdressId + -- delivery → Belegkopf.LieferAdressId + -- billing → Belegkopf.RechnungsAdressId + -- contact_person → Belegkopf.AnsprechpartnerId + -- customer_master → Kunden.AdressId (über Belegkopf.KundenId) + role TEXT NOT NULL + CHECK (role IN ('header','delivery','billing', + 'contact_person','customer_master')), + anrede TEXT, + titel TEXT, + name1 TEXT, + name2 TEXT, + name3 TEXT, + abteilung TEXT, + funktion TEXT, + UNIQUE (delivery_id, role) +); +CREATE INDEX delivery_contact_sources_delivery + ON delivery_contact_sources(delivery_id); + +CREATE TABLE delivery_contact_channels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_id UUID NOT NULL REFERENCES delivery_contact_sources(id) ON DELETE CASCADE, + -- phone → Adressen.Telefon{,2,3,4} + -- mobile → Adressen.Mobiltel{,2} + -- email → Adressen.EMail{,2,3} + -- web → Adressen.InternetAdresse + kind TEXT NOT NULL + CHECK (kind IN ('phone','mobile','email','web')), + -- 1-basierte Position aus dem ERP-Spaltennamen (Telefon → 1, Telefon2 → 2, + -- usw.). Erlaubt der App, „primären" Kanal je Kind stabil zu erkennen. + position SMALLINT NOT NULL CHECK (position >= 1), + value TEXT NOT NULL CHECK (value <> ''), + UNIQUE (source_id, kind, position) +); +CREATE INDEX delivery_contact_channels_source + ON delivery_contact_channels(source_id); diff --git a/tool/dev_usb.sh b/tool/dev_usb.sh new file mode 100755 index 0000000..55fc5d8 --- /dev/null +++ b/tool/dev_usb.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# +# Fährt das Backend im USB-Tunnel-Modus hoch — für App-Tests in fremden +# Netzwerken, in denen das Android-Gerät den Mac nicht über eine LAN-IP +# erreicht. Statt LAN-IP läuft alles über `adb reverse` auf localhost. +# +# Was dieses Skript macht: +# 1. Keycloak mit KC_HOSTNAME=localhost (neu)starten, damit das 'iss'-Claim +# auf http://localhost:8080/... lautet. +# 2. adb-reverse-Tunnel für API (3000) und Keycloak (8080) setzen. +# 3. Backend mit einer von config.toml abgeleiteten Temp-Config starten, +# in der nur `issuer_url` auf localhost überschrieben ist. Der Pfad +# wird per HOLZLEITNER_CONFIG injiziert; config.toml bleibt unangetastet. +# +# App-Seite (separat): mit dem passenden Flag bauen/starten: +# flutter run --dart-define=HL_BACKEND=usb +# +# Aufruf: +# ./tool/dev_usb.sh +# +# Rückkehr in den LAN-Modus: +# docker compose up -d # Keycloak wieder mit LAN-IP-Hostname +# adb reverse --remove-all # Tunnel abbauen +# cargo run # Backend mit config.toml (LAN-IP-Issuer) + +set -euo pipefail + +KC_HOST="${KC_HOSTNAME_OVERRIDE:-localhost}" +API_PORT="${API_PORT:-3000}" +KC_PORT="${KC_PORT:-8080}" +ISSUER="http://${KC_HOST}:${KC_PORT}/realms/holzleitner" + +echo "→ USB-Tunnel-Modus (Issuer: ${ISSUER})" + +# ── 1. Keycloak mit localhost-Hostname (neu)starten ────────────────────── +echo "→ Keycloak mit KC_HOSTNAME=${KC_HOST} starten …" +KC_HOSTNAME="${KC_HOST}" docker compose up -d keycloak postgres + +# ── 2. adb-reverse-Tunnel ──────────────────────────────────────────────── +if ! command -v adb >/dev/null 2>&1; then + echo "✗ adb nicht gefunden. Android-Platform-Tools installieren oder PATH prüfen." >&2 + exit 1 +fi + +if [ -z "$(adb devices | sed -n '2p')" ]; then + echo "✗ Kein Gerät über adb sichtbar. USB-Kabel + USB-Debugging prüfen." >&2 + exit 1 +fi + +echo "→ adb reverse: localhost:${API_PORT} (API) + localhost:${KC_PORT} (Keycloak)" +adb reverse "tcp:${API_PORT}" "tcp:${API_PORT}" +adb reverse "tcp:${KC_PORT}" "tcp:${KC_PORT}" +adb reverse --list + +# ── 3. Backend mit localhost-Issuer starten ────────────────────────────── +# Config ist jetzt datei-basiert (config.toml). Statt einer Env-Variablen +# leiten wir eine Temp-Config ab, in der nur `issuer_url` auf localhost zeigt, +# und reichen sie per HOLZLEITNER_CONFIG rein. So bleibt die echte config.toml +# (LAN-IP-Issuer) unverändert. +BASE_CONFIG="${HOLZLEITNER_CONFIG:-config.toml}" +if [ ! -f "${BASE_CONFIG}" ]; then + echo "✗ ${BASE_CONFIG} nicht gefunden (cp config.example.toml config.toml)." >&2 + exit 1 +fi + +TMP_CONFIG="$(mktemp -t holzleitner-usb-config.XXXXXX)" +trap 'rm -f "${TMP_CONFIG}"' EXIT +# Nur die issuer_url-Zeile ersetzen (es gibt genau eine, unter [keycloak]). +awk -v iss="${ISSUER}" ' + /^[[:space:]]*issuer_url[[:space:]]*=/ { print "issuer_url = \"" iss "\""; next } + { print } +' "${BASE_CONFIG}" > "${TMP_CONFIG}" + +echo "→ Backend starten (issuer_url=${ISSUER}, Temp-Config ${TMP_CONFIG}) …" +echo " Tipp: App in einem zweiten Terminal mit" +echo " flutter run --dart-define=HL_BACKEND=usb" +echo + +HOLZLEITNER_CONFIG="${TMP_CONFIG}" cargo run diff --git a/tool/mark_all_scanned.sh b/tool/mark_all_scanned.sh new file mode 100755 index 0000000..b1a94bc --- /dev/null +++ b/tool/mark_all_scanned.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Markiert ALLE scanbaren Items der Test-Touren als erfolgreich gescannt +# (scan_status = 'done', scanned_quantity = required_quantity). Damit +# erscheint jede Lieferung als „fertig beladen" und die Auslieferungs-Phase +# lässt sich Ende-zu-Ende testen, ohne jedes Gerät am Gerät zu scannen. +# +# Im Gegensatz zur früheren Inline-Variante (nur Standardlager) erfasst +# dieses Skript bewusst auch das Filiale — Ziel ist „nichts mehr offen". +# +# Regeln: +# * nur scanbare Artikel (Dienstleistungen werden nicht gescannt) +# * Standard- UND Filiale +# * Items mit Status 'removed' bleiben unangetastet (bewusst entfernt) +# * 'held' wird ebenfalls auf 'done' gehoben (sauberer Fertig-Zustand) +# +# Accounts: +# * Default: 1001,1002 (die Demo-Test-Accounts) +# * via Env überschreibbar: ACCOUNTS="1001,1002,1003" ./tool/mark_all_scanned.sh +# +# Aufruf: +# ./tool/mark_all_scanned.sh + +set -euo pipefail + +CONTAINER="${CONTAINER:-holzleitner-postgres}" +DB_USER="${DB_USER:-holzleitner}" +DB_NAME="${DB_NAME:-holzleitner}" +ACCOUNTS="${ACCOUNTS:-1001,1002}" + +# Nur Ziffern + Kommas — schützt die rohe psql-Variablen-Substitution unten +# vor Injection. +if ! [[ "$ACCOUNTS" =~ ^[0-9]+(,[0-9]+)*$ ]]; then + echo "✗ Ungültige ACCOUNTS-Liste '$ACCOUNTS'. Erwartet: kommagetrennte Zahlen, z.B. 1001,1002." >&2 + exit 1 +fi + +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "✗ Container '$CONTAINER' läuft nicht. Starte docker compose up -d." >&2 + exit 1 +fi + +echo "→ Alle scanbaren Items der Accounts ($ACCOUNTS) auf 'done' setzen …" + +docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q \ + -v accounts="$ACCOUNTS" <<'SQL' +BEGIN; + +-- Status VOR dem Update (Kontrolle). +\echo +\echo --- VORHER --- +SELECT w.name AS lager, di.scan_status, COUNT(*) AS items + FROM delivery_items di + JOIN deliveries d ON d.id = di.delivery_id + JOIN tours t ON t.id = d.tour_id + JOIN warehouses w ON w.id = di.warehouse_id + JOIN articles a ON a.id = di.article_id + WHERE t.account_id IN (:accounts) + AND a.scannable = TRUE + GROUP BY w.name, di.scan_status + ORDER BY w.name, di.scan_status; + +-- Update über Subquery: Postgres erlaubt im UPDATE … FROM keinen +-- Self-Join auf die Zieltabelle in der JOIN-Bedingung, daher die Id-Liste +-- vorab per SELECT bestimmen. +UPDATE delivery_items + SET scanned_quantity = required_quantity, + scan_status = 'done', + scan_last_updated_at = now() + WHERE id IN ( + SELECT di.id + FROM delivery_items di + JOIN deliveries d ON d.id = di.delivery_id + JOIN tours t ON t.id = d.tour_id + JOIN articles a ON a.id = di.article_id + WHERE t.account_id IN (:accounts) + AND a.scannable = TRUE + AND di.scan_status <> 'removed' + ); + +\echo +\echo --- NACHHER --- +SELECT w.name AS lager, di.scan_status, COUNT(*) AS items + FROM delivery_items di + JOIN deliveries d ON d.id = di.delivery_id + JOIN tours t ON t.id = d.tour_id + JOIN warehouses w ON w.id = di.warehouse_id + JOIN articles a ON a.id = di.article_id + WHERE t.account_id IN (:accounts) + AND a.scannable = TRUE + GROUP BY w.name, di.scan_status + ORDER BY w.name, di.scan_status; + +COMMIT; +SQL + +echo "✓ Alle scanbaren Items auf 'done' gesetzt — Auslieferung testbar." diff --git a/tool/mark_standard_scanned.sh b/tool/mark_standard_scanned.sh new file mode 100755 index 0000000..c44a0cf --- /dev/null +++ b/tool/mark_standard_scanned.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# Markiert nur die STANDARDLAGER-Items der Test-Touren als gescannt +# (scan_status = 'done'). Filial-Items bleiben bewusst 'in_progress' — +# damit lässt sich der Prozess „Artikel aus der Filiale abholen" testen: +# die Lieferung gilt im Standardlager als fertig, hat aber noch offene +# Filial-Positionen. +# +# Schwester-Skript zu `mark_all_scanned.sh` (das ALLES inkl. Filiale +# scannt). Gleiche Regeln, nur zusätzlich der `w.is_standard = TRUE`-Filter. +# +# Regeln: +# * nur scanbare Artikel (Dienstleistungen werden nicht gescannt) +# * NUR Standardlager (Filiale bleibt offen) +# * Items mit Status 'removed' bleiben unangetastet +# * 'held' wird auf 'done' gehoben +# +# Accounts: +# * Default: 1001,1002 +# * via Env überschreibbar: ACCOUNTS="1001,1002,1003" ./tool/mark_standard_scanned.sh +# +# Aufruf: +# ./tool/mark_standard_scanned.sh + +set -euo pipefail + +CONTAINER="${CONTAINER:-holzleitner-postgres}" +DB_USER="${DB_USER:-holzleitner}" +DB_NAME="${DB_NAME:-holzleitner}" +ACCOUNTS="${ACCOUNTS:-1001,1002}" + +# Nur Ziffern + Kommas — schützt die rohe psql-Variablen-Substitution unten +# vor Injection. +if ! [[ "$ACCOUNTS" =~ ^[0-9]+(,[0-9]+)*$ ]]; then + echo "✗ Ungültige ACCOUNTS-Liste '$ACCOUNTS'. Erwartet: kommagetrennte Zahlen, z.B. 1001,1002." >&2 + exit 1 +fi + +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "✗ Container '$CONTAINER' läuft nicht. Starte docker compose up -d." >&2 + exit 1 +fi + +echo "→ Nur Standardlager-Items der Accounts ($ACCOUNTS) auf 'done' setzen (Filiale bleibt offen) …" + +docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q \ + -v accounts="$ACCOUNTS" <<'SQL' +BEGIN; + +-- Status VOR dem Update (Kontrolle). +\echo +\echo --- VORHER --- +SELECT w.name AS lager, di.scan_status, COUNT(*) AS items + FROM delivery_items di + JOIN deliveries d ON d.id = di.delivery_id + JOIN tours t ON t.id = d.tour_id + JOIN warehouses w ON w.id = di.warehouse_id + JOIN articles a ON a.id = di.article_id + WHERE t.account_id IN (:accounts) + AND a.scannable = TRUE + GROUP BY w.name, di.scan_status + ORDER BY w.name, di.scan_status; + +-- Update über Subquery: Postgres erlaubt im UPDATE … FROM keinen +-- Self-Join auf die Zieltabelle in der JOIN-Bedingung, daher die Id-Liste +-- vorab per SELECT bestimmen. Filter `w.is_standard = TRUE` lässt das +-- Filiale bewusst offen. +UPDATE delivery_items + SET scanned_quantity = required_quantity, + scan_status = 'done', + scan_last_updated_at = now() + WHERE id IN ( + SELECT di.id + FROM delivery_items di + JOIN deliveries d ON d.id = di.delivery_id + JOIN tours t ON t.id = d.tour_id + JOIN warehouses w ON w.id = di.warehouse_id + JOIN articles a ON a.id = di.article_id + WHERE t.account_id IN (:accounts) + AND a.scannable = TRUE + AND w.is_standard = TRUE + AND di.scan_status <> 'removed' + ); + +\echo +\echo --- NACHHER --- +SELECT w.name AS lager, di.scan_status, COUNT(*) AS items + FROM delivery_items di + JOIN deliveries d ON d.id = di.delivery_id + JOIN tours t ON t.id = d.tour_id + JOIN warehouses w ON w.id = di.warehouse_id + JOIN articles a ON a.id = di.article_id + WHERE t.account_id IN (:accounts) + AND a.scannable = TRUE + GROUP BY w.name, di.scan_status + ORDER BY w.name, di.scan_status; + +COMMIT; +SQL + +echo "✓ Standardlager gescannt, Filiale offen — Abhol-Prozess testbar." diff --git a/tool/reseed_today.sh b/tool/reseed_today.sh new file mode 100755 index 0000000..92d383e --- /dev/null +++ b/tool/reseed_today.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# Datiert ALLE vorhandenen Touren auf den heutigen Tag um. +# +# Zweck: nach `seed_demo_data.sh 2026-06-01` (oder Touren aus dem ERP-Sync +# mit anderem Datum) holt dieses Skript alle Touren auf CURRENT_DATE, damit +# die App sie als „heutige Tour" anzeigt. +# +# WICHTIG: ersetzt, fügt nichts hinzu. +# * Es werden KEINE neuen Touren angelegt. +# * Bestehende Lieferungen / Items / Notizen / Scans bleiben erhalten — +# nur `tour_date` (und `synced_at`) der Tour selbst wandert auf heute. +# +# Sonderfall mehrere Touren pro Account: die Tabelle hat +# `UNIQUE (account_id, tour_date)`. Hätte ein Account mehrere Touren an +# verschiedenen Tagen, würde ein pauschales Umdatieren den Constraint +# verletzen. Deshalb behalten wir pro Account die jüngste Tour +# (tour_date, dann synced_at, dann id als Tie-Break) und löschen die +# älteren Duplikate (CASCADE räumt deren Lieferungen). +# +# Aufruf: +# ./tool/reseed_today.sh + +set -euo pipefail + +CONTAINER="${CONTAINER:-holzleitner-postgres}" +DB_USER="${DB_USER:-holzleitner}" +DB_NAME="${DB_NAME:-holzleitner}" + +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "✗ Container '$CONTAINER' läuft nicht. Starte docker compose up -d." >&2 + exit 1 +fi + +echo "→ Alle Touren auf heute (CURRENT_DATE) umdatieren …" + +docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q <<'SQL' +BEGIN; + +-- ── 1. Duplikate pro Account abräumen ──────────────────────────────── +-- Pro account_id genau die jüngste Tour behalten; ältere löschen, damit +-- das anschließende Umdatieren nicht in die UNIQUE(account_id, tour_date)- +-- Constraint läuft. CASCADE räumt deliveries → items → scans → notes. +DELETE FROM tours t + USING tours newer + WHERE t.account_id = newer.account_id + AND (newer.tour_date, newer.synced_at, newer.id) + > (t.tour_date, t.synced_at, t.id); + +-- ── 2. Verbleibende Touren auf heute datieren ──────────────────────── +-- Nach Schritt 1 gibt es pro Account genau eine Tour → kein Constraint- +-- Konflikt. Touren, die bereits heute liegen, sind ein No-Op. +UPDATE tours + SET tour_date = CURRENT_DATE, + synced_at = NOW(); + +COMMIT; + +\echo +\echo --- TOUREN (jetzt alle heute) --- +SELECT t.account_id, + t.tour_date, + COUNT(d.id) AS lieferungen + FROM tours t + LEFT JOIN deliveries d ON d.tour_id = t.id + GROUP BY t.account_id, t.tour_date + ORDER BY t.account_id; +SQL + +echo "✓ Alle Touren auf heute umdatiert." diff --git a/tool/reset_test_tour.sh b/tool/reset_test_tour.sh new file mode 100755 index 0000000..0f35ef4 --- /dev/null +++ b/tool/reset_test_tour.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# Setzt die Smoke-Test-Touren der Accounts 1001/1002 auf einen sauberen +# Zustand für den App-Smoke-Test: +# +# * tour_date → heute (sonst filtert /me/tours/today sie weg) +# * deliveries → state='active', state_reason=NULL, assigned_car_id=NULL +# * delivery_items → scan_status='in_progress', scanned_quantity=0, +# held_reason=NULL, scan_last_updated_at=NOW() +# +# Verwendet den laufenden Postgres-Container aus docker-compose.yml. Falls +# der Container anders heißt, kann der Name per CONTAINER überschrieben +# werden: +# +# CONTAINER=hl-postgres ./tool/reset_test_tour.sh +# +# Idempotent — kann beliebig oft ausgeführt werden. + +set -euo pipefail + +CONTAINER="${CONTAINER:-holzleitner-postgres}" +DB_USER="${DB_USER:-holzleitner}" +DB_NAME="${DB_NAME:-holzleitner}" +ACCOUNTS="${ACCOUNTS:-1001,1002}" + +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "✗ Container '$CONTAINER' läuft nicht. Starte docker compose up -d." >&2 + exit 1 +fi + +echo "→ Reset Smoke-Test-Touren für Accounts ($ACCOUNTS) …" + +docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q <&2 + exit 1 + fi + # SQL-Literal — der Wert ist durch die Regex oben auf reine Ziffern/Bindestriche + # beschränkt, daher kein Injection-Risiko. + TOUR_DATE_EXPR="DATE '$TOUR_DATE'" + DATE_LABEL="$TOUR_DATE" +else + TOUR_DATE_EXPR="CURRENT_DATE" + DATE_LABEL="heute" +fi + +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "✗ Container '$CONTAINER' läuft nicht. Starte docker compose up -d." >&2 + exit 1 +fi + +echo "→ Seed Demo-Daten (8 Elektrogeräte-Lieferungen, PN 1001, Tour-Datum: $DATE_LABEL) …" + +docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q \ + -v tour_date_expr="$TOUR_DATE_EXPR" <<'SQL' +BEGIN; + +-- ── 1. Alte Test-Daten räumen ──────────────────────────────────────── +-- Tours mit CASCADE räumt deliveries → delivery_items → scan_audit ab. +DELETE FROM tours WHERE account_id IN (1001, 1002); + +-- Existierende Test-Customer und Custom-Artikel wegwerfen — sie können +-- jetzt sauber durch realistische Daten ersetzt werden. +DELETE FROM customers + WHERE erp_customer_id BETWEEN 4700 AND 5200; + +DELETE FROM articles + WHERE article_number IN ( + 'BRETT-200','PALETTE-EUR','FRACHT-PAUSCH','NEU-BALKEN', + 'KS-EXP-200','WM-BOS-7K','TR-SIE-8K','SP-MIE-CL', + 'BO-AEG-PY','MW-SAM-23','ST-GAG-IN','DH-MIE-AC' + ); + +-- ── 2. Elektrogeräte-Stamm ─────────────────────────────────────────── +-- Default-Warehouse setzen wir nur dort, wo das Backend-Schema das +-- semantisch erwartet (Lager-Hint im ERP). Die *tatsächliche* Lager- +-- Zuordnung pro Lieferposition kommt unten in delivery_items.warehouse_id. +INSERT INTO articles (id, article_number, name, scannable, default_warehouse_id) VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa001', 'KS-EXP-200', 'Kühl-Gefrierkombi Exquisit 200L', true, '11111111-1111-1111-1111-111111111111'), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa002', 'WM-BOS-7K', 'Waschmaschine Bosch WAU28T70 7kg', true, '11111111-1111-1111-1111-111111111111'), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa003', 'TR-SIE-8K', 'Wärmepumpentrockner Siemens iQ500 8kg', true, '11111111-1111-1111-1111-111111111111'), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa004', 'SP-MIE-CL', 'Geschirrspüler Miele G5210 Classic', true, '11111111-1111-1111-1111-111111111111'), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa005', 'BO-AEG-PY', 'Einbau-Backofen AEG Pyrolyse', true, '11111111-1111-1111-1111-111111111112'), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa006', 'MW-SAM-23', 'Mikrowelle Samsung MS23K3513 23L', true, '11111111-1111-1111-1111-111111111111'), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa007', 'ST-GAG-IN', 'Induktions-Standherd Gaggenau CI491', true, '11111111-1111-1111-1111-111111111112'), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa008', 'DH-MIE-AC', 'Dunstabzugshaube Miele PUR98W', true, '11111111-1111-1111-1111-111111111111'); + +-- ── 3. Kunden (BGL-Region) ─────────────────────────────────────────── +INSERT INTO customers (id, erp_customer_id, name, street, house_number, postal_code, city, country) VALUES + ('cccccccc-cccc-cccc-cccc-ccccccccc001', 5101, 'Familie Bauer', 'Marktplatz', '5', '83435', 'Bad Reichenhall', 'Deutschland'), + ('cccccccc-cccc-cccc-cccc-ccccccccc002', 5102, 'Familie Hofer', 'Ludwigstraße', '12', '83435', 'Bad Reichenhall', 'Deutschland'), + ('cccccccc-cccc-cccc-cccc-ccccccccc003', 5103, 'Familie Steiner', 'Bahnhofstraße', '8', '83454', 'Anger', 'Deutschland'), + ('cccccccc-cccc-cccc-cccc-ccccccccc004', 5104, 'Familie Mayr', 'Hauptstraße', '22', '83471', 'Berchtesgaden', 'Deutschland'), + ('cccccccc-cccc-cccc-cccc-ccccccccc005', 5105, 'Familie Wagner', 'Wittelsbacherstraße', '3', '83435', 'Bad Reichenhall', 'Deutschland'), + ('cccccccc-cccc-cccc-cccc-ccccccccc006', 5106, 'Familie Berger', 'Salzburger Straße', '45', '83454', 'Anger', 'Deutschland'), + ('cccccccc-cccc-cccc-cccc-ccccccccc007', 5107, 'Familie Huber', 'Reichenhaller Straße', '10', '83483', 'Bischofswiesen', 'Deutschland'), + ('cccccccc-cccc-cccc-cccc-ccccccccc008', 5108, 'Familie Lechner', 'Maximilianstraße', '15', '83435', 'Bad Reichenhall', 'Deutschland'); + +-- Ansprechpartner — eine Person pro Familie, primär als Telefon-Anker. +INSERT INTO customer_contacts (id, customer_id, name, phone, email) VALUES + ('dddddddd-dddd-dddd-dddd-ddddddddd001', 'cccccccc-cccc-cccc-cccc-ccccccccc001', 'Martin Bauer', '+49 8651 12345', NULL), + ('dddddddd-dddd-dddd-dddd-ddddddddd002', 'cccccccc-cccc-cccc-cccc-ccccccccc002', 'Anna Hofer', '+49 8651 23456', 'a.hofer@example.de'), + ('dddddddd-dddd-dddd-dddd-ddddddddd003', 'cccccccc-cccc-cccc-cccc-ccccccccc003', 'Josef Steiner', '+49 8656 34567', NULL), + ('dddddddd-dddd-dddd-dddd-ddddddddd004', 'cccccccc-cccc-cccc-cccc-ccccccccc004', 'Sabine Mayr', '+49 8652 45678', NULL), + ('dddddddd-dddd-dddd-dddd-ddddddddd005', 'cccccccc-cccc-cccc-cccc-ccccccccc005', 'Thomas Wagner', '+49 8651 56789', 't.wagner@example.de'), + ('dddddddd-dddd-dddd-dddd-ddddddddd006', 'cccccccc-cccc-cccc-cccc-ccccccccc006', 'Petra Berger', '+49 8656 67890', NULL), + ('dddddddd-dddd-dddd-dddd-ddddddddd007', 'cccccccc-cccc-cccc-cccc-ccccccccc007', 'Franz Huber', '+49 8652 78901', NULL), + ('dddddddd-dddd-dddd-dddd-ddddddddd008', 'cccccccc-cccc-cccc-cccc-ccccccccc008', 'Maria Lechner', '+49 8651 89012', 'm.lechner@example.de'); + +-- ── 4. Tour für PN 1001 ─────────────────────────────────────────────── +-- Datum kommt als psql-Variable (Default CURRENT_DATE, sonst DATE 'YYYY-MM-DD'). +INSERT INTO tours (id, account_id, tour_date, synced_at) VALUES + ('55555555-5555-5555-5555-555555555555', 1001, :tour_date_expr, NOW()); + +-- ── 5. Lieferungen ─────────────────────────────────────────────────── +-- erp_belegart_id = 1 (Auftragsbestätigung, AB) wie in den ursprünglichen +-- Seeds. sort_order entspricht der ERP-Vorgabe; die App kann nachträglich +-- über PUT /tours/{id}/delivery-order anders sortieren. +-- Payment-Method-IDs (siehe Migration 0008): +-- cash = 99999999-9999-9999-9999-999999999901 +-- ec_card = 99999999-9999-9999-9999-999999999902 +-- invoice = 99999999-9999-9999-9999-999999999904 +-- (credit_card wurde aus den Stammdaten entfernt — Migration 0021) +INSERT INTO deliveries ( + id, tour_id, erp_belegart_id, erp_belegnummer, customer_id, + snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, + assigned_car_id, desired_time, special_agreements, + state, state_reason, sort_order, + prepaid_amount, payment_method_id +) VALUES + -- Bauer: voll vorab bezahlt (Online-Kauf), Methode bleibt der ERP-Default (cash) + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee001', '55555555-5555-5555-5555-555555555555', + 1, 'AB-2026-1001', 'cccccccc-cccc-cccc-cccc-ccccccccc001', + 'Marktplatz', '5', '83435', 'Bad Reichenhall', 'Deutschland', + NULL, '08:30 – 10:00', NULL, 'active', NULL, 1, + 899.00, '99999999-9999-9999-9999-999999999901'), + + -- Hofer: nichts vorab, EC bei Lieferung (Wasch+Trockner) + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee002', '55555555-5555-5555-5555-555555555555', + 1, 'AB-2026-1002', 'cccccccc-cccc-cccc-cccc-ccccccccc002', + 'Ludwigstraße', '12', '83435', 'Bad Reichenhall', 'Deutschland', + NULL, '09:00 – 11:00', 'Bitte alte Geräte mitnehmen', 'active', NULL, 2, + 0.00, '99999999-9999-9999-9999-999999999902'), + + -- Steiner: 200 EUR Anzahlung, Rest auf Rechnung + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee003', '55555555-5555-5555-5555-555555555555', + 1, 'AB-2026-1003', 'cccccccc-cccc-cccc-cccc-ccccccccc003', + 'Bahnhofstraße', '8', '83454', 'Anger', 'Deutschland', + NULL, NULL, NULL, 'active', NULL, 3, + 200.00, '99999999-9999-9999-9999-999999999904'), + + -- Mayr: Großgerät, EC bei Lieferung + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee004', '55555555-5555-5555-5555-555555555555', + 1, 'AB-2026-1004', 'cccccccc-cccc-cccc-cccc-ccccccccc004', + 'Hauptstraße', '22', '83471', 'Berchtesgaden', 'Deutschland', + NULL, '13:00 – 15:00', 'Einbau erfolgt durch Servicepartner', 'active', NULL, 4, + 0.00, '99999999-9999-9999-9999-999999999902'), + + -- Wagner: nichts vorab, Bar + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee005', '55555555-5555-5555-5555-555555555555', + 1, 'AB-2026-1005', 'cccccccc-cccc-cccc-cccc-ccccccccc005', + 'Wittelsbacherstraße', '3', '83435', 'Bad Reichenhall', 'Deutschland', + NULL, NULL, NULL, 'active', NULL, 5, + 0.00, '99999999-9999-9999-9999-999999999901'), + + -- Berger: 500 EUR Anzahlung, Rest EC + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee006', '55555555-5555-5555-5555-555555555555', + 1, 'AB-2026-1006', 'cccccccc-cccc-cccc-cccc-ccccccccc006', + 'Salzburger Straße', '45', '83454', 'Anger', 'Deutschland', + NULL, '11:00 – 13:00', NULL, 'active', NULL, 6, + 500.00, '99999999-9999-9999-9999-999999999902'), + + -- Huber: nichts vorab, Rechnung (Stammkunde) + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee007', '55555555-5555-5555-5555-555555555555', + 1, 'AB-2026-1007', 'cccccccc-cccc-cccc-cccc-ccccccccc007', + 'Reichenhaller Straße', '10', '83483', 'Bischofswiesen', 'Deutschland', + NULL, NULL, 'Anlieferung Tiefgaragen-Zugang', 'active', NULL, 7, + 0.00, '99999999-9999-9999-9999-999999999904'), + + -- Lechner: nichts vorab, Bar + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee008', '55555555-5555-5555-5555-555555555555', + 1, 'AB-2026-1008', 'cccccccc-cccc-cccc-cccc-ccccccccc008', + 'Maximilianstraße', '15', '83435', 'Bad Reichenhall', 'Deutschland', + NULL, '15:30 – 17:00', NULL, 'active', NULL, 8, + 0.00, '99999999-9999-9999-9999-999999999901'); + +-- Ansprechpartner pro Lieferung (1:1). +INSERT INTO delivery_contact_persons (delivery_id, customer_contact_id) VALUES + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee001', 'dddddddd-dddd-dddd-dddd-ddddddddd001'), + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee002', 'dddddddd-dddd-dddd-dddd-ddddddddd002'), + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee003', 'dddddddd-dddd-dddd-dddd-ddddddddd003'), + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee004', 'dddddddd-dddd-dddd-dddd-ddddddddd004'), + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee005', 'dddddddd-dddd-dddd-dddd-ddddddddd005'), + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee006', 'dddddddd-dddd-dddd-dddd-ddddddddd006'), + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee007', 'dddddddd-dddd-dddd-dddd-ddddddddd007'), + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeee008', 'dddddddd-dddd-dddd-dddd-ddddddddd008'); + +-- ── 6. Items pro Lieferung ─────────────────────────────────────────── +-- 1 Stück pro Gerät (Elektrogeräte-Geschäft: pro Belegzeile genau ein +-- Gerät). Filial-Items (Backofen, Standherd) landen explizit auf +-- Filiale Freilassing, weil die Geräte zu sperrig für das Standardlager sind. +INSERT INTO delivery_items ( + id, delivery_id, article_id, required_quantity, warehouse_id, + belegzeilen_nr, komponenten_artikel_nr, + scanned_quantity, scan_status, held_reason +) VALUES + -- Bauer: Kühl-Gefrierkombi + ('ffffffff-ffff-ffff-ffff-fffffffff001', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee001', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa001', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL), + + -- Hofer: Waschmaschine + Trockner + ('ffffffff-ffff-ffff-ffff-fffffffff002', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee002', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa002', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL), + ('ffffffff-ffff-ffff-ffff-fffffffff003', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee002', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa003', 1, '11111111-1111-1111-1111-111111111111', 2, NULL, 0, 'in_progress', NULL), + + -- Steiner: Geschirrspüler + ('ffffffff-ffff-ffff-ffff-fffffffff004', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee003', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa004', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL), + + -- Mayr: NUR Filiale (Backofen) — UX-Test „Standardlager fertig — Filiale offen" + ('ffffffff-ffff-ffff-ffff-fffffffff005', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee004', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa005', 1, '11111111-1111-1111-1111-111111111112', 1, NULL, 0, 'in_progress', NULL), + + -- Wagner: Mikrowelle + Dunstabzugshaube + ('ffffffff-ffff-ffff-ffff-fffffffff006', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee005', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa006', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL), + ('ffffffff-ffff-ffff-ffff-fffffffff007', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee005', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa008', 1, '11111111-1111-1111-1111-111111111111', 2, NULL, 0, 'in_progress', NULL), + + -- Berger: drei Geräte (alle Standard) + ('ffffffff-ffff-ffff-ffff-fffffffff008', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee006', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa001', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL), + ('ffffffff-ffff-ffff-ffff-fffffffff009', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee006', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa002', 1, '11111111-1111-1111-1111-111111111111', 2, NULL, 0, 'in_progress', NULL), + ('ffffffff-ffff-ffff-ffff-fffffffff010', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee006', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa006', 1, '11111111-1111-1111-1111-111111111111', 3, NULL, 0, 'in_progress', NULL), + + -- Huber: NUR Filiale (Standherd) — UX-Test „nur Filiale" + ('ffffffff-ffff-ffff-ffff-fffffffff011', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee007', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa007', 1, '11111111-1111-1111-1111-111111111112', 1, NULL, 0, 'in_progress', NULL), + + -- Lechner: Spüler (Standard) + Backofen (Filiale) — UX-Test „gemischt" + ('ffffffff-ffff-ffff-ffff-fffffffff012', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee008', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa004', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL), + ('ffffffff-ffff-ffff-ffff-fffffffff013', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee008', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa005', 1, '11111111-1111-1111-1111-111111111112', 2, NULL, 0, 'in_progress', NULL); + +-- Stückpreise (brutto, EUR) pro Artikel. Der Warenwert einer Lieferung = +-- Σ unit_price × ausgelieferte Menge (Soll − Gutschrift). Plausible Demo-Werte; +-- das ERP-Sync-Makro liefert die Preise später live mit. +UPDATE delivery_items di SET unit_price = CASE di.article_id + WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa001' THEN 899.00 -- Kühl-Gefrierkombi + WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa002' THEN 649.00 -- Waschmaschine + WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa003' THEN 599.00 -- Trockner + WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa004' THEN 549.00 -- Geschirrspüler + WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa005' THEN 499.00 -- Backofen + WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa006' THEN 199.00 -- Mikrowelle + WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa007' THEN 749.00 -- Standherd + WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa008' THEN 329.00 -- Dunstabzugshaube + ELSE unit_price END +FROM deliveries d +WHERE d.id = di.delivery_id AND d.tour_id = '55555555-5555-5555-5555-555555555555'; + +COMMIT; + +\echo +\echo --- ARTIKEL --- +SELECT article_number, name, scannable FROM articles ORDER BY article_number; + +\echo +\echo --- KUNDEN --- +SELECT erp_customer_id, name, postal_code, city FROM customers ORDER BY erp_customer_id; + +\echo +\echo --- TOUR --- +SELECT account_id, tour_date, synced_at + FROM tours + WHERE id = '55555555-5555-5555-5555-555555555555'; + +\echo +\echo --- LIEFERUNGEN (PN 1001) --- +SELECT d.sort_order, d.erp_belegnummer, c.name AS kunde, d.snap_city + FROM deliveries d + JOIN customers c ON c.id = d.customer_id + WHERE d.tour_id = '55555555-5555-5555-5555-555555555555' + ORDER BY d.sort_order; + +\echo +\echo --- ITEMS PRO LIEFERUNG --- +SELECT d.erp_belegnummer, di.belegzeilen_nr, a.article_number, a.name, + w.name AS lager, di.required_quantity + FROM delivery_items di + JOIN deliveries d ON d.id = di.delivery_id + JOIN articles a ON a.id = di.article_id + JOIN warehouses w ON w.id = di.warehouse_id + WHERE d.tour_id = '55555555-5555-5555-5555-555555555555' + ORDER BY d.sort_order, di.belegzeilen_nr; +SQL + +echo "✓ Demo-Daten geseedet (Tour-Datum: $DATE_LABEL)."