diff --git a/.env.example b/.env.example
index e7d43ad..faf6162 100644
--- a/.env.example
+++ b/.env.example
@@ -8,3 +8,43 @@ DB_PASSWORD=CHANGE_ME
# Protokolltabellen log/logoperation auf demselben Server, gelesen mit DB_USER.
# Nur nötig, wenn das vorhandene Schema anders heißt:
# HISTORY_DB_SCHEMA=phoenix_log
+
+# Sonderprozesse der Auftragserfassung. Bestehende Dienste verwenden, keine neue DB.
+# Vollständiger Endpunkt wie in PHP get_zone_distance (inklusive /api/pzm):
+PZM_URL=
+# Optionaler zweiter PZM-Endpunkt:
+PZM_FALLBACK_URL=
+# Geokodierung / PZM; alternativ vorhandener globaler Parameter GOOGLE_MAPS_API_KEY.
+# Den Schlüssel lokal eintragen, niemals versionieren.
+GOOGLE_MAPS_API_KEY=
+# Optional: separate PHP-Parameterquelle für einen einmaligen Schlüsselabruf.
+# Kein Ersatz für DB_URL; der laufende Server liest GOOGLE_MAPS_API_KEY direkt.
+# PARAM_DB_URL=jdbc:mariadb://PARAMETER-HOST:PORT/phoenix?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false
+# PARAM_DB_USER=
+# PARAM_DB_PASSWORD=
+# Fotodienst: bestehender globaler DB-Parameter URL_CS_PHOTO_WORKFLOW.
+
+# CO2e-Dienst aus dem PHP-Vertrag. Zugangsdaten ausschließlich lokal in .env setzen.
+CO2_URL=
+CO2_USERNAME=
+CO2_PASSWORD=
+
+# Terminbenachrichtigungen; PHP-Relay aus include/email/htmlMimeMail.php.
+SMTP_HOST=smtp.pop-i.de
+SMTP_PORT=25
+SMTP_USERNAME=
+SMTP_PASSWORD=
+SMTP_STARTTLS=true
+SMTP_SSL=false
+# Leer: E-Mail-Adresse des angemeldeten Mitarbeiters
+SMTP_FROM=
+
+# Bilder für Formulare/Newsletter. Für parallelen PHP-Betrieb ein gemeinsames Verzeichnis einbinden.
+FORM_IMAGES_DIRECTORY=data/form-images
+GROUPWARE_DB_SCHEMA=phoenix_group
+
+# Optional: zentrale Metaobjekt-DB für persönliche Newsletter-Abmeldelinks.
+# Leer verwendet EXTERNAL_DB_METAOBJECT aus PHP und DB_USER/DB_PASSWORD.
+META_DB_URL=
+# META_DB_USER=
+# META_DB_PASSWORD=
diff --git a/README.md b/README.md
index 7ee9957..06a46da 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,20 @@
# VoTiAn NG · Stadtbote
-React-/TypeScript-Oberfläche und Spring-Boot-Anbindung für die vorhandene PHP-Anwendung in `votian/`. Die neue Anwendung verwendet **dieselbe vorhandene MySQL-Datenbank und deren Tabellen**. Sie enthält keine eigene Datenbank, keine automatischen Schemaänderungen und keine Demodatensätze.
+React-/TypeScript-Oberfläche und Spring-Boot-Anbindung für die vorhandene PHP-Anwendung. Verbindliche Referenz ist ausschließlich der aktuelle Serverstand per SSH auf `root@172.16.0.158`, Verzeichnis `/data/0/votian-test/html` (Projektwurzel `/data/0/votian-test`). Der alte lokale PHP-Ordner wird nicht mehr als Referenz verwendet. Die neue Anwendung verwendet **dieselbe vorhandene MySQL-Datenbank und deren Tabellen**. Sie enthält keine eigene Datenbank, keine automatischen Schemaänderungen und keine Demodatensätze.
## Stand
Die Altanwendung wurde fachlich und technisch analysiert. Die neue Oberfläche bildet Auftragslisten, Disposition, Touren, Kunden, Kuriere und Auswertung ab. Das Spring-Boot-Backend greift ausschließlich über den zentralen `DatabaseService` auf das vorhandene Schema zu. Die Anmeldung verwendet vorhandene VoTiAn-Zentralenbenutzer mit ihren Niederlassungs- und Mitarbeiterrechten.
-**Die vollständige Ablösung der PHP-Anwendung ist noch nicht abgeschlossen.** Schreibende Vorgänge wie Auftragserfassung, Tarifberechnung, Storno und Rechnungserzeugung haben im Altcode zahlreiche weitere Auswirkungen (unter anderem Fahrerbenachrichtigungen und Gegenbuchungen). Solange diese nicht vollständig portiert und am aktiven Bestand geprüft sind, öffnet die Oberfläche dafür die passenden Module der vorhandenen PHP-Anwendung, wenn `LEGACY_APP_URL` konfiguriert ist. Es gibt keine vereinfachten Schreibzugriffe, die diese Regeln umgehen.
+**Die Auftragserfassung läuft jetzt nativ in React und Spring Boot:** Sofortauftrag, Vorbestellung, Dauerauftrag, Barzahlung, direkte Fahrzeugzuweisung und Nacherfassung. Dazu gehören Kostenstellensuche, mehrere Stationen, PLZ-/Gebietstarife oder Festpreise, Preisvorschau, Rechteprüfung und transaktionales Speichern im Bestand. Hinzu kommen Kostensplit, Kurier-Unteraufträge bei Nacherfassungen, Artikelpositionen mit optionalen Lagerbewegungen und eingebettete Kartenauswahl. Voraussetzung ist das bestehende Erfassungsrecht 7; Lagerbewegungen benötigen zusätzlich Recht 14, Unternehmerverrechnung Recht 38.
+
+Adressvorschläge über Google Places ergänzen Kunden-, Kurier- und Kostenstellenadressen. In der Kundenliste lassen sich Kostenstellen einblenden, nach Namen filtern und direkt bearbeiten. Der Google-Schlüssel bleibt im Backend.
+
+Der geprüfte Menüumfang und noch fehlende Funktionen stehen in [docs/MENUE-ABGLEICH.md](docs/MENUE-ABGLEICH.md). Freie Artikelpositionen, Alt-Tastenkürzel, Nachhaltigkeitsdaten und Erfassung in freigegebenen Niederlassungen sind ebenfalls implementiert.
+
+**Neu aus dem Menüabgleich:** Unter Verwaltung sind Feiertage, CLASSIC-/BWV-Anfahrtszeiten, Gruppen mit Mitgliedschaften und das Impressum angebunden. Die Auswertung berechnet komplette Zeiträume direkt auf dem Bestand, mit Niederlassungen, Vergleichszeiträumen, Gruppierungen, KM/CO₂e und CSV. Der genaue Funktionsumfang und die noch offenen PHP-Funktionen stehen im Menüabgleich.
+
+Die vollständige Ablösung der PHP-Anwendung ist weiterhin offen. Native Auftragsbearbeitung, Fahrzeugzuweisung, Abschluss, Storno/Gegenbuchung, Kunden-/Kurier-/Kostenstellen-/Fahrzeugpflege sowie Abrechnungsübersichten, Rechnungszuordnung, CSV und PDF-Leistungsübersichten sind ergänzt. Vollständige Rechnungserstellung, Buchhaltungsverarbeitung und vorhandene Hintergrunddienste sind weiterhin offen. Foto-Workflow-Sets mit Stationsvorgaben, automatische Kilometer-/PZM- und Fernverkehrskalkulation sowie Auslandsabholung sind angebunden. Die Routenfunktionen benötigen den bestehenden PZM-Dienst (`PZM_URL`) und einen Google-Schlüssel (`GOOGLE_MAPS_API_KEY`); der Fotodienst kommt aus dem Bestandsparameter `URL_CS_PHOTO_WORKFLOW`, die eingebettete Karte aus `URL_GEOCORDS`. Die automatische Adressauflösung gewählter Kartenpunkte verwendet ebenfalls den Google-Schlüssel. Umfang, Betriebsabhängigkeiten und Prüfnachweise stehen in [docs/AUFTRAGSERFASSUNG.md](docs/AUFTRAGSERFASSUNG.md).
Die vollständige Analyse und Abdeckungsmatrix stehen in [docs/ALTSYSTEM-ANALYSE.md](docs/ALTSYSTEM-ANALYSE.md).
@@ -109,7 +117,7 @@ python3 -m unittest discover -s tests -p 'test_*.py' -v
cd backend && ./mvnw test
```
-Die Tests verwenden Mocks und getrennte Testfixtures im Arbeitsspeicher der Tests. Sie erzeugen keine Testdatenbank und führen keine Schreibzugriffe gegen den Bestand aus. Ein erfolgreicher Build ersetzt nicht die noch notwendige Prüfung mit aktivem VPN, dem korrekten Datenbankendpunkt und fachlichen Vergleichsfällen aus dem Altbetrieb.
+Standardtests verwenden Mocks. Optionale Integrationsprüfungen sind ausschließlich für die disposable MariaDB unter `127.0.0.1:33079/crm_qa` freigeschaltet (`CRM_INTEGRATION_URL`); dort erzeugen sie isolierte Testtabellen. Sie führen keine Schreibzugriffe gegen den Bestand aus. Ein erfolgreicher Build ersetzt nicht die noch notwendige Prüfung mit aktivem VPN, dem korrekten Datenbankendpunkt und fachlichen Vergleichsfällen aus dem Altbetrieb.
Die optionale Diagnose `python3 scripts/check-database.py` startet das gebaute Backend-JAR im Modus `--database-check`, ohne Webserver. Verbindung und benötigte Tabellenspalten werden ausschließlich über denselben `DatabaseService` mit `SELECT` auf Serverversion und `information_schema` geprüft. Die Diagnose liest keine Auftrags-/Benutzerdatensätze, gibt keine Zugangsdaten aus und beendet danach den Prozess. Ein eigener Python-Datenbanktreiber ist nicht mehr erforderlich.
@@ -117,12 +125,12 @@ Die optionale Diagnose `python3 scripts/check-database.py` startet das gebaute B
Alle Zugriffe der neuen Anwendung laufen über `backend/src/main/java/de/stadtbote/votian/database/DatabaseService.java`: Anmeldung, Aufträge und Stationen, Kunden, Kuriere, CSV-Daten sowie Verbindungs- und Schemadiagnose. Nur dieser Service verwendet JDBC und steuert Datenbanktransaktionen. Auftragsanzahl, Liste und Stationen werden in einer gemeinsamen Lesetransaktion gelesen. SQL-Werte werden als Parameter gebunden.
-Der zentrale Schreibzugang `update(...)` ist in der aktuellen Ausbaustufe gesperrt und öffnet keine Verbindung. Die Fachprozesse für schreibende Vorgänge sind weiterhin erst vollständig zu portieren. Architekturtests prüfen Java-Bytecode und Python-Launcher auf Direktzugriffe außerhalb dieser Schicht. Die vorhandene PHP-Anwendung ist von dieser Umstellung nicht betroffen.
+Die Auftragserfassung öffnet mit `writeTransaction(...)` eine explizite Schreibtransaktion. Nur innerhalb dieser Grenze sind `insert`, `update` und `lockRows` erlaubt. Die freigegebenen Verwaltungsprozesse, Auftragsbearbeitung und Stammdatenpflege verwenden dieselbe Schreibgrenze. Architekturtests prüfen Java-Bytecode und Python-Launcher auf Direktzugriffe außerhalb dieser Schicht. Die vorhandene PHP-Anwendung ist von dieser Umstellung nicht betroffen.
## Projektstruktur
```text
-votian/ Bestehende PHP-Anwendung, unverändert weiter nutzbar
+votian/ Historischer lokaler PHP-Stand, keine Referenz mehr
frontend/ React, TypeScript, Vite und Stadtbote-Oberfläche
backend/ Spring Boot, JDBC und vorhandene Benutzer-/Rechtestruktur
docs/ Analyse, Fachregeln, Abdeckung und weitere Abnahme
diff --git a/backend/README.md b/backend/README.md
index 2b760ee..5ca6d4a 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,20 +1,27 @@
# Spring-Boot-Adapter für die vorhandene VoTiAn-Datenbank
-Java 21, Spring Boot 3.5.16 und MariaDB JDBC. Dieser Adapter verwendet ausschließlich bestehende Tabellen. Er enthält kein JPA-Modell, keine Schema-Migration, keine eingebettete Datenbank und keine Seed-Daten. Alle implementierten Datenbankabfragen sind `SELECT`; der Verbindungspool ist zusätzlich schreibgeschützt.
+Java 21, Spring Boot 3.5.16 und MariaDB JDBC. Dieser Adapter verwendet ausschließlich bestehende Tabellen. Er enthält kein JPA-Modell, keine Schema-Migration, keine eingebettete Datenbank und keine Seed-Daten. Lesen bleibt serverseitig schreibgeschützt; die native Auftragserfassung und freigegebene Verwaltungsprozesse verwenden explizite Schreibgrenzen über denselben Service.
## Zentrale Datenbankserviceschicht
-`database.DatabaseService` ist die einzige Komponente mit Zugriff auf `JdbcTemplate` und den Transaktionsmanager. `LegacyAuthenticationProvider`, `LegacyReadService`, `LegacyHistoryService` und `database.DatabaseDiagnostics` verwenden ausschließlich diesen Service; auch Integrationstests lesen den Bestand über ihn. Der Service aktiviert `setEnforceReadOnly(true)`, sodass jede Lesetransaktion mit `SET TRANSACTION READ ONLY` serverseitig abgesichert wird. Der JDBC-Hinweis allein reicht hierfür beim verwendeten MariaDB-Treiber nicht aus.
+`database.DatabaseService` ist die einzige Komponente mit Zugriff auf `JdbcTemplate` und den Transaktionsmanager. Authentifizierung, Leseadapter, Auftragserfassung, Protokollierung und Diagnose verwenden ausschließlich diesen Service; auch Integrationstests lesen den Bestand über ihn. Der Service aktiviert `setEnforceReadOnly(true)`, sodass jede Lesetransaktion mit `SET TRANSACTION READ ONLY` serverseitig abgesichert wird. Der JDBC-Hinweis allein reicht hierfür beim verwendeten MariaDB-Treiber nicht aus.
- `queryForList(sql, parameters...)` und `queryForObject(sql, type, parameters...)` führen einzelne parametrisierte SELECT-Abfragen in Lesetransaktionen aus.
- `readTransaction(work)` fasst abhängige Abfragen zusammen; Auftragsliste einschließlich Anzahl und Stationen sowie Auftragsdetails verwenden diese Grenze.
- `checkConnection()` liefert einen bereinigten Verbindungsstatus und unterscheidet Netzwerkfehler, abgewiesene Anmeldung und fehlende Datenbankrechte.
-- `update(sql, parameters...)` ist der zentrale Einstieg für zukünftige Schreibprozesse. Er wirft derzeit vor jedem Datenbankzugriff `DatabaseWritesDisabledException`. Es wurde kein fachlicher Schreibvorgang freigeschaltet.
+- `writeTransaction(work)` erlaubt ausschließlich darin `insert`, `update` und `lockRows`. Auftrag und zugehörige Stationen/Preise/Marker werden gemeinsam committed oder zurückgerollt. DDL und Mehrfachstatements bleiben gesperrt.
+- Das nicht transaktionsfähige Bestandsprotokoll erhält Ereignisse erst nach dem Commit über eine wiederholbare Outbox in `genericdatacontainer`; siehe [Auftragserfassung](../docs/AUFTRAGSERFASSUNG.md).
SQL bleibt anwendungseigener Code. Benutzereingaben dürfen nur als gebundene Werte übergeben werden. Der Service weist Nicht-SELECTs, mehrere Statements und bekannte SELECT-Schreib-/Sperrformen über die Lese-API zurück; diese Prüfung ist kein SQL-Parser für beliebige fremde Abfragen. Es werden weder JDBC-Verbindungen noch JDBC-Callbacks nach außen gegeben.
`python3 scripts/check-database.py` im Projektstamm startet das gebaute JAR mit `--database-check`. Dieser Modus nutzt denselben Service und dieselbe Poolkonfiguration ohne Webserver, gibt JSON aus und schließt den Spring-Kontext. Python stellt keine eigene Datenbankverbindung mehr her.
+## Verwaltung und Statistik
+
+`/api/administration` gibt die für das Konto freigegebenen Verwaltungsbereiche zurück. Feiertage und Anfahrtszeiten benötigen Recht 2, Gruppen die Rechte 8 und 25; gemeinsame Gruppen/mandantenweite Mitgliedsarten zusätzlich Recht 10. Schreibanfragen verwenden CSRF und Bearbeitungsrevisionen. Die Einzelaktualisierung einer zentralen BWV-PLZ ist wegen der MyISAM-Bestandstabelle nicht mit anderen Schreiboperationen gebündelt. Gruppen dürfen erst ohne Mitglieder gelöscht werden.
+
+`GET /api/statistics/options` und `POST /api/statistics/query` benötigen Recht 8. Die Statistik ist rein lesend; POST übermittelt die umfangreichen Filter und benötigt ebenfalls CSRF. Mehrere Niederlassungen sind auf Recht 10 und den bestehenden Mandanten begrenzt. Zeitraum und Vergleiche werden jeweils als vollständige Aggregation abgefragt; die Grenze der Auftragsübersicht spielt dabei keine Rolle. Bei mehr als 5000 Ergebnisgruppen wird die Abfrage abgewiesen. Details und verbleibende Unterschiede: [Menüabgleich](../docs/MENUE-ABGLEICH.md).
+
## Start und Konfiguration
Die lokale Datenbankkonfiguration steht in der `.env` im Projektstamm: `DB_URL`, `DB_USER` und `DB_PASSWORD`. `./run.sh`, `./dev.sh` und `python3 scripts/check-database.py` laden diese Datei automatisch. Bereits gesetzte Prozessumgebungsvariablen haben Vorrang vor den Werten aus `.env`.
@@ -31,7 +38,10 @@ Verfügbare Konfiguration:
| --- | --- |
| `DB_URL` | JDBC-Adresse der bestehenden DB, z. B. `jdbc:mariadb://DB-HOST:3306/DB-NAME?connectTimeout=2000&socketTimeout=5000` |
| `DB_USER`, `DB_PASSWORD` | Vorhandenes Datenbankkonto; keine Zugangsdaten im Quelltext |
-| `HISTORY_DB_SCHEMA` | Bestehendes Protokollschema auf demselben Server, Standard `phoenix_log`; benötigt SELECT-Rechte für `log` und `logoperation` mit demselben Datenbankkonto. Name aus Buchstaben, Ziffern und Unterstrichen, maximal 64 Zeichen |
+| `PZM_URL`, `PZM_FALLBACK_URL` | Vollständiger vorhandener `/api/pzm`-Endpunkt und optionaler Ausweichendpunkt für Routen-/Zonenberechnung; alternativ globaler Bestandsparameter `URL_PZM` |
+| `CO2_URL`, `CO2_USERNAME`, `CO2_PASSWORD` | Vorhandener HTTPS-CO₂e-Dienst mit Basic-Authentifizierung; nur serverseitig aus .env/Umgebung |
+| `GOOGLE_MAPS_API_KEY` | Schlüssel für Geokodierung/PZM; alternativ gleichnamiger globaler Bestandsparameter. Nicht versionieren |
+| `HISTORY_DB_SCHEMA` | Bestehendes Protokollschema auf demselben Server, Standard `phoenix_log`; benötigt SELECT-Rechte für `log`/`logoperation`, für Auftragserfassung zusätzlich INSERT auf `log` und SELECT/INSERT auf `semaphor`. Name aus Buchstaben, Ziffern und Unterstrichen, maximal 64 Zeichen |
| `LEGACY_APP_URL` | Optionale HTTP(S)-Basisadresse der vorhandenen PHP-Anwendung |
| `SERVER_ADDRESS` | Standard `127.0.0.1` |
| `PORT` | Standard `8080` |
@@ -49,7 +59,7 @@ Die Anmeldung prüft ausschließlich vorhandene VoTiAn-Konten über `user.usr_ac
HTTP Basic ist im Backend deaktiviert. Ein vom Browser für den vorgeschalteten Nginx-Passwortschutz gesendeter `Authorization`-Header wird nicht zur VoTiAn-Anmeldung verwendet. VoTiAn erhält Benutzername und Passwort ausschließlich über `POST /api/login`; das Frontend speichert diese Werte nicht in `sessionStorage` oder `localStorage`.
-Die Sitzung wird ausschließlich per Cookie `VOTIANSESSION` übertragen (`HttpOnly`, `SameSite=Lax`, Pfad `/`), nicht per URL. Ihre Kennung wechselt bei erfolgreicher Anmeldung. Nach standardmäßig 30 Minuten Inaktivität oder nach `POST /api/logout` ist die Sitzung ungültig. Sitzungen liegen im Backend-Arbeitsspeicher, benötigen keine Datenbanktabellen und gehen beim Neustart verloren. Mehrere Instanzen erfordern Sitzungsaffinität oder eine gemeinsame Sitzungsablage. Niederlassung und Rechte sind ein Snapshot bei Anmeldung; Änderungen im Bestand greifen für eine vorhandene Sitzung erst nach erneuter Anmeldung.
+Die Sitzung wird ausschließlich per Cookie `VOTIANSESSION` übertragen (`HttpOnly`, `SameSite=Lax`, Pfad `/`), nicht per URL. Ihre Kennung wechselt bei erfolgreicher Anmeldung. Nach standardmäßig 30 Minuten Inaktivität oder nach `POST /api/logout` ist die Sitzung ungültig. Sitzungen liegen im Backend-Arbeitsspeicher, benötigen keine Datenbanktabellen und gehen beim Neustart verloren. Mehrere Instanzen erfordern Sitzungsaffinität oder eine gemeinsame Sitzungsablage. Der allgemeine Sitzungsbereich ist ein Snapshot bei Anmeldung; Erfassungsanfragen mit `X-Order-Headquarters` prüfen die Niederlassungsfreigaben und Erfassungsrechte zusätzlich aktuell.
### Ablauf für API-Clients
@@ -67,7 +77,7 @@ Frontend und API unter derselben HTTPS-Origin bereitstellen und Cookies unverän
Im HTTPS-Produktionscontainer `SERVER_SERVLET_SESSION_COOKIE_SECURE=true` setzen. Dann funktionieren Sitzungsanmeldungen nur über HTTPS, auch wenn der interne Weg vom Proxy zum Backend HTTP verwendet. Für den lokalen HTTP-Start die Variable weglassen. Die Vite-Entwicklungsoberfläche verwendet ihren bestehenden `/api`-Proxy, sodass auch dort Oberfläche und API aus Browsersicht dieselbe Origin haben. Ein vorhandener Nginx-Basic-Passwortschutz kann aktiv bleiben; die Systemanmeldung verwendet dafür keinen `Authorization`-Header mehr.
-Die ursprüngliche Niederlassung `user.hq_id` begrenzt alle Abfragen. Bei Aufträgen gilt die **Erfassungsniederlassung** `job.hq_id`; zusätzliche Dispositions-/Ausführungs-/Vertriebsniederlassungen werden nicht pauschal freigegeben. Kunden werden über `customer.hq_id`, Kuriere über `courier.hq_id` und `user.hq_id` beschränkt. Zusätzliche Rechteprüfungen verwenden die vorhandenen `employee.emp_rights`-Positionen: Kunden 0, Kuriere 1, Auftragslisten 4, Rechnung/CSV 5. Kunden- und Kurierlogins sowie Niederlassungswechsel sind noch nicht portiert.
+Die ursprüngliche Niederlassung `user.hq_id` begrenzt alle Abfragen. Bei Aufträgen gilt die **Erfassungsniederlassung** `job.hq_id`; zusätzliche Dispositions-/Ausführungs-/Vertriebsniederlassungen werden nicht pauschal freigegeben. Kunden werden über `customer.hq_id`, Kuriere über `courier.hq_id` und `user.hq_id` beschränkt. Zusätzliche Rechteprüfungen verwenden die vorhandenen `employee.emp_rights`-Positionen: Kunden 0, Kuriere 1, Auftragslisten 4, Rechnung/CSV 5, Erfassung 7, Lagerbewegungen 14, manuelle Frachtvergütung/Unternehmerverrechnung 38, Überschreitung ihrer Preisgrenze 39. Kunden- und Kurierlogins sind noch nicht portiert. Für die Auftragserfassung ist ein ausdrücklich ausgewählter, je Anfrage berechtigungsgeprüfter Niederlassungswechsel innerhalb des Mandanten verfügbar; andere Listen behalten den ursprünglichen Bereich. Details: [AUFTRAGSERFASSUNG.md](../docs/AUFTRAGSERFASSUNG.md).
## API
@@ -84,6 +94,14 @@ Die ursprüngliche Niederlassung `user.hq_id` begrenzt alle Abfragen. Bei Auftr
| `GET /api/orders/{id}/history` | Vollständige Historie eines aktuellen Auftrags derselben Erfassungsniederlassung; Recht 4; aufsteigend nach Ereigniszeit und Log-ID |
| `GET /api/orders/export.csv` | Gefilterter UTF-8-Export, zusätzlich Recht 5; verweigert einen unvollständigen Export oberhalb der Ergebnisgrenze |
| `GET /api/customers`, `GET /api/couriers` | Stammdaten, optionaler Suchparameter `q` |
+| `GET /api/orders/entry/options` | Fahrzeugtypen, Steuern, Wiederholungen; Recht 7 |
+| `GET /api/orders/entry/cost-centers`, `GET /api/orders/entry/cost-centers/{id}/addresses`, `GET /api/orders/entry/vehicles` | HQ-begrenzte Erfassungssuche; Recht 7, unabhängig von Stammdatenrechten |
+| `GET /api/orders/entry/cost-centers/{id}/photo-workflows` | Kundenbezogene Fotooptionen nach HQ-/Kostenstellenprüfung; Recht 7 |
+| `GET /api/orders/entry/features`, `GET /api/orders/entry/articles` | Kartenkonfiguration/Lagerrecht und HQ-begrenzte Artikelsuche; Recht 7 |
+| `GET /api/orders/entry/stocks` | Zugängliche Lagerorte und Artikelbestand für `payerId`, `articleId`, `q`; Rechte 7 und 14 |
+| `POST /api/orders/entry/map/locate`, `POST /api/orders/entry/map/address` | Nur lesende Koordinaten-/Adressauflösung für die eingebettete Karte; Recht 7 und CSRF |
+| `POST /api/orders/quote` | Nur lesende Preisvorschau; Recht 7 und CSRF |
+| `POST /api/orders` | Neuanlage nach erneuter Preisprüfung; 201, identische Wiederholung 200; Recht 7 und CSRF |
| `GET /api/invoices` | 501, Bestands-Abrechnungsprozess noch nicht portiert |
| Übrige Schreibmethoden unter `/api/**` | 501 nach erfolgreicher Anmeldung und gültiger CSRF-Prüfung; keine Datenänderung |
@@ -97,18 +115,18 @@ Listen liefern Arrays. Auftragslisten senden zusätzlich `X-Total-Count` und `X-
- Kundenpreis aus `jb_totalprice` (Netto einschließlich Zuschlägen), Kurierpreis aus `jb_cr_price`; keine neu erfundene Tarifierung.
- Status 0/1/2/8/9 bleiben zusätzlich als `legacyStatus`/`legacyStatusLabel` erhalten. Die UI fasst 8/9 unter Disposition zusammen.
- `jb_storno` bleibt als `legacyStorno` erhalten. 3/4 sind Gegenbuchungen und werden ausdrücklich als Storno-Gutschriften markiert (`isCreditNote`).
-- Tourrichtung 1=Abholung, 2=Zustellung, 3=beides (`BOTH`), 0=positionsabhängige Legacy-Regel.
+- Tourrichtung 2=Abholung, 1=Zustellung, 3=beides (`BOTH`), 0=positionsabhängige Legacy-Regel. Maßgeblich sind `job_options.php` und `get_saved_tour.php`; der alte Rechnungstext-Helfer enthält widersprüchlich vertauschte Beschriftungen.
- Leistungen aus der Bitmaske `jb_service`, wobei `metatype.mt_sort` die Bitposition ist. Fahrzeugtypen werden über `metatype` gelesen.
- Historie wird beim Öffnen von „Verlauf“ separat über `/api/orders/{id}/history` aus `phoenix_log.log` geladen. `logoperation` liefert Vorgangsbezeichnungen, `user` den Bearbeiter. Die Oberfläche zeigt die neuesten Ereignisse zuerst und unterscheidet Laden, eine erfolgreich gelesene leere Historie und einen Zugriffsfehler mit Wiederholungsmöglichkeit. Das bisherige `history`-Feld in Auftragslisten/-details bleibt leer; maßgeblich ist der eigene Historien-Endpunkt.
-- Die Historienberechtigung folgt `job.hq_id` und Recht 4; Ereignisse anderer Niederlassungen zum selben sichtbaren Auftrag bleiben erhalten. Fehlende/fremde Aufträge liefern 404, Datenbank-/Schemarechtefehler 503. Die Historie wird weder erfunden noch bei einem Fehler durch ein leeres Ergebnis ersetzt. Legacy-Details werden als Text mit bekannten deutschen Feldnamen angezeigt, unbekannte Angaben bleiben erhalten; gespeicherter HTML-Code wird nicht ausgeführt. Es erfolgen keine Schemaänderungen oder Datenbankschreibzugriffe.
-- Abrechnung im Bestand ist keine nachgewiesene einfache Invoice-CRUD-Tabelle. Belege, Gegenbuchungen, Zahlungsstatus, Tarifierung, Auftragsanlage/Änderung und Fahrerkommunikation bleiben bis zur vollständigen Portierung im vorhandenen PHP-System. Optional verlinkt die React-Oberfläche diese Module.
+- Die Historienberechtigung folgt `job.hq_id` und Recht 4; Ereignisse anderer Niederlassungen zum selben sichtbaren Auftrag bleiben erhalten. Fehlende/fremde Aufträge liefern 404, Datenbank-/Schemarechtefehler 503. Die Historie wird weder erfunden noch bei einem Fehler durch ein leeres Ergebnis ersetzt. Legacy-Details werden als Text mit bekannten deutschen Feldnamen angezeigt, unbekannte Angaben bleiben erhalten; gespeicherter HTML-Code wird nicht ausgeführt. Das Lesen der Historie erzeugt keine Datenbankschreibzugriffe. Die Erfassung ergänzt nach dem Commit eigene Ereignisse.
+- Abrechnung im Bestand ist keine nachgewiesene einfache Invoice-CRUD-Tabelle. Belege, Gegenbuchungen, Zahlungsstatus, Auftragsänderung und Fahrerkommunikation bleiben bis zur vollständigen Portierung im vorhandenen PHP-System. Optional verlinkt die React-Oberfläche diese Module.
- Archivtabellen und Mehrniederlassungsrechte sind noch nicht eingebunden. Die bestehende Anwendung bleibt für diese Funktionen erforderlich.
SQL-/Mapping-Quellen im vorhandenen Code: `include/mcglobal.inc.php`, `include/dbglobal.inc.php`, `include/caglobal.inc.php`, `admin/login.php`, `admin/menu.php`, `jobs/job_options.php`, `admin/jb_list.php` und `invoice/inv.inc.php`.
## Prüfung
-`./mvnw test` führt Tests ohne Bestandszugriff aus: bestehende Anmeldung und Rechte, Sitzungs- und CSRF-Verhalten, HQ-Abgrenzung, SQL-Parameterbindung, Nichtverfügbarkeit, Verbot fachlicher Schreibpfade, serverseitige Vorbereitung der Lesetransaktion, Transaktions-/Fehlerverhalten, Schemadiagnose, Legacy-Status/Preis-/Tour-Mapping einschließlich JDBC-Boolean-Flags und CSV-Schutz. Normale Maven-Builds und Unit-Tests laden `.env` nicht. Ein Architekturtest prüft die kompilierten Java-Abhängigkeiten und Python-Launcher auf direkte Datenbankzugriffe außerhalb des Services. Nur dessen eigener Unit-Test darf JDBC mocken. Es wird keine Testdatenbank angelegt und keine Bestandsdatenbank verändert.
+`./mvnw test` führt Tests ohne Bestandszugriff aus: bestehende Anmeldung und Rechte, Sitzungs- und CSRF-Verhalten, HQ-Abgrenzung, SQL-Parameterbindung, Nichtverfügbarkeit, Abgrenzung der Erfassungs-Schreibtransaktionen, serverseitige Vorbereitung der Lesetransaktion, Transaktions-/Fehlerverhalten, Schemadiagnose, Legacy-Status/Preis-/Tour-Mapping einschließlich JDBC-Boolean-Flags und CSV-Schutz. Normale Maven-Builds und Unit-Tests laden `.env` nicht. Ein Architekturtest prüft die kompilierten Java-Abhängigkeiten und Python-Launcher auf direkte Datenbankzugriffe außerhalb des Services. Nur dessen eigener Unit-Test darf JDBC mocken. Es wird keine Testdatenbank angelegt und keine Bestandsdatenbank verändert. Erfassungs-, Preis- und Audit-Tests verwenden gemockte Datenzugriffe; sie belegen keine produktive Ende-zu-Ende-Verarbeitung.
Mit `VOTIAN_VERIFY_EXISTING_DB=true` und den konfigurierten `DB_URL`, `DB_USER`, `DB_PASSWORD` wird zusätzlich `LegacyDatabaseReadIntegrationTest` ausgeführt. Er prüft die drei Listen-APIs mit echten SELECT-Abfragen über MockMvc, die numerische JDBC-Abbildung von `TINYINT(1)` und den Historien-Endpunkt gegen das konfigurierte Protokollschema. Für die Historie muss einer der neuesten 100 aktuellen Aufträge Protokolleinträge besitzen; geprüft werden Anzahl und Antwortformat ohne Ausgabe der Protokollinhalte. Die Authentifizierung wird nur in diesem Test simuliert; die echte Anmeldung wird damit nicht getestet. Die gewählte Niederlassung muss vorhandene Aufträge, Kunden und Kuriere enthalten. Ohne ausdrückliche Aktivierung bleibt dieser Test deaktiviert.
@@ -119,3 +137,11 @@ VOTIAN_VERIFY_EXISTING_DB=true python3 ../scripts/env_config.py ./mvnw test
```
Der Integrationstest vergleicht außerdem die Kundenprojektion der neuesten 100 Aufträge mit unabhängigen Abfragen der Zahler-, Barzahler- und Erfassungskundenbezüge. Auch hierbei enthalten Testausgaben keine Kunden- oder Auftragsinhalte.
+
+### Vertriebsfunktionen und SMTP
+
+`/api/crm` stellt Kontakte (Metafeldkategorie 1), Kundenstatusauswertung und Motivation-Counter bereit. Kontaktänderungen benötigen InnoDB sowie `LOCK TABLES` auf den vier vorhandenen Metafeldtabellen, um die ID-Vergabe ohne Schemaänderung mit dem PHP-Bestand zu serialisieren. Counter-Ziele nutzen die bestehenden `MOTIVATIONCOUNTER_YYYY_MM`-Parameter und das vorhandene `MOTIVATIONCOUNTER_PASSWORD` der jeweiligen Niederlassung.
+
+Termin-E-Mails werden separat über Vorschau und explizite Sendebestätigung ausgelöst. Konfiguration aus `.env`: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_STARTTLS`, `SMTP_SSL`, `SMTP_FROM`. Leeres `SMTP_FROM` verwendet die Benutzer-Mailadresse. Kein Versand beim bloßen Speichern, keine automatische Wiederholung nach unklarer Zustellung. SMTP wurde nur gegen einen isolierten lokalen Testserver geprüft.
+
+Optionaler Kontakt-Integrationstest: ausschließlich eine wegwerfbare lokale MariaDB mit Datenbank `crm_qa`, Port 33079 und Testkonto `root`/`isolated-test-only` bereitstellen; `CRM_INTEGRATION_URL=jdbc:mariadb://127.0.0.1:33079/crm_qa ./mvnw test`. Der Test ersetzt ausschließlich vier Metafeldtabellen dieser Testdatenbank; nie auf den Bestand richten.
diff --git a/backend/pom.xml b/backend/pom.xml
index 2da5d28..d95dc1b 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -5,6 +5,8 @@
de.stadtbote votian-backend 0.1.2
Votian NG Stadtbote 21
+ org.jsoup jsoup 1.20.1
+ org.springframework.boot spring-boot-starter-mail
org.springframework.boot spring-boot-starter-web
org.springframework.boot spring-boot-starter-jdbc
org.springframework.boot spring-boot-starter-validation
diff --git a/backend/src/main/java/de/stadtbote/votian/AddressAdministration.java b/backend/src/main/java/de/stadtbote/votian/AddressAdministration.java
new file mode 100644
index 0000000..374e81d
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/AddressAdministration.java
@@ -0,0 +1,92 @@
+package de.stadtbote.votian;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import de.stadtbote.votian.database.DatabaseService;
+import org.springframework.stereotype.Service;
+import java.util.*;
+import static de.stadtbote.votian.AdminSupport.*;
+import static de.stadtbote.votian.LegacyMapping.*;
+import static de.stadtbote.votian.OrderEntryService.fields;
+
+/** Address-to-tariff mappings from srvpa_list.php and srvpaa_list.php. */
+@Service
+class AddressAdministration {
+ enum Kind {
+ POSTAL("serviceplzaddress","srvpxa","srvp_id","serviceplz","srvp_plz"),
+ AREA("serviceplzareaaddress","srvpaa","srvpa_id","serviceplzarea","srvpa_name");
+ final String table,prefix,target,targetTable,label;
+ Kind(String table,String prefix,String target,String targetTable,String label){this.table=table;this.prefix=prefix;this.target=target;this.targetTable=targetTable;this.label=label;}
+ }
+ record Query(String target,String street,String postalCode,String city,String country,String from,String to,Integer mode,String sort,int page){}
+ record Save(String requestId,String target,String street,String postalCode,String city,String country,String from,String to,int mode){}
+ record Selection(long id,String revision){}
+ record Delete(String requestId,List items){}
+ record Operation(String kind,long hq,long user,String request,List> rows){}
+ private final DatabaseService db;
+ private final ObjectMapper json;
+ private final OrderEntryCatalog catalog;
+ AddressAdministration(DatabaseService db,ObjectMapper json,OrderEntryCatalog catalog){this.db=db;this.json=json;this.catalog=catalog;}
+ void access(LegacyUser u){u.require(2);}
+ static String clean(String value,int max){value=Objects.requireNonNullElse(value,"").strip();if(value.length()>max)throw bad("Such- oder Eingabefeld ist zu lang.");return value;}
+ static String like(String value){return value.replace("!","!!").replace("%","!%").replace("_","!_")+"%";}
+ String targetName(LegacyUser u,Kind k,String value){value=clean(value,10);if(k==Kind.AREA&&!value.isEmpty()){
+ String pad=catalog.parameter(u.headquartersId(),"ZIPCODEAREA_PADLENGTH");
+ if(pad.matches("[1-9]|10"))value="0".repeat(Math.max(0,Integer.parseInt(pad)-value.length()))+value;
+ }return value;}
+ String select(Kind k){return "SELECT m."+k.prefix+"_id AS id,m.hq_id,m."+k.target+" AS targetId,m.ad_id,m."+k.prefix+"_hsno_from AS houseFrom,m."+k.prefix+"_hsno_to AS houseTo,m."+k.prefix+"_mode AS mode,t."+k.label+" AS target,a.ad_street AS street,a.ad_zipcode AS postalCode,a.ad_city AS city,a.ad_country AS country"+from(k);}
+ String from(Kind k){return " FROM "+k.table+" m JOIN address a ON a.ad_id=m.ad_id JOIN "+k.targetTable+" t ON t."+k.target+"=m."+k.target+" WHERE m.hq_id=?"+(k==Kind.AREA?" AND t.hq_id=m.hq_id":"");}
+ Map query(LegacyUser u,Kind k,Query q){access(u);if(q==null||q.page()<0||q.page()>100000)throw bad("Ungültige Suchanfrage.");
+ var args=new ArrayList();args.add(u.headquartersId());StringBuilder where=new StringBuilder();
+ String target=targetName(u,k,q.target());if(!target.isEmpty()){where.append(" AND t.").append(k.label).append("=?");args.add(target);}
+ String[] values={q.street(),q.postalCode(),q.city(),q.country()};String[] columns={"ad_street","ad_zipcode","ad_city","ad_country"};int[] lengths={50,10,50,10};
+ for(int i=0;i2)throw bad("Ungültiger Hausnummernmodus.");where.append(" AND m.").append(k.prefix).append("_mode=?");args.add(q.mode());}
+ String sort=switch(Objects.requireNonNullElse(q.sort(),"street")){case "target"->"t."+k.label;case "from"->"m."+k.prefix+"_hsno_from";case "to"->"m."+k.prefix+"_hsno_to";case "mode"->"m."+k.prefix+"_mode";case "postalCode"->"a.ad_zipcode";case "city"->"a.ad_city";case "street"->"a.ad_street";default->throw bad("Ungültige Sortierung.");};
+ long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*)"+from(k)+where,Long.class,args.toArray())).orElse(0L);
+ args.add(q.page()*100);var rows=db.queryForList(select(k)+where+" ORDER BY "+sort+",m."+k.prefix+"_id LIMIT 100 OFFSET ?",args.toArray());
+ var items=new ArrayList>();for(var r:rows){var v=new LinkedHashMap<>(r);v.put("revision",revision(r));v.put("exceptions",k==Kind.AREA?exceptions(u,id(r,"id")):List.of());items.add(v);}
+ return fields("items",items,"total",total);
+ }
+ List> exceptions(LegacyUser u,long id){
+ var result=new ArrayList>();
+ for(var row:db.queryForList("SELECT par_value FROM parameter WHERE par_key=? AND hq_id IN (0,?) AND emp_id=0","MASK_AREA_ID_FROM_ADDRESS_"+id,u.headquartersId()))
+ for(String pair:text(row,"par_value").split(";")){String[] parts=pair.split(",");if(parts.length!=2||!parts[1].matches("[0-9]{1,9}"))continue;var targets=db.queryForList("SELECT srvpa_name FROM serviceplzarea WHERE srvpa_id=? AND hq_id=?",Long.parseLong(parts[1]),u.headquartersId());result.add(fields("house",parts[0],"area",targets.isEmpty()?parts[1]:text(targets.getFirst(),"srvpa_name")));}
+ return result;
+ }
+ Map options(LegacyUser u,Kind k){access(u);return fields("headquartersId",u.headquartersId(),"operations",db.queryForList("SELECT gdc_id AS id,SUBSTRING(gdc_gen_fieldname,9) AS token FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_obj_id=? AND LEFT(gdc_gen_fieldname,8)='ng_addr_' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 100",k.prefix,u.headquartersId()));}
+ static Integer house(String value){String v=clean(value,4);if(v.isEmpty())return null;if(!v.matches("[0-9]{1,4}"))throw bad("Hausnummerngrenzen müssen ganze Zahlen von 0 bis 9999 sein.");return Integer.parseInt(v);}
+ long targetId(LegacyUser u,Kind k,String value,String country){var rows=db.queryForList("SELECT "+k.target+" AS id FROM "+k.targetTable+" WHERE "+k.label+"=?"+(k==Kind.AREA?" AND hq_id=?":" AND COALESCE(NULLIF(srvp_country,''),'DE')=?"),targetName(u,k,value),k==Kind.AREA?u.headquartersId():country);if(rows.size()!=1)throw bad("Abrechnungs-PLZ bzw. Bereich existiert nicht eindeutig in der ausgewählten Niederlassung / im Land.");return id(rows.getFirst(),"id");}
+ String encode(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("Vorgang konnte nicht gesichert werden.",e);}}
+ Operation decode(Map row){try{return json.readValue(text(row,"gdc_content"),Operation.class);}catch(Exception e){throw bad("Gespeicherter Adressvorgang ist beschädigt.");}}
+ Map operation(LegacyUser u,Kind k,String token,boolean lock){String sql="SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_obj_id=? AND gdc_gen_fieldname=?";var rows=lock?db.lockRows(sql,k.prefix,u.headquartersId(),"ng_addr_"+token):db.queryForList(sql,k.prefix,u.headquartersId(),"ng_addr_"+token);if(rows.size()>1)throw bad("Mehrdeutige Vorgangskennung.");return rows.isEmpty()?null:rows.getFirst();}
+ void persist(LegacyUser u,Kind k,String token,Operation op){String content=encode(op);if(content.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65000)throw bad("Bitte weniger Zuordnungen auf einmal auswählen.");db.insert("genericdatacontainer",fields("gdc_obj_type",k.prefix,"gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname","ng_addr_"+token,"gdc_content",content,"gdc_context","PENDING"));}
+ boolean repeated(Map row,LegacyUser u,String request){if(row==null)return false;var op=decode(row);if(op.user()!=u.userId()||!op.request().equals(request))throw bad("Vorgangskennung wurde bereits für eine andere Änderung verwendet.");return true;}
+ Map create(LegacyUser u,Kind k,Save s){access(u);if(s==null)throw bad("Adressdaten fehlen.");String token=FilterAdministration.token(s.requestId());String request=encode(s);
+ String street=required(s.street(),50,"Straße"),postal=required(s.postalCode(),10,"PLZ"),country=clean(s.country(),10).toUpperCase(Locale.ROOT);if(country.isEmpty())country="DE";final String nation=country;
+ Integer start=house(s.from()),end=house(s.to());if(start!=null&&end!=null&&start>end)throw bad("Hausnummer von darf nicht größer als bis sein.");if(s.mode()<0||s.mode()>2)throw bad("Ungültiger Hausnummernmodus.");
+ db.writeTransaction(()->{lockHeadquarters(db,u);if(repeated(operation(u,k,token,true),u,request))return null;
+ long target=targetId(u,k,required(s.target(),10,"Abrechnungsziel"),nation);
+ if(db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=? AND COALESCE(NULLIF(srvp_country,''),'DE')=?",postal,nation).isEmpty())throw bad("Die Straßen-PLZ existiert nicht im gewählten Land.");
+ String city=clean(s.city(),50);if(city.isEmpty()){var cities=db.queryForList("SELECT DISTINCT ad_city FROM address WHERE ad_zipcode=? AND ad_country=? AND ad_city<>'' ORDER BY ad_city LIMIT 2",postal,nation);if(cities.size()==1)city=text(cities.getFirst(),"ad_city");else throw bad("Bitte den Ort ergänzen; zur PLZ ist kein eindeutiger Ort hinterlegt.");}
+ var addresses=db.queryForList("SELECT ad_id FROM address WHERE ad_street=? AND ad_zipcode=? AND ad_city=? AND ad_country=? ORDER BY ad_id LIMIT 1",street,postal,city,nation);
+ long address=addresses.isEmpty()?db.insert("address",fields("ad_street",street,"ad_zipcode",postal,"ad_city",city,"ad_country",nation)):id(addresses.getFirst(),"ad_id");
+ var row=fields("targetId",target,"ad_id",address,"houseFrom",start,"houseTo",end==null?null:end.toString(),"mode",s.mode());
+ // Commit address and intent before touching the nontransactional association table.
+ persist(u,k,token,new Operation("CREATE",u.headquartersId(),u.userId(),request,List.of(row)));return null;});
+ return resume(u,k,token);
+ }
+ Map remove(LegacyUser u,Kind k,Delete s){access(u);if(s==null||s.items()==null||s.items().isEmpty()||s.items().size()>100)throw bad("Bitte 1 bis 100 Zuordnungen auswählen.");String token=FilterAdministration.token(s.requestId()),request=encode(s);
+ db.writeTransaction(()->{lockHeadquarters(db,u);if(repeated(operation(u,k,token,true),u,request))return null;var rows=new ArrayList>();var seen=new HashSet();for(var selection:s.items()){if(selection==null||!seen.add(selection.id()))throw bad("Ungültige oder doppelte Auswahl.");var r=one(db.queryForList(select(k)+" AND m."+k.prefix+"_id=?",u.headquartersId(),selection.id()));unchanged(r,selection.revision());rows.add(r);}persist(u,k,token,new Operation("DELETE",u.headquartersId(),u.userId(),request,rows));return null;});return resume(u,k,token);
+ }
+ String match(Kind k){return k.target+"=? AND ad_id=? AND "+k.prefix+"_hsno_from <=> ? AND "+k.prefix+"_hsno_to <=> ? AND "+k.prefix+"_mode <=> ?";}
+ List args(Map r){return new ArrayList<>(Arrays.asList(id(r,"targetId"),id(r,"ad_id"),r.get("houseFrom"),r.get("houseTo"),r.get("mode")));}
+ Map resume(LegacyUser u,Kind k,String token){access(u);FilterAdministration.token(token);if(operation(u,k,token,false)==null)throw bad("Adressvorgang nicht gefunden.");
+ try{return db.writeTransaction(()->{lockHeadquarters(db,u);var saved=operation(u,k,token,true);if(text(saved,"gdc_context").equals("DONE"))return fields("state","DONE","message","Adressvorgang abgeschlossen.");var op=decode(saved);
+ for(var r:op.rows())if(op.kind().equals("CREATE")){var parameters=args(r);parameters.add(0,op.hq());var insertArgs=new ArrayList<>(parameters);insertArgs.addAll(parameters);db.update("INSERT INTO "+k.table+" (hq_id,"+k.target+",ad_id,"+k.prefix+"_hsno_from,"+k.prefix+"_hsno_to,"+k.prefix+"_mode) SELECT ?,?,?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM "+k.table+" WHERE hq_id=? AND "+match(k)+")",insertArgs.toArray());}
+ else if(op.kind().equals("DELETE")){var parameters=args(r);parameters.add(0,op.hq());parameters.add(0,id(r,"id"));int removed=db.update("DELETE FROM "+k.table+" WHERE "+k.prefix+"_id=? AND hq_id=? AND "+match(k),parameters.toArray());if(removed==0&&!db.queryForList("SELECT "+k.prefix+"_id FROM "+k.table+" WHERE "+k.prefix+"_id=?",id(r,"id")).isEmpty())throw bad("Eine Zuordnung wurde zwischenzeitlich geändert. Bereits entfernte Zuordnungen bleiben entfernt.");}
+ else throw bad("Unbekannter Adressvorgang.");
+ db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(saved,"gdc_id"));return fields("state","DONE","message",op.kind().equals("CREATE")?"Adresszuordnung gespeichert.":"Ausgewählte Zuordnungen entfernt.");});}
+ catch(RuntimeException e){return fields("state","PENDING","message","Der Adressvorgang ist gesichert, aber noch nicht abgeschlossen. Bitte den vorhandenen Vorgang fortsetzen."+(e instanceof org.springframework.web.server.ResponseStatusException r&&r.getReason()!=null?" "+r.getReason():""));}
+ }
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/AddressController.java b/backend/src/main/java/de/stadtbote/votian/AddressController.java
new file mode 100644
index 0000000..4681448
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/AddressController.java
@@ -0,0 +1,15 @@
+package de.stadtbote.votian;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+import java.util.Map;
+import static de.stadtbote.votian.ApiController.user;
+@RestController @RequestMapping("/api/administration/addresses/{kind}")
+class AddressController {
+ private final AddressAdministration service;
+ AddressController(AddressAdministration service){this.service=service;}
+ @GetMapping Map options(Authentication a,@PathVariable AddressAdministration.Kind kind){return service.options(user(a),kind);}
+ @PostMapping("/query") Map query(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Query q){return service.query(user(a),kind,q);}
+ @PostMapping Map create(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Save s){return service.create(user(a),kind,s);}
+ @PostMapping("/remove") Map remove(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Delete s){return service.remove(user(a),kind,s);}
+ @PostMapping("/operations/{token}/resume") Map resume(Authentication a,@PathVariable AddressAdministration.Kind kind,@PathVariable String token){return service.resume(user(a),kind,token);}
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/AdminSupport.java b/backend/src/main/java/de/stadtbote/votian/AdminSupport.java
new file mode 100644
index 0000000..5fd6a77
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/AdminSupport.java
@@ -0,0 +1,51 @@
+package de.stadtbote.votian;
+
+import de.stadtbote.votian.database.DatabaseService;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.*;
+import java.util.*;
+import static de.stadtbote.votian.LegacyMapping.*;
+
+/** Shared validation for the explicitly ported administration workflows. */
+final class AdminSupport {
+ private AdminSupport() {}
+ static String revision(Object value) {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical(value).getBytes(StandardCharsets.UTF_8)));
+ } catch (java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
+ }
+ private static String canonical(Object value) {
+ if(value instanceof Map,?> map) {
+ var sorted=new TreeMap();map.forEach((k,v)->sorted.put(String.valueOf(k),canonical(v)));
+ return sorted.toString();
+ }
+ if(value instanceof Collection> list)return list.stream().map(AdminSupport::canonical).toList().toString();
+ return value==null?"":value.getClass().getSimpleName()+":"+value;
+ }
+ static void unchanged(Object current,String expected) {
+ if(expected==null || !revision(current).equals(expected))throw new ResponseStatusException(HttpStatus.CONFLICT,"Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderungen prüfen.");
+ }
+ static String required(String value,int max,String label) {
+ value=value==null?"":value.strip();
+ if(value.isEmpty() || value.length()>max)throw bad(label+": Bitte 1 bis "+max+" Zeichen eingeben.");
+ return value;
+ }
+ static void range(LocalDate from,LocalDate to) {
+ if(from==null || to==null || from.isAfter(to) || from.plusYears(10).isBefore(to))throw bad("Bitte einen gültigen Zeitraum von höchstens zehn Jahren wählen.");
+ }
+ static Map one(List> rows) {
+ if(rows.size()!=1)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"Der Datensatz ist nicht vorhanden oder nicht freigegeben.");
+ return rows.getFirst();
+ }
+ static void lockHeadquarters(DatabaseService db,LegacyUser user) {
+ one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",user.headquartersId()));
+ }
+ static long mandator(DatabaseService db,LegacyUser user) {
+ var rows=db.queryForList("SELECT md_id FROM mandatorheadquarters WHERE hq_id=? AND md_id>0",user.headquartersId());
+ return id(one(rows),"md_id");
+ }
+ static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);}
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/AdministrationController.java b/backend/src/main/java/de/stadtbote/votian/AdministrationController.java
new file mode 100644
index 0000000..40856d4
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/AdministrationController.java
@@ -0,0 +1,35 @@
+package de.stadtbote.votian;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+import java.util.*;
+import static de.stadtbote.votian.ApiController.user;
+import static de.stadtbote.votian.CalendarAdministration.*;
+
+@RestController
+@RequestMapping("/api/administration")
+public class AdministrationController {
+ private final CalendarAdministration calendar;
+ private final GroupAdministration groups;
+ private final GroupDeletion deletion;
+ public AdministrationController(CalendarAdministration calendar,GroupAdministration groups,GroupDeletion deletion){this.calendar=calendar;this.groups=groups;this.deletion=deletion;}
+ @GetMapping Map sections(Authentication auth) {
+ var u=user(auth);
+ return OrderEntryService.fields("postalPrices",u.hasRight(2),"postalDiscounts",u.hasRight(2),"areaPrices",u.hasRight(2),"areaDiscounts",u.hasRight(2),"servicePrices",u.hasRight(2)&&(u.hasRight(29)||u.hasRight(0)),"serviceDiscounts",u.hasRight(2)&&(u.hasRight(29)||u.hasRight(0)),"postalAddresses",u.hasRight(2),"areaAddresses",u.hasRight(2),"headquartersId",u.headquartersId(),"holidays",u.hasRight(2),"travelTimes",u.hasRight(2),"groups",u.hasRight(8)&&u.hasRight(25),"sharedGroups",u.hasRight(10),"evaluations",u.hasRight(0)&&u.hasRight(8),"filters",u.hasRight(0),"customerReports",u.hasRight(0)&&u.hasRight(16),"courierReports",u.hasRight(1)&&u.hasRight(16),"groupKinds",Arrays.stream(GroupAdministration.Kind.values()).filter(k->u.hasRight(k.right)&&(k.hq!=null||u.hasRight(10))).map(k->Map.of("id",k.name(),"label",k.label)).toList());
+ }
+ @GetMapping("/holidays/{year}") Map holidays(Authentication auth,@PathVariable int year){return calendar.holidays(user(auth),year);}
+ @PutMapping("/holidays/{year}") Map holidays(Authentication auth,@PathVariable int year,@RequestBody HolidaySave request){return calendar.saveHolidays(user(auth),year,request);}
+ @GetMapping("/travel-times") Map travel(Authentication auth,@RequestParam TravelMode mode,@RequestParam String from,@RequestParam String to){return calendar.travelTimes(user(auth),mode,from,to);}
+ @PutMapping("/travel-times/{id}") Map travel(Authentication auth,@PathVariable long id,@RequestParam TravelMode mode,@RequestBody TravelSave request){return calendar.saveTravelTime(user(auth),mode,id,request);}
+ @GetMapping("/groups") List> groups(Authentication auth,@RequestParam(required=false) Long headquartersId){return groups.list(groups.scoped(user(auth),headquartersId));}
+ @PostMapping("/groups") Map createGroup(Authentication auth,@RequestParam(required=false) Long headquartersId,@RequestBody GroupAdministration.GroupSave request){return groups.save(groups.scoped(user(auth),headquartersId),0,request);}
+ @PutMapping("/groups/{id}") Map saveGroup(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@RequestBody GroupAdministration.GroupSave request){return groups.save(groups.scoped(user(auth),headquartersId),id,request);}
+ record Revision(String revision) {}
+ @GetMapping("/groups/headquarters") Object groupHeadquarters(Authentication auth){return groups.headquarters(user(auth));}
+ @GetMapping("/groups/operations") Object groupOperations(Authentication auth,@RequestParam(required=false) Long headquartersId){return deletion.pending(groups.scoped(user(auth),headquartersId));}
+ @GetMapping("/groups/{id}/deletion") Object groupDeletion(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId){return deletion.preview(groups.scoped(user(auth),headquartersId),id);}
+ @DeleteMapping("/groups/{id}") Object removeGroup(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@RequestBody GroupDeletion.Delete request){return deletion.remove(groups.scoped(user(auth),headquartersId),id,request);}
+ @PostMapping("/groups/operations/{token}") Object resumeGroupDeletion(Authentication auth,@PathVariable String token,@RequestParam(required=false) Long headquartersId){return deletion.resume(groups.scoped(user(auth),headquartersId),token);}
+ @GetMapping("/groups/{id}/members") Map members(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@RequestParam GroupAdministration.Kind kind,@RequestParam(defaultValue="") String q,@RequestParam(defaultValue="true") boolean onlyMembers){return groups.members(groups.scoped(user(auth),headquartersId),id,kind,q,onlyMembers);}
+ @PutMapping("/groups/{id}/members/{kind}/{objectId}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void membership(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@PathVariable GroupAdministration.Kind kind,@PathVariable long objectId,@RequestBody GroupAdministration.MembershipSave request){groups.membership(groups.scoped(user(auth),headquartersId),id,kind,objectId,request);}
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/ApiController.java b/backend/src/main/java/de/stadtbote/votian/ApiController.java
index 94acfe9..66d7ef6 100644
--- a/backend/src/main/java/de/stadtbote/votian/ApiController.java
+++ b/backend/src/main/java/de/stadtbote/votian/ApiController.java
@@ -38,11 +38,11 @@ public class ApiController {
boolean connected=connection.connected();
LegacyUser u=authentication!=null&&authentication.getPrincipal() instanceof LegacyUser lu?lu:null;
Map result=new LinkedHashMap<>();
- result.put("connected",connected);result.put("database","legacy-mysql");result.put("readOnly",true);result.put("schemaChanges",false);
+ result.put("connected",connected);result.put("database","legacy-mysql");result.put("readOnly",u==null||!(u.hasRight(0)||u.hasRight(1)||u.hasRight(7)||u.hasRight(2)||u.hasRight(4)||u.hasRight(18)||u.hasRight(15)||(u.hasRight(0)&&u.hasRight(32))||(u.hasRight(0)&&u.hasRight(8))||(u.hasRight(0)&&u.hasRight(17))||(u.hasRight(0)&&u.hasRight(11))||(u.hasRight(8)&&u.hasRight(25))||(u.hasRight(16)&&(u.hasRight(0)||u.hasRight(1)))));result.put("schemaChanges",false);
result.put("message",connection.message());result.put("reason",connection.reason());
result.put("legacyUrl",legacyUrl);result.put("maxResults",legacy.maxResults());
result.put("capabilities",u==null?new Capabilities(false,false,false,false,false,false,false,false,false,false):u.capabilities());
- result.put("unavailableModules",List.of("Rechnungserstellung und Zahlungsstatus","Änderungen an Aufträgen und Stammdaten","Archivtabellen"));
+ result.put("unavailableModules",List.of("Fibu-Zahlungsabgleich","Archivtabellen"));
return result;
}
@GetMapping("/me") public Map me(Authentication authentication) {
@@ -57,7 +57,7 @@ public class ApiController {
}
@GetMapping("/orders/{id}") public DeliveryOrder order(Authentication auth,@PathVariable long id) { return legacy.order(user(auth),id); }
@GetMapping("/orders/{id}/history") public List history(Authentication auth,@PathVariable long id) { return history.history(user(auth),id); }
- @GetMapping("/customers") public List customers(Authentication auth,@RequestParam(required=false) String q) { return legacy.customers(user(auth),q); }
+ @GetMapping("/customers") public List customers(Authentication auth,@RequestParam(required=false) String q,@RequestParam(defaultValue="false") boolean showCostCenters,@RequestParam(required=false) String costCenterName) { return legacy.customers(user(auth),q,showCostCenters,costCenterName); }
@GetMapping("/couriers") public List couriers(Authentication auth,@RequestParam(required=false) String q) { return legacy.couriers(user(auth),q); }
@GetMapping("/orders/export.csv") public ResponseEntity csv(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to) {
LegacyUser u=user(auth);u.require(5);
diff --git a/backend/src/main/java/de/stadtbote/votian/ApiErrors.java b/backend/src/main/java/de/stadtbote/votian/ApiErrors.java
index 57d6ce6..90e684c 100644
--- a/backend/src/main/java/de/stadtbote/votian/ApiErrors.java
+++ b/backend/src/main/java/de/stadtbote/votian/ApiErrors.java
@@ -25,6 +25,15 @@ public class ApiErrors {
ResponseEntity> status(ResponseStatusException exception) { return ResponseEntity.status(exception.getStatusCode()).body(body(exception.getReason()==null?"Die Anfrage konnte nicht verarbeitet werden.":exception.getReason())); }
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
ResponseEntity> argument(MethodArgumentTypeMismatchException exception) { return ResponseEntity.badRequest().body(body("Ungültiger Parameter: "+exception.getName())); }
+ @ExceptionHandler(org.springframework.web.bind.MethodArgumentNotValidException.class)
+ ResponseEntity> validation(org.springframework.web.bind.MethodArgumentNotValidException exception) {
+ return ResponseEntity.badRequest().body(Map.of("message","Bitte die Eingaben prüfen.","errors",exception.getBindingResult().getFieldErrors().stream()
+ .map(error -> error.getField()+": "+error.getDefaultMessage()).distinct().toList()));
+ }
+ @ExceptionHandler(org.springframework.http.converter.HttpMessageNotReadableException.class)
+ ResponseEntity> malformed(org.springframework.http.converter.HttpMessageNotReadableException exception) {
+ return ResponseEntity.badRequest().body(body("Ungültige Eingaben. Bitte Feldwerte, Datum und Uhrzeit prüfen."));
+ }
@ExceptionHandler(Exception.class)
ResponseEntity> unknown(Exception exception) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body("Die Anfrage konnte nicht verarbeitet werden. Bitte die Konfiguration des Legacy-Adapters prüfen.")); }
}
diff --git a/backend/src/main/java/de/stadtbote/votian/AppointmentController.java b/backend/src/main/java/de/stadtbote/votian/AppointmentController.java
new file mode 100644
index 0000000..f151418
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/AppointmentController.java
@@ -0,0 +1,23 @@
+package de.stadtbote.votian;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+import java.util.*;
+import static de.stadtbote.votian.ApiController.user;
+import static de.stadtbote.votian.AppointmentService.*;
+@RestController
+@RequestMapping("/api/appointments")
+public class AppointmentController {
+ private final AppointmentService service;
+ public AppointmentController(AppointmentService service) { this.service=service; }
+ @GetMapping("/options") Map options(Authentication auth) { return service.options(user(auth)); }
+ @GetMapping("/customers") List> customers(Authentication auth,@RequestParam(defaultValue="") String q) { return service.customers(user(auth),q); }
+ @PostMapping("/query") Map list(Authentication auth,@RequestBody Query query) { return service.list(user(auth),query); }
+ @PostMapping("/statistics") Map statistics(Authentication auth,@RequestBody Query query) { return service.statistics(user(auth),query); }
+ @PostMapping Map create(Authentication auth,@RequestBody Save request) { return service.create(user(auth),request); }
+ @PutMapping("/{id}") Map save(Authentication auth,@PathVariable long id,@RequestBody Save request) { return service.save(user(auth),id,request); }
+ @PostMapping("/{id}/resubmit") Map resubmit(Authentication auth,@PathVariable long id,@RequestBody Resubmission request) { return service.resubmit(user(auth),id,request); }
+ record Revision(String revision) {}
+ @DeleteMapping("/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void remove(Authentication auth,@PathVariable long id,@RequestBody Revision request) { service.remove(user(auth),id,request.revision()); }
+ @PostMapping("/{id}/confirm") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void confirm(Authentication auth,@PathVariable long id,@RequestBody Revision request) { service.confirm(user(auth),id,request.revision()); }
+ @PostMapping("/{id}/finish") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void finish(Authentication auth,@PathVariable long id,@RequestBody Revision request) { service.finish(user(auth),id,request.revision()); }
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/AppointmentMailTransport.java b/backend/src/main/java/de/stadtbote/votian/AppointmentMailTransport.java
new file mode 100644
index 0000000..1c2859c
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/AppointmentMailTransport.java
@@ -0,0 +1,18 @@
+package de.stadtbote.votian;
+import org.springframework.stereotype.Component;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSenderImpl;
+@Component
+class AppointmentMailTransport {
+ private final JavaMailSenderImpl sender=new JavaMailSenderImpl();
+ final String from;
+ AppointmentMailTransport(@Value("${app.mail.host:}")String host,@Value("${app.mail.port:25}")int port,@Value("${app.mail.username:}")String username,@Value("${app.mail.password:}")String password,@Value("${app.mail.starttls:true}")boolean tls,@Value("${app.mail.ssl:false}")boolean ssl,@Value("${app.mail.from:}")String from){sender.setHost(host);sender.setPort(port);sender.setUsername(username);sender.setPassword(password);sender.setDefaultEncoding("UTF-8");this.from=from;var p=sender.getJavaMailProperties();p.setProperty("mail.smtp.auth",Boolean.toString(!username.isBlank()));p.setProperty("mail.smtp.starttls.enable",Boolean.toString(tls));p.setProperty("mail.smtp.ssl.enable",Boolean.toString(ssl));p.setProperty("mail.smtp.ssl.checkserveridentity","true");p.setProperty("mail.smtp.connectiontimeout","10000");p.setProperty("mail.smtp.timeout","10000");p.setProperty("mail.smtp.writetimeout","10000");}
+ boolean configured(){return sender.getHost()!=null&&!sender.getHost().isBlank();}
+ void send(String from,String replyTo,java.util.List recipients,String subject,String body){var msg=new SimpleMailMessage();msg.setFrom(from);msg.setReplyTo(replyTo);msg.setBcc(recipients.toArray(String[]::new));msg.setSubject(subject);msg.setText(body);sender.send(msg);}
+ void sendHtml(String from,String replyTo,String recipient,java.util.List cc,java.util.List bcc,String subject,String html){
+ try {var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(from);helper.setReplyTo(replyTo);helper.setTo(recipient);if(!cc.isEmpty())helper.setCc(cc.toArray(String[]::new));if(!bcc.isEmpty())helper.setBcc(bcc.toArray(String[]::new));helper.setSubject(subject);
+ var document=org.jsoup.Jsoup.parse(html);var embedded=new java.util.LinkedHashMap();for(var img:document.select("img[src]")){String data=img.attr("src");if(data.matches("data:image/(png|jpeg|gif);base64,[A-Za-z0-9+/=]+")){String cid="newsletter-image-"+embedded.size();embedded.put(cid,data);img.attr("src","cid:"+cid);}}helper.setText(document.text(),document.html());for(var item:embedded.entrySet()){String data=item.getValue();String type=data.substring(5,data.indexOf(';'));byte[] bytes=java.util.Base64.getDecoder().decode(data.substring(data.indexOf(',')+1));helper.addInline(item.getKey(),new org.springframework.core.io.ByteArrayResource(bytes),type);}sender.send(mime);
+ }catch(jakarta.mail.MessagingException e){throw new org.springframework.mail.MailPreparationException("Newsletter konnte nicht vorbereitet werden.",e);}
+ }
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/AppointmentNotificationController.java b/backend/src/main/java/de/stadtbote/votian/AppointmentNotificationController.java
new file mode 100644
index 0000000..83f3c35
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/AppointmentNotificationController.java
@@ -0,0 +1,13 @@
+package de.stadtbote.votian;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+import java.util.Map;
+import static de.stadtbote.votian.ApiController.user;
+@RestController
+@RequestMapping("/api/appointments")
+class AppointmentNotificationController {
+ private final AppointmentNotifications service;
+ AppointmentNotificationController(AppointmentNotifications service){this.service=service;}
+ @PostMapping("/{id}/notifications/preview") Map preview(Authentication a,@PathVariable long id,@RequestBody AppointmentNotifications.Draft d){return service.preview(user(a),id,d);}
+ @PostMapping("/notifications/{token}/send") Map send(Authentication a,@PathVariable String token){return service.send(user(a),token);}
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/AppointmentNotifications.java b/backend/src/main/java/de/stadtbote/votian/AppointmentNotifications.java
new file mode 100644
index 0000000..99db3af
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/AppointmentNotifications.java
@@ -0,0 +1,27 @@
+package de.stadtbote.votian;
+import de.stadtbote.votian.database.DatabaseService;
+import org.springframework.stereotype.Service;
+import java.time.*;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import jakarta.mail.internet.InternetAddress;
+import static de.stadtbote.votian.AdminSupport.*;
+import static de.stadtbote.votian.LegacyMapping.*;
+import static de.stadtbote.votian.OrderEntryService.fields;
+@Service
+class AppointmentNotifications {
+ enum Kind {NEW,CHANGED,CANCELLED}
+ record Draft(Kind kind,boolean participants,String external,String salutation,String greeting){}
+ private final DatabaseService db;private final AppointmentService appointments;private final AppointmentMailTransport transport;
+ private final Map pending=new LinkedHashMap<>();
+ private static class Pending {long user,hq,appointment;String revision,from,reply,subject,body;List recipients;Instant expires;Map result;boolean sending;}
+ AppointmentNotifications(DatabaseService db,AppointmentService appointments,AppointmentMailTransport transport){this.db=db;this.appointments=appointments;this.transport=transport;}
+ static String address(String raw){if(raw==null||raw.isBlank()||raw.length()>254||raw.contains("\r")||raw.contains("\n"))throw bad("Ungültige E-Mail-Adresse.");try{var a=new InternetAddress(raw.strip(),true);a.validate();if(a.getPersonal()!=null||!a.getAddress().contains("@"))throw bad("Bitte reine E-Mail-Adressen ohne Anzeigenamen eingeben.");return a.getAddress();}catch(jakarta.mail.internet.AddressException e){throw bad("Ungültige E-Mail-Adresse.");}}
+ Map preview(LegacyUser u,long appointment,Draft d){var row=appointments.appointment(u,appointment);AppointmentService.editable(u,row);if(d==null||d.kind()==null)throw bad("Benachrichtigungsart fehlt.");if(d.kind()==Kind.CANCELLED&&id(row,"usr_id")!=u.userId())u.require(3);if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert. SMTP_HOST in der .env ergänzen.");
+ String external=d.external()==null?"":d.external().strip();if(external.length()>2000)throw bad("Zu viele externe Adressen.");var recipients=new TreeMap();var missing=new ArrayList();String actor=address(text(one(db.queryForList("SELECT usr_email FROM user WHERE usr_id=? AND hq_id=?",u.userId(),u.headquartersId())),"usr_email"));
+ if(d.participants()){recipients.put(actor.toLowerCase(Locale.ROOT),actor);var ids=GroupAdministration.memberships(text(row,"ap_participants"));if(!ids.isEmpty()){var args=new ArrayList(ids);args.add(u.headquartersId());var found=new HashSet();for(var person:db.queryForList("SELECT DISTINCT u.usr_id,u.usr_email,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM user u JOIN mandatorheadquarters m ON m.hq_id=u.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE u.usr_id IN ("+CrmScope.marks(ids.size())+") AND own.hq_id=? AND own.md_id>0",args.toArray())){found.add(id(person,"usr_id"));try{String a=address(text(person,"usr_email"));recipients.put(a.toLowerCase(Locale.ROOT),a);}catch(org.springframework.web.server.ResponseStatusException e){missing.add(text(person,"label"));}}for(long id:ids)if(!found.contains(id))missing.add("Benutzer "+id+" (nicht verfügbar)");}}
+ if(!external.isEmpty())for(String part:external.split("[,;]",-1)){String a=address(part);recipients.put(a.toLowerCase(Locale.ROOT),a);}if(recipients.isEmpty()||recipients.size()>100)throw bad("Bitte 1 bis 100 Empfänger auswählen.");
+ String salutation=d.salutation()==null?"":d.salutation().strip(),greeting=d.greeting()==null?"":d.greeting().strip();if(salutation.length()>1000||greeting.length()>1000)throw bad("Anrede oder Grußformel ist zu lang.");String label=switch(d.kind()){case NEW->"Neuer Termin";case CHANGED->"Termin geändert";case CANCELLED->"Termin abgesagt";};if(dateTime(row,"ap_execdate")==null)throw bad("Der gespeicherte Termin hat keinen gültigen Beginn.");String start=dateTime(row,"ap_execdate").format(DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm"));var p=new Pending();p.user=u.userId();p.hq=u.headquartersId();p.appointment=appointment;p.revision=revision(AppointmentService.version(row));p.from=transport.from.isBlank()?actor:address(transport.from);p.reply=actor;p.subject=label+": "+start;p.body=salutation+"\n\n"+label+"\nBeginn: "+start+"\nEnde: "+(dateTime(row,"ap_enddate")==null?"offen":dateTime(row,"ap_enddate").format(DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm")))+"\n\n"+text(row,"ap_text")+"\n\n"+greeting;p.recipients=List.copyOf(recipients.values());p.expires=Instant.now().plusSeconds(900);String token=UUID.randomUUID().toString();synchronized(pending){pending.values().removeIf(v->v.expires.isBefore(Instant.now())&&!v.sending);if(pending.size()>=1000)throw bad("Zu viele Versandvorschauen. Bitte später versuchen.");pending.put(token,p);}return fields("token",token,"from",p.from,"replyTo",p.reply,"recipients",p.recipients,"missing",missing,"subject",p.subject,"body",p.body);}
+ Map send(LegacyUser u,String token){u.require(0);u.require(11);Pending p;synchronized(pending){p=pending.get(token);if(p==null||p.user!=u.userId()||p.hq!=u.headquartersId()||p.expires.isBefore(Instant.now()))throw bad("Versandvorschau abgelaufen. Bitte neu erstellen.");if(p.result!=null)return p.result;if(p.sending)throw bad("Versand läuft bereits. Bitte warten.");var row=appointments.appointment(u,p.appointment);AppointmentService.editable(u,row);unchanged(AppointmentService.version(row),p.revision);p.sending=true;}
+ Map result;try{transport.send(p.from,p.reply,p.recipients,p.subject,p.body);result=fields("sent",true,"message","E-Mail vom SMTP-Server angenommen. Der Termin bleibt gespeichert.");}catch(org.springframework.mail.MailException e){result=fields("sent",false,"message","Der Versand konnte nicht vollständig bestätigt werden. Der Termin bleibt gespeichert. Bitte Zustellung prüfen, bevor Sie eine neue Nachricht senden.");}synchronized(pending){p.result=result;p.sending=false;}return result;}
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/AppointmentService.java b/backend/src/main/java/de/stadtbote/votian/AppointmentService.java
new file mode 100644
index 0000000..047b998
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/AppointmentService.java
@@ -0,0 +1,264 @@
+package de.stadtbote.votian;
+
+import de.stadtbote.votian.database.DatabaseService;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.stereotype.Service;
+import org.springframework.web.server.ResponseStatusException;
+import java.nio.charset.StandardCharsets;
+import java.time.*;
+import java.time.temporal.ChronoUnit;
+import java.util.*;
+import static de.stadtbote.votian.AdminSupport.*;
+import static de.stadtbote.votian.LegacyMapping.*;
+import static de.stadtbote.votian.OrderEntryService.fields;
+
+/** Port of groupware/appointment.php. Notifications use the separate preview/send workflow. */
+@Service
+public class AppointmentService {
+ static final String TABLE="`phoenix_group`.appointment";
+ static final List COLUMNS=List.of("ap_id","hq_id","usr_id","ap_type","ap_category_1","ap_category_2","ap_category_3","ap_category_4","ap_participants","ap_text","ap_execdate","ap_enddate","ap_confirmed","ap_modifytime","cs_id","csc_id","pt_id");
+ private final DatabaseService db;
+ private final OrderEntryCatalog catalog;
+ public AppointmentService(DatabaseService db,OrderEntryCatalog catalog) { this.db=db;this.catalog=catalog; }
+ enum Visibility { OWN_PRIVATE,OWN_PARTICIPANTS,OWN_PUBLIC,INVITED,OTHER_PUBLIC }
+ record Query(LocalDate from,LocalDate to,List headquarters,Long personId,Long customerId,Integer status,Integer kind,Integer monitoring,List visibility,String search) {}
+ record Save(String revision,LocalDateTime startsAt,LocalDateTime endsAt,String text,int status,int visibility,int kind,int monitoring,Long customerId,List participants) {}
+ record Resubmission(String revision,LocalDateTime startsAt,LocalDateTime endsAt) {}
+ record Sql(String where,Object[] args) {}
+ private void access(LegacyUser user) { user.require(0);user.require(11); }
+ private static String marks(int size) { return String.join(",",Collections.nCopies(size,"?")); }
+ private static String participant(long userId) { return "%,"+userId+",%"; }
+ private List> headquarters(LegacyUser user) {
+ return db.queryForList("SELECT DISTINCT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE own.hq_id=? AND own.md_id>0"+(user.hasRight(10)?"":" AND h.hq_id=?")+" ORDER BY h.hq_mnemonic",user.hasRight(10)?new Object[]{user.headquartersId()}:new Object[]{user.headquartersId(),user.headquartersId()});
+ }
+ private List scope(LegacyUser user,List requested) {
+ if(requested==null||requested.isEmpty()||requested.size()>100||requested.stream().anyMatch(Objects::isNull))throw bad("Bitte mindestens eine Niederlassung wählen.");
+ var ids=requested.stream().distinct().sorted().toList();
+ if(ids.size()==1&&ids.getFirst()==user.headquartersId())return ids;
+ user.require(10);
+ var allowed=headquarters(user).stream().map(r->id(r,"id")).toList();
+ if(!allowed.containsAll(ids))throw new AccessDeniedException("Niederlassung ist für diesen Mandanten nicht freigegeben.");
+ return ids;
+ }
+ private List allowedHeadquarters(LegacyUser user) {
+ var ids=headquarters(user).stream().map(r->id(r,"id")).toList();
+ if(ids.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben.");return ids;
+ }
+ private List> employees(List hqs) {
+ var rows=db.queryForList("SELECT u.usr_id AS id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label,u.hq_id AS headquartersId,h.hq_mnemonic AS headquarters,e.emp_group AS groups FROM user u JOIN employee e ON e.usr_id=u.usr_id JOIN headquarters h ON h.hq_id=u.hq_id WHERE u.usr_type=1 AND u.hq_id IN ("+marks(hqs.size())+") ORDER BY u.usr_name,u.usr_firstname,u.usr_id LIMIT 5001",hqs.toArray());
+ if(rows.size()>5000)throw bad("Zu viele Mitarbeiter. Bitte die Niederlassungsauswahl eingrenzen.");
+ return rows;
+ }
+ private Map>> categories() {
+ var result=new LinkedHashMap>>();
+ var labels=new HashMap();
+ for(var p:db.queryForList("SELECT par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id=0 AND par_key LIKE 'MASK_AP_CAT_%'"))labels.put(text(p,"par_key"),text(p,"par_value"));
+ for(int category=1;category<=4;category++) {
+ var options=new ArrayList>();
+ for(var r:db.queryForList("SELECT mt_sort AS id,MIN(mt_value) AS label FROM metatype WHERE mt_type=? GROUP BY mt_sort ORDER BY mt_sort","appointment_category_"+category)) {
+ long id=id(r,"id");String override=labels.get("MASK_AP_CAT_"+category+"_"+id);
+ options.add(fields("id",id,"label",override==null||override.isBlank()?text(r,"label"):override));
+ }
+ if(category==4&&options.stream().noneMatch(o->id(o,"id")==2))options.add(fields("id",2L,"label",labels.getOrDefault("MASK_AP_CAT_4_9","Erledigt")));
+ result.put("category"+category,options);
+ }
+ return result;
+ }
+ public Map options(LegacyUser user) {
+ access(user);var hqs=headquarters(user);if(hqs.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben.");
+ var people=employees(hqs.stream().map(r->id(r,"id")).toList());
+ var groups=db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? ORDER BY grp_name",mandator(db,user));
+ var groupViews=new ArrayList>();
+ for(var g:groups) {
+ var members=people.stream().filter(p->GroupAdministration.memberships(text(p,"groups")).contains(id(g,"id"))).map(p->id(p,"id")).toList();
+ if(!members.isEmpty())groupViews.add(fields("id",id(g,"id"),"label",text(g,"label"),"members",members));
+ }
+ return fields("headquarters",hqs,"currentHeadquarters",user.headquartersId(),"currentUser",user.userId(),"now",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0),"categories",categories(),"people",people,"groups",groupViews,
+ "defaultMonitoring",catalog.enabled(0,"MASK_AP_CATEGORY_4_PRESET")?1:0,"miscType",catalog.parameter(0,"MASK_AP_CATEGORY_3_MISC_ITEM"),"canReadReports",user.hasRight(16));
+ }
+ public List> customers(LegacyUser user,String q) {
+ access(user);if(q==null||q.length()>100)throw bad("Suchtext zu lang.");var hqs=allowedHeadquarters(user);
+ var args=new ArrayList(hqs);args.add("%"+q.strip()+"%");
+ return db.queryForList("SELECT c.cs_id AS id,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer c LEFT JOIN company co ON co.cmp_id=c.cmp_id WHERE c.hq_id IN ("+marks(hqs.size())+") AND CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) LIKE ? ORDER BY label,id LIMIT 50",args.toArray());
+ }
+ private void customer(LegacyUser user,long customerId) {
+ var hqs=allowedHeadquarters(user);var args=new ArrayList(List.of(customerId));args.addAll(hqs);
+ one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id IN ("+marks(hqs.size())+")",args.toArray()));
+ }
+ static void validate(Query q,boolean statistics) {
+ if(q==null)throw bad("Terminfilter fehlen.");range(q.from(),q.to());
+ if(q.from().getYear()<1000||q.to().getYear()>9998||!statistics&&ChronoUnit.DAYS.between(q.from(),q.to())>365)throw bad("Kalenderabfragen dürfen höchstens ein Jahr umfassen.");
+ if(q.visibility()==null||q.visibility().isEmpty()||q.visibility().stream().anyMatch(Objects::isNull)||q.visibility().size()>5)throw bad("Bitte mindestens eine Sichtbarkeitsgruppe wählen.");
+ if(q.search()==null||q.search().length()>200)throw bad("Suchtext darf höchstens 200 Zeichen enthalten.");
+ for(Long id:Arrays.asList(q.personId(),q.customerId()))if(id!=null&&id<=0)throw bad("Ungültiger Personen-/Kundenfilter.");
+ for(Integer code:Arrays.asList(q.status(),q.kind(),q.monitoring()))if(code!=null&&(code<0||code>127))throw bad("Ungültiger Kategoriefilter.");
+ }
+ /** Visibility is authorization, not merely a UI search option. No master bypass for private appointments. */
+ private static String visible(LegacyUser user,List args) {
+ args.add(user.userId());args.add(participant(user.userId()));
+ return "(a.usr_id=? OR a.ap_category_2=3 OR (a.ap_category_2=1 AND a.ap_participants LIKE ?))";
+ }
+ private Sql selection(LegacyUser user,Query q,boolean statistics) {
+ validate(q,statistics);var hqs=scope(user,q.headquarters());
+ var args=new ArrayList(hqs);
+ String where="a.hq_id IN ("+marks(hqs.size())+") AND "+visible(user,args)+" AND a.ap_execdate AND (a.ap_execdate>=? OR a.ap_enddate>=?)";
+ args.add(q.to().plusDays(1).atStartOfDay());args.add(q.from().atStartOfDay());args.add(q.from().atStartOfDay());
+ if(q.personId()!=null){where+=" AND (a.usr_id=? OR a.ap_participants LIKE ?)";args.add(q.personId());args.add(participant(q.personId()));}
+ if(q.customerId()!=null){customer(user,q.customerId());where+=" AND a.cs_id=?";args.add(q.customerId());}
+ Integer[] codes={q.status(),q.kind(),q.monitoring()};int[] categories={1,3,4};
+ for(int i=0;i(q.visibility()).containsAll(List.of(Visibility.values()))) {
+ var clauses=new ArrayList();
+ for(var v:new LinkedHashSet<>(q.visibility()))switch(v) {
+ case OWN_PRIVATE,OWN_PARTICIPANTS,OWN_PUBLIC -> { clauses.add("(a.usr_id=? AND a.ap_category_2=?)");args.add(user.userId());args.add(v==Visibility.OWN_PRIVATE?2:v==Visibility.OWN_PARTICIPANTS?1:3); }
+ case INVITED -> { clauses.add("(a.usr_id<>? AND a.ap_participants LIKE ?)");args.add(user.userId());args.add(participant(user.userId())); }
+ case OTHER_PUBLIC -> { clauses.add("(a.usr_id<>? AND a.ap_category_2=3 AND (a.ap_participants IS NULL OR a.ap_participants NOT LIKE ?))");args.add(user.userId());args.add(participant(user.userId())); }
+ }
+ where+=" AND ("+String.join(" OR ",clauses)+")";
+ }
+ return new Sql(where,args.toArray());
+ }
+ private static String selectColumns() { return String.join(",",COLUMNS.stream().map(c->"a."+c).toList()); }
+ private static String joins() { return " FROM "+TABLE+" a LEFT JOIN user u ON u.usr_id=a.usr_id LEFT JOIN headquarters h ON h.hq_id=a.hq_id LEFT JOIN customer c ON c.cs_id=a.cs_id LEFT JOIN company co ON co.cmp_id=c.cmp_id"; }
+ public Map list(LegacyUser user,Query query) {
+ access(user);return db.readTransaction(()->{
+ var sql=selection(user,query,false);
+ var rows=db.queryForList("SELECT "+selectColumns()+",CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS author,h.hq_mnemonic AS headquarters,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS customer_label"+joins()+" WHERE "+sql.where()+" ORDER BY a.ap_execdate,a.ap_id LIMIT 5001",sql.args());
+ if(rows.size()>5000)throw bad("Mehr als 5000 Termine. Bitte Zeitraum oder Filter eingrenzen.");
+ var names=participantNames(user,rows);
+ return fields("items",rows.stream().map(r->view(user,r,names)).toList(),"total",rows.size(),"from",query.from(),"to",query.to());
+ });
+ }
+ private Map participantNames(LegacyUser user,List> rows) {
+ var ids=new LinkedHashSet();for(var row:rows)ids.addAll(GroupAdministration.memberships(text(row,"ap_participants")));
+ var names=new HashMap();if(ids.isEmpty())return names;
+ if(ids.size()>10000)throw bad("Zu viele Teilnehmer. Bitte den Zeitraum eingrenzen.");
+ var all=new ArrayList<>(ids);
+ for(int i=0;i(batch);args.add(user.headquartersId());
+ for(var r:db.queryForList("SELECT DISTINCT u.usr_id AS id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM user u JOIN mandatorheadquarters m ON m.hq_id=u.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE u.usr_id IN ("+marks(batch.size())+") AND own.hq_id=? AND own.md_id>0",args.toArray()))names.put(id(r,"id"),text(r,"label"));
+ }
+ return names;
+ }
+ static Map version(Map row) { var result=new LinkedHashMap();for(String key:COLUMNS)result.put(key,value(row,key));return result; }
+ private static boolean canEdit(LegacyUser user,Map row) { return id(row,"usr_id")==user.userId()||user.hasRight(3)||GroupAdministration.memberships(text(row,"ap_participants")).contains(user.userId()); }
+ private Map view(LegacyUser user,Map row,Map names) {
+ var people=GroupAdministration.memberships(text(row,"ap_participants"));var confirmed=GroupAdministration.memberships(text(row,"ap_confirmed"));
+ return fields("id",id(row,"ap_id"),"headquartersId",id(row,"hq_id"),"headquarters",text(row,"headquarters"),"authorId",id(row,"usr_id"),"author",text(row,"author"),"text",text(row,"ap_text"),"startsAt",dateTime(row,"ap_execdate"),"endsAt",dateTime(row,"ap_enddate"),
+ "status",id(row,"ap_category_1"),"visibility",id(row,"ap_category_2"),"kind",id(row,"ap_category_3"),"monitoring",id(row,"ap_category_4"),"customerId",positiveId(row,"cs_id"),"customerLabel",text(row,"customer_label"),
+ "participants",people.stream().map(id->fields("id",id,"label",names.getOrDefault(id,"Benutzer "+id),"confirmed",confirmed.contains(id))).toList(),"revision",revision(version(row)),"canEdit",canEdit(user,row),"canDelete",id(row,"usr_id")==user.userId()||user.hasRight(3),"canConfirm",people.contains(user.userId())&&!confirmed.contains(user.userId()),"canFinish",canEdit(user,row)&&id(row,"ap_category_4")==1,
+ "hasLegacyRelation",id(row,"csc_id")>0||id(row,"pt_id")>0);
+ }
+ public Map statistics(LegacyUser user,Query query) {
+ access(user);return db.readTransaction(()->{
+ var sql=selection(user,query,true);
+ var rows=db.queryForList("SELECT a.ap_category_1,a.ap_category_2,a.ap_category_3,a.ap_category_4,COUNT(*) AS total FROM "+TABLE+" a WHERE "+sql.where()+" GROUP BY a.ap_category_1,a.ap_category_2,a.ap_category_3,a.ap_category_4 LIMIT 5001",sql.args());
+ if(rows.size()>5000)throw bad("Zu viele Statistikgruppen. Bitte Filter eingrenzen.");
+ var metadata=categories();var categories=new ArrayList>();
+ for(int n=1;n<=4;n++) {
+ var counts=new TreeMap();for(var row:rows)counts.merge(id(row,"ap_category_"+n),id(row,"total"),Long::sum);
+ var labels=new HashMap();for(var option:metadata.get("category"+n)){long id=id(option,"id");labels.put(id,text(option,"label"));counts.putIfAbsent(id,0L);}
+ categories.add(fields("category",n,"items",counts.entrySet().stream().map(e->fields("id",e.getKey(),"label",labels.getOrDefault(e.getKey(),"Unbekannt ("+e.getKey()+")"),"total",e.getValue())).toList()));
+ }
+ return fields("total",rows.stream().mapToLong(r->id(r,"total")).sum(),"categories",categories,"from",query.from(),"to",query.to());
+ });
+ }
+ Map appointment(LegacyUser user,long appointmentId) {
+ access(user);var hqs=allowedHeadquarters(user);var args=new ArrayList(List.of(appointmentId));args.addAll(hqs);
+ String where="a.ap_id=? AND a.hq_id IN ("+marks(hqs.size())+") AND "+visible(user,args);
+ return one(db.queryForList("SELECT "+selectColumns()+" FROM "+TABLE+" a WHERE "+where,args.toArray()));
+ }
+ static void editable(LegacyUser user,Map old) { if(!canEdit(user,old))throw new AccessDeniedException("Nur Ersteller, Teilnehmer oder Mitarbeiterverwaltung dürfen diesen Termin ändern."); }
+ static void interval(LocalDateTime starts,LocalDateTime ends) {
+ if(starts==null||ends==null||starts.getYear()<1000||ends.getYear()>9998||ends.isBefore(starts)||starts.plusYears(10).isBefore(ends))throw bad("Bitte einen gültigen Beginn und ein Ende ab Beginn angeben (höchstens zehn Jahre).");
+ if(starts.getSecond()!=0||starts.getNano()!=0||ends.getSecond()!=0||ends.getNano()!=0)throw bad("Termine bitte minutengenau eingeben.");
+ }
+ private Map values(LegacyUser user,Save request,Map old) {
+ if(request==null)throw bad("Termin fehlt.");interval(request.startsAt(),request.endsAt());
+ String text=required(request.text(),65535,"Termintext");if(text.getBytes(StandardCharsets.UTF_8).length>65535)throw bad("Termintext ist zu lang (65535 UTF-8-Bytes).");
+ int[] values={request.status(),request.visibility(),request.kind(),request.monitoring()};var metadata=categories();
+ for(int i=0;i127||metadata.get("category"+(i+1)).stream().noneMatch(v->id(v,"id")==code))throw bad("Unbekannte Terminkategorie.");}
+ if(!Set.of(1,2).contains(request.status())||!Set.of(1,2,3).contains(request.visibility())||!Set.of(0,1,2).contains(request.monitoring()))throw bad("Ungültiger Status, Sichtbarkeit oder Kontrolle.");
+ if(request.customerId()!=null) {
+ if(request.customerId()<=0)throw bad("Ungültige Kundenzuordnung.");
+ if(old==null||request.customerId()!=id(old,"cs_id"))customer(user,request.customerId());
+ }
+ if(old!=null&&(id(old,"csc_id")>0||id(old,"pt_id")>0)&&!Objects.equals(request.customerId(),positiveId(old,"cs_id")))throw bad("Die vorhandene Kostenstellen-/Kontaktverknüpfung verhindert eine andere Kundenzuordnung.");
+ if(request.participants()==null||request.participants().size()>500||request.participants().stream().anyMatch(id->id==null||id<=0))throw bad("Bitte höchstens 500 gültige Teilnehmer auswählen.");
+ var people=new TreeSet<>(request.participants());var allowed=new HashSet();for(var p:employees(allowedHeadquarters(user)))allowed.add(id(p,"id"));
+ if(old!=null)allowed.addAll(GroupAdministration.memberships(text(old,"ap_participants")));
+ if(!allowed.containsAll(people))throw new AccessDeniedException("Ein Teilnehmer ist nicht für die freigegebenen Niederlassungen verfügbar.");
+ var confirmed=old==null?new TreeSet():new TreeSet<>(GroupAdministration.memberships(text(old,"ap_confirmed")));
+ if(old!=null&&(!request.startsAt().equals(dateTime(old,"ap_execdate"))||!request.endsAt().equals(dateTime(old,"ap_enddate"))))confirmed.clear();
+ confirmed.retainAll(people);
+ return fields("ap_text",text,"ap_execdate",request.startsAt(),"ap_enddate",request.endsAt(),"ap_category_1",request.status(),"ap_category_2",request.visibility(),"ap_category_3",request.kind(),"ap_category_4",request.monitoring(),"cs_id",request.customerId(),"ap_participants",ids(people),"ap_confirmed",ids(confirmed),"ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0));
+ }
+ private static Long positiveId(Map row,String key) { long value=id(row,key);return value>0?value:null; }
+ static String ids(Collection ids) { return ids.isEmpty()?"":","+String.join(",",ids.stream().map(String::valueOf).toList())+","; }
+ public Map create(LegacyUser user,Save request) {
+ access(user);var fields=values(user,request,null);
+ if(request.monitoring()==2)throw bad("Neue Termine bitte zunächst ohne Kontrolle oder mit aktiver Überwachung anlegen.");
+ fields.putAll(fields("usr_id",user.userId(),"hq_id",user.headquartersId(),"ap_type",0,"csc_id",null,"pt_id",null));
+ long id=db.writeTransaction(()->db.insert(TABLE,fields));return fields("id",id);
+ }
+ public Map save(LegacyUser user,long appointmentId,Save request) {
+ access(user);return db.writeTransaction(()->{
+ var old=appointment(user,appointmentId);editable(user,old);if(request==null)throw bad("Termin fehlt.");unchanged(version(old),request.revision());
+ var values=values(user,request,old);
+ if(id(old,"ap_category_4")!=2&&request.monitoring()==2) {
+ // Check the resulting customer/status too; changing the assignment must not bypass the report rule.
+ var completed=new LinkedHashMap<>(old);completed.putAll(values);requireReport(user,completed);
+ }
+ update(old,values);return fields("id",appointmentId);
+ });
+ }
+ public void remove(LegacyUser user,long appointmentId,String revision) {
+ access(user);db.writeTransaction(()->{
+ var old=appointment(user,appointmentId);if(id(old,"usr_id")!=user.userId())user.require(3);unchanged(version(old),revision);
+ changed(db.update("DELETE FROM "+TABLE+" WHERE "+cas(),casArgs(old).toArray()));return null;
+ });
+ }
+ public Map resubmit(LegacyUser user,long appointmentId,Resubmission request) {
+ access(user);if(request==null)throw bad("Wiedervorlage fehlt.");interval(request.startsAt(),request.endsAt());
+ var old=appointment(user,appointmentId);unchanged(version(old),request.revision());
+ // A resubmission creates a new appointment. Its source and source confirmations stay untouched.
+ var values=new LinkedHashMap<>(version(old));values.remove("ap_id");values.put("usr_id",user.userId());values.put("hq_id",user.headquartersId());
+ values.put("ap_execdate",request.startsAt());values.put("ap_enddate",request.endsAt());values.put("ap_confirmed","");values.put("ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0));
+ if(id(old,"ap_category_4")==2)values.put("ap_category_4",1);
+ long id=db.writeTransaction(()->db.insert(TABLE,values));return fields("id",id);
+ }
+ public void confirm(LegacyUser user,long appointmentId,String revision) {
+ access(user);db.writeTransaction(()->{
+ var old=appointment(user,appointmentId);
+ if(!GroupAdministration.memberships(text(old,"ap_participants")).contains(user.userId()))throw new AccessDeniedException("Nur eigene Teilnahme kann bestätigt werden.");
+ unchanged(version(old),revision);var confirmed=new TreeSet<>(GroupAdministration.memberships(text(old,"ap_confirmed")));confirmed.add(user.userId());
+ update(old,fields("ap_confirmed",ids(confirmed)));return null;
+ });
+ }
+ public void finish(LegacyUser user,long appointmentId,String revision) {
+ access(user);db.writeTransaction(()->{
+ var old=appointment(user,appointmentId);editable(user,old);unchanged(version(old),revision);
+ if(id(old,"ap_category_4")!=1)throw bad("Nur ein überwachter Termin kann abgeschlossen werden.");requireReport(user,old);
+ update(old,fields("ap_category_4",2,"ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0)));return null;
+ });
+ }
+ private void requireReport(LegacyUser user,Map appointment) {
+ long customerId=id(appointment,"cs_id");
+ if(customerId>0&&id(appointment,"ap_category_1")==1&&catalog.enabled(0,"MASK_AP_WARNING_DISABLE_QUESTION_NO_REPORT_OF_SAME_DAY")) {
+ var today=LocalDate.now(ZoneId.of("Europe/Berlin"));var args=new ArrayList(List.of(customerId,id(appointment,"hq_id"),today.atStartOfDay(),today.plusDays(1).atStartOfDay()));
+ String sql="SELECT rp_id FROM `phoenix_group`.report_process WHERE rp_objtype='cs' AND rp_objid=? AND hq_id=? AND rp_createtime>=? AND rp_createtime";
+ if(!user.hasRight(10)){sql+=" AND (rp_confidential=0 OR usr_id=?)";args.add(user.userId());}
+ if(db.queryForList(sql+" LIMIT 1",args.toArray()).isEmpty())throw bad("Bitte zuerst einen Kundenbericht für den heutigen Tag anlegen.");
+ }
+ }
+ private void update(Map old,Map values) {
+ var args=new ArrayList<>(values.values());args.addAll(casArgs(old));
+ changed(db.update("UPDATE "+TABLE+" SET "+String.join(",",values.keySet().stream().map(c->c+"=?").toList())+" WHERE "+cas(),args.toArray()));
+ }
+ // MyISAM does not support transactional row locks. Include all source values in each mutation.
+ private static String cas() { return String.join(" AND ",COLUMNS.stream().map(c->Set.of("ap_text","ap_participants","ap_confirmed").contains(c)?"BINARY "+c+" <=> BINARY ?":c+" <=> ?").toList()); }
+ private static List casArgs(Map old) { var args=new ArrayList();for(String c:COLUMNS)args.add(value(old,c));return args; }
+ private static void changed(int rows) { if(rows!=1)throw new ResponseStatusException(HttpStatus.CONFLICT,"Der Termin wurde zwischenzeitlich geändert oder gelöscht. Bitte neu laden."); }
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/BillingService.java b/backend/src/main/java/de/stadtbote/votian/BillingService.java
new file mode 100644
index 0000000..1de7fe0
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/BillingService.java
@@ -0,0 +1,90 @@
+package de.stadtbote.votian;
+
+import de.stadtbote.votian.database.DatabaseService;
+import org.springframework.stereotype.Service;
+import java.math.*;
+import java.time.*;
+import java.util.*;
+import static de.stadtbote.votian.AdminSupport.*;
+import static de.stadtbote.votian.LegacyMapping.*;
+import static de.stadtbote.votian.OrderEntryService.fields;
+import static de.stadtbote.votian.BusinessSupport.*;
+
+/** Native equivalents of invoice.php, jb_inv_assoc.php and the customer/courier statement. */
+@Service
+public class BillingService {
+ final DatabaseService db;final BusinessSupport support;
+ public BillingService(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
+ public record Query(LocalDate from,LocalDate to,String mode,Long customer,Long center,boolean descendants,Long courier,String invoice,boolean unassigned){}
+ public record Assign(String requestId,Query query,String revision,List orders,String number,LocalDate date){}
+ record Assignment(long id,String revision){}
+ record Operation(long user,long hq,String number,String date,List items,String hash){}
+ static BigDecimal nz(BigDecimal v){return v==null?BigDecimal.ZERO:v;}
+ static BigDecimal money(BigDecimal v){return v.setScale(2,RoundingMode.HALF_UP);}
+ static BigDecimal net(Map p){return money(nz(decimal(p,"trs_price")).multiply(BigDecimal.valueOf(100).subtract(nz(decimal(p,"trs_discount")))).movePointLeft(2));}
+ void check(LegacyUser u,Query q){u.require(5);if(q==null)throw bad("Abrechnungsfilter fehlen.");range(q.from(),q.to());if(q.from().plusYears(1).isBefore(q.to()))throw bad("Bitte einen Zeitraum von höchstens einem Jahr wählen.");if(q.mode()==null||!Set.of("CUSTOMER","COURIER").contains(q.mode()))throw bad("Ungültiger Abrechnungsbereich.");}
+ public Map options(LegacyUser u){u.require(5);return fields("customers",db.queryForList("SELECT cs.cs_id AS id,CONCAT(cs.cs_eid,' · ',c.cmp_comp) AS label FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=? ORDER BY c.cmp_comp",u.headquartersId()),"couriers",db.queryForList("SELECT cr.cr_id AS id,CONCAT(cr.cr_eid,' · ',c.cmp_comp) AS label FROM courier cr JOIN company c ON c.cmp_id=cr.cmp_id WHERE cr.hq_id=? ORDER BY c.cmp_comp",u.headquartersId()),"canAssign",u.hasRight(7));}
+ public List> centers(LegacyUser u,long customer){u.require(5);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));return db.queryForList("SELECT csc_id AS id,csc_name AS label,csc_pre_id AS parent FROM costcenter WHERE cs_id=? ORDER BY csc_path,csc_name",customer);}
+ public Map query(LegacyUser u,Query q){check(u,q);return db.readTransaction(()->statement(u,q));}
+ Map statement(LegacyUser u,Query q){
+ Set centers=new HashSet<>();if(q.center()!=null){var cs=one(db.queryForList("SELECT c.csc_id,c.cs_id FROM costcenter c JOIN customer s ON s.cs_id=c.cs_id WHERE c.csc_id=? AND s.hq_id=?",q.center(),u.headquartersId()));if(q.customer()!=null&&q.customer()!=id(cs,"cs_id"))throw bad("Die Kostenstelle gehört zu einem anderen Kunden.");centers.add(q.center());if(q.descendants()){var all=db.queryForList("SELECT csc_id,csc_pre_id FROM costcenter WHERE cs_id=?",id(cs,"cs_id"));boolean added;do{added=false;for(var r:all)if(centers.contains(id(r,"csc_pre_id")))added|=centers.add(id(r,"csc_id"));}while(added);}}
+ String sql="SELECT j.*,i.inv_no,i.inv_datetime,c.cs_id,c.csc_name,cs.cs_eid,cmp.cmp_comp FROM job j LEFT JOIN invoice i ON i.jb_id=j.jb_id LEFT JOIN costcenter c ON c.csc_id=j.csc_id_payer LEFT JOIN customer cs ON cs.cs_id=c.cs_id LEFT JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE j.hq_id=? AND j.jb_status=2 AND COALESCE(j.jb_offer,0)=0 AND j.jb_finishtime>=? AND j.jb_finishtime";
+ var args=new ArrayList(List.of(u.headquartersId(),q.from().atStartOfDay(),q.to().plusDays(1).atStartOfDay()));
+ if(q.mode().equals("CUSTOMER"))sql+=" AND j.jb_payment<>2 AND j.csc_id_payer<>-1";
+ if(q.customer()!=null){sql+=" AND (c.cs_id=? OR EXISTS (SELECT 1 FROM tourservice t JOIN costcenter tc ON tc.csc_id=t.csc_id WHERE t.jb_id=j.jb_id AND tc.cs_id=?))";args.add(q.customer());args.add(q.customer());}
+ if(q.courier()!=null){sql+=" AND j.cr_id=?";args.add(q.courier());}
+ if(q.invoice()!=null&&!q.invoice().isBlank()){sql+=" AND i.inv_no=?";args.add(q.invoice().strip());}
+ if(q.unassigned())sql+=" AND i.jb_id IS NULL";
+ var jobs=db.queryForList(sql+" ORDER BY j.jb_finishtime,j.jb_id LIMIT 2001",args.toArray());if(jobs.size()>2000)throw bad("Mehr als 2.000 Aufträge gefunden. Bitte den Zeitraum oder den Kundenfilter eingrenzen.");
+ var rows=new ArrayList>();var revisionData=new ArrayList();revisionData.add(jobs);
+ for(var j:jobs){long job=id(j,"jb_id");var prices=db.queryForList("SELECT t.*,c.cs_id,c.csc_name,s.cs_eid,cmp.cmp_comp FROM tourservice t LEFT JOIN costcenter c ON c.csc_id=t.csc_id LEFT JOIN customer s ON s.cs_id=c.cs_id LEFT JOIN company cmp ON cmp.cmp_id=s.cmp_id WHERE t.jb_id=? ORDER BY t.csc_id,t.tr_sort,t.srv_id,t.srvt_id",job);revisionData.add(prices);
+ var tollRows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",job);revisionData.add(tollRows);if(tollRows.size()>1)throw bad("Auftrag #"+job+": Maut ist mehrfach hinterlegt.");BigDecimal toll=tollRows.isEmpty()?BigDecimal.ZERO:new BigDecimal(text(tollRows.getFirst(),"gdc_content"));
+ if(q.mode().equals("COURIER")){BigDecimal amount=courierNet(j,prices);rows.add(line(j,id(j,"csc_id_payer"),text(j,"csc_name"),amount,toll,text(j,"jb_invtext_cr")));continue;}
+ var byCenter=new TreeMap>>();for(var p:prices)byCenter.computeIfAbsent(id(p,"csc_id"),k->new ArrayList<>()).add(p);
+ if(byCenter.isEmpty())byCenter.put(id(j,"csc_id_payer"),List.of());int index=0;for(var e:byCenter.entrySet()){BigDecimal share=OrderEntryAccounting.portion(toll,byCenter.size(),index++);if(!centers.isEmpty()&&!centers.contains(e.getKey()))continue;
+ var context=e.getValue().isEmpty()?j:e.getValue().getFirst();if(q.customer()!=null&&id(context,"cs_id")!=q.customer())continue;
+ BigDecimal amount=e.getValue().isEmpty()?nz(decimal(j,"jb_totalprice")):e.getValue().stream().map(BillingService::net).reduce(BigDecimal.ZERO,BigDecimal::add);
+ var line=line(j,e.getKey(),text(context,"csc_name"),amount,share,text(j,"jb_invtext"));line.put("customer",text(context,"cmp_comp"));line.put("customerId",id(context,"cs_id"));line.put("components",e.getValue().stream().map(p->fields("name",text(p,"trs_srv_name")+" "+text(p,"trs_srvt_name"),"net",net(p))).toList());rows.add(line);
+ }
+ }
+ var terms=new HashMap>();
+ for(var row:rows){var term=terms.computeIfAbsent(id(row,"id"),key->OrderPaymentTerms.read(db,key));row.put("paymentTerm",text(term,"label"));row.put("paymentTermCode",term.getOrDefault("value",text(term,"label")));row.put("paymentTermSource",text(term,"source"));}
+ revisionData.add(terms);
+ BigDecimal net=rows.stream().map(r->(BigDecimal)r.get("net")).reduce(BigDecimal.ZERO,BigDecimal::add),tax=rows.stream().map(r->(BigDecimal)r.get("tax")).reduce(BigDecimal.ZERO,BigDecimal::add);
+ var pending=db.queryForList("SELECT gdc_gen_fieldname AS token FROM genericdatacontainer WHERE gdc_obj_type='nginv' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id",u.headquartersId());
+ return fields("rows",rows,"net",money(net),"tax",money(tax),"gross",money(net.add(tax)),"revision",revision(revisionData),"pending",pending);
+ }
+ BigDecimal courierNet(Map job,List> prices){
+ var catalog=new OrderEntryCatalog(db);long hq=id(job,"hq_id");var vehicle=db.queryForList("SELECT crvh_prov FROM couriervehicle WHERE crvh_sid=?",text(job,"cr_sid"));BigDecimal commission=vehicle.isEmpty()?BigDecimal.ZERO:nz(decimal(one(vehicle),"crvh_prov"));
+ BigDecimal stored=nz(decimal(job,hq>100&&hq<=1000?"jb_cr_subprice":"jb_cr_price"));if(hq>1000)return money(stored.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2));
+ boolean mode=catalog.parameter(hq,"MASK_CR_PRICE_MODE").equals("1");String from=catalog.parameter(hq,"MASK_CR_PRICE_MODE_DATE");LocalDateTime at=dateTime(job,"jb_ordertime");boolean after=false;if(mode&&at!=null&&!from.isBlank()){try{after=at.isAfter(LocalDate.parse(from.substring(0,10)).atStartOfDay());}catch(RuntimeException ignored){throw bad("MASK_CR_PRICE_MODE_DATE ist ungültig.");}}
+ if(stored.signum()!=0&&(hq>100||!mode)||mode&&after)return stored;
+ boolean subtract=catalog.parameter(hq,"INV_JB_CR_PRICE").equals("1");return prices.stream().map(p->{BigDecimal amount=net(p);return subtract?money(amount.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2)):amount;}).reduce(BigDecimal.ZERO,BigDecimal::add);
+ }
+ Map line(Map j,long center,String centerName,BigDecimal amount,BigDecimal toll,String description){BigDecimal net=money(amount.add(toll)),rate=nz(decimal(j,"jb_sales_tax_rate")),tax=money(net.multiply(rate).movePointLeft(2));return fields("id",id(j,"jb_id"),"center",center,"centerName",centerName,"customer",text(j,"cmp_comp"),"customerId",id(j,"cs_id"),"courier",text(j,"cr_sid"),"finished",dateTime(j,"jb_finishtime"),"ordered",dateTime(j,"jb_ordertime"),"booked",dateTime(j,"jb_booktime"),"reference",text(j,"jb_commission_no"),"description",description,"net",net,"toll",money(toll),"taxRate",rate,"tax",tax,"gross",net.add(tax),"storno",id(j,"jb_storno"),"incomplete",id(j,"jb_incomplete")==1,"exported",OrderOperations.exported(j),"invoice",text(j,"inv_no"),"invoiceDate",dateTime(j,"inv_datetime"));}
+ public Map assign(LegacyUser u,Assign a){u.require(5);u.require(7);if(a==null)throw bad("Rechnungsangaben fehlen.");check(u,a.query());token(a.requestId());String number=required(a.number(),30,"Rechnungsnummer");if(a.date()==null||a.orders()==null||a.orders().isEmpty()||a.orders().size()>2000||new HashSet<>(a.orders()).size()!=a.orders().size())throw bad("Bitte Rechnungsdatum und eindeutige Aufträge auswählen.");if(!a.query().mode().equals("CUSTOMER")||a.query().customer()==null)throw bad("Rechnungszuordnungen benötigen genau einen ausgewählten Kunden.");
+ support.transactional("headquarters","genericdatacontainer","job","tourservice");db.writeTransaction(()->{lockHeadquarters(db,u);var existing=operation(a.requestId());String hash=revision(List.of(u.userId(),u.headquartersId(),a));if(existing!=null){if(!decode(existing).hash().equals(hash))throw bad("Die Vorgangskennung wurde bereits für andere Daten verwendet.");return null;}
+ var current=statement(u,a.query());if(!current.get("revision").equals(a.revision()))throw bad("Abrechnungsdaten wurden geändert. Bitte neu laden.");
+ var lines=(List>)current.get("rows");Set eligible=new HashSet<>();for(var row:lines)if(!(Boolean)row.get("incomplete")&&!(Boolean)row.get("exported")&&text(row,"invoice").isBlank())eligible.add(id(row,"id"));
+ var items=new ArrayList();for(long job:a.orders()){if(!eligible.contains(job))throw bad("Auftrag #"+job+" ist unfertig, bereits zugeordnet, exportiert oder gehört nicht zur Auswahl.");one(db.lockRows("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",job,u.headquartersId()));if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock'",job).isEmpty())throw bad("Für diesen Auftrag läuft bereits eine Rechnungszuordnung.");if(!db.queryForList("SELECT jb_id FROM invoice WHERE jb_id=?",job).isEmpty())throw bad("Auftrag bereits zugeordnet.");var shares=db.queryForList("SELECT DISTINCT c.cs_id FROM tourservice t JOIN costcenter c ON c.csc_id=t.csc_id WHERE t.jb_id=?",job);if(shares.stream().anyMatch(part->id(part,"cs_id")!=a.query().customer()))throw bad("Auftrag #"+job+" verteilt Kosten auf mehrere Kunden. Eine einzelne Rechnungsnummer darf diese Anteile nicht zusammenfassen.");items.add(new Assignment(job,assignmentRevision(job)));db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname","ng_invoice_lock","gdc_content",a.requestId(),"gdc_context","PENDING"));}
+ if(!db.queryForList("SELECT i.jb_id FROM invoice i JOIN job j ON j.jb_id=i.jb_id LEFT JOIN costcenter c ON c.csc_id=j.csc_id_payer WHERE i.inv_no=? AND (j.hq_id<>? OR COALESCE(c.cs_id,0)<>?) LIMIT 1",number,u.headquartersId(),a.query().customer()).isEmpty())throw bad("Diese Rechnungsnummer gehört bereits zu einem anderen Kunden oder einer anderen Niederlassung.");
+ // MyISAM cannot roll back. Save a durable intent before touching the invoice table.
+ db.insert("genericdatacontainer",fields("gdc_obj_type","nginv","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",a.requestId(),"gdc_content",support.encode(new Operation(u.userId(),u.headquartersId(),number,a.date().toString(),items,hash)),"gdc_context","PENDING"));return null;});
+ return resume(u,a.requestId());
+ }
+ String assignmentRevision(long job){var j=new LinkedHashMap<>(one(db.queryForList("SELECT * FROM job WHERE jb_id=?",job)));j.remove("jb_locktime");j.remove("jb_lockuser");j.remove("jb_modify");return revision(List.of(j,db.queryForList("SELECT * FROM tourservice WHERE jb_id=? ORDER BY csc_id,tr_sort,srv_id,srvt_id",job),db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",job)));}
+ Map operation(String token){var rows=db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='nginv' AND gdc_gen_fieldname=?",token(token));return rows.isEmpty()?null:one(rows);}
+ Operation decode(Map row){try{return support.json.readValue(text(row,"gdc_content"),Operation.class);}catch(Exception e){throw new IllegalStateException(e);}}
+ public Map resume(LegacyUser u,String token){u.require(5);u.require(7);token(token);return db.writeTransaction(()->{lockHeadquarters(db,u);var row=operation(token);if(row==null)throw bad("Rechnungsvorgang nicht gefunden.");var op=decode(row);if(op.hq()!=u.headquartersId())throw bad("Andere Niederlassung.");if(text(row,"gdc_context").equals("STOPPED"))throw bad("Der Vorgang wurde beendet. Bitte die aktuelle Zuordnung neu laden.");if(text(row,"gdc_context").equals("DONE"))return fields("state","DONE","number",op.number());
+ for(var item:op.items()){
+ one(db.lockRows("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",item.id(),u.headquartersId()));
+ if(!item.revision().equals(assignmentRevision(item.id())))throw bad("Auftrag #"+item.id()+" wurde seit Beginn der Zuordnung geändert. Der Vorgang bleibt zur Prüfung offen.");
+ // Single-row compare-and-insert preserves PHP changes and makes retries safe.
+ db.update("INSERT INTO invoice (jb_id,inv_no,inv_datetime) SELECT ?,?,? WHERE NOT EXISTS (SELECT jb_id FROM invoice WHERE jb_id=?)",item.id(),op.number(),LocalDate.parse(op.date()).atTime(12,0),item.id());
+ var stored=one(db.queryForList("SELECT inv_no,inv_datetime FROM invoice WHERE jb_id=?",item.id()));if(!text(stored,"inv_no").equals(op.number())||!dateTime(stored,"inv_datetime").toLocalDate().equals(LocalDate.parse(op.date())))throw bad("Auftrag #"+item.id()+" wurde anderweitig zugeordnet. Der Vorgang bleibt zur Prüfung offen.");
+ }
+ for(var item:op.items())db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock' AND gdc_content=?",item.id(),token);
+ support.audit(u,0,0,0,2,"Rechnungsnummer "+op.number()+" zugeordnet · "+op.items().size()+" Aufträge",token);db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(row,"gdc_id"));return fields("state","DONE","number",op.number());});}
+ public Map stop(LegacyUser u,String token){u.require(5);u.require(7);token(token);return db.writeTransaction(()->{lockHeadquarters(db,u);var row=operation(token);if(row==null||decode(row).hq()!=u.headquartersId())throw bad("Vorgang nicht gefunden.");if(text(row,"gdc_context").equals("DONE"))throw bad("Vorgang ist bereits abgeschlossen.");for(var item:decode(row).items())db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock' AND gdc_content=?",item.id(),token);db.update("UPDATE genericdatacontainer SET gdc_context='STOPPED' WHERE gdc_id=?",id(row,"gdc_id"));support.audit(u,0,0,0,2,"Rechnungszuordnung beendet; bereits gespeicherte Zuordnungen bleiben erhalten",token);return fields("state","STOPPED");});}
+ public byte[] csv(LegacyUser u,Query q){var data=query(u,q);StringBuilder csv=new StringBuilder("\uFEFFAuftrag;Kostenstelle;Kunde;Fahrer;Erledigt;Rechnungsnummer;Netto;MwSt;Brutto;Zahlungsbedingung;Zahlungsbedingung-Code;Text\n");for(var r:(List>)data.get("rows")){for(String k:List.of("id","centerName","customer","courier","finished","invoice","net","tax","gross","paymentTerm","paymentTermCode","description"))csv.append(ApiController.csvCell(r.get(k))).append(';');csv.append('\n');}return csv.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);}
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/BusinessController.java b/backend/src/main/java/de/stadtbote/votian/BusinessController.java
new file mode 100644
index 0000000..7387f75
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/BusinessController.java
@@ -0,0 +1,27 @@
+package de.stadtbote.votian;
+
+import org.springframework.web.bind.annotation.*;
+import org.springframework.security.core.Authentication;
+import org.springframework.http.*;
+import java.util.*;
+
+@RestController
+@RequestMapping("/api")
+public class BusinessController {
+ final MasterData masters;final OrderOperations orders;final BillingService billing;
+ public BusinessController(MasterData masters,OrderOperations orders,BillingService billing){this.masters=masters;this.orders=orders;this.billing=billing;}
+ @GetMapping("/master-data/{kind}/{id}") public Object master(Authentication a,@PathVariable String kind,@PathVariable long id,@RequestParam(defaultValue="0")long parent){return masters.view(ApiController.user(a),kind,id,parent);}
+ @PutMapping("/master-data/{kind}/{id}") public Object saveMaster(Authentication a,@PathVariable String kind,@PathVariable long id,@RequestParam(defaultValue="0")long parent,@RequestBody MasterData.Save value){return masters.save(ApiController.user(a),kind,id,parent,value);}
+ @GetMapping("/orders/{id}/workbench") public Object order(Authentication a,@PathVariable long id){return orders.view(ApiController.user(a),id);}
+ @PutMapping("/orders/{id}/workbench") public Object edit(Authentication a,@PathVariable long id,@RequestBody OrderOperations.Edit value){return orders.edit(ApiController.user(a),id,value);}
+ @PutMapping("/orders/{id}/price") public Object price(Authentication a,@PathVariable long id,@RequestBody OrderOperations.Price value){return orders.price(ApiController.user(a),id,value);}
+ @PostMapping("/orders/{id}/actions") public Object action(Authentication a,@PathVariable long id,@RequestBody OrderOperations.Action value){return orders.action(ApiController.user(a),id,value);}
+ @GetMapping("/billing/options") public Object options(Authentication a){return billing.options(ApiController.user(a));}
+ @GetMapping("/billing/centers") public Object centers(Authentication a,@RequestParam long customer){return billing.centers(ApiController.user(a),customer);}
+ @PostMapping("/billing/query") public Object query(Authentication a,@RequestBody BillingService.Query q){return billing.query(ApiController.user(a),q);}
+ @PostMapping("/billing/assign") public Object assign(Authentication a,@RequestBody BillingService.Assign value){return billing.assign(ApiController.user(a),value);}
+ @PostMapping("/billing/operations/{token}/resume") public Object resume(Authentication a,@PathVariable String token){return billing.resume(ApiController.user(a),token);}
+ @PostMapping("/billing/operations/{token}/stop") public Object stop(Authentication a,@PathVariable String token){return billing.stop(ApiController.user(a),token);}
+ @PostMapping("/billing/export.csv") public ResponseEntity csv(Authentication a,@RequestBody BillingService.Query q){return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=abrechnung.csv").body(billing.csv(ApiController.user(a),q));}
+ @PostMapping("/billing/statement.pdf") public ResponseEntity pdf(Authentication a,@RequestBody BillingService.Query q){return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=rechnungsanlage.pdf").body(StatementPdf.create(billing.query(ApiController.user(a),q),q));}
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/BusinessSupport.java b/backend/src/main/java/de/stadtbote/votian/BusinessSupport.java
new file mode 100644
index 0000000..3a70fcc
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/BusinessSupport.java
@@ -0,0 +1,54 @@
+package de.stadtbote.votian;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import de.stadtbote.votian.database.DatabaseService;
+import org.springframework.stereotype.Service;
+import org.springframework.scheduling.annotation.Scheduled;
+import java.time.*;
+import java.util.*;
+import static de.stadtbote.votian.AdminSupport.*;
+import static de.stadtbote.votian.LegacyMapping.*;
+import static de.stadtbote.votian.OrderEntryService.fields;
+
+/** Receipts and audit outbox use the existing transactional generic data container. */
+@Service
+public class BusinessSupport {
+ final DatabaseService db;
+ final ObjectMapper json;
+ final String historySchema;
+ public BusinessSupport(DatabaseService db,ObjectMapper json){this(db,json,"phoenix_log");}
+ @org.springframework.beans.factory.annotation.Autowired
+ public BusinessSupport(DatabaseService db,ObjectMapper json,@org.springframework.beans.factory.annotation.Value("${app.history-schema:phoenix_log}") String schema){this.db=db;this.json=json;if(!schema.matches("[A-Za-z0-9_]+"))throw new IllegalArgumentException("Ungültiges Historienschema");this.historySchema="`"+schema+"`";}
+ record Audit(long headquarters,long user,long job,long customer,long courier,int operation,String description,String at){}
+ String encode(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
+ static String token(String value){try{String normalized=UUID.fromString(value).toString();if(!normalized.equals(value))throw new IllegalArgumentException();return normalized;}catch(Exception e){throw bad("Die Vorgangskennung fehlt. Bitte die Ansicht neu öffnen.");}}
+ Long receipt(LegacyUser user,String token,Object request){
+ var rows=db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='ngop' AND gdc_obj_id=? AND gdc_gen_fieldname=?",user.userId(),token(token));
+ if(rows.isEmpty())return null;
+ var row=one(rows);if(!text(row,"gdc_context").equals(revision(List.of(user.headquartersId(),request))))throw bad("Diese Vorgangskennung wurde bereits für andere Daten verwendet.");
+ return Long.valueOf(text(row,"gdc_content"));
+ }
+ void receipt(LegacyUser user,String token,Object request,long result){db.insert("genericdatacontainer",fields("gdc_obj_type","ngop","gdc_obj_id",user.userId(),"gdc_gen_fieldname",token(token),"gdc_content",Long.toString(result),"gdc_context",revision(List.of(user.headquartersId(),request))));}
+ void audit(LegacyUser user,long job,long customer,long courier,int operation,String description,String token){
+ var event=new Audit(user.headquartersId(),user.userId(),job,customer,courier,operation,description+" | NG="+token,LocalDateTime.now().withNano(0).toString());
+ db.insert("genericdatacontainer",fields("gdc_obj_type","ngaudit","gdc_obj_id",user.headquartersId(),"gdc_gen_fieldname",token,"gdc_content",encode(event),"gdc_context","PENDING"));
+ }
+ @Scheduled(initialDelay=60000,fixedDelay=30000)
+ public void publish(){
+ try {for(var pending:db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngaudit' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 50")) {
+ db.writeTransaction(()->{var rows=db.lockRows("SELECT gdc_content FROM genericdatacontainer WHERE gdc_id=? AND gdc_context='PENDING'",id(pending,"gdc_id"));if(rows.isEmpty())return null;
+ Audit e;try{e=json.readValue(text(rows.getFirst(),"gdc_content"),Audit.class);}catch(Exception ex){throw new IllegalStateException(ex);}
+ if(db.queryForList("SELECT log_id FROM "+historySchema+".log WHERE hq_id=? AND usr_id=? AND logo_description=? LIMIT 1",e.headquarters(),e.user(),e.description()).isEmpty())
+ db.update("INSERT INTO "+historySchema+".log (logo_id,hq_id,jb_id,usr_id,cr_id,cr_sid,cs_id,at_id,pt_id,emp_id,logo_description,log_createtime) VALUES (?,?,?,?,?,'',?,0,0,0,?,?)",e.operation(),e.headquarters(),e.job(),e.user(),e.courier(),e.customer(),e.description(),LocalDateTime.parse(e.at()));
+ db.update("UPDATE genericdatacontainer SET gdc_context='SENT' WHERE gdc_id=?",id(pending,"gdc_id"));return null;});
+ }}catch(RuntimeException ignored){/* Durable pending events remain available for the next run. */}
+ }
+ void transactional(String... tables) {
+ for(String table:tables){String[] parts=table.split("\\.");String schema=parts.length==2?parts[0]:null;String name=parts[parts.length-1];
+ Integer count=db.queryForObject("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA="+(schema==null?"DATABASE()":"?")+" AND TABLE_NAME=? AND ENGINE='InnoDB'",Integer.class,schema==null?new Object[]{name}:new Object[]{schema,name});
+ if(count==null||count!=1)throw bad("Speichern benötigt eine transaktionale InnoDB-Tabelle: "+table);
+ }
+ }
+ static void update(DatabaseService db,String table,String key,long id,Map values){if(values.isEmpty())return;var args=new ArrayList<>(values.values());args.add(id);db.update("UPDATE "+table+" SET "+String.join(",",values.keySet().stream().map(k->k+"=?").toList())+" WHERE "+key+"=?",args.toArray());}
+ static void insertWithoutKey(DatabaseService db,String table,Map values){db.update("INSERT INTO "+table+" ("+String.join(",",values.keySet())+") VALUES ("+String.join(",",Collections.nCopies(values.size(),"?"))+")",values.values().toArray());}
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/CalendarAdministration.java b/backend/src/main/java/de/stadtbote/votian/CalendarAdministration.java
new file mode 100644
index 0000000..2abbdff
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/CalendarAdministration.java
@@ -0,0 +1,114 @@
+package de.stadtbote.votian;
+
+import de.stadtbote.votian.database.DatabaseService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import java.time.*;
+import java.util.*;
+import static de.stadtbote.votian.LegacyMapping.*;
+import static de.stadtbote.votian.AdminSupport.*;
+import static de.stadtbote.votian.OrderEntryService.fields;
+
+/** Native counterparts of admin/public_holiday.php and admin/traveltime.php. */
+@Service
+public class CalendarAdministration {
+ private final DatabaseService db;
+ private final Clock clock;
+ @Autowired public CalendarAdministration(DatabaseService db){this(db,Clock.system(ZoneId.of("Europe/Berlin")));}
+ CalendarAdministration(DatabaseService db,Clock clock){this.db=db;this.clock=clock;}
+ record Holiday(int month,int day,String name) {}
+ record HolidaySave(String revision,List days) {}
+ record TravelSave(String revision,Integer minutes) {}
+ enum TravelMode { CLASSIC,BWV }
+ private List> holidayRows(LegacyUser user,int year) {return holidayRows(user,year,false);}
+ private List> holidayRows(LegacyUser user,int year,boolean lock) {
+ if(year!=0 && (year<1900 || year>2200))throw bad("Ungültiges Jahr.");
+ String sql="SELECT ph_id,ph_month,ph_day,ph_name,ph_time_from,ph_time_to FROM publicholiday WHERE hq_id=? AND ph_year=? ORDER BY ph_month,ph_day,ph_time_from,ph_id";
+ return lock?db.lockRows(sql,user.headquartersId(),year):db.queryForList(sql,user.headquartersId(),year);
+ }
+ public Map holidays(LegacyUser user,int year) {
+ user.require(2);
+ return db.readTransaction(()->{
+ var rows=holidayRows(user,year);
+ var days=new LinkedHashMap();
+ for(var r:rows)days.putIfAbsent(id(r,"ph_month")+"-"+id(r,"ph_day"),new Holiday((int)id(r,"ph_month"),(int)id(r,"ph_day"),text(r,"ph_name")));
+ return fields("year",year,"today",LocalDate.now(clock),"revision",revision(rows),"days",List.copyOf(days.values()),
+ "years",db.queryForList("SELECT DISTINCT ph_year AS year FROM publicholiday WHERE hq_id=? ORDER BY ph_year DESC",user.headquartersId()));
+ });
+ }
+ public Map saveHolidays(LegacyUser user,int year,HolidaySave request) {
+ user.require(2);
+ LocalDate today=LocalDate.now(clock);
+ if(year2200)throw bad("Vergangene Jahre und die Default-Vorlage können nicht geändert werden.");
+ if(request.days()==null || request.days().size()>366)throw bad("Ungültige Feiertagsliste.");
+ var requested=new LinkedHashMap();
+ for(var day:request.days()) {
+ if(day==null)throw bad("Ungültiger Feiertag.");
+ LocalDate date;
+ try {date=LocalDate.of(year,day.month(),day.day());}catch(DateTimeException e){throw bad("Ungültiges Feiertagsdatum.");}
+ String name=day.name()==null?"":day.name().strip();
+ if(name.length()>50)throw bad("Feiertagsname: höchstens 50 Zeichen.");
+ if(requested.putIfAbsent(date,name)!=null)throw bad("Ein Feiertag ist mehrfach enthalten.");
+ }
+ return db.writeTransaction(()->{
+ lockHeadquarters(db,user);
+ var rows=holidayRows(user,year,true);unchanged(rows,request.revision());
+ var existing=new HashMap>>();
+ for(var row:rows)existing.computeIfAbsent(LocalDate.of(year,(int)id(row,"ph_month"),(int)id(row,"ph_day")),x->new ArrayList<>()).add(row);
+ // Preserve every historical row, including optional time windows. The PHP UI disables these days.
+ for(var item:requested.entrySet())if(!item.getKey().isAfter(today)) {
+ var old=existing.get(item.getKey());
+ if(old==null || old.stream().noneMatch(r->text(r,"ph_name").equals(item.getValue())))throw bad("Vergangene Tage und heute dürfen nicht geändert werden.");
+ }
+ for(var item:existing.entrySet()) {
+ if(!item.getKey().isAfter(today))continue;
+ String name=requested.get(item.getKey());
+ for(var row:item.getValue()) {
+ if(name==null)db.update("DELETE FROM publicholiday WHERE ph_id=? AND hq_id=?",id(row,"ph_id"),user.headquartersId());
+ else if(!name.equals(text(item.getValue().getFirst(),"ph_name")) && !name.equals(text(row,"ph_name")))db.update("UPDATE publicholiday SET ph_name=? WHERE ph_id=? AND hq_id=?",name,id(row,"ph_id"),user.headquartersId());
+ }
+ }
+ for(var item:requested.entrySet())if(item.getKey().isAfter(today) && !existing.containsKey(item.getKey()))
+ db.insert("publicholiday",fields("hq_id",user.headquartersId(),"ph_year",year,"ph_month",item.getKey().getMonthValue(),"ph_day",item.getKey().getDayOfMonth(),"ph_name",item.getValue()));
+ return holidays(user,year);
+ });
+ }
+ public Map travelTimes(LegacyUser user,TravelMode mode,String from,String to) {
+ user.require(2);required(from,10,"PLZ von");required(to,10,"PLZ bis");
+ if(from.compareTo(to)>0)throw bad("Der PLZ-Bereich ist umgekehrt.");
+ var rows=db.queryForList("SELECT p.srvp_id AS id,p.srvp_plz AS postalCode,p.srvp_country AS country,p.srvp_traveltime AS globalMinutes,t.srvpt_traveltime AS localMinutes FROM serviceplz p LEFT JOIN serviceplztraveltime t ON t.srvp_id=p.srvp_id AND t.hq_id=? WHERE p.srvp_valid=1 AND p.srvp_plz>=? AND p.srvp_plz<=? ORDER BY p.srvp_plz,p.srvp_id LIMIT 4001",user.headquartersId(),from,to);
+ if(rows.size()>4000)throw bad("Mehr als 4000 PLZ gefunden. Bitte den Bereich eingrenzen.");
+ return fields("mode",mode,"defaultMinutes",defaultTravelTime(user),"items",rows.stream().map(r->travel(r,mode)).toList());
+ }
+ private Map travel(Map row,TravelMode mode) {
+ Object minutes=value(row,mode==TravelMode.BWV?"globalMinutes":"localMinutes");
+ return fields("id",id(row,"id"),"postalCode",text(row,"postalCode"),"country",text(row,"country"),"minutes",minutes,"revision",revision(Arrays.asList(id(row,"id"),mode,minutes)));
+ }
+ Integer defaultTravelTime(LegacyUser user) {
+ String configured=new OrderEntryCatalog(db).parameter(user.headquartersId(),"LATEST_TAKETIME_IN_MINUTES");
+ try {int minutes=Integer.parseInt(configured.strip());return minutes>=1&&minutes<=9999?minutes:null;}
+ catch(NumberFormatException e){return null;}
+ }
+ public Map saveTravelTime(LegacyUser user,TravelMode mode,long postalId,TravelSave request) {
+ user.require(2);
+ if(request==null||request.minutes()!=null&&(request.minutes()<0||request.minutes()>9999))throw bad("Anfahrtszeit: Bitte 0 bis 9999 Minuten eingeben.");
+ Integer minutes=request.minutes()==null||request.minutes()==0?defaultTravelTime(user):request.minutes();
+ if(minutes==null)throw bad("Für diese Niederlassung fehlt ein gültiger Standard LATEST_TAKETIME_IN_MINUTES. Bitte eine Anfahrtszeit eingeben.");
+ return db.writeTransaction(()->{
+ lockHeadquarters(db,user);
+ if(mode==TravelMode.CLASSIC)db.lockRows("SELECT srvp_id FROM serviceplztraveltime WHERE srvp_id=? AND hq_id=?",postalId,user.headquartersId());
+ var row=one(db.queryForList("SELECT p.srvp_id AS id,p.srvp_plz AS postalCode,p.srvp_country AS country,p.srvp_traveltime AS globalMinutes,t.srvpt_traveltime AS localMinutes FROM serviceplz p LEFT JOIN serviceplztraveltime t ON t.srvp_id=p.srvp_id AND t.hq_id=? WHERE p.srvp_valid=1 AND p.srvp_id=?",user.headquartersId(),postalId));
+ Object old=value(row,mode==TravelMode.BWV?"globalMinutes":"localMinutes");
+ unchanged(Arrays.asList(postalId,mode,old),request.revision());
+ if(mode==TravelMode.BWV) {
+ // MyISAM cannot roll back a batch. One conditional UPDATE is the complete business write.
+ if(db.update("UPDATE serviceplz SET srvp_traveltime=? WHERE srvp_id=? AND srvp_valid=1 AND srvp_traveltime <=> ?",minutes,postalId,old)!=1)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Anfahrtszeit wurde zwischenzeitlich geändert.");
+ }else {
+ if(old==null)db.update("INSERT INTO serviceplztraveltime (srvp_id,hq_id,srvpt_traveltime) VALUES (?,?,?)",postalId,user.headquartersId(),minutes);
+ else db.update("UPDATE serviceplztraveltime SET srvpt_traveltime=? WHERE srvp_id=? AND hq_id=?",minutes,postalId,user.headquartersId());
+ }
+ row.put(mode==TravelMode.BWV?"globalMinutes":"localMinutes",minutes);
+ return travel(row,mode);
+ });
+ }
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/ContactService.java b/backend/src/main/java/de/stadtbote/votian/ContactService.java
new file mode 100644
index 0000000..3452010
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/ContactService.java
@@ -0,0 +1,46 @@
+package de.stadtbote.votian;
+import de.stadtbote.votian.database.DatabaseService;
+import org.springframework.stereotype.Service;
+import java.util.*;
+import java.time.*;
+import java.time.format.*;
+import java.nio.charset.StandardCharsets;
+import static de.stadtbote.votian.AdminSupport.*;
+import static de.stadtbote.votian.LegacyMapping.*;
+import static de.stadtbote.votian.OrderEntryService.fields;
+import static de.stadtbote.votian.CrmScope.marks;
+@Service
+class ContactService {
+ private final DatabaseService db;private final CrmScope scope;
+ ContactService(DatabaseService db,CrmScope scope){this.db=db;this.scope=scope;}
+ record Query(List headquarters,String search,boolean prefix,String letter,Long customerId){}
+ record Save(long headquarters,String revision,String schemaRevision,Map values){}
+ private List> definitions(long hq){return db.queryForList("SELECT ck.mtfck_id AS id,ck.mtfk_id AS keyId,ck.mtfck_history AS history,k.mtfk_name AS label,k.mtfk_type AS type FROM metafieldcategorykey ck JOIN metafieldkey k ON k.mtfk_id=ck.mtfk_id WHERE ck.mtfc_id=1 AND ck.hq_id=? ORDER BY ck.mtfck_sort,ck.mtfck_id",hq);}
+ Map schema(LegacyUser u,long hq){scope.checked(u,List.of(hq));var d=definitions(hq);if(d.isEmpty())throw bad("Für diese Niederlassung sind keine Kontaktfelder eingerichtet.");return fields("fields",d,"revision",revision(d));}
+ private List> raw(long id,List> defs){var args=new ArrayList(List.of(id));args.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id,mtfv_id,mtfv_value FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfck_id,mtfv_value",args.toArray());}
+ private Map customerLabels(LegacyUser u,Collection ids){var valid=ids.stream().filter(v->v!=null&&v.matches("[1-9][0-9]{0,9}")).distinct().toList();if(valid.isEmpty())return Map.of();var hqs=scope.headquarters(u).stream().map(h->id(h,"id")).toList();if(hqs.isEmpty())return Map.of();var args=new ArrayList(valid);args.addAll(hqs);var result=new HashMap();for(var r:db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' ',cs.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer cs LEFT JOIN company co ON co.cmp_id=cs.cmp_id WHERE cs.cs_id IN ("+marks(valid.size())+") AND cs.hq_id IN ("+marks(hqs.size())+")",args.toArray()))result.put(Long.toString(id(r,"id")),text(r,"label"));return result;}
+ Map get(LegacyUser u,long hq,long contact){scope.checked(u,List.of(hq));var defs=definitions(hq);if(defs.isEmpty())throw bad("Keine Kontaktfelder eingerichtet.");var rows=raw(contact,defs);if(rows.isEmpty())one(List.of());var vals=new LinkedHashMap();for(var r:rows)if(vals.put(id(r,"mtfck_id"),text(r,"mtfv_value"))!=null)throw bad("Doppelte Kontaktfelder im Bestand. Bitte zuerst bereinigen.");return fields("id",contact,"headquartersId",hq,"values",vals,"customerLabels",customerLabels(u,defs.stream().filter(d->text(d,"type").equals("co2cs")).map(d->vals.get(id(d,"id"))).toList()),"revision",revision(rows),"schema",fields("fields",defs,"revision",revision(defs)));}
+ List> list(LegacyUser u,Query q){if(q==null)throw bad("Kontaktfilter fehlen.");var h=scope.checked(u,q.headquarters());String term=q.search()==null?"":q.search().strip();if(term.length()>100||q.letter()!=null&&!q.letter().matches("[A-ZÄÖÜ]?"))throw bad("Ungültige Kontaktsuche.");if(q.customerId()!=null)scope.customer(u,q.customerId());
+ var args=new ArrayList(h);String where="ck.mtfc_id=1 AND ck.hq_id IN ("+marks(h.size())+")";
+ if(!term.isEmpty()){where+=" AND EXISTS (SELECT 1 FROM metafieldvalue sv JOIN metafieldcategorykey sk ON sk.mtfck_id=sv.mtfck_id JOIN metafieldkey fk ON fk.mtfk_id=sk.mtfk_id LEFT JOIN customer c ON fk.mtfk_type='co2cs' AND c.cs_id=sv.mtfv_value AND c.hq_id=sk.hq_id LEFT JOIN company co ON co.cmp_id=c.cmp_id WHERE sv.mtfv_id=v.mtfv_id AND sk.mtfc_id=1 AND sk.hq_id=ck.hq_id AND (sv.mtfv_value LIKE ? OR CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) LIKE ?))";String like=(q.prefix()?"":"%")+term.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%";args.add(like);args.add(like);}
+ if(q.letter()!=null&&!q.letter().isEmpty()){where+=" AND EXISTS (SELECT 1 FROM metafieldvalue lv JOIN metafieldcategorykey lk ON lk.mtfck_id=lv.mtfck_id JOIN metafieldkey k ON k.mtfk_id=lk.mtfk_id WHERE lv.mtfv_id=v.mtfv_id AND lk.mtfc_id=1 AND lk.hq_id=ck.hq_id AND (k.mtfk_name='Name' OR k.mtfk_type='surname') AND lv.mtfv_value LIKE ?)";args.add(q.letter()+"%");}
+ if(q.customerId()!=null){where+=" AND EXISTS (SELECT 1 FROM metafieldvalue cv JOIN metafieldcategorykey ck2 ON ck2.mtfck_id=cv.mtfck_id JOIN metafieldkey k2 ON k2.mtfk_id=ck2.mtfk_id WHERE cv.mtfv_id=v.mtfv_id AND ck2.mtfc_id=1 AND ck2.hq_id=ck.hq_id AND k2.mtfk_type='co2cs' AND cv.mtfv_value=?)";args.add(q.customerId().toString());}
+ var matches=db.queryForList("SELECT DISTINCT v.mtfv_id AS id,ck.hq_id AS headquartersId FROM metafieldvalue v JOIN metafieldcategorykey ck ON ck.mtfck_id=v.mtfck_id WHERE "+where+" ORDER BY v.mtfv_id DESC LIMIT 501",args.toArray());if(matches.size()>500)throw bad("Mehr als 500 Kontakte. Bitte Suche oder Niederlassungen eingrenzen.");
+ var result=new ArrayList>();for(long hq:h){var ids=matches.stream().filter(m->id(m,"headquartersId")==hq).map(m->id(m,"id")).toList();if(ids.isEmpty())continue;var p=new ArrayList(List.of(hq));p.addAll(ids);var rows=db.queryForList("SELECT v.mtfv_id AS id,ck.mtfck_id AS fieldId,k.mtfk_name AS label,k.mtfk_type AS type,v.mtfv_value AS value FROM metafieldvalue v JOIN metafieldcategorykey ck ON ck.mtfck_id=v.mtfck_id JOIN metafieldkey k ON k.mtfk_id=ck.mtfk_id WHERE ck.mtfc_id=1 AND ck.hq_id=? AND v.mtfv_id IN ("+marks(ids.size())+") ORDER BY ck.mtfck_sort,ck.mtfck_id",p.toArray());for(long cid:ids){var values=rows.stream().filter(r->id(r,"id")==cid).toList();result.add(fields("id",cid,"headquartersId",hq,"fields",values));}}
+ var linked=customerLabels(u,result.stream().flatMap(r->((List>)r.get("fields")).stream()).filter(f->text(f,"type").equals("co2cs")).map(f->text(f,"value")).toList());for(var r:result)for(var f:(List>)r.get("fields"))if(text(f,"type").equals("co2cs"))f.put("displayValue",linked.getOrDefault(text(f,"value"),"Kunde "+text(f,"value")));
+ result.sort(Comparator.comparing(r->contactName((List>)r.get("fields")),String.CASE_INSENSITIVE_ORDER));return result;
+ }
+ static String contactName(List> fields){return String.join(" ",fields.stream().filter(f->Set.of("Name","Vorname").contains(text(f,"label"))).map(f->text(f,"value")).toList());}
+ Map save(LegacyUser u,Long cid,Save s){if(s==null||s.values()==null)throw bad("Kontaktdaten fehlen.");scope.checked(u,List.of(s.headquarters()));var defs=definitions(s.headquarters());unchanged(defs,s.schemaRevision());if(defs.isEmpty()||s.values().size()>200)throw bad("Ungültige Kontaktfelder.");var known=defs.stream().map(d->id(d,"id")).toList();if(!known.containsAll(s.values().keySet()))throw bad("Unbekanntes Kontaktfeld.");var vals=new LinkedHashMap();var before=cid==null?List.>of():raw(cid,defs);if(cid!=null){if(before.isEmpty())one(before);unchanged(before,s.revision());}
+ for(var d:defs){String v=s.values().getOrDefault(id(d,"id"),"");if(v==null)v="";v=v.strip();if(v.getBytes(StandardCharsets.UTF_8).length>65535)throw bad("Kontaktfeld ist zu lang.");String type=text(d,"type");String oldValue=before.stream().filter(r->id(r,"mtfck_id")==id(d,"id")).map(r->text(r,"mtfv_value")).findFirst().orElse("");boolean modified=!v.equals(oldValue);if(modified&&type.equals("co2cs")&&!v.isBlank()){try{scope.customer(u,Long.parseLong(v));}catch(NumberFormatException e){throw bad("Bitte einen Kunden auswählen.");}}
+ if(modified&&(type.equals("date")||type.equals("birthdate"))&&!v.isBlank()){try{LocalDate.parse(v,DateTimeFormatter.ofPattern("dd.MM.uuuu").withResolverStyle(ResolverStyle.STRICT));}catch(DateTimeParseException e){throw bad(text(d,"label")+": Datum bitte als TT.MM.JJJJ eingeben.");}}
+ vals.put(id(d,"id"),v);}
+ if(vals.values().stream().allMatch(String::isBlank))throw bad("Bitte mindestens ein Kontaktfeld ausfüllen.");
+ return db.contactWriteTransaction(()->{unchanged(definitions(s.headquarters()),s.schemaRevision());List> old=cid==null?List.of():raw(cid,defs);if(cid!=null){if(old.isEmpty())one(old);unchanged(old,s.revision());}long contact=cid==null?db.queryForObject("SELECT COALESCE(MAX(mtfv_id),0)+1 FROM metafieldvalue",Long.class):cid;if(contact<=0||contact>Integer.MAX_VALUE)throw bad("Kontakt-ID-Bereich ausgeschöpft.");
+ if(cid!=null){var a=new ArrayList(List.of(contact));a.addAll(known);db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(known.size())+")",a.toArray());}
+ for(var d:defs){long key=id(d,"id");String v=vals.get(key);db.update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",key,contact,v);String previous=id(d,"history")==1?db.queryForList("SELECT mtfv_value FROM metafieldvaluehistory WHERE mtfck_id=? AND mtfv_id=? ORDER BY mtfvh_createtime DESC LIMIT 1",key,contact).stream().map(r->text(r,"mtfv_value")).findFirst().orElse(""):v;if(id(d,"history")==1&&!previous.equals(v))db.update("INSERT INTO metafieldvaluehistory (mtfck_id,mtfv_id,mtfv_value,mtfvh_createtime) VALUES (?,?,?,?)",key,contact,v,LocalDateTime.now(ZoneId.of("Europe/Berlin")));}
+ return fields("id",contact);});
+ }
+ void remove(LegacyUser u,long hq,long cid,String rev){scope.checked(u,List.of(hq));var defs=definitions(hq);if(defs.isEmpty())throw bad("Keine Kontaktfelder.");db.contactWriteTransaction(()->{var current=definitions(hq);unchanged(current,revision(defs));var old=raw(cid,defs);if(old.isEmpty())one(old);unchanged(old,rev);var a=new ArrayList(List.of(cid));a.addAll(defs.stream().map(d->id(d,"id")).toList());db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+")",a.toArray());return null;});}
+ List> history(LegacyUser u,long hq,long cid){get(u,hq,cid);var defs=definitions(hq);var a=new ArrayList(List.of(cid));a.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id AS fieldId,mtfv_value AS value,mtfvh_createtime AS time FROM metafieldvaluehistory WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfvh_createtime DESC LIMIT 1000",a.toArray());}
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/CrmController.java b/backend/src/main/java/de/stadtbote/votian/CrmController.java
new file mode 100644
index 0000000..2ce9593
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/CrmController.java
@@ -0,0 +1,25 @@
+package de.stadtbote.votian;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+import java.util.*;
+import static de.stadtbote.votian.ApiController.user;
+@RestController
+@RequestMapping("/api/crm")
+class CrmController {
+ private final ContactService contacts;private final SalesAnalytics sales;
+ CrmController(ContactService contacts,SalesAnalytics sales){this.contacts=contacts;this.sales=sales;}
+ @GetMapping("/options") Map options(Authentication a){return sales.options(user(a));}
+ @GetMapping("/contacts/schema") Map schema(Authentication a,@RequestParam long headquarters){return contacts.schema(user(a),headquarters);}
+ @PostMapping("/contacts/query") List> contacts(Authentication a,@RequestBody ContactService.Query q){return contacts.list(user(a),q);}
+ @GetMapping("/contacts/{id}") Map contact(Authentication a,@PathVariable long id,@RequestParam long headquarters){return contacts.get(user(a),headquarters,id);}
+ @GetMapping("/contacts/{id}/history") List> history(Authentication a,@PathVariable long id,@RequestParam long headquarters){return contacts.history(user(a),headquarters,id);}
+ @PostMapping("/contacts") Map create(Authentication a,@RequestBody ContactService.Save s){return contacts.save(user(a),null,s);}
+ @PutMapping("/contacts/{id}") Map save(Authentication a,@PathVariable long id,@RequestBody ContactService.Save s){return contacts.save(user(a),id,s);}
+ record Delete(long headquarters,String revision){}
+ @DeleteMapping("/contacts/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void remove(Authentication a,@PathVariable long id,@RequestBody Delete s){contacts.remove(user(a),s.headquarters(),id,s.revision());}
+ @PostMapping("/switches") Map switches(Authentication a,@RequestBody SalesAnalytics.Query q){return sales.switches(user(a),q);}
+ record Motivation(String month,List headquarters){}
+ @PostMapping("/motivation") Map motivation(Authentication a,@RequestBody Motivation q){return sales.motivation(user(a),q.month(),q.headquarters());}
+ @PostMapping("/targets/query") List> targets(Authentication a,@RequestBody SalesAnalytics.TargetAccess s){return sales.targets(user(a),s);}
+ @PostMapping("/targets") Map target(Authentication a,@RequestBody SalesAnalytics.Target s){return sales.target(user(a),s);}
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/CrmScope.java b/backend/src/main/java/de/stadtbote/votian/CrmScope.java
new file mode 100644
index 0000000..cd80868
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/CrmScope.java
@@ -0,0 +1,17 @@
+package de.stadtbote.votian;
+import de.stadtbote.votian.database.DatabaseService;
+import org.springframework.stereotype.Service;
+import org.springframework.security.access.AccessDeniedException;
+import java.util.*;
+import static de.stadtbote.votian.LegacyMapping.*;
+import static de.stadtbote.votian.AdminSupport.*;
+@Service
+class CrmScope {
+ final DatabaseService db;
+ CrmScope(DatabaseService db){this.db=db;}
+ void access(LegacyUser u){u.require(0);u.require(11);}
+ List> headquarters(LegacyUser u){access(u);return db.queryForList("SELECT DISTINCT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE own.hq_id=? AND own.md_id>0"+(u.hasRight(10)?"":" AND h.hq_id=?")+" ORDER BY h.hq_mnemonic",u.hasRight(10)?new Object[]{u.headquartersId()}:new Object[]{u.headquartersId(),u.headquartersId()});}
+ List checked(LegacyUser u,List hqs){access(u);if(hqs==null||hqs.isEmpty()||hqs.size()>100||hqs.stream().anyMatch(Objects::isNull))throw bad("Bitte Niederlassungen auswählen.");var allowed=headquarters(u).stream().map(r->id(r,"id")).toList();if(!allowed.containsAll(hqs))throw new AccessDeniedException("Niederlassung nicht freigegeben.");return hqs.stream().distinct().sorted().toList();}
+ static String marks(int n){return String.join(",",Collections.nCopies(n,"?"));}
+ void customer(LegacyUser u,long cs){var h=checked(u,headquarters(u).stream().map(r->id(r,"id")).toList());var args=new ArrayList(h);args.add(cs);one(db.queryForList("SELECT cs_id FROM customer WHERE hq_id IN ("+marks(h.size())+") AND cs_id=?",args.toArray()));}
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerServices.java b/backend/src/main/java/de/stadtbote/votian/CustomerServices.java
new file mode 100644
index 0000000..34c8339
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/CustomerServices.java
@@ -0,0 +1,52 @@
+package de.stadtbote.votian;
+import de.stadtbote.votian.database.DatabaseService;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import static de.stadtbote.votian.AdminSupport.*;
+import static de.stadtbote.votian.BusinessSupport.*;
+import static de.stadtbote.votian.LegacyMapping.*;
+import static de.stadtbote.votian.OrderEntryService.fields;
+
+/** Ordered customer_service.php assignments, including resumable MyISAM writes. */
+@RestController @RequestMapping("/api/master/customers/{id}/services")
+class CustomerServices {
+ final DatabaseService db;final BusinessSupport support;final OrderEntryCatalog catalog;
+ CustomerServices(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog){this.db=db;this.support=support;this.catalog=catalog;}
+ record Assignment(long id,int sort){}
+ record Save(String requestId,String revision,List services,List types){}
+ record Plan(long user,long headquarters,long customer,Save request,List beforeServices,List beforeTypes){}
+ @GetMapping Map get(Authentication a,@PathVariable long id){return view(ApiController.user(a),id);}
+ @PutMapping Map put(Authentication a,@PathVariable long id,@RequestBody Save s){return save(ApiController.user(a),id,s);}
+ @PostMapping("/operations/{token}") Map resume(Authentication a,@PathVariable long id,@PathVariable String token){return resume(ApiController.user(a),id,token);}
+ private void access(LegacyUser u,long customer){u.require(0);u.require(2);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));}
+ private List assignments(long customer,boolean types){String table=types?"customerservicetype":"customerservice",key=types?"srvt_id":"srv_id",sort=types?"cssrvt_sort":"cssrv_sort";return db.queryForList("SELECT "+key+" AS id,"+sort+" AS sort FROM "+table+" WHERE cs_id=? ORDER BY "+sort+","+key,customer).stream().map(r->new Assignment(id(r,"id"),(int)id(r,"sort"))).toList();}
+ private List> choices(LegacyUser u,boolean types){String table=types?"servicetype":"service",prefix=types?"srvt":"srv";var result=new ArrayList>();for(int mode:List.of(0,1)){long hq=catalog.enabled(u.headquartersId(),mode==0?"MANDATOR_SERVICE_ENABLED":"MANDATOR_SERVICE2_ENABLED")?u.headquartersId():0;result.addAll(db.queryForList("SELECT "+prefix+"_id AS id,"+prefix+"_name AS label,"+prefix+"_mode AS mode FROM "+table+" WHERE hq_id=? AND cs_id=0 AND "+prefix+"_mode=? ORDER BY "+prefix+"_name,"+prefix+"_id",hq,mode));}return result;}
+ private List> pending(long customer){return db.queryForList("SELECT gdc_gen_fieldname AS token,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcssrv' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id",customer);}
+ Map view(LegacyUser u,long customer){access(u,customer);return db.readTransaction(()->{var services=assignments(customer,false);var types=assignments(customer,true);return fields("services",services.stream().map(Assignment::id).toList(),"types",types.stream().map(Assignment::id).toList(),"serviceOptions",choices(u,false),"typeOptions",choices(u,true),"revision",revision(List.of(services,types)),"operations",pending(customer).stream().map(r->{var p=plan(text(r,"gdc_content"));return fields("token",text(r,"token"),"canResume",p.user()==u.userId()&&p.headquarters()==u.headquartersId());}).toList());});}
+ private void validate(List requested,List before,List> choices){if(requested==null||requested.size()>500||requested.stream().anyMatch(i->i==null||i<=0)||new HashSet<>(requested).size()!=requested.size())throw bad("Ungültige oder doppelte Servicezuordnung.");var allowed=new HashSet<>(before.stream().map(Assignment::id).toList());choices.forEach(c->allowed.add(id(c,"id")));if(!allowed.containsAll(requested))throw bad("Eine Servicezuordnung ist nicht freigegeben.");}
+ Map save(LegacyUser u,long customer,Save s){access(u,customer);if(s==null)throw bad("Serviceauswahl fehlt.");token(s.requestId());support.transactional("customer","genericdatacontainer");
+ db.writeTransaction(()->{one(db.lockRows("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));var existing=operation(customer,s.requestId());if(!existing.isEmpty()){var p=plan(text(one(existing),"gdc_content"));if(p.user()!=u.userId()||p.headquarters()!=u.headquartersId()||!p.request().equals(s))throw bad("Diese Vorgangskennung gehört zu anderen Daten.");return null;}
+ if(!pending(customer).isEmpty())throw bad("Bitte zuerst den ausstehenden Speichervorgang abschließen.");var beforeServices=assignments(customer,false);var beforeTypes=assignments(customer,true);unchanged(List.of(beforeServices,beforeTypes),s.revision());validate(s.services(),beforeServices,choices(u,false));validate(s.types(),beforeTypes,choices(u,true));
+ String encoded=support.encode(new Plan(u.userId(),u.headquartersId(),customer,s,beforeServices,beforeTypes));if(encoded.getBytes(StandardCharsets.UTF_8).length>65000)throw bad("Zu viele Servicezuordnungen für einen Vorgang.");db.insert("genericdatacontainer",fields("gdc_obj_type","ngcssrv","gdc_obj_id",customer,"gdc_gen_fieldname",s.requestId(),"gdc_content",encoded,"gdc_context","PENDING"));return null;
+ });return resume(u,customer,s.requestId());
+ }
+ private List> operation(long customer,String token){return db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='ngcssrv' AND gdc_obj_id=? AND gdc_gen_fieldname=?",customer,token);}
+ private Plan plan(String json){try{return support.json.readValue(json,Plan.class);}catch(Exception e){throw bad("Der gespeicherte Zuordnungsvorgang ist nicht lesbar.");}}
+ Map resume(LegacyUser u,long customer,String token){access(u,customer);token(token);support.transactional("customer","genericdatacontainer");var row=one(operation(customer,token));var p=plan(text(row,"gdc_content"));if(p.user()!=u.userId()||p.headquarters()!=u.headquartersId()||p.customer()!=customer)throw bad("Nur der ursprüngliche Bearbeiter kann diesen Vorgang fortsetzen.");if(text(row,"gdc_context").equals("DONE"))return fields("state","DONE","token",token);
+ try {return db.customerServiceWriteTransaction(()->{access(u,customer);var current=one(operation(customer,token));if(text(current,"gdc_context").equals("DONE"))return fields("state","DONE","token",token);
+ var services=assignments(customer,false);var types=assignments(customer,true);
+ validate(p.request().services(),p.beforeServices(),choices(u,false));validate(p.request().types(),p.beforeTypes(),choices(u,true));
+ if(!recoverable(services,p.beforeServices(),p.request().services())||!recoverable(types,p.beforeTypes(),p.request().types()))throw bad("Die Zuordnungen wurden außerhalb dieses Vorgangs geändert. Bitte den Konflikt prüfen.");
+ replace(customer,false,services,p.request().services());replace(customer,true,types,p.request().types());
+ support.audit(u,0,customer,0,36,"Servicezuordnungen geändert · Preistypen "+p.request().services().size()+" · Leistungstypen "+p.request().types().size(),token);
+ db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_obj_type='ngcssrv' AND gdc_obj_id=? AND gdc_gen_fieldname=?",customer,token);return fields("state","DONE","token",token);
+ });}catch(org.springframework.dao.DataAccessException|org.springframework.web.server.ResponseStatusException e){return fields("state","PENDING","token",token,"message","Der Zuordnungsvorgang ist gesichert, aber noch nicht vollständig abgeschlossen. Bitte erneut prüfen. "+(e instanceof org.springframework.web.server.ResponseStatusException r?r.getReason():"Die Datenbank war während des Schreibens nicht verfügbar."));}
+ }
+ static List desired(List ids){var result=new ArrayList();for(int i=0;i current,List before,List desired){var after=desired(desired);return current.equals(before)||current.size()<=after.size()&¤t.equals(after.subList(0,current.size()));}
+ private void replace(long customer,boolean types,List current,List desired){var after=desired(desired);if(current.equals(after))return;String table=types?"customerservicetype":"customerservice",key=types?"srvt_id":"srv_id",sort=types?"cssrvt_sort":"cssrv_sort";
+ db.update("DELETE FROM "+table+" WHERE cs_id=?",customer);for(var a:after)db.update("INSERT INTO "+table+" (cs_id,"+key+","+sort+") VALUES (?,?,?)",customer,a.id(),a.sort());
+ }
+}
diff --git a/backend/src/main/java/de/stadtbote/votian/EvaluationController.java b/backend/src/main/java/de/stadtbote/votian/EvaluationController.java
new file mode 100644
index 0000000..75d5b09
--- /dev/null
+++ b/backend/src/main/java/de/stadtbote/votian/EvaluationController.java
@@ -0,0 +1,29 @@
+package de.stadtbote.votian;
+import de.stadtbote.votian.database.DatabaseService;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+import jakarta.annotation.PreDestroy;
+import java.util.*;
+import static de.stadtbote.votian.ApiController.user;
+import static de.stadtbote.votian.OrderEntryService.fields;
+@RestController @RequestMapping("/api/evaluations")
+class EvaluationController {
+ final EvaluationService service;final NewsletterTemplate templates;final NewsletterDispatch dispatch;
+ EvaluationController(EvaluationService service,NewsletterTemplate templates,AppointmentMailTransport transport,DatabaseService db){this.service=service;this.templates=templates;this.dispatch=new NewsletterDispatch(db,service,templates,transport);}
+ @PreDestroy void shutdown(){dispatch.shutdown();}
+ @GetMapping("/options") Map options(Authentication a){return service.options(user(a));}
+ @GetMapping("/lookup") List> lookup(Authentication a,@RequestParam long headquarters,@RequestParam String kind,@RequestParam(defaultValue="")String q){return service.lookup(user(a),headquarters,kind,q);}
+ @GetMapping("/schema") Map schema(Authentication a,@RequestParam long headquarters){return service.schema(user(a),headquarters);}
+ @PostMapping("/query") Map query(Authentication a,@RequestBody MetafieldRecords.Query q){return service.query(user(a),q);}
+ @GetMapping("/{id}") Map get(Authentication a,@PathVariable long id,@RequestParam long headquarters){return service.get(user(a),headquarters,id);}
+ @PostMapping Map create(Authentication a,@RequestBody MetafieldRecords.Save s){return service.save(user(a),null,s);}
+ @PutMapping("/{id}") Map save(Authentication a,@PathVariable long id,@RequestBody MetafieldRecords.Save s){return service.save(user(a),id,s);}
+ record Revision(String revision){}
+ @DeleteMapping("/{id}") void remove(Authentication a,@PathVariable long id,@RequestParam long headquarters,@RequestBody Revision r){service.remove(user(a),headquarters,id,r.revision());}
+ @GetMapping("/{id}/history") List> history(Authentication a,@PathVariable long id,@RequestParam long headquarters){return service.history(user(a),headquarters,id);}
+ @GetMapping("/{id}/preview") Map preview(Authentication a,@PathVariable long id,@RequestParam long headquarters){var data=service.rendering(user(a),headquarters,id);return fields("html",NewsletterDispatch.previewHtml(templates.render(data)),"revision",data.get("revision"));}
+ @GetMapping("/mail/options") Map mailOptions(Authentication a){return dispatch.options(user(a));}
+ @PostMapping("/{id}/mail/preview") Map mailPreview(Authentication a,@PathVariable long id,@RequestBody NewsletterDispatch.Draft d){return dispatch.preview(user(a),id,d);}
+ @PostMapping("/mail/{token}/send") Map mailSend(Authentication a,@PathVariable String token){return dispatch.send(user(a),token);}
+ @GetMapping("/mail/{token}") Map