Erweitere VoTiAn NG um Auftrags-, Stamm- und Verwaltungsfunktionen

Ergänzt Auftragserfassung und -bearbeitung, Unternehmerbelege, Stornos, Lagerkorrekturen, Stammdaten, Abrechnung, CRM, Formulare, Berichte und Preisverwaltung auf Basis der aktuellen PHP-Anwendung.

Enthält Integrations- und Oberflächentests sowie dokumentierte Abgleiche und Prüfstände.
This commit is contained in:
Sven committed 2026-10-13 20:31:31 +02:00
1 parent 42c87282ee
commit 363bc27c26
190 files changed
+14815 -1857

No files matched your search

+36 -10
View File
@@ -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.
+2
View File
@@ -5,6 +5,8 @@
<groupId>de.stadtbote</groupId><artifactId>votian-backend</artifactId><version>0.1.2</version>
<name>Votian NG Stadtbote</name><properties><java.version>21</java.version></properties>
<dependencies>
<dependency><groupId>org.jsoup</groupId><artifactId>jsoup</artifactId><version>1.20.1</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-mail</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
@@ -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<Selection> items){}
record Operation(String kind,long hq,long user,String request,List<Map<String,Object>> 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<String,Object> 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<Object>();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;i<values.length;i++){String v=clean(values[i],lengths[i]);if(!v.isEmpty()){where.append(" AND a.").append(columns[i]).append(" LIKE ? ESCAPE '!'");args.add(like(v));}}
for(var entry:Map.of("from",clean(q.from(),4),"to",clean(q.to(),4)).entrySet())if(!entry.getValue().isEmpty()){where.append(" AND m.").append(k.prefix).append("_hsno_").append(entry.getKey()).append("=?");args.add(entry.getValue());}
if(q.mode()!=null){if(q.mode()<0||q.mode()>2)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<Map<String,Object>>();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<Map<String,Object>> exceptions(LegacyUser u,long id){
var result=new ArrayList<Map<String,Object>>();
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<String,Object> 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<String,Object> row){try{return json.readValue(text(row,"gdc_content"),Operation.class);}catch(Exception e){throw bad("Gespeicherter Adressvorgang ist beschädigt.");}}
Map<String,Object> 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<String,Object> 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<String,Object> 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<String,Object> 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<Map<String,Object>>();var seen=new HashSet<Long>();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<Object> args(Map<String,Object> r){return new ArrayList<>(Arrays.asList(id(r,"targetId"),id(r,"ad_id"),r.get("houseFrom"),r.get("houseTo"),r.get("mode")));}
Map<String,Object> 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():""));}
}
}
@@ -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<String,Object> options(Authentication a,@PathVariable AddressAdministration.Kind kind){return service.options(user(a),kind);}
@PostMapping("/query") Map<String,Object> query(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Query q){return service.query(user(a),kind,q);}
@PostMapping Map<String,Object> create(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Save s){return service.create(user(a),kind,s);}
@PostMapping("/remove") Map<String,Object> remove(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Delete s){return service.remove(user(a),kind,s);}
@PostMapping("/operations/{token}/resume") Map<String,Object> resume(Authentication a,@PathVariable AddressAdministration.Kind kind,@PathVariable String token){return service.resume(user(a),kind,token);}
}
@@ -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<String,String>();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?"<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<String,Object> one(List<Map<String,Object>> 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);}
}
@@ -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<String,Object> 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<String,Object> holidays(Authentication auth,@PathVariable int year){return calendar.holidays(user(auth),year);}
@PutMapping("/holidays/{year}") Map<String,Object> holidays(Authentication auth,@PathVariable int year,@RequestBody HolidaySave request){return calendar.saveHolidays(user(auth),year,request);}
@GetMapping("/travel-times") Map<String,Object> 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<String,Object> travel(Authentication auth,@PathVariable long id,@RequestParam TravelMode mode,@RequestBody TravelSave request){return calendar.saveTravelTime(user(auth),mode,id,request);}
@GetMapping("/groups") List<Map<String,Object>> groups(Authentication auth,@RequestParam(required=false) Long headquartersId){return groups.list(groups.scoped(user(auth),headquartersId));}
@PostMapping("/groups") Map<String,Object> 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<String,Object> 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<String,Object> 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);}
}
@@ -38,11 +38,11 @@ public class ApiController {
boolean connected=connection.connected();
LegacyUser u=authentication!=null&&authentication.getPrincipal() instanceof LegacyUser lu?lu:null;
Map<String,Object> 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<String,Object> 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> history(Authentication auth,@PathVariable long id) { return history.history(user(auth),id); }
@GetMapping("/customers") public List<Customer> customers(Authentication auth,@RequestParam(required=false) String q) { return legacy.customers(user(auth),q); }
@GetMapping("/customers") public List<Customer> 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<Courier> couriers(Authentication auth,@RequestParam(required=false) String q) { return legacy.couriers(user(auth),q); }
@GetMapping("/orders/export.csv") public ResponseEntity<byte[]> 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);
@@ -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.")); }
}
@@ -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<String,Object> options(Authentication auth) { return service.options(user(auth)); }
@GetMapping("/customers") List<Map<String,Object>> customers(Authentication auth,@RequestParam(defaultValue="") String q) { return service.customers(user(auth),q); }
@PostMapping("/query") Map<String,Object> list(Authentication auth,@RequestBody Query query) { return service.list(user(auth),query); }
@PostMapping("/statistics") Map<String,Object> statistics(Authentication auth,@RequestBody Query query) { return service.statistics(user(auth),query); }
@PostMapping Map<String,Object> create(Authentication auth,@RequestBody Save request) { return service.create(user(auth),request); }
@PutMapping("/{id}") Map<String,Object> save(Authentication auth,@PathVariable long id,@RequestBody Save request) { return service.save(user(auth),id,request); }
@PostMapping("/{id}/resubmit") Map<String,Object> 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()); }
}
@@ -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<String> 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<String> cc,java.util.List<String> 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<String,String>();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);}
}
}
@@ -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<String,Object> preview(Authentication a,@PathVariable long id,@RequestBody AppointmentNotifications.Draft d){return service.preview(user(a),id,d);}
@PostMapping("/notifications/{token}/send") Map<String,Object> send(Authentication a,@PathVariable String token){return service.send(user(a),token);}
}
@@ -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<String,Pending> pending=new LinkedHashMap<>();
private static class Pending {long user,hq,appointment;String revision,from,reply,subject,body;List<String> recipients;Instant expires;Map<String,Object> 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<String,Object> 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<String,String>();var missing=new ArrayList<String>();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<Object>(ids);args.add(u.headquartersId());var found=new HashSet<Long>();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<String,Object> 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<String,Object> 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;}
}
@@ -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<String> 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<Long> headquarters,Long personId,Long customerId,Integer status,Integer kind,Integer monitoring,List<Visibility> visibility,String search) {}
record Save(String revision,LocalDateTime startsAt,LocalDateTime endsAt,String text,int status,int visibility,int kind,int monitoring,Long customerId,List<Long> 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<Map<String,Object>> 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<Long> scope(LegacyUser user,List<Long> 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<Long> 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<Map<String,Object>> employees(List<Long> 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<String,List<Map<String,Object>>> categories() {
var result=new LinkedHashMap<String,List<Map<String,Object>>>();
var labels=new HashMap<String,String>();
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<Map<String,Object>>();
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<String,Object> 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<Map<String,Object>>();
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<Map<String,Object>> 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<Object>(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<Object>(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<Object> 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<Object>(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<codes.length;i++)if(codes[i]!=null){where+=" AND a.ap_category_"+categories[i]+"=?";args.add(codes[i]);}
if(!q.search().isBlank()){where+=" AND a.ap_text LIKE ?";args.add("%"+q.search().strip()+"%");}
if(!new HashSet<>(q.visibility()).containsAll(List.of(Visibility.values()))) {
var clauses=new ArrayList<String>();
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<String,Object> 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<Long,String> participantNames(LegacyUser user,List<Map<String,Object>> rows) {
var ids=new LinkedHashSet<Long>();for(var row:rows)ids.addAll(GroupAdministration.memberships(text(row,"ap_participants")));
var names=new HashMap<Long,String>();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<all.size();i+=500) {
var batch=all.subList(i,Math.min(i+500,all.size()));var args=new ArrayList<Object>(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<String,Object> version(Map<String,Object> row) { var result=new LinkedHashMap<String,Object>();for(String key:COLUMNS)result.put(key,value(row,key));return result; }
private static boolean canEdit(LegacyUser user,Map<String,Object> row) { return id(row,"usr_id")==user.userId()||user.hasRight(3)||GroupAdministration.memberships(text(row,"ap_participants")).contains(user.userId()); }
private Map<String,Object> view(LegacyUser user,Map<String,Object> row,Map<Long,String> 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<String,Object> 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<Map<String,Object>>();
for(int n=1;n<=4;n++) {
var counts=new TreeMap<Long,Long>();for(var row:rows)counts.merge(id(row,"ap_category_"+n),id(row,"total"),Long::sum);
var labels=new HashMap<Long,String>();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<String,Object> appointment(LegacyUser user,long appointmentId) {
access(user);var hqs=allowedHeadquarters(user);var args=new ArrayList<Object>(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<String,Object> 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<String,Object> values(LegacyUser user,Save request,Map<String,Object> 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;i<values.length;i++){int code=values[i];if(code<0||code>127||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<Long>();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<Long>():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<String,Object> row,String key) { long value=id(row,key);return value>0?value:null; }
static String ids(Collection<Long> ids) { return ids.isEmpty()?"":","+String.join(",",ids.stream().map(String::valueOf).toList())+","; }
public Map<String,Object> 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<String,Object> 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<String,Object> 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<String,Object> 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<Object>(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<String,Object> old,Map<String,Object> 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<Object> casArgs(Map<String,Object> old) { var args=new ArrayList<Object>();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."); }
}
@@ -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<Long> orders,String number,LocalDate date){}
record Assignment(long id,String revision){}
record Operation(long user,long hq,String number,String date,List<Assignment> 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<String,Object> 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<String,Object> 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<Map<String,Object>> 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<String,Object> query(LegacyUser u,Query q){check(u,q);return db.readTransaction(()->statement(u,q));}
Map<String,Object> statement(LegacyUser u,Query q){
Set<Long> 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<Object>(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<Map<String,Object>>();var revisionData=new ArrayList<Object>();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<Long,List<Map<String,Object>>>();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<Long,Map<String,Object>>();
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<String,Object> job,List<Map<String,Object>> 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<String,Object> line(Map<String,Object> 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<String,Object> 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<Map<String,Object>>)current.get("rows");Set<Long> 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<Assignment>();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<String,Object> 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<String,Object> row){try{return support.json.readValue(text(row,"gdc_content"),Operation.class);}catch(Exception e){throw new IllegalStateException(e);}}
public Map<String,Object> 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<String,Object> 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<Map<String,Object>>)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);}
}
@@ -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<byte[]> 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<byte[]> 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));}
}
@@ -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<String,Object> 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<String,Object> values){db.update("INSERT INTO "+table+" ("+String.join(",",values.keySet())+") VALUES ("+String.join(",",Collections.nCopies(values.size(),"?"))+")",values.values().toArray());}
}
@@ -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<Holiday> days) {}
record TravelSave(String revision,Integer minutes) {}
enum TravelMode { CLASSIC,BWV }
private List<Map<String,Object>> holidayRows(LegacyUser user,int year) {return holidayRows(user,year,false);}
private List<Map<String,Object>> 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<String,Object> holidays(LegacyUser user,int year) {
user.require(2);
return db.readTransaction(()->{
var rows=holidayRows(user,year);
var days=new LinkedHashMap<String,Holiday>();
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<String,Object> saveHolidays(LegacyUser user,int year,HolidaySave request) {
user.require(2);
LocalDate today=LocalDate.now(clock);
if(year<today.getYear() || year>2200)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<LocalDate,String>();
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<LocalDate,List<Map<String,Object>>>();
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<String,Object> 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<String,Object> travel(Map<String,Object> 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<String,Object> 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);
});
}
}
@@ -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<Long> headquarters,String search,boolean prefix,String letter,Long customerId){}
record Save(long headquarters,String revision,String schemaRevision,Map<Long,String> values){}
private List<Map<String,Object>> 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<String,Object> 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<Map<String,Object>> raw(long id,List<Map<String,Object>> defs){var args=new ArrayList<Object>(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<String,String> customerLabels(LegacyUser u,Collection<String> 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<Object>(valid);args.addAll(hqs);var result=new HashMap<String,String>();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<String,Object> 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<Long,String>();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<Map<String,Object>> 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<Object>(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<Map<String,Object>>();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<Object>(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<Map<String,Object>>)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<Map<String,Object>>)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<Map<String,Object>>)r.get("fields")),String.CASE_INSENSITIVE_ORDER));return result;
}
static String contactName(List<Map<String,Object>> fields){return String.join(" ",fields.stream().filter(f->Set.of("Name","Vorname").contains(text(f,"label"))).map(f->text(f,"value")).toList());}
Map<String,Object> 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<Long,String>();var before=cid==null?List.<Map<String,Object>>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<Map<String,Object>> 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<Object>(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<Object>(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<Map<String,Object>> history(LegacyUser u,long hq,long cid){get(u,hq,cid);var defs=definitions(hq);var a=new ArrayList<Object>(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());}
}
@@ -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<String,Object> options(Authentication a){return sales.options(user(a));}
@GetMapping("/contacts/schema") Map<String,Object> schema(Authentication a,@RequestParam long headquarters){return contacts.schema(user(a),headquarters);}
@PostMapping("/contacts/query") List<Map<String,Object>> contacts(Authentication a,@RequestBody ContactService.Query q){return contacts.list(user(a),q);}
@GetMapping("/contacts/{id}") Map<String,Object> contact(Authentication a,@PathVariable long id,@RequestParam long headquarters){return contacts.get(user(a),headquarters,id);}
@GetMapping("/contacts/{id}/history") List<Map<String,Object>> history(Authentication a,@PathVariable long id,@RequestParam long headquarters){return contacts.history(user(a),headquarters,id);}
@PostMapping("/contacts") Map<String,Object> create(Authentication a,@RequestBody ContactService.Save s){return contacts.save(user(a),null,s);}
@PutMapping("/contacts/{id}") Map<String,Object> 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<String,Object> switches(Authentication a,@RequestBody SalesAnalytics.Query q){return sales.switches(user(a),q);}
record Motivation(String month,List<Long> headquarters){}
@PostMapping("/motivation") Map<String,Object> motivation(Authentication a,@RequestBody Motivation q){return sales.motivation(user(a),q.month(),q.headquarters());}
@PostMapping("/targets/query") List<Map<String,Object>> targets(Authentication a,@RequestBody SalesAnalytics.TargetAccess s){return sales.targets(user(a),s);}
@PostMapping("/targets") Map<String,Object> target(Authentication a,@RequestBody SalesAnalytics.Target s){return sales.target(user(a),s);}
}
@@ -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<Map<String,Object>> 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<Long> checked(LegacyUser u,List<Long> 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<Object>(h);args.add(cs);one(db.queryForList("SELECT cs_id FROM customer WHERE hq_id IN ("+marks(h.size())+") AND cs_id=?",args.toArray()));}
}
@@ -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<Long> services,List<Long> types){}
record Plan(long user,long headquarters,long customer,Save request,List<Assignment> beforeServices,List<Assignment> beforeTypes){}
@GetMapping Map<String,Object> get(Authentication a,@PathVariable long id){return view(ApiController.user(a),id);}
@PutMapping Map<String,Object> put(Authentication a,@PathVariable long id,@RequestBody Save s){return save(ApiController.user(a),id,s);}
@PostMapping("/operations/{token}") Map<String,Object> 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<Assignment> 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<Map<String,Object>> choices(LegacyUser u,boolean types){String table=types?"servicetype":"service",prefix=types?"srvt":"srv";var result=new ArrayList<Map<String,Object>>();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<Map<String,Object>> 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<String,Object> 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<Long> requested,List<Assignment> before,List<Map<String,Object>> 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<String,Object> 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<Map<String,Object>> 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<String,Object> 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<Assignment> desired(List<Long> ids){var result=new ArrayList<Assignment>();for(int i=0;i<ids.size();i++)result.add(new Assignment(ids.get(i),i));return result;}
static boolean recoverable(List<Assignment> current,List<Assignment> before,List<Long> desired){var after=desired(desired);return current.equals(before)||current.size()<=after.size()&&current.equals(after.subList(0,current.size()));}
private void replace(long customer,boolean types,List<Assignment> current,List<Long> 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());
}
}
@@ -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<String,Object> options(Authentication a){return service.options(user(a));}
@GetMapping("/lookup") List<Map<String,Object>> lookup(Authentication a,@RequestParam long headquarters,@RequestParam String kind,@RequestParam(defaultValue="")String q){return service.lookup(user(a),headquarters,kind,q);}
@GetMapping("/schema") Map<String,Object> schema(Authentication a,@RequestParam long headquarters){return service.schema(user(a),headquarters);}
@PostMapping("/query") Map<String,Object> query(Authentication a,@RequestBody MetafieldRecords.Query q){return service.query(user(a),q);}
@GetMapping("/{id}") Map<String,Object> get(Authentication a,@PathVariable long id,@RequestParam long headquarters){return service.get(user(a),headquarters,id);}
@PostMapping Map<String,Object> create(Authentication a,@RequestBody MetafieldRecords.Save s){return service.save(user(a),null,s);}
@PutMapping("/{id}") Map<String,Object> 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<Map<String,Object>> history(Authentication a,@PathVariable long id,@RequestParam long headquarters){return service.history(user(a),headquarters,id);}
@GetMapping("/{id}/preview") Map<String,Object> 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<String,Object> mailOptions(Authentication a){return dispatch.options(user(a));}
@PostMapping("/{id}/mail/preview") Map<String,Object> mailPreview(Authentication a,@PathVariable long id,@RequestBody NewsletterDispatch.Draft d){return dispatch.preview(user(a),id,d);}
@PostMapping("/mail/{token}/send") Map<String,Object> mailSend(Authentication a,@PathVariable String token){return dispatch.send(user(a),token);}
@GetMapping("/mail/{token}") Map<String,Object> mailStatus(Authentication a,@PathVariable String token){return dispatch.status(user(a),token);}
}
@@ -0,0 +1,47 @@
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 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;
/** Category 15, matching the PHP Bewertung form and its persisted value formats. */
@Service
class EvaluationService extends MetafieldRecords {
static final DateTimeFormatter DATE=DateTimeFormatter.ofPattern("dd.MM.uuuu").withResolverStyle(ResolverStyle.STRICT);
EvaluationService(DatabaseService db,FormAdministration forms,MessageAdministration messages){super(db,forms,messages,15);}
void access(LegacyUser u){u.require(0);u.require(8);}
Map<String,Object> options(LegacyUser u){return fields("headquarters",headquarters(u),"currentHeadquarters",u.headquartersId());}
List<Map<String,Object>> lookup(LegacyUser u,long hq,String kind,String q){checked(u,hq);if(q==null)q="";if(q.length()>100)throw bad("Suchbegriff ist zu lang.");String search="%"+q.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%";
return switch(kind){
case "customer" -> db.queryForList("SELECT cs.cs_id AS id,cs.cs_eid AS value,CONCAT_WS(' · ',cs.cs_eid,cmp.cmp_comp,cmp.cmp_comp2) AS label FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cs.hq_id=? AND (cs.cs_eid LIKE ? OR cmp.cmp_comp LIKE ?) ORDER BY cs.cs_eid LIMIT 100",hq,search,search);
case "courier" -> {u.require(1);yield db.queryForList("SELECT cr.cr_id AS id,cr.cr_eid AS value,CONCAT_WS(' · ',cr.cr_eid,cmp.cmp_comp) AS label FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE cr.hq_id=? AND (cr.cr_eid LIKE ? OR cmp.cmp_comp LIKE ?) ORDER BY cr.cr_eid LIMIT 100",hq,search,search);}
case "vehicle" -> {u.require(1);yield db.queryForList("SELECT cv.crvh_id AS id,cv.crvh_sid AS value,cv.crvh_sid AS label FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id WHERE cr.hq_id=? AND cv.crvh_sid LIKE ? ORDER BY cv.crvh_sid LIMIT 100",hq,search);}
case "vehicleType" -> db.queryForList("SELECT mt_sort AS id,mt_sort AS value,mt_value AS label FROM metatype WHERE mt_type='vehicletype' ORDER BY mt_sort");
case "group" -> {var hqs=headquarters(u).stream().map(r->id(r,"id")).toList();var args=new ArrayList<Object>(List.of(mandator(db,u)));args.addAll(hqs);args.add(search);yield db.queryForList("SELECT grp_id AS id,grp_id AS value,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=0 OR hq_id IN ("+marks(hqs.size())+")) AND grp_name LIKE ? ORDER BY grp_name LIMIT 100",args.toArray());}
case "branch" -> db.queryForList("SELECT br_id AS id,br_id AS value,br_name AS label FROM branch WHERE br_name LIKE ? ORDER BY br_name LIMIT 100",search);
default -> throw bad("Unbekannte Bewertungsauswahl.");
};
}
String validateValue(LegacyUser u,long hq,Map<String,Object> def,String value){String type=text(def,"type"),v=value.strip();if(v.isEmpty())return "";switch(type){
case "date_begin","date_end","date" -> {parseDate(v);return v;}
case "checkboxes_hq" -> {if(!v.matches("[0-9]+(,[0-9]+)*"))throw bad("Ungültige Niederlassungsauswahl.");var allowed=headquarters(u).stream().map(r->Long.toString(id(r,"id"))).toList();var requested=Arrays.stream(v.split(",")).distinct().toList();if(!allowed.containsAll(requested))throw bad("Niederlassung nicht freigegeben.");return String.join(",",requested);}
case "button:cs:cs_eid" -> {one(db.queryForList("SELECT cs_id FROM customer WHERE hq_id=? AND cs_eid=?",hq,v));return v;}
case "button:cr:cr_eid" -> {u.require(1);one(db.queryForList("SELECT cr_id FROM courier WHERE hq_id=? AND cr_eid=?",hq,v));return v;}
case "select:crvh:crvh_sid" -> {u.require(1);if(db.queryForList("SELECT cv.crvh_id FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id WHERE cr.hq_id=? AND cv.crvh_sid=?",hq,v).isEmpty())throw bad("Fahrzeug nicht gefunden.");return v;}
case "select:vht" -> {one(db.queryForList("SELECT mt_sort FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",number(v)));return v;}
case "select:grp" -> {var hqs=headquarters(u).stream().map(r->id(r,"id")).toList();var args=new ArrayList<Object>(List.of(number(v),mandator(db,u)));args.addAll(hqs);one(db.queryForList("SELECT grp_id FROM groups WHERE grp_id=? AND md_id=? AND (hq_id=0 OR hq_id IN ("+marks(hqs.size())+"))",args.toArray()));return v;}
case "select:br" -> {one(db.queryForList("SELECT br_id FROM branch WHERE br_id=?",number(v)));return v;}
default -> throw bad(text(def,"label")+": Nicht unterstützter Bewertungsfeldtyp.");
}}
static long number(String v){try{long n=Long.parseLong(v);if(n<0)throw new NumberFormatException();return n;}catch(NumberFormatException e){throw bad("Ungültiger Auswahlwert.");}}
static LocalDate parseDate(String v){try{return LocalDate.parse(v,DATE);}catch(DateTimeParseException e){throw bad("Ungültiges Datum (TT.MM.JJJJ).");}}
void validateValues(List<Map<String,Object>> defs,Map<Long,String> values){LocalDate begin=null,end=null;for(var d:defs){String v=values.get(id(d,"id"));if(v==null||v.isBlank())continue;if(text(d,"type").equals("date_begin"))begin=parseDate(v);if(text(d,"type").equals("date_end"))end=parseDate(v);}if(begin!=null&&end!=null&&end.isBefore(begin))throw bad("Das Enddatum liegt vor dem Beginn.");}
@Override Map<String,Object> rendering(LegacyUser u,long hq,long object){var result=super.rendering(u,hq,object);var data=new LinkedHashMap<>((Map<String,Object>)result.get("data"));var values=new LinkedHashMap<>((Map<Long,String>)data.get("values"));var defs=(List<Map<String,Object>>)result.get("definitions");for(var def:defs){long field=id(def,"id");String type=text(def,"type"),v=values.getOrDefault(field,"");if(v.isBlank())continue;if(type.equals("checkboxes_hq")){var names=headquarters(u).stream().collect(java.util.stream.Collectors.toMap(r->Long.toString(id(r,"id")),r->text(r,"label")));values.put(field,Arrays.stream(v.split(",")).map(x->names.getOrDefault(x,x)).collect(java.util.stream.Collectors.joining(", ")));}}
data.put("values",values);result.put("data",data);return result;
}
}
@@ -0,0 +1,58 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import java.util.*;
import java.time.*;
import java.nio.charset.StandardCharsets;
import static de.stadtbote.votian.AdminSupport.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Global PHP courierfilter catalog. Durable operations recover MyISAM changes after connection loss. */
@Service
class FilterAdministration {
static final String COLUMNS="crf_id,crf_type,crf_short,crf_text,crf_status,vht_id,crf_sort,crf_longtext";
static final String PREFIX="ng_filter_";
final DatabaseService db;final ObjectMapper json;final String log;
FilterAdministration(DatabaseService db,ObjectMapper json,@Value("${app.history-schema:phoenix_log}")String history){this.db=db;this.json=json;if(!history.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Protokollschema.");log="`"+history+"`.log";}
record Save(String requestId,String revision,int type,String code,String name,int status,int sort,String description){}
record Delete(String requestId,String revision){}
record Operation(String token,String kind,long id,long user,long hq,String at,String beforeRevision,Map<String,Object> before,Map<String,Object> after,boolean cleanup){}
void readAccess(LegacyUser u){u.require(0);}void writeAccess(LegacyUser u){readAccess(u);u.require(32);}
List<Map<String,Object>> rows(){return db.queryForList("SELECT "+COLUMNS+" FROM courierfilter ORDER BY crf_sort,crf_short,crf_id");}
Map<String,Object> options(LegacyUser u){readAccess(u);var all=rows();return fields("manage",u.hasRight(32),"items",all.stream().map(r->fields("id",id(r,"crf_id"),"type",id(r,"crf_type"),"code",text(r,"crf_short"),"name",text(r,"crf_text"),"status",id(r,"crf_status"),"sort",id(r,"crf_sort"),"description",text(r,"crf_longtext"),"revision",revision(r))).toList(),"operations",u.hasRight(32)?db.queryForList("SELECT gdc_id AS id,gdc_obj_id AS filterId,SUBSTRING(gdc_gen_fieldname,11) AS token,gdc_context AS state FROM genericdatacontainer WHERE gdc_obj_type='crf' AND LEFT(gdc_gen_fieldname,10)='ng_filter_' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 100"):List.of());}
Map<String,Object> row(long id){return one(db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_id=?",id));}
static String token(String s){try{return UUID.fromString(s).toString();}catch(Exception e){throw bad("Gültige Vorgangskennung fehlt.");}}
void globalLock(){if(db.lockRows("SELECT hq_id FROM headquarters ORDER BY hq_id").isEmpty())throw bad("Keine Niederlassung für die Verwaltungssperre vorhanden.");}
Map<String,Object> save(LegacyUser u,Long id,Save s){writeAccess(u);if(s==null)throw bad("Filterdaten fehlen.");String token=token(s.requestId());String code=required(s.code(),4,"Kürzel"),name=required(s.name(),50,"Bezeichnung"),description=Objects.requireNonNullElse(s.description(),"");if(!code.matches("[A-Za-z0-9_+-]{1,4}"))throw bad("Kürzel: 1 bis 4 Buchstaben, Ziffern, _, + oder -.");if(s.type()<0||s.type()>1||s.status()<(id==null?1:0)||s.status()>2||s.sort()<-999999||s.sort()>9999999||description.getBytes(StandardCharsets.UTF_8).length>30000)throw bad("Ungültige Filterart, Pflichtauswahl, Sortiernummer oder zu langer Langtext (max. 30000 UTF-8-Bytes).");
var after=fields("crf_type",s.type(),"crf_short",code,"crf_text",name,"crf_status",s.status(),"vht_id",0,"crf_sort",s.sort(),"crf_longtext",description);
String kind=id==null?"CREATE":"UPDATE";
db.writeTransaction(()->{globalLock();var existing=operationRow(token,false);if(existing!=null){Operation op=decode(existing);if(op.user()!=u.userId()||op.id()!=(id==null?0:id)||!op.kind().equals(kind)||!sameDesired(op.after(),after))throw bad("Vorgangskennung wurde bereits für andere Filterdaten verwendet.");return null;}
rejectPending(id,code);Map<String,Object> before=id==null?Map.of():row(id);if(id!=null){unchanged(before,s.revision());if(!text(before,"crf_short").equals(code)||!text(before,"crf_text").equals(name)||id(before,"crf_type")!=s.type()||id(before,"crf_status")!=s.status())throw bad("Bei bestehenden Filtern können Langtext und Sortiernummer geändert werden. Kürzel und Klassifizierung bleiben erhalten.");after.put("vht_id",id(before,"vht_id"));}
else {if(!db.queryForList("SELECT crf_id FROM courierfilter WHERE crf_short=? OR crf_text=?",code,name).isEmpty())throw bad("Kürzel oder Bezeichnung existiert bereits.");if(reserved().stream().anyMatch(v->v.equalsIgnoreCase(code)))throw bad("Das Kürzel ist für eine laufende Löschbereinigung gesperrt.");}
persist(new Operation(token,kind,id==null?0:id,u.userId(),u.headquartersId(),LocalDateTime.now().toString(),s.revision(),before,after,false));return null;});return resume(u,token);
}
void rejectPending(Long id,String code){for(var r:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='crf' AND LEFT(gdc_gen_fieldname,10)='ng_filter_' AND gdc_context='PENDING'")){Operation op=decode(r);String reservedCode=text(op.kind().equals("CREATE")?op.after():op.before(),"crf_short");if((id!=null&&op.id()==id)||reservedCode.equalsIgnoreCase(code))throw bad("Für diesen Filter besteht bereits ein gesicherter Vorgang. Bitte diesen zuerst fortsetzen.");}}
static boolean sameDesired(Map<String,Object> a,Map<String,Object> b){for(String k:List.of("crf_type","crf_short","crf_text","crf_status","crf_sort","crf_longtext"))if(!Objects.toString(a.get(k),"").equals(Objects.toString(b.get(k),"")))return false;return true;}
Map<String,Object> remove(LegacyUser u,long id,Delete d){writeAccess(u);if(d==null)throw bad("Vorgangsdaten fehlen.");String token=token(d.requestId());db.writeTransaction(()->{globalLock();var existing=operationRow(token,false);if(existing!=null){Operation op=decode(existing);if(op.user()!=u.userId()||op.id()!=id||!op.kind().equals("DELETE"))throw bad("Vorgangskennung bereits verwendet.");return null;}var before=row(id);rejectPending(id,text(before,"crf_short"));unchanged(before,d.revision());String code=text(before,"crf_short");if(code.isBlank()||code.contains(","))throw bad("Ungültiges Kürzel im Bestand.");boolean cleanup=db.queryForList("SELECT crf_id FROM courierfilter WHERE crf_short=? AND crf_id<>?",code,id).isEmpty();persist(new Operation(token,"DELETE",id,u.userId(),u.headquartersId(),LocalDateTime.now().toString(),d.revision(),before,Map.of(),cleanup));return null;});return resume(u,token);}
Set<String> reserved(){var rows=db.queryForList("SELECT par_value FROM parameter WHERE par_key='FILTER_TO_BE_DELETED' AND hq_id=0 AND emp_id=0 AND md_id=0");if(rows.size()>1)throw bad("Mehrdeutige Warteschlange für Filterbereinigung.");return rows.isEmpty()?new LinkedHashSet<>():new LinkedHashSet<>(Arrays.stream(text(rows.getFirst(),"par_value").split(",")).filter(v->!v.isBlank()).toList());}
void reserve(String code){var codes=reserved();codes.add(code);String value=","+String.join(",",codes)+",";if(value.getBytes(StandardCharsets.UTF_8).length>65000)throw bad("Warteschlange für Filterbereinigung ist voll.");var rows=db.queryForList("SELECT par_id FROM parameter WHERE par_key='FILTER_TO_BE_DELETED' AND hq_id=0 AND emp_id=0 AND md_id=0");if(rows.isEmpty())db.insert("parameter",fields("par_key","FILTER_TO_BE_DELETED","md_id",0,"hq_id",0,"emp_id",0,"par_value",value,"par_text",""));else db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(rows.getFirst(),"par_id"));}
void persist(Operation op){try{String content=json.writeValueAsString(op);if(content.getBytes(StandardCharsets.UTF_8).length>65000)throw bad("Änderung ist für das Bestandsprotokoll zu groß. Langtext bitte verkürzen.");db.insert("genericdatacontainer",fields("gdc_obj_type","crf","gdc_obj_id",op.id(),"gdc_gen_fieldname",PREFIX+op.token(),"gdc_content",content,"gdc_context","PENDING"));}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException(e);}}
Operation decode(Map<String,Object> row){try{return json.readValue(text(row,"gdc_content"),Operation.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw bad("Gespeicherter Filtervorgang ist beschädigt.");}}
Map<String,Object> operationRow(String token,boolean lock){String sql="SELECT gdc_id,gdc_obj_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='crf' AND gdc_gen_fieldname=?";var rows=lock?db.lockRows(sql,PREFIX+token):db.queryForList(sql,PREFIX+token);if(rows.size()>1)throw bad("Doppelte Vorgangskennung im Bestand.");return rows.isEmpty()?null:rows.getFirst();}
Map<String,Object> resume(LegacyUser u,String token){writeAccess(u);token(token);var saved=operationRow(token,false);if(saved==null)throw bad("Filtervorgang nicht gefunden.");try{return db.writeTransaction(()->{globalLock();var state=operationRow(token,true);if(text(state,"gdc_context").equals("DONE"))return fields("token",token,"state","DONE","message","Filtervorgang abgeschlossen.");Operation op=decode(state);execute(op);db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(state,"gdc_id"));return fields("token",token,"state","DONE","message",op.kind().equals("DELETE")?"Filter entfernt. Tour- und Dauerauftragsvorlagen werden über die PHP-Bereinigungswarteschlange nachgeführt.":"Filter gespeichert.");});}catch(RuntimeException e){return fields("token",token,"state","PENDING","message","Der Vorgang ist gesichert, aber noch nicht vollständig abgeschlossen. Bitte Liste und Vorgangsstatus prüfen und den gespeicherten Vorgang fortsetzen. Keinen zweiten Vorgang anlegen."+(e instanceof org.springframework.web.server.ResponseStatusException r&&r.getReason()!=null?" "+r.getReason():""));}}
void execute(Operation op){long filterId=op.id();if(op.kind().equals("CREATE")){var rows=db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_short=?",text(op.after(),"crf_short"));if(rows.isEmpty()){db.update("INSERT INTO courierfilter (crf_type,crf_short,crf_text,crf_status,vht_id,crf_sort,crf_longtext) SELECT ?,?,?,?,0,?,? WHERE NOT EXISTS (SELECT 1 FROM courierfilter WHERE crf_short=? OR crf_text=?)",id(op.after(),"crf_type"),text(op.after(),"crf_short"),text(op.after(),"crf_text"),id(op.after(),"crf_status"),id(op.after(),"crf_sort"),text(op.after(),"crf_longtext"),text(op.after(),"crf_short"),text(op.after(),"crf_text"));var created=one(db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_short=?",text(op.after(),"crf_short")));if(!sameDesired(created,op.after()))throw bad("Filterkürzel inzwischen anderweitig belegt.");filterId=id(created,"crf_id");}else {if(rows.size()!=1||!sameDesired(rows.getFirst(),op.after()))throw bad("Filterkürzel inzwischen anderweitig belegt.");filterId=id(rows.getFirst(),"crf_id");}}
else if(op.kind().equals("UPDATE")){var current=row(op.id());if(!sameDesired(current,op.after())){unchanged(current,op.beforeRevision());var args=new ArrayList<Object>(List.of(text(op.after(),"crf_longtext"),id(op.after(),"crf_sort")));args.addAll(compareArgs(op.before()));if(db.update("UPDATE courierfilter SET crf_longtext=?,crf_sort=? WHERE "+compareClause(),args.toArray())!=1)throw bad("Filter wurde zwischenzeitlich geändert.");}}
else if(op.kind().equals("DELETE")){var current=db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_id=?",op.id());if(!current.isEmpty()){unchanged(one(current),op.beforeRevision());if(db.update("DELETE FROM courierfilter WHERE "+compareClause(),compareArgs(op.before()).toArray())!=1)throw bad("Filter wurde zwischenzeitlich geändert.");}if(op.cleanup()){reserve(text(op.before(),"crf_short"));String needle=","+text(op.before(),"crf_short")+",";
db.update("UPDATE customer c JOIN company p ON p.cmp_id=c.cmp_id SET c.cs_filter=CASE WHEN REPLACE(c.cs_filter,?,',')=',' THEN '' ELSE REPLACE(c.cs_filter,?,',') END,c.cs_modify=c.cs_modify WHERE p.cmp_archived=0 AND LOCATE(?,c.cs_filter)>0",needle,needle,needle);
db.update("UPDATE courier c JOIN company p ON p.cmp_id=c.cmp_id SET c.cr_filter=CASE WHEN REPLACE(c.cr_filter,?,',')=',' THEN '' ELSE REPLACE(c.cr_filter,?,',') END,c.cr_modify=c.cr_modify WHERE p.cmp_archived=0 AND LOCATE(?,c.cr_filter)>0",needle,needle,needle);
db.update("UPDATE couriervehicle SET crvh_filter=CASE WHEN REPLACE(crvh_filter,?,',')=',' THEN '' ELSE REPLACE(crvh_filter,?,',') END,crvh_modify=crvh_modify WHERE LOCATE(?,crvh_filter)>0",needle,needle,needle);}}
else throw bad("Unbekannter Filtervorgang.");
String description="crf_id="+filterId+"|OP="+op.kind()+"|NG_FILTER_REQUEST="+op.token();int operation=op.kind().equals("CREATE")?168:op.kind().equals("DELETE")?169:text(op.before(),"crf_longtext").equals(text(op.after(),"crf_longtext"))?171:170;
if(db.queryForList("SELECT log_id FROM "+log+" WHERE logo_id=? AND logo_description=? LIMIT 1",operation,description).isEmpty())db.update("INSERT INTO "+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,0,0,0,?,?)",operation,op.hq(),op.user(),description,LocalDateTime.parse(op.at()));
}
static String compareClause(){return "crf_id=? AND crf_type=? AND BINARY crf_short=BINARY ? AND BINARY crf_text=BINARY ? AND crf_status=? AND vht_id=? AND crf_sort=? AND BINARY crf_longtext=BINARY ?";}
static List<Object> compareArgs(Map<String,Object> r){return List.of(id(r,"crf_id"),id(r,"crf_type"),text(r,"crf_short"),text(r,"crf_text"),id(r,"crf_status"),id(r,"vht_id"),id(r,"crf_sort"),text(r,"crf_longtext"));}
}
@@ -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/filters")
class FilterController {
final FilterAdministration service;
FilterController(FilterAdministration service){this.service=service;}
@GetMapping Map<String,Object> options(Authentication a){return service.options(user(a));}
@PostMapping Map<String,Object> create(Authentication a,@RequestBody FilterAdministration.Save s){return service.save(user(a),null,s);}
@PutMapping("/{id}") Map<String,Object> save(Authentication a,@PathVariable long id,@RequestBody FilterAdministration.Save s){return service.save(user(a),id,s);}
@DeleteMapping("/{id}") Map<String,Object> remove(Authentication a,@PathVariable long id,@RequestBody FilterAdministration.Delete s){return service.remove(user(a),id,s);}
@PostMapping("/operations/{token}/resume") Map<String,Object> resume(Authentication a,@PathVariable String token){return service.resume(user(a),token);}
}
@@ -0,0 +1,56 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.util.*;
import static de.stadtbote.votian.AdminSupport.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Explicit allow-listed metadata operations, corresponding to metafield_editor/template.php. */
@Service
class FormAdministration {
final DatabaseService db;
FormAdministration(DatabaseService db){this.db=db;}
record CategorySave(String revision,String mnemonic,String description,long template,List<Long> groups){}
record FieldSave(String revision,String name,String type){}
record TemplateSave(String revision,String name,String content,List<Long> groups){}
record BindingSave(String revision,List<Long> keys){}
static final String CATEGORY="SELECT mtfc_id,mtfc_mnemonic,mtfc_description,mtfc_group,mtft_id FROM metafieldcategory";
static final String KEY="SELECT mtfk_id,mtfk_name,mtfk_type FROM metafieldkey";
static final String TEMPLATE="SELECT mtft_id,mtfc_id,mtft_name,mtft_group,mtft_content FROM metafieldtemplate";
static final String BINDING="SELECT ck.mtfck_id AS id,ck.mtfk_id AS keyId,ck.mtfck_sort AS sort,ck.mtfck_history AS history,k.mtfk_name AS name,k.mtfk_type AS type FROM metafieldcategorykey ck JOIN metafieldkey k ON k.mtfk_id=ck.mtfk_id WHERE ck.mtfc_id=? AND ck.hq_id=? ORDER BY ck.mtfck_sort,ck.mtfck_id";
List<Map<String,Object>> groups(LegacyUser u){return db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0) ORDER BY grp_name",mandator(db,u),u.headquartersId());}
Map<String,Object> category(Map<String,Object> r){return fields("id",id(r,"mtfc_id"),"mnemonic",text(r,"mtfc_mnemonic"),"description",text(r,"mtfc_description"),"template",id(r,"mtft_id"),"groups",GroupAdministration.memberships(text(r,"mtfc_group")),"revision",revision(r));}
Map<String,Object> key(Map<String,Object> r){return fields("id",id(r,"mtfk_id"),"name",text(r,"mtfk_name"),"type",text(r,"mtfk_type"),"revision",revision(r));}
Map<String,Object> template(Map<String,Object> r,boolean content){var m=fields("id",id(r,"mtft_id"),"name",text(r,"mtft_name"),"groups",GroupAdministration.memberships(text(r,"mtft_group")),"revision",revision(r));if(content)m.put("content",text(r,"mtft_content"));return m;}
Map<String,Object> catalog(LegacyUser u){u.require(15);return db.readTransaction(()->fields("categories",db.queryForList(CATEGORY+" ORDER BY mtfc_description,mtfc_id").stream().map(this::category).toList(),"fields",db.queryForList(KEY+" ORDER BY mtfk_name,mtfk_id").stream().map(this::key).toList(),"templates",db.queryForList(TEMPLATE+" ORDER BY mtft_name,mtft_id").stream().map(r->template(r,false)).toList(),"groups",groups(u),"headquartersId",u.headquartersId(),"types",LegacyFormTypes.MAPPED.stream().sorted().toList()));}
Map<String,Object> template(LegacyUser u,long id){u.require(15);return template(one(db.queryForList(TEMPLATE+" WHERE mtft_id=?",id)),true);}
private String groupsValue(LegacyUser u,List<Long> requested,String current){if(requested==null||requested.size()>100||requested.stream().anyMatch(Objects::isNull))throw bad("Ungültige Gruppenauswahl.");var allowed=groups(u).stream().map(r->id(r,"id")).toList();var old=GroupAdministration.memberships(current);for(long g:requested)if(!allowed.contains(g)&&!old.contains(g))throw bad("Gruppe nicht freigegeben.");var next=new TreeSet<Long>(requested);for(long g:old)if(!allowed.contains(g))next.add(g);String value=MessageAdministration.comma(next);if(value.length()>255)throw bad("Zu viele Gruppen.");return value;}
Map<String,Object> saveCategory(LegacyUser u,long cid,CategorySave s){u.require(15);if(s==null)throw bad("Formulardaten fehlen.");String mnemonic=required(s.mnemonic(),30,"Kürzel"),description=required(s.description(),1000,"Bezeichnung");if(s.template()<0)throw bad("Ungültige Vorlage.");return db.writeTransaction(()->{
// These definitions are global. Lock the complete small metadata index to serialize names across branches.
var all=db.lockRows(CATEGORY+" ORDER BY mtfc_id");var before=cid==0?Map.<String,Object>of():one(all.stream().filter(r->id(r,"mtfc_id")==cid).toList());if(cid>0)unchanged(before,s.revision());
if(!db.queryForList("SELECT mtfc_id FROM metafieldcategory WHERE mtfc_id<>? AND ((?=1 AND mtfc_mnemonic=?) OR (?=1 AND mtfc_description=?))",cid,cid==0||!mnemonic.equals(text(before,"mtfc_mnemonic"))?1:0,mnemonic,cid==0||!description.equals(text(before,"mtfc_description"))?1:0,description).isEmpty())throw bad("Kürzel oder Bezeichnung ist bereits vergeben.");if(s.template()>0)one(db.queryForList("SELECT mtft_id FROM metafieldtemplate WHERE mtft_id=?",s.template()));String memberships=groupsValue(u,s.groups(),text(before,"mtfc_group"));
long saved=cid;if(cid==0)saved=db.insert("metafieldcategory",fields("mtfc_mnemonic",mnemonic,"mtfc_description",description,"mtfc_group",memberships,"mtft_id",s.template()));else db.update("UPDATE metafieldcategory SET mtfc_mnemonic=?,mtfc_description=?,mtfc_group=?,mtft_id=? WHERE mtfc_id=?",mnemonic,description,memberships,s.template(),cid);return fields("id",saved);
});}
Map<String,Object> saveField(LegacyUser u,long fid,FieldSave s){u.require(15);if(s==null)throw bad("Felddaten fehlen.");String name=required(s.name(),100,"Feldname"),type=required(s.type(),50,"Feldtyp");return db.writeTransaction(()->{
var all=db.lockRows(KEY+" ORDER BY mtfk_id");var before=fid==0?Map.<String,Object>of():one(all.stream().filter(r->id(r,"mtfk_id")==fid).toList());if(fid>0)unchanged(before,s.revision());
if((fid==0||!name.equals(text(before,"mtfk_name")))&&!db.queryForList("SELECT mtfk_id FROM metafieldkey WHERE mtfk_id<>? AND mtfk_name=?",fid,name).isEmpty())throw bad("Dieser Feldname ist bereits vergeben.");
boolean known=all.stream().anyMatch(r->text(r,"mtfk_type").equals(type));
if(type.startsWith("ref:")&&!(fid>0&&type.equals(text(before,"mtfk_type"))&&!type.matches("ref:[1-9][0-9]{0,9}(?::[1-9][0-9]{0,9})*"))){if(!type.matches("ref:[1-9][0-9]{0,9}(?::[1-9][0-9]{0,9})*"))throw bad("Kombinierte Felder als ref:ID:ID angeben.");var references=Arrays.stream(type.substring(4).split(":")).map(Long::parseLong).toList();var available=all.stream().map(r->id(r,"mtfk_id")).toList();if(!available.containsAll(references)||references.contains(fid))throw bad("Ungültige Feldreferenz.");var graph=new HashMap<Long,List<Long>>();for(var r:all){String t=text(r,"mtfk_type");if(t.matches("ref:[1-9][0-9]{0,9}(?::[1-9][0-9]{0,9})*"))graph.put(id(r,"mtfk_id"),Arrays.stream(t.substring(4).split(":")).map(Long::parseLong).toList());}if(fid>0){graph.put(fid,references);checkCycle(fid,graph,new HashSet<>(),new HashSet<>());}known=true;}
if(!known&&!LegacyFormTypes.MAPPED.contains(type)&&!Set.of("varchar","text","checkbox","date","date_begin","date_end","datetime","time","birthdate","number","author","co2cs","image").contains(type))throw bad("Bitte einen vorhandenen Feldtyp auswählen.");
long saved=fid;if(fid==0)saved=db.insert("metafieldkey",fields("mtfk_name",name,"mtfk_type",type));else db.update("UPDATE metafieldkey SET mtfk_name=?,mtfk_type=? WHERE mtfk_id=?",name,type,fid);return fields("id",saved);
});}
private static void checkCycle(long id,Map<Long,List<Long>> graph,Set<Long> path,Set<Long> done){if(done.contains(id))return;if(!path.add(id))throw bad("Zyklische Feldreferenzen sind nicht erlaubt.");for(long child:graph.getOrDefault(id,List.of()))checkCycle(child,graph,path,done);path.remove(id);done.add(id);}
Map<String,Object> saveTemplate(LegacyUser u,long tid,TemplateSave s){u.require(15);if(s==null)throw bad("Vorlagendaten fehlen.");String name=required(s.name(),50,"Vorlagenname");String content=s.content()==null?"":s.content();if(content.getBytes(StandardCharsets.UTF_8).length>65535)throw bad("Die Vorlage überschreitet 65.535 Bytes.");return db.writeTransaction(()->{
var all=db.lockRows(TEMPLATE+" ORDER BY mtft_id");var before=tid==0?Map.<String,Object>of():one(all.stream().filter(r->id(r,"mtft_id")==tid).toList());if(tid>0)unchanged(before,s.revision());if((tid==0||!name.equals(text(before,"mtft_name")))&&!db.queryForList("SELECT mtft_id FROM metafieldtemplate WHERE mtft_id<>? AND mtft_name=?",tid,name).isEmpty())throw bad("Vorlagenname ist bereits vergeben.");String memberships=groupsValue(u,s.groups(),text(before,"mtft_group"));long saved=tid;if(tid==0)saved=db.insert("metafieldtemplate",fields("mtft_name",name,"mtft_group",memberships,"mtft_content",content));else db.update("UPDATE metafieldtemplate SET mtft_name=?,mtft_group=?,mtft_content=? WHERE mtft_id=?",name,memberships,content,tid);return fields("id",saved);
});}
long fieldHeadquarters(LegacyUser u,long category){String specific=parameter("SYSTEM_FORM_SINGLE_HQ_"+category),general=parameter("SYSTEM_FORM_SINGLE_HQ");return (!specific.isBlank()&&!specific.equals("0"))||(!general.isBlank()&&!general.equals("0"))?u.headquartersId():0;}
private String parameter(String key){var p=db.queryForList("SELECT par_value FROM parameter WHERE hq_id=0 AND emp_id=0 AND par_key=?",key);if(p.size()>1)throw bad("Mehrdeutige Formularzuordnung.");return p.isEmpty()?"":text(p.getFirst(),"par_value");}
Map<String,Object> bindings(LegacyUser u,long category){u.require(15);var form=one(db.queryForList(CATEGORY+" WHERE mtfc_id=?",category));long hq=fieldHeadquarters(u,category);var rows=db.queryForList(BINDING,category,hq);return fields("category",category(form),"headquarters",hq,"fields",rows,"revision",revision(Arrays.asList(hq,rows)));}
Map<String,Object> saveBindings(LegacyUser u,long category,BindingSave s){u.require(15);if(s==null||s.keys()==null||s.keys().size()>1000||s.keys().stream().anyMatch(k->k==null||k<=0)||new HashSet<>(s.keys()).size()!=s.keys().size())throw bad("Feldzuordnung ist ungültig oder enthält doppelte Felder.");return db.writeTransaction(()->{
one(db.lockRows(CATEGORY+" WHERE mtfc_id=?",category));long hq=fieldHeadquarters(u,category);db.lockRows("SELECT mtfck_id FROM metafieldcategorykey WHERE mtfc_id=? AND hq_id=? ORDER BY mtfck_id",category,hq);var current=db.queryForList(BINDING,category,hq);unchanged(Arrays.asList(hq,current),s.revision());
if(!s.keys().containsAll(current.stream().map(r->id(r,"keyId")).toList()))throw bad("Bestehende Felder dürfen nicht entfernt werden; ihre gespeicherten Werte bleiben erhalten.");if(current.stream().map(r->id(r,"keyId")).distinct().count()!=current.size())throw bad("Doppelte Felder im Bestand. Bitte die Zuordnung zuerst bereinigen.");var keys=db.queryForList("SELECT mtfk_id FROM metafieldkey").stream().map(r->id(r,"mtfk_id")).toList();if(!keys.containsAll(s.keys()))throw bad("Unbekanntes Feld.");
for(int i=0;i<s.keys().size();i++){long key=s.keys().get(i);var existing=current.stream().filter(r->id(r,"keyId")==key).findFirst();if(existing.isPresent())db.update("UPDATE metafieldcategorykey SET mtfck_sort=? WHERE mtfck_id=? AND hq_id=? AND mtfc_id=?",i+1,id(existing.get(),"id"),hq,category);else db.insert("metafieldcategorykey",fields("hq_id",hq,"mtfc_id",category,"mtfk_id",key,"mtfck_sort",i+1,"mtfck_history",0));}return fields("saved",true);
});}
}
@@ -0,0 +1,21 @@
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/forms")
class FormController {
final FormAdministration service;
FormController(FormAdministration service){this.service=service;}
@GetMapping Map<String,Object> catalog(Authentication a){return service.catalog(user(a));}
@PostMapping("/categories") Map<String,Object> category(Authentication a,@RequestBody FormAdministration.CategorySave s){return service.saveCategory(user(a),0,s);}
@PutMapping("/categories/{id}") Map<String,Object> category(Authentication a,@PathVariable long id,@RequestBody FormAdministration.CategorySave s){return service.saveCategory(user(a),id,s);}
@PostMapping("/fields") Map<String,Object> field(Authentication a,@RequestBody FormAdministration.FieldSave s){return service.saveField(user(a),0,s);}
@PutMapping("/fields/{id}") Map<String,Object> field(Authentication a,@PathVariable long id,@RequestBody FormAdministration.FieldSave s){return service.saveField(user(a),id,s);}
@GetMapping("/templates/{id}") Map<String,Object> template(Authentication a,@PathVariable long id){return service.template(user(a),id);}
@PostMapping("/templates") Map<String,Object> template(Authentication a,@RequestBody FormAdministration.TemplateSave s){return service.saveTemplate(user(a),0,s);}
@PutMapping("/templates/{id}") Map<String,Object> template(Authentication a,@PathVariable long id,@RequestBody FormAdministration.TemplateSave s){return service.saveTemplate(user(a),id,s);}
@GetMapping("/categories/{id}/fields") Map<String,Object> bindings(Authentication a,@PathVariable long id){return service.bindings(user(a),id);}
@PutMapping("/categories/{id}/fields") Map<String,Object> bindings(Authentication a,@PathVariable long id,@RequestBody FormAdministration.BindingSave s){return service.saveBindings(user(a),id,s);}
}
@@ -0,0 +1,134 @@
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.*;
import static de.stadtbote.votian.OrderEntryService.fields;
@Service
public class GroupAdministration {
private final DatabaseService db;
public GroupAdministration(DatabaseService db){this.db=db;}
record GroupSave(String revision,String name,boolean shared) {}
record MembershipSave(String revision,boolean member) {}
// Every identifier is a code constant. No client-controlled table or column names.
enum Kind {
CUSTOMER("Kunden","customer","cs","JOIN company c ON c.cmp_id=o.cmp_id","CONCAT_WS(' ',o.cs_eid,c.cmp_comp,c.cmp_comp2)","o.hq_id",0),
COURIER("Transporteure","courier","cr","JOIN user u ON u.usr_id=o.usr_id","CONCAT_WS(' ',o.cr_eid,u.usr_firstname,u.usr_name)","o.hq_id",1),
VEHICLE("Fahrzeuge","couriervehicle","crvh","JOIN courier cr ON cr.cr_id=o.cr_id","CONCAT_WS(' ',o.crvh_sid,o.crvh_vh_sign,o.crvh_vh_name)","cr.hq_id",1),
EMPLOYEE("Mitarbeiter","employee","emp","JOIN user u ON u.usr_id=o.usr_id","CONCAT_WS(' ',u.usr_firstname,u.usr_name)","u.hq_id",3),
ARTICLE("Artikel","article","at","","CONCAT_WS(' ',o.at_eid,o.at_name)","o.hq_id",14),
SERVICE("Services","service","srv","","o.srv_name","o.hq_id",2),
SERVICE_TYPE("Servicetypen","servicetype","srvt","","o.srvt_name","o.hq_id",2),
GROUP("Gruppen","groups","grp","","o.grp_name","o.hq_id",25),
BRANCH("Branchen","branch","br","","CONCAT_WS(' ',o.br_key,o.br_name)",null,10),
FORM("Formulare","metafieldcategory","mtfc","","o.mtfc_mnemonic",null,15),
TEMPLATE("Vorlagen","metafieldtemplate","mtft","","o.mtft_name",null,15);
final String label,table,prefix,joins,labelSql,hq;final int right;
Kind(String label,String table,String prefix,String joins,String labelSql,String hq,int right){this.label=label;this.table=table;this.prefix=prefix;this.joins=joins;this.labelSql=labelSql;this.hq=hq;this.right=right;}
String id(){return prefix+"_id";}String group(){return prefix+"_group";}
}
void access(LegacyUser user){user.require(8);user.require(25);}
public List<Map<String,Object>> headquarters(LegacyUser user) {
access(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 WHERE m.md_id=?"+(user.hasRight(10)?"":" AND h.hq_id=?")+" ORDER BY label",user.hasRight(10)?new Object[]{mandator(db,user)}:new Object[]{mandator(db,user),user.headquartersId()});
}
LegacyUser scoped(LegacyUser user,Long headquarters) {
access(user);
if(headquarters==null||headquarters==user.headquartersId())return user;
user.require(10);
if(headquarters(user).stream().noneMatch(h->id(h,"id")==headquarters))throw new AccessDeniedException("Niederlassung gehört nicht zum freigegebenen Mandanten.");
return new LegacyUser(user.userId(),user.employeeId(),headquarters,user.username(),user.rights(),user.originalHeadquartersId());
}
void rejectDeletion(long id) {
if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='nggrpdel' AND gdc_obj_id=? AND gdc_context='PENDING'",id).isEmpty())throw bad("Die Gruppe wird gerade gelöscht. Bitte den gespeicherten Löschvorgang fortsetzen.");
}
private List<Map<String,Object>> rows(LegacyUser user) {
return db.queryForList("SELECT grp_id,md_id,hq_id,grp_name,grp_readonly,grp_system,grp_group FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0) ORDER BY grp_name,grp_id",mandator(db,user),user.headquartersId());
}
public List<Map<String,Object>> list(LegacyUser user) {
access(user);return db.readTransaction(()->rows(user).stream().map(r->view(user,r)).toList());
}
private Map<String,Object> view(LegacyUser user,Map<String,Object> r) {
return fields("id",id(r,"grp_id"),"name",text(r,"grp_name"),"headquartersId",id(r,"hq_id"),"shared",id(r,"hq_id")==0,"readOnly",id(r,"grp_readonly")!=0 || id(r,"grp_system")!=0 || (id(r,"hq_id")==0&&!user.hasRight(10)),"revision",revision(r));
}
Map<String,Object> group(LegacyUser user,long groupId,boolean write) {
String sql="SELECT grp_id,md_id,hq_id,grp_name,grp_readonly,grp_system,grp_group FROM groups WHERE grp_id=? AND md_id=? AND (hq_id=? OR hq_id=0)";
Object[] args={groupId,mandator(db,user),user.headquartersId()};
var r=one(write?db.lockRows(sql,args):db.queryForList(sql,args));
if(write && (id(r,"grp_readonly")!=0 || id(r,"grp_system")!=0))throw bad("Diese Systemgruppe ist gegen Änderungen geschützt.");
if(write && id(r,"hq_id")==0)user.require(10);
return r;
}
public Map<String,Object> save(LegacyUser user,long groupId,GroupSave request) {
access(user);String name=required(request.name(),100,"Gruppenbezeichnung");
if(request.shared())user.require(10);
return db.writeTransaction(()->{
long md=mandator(db,user);
// Serialize names within the mandator, not merely within one branch.
one(db.lockRows("SELECT md_id FROM mandator WHERE md_id=?",md));
if(groupId>0){rejectDeletion(groupId);unchanged(group(user,groupId,true),request.revision());}
if(!db.queryForList("SELECT grp_id FROM groups WHERE md_id=? AND grp_name=? AND grp_id<>?",md,name,groupId).isEmpty())throw bad("Diese Gruppenbezeichnung existiert bereits im Mandanten.");
long id=groupId;
if(id==0)id=db.insert("groups",fields("md_id",md,"hq_id",request.shared()?0:user.headquartersId(),"grp_name",name));
else db.update("UPDATE groups SET grp_name=?,hq_id=? WHERE grp_id=? AND md_id=?",name,request.shared()?0:user.headquartersId(),id,md);
return view(user,group(user,id,false));
});
}
public Map<String,Object> members(LegacyUser user,long groupId,Kind kind,String search,boolean onlyMembers) {
access(user);user.require(kind.right);var g=group(user,groupId,false);
var args=new ArrayList<Object>();String where=scope(user,kind,args);
if(onlyMembers){where+=" AND o."+kind.group()+" LIKE ?";args.add("%,"+groupId+",%");}
if(search!=null&&!search.isBlank()){if(search.length()>100)throw bad("Suchtext zu lang.");where+=" AND "+kind.labelSql+" LIKE ?";args.add("%"+search.strip()+"%");}
String sql="SELECT o."+kind.id()+" AS id,"+kind.labelSql+" AS label,o."+kind.group()+" AS memberships FROM "+kind.table+" o "+kind.joins+" WHERE "+where+" ORDER BY label,id LIMIT 501";
var data=db.queryForList(sql,args.toArray());
boolean truncated=data.size()>500;
return fields("group",view(user,g),"kinds",Arrays.stream(Kind.values()).filter(k->user.hasRight(k.right)&&(k.hq!=null||user.hasRight(10))).map(k->fields("id",k.name(),"label",k.label)).toList(),"truncated",truncated,
"items",data.stream().limit(500).map(r->fields("id",id(r,"id"),"label",text(r,"label"),"member",memberships(text(r,"memberships")).contains(groupId),"revision",revision(Arrays.asList(id(r,"id"),value(r,"memberships"))))).toList());
}
private String scope(LegacyUser user,Kind kind,List<Object> args) {
if(kind.hq==null){user.require(10);return "1=1";}
if(kind==Kind.GROUP){args.add(mandator(db,user));args.add(user.headquartersId());return "o.md_id=? AND (o.hq_id=? OR o.hq_id=0)";}
args.add(user.headquartersId());return kind.hq+"=?";
}
public void membership(LegacyUser user,long groupId,Kind kind,long objectId,MembershipSave request) {
access(user);user.require(kind.right);
db.writeTransaction(()->{
long md=mandator(db,user);one(db.lockRows("SELECT md_id FROM mandator WHERE md_id=?",md));if(request.member())rejectDeletion(groupId);group(user,groupId,true);
var args=new ArrayList<Object>();String where=scope(user,kind,args);args.add(objectId);
var object=one(db.queryForList("SELECT o."+kind.id()+" AS id,o."+kind.group()+" AS memberships FROM "+kind.table+" o "+kind.joins+" WHERE "+where+" AND o."+kind.id()+"=?",args.toArray()));
unchanged(Arrays.asList(objectId,value(object,"memberships")),request.revision());
if(kind==Kind.GROUP) {
group(user,objectId,true);
if(request.member())checkCycle(md,objectId,groupId);
}
var ids=memberships(text(object,"memberships"));if(request.member())ids.add(groupId);else ids.remove(groupId);
String newValue=ids.isEmpty()?"":","+String.join(",",ids.stream().map(String::valueOf).toList())+",";
if(newValue.length()>255)throw bad("Zu viele Gruppenzuordnungen für diesen Datensatz.");
if(db.update("UPDATE "+kind.table+" SET "+kind.group()+"=? WHERE "+kind.id()+"=? AND "+kind.group()+" <=> ?",newValue,objectId,value(object,"memberships"))!=1)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Gruppenzuordnung wurde zwischenzeitlich geändert.");
return null;
});
}
static LinkedHashSet<Long> memberships(String value) {
var ids=new LinkedHashSet<Long>();for(String part:value.split(","))if(part.strip().matches("[0-9]{1,10}")){long id=Long.parseLong(part.strip());if(id>0)ids.add(id);}return ids;
}
private void checkCycle(long md,long child,long parent) {
var all=db.queryForList("SELECT grp_id,grp_group FROM groups WHERE md_id=?",md);
var graph=new HashMap<Long,Set<Long>>();for(var r:all)graph.put(id(r,"grp_id"),memberships(text(r,"grp_group")));
var pending=new ArrayDeque<Long>();var visited=new HashSet<Long>();pending.add(parent);
while(!pending.isEmpty()){long current=pending.removeFirst();if(current==child)throw bad("Eine Gruppe darf weder direkt noch indirekt Mitglied von sich selbst werden.");if(visited.add(current))pending.addAll(graph.getOrDefault(current,Set.of()));}
}
public void remove(LegacyUser user,long groupId,String expected) {
access(user);
db.writeTransaction(()->{
long md=mandator(db,user);one(db.lockRows("SELECT md_id FROM mandator WHERE md_id=?",md));unchanged(group(user,groupId,true),expected);
// Require explicit removal of memberships first. This avoids silently changing rights,
// and makes deletion atomic even when legacy branch records use MyISAM.
for(Kind k:Kind.values())if(!db.queryForList("SELECT "+k.id()+" FROM "+k.table+" WHERE "+k.group()+" LIKE ? LIMIT 1","%,"+groupId+",%").isEmpty())throw bad("Die Gruppe hat noch Mitglieder. Bitte die Zuordnungen vor dem Löschen entfernen.");
db.update("DELETE FROM groups WHERE grp_id=? AND md_id=?",groupId,md);return null;
});
}
}
@@ -0,0 +1,107 @@
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;
/** A durable, reviewed deletion plan: branch memberships can live in MyISAM. */
@Service
class GroupDeletion {
final DatabaseService db; final GroupAdministration groups; final ObjectMapper json; final BusinessSupport support;
GroupDeletion(DatabaseService db,GroupAdministration groups,ObjectMapper json,BusinessSupport support){this.db=db;this.groups=groups;this.json=json;this.support=support;}
record Member(String kind,long id,String before,String after) {}
record Plan(long id,long mandator,long headquarters,long user,String groupRevision,String name,List<Member> members) {}
record Delete(String requestId,String revision) {}
static final String PREFIX="remove_";
List<Member> members(LegacyUser user,long groupId) {
var result=new ArrayList<Member>();var allowed=groups.headquarters(user).stream().map(h->id(h,"id")).toList();
for(var k:GroupAdministration.Kind.values()) {
String alias="member_"+k.prefix;String hq=k.hq==null?"0":k.hq.replace("o.",alias+".");
String joins=k==GroupAdministration.Kind.VEHICLE?"JOIN courier cr ON cr.cr_id="+alias+".cr_id":k==GroupAdministration.Kind.EMPLOYEE?"JOIN user u ON u.usr_id="+alias+".usr_id":"";
var rows=db.queryForList("SELECT "+alias+"."+k.id()+" AS id,"+alias+"."+k.group()+" AS memberships,"+hq+" AS headquarters"+(k==GroupAdministration.Kind.GROUP?","+alias+".md_id,"+alias+".grp_readonly,"+alias+".grp_system":"")+" FROM "+k.table+" "+alias+" "+joins+" WHERE FIND_IN_SET(CAST(? AS CHAR),TRIM(BOTH ',' FROM COALESCE("+alias+"."+k.group()+",'')))>0 ORDER BY "+alias+"."+k.id(),groupId);
for(var r:rows) {
user.require(k.right);if(k.hq==null)user.require(10);
if(k.hq!=null&&id(r,"headquarters")!=0&&!allowed.contains(id(r,"headquarters")))throw bad("Die Gruppe besitzt Zuordnungen außerhalb Ihrer freigegebenen Niederlassungen.");
if(k==GroupAdministration.Kind.GROUP&&(id(r,"md_id")!=mandator(db,user)||id(r,"grp_readonly")!=0||id(r,"grp_system")!=0))throw bad("Eine geschützte oder fremde Gruppe verweist auf diese Gruppe.");
String before=text(r,"memberships");result.add(new Member(k.name(),id(r,"id"),before,without(before,groupId)));
}
}
// Message groups use an unpadded comma list, unlike ordinary memberships.
for(var r:db.queryForList("SELECT msggrp_id AS id,msggrp_group AS memberships,hq_id AS headquarters FROM messagegroup WHERE FIND_IN_SET(CAST(? AS CHAR),TRIM(BOTH ',' FROM COALESCE(msggrp_group,'')))>0 ORDER BY msggrp_id",groupId)) {
user.require(52);if(!allowed.contains(id(r,"headquarters")))throw bad("Eine Nachrichtengruppe einer nicht freigegebenen Niederlassung verweist auf die Gruppe.");
String before=text(r,"memberships");result.add(new Member("MESSAGE_GROUP",id(r,"id"),before,without(before,groupId)));
}
if(result.size()>20000)throw bad("Mehr als 20000 Zuordnungen. Bitte die Gruppe zunächst über die Mitgliederpflege verkleinern.");
return result;
}
static String without(String before,long id) {
var parts=new ArrayList<>(Arrays.asList(before.split(",",-1)));
parts.removeIf(p->p.strip().equals(Long.toString(id)));
String after=String.join(",",parts);return after.replace(",","").isBlank()?"":after;
}
Plan plan(LegacyUser user,long id) {
groups.access(user);var group=groups.group(user,id,true);
return new Plan(id,mandator(db,user),user.headquartersId(),user.userId(),revision(group),text(group,"grp_name"),members(user,id));
}
Map<String,Object> preview(LegacyUser user,long id) {
groups.access(user);return db.readTransaction(()->{
// Preview must not request a database write lock.
var group=groups.group(user,id,false);
if(id(group,"grp_readonly")!=0||id(group,"grp_system")!=0)throw bad("Diese Systemgruppe ist gegen Änderungen geschützt.");
if(id(group,"hq_id")==0)user.require(10);
var p=new Plan(id,mandator(db,user),user.headquartersId(),user.userId(),revision(group),text(group,"grp_name"),members(user,id));
var counts=new LinkedHashMap<String,Integer>();p.members().forEach(m->counts.merge(m.kind(),1,Integer::sum));
return fields("revision",revision(p),"total",p.members().size(),"counts",counts.entrySet().stream().map(e->fields("label",e.getKey().equals("MESSAGE_GROUP")?"Nachrichtengruppen":GroupAdministration.Kind.valueOf(e.getKey()).label,"count",e.getValue())).toList());
});
}
Map<String,Object> operation(String token,boolean lock) {
String sql="SELECT gdc_id,gdc_obj_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='nggrpdel' AND gdc_gen_fieldname=?";
var rows=lock?db.lockRows(sql,PREFIX+token):db.queryForList(sql,PREFIX+token);
return rows.isEmpty()?null:one(rows);
}
Plan decode(Map<String,Object> row) {try{return json.readValue(text(row,"gdc_content"),Plan.class);}catch(Exception e){throw bad("Löschvorgang ist nicht lesbar.");}}
void owner(LegacyUser user,Plan p) {
groups.access(user);if(p.user()!=user.userId()||p.mandator()!=mandator(db,user)||p.headquarters()!=user.headquartersId())throw bad("Der Löschvorgang gehört zu einem anderen Benutzer oder Niederlassungsbereich.");
}
List<Map<String,Object>> pending(LegacyUser user) {
groups.access(user);return db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='nggrpdel' AND gdc_context='PENDING' ORDER BY gdc_id").stream().filter(r->{var p=decode(r);return p.user()==user.userId()&&p.headquarters()==user.headquartersId()&&p.mandator()==mandator(db,user);}).<Map<String,Object>>map(r->fields("token",text(r,"gdc_gen_fieldname").substring(PREFIX.length()),"name",decode(r).name())).toList();
}
Map<String,Object> remove(LegacyUser user,long id,Delete command) {
groups.access(user);if(command==null)throw bad("Löschvorgang fehlt.");String token=BusinessSupport.token(command.requestId());
support.transactional("groups","genericdatacontainer","mandator");
db.writeTransaction(()->{
one(db.lockRows("SELECT md_id FROM mandator WHERE md_id=?",mandator(db,user)));
var existing=operation(token,false);
if(existing!=null){var p=decode(existing);owner(user,p);if(p.id()!=id||!revision(p).equals(command.revision()))throw bad("Vorgangskennung wurde bereits für eine andere Löschung verwendet.");return null;}
groups.rejectDeletion(id);var p=plan(user,id);unchanged(p,command.revision());
String encoded=support.encode(p);if(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65000)throw bad("Zu viele Zuordnungen für einen Löschvorgang. Bitte zunächst Mitglieder entfernen.");
db.insert("genericdatacontainer",fields("gdc_obj_type","nggrpdel","gdc_obj_id",id,"gdc_gen_fieldname",PREFIX+token,"gdc_content",encoded,"gdc_context","PENDING"));return null;
});return resume(user,token);
}
Map<String,Object> resume(LegacyUser user,String token) {
groups.access(user);BusinessSupport.token(token);var saved=operation(token,false);if(saved==null)throw bad("Löschvorgang nicht gefunden.");owner(user,decode(saved));
try {return db.groupDeleteTransaction(()->{
one(db.lockRows("SELECT md_id FROM mandator WHERE md_id=?",mandator(db,user)));var state=operation(token,true);var p=decode(state);owner(user,p);
if(text(state,"gdc_context").equals("DONE"))return fields("state","DONE","token",token);
unchanged(groups.group(user,p.id(),true),p.groupRevision());
var current=members(user,p.id());
// Only reviewed memberships may be removed. New PHP additions require manual resolution.
for(var m:current)if(p.members().stream().noneMatch(old->old.kind().equals(m.kind())&&old.id()==m.id()&&old.before().equals(m.before())))throw bad("Zuordnungen wurden seit der Vorschau geändert. Bitte diese Änderung zuerst in der Mitgliederverwaltung klären.");
for(var m:current) {
String table,key,column;
if(m.kind().equals("MESSAGE_GROUP")){table="messagegroup";key="msggrp_id";column="msggrp_group";}else{var kind=GroupAdministration.Kind.valueOf(m.kind());table=kind.table;key=kind.id();column=kind.group();}
if(db.update("UPDATE "+table+" SET "+column+"=? WHERE "+key+"=? AND BINARY "+column+" <=> BINARY ?",m.after(),m.id(),m.before())!=1)throw bad("Eine Zuordnung wurde parallel geändert. Bitte den Vorgang erneut prüfen.");
if(m.kind().equals("MESSAGE_GROUP"))support.audit(user,0,0,0,203,"MSGGRP_ID="+m.id()+"|MSGGRP_NAME=EMPTY|OP=REMOVE_GROUP|GRP_ID="+p.id(),token+"-"+m.id());
}
if(!members(user,p.id()).isEmpty())throw bad("Es bestehen inzwischen weitere Zuordnungen. Die Gruppe bleibt erhalten.");
db.update("DELETE FROM groups WHERE grp_id=? AND md_id=?",p.id(),p.mandator());
db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(state,"gdc_id"));
return fields("state","DONE","token",token);
});}catch(RuntimeException e){return fields("state","PENDING","token",token,"message","Löschvorgang gespeichert. Bereits entfernte Zuordnungen bleiben entfernt; bitte denselben Vorgang fortsetzen."+(e instanceof org.springframework.web.server.ResponseStatusException r?" "+r.getReason():""));}
}
}
@@ -0,0 +1,34 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryModels.Station;
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
/** PHP services_func.inc.php: most specific enclosing range and odd/even selection. */
final class LegacyAddressTariffs {
private LegacyAddressTariffs(){}
static long postal(DatabaseService db,long hq,Station station){
if(station.billingPostalId()!=null){if(db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_id=?",station.billingPostalId()).isEmpty())throw invalid("Die gewählte Abrechnungs-PLZ ist nicht mehr verfügbar.");return station.billingPostalId();}
long mapped=mapping(db,null,hq,station,false);if(mapped>0)return mapped;
var rows=db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=? AND COALESCE(NULLIF(srvp_country,''),'DE')=?",station.postalCode(),station.country());
if(rows.size()>1)throw invalid("Die Abrechnungs-PLZ ist im gewählten Land mehrdeutig.");return rows.isEmpty()?0:id(rows.getFirst(),"srvp_id");
}
static long area(DatabaseService db,OrderEntryCatalog catalog,long hq,long postal,Station station){
long mapped=mapping(db,catalog,hq,station,true);if(mapped>0)return mapped;
var rows=db.queryForList("SELECT m.srvpa_id FROM serviceplzareamapping m JOIN serviceplzarea a ON a.srvpa_id=m.srvpa_id WHERE m.srvp_id=? AND a.hq_id=?",postal,hq);
if(rows.size()>1)throw invalid("Die Tarifgebietszuordnung der PLZ "+station.postalCode()+" ist mehrdeutig.");return rows.isEmpty()?0:id(rows.getFirst(),"srvpa_id");
}
static long mapping(DatabaseService db,OrderEntryCatalog catalog,long hq,Station station,boolean area){
String digits=station.houseNumber().strip().replaceFirst("[^0-9].*$","");if(digits.isEmpty()||digits.length()>7||station.street().isBlank())return 0;
long house=Long.parseLong(digits),lower=0,upper=99999,match=0;
String p=area?"srvpaa":"srvpxa",target=area?"srvpa_id":"srvp_id",table=area?"serviceplzareaaddress":"serviceplzaddress";
String join=area?" JOIN serviceplzarea t ON t.srvpa_id=m.srvpa_id AND t.hq_id=m.hq_id":" JOIN serviceplz t ON t.srvp_id=m.srvp_id";
var rows=db.queryForList("SELECT m."+p+"_id AS id,m."+target+" AS targetId,m."+p+"_hsno_from AS houseFrom,m."+p+"_hsno_to AS houseTo,m."+p+"_mode AS mode FROM "+table+" m JOIN address a ON a.ad_id=m.ad_id"+join+" WHERE m.hq_id=? AND a.ad_zipcode=? AND COALESCE(NULLIF(a.ad_country,''),'DE')=? AND a.ad_street LIKE ? ESCAPE '!' ORDER BY m."+p+"_id",hq,station.postalCode(),station.country(),AddressAdministration.like(station.street()));
for(var row:rows){long from=bound(row,"houseFrom",0),to=bound(row,"houseTo",99999),mode=id(row,"mode");if(from>=lower&&to<=upper&&house>=from&&house<=to&&(mode==0||mode==(house%2==0?2:1))){lower=from;upper=to;match=id(row,"targetId");
if(area){String raw=catalog.parameter(0,"MASK_AREA_ID_FROM_ADDRESS_"+id(row,"id"));for(String pair:raw.split(";")){String[] v=pair.split(",");if(v.length==2&&v[0].equals(station.houseNumber().replace(" ",""))&&v[1].matches("[0-9]{1,9}")){long special=Long.parseLong(v[1]);if(db.queryForList("SELECT srvpa_id FROM serviceplzarea WHERE srvpa_id=? AND hq_id=?",special,hq).isEmpty())throw invalid("Der abweichende Hausnummernbereich ist nicht mehr in dieser Niederlassung verfügbar.");match=special;}}}
}}return match;
}
static long bound(Map<String,Object> row,String key,long fallback){String v=text(row,key);try{return v.isBlank()?fallback:Long.parseLong(v);}catch(NumberFormatException e){return fallback;}}
}
@@ -0,0 +1,105 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.JsonNode;
import java.math.*;
import java.time.LocalDateTime;
import java.util.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.LegacyOrderPricing.*;
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
/** ajaxReqKmPrice.php: distance/zone rates, approach/departure, minimum and adoption rounding. */
final class LegacyDistancePricing {
record Price(BigDecimal base,BigDecimal courier,List<CalculationLine> lines) {}
private final OrderEntryCatalog catalog;
private final LegacyOrderPricing tariffs;
LegacyDistancePricing(OrderEntryCatalog catalog,LegacyOrderPricing tariffs){this.catalog=catalog;this.tariffs=tariffs;}
Price calculate(Request request,OrderRoutingService.Result route,long cs,String vehicle,LocalDateTime at,BigDecimal customerDiscount,BigDecimal deduction) {
if(route==null)throw invalid("Für diesen Preis muss zunächst die Strecke berechnet werden.");
long hq=route.info().pricingHeadquartersId();boolean zone=request.pricing().mode()==PriceMode.ZONE;
boolean longhaul=route.info().longhaul();
List<CalculationLine> lines=new ArrayList<>();BigDecimal customer=BigDecimal.ZERO,courier=BigDecimal.ZERO;
Tariff minimum=new Tariff(BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,false);
if(!zone) {
Tariff km=tariffs.service(hq,cs,vehicle,"km-Preis",at,customerDiscount);
if(!km.found() || km.price().signum()<=0)throw invalid("Für Fahrzeug und Kunde fehlt ein gültiger km-Preis.");
BigDecimal distance=route.info().distanceKm();
customer=money(km.price().multiply(distance));courier=cr(km,deduction).multiply(distance);
lines.add(new CalculationLine("Kilometer",distance,km.price(),customer));
minimum=tariffs.service(hq,cs,vehicle,"Mindestpreis",at,customerDiscount);
} else {
Map<String,String> names=new HashMap<>();
for(String entry:catalog.parameter(hq,"PZM_MASK_ZONES").split(";")) {
if(entry.isBlank())continue;String[] parts=entry.split(",",2);
if(parts.length!=2)throw invalid("Die Zuordnung der PZM-Tarifzonen ist ungültig.");names.put(parts[0].trim(),parts[1].trim());
}
boolean found=false;
for(String group:List.of("zoneDistance","extraApproachDistance","extraDepartureCalculation")) {
if(group.equals("extraDepartureCalculation") && longhaul && catalog.enabled(hq,"PZM_ROUNDTRIPKM"))continue;
var distances=new LinkedHashMap<String,BigDecimal>();Map<String,String> zoneKeys=new HashMap<>();int unique=0;
for(JsonNode part:route.zones().path(group)) {
String name=part.path("zone").asText();if(name.isBlank())throw invalid("Die Route enthält eine unbenannte Tarifzone.");
if(group.equals("extraDepartureCalculation"))name=name.replace("_edc","_eap");
String key=name.contains("_fix")?name+"#"+(unique++):name;
distances.merge(key,OrderRoutingService.metric(part,"distance"),BigDecimal::add);zoneKeys.put(key,name);
}
for(var entry:distances.entrySet()) {
String key=zoneKeys.get(entry.getKey()),name=names.getOrDefault(key,key);
Tariff tariff=tariffs.service(hq,cs,vehicle,name,at,customerDiscount);
BigDecimal rate=tariff.price(),crRate=cr(tariff,deduction);
if(longhaul && group.equals("zoneDistance")) {
rate=parameter("BWV_"+name.toUpperCase(Locale.ROOT)+"_"+vehicle,rate);
BigDecimal rateDeduction=parameter("BWV_Frachtverg_Abschlag_"+vehicle,deduction);
crRate=rawDiscount(tariff.price(),rateDeduction);
}
if(rate.signum()<=0)continue;found=true;
if(longhaul && group.equals("extraApproachDistance") && catalog.enabled(hq,"PZM_ROUNDTRIPKM"))rate=rate.multiply(BigDecimal.TWO);
BigDecimal quantity=key.contains("_fix")?BigDecimal.ONE:OrderRoutingService.kilometers(entry.getValue());
BigDecimal charge=quantity.multiply(rate),crCharge=quantity.multiply(crRate);
if(group.equals("zoneDistance") || key.contains("_fix")){charge=money(charge);crCharge=money(crCharge);}
customer=customer.add(charge);courier=courier.add(crCharge);
String label=(group.equals("extraApproachDistance")?"Anfahrt · ":group.equals("extraDepartureCalculation")?"Abfahrt · ":"")+name.replace("<br>"," ");
lines.add(new CalculationLine(label,quantity,rate,money(charge)));
String minKey=key.replace("_km","").replace("_fix","").replace("_eap","")+"_min";
Tariff candidate=tariffs.service(hq,cs,vehicle,names.getOrDefault(minKey,minKey),at,customerDiscount);
if(candidate.price().compareTo(minimum.price())>0)minimum=candidate;
}
}
if(!found)throw invalid("Für die ermittelten PZM-Zonen fehlen gültige Tarife.");
}
Tariff basic=(!zone && catalog.enabled(hq,"MASK_NO_BASIC_PRICE_FOR_KM_PRICE"))?new Tariff(BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,true)
:tariffs.service(hq,cs,vehicle,"Grundpreis",at,customerDiscount);
Tariff extra=tariffs.service(hq,cs,vehicle,"Weitere<br>Adressen",at,customerDiscount);
BigDecimal stops=BigDecimal.valueOf(request.stops().size()-2);
BigDecimal extraPrice=extra.price().multiply(stops);
customer=money(customer.add(basic.price()).add(extraPrice));courier=money(courier.add(cr(basic,deduction)).add(cr(extra,deduction).multiply(stops)));
if(basic.price().signum()!=0)lines.add(new CalculationLine("Grundpreis",BigDecimal.ONE,basic.price(),basic.price()));
if(stops.signum()>0)lines.add(new CalculationLine("Weitere Adressen",stops,extra.price(),money(extraPrice)));
if(customer.compareTo(minimum.price())<0) {
lines.add(new CalculationLine("Anhebung auf Mindestpreis",BigDecimal.ONE,minimum.price(),minimum.price().subtract(customer)));
customer=minimum.price();
}
// PHP uses the entire customer base when no explicit minimum courier price is defined.
BigDecimal crMinimum=minimum.courier().signum()==0?rawDiscount(customer,deduction):minimum.courier();
courier=money(courier.max(crMinimum));
BigDecimal adopted=(!zone || (catalog.enabled(hq,"PZM_MASK_ROUND") && !catalog.enabled(0,"PZM_MASK_DONT_ROUND_"+cs)))
?customer.setScale(0,RoundingMode.CEILING):money(customer);
if(adopted.compareTo(customer)!=0)lines.add(new CalculationLine("Aufrundung",BigDecimal.ONE,adopted.subtract(customer),adopted.subtract(customer)));
return new Price(money(adopted),courier,List.copyOf(lines));
}
static String legacyType(Request request,OrderRoutingService.Result route,Price price) {
if(request.pricing().mode()==PriceMode.ZONE) {
return price.lines().stream().filter(line -> line.name().equals("Anhebung auf Mindestpreis"))
.map(line -> "[M] Mindestpreis "+money(line.rate()).toPlainString()).findFirst().orElse("");
}
BigDecimal kmRate=price.lines().stream().filter(line -> line.name().equals("Kilometer")).map(CalculationLine::rate).findFirst().orElse(BigDecimal.ZERO);
BigDecimal basic=price.lines().stream().filter(line -> line.name().equals("Grundpreis")).map(CalculationLine::amount).findFirst().orElse(BigDecimal.ZERO);
return route.info().distanceKm().toPlainString()+"|"+kmRate.toPlainString()+"|"+basic.toPlainString();
}
private static BigDecimal cr(Tariff tariff,BigDecimal deduction){return tariff.courier().signum()==0?rawDiscount(tariff.price(),deduction):tariff.courier();}
private static BigDecimal rawDiscount(BigDecimal amount,BigDecimal deduction){return amount.multiply(BigDecimal.valueOf(100).subtract(deduction)).divide(BigDecimal.valueOf(100));}
private BigDecimal parameter(String key,BigDecimal fallback) {
String value=catalog.parameter(0,key);if(value.isBlank())return fallback;
try{return new BigDecimal(value.trim().replace(',','.'));}catch(NumberFormatException e){throw invalid("Der Bestandsparameter "+key+" ist ungültig.");}
}
}
@@ -0,0 +1,194 @@
package de.stadtbote.votian;
import java.util.Set;
/** Field type identifiers from include/inc_metafield.inc.php; not executable SQL. */
final class LegacyFormTypes {
private LegacyFormTypes() {}
static final Set<String> MAPPED=Set.of(
"db:at:article_barcode",
"db:at:article_bundlecode",
"db:at:article_bundlequantity",
"db:at:article_description",
"db:at:article_eid",
"db:at:article_match",
"db:at:article_name",
"db:at:article_packingpieces",
"db:at:article_serialnumber",
"db:at:article_stock_areaquantity",
"db:at:article_stock_itemquantity",
"db:at:article_timeunits",
"db:cr:bank",
"db:cr:bankaccount",
"db:cr:banknumber",
"db:cr:birthdate",
"db:cr:city",
"db:cr:company_name",
"db:cr:company_name2",
"db:cr:courier_eid",
"db:cr:email",
"db:cr:fax",
"db:cr:firstname",
"db:cr:housenumber",
"db:cr:iban",
"db:cr:imei",
"db:cr:name",
"db:cr:outlay",
"db:cr:phone",
"db:cr:phone2",
"db:cr:phone_pda",
"db:cr:postage",
"db:cr:staxno",
"db:cr:street",
"db:cr:swift",
"db:cr:taxno",
"db:cr:zipcode",
"db:cs:bank",
"db:cs:bankaccount",
"db:cs:banknumber",
"db:cs:city",
"db:cs:company_name",
"db:cs:company_name2",
"db:cs:costcenteraddress_city",
"db:cs:costcenteraddress_comp",
"db:cs:costcenteraddress_email",
"db:cs:costcenteraddress_hsno",
"db:cs:costcenteraddress_phone",
"db:cs:costcenteraddress_street",
"db:cs:costcenteraddress_zipcode",
"db:cs:discount",
"db:cs:eid",
"db:cs:email",
"db:cs:fax",
"db:cs:hash",
"db:cs:housenumber",
"db:cs:iban",
"db:cs:logo",
"db:cs:logo_height",
"db:cs:logo_width",
"db:cs:markup_provision",
"db:cs:phone",
"db:cs:phone2",
"db:cs:postage",
"db:cs:provision",
"db:cs:stat_businessvolume_01",
"db:cs:stat_businessvolume_02",
"db:cs:staxno",
"db:cs:street",
"db:cs:swift",
"db:cs:taxno",
"db:cs:zipcode",
"db:jb:cr_price",
"db:jb:cr_sid",
"db:jb:cr_subprice",
"db:jb:createtime",
"db:jb:daytime",
"db:jb:export_time",
"db:jb:filter_mandatory",
"db:jb:filter_optional",
"db:jb:finishtime",
"db:jb:fixprice",
"db:jb:freetext_1",
"db:jb:freetext_2",
"db:jb:freetext_3",
"db:jb:gdc_jb",
"db:jb:id",
"db:jb:id_parent",
"db:jb:invoice_text",
"db:jb:is_installation",
"db:jb:markup",
"db:jb:ordertime",
"db:jb:parent_id_fallback_jb_id",
"db:jb:postage",
"db:jb:sales_tax_rate",
"db:jb:service",
"db:jb:service_acceptance_protocol_packing_pieces",
"db:jb:service_acceptance_protocol_points",
"db:jb:service_acceptance_protocol_text",
"db:jb:service_photos",
"db:jb:service_sign",
"db:jb:serviceprice",
"db:jb:status",
"db:jb:subtotalprice",
"db:jb:taketime",
"db:jb:timeunits",
"db:jb:totalprice",
"db:jb:tourdata",
"db:jb:type",
"db:jb:vehicle_height",
"db:jb:vehicle_length",
"db:jb:vehicle_planned",
"db:jb:vehicle_position",
"db:jb:vehicle_real",
"db:jb:vehicle_width",
"db:jb:warningtime",
"db:jb:weight",
"db:pt:city",
"db:pt:company_name",
"db:pt:company_name2",
"db:pt:discount",
"db:pt:eid",
"db:pt:housenumber",
"db:pt:street",
"db:pt:zipcode",
"db:tr:city",
"db:tr:commission_number",
"db:tr:comp",
"db:tr:comp2",
"db:tr:country",
"db:tr:hsno",
"db:tr:jobpayment_exist_payment_difference",
"db:tr:jobpayment_payment_method",
"db:tr:person",
"db:tr:phone",
"db:tr:remark",
"db:tr:sign",
"db:tr:station_no",
"db:tr:street",
"db:tr:tourarticle_article_barcode",
"db:tr:tourarticle_article_bundlecode",
"db:tr:tourarticle_article_bundlequantity",
"db:tr:tourarticle_article_description",
"db:tr:tourarticle_article_eid",
"db:tr:tourarticle_article_id",
"db:tr:tourarticle_article_match",
"db:tr:tourarticle_article_name",
"db:tr:tourarticle_article_packingpieces",
"db:tr:tourarticle_article_serialnumber",
"db:tr:tourarticle_article_stock_areaquantity",
"db:tr:tourarticle_article_stock_itemquantity",
"db:tr:tourarticle_article_timeunits",
"db:tr:tourarticle_calculator_quantity",
"db:tr:tourarticle_calculator_servicename",
"db:tr:tourarticle_calculator_single_price",
"db:tr:tourarticle_calculator_total_price",
"db:tr:tourarticle_id",
"db:tr:tourarticle_number_of_all_packingpieces",
"db:tr:tourarticle_packingpieces",
"db:tr:tourarticle_price",
"db:tr:tourarticle_quantity",
"db:tr:tourarticle_remark",
"db:tr:tourarticle_serialno",
"db:tr:tourarticle_sort",
"db:tr:tourarticle_state",
"db:tr:tourarticle_sum_of_volume",
"db:tr:tourarticle_sum_of_weight",
"db:tr:tourarticle_summated_calculator_value",
"db:tr:tourarticle_summated_price",
"db:tr:tourarticle_summated_value_of_goods",
"db:tr:tourarticle_value_of_goods",
"db:tr:ware_from_to",
"db:tr:zipcode",
"sys:current_date",
"sys:current_headquarters_city",
"sys:current_headquarters_company_logo",
"sys:current_headquarters_company_name",
"sys:current_headquarters_company_name2",
"sys:current_headquarters_housenumber",
"sys:current_headquarters_id",
"sys:current_headquarters_mnemonic",
"sys:current_headquarters_name",
"sys:current_headquarters_street",
"sys:current_headquarters_zipcode",
"sys:current_user",
"sys:empty_field"
);
}
@@ -35,10 +35,10 @@ final class LegacyMapping {
}
static String join(String a,String b) { return (a+" "+b).trim(); }
static Customer customer(Map<String,Object> row) {
Customer c=new Customer();c.id=id(row,"cs_id");c.number=text(row,"cs_eid");c.name=text(row,"cmp_comp");
Customer c=new Customer();c.costCenterId=nullableId(row,"app_costcenter_id");c.costCenterName=text(row,"app_costcenter_name");c.id=id(row,"cs_id");c.number=text(row,"cs_eid");c.name=text(row,"cmp_comp");
c.street=join(text(row,"ad_street"),text(row,"cmp_hsno"));c.postalCode=text(row,"ad_zipcode");c.city=text(row,"ad_city");
c.contact=join(text(row,"usr_firstname"),text(row,"usr_name"));c.phone=text(row,"usr_phone");c.email=text(row,"usr_email");
String days=text(row,"cs_payment_term");c.paymentDays=days.matches("\\d+")?Integer.valueOf(days):null;
c.paymentTermLabel=text(row,"payment_term_label");String days=text(row,"cs_payment_term");c.paymentDays=days.matches("\\d+")?Integer.valueOf(days):null;
c.active=id(row,"cmp_archived")==0 && id(row,"cmp_visible")==1 && id(row,"cmp_authenticated")==1;
return c;
}
@@ -68,7 +68,9 @@ final class LegacyMapping {
static Stop stop(Map<String,Object> row) {
Stop s=new Stop();s.id=id(row,"tr_id");s.sequence=(int)id(row,"tr_sort");
// Existing VoTiAn tour field is preserved through the explicit warehouse direction mapping.
s.type=switch((int)id(row,"tr_ware_from_to")){case 1->StopType.PICKUP;case 2->StopType.DELIVERY;case 3->StopType.BOTH;default->s.sequence==1?StopType.PICKUP:StopType.DELIVERY;};
// job_options.php and get_saved_tour.php: bit 2 = collection, bit 1 = delivery.
// The old get_tr_ware_from_to_text helper contains the inverse labels.
s.type=switch((int)id(row,"tr_ware_from_to")){case 2->StopType.PICKUP;case 1->StopType.DELIVERY;case 3->StopType.BOTH;default->s.sequence==1?StopType.PICKUP:StopType.DELIVERY;};
s.name=join(text(row,"tr_comp"),text(row,"tr_comp2"));s.street=join(text(row,"ad_street"),text(row,"tr_hsno"));
s.postalCode=text(row,"ad_zipcode");s.city=text(row,"ad_city");s.contact=text(row,"tr_person");s.phone=text(row,"tr_phone");s.note=text(row,"tr_remark");
s.completedAt=id(row,"tr_status")==1?dateTime(row,"tr_finishtime"):null;s.recipientName=text(row,"tr_signname");return s;
@@ -0,0 +1,306 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryCatalog.*;
import static de.stadtbote.votian.OrderEntryModels.*;
/** Port of services_func.inc.php and job_options.php:1828-1867. All amounts remain decimal. */
@Service
public class LegacyOrderPricing {
private static final BigDecimal ZERO=BigDecimal.ZERO, HUNDRED=new BigDecimal("100");
private final DatabaseService db;
private final OrderEntryCatalog catalog;
private final List<MarkupHistory> markupHistory;
record MarkupHistory(long customerId, String after, BigDecimal rate) {}
record Tariff(BigDecimal price,BigDecimal discount,BigDecimal courier,boolean found) {}
public LegacyOrderPricing(DatabaseService db,OrderEntryCatalog catalog) {
this.db=db;this.catalog=catalog;
try(var input=getClass().getResourceAsStream("/legacy-markup-history.json")) {
markupHistory=new ObjectMapper().readValue(input,new TypeReference<List<MarkupHistory>>() {});
} catch(Exception e) { throw new IllegalStateException("Die übernommenen Zuschlagsstände fehlen.",e); }
}
Quote calculate(LegacyUser user,Request request,Map<String,Object> payer,LocalDateTime at) {
return calculate(user,request,payer,at,null,List.of());
}
Quote calculate(LegacyUser user,Request request,Map<String,Object> payer,LocalDateTime at,OrderRoutingService.Result routing,List<PhotoChoice> photos) {
return calculate(user,request,payer,at,routing,photos,null);
}
Quote calculate(LegacyUser user,Request request,Map<String,Object> payer,LocalDateTime at,OrderRoutingService.Result routing,List<PhotoChoice> photos,PhotoSet photoSet) {
Pricing input=request.pricing();
RouteInfo route=routing==null?null:routing.info();
long hq=route!=null && (input.mode()==PriceMode.KILOMETER || input.mode()==PriceMode.ZONE)?route.pricingHeadquartersId():user.headquartersId();
Map<String,Object> customer=payer;
Station pickup=request.stops().getFirst();
if(pickup.costCenterId()!=null && input.mode()!=PriceMode.SERVICES) {
var origin=catalog.costCenter(user,pickup.costCenterId(),false);
if(id(origin,"cs_jbcond")==1) customer=origin;
}
long cs=id(customer,"cs_id");
String vehicle=text(one(db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",
request.vehicleTypeId()),"Bitte einen gültigen Fahrzeugtyp wählen."),"mt_value");
String override=catalog.parameter(0,"COSTCENTER_INV_MODE_"+request.payerCostCenterId());
int invoiceMode=override.isBlank()?(int)id(customer,"cs_invmode"):Integer.parseInt(override);
boolean batch=id(customer,"cs_jobbatch")==1 && request.mode()!=Mode.RETROSPECTIVE;
BigDecimal markup=input.markup()!=null?input.markup():fuelMarkup(hq,cs,vehicle,at,customer);
if(route!=null && route.longhaul() && input.mode()==PriceMode.ZONE && input.markup()==null) {
String value=catalog.parameter(0,"BWV_AUSSERHALB_TSZ");
markup=value.isBlank()?ZERO:new BigDecimal(value.trim().replace(',','.'));
}
BigDecimal courierMarkup=input.courierMarkup()!=null?input.courierMarkup():fuelCourierMarkup(hq,cs,vehicle,at,customer);
if(Set.of(2L,3L,5L).contains(hq) && at.isBefore(LocalDateTime.of(2021,11,16,0,0)) && markup.signum()>0) markup=ZERO;
BigDecimal discount=input.applyFixedDiscount()?number(customer,"cs_fixprice_discount"):ZERO;
BigDecimal deduction=courierDeduction(hq,cs,vehicle,at);
List<Component> components=new ArrayList<>();
BigDecimal courierBase=ZERO;
boolean automatic=input.mode()==PriceMode.KILOMETER || input.mode()==PriceMode.ZONE;
var autoPrice=automatic?new LegacyDistancePricing(catalog,this).calculate(request,routing,cs,vehicle,at,number(customer,"cs_discount"),deduction):null;
List<CalculationLine> calculation=autoPrice==null?List.of():autoPrice.lines();
if(input.mode()==PriceMode.SERVICES) {
if(request.services().isEmpty())throw invalid("Bitte mindestens eine Leistungsposition auswählen.");
} else if((input.mode()==PriceMode.FIXED || automatic) && !batch) {
if(!automatic && (input.fixedPrice()==null || input.fixedPrice().signum()<=0))
throw invalid("Ein Festpreis muss größer als 0 sein. Für die Tarifberechnung bitte Tarif wählen.");
BigDecimal base=automatic?autoPrice.base():input.fixedPrice();
BigDecimal cr=positive(input.courierPrice())?discount(input.courierPrice(),discount):automatic?discount(autoPrice.courier(),discount):discount(discount(base,discount),deduction);
courierBase=cr;
components.add(new Component(0,"Fixpreis",automatic?LegacyDistancePricing.legacyType(request,routing,autoPrice):"",markup(base,markup),discount,discount(markup(base,markup),discount),
discount(base,discount),markup(cr,courierMarkup),cr));
} else {
// The active PHP saveServiceCosts branch only implements PLZ/area tariffs.
if(invoiceMode<1)
throw invalid("Für diesen Kunden ist kein PLZ-/Gebietstarif hinterlegt. Bitte einen kalkulierten Festpreis eingeben.");
Tariff basic=service(hq,cs,vehicle,"Grundpreis",at,number(customer,"cs_discount"));
boolean noBasicMarkup=catalog.enabled(hq,"MASK_NO_MARKUP_ON_VEHICLE_PRICE");
components.add(component(0,"Grundpreis",vehicle,basic,noBasicMarkup?ZERO:markup,noBasicMarkup?ZERO:courierMarkup,deduction));
int mappedVehicle=mappedVehicle(hq,request.vehicleTypeId());
for(int i=1;i<request.stops().size();i++) {
Station from=request.stops().get(i-1),to=request.stops().get(i);
if(!from.country().equals("DE") || !to.country().equals("DE"))
throw invalid("Für eine Auslandsstrecke bitte einen kalkulierten Festpreis eingeben.");
Tariff tariff=route(hq,cs,mappedVehicle,invoiceMode,from,to,at,number(customer,"cs_discount"));
if(!tariff.found()) throw invalid("Kein gültiger Tarif für Station "+i+" → "+(i+1)+". Bitte die Adressen oder den Festpreis prüfen.");
components.add(component(i,"PLZ "+from.postalCode()+" nach PLZ "+to.postalCode(),"",tariff,markup,courierMarkup,deduction));
}
courierBase=sum(components,Component::courierSubtotal);
if(positive(input.courierPrice())) courierBase=input.courierPrice();
}
if(!request.services().isEmpty()) {
var allowed=catalog.services(user,request.payerCostCenterId());
for(ServiceItem item:request.services()) {
var choice=allowed.stream().filter(s -> s.serviceId()==item.serviceId() && s.typeId()==item.typeId()).findFirst()
.orElseThrow(() -> invalid("Die ausgewählte Leistung ist für den Kunden nicht verfügbar."));
Tariff tariff=serviceValues(hq,cs,item.serviceId(),item.typeId(),at);
if(!tariff.found())tariff=serviceValues(hq,0,item.serviceId(),item.typeId(),at);
if(!tariff.found())tariff=serviceValues(0,cs,item.serviceId(),item.typeId(),at);
if(!tariff.found())tariff=serviceValues(0,0,item.serviceId(),item.typeId(),at);
if(!tariff.found())throw invalid("Für die Leistung fehlt ein gültiger Preis: "+choice.service());
BigDecimal unit=placeholder(tariff.price()),cr=placeholder(tariff.courier());
if(cr.signum()==0)cr=discount(unit,deduction);
var extended=new Tariff(money(unit.multiply(item.quantity())),input.applyFixedDiscount()?discount:tariff.discount(),money(cr.multiply(item.quantity())),true);
components.add(component(0,item.quantity().stripTrailingZeros().toPlainString()+" × "+choice.service(),choice.type(),extended,markup,courierMarkup,deduction));
}
courierBase=sum(components,Component::courierSubtotal);
}
BigDecimal net=sum(components,Component::net),subtotal=sum(components,Component::subtotal);
BigDecimal courierNet=sum(components,Component::courierNet),courierSubtotal=sum(components,Component::courierSubtotal);
if((input.mode()==PriceMode.TARIFF || input.mode()==PriceMode.SERVICES) && positive(input.courierPrice())) {
courierBase=input.courierPrice();courierSubtotal=input.courierPrice();courierNet=markup(courierSubtotal,courierMarkup);
}
BigDecimal service=nz(input.servicePrice());
BigDecimal serviceDiscount=catalog.enabled(hq,"INV_SERVICEPRICE_DISCOUNT")?discount:ZERO;
BigDecimal courierService=positive(input.courierServicePrice())?discount(input.courierServicePrice(),serviceDiscount):discount(service,deduction);
if(service.signum()>0 || courierService.signum()>0) {
components.add(new Component(0,"Servicepreis","",service,serviceDiscount,discount(service,serviceDiscount),
discount(service,serviceDiscount),courierService,courierService));
net=net.add(discount(service,serviceDiscount));subtotal=subtotal.add(discount(service,serviceDiscount));
courierNet=courierNet.add(courierService);courierSubtotal=courierSubtotal.add(courierService);
}
BigDecimal automaticService=streetService(hq,request,at);
if(automaticService.signum()>0) {
components.removeIf(c -> c.name().equals("Servicepreis"));
components.add(new Component(0,"Servicepreis","",service.add(automaticService),ZERO,
discount(service,serviceDiscount).add(automaticService),discount(service,serviceDiscount).add(automaticService),courierService,courierService));
net=net.add(automaticService);subtotal=subtotal.add(automaticService);
}
BigDecimal courierAgio=ZERO;
if(request.mode()==Mode.RETROSPECTIVE && request.courierVehicleId()!=null && deduction.signum()>0) {
var rates=db.queryForList("""
SELECT h.srvprch_price FROM servicepricehistory h JOIN couriervehicle v ON v.cr_id=h.srvprch_obj_id
WHERE v.crvh_id=? AND h.srvprch_obj_type='cr' AND h.srvprch_fieldname='cscr_prov'
AND h.srvprch_context=? AND h.srvprch_validfrom<=? ORDER BY h.srvprch_validfrom DESC LIMIT 1
""",request.courierVehicleId(),Long.toString(id(payer,"cs_id")),at);
if(!rates.isEmpty())courierAgio=number(rates.getFirst(),"srvprch_price");
if(courierAgio.signum()>0) {
// global.inc.php:check_cr_agio, applied once on retrospective acceptance.
BigDecimal adjusted=discount(subtotal.subtract(service).subtract(automaticService),deduction.subtract(courierAgio));
courierSubtotal=adjusted.add(courierService);courierNet=markup(adjusted,courierMarkup).add(courierService);
}
}
if(batch) {net=automaticService;subtotal=automaticService;courierNet=ZERO;courierSubtotal=ZERO;courierBase=ZERO;courierService=ZERO;}
if(route!=null && route.longhaul() && request.mode()!=Mode.RETROSPECTIVE) {
courierNet=ZERO;courierSubtotal=ZERO;courierBase=ZERO;courierService=ZERO;
components=components.stream().map(c -> new Component(c.sequence(),c.name(),c.type(),c.price(),c.discount(),c.net(),c.subtotal(),ZERO,ZERO)).toList();
}
var tax=one(db.queryForList("SELECT tx_value,tx_sign FROM tax WHERE tx_id=?",input.taxId()),"Bitte einen gültigen Steuerschlüssel wählen.");
BigDecimal taxRate=number(tax,"tx_value");String taxSign=text(tax,"tx_sign");
if(taxRate.signum()>0 && at.isBefore(LocalDateTime.of(2020,7,1,0,0))) taxRate=new BigDecimal("19");
var payerCountries=db.queryForList("SELECT a.ad_country FROM costcenteraddress ca JOIN address a ON a.ad_id=ca.ad_id WHERE ca.csc_id=? AND ca.adt_id=2",request.payerCostCenterId());
if(!payerCountries.isEmpty()) {
String country=text(payerCountries.getFirst(),"ad_country");
if(!Set.of("","D","DE","LI","NO","IS","HR","MC","AL","MK","CH").contains(country)) { taxRate=ZERO;taxSign="0"; }
}
BigDecimal payableNet=net.add(nz(request.toll()));
BigDecimal taxAmount=money(payableNet.multiply(taxRate).divide(HUNDRED));
String limitText=catalog.parameter(0,"JB_CR_PRICE_BLOCK");
BigDecimal limit=limitText.isBlank()?ZERO:new BigDecimal(limitText.replace(',','.'));
boolean limitExceeded=limit.signum()>0 && courierNet.compareTo(money(net.multiply(limit).divide(HUNDRED)))>0;
Quote quote=new Quote(money(net),taxAmount,money(payableNet.add(taxAmount)),money(subtotal),money(courierNet),money(courierSubtotal),
markup,courierMarkup,discount,taxRate,taxSign,money(courierBase),money(courierService),deduction,invoiceMode,batch,List.copyOf(components),"",courierAgio,limit,limitExceeded,route,photos,calculation,autoPrice==null?null:autoPrice.base(),autoPrice==null?null:autoPrice.courier(),money(nz(request.toll())),OrderEntryExtras.insurancePremium(request.insurance()),request.copies(),List.of(),List.of(),List.of(),List.of(),null,photoSet);
String fingerprint=hash(user.headquartersId()+"|"+quote.toString()+"|"+request.insurance());
return new Quote(quote.net(),quote.tax(),quote.gross(),quote.subtotal(),quote.courierNet(),quote.courierSubtotal(),
markup,courierMarkup,discount,taxRate,taxSign,quote.courierBase(),quote.courierService(),deduction,invoiceMode,batch,quote.components(),fingerprint,courierAgio,limit,limitExceeded,route,photos,calculation,autoPrice==null?null:autoPrice.base(),autoPrice==null?null:autoPrice.courier(),money(nz(request.toll())),OrderEntryExtras.insurancePremium(request.insurance()),request.copies(),List.of(),List.of(),List.of(),List.of(),null,photoSet);
}
private BigDecimal streetService(long hq,Request request,LocalDateTime at) {
String setting=catalog.parameter(hq,"MASK_SERVICE_PRICE_STREET");
if(setting.isBlank())return ZERO;
String[] rule=setting.split(",");
if(rule.length!=7)throw invalid("Die hinterlegte Regel für den Adresszuschlag ist ungültig.");
var day=at.toLocalDate();
if(day.isBefore(java.time.LocalDate.parse(rule[4].trim())) || day.isAfter(java.time.LocalDate.parse(rule[5].trim())))return ZERO;
BigDecimal result=ZERO;
for(Station stop:request.stops()) {
if(!stop.postalCode().equals(rule[0].trim()) || !stop.street().startsWith(rule[1].trim()))continue;
String digits=stop.houseNumber().replace(" ","").replaceFirst("[^0-9].*$","");
if(digits.isBlank())continue;
int house=Integer.parseInt(digits);boolean match=false;
// PHP tests both configured ranges, without an additional parity test.
for(int i=2;i<=3;i++) {String[] range=rule[i].trim().split("-");if(range.length!=2)throw invalid("Ungültiger Hausnummernbereich im Adresszuschlag.");if(house>=Integer.parseInt(range[0])&&house<=Integer.parseInt(range[1]))match=true;}
if(match)result=result.add(new BigDecimal(rule[6].trim()));
}
return result;
}
static Component component(int sequence,String name,String type,Tariff tariff,BigDecimal markup,BigDecimal courierMarkup,BigDecimal deduction) {
BigDecimal cr=tariff.courier().signum()==0?discount(tariff.price(),deduction):tariff.courier();
return new Component(sequence,name,type,markup(tariff.price(),markup),tariff.discount(),
discount(markup(tariff.price(),markup),tariff.discount()),discount(tariff.price(),tariff.discount()),
discount(markup(cr,courierMarkup),tariff.discount()),discount(cr,tariff.discount()));
}
Tariff service(long hq,long cs,String vehicle,String name,LocalDateTime at,BigDecimal customerDiscount) {
var type=db.queryForList("SELECT srvt_id,hq_id FROM servicetype WHERE srvt_name=? AND srvt_mode=0 AND hq_id IN (?,0) ORDER BY hq_id DESC LIMIT 1",vehicle,hq);
if(type.isEmpty()) return new Tariff(ZERO,ZERO,ZERO,false);
long tariffHq=id(type.getFirst(),"hq_id"),typeId=id(type.getFirst(),"srvt_id");
var services=db.queryForList("SELECT srv_id FROM service WHERE srv_name=? AND srv_mode=0 AND hq_id=?",name,tariffHq);
if(services.isEmpty()) return new Tariff(ZERO,ZERO,ZERO,false);
long serviceId=id(services.getFirst(),"srv_id");
Tariff specific=serviceValues(tariffHq,cs,serviceId,typeId,at);
Tariff common=specific.price().signum()<=0?serviceValues(tariffHq,0,serviceId,typeId,at):specific;
Tariff resolved=resolve(specific,common,customerDiscount,false);
return new Tariff(placeholder(resolved.price()),resolved.discount(),placeholder(resolved.courier()),resolved.found());
}
private Tariff serviceValues(long hq,long cs,long service,long type,LocalDateTime at) {
return values(db.queryForList("""
SELECT srvh_price AS price,srvh_discount AS discount,srvh_cr_price AS courier
FROM servicehistory WHERE hq_id=? AND cs_id=? AND srv_id=? AND srvt_id=? AND srvh_validfrom<=?
ORDER BY srvh_validfrom DESC LIMIT 1
""",hq,cs,service,type,at));
}
private BigDecimal fuelMarkup(long hq,long cs,String vehicle,LocalDateTime at,Map<String,Object> customer) {
if(id(customer,"cs_markup")!=1) return ZERO;
BigDecimal direct=number(customer,"cs_markup_prov");
if(direct.signum()!=0) {
// PHP checks the first matching entry with a strictly earlier timestamp.
for(var history:markupHistory) if(history.customerId()==cs && at.isAfter(LocalDateTime.parse(history.after().replace(' ','T')))) return history.rate();
return direct;
}
Tariff tariff=service(hq,cs,vehicle,"Aufschlag<br>Sprit [%]",at,ZERO);
return tariff.price().multiply(HUNDRED.subtract(tariff.discount())).divide(HUNDRED);
}
private BigDecimal fuelCourierMarkup(long hq,long cs,String vehicle,LocalDateTime at,Map<String,Object> customer) {
Tariff tariff=service(hq,cs,vehicle,"Aufschlag<br>Sprit [%]",at,ZERO);
return tariff.courier().signum()==0?fuelMarkup(hq,cs,vehicle,at,customer):tariff.courier();
}
private BigDecimal courierDeduction(long hq,long cs,String vehicle,LocalDateTime at) {
String sql="""
SELECT h.srvh_cr_price AS price,h.srvh_discount AS discount,h.srvh_cr_price AS courier
FROM servicehistory h JOIN service s ON s.srv_id=h.srv_id JOIN servicetype t ON t.srvt_id=h.srvt_id
WHERE h.hq_id=? AND h.cs_id=? AND s.hq_id=h.hq_id AND t.hq_id=h.hq_id
AND s.srv_name='Globaler<br>Frachtverg.-Abschlag [%]' AND s.srv_mode=0
AND t.srvt_mode=0 AND t.srvt_name=? AND h.srvh_validfrom<=? ORDER BY h.srvh_validfrom DESC LIMIT 1
""";
BigDecimal rate=values(db.queryForList(sql,hq,cs,vehicle,at)).price();
return rate.signum()==0?values(db.queryForList(sql,hq,0,vehicle,at)).price():rate;
}
private Tariff route(long hq,long cs,int vehicle,int mode,Station from,Station to,LocalDateTime at,BigDecimal discount) {
long fromZip=zip(hq,from),toZip=zip(hq,to);
long fromId=mode==1?fromZip:area(hq,fromZip,from),toId=mode==1?toZip:area(hq,toZip,to);
Tariff specific=pair(hq,cs,vehicle,mode,fromId,toId,at);
Tariff common=pair(hq,0,vehicle,mode,fromId,toId,at);
if(mode>1 && common.price().signum()==0 && catalog.enabled(hq,"MASK_INVMODE_FALLBACK")) common=pair(hq,0,vehicle,1,fromZip,toZip,at);
return resolve(specific,common,discount,true);
}
private long zip(long hq,Station station) { return LegacyAddressTariffs.postal(db,hq,station); }
private long area(long hq,long zip,Station station) { return LegacyAddressTariffs.area(db,catalog,hq,zip,station); }
private Tariff pair(long hq,long cs,int vehicle,int mode,long from,long to,LocalDateTime at) {
if(from==0 || to==0)return new Tariff(ZERO,ZERO,ZERO,false);
String prefix=mode==1?"srvph":"srvpah",idPrefix=mode==1?"srvp":"srvpa",table=mode==1?"serviceplzhistory":"serviceplzareahistory";
String sql="SELECT "+prefix+"_price AS price,"+prefix+"_discount AS discount,"+prefix+"_cr_price AS courier FROM "+table+
" WHERE hq_id=? AND cs_id=? AND vht_id=? AND "+idPrefix+"_id=? AND "+idPrefix+"2_id=? AND "+prefix+"_validfrom<=? ORDER BY "+prefix+"_validfrom DESC LIMIT 1";
Tariff result=values(db.queryForList(sql,hq,cs,vehicle,from,to,at));
return result.price().signum()==0 && vehicle!=0?values(db.queryForList(sql,hq,cs,0,from,to,at)):result;
}
private int mappedVehicle(long hq,int vehicle) {
if(!catalog.enabled(hq,"SERVICE_VEHICLE_TYPE_ENABLED"))return vehicle;
for(String mapping:catalog.parameter(hq,"SERVICE_VEHICLE_TYPE_MAPPING").split("\\|")) {
String[] pair=mapping.split("=");
if(pair.length==2 && pair[0].trim().equals(Integer.toString(vehicle)) && pair[1].trim().matches("[0-9]+")) {
int mapped=Integer.parseInt(pair[1].trim());
return db.queryForList("SELECT mt_sort FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",mapped).isEmpty()?vehicle:mapped;
}
}
return vehicle;
}
static Tariff resolve(Tariff specific,Tariff common,BigDecimal customerDiscount,boolean postal) {
BigDecimal price=specific.price(),discount=customerDiscount.signum()==0?specific.discount():customerDiscount;
BigDecimal correction=price.signum()<0?price:ZERO;
// Preserve the active PHP PLZ branch's correction value (services_func.inc.php:691-692).
BigDecimal discountCorrection=discount.signum()<0?(postal?price:discount):ZERO;
boolean fallback=price.signum()<=0 || (postal && discount.signum()<=0);
if(fallback) { if(price.signum()<=0)price=common.price();if(discount.signum()<=0)discount=common.discount(); }
return new Tariff(price.add(correction),discount.add(discountCorrection),fallback?common.courier():specific.courier(),
specific.found()||common.found());
}
static Tariff values(List<Map<String,Object>> rows) {
return rows.isEmpty()?new Tariff(ZERO,ZERO,ZERO,false):new Tariff(number(rows.getFirst(),"price"),number(rows.getFirst(),"discount"),number(rows.getFirst(),"courier"),true);
}
static BigDecimal placeholder(BigDecimal value) { return money(value).compareTo(new BigDecimal("0.01"))==0?ZERO:value; }
static BigDecimal nz(BigDecimal value) { return value==null?ZERO:value; }
static boolean positive(BigDecimal value) { return value!=null && value.signum()>0; }
static BigDecimal money(BigDecimal value) { return value.setScale(2,RoundingMode.HALF_UP); }
static BigDecimal discount(BigDecimal value,BigDecimal percent) { return money(value.multiply(HUNDRED.subtract(percent)).divide(HUNDRED)); }
static BigDecimal markup(BigDecimal value,BigDecimal percent) { return money(value.multiply(HUNDRED.add(percent)).divide(HUNDRED)); }
static BigDecimal sum(List<Component> components,java.util.function.Function<Component,BigDecimal> field) { return components.stream().map(field).reduce(ZERO,BigDecimal::add); }
static String hash(String text) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); }
catch(java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
}
@@ -78,18 +78,23 @@ public class LegacyReadService {
DeliveryOrder o=byId.get(LegacyMapping.id(row,"jb_id"));if(o!=null){Stop s=LegacyMapping.stop(row);o.stops.add(s);if(s.recipientName!=null&&!s.recipientName.isBlank())o.recipientName=s.recipientName;}
});
}
public List<Customer> customers(LegacyUser user,String q) {
public List<Customer> customers(LegacyUser user,String q) { return customers(user,q,false,null); }
public List<Customer> customers(LegacyUser user,String q,boolean showCostCenters,String costCenterName) {
user.require(0);
if((q!=null && q.length()>200)||(costCenterName!=null && costCenterName.length()>200))throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Suchtext ist zu lang.");
String sql="""
SELECT cs.*,cmp.cmp_comp,cmp.cmp_hsno,cmp.cmp_visible,cmp.cmp_authenticated,cmp.cmp_archived,
a.ad_street,a.ad_zipcode,a.ad_city,u.usr_firstname,u.usr_name,u.usr_phone,u.usr_email
a.ad_street,a.ad_zipcode,a.ad_city,u.usr_firstname,u.usr_name,u.usr_phone,u.usr_email,
(SELECT MIN(mt.mt_mapped_value) FROM metatype mt WHERE mt.mt_type='payment_term' AND mt.mt_sort=cs.cs_payment_term) AS payment_term_label
FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id
LEFT JOIN address a ON a.ad_id=cmp.ad_id LEFT JOIN employee e ON e.emp_id=cs.cs_admin
LEFT JOIN user u ON u.usr_id=e.usr_id WHERE cs.hq_id=?
""";
if(showCostCenters)sql=sql.replace("SELECT cs.*,", "SELECT csc.csc_id AS app_costcenter_id,csc.csc_name AS app_costcenter_name,cs.*,").replace("WHERE cs.hq_id=?", "JOIN costcenter csc ON csc.cs_id=cs.cs_id WHERE cs.hq_id=?");
List<Object> args=new ArrayList<>(List.of(user.headquartersId()));
if(showCostCenters && costCenterName!=null && !costCenterName.isBlank()){sql+=" AND csc.csc_name LIKE ?";args.add("%"+costCenterName.trim()+"%");}
if(q!=null&&!q.isBlank()){sql+=" AND (cmp.cmp_comp LIKE ? OR cs.cs_eid LIKE ? OR a.ad_city LIKE ?)";String term="%"+q.trim()+"%";args.add(term);args.add(term);args.add(term);}
return database.queryForList(sql+" ORDER BY cmp.cmp_comp LIMIT "+maxResults,args.toArray()).stream().map(LegacyMapping::customer).toList();
return database.queryForList(sql+" ORDER BY cmp.cmp_comp,cs.cs_id"+(showCostCenters?",csc.csc_name,csc.csc_id":"")+" LIMIT "+maxResults,args.toArray()).stream().map(LegacyMapping::customer).toList();
}
public List<Courier> couriers(LegacyUser user,String q) {
user.require(1);
@@ -4,12 +4,13 @@ import org.springframework.security.access.AccessDeniedException;
import java.security.Principal;
import static de.stadtbote.votian.Models.*;
/** Scope is the original headquarters. Cross-headquarters and customer access are never widened implicitly. */
public record LegacyUser(long userId, long employeeId, long headquartersId, String username, String rights) implements Principal {
/** Entry scope can be selected explicitly; the original account headquarters remains immutable. */
public record LegacyUser(long userId, long employeeId, long headquartersId, String username, String rights, long originalHeadquartersId) implements Principal {
public LegacyUser(long userId,long employeeId,long headquartersId,String username,String rights) {this(userId,employeeId,headquartersId,username,rights,headquartersId);}
@Override public String getName() { return username; }
public boolean hasRight(int position) { return rights != null && rights.length() > position && rights.charAt(position) == '1'; }
public void require(int position) {
if (!hasRight(position)) throw new AccessDeniedException("Für diesen Bereich fehlt die Berechtigung im vorhandenen VoTiAn-Konto.");
}
public Capabilities capabilities() { return new Capabilities(hasRight(4),hasRight(0),hasRight(1),false,false,false,false,false,false,hasRight(4) && hasRight(5)); }
public Capabilities capabilities() { return new Capabilities(hasRight(4),hasRight(0),hasRight(1),hasRight(5),hasRight(4)&&hasRight(7),hasRight(0),hasRight(1),hasRight(5)&&hasRight(7),hasRight(4)&&hasRight(7),hasRight(4) && hasRight(5),hasRight(7),hasRight(8),hasRight(0)&&hasRight(11),hasRight(4)||hasRight(18)||(hasRight(0)&&hasRight(17)),hasRight(15)); }
}
@@ -0,0 +1,47 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.Authentication;
import java.util.*;
import static de.stadtbote.votian.AdminSupport.bad;
import static de.stadtbote.votian.OrderIntegrationClient.unavailable;
/** Places Autocomplete / Details; the API key stays on the server. No business writes. */
@RestController
@RequestMapping("/api/master-data/{kind}")
public class MasterAddressSuggestions {
final OrderIntegrationClient client; final OrderRoutingService routing;
public MasterAddressSuggestions(OrderIntegrationClient client,OrderRoutingService routing){this.client=client;this.routing=routing;}
public record Suggestion(String id,String label) {}
public record Address(String street,String houseNumber,String postalCode,String city,String country) {}
private void access(LegacyUser user,String kind){switch(kind){case "customers","centers"->user.require(0);case "couriers"->user.require(1);default->throw bad("Dieser Stammdatenbereich enthält keine Adresse.");}}
private String key(){String key=routing.apiKey();if(key.isBlank())throw unavailable("Adressvorschläge sind nicht konfiguriert. Die Adresse kann manuell eingegeben werden.");return key;}
@GetMapping("/address-suggestions") public List<Suggestion> suggestions(Authentication auth,@PathVariable String kind,@RequestParam String q,@RequestParam(defaultValue="DE")String country,@RequestParam String sessionToken){return suggestions(ApiController.user(auth),kind,q,country,sessionToken);}
List<Suggestion> suggestions(LegacyUser user,String kind,String query,String country,String session){
access(user,kind);BusinessSupport.token(session);
if(query==null||query.length()>200||country==null||!country.matches("[A-Z]{2}"))throw bad("Ungültige Adresssuche.");
if(query.strip().length()<3)return List.of();
var payload=Map.of("input",query.strip(),"languageCode","de","regionCode",country.toLowerCase(Locale.ROOT),"sessionToken",session,"includedPrimaryTypes",List.of("street_address","route","premise","subpremise"));
var response=client.googlePlaces("places:autocomplete",payload,key(),"suggestions.placePrediction.placeId,suggestions.placePrediction.text.text");
if(response==null||response.has("error")||response.has("suggestions")&&!response.path("suggestions").isArray())throw unavailable("Der Adressdienst hat ungültige Vorschläge geliefert.");
var result=new ArrayList<Suggestion>();for(var item:response.path("suggestions")){var prediction=item.path("placePrediction");String id=prediction.path("placeId").asText(),label=prediction.path("text").path("text").asText();if(validId(id)&&!label.isBlank())result.add(new Suggestion(id,label));if(result.size()==5)break;}return List.copyOf(result);
}
@GetMapping("/address-details") public Address details(Authentication auth,@PathVariable String kind,@RequestParam String placeId,@RequestParam String sessionToken){return details(ApiController.user(auth),kind,placeId,sessionToken);}
Address details(LegacyUser user,String kind,String placeId,String session){
access(user,kind);BusinessSupport.token(session);if(!validId(placeId))throw bad("Ungültiger Adressvorschlag.");
var result=client.googlePlaces("places/"+placeId+"?languageCode=de&sessionToken="+session,null,key(),"addressComponents");
if(result==null||!result.path("addressComponents").isArray())throw unavailable("Die Adresse konnte nicht aufgelöst werden. Bitte manuell ergänzen.");
return components(result.path("addressComponents"));
}
private static boolean validId(String value){return value!=null&&value.matches("[A-Za-z0-9_-]{1,255}");}
static Address components(JsonNode nodes){
var parts=new HashMap<String,String>();boolean postalPrefix=false;
for(var component:nodes)for(var type:component.path("types")){String name=type.asText();if(name.equals("postal_code_prefix"))postalPrefix=true;parts.put(name,component.path(name.equals("country")?"shortText":"longText").asText());}
String street=parts.getOrDefault("route",""),country=parts.getOrDefault("country","");
if(street.isBlank()||!country.matches("[A-Z]{2}"))throw unavailable("Der Vorschlag enthält keine vollständige Straßenadresse. Bitte eine andere Adresse wählen.");
String postal=parts.getOrDefault("postal_code","");if(country.equals("DE")&&(postalPrefix||!postal.matches("[0-9]{5}")))postal="";
// Empty components intentionally clear stale values from the previously entered address.
return new Address(street,parts.getOrDefault("street_number",""),postal,parts.getOrDefault("locality",parts.getOrDefault("postal_town",parts.getOrDefault("administrative_area_level_3",""))),country);
}
}
@@ -0,0 +1,146 @@
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.*;
/** Customer/courier editors: inc_customer, inc_courier, csc_special and courier_vehicle. */
@Service
public class MasterData {
final DatabaseService db; final BusinessSupport support;
public MasterData(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
public record Field(String key,String label,String group,String type,int max,boolean required,Map<String,String> options){}
public record Save(String requestId,String revision,Map<String,Object> values){}
static Field f(String key,String label,String group,String type,int max,boolean required){return new Field(key,label,group,type,max,required,Map.of());}
static Field choice(String key,String label,String group,String... options){var m=new LinkedHashMap<String,String>();for(int i=0;i<options.length;i+=2)m.put(options[i],options[i+1]);return new Field(key,label,group,"select",0,false,m);}
static final List<Field> COMMON=List.of(f("tx_id","Umsatzsteuer","Bank und Rechnung","tax",0,true),
f("cmp_comp","Firma","Firma","text",50,true),f("cmp_comp2","Zusatz 1","Firma","text",50,false),f("cmp_comp3","Zusatz 2","Firma","text",50,false),f("cmp_comp4","Zusatz 3","Firma","text",50,false),
f("cmp_match","Suchbegriff","Firma","text",200,false),f("cmp_match2","Weiterer Suchbegriff","Firma","text",50,false),
f("ad_street","Straße","Adresse","text",50,true),f("cmp_hsno","Hausnummer","Adresse","text",10,true),f("ad_zipcode","PLZ","Adresse","text",10,true),f("ad_city","Ort","Adresse","text",50,true),f("ad_country","Land (ISO)","Adresse","country",2,true),
f("usr_name","Nachname","Kontakt","text",50,false),f("usr_firstname","Vorname","Kontakt","text",50,false),f("usr_phone","Telefon","Kontakt","text",20,true),f("usr_phone2","Mobiltelefon","Kontakt","text",20,false),f("usr_fax","Fax","Kontakt","text",20,false),f("usr_email","E-Mail","Kontakt","emails",250,false),f("usr_inv_email","Rechnungs-E-Mail","Kontakt","emails",250,false),f("usr_reminder_email","Mahn-E-Mail","Kontakt","emails",250,false),
f("cmp_tax_idno","Steuernummer","Abrechnung","text",30,false),f("cmp_stax_idno","USt-IdNr.","Abrechnung","text",30,false),f("cmp_bank","Bank","Abrechnung","text",30,false),f("cmp_bankno","Bankleitzahl","Abrechnung","text",20,false),f("cmp_bankacc","Kontonummer","Abrechnung","text",20,false),f("cmp_iban","IBAN","Abrechnung","text",40,false),f("cmp_swift","BIC / Swift","Abrechnung","text",20,false),choice("cmp_bankmode","Zahlungsart","Abrechnung","0","Rechnung","1","Einzug"),f("cmp_hqlino","Lieferantennummer","Abrechnung","text",20,false),f("cmp_gln","GLN","Abrechnung","text",20,false),f("cmp_postage","Porto (€)","Abrechnung","money",999,false),
f("cmp_contract_date","Vertragsdatum","Vertrag und Status","date",0,false),f("cmp_visible","Sichtbar","Vertrag und Status","check",1,false),f("cmp_authenticated","Freigegeben","Vertrag und Status","check",1,false),f("cmp_archived","Archiviert","Vertrag und Status","check",1,false),
f("cmp_remark","Bemerkung","Notizen","textarea",10000,false),f("cmp_remark2","Interne Bemerkung","Notizen","textarea",10000,false),f("cmp_freetext1","Freitext","Notizen","textarea",10000,false));
static final List<Field> CUSTOMER=List.of(
f("cs_eid","Kundennummer / ExtID","Firma","text",10,true),
choice("cs_invmode","Preisermittlung","Abrechnung","0","PZM","1","PLZ","2","Bereich"),
f("cs_payment_term","Zahlungsbedingung","Abrechnung","payment",0,false),f("cs_discount","Rabatt (%)","Konditionen","percent",100,false),f("cs_fixprice_discount","Rabatt auf Fixpreise","Konditionen","check",1,false),f("cs_prov","Provision (%)","Konditionen","percent",100,false),f("cs_markup","Treibstoffaufschlag erheben","Konditionen","check",1,false),f("cs_markup_prov","Treibstoffaufschlag (%)","Konditionen","percent",100,false),
f("cs_commission_no","Kommissionsnummer verpflichtend","Abrechnung","check",1,false),f("cs_jb_commission_no","Rechnungskommissionsnummer","Abrechnung","check",1,false),f("cs_jobbatch","Listenkunde","Auftragsvorgaben","check",1,false),f("cs_cr_fav_only","Nur angeforderte Kuriere","Auftragsvorgaben","check",1,false),f("cs_invtext_mode","Rechnungskurztext","Abrechnung","check",1,false),f("cs_tracking","Sendungsverfolgung","Auftragsvorgaben","check",1,false),f("cs_charging_time","Ladedauer (Minuten)","Auftragsvorgaben","integer",127,false),f("cs_blur_markup_time","Zeitaufschlag (Minuten)","Auftragsvorgaben","integer",127,false),f("cmp_no_longhaul","Keine bundesweite Vermittlung","Auftragsvorgaben","check",1,false),
f("cs_jbstatusmail","E-Mail bei Erledigung","Benachrichtigungen","check",1,false),f("cs_jbstatusmail2","E-Mail bei Abholung","Benachrichtigungen","check",1,false),f("cs_jbstatusmail3","E-Mail bei Erfassung","Benachrichtigungen","check",1,false),f("cs_jbstatusmail2csc","Rechnungsadresse der Kostenstelle verwenden","Benachrichtigungen","check",1,false),f("cs_jbstatusmail_price","Preis anzeigen","Benachrichtigungen","check",1,false),f("cs_jbstatusmail_emp","Mitarbeiterdaten anzeigen","Benachrichtigungen","check",1,false),f("cs_jbstatusmail_pdf","PDF anhängen","Benachrichtigungen","check",1,false),
f("cmp_sustainability_email","Nachhaltigkeits-E-Mail","Benachrichtigungen","emails",250,false),f("cmp_sustainability_interval","Nachhaltigkeitsbericht","Benachrichtigungen","reportInterval",0,false));
static final List<Field> COURIER=List.of(f("vht_id_inv","Abrechnungsfahrzeugtyp","Abrechnung","vehicle",0,false),f("cr_imei","Geräte-IMEI","Gerät","text",20,false),f("cr_serialno","Seriennummer","Gerät","text",30,false),f("cr_mobile_pda","Gerätetelefon","Gerät","text",30,false),f("cmp_no_longhaul","Keine bundesweite Vermittlung","Disposition","check",1,false),f("cr_eid","Kuriernummer / ExtID","Firma","text",10,true),f("cr_maxweight","Maximale Zuladung (kg)","Disposition","integer",100000,false),f("cr_outlay","Maximale Auslage (€)","Disposition","integer",100000,false),f("cr_locationzipcode","Standort-PLZ","Disposition","text",10,false),f("cr_disposition","Disposition aktiviert","Disposition","check",1,false),f("cr_price_show","Preise anzeigen","Disposition","check",1,false));
static List<Field> centerFields(){var all=new ArrayList<>(CENTER);String[] groups={"","Leistungsanschrift","Rechnungsanschrift","Zusatzadresse (Typ 3)","Abhol- / Lieferadresse"};for(int type=1;type<=4;type++)for(var f:CENTER_ADDRESS)all.add(new Field("addr"+type+"_"+f.key(),f.label(),groups[type],f.type(),f.max(),type!=3&&f.required(),f.options()));return all;}
static final List<Field> CENTER_ADDRESS=List.of(f("cscad_comp","Firma","","text",50,true),f("cscad_comp2","Zusatz","","text",50,false),f("cscad_comp3","Zusatz 2","","text",50,false),f("cscad_comp4","Zusatz 3","","text",50,false),f("ad_street","Straße","","text",50,true),f("cscad_hsno","Hausnummer","","text",10,false),f("ad_zipcode","PLZ","","text",10,true),f("ad_city","Ort","","text",50,true),f("ad_country","Land (ISO)","","country",2,true),f("cscad_person","Ansprechpartner","","text",50,false),f("cscad_phone","Telefon","","text",20,false),f("cscad_email","E-Mail","","emails",100,false),f("cscad_gln","GLN","","text",20,false),f("cscad_remark","Bemerkung","","textarea",10000,false));
static final List<Field> CENTER=List.of(f("csc_name","Kostenstellenname","Kostenstelle","text",50,true),f("csc_pre_id","Übergeordnete Kostenstelle","Kostenstelle","parent",0,true),f("csc_visible","Sichtbar","Kostenstelle","check",1,false),f("csc_blocked","Gesperrt","Kostenstelle","check",1,false),f("csc_is_extern","Externe Kostenstelle","Kostenstelle","check",1,false),f("csc_inv_interval","Rechnungsintervall","Abrechnung","interval",0,false),f("csc_inv_type","Rechnungstyp","Abrechnung","invoiceType",0,false));
static final List<Field> VEHICLE=List.of(f("crvh_sid","Fahrzeugnummer / SID","Fahrzeug","text",10,true),f("vht_id","Fahrzeugtyp","Fahrzeug","vehicle",0,true),f("crvh_vh_sign","Kennzeichen","Fahrzeug","text",10,false),f("crvh_vh_name","Bezeichnung","Fahrzeug","text",30,false),f("crvh_comm_no","Kommissionsnummer","Fahrzeug","text",10,false),f("crvh_mobile","Mobiltelefon","Fahrzeug","text",20,false),f("crvh_licence_date","Erstzulassung","Fahrzeug","date",0,false),f("crvh_payload","Zuladung (kg)","Laderaum","integer",100000,false),f("crvh_totalweight","Gesamtgewicht (kg)","Laderaum","integer",100000,false),f("crvh_length","Länge (cm)","Laderaum","integer",10000,false),f("crvh_width","Breite (cm)","Laderaum","integer",10000,false),f("crvh_height","Höhe (cm)","Laderaum","integer",10000,false),f("crvh_position","Stellplätze","Laderaum","integer",1000,false),f("crvh_prov","Provision (%)","Abrechnung","percent",100,false),f("crvh_insurance_name","Versicherung","Vertrag","text",30,false),f("crvh_insurance_no","Versicherungsnummer","Vertrag","text",30,false),f("crvh_remark","Bemerkung","Vertrag","text",30,false),f("crvh_vhd_disabled","Fahrzeugdisposition deaktiviert","Fahrzeug","check",1,false),f("crvh_freetext_1","Freitext","Vertrag","textarea",10000,false));
List<Field> fieldsFor(String kind){return switch(kind){case "customers"->{var a=new ArrayList<>(COMMON);a.addAll(CUSTOMER);yield a;}case "couriers"->{var a=new ArrayList<>(COMMON);a.addAll(COURIER);yield a;}case "centers"->centerFields();case "vehicles"->VEHICLE;default->throw bad("Unbekannter Stammdatenbereich.");};}
void access(LegacyUser u,String kind){u.require(kind.equals("customers")||kind.equals("centers")?0:1);fieldsFor(kind);}
public Map<String,Object> view(LegacyUser u,String kind,long id,long parent){access(u,kind);return db.readTransaction(()->{
Map<String,Object> row=id==0?new LinkedHashMap<>():load(u,kind,id,false);
if(kind.equals("centers")||kind.equals("vehicles")){load(u,kind.equals("centers")?"customers":"couriers",parent,false);if(id>0&&id(row,kind.equals("centers")?"cs_id":"cr_id")!=parent)throw bad("Falsche Zuordnung.");}
var values=new LinkedHashMap<String,Object>();for(var f:fieldsFor(kind)){Object value=row.get(f.key());if(value instanceof Boolean b)value=b?1:0;if(value!=null&&f.type().equals("date"))value=value.toString().startsWith("0000-")?"":value.toString().substring(0,10);if(f.type().equals("country")&&"D".equals(value))value="DE";values.put(f.key(),value==null?defaultValue(f):value);}
if(id==0&&kind.equals("centers")){var customer=load(u,"customers",parent,false);values.put("csc_pre_id",id(customer,"csc_id"));for(int type:List.of(1,2,4))for(var f:CENTER_ADDRESS){String key=f.key().replace("cscad_","cmp_");if(f.key().equals("cscad_person"))key="usr_name";if(f.key().equals("cscad_phone"))key="usr_phone";if(f.key().equals("cscad_email"))key="usr_inv_email";values.put("addr"+type+"_"+f.key(),customer.getOrDefault(key,defaultValue(f)));}}
var result=fields("id",id,"parent",parent,"fields",fieldsFor(kind),"values",values,"revision",revision(row),"options",options(u,kind,parent),"canManageServices",kind.equals("customers")&&u.hasRight(2));
if(id>0&&kind.equals("customers"))result.put("children",db.queryForList("SELECT csc_id AS id,csc_name AS label,csc_pre_id AS parent,csc_blocked AS blocked FROM costcenter WHERE cs_id=? ORDER BY csc_path,csc_name,csc_id",id));
if(id>0&&kind.equals("couriers"))result.put("children",db.queryForList("SELECT crvh_id AS id,CONCAT(crvh_sid,' · ',COALESCE(crvh_vh_sign,'')) AS label FROM couriervehicle WHERE cr_id=? ORDER BY crvh_sid,crvh_id",id));
return result;
});}
Object defaultValue(Field f){return switch(f.type()){case "country"->"DE";case "check"->Set.of("cmp_visible","cmp_authenticated","csc_visible").contains(f.key())?1:0;case "integer","money","percent","vehicle","parent","interval","invoiceType","payment","tax","reportInterval","select"->0;default->"";};}
Map<String,Object> options(LegacyUser u,String kind,long parent){var out=new LinkedHashMap<String,Object>();
out.put("tax",db.queryForList("SELECT tx_id AS id,CONCAT(tx_sign,' · ',tx_value,' %') AS label FROM tax ORDER BY tx_id"));
out.put("vehicle",db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='vehicletype' ORDER BY mt_sort"));
out.put("payment",db.queryForList("SELECT mt_sort AS id,mt_mapped_value AS label FROM metatype WHERE mt_type='payment_term' ORDER BY mt_sort"));
out.put("reportInterval",db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='interval' ORDER BY mt_sort"));
out.put("interval",db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='invoiceinterval' ORDER BY mt_sort"));
out.put("invoiceType",db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='invoicetype' ORDER BY mt_sort"));
if(kind.equals("centers"))out.put("parent",db.queryForList("SELECT csc_id AS id,csc_name AS label FROM costcenter WHERE cs_id=? ORDER BY csc_name",parent));return out;
}
Map<String,Object> load(LegacyUser u,String kind,long id,boolean lock){
String sql=switch(kind){
case "customers"->"SELECT cs.*,cmp.*,ad.*,usr.usr_id,usr.usr_name,usr.usr_firstname,usr.usr_phone,usr.usr_phone2,usr.usr_fax,usr.usr_email,usr.usr_inv_email,usr.usr_reminder_email,usr.usr_modify FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id JOIN address ad ON ad.ad_id=cmp.ad_id JOIN employee emp ON emp.emp_id=cs.cs_admin JOIN user usr ON usr.usr_id=emp.usr_id WHERE cs.cs_id=? AND cs.hq_id=?";
case "couriers"->"SELECT cr.*,cmp.*,ad.*,usr.usr_name,usr.usr_firstname,usr.usr_phone,usr.usr_phone2,usr.usr_fax,usr.usr_email,usr.usr_inv_email,usr.usr_reminder_email,usr.usr_modify FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id JOIN address ad ON ad.ad_id=cmp.ad_id JOIN user usr ON usr.usr_id=cr.usr_id WHERE cr.cr_id=? AND cr.hq_id=?";
case "centers"->"SELECT c.* FROM costcenter c JOIN customer cs ON cs.cs_id=c.cs_id WHERE c.csc_id=? AND cs.hq_id=?";
case "vehicles"->"SELECT v.* FROM couriervehicle v JOIN courier c ON c.cr_id=v.cr_id WHERE v.crvh_id=? AND c.hq_id=?";
default->throw bad("Unbekannter Stammdatenbereich.");};
var row=new LinkedHashMap<>(one(lock?db.lockRows(sql,id,u.headquartersId()):db.queryForList(sql,id,u.headquartersId())));
if(kind.equals("centers")){String addressSql="SELECT ca.*,a.* FROM costcenteraddress ca JOIN address a ON a.ad_id=ca.ad_id WHERE ca.csc_id=? ORDER BY ca.adt_id";for(var address:lock?db.lockRows(addressSql,id):db.queryForList(addressSql,id))address.forEach((k,v)->row.put("addr"+id(address,"adt_id")+"_"+k,v));}return row;
}
public Map<String,Object> save(LegacyUser u,String kind,long target,long parent,Save request){access(u,kind);if(request==null||request.values()==null)throw bad("Stammdaten fehlen.");token(request.requestId());
var clean=validate(kind,request.values());
support.transactional("headquarters","genericdatacontainer","company","customer","courier","user","employee","address","costcenter","costcenteraddress","couriervehicle","servicepricehistory");
long saved=db.writeTransaction(()->{
// ExtIDs and SIDs are shared with PHP; lock matching index ranges as well as our creation mutex.
one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId()));
Long receipt=support.receipt(u,request.requestId(),List.of(kind,target,parent,request));if(receipt!=null)return receipt;
Map<String,Object> old=target==0?new LinkedHashMap<>():load(u,kind,target,true);if(target>0)unchanged(old,request.revision());
long result=target;
if(kind.equals("customers")||kind.equals("couriers")){
boolean cs=kind.equals("customers");String table=cs?"customer":"courier",key=cs?"cs_id":"cr_id",eid=cs?"cs_eid":"cr_eid";
var duplicates=db.lockRows("SELECT "+key+" FROM "+table+" WHERE "+eid+"=? AND "+key+"<>?"+(cs?"":" AND hq_id=?"),cs?new Object[]{clean.get(eid),target}:new Object[]{clean.get(eid),target,u.headquartersId()});if(!duplicates.isEmpty())throw bad("Diese ExtID ist bereits vergeben.");
if(cs&&priceChanged(old,clean)&&List.of("cs_discount","cs_prov","cs_markup_prov").stream().anyMatch(k->decimal(clean,k).signum()!=0 || (target>0 && decimal(old,k)!=null && decimal(old,k).signum()!=0)))u.require(2);
var company=prefix(clean,"cmp_");company.put("tx_id",clean.get("tx_id"));var person=prefix(clean,"usr_");var entity=prefix(clean,cs?"cs_":"cr_");if(!cs)entity.put("vht_id_inv",clean.get("vht_id_inv"));
long addressId=id(old,"ad_id");var address=prefix(clean,"ad_");boolean changed=target==0||address.entrySet().stream().anyMatch(e->!Objects.toString(old.get(e.getKey()),"").equals(e.getValue().toString()));
if(changed)addressId=db.insert("address",address); // Never mutate shared addresses referenced by past jobs.
company.put("ad_id",addressId);company.put("cmp_modify_status",1);
if(target==0){
company.put("cmp_fingerprint","");company.put("cmp_new_date",LocalDateTime.now());company.putIfAbsent("cmp_contract_date","0000-00-00");company.putIfAbsent("cmp_sustainability_email","");company.put("cmp_newsletter",0);
long cmp=db.insert("company",company);
person.putAll(fields("hq_id",u.headquartersId(),"usr_type",cs?2:3,"usr_country","","usr_account","","usr_password",null,"usr_password_old","","usr_totp_secret","","usr_totp_sessionkey",""));long usr=db.insert("user",person);
entity.putAll(fields("hq_id",u.headquartersId(),"cmp_id",cmp));
if(cs){long center=db.insert("costcenter",fields("csc_name",clean.get("cmp_comp"),"csc_visible",1));long emp=db.insert("employee",fields("usr_id",usr,"csc_id",center,"emp_rights","0".repeat(70),"emp_heritable_rights","0".repeat(70),"emp_headquarters",""));entity.putAll(fields("csc_id",center,"cs_admin",emp,"cs_become_cs_date",LocalDate.now()));result=db.insert(table,entity);db.update("UPDATE costcenter SET cs_id=? WHERE csc_id=?",result,center);for(int type:List.of(1,2,4))addressCopy(center,type,addressId,clean);}
else{entity.putAll(fields("usr_id",usr,"cr_filter","","cr_device_info","","cr_mobile_cell",""));result=db.insert(table,entity);}
}else{update(db,"company","cmp_id",id(old,"cmp_id"),company);update(db,"user","usr_id",id(old,"usr_id"),person);update(db,table,key,result,entity);}
if(cs)for(String field:List.of("cs_discount","cs_prov","cs_markup_prov"))if(target==0||decimalChanged(old,clean,field))history("cs",result,clean.get(eid).toString(),field,clean.get(field));
}else if(kind.equals("centers")){
var customer=load(u,"customers",parent,true);if(target>0&&id(old,"cs_id")!=parent)throw bad("Falscher Kunde.");
long ancestor=((Number)clean.get("csc_pre_id")).longValue();boolean root=target==id(customer,"csc_id");if(root){if(ancestor!=0)throw bad("Die Hauptkostenstelle kann nicht verschoben werden.");clean.put("csc_pre_id",null);}else{
var seen=new HashSet<Long>();long cursor=ancestor;while(cursor>0){if(cursor==target||!seen.add(cursor))throw bad("Kostenstellen dürfen keinen Kreis bilden.");var p=load(u,"centers",cursor,true);if(id(p,"cs_id")!=parent)throw bad("Die übergeordnete Kostenstelle gehört zu einem anderen Kunden.");cursor=id(p,"csc_pre_id");}if(ancestor<=0)throw bad("Bitte eine übergeordnete Kostenstelle wählen.");}
if(clean.get("csc_name").toString().contains("//"))throw bad("Kostenstellennamen dürfen kein // enthalten.");
var center=prefix(clean,"csc_");center.put("cs_id",parent);if(target==0)result=db.insert("costcenter",center);else update(db,"costcenter","csc_id",result,center);
for(int type=1;type<=4;type++){String pre="addr"+type+"_";if(type==3&&id(old,pre+"ad_id")==0&&!optionalAddressPresent(clean))continue;var address=new LinkedHashMap<String,Object>();var info=new LinkedHashMap<String,Object>();for(var f:CENTER_ADDRESS){if(f.key().startsWith("ad_"))address.put(f.key(),clean.get(pre+f.key()));else info.put(f.key(),clean.get(pre+f.key()));}
long addressId=id(old,pre+"ad_id");boolean changed=target==0||addressId==0||address.entrySet().stream().anyMatch(e->!Objects.toString(old.get(pre+e.getKey()),"").equals(Objects.toString(e.getValue(),"")));if(changed)addressId=db.insert("address",address);info.put("ad_id",addressId);
db.update("DELETE FROM costcenteraddress WHERE csc_id=? AND adt_id=?",result,type);info.putAll(fields("csc_id",result,"adt_id",type));insertWithoutKey(db,"costcenteraddress",info);
}
rebuildPaths(parent);
}else{
load(u,"couriers",parent,true);if(target>0&&id(old,"cr_id")!=parent)throw bad("Falscher Transporteur.");
if(decimalChanged(old,clean,"crvh_prov")&&(target>0||decimal(clean,"crvh_prov").signum()!=0))u.require(41);
if(!db.lockRows("SELECT crvh_id FROM couriervehicle WHERE crvh_sid=? AND crvh_id<>?",clean.get("crvh_sid"),target).isEmpty())throw bad("Die Fahrzeug-SID ist bereits vergeben.");
if(db.queryForList("SELECT mt_id FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",clean.get("vht_id")).isEmpty())throw bad("Unbekannter Fahrzeugtyp.");
clean.put("cr_id",parent);clean.put("crvh_modify_status",1);
if(target==0){clean.putAll(fields("crvh_filter","","crvh_service","","crvh_funding_state",0,"crvh_funding_residual_value",0,"crvh_partner_text","","crvh_transport_insurance_date","0000-00-00","crvh_business_liability_insurance_date","0000-00-00"));result=db.insert("couriervehicle",clean);}else update(db,"couriervehicle","crvh_id",result,clean);
}
support.audit(u,0,kind.equals("customers")?result:kind.equals("centers")?parent:0,kind.equals("couriers")?result:kind.equals("vehicles")?parent:0,kind.equals("vehicles")?(target==0?85:86):csOperation(kind,target),"Stammdaten "+kind+" #"+result+" · "+(target==0?"Angelegt":"Bearbeitet")+" · Felder: "+String.join(",",clean.keySet()),request.requestId());
support.receipt(u,request.requestId(),List.of(kind,target,parent,request),result);return result;
});return view(u,kind,saved,parent);
}
static int csOperation(String kind,long id){return kind.equals("customers")?(id==0?34:36):kind.equals("couriers")?(id==0?35:37):49;}
static boolean decimalChanged(Map<String,Object> a,Map<String,Object> b,String k){BigDecimal x=decimal(a,k),y=decimal(b,k);return x==null||y==null||x.compareTo(y)!=0;}
static boolean priceChanged(Map<String,Object>a,Map<String,Object>b){return List.of("cs_discount","cs_prov","cs_markup_prov").stream().anyMatch(k->decimalChanged(a,b,k));}
void history(String type,long id,String eid,String field,Object value){insertWithoutKey(db,"servicepricehistory",fields("srvprch_obj_type",type,"srvprch_obj_id",id,"srvprch_obj_eid",eid,"srvprch_fieldname",field,"srvprch_context","","srvprch_price",value,"srvprch_validfrom",LocalDateTime.now().withNano(0)));}
void addressCopy(long center,int type,long address,Map<String,Object> v){insertWithoutKey(db,"costcenteraddress",fields("csc_id",center,"adt_id",type,"ad_id",address,"cscad_comp",text(v,"cmp_comp"),"cscad_comp2",text(v,"cmp_comp2"),"cscad_comp3",text(v,"cmp_comp3"),"cscad_comp4",text(v,"cmp_comp4"),"cscad_hsno",text(v,"cmp_hsno"),"cscad_person",join(text(v,"usr_firstname"),text(v,"usr_name")),"cscad_phone",text(v,"usr_phone"),"cscad_email",text(v,"usr_inv_email"),"cscad_gln",text(v,"cmp_gln")));}
void rebuildPaths(long customer){var rows=db.lockRows("SELECT csc_id,csc_name,csc_pre_id FROM costcenter WHERE cs_id=?",customer);var byId=new HashMap<Long,Map<String,Object>>();rows.forEach(r->byId.put(id(r,"csc_id"),r));for(var r:rows){var names=new ArrayList<String>();var seen=new HashSet<Long>();long cursor=id(r,"csc_pre_id");while(cursor>0){if(!seen.add(cursor)||!byId.containsKey(cursor))throw bad("Kostenstellenhierarchie ist ungültig.");var p=byId.get(cursor);names.addFirst(text(p,"csc_name"));cursor=id(p,"csc_pre_id");}db.update("UPDATE costcenter SET csc_path=? WHERE csc_id=?",names.isEmpty()?"":"//"+String.join("//",names)+"//",id(r,"csc_id"));}}
static LinkedHashMap<String,Object> prefix(Map<String,Object> v,String prefix){var out=new LinkedHashMap<String,Object>();v.forEach((k,x)->{if(k.startsWith(prefix))out.put(k,x);});return out;}
Map<String,Object> validate(String kind,Map<String,Object> values){var result=validateFields(fieldsFor(kind),kind,values);if(kind.equals("centers")&&optionalAddressPresent(result))for(var field:CENTER_ADDRESS)if(field.required()&&text(result,"addr3_"+field.key()).isBlank())throw bad("Zusatzadresse: "+field.label()+" fehlt.");if(result.containsKey("tx_id")&&db.queryForList("SELECT tx_id FROM tax WHERE tx_id=?",result.get("tx_id")).isEmpty())throw bad("Bitte eine gültige Umsatzsteuer wählen.");for(var f:fieldsFor(kind)){String type=switch(f.type()){case "payment"->"payment_term";case "reportInterval"->"interval";case "interval"->"invoiceinterval";case "invoiceType"->"invoicetype";case "vehicle"->"vehicletype";default->null;};if(type!=null&&((Number)result.get(f.key())).longValue()!=0&&db.queryForList("SELECT mt_id FROM metatype WHERE mt_type=? AND mt_sort=?",type,result.get(f.key())).isEmpty())throw bad(f.label()+": Ungültige Auswahl.");}return result;}
static boolean optionalAddressPresent(Map<String,Object> values){return CENTER_ADDRESS.stream().filter(f->!f.key().equals("ad_country")).anyMatch(f->!text(values,"addr3_"+f.key()).isBlank());}
static Map<String,Object> validateFields(List<Field> allowed,String kind,Map<String,Object> values){var result=new LinkedHashMap<String,Object>();if(values.keySet().stream().anyMatch(k->allowed.stream().noneMatch(f->f.key().equals(k))))throw bad("Unbekanntes Stammdatenfeld.");
for(var f:allowed){String v=Objects.toString(values.get(f.key()),"").trim();Object parsed=v;
if(f.required()&&v.isEmpty())throw bad(f.label()+" fehlt.");
switch(f.type()){
case "check"->{if(!Set.of("0","1","true","false","").contains(v))throw bad(f.label()+": Ungültiger Wert.");parsed=Set.of("1","true").contains(v)?1:0;}
case "integer","money","percent","parent","vehicle","payment","interval","invoiceType","tax","reportInterval"->{try{BigDecimal n=new BigDecimal(v.isEmpty()?"0":v.replace(',','.'));boolean decimal=Set.of("money","percent").contains(f.type());if(n.signum()<0||n.compareTo(BigDecimal.valueOf(f.max()>0?f.max():Integer.MAX_VALUE))>0||n.stripTrailingZeros().scale()>(decimal?2:0))throw new NumberFormatException();parsed=decimal?n:n.longValueExact();}catch(ArithmeticException|NumberFormatException e){throw bad(f.label()+": Ungültige Zahl.");}}
case "select"->{if(!f.options().containsKey(v))throw bad(f.label()+": Ungültige Auswahl.");parsed=Integer.parseInt(v);}
case "datetime-local"->{try{parsed=v.isEmpty()?null:LocalDateTime.parse(v.replace(' ','T'));}catch(Exception e){throw bad(f.label()+": Ungültiger Zeitpunkt.");}}
case "date"->{try{parsed=v.isEmpty()?(kind.equals("vehicles")?null:"0000-00-00"):LocalDate.parse(v);}catch(Exception e){throw bad(f.label()+": Ungültiges Datum.");}}
default->{if(v.length()>f.max())throw bad(f.label()+" ist zu lang.");if(f.type().equals("country")&&(!v.isEmpty()||f.required())&&!v.matches("[A-Z]{2}"))throw bad("Das Land benötigt einen ISO-Ländercode wie DE.");if(f.type().equals("emails")&&!v.isEmpty())for(String email:v.split("[;,]"))if(!email.trim().matches("[^\\s@]+@[^\\s@]+\\.[^\\s@]+"))throw bad(f.label()+": Ungültige E-Mail-Adresse.");}
}result.put(f.key(),parsed);
}return result;
}
}
@@ -0,0 +1,153 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
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;
/** Native counterparts of newsticker.php and mf_history.php. */
@Service
class MessageAdministration {
final DatabaseService db;
final String ticker,forum;
final BusinessSupport audit;
private final Map<String,Pending> previews=new LinkedHashMap<>();
private static class Pending {
long user,hq,reply; int type; String subject,body,replyRevision,recipientRevision;
Dispatch draft; List<Map<String,Object>> recipients; Instant expires;
boolean sending; Map<String,Object> result;
}
record Bulletin(LocalDate from,LocalDate to,long headquarters,long employee,String text) {}
record Query(LocalDate from,LocalDate to,List<Integer> states,int maxPriority,String direction,Long courier,int page) {}
record Dispatch(String subject,String body,Long courier,List<Long> groups,boolean allActive,Long replyTo,String revision) {}
record GroupSave(String revision,String name,List<Long> groups) {}
MessageAdministration(DatabaseService db,@Value("${app.history-schema:phoenix_log}") String history,@Value("${app.groupware-schema:phoenix_group}") String groupware) {
this.db=db;
this.audit=new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper(),history);
if(!history.matches("[A-Za-z0-9_]{1,64}")||!groupware.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Nachrichtenschema.");
ticker="`"+groupware+"`.tickerforum";forum="`"+history+"`.messageforum";
}
List<Map<String,Object>> headquarters(LegacyUser 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 label",u.hasRight(10)?new Object[]{u.headquartersId()}:new Object[]{u.headquartersId(),u.headquartersId()});
}
int limit(LegacyUser u,String key,int fallback) {
var rows=db.queryForList("SELECT par_value FROM parameter WHERE par_key=? AND emp_id=0 AND hq_id=?",key,u.headquartersId());
if(rows.size()>1)throw bad("Mehrdeutige Nachrichteneinstellung: "+key);
try {int n=Integer.parseInt(rows.isEmpty()?"":text(rows.getFirst(),"par_value"));return n>0?Math.min(n,16000):fallback;}catch(NumberFormatException e){return fallback;}
}
Map<String,Object> options(LegacyUser u) {
if(!u.hasRight(4)&&!u.hasRight(18)&&!(u.hasRight(0)&&u.hasRight(17)))throw new AccessDeniedException("Keine Nachrichtenberechtigung.");
var hqs=headquarters(u);var ids=hqs.stream().map(r->id(r,"id")).toList();
return fields("newsletters",u.hasRight(0)&&u.hasRight(17),"devices",u.hasRight(4),"bulletins",u.hasRight(18),"manageGroups",u.hasRight(4)&&u.hasRight(52),"allActive",u.hasRight(4)&&u.hasRight(52)&&u.hasRight(10),
"today",LocalDate.now(),"headquartersId",u.headquartersId(),"headquarters",hqs,"maxBulletin",limit(u,"NEWSTICKER_MAX_BODY_LENGTH",1000),"maxMessage",limit(u,"MESSAGE_MAX_BODY_LENGTH",250),
"employees",u.hasRight(18)&&!ids.isEmpty()?db.queryForList("SELECT e.emp_id AS id,u.hq_id AS headquarters,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM employee e JOIN user u ON u.usr_id=e.usr_id WHERE u.usr_type=1 AND u.hq_id IN ("+CrmScope.marks(ids.size())+") ORDER BY label",ids.toArray()):List.of());
}
private String bulletinScope(){return "(t.emp_id_creator=? OR t.emp_id=? OR (COALESCE(t.emp_id,0)=0 AND (t.hq_id=? OR COALESCE(t.hq_id,0)=0)))";}
private boolean root(LegacyUser u){return !db.queryForList("SELECT hq_id FROM headquarters WHERE hq_id=? AND hq_admin=?",u.headquartersId(),u.employeeId()).isEmpty();}
Map<String,Object> bulletins(LegacyUser u,boolean expired,int page) {
u.require(18);page(page);return db.readTransaction(()->{
String where=" WHERE t.tif_unerasable<>1 AND "+bulletinScope()+(expired?"":" AND t.tif_endtime>=?");
var args=new ArrayList<Object>(List.of(u.employeeId(),u.employeeId(),u.headquartersId()));if(!expired)args.add(LocalDateTime.now());
long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*) FROM "+ticker+" t"+where,Long.class,args.toArray())).orElse(0L);
boolean admin=root(u);
var rows=db.queryForList("SELECT t.*,CONCAT_WS(' ',su.usr_firstname,su.usr_name) AS sender,CONCAT_WS(' ',ru.usr_firstname,ru.usr_name) AS recipient,h.hq_mnemonic AS headquarters FROM "+ticker+" t LEFT JOIN employee se ON se.emp_id=t.emp_id_creator LEFT JOIN user su ON su.usr_id=se.usr_id LEFT JOIN employee re ON re.emp_id=t.emp_id LEFT JOIN user ru ON ru.usr_id=re.usr_id LEFT JOIN headquarters h ON h.hq_id=t.hq_id"+where+" ORDER BY t.tif_createtime DESC,t.tif_id DESC LIMIT 100 OFFSET "+page*100,args.toArray());
return fields("total",total,"items",rows.stream().<Map<String,Object>>map(r->fields("id",id(r,"tif_id"),"created",dateTime(r,"tif_createtime"),"from",dateTime(r,"tif_starttime"),"to",dateTime(r,"tif_endtime"),"text",text(r,"tif_text"),"sender",text(r,"sender"),"senderId",id(r,"emp_id_creator"),"recipient",text(r,"recipient"),"headquarters",text(r,"headquarters"),"removable",id(r,"emp_id_creator")==u.employeeId()||admin,"revision",revision(bulletinVersion(r)))).toList());
});
}
private static Map<String,Object> bulletinVersion(Map<String,Object> row){var v=new LinkedHashMap<String,Object>();for(String k:List.of("tif_id","hq_id","emp_id","emp_id_creator","tif_createtime","tif_starttime","tif_endtime","tif_subject","tif_text","tif_unerasable"))v.put(k,row.get(k));return v;}
Map<String,Object> createBulletin(LegacyUser u,Bulletin b) {
u.require(18);if(b==null||b.employee()<0||b.headquarters()<0)throw bad("Mitteilung oder Empfängerauswahl ungültig.");range(b.from(),b.to());String body=required(b.text(),limit(u,"NEWSTICKER_MAX_BODY_LENGTH",1000),"Mitteilung");
if(b.to().isBefore(LocalDate.now()))throw bad("Das Enddatum darf nicht in der Vergangenheit liegen.");
var allowed=headquarters(u).stream().map(r->id(r,"id")).toList();long hq=b.headquarters();
if(b.employee()>0){var recipient=one(db.queryForList("SELECT u.hq_id FROM employee e JOIN user u ON u.usr_id=e.usr_id WHERE e.emp_id=? AND u.usr_type=1",b.employee()));if(!allowed.contains(id(recipient,"hq_id")))throw new AccessDeniedException("Empfänger nicht freigegeben.");hq=0;}
else if(hq==0){u.require(10);if(allowed.isEmpty())throw bad("Keine Niederlassungen verfügbar.");}
else if(!allowed.contains(hq))throw new AccessDeniedException("Niederlassung nicht freigegeben.");
var targets=hq==0&&b.employee()==0?allowed:List.of(hq);return db.writeTransaction(()->{var ids=new ArrayList<Long>();for(long target:targets)ids.add(db.insert(ticker,fields("hq_id",target,"emp_id",b.employee(),"emp_id_creator",u.employeeId(),"tif_createtime",LocalDateTime.now(),"tif_starttime",b.from().isBefore(LocalDate.now())?LocalDateTime.now():b.from().atStartOfDay(),"tif_endtime",b.to().atTime(23,59,59),"tif_subject","","tif_text",body,"tif_unerasable",0)));return fields("ids",ids);});
}
void deleteBulletin(LegacyUser u,long id,String rev) {
u.require(18);db.writeTransaction(()->{var row=one(db.lockRows("SELECT t.* FROM "+ticker+" t WHERE t.tif_id=? AND t.tif_unerasable<>1 AND "+bulletinScope(),id,u.employeeId(),u.employeeId(),u.headquartersId()));
if(id(row,"emp_id_creator")!=u.employeeId()&&!root(u))throw new AccessDeniedException("Nur der Verfasser oder Niederlassungsadministrator darf diese Mitteilung entfernen.");unchanged(bulletinVersion(row),rev);db.update("DELETE FROM "+ticker+" WHERE tif_id=?",id);return null;});
}
static void page(int n){if(n<0||n>100000)throw bad("Ungültige Seite.");}
Map<String,Object> history(LegacyUser u,Query q) {
u.require(4);if(q==null)throw bad("Filter fehlen.");range(q.from(),q.to());page(q.page());
if(q.states()==null||q.states().isEmpty()||q.states().size()>4||q.states().stream().anyMatch(v->v==null||v<0||v>3)||q.maxPriority()<0||q.maxPriority()>3||!List.of("ALL","EXCLUDE_HQ","ONLY_HQ").contains(q.direction()))throw bad("Ungültige Nachrichtenfilter.");
var args=new ArrayList<Object>(List.of(u.headquartersId(),q.from().atStartOfDay(),q.to().plusDays(1).atStartOfDay(),q.maxPriority()));args.addAll(q.states());
String where=" WHERE f.hq_id=? AND f.mf_createtime>=? AND f.mf_createtime<? AND f.mf_prio<=? AND f.mf_state IN ("+CrmScope.marks(q.states().size())+")";
if(q.direction().equals("EXCLUDE_HQ"))where+=" AND f.mf_type<>1";if(q.direction().equals("ONLY_HQ"))where+=" AND f.mf_type=1";
if(q.courier()!=null&&q.courier()>0){var c=one(db.queryForList("SELECT usr_id FROM courier WHERE cr_id=? AND hq_id=?",q.courier(),u.headquartersId()));where+=" AND (f.usr_id_sender=? OR f.usr_id_receiver=?)";args.add(id(c,"usr_id"));args.add(id(c,"usr_id"));}
final String filter=where;return db.readTransaction(()->{
long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*) FROM "+forum+" f"+filter,Long.class,args.toArray())).orElse(0L);
var rows=db.queryForList("SELECT f.*,CONCAT_WS(' ',s.usr_firstname,s.usr_name) AS sender,CONCAT_WS(' ',r.usr_firstname,r.usr_name) AS recipient,CONCAT_WS(' ',rd.usr_firstname,rd.usr_name) AS reader,CONCAT_WS(' ',an.usr_firstname,an.usr_name) AS answerer FROM "+forum+" f LEFT JOIN user s ON s.usr_id=f.usr_id_sender LEFT JOIN user r ON r.usr_id=f.usr_id_receiver LEFT JOIN user rd ON rd.usr_id=f.usr_id_first_reader LEFT JOIN user an ON an.usr_id=f.usr_id_answerer"+filter+" ORDER BY f.mf_createtime DESC,f.mf_id DESC LIMIT 100 OFFSET "+q.page()*100,args.toArray());
return fields("total",total,"items",rows.stream().<Map<String,Object>>map(r->fields("id",id(r,"mf_id"),"created",dateTime(r,"mf_createtime"),"delivery",dateTime(r,"mf_delivery"),"acknowledged",dateTime(r,"mf_acknowledge"),"state",id(r,"mf_state"),"priority",id(r,"mf_prio"),"sender",text(r,"sender"),"recipient",text(r,"recipient"),"reader",text(r,"reader"),"answerer",text(r,"answerer"),"subject",text(r,"mf_subject"),"body",text(r,"mf_body"),"replyTo",id(r,"mf_pre_id"),"canRead",id(r,"mf_usr_type")==3&&id(r,"mf_state")<2,"canReply",id(r,"mf_usr_type")==3&&id(r,"mf_state")<3,"revision",revision(messageVersion(r)))).toList());
});
}
static Map<String,Object> messageVersion(Map<String,Object> r){var v=new TreeMap<String,Object>();r.forEach((k,val)->{if(k.startsWith("mf_")||k.startsWith("usr_id_")||k.equals("hq_id"))v.put(k,val);});return v;}
Map<String,Object> incoming(LegacyUser u,long id){var row=one(db.queryForList("SELECT * FROM "+forum+" WHERE mf_id=? AND hq_id=? AND mf_usr_type=3",id,u.headquartersId()));if(id(row,"mf_state")>=3)throw bad("Nachricht wurde bereits beantwortet.");return row;}
void acknowledge(LegacyUser u,long id,String rev){u.require(4);var row=incoming(u,id);unchanged(messageVersion(row),rev);if(id(row,"mf_state")>=2)throw bad("Nachricht wurde bereits gelesen.");db.writeTransaction(()->{if(db.update("UPDATE "+forum+" SET mf_state=2,mf_acknowledge=?,usr_id_first_reader=? WHERE mf_id=? AND hq_id=? AND mf_usr_type=3 AND mf_state=?",LocalDateTime.now(),u.userId(),id,u.headquartersId(),id(row,"mf_state"))!=1)throw bad("Die Nachricht wurde inzwischen gelesen. Bitte aktualisieren.");return null;});}
List<Map<String,Object>> couriers(LegacyUser u,String search){u.require(4);if(search==null||search.length()>100)throw bad("Ungültiger Suchtext.");return db.queryForList("SELECT c.cr_id AS id,c.usr_id,CONCAT_WS(' · ',c.cr_sid,c.cr_eid,p.cmp_comp) AS label,c.cr_mobile_pda AS device,c.cr_availabletime AS available FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE c.hq_id=? AND p.cmp_archived=0 AND p.cmp_authenticated=1 AND (c.cr_sid LIKE ? OR c.cr_eid LIKE ? OR p.cmp_comp LIKE ?) ORDER BY label LIMIT 100",u.headquartersId(),"%"+search+"%","%"+search+"%","%"+search+"%");}
List<Map<String,Object>> groups(LegacyUser u){u.require(4);return db.queryForList("SELECT msggrp_id,msggrp_name,msggrp_group,hq_id FROM messagegroup WHERE hq_id=? ORDER BY msggrp_name,msggrp_id",u.headquartersId()).stream().<Map<String,Object>>map(r->fields("id",id(r,"msggrp_id"),"name",text(r,"msggrp_name"),"groups",GroupAdministration.memberships(text(r,"msggrp_group")),"revision",revision(r))).toList();}
List<Map<String,Object>> genericGroups(LegacyUser u){u.require(4);u.require(52);return db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0) ORDER BY grp_name",mandator(db,u),u.headquartersId());}
private Map<String,Object> group(LegacyUser u,long id,boolean lock){String sql="SELECT msggrp_id,msggrp_name,msggrp_group,hq_id FROM messagegroup WHERE msggrp_id=? AND hq_id=?";return one(lock?db.lockRows(sql,id,u.headquartersId()):db.queryForList(sql,id,u.headquartersId()));}
void groupLog(LegacyUser u,long id,String name,String operation,String detail) {
audit.audit(u,0,0,0,203,"MSGGRP_ID="+id+"|MSGGRP_NAME="+name.replace("|","/")+"|OP="+operation+detail,UUID.randomUUID().toString());
}
Map<String,Object> saveGroup(LegacyUser u,long id,GroupSave s) {
u.require(4);u.require(52);
if(s==null||s.groups()==null||s.groups().size()>100||s.groups().stream().anyMatch(Objects::isNull))throw bad("Ungültige Gruppen.");
String name=required(s.name(),100,"Bezeichnung");var allowed=genericGroups(u).stream().map(r->id(r,"id")).toList();
if(!allowed.containsAll(s.groups()))throw bad("Eine zugeordnete Gruppe ist nicht freigegeben.");
String joined=String.join(",",s.groups().stream().distinct().sorted().map(String::valueOf).toList());if(joined.length()>255)throw bad("Zu viele zugeordnete Gruppen.");
return db.writeTransaction(()->{
lockHeadquarters(db,u);var before=id>0?group(u,id,true):Map.<String,Object>of();if(id>0)unchanged(before,s.revision());
if(!db.queryForList("SELECT msggrp_id FROM messagegroup WHERE hq_id=? AND msggrp_name=? AND msggrp_id<>?",u.headquartersId(),name,id).isEmpty())throw bad("Nachrichtengruppe existiert bereits.");
long saved=id;if(id==0)saved=db.insert("messagegroup",fields("hq_id",u.headquartersId(),"msggrp_name",name,"msggrp_group",joined));else db.update("UPDATE messagegroup SET msggrp_name=?,msggrp_group=? WHERE msggrp_id=? AND hq_id=?",name,joined,id,u.headquartersId());
if(id==0||!text(before,"msggrp_name").equals(name))groupLog(u,saved,name,id==0?"INSERT":"UPDATE","");
var old=GroupAdministration.memberships(text(before,"msggrp_group"));var next=new TreeSet<>(s.groups());
for(long g:next)if(!old.contains(g))groupLog(u,saved,name,"ADD_GROUP","|GRP_ID="+g);
for(long g:old)if(!next.contains(g))groupLog(u,saved,name,"REMOVE_GROUP","|GRP_ID="+g);
return fields("id",saved);
});
}
void deleteGroup(LegacyUser u,long id,String rev){u.require(4);u.require(52);db.writeTransaction(()->{lockHeadquarters(db,u);var old=group(u,id,true);unchanged(old,rev);var rows=db.lockRows("SELECT cr_id,cr_msggrp,hq_id FROM courier WHERE cr_msggrp LIKE ?","%,"+id+",%");if(rows.stream().anyMatch(r->id(r,"hq_id")!=u.headquartersId()))throw bad("Die Gruppe hat fremde Niederlassungszuordnungen. Diese müssen zuerst bereinigt werden.");for(var row:rows){var members=GroupAdministration.memberships(text(row,"cr_msggrp"));members.remove(id);db.update("UPDATE courier SET cr_msggrp=? WHERE cr_id=?",comma(members),id(row,"cr_id"));}db.update("DELETE FROM messagegroup WHERE msggrp_id=? AND hq_id=?",id,u.headquartersId());groupLog(u,id,text(old,"msggrp_name"),"REMOVE","");return null;});}
static String comma(Collection<Long> ids){return ids.isEmpty()?"":","+String.join(",",ids.stream().sorted().map(String::valueOf).toList())+",";}
List<Map<String,Object>> groupMembers(LegacyUser u,long group){u.require(4);u.require(52);group(u,group,false);return db.queryForList("SELECT c.cr_id AS id,CONCAT_WS(' · ',c.cr_sid,c.cr_eid,p.cmp_comp) AS label,c.cr_msggrp FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE c.hq_id=? ORDER BY label",u.headquartersId()).stream().<Map<String,Object>>map(r->fields("id",id(r,"id"),"label",text(r,"label"),"member",GroupAdministration.memberships(text(r,"cr_msggrp")).contains(group),"revision",revision(r))).toList();}
void membership(LegacyUser u,long group,long courier,String rev,boolean member){u.require(4);u.require(52);db.writeTransaction(()->{lockHeadquarters(db,u);group(u,group,true);var row=one(db.lockRows("SELECT c.cr_id AS id,CONCAT_WS(' · ',c.cr_sid,c.cr_eid,p.cmp_comp) AS label,c.cr_msggrp FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE c.hq_id=? AND c.cr_id=?",u.headquartersId(),courier));unchanged(row,rev);var ids=GroupAdministration.memberships(text(row,"cr_msggrp"));if(member)ids.add(group);else ids.remove(group);String value=comma(ids);if(value.length()>255)throw bad("Zu viele Nachrichtengruppen.");db.update("UPDATE courier SET cr_msggrp=? WHERE cr_id=? AND hq_id=?",value,courier,u.headquartersId());if(!text(row,"cr_msggrp").equals(value))groupLog(u,group,"EMPTY",member?"ADD_COURIER":"REMOVE_COURIER","|CR_ID="+courier);return null;});}
private List<Map<String,Object>> recipients(LegacyUser u,Dispatch d) {
var unique=new TreeMap<Long,Map<String,Object>>();for(var row:recipientRows(u,d)){if(id(row,"id")<=0)throw bad("Empfänger hat keine gültige Benutzerkennung.");unique.putIfAbsent(id(row,"id"),row);}return List.copyOf(unique.values());
}
private List<Map<String,Object>> recipientRows(LegacyUser u,Dispatch d) {
if(d.replyTo()!=null&&d.replyTo()>0){var row=incoming(u,d.replyTo());unchanged(messageVersion(row),d.revision());return db.queryForList("SELECT c.usr_id AS id,CONCAT_WS(' · ',c.cr_sid,p.cmp_comp) AS label FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE c.usr_id=? AND c.hq_id=?",id(row,"usr_id_sender"),u.headquartersId());}
if(d.courier()!=null&&d.courier()>0)return db.queryForList("SELECT c.usr_id AS id,CONCAT_WS(' · ',c.cr_sid,p.cmp_comp) AS label FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE c.cr_id=? AND c.hq_id=? AND p.cmp_archived=0 AND p.cmp_authenticated=1",d.courier(),u.headquartersId());
var args=new ArrayList<Object>();String scope;
if(d.allActive()){u.require(52);u.require(10);var h=headquarters(u).stream().map(r->id(r,"id")).toList();if(h.isEmpty())throw bad("Keine Niederlassungen verfügbar.");scope="c.hq_id IN ("+CrmScope.marks(h.size())+") AND c.cr_availabletime>?";args.addAll(h);var par=db.queryForList("SELECT par_value FROM parameter WHERE hq_id=0 AND emp_id=0 AND par_key='MESSAGE_MIN_DAYS_BEFORE'");int days=7;try{if(par.size()==1)days=Integer.parseInt(text(par.getFirst(),"par_value"));}catch(NumberFormatException ignored){}if(days<1||days>365)days=7;args.add(LocalDateTime.now().minusDays(days));}
else {if(d.groups()==null||d.groups().isEmpty()||d.groups().size()>100||d.groups().stream().anyMatch(Objects::isNull))throw bad("Bitte mindestens eine Nachrichtengruppe oder einen Transporteur auswählen.");var generic=new TreeSet<Long>();for(long g:d.groups())generic.addAll(GroupAdministration.memberships(text(group(u,g,false),"msggrp_group")));
// Generic group membership is transitive; a child's grp_group contains its parents.
var tree=db.queryForList("SELECT grp_id,grp_group FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0)",mandator(db,u),u.headquartersId());var allowed=tree.stream().map(r->id(r,"grp_id")).toList();if(!allowed.containsAll(generic))throw bad("Nachrichtengruppe enthält nicht freigegebene Gruppen.");boolean changed=true;while(changed){changed=false;for(var g:tree)if(!Collections.disjoint(generic,GroupAdministration.memberships(text(g,"grp_group"))))changed|=generic.add(id(g,"grp_id"));}
scope="c.hq_id=? AND (";args.add(u.headquartersId());var predicates=new ArrayList<String>();for(long g:new TreeSet<>(d.groups())){predicates.add("c.cr_msggrp LIKE ?");args.add("%,"+g+",%");}for(long g:generic){predicates.add("c.cr_group LIKE ?");args.add("%,"+g+",%");}scope+=String.join(" OR ",predicates)+")";
}
return db.queryForList("SELECT DISTINCT c.usr_id AS id,CONCAT_WS(' · ',c.cr_sid,p.cmp_comp) AS label FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE "+scope+" AND p.cmp_archived=0 AND p.cmp_authenticated=1 ORDER BY id LIMIT 5001",args.toArray());
}
Map<String,Object> preview(LegacyUser u,Dispatch d) {
u.require(4);if(d==null)throw bad("Nachricht fehlt.");String subject=d.subject()==null?"":d.subject().strip();if(subject.length()>40)throw bad("Betreff darf höchstens 40 Zeichen enthalten.");String body=required(d.body(),limit(u,"MESSAGE_MAX_BODY_LENGTH",250),"Nachricht");
if((d.replyTo()!=null&&d.replyTo()>0?1:0)+(d.courier()!=null&&d.courier()>0?1:0)+(d.groups()!=null&&!d.groups().isEmpty()?1:0)+(d.allActive()?1:0)!=1)throw bad("Bitte genau eine Empfängerauswahl verwenden.");
var rows=recipients(u,d);if(rows.isEmpty()||rows.size()>5000)throw bad("Die Auswahl muss 1 bis 5000 Empfänger enthalten.");
var p=new Pending();p.user=u.userId();p.hq=u.headquartersId();p.draft=d;p.reply=d.replyTo()==null?0:d.replyTo();p.subject=subject;p.body=body;p.recipients=rows;p.recipientRevision=revision(rows);p.type=d.allActive()||d.groups()!=null&&!d.groups().isEmpty()?1:0;p.expires=Instant.now().plusSeconds(900);
String token=UUID.randomUUID().toString();synchronized(previews){previews.values().removeIf(v->v.expires.isBefore(Instant.now())&&!v.sending);if(previews.size()>=1000)throw bad("Zu viele Versandvorschauen.");previews.put(token,p);}return fields("token",token,"recipients",rows,"subject",subject,"body",body,"replyTo",p.reply);
}
Map<String,Object> send(LegacyUser u,String token) {
u.require(4);Pending p;synchronized(previews){p=previews.get(token);if(p==null||p.user!=u.userId()||p.hq!=u.headquartersId()||p.expires.isBefore(Instant.now()))throw bad("Vorschau abgelaufen. Bitte neu erstellen.");if(p.result!=null)return p.result;if(p.sending)throw bad("Versand läuft bereits.");unchanged(recipients(u,p.draft),p.recipientRevision);if(p.reply>0){int claimed=db.writeTransaction(()->db.update("UPDATE "+forum+" SET mf_state=3,usr_id_answerer=?,mf_acknowledge=? WHERE mf_id=? AND hq_id=? AND mf_usr_type=3 AND mf_state<3",u.userId(),LocalDateTime.now(),p.reply,u.headquartersId()));if(claimed!=1)throw bad("Diese Nachricht wurde zwischenzeitlich beantwortet. Bitte Historie neu laden.");}if(p.draft.allActive())db.writeTransaction(()->{audit.audit(u,0,0,0,203,"MSGGRP_ID=ALL|MSGGRP_NAME=ALL|OP=SEND_TO_ALL|RECIPIENTS="+p.recipients.size(),token);return null;});p.sending=true;}
var sent=new ArrayList<Map<String,Object>>();String warning="";int uncertain=0;
try {
for(var r:p.recipients){long message=db.writeTransaction(()->db.insert(forum,fields("hq_id",u.headquartersId(),"mf_type",p.type,"mf_createtime",LocalDateTime.now(),"usr_id_sender",u.userId(),"usr_id_receiver",id(r,"id"),"mf_usr_type",1,"mf_subject",p.subject,"mf_body",p.body,"mf_pre_id",p.reply)));sent.add(fields("recipient",id(r,"id"),"message",message));}
}catch(RuntimeException e){uncertain=1;warning="Die Übergabe wurde unterbrochen. Der nächste Empfänger könnte die Nachricht bereits erhalten haben. Bitte die Historie prüfen; es erfolgt keine automatische Wiederholung.";}
if(p.reply>0&&uncertain>0)warning+=" Die Ursprungsnachricht ist zur Vermeidung doppelter Antworten als beantwortet markiert; die tatsächliche Antwortübergabe in der Historie prüfen.";
var result=fields("queued",sent.size(),"total",p.recipients.size(),"uncertain",uncertain,"unattempted",p.recipients.size()-sent.size()-uncertain,"messages",sent,"warning",warning);
synchronized(previews){p.result=result;p.sending=false;}return result;
}
}
@@ -0,0 +1,29 @@
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/messages")
class MessageController {
private final MessageAdministration service;
MessageController(MessageAdministration service){this.service=service;}
record Revision(String revision) {}
record Membership(String revision,boolean member) {}
@GetMapping("/options") Map<String,Object> options(Authentication a){return service.options(user(a));}
@GetMapping("/bulletins") Map<String,Object> bulletins(Authentication a,@RequestParam(defaultValue="false") boolean expired,@RequestParam(defaultValue="0") int page){return service.bulletins(user(a),expired,page);}
@PostMapping("/bulletins") Map<String,Object> create(Authentication a,@RequestBody MessageAdministration.Bulletin b){return service.createBulletin(user(a),b);}
@DeleteMapping("/bulletins/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void remove(Authentication a,@PathVariable long id,@RequestBody Revision r){service.deleteBulletin(user(a),id,r.revision());}
@PostMapping("/history") Map<String,Object> history(Authentication a,@RequestBody MessageAdministration.Query q){return service.history(user(a),q);}
@PostMapping("/{id}/read") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void read(Authentication a,@PathVariable long id,@RequestBody Revision r){service.acknowledge(user(a),id,r.revision());}
@GetMapping("/couriers") List<Map<String,Object>> couriers(Authentication a,@RequestParam(defaultValue="") String q){return service.couriers(user(a),q);}
@GetMapping("/groups") List<Map<String,Object>> groups(Authentication a){return service.groups(user(a));}
@GetMapping("/generic-groups") List<Map<String,Object>> generic(Authentication a){return service.genericGroups(user(a));}
@PostMapping("/groups") Map<String,Object> group(Authentication a,@RequestBody MessageAdministration.GroupSave r){return service.saveGroup(user(a),0,r);}
@PutMapping("/groups/{id}") Map<String,Object> group(Authentication a,@PathVariable long id,@RequestBody MessageAdministration.GroupSave r){return service.saveGroup(user(a),id,r);}
@DeleteMapping("/groups/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void group(Authentication a,@PathVariable long id,@RequestBody Revision r){service.deleteGroup(user(a),id,r.revision());}
@GetMapping("/groups/{id}/members") List<Map<String,Object>> members(Authentication a,@PathVariable long id){return service.groupMembers(user(a),id);}
@PutMapping("/groups/{id}/members/{courier}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void member(Authentication a,@PathVariable long id,@PathVariable long courier,@RequestBody Membership r){service.membership(user(a),id,courier,r.revision(),r.member());}
@PostMapping("/preview") Map<String,Object> preview(Authentication a,@RequestBody MessageAdministration.Dispatch d){return service.preview(user(a),d);}
@PostMapping("/send/{token}") Map<String,Object> send(Authentication a,@PathVariable String token){return service.send(user(a),token);}
}
@@ -0,0 +1,40 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import java.time.*;
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;
/** Category-scoped EAV records shared by native editors; all writes retain PHP field IDs. */
abstract class MetafieldRecords {
final DatabaseService db;final FormAdministration forms;final MessageAdministration messages;
final long category;
MetafieldRecords(DatabaseService db,FormAdministration forms,MessageAdministration messages,long category){this.db=db;this.forms=forms;this.messages=messages;this.category=category;}
record Query(List<Long> headquarters,String search,boolean prefix,int page){}
record Save(long headquarters,String revision,String schemaRevision,Map<Long,String> values){}
abstract void access(LegacyUser u);
abstract String validateValue(LegacyUser u,long hq,Map<String,Object> def,String value);
void validateValues(List<Map<String,Object>> defs,Map<Long,String> values){}
List<Map<String,Object>> headquarters(LegacyUser u){access(u);return messages.headquarters(u);}
long checked(LegacyUser u,long hq){access(u);if(headquarters(u).stream().noneMatch(r->id(r,"id")==hq))throw new AccessDeniedException("Niederlassung nicht freigegeben.");return forms.fieldHeadquarters(u,category)==0?0:hq;}
static String definitionsSql(long category){return "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="+category+" AND ck.hq_id=? ORDER BY ck.mtfck_sort,ck.mtfck_id";}
List<Map<String,Object>> definitions(long hq){var rows=db.queryForList(definitionsSql(category),hq);if(rows.isEmpty())throw bad("Keine Formularfelder für diese Niederlassung eingerichtet.");return rows;}
Map<String,Object> schema(LegacyUser u,long hq){long scope=checked(u,hq);var defs=definitions(scope);return fields("fields",defs,"headquarters",scope,"revision",revision(defs));}
List<Map<String,Object>> raw(long object,List<Map<String,Object>> defs){var args=new ArrayList<Object>(List.of(object));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());}
static Map<Long,String> values(List<Map<String,Object>> rows){var result=new LinkedHashMap<Long,String>();for(var r:rows)if(result.put(id(r,"mtfck_id"),text(r,"mtfv_value"))!=null)throw bad("Doppelte Formularwerte im Bestand. Bitte zuerst bereinigen.");return result;}
Map<String,Object> get(LegacyUser u,long hq,long object){long scope=checked(u,hq);var defs=definitions(scope);var rows=raw(object,defs);if(rows.isEmpty())one(rows);return fields("id",object,"headquarters",hq,"values",values(rows),"revision",revision(rows),"schema",fields("fields",defs,"headquarters",scope,"revision",revision(defs)));}
Map<String,Object> query(LegacyUser u,Query q){access(u);if(q==null||q.headquarters()==null||q.headquarters().isEmpty()||q.headquarters().size()>100||q.headquarters().stream().anyMatch(Objects::isNull))throw bad("Bitte Niederlassungen auswählen.");MessageAdministration.page(q.page());String search=q.search()==null?"":q.search();if(search.length()>100)throw bad("Suchbegriff ist zu lang.");var scopes=new TreeSet<Long>();for(long h:q.headquarters())scopes.add(checked(u,h));var args=new ArrayList<Object>(scopes);String where="ck.mtfc_id="+category+" AND ck.hq_id IN ("+marks(scopes.size())+")";if(!search.isBlank()){where+=" AND v.mtfv_value LIKE ?";args.add((q.prefix()?"":"%")+search.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%");}
String from=" FROM metafieldvalue v JOIN metafieldcategorykey ck ON ck.mtfck_id=v.mtfck_id WHERE "+where;long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*) FROM (SELECT DISTINCT ck.hq_id,v.mtfv_id"+from+") matches",Long.class,args.toArray())).orElse(0L);var items=db.queryForList("SELECT DISTINCT ck.hq_id AS headquarters,v.mtfv_id AS id"+from+" ORDER BY v.mtfv_id DESC,ck.hq_id LIMIT 50 OFFSET "+q.page()*50,args.toArray());var result=new ArrayList<Map<String,Object>>();for(var item:items){long hq=id(item,"headquarters");var defs=definitions(hq);var vals=values(raw(id(item,"id"),defs));result.add(fields("id",id(item,"id"),"headquarters",hq==0?u.headquartersId():hq,"global",hq==0,"fields",defs.stream().map(d->fields("label",text(d,"label"),"type",text(d,"type"),"value",vals.getOrDefault(id(d,"id"),""))).toList()));}return fields("items",result,"total",total);
}
Map<String,Object> save(LegacyUser u,Long object,Save s){access(u);if(s==null||s.values()==null||s.values().size()>1000)throw bad("Formulardaten fehlen.");long scope=checked(u,s.headquarters());var defs=definitions(scope);unchanged(defs,s.schemaRevision());var keys=defs.stream().map(d->id(d,"id")).toList();if(!keys.containsAll(s.values().keySet()))throw bad("Unbekanntes Formularfeld.");var old=object==null?List.<Map<String,Object>>of():raw(object,defs);if(object!=null){if(old.isEmpty())one(old);unchanged(old,s.revision());}var before=values(old);var next=new LinkedHashMap<Long,String>();for(var def:defs){long key=id(def,"id");String type=text(def,"type"),v=Objects.requireNonNullElse(s.values().getOrDefault(key,before.getOrDefault(key,"")),"");if(v.getBytes(StandardCharsets.UTF_8).length>65535)throw bad(text(def,"label")+": Feld ist zu lang.");if(!v.equals(before.getOrDefault(key,""))){v=validateValue(u,s.headquarters(),def,v);}next.put(key,v);}if(next.values().stream().allMatch(String::isBlank))throw bad("Bitte mindestens ein Formularfeld ausfüllen.");
validateValues(defs,next);
return db.contactWriteTransaction(()->{unchanged(definitions(scope),s.schemaRevision());var current=object==null?List.<Map<String,Object>>of():raw(object,defs);if(object!=null){if(current.isEmpty())one(current);unchanged(current,s.revision());}long saved=object==null?db.queryForObject("SELECT COALESCE(MAX(mtfv_id),0)+1 FROM metafieldvalue",Long.class):object;if(saved<=0||saved>Integer.MAX_VALUE)throw bad("Formular-ID-Bereich ausgeschöpft.");if(object!=null){var a=new ArrayList<Object>(List.of(saved));a.addAll(keys);db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(keys.size())+")",a.toArray());}for(var def:defs){long key=id(def,"id");String value=next.get(key);db.update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",key,saved,value);if(id(def,"history")==1){String previous=db.queryForList("SELECT mtfv_value FROM metafieldvaluehistory WHERE mtfck_id=? AND mtfv_id=? ORDER BY mtfvh_createtime DESC LIMIT 1",key,saved).stream().map(r->text(r,"mtfv_value")).findFirst().orElse("");if(!previous.equals(value))db.update("INSERT INTO metafieldvaluehistory (mtfck_id,mtfv_id,mtfv_value,mtfvh_createtime) VALUES (?,?,?,?)",key,saved,value,LocalDateTime.now(ZoneId.of("Europe/Berlin")));}}return fields("id",saved);});
}
void remove(LegacyUser u,long hq,long object,String rev){long scope=checked(u,hq);var defs=definitions(scope);db.contactWriteTransaction(()->{unchanged(definitions(scope),revision(defs));var old=raw(object,defs);if(old.isEmpty())one(old);unchanged(old,rev);var a=new ArrayList<Object>(List.of(object));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<Map<String,Object>> history(LegacyUser u,long hq,long object){get(u,hq,object);var defs=definitions(checked(u,hq));var a=new ArrayList<Object>(List.of(object));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());}
Map<String,Object> rendering(LegacyUser u,long hq,long object){var data=get(u,hq,object);var row=one(db.queryForList("SELECT c.mtft_id,t.mtft_content FROM metafieldcategory c LEFT JOIN metafieldtemplate t ON t.mtft_id=c.mtft_id WHERE c.mtfc_id=?",category));var defs=definitions(checked(u,hq));return fields("data",data,"template",text(row,"mtft_content"),"definitions",defs,"revision",revision(Arrays.asList(data.get("revision"),data.get("schema"),row)));}
}
@@ -12,9 +12,11 @@ public final class Models {
public enum CourierStatus { AVAILABLE, BUSY, OFFLINE }
public enum CancelMode { FREE, CHARGEABLE }
public static class Customer {
public Long id, version;
public Long id, version, costCenterId;
public String costCenterName;
public String number, name, street, postalCode, city, contact, phone, email;
public Integer paymentDays;
public String paymentTermLabel;
public boolean active;
}
public static class Courier {
@@ -57,5 +59,27 @@ public final class Models {
public String action, detail, actor;
}
public record Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders) {}
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders,
boolean createOrders, boolean readStatistics, boolean readAppointments, boolean readMessages, boolean manageForms) {
public Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders, boolean createOrders, boolean readStatistics, boolean readAppointments, boolean readMessages) {
this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,createOrders,readStatistics,readAppointments,readMessages,false);
}
public Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders, boolean createOrders, boolean readStatistics, boolean readAppointments) {
this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,createOrders,readStatistics,readAppointments,false);
}
public Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders, boolean createOrders, boolean readStatistics) {
this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,createOrders,readStatistics,false);
}
public Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders, boolean createOrders) {
this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,createOrders,false);
}
public Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders) {
this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,false);
}
}
}
@@ -0,0 +1,27 @@
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.OrderEntryService.fields;
@RestController @RequestMapping("/api/newsletters")
class NewsletterController {
final NewsletterService service;final NewsletterTemplate templates;final NewsletterImages images;final NewsletterDispatch dispatch;
NewsletterController(NewsletterService service,NewsletterTemplate templates,NewsletterImages images,NewsletterDispatch dispatch){this.service=service;this.templates=templates;this.images=images;this.dispatch=dispatch;}
@GetMapping("/mail/options") Map<String,Object> mailOptions(Authentication a){return dispatch.options(user(a));}
@PostMapping("/{id}/mail/preview") Map<String,Object> mailPreview(Authentication a,@PathVariable long id,@RequestBody NewsletterDispatch.Draft d){return dispatch.preview(user(a),id,d);}
@PostMapping("/mail/{token}/send") Map<String,Object> mailSend(Authentication a,@PathVariable String token){return dispatch.send(user(a),token);}
@GetMapping("/mail/{token}") Map<String,Object> mailStatus(Authentication a,@PathVariable String token){return dispatch.status(user(a),token);}
record Revision(String revision){}
record ImageUpload(String data){}
@PostMapping("/images") Map<String,Object> upload(Authentication a,@RequestBody ImageUpload s){return fields("filename",images.upload(user(a),s.data()));}
@GetMapping("/options") Map<String,Object> options(Authentication a){var u=user(a);return fields("headquarters",service.headquarters(u),"currentHeadquarters",u.headquartersId());}
@GetMapping("/schema") Map<String,Object> schema(Authentication a,@RequestParam long headquarters){return service.schema(user(a),headquarters);}
@PostMapping("/query") Map<String,Object> query(Authentication a,@RequestBody NewsletterService.Query q){return service.query(user(a),q);}
@GetMapping("/{id}") Map<String,Object> get(Authentication a,@PathVariable long id,@RequestParam long headquarters){return service.get(user(a),headquarters,id);}
@PostMapping Map<String,Object> create(Authentication a,@RequestBody NewsletterService.Save s){return service.save(user(a),null,s);}
@PutMapping("/{id}") Map<String,Object> save(Authentication a,@PathVariable long id,@RequestBody NewsletterService.Save s){return service.save(user(a),id,s);}
@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<Map<String,Object>> history(Authentication a,@PathVariable long id,@RequestParam long headquarters){return service.history(user(a),headquarters,id);}
@GetMapping("/{id}/preview") Map<String,Object> 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"));}
}
@@ -0,0 +1,56 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import jakarta.annotation.PreDestroy;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
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 NewsletterDispatch {
record Draft(long headquarters,String mode,List<Long> headquartersSelection,String external,List<Integer> customerTypes,List<Long> branches,List<Long> groups,String cc,String bcc,boolean copiesEveryMail,String subject){}
record Recipient(String address,String type,long id,String label,String hash){}
static final String HASH="NG_NEWSLETTER_PERSONAL_HASH";
final DatabaseService db;final MetafieldRecords newsletters;final NewsletterTemplate templates;final AppointmentMailTransport transport;
final ExecutorService executor=Executors.newSingleThreadExecutor(r->{Thread t=new Thread(r,"newsletter-delivery");t.setDaemon(true);return t;});
final Map<String,Pending> pending=new LinkedHashMap<>();
static class Pending {LegacyUser actor;long object;Draft draft;String revision,recipientRevision,from,reply,html;List<Recipient> recipients;List<String> cc,bcc;volatile Instant expires;int sent,uncertain;volatile boolean started,running;String warning="";}
@Autowired NewsletterDispatch(DatabaseService db,NewsletterService newsletters,NewsletterTemplate templates,AppointmentMailTransport transport){this(db,(MetafieldRecords)newsletters,templates,transport);}
NewsletterDispatch(DatabaseService db,MetafieldRecords newsletters,NewsletterTemplate templates,AppointmentMailTransport transport){this.db=db;this.newsletters=newsletters;this.templates=templates;this.transport=transport;}
@PreDestroy void shutdown(){executor.shutdownNow();}
String parameter(long hq,String key){var rows=db.queryForList("SELECT par_value FROM parameter WHERE hq_id=? AND emp_id=0 AND par_key=?",hq,key);if(rows.size()>1)throw bad("Mehrdeutige Mail-Einstellung: "+key);return rows.isEmpty()?"":text(rows.getFirst(),"par_value");}
Map<String,Object> options(LegacyUser u){newsletters.access(u);return fields("jobs",jobs(u),"bulk",u.hasRight(20),"headquarters",newsletters.headquarters(u),"branches",u.hasRight(20)?db.queryForList("SELECT br_id AS id,br_name AS label FROM branch ORDER BY br_name"):List.of(),"groups",u.hasRight(20)?db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=0 OR hq_id IN (SELECT hq_id FROM mandatorheadquarters WHERE md_id=?))"+(u.hasRight(10)?"":" AND (hq_id=0 OR hq_id="+u.headquartersId()+")")+" ORDER BY grp_name",mandator(db,u),mandator(db,u)):List.of());}
List<Map<String,Object>> jobs(LegacyUser u){var result=new ArrayList<Map<String,Object>>();synchronized(pending){for(var e:pending.entrySet()){var p=e.getValue();if(p.actor.userId()==u.userId()&&p.actor.headquartersId()==u.headquartersId()&&p.started){synchronized(p){result.add(fields("token",e.getKey(),"newsletter",p.object,"subject",p.draft.subject(),"status",status(p)));}}}}return result;}
static List<String> addresses(String raw){if(raw==null||raw.isBlank())return List.of();if(raw.length()>10000)throw bad("Zu viele E-Mail-Adressen.");var result=new TreeMap<String,String>();for(String item:raw.split("[,;]",-1)){String a=AppointmentNotifications.address(item);result.put(a.toLowerCase(Locale.ROOT),a);}if(result.size()>100)throw bad("Höchstens 100 manuelle Adressen sind erlaubt.");return List.copyOf(result.values());}
List<Recipient> recipients(LegacyUser u,Draft d,boolean needHash){newsletters.access(u);if(d==null||d.mode()==null||!Set.of("manual","customers","groups").contains(d.mode()))throw bad("Empfängerauswahl fehlt.");var result=new ArrayList<Recipient>();
if(d.mode().equals("manual")){for(String address:addresses(d.external()))result.add(new Recipient(address,"",0,address,""));if(needHash)throw bad("Diese Vorlage enthält persönliche Abmeldelinks. Dafür Kunden oder Gruppen auswählen; eine manuelle Adresse hat keinen zugeordneten Abmeldeschlüssel.");}
else {u.require(20);if(d.headquartersSelection()==null||d.headquartersSelection().isEmpty()||d.headquartersSelection().size()>100||d.headquartersSelection().stream().anyMatch(Objects::isNull))throw bad("Bitte Empfänger-Niederlassungen auswählen.");var hqs=d.headquartersSelection().stream().distinct().sorted().toList();for(long hq:hqs)newsletters.checked(u,hq);var args=new ArrayList<Object>(hqs);String where="cs.hq_id IN ("+marks(hqs.size())+") AND cmp.cmp_newsletter=1"+(parameter(u.headquartersId(),"MAIL_NEWSLETTER_DSGVO_DISABLED").equals("1")?"":" AND cmp.cmp_dsgvo=1");
if(d.mode().equals("customers")){if(d.customerTypes()==null||d.customerTypes().isEmpty()||d.customerTypes().size()>4||d.customerTypes().stream().anyMatch(t->t==null||t<0||t>3))throw bad("Bitte Kundentypen auswählen.");where+=" AND cmp.cmp_type IN ("+marks(d.customerTypes().size())+")";args.addAll(d.customerTypes());if(d.branches()!=null&&!d.branches().isEmpty()){if(d.branches().size()>500||d.branches().stream().anyMatch(b->b==null||b<=0))throw bad("Ungültige Branchen.");where+=" AND cmp.br_id IN ("+marks(d.branches().size())+")";args.addAll(d.branches());}}
else {if(d.groups()==null||d.groups().isEmpty()||d.groups().size()>100||d.groups().stream().anyMatch(g->g==null||g<=0))throw bad("Bitte Gruppen auswählen.");var valid=db.queryForList("SELECT grp_id FROM groups WHERE md_id=? AND (hq_id=0 OR hq_id IN ("+marks(hqs.size())+"))",concat(List.of(mandator(db,u)),hqs)).stream().map(r->id(r,"grp_id")).toList();if(!valid.containsAll(d.groups()))throw bad("Empfängergruppe ist nicht freigegeben.");where+=" AND ("+groupWhere("cs.cs_group",d.groups().size())+")";args.addAll(d.groups().stream().map(g->"%,"+g+",%").toList());}
String sql="SELECT DISTINCT cs.cs_id AS id,'cs' AS type,CONCAT_WS(' ',cs.cs_eid,cmp.cmp_comp,cmp.cmp_comp2) AS label,CASE WHEN cs.cs_jbstatusmail2csc=1 THEN ca.cscad_email ELSE usr.usr_email END AS email FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id LEFT JOIN employee emp ON emp.emp_id=cs.cs_admin LEFT JOIN user usr ON usr.usr_id=emp.usr_id LEFT JOIN costcenter csc ON csc.cs_id=cs.cs_id AND cs.cs_jbstatusmail2csc=1 LEFT JOIN costcenteraddress ca ON ca.csc_id=csc.csc_id AND ca.adt_id=2 WHERE "+where+" ORDER BY cs.cs_id,email LIMIT 5001";
addRows(result,db.queryForList(sql,args.toArray()));
if(d.mode().equals("groups")){var p=new ArrayList<Object>(hqs);p.addAll(d.groups().stream().map(g->"%,"+g+",%").toList());String consent=" AND cmp.cmp_newsletter=1"+(parameter(u.headquartersId(),"MAIL_NEWSLETTER_DSGVO_DISABLED").equals("1")?"":" AND cmp.cmp_dsgvo=1");
addRows(result,db.queryForList("SELECT DISTINCT cr.cr_id AS id,'cr' AS type,CONCAT_WS(' ',cr.cr_eid,cmp.cmp_comp) AS label,usr.usr_email AS email FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id JOIN user usr ON usr.usr_id=cr.usr_id WHERE cr.hq_id IN ("+marks(hqs.size())+") AND ("+groupWhere("cr.cr_group",d.groups().size())+")"+consent+" ORDER BY cr.cr_id LIMIT 5001",p.toArray()));
addRows(result,db.queryForList("SELECT DISTINCT emp.emp_id AS id,'emp' AS type,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS label,usr.usr_email AS email FROM employee emp JOIN user usr ON usr.usr_id=emp.usr_id WHERE usr.usr_type=1 AND usr.hq_id IN ("+marks(hqs.size())+") AND ("+groupWhere("emp.emp_group",d.groups().size())+") ORDER BY emp.emp_id LIMIT 5001",p.toArray()));}
}
result=new ArrayList<>(new LinkedHashSet<>(result));if(result.isEmpty()||result.size()>5000)throw bad("Bitte 1 bis 5000 Empfänger auswählen. Größere Verteiler nach Niederlassung, Branche oder Gruppe eingrenzen.");
if(needHash){String instance=parameter(0,"GLOBAL_UNIQUE_DB_INSTANCE_NO");if(!instance.matches("[1-9][0-9]{0,9}"))throw bad("Die Datenbank-Instanznummer für persönliche Abmeldelinks ist nicht konfiguriert.");var hashes=new HashMap<String,String>();for(int start=0;start<result.size();start+=100){var slice=result.subList(start,Math.min(start+100,result.size()));var args=new ArrayList<Object>(List.of(Long.parseLong(instance)));for(var r:slice){args.add(r.type());args.add(r.id());}for(var row:db.queryMetaForList("SELECT mo_obj_type,mo_obj_id,mo_hash FROM metaobject WHERE mo_id_ref_db=? AND ("+String.join(" OR ",Collections.nCopies(slice.size(),"(mo_obj_type=? AND mo_obj_id=?)"))+")",args.toArray())){String key=text(row,"mo_obj_type")+":"+id(row,"mo_obj_id"),hash=text(row,"mo_hash");if(!hash.matches("[A-Za-z0-9_-]{1,200}")||hashes.putIfAbsent(key,hash)!=null&&!hashes.get(key).equals(hash))throw bad("Ungültiger oder mehrdeutiger Newsletter-Abmeldeschlüssel.");}}
var personalized=new ArrayList<Recipient>();for(var r:result){String hash=hashes.get(r.type()+":"+r.id());if(hash==null)throw bad("Für "+r.label()+" fehlt der persönliche Abmeldeschlüssel.");personalized.add(new Recipient(r.address(),r.type(),r.id(),r.label(),hash));}result=personalized;}
return List.copyOf(result);
}
static Object[] concat(List<?> a,List<?> b){var all=new ArrayList<Object>(a);all.addAll(b);return all.toArray();}
static String groupWhere(String column,int count){return String.join(" OR ",Collections.nCopies(count,column+" LIKE ?"));}
static void addRows(List<Recipient> out,List<Map<String,Object>> rows){if(rows.size()>5000)throw bad("Mehr als 5000 Empfänger. Bitte Auswahl eingrenzen.");for(var row:rows){String raw=text(row,"email");if(raw.isBlank())continue;for(String address:addresses(raw))out.add(new Recipient(address,text(row,"type"),id(row,"id"),text(row,"label"),""));}}
@SuppressWarnings("unchecked") Map<String,Object> preview(LegacyUser u,long object,Draft draft){newsletters.access(u);if(draft==null)throw bad("Versanddaten fehlen.");String subject=required(draft.subject(),200,"Betreff");if(subject.contains("\r")||subject.contains("\n"))throw bad("Ungültiger Betreff.");if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert.");var rendering=new LinkedHashMap<>(newsletters.rendering(u,draft.headquarters(),object));String template=text(rendering,"template");boolean personalized=template.contains("<postparser>HASH</postparser>");rendering.put("template",template.replace("<postparser>HASH</postparser>",HASH));if(text(rendering,"template").contains("<postparser>"))throw bad("Die Vorlage enthält einen unbekannten Mail-Platzhalter.");String html=templates.render(rendering);var recipients=recipients(u,draft,personalized);var p=new Pending();p.actor=u;p.object=object;p.draft=draft;p.revision=text(rendering,"revision");p.recipientRevision=revision(recipients);p.recipients=recipients;p.html=html;p.cc=addresses(draft.cc());p.bcc=addresses(draft.bcc());if((!p.cc.isEmpty()||!p.bcc.isEmpty())&&!draft.copiesEveryMail())throw bad("Für Cc/Bcc bitte die Kopie bei jeder generierten Mail bestätigen.");p.reply=AppointmentNotifications.address(text(one(db.queryForList("SELECT usr_email FROM user WHERE usr_id=? AND hq_id=?",u.userId(),u.headquartersId())),"usr_email"));String sender=parameter(u.headquartersId(),"MAIL_NEWSLETTER_SENDER_ADDRESS");if(sender.isBlank())sender=parameter(u.headquartersId(),"MAIL_SENDER_ADDRESS");p.from=AppointmentNotifications.address(!transport.from.isBlank()?transport.from:sender.isBlank()?p.reply:sender);p.expires=Instant.now().plusSeconds(900);String token=UUID.randomUUID().toString();synchronized(pending){pending.values().removeIf(v->!v.running&&v.expires.isBefore(Instant.now()));if(pending.size()>=20)throw bad("Zu viele Newsletter-Vorschauen. Bitte später erneut versuchen.");pending.put(token,p);}return fields("token",token,"from",p.from,"replyTo",p.reply,"subject",subject,"recipients",recipients.stream().map(r->fields("address",r.address(),"label",r.label())).toList(),"cc",p.cc,"bcc",p.bcc,"personalized",personalized,"html",previewHtml(html));}
static String previewHtml(String html){var doc=org.jsoup.Jsoup.parse(html);doc.select("a").removeAttr("href");return doc.html();}
Pending checked(LegacyUser u,String token){newsletters.access(u);Pending p;synchronized(pending){p=pending.get(token);}if(p==null||p.actor.userId()!=u.userId()||p.actor.headquartersId()!=u.headquartersId()||!p.running&&p.expires.isBefore(Instant.now()))throw bad("Versandvorschau ist abgelaufen oder durch einen Serverneustart nicht mehr verfügbar. Einen zuvor gestarteten Versand zuerst anhand der SMTP-Protokolle prüfen; er wird nicht automatisch wiederholt.");return p;}
Map<String,Object> send(LegacyUser u,String token){var p=checked(u,token);synchronized(p){if(!p.started){var current=newsletters.rendering(u,p.draft.headquarters(),p.object);if(!Objects.equals(current.get("revision"),p.revision))throw bad("Newsletter oder Vorlage wurde inzwischen geändert. Bitte Vorschau neu erstellen.");unchanged(recipients(u,p.draft,p.html.contains(HASH)),p.recipientRevision);p.started=true;p.running=true;p.expires=Instant.now().plusSeconds(86400);executor.execute(()->deliver(p));}return status(p);}}
void deliver(Pending p){try{for(var r:p.recipients){if(Thread.currentThread().isInterrupted()){synchronized(p){p.warning="Versand durch Serverneustart unterbrochen. Vor einer Wiederholung Zustellung prüfen.";}break;}try{transport.sendHtml(p.from,p.reply,r.address(),p.cc,p.bcc,p.draft.subject().strip(),p.html.replace(HASH,r.hash()));synchronized(p){p.sent++;}}catch(RuntimeException e){synchronized(p){p.uncertain=1;p.warning="Die Übergabe an "+r.address()+" konnte nicht bestätigt werden. Die übrigen Empfänger wurden nicht versucht. Zustellung vor einer neuen Versandvorschau prüfen.";}break;}}}finally{synchronized(p){p.running=false;}}}
Map<String,Object> status(LegacyUser u,String token){var p=checked(u,token);synchronized(p){return status(p);}}
static Map<String,Object> status(Pending p){return fields("started",p.started,"running",p.running,"sent",p.sent,"uncertain",p.uncertain,"unattempted",p.recipients.size()-p.sent-p.uncertain,"total",p.recipients.size(),"warning",p.warning);}
}
@@ -0,0 +1,17 @@
package de.stadtbote.votian;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.file.*;
import java.io.*;
import java.util.*;
import javax.imageio.ImageIO;
import static de.stadtbote.votian.AdminSupport.bad;
@Component
class NewsletterImages {
final Path directory;
NewsletterImages(@Value("${app.form-images-directory:data/form-images}")String path){directory=Path.of(path).toAbsolutePath().normalize();}
String upload(LegacyUser u,String data){u.require(0);u.require(17);if(data==null||data.length()>410000||!data.matches("data:image/(png|jpeg|gif);base64,[A-Za-z0-9+/=\\r\\n]+"))throw bad("Bitte ein PNG-, JPG- oder GIF-Bild bis 300 KB auswählen.");byte[] bytes;try{bytes=Base64.getMimeDecoder().decode(data.substring(data.indexOf(',')+1));}catch(IllegalArgumentException e){throw bad("Ungültige Bilddatei.");}if(bytes.length>300000)throw bad("Bilder dürfen höchstens 300 KB groß sein.");
try(var input=ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))){var readers=ImageIO.getImageReaders(input);if(!readers.hasNext())throw bad("Die Datei enthält kein unterstütztes Bild.");var reader=readers.next();try{reader.setInput(input);String format=reader.getFormatName().toLowerCase(Locale.ROOT);if(!Set.of("png","jpeg","gif").contains(format)||!data.startsWith("data:image/"+format+";"))throw bad("Bildinhalt und Dateityp stimmen nicht überein.");if(reader.getWidth(0)>8000||reader.getHeight(0)>8000||(long)reader.getWidth(0)*reader.getHeight(0)>32000000)throw bad("Die Bildabmessungen sind zu groß.");reader.read(0);String name="ng_"+UUID.randomUUID()+"."+(format.equals("jpeg")?"jpg":format);Files.createDirectories(directory);Files.write(directory.resolve(name),bytes,StandardOpenOption.CREATE_NEW);return name;}finally{reader.dispose();}}catch(IOException e){throw bad("Bild konnte nicht gespeichert werden. Bitte das konfigurierte Bildverzeichnis prüfen.");}
}
String data(String name){if(!name.matches("ng_[0-9a-f-]{36}\\.(png|jpg|gif)"))return null;try{Path path=directory.resolve(name);if(!Files.isRegularFile(path,LinkOption.NOFOLLOW_LINKS))return null;if(Files.size(path)>300000)throw bad("Gespeicherte Bilddatei überschreitet 300 KB.");String type=name.endsWith(".jpg")?"jpeg":name.endsWith(".gif")?"gif":"png";return "data:image/"+type+";base64,"+Base64.getEncoder().encodeToString(Files.readAllBytes(path));}catch(IOException e){throw bad("Gespeicherte Bilddatei ist nicht lesbar.");}}
}
@@ -0,0 +1,13 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.AdminSupport.*;
@Service
class NewsletterService extends MetafieldRecords {
static final String DEFINITIONS=definitionsSql(10);
NewsletterService(DatabaseService db,FormAdministration forms,MessageAdministration messages){super(db,forms,messages,10);}
void access(LegacyUser u){u.require(0);u.require(17);}
String validateValue(LegacyUser u,long hq,Map<String,Object> def,String value){String type=text(def,"type");if(!Set.of("text","varchar","image","link","url_business","checkbox").contains(type))throw bad(text(def,"label")+": Dieser Feldtyp ist kein Newsletter-Eingabefeld.");if(type.equals("image"))NewsletterTemplate.imageParts(value);if(type.equals("checkbox")&&!Set.of("","1").contains(value))throw bad("Ungültige Checkbox.");return value;}
}
@@ -0,0 +1,37 @@
package de.stadtbote.votian;
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.util.HtmlUtils;
import java.net.URI;
import java.util.*;
import java.util.regex.*;
import static de.stadtbote.votian.AdminSupport.bad;
import static de.stadtbote.votian.LegacyMapping.*;
/** Renders newsletter placeholders as data. PHP/JavaScript from templates is never executed. */
@Component
class NewsletterTemplate {
private final String legacy;private final NewsletterImages images;
NewsletterTemplate(@Value("${app.legacy-url:}")String legacy,NewsletterImages images){this.legacy=legacy.replaceAll("/+$","");this.images=images;}
static String[] imageParts(String raw){if(raw==null||raw.isBlank())return new String[]{"","","",""};String[] p=raw.split(",",4);String name=p[0].strip();if(!name.matches("[\\p{L}\\p{N}_ .()-]{1,200}\\.(?i:png|jpe?g|gif|webp)")||name.contains(".."))throw bad("Bildname muss eine PNG-, JPG-, GIF- oder WebP-Datei ohne Pfadangabe sein.");for(int i=1;i<Math.min(3,p.length);i++)if(!p[i].isBlank()&&(!p[i].strip().matches("[1-9][0-9]{0,3}")||Integer.parseInt(p[i].strip())>4000))throw bad("Bildabmessungen müssen zwischen 1 und 4000 liegen.");return new String[]{name,p.length>1?p[1].strip():"",p.length>2?p[2].strip():"",p.length>3?p[3]:""};}
static String escape(String s){return HtmlUtils.htmlEscape(s==null?"":s);}
String image(String value){if(value.isBlank())return "";String[] p=imageParts(value);String local=images.data(p[0]);String src=local!=null?local:legacy+"/documents/images/"+org.springframework.web.util.UriUtils.encodePathSegment(p[0],java.nio.charset.StandardCharsets.UTF_8);return "<img src=\""+escape(src)+"\" alt=\""+escape(p[3])+"\""+(p[1].isBlank()?"":" width=\""+p[1]+"\"")+(p[2].isBlank()?"":" height=\""+p[2]+"\"")+">";}
@SuppressWarnings("unchecked") String render(Map<String,Object> rendering){var defs=(List<Map<String,Object>>)rendering.get("definitions");var data=(Map<String,Object>)rendering.get("data");var vals=(Map<Long,String>)data.get("values");String template=(String)rendering.get("template");var byKey=new HashMap<Long,String>();for(var d:defs){String type=text(d,"type"),v=vals.getOrDefault(id(d,"id"),"");String output=switch(type){case "image"->image(v);case "checkbox"->v.equals("1")?"✓":"";case "text"->Jsoup.clean(v.replace("\n","<br>"),Safelist.basic());default->escape(v);};byKey.put(id(d,"keyId"),output);}if(template.isBlank()){var out=new StringBuilder();for(var d:defs)out.append("<h3>").append(escape(text(d,"label"))).append("</h3><div>").append(byKey.get(id(d,"keyId"))).append("</div>");return clean(out.toString());}
var matcher=Pattern.compile("\\[-([0-9]{1,10})(?:,[^\\]]*)?-]").matcher(template);var out=new StringBuffer();while(matcher.find())matcher.appendReplacement(out,Matcher.quoteReplacement(byKey.getOrDefault(Long.parseLong(matcher.group(1)),"")));matcher.appendTail(out);String html=out.toString();if(html.contains("[-"))throw bad("Die Newslettervorlage enthält nicht unterstützte Spezialplatzhalter.");
// PHP removes an entire numbered container when its CHK_EMPTY element is empty.
for(int pass=0;pass<1000;pass++){String before=html;var check=Pattern.compile("(?i)<CHK_EMPTY_([0-9]+)>").matcher(html);var empty=new HashSet<String>();String upper=html.toUpperCase(Locale.ROOT);while(check.find()){int end=upper.indexOf("</CHK_EMPTY_"+check.group(1)+">",check.end());if(end>=0){String content=html.substring(check.end(),end);if(Jsoup.parse(content).text().isBlank()&&!content.toLowerCase(Locale.ROOT).contains("<img"))empty.add(check.group(1));}}for(String n:empty)html=html.replaceAll("(?is)<REMOVE_EMPTY_"+n+">.*?</REMOVE_EMPTY_"+n+">","");if(html.equals(before))break;}
html=html.replaceAll("(?is)</?CHK_EMPTY_[0-9]+>","");
html=html.replaceAll("(?is)</?REMOVE_EMPTY_[0-9]+>","");return clean(html);
}
String clean(String html){var doc=Jsoup.parse(html,legacy+"/admin/");doc.select("script,iframe,object,embed,form,input,button,textarea,select,meta,link,base").remove();
// Only display properties used by the PHP newsletter layouts survive; no URLs/imports in CSS.
for(var el:doc.select("[style]"))el.attr("style",safeStyle(el.attr("style")));
StringBuilder css=new StringBuilder();for(var el:doc.select("style")){var m=Pattern.compile("([a-zA-Z0-9_.# \\s:>,\\-]+)\\{([^{}]*)}").matcher(el.data());while(m.find())css.append(m.group(1)).append('{').append(safeStyle(m.group(2))).append('}');}doc.select("style").remove();
for(var image:doc.select("img[src]")){String abs=image.absUrl("src");if(abs.startsWith(legacy+"/")&&!legacy.isBlank())image.attr("src",abs);else if(!image.attr("src").matches("data:image/(png|jpeg|gif);base64,[A-Za-z0-9+/=]+"))image.remove();}
var safe=Safelist.relaxed().addTags("font","hr").addAttributes(":all","style","class").addAttributes("table","width","cellpadding","cellspacing","border").addAttributes("td","width","height","align","valign","bgcolor").addAttributes("font","color","face","size").addProtocols("img","src","https","http","data");
return "<style>body{font-family:Arial,sans-serif;margin:20px}img{max-width:100%;height:auto}"+css+"</style>"+Jsoup.clean(doc.body().html(),legacy+"/admin/",safe,new org.jsoup.nodes.Document.OutputSettings().prettyPrint(false));
}
static String safeStyle(String css){var allowed=Set.of("color","background-color","font-family","font-size","font-weight","font-style","text-align","text-decoration","line-height","vertical-align","width","height","max-width","border","border-collapse","border-color","border-width","border-style","padding","padding-left","padding-right","padding-top","padding-bottom","margin","margin-left","margin-right","margin-top","margin-bottom","display","float","clear","min-width","min-height","box-sizing","white-space","page-break-before","page-break-after","page-break-inside","break-inside");var out=new StringBuilder();for(String part:css.split(";")){String[] kv=part.split(":",2);if(kv.length==2&&allowed.contains(kv[0].strip().toLowerCase(Locale.ROOT))&&kv[1].matches("[a-zA-Z0-9#.,%() '"+'"'+"-]+")&&!kv[1].toLowerCase(Locale.ROOT).matches(".*(url|expression|javascript|behavior).*"))out.append(kv[0].strip()).append(':').append(kv[1].strip()).append(';');}return out.toString();}
}
@@ -0,0 +1,140 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import jakarta.validation.Valid;
import jakarta.validation.Validator;
import jakarta.validation.constraints.*;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
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.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryCatalog.number;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Article snapshots are edited in place. Stock corrections append journal entries. */
@RestController
@RequestMapping("/api/orders/{id}/articles")
public class OrderArticles {
final DatabaseService db;final BusinessSupport support;final OrderOperations orders;
final OrderEntryInventory inventory;final Validator validator;
public OrderArticles(DatabaseService db,BusinessSupport support,OrderOperations orders,OrderEntryInventory inventory,Validator validator){this.db=db;this.support=support;this.orders=orders;this.inventory=inventory;this.validator=validator;}
public record Position(@Min(0) long id,@Positive long stationId,@Min(0) long articleId,
@NotBlank @Size(max=30) String name,@Min(1) @Max(32767) int quantity,
@NotNull @DecimalMin("0") @DecimalMax("999999.999") @Digits(integer=6,fraction=3) BigDecimal weight,
@Min(0) @Max(127) int packingPieces,@NotNull @DecimalMin("0") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal price,
@NotNull @Size(max=50) String serialNumber,@NotNull @Size(max=255) String remark) {}
public record Save(String requestId,String revision,@NotNull @Size(max=100) List<@NotNull Position> positions) {}
public record Movement(String requestId,String revision,@Min(0) long reverseId,@Valid ArticleItem item,@NotBlank @Size(max=180) String reason,String fingerprint,@Valid Extra extra) {
public Movement(String requestId,String revision,long reverseId,ArticleItem item,String reason,String fingerprint){this(requestId,revision,reverseId,item,reason,fingerprint,null);}
}
public record Extra(@Size(max=20) String transaction,java.time.LocalDate specialDate,@Size(max=25) List<@NotNull @Size(max=50) String> data){}
record Planned(long reverseId,long bundleId,ArticleItem item,String transaction,LocalDateTime specialDate,List<String> data){}
record State(OrderOperations.Snapshot order,List<Map<String,Object>> articles,List<Map<String,Object>> scans,List<Map<String,Object>> moves,List<Map<String,Object>> reversals,List<Map<String,Object>> itemData){}
@GetMapping public Object get(Authentication auth,@PathVariable long id){return view(ApiController.user(auth),id);}
@PutMapping public Object put(Authentication auth,@PathVariable long id,@Valid @RequestBody Save input){return save(ApiController.user(auth),id,input);}
@PostMapping("/movement/preview") public Object preview(Authentication auth,@PathVariable long id,@Valid @RequestBody Movement input){return preview(ApiController.user(auth),id,input);}
@PostMapping("/movement") public Object move(Authentication auth,@PathVariable long id,@Valid @RequestBody Movement input){return move(ApiController.user(auth),id,input);}
State state(LegacyUser u,long id,boolean lock){
u.require(4);var order=orders.snapshot(u,id,lock);
var articles=read(lock,"SELECT * FROM tourarticle WHERE jb_id=? ORDER BY trat_sort,trat_id",id);
var scans=read(lock,"SELECT p.* FROM tourarticleprocess p JOIN tourarticle a ON a.trat_id=p.trat_id WHERE a.jb_id=? ORDER BY p.trat_id,p.tratp_id",id);
var moves=u.hasRight(14)?read(lock,"SELECT * FROM stockmove WHERE jb_id=? ORDER BY stkmv_id",id):List.<Map<String,Object>>of();
var reversals=u.hasRight(14)?read(lock,"SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngstkrv' AND gdc_obj_id=? ORDER BY gdc_id",id):List.<Map<String,Object>>of();
var itemData=u.hasRight(14)?read(lock,"SELECT g.gdc_obj_id,g.gdc_content FROM genericdatacontainer g JOIN stockmove m ON m.stkmv_id=g.gdc_obj_id WHERE g.gdc_obj_type='ngstkmeta' AND g.gdc_gen_fieldname='articleitem' AND m.jb_id=? ORDER BY g.gdc_id",id):List.<Map<String,Object>>of();
return new State(order,articles,scans,moves,reversals,itemData);
}
List<Map<String,Object>> read(boolean lock,String sql,Object...args){return lock?db.lockRows(sql,args):db.queryForList(sql,args);}
Position position(Map<String,Object> r){return new Position(id(r,"trat_id"),id(r,"tr_id"),id(r,"at_id"),text(r,"trat_name"),(int)id(r,"trat_quantity"),number(r,"trat_weight"),(int)id(r,"trat_packingpieces"),number(r,"trat_price"),text(r,"trat_serialno"),text(r,"trat_remark"));}
boolean protectedPosition(State s,Map<String,Object> r){return id(r,"trat_state")!=0||s.scans().stream().anyMatch(p->id(p,"trat_id")==id(r,"trat_id"))||s.order().stops().stream().anyMatch(t->id(t,"tr_id")==id(r,"tr_id")&&id(t,"tr_status")==1);}
Map<String,Object> view(LegacyUser u,long id){return db.readTransaction(()->{
var s=state(u,id,false);
return fields("revision",revision(s),"payerId",OrderRecalculation.payerId(s.order()),"canBook",u.hasRight(7)&&u.hasRight(14),"positions",s.articles().stream().map(this::position).toList(),"protectedIds",s.articles().stream().filter(r->protectedPosition(s,r)).map(r->id(r,"trat_id")).toList(),
"stations",s.order().stops().stream().map(t->fields("id",id(t,"tr_id"),"label",id(t,"tr_sort")+". "+text(t,"tr_comp"),"completed",id(t,"tr_status")==1)).toList(),
"movements",s.moves().stream().map(m->fields("id",id(m,"stkmv_id"),"at",text(m,"stkmv_timestamp"),"articleId",id(m,"at_id"),"quantity",id(m,"stkmv_itemquantity"),"from",id(m,"stk_id_from"),"to",id(m,"stk_id_to"),"serial",text(m,"stkmv_serialno"),"remark",text(m,"stkmv_remark"),"bundleId",id(m,"at_id_bundle"),"transaction",text(m,"stkmv_tan"),"specialDate",text(m,"stkmv_datetime2"),"data",text(m,"stkmv_data"),"reversed",s.reversals().stream().anyMatch(r->text(r,"gdc_gen_fieldname").equals(Long.toString(id(m,"stkmv_id")))))).toList());
});}
void validate(Object input){if(input==null||!validator.validate(input).isEmpty())throw bad("Bitte Artikel- und Mengenangaben prüfen.");}
Map<String,Object> save(LegacyUser u,long id,Save input){u.require(4);u.require(7);validate(input);token(input.requestId());
support.transactional("headquarters","job","tour","tourarticle","tourarticleprocess","article","genericdatacontainer","phoenix_pda.commandexec");
db.writeTransaction(()->{lockHeadquarters(db,u);if(support.receipt(u,input.requestId(),List.of("articles",id,input))!=null)return null;
var s=state(u,id,true);unchanged(s,input.revision());orders.writable(s.order());
var old=new HashMap<Long,Map<String,Object>>();s.articles().forEach(r->old.put(id(r,"trat_id"),r));var seen=new HashSet<Long>();
for(var p:input.positions()){
if(p.id()>0&&(!old.containsKey(p.id())||!seen.add(p.id())))throw bad("Unbekannte oder doppelte Artikelposition.");
var before=old.get(p.id());if(before!=null&&same(position(before),p))continue;
validate(p);
if(before!=null&&protectedPosition(s,before))throw bad("Bereits gescannte oder abgeschlossene Artikelpositionen bleiben als Nachweis erhalten.");
var stop=s.order().stops().stream().filter(t->id(t,"tr_id")==p.stationId()).findFirst().orElseThrow(()->bad("Die Station gehört nicht zu diesem Auftrag."));
if(id(stop,"tr_status")==1)throw bad("An abgeschlossenen Stationen können keine Artikelpositionen geändert werden.");
if(p.articleId()>0){var article=inventory.article(u,p.articleId());if(id(article,"at_serialno")!=0&&p.serialNumber().isBlank())throw bad("Der Artikel benötigt eine Seriennummer.");}
if(!p.serialNumber().isBlank()&&p.quantity()!=1)throw bad("Seriennummern erfordern Stückzahl 1.");
}
for(var r:s.articles())if(!seen.contains(id(r,"trat_id"))){if(protectedPosition(s,r))throw bad("Bereits gescannte oder abgeschlossene Artikelpositionen können nicht entfernt werden.");db.update("DELETE FROM tourarticle WHERE trat_id=? AND jb_id=?",id(r,"trat_id"),id);}
int sort=s.articles().stream().mapToInt(r->(int)id(r,"trat_sort")).max().orElse(0);
for(var p:input.positions()){
var before=old.get(p.id());if(before!=null&&same(position(before),p))continue;
long sequence=s.order().stops().stream().filter(t->id(t,"tr_id")==p.stationId()).mapToLong(t->id(t,"tr_sort")).findFirst().orElseThrow();
var row=fields("tr_id",p.stationId(),"tr_sort",sequence,"at_id",p.articleId(),"trat_name",p.name(),"trat_quantity",p.quantity(),"trat_weight",p.weight(),"trat_packingpieces",p.packingPieces(),"trat_price",p.price(),"trat_serialno",p.serialNumber(),"trat_remark",p.remark());
if(p.id()==0){row.putAll(fields("jb_id",id,"trat_sort",++sort,"trat_state",0,"trat_int_serialno","","trat_createtime",LocalDateTime.now()));db.insert("tourarticle",row);}else update(db,"tourarticle","trat_id",p.id(),row);
}
changed(u,id,s.order(),input.requestId(),"Artikelpositionen bearbeitet; Lagerjournal unverändert");support.receipt(u,input.requestId(),List.of("articles",id,input),id);return null;
});return view(u,id);
}
static boolean same(Position a,Position b){return a.id()==b.id()&&a.stationId()==b.stationId()&&a.articleId()==b.articleId()&&a.quantity()==b.quantity()&&a.packingPieces()==b.packingPieces()&&Objects.equals(a.name(),b.name())&&Objects.equals(a.serialNumber(),b.serialNumber())&&Objects.equals(a.remark(),b.remark())&&b.weight()!=null&&b.price()!=null&&a.weight().compareTo(b.weight())==0&&a.price().compareTo(b.price())==0;}
List<Planned> plan(State s,Movement m){
if(m.reverseId()==0){
if(m.item()==null||m.item().articleId()<=0||m.item().booking()==null||m.item().freeArticle()!=null)throw bad("Artikel und Lagerorte für die Bewegung fehlen.");
var item=new ArticleItem(m.item().articleId(),1,m.item().quantity(),m.item().serialNumber(),m.reason(),m.item().booking());
var extra=m.extra();return List.of(new Planned(0,0,item,extra==null||extra.transaction()==null?Long.toString(id(s.order().job(),"jb_id")):extra.transaction(),extra==null||extra.specialDate()==null?null:extra.specialDate().atStartOfDay(),extra==null||extra.data()==null?List.of():extra.data()));
}
var old=s.moves().stream().filter(r->id(r,"stkmv_id")==m.reverseId()).findFirst().orElseThrow(()->bad("Lagerbewegung gehört nicht zum Auftrag."));
var group=new ArrayList<Map<String,Object>>();long bundle=id(old,"at_id_bundle");
if(bundle>0){
var at=dateTime(old,"stkmv_datetime");if(at==null)throw bad("Der Bündelbewegung fehlt ein eindeutiger Buchungszeitpunkt.");
var related=db.queryForList("SELECT stkmv_id,jb_id FROM stockmove WHERE at_id_bundle=? AND stkmv_datetime=? ORDER BY stkmv_id",bundle,at);
if(related.size()>100||related.stream().anyMatch(r->id(r,"jb_id")!=id(s.order().job(),"jb_id")))throw bad("Das Bündel umfasst weitere Aufträge. Bitte gemeinsam in der Lagerverwaltung korrigieren.");
for(var r:related)group.add(s.moves().stream().filter(row->id(row,"stkmv_id")==id(r,"stkmv_id")).findFirst().orElseThrow(()->bad("Die Bündelbewegung wurde geändert. Bitte neu laden.")));
}else group.add(old);
group.sort(Comparator.comparingLong((Map<String,Object> r)->id(r,"stkmv_id")).reversed());
var result=new ArrayList<Planned>();
for(var row:group){
long movement=id(row,"stkmv_id");if(s.reversals().stream().anyMatch(r->text(r,"gdc_gen_fieldname").equals(Long.toString(movement))))throw bad("Für diese Lagerbewegung besteht bereits eine Gegenbuchung.");
if(id(row,"stkmv_itemquantity")<1||id(row,"stkmv_itemquantity")>32767)throw bad("Ungültige Menge in der Lagerbewegung.");
var data=new ArrayList<String>();var stored=s.itemData().stream().filter(r->id(r,"gdc_obj_id")==movement).toList();
if(stored.size()>1)throw bad("Gesicherte Artikeldaten sind nicht eindeutig.");
if(stored.size()==1)try{var tree=support.json.readTree(text(stored.getFirst(),"gdc_content"));for(int i=1;i<=25;i++)data.add(tree.path(String.format("ati_data_%02d",i)).asText(""));}catch(java.io.IOException e){throw bad("Gesicherte Artikeldaten sind nicht lesbar.");}
else if(!text(row,"stkmv_data").isBlank())data.addAll(Arrays.asList(text(row,"stkmv_data").split("(?i)<br\\s*/?>",-1)));
else if(group.size()==1&&m.extra()!=null&&m.extra().data()!=null)data.addAll(m.extra().data());
if(id(row,"stk_id_to")==0&&!text(row,"stkmv_serialno").isBlank()&&data.isEmpty())throw bad("Für diese alte Seriennummern-Auslagerung fehlen Zusatzdaten. Bitte die vollständigen Artikeldaten in den Zusatzfeldern ergänzen.");
var extra=new Extra(text(row,"stkmv_tan"),null,data);validate(extra);
var item=new ArticleItem(id(row,"at_id"),1,(int)id(row,"stkmv_itemquantity"),text(row,"stkmv_serialno"),"Gegenbuchung #"+movement+": "+m.reason(),new StockBooking(id(row,"stk_id_to"),id(row,"stk_id_from")));
result.add(new Planned(movement,bundle,item,text(row,"stkmv_tan"),dateTime(row,"stkmv_datetime2"),data));
}return result;
}
Request request(State s,List<Planned> plan){var j=s.order().job();if(id(j,"jb_offer")!=0||id(j,"jb_permanent")!=0)throw bad("Lagerbuchungen benötigen einen Einzelauftrag ohne Angebotsstatus.");if(s.order().stops().isEmpty())throw bad("Der Auftrag besitzt keine Stationen.");for(var p:plan)validate(p.item());return support.json.convertValue(fields("payerCostCenterId",OrderRecalculation.payerId(s.order()),"mode","IMMEDIATE","copies",1,"stops",List.of(Map.of("name","Lagerbuchung","country","DE")),"articles",plan.stream().map(Planned::item).toList()),Request.class);}
Map<String,Object> preview(LegacyUser u,long id,Movement m){u.require(4);u.require(7);u.require(14);validate(m);return db.readTransaction(()->{var s=state(u,id,false);unchanged(s,m.revision());orders.writable(s.order());var plan=plan(s,m);var lines=inventory.quote(u,request(s,plan));return fields("item",plan.getFirst().item(),"plan",plan,"lines",lines,"fingerprint",revision(List.of(s,plan,lines)));});}
Map<String,Object> move(LegacyUser u,long id,Movement m){u.require(4);u.require(7);u.require(14);validate(m);token(m.requestId());
support.transactional("headquarters","job","tour","tourarticle","tourarticleprocess","article","stock","stockarticle","articleitem","stockmove","genericdatacontainer","phoenix_pda.commandexec");
db.writeTransaction(()->{lockHeadquarters(db,u);if(support.receipt(u,m.requestId(),List.of("stock",id,m))!=null)return null;var s=state(u,id,true);unchanged(s,m.revision());orders.writable(s.order());var plan=plan(s,m);var request=request(s,plan);inventory.lock(u,request);var lines=inventory.quote(u,request,true);if(!revision(List.of(s,plan,lines)).equals(m.fingerprint()))throw bad("Die Buchungsvorschau hat sich geändert. Bitte neu prüfen.");
var now=LocalDateTime.now().withNano(0);long bundle=plan.getFirst().bundleId();
// Legacy journal groups use bundle + second; never merge a correction into an older group.
if(bundle>0)while(!db.lockRows("SELECT stkmv_id FROM stockmove WHERE at_id_bundle=? AND stkmv_datetime=?",bundle,now).isEmpty())now=now.plusSeconds(1);
for(var p:plan){
var item=p.item();long movement=inventory.book(u,item,id,now);
update(db,"stockmove","stkmv_id",movement,fields("at_id_bundle",p.bundleId(),"stkmv_tan",p.transaction(),"stkmv_datetime2",p.specialDate()==null?now:p.specialDate(),"stkmv_data",String.join("<br>",p.data())));
if(item.booking().fromStockId()==0&&!Objects.toString(item.serialNumber(),"").isBlank()&&!p.data().isEmpty()){
var row=new LinkedHashMap<String,Object>();for(int i=1;i<=25;i++)row.put(String.format("ati_data_%02d",i),i<=p.data().size()?p.data().get(i-1):"");
long itemId=id(db.lockRows("SELECT ati_id FROM articleitem WHERE at_id=? AND stk_id=? AND ati_serialno=?",item.articleId(),item.booking().toStockId(),item.serialNumber().trim()).getFirst(),"ati_id");update(db,"articleitem","ati_id",itemId,row);
}
if(p.reverseId()>0)db.insert("genericdatacontainer",fields("gdc_obj_type","ngstkrv","gdc_obj_id",id,"gdc_gen_fieldname",Long.toString(p.reverseId()),"gdc_content",Long.toString(movement),"gdc_context",m.reason()));
support.audit(u,id,0,id(s.order().job(),"cr_id"),2,"Lagerbewegung #"+movement+" · "+item.remark(),m.requestId());
}
changed(u,id,s.order(),m.requestId(),"Lagerkorrektur · "+plan.size()+" Bewegung(en)");support.receipt(u,m.requestId(),List.of("stock",id,m),id);return null;
});return view(u,id);
}
void changed(LegacyUser u,long id,OrderOperations.Snapshot s,String token,String message){db.update("UPDATE job SET jb_version=jb_version+1 WHERE jb_id=?",id);db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_invtext_dirty'",id);db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname","jb_invtext_dirty","gdc_content","1","gdc_context",""));if(id(s.job(),"jb_status")==1)orders.command(u,id(s.job(),"cr_id"),"5",Long.toString(id));support.audit(u,id,0,id(s.job(),"cr_id"),2,message,token);}
}
@@ -0,0 +1,85 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import jakarta.validation.Valid;
import jakarta.validation.Validator;
import jakarta.validation.constraints.*;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
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;
/** A reviewed, atomic correction of a main order and its settlement documents. */
@RestController
@RequestMapping("/api/orders/{id}/cancellation")
public class OrderCancellation {
final DatabaseService db;final BusinessSupport support;final OrderOperations orders;final Validator validator;
public OrderCancellation(DatabaseService db,BusinessSupport support,OrderOperations orders,Validator validator){this.db=db;this.support=support;this.orders=orders;this.validator=validator;}
public record Input(@NotBlank @Pattern(regexp="CANCEL|CREDIT") String action,@NotBlank @Size(max=500) String reason,boolean chargeable,boolean endSeries,boolean offsetPayments){}
public record Command(String requestId,String revision,@NotNull @Valid Input input,String fingerprint){}
record State(List<OrderOperations.Snapshot> documents,List<Map<String,Object>> metadata){}
@GetMapping public Object get(Authentication auth,@PathVariable long id){return options(ApiController.user(auth),id);}
@PostMapping("/preview") public Object preview(Authentication auth,@PathVariable long id,@Valid @RequestBody Command c){return preview(ApiController.user(auth),id,c);}
@PostMapping public Object save(Authentication auth,@PathVariable long id,@Valid @RequestBody Command c){return save(ApiController.user(auth),id,c);}
State state(LegacyUser u,long id,boolean lock){
u.require(4);u.require(7);var main=orders.snapshot(u,id,lock);var list=new ArrayList<OrderOperations.Snapshot>();list.add(main);
var pairs=new OrderChildRecalculation(db,orders,support).load(u,main,lock);if(!pairs.isEmpty())u.require(38);
for(var pair:pairs){list.add(pair.positive());list.add(pair.negative());}
var metadata=new ArrayList<Map<String,Object>>();
for(var s:list){String sql="SELECT * FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_toll','ng_invoice_lock') ORDER BY gdc_id";metadata.addAll(lock?db.lockRows(sql,id(s.job(),"jb_id")):db.queryForList(sql,id(s.job(),"jb_id")));}
return new State(list,metadata);
}
Map<String,Object> options(LegacyUser u,long id){return db.readTransaction(()->{var s=state(u,id,false);var main=s.documents().getFirst();return fields("revision",revision(s),"canCancel",s.documents().stream().allMatch(d->!OrderOperations.exported(d.job())&&d.invoices().isEmpty()),"canCredit",s.documents().stream().allMatch(d->id(d.job(),"jb_status")==2),"canOffsetPayments",u.hasRight(5),"hasPayments",s.documents().stream().anyMatch(d->!d.payments().isEmpty()),"series",id(main.job(),"jb_permanent")>0,"documents",rows(s,BigDecimal.ZERO,BigDecimal.ZERO));});}
State prepare(LegacyUser u,long id,Command c,boolean lock){
if(c==null||!validator.validate(c).isEmpty())throw bad("Bitte Stornoart und Begründung prüfen.");var s=state(u,id,lock);unchanged(s,c.revision());
if(s.metadata().stream().anyMatch(m->text(m,"gdc_gen_fieldname").equals("ng_invoice_lock")))throw bad("Für einen verbundenen Beleg läuft noch eine Rechnungszuordnung.");
for(var d:s.documents()){
long job=id(d.job(),"jb_id");if(id(d.job(),"jb_storno")!=0||!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",job).isEmpty())throw bad("Für einen Beleg besteht bereits ein Storno / eine Gegenbuchung.");
var edit=dateTime(d.job(),"jb_locktime");if(edit!=null&&edit.isAfter(LocalDateTime.now().minusMinutes(5)))throw bad("Ein Beleg wird noch in der alten Anwendung bearbeitet.");
if(c.input().action().equals("CANCEL"))orders.writable(d);else if(id(d.job(),"jb_status")!=2)throw bad("Gutschriften setzen erledigte Belege voraus.");
if(!d.payments().isEmpty()){u.require(5);if(!c.input().offsetPayments())throw bad("Bitte den Ausgleich der vorhandenen Einnahmenbuchungen ausdrücklich bestätigen.");}
}
var main=s.documents().getFirst();if(c.input().chargeable()&&id(main.job(),"cr_id")<=0&&id(main.job(),"cr_id_order")<=0)throw bad("Kostenpflichtiges Storno benötigt einen Kurier.");return s;
}
Map<String,Object> quote(LegacyUser u,State s,Command c){
BigDecimal fee=BigDecimal.ZERO,courierFee=BigDecimal.ZERO;if(c.input().chargeable()){var tariff=orders.cancellationTariff(u,s.documents().getFirst());fee=tariff.price();}
var rows=rows(s,fee,courierFee);return fields("documents",rows,"fee",fee,"courierFee",courierFee,"fingerprint",revision(List.of(s,c.input(),rows,fee,courierFee)));
}
List<Map<String,Object>> rows(State s,BigDecimal fee,BigDecimal courierFee){
var result=new ArrayList<Map<String,Object>>();int index=0;for(var d:s.documents()){
long id=id(d.job(),"jb_id");BigDecimal toll=s.metadata().stream().filter(m->id(m,"gdc_obj_id")==id&&text(m,"gdc_gen_fieldname").equals("jb_toll")).map(m->new BigDecimal(text(m,"gdc_content"))).reduce(BigDecimal.ZERO,BigDecimal::add);
result.add(fields("id",id,"kind",index==0?"Hauptauftrag":id(d.job(),"jb_id_parent")==id(s.documents().getFirst().job(),"jb_id")?"Unterauftrag":"Gegenbeleg","courier",text(d.job(),"cr_sid"),"net",OrderOperations.customerNet(d),"creditNet",OrderOperations.customerNet(d).negate().add(index==0?fee:BigDecimal.ZERO),"creditCourierNet",decimal(d.job(),"jb_cr_price").negate().add(index==0?courierFee:BigDecimal.ZERO),"creditToll",toll.negate(),"payments",d.payments().stream().map(p->fields("id",id(p,"jbp_id"),"amount",decimal(p,"jbp_price"),"offset",decimal(p,"jbp_price").negate(),"exported",id(p,"jbpc_id")>0||!text(p,"jbp_export_time").isBlank(),"counter",id(p,"jbp_counter"),"mode",id(p,"jbp_mode"))).toList()));index++;
}return result;
}
Map<String,Object> preview(LegacyUser u,long id,Command c){return db.readTransaction(()->quote(u,prepare(u,id,c,false),c));}
Map<String,Object> save(LegacyUser u,long id,Command c){
u.require(4);u.require(7);if(c==null)throw bad("Stornoangaben fehlen.");token(c.requestId());orders.transactional();
long credit=db.writeTransaction(()->{lockHeadquarters(db,u);Long receipt=support.receipt(u,c.requestId(),List.of("cancel-group",id,c));if(receipt!=null)return receipt;
var s=prepare(u,id,c,true);if(!text(quote(u,s,c),"fingerprint").equals(c.fingerprint()))throw bad("Die Stornovorschau hat sich geändert. Bitte neu prüfen.");long result=0;
for(var d:s.documents()){
long job=id(d.job(),"jb_id"),courier=id(d.job(),"cr_id"),offered=id(d.job(),"cr_id_order");boolean main=job==id;
var action=new OrderOperations.Action(c.requestId(),null,c.input().action(),null,null,null,c.input().reason(),main&&c.input().chargeable(),main&&c.input().endSeries());
long created=orders.credit(u,job,d,action,c.input().reason());if(main)result=created;
var changes=fields("jb_id_copy",created,"jb_version",id(d.job(),"jb_version")+1);
if(s.documents().size()>1){changes.put("jb_id_parent",0);db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname","ng_previous_parent","gdc_content",Long.toString(id(d.job(),"jb_id_parent")),"gdc_context","CANCELLATION"));}
if(c.input().action().equals("CANCEL"))changes.putAll(fields("jb_storno",action.chargeable()?1:2,"usr_id_storno",u.userId(),"jb_status",2,"jb_finishtime",LocalDateTime.now(),"cr_id",courier>0?courier:offered));if(action.endSeries())changes.put("jb_permanent",0);update(db,"job","jb_id",job,changes);
// Preserve exported and unexported receipts, with an individually linked accounting offset.
for(var payment:d.payments()){
var copy=new LinkedHashMap<>(payment);copy.remove("jbp_id");copy.putAll(fields("jbp_price",decimal(payment,"jbp_price").negate(),"jbp_bookingtime",LocalDateTime.now(),"jbpc_id",0,"jbp_export_time",null,"jbp_tan","ST"+id(payment,"jbp_id")));
long offset=db.insert("jobpayment",copy);db.insert("genericdatacontainer",fields("gdc_obj_type","ngpayrv","gdc_obj_id",id(payment,"jbp_id"),"gdc_gen_fieldname","offset","gdc_content",Long.toString(offset),"gdc_context",Long.toString(created)));
}
if(id(d.job(),"jb_status")!=2)for(long previous:new TreeSet<>(List.of(courier,offered))){orders.command(u,previous,"3",Long.toString(job));orders.command(u,previous,"8",Long.toString(job));}
db.update("DELETE FROM vehicledisposition WHERE jb_id=?",job);db.update("DELETE FROM autoranking WHERE jb_id=?",job);
for(long cr:new TreeSet<>(List.of(courier,offered)))if(cr>0)db.update("UPDATE courier SET cr_occupied=(SELECT COUNT(*) FROM job WHERE (cr_id_order=? OR cr_id=?) AND jb_status IN (0,1) AND COALESCE(jb_storno,0)=0) WHERE cr_id=?",cr,cr,cr);
support.audit(u,job,0,courier,10,c.input().action()+" gemeinsam mit Auftrag #"+id+" · "+c.input().reason()+" · Gegenbuchung #"+created,c.requestId());
}
if(s.documents().size()>1){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_courier_children'",id);db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname","ng_cancelled_group","gdc_content",support.encode(s.documents().stream().map(d->id(d.job(),"jb_id")).toList()),"gdc_context",c.requestId()));}
support.receipt(u,c.requestId(),List.of("cancel-group",id,c),result);return result;
});return fields("id",id,"resultId",credit,"order",orders.view(u,id));
}
}
@@ -0,0 +1,106 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import java.math.BigDecimal;
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.LegacyOrderPricing.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Updates existing settlement pairs atomically, preserving their IDs and delivery evidence. */
class OrderChildRecalculation {
final DatabaseService db;final OrderOperations orders;final BusinessSupport support;
OrderChildRecalculation(DatabaseService db,OrderOperations orders,BusinessSupport support){this.db=db;this.orders=orders;this.support=support;}
record Pair(OrderOperations.Snapshot positive,OrderOperations.Snapshot negative){}
List<Pair> load(LegacyUser user,OrderOperations.Snapshot main,boolean lock){
if(id(main.job(),"jb_id_parent")>0)throw bad("Unterauftrag bitte gemeinsam über den Hauptauftrag kalkulieren.");
var children=read(lock,"SELECT jb_id,hq_id FROM job WHERE jb_id_parent=? AND COALESCE(jb_storno,0)=0 ORDER BY jb_id",id(main.job(),"jb_id"));
if(children.size()>20)throw bad("Die Unternehmerverrechnung enthält mehr als 20 Unteraufträge.");
if(children.isEmpty()&&id(main.job(),"jb_id_parent")==-1)throw bad("Der Hauptauftrag verweist auf fehlende Unteraufträge.");
var pairs=new ArrayList<Pair>();
for(var child:children){if(id(child,"hq_id")!=user.headquartersId())throw bad("Verbundene Aufträge liegen in verschiedenen Niederlassungen.");
var positive=orders.snapshot(user,id(child,"jb_id"),lock);
var negatives=read(lock,"SELECT jb_id,hq_id FROM job WHERE jb_id_parent=? AND COALESCE(jb_storno,0)=0 ORDER BY jb_id",id(child,"jb_id"));
if(negatives.size()!=1||id(negatives.getFirst(),"hq_id")!=user.headquartersId())throw bad("Zum Unterauftrag fehlt ein eindeutiger Gegenbeleg in dieser Niederlassung.");
var negative=orders.snapshot(user,id(negatives.getFirst(),"jb_id"),lock);
if(!read(lock,"SELECT jb_id FROM job WHERE jb_id_parent=?",id(negative.job(),"jb_id")).isEmpty())throw bad("Mehrstufige Sonderverrechnung kann nicht als einfaches Belegpaar geändert werden.");
for(String field:List.of("jb_totalprice","jb_cr_price","jb_fixprice","jb_serviceprice"))
if(decimal(positive.job(),field).signum()<0||decimal(positive.job(),field).add(decimal(negative.job(),field)).abs().compareTo(new BigDecimal("0.005"))>0)throw bad("Unterauftrag und Gegenbeleg bilden kein ausgeglichenes Verrechnungspaar.");
if(id(negative.job(),"cr_id")!=id(main.job(),"cr_id"))throw bad("Der Gegenbeleg gehört nicht zum ausführenden Unternehmer des Hauptauftrags.");
pairs.add(new Pair(positive,negative));
}
return pairs;
}
List<Map<String,Object>> read(boolean lock,String sql,Object... args){return lock?db.lockRows(sql,args):db.queryForList(sql,args);}
List<CourierShare> shares(LegacyUser user,List<Pair> pairs){var result=new ArrayList<CourierShare>();for(var p:pairs){var j=p.positive().job();var vehicles=db.queryForList("SELECT v.crvh_id FROM couriervehicle v JOIN courier c ON c.cr_id=v.cr_id WHERE v.cr_id=? AND v.crvh_sid=? AND c.hq_id=?",id(j,"cr_id"),text(j,"cr_sid"),user.headquartersId());if(vehicles.size()!=1)throw bad("Das Fahrzeug eines Unterauftrags ist nicht eindeutig. Bitte den Fahrzeugstamm prüfen.");result.add(new CourierShare(id(vehicles.getFirst(),"crvh_id"),decimal(j,"jb_fixprice")));}return result;}
List<Long> ids(List<Pair> pairs,OrderRecalculation.Input input){
if(input.pairIds()!=null){if(input.pairIds().size()!=input.courierShares().size())throw bad("Belegzuordnung und Unternehmerbeträge stimmen nicht überein.");return input.pairIds();}
if(pairs.size()!=input.courierShares().size())throw bad("Bitte die Kalkulation neu öffnen, um Belegpaare hinzuzufügen oder zu entfernen.");
return pairs.stream().map(p->id(p.positive().job(),"jb_id")).toList();
}
List<Map<String,Object>> changes(List<Pair> pairs,OrderRecalculation.Input input){
var ids=ids(pairs,input);var result=new ArrayList<Map<String,Object>>();
for(long selected:ids){var pair=pairs.stream().filter(p->id(p.positive().job(),"jb_id")==selected).findFirst();result.add(fields("action",selected==0?"ADD":"UPDATE","positive",selected,"negative",pair.map(p->id(p.negative().job(),"jb_id")).orElse(0L)));}
for(var pair:pairs)if(!ids.contains(id(pair.positive().job(),"jb_id")))result.add(fields("action","REMOVE","positive",id(pair.positive().job(),"jb_id"),"negative",id(pair.negative().job(),"jb_id")));
return result;
}
void validate(LegacyUser user,OrderOperations.Snapshot main,List<Pair> pairs,OrderRecalculation.Input input){
var selected=ids(pairs,input);if(pairs.isEmpty()&&selected.isEmpty())return;
user.require(38);
if(!revision(pairs).equals(input.childrenRevision()))throw bad("Unteraufträge wurden zwischenzeitlich geändert. Bitte die Kalkulation neu öffnen.");
var seen=new HashSet<Long>();for(long id:selected)if(id>0&&(!seen.add(id)||pairs.stream().noneMatch(p->id(p.positive().job(),"jb_id")==id)))throw bad("Unbekanntes oder doppelt zugeordnetes Unternehmerbelegpaar.");
if(id(main.job(),"jb_status")!=2||id(main.job(),"jb_longhaul")!=0)throw bad("Gemeinsame Nachkalkulation benötigt einen erledigten Inlandsauftrag.");
for(var p:pairs)for(var s:List.of(p.positive(),p.negative())){
orders.writable(s);if(id(s.job(),"jb_status")!=2||!s.payments().isEmpty())throw bad("Verbundene Belege müssen erledigt sein und dürfen keine Einnahmenbuchung enthalten.");
}
}
void write(LegacyUser user,OrderOperations.Snapshot main,List<Pair> pairs,OrderRecalculation.Input input,Quote quote,String token){
var selected=ids(pairs,input);if(pairs.isEmpty()&&selected.isEmpty())return;var allocations=quote.courierAllocations();
if(allocations.size()!=selected.size())throw bad("Die Kalkulation enthält nicht alle Unternehmerbelege.");
var active=new ArrayList<Long>();long mainId=id(main.job(),"jb_id");
for(int index=0;index<selected.size();index++){
long selectedId=selected.get(index);var a=allocations.get(index);
var pair=pairs.stream().filter(p->id(p.positive().job(),"jb_id")==selectedId).findFirst().orElse(null);
if(pair==null){long pos=create(user,main,mainId,a);long neg=create(user,main,pos,a);pair=new Pair(orders.snapshot(user,pos,true),orders.snapshot(user,neg,true));}
writeOne(user,main,pair.positive(),a,1,quote,index==0,token);writeOne(user,main,pair.negative(),a,-1,quote,index==0,token);
active.add(id(pair.positive().job(),"jb_id"));active.add(id(pair.negative().job(),"jb_id"));
}
for(var pair:pairs)if(!selected.contains(id(pair.positive().job(),"jb_id")))for(var old:List.of(pair.positive(),pair.negative())){
long oldId=id(old.job(),"jb_id");String reason="Unternehmerbelegpaar aus Auftrag #"+mainId+" entfernt";
long credit=orders.credit(user,oldId,old,new OrderOperations.Action(token,null,"CANCEL",null,null,null,reason,false,false),reason);
update(db,"job","jb_id",oldId,fields("jb_storno",2,"jb_id_parent",0,"usr_id_storno",user.userId(),"jb_id_copy",credit,"jb_version",id(old.job(),"jb_version")+1));
metadata(oldId,"ng_previous_parent",Long.toString(id(old.job(),"jb_id_parent")));
support.audit(user,oldId,0,id(old.job(),"cr_id"),10,reason+" · Gegenbuchung #"+credit,token);
}
db.update("UPDATE job SET jb_id_parent=? WHERE jb_id=?",active.isEmpty()?0:-1,mainId);
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_courier_children'",mainId);
if(!active.isEmpty())metadata(mainId,"ng_courier_children",support.encode(active));
}
long create(LegacyUser user,OrderOperations.Snapshot main,long parent,CourierAllocation allocation){
var source=main.job();var row=fields("hq_id",user.headquartersId(),"csc_id_payer",allocation.payerId(),"jb_status",2,"jb_id_parent",parent,"emp_id",user.userId(),"jb_postage",0,"jb_payment",0,"jb_version",0,"jb_storno",0,"jb_incomplete",1,"jb_freetext_1","","jb_service","","jb_orderer","","jb_scrm","","jb_createtime",java.time.LocalDateTime.now(),"jb_booktime",java.time.LocalDateTime.now(),"jb_invtext","Unternehmerverrechnung zu Auftrag Nr. "+id(source,"jb_id"));
for(String field:List.of("vht_id","jb_ordertime","jb_taketime","jb_finishtime","jb_tourdata","jb_invtext_cr","jb_invmode"))if(source.get(field)!=null)row.put(field,source.get(field));
long child=db.insert("job",row);OrderPaymentTerms.copy(db,id(source,"jb_id"),child);
for(var tour:db.queryForList("SELECT * FROM tour WHERE jb_id=? ORDER BY tr_sort",id(source,"jb_id"))){var copy=new LinkedHashMap<>(tour);copy.remove("tr_id");copy.remove("tr_modify");copy.put("jb_id",child);db.insert("tour",copy);}return child;
}
void writeOne(LegacyUser user,OrderOperations.Snapshot main,OrderOperations.Snapshot child,CourierAllocation a,int sign,Quote q,boolean first,String token){
long id=id(child.job(),"jb_id");BigDecimal factor=BigDecimal.valueOf(sign);
// Keep the original accounting customer rather than silently changing historical relations.
if(id(child.job(),"csc_id_payer")!=a.payerId())throw bad("Der Verrechnungszahler hat sich seit der ursprünglichen Buchung geändert.");
long courier=sign>0?a.courierId():id(main.job(),"cr_id");String sid=sign>0?a.sid():text(main.job(),"cr_sid");
update(db,"job","jb_id",id,fields("jb_totalprice",a.net().multiply(factor),"jb_cr_price",a.net().multiply(factor),"jb_fixprice",a.base().multiply(factor),"jb_subtotalprice",a.subtotal().multiply(factor),"jb_cr_subprice",a.subtotal().subtract(a.service()).multiply(factor),"jb_serviceprice",a.service().multiply(factor),"jb_markup",q.markup(),"jb_sales_tax_rate",a.taxRate(),"jb_sales_tax_rate_sign",a.taxSign(),"cr_id",courier,"cr_id_order",courier,"cr_sid",sid,"jb_version",id(child.job(),"jb_version")+1));
db.update("DELETE FROM tourservice WHERE jb_id=?",id);
insertWithoutKey(db,"tourservice",fields("jb_id",id,"csc_id",a.payerId(),"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Fixpreis","trs_srvt_name","","trs_price",markup(a.base(),q.markup()).multiply(factor),"trs_discount",a.discount()));
if(a.service().signum()>0)insertWithoutKey(db,"tourservice",fields("jb_id",id,"csc_id",a.payerId(),"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Servicepreis","trs_srvt_name","","trs_price",a.service().multiply(factor),"trs_discount",0));
db.update("DELETE FROM jobprice WHERE jb_id=? AND (mt_sort<11 OR mt_sort=15)",id);
var prices=Map.of(1,q.markup(),2,a.subtotal().subtract(a.service()).multiply(factor),3,a.service().multiply(factor),10,a.subtotal().multiply(factor));
for(var price:prices.entrySet())insertWithoutKey(db,"jobprice",fields("jb_id",id,"mt_sort",price.getKey(),"jbprc_price",price.getValue(),"jbprc_remark",""));
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_toll','jb_invtext_dirty','ng_calculation_input','ng_calculation_result')",id);
if(first&&q.toll()!=null&&q.toll().signum()!=0)metadata(id,"jb_toll",q.toll().multiply(factor).toPlainString());
metadata(id,"jb_invtext_dirty","1");
support.audit(user,id,0,courier,2,"Unternehmerverrechnung zu Auftrag #"+id(main.job(),"jb_id")+" neu kalkuliert · Netto "+a.net().multiply(factor),token);
}
void metadata(long id,String key,String value){db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname",key,"gdc_content",value,"gdc_context",""));}
}
@@ -0,0 +1,137 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import de.stadtbote.votian.database.DatabaseService;
import java.math.*;
import java.util.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryCatalog.*;
import static de.stadtbote.votian.OrderEntryService.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.LegacyOrderPricing.*;
/** Legacy payment=1 and caglobal.inc.php/saveJobChildren, within the order transaction. */
final class OrderEntryAccounting {
private static final ObjectMapper JSON=new ObjectMapper().findAndRegisterModules();
private final DatabaseService db;
private final OrderEntryCatalog catalog;
OrderEntryAccounting(DatabaseService db,OrderEntryCatalog catalog) {this.db=db;this.catalog=catalog;}
Quote quote(LegacyUser user,Request request,Quote quote,List<ArticleLine> articles) {
var allocations=new ArrayList<CostAllocation>();
if(request.costSplit()) {
if(request.payment()!=Payment.INVOICE || quote.batch())throw invalid("Kostensplit benötigt einen direkt abgerechneten Rechnungsauftrag.");
var centers=new TreeMap<Long,Map<String,Object>>();
centers.put(request.payerCostCenterId(),catalog.costCenter(user,request.payerCostCenterId(),true));
for(var stop:request.stops())if(stop.costCenterId()!=null) {
var center=catalog.costCenter(user,stop.costCenterId(),false);
if(id(center,"csc_is_extern")==0)centers.put(stop.costCenterId(),center);
}
if(centers.size()<2)throw invalid("Kostensplit benötigt mindestens zwei unterschiedliche Rechnungskostenstellen an den Stationen oder beim Zahler.");
int count=centers.size(),index=0;
for(var entry:centers.entrySet()) {
final int part=index++;
BigDecimal net=quote.components().stream().map(c -> portion(c.net(),count,part)).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal toll=portion(nz(request.toll()),count,part),tax=money(net.add(toll).multiply(quote.taxRate()).movePointLeft(2));
allocations.add(new CostAllocation(entry.getKey(),text(entry.getValue(),"cmp_comp")+" · "+text(entry.getValue(),"csc_name"),net,toll,tax,net.add(toll).add(tax)));
}
}
var couriers=new ArrayList<CourierAllocation>();
if(!request.courierShares().isEmpty()) {
user.require(38);
if(request.mode()!=Mode.RETROSPECTIVE || request.courierVehicleId()==null || request.stops().stream().anyMatch(s -> !s.country().equals("DE")))
throw invalid("Kurier-Unteraufträge sind wie im Bestand nur bei Nacherfassungen im Inland mit ausführendem Fahrzeug möglich.");
if(quote.batch() || (quote.route()!=null && quote.route().longhaul()))throw invalid("Kurier-Unteraufträge sind für diesen Abrechnungsmodus nicht verfügbar.");
if(request.pricing().courierPrice()!=null || request.pricing().courierServicePrice()!=null || request.pricing().courierMarkup()!=null)
throw invalid("Bei Kurier-Unteraufträgen die einzelne Frachtvergütung und den Kurierzuschlag leerlassen. Es gelten die aufgeteilten Beträge und der Kundenzuschlag.");
long payer=catalog.calculatorPayer(user);
BigDecimal discount=catalog.enabled(user.headquartersId(),"INV_CHILDREN_DISCOUNT")?quote.discount():BigDecimal.ZERO;
BigDecimal service=quote.components().stream().filter(c -> c.name().equals("Servicepreis")).map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add);
boolean taxOverride=catalog.enabled(0,"MASK_CHILDREN_TAX_HARDCODED");
var seen=new HashSet<Long>();
BigDecimal base=BigDecimal.ZERO;
for(var share:request.courierShares()) {
var vehicle=catalog.vehicle(user,share.vehicleId());
if(!seen.add(id(vehicle,"cr_id")))throw invalid("Bitte jeden Unternehmer nur einmal in der Kostenaufteilung angeben.");
BigDecimal subtotal=discount(share.amount(),discount);
BigDecimal net=markup(subtotal,quote.markup()).add(service);
couriers.add(new CourierAllocation(share.vehicleId(),id(vehicle,"cr_id"),text(vehicle,"crvh_sid"),share.amount(),subtotal.add(service),net,service,discount,taxOverride?BigDecimal.ZERO:quote.taxRate(),taxOverride?"4":quote.taxSign(),payer));
base=base.add(share.amount());
}
if(base.compareTo(quote.subtotal().subtract(service))>0)throw invalid("Die Summe der Unternehmerbeträge darf den Kundenpreis vor Zuschlag nicht überschreiten.");
}
if(allocations.isEmpty() && couriers.isEmpty() && articles.isEmpty())return quote;
ObjectNode tree=JSON.valueToTree(quote);
tree.set("costAllocations",JSON.valueToTree(allocations));tree.set("courierAllocations",JSON.valueToTree(couriers));tree.set("articleLines",JSON.valueToTree(articles));
if(!allocations.isEmpty()) {
tree.put("tax",allocations.stream().map(CostAllocation::tax).reduce(BigDecimal.ZERO,BigDecimal::add));
tree.put("gross",allocations.stream().map(CostAllocation::gross).reduce(BigDecimal.ZERO,BigDecimal::add));
}
if(!couriers.isEmpty()) {
BigDecimal net=couriers.stream().map(CourierAllocation::net).reduce(BigDecimal.ZERO,BigDecimal::add);
tree.put("courierNet",net);tree.put("courierSubtotal",couriers.stream().map(CourierAllocation::subtotal).reduce(BigDecimal.ZERO,BigDecimal::add));
tree.put("courierBase",couriers.stream().map(c -> c.subtotal().subtract(c.service())).reduce(BigDecimal.ZERO,BigDecimal::add));
tree.put("courierService",couriers.stream().map(CourierAllocation::service).reduce(BigDecimal.ZERO,BigDecimal::add));
tree.put("courierMarkup",quote.markup());tree.put("courierDeduction",BigDecimal.ZERO);tree.put("courierAgio",BigDecimal.ZERO);
tree.put("courierLimitExceeded",nz(quote.courierLimit()).signum()>0 && net.compareTo(money(quote.net().multiply(quote.courierLimit()).movePointLeft(2)))>0);
}
tree.put("fingerprint",hash(tree.toString()+"|"+request.costSplit()+"|"+request.courierShares()+"|"+request.articles()));
try{return JSON.treeToValue(tree,Quote.class);}catch(Exception e){throw new IllegalStateException(e);}
}
/** Residual cents go to the first stable IDs; the sum is always exact. */
static BigDecimal portion(BigDecimal amount,int count,int index) {
long cents=money(amount).movePointRight(2).longValueExact();
long absolute=Math.abs(cents),value=absolute/count+(index<absolute%count?1:0);
return BigDecimal.valueOf(cents<0?-value:value,2);
}
void split(long orderId,Quote quote) {
if(quote.costAllocations()==null || quote.costAllocations().isEmpty())return;
db.update("DELETE FROM tourservice WHERE jb_id=?",orderId);
int count=quote.costAllocations().size();
for(int i=0;i<count;i++)for(var component:quote.components()) {
// Discounts are already included. Persist cents exactly as shown in the recipient preview.
db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,?,0,?,0,?,?,0)",
orderId,quote.costAllocations().get(i).costCenterId(),component.sequence(),clip(component.name(),50),clip(component.type(),50),portion(component.net(),count,i));
}
metadata(orderId,"ng_cost_split",json(quote.costAllocations()));
}
List<Long> children(LegacyUser user,Request request,OrderEntryService.Prepared prepared,long parent,Map<String,Object> parentJob,Quote quote) {
if(quote.courierAllocations()==null || quote.courierAllocations().isEmpty())return List.of();
var ids=new ArrayList<Long>();int index=0;
var tours=db.queryForList("SELECT * FROM tour WHERE jb_id=? ORDER BY tr_sort",parent);
for(var allocation:quote.courierAllocations()) {
long positive=child(user,prepared,parent,parent,allocation,1,allocation.courierId(),allocation.sid(),parentJob,quote,tours);
long negative=child(user,prepared,parent,positive,allocation,-1,id(prepared.vehicle(),"cr_id"),text(prepared.vehicle(),"crvh_sid"),parentJob,quote,tours);
ids.add(positive);ids.add(negative);
if(index++==0 && positive(request.toll())) {metadata(positive,"jb_toll",request.toll().toPlainString());metadata(negative,"jb_toll",request.toll().negate().toPlainString());}
}
db.update("UPDATE job SET jb_id_parent=-1,jb_cr_price=? WHERE jb_id=? AND hq_id=?",quote.courierNet(),parent,user.headquartersId());
metadata(parent,"ng_courier_children",json(ids));return ids;
}
private long child(LegacyUser user,OrderEntryService.Prepared prepared,long original,long parent,CourierAllocation item,int sign,long courier,String sid,Map<String,Object> parentJob,Quote quote,List<Map<String,Object>> tours) {
BigDecimal factor=BigDecimal.valueOf(sign);
var job=fields("hq_id",user.headquartersId(),"vht_id",parentJob.get("vht_id"),"csc_id_payer",item.payerId(),"jb_payment",parentJob.get("jb_payment"),
"jb_status",2,"jb_autoranking",0,"emp_id",user.userId(),"jb_postage",0,"jb_invmode",quote.invoiceMode(),"jb_freetext_1","",
"jb_incomplete",parentJob.get("jb_incomplete"),"jb_sales_tax_rate",item.taxRate(),"jb_sales_tax_rate_sign",item.taxSign(),
"jb_ordertime",prepared.at(),"jb_taketime",prepared.at(),"jb_finishtime",prepared.at(),"jb_booktime",prepared.now(),"jb_createtime",prepared.now(),
"jb_totalprice",item.net().multiply(factor),"jb_cr_price",item.net().multiply(factor),"jb_fixprice",item.base().multiply(factor),
"jb_subtotalprice",item.subtotal().multiply(factor),"jb_serviceprice",item.service().multiply(factor),"jb_markup",quote.markup(),
"cr_id",courier,"cr_sid",sid,"cr_id_order",courier,"jb_id_parent",parent,"jb_tourdata",parentJob.get("jb_tourdata"),
"jb_invtext","Unternehmerverrechnung zu Auftrag Nr. "+original,"jb_invtext_cr",parentJob.get("jb_invtext_cr"));
long id=db.insert("job",job);
db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,0,0,'Fixpreis',0,'',?,?)",
id,item.payerId(),markup(item.base(),quote.markup()).multiply(factor),item.discount());
if(item.service().signum()>0)db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,0,0,'Servicepreis',0,'',?,0)",id,item.payerId(),item.service().multiply(factor));
for(var tour:tours) {var copy=new LinkedHashMap<>(tour);copy.remove("tr_id");copy.put("jb_id",id);db.insert("tour",copy);}
metadata(id,"jb_invtext_dirty","1");return id;
}
List<Long> childIds(long parent) {
var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_courier_children'",parent);
if(rows.isEmpty())return List.of();
try{return JSON.readValue(text(rows.getFirst(),"gdc_content"),JSON.getTypeFactory().constructCollectionType(List.class,Long.class));}catch(Exception e){throw new IllegalStateException(e);}
}
private void metadata(long id,String name,String value){db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname",name,"gdc_content",value,"gdc_context",""));}
private static String clip(String value,int limit){return value.substring(0,Math.min(value.length(),limit));}
private static String json(Object value){try{return JSON.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
}
@@ -0,0 +1,73 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.slf4j.LoggerFactory;
import java.time.LocalDateTime;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.LegacyMapping.*;
/** Durable outbox in an existing InnoDB table; the legacy log itself may be MyISAM. */
@Service
@EnableScheduling
public class OrderEntryAudit {
private final DatabaseService db;
private final ObjectMapper json;
private final String logTable;
record Event(long userId,long headquartersId,String at,String description,int operation) {}
public OrderEntryAudit(DatabaseService db,ObjectMapper json,@Value("${app.history-schema:phoenix_log}") String schema) {
this.db=db;this.json=json;
if(!schema.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Protokollschema.");
logTable="`"+schema+"`.log";
}
void enqueue(long orderId,LegacyUser user,LocalDateTime at,OrderEntryModels.Mode mode,String requestId) {
String label=switch(mode){case IMMEDIATE->"Erfassung";case RESERVATION->"Vorbestellung";case RECURRING->"Dauerauftrag";case RETROSPECTIVE->"Nacherfassung";};
try {
String content=json.writeValueAsString(new Event(user.userId(),user.headquartersId(),at.toString(),label+" (VoTiAn NG)|NG_REQUEST="+requestId,1));
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","ng_creation_log","gdc_content",content,"gdc_context","PENDING"));
} catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw new IllegalStateException("Protokoll konnte nicht vorbereitet werden.",e);}
}
void enqueueLimit(long orderId,LegacyUser user,LocalDateTime at,String requestId,java.math.BigDecimal limit,java.math.BigDecimal price) {
try {
String content=json.writeValueAsString(new Event(user.userId(),user.headquartersId(),at.toString(),
"BLOCK="+limit+"%|OVERRIDE="+price+"|NG_REQUEST="+requestId,154));
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","ng_creation_log","gdc_content",content,"gdc_context","PENDING"));
}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException(e);}
}
void tryPublish(long orderId) {
try { publish(orderId); }
catch(RuntimeException e) {
LoggerFactory.getLogger(getClass()).warn("Ein gesicherter Auftragshistorieneintrag wird später erneut übertragen.");
}
}
void publish(long orderId) {
db.writeTransaction(() -> {
// Serialize publishers across instances. A MyISAM insert can survive a failed commit;
// the stable description marker lets the next attempt find and reuse that log entry.
if(db.lockRows("SELECT jb_id FROM job WHERE jb_id=?",orderId).isEmpty())return null;
var rows=db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_creation_log' AND gdc_context='PENDING'",orderId);
for(var row:rows) {
Event event;
try {event=json.readValue(text(row,"gdc_content"),Event.class);}
catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw new IllegalStateException("Ungültiger Protokollauftrag.",e);}
int operation=event.operation()==154?154:1;
var existing=db.queryForList("SELECT log_id FROM "+logTable+" WHERE jb_id=? AND logo_id=? AND logo_description=? LIMIT 1",orderId,operation,event.description());
if(existing.isEmpty())db.update("INSERT INTO "+logTable+" (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,0,0,?,?)",
operation,event.headquartersId(),orderId,event.userId(),event.description(),LocalDateTime.parse(event.at()));
db.update("UPDATE genericdatacontainer SET gdc_context='SENT' WHERE gdc_id=? AND gdc_context='PENDING'",id(row,"gdc_id"));
}
return null;
});
}
@Scheduled(initialDelay=60000,fixedDelay=30000)
public void retryPending() {
try {
var rows=db.queryForList("SELECT gdc_obj_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_gen_fieldname='ng_creation_log' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 50");
for(var row:rows)tryPublish(id(row,"gdc_obj_id"));
} catch(RuntimeException e) { /* A disconnected DB is already reported by /api/system. Outbox entries remain pending. */ }
}
}
@@ -0,0 +1,188 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryModels.*;
/** Entry lookups have their own right (7); customer/driver administration rights are not required. */
@Service
public class OrderEntryCatalog {
private final DatabaseService db;
static final String COST_CENTER_FROM = """
FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id
WHERE cs.hq_id=? AND cc.csc_visible=1 AND COALESCE(cc.csc_blocked,0)=0
AND cmp.cmp_visible=1 AND cmp.cmp_authenticated=1 AND COALESCE(cmp.cmp_archived,0)=0
""";
static final String COST_CENTER_SELECT = """
SELECT cc.csc_id,cc.csc_name,cc.csc_is_extern,cs.cs_id,cs.cs_eid,cmp.cmp_comp,
cs.csc_id_payer,cs.cs_discount,cs.cs_fixprice_discount,cs.cs_invmode,cs.cs_jbcond,
cs.cs_jobbatch,cs.cs_markup,cs.cs_markup_prov,cmp.cmp_postage,cs.hq_id,
cs.cs_jb_commission_no,cs.cs_commission_no,cs.cs_id_parent,cmp.cmp_sustainability_email,
cs.cs_filter,cmp.tx_id,cmp.cmp_stax_idno
""";
static final String VEHICLE_FROM = """
FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id
JOIN user u ON u.usr_id=cr.usr_id JOIN company cmp ON cmp.cmp_id=cr.cmp_id
WHERE cr.hq_id=? AND u.hq_id=cr.hq_id AND cmp.cmp_visible=1
AND cmp.cmp_authenticated=1 AND COALESCE(cmp.cmp_archived,0)=0
AND COALESCE(cr.cr_app_blocked,0)=0 AND cv.crvh_sid<>''
""";
public OrderEntryCatalog(DatabaseService db) { this.db=db; }
public Options options(LegacyUser user) {
user.require(7);
return db.readTransaction(() -> new Options(
db.queryForList("SELECT mt_sort,mt_value FROM metatype WHERE mt_type='vehicletype' ORDER BY mt_sort")
.stream().map(r -> new Option(id(r,"mt_sort"),text(r,"mt_value"))).toList(),
db.queryForList("SELECT tx_id,tx_sign,tx_value FROM tax ORDER BY tx_id").stream()
.map(r -> new Tax(id(r,"tx_id"),text(r,"tx_sign"),decimal(r,"tx_value"))).toList(),
List.of(new Option(1,"Werktags"),new Option(2,"Werktags mit Samstag"),new Option(3,"Täglich"),
new Option(4,"Wöchentlich"),new Option(5,"Vierzehntäglich"),new Option(6,"Monatlich"),
new Option(7,"Zum Monatsende"),new Option(8,"15. und Monatsende"),new Option(9,"Feiertags"),
new Option(10,"1. und 16."),new Option(11,"Vierwöchentlich")),
user.hasRight(38),user.hasRight(39),Arrays.stream(parameter(0,"EU_COUNTRYCODES").split("[^A-Z]+")).filter(v -> v.matches("[A-Z]{2}")).toList()));
}
public List<CostCenter> costCenters(LegacyUser user,String query) {
user.require(7);
String term=search(query);
return db.queryForList(COST_CENTER_SELECT+COST_CENTER_FROM+
" AND (cmp.cmp_comp LIKE ? OR cs.cs_eid LIKE ? OR cc.csc_name LIKE ? OR CAST(cc.csc_id AS CHAR)=?) ORDER BY cmp.cmp_comp,cc.csc_name,cc.csc_id LIMIT 50",
user.headquartersId(),term,term,term,query==null?"":query.trim()).stream()
.map(r -> new CostCenter(id(r,"csc_id"),id(r,"cs_id"),text(r,"cs_eid"),text(r,"cmp_comp"),
text(r,"csc_name"),nullableId(r,"csc_id_payer"),number(r,"cs_fixprice_discount"),nullableId(r,"tx_id"))).toList();
}
Map<String,Object> costCenter(LegacyUser user,long costCenterId,boolean payer) {
var result=one(db.queryForList(COST_CENTER_SELECT+COST_CENTER_FROM+" AND cc.csc_id=?",user.headquartersId(),costCenterId),
"Kostenstelle ist gesperrt, nicht freigegeben oder gehört zu einer anderen Niederlassung.");
if (payer && id(result,"csc_is_extern")==1)
throw invalid("Eine externe Adresskostenstelle kann nicht als Rechnungszahler verwendet werden.");
return result;
}
long calculatorPayer(LegacyUser user) {
user.require(7);user.require(38);
String configured=parameter(user.headquartersId(),"CSC_ID_PAYER_CALCULATOR");
if(!configured.matches("[1-9][0-9]*"))throw invalid("Die Verrechnungskostenstelle CSC_ID_PAYER_CALCULATOR fehlt.");
// This exact configured technical account is intentionally hidden/unapproved in PHP.
// Never relax the normal payer search or accept a caller-selected hidden cost center.
var row=one(db.queryForList("SELECT cc.csc_id FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cc.csc_id=? AND cs.hq_id=? AND COALESCE(cc.csc_blocked,0)=0 AND cc.csc_is_extern=0 AND COALESCE(cmp.cmp_archived,0)=0",
Long.parseLong(configured),user.headquartersId()),"Die konfigurierte Verrechnungskostenstelle fehlt, ist gesperrt oder gehört zu einer anderen Niederlassung.");
return id(row,"csc_id");
}
public List<Address> addresses(LegacyUser user,long costCenterId) {
user.require(7);
return db.readTransaction(() -> {
costCenter(user,costCenterId,false);
return db.queryForList("""
SELECT ca.adt_id,ca.csc_id,ca.cscad_comp,ca.cscad_hsno,ca.cscad_person,ca.cscad_phone,ca.cscad_remark,
a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country
FROM costcenteraddress ca JOIN address a ON a.ad_id=ca.ad_id
WHERE ca.csc_id=? AND ca.adt_id=4 ORDER BY ca.adt_id
""",costCenterId).stream().map(r -> new Address(id(r,"adt_id"),costCenterId,text(r,"cscad_comp"),
text(r,"ad_street"),text(r,"cscad_hsno"),text(r,"ad_zipcode"),text(r,"ad_city"),
text(r,"ad_country").equals("D")?"DE":text(r,"ad_country"),text(r,"cscad_person"),text(r,"cscad_phone"),text(r,"cscad_remark"))).toList();
});
}
public CustomerDefaults defaults(LegacyUser user,long costCenterId) {
user.require(7);
return db.readTransaction(() -> {
var center=costCenter(user,costCenterId,false);
var mail=db.queryForList("SELECT u.usr_inv_email FROM customer cs JOIN employee e ON e.emp_id=cs.cs_admin JOIN user u ON u.usr_id=e.usr_id WHERE cs.cs_id=?",id(center,"cs_id"));
return new CustomerDefaults(mail.isEmpty()?"":text(mail.getFirst(),"usr_inv_email"),text(center,"cmp_stax_idno"),
id(center,"cs_jb_commission_no")==1 && enabled(user.headquartersId(),"MASK_COMMISSION_NO_MANDATORY"),
enabled(0,"CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"+id(center,"cs_id")));
});
}
public List<CourierFilter> filters(LegacyUser user) {
user.require(7);
return db.queryForList("SELECT crf_short,crf_longtext,crf_status FROM courierfilter ORDER BY crf_short").stream()
.map(r -> new CourierFilter(text(r,"crf_short"),text(r,"crf_longtext"),id(r,"crf_status")==1)).toList();
}
public List<Option> billingPostalCodes(LegacyUser user,String query) {
user.require(7);
return db.queryForList("SELECT srvp_id,srvp_plz FROM serviceplz WHERE srvp_plz LIKE ? ORDER BY srvp_plz LIMIT 50",search(query))
.stream().map(r -> new Option(id(r,"srvp_id"),text(r,"srvp_plz"))).toList();
}
public List<ServiceOption> services(LegacyUser user,long costCenterId) {
user.require(7);var center=costCenter(user,costCenterId,false);
long cs=id(center,"cs_id");
var selected=db.queryForList("SELECT srv_id FROM customerservice WHERE cs_id=? UNION SELECT srv_id FROM service WHERE cs_id=?",cs,cs);
boolean specific=!selected.isEmpty();
var types=specific?db.queryForList("SELECT srvt_id FROM customerservicetype WHERE cs_id=? UNION SELECT srvt_id FROM servicetype WHERE cs_id=?",cs,cs):List.<Map<String,Object>>of();
if(specific && types.isEmpty())return List.of();
var services=selected.stream().map(r -> id(r,"srv_id")).toList();
var typeIds=types.stream().map(r -> id(r,"srvt_id")).toList();
return db.queryForList("""
SELECT DISTINCT s.srv_id,s.srv_name,t.srvt_id,t.srvt_name,s.srv_mode,t.srvt_mode FROM servicehistory h
JOIN service s ON s.srv_id=h.srv_id JOIN servicetype t ON t.srvt_id=h.srvt_id
WHERE h.hq_id=? AND h.cs_id IN (0,?) AND s.srv_name<>'DELETE' AND t.srvt_name<>'DELETE'
ORDER BY s.srv_name,t.srvt_name
""",user.headquartersId(),cs).stream()
.filter(r -> specific?services.contains(id(r,"srv_id")) && typeIds.contains(id(r,"srvt_id")):id(r,"srv_mode")==1 && id(r,"srvt_mode")==1)
.map(r -> new ServiceOption(id(r,"srv_id"),id(r,"srvt_id"),text(r,"srv_name"),text(r,"srvt_name"))).toList();
}
public List<Address> searchAddresses(LegacyUser user,String query) {
user.require(7);if(query==null || query.trim().length()<3)return List.of();
// Saved addresses already visible within this branch, including external delivery addresses.
return db.queryForList("""
SELECT DISTINCT a.ad_id,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM address a
JOIN tour t ON t.ad_id=a.ad_id JOIN job j ON j.jb_id=t.jb_id
WHERE j.hq_id=? AND CONCAT(a.ad_street,' ',a.ad_zipcode,' ',a.ad_city) LIKE ?
ORDER BY a.ad_street,a.ad_zipcode LIMIT 50
""",user.headquartersId(),search(query)).stream()
.map(r -> new Address(id(r,"ad_id"),0,"",text(r,"ad_street"),"",text(r,"ad_zipcode"),text(r,"ad_city"),
text(r,"ad_country").equals("D")?"DE":text(r,"ad_country"),"","","")).toList();
}
public List<Vehicle> vehicles(LegacyUser user,String query) {
user.require(7);
String term=search(query);
return db.queryForList("SELECT cv.crvh_id,cv.cr_id,cv.crvh_sid,cv.vht_id,cv.crvh_vh_sign,cmp.cmp_comp "+VEHICLE_FROM+
" AND (cv.crvh_sid LIKE ? OR cmp.cmp_comp LIKE ? OR cv.crvh_vh_sign LIKE ?) ORDER BY cv.crvh_sid,cv.crvh_id LIMIT 50",
user.headquartersId(),term,term,term).stream().map(r -> new Vehicle(id(r,"crvh_id"),id(r,"cr_id"),
text(r,"crvh_sid"),text(r,"cmp_comp"),(int)id(r,"vht_id"),text(r,"crvh_vh_sign"))).toList();
}
Map<String,Object> vehicle(LegacyUser user,long vehicleId) {
return one(db.queryForList("SELECT cv.crvh_id,cv.cr_id,cv.crvh_sid,cv.vht_id,cv.crvh_payload,cv.crvh_env_badge_level,cr.cr_sid AS current_sid "+VEHICLE_FROM+
" AND cv.crvh_id=?",user.headquartersId(),vehicleId),"Fahrzeug ist nicht verfügbar oder gehört zu einer anderen Niederlassung.");
}
Map<String,Object> headquarters(LegacyUser user) {
return one(db.queryForList("SELECT hq_id,hq_workmode,hq_invmode FROM headquarters WHERE hq_id=? AND COALESCE(hq_disabled,0)=0",
user.headquartersId()),"Die Niederlassung ist nicht für die Auftragserfassung freigegeben.");
}
String parameter(long headquartersId,String key) {
var rows=db.queryForList("SELECT par_value FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key=?",headquartersId,key);
if(rows.size()>1) throw invalid("Mehrdeutige Einstellung im Bestand: "+key);
return rows.isEmpty()?"":text(rows.getFirst(),"par_value");
}
boolean enabled(long hq,String key) { return parameter(hq,key).equals("1"); }
static String search(String query) {
if(query!=null && query.length()>100) throw invalid("Der Suchtext darf höchstens 100 Zeichen enthalten.");
return "%"+(query==null?"":query.trim())+"%";
}
static Map<String,Object> one(List<Map<String,Object>> rows,String message) {
if(rows.size()!=1) throw invalid(message);
return rows.getFirst();
}
static BigDecimal number(Map<String,Object> row,String key) {
BigDecimal value=decimal(row,key);return value==null?BigDecimal.ZERO:value;
}
static ResponseStatusException invalid(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
}
@@ -0,0 +1,29 @@
package de.stadtbote.votian;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.ApiController.user;
@RestController
@RequestMapping("/api/orders")
public class OrderEntryController {
private final OrderEntryCatalog catalog;
private final OrderEntryService service;
private final OrderPhotoWorkflows photos;
public OrderEntryController(OrderEntryCatalog catalog,OrderEntryService service,OrderPhotoWorkflows photos) {this.catalog=catalog;this.service=service;this.photos=photos;}
@GetMapping("/entry/options") public Options options(Authentication auth) {return catalog.options(user(auth));}
@GetMapping("/entry/cost-centers") public List<CostCenter> costCenters(Authentication auth,@RequestParam(defaultValue="") String q) {return catalog.costCenters(user(auth),q);}
@GetMapping("/entry/cost-centers/{id}/addresses") public List<Address> addresses(Authentication auth,@PathVariable long id) {return catalog.addresses(user(auth),id);}
@GetMapping("/entry/cost-centers/{id}/photo-workflows") public PhotoOptions photoWorkflows(Authentication auth,@PathVariable long id) {return photos.options(user(auth),id);}
@GetMapping("/entry/vehicles") public List<Vehicle> vehicles(Authentication auth,@RequestParam(defaultValue="") String q) {return catalog.vehicles(user(auth),q);}
@PostMapping("/quote") public Quote quote(Authentication auth,@Valid @RequestBody Request request) {return service.quote(user(auth),request);}
@PostMapping public ResponseEntity<Created> create(Authentication auth,@Valid @RequestBody Request request) {
var result=service.create(user(auth),request);
return ResponseEntity.status(result.replayed()?200:201).location(URI.create("/api/orders/"+result.id())).body(result);
}
}
@@ -0,0 +1,199 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import de.stadtbote.votian.database.DatabaseService;
import java.math.*;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryCatalog.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Additional entry workflows, using only existing legacy tables and the caller's transaction. */
final class OrderEntryExtras {
private static final ObjectMapper JSON=new ObjectMapper().findAndRegisterModules();
private final DatabaseService db;
private final OrderEntryCatalog catalog;
OrderEntryExtras(DatabaseService db,OrderEntryCatalog catalog) {this.db=db;this.catalog=catalog;}
void validate(LegacyUser user,Request request,Map<String,Object> payer) {
if(id(payer,"cs_jb_commission_no")==1 && catalog.enabled(user.headquartersId(),"MASK_COMMISSION_NO_MANDATORY") && blank(request.reference()))
throw invalid("Für diesen Kunden ist eine Auftragsreferenz / Kommissionsnummer erforderlich.");
if(catalog.enabled(0,"CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"+id(payer,"cs_id")) && request.services().isEmpty())
throw invalid("Dieser Kunde benötigt eine Kalkulation mit Leistungspositionen.");
if(request.payment()==Payment.INVOICE && !request.stops().getFirst().country().equals("DE") && text(payer,"cmp_stax_idno").isBlank())
throw invalid("Für eine Auslandsabholung auf Rechnung fehlt die Umsatzsteuer-ID des Zahlers im Kundenstamm.");
if(!request.services().isEmpty() && request.pricing().mode()!=PriceMode.SERVICES)throw invalid("Für Leistungspositionen bitte die Preisermittlung Leistungskalkulation wählen.");
var filters=catalog.filters(user).stream().map(CourierFilter::code).toList();
if(!filters.containsAll(request.courierFilters()))throw invalid("Ein gewählter Kurierfilter ist nicht mehr verfügbar.");
for(Station stop:request.stops()) {
if(stop.billingPostalId()!=null && db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_id=?",stop.billingPostalId()).isEmpty())
throw invalid("Die gewählte Rechnungs-PLZ ist nicht mehr verfügbar.");
for(Attachment file:stop.attachments())validateAttachment(file);
}
if(request.insurance()!=null)insuranceCourier(user,request);
if(request.sourceOrderId()!=null)requireOrder(user,request.sourceOrderId());
if(request.overwriteTemplateId()!=null && blank(request.tourName()))throw invalid("Bitte einen Namen für die gespeicherte Tour angeben.");
if(request.updateInvoiceEmail() && blank(request.invoiceEmail()))throw invalid("Bitte eine Rechnungsmailadresse angeben.");
}
void globalExclusions(LegacyUser user,Request request,long orderId) {
var excluded=new TreeSet<Long>();StringBuilder context=new StringBuilder();
String home=catalog.parameter(0,"GLOBAL_NATIONALITY_DEFAULT");if(home.isBlank())home="DE";
Map<String,Set<String>> whitelist=new HashMap<>();
String configured=catalog.parameter(0,"GLOBAL_NATIONALITY_WHITELIST");
for(String part:configured.split("\\|"))if(!part.isBlank()) {
String[] pair=part.split("=",2);
if(pair.length!=2 || !pair[0].trim().matches("[A-Z]{2}"))throw invalid("Die Länderfreigaben für die Kurierverteilung sind ungültig.");
whitelist.put(pair[0].trim(),new HashSet<>(Arrays.asList(pair[1].split(","))));
}
var countries=request.stops().stream().map(Station::country).distinct().toList();
var couriers=db.queryForList("""
SELECT DISTINCT c.cr_id,u.usr_country FROM courier c JOIN company cmp ON cmp.cmp_id=c.cmp_id
JOIN couriervehicle cv ON cv.cr_id=c.cr_id JOIN user u ON u.usr_id=c.usr_id
WHERE c.hq_id=? AND cmp.cmp_authenticated=1 AND cmp.cmp_visible=1 AND cmp.cmp_archived=0
""",user.headquartersId());
for(String country:countries)if(!country.equals(home) && whitelist.containsKey(country)) {
for(var row:couriers)if(!whitelist.get(country).contains(text(row,"usr_country")))excluded.add(id(row,"cr_id"));
context.append("[NATION_CHK:").append(country).append("]");
}
String eu=catalog.parameter(0,"EU_COUNTRYCODES");final String domestic=home;
if(countries.stream().anyMatch(c -> !c.equals(domestic) && eu.contains(c))) {
var rows=db.queryForList("""
SELECT DISTINCT c.cr_id FROM courier c JOIN company cmp ON cmp.cmp_id=c.cmp_id
JOIN couriervehicle cv ON cv.cr_id=c.cr_id AND cv.crvh_sid=c.cr_sid
WHERE c.hq_id=? AND cmp.cmp_authenticated=1 AND cmp.cmp_visible=1 AND cmp.cmp_archived=0
AND cv.crvh_totalweight>=2800 AND cv.crvh_filter NOT LIKE '%,EG,%' AND c.cr_filter NOT LIKE '%,EG,%'
""",user.headquartersId());
for(var row:rows)excluded.add(id(row,"cr_id"));
context.append("[3RD_COUNTRY_CHK]");
}
if(!excluded.isEmpty())metadata(orderId,"global_job_excluded_cr_list",excluded.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining(",")),context.toString());
}
private static boolean blank(String value) {return value==null || value.isBlank();}
void replaceTemplate(LegacyUser user,Request request) {
if(blank(request.tourName()))return;
// A shared payer lock also serializes first-time names (no existing template row to lock).
if(db.lockRows("SELECT csc_id FROM costcenter WHERE csc_id=?",request.payerCostCenterId()).isEmpty())throw invalid("Die zahlende Kostenstelle ist nicht mehr verfügbar.");
var matches=db.queryForList("SELECT jb_id FROM job WHERE hq_id=? AND jb_tourname=? AND (csc_id_payer=? OR csc_id_payer_cash=?)",
user.headquartersId(),request.tourName().trim(),request.payerCostCenterId(),request.payerCostCenterId());
if(!matches.isEmpty()) {
if(matches.size()!=1 || !Objects.equals(request.overwriteTemplateId(),id(matches.getFirst(),"jb_id")))
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Dieser Tourname ist bereits vergeben. Bitte die vorhandene Tour laden und das Überschreiben ausdrücklich auswählen.");
db.update("UPDATE job SET jb_tourname=NULL WHERE jb_id=? AND hq_id=?",request.overwriteTemplateId(),user.headquartersId());
} else if(request.overwriteTemplateId()!=null)throw invalid("Die zu überschreibende Tour wurde inzwischen umbenannt. Bitte neu laden.");
}
long persist(LegacyUser user,Request request,OrderEntryService.Prepared prepared,long orderId) {
if(request.toll()!=null && request.toll().signum()>0)metadata(orderId,"jb_toll",request.toll().toPlainString(),"");
if(!blank(request.invoiceOnlyText()))metadata(orderId,"jb_hiddenFreetext_1",request.invoiceOnlyText(),"");
if(request.origin()==6)metadata(orderId,"jb_origin_other",request.originOther().trim(),"");
if(request.sourceOrderId()!=null)metadata(orderId,"ng_source_order",request.sourceOrderId().toString(),"");
if(request.acceptUnverifiedAddresses())metadata(orderId,"ng_address_override","1",Long.toString(user.userId()));
String seed=json(request);
// TEXT holds at most 64 KiB; full multi-stop drafts can be larger.
for(int start=0,index=0;start<seed.length();index++) {
int end=seed.offsetByCodePoints(start,Math.min(12000,seed.codePointCount(start,seed.length())));
metadata(orderId,"ng_entry_seed",seed.substring(start,end),Integer.toString(index));start=end;
}
if(request.updateInvoiceEmail()) {
// The PHP helper also marks the company for master-data export.
var account=one(db.queryForList("SELECT u.usr_id,cs.cmp_id FROM customer cs JOIN employee e ON e.emp_id=cs.cs_admin JOIN user u ON u.usr_id=e.usr_id WHERE cs.cs_id=?",id(prepared.payer(),"cs_id")),"Für den Kunden fehlt das Rechnungsmail-Benutzerkonto.");
var engines=db.queryForList("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='company'");
if(engines.size()!=1 || !text(engines.getFirst(),"ENGINE").equalsIgnoreCase("InnoDB"))throw invalid("Die Rechnungsmail kann nur mit transaktionsfähigem Kundenstamm geändert werden.");
db.update("UPDATE user SET usr_inv_email=? WHERE usr_id=?",request.invoiceEmail().trim(),id(account,"usr_id"));
db.update("UPDATE company SET cmp_modify_status=2 WHERE cmp_id=? AND cmp_modify_status=0",id(account,"cmp_id"));
metadata(orderId,"ng_invoice_email_change",request.invoiceEmail().trim(),user.userId()+"|"+id(account,"usr_id"));
}
return request.insurance()!=null?persistInsurance(user,request,prepared,orderId):0;
}
private void validateAttachment(Attachment file) {
String configured=catalog.parameter(0,"URL_DOC_UPLOAD");
try {
URI expected=URI.create(configured),url=URI.create(file.viewUrl());
if(!Set.of("http","https").contains(expected.getScheme()) || expected.getHost()==null || url.getUserInfo()!=null ||
!Objects.equals(expected.getScheme(),url.getScheme()) || !Objects.equals(expected.getHost(),url.getHost()) || expected.getPort()!=url.getPort() || file.viewUrl().contains("|"))throw new IllegalArgumentException();
} catch(IllegalArgumentException e) {throw invalid("Der Dateilink gehört nicht zum konfigurierten Upload-Dienst.");}
}
void attachments(long tourId,List<Attachment> files) {
int i=0;
for(Attachment file:files)db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",tourId,"gdc_gen_fieldname","file_upload",
"gdc_content",file.filename(),"gdc_context",(file.remark()==null?"":file.remark().replace('|',' '))+"|"+file.viewUrl()+"|"+(++i)));
}
long linkedInsurance(long orderId) {
var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_id_insurance'",orderId);
return rows.isEmpty()?0:Long.parseLong(text(rows.getFirst(),"gdc_content"));
}
void metadata(long orderId,String key,String content,String context) {
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname",key,"gdc_content",content,"gdc_context",context));
}
static String json(Object value) {try {return JSON.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("Auftragsdaten konnten nicht gesichert werden.",e);}}
Request copyCommand(Request request,int index) {
ObjectNode tree=JSON.valueToTree(request);
tree.put("requestId",UUID.nameUUIDFromBytes((request.requestId()+":"+index).getBytes(StandardCharsets.UTF_8)).toString());
tree.put("copies",1);tree.putNull("tourName");tree.putNull("overwriteTemplateId");tree.put("updateInvoiceEmail",false);
try {return JSON.treeToValue(tree,Request.class);}catch(Exception e){throw new IllegalStateException(e);}
}
Request seriesCommand(Request request,int index,java.time.LocalDateTime at,boolean keepCommission) {
ObjectNode tree=JSON.valueToTree(copyCommand(request,index));tree.put("allOccurrences",false);
tree.set("pickupAt",JSON.valueToTree(at));
if(request.warningAt()!=null)tree.set("warningAt",JSON.valueToTree(request.warningAt().plus(java.time.Duration.between(request.pickupAt(),at))));
if(request.acceptUntil()!=null)tree.set("acceptUntil",JSON.valueToTree(request.acceptUntil().plus(java.time.Duration.between(request.pickupAt(),at))));
if(!keepCommission)for(var stop:tree.withArray("stops"))((ObjectNode)stop).put("reference","");
try {return JSON.treeToValue(tree,Request.class);}catch(Exception e){throw new IllegalStateException(e);}
}
Quote withSchedule(Quote first,List<ScheduleItem> schedule,boolean limitExceeded) {
ObjectNode tree=JSON.valueToTree(first);tree.set("schedule",JSON.valueToTree(schedule));tree.put("courierLimitExceeded",limitExceeded);
tree.put("fingerprint",LegacyOrderPricing.hash(first.fingerprint()+"|"+limitExceeded+"|"+json(schedule)));
try {return JSON.treeToValue(tree,Quote.class);}catch(Exception e){throw new IllegalStateException(e);}
}
void saveGroup(long id,List<Long> ids) {metadata(id,"ng_created_group",json(ids),"1");}
List<Long> group(long id) {
var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_created_group'",id);
if(rows.isEmpty())return List.of(id);
try {return JSON.readValue(text(rows.getFirst(),"gdc_content"),JSON.getTypeFactory().constructCollectionType(List.class,Long.class));}
catch(Exception e){throw new IllegalStateException("Die gespeicherte Auftragsgruppe konnte nicht gelesen werden.",e);}
}
Map<String,Object> requireOrder(LegacyUser user,long orderId) {
user.require(7);
return one(db.queryForList("SELECT * FROM job WHERE jb_id=? AND hq_id=?",orderId,user.headquartersId()),"Der Auftrag ist in dieser Niederlassung nicht verfügbar.");
}
String calculatorText(LegacyUser user,Request request,Quote quote) {
var options=catalog.services(user,request.payerCostCenterId());
var names=new ArrayList<String>();var types=new ArrayList<String>();var amounts=new ArrayList<String>();var prices=new ArrayList<String>();
for(int i=0;i<request.services().size();i++) {
var item=request.services().get(i);var option=options.stream().filter(o -> o.serviceId()==item.serviceId() && o.typeId()==item.typeId()).findFirst().orElseThrow(() -> invalid("Leistungsposition nicht mehr verfügbar."));
if(option.service().contains("¹") || option.service().contains("²") || option.type().contains("¹") || option.type().contains("²"))throw invalid("Der Leistungsname enthält ein reserviertes Bestands-Trennzeichen.");
names.add(option.service());types.add(option.type());amounts.add(item.quantity().stripTrailingZeros().toPlainString());prices.add(quote.components().get(i).subtotal().toPlainString());
}
return String.join("¹",names)+"²"+String.join("¹",types)+"²"+String.join("¹",amounts)+"²"+String.join("¹",prices);
}
static BigDecimal insurancePremium(Insurance insurance) {
if(insurance==null)return BigDecimal.ZERO;
BigDecimal rate=new BigDecimal(switch(insurance.rate()){case 1,5->"0.070";case 2->"0.175";case 3->"0.085";case 4->"0.200";default->throw invalid("Ungültiger Versicherungstarif.");});
BigDecimal factor=new BigDecimal(insurance.rate()==3 || insurance.rate()==4?"1":"1.19");
return insurance.goodsValue().multiply(rate).movePointLeft(2).multiply(factor).setScale(2,RoundingMode.CEILING).max(new BigDecimal("10").multiply(factor));
}
private Map<String,Object> insuranceCourier(LegacyUser user,Request request) {
return one(db.queryForList("SELECT cv.cr_id,cv.crvh_sid FROM couriervehicle cv JOIN courier c ON c.cr_id=cv.cr_id JOIN headquarters h ON h.hq_id=c.hq_id WHERE h.hq_id=? AND cv.crvh_sid=CONCAT(h.hq_mnemonic,?)",
user.headquartersId(),request.vehicleTypeId()>=7?"1777":"1888"),"Der technische Versicherungskurier fehlt für diese Niederlassung.");
}
private long persistInsurance(LegacyUser user,Request request,OrderEntryService.Prepared prepared,long parent) {
Insurance insurance=request.insurance();BigDecimal premium=insurancePremium(insurance);
var courier=insuranceCourier(user,request);
long id=db.insert("job",fields("hq_id",user.headquartersId(),"vht_id",999,"csc_id_payer",request.payerCostCenterId(),"jb_ordertime",prepared.at(),
"jb_reserv",0,"cr_id",id(courier,"cr_id"),"cr_sid",text(courier,"crvh_sid"),"cr_id_order",id(courier,"cr_id"),
"jb_cr_filter","","jb_cr_filter_opt","","jb_waitstorno",0,"jb_status",insurance.specialCase()?1:2,"jb_incomplete",1,"jb_globaljob",0,
"jb_taketime",prepared.now(),"jb_finishtime",prepared.now(),"emp_id",user.userId(),"jb_fixprice",premium,"jb_totalprice",premium,"jb_subtotalprice",premium,
"jb_mediationarea_id",0,"jb_mediationarea_name","","jb_sales_tax_rate",19,"jb_sales_tax_rate_sign","V",
"jb_freetext_1","Gebuchte Transportversicherung zu Auftrag "+parent+" mit "+insurance.goodsValue()+" EUR Warenwert, Tarif "+insurance.rate()+", Prämie "+premium+" EUR",
"jb_tourdata",".|.|DE","jb_createtime",prepared.now(),"jb_id_parent",0,"jb_dispoinfo","HV zu "+parent,"jb_timeunits",2,"jb_booktime",prepared.now(),"jb_origin",8,"jb_orderer",request.orderer()));
long address=db.insert("address",fields("ad_street","Warenwert "+insurance.goodsValue()+" Euro","ad_zipcode",".","ad_city",".","ad_country","DE"));
db.insert("tour",fields("jb_id",id,"ad_id",address,"tr_sort",1,"tr_comp","Transportzusatzversicherung","tr_hsno",".","tr_person","","csc_id",request.payerCostCenterId(),"tr_remark","","tr_status",0,"tr_ware_from_to",0,"tr_mediationarea_id",0));
db.insert("tourservice",fields("jb_id",id,"csc_id",request.payerCostCenterId(),"tr_sort",0,"srv_id",0,"trs_srv_name","Fixpreis","srvt_id",0,"trs_srvt_name","","trs_price",premium,"trs_discount",0));
metadata(parent,"jb_id_insurance",Long.toString(id),insurance.goodsValue()+"|"+insurance.rate()+"|"+(insurance.specialCase()?1:0));
metadata(id,"jb_invtext_dirty","1",prepared.now().toString());
return id;
}
}
@@ -0,0 +1,33 @@
package de.stadtbote.votian;
import jakarta.validation.Valid;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.ApiController.user;
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
@RestController
@RequestMapping("/api/orders/entry")
public class OrderEntryFeaturesController {
private final OrderEntryInventory inventory;
private final OrderEntryCatalog catalog;
private final OrderRoutingService routing;
public OrderEntryFeaturesController(OrderEntryInventory inventory,OrderEntryCatalog catalog,OrderRoutingService routing){this.inventory=inventory;this.catalog=catalog;this.routing=routing;}
@GetMapping("/articles") public List<ArticleOption> articles(Authentication auth,@RequestParam(defaultValue="") String q){return inventory.articles(user(auth),q);}
@GetMapping("/stocks") public List<StockOption> stocks(Authentication auth,@RequestParam long payerId,@RequestParam long articleId,@RequestParam(defaultValue="") String q){return inventory.stocks(user(auth),payerId,articleId,q);}
@GetMapping("/features") public Map<String,Object> features(Authentication auth) {
var user=user(auth);user.require(7);String url=catalog.parameter(0,"URL_GEOCORDS").trim();
if(!url.isEmpty())try {
URI uri=URI.create(url);
if(!Set.of("http","https").contains(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null || uri.getFragment()!=null)throw new IllegalArgumentException();
}catch(IllegalArgumentException e){throw invalid("Die Kartenadresse URL_GEOCORDS ist ungültig.");}
return Map.of("mapUrl",url,"bookStock",user.hasRight(14));
}
@PostMapping("/map/locate") public MapPoint locate(Authentication auth,@Valid @RequestBody Station station) {
user(auth).require(7);var point=routing.geocode(station);return new MapPoint(java.math.BigDecimal.valueOf(point.latitude()),java.math.BigDecimal.valueOf(point.longitude()));
}
@PostMapping("/map/address") public MapAddress address(Authentication auth,@Valid @RequestBody MapPoint point){user(auth).require(7);return routing.reverse(point);}
}
@@ -0,0 +1,73 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryModels.*;
/** Existing emp_headquarters plus employeerights (one-based rights), within the original mandator. */
@Service
public class OrderEntryHeadquarters {
private final DatabaseService db;
public OrderEntryHeadquarters(DatabaseService db){this.db=db;}
private record Access(Map<String,Object> employee,List<Map<String,Object>> branches,List<Map<String,Object>> rights) {}
private Access access(LegacyUser user) {
user.require(7);
var rows=db.queryForList("SELECT u.hq_id,e.emp_rights,e.emp_headquarters,h.hq_admin 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_id=? AND e.emp_id=? AND u.usr_type=1 AND u.hq_id=?",user.userId(),user.employeeId(),user.originalHeadquartersId());
if(rows.size()!=1)throw denied();
var branches=db.queryForList("SELECT DISTINCT h.hq_id,h.hq_mnemonic FROM headquarters h JOIN mandatorheadquarters target ON target.hq_id=h.hq_id JOIN mandatorheadquarters origin ON origin.md_id=target.md_id WHERE origin.hq_id=? AND origin.md_id>0 ORDER BY h.hq_mnemonic,h.hq_id",user.originalHeadquartersId());
return new Access(rows.getFirst(),branches,db.queryForList("SELECT hq_id,rgt_id FROM employeerights WHERE emp_id=?",user.employeeId()));
}
private String rights(LegacyUser user,long target,Access access) {
String rights=text(access.employee(),"emp_rights");
if(target==user.originalHeadquartersId())return rights;
boolean root=id(access.employee(),"hq_admin")==user.employeeId();
if(!root) {
boolean listed=Arrays.stream(text(access.employee(),"emp_headquarters").split(",")).anyMatch(s -> s.trim().equals(Long.toString(target)));
boolean entry=access.rights().stream().anyMatch(r -> id(r,"hq_id")==target && id(r,"rgt_id")==8);
if(!listed || !entry)throw denied();
}
// PHP getRights: foreign-branch call-center access without master right 10 is restricted.
if(rights.length()<=10 || rights.charAt(10)!='1') {
String mask="1110100111000000000000000000000000000000000000000000000000000000000000";
StringBuilder restricted=new StringBuilder();
for(int i=0;i<rights.length();i++)restricted.append(i<mask.length() && mask.charAt(i)=='1' && rights.charAt(i)=='1'?'1':'0');
rights=restricted.toString();
}
// Warehouse access is a separate master right; foreign headquarters require its matrix grant too.
if(!root && access.rights().stream().noneMatch(r -> id(r,"hq_id")==target && id(r,"rgt_id")==15) && rights.length()>14)
rights=rights.substring(0,14)+"0"+rights.substring(15);
return rights;
}
public List<Option> available(LegacyUser user) {
return db.readTransaction(() -> {
var access=access(user);var result=new ArrayList<Option>();
for(var branch:access.branches())try {
long id=id(branch,"hq_id");String rights=rights(user,id,access);
if(rights.length()>7 && rights.charAt(7)=='1')result.add(new Option(id,text(branch,"hq_mnemonic")));
}catch(AccessDeniedException ignored) {}
return result;
});
}
LegacyUser select(LegacyUser user,long target) {
return db.readTransaction(() -> {
var access=access(user);
if(access.branches().stream().noneMatch(r -> id(r,"hq_id")==target))throw denied();
var scoped=new LegacyUser(user.userId(),user.employeeId(),target,user.username(),rights(user,target,access),user.originalHeadquartersId());
scoped.require(7);return scoped;
});
}
private static AccessDeniedException denied(){return new AccessDeniedException("Keine Freigabe zur Auftragserfassung in dieser Niederlassung.");}
}
@RestController
class OrderEntryHeadquartersController {
private final OrderEntryHeadquarters headquarters;
OrderEntryHeadquartersController(OrderEntryHeadquarters headquarters){this.headquarters=headquarters;}
@GetMapping("/api/orders/entry/headquarters") Map<String,Object> available(Authentication auth) {
var user=ApiController.user(auth);return Map.of("currentId",user.originalHeadquartersId(),"items",headquarters.available(user));
}
}
@@ -0,0 +1,223 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import java.math.*;
import java.time.LocalDateTime;
import java.util.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryCatalog.*;
import static de.stadtbote.votian.OrderEntryService.*;
import static de.stadtbote.votian.LegacyMapping.*;
/** Articles and immediate stock bookings, matching inc_stock.inc.php without schema changes. */
@Service
public class OrderEntryInventory {
private final DatabaseService db;
private final OrderEntryCatalog catalog;
private final ThreadLocal<Boolean> currentReads=ThreadLocal.withInitial(() -> false);
private List<Map<String,Object>> read(String sql,Object... parameters) {return currentReads.get()?db.lockRows(sql,parameters):db.queryForList(sql,parameters);}
public OrderEntryInventory(DatabaseService db,OrderEntryCatalog catalog){this.db=db;this.catalog=catalog;}
public List<ArticleOption> articles(LegacyUser user,String query) {
user.require(7);
return read("SELECT at_id,at_eid,at_name,at_serialno,at_totalweight,at_packingpieces FROM article WHERE hq_id=? AND at_visible=1 AND at_authenticated=1 AND (at_name LIKE ? OR at_eid LIKE ? OR at_barcode LIKE ?) ORDER BY at_name,at_id LIMIT 50",user.headquartersId(),search(query),search(query),search(query))
.stream().map(r -> new ArticleOption(id(r,"at_id"),text(r,"at_eid"),text(r,"at_name"),id(r,"at_serialno")!=0,number(r,"at_totalweight"),(int)id(r,"at_packingpieces"))).toList();
}
public List<StockOption> stocks(LegacyUser user,long payerId,long articleId,String query) {
user.require(7);user.require(14);
return db.readTransaction(() -> {
long customer=id(catalog.costCenter(user,payerId,true),"cs_id");article(user,articleId);
var result=new ArrayList<StockOption>();
var rows=read("SELECT s.*,COALESCE(sa.stkat_itemquantity,0) AS available FROM stock s LEFT JOIN stockarticle sa ON sa.stk_id=s.stk_id AND sa.at_id=? WHERE s.hq_id=? AND s.stk_visible=1 AND s.stk_readonly=0 AND (s.cs_id=0 OR s.cs_id=?) AND (s.stk_name LIKE ? OR s.stk_barcode LIKE ?) ORDER BY s.stk_name,s.stk_id LIMIT 200",articleId,user.headquartersId(),customer,search(query),search(query));
for(var row:rows) {
if(allowed(user,row,customer))result.add(new StockOption(id(row,"stk_id"),text(row,"stk_name")+" (#"+id(row,"stk_id")+")",id(row,"available")));
if(result.size()==50)break;
}
return result;
});
}
Map<String,Object> article(LegacyUser user,long articleId) {
return one(read("SELECT * FROM article WHERE at_id=? AND hq_id=? AND at_visible=1 AND at_authenticated=1",articleId,user.headquartersId()),"Artikel ist nicht freigegeben oder gehört zu einer anderen Niederlassung.");
}
private Map<String,Object> article(LegacyUser user,ArticleItem item) {
if(item.articleId()>0) {
if(item.freeArticle()!=null)throw invalid("Bitte entweder einen Stammartikel oder eine freie Artikelposition wählen.");
return article(user,item.articleId());
}
if(item.freeArticle()==null)throw invalid("Für eine freie Artikelposition fehlen Bezeichnung und Artikeldaten.");
if(item.booking()!=null)throw invalid("Lagerbewegungen benötigen einen Artikel aus dem Artikelstamm.");
var free=item.freeArticle();
return fields("at_id",0,"at_name",free.name().trim(),"at_packingpieces",free.packingPieces());
}
private BigDecimal weight(ArticleItem item,Map<String,Object> article) {
return item.freeArticle()!=null?item.freeArticle().totalWeight():number(article,"at_totalweight").multiply(BigDecimal.valueOf(item.quantity()));
}
private List<Map<String,Object>> ancestry(LegacyUser user,Map<String,Object> row) {
var result=new ArrayList<Map<String,Object>>();var seen=new HashSet<Long>();
while(true) {
if(!seen.add(id(row,"stk_id")) || seen.size()>50)throw invalid("Die Lagerhierarchie ist ungültig.");
result.add(row);long parent=id(row,"stk_pre_id");if(parent==0)return result;
var parents=read("SELECT * FROM stock WHERE stk_id=? AND hq_id=?",parent,user.headquartersId());
if(parents.size()!=1)throw invalid("Der übergeordnete Lagerort ist nicht zugänglich.");row=parents.getFirst();
}
}
private boolean allowed(LegacyUser user,Map<String,Object> row,long customer) {
var chain=ancestry(user,row);
if(chain.stream().anyMatch(r -> id(r,"stk_visible")!=1 || id(r,"stk_readonly")!=0 || (id(r,"cs_id")!=0 && id(r,"cs_id")!=customer)))return false;
var parameters=read("SELECT par_value FROM parameter WHERE emp_id=? AND hq_id IN (0,?) AND par_key='MASK_STK_SUBSTOCK_ACCESS' ORDER BY hq_id DESC",user.employeeId(),user.headquartersId());
String restriction=parameters.isEmpty()?"":text(parameters.getFirst(),"par_value");
if(restriction.isBlank())return true;
long root=id(chain.getLast(),"stk_id");boolean restricted=false,accessible=false;
for(String group:restriction.split("\\||--")) {
String[] ids=group.split(",");if(ids.length==0 || !ids[0].trim().equals(Long.toString(root)))continue;
restricted=true;
for(int i=1;i<ids.length;i++) {String selected=ids[i].trim();if(chain.stream().anyMatch(r -> selected.equals(Long.toString(id(r,"stk_id")))))accessible=true;}
}
return !restricted || accessible;
}
private Map<String,Object> stock(LegacyUser user,long stockId,long customer) {
var row=one(read("SELECT * FROM stock WHERE stk_id=? AND hq_id=?",stockId,user.headquartersId()),"Lagerort ist in dieser Niederlassung nicht verfügbar.");
if(!allowed(user,row,customer))throw invalid("Für den Lagerort fehlen Schreibrechte oder die Kundenzuordnung.");return row;
}
record Key(long stock,long article) {}
List<ArticleLine> quote(LegacyUser user,Request request) {return quote(user,request,false);}
List<ArticleLine> quote(LegacyUser user,Request request,boolean locked) {
currentReads.set(locked);
try {return preview(user,request);}finally{currentReads.remove();}
}
private List<ArticleLine> preview(LegacyUser user,Request request) {
if(request.articles().isEmpty())return List.of();
user.require(7);
long customer=id(catalog.costCenter(user,request.payerCostCenterId(),true),"cs_id");
var balances=new HashMap<Key,Long>();var occupied=new HashMap<Long,Long>();var serialLocations=new HashMap<String,Long>();
var result=new ArrayList<ArticleLine>();
boolean booking=request.articles().stream().anyMatch(i -> i.booking()!=null);
if(booking) {
user.require(14);
if(request.offer() || request.mode()==Mode.RECURRING || request.allOccurrences() || (request.tourName()!=null && !request.tourName().isBlank()))
throw invalid("Sofortige Lagerbuchungen sind nicht mit Angeboten, Daueraufträgen oder Tourvorlagen kombinierbar.");
}
int copies=booking?request.copies():1;
for(int copy=0;copy<copies;copy++)for(var item:request.articles()) {
if(item.station()>request.stops().size())throw invalid("Die Artikelposition verweist auf eine nicht vorhandene Station.");
var article=article(user,item);
String serial=textOrEmpty(item.serialNumber()).trim();
if(id(article,"at_serialno")!=0 && (serial.isBlank() || item.quantity()!=1))throw invalid("Seriennummernpflichtige Artikel benötigen eine Seriennummer und Menge 1.");
if(!serial.isBlank() && item.quantity()!=1)throw invalid("Eine Seriennummer kann nur einer einzelnen Einheit zugeordnet werden.");
String source="",destination="";
if(item.booking()!=null) {
long from=item.booking().fromStockId(),to=item.booking().toStockId();
if(from==to)throw invalid("Quell- und Ziellager müssen verschieden sein; 0 steht für Ein-/Ausgang außerhalb des Lagers.");
if(id(article,"at_stk_itemquantity")<=0 || id(article,"at_stk_areaquantity")<=0)throw invalid("Für diesen Artikel fehlt das Verhältnis von Stückzahl zu Lagerplätzen.");
Map<String,Object> fromStock=from==0?Map.of():stock(user,from,customer),toStock=to==0?Map.of():stock(user,to,customer);
source=from==0?"Wareneingang":text(fromStock,"stk_name");destination=to==0?"Warenausgang":text(toStock,"stk_name");
long beforeFrom=from==0?0:balance(balances,from,item.articleId()),beforeTo=to==0?0:balance(balances,to,item.articleId());
if(from>0 && beforeFrom<item.quantity())throw invalid("Nicht genügend Bestand in "+source+" für "+text(article,"at_name")+" (verfügbar: "+beforeFrom+").");
if(!serial.isBlank()) {
String key=item.articleId()+"|"+serial;
long current=serialLocations.computeIfAbsent(key,k -> {
var rows=read("SELECT stk_id FROM articleitem WHERE at_id=? AND ati_serialno=?",item.articleId(),serial);
if(rows.size()>1)throw invalid("Die Seriennummer ist im Bestand mehrfach vorhanden.");
return rows.isEmpty()?0L:id(rows.getFirst(),"stk_id");
});
if(current!=from)throw invalid("Die Seriennummer befindet sich nicht im angegebenen Quelllager oder ist bereits eingelagert.");
serialLocations.put(key,to);
}
if(from>0)changeBalance(balances,occupied,fromStock,article,beforeFrom,beforeFrom-item.quantity());
if(to>0) {
long used=occupation(occupied,to),delta=areas(article,beforeTo+item.quantity())-areas(article,beforeTo);
var chain=ancestry(user,toStock);
if(id(chain.getLast(),"stk_planning")==1) {
long reserved=read("SELECT COALESCE(SUM(stk_maxquantity),0) AS reserved FROM stock WHERE stk_pre_id=?",to).stream().mapToLong(r -> id(r,"reserved")).sum();
if(used+delta+reserved>id(toStock,"stk_maxquantity"))throw invalid("Die Lagerkapazität in "+destination+" reicht für diese Buchung nicht aus.");
}
changeBalance(balances,occupied,toStock,article,beforeTo,beforeTo+item.quantity());
}
}
if(weight(item,article).compareTo(new BigDecimal("999999.999"))>0)
throw invalid("Das Artikelgesamtgewicht überschreitet die Grenze der Bestandsdatenbank.");
if(copy==0)result.add(new ArticleLine(item.articleId(),item.station(),text(article,"at_name"),item.quantity(),serial,
weight(item,article).setScale(3,RoundingMode.HALF_UP),(int)id(article,"at_packingpieces"),source,destination,item.booking()!=null));
}
return result;
}
private long balance(Map<Key,Long> values,long stock,long article) {return values.computeIfAbsent(new Key(stock,article),k -> read("SELECT stkat_itemquantity FROM stockarticle WHERE stk_id=? AND at_id=?",stock,article).stream().mapToLong(r -> id(r,"stkat_itemquantity")).sum());}
private long occupation(Map<Long,Long> values,long stock) {return values.computeIfAbsent(stock,k -> read("SELECT COALESCE(SUM(stkat_quantity),0) AS occupied FROM stockarticle WHERE stk_id=?",stock).stream().mapToLong(r -> id(r,"occupied")).sum());}
private void changeBalance(Map<Key,Long> balances,Map<Long,Long> occupied,Map<String,Object> stock,Map<String,Object> article,long before,long after) {
long id=id(stock,"stk_id");occupied.put(id,occupation(occupied,id)+areas(article,after)-areas(article,before));balances.put(new Key(id,id(article,"at_id")),after);
}
static long areas(Map<String,Object> article,long quantity) {
return BigDecimal.valueOf(quantity).multiply(BigDecimal.valueOf(id(article,"at_stk_areaquantity"))).divide(BigDecimal.valueOf(id(article,"at_stk_itemquantity")),0,RoundingMode.CEILING).longValueExact();
}
void lock(LegacyUser user,Request request) {
if(request.articles().isEmpty())return;
boolean booking=request.articles().stream().anyMatch(i -> i.booking()!=null);
List<String> tables=booking?List.of("article","tourarticle","stock","stockarticle","stockmove","articleitem"):List.of("article","tourarticle");
for(String table:tables) {
var engine=read("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?",table);
if(engine.size()!=1 || !text(engine.getFirst(),"ENGINE").equalsIgnoreCase("InnoDB"))throw invalid("Artikel-/Lagerbuchung benötigt transaktionsfähige Bestandstabellen: "+table);
}
// Stable lock order includes parent stocks (capacity) and articles (new serial-number gaps).
var stockIds=new TreeSet<Long>();var articleIds=new TreeSet<Long>();
long customer=id(catalog.costCenter(user,request.payerCostCenterId(),true),"cs_id");
for(var item:request.articles()) {
if(item.articleId()>0)articleIds.add(item.articleId());
if(item.booking()!=null)for(long id:List.of(item.booking().fromStockId(),item.booking().toStockId()))if(id>0)
for(var parent:ancestry(user,stock(user,id,customer)))stockIds.add(id(parent,"stk_id"));
}
for(long id:stockIds)db.lockRows("SELECT stk_id FROM stock WHERE stk_id=? AND hq_id=?",id,user.headquartersId());
for(long id:articleIds)db.lockRows("SELECT at_id FROM article WHERE at_id=? AND hq_id=?",id,user.headquartersId());
for(long id:stockIds)db.lockRows("SELECT stk_id,at_id FROM stockarticle WHERE stk_id=? ORDER BY at_id",id);
for(long id:articleIds)db.lockRows("SELECT ati_id FROM articleitem WHERE at_id=? ORDER BY ati_id",id);
}
void persist(LegacyUser user,Request request,long orderId,List<Long> tours,LocalDateTime now) {
currentReads.set(true);
try {write(user,request,orderId,tours,now);}finally{currentReads.remove();}
}
private void write(LegacyUser user,Request request,long orderId,List<Long> tours,LocalDateTime now) {
if(request.articles().isEmpty())return;
int sort=0;
for(var item:request.articles()) {
var article=article(user,item);String serial=textOrEmpty(item.serialNumber()).trim();
db.insert("tourarticle",fields("jb_id",orderId,"tr_id",tours.get(item.station()-1),"tr_sort",item.station(),"trat_sort",++sort,"at_id",item.articleId(),
"trat_name",text(article,"at_name").substring(0,Math.min(30,text(article,"at_name").length())),"trat_quantity",item.quantity(),
"trat_weight",weight(item,article).setScale(3,RoundingMode.HALF_UP),"trat_packingpieces",id(article,"at_packingpieces"),
"trat_int_serialno","","trat_serialno",serial,"trat_price",item.freeArticle()==null?BigDecimal.ZERO:item.freeArticle().price(),"trat_state",0,"trat_remark",textOrEmpty(item.remark()),"trat_createtime",now));
if(item.booking()==null)continue;
book(user,item,orderId,now);
}
}
long book(LegacyUser user,ArticleItem item,long orderId,LocalDateTime now) {
boolean previous=currentReads.get();currentReads.set(true);
try {
var article=article(user,item);String serial=textOrEmpty(item.serialNumber()).trim();
long from=item.booking().fromStockId(),to=item.booking().toStockId();
for(long stock:List.of(from,to))if(stock>0) {
long current=balance(new HashMap<>(),stock,item.articleId());long after=current+(stock==from?-item.quantity():item.quantity());
if(after<0)throw conflict("Der Lagerbestand hat sich geändert. Bitte neu berechnen.");
if(after==0)db.update("DELETE FROM stockarticle WHERE stk_id=? AND at_id=?",stock,item.articleId());
else if(db.update("UPDATE stockarticle SET stkat_itemquantity=?,stkat_quantity=?,stkat_modifytime=? WHERE stk_id=? AND at_id=?",after,areas(article,after),now,stock,item.articleId())==0)
db.update("INSERT INTO stockarticle (stk_id,at_id,stkat_itemquantity,stkat_quantity,stkat_modifytime) VALUES (?,?,?,?,?)",stock,item.articleId(),after,areas(article,after),now);
}
var serialData=new LinkedHashMap<String,Object>();
if(!serial.isBlank()&&from>0&&to==0){
var rows=read("SELECT * FROM articleitem WHERE at_id=? AND stk_id=? AND ati_serialno=?",item.articleId(),from,serial);
if(rows.size()!=1)throw conflict("Der Seriennummernbestand hat sich geändert.");
rows.getFirst().forEach((key,value)->{if(key.startsWith("ati_data_"))serialData.put(key,value);});
}
if(!serial.isBlank()) {
if(from==0)db.insert("articleitem",fields("at_id",item.articleId(),"stk_id",to,"ati_serialno",serial));
else if(to==0)db.update("DELETE FROM articleitem WHERE at_id=? AND stk_id=? AND ati_serialno=?",item.articleId(),from,serial);
else db.update("UPDATE articleitem SET stk_id=? WHERE at_id=? AND stk_id=? AND ati_serialno=?",to,item.articleId(),from,serial);
}
long movement=db.insert("stockmove",fields("stkmv_timestamp",now,"stk_id_from",from,"stk_id_to",to,"at_id",item.articleId(),"stkmv_itemquantity",item.quantity(),
"stkmv_bundlequantity",id(article,"at_bundlequantity"),"stkmv_bundlecode",text(article,"at_bundlecode"),"stkmv_tan",Long.toString(orderId),
"stkmv_remark",textOrEmpty(item.remark()),"stkmv_serialno",serial,"stkmv_data","","at_id_bundle",0,"stkmv_datetime",now,"stkmv_datetime2",now,"jb_id",orderId,"usr_id",user.userId()));
if(!serialData.isEmpty()){
String data;try{data=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(serialData);}catch(Exception e){throw new IllegalStateException(e);}
db.insert("genericdatacontainer",fields("gdc_obj_type","ngstkmeta","gdc_obj_id",movement,"gdc_gen_fieldname","articleitem","gdc_content",data,"gdc_context",""));
}
return movement;
} finally {if(previous)currentReads.set(true);else currentReads.remove();}
}
}
@@ -0,0 +1,156 @@
package de.stadtbote.votian;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Commands for new orders only; no client-owned status, totals, branch or audit fields. */
public final class OrderEntryModels {
private OrderEntryModels() {}
public enum Mode { IMMEDIATE, RESERVATION, RECURRING, RETROSPECTIVE }
public enum Payment { INVOICE, CASH }
public enum PriceMode { TARIFF, FIXED, KILOMETER, ZONE, SERVICES }
public enum LonghaulMode { AUTO, ASAP }
public record Station(
@Positive Long costCenterId,
@NotNull Models.StopType type,
@NotBlank @Size(max=50) String name,
@NotBlank @Size(max=50) String street,
@NotBlank @Size(max=10) String houseNumber,
@NotBlank @Size(max=10) String postalCode,
@NotBlank @Size(max=50) String city,
@NotBlank @Pattern(regexp="[A-Z]{2}") String country,
@Size(max=50) String contact, @Size(max=20) String phone,
@Size(max=2000) String note, @Size(max=30) String reference, @Min(0) Integer photoWorkflow,
@Positive Long billingPostalId, @Size(max=30) List<@Valid Attachment> attachments) {
public Station { attachments=attachments==null?List.of():List.copyOf(attachments); }
}
public record Attachment(@NotBlank @Size(max=255) String filename, @NotBlank @Size(max=1500) String viewUrl,
@Size(max=200) String remark) {}
public record ServiceItem(@Positive long serviceId, @Positive long typeId,
@NotNull @DecimalMin("0.001") @DecimalMax("99999") @Digits(integer=5,fraction=3) BigDecimal quantity) {}
public record Insurance(@NotNull @DecimalMin("0.01") @DecimalMax("9999999.99") @Digits(integer=7,fraction=2) BigDecimal goodsValue,
@Min(1) @Max(5) int rate, boolean specialCase) {}
public record Recurrence(@Min(1) @Max(11) int rhythm,
@NotNull @Pattern(regexp="[01]{7}") String weekdays,
LocalDate endDate) {}
public record Pricing(@NotNull PriceMode mode,
@DecimalMin("0.00") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal fixedPrice,
@DecimalMin("0.00") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal servicePrice,
@DecimalMin("0.00") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal courierPrice,
@DecimalMin("0.00") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal courierServicePrice,
@DecimalMin("-100") @DecimalMax("100") @Digits(integer=3,fraction=2) BigDecimal markup,
@DecimalMin("-100") @DecimalMax("100") @Digits(integer=3,fraction=2) BigDecimal courierMarkup,
boolean applyFixedDiscount,
@NotNull @Positive Long taxId) {}
public record Request(
@NotNull Mode mode, LocalDateTime pickupAt, LocalDateTime warningAt,
@Valid Recurrence recurrence,
@NotNull @Positive Long payerCostCenterId,
@NotNull Payment payment, @Min(1) @Max(100) Integer cashStop,
@NotNull @Min(0) Integer vehicleTypeId,
@Positive Long courierVehicleId,
@NotNull @Size(min=1,max=100) List<@Valid @NotNull Station> stops,
@NotBlank @Size(max=50) String orderer, @Size(max=50) String reference,
@Size(max=4000) String notes, @Size(max=120) String dispatchNote,
@DecimalMin("0") @DecimalMax("999999") @Digits(integer=6,fraction=0) BigDecimal weight,
@DecimalMin("0") @DecimalMax("9999") @Digits(integer=4,fraction=0) BigDecimal length,
@DecimalMin("0") @DecimalMax("9999") @Digits(integer=4,fraction=0) BigDecimal width,
@DecimalMin("0") @DecimalMax("9999") @Digits(integer=4,fraction=0) BigDecimal height,
@DecimalMin("0") @DecimalMax("9999") @Digits(integer=4,fraction=0) BigDecimal positions,
@DecimalMin("0") @DecimalMax("999999") BigDecimal distanceKm,
boolean manualDispatch, boolean incomplete, boolean confirmCourierLimit, LonghaulMode longhaul,
@NotNull @Valid Pricing pricing,
@Size(max=64) String quoteFingerprint,
@NotNull @Pattern(regexp="[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}") String requestId,
@Min(1) @Max(6) int origin, @Size(max=40) String originOther, boolean offer,
@DecimalMin("0") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal toll,
@Size(max=4000) String invoiceOnlyText,
@Size(max=50) List<@NotBlank @Size(max=20) String> courierFilters,
boolean cancelIfUnavailable, boolean allCouriers, LocalDateTime acceptUntil,
boolean acceptUnverifiedAddresses,
@Min(1) @Max(10) int copies, boolean allOccurrences,
@Size(max=50) String tourName, @Positive Long overwriteTemplateId, @Positive Long sourceOrderId,
@Valid Insurance insurance, @Size(max=100) List<@Valid @NotNull ServiceItem> services,
@Email @Size(max=254) String invoiceEmail, boolean updateInvoiceEmail,
boolean costSplit, @Size(max=20) List<@NotNull @Valid CourierShare> courierShares,
@Size(max=100) List<@NotNull @Valid ArticleItem> articles, @Valid SustainabilityInput sustainability, @Positive Integer photoSet) {
public Request {
courierShares=courierShares==null?List.of():List.copyOf(courierShares);
articles=articles==null?List.of():List.copyOf(articles);
if(origin==0)origin=1;
if(copies==0)copies=1;
courierFilters=courierFilters==null?List.of():List.copyOf(courierFilters);
services=services==null?List.of():List.copyOf(services);
}
}
public record CourierShare(@Positive long vehicleId,
@NotNull @DecimalMin("0.01") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal amount) {}
public record ArticleItem(@Min(0) long articleId, @Min(1) @Max(100) int station,
@Min(1) @Max(32767) int quantity, @Size(max=50) String serialNumber,
@Size(max=255) String remark, @Valid StockBooking booking, @Valid FreeArticle freeArticle) {
public ArticleItem(long articleId,int station,int quantity,String serialNumber,String remark,StockBooking booking) {this(articleId,station,quantity,serialNumber,remark,booking,null);}
}
public record FreeArticle(@NotBlank @Size(max=30) String name,
@NotNull @DecimalMin("0") @DecimalMax("999999.999") @Digits(integer=6,fraction=3) BigDecimal totalWeight,
@Min(0) @Max(127) int packingPieces,
@NotNull @DecimalMin("0") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal price) {}
public record StockBooking(@Min(0) long fromStockId, @Min(0) long toStockId) {}
public record ArticleOption(long id,String number,String name,boolean serialRequired,BigDecimal weight,int packingPieces) {}
public record StockOption(long id,String label,long available) {}
public record ArticleLine(long articleId,int station,String name,int quantity,String serialNumber,
BigDecimal weight,int packingPieces,String source,String destination,boolean booked) {}
public record CostAllocation(long costCenterId,String name,BigDecimal net,BigDecimal toll,BigDecimal tax,BigDecimal gross) {}
public record CourierAllocation(long vehicleId,long courierId,String sid,BigDecimal base,BigDecimal subtotal,
BigDecimal net,BigDecimal service,BigDecimal discount,BigDecimal taxRate,String taxSign,long payerId) {}
public enum SustainabilityMode { AUTO, MANUAL }
public record SustainabilityInput(@NotNull SustainabilityMode mode,
@DecimalMin("0.01") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal distanceKm,
@DecimalMin("0.01") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal co2eKg) {}
public record SustainabilityResult(BigDecimal distanceKm,BigDecimal co2eKg,String source,String calculation,String inputFingerprint) {}
public record MapPoint(@DecimalMin("-90") @DecimalMax("90") @NotNull BigDecimal latitude,
@DecimalMin("-180") @DecimalMax("180") @NotNull BigDecimal longitude) {}
public record MapAddress(String street,String houseNumber,String postalCode,String city,String country,String warning) {}
public record Option(long id, String label) {}
public record Tax(long id, String label, BigDecimal rate) {}
public record CostCenter(long id, long customerId, String customerNumber, String customerName,
String name, Long defaultPayerId, BigDecimal fixedDiscount, Long taxId) {}
public record CustomerDefaults(String invoiceEmail, String vatId, boolean referenceRequired, boolean calculationRequired) {}
public record CourierFilter(String code, String label, boolean mandatory) {}
public record ServiceOption(long serviceId,long typeId,String service,String type) {}
public record SavedOrder(long id,String name,String customer,LocalDateTime pickupAt) {}
public record Address(long id, long costCenterId, String name, String street, String houseNumber,
String postalCode, String city, String country, String contact, String phone, String note) {}
public record Vehicle(long id, long courierId, String sid, String name, int vehicleTypeId, String plate) {}
public record Options(List<Option> vehicleTypes, List<Tax> taxes, List<Option> rhythms,
boolean editCourierPrice, boolean overrideCourierLimit, List<String> euCountries) {}
public record Component(int sequence, String name, String type, BigDecimal price,
BigDecimal discount, BigDecimal net, BigDecimal subtotal,
BigDecimal courierNet, BigDecimal courierSubtotal) {}
public record Quote(BigDecimal net, BigDecimal tax, BigDecimal gross, BigDecimal subtotal,
BigDecimal courierNet, BigDecimal courierSubtotal, BigDecimal markup,
BigDecimal courierMarkup, BigDecimal discount, BigDecimal taxRate,
String taxSign, BigDecimal courierBase, BigDecimal courierService,
BigDecimal courierDeduction, int invoiceMode, boolean batch,
List<Component> components, String fingerprint,
BigDecimal courierAgio, BigDecimal courierLimit, boolean courierLimitExceeded,
RouteInfo route, List<PhotoChoice> photos, List<CalculationLine> calculation, BigDecimal calculatedBase, BigDecimal calculatedCourierBase, BigDecimal toll, BigDecimal insurancePremium, int copies, List<ScheduleItem> schedule, List<CostAllocation> costAllocations, List<CourierAllocation> courierAllocations, List<ArticleLine> articleLines, SustainabilityResult sustainability, PhotoSet photoSet) {}
public record ScheduleItem(LocalDateTime pickupAt,int count,BigDecimal net,BigDecimal gross,BigDecimal courierNet,String priceFingerprint) {}
public record PhotoWorkflow(int number, String name, boolean defaultWorkflow, int min, int max, String stationScope, List<String> positions) {}
public record PhotoSet(int number, String name, boolean defaultSet, List<PhotoWorkflow> workflows) {}
public record PhotoOptions(boolean configured, List<PhotoSet> sets) {}
public record PhotoPlan(PhotoSet set, List<PhotoChoice> choices) {}
public record PhotoChoice(int station, int number, String name) {}
public record HeadquartersRoute(long id, String name, BigDecimal roundTripKm) {}
public record RouteInfo(BigDecimal distanceKm, BigDecimal calculationKm, long durationSeconds,
long dispatchHeadquartersId, String dispatchHeadquarters, long pricingHeadquartersId,
boolean longhaulEligible, boolean longhaul, List<HeadquartersRoute> comparedHeadquarters) {}
public record CalculationLine(String name, BigDecimal quantity, BigDecimal rate, BigDecimal amount) {}
public record Created(long id, String number, int legacyStatus, boolean replayed, LocalDateTime pickupAt, boolean photoSyncPending, List<Long> orderIds) {
public Created(long id,String number,int status,boolean replayed,LocalDateTime at,boolean pending) {this(id,number,status,replayed,at,pending,List.of(id));}
public Created {orderIds=orderIds==null?List.of(id):List.copyOf(orderIds);}
}
}
@@ -0,0 +1,33 @@
package de.stadtbote.votian;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.access.AccessDeniedException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
/** Per-request entry scope; never changes the session principal or other browser tabs. */
final class OrderEntryScopeFilter extends OncePerRequestFilter {
private final OrderEntryHeadquarters headquarters;private final ObjectMapper json;
OrderEntryScopeFilter(OrderEntryHeadquarters headquarters,ObjectMapper json){this.headquarters=headquarters;this.json=json;}
@Override protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain) throws ServletException,IOException {
String selected=request.getHeader("X-Order-Headquarters"),path=request.getServletPath();
boolean entry=path.startsWith("/api/orders/entry/") || path.equals("/api/orders/quote") || path.equals("/api/orders") && request.getMethod().equals("POST");
if(selected==null || !entry || path.equals("/api/orders/entry/headquarters")){chain.doFilter(request,response);return;}
if(!selected.matches("[1-9][0-9]{0,9}"))throw new AccessDeniedException("Ungültige Erfassungsniederlassung.");
var original=SecurityContextHolder.getContext();
var auth=original.getAuthentication();
try {
var user=headquarters.select(ApiController.user(auth),Long.parseLong(selected));
var scoped=SecurityContextHolder.createEmptyContext();
scoped.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(user,null,auth.getAuthorities()));
SecurityContextHolder.setContext(scoped);chain.doFilter(request,response);
}catch(org.springframework.dao.DataAccessException e){
if(response.isCommitted())throw e;
response.setStatus(503);response.setContentType("application/json;charset=UTF-8");json.writeValue(response.getOutputStream(),ApiErrors.body(ApiErrors.DATABASE_UNAVAILABLE));
}finally{SecurityContextHolder.setContext(original);}
}
}
@@ -0,0 +1,56 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import java.time.*;
import java.util.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
import static de.stadtbote.votian.LegacyMapping.id;
/** getNextOrderDate / isPublicHoliday: holidays are read from the maintained HQ calendar. */
final class OrderEntrySeries {
static List<LocalDateTime> dates(DatabaseService db,LegacyUser user,Request request) {
if(!request.allOccurrences())return Collections.singletonList(request.pickupAt());
if(request.mode()!=Mode.RECURRING || request.recurrence()==null || request.recurrence().endDate()==null)
throw invalid("Für die sofortige Serienanlage sind Dauerauftrag und Serienende erforderlich.");
var start=request.pickupAt();var end=request.recurrence().endDate();
if(end.isAfter(start.toLocalDate().plusYears(2)))throw invalid("Die sofortige Serienanlage ist auf zwei Jahre begrenzt.");
var holidays=new HashSet<LocalDate>();
for(var row:db.queryForList("SELECT ph_year,ph_month,ph_day FROM publicholiday WHERE hq_id=? AND ph_year BETWEEN ? AND ?",user.headquartersId(),start.getYear(),end.getYear()))
holidays.add(LocalDate.of((int)id(row,"ph_year"),(int)id(row,"ph_month"),(int)id(row,"ph_day")));
var dates=new ArrayList<LocalDateTime>();dates.add(start);
var date=start.toLocalDate();
while(true) {
date=next(date,request.recurrence(),holidays,end);
if(date.isAfter(end))break;
var at=date.atTime(start.toLocalTime());
if(ZoneId.of("Europe/Berlin").getRules().getValidOffsets(at).size()!=1)throw invalid("Ein Serientermin fällt in eine ungültige oder mehrdeutige Uhrzeit bei der Zeitumstellung: "+at);
dates.add(at);
if(dates.size()*request.copies()>500 || dates.size()*request.copies()*request.stops().size()>10000)
throw invalid("Die sofortige Serie ist zu groß (maximal 500 Aufträge bzw. 10.000 Stationen). Bitte das Serienende eingrenzen.");
}
return dates;
}
static LocalDate next(LocalDate previous,Recurrence recurrence,Set<LocalDate> holidays,LocalDate end) {
int rhythm=recurrence.rhythm();LocalDate next;
if(rhythm<=3 || rhythm==9) {
for(next=previous.plusDays(1);!next.isAfter(end);next=next.plusDays(1)) {
boolean holiday=holidays.contains(next),weekday=recurrence.weekdays().charAt(next.getDayOfWeek().getValue()-1)=='1';
if((rhythm==9 && holiday) || (!holiday && weekday))return next;
}
return next;
}
boolean backwards=rhythm==7 || rhythm==8;
next=switch(rhythm) {
case 4->previous.plusWeeks(1);case 5->previous.plusWeeks(2);case 11->previous.plusWeeks(4);
case 6->previous.plusMonths(1);
case 7->previous.plusMonths(1).withDayOfMonth(previous.plusMonths(1).lengthOfMonth());
case 8->previous.getDayOfMonth()<20?previous.withDayOfMonth(previous.lengthOfMonth()):previous.plusMonths(1).withDayOfMonth(15);
case 10->previous.getDayOfMonth()<16?previous.withDayOfMonth(16):previous.plusMonths(1).withDayOfMonth(1);
default->throw invalid("Ungültiger Serienrhythmus.");
};
while(holidays.contains(next))next=next.plusDays(backwards?-1:1);
if(!next.isAfter(previous))throw invalid("Der Feiertagskalender lässt keinen folgenden Serientermin zu.");
return next;
}
}
@@ -0,0 +1,481 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import jakarta.validation.Validator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.LegacyOrderPricing.*;
import static de.stadtbote.votian.OrderEntryCatalog.*;
import static de.stadtbote.votian.OrderEntryModels.*;
@Service
public class OrderEntryService {
private final DatabaseService db;
private final OrderEntryCatalog catalog;
private final LegacyOrderPricing pricing;
private final Validator validator;
private final OrderEntryAudit audit;
private final OrderRoutingService routing;
private final OrderPhotoWorkflows photos;
private final String logSchema;
private final Clock clock;
private final OrderEntryExtras extras;
private final OrderEntryAccounting accounting;
private final OrderEntryInventory inventory;
private OrderSustainability sustainability;
@org.springframework.beans.factory.annotation.Autowired
public OrderEntryService(DatabaseService db,OrderEntryCatalog catalog,LegacyOrderPricing pricing,Validator validator,
OrderEntryAudit audit,OrderRoutingService routing,OrderPhotoWorkflows photos,OrderSustainability sustainability,@Value("${app.history-schema:phoenix_log}") String schema) {
this(db,catalog,pricing,validator,audit,routing,photos,schema,Clock.system(ZoneId.of("Europe/Berlin")));
this.sustainability=sustainability;
}
OrderEntryService(DatabaseService db,OrderEntryCatalog catalog,LegacyOrderPricing pricing,Validator validator,
OrderEntryAudit audit,OrderRoutingService routing,OrderPhotoWorkflows photos,String schema,Clock clock) {
this.db=db;this.catalog=catalog;this.pricing=pricing;this.validator=validator;this.audit=audit;this.clock=clock;this.routing=routing;this.photos=photos;
if(!schema.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Protokollschema.");
this.logSchema=schema;this.extras=new OrderEntryExtras(db,catalog);this.accounting=new OrderEntryAccounting(db,catalog);this.inventory=new OrderEntryInventory(db,catalog);
}
public Quote quote(LegacyUser user,Request request) {
validate(user,request);
var prepared=db.readTransaction(() -> prepare(user,request));
validateAddresses(request);
var selections=photos.resolve(request,prepared.photoCustomer());
var route=routing.resolve(user,request,prepared.dispatchHq());
var emission=resolveSustainability(user,request,prepared,route);
return db.readTransaction(() -> batch(user,request,prepared,route,selections,false,emission).summary());
}
public Created create(LegacyUser user,Request request) {
validate(user,request);
String requestHash=requestHash(request);
// A committed retry remains available even when routing or photography is down.
Created created=db.readTransaction(() -> replay(user,request,requestHash));
if(created==null) {
var preview=db.readTransaction(() -> prepare(user,request));
validateAddresses(request);
var selections=photos.resolve(request,preview.photoCustomer());
var route=routing.resolve(user,request,preview.dispatchHq());
var emission=resolveSustainability(user,request,preview,route);
created=db.writeTransaction(() -> {
requireTransactionalTables();
if(db.lockRows("SELECT usr_id FROM user WHERE usr_id=? AND hq_id=?",user.userId(),user.originalHeadquartersId()).isEmpty())
throw invalid("Das Benutzerkonto ist nicht mehr verfügbar. Bitte erneut anmelden.");
Created previous=replay(user,request,requestHash);if(previous!=null)return previous;
var prepared=prepare(user,request);
if(id(prepared.payer(),"cs_id")!=id(preview.payer(),"cs_id") || prepared.photoCustomer()!=preview.photoCustomer())throw conflict("Der Zahler hat sich geändert. Bitte die Vorschau neu laden.");
inventory.lock(user,request);
Batch batch=batch(user,request,prepared,route,selections,true,emission);
if(request.quoteFingerprint()==null || !batch.summary().fingerprint().equals(request.quoteFingerprint()))
throw conflict("Die Preisvorschau fehlt oder hat sich geändert. Bitte den Preis erneut berechnen und prüfen.");
for(var item:batch.items())checkCourierLimit(user,request,item.quote());
Created first=null;var ids=new ArrayList<Long>();long[] previousIds=new long[request.copies()];
for(int n=0;n<batch.items().size();n++) {
var item=batch.items().get(n);
for(int i=0;i<request.copies();i++) {
int index=n*request.copies()+i;
Request command=index==0?request:request.allOccurrences()?extras.seriesCommand(request,index,item.prepared().at(),(n==0 || catalog.enabled(user.headquartersId(),"MASK_STANDING_KEEP_COMMISSION"))):extras.copyCommand(request,index);
Created createdItem=persist(user,command,item.prepared(),item.quote(),index==0?requestHash:requestHash(command));
if(first==null)first=createdItem;ids.add(createdItem.id());
if(request.allOccurrences() && n>0) {
db.update("UPDATE job SET jb_id_copy=?,jb_id_copy_permanent=? WHERE jb_id=? AND hq_id=?",createdItem.id(),createdItem.id(),previousIds[i],user.headquartersId());
db.update("UPDATE job SET jb_origin=5 WHERE jb_id=? AND hq_id=?",createdItem.id(),user.headquartersId());
}
previousIds[i]=createdItem.id();
}
}
if(ids.size()>1)extras.saveGroup(first.id(),ids);
return new Created(first.id(),first.number(),first.legacyStatus(),false,first.pickupAt(),first.photoSyncPending(),ids);
});
}
boolean pending=false;
for(long orderId:created.orderIds()) {
audit.tryPublish(orderId);photos.tryPublish(orderId);
for(long child:accounting.childIds(orderId))audit.tryPublish(child);
long insuranceId=extras.linkedInsurance(orderId);if(insuranceId>0)audit.tryPublish(insuranceId);
pending|=photos.pending(orderId);
}
// Include the actual status after photo publication, without changing idempotency semantics.
var rows=db.queryForList("SELECT jb_status FROM job WHERE jb_id=? AND hq_id=?",created.id(),user.headquartersId());
int status=rows.isEmpty()?created.legacyStatus():(int)id(rows.getFirst(),"jb_status");
return new Created(created.id(),created.number(),status,created.replayed(),created.pickupAt(),pending,created.orderIds());
}
static String requestHash(Request request){
String text=request.toString();
// Preserve receipts from the previous request record when the added optional field is absent.
if(request.photoSet()==null)text=text.substring(0,text.length()-", photoSet=null]".length())+"]";
return hash(text);
}
private Created replay(LegacyUser user,Request request,String requestHash) {
var previous=db.queryForList("SELECT sp_context FROM `"+logSchema+"`.semaphor WHERE sp_obj_type='usr' AND sp_obj_id=? AND sp_fieldname='ng_order_request' AND sp_content=?",user.userId(),request.requestId());
if(previous.isEmpty())return null;
String[] saved=text(previous.getFirst(),"sp_context").split("\\|");
if(saved.length!=3 || !saved[0].equals(Long.toString(user.headquartersId())) || !saved[1].equals(requestHash))
throw conflict("Diese Anfragekennung wurde bereits für andere Auftragsdaten verwendet.");
long id=Long.parseLong(saved[2]);
var order=one(db.queryForList("SELECT jb_status,jb_ordertime FROM job WHERE jb_id=? AND hq_id=?",id,user.headquartersId()),"Der bereits angelegte Auftrag ist nicht mehr verfügbar.");
return new Created(id,Long.toString(id),(int)id(order,"jb_status"),true,dateTime(order,"jb_ordertime"),false,extras.group(id));
}
record Prepared(Map<String,Object> payer,Map<String,Object> headquarters,Map<String,Object> vehicle,
LocalDateTime at,LocalDateTime now,long cashPayer,long dispatchHq,boolean favoured,long photoCustomer) {}
private Prepared prepare(LegacyUser user,Request request) {
var payer=catalog.costCenter(user,request.payerCostCenterId(),request.payment()==Payment.INVOICE);
long photoCustomer=id(payer,"cs_id");
var hq=catalog.headquarters(user);
for(Station stop:request.stops()) if(stop.costCenterId()!=null)catalog.costCenter(user,stop.costCenterId(),false);
var vehicle=request.courierVehicleId()==null?Map.<String,Object>of():catalog.vehicle(user,request.courierVehicleId());
LocalDateTime now=LocalDateTime.now(clock).withNano(0),at=request.mode()==Mode.IMMEDIATE?now:request.pickupAt();
if(request.mode()==Mode.RETROSPECTIVE && at.isAfter(now))throw invalid("Eine Nacherfassung muss in der Vergangenheit liegen.");
if((request.mode()==Mode.RESERVATION || request.mode()==Mode.RECURRING) && at.isBefore(now))
throw invalid("Der Vorbestellungs- oder Serienbeginn muss in der Zukunft liegen.");
long cashPayer=0;
if(request.payment()==Payment.CASH) {
String configured=catalog.parameter(user.headquartersId(),"CSC_ID_PAYER_CASH");
if(!configured.matches("[0-9]+")) throw invalid("Für die Niederlassung ist kein Systembarzahler konfiguriert.");
cashPayer=Long.parseLong(configured);
catalog.costCenter(user,cashPayer,false);
if(!catalog.enabled(user.headquartersId(),"MASK_CASH_PAYER_SELECT"))
payer=catalog.costCenter(user,cashPayer,false);
}
var dispatch=db.queryForList("SELECT hq_id FROM serviceplz WHERE srvp_plz=?",request.stops().getFirst().postalCode());
long dispatchHq=dispatch.isEmpty()?user.headquartersId():id(dispatch.getFirst(),"hq_id");
if(dispatchHq<=0)dispatchHq=user.headquartersId();
if(!request.stops().getFirst().country().equals("DE"))dispatchHq=user.headquartersId();
boolean favoured=!db.queryForList("SELECT cs_id FROM customercourier WHERE cs_id=? AND cscr_relation=1 LIMIT 1",id(payer,"cs_id")).isEmpty();
extras.validate(user,request,payer);
if(request.acceptUntil()!=null && request.acceptUntil().isBefore(at))throw invalid("Die Annahmefrist liegt vor dem Auftragsbeginn.");
return new Prepared(payer,hq,vehicle,at,now,cashPayer,dispatchHq,favoured,photoCustomer);
}
void validate(LegacyUser user,Request request) {
user.require(7);
if(request==null)throw invalid("Auftragsdaten fehlen.");
var violations=validator.validate(request);
if(!violations.isEmpty())throw invalid("Bitte die Auftragsdaten prüfen: "+violations.stream()
.sorted(Comparator.comparing(v -> v.getPropertyPath().toString()))
.map(v -> v.getPropertyPath()+" "+v.getMessage()).limit(5).reduce((a,b)->a+"; "+b).orElse("Ungültige Eingabe"));
if(request.mode()!=Mode.IMMEDIATE && request.pickupAt()==null)throw invalid("Bitte Datum und Uhrzeit angeben.");
if(request.mode()==Mode.IMMEDIATE && request.pickupAt()!=null)throw invalid("Ein Sofortauftrag verwendet die aktuelle Uhrzeit.");
if(textOrEmpty(request.dispatchNote()).getBytes(java.nio.charset.StandardCharsets.UTF_8).length>255)
throw invalid("Die Dispo-Info ist für das Bestandsfeld zu lang. Bitte den Text kürzen.");
if(request.pickupAt()!=null && request.pickupAt().getYear()<2000)throw invalid("Das Auftragsdatum muss ab dem Jahr 2000 liegen.");
if(request.pickupAt()!=null && ZoneId.of("Europe/Berlin").getRules().getValidOffsets(request.pickupAt()).size()!=1)
throw invalid("Die gewählte Uhrzeit ist durch die Zeitumstellung ungültig oder mehrdeutig. Bitte eine eindeutige Uhrzeit wählen.");
if(request.mode()==Mode.RECURRING) {
if(request.recurrence()==null)throw invalid("Bitte einen Wiederholungsrhythmus wählen.");
Recurrence recurrence=request.recurrence();
if(recurrence.rhythm()<=3 && !recurrence.weekdays().contains("1"))throw invalid("Bitte mindestens einen Wochentag wählen.");
if(recurrence.endDate()!=null && recurrence.endDate().isBefore(request.pickupAt().toLocalDate()))throw invalid("Das Serienende liegt vor dem Beginn.");
} else if(request.recurrence()!=null)throw invalid("Wiederholungen sind nur bei Daueraufträgen möglich.");
if(request.mode()==Mode.RETROSPECTIVE && request.courierVehicleId()==null)throw invalid("Eine Nacherfassung benötigt das ausführende Fahrzeug.");
if(request.mode()==Mode.RETROSPECTIVE && request.manualDispatch())throw invalid("Eine Nacherfassung ist bereits erledigt und kann nicht zur Disposition stehen.");
if(request.manualDispatch() && request.courierVehicleId()!=null)throw invalid("Bitte entweder manuelle Disposition oder ein Fahrzeug wählen.");
if(request.payment()==Payment.CASH && (request.cashStop()==null || request.cashStop()>request.stops().size()))
throw invalid("Bitte die Station auswählen, an der kassiert wird.");
if(request.payment()==Payment.INVOICE && request.cashStop()!=null)throw invalid("Eine Kassierstation ist nur bei Barzahlung möglich.");
if(!Set.of(1,2,6).contains(request.origin()))throw invalid("Bitte Telefon, Mail oder Sonstiges als Auftragseingang wählen.");
if(request.origin()==6 && textOrEmpty(request.originOther()).isBlank())throw invalid("Bitte die sonstige Auftragsherkunft angeben.");
if(request.offer() && (!request.manualDispatch() || request.mode()==Mode.RECURRING || request.mode()==Mode.RETROSPECTIVE || request.insurance()!=null || request.allCouriers()))
throw invalid("Ein Angebot benötigt manuelle Disposition und ist nicht mit Dauerauftrag, Nacherfassung, Versicherung oder allen Kurieren kombinierbar.");
if(request.allCouriers() && (request.courierVehicleId()!=null || request.manualDispatch() || request.mode()==Mode.RETROSPECTIVE))throw invalid("Alle Kuriere ist nicht mit Fahrzeugzuweisung, manueller Disposition oder Nacherfassung kombinierbar.");
if(request.allOccurrences() && (request.mode()!=Mode.RECURRING || request.recurrence().endDate()==null))throw invalid("Bitte ein Serienende für die sofortige Serienanlage angeben.");
if(!user.hasRight(38) && (positive(request.pricing().courierPrice()) || positive(request.pricing().courierServicePrice()) || request.pricing().courierMarkup()!=null))
user.require(38);
}
private Created persist(LegacyUser user,Request request,Prepared prepared,Quote quote,String requestHash) {
long hq=user.headquartersId();
boolean retrospective=request.mode()==Mode.RETROSPECTIVE,reservation=request.mode()==Mode.RESERVATION || request.mode()==Mode.RECURRING;
boolean longhaul=quote.route()!=null && quote.route().longhaul();
List<PhotoChoice> photoChoices=quote.photos()==null?List.of():quote.photos();
boolean manual=longhaul || request.offer() || request.manualDispatch() || (prepared.favoured() && catalog.enabled(hq,"MASK_FAVOURED_COURIER_MANUAL") && prepared.vehicle().isEmpty());
int status=initialStatus(request.mode(),(int)id(prepared.headquarters(),"hq_workmode"),!prepared.vehicle().isEmpty(),
catalog.enabled(hq,"MASK_COURIER_IMMEDIATE"),manual);
if(request.allCouriers() && longhaul)throw invalid("Fernaufträge werden manuell disponiert und können nicht an alle Kuriere verteilt werden.");
boolean global=request.allCouriers() || status==99;
if(global)status=0; // Exclusion list is written atomically before the dispatcher can see this order.
int desiredStatus=status;
if((quote.photoSet()!=null || !photoChoices.isEmpty()) && !retrospective)status=9;
long payer=request.payment()==Payment.CASH?prepared.cashPayer():request.payerCostCenterId();
var job=fields("csc_id",0,"hq_id",hq,"hq_id_dispo",quote.route()==null?prepared.dispatchHq():quote.route().dispatchHeadquartersId(),"hq_id_sales",id(prepared.payer(),"hq_id"),
"csc_id_payer",payer,"csc_id_payer_cash",request.payment()==Payment.CASH?request.payerCostCenterId():0,
"jb_payment",request.payment()==Payment.CASH?2:request.costSplit()?1:0,"vht_id",request.vehicleTypeId(),"jb_status",status,
"jb_createtime",prepared.now(),"jb_ordertime",prepared.at(),"jb_reserv",reservation?1:0,"jb_warningtime",request.warningAt(),
"emp_id",user.userId(),"jb_version",0,"jb_storno",0,"jb_globaljob",request.allCouriers()?1:0,"jb_offer",request.offer()?1:0,"jb_waitstorno",request.cancelIfUnavailable()?1:0,"jb_waittime",request.acceptUntil(),"jb_longhaul",longhaul?1:0,
"jb_service","0","jb_scrm","","jb_origin",request.origin(),"jb_orderer",textOrEmpty(request.orderer()),
"jb_commission_no",textOrEmpty(request.reference()),"jb_freetext_1",textOrEmpty(request.notes()),"jb_dispoinfo",textOrEmpty(request.dispatchNote()),
"jb_weight",nz(request.weight()),"jb_crvh_length",nz(request.length()),"jb_crvh_width",nz(request.width()),
"jb_crvh_height",nz(request.height()),"jb_crvh_position",nz(request.positions()),
"jb_fixprice",!quote.batch()?(quote.calculatedBase()!=null?quote.calculatedBase():request.pricing().mode()==PriceMode.FIXED?nz(request.pricing().fixedPrice()):BigDecimal.ZERO):BigDecimal.ZERO,
"jb_serviceprice",quote.components().stream().filter(c -> c.name().equals("Servicepreis")).map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add),"jb_totalprice",quote.net(),"jb_subtotalprice",quote.subtotal(),
"jb_cr_subprice",longhaul&&!retrospective?BigDecimal.ZERO:positive(request.pricing().courierPrice())?request.pricing().courierPrice():nz(quote.calculatedCourierBase()),"jb_cr_price",quote.courierNet(),"jb_markup",quote.markup(),
"jb_sales_tax_rate",quote.taxRate(),"jb_sales_tax_rate_sign",quote.taxSign(),"jb_invmode",quote.invoiceMode(),
"jb_postage",number(prepared.payer(),"cmp_postage"),"jb_incomplete",request.incomplete()?1:0,"jb_cr_fav",prepared.favoured()?1:0);
if(request.pricing().mode()==PriceMode.SERVICES) {
job.put("jb_fixprice",quote.batch()?BigDecimal.ZERO:quote.components().stream().limit(request.services().size()).map(Component::subtotal).reduce(BigDecimal.ZERO,BigDecimal::add));
job.put("jb_freetext_2",extras.calculatorText(user,request,quote));
}
if(!textOrEmpty(request.tourName()).isBlank())job.put("jb_tourname",request.tourName().trim());
if(request.recurrence()!=null) {
job.put("jb_permanent",request.recurrence().rhythm());job.put("jb_permanent2",request.recurrence().weekdays());
job.put("jp_permenddat",request.recurrence().endDate()==null?null:request.recurrence().endDate().atTime(23,59,59));
}
if(!prepared.vehicle().isEmpty()) {
job.put("cr_sid",text(prepared.vehicle(),"crvh_sid"));job.put("cr_id_permanent",text(prepared.vehicle(),"crvh_sid"));
job.put("cr_id_order",id(prepared.vehicle(),"cr_id"));
if(retrospective)job.put("cr_id",id(prepared.vehicle(),"cr_id"));
}
if(retrospective) {job.put("jb_taketime",prepared.at());job.put("jb_finishtime",prepared.at());job.put("jb_booktime",prepared.now());}
String tourData=request.stops().stream().map(s -> s.name()+", "+s.street()+" "+s.houseNumber()+", "+s.postalCode()+" "+s.city()).reduce((a,b)->a+" | "+b).orElse("");
// This compact field is parsed by PHP as three parallel, semicolon-separated lists.
job.put("jb_tourdata",request.stops().stream().map(Station::postalCode).collect(java.util.stream.Collectors.joining(";"))+"|"
+request.stops().stream().map(Station::city).collect(java.util.stream.Collectors.joining(";"))+"|"
+request.stops().stream().map(Station::country).collect(java.util.stream.Collectors.joining(";")));
job.put("jb_incomplete",isIncomplete(request,prepared.payer(),quote)?1:0);
String filterNotes=dispatchDefaults(user,request,prepared,job);
job.put("jb_invtext",tourData);job.put("jb_invtext_cr",tourData);
if(longhaul) {
var filters=new LinkedHashSet<String>();addFilters(filters,String.valueOf(job.get("jb_cr_filter_opt")));filters.add("BWV");
job.put("jb_cr_filter_opt",String.join(",",filters));
}
extras.replaceTemplate(user,request);
long orderId=db.insert("job",job);
OrderPaymentTerms.capture(db,orderId,prepared.photoCustomer());
List<OrderPhotoWorkflows.Selection> photoSelections=new ArrayList<>();
List<Long> tourIds=new ArrayList<>();
for(int i=0;i<request.stops().size();i++) {
Station stop=request.stops().get(i);
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",
stop.street(),stop.postalCode(),stop.city(),stop.country());
long addressId=addresses.isEmpty()?db.insert("address",fields("ad_street",stop.street(),"ad_zipcode",stop.postalCode(),"ad_city",stop.city(),"ad_country",stop.country())):id(addresses.getFirst(),"ad_id");
String note=textOrEmpty(stop.note());
if(request.payment()==Payment.CASH && request.cashStop()==i+1)
note+="\n*** ZAHLT BAR ***\n*** Bitte im Auftrag STADTBOTE Bar "+money(quote.net().add(nz(request.toll())))+" EUR zzgl. "+quote.tax()+" EUR MwSt (Gesamt "+quote.gross()+" EUR) kassieren und quittieren. ***";
if(i==0) {
if(positive(request.toll()))note+="\nMaut / Auslagen: "+money(request.toll())+" EUR";
if(catalog.enabled(hq,"MASK_JB_CR_PRICE_2_TR_REMARK") && catalog.enabled(hq,"MASK_CR_PRICE_MODE"))
note+="\nFrachtverguetung: "+quote.courierNet()+" EUR";
note+=filterNotes;
if(catalog.enabled(hq,"MASK_JB_MEASURES_2_TR_REMARK")) {
if(positive(request.weight()))note+="\nMaximalgewicht: "+request.weight()+" kg";
if(positive(request.length()))note+="\nLänge: "+request.length()+" cm";
if(positive(request.width()))note+="\nBreite: "+request.width()+" cm";
if(positive(request.height()))note+="\nHöhe: "+request.height()+" cm";
if(positive(request.positions()))note+="\nPlätze: "+request.positions();
}
}
long tourId=db.insert("tour",fields("jb_id",orderId,"ad_id",addressId,"tr_sort",i+1,"csc_id",stop.costCenterId()==null?0:stop.costCenterId(),
"tr_comp",stop.name(),"tr_hsno",stop.houseNumber(),"tr_person",textOrEmpty(stop.contact()),"tr_phone",textOrEmpty(stop.phone()),
"tr_remark",note,"tr_commission_no",textOrEmpty(stop.reference()),"tr_cs_freetext","",
"tr_mediationarea_id",stationArea(hq,stop),
"tr_ware_from_to",switch(stop.type()){case PICKUP->2;case DELIVERY->1;case BOTH->3;},
"tr_status",retrospective?1:0,"tr_finishtime",retrospective?prepared.at():null));
tourIds.add(tourId);
extras.attachments(tourId,stop.attachments());
final int stationIndex=i+1;
photoChoices.stream().filter(p -> p.station()==stationIndex).findFirst().ifPresent(p -> photoSelections.add(new OrderPhotoWorkflows.Selection(orderId,tourId,p.number())));
}
if(!quote.batch() && request.pricing().mode()==PriceMode.SERVICES)db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,0,0,'Fixpreis',0,'',?,0)",orderId,payer,
quote.components().stream().limit(request.services().size()).map(Component::net).reduce(BigDecimal.ZERO,BigDecimal::add));
if(!quote.batch())for(Component component:quote.components().stream().skip(request.pricing().mode()==PriceMode.SERVICES?request.services().size():0).toList())db.update("""
INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount)
VALUES (?,?,?,0,?,0,?,?,?)
""",orderId,payer,component.sequence(),clip(component.name(),50),clip(component.type(),50),component.price(),component.discount());
// PHP recognizes a zonal fixed price by these zero-valued 'z' detail rows.
if(request.pricing().mode()==PriceMode.ZONE && !quote.batch()) {
int sequence=1;
for(CalculationLine line:quote.calculation()) {
String description=line.quantity().toPlainString()+" × "+line.name().replace("KM-Preis ","")+" à "+money(line.rate()).toPlainString()+" EUR";
if(description.length()>50)description=description.substring(0,50);
db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,?,0,?,0,'z',0,0)",orderId,payer,sequence++,description);
}
}
price(orderId,request.pricing().courierMarkup()==null?1:9,quote.courierMarkup(),"");
price(orderId,2,quote.courierBase(),"");price(orderId,3,quote.courierService(),"");price(orderId,5,quote.courierDeduction(),"");
price(orderId,10,quote.courierSubtotal(),"");
if(request.pricing().markup()!=null)price(orderId,8,quote.markup(),"");
if(positive(quote.courierAgio()))price(orderId,7,quote.courierAgio(),"");
if(quote.sustainability()!=null) {
price(orderId,11,quote.sustainability().distanceKm(),quote.sustainability().source().equals("Manuell")?"m":"c");
price(orderId,12,quote.sustainability().co2eKg(),quote.sustainability().calculation());
// co2e_sendReq.php also snapshots the historical partner commission after calculation.
var commission=db.queryForList("SELECT srvprch_price FROM servicepricehistory WHERE srvprch_obj_type='crvh' AND srvprch_obj_id=? AND srvprch_fieldname='crvh_partner_commission' AND srvprch_validfrom<=? ORDER BY srvprch_validfrom DESC LIMIT 1",request.courierVehicleId(),prepared.at());
price(orderId,14,commission.isEmpty()?BigDecimal.ZERO:number(commission.getFirst(),"srvprch_price"),"crvh_partner_commission");
} else if(quote.route()!=null)price(orderId,11,quote.route().calculationKm(),"");
else if(request.distanceKm()!=null)price(orderId,11,request.distanceKm(),"m");
if(quote.route()!=null) {
if(quote.route().longhaulEligible() && !quote.route().longhaul())
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","longhaul_deselected","gdc_content","true","gdc_context",prepared.now().toString()));
saveRouteEvidence(orderId,quote);
}
accounting.split(orderId,quote);
inventory.persist(user,request,orderId,tourIds,prepared.now());
for(long child:accounting.children(user,request,prepared,orderId,job,quote))
audit.enqueue(child,user,prepared.now(),Mode.RETROSPECTIVE,request.requestId()+":child:"+child);
long insuranceId=extras.persist(user,request,prepared,orderId);
if(insuranceId>0)audit.enqueue(insuranceId,user,prepared.now(),Mode.RETROSPECTIVE,request.requestId()+":insurance");
if(global)extras.globalExclusions(user,request,orderId);
photos.enqueue(orderId,photoSelections,desiredStatus,id(prepared.vehicle(),"cr_id"),hq,quote.photoSet()==null?null:quote.photoSet().number());
if(status==0 && !prepared.vehicle().isEmpty())db.update("UPDATE courier SET cr_occupied=1 WHERE cr_id=? AND hq_id=?",id(prepared.vehicle(),"cr_id"),hq);
for(String key:List.of("automailer","automailer2","automailer3","automailer5","automailer6","automailer7","jb_job_jam"))
db.insert("`"+logSchema+"`.semaphor",fields("sp_obj_type","jb","sp_obj_id",orderId,"sp_fieldname",key,"sp_content","","sp_context","INIT","sp_createtime",prepared.now()));
db.insert("`"+logSchema+"`.semaphor",fields("sp_obj_type","usr","sp_obj_id",user.userId(),"sp_fieldname","ng_order_request",
"sp_content",request.requestId(),"sp_context",hq+"|"+requestHash+"|"+orderId,"sp_createtime",prepared.now()));
audit.enqueue(orderId,user,prepared.now(),request.mode(),request.requestId());
if(quote.courierLimitExceeded())audit.enqueueLimit(orderId,user,prepared.now(),request.requestId(),quote.courierLimit(),quote.courierNet());
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","jb_invtext_dirty","gdc_content","1","gdc_context",prepared.now().toString()));
return new Created(orderId,Long.toString(orderId),status,false,prepared.at(),quote.photoSet()!=null || !photoSelections.isEmpty());
}
record BatchItem(Prepared prepared,Quote quote) {}
record Batch(Quote summary,List<BatchItem> items) {}
private Batch batch(LegacyUser user,Request request,Prepared prepared,OrderRoutingService.Result route,PhotoPlan photoPlan,boolean lockedInventory,SustainabilityResult emission) {
var articleLines=inventory.quote(user,request,lockedInventory);
var dates=OrderEntrySeries.dates(db,user,request);var items=new ArrayList<BatchItem>();var schedule=new ArrayList<ScheduleItem>();
for(var date:dates) {
var at=date==null?prepared.at():date;
Prepared entry=new Prepared(prepared.payer(),prepared.headquarters(),prepared.vehicle(),at,prepared.now(),prepared.cashPayer(),prepared.dispatchHq(),prepared.favoured(),prepared.photoCustomer());
Quote price=accounting.quote(user,request,(photoPlan==null || photoPlan.set()==null ? pricing.calculate(user,request,prepared.payer(),at,route,photoPlan==null?List.of():photoPlan.choices()) : pricing.calculate(user,request,prepared.payer(),at,route,photoPlan.choices(),photoPlan.set())),articleLines);
if(emission!=null)price=sustainability.apply(user,request,entry,price,emission);
items.add(new BatchItem(entry,price));
schedule.add(new ScheduleItem(at,request.copies(),price.net(),price.gross(),price.courierNet(),price.fingerprint()));
}
Quote summary=items.getFirst().quote();
if(request.allOccurrences())summary=extras.withSchedule(summary,schedule,items.stream().anyMatch(i -> i.quote().courierLimitExceeded()));
return new Batch(summary,items);
}
private SustainabilityResult resolveSustainability(LegacyUser user,Request request,Prepared prepared,OrderRoutingService.Result route) {
if(request.sustainability()==null)return null;
if(sustainability==null)throw new IllegalStateException("Sustainability service missing");
return sustainability.resolve(user,request,prepared,route);
}
private void validateAddresses(Request request) {
if(request.acceptUnverifiedAddresses())return;
for(Station stop:request.stops()) {
var known=db.queryForList("SELECT ad_id FROM address WHERE ad_street=? AND ad_zipcode=? AND ad_city=? AND ad_country=? LIMIT 1",
stop.street(),stop.postalCode(),stop.city(),stop.country());
if(known.isEmpty())routing.geocode(stop);
}
}
private void saveRouteEvidence(long orderId,Quote quote) {
try {
String content=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of("route",quote.route(),"calculation",quote.calculation()));
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","ng_route_calculation","gdc_content",content,"gdc_context","CREATE"));
} catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw new IllegalStateException("Routenbeleg konnte nicht gesichert werden.",e);}
}
private boolean isIncomplete(Request request,Map<String,Object> payer,Quote quote) {
if(request.offer() || request.incomplete() || quote.batch() || catalog.enabled(0,"CUSTOMER_MASK_JB_INCOMPLETE_"+id(payer,"cs_id")))return true;
var significant=quote.components().stream().filter(c -> !c.name().contains("Grundpreis")).toList();
if(significant.isEmpty() || significant.stream().anyMatch(c -> c.price().abs().compareTo(new BigDecimal("0.02"))<0)
|| significant.stream().map(Component::net).reduce(BigDecimal.ZERO,BigDecimal::add).signum()==0)return true;
if(id(payer,"cs_jb_commission_no")==1 && textOrEmpty(request.reference()).isBlank())return true;
if(id(payer,"cs_commission_no")==1 && catalog.enabled(0,"CS_MASK_COMM_NO_B_"+id(payer,"cs_id"))) {
boolean all=catalog.enabled(0,"CS_MASK_COMM_NO_C_"+id(payer,"cs_id"));
if(all?request.stops().stream().anyMatch(s -> textOrEmpty(s.reference()).isBlank()):request.stops().stream().allMatch(s -> textOrEmpty(s.reference()).isBlank()))return true;
}
if(request.mode()==Mode.RETROSPECTIVE && quote.sustainability()==null) {
if(!text(payer,"cmp_sustainability_email").isBlank())return true;
if(id(payer,"cs_id_parent")>0) {
var parent=db.queryForList("SELECT cmp.cmp_sustainability_email FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cs.cs_id=?",id(payer,"cs_id_parent"));
if(!parent.isEmpty() && !text(parent.getFirst(),"cmp_sustainability_email").isBlank())return true;
}
}
return false;
}
/** job_options.php mediation and mandatory filters; job_edit.js customer defaults. */
private String dispatchDefaults(LegacyUser user,Request request,Prepared prepared,Map<String,Object> job) {
long hq=user.headquartersId();
String zip=request.stops().getFirst().postalCode(),mode=catalog.parameter(hq,"MODE_INTERMEDIATION");
long area=0;String name="";
if(mode.equals("1")) {
var rows=db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=?",zip);
if(!rows.isEmpty())area=id(rows.getFirst(),"srvp_id");
name=zip;
} else if(mode.equals("2") || mode.equals("3")) {
var rows=db.queryForList("""
SELECT a.srvpa_id,a.srvpa_name FROM serviceplz p
JOIN serviceplzareamapping m ON m.srvp_id=p.srvp_id
JOIN serviceplzarea a ON a.srvpa_id=m.srvpa_id WHERE p.srvp_plz=? AND a.hq_id=?
""",zip,hq);
if(!rows.isEmpty()) {area=id(rows.getFirst(),"srvpa_id");name=text(rows.getFirst(),"srvpa_name");}
}
job.put("jb_mediationarea_id",area);job.put("jb_mediationarea_name",name);
var defaults=new LinkedHashSet<String>(request.courierFilters());addFilters(defaults,text(prepared.payer(),"cs_filter"));
for(Station stop:request.stops())if(stop.costCenterId()!=null) {
var customer=catalog.costCenter(user,stop.costCenterId(),false);
addFilters(defaults,text(customer,"cs_filter"));
if(catalog.enabled(0,"CUSTOMER_MASK_JB_INCOMPLETE_"+id(customer,"cs_id")))job.put("jb_incomplete",1);
}
var required=new LinkedHashSet<String>();var optional=new LinkedHashSet<String>();
var filterRows=db.queryForList("SELECT crf_short,crf_status,crf_longtext FROM courierfilter ORDER BY crf_short");
for(var filter:filterRows)if(defaults.contains(text(filter,"crf_short")))
(id(filter,"crf_status")==1?required:optional).add(text(filter,"crf_short"));
addFilters(required,catalog.parameter(hq,"MASK_MANDATORY_FILTERS"));
if(request.stops().stream().anyMatch(s -> !s.country().equals("DE"))) {
required.add("EU");
String since=catalog.parameter(0,"MASK_FILTER_AL_DATE");
// PHP strtotime("") behaves like an already elapsed threshold.
if(since.isBlank() || !configuredDate(since).isAfter(prepared.at().toLocalDate()))required.add("AL");
} else {required.remove("EU");required.remove("AL");}
job.put("jb_cr_filter",String.join(",",required));job.put("jb_cr_filter_opt",String.join(",",optional));
StringBuilder notes=new StringBuilder();
for(var filter:filterRows)if(required.contains(text(filter,"crf_short")) || optional.contains(text(filter,"crf_short"))) {
String description=text(filter,"crf_longtext");if(!description.isBlank())notes.append("\n").append(description);
}
if(optional.contains("LZ"))notes.append("\n*** LIEFERSCHEIN ZUR ZENTRALE STADTBOTE ***");
return notes.toString();
}
private long stationArea(long hq,Station stop) { return LegacyAddressTariffs.postal(db,hq,stop); }
private static void addFilters(Set<String> filters,String value) {
for(String filter:value.split(","))if(!filter.isBlank())filters.add(filter.trim());
}
private static LocalDate configuredDate(String value) {
try {return LocalDate.parse(value.substring(0,10));}
catch(RuntimeException ignored) {
try {return LocalDate.parse(value,java.time.format.DateTimeFormatter.ofPattern("dd.MM.uuuu"));}
catch(RuntimeException e) {throw invalid("Das Datum für MASK_FILTER_AL_DATE im Bestand ist ungültig.");}
}
}
static int initialStatus(Mode mode,int workMode,boolean vehicle,boolean immediateCourier,boolean manual) {
if(mode==Mode.RETROSPECTIVE)return 2;
if(manual)return 9;
if(vehicle) {
if(mode==Mode.IMMEDIATE || workMode==1 || immediateCourier)return 0;
if(workMode==2)return 8;
}
return workMode==1?9:workMode==2?8:99;
}
private static String clip(String value,int limit) {return value.length()>limit?value.substring(0,limit):value;}
private void price(long id,int kind,BigDecimal value,String remark) {
db.update("INSERT INTO jobprice (jb_id,mt_sort,jbprc_price,jbprc_remark) VALUES (?,?,?,?)",id,kind,value,remark);
}
private void checkCourierLimit(LegacyUser user,Request request,Quote quote) {
if(!quote.courierLimitExceeded())return;
user.require(39);
if(!request.confirmCourierLimit())throw invalid("Die Frachtvergütung liegt über der hinterlegten Grenze. Bitte die Überschreitung ausdrücklich bestätigen.");
}
private void requireTransactionalTables() {
var rows=db.queryForList("""
SELECT TABLE_NAME,ENGINE FROM information_schema.TABLES WHERE
(TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('job','tour','address','tourservice','jobprice','genericdatacontainer','courier','user'))
OR (TABLE_SCHEMA=? AND TABLE_NAME='semaphor')
""",logSchema);
if(rows.size()!=9 || rows.stream().anyMatch(row -> !text(row,"ENGINE").equalsIgnoreCase("InnoDB")))
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Die Auftragserfassung benötigt transaktionsfähige Bestandstabellen. Es wurden keine Daten verändert.");
}
static LinkedHashMap<String,Object> fields(Object... pairs) {
var result=new LinkedHashMap<String,Object>();for(int i=0;i<pairs.length;i+=2)result.put((String)pairs[i],pairs[i+1]);return result;
}
static String textOrEmpty(String text) {return text==null?"":text;}
static ResponseStatusException conflict(String message) {return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,98 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.Authentication;
import java.util.*;
import static de.stadtbote.votian.ApiController.user;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.OrderEntryCatalog.*;
@RestController
@RequestMapping("/api/orders/entry")
public class OrderEntryTemplates {
private final DatabaseService db;private final OrderEntryCatalog catalog;
public OrderEntryTemplates(DatabaseService db,OrderEntryCatalog catalog) {this.db=db;this.catalog=catalog;}
@GetMapping("/saved-orders") public List<SavedOrder> search(Authentication auth,@RequestParam(defaultValue="") String q,@RequestParam(required=false) Long payerId,@RequestParam(defaultValue="false") boolean templates) {
LegacyUser user=user(auth);user.require(7);
if(payerId!=null)catalog.costCenter(user,payerId,false);
var args=new ArrayList<Object>();args.add(user.headquartersId());args.add(OrderEntryCatalog.search(q));args.add(OrderEntryCatalog.search(q));
if(payerId!=null){args.add(payerId);args.add(payerId);}
return db.queryForList("""
SELECT j.jb_id,j.jb_tourname,j.jb_ordertime,cmp.cmp_comp FROM job j
JOIN costcenter cc ON cc.csc_id=IF(j.csc_id_payer_cash>0,j.csc_id_payer_cash,j.csc_id_payer)
JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id
WHERE j.hq_id=? AND (CAST(j.jb_id AS CHAR) LIKE ? OR j.jb_tourname LIKE ?)
"""+(payerId==null?"":" AND (j.csc_id_payer=? OR j.csc_id_payer_cash=?)")+(templates?" AND LENGTH(j.jb_tourname)>0":"")+" ORDER BY j.jb_id DESC LIMIT 50",args.toArray())
.stream().map(r -> new SavedOrder(id(r,"jb_id"),text(r,"jb_tourname"),text(r,"cmp_comp"),dateTime(r,"jb_ordertime"))).toList();
}
@GetMapping("/saved-orders/{id}") public Map<String,Object> load(Authentication auth,@PathVariable long id) {
LegacyUser user=user(auth);user.require(7);
return db.readTransaction(() -> {
var job=new OrderEntryExtras(db,catalog).requireOrder(user,id);
long payer=id(job,"csc_id_payer_cash")>0?id(job,"csc_id_payer_cash"):id(job,"csc_id_payer");
var center=catalog.costCenter(user,payer,id(job,"jb_payment")!=2);
var payerDto=new CostCenter(payer,id(center,"cs_id"),text(center,"cs_eid"),text(center,"cmp_comp"),text(center,"csc_name"),nullableId(center,"csc_id_payer"),number(center,"cs_fixprice_discount"),nullableId(center,"tx_id"));
var stops=db.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",id).stream().map(r -> fields(
"costCenterId",nullableId(r,"csc_id"),"type",id(r,"tr_ware_from_to")==3?"BOTH":id(r,"tr_ware_from_to")==1?"DELIVERY":"PICKUP",
"name",text(r,"tr_comp"),"street",text(r,"ad_street"),"houseNumber",text(r,"tr_hsno"),"postalCode",text(r,"ad_zipcode"),"city",text(r,"ad_city"),
"country",text(r,"ad_country").equals("D")?"DE":text(r,"ad_country"),"contact",text(r,"tr_person"),"phone",text(r,"tr_phone"),"note",text(r,"tr_remark"),"reference",text(r,"tr_commission_no"),
"billingPostalId",nullableId(r,"tr_mediationarea_id"),"photoWorkflow",null,"attachments",List.of())).toList();
Map<String,Object> meta=new HashMap<>();
for(var r:db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_toll','jb_hiddenFreetext_1','jb_origin_other') ORDER BY gdc_id",id))meta.put(text(r,"gdc_gen_fieldname"),text(r,"gdc_content"));
var serviceItems=services(user,payer,text(job,"jb_freetext_2"));
if(id(job,"jb_version")==0) {
var seedRows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_entry_seed' ORDER BY gdc_id",id);
if(!seedRows.isEmpty())try {
String seed=seedRows.stream().map(r -> text(r,"gdc_content")).collect(java.util.stream.Collectors.joining());
var original=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().readValue(seed,Request.class);
if(original.stops().size()==stops.size())for(int i=0;i<stops.size();i++)stops.get(i).put("note",original.stops().get(i).note());
} catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw invalid("Die ursprünglichen Stationshinweise konnten nicht gelesen werden.");}
}
var articles=new ArrayList<Map<String,Object>>();
for(var row:db.queryForList("SELECT ta.at_id,ta.tr_sort,ta.trat_quantity,ta.trat_remark,ta.trat_name,ta.trat_weight,ta.trat_packingpieces,ta.trat_price FROM tourarticle ta WHERE ta.jb_id=? ORDER BY ta.tr_sort,ta.trat_sort,ta.trat_id",id)) {
if(id(row,"at_id")<=0) {
articles.add(fields("article",null,"freeArticle",new FreeArticle(text(row,"trat_name"),number(row,"trat_weight"),(int)id(row,"trat_packingpieces"),number(row,"trat_price")),
"station",id(row,"tr_sort"),"quantity",id(row,"trat_quantity"),"remark",text(row,"trat_remark")));
continue;
}
var article=new OrderEntryInventory(db,catalog).article(user,id(row,"at_id"));
articles.add(fields("article",new ArticleOption(id(article,"at_id"),text(article,"at_eid"),text(article,"at_name"),id(article,"at_serialno")!=0,number(article,"at_totalweight"),(int)id(article,"at_packingpieces")),
"station",id(row,"tr_sort"),"quantity",id(row,"trat_quantity"),"remark",text(row,"trat_remark")));
}
// Existing serial numbers and inventory movements must never be replayed by copying a tour.
// Operational state, vehicle assignment, attachments, deadlines and insurance never carry over.
return fields("articles",articles,"costSplit",id(job,"jb_payment")==1,"services",serviceItems,"payer",payerDto,"sourceOrderId",id,"tourName",text(job,"jb_tourname"),"stops",stops,
"vehicleTypeId",id(job,"vht_id"),"payment",id(job,"jb_payment")==2?"CASH":"INVOICE",
"orderer",text(job,"jb_orderer"),"reference",text(job,"jb_commission_no"),"notes",text(job,"jb_freetext_1"),"dispatchNote",text(job,"jb_dispoinfo"),
"weight",number(job,"jb_weight"),"length",number(job,"jb_crvh_length"),"width",number(job,"jb_crvh_width"),"height",number(job,"jb_crvh_height"),"positions",number(job,"jb_crvh_position"),
"fixedPrice",number(job,"jb_fixprice"),"servicePrice",number(job,"jb_serviceprice"),"toll",meta.getOrDefault("jb_toll",""),"invoiceOnlyText",meta.getOrDefault("jb_hiddenFreetext_1",""));
});
}
List<ServiceItem> services(LegacyUser user,long payer,String calc) {
var serviceItems=new ArrayList<ServiceItem>();
String[] parts=calc.split("²",-1);
if(parts.length==4 && !parts[0].isBlank()) {
var allowed=catalog.services(user,payer);String[] names=parts[0].split("¹",-1),types=parts[1].split("¹",-1),quantities=parts[2].split("¹",-1);
if(names.length!=types.length || names.length!=quantities.length)throw invalid("Die gespeicherte Kalkulation ist unvollständig. Bitte den Quellauftrag in VoTiAn prüfen.");
for(int i=0;i<names.length;i++) {
final int index=i;var option=allowed.stream().filter(o -> o.service().equals(names[index]) && o.type().equals(types[index])).findFirst().orElseThrow(() -> invalid("Eine gespeicherte Leistungsposition ist für diesen Kunden nicht mehr verfügbar."));
try {serviceItems.add(new ServiceItem(option.serviceId(),option.typeId(),new java.math.BigDecimal(quantities[i].replace(',','.'))));}
catch(NumberFormatException e){throw invalid("Eine gespeicherte Leistungsmenge ist ungültig.");}
}
}
return serviceItems;
}
@GetMapping("/courier-filters") public List<CourierFilter> filters(Authentication auth) {return catalog.filters(user(auth));}
@GetMapping("/billing-postal-codes") public List<Option> postcodes(Authentication auth,@RequestParam(defaultValue="") String q) {return catalog.billingPostalCodes(user(auth),q);}
@GetMapping("/addresses") public List<Address> addresses(Authentication auth,@RequestParam(defaultValue="") String q) {return catalog.searchAddresses(user(auth),q);}
@GetMapping("/cost-centers/{id}/defaults") public CustomerDefaults defaults(Authentication auth,@PathVariable long id) {return catalog.defaults(user(auth),id);}
@GetMapping("/cost-centers/{id}/services") public List<ServiceOption> services(Authentication auth,@PathVariable long id) {return catalog.services(user(auth),id);}
@GetMapping("/upload") public Map<String,String> upload(Authentication auth) {
user(auth).require(7);String url=catalog.parameter(0,"URL_DOC_UPLOAD");
if(!url.isBlank())try {var uri=java.net.URI.create(url);if(!Set.of("https","http").contains(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null)url="";}catch(IllegalArgumentException e){url="";}
return Map.of("url",url);
}
}
@@ -0,0 +1,66 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
/** Only server-owned integration URLs are accepted. Never expose payloads, keys or remote errors. */
@Component
public class OrderIntegrationClient {
private final ObjectMapper json;
private final HttpClient http=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NEVER).build();
public OrderIntegrationClient(ObjectMapper json) {this.json=json;}
JsonNode get(String url,String label) {return exchange(url,null,label,"",java.util.Map.of());}
JsonNode post(String url,Object payload,String label) {return exchange(url,payload,label,"",java.util.Map.of());}
JsonNode postAuthenticated(String url,Object payload,String label,String username,String password) {
if(!url.startsWith("https://"))throw unavailable(label+" benötigt einen HTTPS-Endpunkt.");
if(username.contains(":"))throw unavailable(label+" ist nicht korrekt konfiguriert.");
String auth="Basic "+java.util.Base64.getEncoder().encodeToString((username+":"+password).getBytes(java.nio.charset.StandardCharsets.UTF_8));
return exchange(url,payload,label,auth,java.util.Map.of());
}
JsonNode googlePlaces(String path,Object payload,String key,String fields) {
return exchange("https://places.googleapis.com/v1/"+path,payload,"Die Adressvorschläge","",java.util.Map.of("X-Goog-Api-Key",key,"X-Goog-FieldMask",fields));
}
private JsonNode exchange(String url,Object payload,String label,String authorization,java.util.Map<String,String> headers) {
try {
URI uri=URI.create(url);
if(!java.util.Set.of("http","https").contains(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null)
throw unavailable(label+" ist nicht korrekt konfiguriert.");
var builder=HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(12)).header("Accept","application/json");
headers.forEach(builder::header);
if(!authorization.isEmpty())builder.header("Authorization",authorization);
if(payload==null)builder.GET();
else builder.header("Content-Type","application/json").POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(payload)));
// Read in the HTTP subscriber so the request timeout also covers the body.
var response=http.send(builder.build(),limitedBody());
if(response.statusCode()<200 || response.statusCode()>=300)throw unavailable(label+" ist momentan nicht verfügbar.");
byte[] body=response.body();
return body.length==0?json.createObjectNode():json.readTree(body);
} catch(InterruptedException e) {Thread.currentThread().interrupt();throw unavailable(label+" wurde unterbrochen.");}
catch(ResponseStatusException e) {throw e;}
catch(Exception e) {throw unavailable(label+" ist momentan nicht verfügbar oder hat ungültige Daten geliefert.");}
}
private static HttpResponse.BodyHandler<byte[]> limitedBody() {
return info -> new HttpResponse.BodySubscriber<>() {
final java.util.concurrent.CompletableFuture<byte[]> result=new java.util.concurrent.CompletableFuture<>();
final java.io.ByteArrayOutputStream data=new java.io.ByteArrayOutputStream();
java.util.concurrent.Flow.Subscription subscription;
public java.util.concurrent.CompletionStage<byte[]> getBody(){return result;}
public void onSubscribe(java.util.concurrent.Flow.Subscription s){subscription=s;s.request(1);}
public void onNext(java.util.List<java.nio.ByteBuffer> buffers){
for(var buffer:buffers){if(data.size()+buffer.remaining()>2_000_000){subscription.cancel();result.completeExceptionally(new IllegalStateException("Response too large"));return;}
byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);data.writeBytes(bytes);}
subscription.request(1);
}
public void onError(Throwable error){result.completeExceptionally(error);}
public void onComplete(){result.complete(data.toByteArray());}
};
}
static ResponseStatusException unavailable(String message){return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,message);}
}
@@ -0,0 +1,106 @@
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.MasterData.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.BusinessSupport.*;
@Service
public class OrderOperations {
final DatabaseService db;final BusinessSupport support;final OrderEntryCatalog catalog;final LegacyOrderPricing pricing;
public OrderOperations(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog,LegacyOrderPricing pricing){this.db=db;this.support=support;this.catalog=catalog;this.pricing=pricing;}
static final List<Field> JOB_FIELDS=List.of(f("jb_ordertime","Auftragszeit","Auftrag","datetime-local",0,true),f("jb_warningtime","Warnzeit","Auftrag","datetime-local",0,false),f("jb_tourname","Tourenname","Auftrag","text",50,false),f("jb_orderer","Besteller","Auftrag","text",50,false),f("jb_commission_no","Kommissionsnummer","Auftrag","text",30,false),f("jb_dispoinfo","Dispo-Info","Auftrag","textarea",255,false),f("jb_freetext_3","Interne Bemerkung","Auftrag","textarea",10000,false),f("jb_freetext_1","Freitext","Auftrag","textarea",10000,false),f("jb_weight","Gewicht (kg)","Ladung","integer",100000,false),f("jb_crvh_length","Länge (cm)","Ladung","integer",10000,false),f("jb_crvh_width","Breite (cm)","Ladung","integer",10000,false),f("jb_crvh_height","Höhe (cm)","Ladung","integer",10000,false),f("jb_crvh_position","Stellplätze","Ladung","integer",1000,false),f("jb_incomplete","Nachbearbeitung erforderlich","Abrechnung","check",1,false),f("jb_invtext","Kundenrechnungstext","Abrechnung","textarea",20000,false),f("jb_invtext_cr","Kurierabrechnungstext","Abrechnung","textarea",20000,false),f("jb_cr_remark","Kurierbemerkung","Abrechnung","text",255,false));
static final List<Field> STOP_FIELDS=List.of(f("tr_comp","Firma / Name","Station","text",50,true),f("tr_comp2","Zusatz","Station","text",50,false),f("ad_street","Straße","Adresse","text",50,true),f("tr_hsno","Hausnummer","Adresse","text",10,false),f("ad_zipcode","PLZ","Adresse","text",10,true),f("ad_city","Ort","Adresse","text",50,true),f("ad_country","Land (ISO)","Adresse","country",2,true),f("tr_person","Kontakt","Station","text",50,false),f("tr_phone","Telefon","Station","text",20,false),f("tr_remark","Bemerkung","Station","textarea",10000,false),f("tr_commission_no","Kommissionsnummer","Station","text",30,false),choice("tr_ware_from_to","Stationstyp","Station","2","Abholung","1","Zustellung","3","Abholung und Zustellung"));
public record Station(long id,Map<String,Object> values){}
public record Edit(String requestId,String revision,Map<String,Object> values,List<Station> stations){}
public record Price(String requestId,String revision,BigDecimal customerNet,BigDecimal courierNet,String reason){}
public record Action(String requestId,String revision,String action,Long vehicleId,Long stationId,String recipient,String reason,boolean chargeable,boolean endSeries){}
record Snapshot(Map<String,Object> job,List<Map<String,Object>> stops,List<Map<String,Object>> prices,List<Map<String,Object>> invoices,List<Map<String,Object>> payments,List<Map<String,Object>> priceDetails){}
Snapshot snapshot(LegacyUser u,long id,boolean lock){
String sql="SELECT * FROM job WHERE jb_id=? AND hq_id=?";var job=one(lock?db.lockRows(sql,id,u.headquartersId()):db.queryForList(sql,id,u.headquartersId()));
String tours="SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id";
return new Snapshot(job,lock?db.lockRows(tours,id):db.queryForList(tours,id),(lock?db.lockRows("SELECT * FROM tourservice WHERE jb_id=? ORDER BY tr_sort,csc_id,srv_id,srvt_id",id):db.queryForList("SELECT * FROM tourservice WHERE jb_id=? ORDER BY tr_sort,csc_id,srv_id,srvt_id",id)),db.queryForList("SELECT * FROM invoice WHERE jb_id=?",id),(lock?db.lockRows("SELECT * FROM jobpayment WHERE jb_id=? ORDER BY jbp_id",id):db.queryForList("SELECT * FROM jobpayment WHERE jb_id=? ORDER BY jbp_id",id)),(lock?db.lockRows("SELECT * FROM jobprice WHERE jb_id=? ORDER BY mt_sort,jbprc_reference",id):db.queryForList("SELECT * FROM jobprice WHERE jb_id=? ORDER BY mt_sort,jbprc_reference",id)));
}
static Map<String,Object> editable(List<Field> fields,Map<String,Object> row){var v=new LinkedHashMap<String,Object>();for(var f:fields){Object x=row.get(f.key());if(x instanceof Boolean b)x=b?1:0;if(x instanceof java.sql.Timestamp t)x=t.toLocalDateTime().toString();if(x==null)x=f.type().equals("check")||f.type().equals("integer")?0:"";if(f.key().equals("ad_country")&&x.equals("D"))x="DE";v.put(f.key(),x);}return v;}
public Map<String,Object> view(LegacyUser u,long id){u.require(4);return db.readTransaction(()->{var s=snapshot(u,id,false);var corrections=db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4) ORDER BY jb_id",id);return fields("creditId",corrections.isEmpty()?0:id(corrections.getFirst(),"jb_id"),"id",id,"revision",revision(s),"fields",JOB_FIELDS,"stopFields",STOP_FIELDS,"values",editable(JOB_FIELDS,s.job()),"stations",s.stops().stream().map(t->fields("id",id(t,"tr_id"),"completed",id(t,"tr_status")==1,"recipient",text(t,"tr_signname"),"values",editable(STOP_FIELDS,t))).toList(),"prices",s.prices(),"status",id(s.job(),"jb_status"),"storno",id(s.job(),"jb_storno"),"courier",text(s.job(),"cr_sid"),"customerNet",decimal(s.job(),"jb_totalprice"),"courierNet",decimal(s.job(),"jb_cr_price"),"readOnly",exported(s.job())||!s.invoices().isEmpty()||!corrections.isEmpty(),"canEdit",u.hasRight(7),"canPrice",u.hasRight(38));});}
static boolean exported(Map<String,Object> job){String time=text(job,"jb_export_time");return !time.isBlank()&&!time.startsWith("0000-")&&!time.startsWith("9999-");}
void writable(Snapshot s){if(!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",id(s.job(),"jb_id")).isEmpty())throw bad("Für diesen Auftrag besteht bereits eine Gegenbuchung.");if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock'",id(s.job(),"jb_id")).isEmpty())throw bad("Für diesen Auftrag läuft eine Rechnungszuordnung.");if(exported(s.job())||!s.invoices().isEmpty())throw bad("Der Auftrag wurde bereits exportiert oder einer Rechnung zugeordnet. Für eine Korrektur ist eine Gutschrift erforderlich.");if(id(s.job(),"jb_storno")!=0)throw bad("Stornierte Aufträge können nicht erneut bearbeitet werden.");var lock=dateTime(s.job(),"jb_locktime");if(lock!=null&&lock.isAfter(LocalDateTime.now().minusMinutes(5)))throw bad("Der Auftrag wird gerade in der alten Anwendung bearbeitet. Bitte dort schließen und erneut laden.");}
public Map<String,Object> edit(LegacyUser u,long id,Edit r){u.require(4);u.require(7);if(r==null||r.values()==null||r.stations()==null)throw bad("Auftragsdaten fehlen.");token(r.requestId());var values=validateFields(JOB_FIELDS,"orders",r.values());
if(r.stations().isEmpty()||r.stations().size()>100)throw bad("Eine Tour benötigt 1 bis 100 Stationen.");
var stops=r.stations().stream().map(s->validateFields(STOP_FIELDS,"orders",s.values())).toList();
if(stops.size()>1&&(((Number)stops.getFirst().get("tr_ware_from_to")).intValue()==1||stops.stream().noneMatch(s->((Number)s.get("tr_ware_from_to")).intValue()!=2)))throw bad("Die Tour benötigt eine Abholung am Anfang und eine Zustellung.");
transactional();db.writeTransaction(()->{lockHeadquarters(db,u);if(support.receipt(u,r.requestId(),List.of(id,r))!=null)return null;var s=snapshot(u,id,true);unchanged(s,r.revision());writable(s);
var original=new HashMap<Long,Map<String,Object>>();s.stops().forEach(t->original.put(id(t,"tr_id"),t));var seen=new HashSet<Long>();
// Station-dependent article scans, prices and signatures must keep their station identity.
boolean structureChanged=r.stations().size()!=s.stops().size();for(int i=0;i<r.stations().size();i++)if(r.stations().get(i).id()!=0){var t=original.get(r.stations().get(i).id());if(t==null||!seen.add(r.stations().get(i).id()))throw bad("Ungültige oder doppelte Station.");if(id(t,"tr_sort")!=i+1)structureChanged=true;}else structureChanged=true;
if(structureChanged&&id(s.job(),"jb_status")==2)throw bad("Die Stationsfolge eines erledigten Auftrags bleibt als Nachweis erhalten.");
if(structureChanged&&(!db.queryForList("SELECT trat_id FROM tourarticle WHERE jb_id=? LIMIT 1",id).isEmpty()||s.prices().stream().anyMatch(p->id(p,"tr_sort")>0)))throw bad("Diese Tour enthält stationsbezogene Artikel oder Preispositionen. Die Reihenfolge muss erhalten bleiben.");
for(var old:s.stops())if(!seen.contains(id(old,"tr_id"))){if(id(old,"tr_status")==1)throw bad("Abgeschlossene Stationen bleiben als Zustellnachweis erhalten.");db.update("DELETE FROM tour WHERE tr_id=? AND jb_id=?",id(old,"tr_id"),id);}
for(int i=0;i<stops.size();i++){var input=stops.get(i);long station=r.stations().get(i).id();var old=original.getOrDefault(station,Map.of());if(id(old,"tr_status")==1)for(String key:List.of("tr_comp","tr_comp2","tr_hsno","ad_street","ad_zipcode","ad_city","ad_country","tr_ware_from_to"))if(!Objects.toString(editable(STOP_FIELDS,old).get(key),"").equals(Objects.toString(input.get(key),"")))throw bad("Adresse und Typ abgeschlossener Stationen bleiben als Nachweis erhalten.");var address=prefix(input,"ad_");long addressId=id(old,"ad_id");if(station==0||address.entrySet().stream().anyMatch(e->!Objects.toString(old.get(e.getKey()),"").equals(e.getValue().toString())))addressId=db.insert("address",address);var tour=prefix(input,"tr_");if(addressId!=id(old,"ad_id"))tour.put("tr_mediationarea_id",0);tour.putAll(fields("ad_id",addressId,"tr_sort",i+1));if(station==0){tour.putAll(fields("jb_id",id,"tr_status",0,"tr_cs_freetext",""));db.insert("tour",tour);}else update(db,"tour","tr_id",station,tour);}
if(id(s.job(),"jb_status")==1&&db.queryForList("SELECT tr_id FROM tour WHERE jb_id=? AND COALESCE(tr_status,0)<>1 LIMIT 1",id).isEmpty())throw bad("Bitte den Auftrag zuerst regulär abschließen; die letzte offene Station kann nicht entfernt werden.");
boolean routeChanged=structureChanged;for(int i=0;i<stops.size();i++){var old=original.getOrDefault(r.stations().get(i).id(),Map.of());for(String key:List.of("ad_street","tr_hsno","ad_zipcode","ad_city","ad_country"))if(!Objects.toString(editable(STOP_FIELDS,old).get(key),"").equals(Objects.toString(stops.get(i).get(key),"")))routeChanged=true;}if(routeChanged||!Objects.toString(editable(JOB_FIELDS,s.job()).get("jb_ordertime"),"").equals(Objects.toString(values.get("jb_ordertime"),"")))values.put("jb_incomplete",1);
values.put("jb_version",id(s.job(),"jb_version")+1);update(db,"job","jb_id",id,values);if(id(s.job(),"jb_status")==1)command(u,id(s.job(),"cr_id"),"5",Long.toString(id));
support.audit(u,id,0,0,2,"Auftrag bearbeitet; Stationen und Texte aktualisiert",r.requestId());support.receipt(u,r.requestId(),List.of(id,r),id);return null;});return view(u,id);
}
public Map<String,Object> action(LegacyUser u,long id,Action r){u.require(4);u.require(7);if(r==null||r.action()==null)throw bad("Aktion fehlt.");token(r.requestId());transactional();long result=db.writeTransaction(()->{lockHeadquarters(db,u);Long receipt=support.receipt(u,r.requestId(),List.of(id,r));if(receipt!=null)return receipt;var s=snapshot(u,id,true);unchanged(s,r.revision());if(!r.action().equals("CREDIT"))writable(s);else if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock'",id).isEmpty())throw bad("Rechnungszuordnung läuft noch.");long state=id(s.job(),"jb_status"),courier=id(s.job(),"cr_id"),offered=id(s.job(),"cr_id_order");long resultId=id;var changes=new LinkedHashMap<String,Object>();int operation=2;
switch(r.action()){
case "ASSIGN"->{if(state==2)throw bad("Erledigte Aufträge können nicht neu disponiert werden.");if(r.vehicleId()==null)throw bad("Bitte ein Fahrzeug wählen.");var v=catalog.vehicle(u,r.vehicleId());long next=id(v,"cr_id");var cr=one(db.lockRows("SELECT cr_sid FROM courier WHERE cr_id=? AND hq_id=?",next,u.headquartersId()));boolean online=text(cr,"cr_sid").equals(text(v,"crvh_sid"));for(long previous:new TreeSet<>(List.of(courier,offered)))if(previous>0&&previous!=next)command(u,previous,"4",Long.toString(id));changes.putAll(fields("cr_id",online?0:next,"cr_id_order",next,"cr_sid",text(v,"crvh_sid"),"vht_id_real",id(v,"vht_id"),"jb_status",online?0:1,"jb_autoranking",0,"jb_globaljob",0,"jb_taketime",online?null:LocalDateTime.now()));db.update("UPDATE courier SET cr_occupied=1 WHERE cr_id=?",next);db.update("DELETE FROM autoranking WHERE jb_id=?",id);insertWithoutKey(db,"autoranking",fields("jb_id",id,"cr_id",next,"ar_challenge",0,"ar_lastassigntime",LocalDateTime.now(),"ar_cr_order",id(s.job(),"jb_reserv")));operation=7;}
case "RECALL"->{if(state==2)throw bad("Erledigte Aufträge können nicht zurückgerufen werden.");for(long previous:new TreeSet<>(List.of(courier,offered)))command(u,previous,"4",Long.toString(id));changes.putAll(fields("cr_id",0,"cr_id_order",0,"cr_sid","","jb_status",9,"jb_autoranking",0,"jb_globaljob",0,"jb_taketime",null));db.update("DELETE FROM autoranking WHERE jb_id=?",id);operation=8;}
case "ACCEPT"->{if(state!=0||offered<=0)throw bad("Nur ein angebotener Auftrag kann angenommen werden.");changes.putAll(fields("cr_id",offered,"jb_status",1,"jb_taketime",LocalDateTime.now()));operation=3;}
case "COMPLETE","COMPLETE_STOP"->{if(state!=1||courier<=0)throw bad("Zum Abschließen muss der Auftrag angenommen und einem Kurier zugewiesen sein.");String recipient=required(r.recipient(),50,"Empfänger / Erledigungsnachweis");LocalDateTime now=LocalDateTime.now().withNano(0);
if(r.action().equals("COMPLETE_STOP")){if(r.stationId()==null||s.stops().stream().noneMatch(t->id(t,"tr_id")==r.stationId()&&id(t,"tr_status")!=1))throw bad("Die Station ist nicht offen.");db.update("UPDATE tour SET tr_status=1,tr_finishtime=?,tr_signname=? WHERE tr_id=? AND jb_id=?",now,recipient,r.stationId(),id);}else db.update("UPDATE tour SET tr_status=1,tr_finishtime=?,tr_signname=? WHERE jb_id=? AND COALESCE(tr_status,0)<>1",now,recipient,id);
if(db.queryForList("SELECT tr_id FROM tour WHERE jb_id=? AND COALESCE(tr_status,0)<>1 LIMIT 1",id).isEmpty()){changes.putAll(fields("jb_status",2,"jb_finishtime",now,"jb_booktime",now));db.update("DELETE FROM vehicledisposition WHERE jb_id=?",id);command(u,courier,"3",Long.toString(id));}else command(u,courier,"5",Long.toString(id));operation=4;}
case "CANCEL","CREDIT"->{String reason=required(r.reason(),500,"Stornogrund");if(id(s.job(),"jb_storno")!=0||!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",id).isEmpty())throw bad("Für diesen Auftrag besteht bereits ein Storno / eine Gegenbuchung.");if(r.chargeable()&&courier<=0&&offered<=0)throw bad("Kostenpflichtiges Storno benötigt einen Kurier.");if(r.action().equals("CREDIT")&&state!=2)throw bad("Gutschriften setzen einen erledigten Auftrag voraus.");
if(id(s.job(),"jb_id_parent")!=0||!db.queryForList("SELECT jb_id FROM job WHERE jb_id_parent=? LIMIT 1",id).isEmpty())throw bad("Verbundene Kurier-Unteraufträge müssen gemeinsam korrigiert werden.");
if(!s.payments().isEmpty())throw bad("Es bestehen Einnahmenbuchungen. Diese müssen vor dem Storno fachlich ausgeglichen werden.");
resultId=credit(u,id,s,r,reason);changes.put("jb_id_copy",resultId);if(!r.action().equals("CREDIT"))changes.putAll(fields("jb_storno",r.chargeable()?1:2,"usr_id_storno",u.userId(),"jb_status",2,"jb_finishtime",LocalDateTime.now(),"cr_id",courier>0?courier:offered));if(r.endSeries())changes.put("jb_permanent",0);for(long previous:new TreeSet<>(List.of(courier,offered))){command(u,previous,"3",Long.toString(id));command(u,previous,"8",Long.toString(id));}db.update("DELETE FROM vehicledisposition WHERE jb_id=?",id);db.update("DELETE FROM autoranking WHERE jb_id=?",id);operation=10;}
default->throw bad("Unbekannte Auftragsaktion.");
}
changes.put("jb_version",id(s.job(),"jb_version")+1);update(db,"job","jb_id",id,changes);
for(long cr:new TreeSet<>(List.of(courier,offered)))if(cr>0)db.update("UPDATE courier SET cr_occupied=(SELECT COUNT(*) FROM job WHERE (cr_id_order=? OR cr_id=?) AND jb_status IN (0,1) AND COALESCE(jb_storno,0)=0) WHERE cr_id=?",cr,cr,cr);
support.audit(u,id,0,courier,operation,r.action()+(r.reason()==null?"":" · "+r.reason())+(resultId!=id?" · Gegenbuchung #"+resultId:""),r.requestId());support.receipt(u,r.requestId(),List.of(id,r),resultId);return resultId;});return fields("id",id,"resultId",result,"order",view(u,id));
}
long credit(LegacyUser u,long id,Snapshot s,Action r,String reason){var copy=new LinkedHashMap<>(s.job());for(String k:List.of("jb_id","jb_modify","jb_export_time","jb_locktime","jb_lockuser","jb_id_copy"))copy.remove(k);copy.putAll(fields("jb_storno",r.chargeable()?3:4,"jb_id_parent",0,"jb_autoranking",0,"jb_globaljob",0,"cr_id",id(s.job(),"cr_id")>0?id(s.job(),"cr_id"):id(s.job(),"cr_id_order"),"jb_status",2,"jb_permanent",0,"jb_reserv",0,"jb_version",0,"jb_id_copy",id,"jb_createtime",LocalDateTime.now(),"jb_finishtime",LocalDateTime.now(),"jb_booktime",LocalDateTime.now(),"emp_id",u.userId(),"jb_invtext","Storno-Gutschrift zu Auftrag "+id+": "+reason,"jb_automailsent",0,"jb_automailsent2",0,"jb_automailsent3",0));
for(String k:List.of("jb_fixprice","jb_serviceprice","jb_subtotalprice","jb_totalprice","jb_cr_price","jb_cr_subprice","jb_hq_id_exec_price")){var n=decimal(s.job(),k);if(n!=null)copy.put(k,n.negate());}copy.put("jb_totalprice",customerNet(s).negate());long credit=db.insert("job",copy);
OrderPaymentTerms.copy(db,id,credit);
for(var t:db.queryForList("SELECT * FROM tour WHERE jb_id=? ORDER BY tr_sort",id)){var c=new LinkedHashMap<>(t);c.remove("tr_id");c.remove("tr_modify");c.put("jb_id",credit);db.insert("tour",c);}
for(var p:s.prices()){var c=new LinkedHashMap<>(p);c.remove("trs_modify");c.put("jb_id",credit);c.put("trs_price",decimal(p,"trs_price").negate());insertWithoutKey(db,"tourservice",c);}
for(var detail:s.priceDetails()){var c=new LinkedHashMap<>(detail);c.put("jb_id",credit);if(Set.of(2L,3L,4L,7L,10L,11L,12L).contains(id(detail,"mt_sort")))c.put("jbprc_price",decimal(detail,"jbprc_price").negate());insertWithoutKey(db,"jobprice",c);}
for(var m:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",id))db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",credit,"gdc_gen_fieldname","jb_toll","gdc_content",new BigDecimal(text(m,"gdc_content")).negate().toPlainString(),"gdc_context",""));
if(r.chargeable()){
var tariff=cancellationTariff(u,s);
if(s.prices().isEmpty()&&customerNet(s).signum()!=0)insertWithoutKey(db,"tourservice",fields("jb_id",credit,"csc_id",id(s.job(),"csc_id_payer"),"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Gegenbuchung Auftragspreis","trs_srvt_name","","trs_price",customerNet(s).negate(),"trs_discount",0));
var centers=new TreeSet<Long>();s.prices().forEach(p->centers.add(id(p,"csc_id")));if(centers.isEmpty())centers.add(id(s.job(),"csc_id_payer"));int index=0;
BigDecimal fee=tariff.price();for(long center:centers)insertWithoutKey(db,"tourservice",fields("jb_id",credit,"csc_id",center,"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Stornogebühr","trs_srvt_name","","trs_price",OrderEntryAccounting.portion(fee,centers.size(),index++),"trs_discount",0));
// PHP books the Storno tariff price without a discount or an additional courier fee.
db.update("UPDATE job SET jb_serviceprice=jb_serviceprice+?,jb_subtotalprice=jb_subtotalprice+?,jb_totalprice=jb_totalprice+? WHERE jb_id=?",fee,fee,fee,credit);
}
support.audit(u,credit,0,0,1,"Gegenbuchung zu Auftrag #"+id,r.requestId());return credit;
}
static BigDecimal customerNet(Snapshot s){return s.prices().isEmpty()?decimal(s.job(),"jb_totalprice"):s.prices().stream().map(BillingService::net).reduce(BigDecimal.ZERO,BigDecimal::add);}
LegacyOrderPricing.Tariff cancellationTariff(LegacyUser u,Snapshot s){
var payer=one(db.queryForList("SELECT cs_id FROM costcenter WHERE csc_id=?",id(s.job(),"csc_id_payer")));
var vehicle=one(db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",id(s.job(),"vht_id")));
var tariff=pricing.service(u.headquartersId(),id(payer,"cs_id"),text(vehicle,"mt_value"),"Storno",dateTime(s.job(),"jb_ordertime"),BigDecimal.ZERO);
if(!tariff.found()||tariff.price().signum()<=0)throw bad("Kein eindeutiger kostenpflichtiger Stornotarif hinterlegt.");return tariff;
}
void transactional(){support.transactional("headquarters","genericdatacontainer","job","tour","tourservice","jobprice","jobpayment","address","courier","autoranking","vehicledisposition","phoenix_pda.commandexec");}
public Map<String,Object> price(LegacyUser u,long id,Price r){u.require(4);u.require(7);u.require(38);if(r==null)throw bad("Preisangaben fehlen.");token(r.requestId());String reason=required(r.reason(),200,"Begründung");
for(BigDecimal amount:List.of(r.customerNet()==null?BigDecimal.valueOf(-1):r.customerNet(),r.courierNet()==null?BigDecimal.valueOf(-1):r.courierNet()))if(amount.signum()<0||amount.scale()>2||amount.compareTo(new BigDecimal("999999.99"))>0)throw bad("Preise müssen zwischen 0 und 999.999,99 € liegen und höchstens zwei Nachkommastellen haben.");
transactional();db.writeTransaction(()->{lockHeadquarters(db,u);if(support.receipt(u,r.requestId(),List.of(id,r))!=null)return null;var s=snapshot(u,id,true);unchanged(s,r.revision());writable(s);
if(id(s.job(),"jb_id_parent")!=0||id(s.job(),"hq_id_exec")>0&&id(s.job(),"hq_id_exec")!=u.headquartersId()||!s.payments().isEmpty())throw bad("Verrechnete Unteraufträge, Fremdniederlassungs- und Einnahmenbuchungen benötigen eine gemeinsame Korrektur.");
var centers=new TreeSet<Long>();s.prices().forEach(p->centers.add(id(p,"csc_id")));if(centers.isEmpty())centers.add(id(s.job(),"csc_id_payer"));
db.update("DELETE FROM tourservice WHERE jb_id=?",id);int index=0;for(long center:centers)insertWithoutKey(db,"tourservice",fields("jb_id",id,"csc_id",center,"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Fixpreis (manuell)","trs_srvt_name","","trs_price",OrderEntryAccounting.portion(r.customerNet(),centers.size(),index++),"trs_discount",0));
db.update("DELETE FROM jobprice WHERE jb_id=? AND (mt_sort<11 OR mt_sort=15)",id);for(int mode:List.of(2,8,9,10))insertWithoutKey(db,"jobprice",fields("jb_id",id,"mt_sort",mode,"jbprc_price",mode==2||mode==10?r.courierNet():BigDecimal.ZERO,"jbprc_remark",""));
update(db,"job","jb_id",id,fields("jb_fixprice",r.customerNet(),"jb_serviceprice",0,"jb_subtotalprice",r.customerNet(),"jb_totalprice",r.customerNet(),"jb_markup",0,"jb_cr_price",r.courierNet(),"jb_cr_subprice",r.courierNet(),"jb_incomplete",0,"jb_version",id(s.job(),"jb_version")+1,"jb_invtext",text(s.job(),"jb_invtext")+"\nPreisberichtigung: "+reason+" · Netto "+r.customerNet()+" EUR","jb_invtext_cr",text(s.job(),"jb_invtext_cr")+"\nPreisberichtigung: "+reason+" · Frachtvergütung "+r.courierNet()+" EUR"));
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_cost_split'",id);
if(id(s.job(),"jb_status")==1)command(u,id(s.job(),"cr_id"),"5",Long.toString(id));db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('ng_calculation_input','ng_calculation_result')",id);support.audit(u,id,0,0,2,"Manueller Fixpreis · "+reason,r.requestId());support.receipt(u,r.requestId(),List.of(id,r),id);return null;});return view(u,id);
}
void command(LegacyUser u,long courier,String command,String value){if(courier<=0)return;db.insert("`phoenix_pda`.commandexec",fields("cr_id",courier,"hq_id",u.headquartersId(),"cmd_command",command,"cmde_mode","1","cmde_param",value,"cmde_exec_time",LocalDateTime.now(),"cmde_exec_timelimit",LocalDateTime.now().plusHours(36)));}
}
@@ -0,0 +1,35 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** A separate snapshot avoids PHP's ambiguous shared jobprice type 16. */
final class OrderPaymentTerms {
private OrderPaymentTerms() {}
static void capture(DatabaseService db,long order,long customer) {
var rows=db.queryForList("SELECT cs.cs_payment_term,mt.mt_value,mt.mt_mapped_value FROM customer cs LEFT JOIN metatype mt ON mt.mt_type='payment_term' AND mt.mt_sort=cs.cs_payment_term WHERE cs.cs_id=?",customer);
if(rows.size()!=1)throw AdminSupport.bad("Die Zahlungsbedingung des Kunden ist nicht eindeutig.");
var value=rows.getFirst();
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",order,"gdc_gen_fieldname","ng_payment_term","gdc_content",text(value,"mt_value"),"gdc_context",Long.toString(id(value,"cs_payment_term"))));
if(!text(value,"mt_mapped_value").isBlank())db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",order,"gdc_gen_fieldname","ng_payment_term_label","gdc_content",text(value,"mt_mapped_value"),"gdc_context",Long.toString(id(value,"cs_payment_term"))));
}
static Map<String,Object> read(DatabaseService db,long order) {
var rows=db.queryForList("SELECT gdc_content AS label,gdc_context AS code FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_payment_term' ORDER BY gdc_id",order);
if(rows.size()>1)throw AdminSupport.bad("Auftrag #"+order+": Zahlungsbedingung ist mehrfach gespeichert.");
if(!rows.isEmpty()) {
var labels=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_payment_term_label'",order);
if(labels.size()>1)throw AdminSupport.bad("Auftrag #"+order+": Zahlungsbedingung ist mehrfach beschriftet.");
return fields("code",text(rows.getFirst(),"code"),"value",text(rows.getFirst(),"label"),"label",labels.isEmpty()?text(rows.getFirst(),"label"):text(labels.getFirst(),"gdc_content"),"source","Auftragsstand");
}
// A kilometer-price row has no remark. Never reinterpret its amount as a payment-term code.
var legacy=db.queryForList("SELECT jbprc_price AS code,jbprc_remark AS label FROM jobprice WHERE jb_id=? AND mt_sort=16 AND TRIM(COALESCE(jbprc_remark,''))<>''",order);
if(legacy.size()>1)throw AdminSupport.bad("Auftrag #"+order+": Mehrdeutige PHP-Zahlungsbedingung (Preistyp 16).");
return legacy.isEmpty()?fields("code","","label","","source","Nicht im Auftrag gespeichert"):fields("code",text(legacy.getFirst(),"code"),"label",text(legacy.getFirst(),"label"),"source","PHP-Auftragsstand");
}
static void copy(DatabaseService db,long from,long to) {
for(var row:db.queryForList("SELECT gdc_content,gdc_context,gdc_gen_fieldname FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('ng_payment_term','ng_payment_term_label')",from))
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",to,"gdc_gen_fieldname",text(row,"gdc_gen_fieldname").isBlank()?"ng_payment_term":text(row,"gdc_gen_fieldname"),"gdc_content",text(row,"gdc_content"),"gdc_context",text(row,"gdc_context")));
}
}
@@ -0,0 +1,182 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import org.springframework.scheduling.annotation.Scheduled;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.OrderIntegrationClient.unavailable;
@Service
public class OrderPhotoWorkflows {
private final DatabaseService db;
private final OrderEntryCatalog catalog;
private final OrderIntegrationClient client;
private final ObjectMapper json;
record Selection(long jbId,long trId,int number) {}
record SetSelection(long jbId,int setNumber) {}
record Transfer(List<Selection> selections,int status,long courierId,long headquartersId,Integer setNumber) {
Transfer(List<Selection> selections,int status,long courierId,long headquartersId){this(selections,status,courierId,headquartersId,null);}
}
public OrderPhotoWorkflows(DatabaseService db,OrderEntryCatalog catalog,OrderIntegrationClient client,ObjectMapper json) {
this.db=db;this.catalog=catalog;this.client=client;this.json=json;
}
private String url() {return catalog.parameter(0,"URL_CS_PHOTO_WORKFLOW").trim().replaceAll("/+$","");}
public PhotoOptions options(LegacyUser user,long costCenter) {
user.require(7);
return options(id(catalog.costCenter(user,costCenter,false),"cs_id"));
}
PhotoOptions options(long customer) {
String url=url();
if(url.isBlank())return new PhotoOptions(false,List.of());
List<PhotoSet> sets=readSets(url,customer);
if(sets.isEmpty() && customer!=0)sets=readSets(url,0);
return new PhotoOptions(true,sets);
}
private List<PhotoSet> readSets(String url,long customer) {
var items=result(client.get(url+"/sets/kunde/"+customer,"Der Fotodienst"));
List<PhotoSet> sets=new ArrayList<>();Set<Integer> numbers=new HashSet<>();
for(var item:items) {
if(!allowed(item))continue;
int set=number(item,false);if(!numbers.add(set))throw unavailable("Der Fotodienst hat doppelte Set-Nummern geliefert.");
if(!item.path("workflows").isArray())throw unavailable("Der Fotodienst hat ein ungültiges Foto-Set geliefert.");
var flows=new ArrayList<PhotoWorkflow>();var flowNumbers=new HashSet<Integer>();
for(var flow:item.path("workflows")) {
if(!allowed(flow))continue;
int no=number(flow,true);if(!flowNumbers.add(no))throw unavailable("Der Fotodienst hat doppelte Workflow-Nummern geliefert.");
int min=quantity(flow,"min"),max=quantity(flow,"max");if(max<min)throw unavailable("Der Fotodienst hat ungültige Fotomengen geliefert.");
String scope=flow.path("stationScope").asText("ALL");
if(!Set.of("ALL","PICKUP_ONLY","DELIVERY_ONLY","EXCEPT_PICKUP","EXCEPT_DELIVERY").contains(scope))throw unavailable("Der Fotodienst hat einen unbekannten Stationsbereich geliefert.");
var positions=new ArrayList<String>();if(flow.hasNonNull("positions")) {
if(!flow.path("positions").isArray())throw unavailable("Der Fotodienst hat ungültige Stationspositionen geliefert.");
for(var position:flow.path("positions")){if(!Set.of("FIRST","MIDDLE","LAST").contains(position.asText()))throw unavailable("Der Fotodienst hat eine unbekannte Stationsposition geliefert.");positions.add(position.asText());}
}
flows.add(new PhotoWorkflow(no,flow.path("name").asText(),flow.path("default").asBoolean(),min,max,scope,List.copyOf(positions)));
}
sets.add(new PhotoSet(set,item.path("name").asText(),item.path("default").asBoolean(),List.copyOf(flows)));
}
if(sets.stream().filter(PhotoSet::defaultSet).count()>1)throw unavailable("Der Fotodienst hat mehrere Standardsets geliefert.");
return List.copyOf(sets);
}
private static boolean allowed(JsonNode node){return !node.hasNonNull("allowed") || node.path("allowed").isBoolean() && node.path("allowed").asBoolean();}
private static int number(JsonNode node,boolean zero){
if(!node.path("number").asText().matches("[0-9]{1,9}") || node.path("name").asText().isBlank() || node.path("name").asText().length()>500)throw unavailable("Der Fotodienst hat eine ungültige Auswahl geliefert.");
int value=node.path("number").asInt();if(!zero && value==0)throw unavailable("Der Fotodienst hat eine ungültige Set-Nummer geliefert.");return value;
}
private static int quantity(JsonNode node,String key){if(!node.hasNonNull(key))return 0;if(!node.path(key).asText().matches("[0-9]{1,6}"))throw unavailable("Der Fotodienst hat eine ungültige Fotomenge geliefert.");return node.path(key).asInt();}
// PHP photoScopeMatches uses ware=1 for PICKUP_ONLY and ware=2 for DELIVERY_ONLY.
// Preserve these legacy flags (our tour encoder writes DELIVERY=1, PICKUP=2).
static boolean offered(PhotoWorkflow flow,Models.StopType type){
if(type==Models.StopType.BOTH)return offered(flow,Models.StopType.PICKUP)||offered(flow,Models.StopType.DELIVERY);
return switch(flow.stationScope()){case "PICKUP_ONLY","EXCEPT_DELIVERY"->type==Models.StopType.DELIVERY;case "DELIVERY_ONLY","EXCEPT_PICKUP"->type==Models.StopType.PICKUP;default->true;};
}
PhotoPlan resolve(Request request,long customer) {
var options=options(customer);PhotoSet set;
if(request.photoSet()!=null)set=options.sets().stream().filter(v->v.number()==request.photoSet()).findFirst().orElseThrow(()->invalid("Das Foto-Set ist für diesen Kunden nicht mehr freigegeben. Bitte neu laden."));
else set=options.sets().stream().filter(PhotoSet::defaultSet).findFirst().orElse(options.sets().isEmpty()?null:options.sets().getFirst());
List<PhotoChoice> choices=new ArrayList<>();
for(int i=0;i<request.stops().size();i++) {
var station=request.stops().get(i);Integer selected=station.photoWorkflow();
List<PhotoWorkflow> offered=set==null?List.of():set.workflows().stream().filter(f->offered(f,station.type())).toList();
String position=i==request.stops().size()-1?"LAST":i==0?"FIRST":"MIDDLE";
var flow=selected==null?offered.stream().filter(f->f.positions().contains(position)).findFirst().orElseGet(()->offered.stream().filter(PhotoWorkflow::defaultWorkflow).findFirst().orElse(null)):
offered.stream().filter(f->f.number()==selected).findFirst().orElseThrow(()->invalid("Die Fotoauswahl passt nicht mehr zum Set oder zur Station. Bitte neu auswählen."));
if(flow!=null)choices.add(new PhotoChoice(i+1,flow.number(),flow.name()));
}
return new PhotoPlan(set,List.copyOf(choices));
}
void enqueue(long job,List<Selection> selections,int desiredStatus,long courierId,long hq,Integer setNumber) {
if(selections.isEmpty() && setNumber==null)return;
try {
String content=json.writeValueAsString(new Transfer(List.copyOf(selections),desiredStatus,courierId,hq,setNumber));
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname","ng_photo_selection",
"gdc_content",content,"gdc_context","PENDING"));
} catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw new IllegalStateException("Fotoauswahl konnte nicht gesichert werden.",e);}
}
record Claim(long id,long job,String context,Transfer transfer) {}
private static String leasePrefix(long seconds) {return "SENDING:"+String.format(Locale.ROOT,"%012d",seconds)+":";}
private static String newLease() {return leasePrefix(java.time.Instant.now().plusSeconds(45).getEpochSecond())+UUID.randomUUID();}
private static String expiredBefore() {return leasePrefix(java.time.Instant.now().getEpochSecond());}
/** Short DB leases prevent parallel publishers without holding a job lock across HTTP calls. */
private Claim claim(long job) {
return db.writeTransaction(() -> {
if(db.lockRows("SELECT jb_id FROM job WHERE jb_id=?",job).isEmpty())return null;
var rows=db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_photo_selection' AND (gdc_context='PENDING' OR (gdc_context LIKE 'SENDING:%' AND gdc_context<?)) ORDER BY gdc_id LIMIT 1",job,expiredBefore());
if(rows.isEmpty())return null;
var row=rows.getFirst();Transfer transfer;
try {transfer=json.readValue(text(row,"gdc_content"),Transfer.class);}
catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw new IllegalStateException("Ungültiger Fotoauftrag.",e);}
String lease=newLease();long id=id(row,"gdc_id");
db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_id=?",lease,id);
return new Claim(id,job,lease,transfer);
});
}
private Claim renew(Claim claim) {
String lease=newLease();
int changed=db.writeTransaction(() -> db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_id=? AND gdc_context=?",lease,claim.id(),claim.context()));
if(changed!=1)throw new IllegalStateException("Fotoübertragung wird bereits von einem anderen Prozess bearbeitet.");
return new Claim(claim.id(),claim.job(),lease,claim.transfer());
}
/** A lost acknowledgement is recovered using GET /selection before retrying POST. */
void publish(long job) {
Claim claim=claim(job);if(claim==null)return;
try {
String url=url();if(url.isBlank())throw unavailable("Der Fotodienst ist nicht konfiguriert.");
// Reapply the same set on retry, then read station selections AFTER that assignment.
// This also restores station overrides if assigning the set resets them remotely.
if(claim.transfer().setNumber()!=null){claim=renew(claim);client.post(url+"/selection/set",new SetSelection(job,claim.transfer().setNumber()),"Der Fotodienst");}
var existing=result(client.get(url+"/selection?jbId="+job,"Der Fotodienst"));
for(var selection:claim.transfer().selections()) {
boolean found=false;
for(var item:existing)if(item.path("tr_id").asLong()==selection.trId() && item.path("number").asInt(-1)==selection.number())found=true;
if(!found) {
claim=renew(claim);
client.post(url+"/selection",selection,"Der Fotodienst");
}
}
finish(claim);
}catch(RuntimeException failure) {
Claim failed=claim;
try {db.writeTransaction(() -> db.update("UPDATE genericdatacontainer SET gdc_context='PENDING' WHERE gdc_id=? AND gdc_context=?",failed.id(),failed.context()));}
catch(RuntimeException ignored) { /* The lease expires after a crash or database outage. */ }
throw failure;
}
}
private void finish(Claim claim) {
db.writeTransaction(() -> {
var jobs=db.lockRows("SELECT jb_id,jb_status,jb_version,jb_storno FROM job WHERE jb_id=?",claim.job());
if(jobs.isEmpty())return null;
int changed=db.update("UPDATE genericdatacontainer SET gdc_context='SENT' WHERE gdc_id=? AND gdc_context=?",claim.id(),claim.context());
if(changed!=1)return null;
Transfer transfer=claim.transfer();
// Never undo an operator's subsequent disposition, cancellation or completion.
if(id(jobs.getFirst(),"jb_status")==9 && id(jobs.getFirst(),"jb_version")==0 && id(jobs.getFirst(),"jb_storno")==0 && transfer.status()!=9) {
db.update("UPDATE job SET jb_status=? WHERE jb_id=? AND jb_status=9 AND jb_version=0 AND COALESCE(jb_storno,0)=0",transfer.status(),claim.job());
if(transfer.status()==0 && transfer.courierId()>0)db.update("UPDATE courier SET cr_occupied=1 WHERE cr_id=? AND hq_id=?",transfer.courierId(),transfer.headquartersId());
}
return null;
});
}
boolean pending(long job) {
return !db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_photo_selection' AND gdc_context<>'SENT'",job).isEmpty();
}
void tryPublish(long job) {
try {publish(job);}catch(RuntimeException e) {org.slf4j.LoggerFactory.getLogger(getClass()).warn("Eine gesicherte Fotoauswahl wird später erneut übertragen; der Auftrag bleibt zur Prüfung in der Disposition.");}
}
@Scheduled(initialDelay=60000,fixedDelay=30000)
public void retryPending() {
try {
var rows=db.queryForList("SELECT gdc_obj_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_gen_fieldname='ng_photo_selection' AND (gdc_context='PENDING' OR (gdc_context LIKE 'SENDING:%' AND gdc_context<?)) ORDER BY gdc_id LIMIT 20",expiredBefore());
for(var row:rows)tryPublish(id(row,"gdc_obj_id"));
}catch(RuntimeException ignored) { /* Durable entries remain pending during a DB outage. */ }
}
static JsonNode result(JsonNode json) {
if(json==null || !json.path("result").isArray())throw unavailable("Der Fotodienst hat ungültige Daten geliefert.");
return json.path("result");
}
}
@@ -0,0 +1,146 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import jakarta.validation.Valid;
import jakarta.validation.Validator;
import jakarta.validation.constraints.*;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
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.LegacyOrderPricing.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Reprices the saved route without replaying creation, stock movements or driver state. */
@RestController
@RequestMapping("/api/orders/{id}/calculation")
public class OrderRecalculation {
final DatabaseService db;
final BusinessSupport support;
final OrderOperations orders;
final OrderEntryCatalog catalog;
final LegacyOrderPricing pricing;
final OrderRoutingService routing;
final Validator validator;
public OrderRecalculation(DatabaseService db,BusinessSupport support,OrderOperations orders,OrderEntryCatalog catalog,LegacyOrderPricing pricing,OrderRoutingService routing,Validator validator) {
this.db=db;this.support=support;this.orders=orders;this.catalog=catalog;this.pricing=pricing;this.routing=routing;this.validator=validator;
}
public record Input(@NotNull @Valid Pricing pricing,@Min(0) int vehicleTypeId,
@Size(max=100) List<@Valid ServiceItem> services,boolean costSplit,boolean confirmCourierLimit,
@Size(max=20) List<@NotNull @Valid CourierShare> courierShares,String childrenRevision,
@Size(max=20) List<@NotNull @Min(0) Long> pairIds) {
public Input { services=services==null?List.of():List.copyOf(services);courierShares=courierShares==null?List.of():List.copyOf(courierShares); }
public Input(Pricing pricing,int vehicleTypeId,List<ServiceItem> services,boolean costSplit,boolean confirmCourierLimit){this(pricing,vehicleTypeId,services,costSplit,confirmCourierLimit,List.of(),null,null);}
public Input(Pricing pricing,int vehicleTypeId,List<ServiceItem> services,boolean costSplit,boolean confirmCourierLimit,List<CourierShare> shares,String revision){this(pricing,vehicleTypeId,services,costSplit,confirmCourierLimit,shares,revision,null);}
}
public record Command(String requestId,String revision,@NotNull @Valid Input input,String fingerprint) {}
record Context(OrderOperations.Snapshot snapshot,Request request,Map<String,Object> payer,List<OrderChildRecalculation.Pair> children) {}
@GetMapping public Object get(Authentication auth,@PathVariable long id) {return options(ApiController.user(auth),id);}
@PostMapping public Object preview(Authentication auth,@PathVariable long id,@Valid @RequestBody Command command) {return preview(ApiController.user(auth),id,command);}
@PutMapping public Object save(Authentication auth,@PathVariable long id,@Valid @RequestBody Command command) {return save(ApiController.user(auth),id,command);}
Map<String,Object> options(LegacyUser user,long id) {
user.require(4);user.require(7);
return db.readTransaction(()->{
var s=orders.snapshot(user,id,false);
var options=catalog.options(user);
long tax=options.taxes().stream().filter(t->t.label().equals(text(s.job(),"jb_sales_tax_rate_sign"))).mapToLong(Tax::id).findFirst().orElse(0);
PriceMode mode=s.prices().stream().anyMatch(p->text(p,"trs_srvt_name").equals("z"))?PriceMode.ZONE:positive(decimal(s.job(),"jb_fixprice"))?PriceMode.FIXED:PriceMode.TARIFF;
var previous=db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_calculation_input'",id);
var services=new OrderEntryTemplates(db,catalog).services(user,payerId(s),text(s.job(),"jb_freetext_2"));
if(!services.isEmpty())mode=PriceMode.SERVICES;
Input input=new Input(new Pricing(mode,decimal(s.job(),"jb_fixprice"),decimal(s.job(),"jb_serviceprice"),detail(s,2),detail(s,3),detail(s,8),detail(s,9),s.prices().stream().anyMatch(p->positive(decimal(p,"trs_discount"))),tax),(int)id(s.job(),"vht_id"),services,id(s.job(),"jb_payment")==1,false);
if(previous.size()==1&&text(previous.getFirst(),"gdc_context").equals(Long.toString(id(s.job(),"jb_version"))))try {input=support.json.readValue(text(previous.getFirst(),"gdc_content"),Input.class);}catch(Exception e){throw bad("Der gespeicherte Kalkulationsstand ist nicht lesbar.");}
if(!user.hasRight(38)){var p=input.pricing();input=new Input(new Pricing(p.mode(),p.fixedPrice(),p.servicePrice(),null,null,p.markup(),null,p.applyFixedDiscount(),p.taxId()),input.vehicleTypeId(),input.services(),input.costSplit(),false);}
var children=new OrderChildRecalculation(db,orders,support);var pairs=children.load(user,s,false);
var currentPricing=input.pricing();input=new Input(pairs.isEmpty()?currentPricing:new Pricing(currentPricing.mode(),currentPricing.fixedPrice(),currentPricing.servicePrice(),null,null,currentPricing.markup(),null,currentPricing.applyFixedDiscount(),currentPricing.taxId()),input.vehicleTypeId(),input.services(),input.costSplit(),false,children.shares(user,pairs),revision(pairs),pairs.stream().map(pair->id(pair.positive().job(),"jb_id")).toList());
return fields("children",pairs.stream().map(p->fields("positive",id(p.positive().job(),"jb_id"),"negative",id(p.negative().job(),"jb_id"),"sid",text(p.positive().job(),"cr_sid"))).toList(),"revision",revision(s),"input",input,"options",options,"services",catalog.services(user,payerId(s)),"paymentTerm",OrderPaymentTerms.read(db,id));
});
}
static BigDecimal detail(OrderOperations.Snapshot s,int kind) {var rows=s.priceDetails().stream().filter(p->id(p,"mt_sort")==kind).toList();return rows.size()==1?decimal(rows.getFirst(),"jbprc_price"):null;}
static long payerId(OrderOperations.Snapshot s) {return id(s.job(),"csc_id_payer_cash")>0?id(s.job(),"csc_id_payer_cash"):id(s.job(),"csc_id_payer");}
Context prepare(LegacyUser user,long id,Command command,boolean lock) {
user.require(4);user.require(7);
if(command==null||command.input()==null||!validator.validate(command).isEmpty())throw bad("Bitte die Kalkulationswerte prüfen.");
var s=orders.snapshot(user,id,lock);unchanged(s,command.revision());orders.writable(s);
var children=new OrderChildRecalculation(db,orders,support);var pairs=children.load(user,s,lock);children.validate(user,s,pairs,command.input());
if(!s.payments().isEmpty())throw bad("Vor der Neukalkulation müssen vorhandene Einnahmenbuchungen ausgeglichen werden.");
if(command.input().pricing().mode()!=PriceMode.SERVICES&&!command.input().services().isEmpty())throw bad("Für Leistungspositionen bitte Leistungskalkulation wählen.");
if(command.input().pricing().courierPrice()!=null||command.input().pricing().courierServicePrice()!=null||command.input().pricing().courierMarkup()!=null)user.require(38);
var payer=catalog.costCenter(user,payerId(s),id(s.job(),"jb_payment")!=2);
var job=s.job();var stops=new ArrayList<Station>();
for(var t:s.stops())stops.add(new Station(nullableId(t,"csc_id"),id(t,"tr_ware_from_to")==3?Models.StopType.BOTH:id(t,"tr_ware_from_to")==1?Models.StopType.DELIVERY:Models.StopType.PICKUP,text(t,"tr_comp"),text(t,"ad_street"),text(t,"tr_hsno"),text(t,"ad_zipcode"),text(t,"ad_city"),text(t,"ad_country").equals("D")?"DE":text(t,"ad_country"),text(t,"tr_person"),text(t,"tr_phone"),text(t,"tr_remark"),text(t,"tr_commission_no"),null,nullableId(t,"tr_mediationarea_id"),List.of()));
if(stops.isEmpty())throw bad("Der Auftrag besitzt keine Stationen.");
var vehicles=db.queryForList("SELECT crvh_id FROM couriervehicle WHERE cr_id=? AND crvh_sid=?",id(job,"cr_id"),text(job,"cr_sid"));
var toll=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",id);
if(toll.size()>1)throw bad("Mehrdeutige Mautposition.");
var data=fields("mode",id(job,"jb_status")==2?"RETROSPECTIVE":"IMMEDIATE","pickupAt",dateTime(job,"jb_ordertime"),"payerCostCenterId",payerId(s),"payment",id(job,"jb_payment")==2?"CASH":"INVOICE","cashStop",id(job,"jb_payment")==2?1:null,
"vehicleTypeId",command.input().vehicleTypeId(),"courierVehicleId",vehicles.size()==1?id(vehicles.getFirst(),"crvh_id"):null,"stops",stops,"orderer",text(job,"jb_orderer"),"reference",text(job,"jb_commission_no"),"weight",decimal(job,"jb_weight"),"longhaul",id(job,"jb_longhaul")==1?"AUTO":"ASAP",
"pricing",command.input().pricing(),"requestId",UUID.randomUUID().toString(),"services",command.input().services(),"copies",1,"courierShares",command.input().courierShares(),"costSplit",command.input().costSplit(),"toll",toll.isEmpty()?BigDecimal.ZERO:new BigDecimal(text(toll.getFirst(),"gdc_content")));
return new Context(s,support.json.convertValue(data,Request.class),payer,pairs);
}
Quote calculate(LegacyUser user,Context context,OrderRoutingService.Result route) {
var q=pricing.calculate(user,context.request(),context.payer(),dateTime(context.snapshot().job(),"jb_ordertime"),route,List.of());
return new OrderEntryAccounting(db,catalog).quote(user,context.request(),q,List.of());
}
Map<String,Object> preview(LegacyUser user,long id,Command command) {
var context=db.readTransaction(()->prepare(user,id,command,false));
var route=routing.resolve(user,context.request(),id(context.snapshot().job(),"hq_id_dispo"));
return db.readTransaction(()->{var current=prepare(user,id,command,false);var q=calculate(user,current,route);return fields("quote",q,"fingerprint",fingerprint(command,q),"pairChanges",new OrderChildRecalculation(db,orders,support).changes(current.children(),command.input()),"previousNet",decimal(current.snapshot().job(),"jb_totalprice"),"previousCourierNet",decimal(current.snapshot().job(),"jb_cr_price"));});
}
static String fingerprint(Command c,Quote q) {return revision(List.of(c.revision(),c.input(),q));}
Map<String,Object> save(LegacyUser user,long id,Command command) {
user.require(4);user.require(7);if(command==null)throw bad("Kalkulationsdaten fehlen.");token(command.requestId());
if(db.readTransaction(()->support.receipt(user,command.requestId(),List.of(id,command)))!=null)return orders.view(user,id);
var context=db.readTransaction(()->prepare(user,id,command,false));
var route=routing.resolve(user,context.request(),id(context.snapshot().job(),"hq_id_dispo"));
orders.transactional();
db.writeTransaction(()->{
lockHeadquarters(db,user);if(support.receipt(user,command.requestId(),List.of(id,command))!=null)return null;
var current=prepare(user,id,command,true);var q=calculate(user,current,route);
if(!fingerprint(command,q).equals(command.fingerprint()))throw bad("Die Preisvorschau hat sich geändert. Bitte neu berechnen.");
if(q.courierLimitExceeded()){user.require(39);if(!command.input().confirmCourierLimit())throw bad("Die Überschreitung der Frachtvergütungsgrenze muss bestätigt werden.");}
write(user,id,current,q);
new OrderChildRecalculation(db,orders,support).write(user,current.snapshot(),current.children(),command.input(),q,command.requestId());
metadata(id,"ng_calculation_input",support.encode(command.input()));
db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_calculation_input'",Long.toString(id(current.snapshot().job(),"jb_version")+1),id);
metadata(id,"ng_calculation_result",support.encode(q));
if(q.route()!=null)metadata(id,"ng_route_calculation",support.encode(q.route()));
support.audit(user,id,id(current.payer(),"cs_id"),id(current.snapshot().job(),"cr_id"),2,"Preis neu berechnet · "+command.input().pricing().mode()+" · Netto "+q.net()+" · Frachtvergütung "+q.courierNet(),command.requestId());
support.receipt(user,command.requestId(),List.of(id,command),id);return null;
});return orders.view(user,id);
}
void metadata(long id,String key,String value) {
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",id,key);
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname",key,"gdc_content",value,"gdc_context","RECALCULATE"));
}
void write(LegacyUser user,long id,Context context,Quote q) {
Request r=context.request();var job=context.snapshot().job();long payer=id(job,"csc_id_payer");
db.update("DELETE FROM tourservice WHERE jb_id=?",id);
int skip=r.pricing().mode()==PriceMode.SERVICES?r.services().size():0;
if(!q.batch()&&skip>0)component(id,payer,0,"Fixpreis","",q.components().stream().limit(skip).map(Component::net).reduce(BigDecimal.ZERO,BigDecimal::add),BigDecimal.ZERO);
if(!q.batch())for(var c:q.components().stream().skip(skip).toList())component(id,payer,c.sequence(),c.name(),c.type(),c.price(),c.discount());
if(r.pricing().mode()==PriceMode.ZONE&&!q.batch()){int n=0;for(var c:q.calculation())component(id,payer,++n,c.quantity().toPlainString()+" × "+c.name()+" à "+c.rate(),"z",BigDecimal.ZERO,BigDecimal.ZERO);}
db.update("DELETE FROM jobprice WHERE jb_id=? AND (mt_sort<11 OR mt_sort=15)",id);
price(id,r.pricing().courierMarkup()==null?1:9,q.courierMarkup());price(id,2,q.courierBase());price(id,3,q.courierService());price(id,5,q.courierDeduction());price(id,10,q.courierSubtotal());
if(r.pricing().markup()!=null)price(id,8,q.markup());if(positive(q.courierAgio()))price(id,7,q.courierAgio());
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_cost_split'",id);
new OrderEntryAccounting(db,catalog).split(id,q);
BigDecimal fixed=q.batch()?BigDecimal.ZERO:q.calculatedBase()!=null?q.calculatedBase():r.pricing().mode()==PriceMode.FIXED?r.pricing().fixedPrice():skip>0?q.components().stream().limit(skip).map(Component::subtotal).reduce(BigDecimal.ZERO,BigDecimal::add):BigDecimal.ZERO;
var update=fields("vht_id",r.vehicleTypeId(),"jb_payment",r.payment()==Payment.CASH?2:r.costSplit()?1:0,"jb_fixprice",fixed,"jb_serviceprice",q.components().stream().filter(c->c.name().equals("Servicepreis")).map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add),"jb_totalprice",q.net(),"jb_subtotalprice",q.subtotal(),"jb_cr_price",q.courierNet(),"jb_cr_subprice",q.courierBase(),"jb_markup",q.markup(),"jb_invmode",q.invoiceMode(),"jb_sales_tax_rate",q.taxRate(),"jb_sales_tax_rate_sign",q.taxSign(),"jb_version",id(job,"jb_version")+1,"jb_freetext_2",skip>0?new OrderEntryExtras(db,catalog).calculatorText(user,r,q):"");
// Repricing does not silently confirm unrelated missing references, emissions or customer checks.
if(q.batch())update.put("jb_incomplete",1);
BusinessSupport.update(db,"job","jb_id",id,update);
metadata(id,"jb_invtext_dirty","1");
if(id(job,"jb_status")==1)orders.command(user,id(job,"cr_id"),"5",Long.toString(id));
}
void component(long id,long payer,int sequence,String name,String type,BigDecimal price,BigDecimal discount) {
db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,?,0,?,0,?,?,?)",id,payer,sequence,name.substring(0,Math.min(50,name.length())),type.substring(0,Math.min(50,type.length())),price,discount);
}
void price(long id,int type,BigDecimal value) {db.update("INSERT INTO jobprice (jb_id,mt_sort,jbprc_price,jbprc_remark) VALUES (?,?,?,'')",id,type,value);}
}
@@ -0,0 +1,262 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.JsonNode;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
import static de.stadtbote.votian.OrderEntryCatalog.number;
import static de.stadtbote.votian.OrderIntegrationClient.unavailable;
/** Unsaved-tour counterpart of get_coords, get_zone_distance and foreign ROUND_KMS selection. */
@Service
public class OrderRoutingService {
private final DatabaseService db;
private final OrderEntryCatalog catalog;
private final OrderIntegrationClient client;
private final String pzmUrl,fallbackUrl,configuredKey,geocodeUrl;
private final Map<String,Cached> cache=Collections.synchronizedMap(new LinkedHashMap<>());
record Cached(Instant at,JsonNode value) {}
record Point(double latitude,double longitude) {
Point {if(!Double.isFinite(latitude)||!Double.isFinite(longitude)||Math.abs(latitude)>90||Math.abs(longitude)>180)throw unavailable("Ungültige Geokoordinaten.");}
String station(){return latitude+","+longitude;}
}
record Result(RouteInfo info,JsonNode zones) {}
public OrderRoutingService(DatabaseService db,OrderEntryCatalog catalog,OrderIntegrationClient client,
@Value("${app.routing.pzm-url:}") String pzmUrl,@Value("${app.routing.fallback-url:}") String fallbackUrl,
@Value("${app.routing.google-api-key:}") String apiKey,
@Value("${app.routing.geocode-url:https://maps.googleapis.com/maps/api/geocode/json}") String geocodeUrl) {
this.db=db;this.catalog=catalog;this.client=client;this.pzmUrl=pzmUrl;this.fallbackUrl=fallbackUrl;this.configuredKey=apiKey;this.geocodeUrl=geocodeUrl;
}
Result resolve(LegacyUser user,Request request,long domesticHq) {
boolean foreign=!request.stops().getFirst().country().equals("DE");
boolean zone=request.pricing().mode()==PriceMode.ZONE;
boolean automatic=zone || request.pricing().mode()==PriceMode.KILOMETER;
if(request.stops().size()==1 && !automatic)return null;
if(request.stops().size()==1)throw invalid("Eine automatische Streckenberechnung benötigt mindestens zwei Stationen.");
BigDecimal threshold=parameterNumber(0,"LONGHAUL_KM",BigDecimal.ZERO);
boolean detect=threshold.signum()>0 && (catalog.enabled(0,"LONGHAUL_ACTIVE") || automatic || foreign);
if(!foreign && !automatic && !detect)return null;
if(pzmUrl.isBlank() && catalog.parameter(0,"URL_PZM").isBlank())throw unavailable("Die Routenberechnung ist noch nicht konfiguriert (PZM_URL).");
List<Point> points=request.stops().stream().map(this::geocode).toList();
long dispatch=domesticHq;
List<HeadquartersRoute> compared=new ArrayList<>();
if(foreign) {
var branches=db.queryForList("SELECT hq_id,hq_mnemonic,hq_gps_lat,hq_gps_long FROM headquarters WHERE hq_gps_lat>0 AND hq_gps_long>0 ORDER BY hq_id");
if(branches.isEmpty())throw invalid("Für die Auslandsabholung fehlen die Niederlassungskoordinaten.");
Map<Long,Map<String,Object>> candidates=new LinkedHashMap<>();
for(Point end:List.of(points.getFirst(),points.getLast()))branches.stream()
.sorted(Comparator.comparingDouble(h -> squared(point(h),end))).limit(3).forEach(h -> candidates.put(id(h,"hq_id"),h));
for(var h:candidates.values()) {
var round=new ArrayList<Point>();round.add(point(h));round.addAll(points);round.add(point(h));
BigDecimal km=kilometers(distance(route(round,0,true,false)));
if(km.signum()<=0)throw unavailable("Eine Niederlassungsroute konnte nicht bestimmt werden. Bitte die Berechnung erneut versuchen.");
compared.add(new HeadquartersRoute(id(h,"hq_id"),text(h,"hq_mnemonic"),km));
}
compared.sort(Comparator.comparing(HeadquartersRoute::roundTripKm).thenComparingLong(HeadquartersRoute::id));
dispatch=compared.getFirst().id();
}
long pricingHq=catalog.enabled(0,"BWV_ACTIVE")?dispatch:user.headquartersId();
JsonNode zones=route(points,pricingHq,!zone,zone);
BigDecimal eligibilityKm=kilometers(zone?totalDistance(zones):distance(zones));
boolean eligible=detect && eligibilityKm.signum()>0 && eligibilityKm.compareTo(threshold.multiply(BigDecimal.valueOf(points.size()-1)))>=0;
boolean longhaul=eligible && request.longhaul()!=LonghaulMode.ASAP;
if(zone) {
if(longhaul) {
BigDecimal best=totalDistance(zones);
for(long candidate:routeHeadquarters(zones.path("poliline").asText())) {
if(candidate==pricingHq)continue;
JsonNode alternative=route(points,candidate,false,true);
if(totalDistance(alternative).compareTo(best)<0) {pricingHq=candidate;zones=alternative;best=totalDistance(alternative);}
}
} else zones=route(points,pricingHq,false,false);
}
var names=db.queryForList("SELECT hq_mnemonic FROM headquarters WHERE hq_id=?",dispatch);
String name=names.isEmpty()?Long.toString(dispatch):text(names.getFirst(),"hq_mnemonic");
BigDecimal calculation=distance(zones);
if(zone) {
calculation=calculation.add(groupDistance(zones,"extraApproachDistance"));
if(!(longhaul && catalog.enabled(pricingHq,"PZM_ROUNDTRIPKM")))calculation=calculation.add(groupDistance(zones,"extraDepartureCalculation"));
}
return new Result(new RouteInfo(kilometers(distance(zones)),kilometers(calculation),duration(zones),dispatch,name,pricingHq,
eligible,longhaul,List.copyOf(compared)),zones);
}
BigDecimal sustainabilityDistance(LegacyUser user,Request request) {
if(request.stops().size()<2)throw invalid("Für automatische Nachhaltigkeitskilometer werden mindestens zwei Stationen benötigt.");
return kilometers(distance(route(request.stops().stream().map(this::geocode).toList(),user.headquartersId(),true,false)));
}
private static final java.util.regex.Pattern COORDINATES=java.util.regex.Pattern.compile(".*\\((-?[0-9]{1,3}(?:\\.[0-9]+)?),\\s*(-?[0-9]{1,3}(?:\\.[0-9]+)?)\\)$");
static Point coordinates(String street) {
var matcher=COORDINATES.matcher(street);
if(!matcher.matches())return null;
return new Point(Double.parseDouble(matcher.group(1)),Double.parseDouble(matcher.group(2)));
}
MapAddress reverse(MapPoint point) {
var location=new Point(point.latitude().doubleValue(),point.longitude().doubleValue());
String suffix=" ("+BigDecimal.valueOf(location.latitude()).setScale(6,RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()+","+BigDecimal.valueOf(location.longitude()).setScale(6,RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()+")";
if(apiKey().isBlank())return new MapAddress("GPS"+suffix,"","","","","Der Kartenpunkt ist gewählt. Automatische Adressermittlung benötigt GOOGLE_MAPS_API_KEY. Bitte Land, PLZ, Ort und Hausnummer prüfen/ergänzen.");
JsonNode response=cached("geo:reverse:"+location.station(),() -> client.get(geocodeUrl+"?latlng="+encode(location.station())+"&language=de&key="+encode(apiKey()),"Die Adressermittlung"));
if(response.path("status").asText().equals("ZERO_RESULTS"))return new MapAddress("GPS"+suffix,"","","","","Für den Kartenpunkt wurde keine Adresse gefunden. Bitte die Adressfelder ergänzen.");
if(!response.path("status").asText().equals("OK") || !response.path("results").isArray() || response.path("results").isEmpty())throw unavailable("Die Adresse zum Kartenpunkt konnte nicht ermittelt werden.");
var parts=new HashMap<String,String>();
for(var component:response.path("results").get(0).path("address_components"))for(var type:component.path("types"))
parts.put(type.asText(),component.path(type.asText().equals("country")?"short_name":"long_name").asText());
String street=parts.getOrDefault("route","GPS");
street=street.substring(0,Math.min(street.length(),50-suffix.length()))+suffix;
String country=parts.getOrDefault("country","");
if(!country.isEmpty() && !country.matches("[A-Z]{2}"))throw unavailable("Die Adressermittlung lieferte kein gültiges Land.");
return new MapAddress(street,parts.getOrDefault("street_number",""),parts.getOrDefault("postal_code",""),parts.getOrDefault("locality",parts.getOrDefault("postal_town","")),country,"");
}
Point geocode(Station station) {
Point selected=coordinates(station.street());if(selected!=null)return selected;
// Cache is shared with PHP when the legacy geo schema is available, but never modified here.
var tables=db.queryForList("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='address_geo' AND TABLE_NAME IN ('address','address_geo')");
if(tables.size()==2) {
var rows=db.queryForList("""
SELECT g.adg_gps_lat,g.adg_gps_long,g.adg_modify FROM address_geo.address a
JOIN address_geo.address_geo g ON g.ad_id=a.ad_id
WHERE (a.ad_country=? OR (?='DE' AND a.ad_country='D')) AND a.ad_zipcode=? AND a.ad_city=?
AND a.ad_street=? AND g.adg_hsno=? AND g.adg_accuracy>=6 ORDER BY g.adg_modify DESC LIMIT 1
""",station.country(),station.country(),station.postalCode(),station.city(),station.street(),station.houseNumber());
if(!rows.isEmpty()) {
var row=rows.getFirst();LocalDateTime modified=dateTime(row,"adg_modify");
int days=parameterNumber(0,"GEOCACHE_LIFETIME",BigDecimal.valueOf(365)).intValue();
if(modified!=null && days>0 && modified.isAfter(LocalDateTime.now().minusDays(days)))
return new Point(number(row,"adg_gps_lat").doubleValue(),number(row,"adg_gps_long").doubleValue());
}
}
String address=station.street()+" "+station.houseNumber()+", "+station.postalCode()+" "+station.city()+", "+station.country();
String key=apiKey();if(key.isBlank())throw unavailable("Für neue Adressen fehlt die Konfiguration GOOGLE_MAPS_API_KEY.");
String cacheKey="geo:"+address;
JsonNode response=cached(cacheKey,() -> client.get(geocodeUrl+"?address="+encode(address)+"&components=country:"+station.country()+"&key="+encode(key),"Die Geokodierung"));
String status=response.path("status").asText();
if(!Set.of("OK","ZERO_RESULTS").contains(status))throw unavailable("Die Geokodierung ist momentan nicht verfügbar. Bitte Dienstkonfiguration und Kontingent prüfen.");
if(!status.equals("OK") || response.path("results").size()!=1)
throw invalid("Die Adresse in "+station.postalCode()+" "+station.city()+" ist nicht eindeutig geokodierbar. Bitte Straße und Hausnummer prüfen.");
var result=response.path("results").get(0);
if(result.path("partial_match").asBoolean() || !Set.of("ROOFTOP","RANGE_INTERPOLATED","GEOMETRIC_CENTER").contains(result.path("geometry").path("location_type").asText()))
throw invalid("Die Adresse in "+station.postalCode()+" "+station.city()+" ist für die Routenberechnung zu ungenau.");
boolean precise=false;for(var type:result.path("types"))if(Set.of("street_address","premise","subpremise","route","establishment","point_of_interest").contains(type.asText()))precise=true;
if(!precise)throw invalid("Die Geokodierung hat nur einen Ort oder PLZ-Bereich gefunden. Bitte die vollständige Stationsadresse prüfen.");
boolean country=false;for(var component:result.path("address_components"))for(var type:component.path("types"))
if(type.asText().equals("country") && component.path("short_name").asText().equals(station.country()))country=true;
if(!country)throw invalid("Das geokodierte Land stimmt nicht mit der Station überein.");
var location=result.path("geometry").path("location");
if(!location.path("lat").isNumber() || !location.path("lng").isNumber())throw unavailable("Die Geokodierung hat keine Koordinaten geliefert.");
return new Point(location.path("lat").asDouble(),location.path("lng").asDouble());
}
JsonNode route(List<Point> points,long hq,boolean distanceOnly,boolean longhaul) {
var payload=new LinkedHashMap<String,Object>();
payload.put("stations",points.stream().map(Point::station).toList());payload.put("hq_id",hq);payload.put("jb_id",0);
payload.put("traffic",false);payload.put("optimized",false);payload.put("distanceOnly",distanceOnly);
String shortest=catalog.parameter(hq,"PZM_SHORTEST");
payload.put("shortest",longhaul || shortest.equals("1"));payload.put("ridiculousShortest",!longhaul && shortest.equals("2"));
payload.put("roundTripKm",!longhaul && catalog.enabled(hq,"PZM_ROUNDTRIPKM"));payload.put("api_key",apiKey());
String url=pzmUrl.isBlank()?catalog.parameter(0,"URL_PZM"):pzmUrl;
if(url.isBlank())throw unavailable("Die Routenberechnung ist noch nicht konfiguriert (PZM_URL).");
String cacheKey=LegacyOrderPricing.hash(url+payload);
JsonNode result=cached(cacheKey,() -> {
try {return client.post(url,payload,"Die Routenberechnung");}
catch(org.springframework.web.server.ResponseStatusException e) {
if(fallbackUrl.isBlank())throw e;
return client.post(fallbackUrl,payload,"Die alternative Routenberechnung");
}
});
try {
distance(result);
if(!distanceOnly)for(String field:List.of("zoneDistance","extraApproachDistance","extraDepartureCalculation","eapLeg","edcLeg"))
if(!result.path(field).isArray())throw unavailable("Die Routenberechnung hat keine vollständigen Tarifzonen geliefert.");
return result;
}catch(org.springframework.web.server.ResponseStatusException failure){cache.remove(cacheKey);throw failure;}
}
private JsonNode cached(String key,java.util.function.Supplier<JsonNode> fetch) {
Cached saved=cache.get(key);if(saved!=null && saved.at().isAfter(Instant.now().minusSeconds(300)))return saved.value();
JsonNode value=fetch.get();
if(!key.startsWith("geo:") || value.path("status").asText().equals("OK"))
synchronized(cache) {if(cache.size()>=512)cache.remove(cache.keySet().iterator().next());cache.put(key,new Cached(Instant.now(),value));}
return value;
}
String apiKey(){String key=configuredKey.isBlank()?catalog.parameter(0,"GOOGLE_MAPS_API_KEY"):configuredKey;return key.split(",",-1)[0].trim();}
private static String encode(String value){return URLEncoder.encode(value,StandardCharsets.UTF_8);}
BigDecimal parameterNumber(long hq,String key,BigDecimal fallback) {
String value=catalog.parameter(hq,key);if(value.isBlank())return fallback;
try{return new BigDecimal(value.trim().replace(',','.'));}catch(NumberFormatException e){throw invalid("Der Bestandsparameter "+key+" ist ungültig.");}
}
static BigDecimal distance(JsonNode node) {return metric(node,"distance");}
static BigDecimal metric(JsonNode node,String name) {
JsonNode value=node.path(name);
if(!value.isNumber() || value.decimalValue().signum()<0 || value.decimalValue().compareTo(new BigDecimal("1000000000"))>0)
throw unavailable("Die Routenberechnung hat eine ungültige Entfernung geliefert.");
return value.decimalValue();
}
static BigDecimal groupDistance(JsonNode node,String field) {BigDecimal sum=BigDecimal.ZERO;for(var part:node.path(field))sum=sum.add(metric(part,"distance"));return sum;}
static BigDecimal totalDistance(JsonNode node) {
BigDecimal sum=distance(node);
for(String field:List.of("eapLeg","edcLeg"))for(var leg:node.path(field))sum=sum.add(metric(leg,"distanceMeters"));
return sum;
}
static BigDecimal kilometers(BigDecimal meters){return meters.divide(BigDecimal.valueOf(1000),2,RoundingMode.HALF_UP);}
static long duration(JsonNode node) {
String value=node.path("duration").asText("0s");
try {return new BigDecimal(value.replace("s","")).max(BigDecimal.ZERO).setScale(0,RoundingMode.HALF_UP).longValueExact();}
catch(ArithmeticException|NumberFormatException e){throw unavailable("Die Routenberechnung hat eine ungültige Fahrzeit geliefert.");}
}
private static Point point(Map<String,Object> h){return new Point(number(h,"hq_gps_lat").doubleValue(),number(h,"hq_gps_long").doubleValue());}
private static double squared(Point a,Point b){return Math.pow(a.latitude()-b.latitude(),2)+Math.pow(a.longitude()-b.longitude(),2);}
/** PHP samples the returned polyline every 2 km and checks the existing postal polygons. */
Set<Long> routeHeadquarters(String encoded) {
List<Point> points=decodePolyline(encoded);Set<Long> result=new TreeSet<>();double accumulated=0;
for(int i=1;i<points.size();i++) {
Point a=points.get(i-1),b=points.get(i);
double dlat=Math.toRadians(b.latitude()-a.latitude()),dlon=Math.toRadians(b.longitude()-a.longitude());
double hav=Math.pow(Math.sin(dlat/2),2)+Math.cos(Math.toRadians(a.latitude()))*Math.cos(Math.toRadians(b.latitude()))*Math.pow(Math.sin(dlon/2),2);
accumulated+=6371*2*Math.asin(Math.min(1,Math.sqrt(hav)));
if(accumulated<2)continue;accumulated%=2;
var polygons=db.queryForList("""
SELECT p.plzp_polygons,s.hq_id FROM phoenix_special.plz_geodb g
JOIN phoenix_special.plz_polygon p ON p.plzp_code=g.plzg_code
JOIN serviceplz s ON s.srvp_plz=g.plzg_code
WHERE ACOS(LEAST(1,GREATEST(-1,SIN(RADIANS(?))*SIN(RADIANS(g.plzg_breite))
+COS(RADIANS(?))*COS(RADIANS(g.plzg_breite))*COS(RADIANS(g.plzg_laenge)-RADIANS(?))))) * 6371 < 50
ORDER BY g.plzg_code
""",b.latitude(),b.latitude(),b.longitude());
for(var polygon:polygons)if(contains(text(polygon,"plzp_polygons"),b)){long id=id(polygon,"hq_id");if(id>0)result.add(id);break;}
}
return result;
}
static boolean contains(String polygons,Point point) {
for(String polygon:polygons.split("\\|")) {
var points=new ArrayList<Point>();
for(String coordinate:polygon.split(",")) {String[] pair=coordinate.trim().split("\\s+");if(pair.length!=2)throw unavailable("Ungültiges PLZ-Polygon im Bestand.");
try {points.add(new Point(Double.parseDouble(pair[1]),Double.parseDouble(pair[0])));}catch(NumberFormatException e){throw unavailable("Ungültiges PLZ-Polygon im Bestand.");}}
boolean inside=false;
for(int i=0,j=points.size()-1;i<points.size();j=i++) {Point a=points.get(i),b=points.get(j);
if((a.latitude()>point.latitude())!=(b.latitude()>point.latitude()) && point.longitude()<(b.longitude()-a.longitude())*(point.latitude()-a.latitude())/(b.latitude()-a.latitude())+a.longitude())inside=!inside;}
if(inside)return true;
}
return false;
}
static List<Point> decodePolyline(String encoded) {
if(encoded.isBlank())throw unavailable("Für die Fernverkehrsoptimierung fehlt der Routenverlauf.");
List<Point> result=new ArrayList<>();int index=0;long lat=0,lon=0;
try {
while(index<encoded.length()) {
long[] delta=new long[2];
for(int k=0;k<2;k++) {int shift=0,bits;long value=0;
do {bits=encoded.charAt(index++)-63;if(bits<0||bits>63||shift>30)throw new IllegalArgumentException();value|=(long)(bits&31)<<shift;shift+=5;}while(bits>=32);
delta[k]=(value&1)!=0?~(value>>1):value>>1;}
lat+=delta[0];lon+=delta[1];result.add(new Point(lat/1e5,lon/1e5));
if(result.size()>50000)throw new IllegalArgumentException();
}
}catch(IndexOutOfBoundsException|IllegalArgumentException e){throw unavailable("Die Routenberechnung hat einen ungültigen Routenverlauf geliefert.");}
return result;
}
}
@@ -0,0 +1,79 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import java.math.*;
import java.util.*;
import java.time.Instant;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryCatalog.*;
import static de.stadtbote.votian.OrderEntryService.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.LegacyOrderPricing.hash;
/** co2e_sendReq.php contract: data[referenz,mlp,gewicht,gefahreneKm] -> data[referenz,co2]. */
@Service
public class OrderSustainability {
private final OrderEntryCatalog catalog;private final OrderRoutingService routing;private final OrderIntegrationClient client;
private final String url,username,password;
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
private record Cached(Instant expires,SustainabilityResult value) {}
private final Map<String,Cached> cache=new java.util.concurrent.ConcurrentHashMap<>();
public OrderSustainability(OrderEntryCatalog catalog,OrderRoutingService routing,OrderIntegrationClient client,
@Value("${app.sustainability.url:}") String url,@Value("${app.sustainability.username:}") String username,@Value("${app.sustainability.password:}") String password) {
this.catalog=catalog;this.routing=routing;this.client=client;this.url=url;this.username=username;this.password=password;
}
private record Input(BigDecimal km,BigDecimal weight,BigDecimal mlp,String evidence,String fingerprint) {}
private Input input(LegacyUser user,Request request,Prepared prepared,BigDecimal km) {
if(request.mode()!=Mode.RETROSPECTIVE)throw invalid("Nachhaltigkeitswerte können bei abgeschlossenen Nacherfassungen erfasst werden.");
if(km==null || km.signum()<=0 || km.compareTo(new BigDecimal("999999.99"))>0)throw invalid("Für Nachhaltigkeitsdaten fehlen gültige gefahrene Kilometer.");
BigDecimal weight=request.weight()!=null && request.weight().signum()>0?request.weight():number(prepared.vehicle(),"crvh_payload").multiply(new BigDecimal("0.5"));
int badge=(int)id(prepared.vehicle(),"crvh_env_badge_level");
String[] small={"0.13","0.13","0.1255","0.133","0.134","0.1285","0.13"},large={"0.13","0.1755","0.17","0.181","0.1795","0.1715","0.1755"};
BigDecimal mlp=new BigDecimal((id(prepared.vehicle(),"vht_id")<=7?small:large)[badge>=1 && badge<=6?badge:0]);
String evidence="T="+weight+"|L="+factor(user,"CO2_FORMULAR_L","1.2")+"|KM="+km+"|MLP="+mlp+"|U="+factor(user,"CO2_FORMULAR_U","3.15")+"|TF="+factor(user,"CO2_FORMULAR_TF","1000");
return new Input(km,weight,mlp,evidence,hash(user.headquartersId()+"|"+request.courierVehicleId()+"|"+request.stops()+"|"+evidence));
}
private String factor(LegacyUser user,String key,String fallback) {
String value=catalog.parameter(user.headquartersId(),key);if(value==null || value.isBlank())value=catalog.parameter(0,key);
if(value==null || value.isBlank())return fallback;
try {var number=new BigDecimal(value);return number.signum()>0?number.toPlainString():fallback;}catch(NumberFormatException e){return fallback;}
}
SustainabilityResult resolve(LegacyUser user,Request request,Prepared prepared,OrderRoutingService.Result route) {
var choice=request.sustainability();if(choice==null)return null;
if(request.mode()!=Mode.RETROSPECTIVE)throw invalid("Nachhaltigkeitswerte können nur bei Nacherfassungen erfasst werden.");
if(choice.mode()==SustainabilityMode.MANUAL) {
if(choice.distanceKm()==null || choice.co2eKg()==null)throw invalid("Bitte gefahrene Kilometer und CO₂e für die manuelle Erfassung angeben.");
return new SustainabilityResult(choice.distanceKm(),choice.co2eKg(),"Manuell","m",hash(choice.toString()));
}
if(choice.distanceKm()!=null || choice.co2eKg()!=null)throw invalid("Automatische und manuelle Nachhaltigkeitswerte können nicht kombiniert werden.");
if(url.isBlank() || username.isBlank() || password.isBlank())throw OrderIntegrationClient.unavailable("Der CO₂e-Dienst ist noch nicht konfiguriert (CO2_URL, CO2_USERNAME, CO2_PASSWORD). Alternativ geprüfte Werte manuell erfassen.");
BigDecimal km=request.distanceKm()!=null && request.distanceKm().signum()>0?request.distanceKm():route!=null?route.info().distanceKm():routing.sustainabilityDistance(user,request);
var input=input(user,request,prepared,km);
if(input.weight().signum()<=0)throw invalid("Bitte Sendungsgewicht oder Fahrzeugnutzlast für die CO₂e-Berechnung hinterlegen.");
String key=hash(request.requestId()+"|"+input.fingerprint());var cached=cache.get(key);
if(cached!=null && cached.expires().isAfter(Instant.now()))return cached.value();
// Calculation reference is request-scoped; no order exists yet and no booking is sent.
long reference=Long.parseLong(hash(request.requestId()).substring(0,7),16);
var payload=Map.of("data",List.of(Map.of("referenz",reference,"mlp",input.mlp(),"gewicht",input.weight(),"gefahreneKm",input.km())));
var response=client.postAuthenticated(url,payload,"Der CO₂e-Dienst",username,password);
if(response==null || response.hasNonNull("errors") && !response.path("errors").isEmpty() || !response.path("data").isArray() || response.path("data").size()!=1)
throw OrderIntegrationClient.unavailable("Der CO₂e-Dienst hat kein eindeutiges Ergebnis geliefert.");
var item=response.path("data").get(0);BigDecimal co2;
try {co2=new BigDecimal(item.path("co2").asText()).setScale(2,RoundingMode.HALF_UP);}catch(Exception e){throw OrderIntegrationClient.unavailable("Der CO₂e-Dienst hat keinen gültigen Emissionswert geliefert.");}
if(!Long.toString(reference).equals(item.path("referenz").asText()) || co2.signum()<=0 || co2.compareTo(new BigDecimal("999999.99"))>0)
throw OrderIntegrationClient.unavailable("Der CO₂e-Dienst lieferte einen ungültigen oder fremden Berechnungsbeleg.");
var result=new SustainabilityResult(km,co2,"CO₂e-Dienst",input.evidence(),input.fingerprint());
cache.entrySet().removeIf(e -> e.getValue().expires().isBefore(Instant.now()));if(cache.size()>=500)cache.clear();
cache.put(key,new Cached(Instant.now().plusSeconds(300),result));return result;
}
Quote apply(LegacyUser user,Request request,Prepared prepared,Quote quote,SustainabilityResult value) {
if(value==null)return quote;
if(request.sustainability().mode()==SustainabilityMode.AUTO && !input(user,request,prepared,value.distanceKm()).fingerprint().equals(value.inputFingerprint()))
throw conflict("Fahrzeug- oder Nachhaltigkeitsdaten haben sich geändert. Bitte neu berechnen.");
ObjectNode tree=json.valueToTree(quote);tree.set("sustainability",json.valueToTree(value));tree.put("fingerprint",hash(tree.toString()));
try{return json.treeToValue(tree,Quote.class);}catch(Exception e){throw new IllegalStateException(e);}
}
}
@@ -0,0 +1,196 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.dao.DataAccessException;
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.util.*;
import static de.stadtbote.votian.AdminSupport.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Native port of groupware/report.php. Reports are MyISAM: each mutation is one CAS statement. */
@Service
public class ReportAdministration {
static final String TABLE = "`phoenix_group`.report_process";
private static final String COLUMNS = "r.rp_id,r.hq_id,r.rp_objtype,r.rp_objid,r.usr_id,r.rp_reporttype,r.rp_text,r.rp_confidential,r.rp_createtime";
private static final List<String> REVISION_KEYS = List.of("rp_id","hq_id","rp_objtype","rp_objid","usr_id","rp_reporttype","rp_text","rp_confidential","rp_createtime");
private final DatabaseService db;
private final OrderEntryCatalog catalog;
public ReportAdministration(DatabaseService db, OrderEntryCatalog catalog) { this.db=db; this.catalog=catalog; }
enum Kind {
CUSTOMER("cs", "customer", "report_type", 0), COURIER("cr", "courier", "report_type_2", 1);
final String prefix, table, type; final int right;
Kind(String prefix,String table,String type,int right) { this.prefix=prefix;this.table=table;this.type=type;this.right=right; }
String objectLabel() { return "CONCAT_WS(' ',o."+prefix+"_eid,c.cmp_comp,c.cmp_comp2)"; }
}
enum Sort { DATE, ID, HEADQUARTERS, OBJECT, AUTHOR, TEXT, TYPE }
record Query(LocalDate from,LocalDate to,List<Long> headquarters,Long objectId,Integer type,String search,int page,Sort sort,boolean descending,boolean splitType,boolean splitCustomer) {}
record Save(String revision,long objectId,int type,String text,boolean confidential) {}
record Sql(String fromWhere,Object[] args) {}
private void access(LegacyUser user,Kind kind) { user.require(16);user.require(kind.right); }
public Map<String,Object> options(LegacyUser user,Kind kind) {
access(user,kind);
LocalDate today=LocalDate.now(ZoneId.of("Europe/Berlin"));
return fields("types",types(kind.type),"headquarters",headquarters(user),"currentHeadquarters",user.headquartersId(),
"from",LocalDate.of(today.getYear(),today.getMonthValue()<7?1:7,1),"to",today,
"miscType",catalog.parameter(0,"MASK_CS_REPORTTYPE_MISC_ITEM"),
"canCreateAppointments",kind==Kind.CUSTOMER&&user.hasRight(11),"completeAppointments",kind==Kind.CUSTOMER&&catalog.enabled(0,"MASK_RP_ENABLE_DEACTIVATION_APPOINTMENT_WARNING_OF_SAME_DAY"));
}
private List<Map<String,Object>> types(String type) {
// Aggregate duplicate legacy labels without multiplying report/statistic rows.
return db.queryForList("SELECT mt_sort AS id,MIN(mt_value) AS label FROM metatype WHERE mt_type=? GROUP BY mt_sort ORDER BY label,mt_sort",type);
}
List<Map<String,Object>> 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<Long> scope(LegacyUser user,List<Long> requested) {
if(requested==null||requested.isEmpty()||requested.size()>100||requested.stream().anyMatch(Objects::isNull))throw bad("Bitte mindestens eine gültige 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("Eine Niederlassung gehört nicht zum freigegebenen Mandanten.");
return ids;
}
private static String placeholders(int count) { return String.join(",",Collections.nCopies(count,"?")); }
public List<Map<String,Object>> objects(LegacyUser user,Kind kind,List<Long> requested,String search) {
access(user,kind);var hqs=scope(user,requested);
if(search==null||search.length()>100)throw bad("Bitte höchstens 100 Suchzeichen eingeben.");
var args=new ArrayList<Object>(hqs);args.add("%"+search.strip()+"%");
return db.queryForList("SELECT o."+kind.prefix+"_id AS id,"+kind.objectLabel()+" AS label,h.hq_mnemonic AS headquarters FROM "+kind.table+" o LEFT JOIN company c ON c.cmp_id=o.cmp_id JOIN headquarters h ON h.hq_id=o.hq_id WHERE o.hq_id IN ("+placeholders(hqs.size())+") AND "+kind.objectLabel()+" LIKE ? ORDER BY label,id LIMIT 50",args.toArray());
}
private void object(LegacyUser user,Kind kind,long objectId) {
if(objectId<=0)throw bad("Bitte einen Kunden oder Transporteur wählen.");
var hqs=headquarters(user).stream().map(r->id(r,"id")).toList();
if(hqs.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben.");
var args=new ArrayList<Object>(List.of(objectId));args.addAll(hqs);
one(db.queryForList("SELECT "+kind.prefix+"_id FROM "+kind.table+" WHERE "+kind.prefix+"_id=? AND hq_id IN ("+placeholders(hqs.size())+")",args.toArray()));
}
static void validate(Query q,Kind kind) {
if(q==null||q.sort()==null)throw bad("Berichtsfilter fehlen.");
range(q.from(),q.to());
if(q.from().getYear()<1000||q.to().getYear()>9998)throw bad("Bitte einen Zeitraum zwischen den Jahren 1000 und 9998 wählen.");
if(q.page()<0||q.page()>1_000_000||q.search()==null||q.search().length()>200||q.objectId()!=null&&q.objectId()<=0||q.type()!=null&&(q.type()<0||q.type()>127))throw bad("Ungültiger Berichtsfilter.");
if(kind==Kind.COURIER&&q.splitCustomer())throw bad("Die Aufteilung nach Kundentyp ist nur für Kundenberichte möglich.");
}
private Sql selection(LegacyUser user,Kind kind,Query q) {
validate(q,kind);var hqs=scope(user,q.headquarters());
if(q.objectId()!=null)object(user,kind,q.objectId());
var args=new ArrayList<Object>();args.add(kind.prefix);args.addAll(hqs);args.add(q.from().atStartOfDay());args.add(q.to().plusDays(1).atStartOfDay());
String where="r.rp_objtype=? AND r.hq_id IN ("+placeholders(hqs.size())+") AND r.rp_createtime>=? AND r.rp_createtime<?";
if(!user.hasRight(10)){where+=" AND (r.rp_confidential=0 OR r.usr_id=?)";args.add(user.userId());}
if(q.objectId()!=null) {
where+=kind==Kind.CUSTOMER?" AND (r.rp_objid=? OR o.cs_id_parent=?)":" AND r.rp_objid=?";
args.add(q.objectId());if(kind==Kind.CUSTOMER)args.add(q.objectId());
}
if(q.type()!=null){where+=" AND r.rp_reporttype=?";args.add(q.type());}
if(!q.search().isBlank()){where+=" AND r.rp_text LIKE ?";args.add("%"+q.search().strip()+"%");}
return new Sql(joins(kind)+" WHERE "+where,args.toArray());
}
private static String joins(Kind kind) {
return " FROM "+TABLE+" r LEFT JOIN "+kind.table+" o ON o."+kind.prefix+"_id=r.rp_objid LEFT JOIN company c ON c.cmp_id=o.cmp_id LEFT JOIN user u ON u.usr_id=r.usr_id LEFT JOIN headquarters h ON h.hq_id=r.hq_id";
}
public Map<String,Object> list(LegacyUser user,Kind kind,Query q) {
access(user,kind);
return db.readTransaction(()->{
var sql=selection(user,kind,q);var typeLabels=labels(types(kind.type));
String sort=switch(q.sort()){case DATE->"r.rp_createtime";case ID->"r.rp_id";case HEADQUARTERS->"h.hq_mnemonic";case OBJECT->"object_label";case AUTHOR->"u.usr_name,u.usr_firstname";case TEXT->"r.rp_text";case TYPE->"r.rp_reporttype";};
String direction=q.descending()?" DESC":" ASC";sort=String.join(direction+",",sort.split(","))+direction;
var args=new ArrayList<Object>(Arrays.asList(sql.args()));args.add(q.page()*100);
var rows=db.queryForList("SELECT "+COLUMNS+","+kind.objectLabel()+" AS object_label,h.hq_mnemonic AS headquarters,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS author,u.usr_phone AS phone"+sql.fromWhere()+" ORDER BY "+sort+",r.rp_id"+direction+" LIMIT 100 OFFSET ?",args.toArray());
long total=id(one(db.queryForList("SELECT COUNT(*) AS total"+sql.fromWhere(),sql.args())),"total");
return fields("items",rows.stream().map(r->view(user,r,typeLabels)).toList(),"total",total,"page",q.page(),"pageSize",100);
});
}
public Map<String,Object> statistics(LegacyUser user,Kind kind,Query q) {
access(user,kind);
return db.readTransaction(()->{
var sql=selection(user,kind,q);
String extras=(q.splitType()?",r.rp_reporttype":"")+(q.splitCustomer()?",c.cmp_type":"");
var rows=db.queryForList("SELECT r.hq_id,r.usr_id,MIN(h.hq_mnemonic) AS headquarters,MIN(CONCAT_WS(' ',u.usr_firstname,u.usr_name)) AS author"+extras+",COUNT(*) AS total"+sql.fromWhere()+" GROUP BY r.hq_id,r.usr_id"+extras+" ORDER BY headquarters,author,r.usr_id"+extras+" LIMIT 5001",sql.args());
if(rows.size()>5000)throw bad("Mehr als 5000 Statistikzeilen. Bitte den Zeitraum oder die Aufteilung eingrenzen.");
var reportTypes=labels(types(kind.type));var customerTypes=q.splitCustomer()?labels(types("customer_type")):Map.<Long,String>of();
return fields("total",rows.stream().mapToLong(r->id(r,"total")).sum(),"rows",rows.stream().map(r->fields("headquarters",text(r,"headquarters"),"author",author(r),"type",q.splitType()?label(reportTypes,id(r,"rp_reporttype")):"", "customerType",q.splitCustomer()?label(customerTypes,id(r,"cmp_type")):"","total",id(r,"total"))).toList());
});
}
private static Map<Long,String> labels(List<Map<String,Object>> types) { var result=new HashMap<Long,String>();for(var t:types)result.put(id(t,"id"),text(t,"label"));return result; }
private static String label(Map<Long,String> labels,long id) { return labels.getOrDefault(id,id==0?"Ohne Berichtstyp":"Typ "+id); }
private static String author(Map<String,Object> row) { String name=text(row,"author");return name.isBlank()?"Nutzer gelöscht ("+id(row,"usr_id")+")":name; }
static Map<String,Object> version(Map<String,Object> row) { var result=new LinkedHashMap<String,Object>();for(String key:REVISION_KEYS)result.put(key,value(row,key));return result; }
private Map<String,Object> view(LegacyUser user,Map<String,Object> row,Map<Long,String> types) {
return fields("id",id(row,"rp_id"),"headquarters",text(row,"headquarters"),"objectId",id(row,"rp_objid"),"objectLabel",text(row,"object_label"),"author",author(row),"phone",text(row,"phone"),
"createdAt",dateTime(row,"rp_createtime"),"text",text(row,"rp_text"),"type",id(row,"rp_reporttype"),"typeLabel",label(types,id(row,"rp_reporttype")),"confidential",id(row,"rp_confidential")!=0,
"canEdit",id(row,"usr_id")==user.userId()||user.hasRight(3),"canDelete",id(row,"usr_id")==user.userId(),"revision",revision(version(row)));
}
Map<String,Object> report(LegacyUser user,Kind kind,long reportId) {
access(user,kind);
var hqs=headquarters(user).stream().map(r->id(r,"id")).toList();
if(hqs.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben.");
var args=new ArrayList<Object>(List.of(reportId,kind.prefix));args.addAll(hqs);
String where="r.rp_id=? AND r.rp_objtype=? AND r.hq_id IN ("+placeholders(hqs.size())+")";
if(!user.hasRight(10)){where+=" AND (r.rp_confidential=0 OR r.usr_id=?)";args.add(user.userId());}
return one(db.queryForList("SELECT "+COLUMNS+" FROM "+TABLE+" r WHERE "+where,args.toArray()));
}
private String validText(Save request,Kind kind) {
if(request==null)throw bad("Bericht fehlt.");
String text=required(request.text(),65_535,"Berichtstext");
if(text.getBytes(StandardCharsets.UTF_8).length>65_535)throw bad("Der Berichtstext ist zu lang (höchstens 65535 UTF-8-Bytes).");
if(request.type()<0||request.type()>127||request.type()!=0&&types(kind.type).stream().noneMatch(r->id(r,"id")==request.type()))throw bad("Bitte einen vorhandenen Berichtstyp wählen.");
return text;
}
public Map<String,Object> create(LegacyUser user,Kind kind,Save request) {
access(user,kind);String text=validText(request,kind);object(user,kind,request.objectId());
boolean complete=kind==Kind.CUSTOMER&&catalog.enabled(0,"MASK_RP_ENABLE_DEACTIVATION_APPOINTMENT_WARNING_OF_SAME_DAY");
var at=LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0);
// No fallible read or second write follows the MyISAM INSERT in this transaction.
long reportId=db.writeTransaction(()->db.insert(TABLE,fields("hq_id",user.headquartersId(),"usr_id",user.userId(),"rp_objtype",kind.prefix,"rp_objid",request.objectId(),"rp_reporttype",request.type(),"rp_text",text,"rp_confidential",request.confidential()?1:0,"rp_createtime",at)));
String warning="";
if(complete)try { completeAppointments(request.objectId(),user.headquartersId(),at.toLocalDate()); }
catch(DataAccessException e) { warning="Der Bericht wurde gespeichert. Terminerinnerungen konnten noch nicht abgeschlossen werden. Bitte den Abschluss erneut versuchen."; }
return fields("id",reportId,"warning",warning);
}
public Map<String,Object> save(LegacyUser user,Kind kind,long reportId,Save request) {
access(user,kind);String text=validText(request,kind);
db.writeTransaction(()->{
var old=report(user,kind,reportId);
if(id(old,"usr_id")!=user.userId())user.require(3);
if(request.objectId()!=id(old,"rp_objid"))throw bad("Die Zuordnung eines Berichts kann nicht geändert werden.");
unchanged(version(old),request.revision());
var args=new ArrayList<Object>(List.of(request.type(),text,request.confidential()?1:0));args.addAll(casArgs(old));
checkChanged(db.update("UPDATE "+TABLE+" SET rp_reporttype=?,rp_text=?,rp_confidential=? WHERE "+cas(),args.toArray()));return null;
});
return fields("id",reportId,"warning","");
}
public void remove(LegacyUser user,Kind kind,long reportId,String expected) {
access(user,kind);
db.writeTransaction(()->{
var old=report(user,kind,reportId);
if(id(old,"usr_id")!=user.userId())throw new AccessDeniedException("Nur der Verfasser darf den Bericht löschen.");
unchanged(version(old),expected);
checkChanged(db.update("DELETE FROM "+TABLE+" WHERE "+cas(),casArgs(old).toArray()));return null;
});
}
// FOR UPDATE cannot lock MyISAM rows. Compare every mutable/scoping value, with binary text comparison.
private static String cas() { return "rp_id=? AND hq_id=? AND rp_objtype=? AND rp_objid=? AND usr_id=? AND rp_reporttype=? AND BINARY rp_text <=> BINARY ? AND rp_confidential=? AND rp_createtime <=> ?"; }
private static List<Object> casArgs(Map<String,Object> row) { var args=new ArrayList<Object>();for(String key:REVISION_KEYS)args.add(value(row,key));return args; }
private static void checkChanged(int rows) { if(rows!=1)throw new ResponseStatusException(HttpStatus.CONFLICT,"Der Bericht wurde zwischenzeitlich geändert oder gelöscht. Bitte neu laden."); }
public int retryAppointments(LegacyUser user,Kind kind,long reportId) {
var report=report(user,kind,reportId);
if(kind!=Kind.CUSTOMER||!catalog.enabled(0,"MASK_RP_ENABLE_DEACTIVATION_APPOINTMENT_WARNING_OF_SAME_DAY"))throw bad("Der automatische Terminabschluss ist nicht aktiviert.");
if(id(report,"usr_id")!=user.userId())throw new AccessDeniedException("Nur der Verfasser darf den Terminabschluss wiederholen.");
LocalDateTime at=dateTime(report,"rp_createtime");if(at==null)throw bad("Der Bericht enthält kein gültiges Erfassungsdatum.");
return completeAppointments(id(report,"rp_objid"),id(report,"hq_id"),at.toLocalDate());
}
private int completeAppointments(long objectId,long hq,LocalDate day) {
// Independently repeatable side effect. Never match courier ids against customer appointments.
return db.writeTransaction(()->db.update("UPDATE `phoenix_group`.appointment SET ap_category_4=2 WHERE cs_id=? AND hq_id=? AND ap_execdate>=? AND ap_execdate<? AND ap_category_4=1",objectId,hq,day.atStartOfDay(),day.plusDays(1).atStartOfDay()));
}
}
@@ -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.ReportAdministration.*;
@RestController
@RequestMapping("/api/administration/reports/{kind}")
public class ReportController {
private final ReportAdministration reports;
public ReportController(ReportAdministration reports) { this.reports=reports; }
@GetMapping("/options") Map<String,Object> options(Authentication auth,@PathVariable Kind kind) { return reports.options(user(auth),kind); }
@GetMapping("/objects") List<Map<String,Object>> objects(Authentication auth,@PathVariable Kind kind,@RequestParam List<Long> headquarters,@RequestParam(defaultValue="") String q) { return reports.objects(user(auth),kind,headquarters,q); }
@PostMapping("/query") Map<String,Object> list(Authentication auth,@PathVariable Kind kind,@RequestBody Query query) { return reports.list(user(auth),kind,query); }
@PostMapping("/statistics") Map<String,Object> statistics(Authentication auth,@PathVariable Kind kind,@RequestBody Query query) { return reports.statistics(user(auth),kind,query); }
@PostMapping Map<String,Object> create(Authentication auth,@PathVariable Kind kind,@RequestBody Save request) { return reports.create(user(auth),kind,request); }
@PutMapping("/{id}") Map<String,Object> save(Authentication auth,@PathVariable Kind kind,@PathVariable long id,@RequestBody Save request) { return reports.save(user(auth),kind,id,request); }
record Revision(String revision) {}
@DeleteMapping("/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void remove(Authentication auth,@PathVariable Kind kind,@PathVariable long id,@RequestBody Revision request) { reports.remove(user(auth),kind,id,request.revision()); }
@PostMapping("/{id}/complete-appointments") Map<String,Integer> appointments(Authentication auth,@PathVariable Kind kind,@PathVariable long id) { return Map.of("completed",reports.retryAppointments(user(auth),kind,id)); }
}
@@ -0,0 +1,154 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.jsoup.Jsoup;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
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.CrmScope.marks;
/** Report-bound PHP forms: IDs are report IDs, never newly allocated contact IDs. */
@Service
class ReportForms {
private final DatabaseService db;
private final ReportAdministration reports;
private final FormAdministration forms;
private final NewsletterTemplate templates;
private static final Set<String> EDITABLE=Set.of("varchar","text","checkbox","phone_business","phone_private","email_business","email_private","date","birthdate","time","select:crvh:crvh_sid");
record Save(String revision,String schemaRevision,String reportRevision,Map<Long,String> values) {}
ReportForms(DatabaseService db,ReportAdministration reports,FormAdministration forms,NewsletterTemplate templates) {
this.db=db;this.reports=reports;this.forms=forms;this.templates=templates;
}
private void category(long category) {if(category!=101&&category!=102)throw bad("Unbekanntes Berichtsformular.");}
private List<Map<String,Object>> definitions(long category,long scope) {
var defs=db.queryForList(MetafieldRecords.definitionsSql(category),scope);
if(defs.isEmpty()||defs.size()>500)throw bad("Keine verwendbare Feldzuordnung für dieses Formular eingerichtet.");
return defs;
}
private List<Map<String,Object>> raw(long report,List<Map<String,Object>> defs) {
var args=new ArrayList<Object>(List.of(report));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 static boolean editable(Map<String,Object> d) {return EDITABLE.contains(text(d,"type"));}
private static boolean canEdit(LegacyUser u,Map<String,Object> report) {return id(report,"usr_id")==u.userId()||u.hasRight(3);}
List<Map<String,Object>> options(LegacyUser u,ReportAdministration.Kind kind,long report) {
reports.report(u,kind,report);
return db.queryForList("SELECT mtfc_id AS id,mtfc_description AS label FROM metafieldcategory WHERE mtfc_id IN (101,102) ORDER BY mtfc_id");
}
Map<String,Object> get(LegacyUser u,ReportAdministration.Kind kind,long report,long category) {
category(category);
return db.readTransaction(()->{
var owner=reports.report(u,kind,report);long scope=forms.fieldHeadquarters(u,category);
var defs=definitions(category,scope);var rows=raw(report,defs);var stored=MetafieldRecords.values(rows);
var template=one(db.queryForList("SELECT c.mtfc_description,t.mtft_content FROM metafieldcategory c LEFT JOIN metafieldtemplate t ON t.mtft_id=c.mtft_id WHERE c.mtfc_id=?",category));
var computed=automatic(u,kind,owner,defs);var display=new LinkedHashMap<Long,String>(stored);display.putAll(computed);
var schema=defs.stream().map(d->{var field=new LinkedHashMap<>(d);field.put("label",Jsoup.parse(text(d,"label")).text());field.put("editable",editable(d));return field;}).toList();
var printDefs=defs.stream().map(d->{var field=new LinkedHashMap<>(d);if(text(d,"type").equals("sys:current_headquarters_company_logo"))field.put("type","image");return (Map<String,Object>)field;}).toList();
var rendering=fields("data",fields("values",display),"definitions",printDefs,"template",text(template,"mtft_content"));
String html=templates.render(rendering);
var layoutValues=new LinkedHashMap<Long,String>(display);
var layoutDefs=printDefs.stream().map(d->{var f=new LinkedHashMap<>(d);if(editable(d)){f.put("type","varchar");layoutValues.put(id(d,"id"),"NGFORMFIELD"+id(d,"id")+"END");}return (Map<String,Object>)f;}).toList();
String layout=templates.render(fields("data",fields("values",layoutValues),"definitions",layoutDefs,"template",text(template,"mtft_content")));
return fields("id",report,"category",category,"title",text(template,"mtfc_description"),"canEdit",canEdit(u,owner),"values",stored,"displayValues",display,
"fields",schema,"revision",revision(rows),"schemaRevision",revision(defs),"reportRevision",revision(ReportAdministration.version(owner)),"html",html,"layout",layout);
});
}
Map<String,Object> save(LegacyUser u,ReportAdministration.Kind kind,long report,long category,Save s) {
category(category);if(s==null||s.values()==null||s.values().size()>500)throw bad("Formularwerte fehlen.");
var owner=reports.report(u,kind,report);if(!canEdit(u,owner))throw new AccessDeniedException("Dieses Berichtsformular darf nur der Verfasser oder ein berechtigter Bearbeiter ändern.");
unchanged(ReportAdministration.version(owner),s.reportRevision());
long scope=forms.fieldHeadquarters(u,category);var defs=definitions(category,scope);unchanged(defs,s.schemaRevision());
var keys=defs.stream().map(d->id(d,"id")).toList();if(!keys.containsAll(s.values().keySet()))throw bad("Unbekanntes Formularfeld.");
var before=MetafieldRecords.values(raw(report,defs));var next=new LinkedHashMap<Long,String>();
for(var d:defs) {
long key=id(d,"id");String old=before.getOrDefault(key,"");String v=Objects.requireNonNullElse(s.values().getOrDefault(key,old),"");
if(!editable(d)){if(!v.equals(old))throw bad(text(d,"label")+": Automatische Felder können nicht überschrieben werden.");continue;}
if(!v.equals(old))validate(u,id(owner,"hq_id"),d,v);
next.put(key,v);
}
db.reportFormWriteTransaction(()->{
var currentOwner=reports.report(u,kind,report);if(!canEdit(u,currentOwner))throw new AccessDeniedException("Keine Bearbeitungsberechtigung.");
unchanged(ReportAdministration.version(currentOwner),s.reportRevision());unchanged(definitions(category,scope),s.schemaRevision());
var current=raw(report,defs);var currentValues=MetafieldRecords.values(current);
// A lost acknowledgement may safely repeat exactly the already stored values.
if(next.entrySet().stream().allMatch(e->e.getValue().equals(currentValues.getOrDefault(e.getKey(),""))))return null;
unchanged(current,s.revision());
for(var d:defs)if(editable(d)) {
long key=id(d,"id");String v=next.get(key);if(v.equals(currentValues.getOrDefault(key,"")))continue;
db.update("DELETE FROM metafieldvalue WHERE mtfck_id=? AND mtfv_id=?",key,report);
db.update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",key,report,v);
if(id(d,"history")==1)db.update("INSERT INTO metafieldvaluehistory (mtfck_id,mtfv_id,mtfv_value,mtfvh_createtime) VALUES (?,?,?,?)",key,report,v,LocalDateTime.now(ZoneId.of("Europe/Berlin")));
}
return null;
});
return fields("id",report);
}
private void validate(LegacyUser u,long hq,Map<String,Object> def,String value) {
if(value.getBytes(StandardCharsets.UTF_8).length>65535)throw bad(text(def,"label")+": Eingabe ist zu lang.");
String type=text(def,"type");if(value.isEmpty())return;
switch(type) {
case "checkbox" -> {if(!value.equals("1"))throw bad("Ungültiger Checkbox-Wert.");}
case "date","birthdate" -> EvaluationService.parseDate(value);
case "time" -> {if(!value.matches("(?:[01][0-9]|2[0-3]):[0-5][0-9]"))throw bad("Uhrzeit bitte als HH:MM eingeben.");}
case "select:crvh:crvh_sid" -> {u.require(1);if(db.queryForList("SELECT cv.crvh_id FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id WHERE cr.hq_id=? AND cv.crvh_sid=?",hq,value).isEmpty())throw bad("Fahrzeug nicht in der Berichtsniederlassung gefunden.");}
default -> { }
}
}
List<Map<String,Object>> vehicles(LegacyUser u,ReportAdministration.Kind kind,long report,String query) {
u.require(1);var owner=reports.report(u,kind,report);if(query==null||query.length()>100)throw bad("Suchbegriff ist zu lang.");
return db.queryForList("SELECT cv.crvh_id AS id,cv.crvh_sid AS value,CONCAT_WS(' · ',cv.crvh_sid,cv.crvh_vh_sign) AS label FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id WHERE cr.hq_id=? AND cv.crvh_sid LIKE ? ORDER BY cv.crvh_sid LIMIT 100",id(owner,"hq_id"),"%"+query.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%");
}
List<Map<String,Object>> history(LegacyUser u,ReportAdministration.Kind kind,long report,long category) {
category(category);reports.report(u,kind,report);var defs=definitions(category,forms.fieldHeadquarters(u,category));
var args=new ArrayList<Object>(List.of(report));args.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",args.toArray());
}
private Map<Long,String> automatic(LegacyUser u,ReportAdministration.Kind kind,Map<String,Object> report,List<Map<String,Object>> defs) {
var result=new LinkedHashMap<Long,String>();
var hq=one(db.queryForList("SELECT h.hq_id,h.hq_name,h.hq_mnemonic,c.cmp_comp,c.cmp_comp2,c.cmp_logo,c.cmp_logo_width,c.cmp_logo_height,c.cmp_hsno,a.ad_street,a.ad_zipcode,a.ad_city FROM headquarters h LEFT JOIN company c ON c.cmp_id=h.cmp_id LEFT JOIN address a ON a.ad_id=c.ad_id WHERE h.hq_id=?",u.headquartersId()));
// Resolve only the report's actual object type; matching numerical IDs in another table convey no access.
var allowed=reports.headquarters(u).stream().map(r->id(r,"id")).toList();
var objectArgs=new ArrayList<Object>(List.of(id(report,"rp_objid")));objectArgs.addAll(allowed);
var objects=db.queryForList("SELECT o."+kind.prefix+"_eid AS eid,o.hq_id,c.cmp_comp,c.cmp_comp2,c.cmp_hsno,a.ad_street,a.ad_zipcode,a.ad_city FROM "+kind.table+" o LEFT JOIN company c ON c.cmp_id=o.cmp_id LEFT JOIN address a ON a.ad_id=c.ad_id WHERE o."+kind.prefix+"_id=? AND o.hq_id IN ("+marks(allowed.size())+")",objectArgs.toArray());
var object=objects.size()==1?objects.getFirst():Map.<String,Object>of();
for(var d:defs) {
String type=text(d,"type"),v="";boolean computed=type.startsWith("sys:")||type.startsWith("db:");if(!computed)continue;
if(type.startsWith("sys:"))v=switch(type.substring(4)) {
case "current_date" -> LocalDate.now(ZoneId.of("Europe/Berlin")).format(EvaluationService.DATE);
case "current_headquarters_id" -> Long.toString(u.headquartersId());
case "current_headquarters_name" -> text(hq,"hq_name");
case "current_headquarters_mnemonic" -> text(hq,"hq_mnemonic");
case "current_headquarters_company_name" -> text(hq,"cmp_comp");
case "current_headquarters_company_name2" -> text(hq,"cmp_comp2");
case "current_headquarters_company_logo" -> text(hq,"cmp_logo").isBlank()?"":text(hq,"cmp_logo")+","+(id(hq,"cmp_logo_width")>0?id(hq,"cmp_logo_width"):"")+","+(id(hq,"cmp_logo_height")>0?id(hq,"cmp_logo_height"):"");
case "current_headquarters_street" -> text(hq,"ad_street");
case "current_headquarters_housenumber" -> text(hq,"cmp_hsno");
case "current_headquarters_zipcode" -> text(hq,"ad_zipcode");
case "current_headquarters_city" -> text(hq,"ad_city");
case "current_user" -> u.username();
default -> "";
};
else if(type.startsWith("db:"+kind.prefix+":")&&!object.isEmpty()) {
String field=type.substring(6);
v=switch(field) {
case "eid","courier_eid" -> text(object,"eid");case "company_name" -> text(object,"cmp_comp");case "company_name2" -> text(object,"cmp_comp2");
case "street" -> text(object,"ad_street");case "housenumber" -> text(object,"cmp_hsno");case "zipcode" -> text(object,"ad_zipcode");case "city" -> text(object,"ad_city");
case "stat_businessvolume_01","stat_businessvolume_02" -> kind==ReportAdministration.Kind.CUSTOMER?turnover(id(report,"rp_objid"),id(object,"hq_id"),field.endsWith("02")):"";
default -> "";
};
}
result.put(id(d,"id"),v);
}
return result;
}
private String turnover(long customer,long hq,boolean previous) {
LocalDate end=LocalDate.now(ZoneId.of("Europe/Berlin"));if(previous)end=end.minusYears(1);
var sum=db.queryForObject("SELECT COALESCE(SUM(j.jb_totalprice),0) FROM job j JOIN costcenter cc ON cc.csc_id=j.csc_id_payer WHERE cc.cs_id=? AND j.hq_id=? AND j.jb_status=2 AND (j.jb_storno IS NULL OR j.jb_storno IN (0,1,3)) AND j.jb_finishtime>=? AND j.jb_finishtime<?",java.math.BigDecimal.class,customer,hq,end.withDayOfYear(1).atStartOfDay(),end.plusDays(1).atStartOfDay());
return String.format(Locale.GERMANY,"%,.2f",sum);
}
}
@@ -0,0 +1,16 @@
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/administration/reports/{kind}/{id}/forms")
class ReportFormsController {
private final ReportForms forms;
ReportFormsController(ReportForms forms){this.forms=forms;}
@GetMapping List<Map<String,Object>> options(Authentication a,@PathVariable ReportAdministration.Kind kind,@PathVariable long id){return forms.options(user(a),kind,id);}
@GetMapping("/vehicles") List<Map<String,Object>> vehicles(Authentication a,@PathVariable ReportAdministration.Kind kind,@PathVariable long id,@RequestParam(defaultValue="")String q){return forms.vehicles(user(a),kind,id,q);}
@GetMapping("/{category}") Map<String,Object> get(Authentication a,@PathVariable ReportAdministration.Kind kind,@PathVariable long id,@PathVariable long category){return forms.get(user(a),kind,id,category);}
@PutMapping("/{category}") Map<String,Object> save(Authentication a,@PathVariable ReportAdministration.Kind kind,@PathVariable long id,@PathVariable long category,@RequestBody ReportForms.Save s){return forms.save(user(a),kind,id,category,s);}
@GetMapping("/{category}/history") List<Map<String,Object>> history(Authentication a,@PathVariable ReportAdministration.Kind kind,@PathVariable long id,@PathVariable long category){return forms.history(user(a),kind,id,category);}
}
@@ -0,0 +1,45 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import java.time.*;
import java.math.*;
import java.security.MessageDigest;
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 SalesAnalytics {
private final DatabaseService db;private final CrmScope scope;private final OrderEntryCatalog catalog;private final String logTable;
SalesAnalytics(DatabaseService db,CrmScope scope,OrderEntryCatalog catalog,@Value("${app.history-schema:phoenix_log}")String schema){this.db=db;this.scope=scope;this.catalog=catalog;if(!schema.matches("[a-zA-Z0-9_]+"))throw new IllegalArgumentException("Ungültiges Historien-Schema");logTable="`"+schema+"`.log";}
record Query(LocalDate from,LocalDate to,List<Long> headquarters){}
record Target(long headquarters,String month,BigDecimal amount,String password,String revision){}
record TargetAccess(long headquarters,String password){}
Map<String,Object> options(LegacyUser u){return fields("headquarters",scope.headquarters(u),"currentHeadquarters",u.headquartersId(),"today",LocalDate.now(ZoneId.of("Europe/Berlin")),"motivationMode",catalog.parameter(0,"MOTIVATIONCOUNTER_MODE"),"previousMonth",catalog.enabled(0,"MOTIVATIONCOUNTER_PREVIOUS_MONTH"));}
static Map<String,String> description(String text){var map=new HashMap<String,String>();for(String part:text.split("\\|")){int sep=part.indexOf('=');if(sep>0)map.put(part.substring(0,sep).strip(),part.substring(sep+1).strip());}return map;}
static LocalDate previousYear(LocalDate date){var result=date.minusYears(1);return date.getMonthValue()==2&&date.getDayOfMonth()==28&&result.isLeapYear()?result.withDayOfMonth(29):result;}
private Map<Long,BigDecimal> revenue(List<Long> hqs,LocalDate from,LocalDate to){var args=new ArrayList<Object>(hqs);args.add(from.atStartOfDay());args.add(to.plusDays(1).atStartOfDay());String payer=catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")?"jb.csc_id_related":"jb.csc_id_payer";var result=new HashMap<Long,BigDecimal>();for(var r:db.queryForList("SELECT cs.cs_id AS id,SUM(jb.jb_totalprice) AS amount FROM job jb JOIN costcenter csc ON csc.csc_id="+payer+" JOIN customer cs ON cs.cs_id=csc.cs_id JOIN company co ON co.cmp_id=cs.cmp_id WHERE jb.hq_id=cs.hq_id AND cs.hq_id IN ("+marks(hqs.size())+") AND jb.jb_status=2 AND (jb.jb_storno IS NULL OR jb.jb_storno IN (0,1,3)) AND jb.jb_finishtime>=? AND jb.jb_finishtime<? GROUP BY cs.cs_id",args.toArray()))result.put(id(r,"id"),amount(r,"amount"));return result;}
Map<String,Object> switches(LegacyUser u,Query q){if(q==null)throw bad("Zeitraum fehlt.");range(q.from(),q.to());if(q.from().getYear()<1001||q.to().getYear()>9998)throw bad("Ungültiges Jahr.");var h=scope.checked(u,q.headquarters());return db.readTransaction(()->{
var current=revenue(h,q.from(),q.to());var prior=revenue(h,previousYear(q.from()),previousYear(q.to()));var args=new ArrayList<Object>(h);args.addAll(h);args.add(q.from().atStartOfDay());args.add(q.to().plusDays(1).atStartOfDay());
var logs=db.queryForList("SELECT l.usr_id AS userId,CONCAT_WS(', ',u.usr_name,u.usr_firstname) AS person,h.hq_mnemonic AS headquarters,l.cs_id AS customerId,c.cs_eid AS customerNumber,co.cmp_comp AS company,l.log_createtime AS time,l.logo_description AS description FROM "+logTable+" l JOIN user u ON u.usr_id=l.usr_id JOIN headquarters h ON h.hq_id=l.hq_id JOIN customer c ON c.cs_id=l.cs_id JOIN company co ON co.cmp_id=c.cmp_id WHERE l.logo_id=95 AND l.hq_id IN ("+marks(h.size())+") AND c.hq_id IN ("+marks(h.size())+") AND l.log_createtime>=? AND l.log_createtime<? ORDER BY l.hq_id,person,l.log_createtime LIMIT 20001",args.toArray());if(logs.size()>20000)throw bad("Zu viele Statuswechsel. Bitte Zeitraum eingrenzen.");var conversions=new ArrayList<Map<String,Object>>();var summary=new LinkedHashMap<String,Map<String,Object>>();
for(var l:logs){var d=description(text(l,"description"));String old=d.getOrDefault("OLD","");String src=d.getOrDefault("SRC","");if(!"0".equals(d.get("NEW"))||!Set.of("1","2","3").contains(old)||!Set.of("jb","cs").contains(src))continue;var row=new LinkedHashMap<>(l);row.remove("description");row.put("source",src.equals("jb")?"Auftrag":"Kundenstamm");row.put("previousStatus",switch(old){case "1"->"Interessent";case "2"->"Potentialkunde";default->"Wunschkunde";});row.put("revenue",current.getOrDefault(id(l,"customerId"),BigDecimal.ZERO));conversions.add(row);String key=id(l,"userId")+"/"+text(l,"headquarters");var person=summary.computeIfAbsent(key,k->fields("person",text(l,"person"),"headquarters",text(l,"headquarters"),"counts",new LinkedHashMap<String,Long>()));var counts=(Map<String,Long>)person.get("counts");counts.merge(src+old,1L,Long::sum);}
var customers=db.queryForList("SELECT cs.cs_id AS customerId,cs.cs_eid AS customerNumber,co.cmp_comp AS company,CONCAT_WS(', ',u.usr_name,u.usr_firstname) AS person,u.usr_id AS userId,h.hq_mnemonic AS headquarters FROM customer cs JOIN company co ON co.cmp_id=cs.cmp_id JOIN employee e ON e.emp_id=co.emp_id_sales JOIN user u ON u.usr_id=e.usr_id JOIN headquarters h ON h.hq_id=cs.hq_id WHERE cs.hq_id IN ("+marks(h.size())+") ORDER BY h.hq_mnemonic,person,co.cmp_comp LIMIT 20001",h.toArray());if(customers.size()>20000)throw bad("Zu viele Kunden. Bitte Niederlassungen eingrenzen.");var growth=new ArrayList<Map<String,Object>>();for(var c:customers){long cid=id(c,"customerId");var row=new LinkedHashMap<>(c);BigDecimal now=current.getOrDefault(cid,BigDecimal.ZERO),before=prior.getOrDefault(cid,BigDecimal.ZERO);row.put("current",now);row.put("previous",before);row.put("growth",now.subtract(before));growth.add(row);summary.computeIfAbsent(id(c,"userId")+"/"+text(c,"headquarters"),k->fields("person",text(c,"person"),"headquarters",text(c,"headquarters"),"counts",Map.of()));}
return fields("conversions",conversions,"summary",summary.values(),"growth",growth,"from",q.from(),"to",q.to(),"previousFrom",previousYear(q.from()),"previousTo",previousYear(q.to()));});}
static YearMonth month(String value){try{var m=YearMonth.parse(value);if(m.getYear()<1000||m.getYear()>9998)throw bad("Ungültiger Monat.");return m;}catch(DateTimeException|NullPointerException e){throw bad("Monat bitte als JJJJ-MM angeben.");}}
private static String targetKey(YearMonth m){return "MOTIVATIONCOUNTER_"+m.toString().replace('-','_');}
Map<String,Object> motivation(LegacyUser u,String selected,List<Long> requested){var h=scope.checked(u,requested);var m=month(selected);var today=LocalDate.now(ZoneId.of("Europe/Berlin"));if(m.isAfter(YearMonth.from(today)))throw bad("Umsatzmonat liegt in der Zukunft.");LocalDate end=m.atEndOfMonth().isAfter(today)?today:m.atEndOfMonth();var args=new ArrayList<Object>(h);args.add(m.atDay(1).atStartOfDay());args.add(end.plusDays(1).atStartOfDay());String exclusion=catalog.parameter(0,"MOTIVATIONCOUNTER_EXCLUDED_CS");var excluded=exclusion==null?List.<String>of():Arrays.stream(exclusion.split(",")).map(String::strip).filter(s->!s.isEmpty()).toList();if(excluded.size()>10000)throw bad("Zu viele ausgeschlossene Kunden im Parameter.");args.addAll(excluded);
var amounts=db.queryForList("SELECT jb.hq_id AS id,COUNT(*) AS jobs,SUM(jb.jb_totalprice) AS amount FROM job jb JOIN costcenter csc ON csc.csc_id=jb.csc_id_payer JOIN customer cs ON cs.cs_id=csc.cs_id WHERE jb.hq_id IN ("+marks(h.size())+") AND jb.jb_status=2 AND (jb.jb_storno IS NULL OR jb.jb_storno IN (0,1,3)) AND jb.jb_finishtime>=? AND jb.jb_finishtime<?"+(excluded.isEmpty()?"":" AND cs.cs_eid NOT IN ("+marks(excluded.size())+")")+" GROUP BY jb.hq_id",args.toArray());
var targets=new ArrayList<Object>(h);targets.add(targetKey(m));var targetRows=db.queryForList("SELECT hq_id,par_value FROM parameter WHERE emp_id=0 AND hq_id IN ("+marks(h.size())+") AND par_key=?",targets.toArray());var hqs=scope.headquarters(u);var rows=new ArrayList<Map<String,Object>>();for(long hq:h){var a=amounts.stream().filter(r->id(r,"id")==hq).findFirst().orElse(Map.of());var ts=targetRows.stream().filter(r->id(r,"hq_id")==hq).toList();if(ts.size()>1)throw bad("Mehrere Zielvorgaben für einen Monat. Bitte Parameter bereinigen.");BigDecimal target=ts.isEmpty()?BigDecimal.ZERO:amount(ts.getFirst(),"par_value"),actual=amount(a,"amount");rows.add(fields("id",hq,"label",hqs.stream().filter(r->id(r,"id")==hq).map(r->text(r,"label")).findFirst().orElse(""),"jobs",id(a,"jobs"),"actual",actual,"target",target,"remaining",target.subtract(actual),"percent",percent(actual,target)));}
return fields("month",m.toString(),"through",end,"items",rows);}
static BigDecimal amount(Map<String,Object> row,String key){try{var amount=decimal(row,key);return amount==null?BigDecimal.ZERO:amount;}catch(NumberFormatException e){throw bad("Ungültiger Betrag im Bestandsparameter.");}}
static BigDecimal percent(BigDecimal actual,BigDecimal target){return target.signum()<=0?null:actual.multiply(BigDecimal.valueOf(100)).divide(target,2,RoundingMode.HALF_UP);}
private final Map<Long,ArrayDeque<Instant>> failures=new HashMap<>();
private void password(LegacyUser u,long hq,String supplied){scope.checked(u,List.of(hq));synchronized(failures){var attempts=failures.computeIfAbsent(u.userId(),k->new ArrayDeque<>());Instant cutoff=Instant.now().minusSeconds(300);while(!attempts.isEmpty()&&attempts.peekFirst().isBefore(cutoff))attempts.removeFirst();if(attempts.size()>=5)throw bad("Zu viele Passwortversuche. Bitte fünf Minuten warten.");String expected=catalog.parameter(hq,"MOTIVATIONCOUNTER_PASSWORD");if(expected==null||expected.isBlank())throw bad("Für diese Niederlassung ist kein Motivation-Counter-Passwort eingerichtet.");if(supplied==null||supplied.length()>500||!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),supplied.getBytes(StandardCharsets.UTF_8))){attempts.addLast(Instant.now());throw new AccessDeniedException("Passwort für Zielvorgaben ist nicht korrekt.");}failures.remove(u.userId());}}
List<Map<String,Object>> targets(LegacyUser u,TargetAccess s){if(s==null)throw bad("Zielvorgaben-Anfrage fehlt.");password(u,s.headquarters(),s.password());return targetList(s.headquarters());}
private List<Map<String,Object>> targetList(long hq){return db.queryForList("SELECT par_id AS id,par_key AS name,par_value AS amount FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key REGEXP '^MOTIVATIONCOUNTER_[0-9]{4}_[0-9]{2}$' ORDER BY par_key DESC",hq).stream().<Map<String,Object>>map(r->fields("month",text(r,"name").substring(18).replace('_','-'),"amount",text(r,"amount"),"revision",revision(r))).toList();}
Map<String,Object> target(LegacyUser u,Target s){if(s==null)throw bad("Zielvorgabe fehlt.");var m=month(s.month());if(m.isBefore(YearMonth.now(ZoneId.of("Europe/Berlin"))))throw bad("Zielvorgaben vergangener Monate können nicht verändert werden.");if(s.amount()==null||s.amount().signum()<0||s.amount().scale()>2||s.amount().compareTo(new BigDecimal("999999999.99"))>0)throw bad("Betrag bitte mit höchstens zwei Nachkommastellen angeben.");password(u,s.headquarters(),s.password());return db.writeTransaction(()->{one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",s.headquarters()));var rows=db.lockRows("SELECT par_id AS id,par_key AS name,par_value AS amount FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key=?",s.headquarters(),targetKey(m));if(rows.size()>1)throw bad("Doppelte Zielvorgabe im Bestand.");if(rows.isEmpty()){if(s.revision()!=null&&!s.revision().isEmpty())throw bad("Zielvorgabe wurde entfernt. Bitte neu laden.");db.insert("parameter",fields("par_key",targetKey(m),"md_id",0,"hq_id",s.headquarters(),"emp_id",0,"par_value",s.amount().toPlainString(),"par_text",""));}else{unchanged(rows.getFirst(),s.revision());db.update("UPDATE parameter SET par_value=? WHERE par_id=?",s.amount().toPlainString(),id(rows.getFirst(),"id"));}return fields("saved",true);});}
}
@@ -26,8 +26,9 @@ import java.util.List;
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class SecurityConfig {
@Bean
SecurityFilterChain security(HttpSecurity http, LegacyAuthenticationProvider provider, ObjectMapper json) throws Exception {
SecurityFilterChain security(HttpSecurity http, LegacyAuthenticationProvider provider, ObjectMapper json, OrderEntryHeadquarters headquarters) throws Exception {
return http.authenticationProvider(provider)
.addFilterAfter(new OrderEntryScopeFilter(headquarters,json), org.springframework.security.web.access.intercept.AuthorizationFilter.class)
.csrf(csrf -> csrf.csrfTokenRepository(new HttpSessionCsrfTokenRepository()))
.cors(cors -> {})
// Authorization belongs to any upstream Basic authentication, not to VoTiAn.
@@ -89,7 +90,7 @@ public class SecurityConfig {
CorsConfigurationSource cors(@Value("${app.cors-origins}") String origins) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.stream(origins.split(",")).map(String::trim).toList());
config.setAllowedMethods(List.of("GET", "POST", "OPTIONS"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN"));
config.setExposedHeaders(List.of("Content-Disposition", "X-Total-Count", "X-Result-Limit"));
config.setAllowCredentials(true);
@@ -0,0 +1,54 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
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;
/** Mode 1 price/benefit types. InnoDB edits and an audit outbox commit together. */
@Service
class ServiceDefinitionAdministration {
enum Kind { PRICE("service","srv"), BENEFIT("servicetype","srvt"); final String table,p;Kind(String table,String p){this.table=table;this.p=p;} }
record Change(String requestId,ServicePriceAdministration.Query query,Kind kind,long id,String revision,String name,boolean remove,List<Long> groups) {}
record Receipt(String hash,long id,long user,long hq,int audit,String description,String at) {}
final DatabaseService db;final ServicePriceAdministration prices;final ObjectMapper json;final String log;
ServiceDefinitionAdministration(DatabaseService db,ServicePriceAdministration prices,ObjectMapper json,@Value("${app.history-schema:phoenix_log}")String history){this.db=db;this.prices=prices;this.json=json;if(!history.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Protokollschema.");log="`"+history+"`.log";}
long access(LegacyUser u,ServicePriceAdministration.Query q){long hq=prices.scope(u,q);if(q.mode()!=1)throw bad("Preis- und Leistungstypen können im Modus 1 verwaltet werden.");return hq;}
Map<String,Object> options(LegacyUser u,ServicePriceAdministration.Query q){long hq=access(u,q);return fields("prices",list(q,hq,Kind.PRICE),"benefits",list(q,hq,Kind.BENEFIT),"canDelete",u.hasRight(31),"groups",groupOptions(u,hq),"canGroup",q.customer()==0&&u.hasRight(8)&&u.hasRight(25)&&(hq>0||u.hasRight(10)),"pending",pending(u));}
List<Map<String,Object>> pending(LegacyUser u){return db.queryForList("SELECT SUBSTRING(gdc_gen_fieldname,8) AS token FROM genericdatacontainer WHERE gdc_obj_type='srvdef' AND gdc_obj_id=? AND LEFT(gdc_gen_fieldname,7)='ng_def_' AND gdc_context='PENDING_LOG' ORDER BY gdc_id",u.userId());}
List<Map<String,Object>> groupOptions(LegacyUser u,long hq){if(!u.hasRight(8)||!u.hasRight(25)||(hq==0&&!u.hasRight(10)))return List.of();return db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0) AND grp_readonly=0 AND grp_system=0 ORDER BY grp_name,grp_id",mandator(db,u),u.headquartersId());}
String select(Kind k){return "SELECT "+k.p+"_id AS id,"+k.p+"_name AS name,"+k.p+"_group AS groups FROM "+k.table+" WHERE hq_id=? AND cs_id=? AND "+k.p+"_mode=1";}
List<Map<String,Object>> list(ServicePriceAdministration.Query q,long hq,Kind k){return db.queryForList(select(k)+" ORDER BY "+k.p+"_name,"+k.p+"_id",hq,q.customer()).stream().<Map<String,Object>>map(r->fields("id",id(r,"id"),"name",text(r,"name"),"groups",GroupAdministration.memberships(text(r,"groups")),"revision",revision(r))).toList();}
Receipt decode(Map<String,Object> r){try{return json.readValue(text(r,"gdc_content"),Receipt.class);}catch(Exception e){throw new IllegalStateException("Gesicherter Tarifvorgang ist nicht lesbar.",e);}}
Map<String,Object> receipt(LegacyUser u,String token){var rows=db.lockRows("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='srvdef' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.userId(),"ng_def_"+token);if(rows.size()>1)throw bad("Mehrdeutige Vorgangskennung.");return rows.isEmpty()?null:rows.getFirst();}
Map<String,Object> change(LegacyUser u,Change c){prices.access(u);if(c==null||c.kind()==null||c.id()<0)throw bad("Ungültiger Preis-/Leistungstyp.");long hq=access(u,c.query());if(c.remove()){u.require(31);if(c.id()==0)throw bad("Zu löschender Typ fehlt.");}
String token=FilterAdministration.token(c.requestId()),hash=revision(prices.encode(c));String name=c.remove()?"":required(c.name(),50,"Bezeichnung");
db.writeTransaction(()->{db.lockRows("SELECT hq_id FROM headquarters ORDER BY hq_id");var prior=receipt(u,token);if(prior!=null){if(!decode(prior).hash().equals(hash))throw bad("Vorgangskennung gehört zu einer anderen Änderung.");return null;}
// Recheck scope inside the transaction and lock the exact owned catalog row.
if(access(u,c.query())!=hq)throw bad("Die Tarifzuordnung wurde geändert. Bitte neu laden.");var k=c.kind();var before=c.id()==0?Map.<String,Object>of():one(db.lockRows(select(k)+" AND "+k.p+"_id=?",hq,c.query().customer(),c.id()));if(c.id()>0)unchanged(before,c.revision());
String groups=text(before,"groups");
if(c.groups()!=null&&!c.remove()){
var desired=new LinkedHashSet<>(c.groups());if(desired.contains(null))throw bad("Ungültige Gruppenauswahl.");var original=GroupAdministration.memberships(groups);
if(!desired.equals(original)){u.require(8);u.require(25);if(hq==0)u.require(10);if(c.query().customer()!=0)throw bad("Gruppen werden bei allgemeinen Preis- und Leistungstypen verwaltet.");var allowed=groupOptions(u,hq).stream().map(r->id(r,"id")).toList();var difference=new HashSet<>(desired);difference.addAll(original);var same=new HashSet<>(desired);same.retainAll(original);difference.removeAll(same);if(!allowed.containsAll(difference))throw bad("Eine Gruppe ist geschützt oder nicht freigegeben.");groups=desired.isEmpty()?"":","+String.join(",",desired.stream().sorted().map(String::valueOf).toList())+",";if(groups.length()>255)throw bad("Zu viele Gruppenzuordnungen.");}
}
if(!c.remove()&&!db.queryForList("SELECT "+k.p+"_id FROM "+k.table+" WHERE hq_id=? AND cs_id=? AND "+k.p+"_mode=1 AND "+k.p+"_name=? AND "+k.p+"_id<>?",hq,c.query().customer(),name,c.id()).isEmpty())throw bad("Die Bezeichnung existiert bereits.");
long id=c.id();int audit;
if(c.remove()){db.update("DELETE FROM "+k.table+" WHERE "+k.p+"_id=? AND hq_id=? AND cs_id=? AND "+k.p+"_mode=1",id,hq,c.query().customer());audit=k==Kind.PRICE?131:132;}
else if(id==0){id=db.insert(k.table,fields(k.p+"_mode",1,"hq_id",hq,"cs_id",c.query().customer(),k.p+"_name",name,k.p+"_group",groups));audit=k==Kind.PRICE?58:60;}
else{db.update("UPDATE "+k.table+" SET "+k.p+"_name=?,"+k.p+"_group=? WHERE "+k.p+"_id=?",name,groups,id);audit=k==Kind.PRICE?57:59;}
String desc="DBOP="+(c.remove()?"Delete":c.id()==0?"Insert":"Update")+"_"+k.table+"|SRV_MODE=1|"+k.p.toUpperCase(Locale.ROOT)+"_ID="+id+"|NAME="+(c.remove()?text(before,"name"):name)+"|NG="+token;
db.insert("genericdatacontainer",fields("gdc_obj_type","srvdef","gdc_obj_id",u.userId(),"gdc_gen_fieldname","ng_def_"+token,"gdc_content",prices.encode(new Receipt(hash,id,u.userId(),u.headquartersId(),audit,desc,LocalDateTime.now(ZoneId.of("Europe/Berlin")).toString())),"gdc_context","PENDING_LOG"));return null;
});return resume(u,token);
}
Map<String,Object> resume(LegacyUser u,String token){prices.access(u);FilterAdministration.token(token);
try{return db.writeTransaction(()->{db.lockRows("SELECT hq_id FROM headquarters ORDER BY hq_id");var row=receipt(u,token);if(row==null)throw bad("Tarifvorgang nicht gefunden.");var r=decode(row);if(text(row,"gdc_context").equals("DONE"))return fields("saved",true,"id",r.id(),"state","DONE");
if(db.queryForList("SELECT log_id FROM "+log+" WHERE logo_id=? AND logo_description=? LIMIT 1",r.audit(),r.description()).isEmpty())db.update("INSERT INTO "+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,0,0,0,?,?)",r.audit(),r.hq(),r.user(),r.description(),LocalDateTime.parse(r.at()));
db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(row,"gdc_id"));return fields("saved",true,"id",r.id(),"state","DONE");});
}catch(org.springframework.dao.DataAccessException e){return fields("saved",true,"state","PENDING_LOG","message","Änderung gespeichert. Der Protokolleintrag steht noch aus und kann erneut übernommen werden.");}
}
}
@@ -0,0 +1,103 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
/** Service matrices from admin/services.php; history is the source for effective dated tariffs. */
@Service
class ServicePriceAdministration {
record Query(int mode,long customer,LocalDate date) {}
record Cell(long service,long type,String value,String courier) {}
record Save(String requestId,Query query,String revision,boolean discount,List<Cell> cells) {}
private final DatabaseService db;
private final OrderEntryCatalog catalog;
private final ObjectMapper json;
ServicePriceAdministration(DatabaseService db,OrderEntryCatalog catalog,ObjectMapper json){this.db=db;this.catalog=catalog;this.json=json;}
LocalDateTime now(){return LocalDateTime.now(ZoneId.of("Europe/Berlin"));}
LocalDate today(){return now().toLocalDate();}
void access(LegacyUser u){u.require(2);}
boolean enabled(long hq,String key){String v=Objects.requireNonNullElse(catalog.parameter(hq,key),"");return !v.isEmpty()&&!v.equals("0");}
Map<String,Object> options(LegacyUser u){access(u);return fields("today",today().toString(),"defaultMode",enabled(u.headquartersId(),"SERVICE_DISPLAY_MODE_DEFAULT")?1:0,"general",u.hasRight(29),"customers",u.hasRight(0));}
List<Map<String,Object>> customers(LegacyUser u,String q){access(u);u.require(0);String term="%"+AddressAdministration.like(AddressAdministration.clean(q,80));return db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' · ',cs.cs_eid,c.cmp_comp,c.cmp_comp2) AS label FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=? AND COALESCE(c.cmp_archived,0)=0 AND (cs.cs_eid LIKE ? ESCAPE '!' OR c.cmp_comp LIKE ? ESCAPE '!') ORDER BY cs.cs_eid,cs.cs_id LIMIT 100",u.headquartersId(),term,term);}
long scope(LegacyUser u,Query q){access(u);if(q==null||q.mode()<0||q.mode()>1||q.customer()<0)throw bad("Ungültige Tarifauswahl.");
if(q.customer()==0)u.require(29);else{u.require(0);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",q.customer(),u.headquartersId()));}
if(q.date()==null||q.date().isBefore(today())||q.date().isAfter(LocalDate.of(today().getYear()+1,12,31))||(q.mode()==1&&!q.date().equals(today())))throw bad("Für Modus 0 bitte heute oder ein zukünftiges Datum bis Ende des nächsten Jahres wählen. Modus 1 gilt ab heute.");
return enabled(u.headquartersId(),q.mode()==0?"MANDATOR_SERVICE_ENABLED":"MANDATOR_SERVICE2_ENABLED")?u.headquartersId():0;
}
List<Map<String,Object>> definitions(LegacyUser u,Query q,long hq,boolean type,boolean lock){
String table=type?"servicetype":"service",p=type?"srvt":"srv";
String sql="SELECT d."+p+"_id AS id,d."+p+"_name AS name,d."+p+"_group AS groups,d.cs_id AS customer"+(type?"":",d.srv_sort AS sort")+" FROM "+table+" d WHERE d.hq_id=? AND d."+p+"_mode=? AND ";
List<Object> args=new ArrayList<>(List.of(hq,q.mode()));
if(q.mode()==1&&q.customer()>0){sql+="(d.cs_id=? OR (d.cs_id=0 AND EXISTS (SELECT 1 FROM customer"+table+" a WHERE a."+p+"_id=d."+p+"_id AND a.cs_id=?)))";args.add(q.customer());args.add(q.customer());}else sql+="d.cs_id=0";
sql+=" ORDER BY d."+p+"_group,"+(type?"d.srvt_name":"d.srv_sort")+",d."+p+"_id";
var rows=lock?db.lockRows(sql,args.toArray()):db.queryForList(sql,args.toArray());
if(!type&&q.mode()==0){String rights=catalog.parameter(u.headquartersId(),"RIGHTS_TABLE_SERVICE");if(rights.isEmpty())rights=catalog.parameter(0,"RIGHTS_TABLE_SERVICE");var denied=new HashSet<String>();for(String entry:rights.split("\\|")){String[] part=entry.split("=",2);if(part.length==2&&part[1].strip().matches("[0-9]{1,3}")&&!u.hasRight(Integer.parseInt(part[1].strip())))denied.add(part[0].strip());}return rows.stream().filter(r->!denied.contains(text(r,"name"))).toList();}
return rows;
}
static String key(long s,long t){return s+":"+t;}
static BigDecimal amount(Object v){return v==null?BigDecimal.ZERO.setScale(2):new BigDecimal(v.toString()).setScale(2,RoundingMode.HALF_UP);}
static BigDecimal input(String v,boolean discount){String s=Objects.requireNonNullElse(v,"").strip().replace(',','.');if(s.isEmpty())s="0";if(!s.matches("-?[0-9]{1,5}(\\.[0-9]{1,2})?"))throw bad("Beträge bitte mit höchstens zwei Nachkommastellen eingeben.");BigDecimal n=new BigDecimal(s).setScale(2);if(n.abs().compareTo(new BigDecimal(discount?"999.99":"99999.99"))>0)throw bad("Der Wert überschreitet den Wertebereich der Tarifdatenbank.");return n;}
static List<String> values(Map<String,Object> row){return List.of("price","discount","courier","courierDiscount").stream().map(k->amount(row.get(k)).toPlainString()).toList();}
record State(long hq,List<Map<String,Object>> columns,List<Map<String,Object>> rows,List<Map<String,Object>> current,List<Map<String,Object>> history,List<Map<String,Object>> cells,String revision) {}
State state(LegacyUser u,Query q,boolean lock){long hq=scope(u,q);var cols=definitions(u,q,hq,false,lock);var rows=definitions(u,q,hq,true,lock);
String base=" WHERE hq_id=? AND cs_id=? AND srv_id IN (SELECT srv_id FROM service WHERE hq_id=? AND srv_mode=?) AND srvt_id IN (SELECT srvt_id FROM servicetype WHERE hq_id=? AND srvt_mode=?)";
Object[] params={hq,q.customer(),hq,q.mode(),hq,q.mode()};
String currentSql="SELECT srv_id AS service,srvt_id AS type,srvc_price AS price,srvc_discount AS discount,srvc_cr_price AS courier,srvc_cr_discount AS courierDiscount FROM servicecustomer"+base+" ORDER BY srv_id,srvt_id";
var current=lock?db.lockRows(currentSql,params):db.queryForList(currentSql,params);
String historySql="SELECT srv_id AS service,srvt_id AS type,srvh_price AS price,srvh_discount AS discount,srvh_cr_price AS courier,srvh_cr_discount AS courierDiscount,srvh_validfrom AS validFrom FROM servicehistory"+base;
boolean future=q.date().isAfter(today());var hp=new ArrayList<>(Arrays.asList(params));
if(future){historySql+=" AND srvh_validfrom>=? AND srvh_validfrom<?";hp.add(q.date().atStartOfDay());hp.add(q.date().plusDays(1).atStartOfDay());}
else {historySql+=" AND srvh_validfrom<=?";hp.add(now());}
historySql+=" ORDER BY srv_id,srvt_id,srvh_validfrom DESC";
var history=lock?db.lockRows(historySql,hp.toArray()):db.queryForList(historySql,hp.toArray());
Map<String,List<Map<String,Object>>> currentBy=new HashMap<>(),historyBy=new HashMap<>();
for(var r:current)currentBy.computeIfAbsent(key(id(r,"service"),id(r,"type")),k->new ArrayList<>()).add(r);
for(var r:history)historyBy.computeIfAbsent(key(id(r,"service"),id(r,"type")),k->new ArrayList<>()).add(r);
var cells=new ArrayList<Map<String,Object>>();
for(var row:rows)for(var col:cols){long s=id(col,"id"),t=id(row,"id");var c=currentBy.getOrDefault(key(s,t),List.of());var h=historyBy.getOrDefault(key(s,t),List.of());
boolean ambiguous=c.size()>1||(!h.isEmpty()&&h.stream().filter(v->Objects.equals(v.get("validFrom"),h.getFirst().get("validFrom"))).count()>1)||(future&&h.size()>1);
var effective=!h.isEmpty()?h.getFirst():!future&&!c.isEmpty()?c.getFirst():Map.<String,Object>of();var v=values(effective);
cells.add(fields("service",s,"type",t,"price",v.get(0),"discount",v.get(1),"courier",v.get(2),"courierDiscount",v.get(3),"ambiguous",ambiguous));
}
return new State(hq,cols,rows,current,history,cells,revision(fields("hq",hq,"query",q,"columns",cols,"rows",rows,"cells",cells,"current",current,"history",history)));
}
Map<String,Object> grid(LegacyUser u,Query q){access(u);return db.readTransaction(()->{var s=state(u,q,false);return fields("columns",s.columns(),"rows",s.rows(),"cells",s.cells(),"revision",s.revision(),"scope",s.hq(),"futureDates",db.queryForList("SELECT DISTINCT DATE(srvh_validfrom) AS date FROM servicehistory WHERE hq_id=? AND cs_id=? AND srvh_validfrom>CURRENT_DATE() ORDER BY date",s.hq(),q.customer()),"future",q.date().isAfter(today()));});}
String encode(Object v){try{return json.writeValueAsString(v);}catch(Exception e){throw new IllegalStateException(e);}}
Map<String,Object> save(LegacyUser u,Save request){access(u);if(request==null||request.cells()==null||request.cells().isEmpty()||request.cells().size()>2000)throw bad("Bitte 1 bis 2000 geänderte Tarifzellen übergeben.");scope(u,request.query());String token=FilterAdministration.token(request.requestId()),hash=revision(encode(request));
var seen=new HashSet<String>();for(var c:request.cells()){if(c==null||!seen.add(key(c.service(),c.type())))throw bad("Tarifzellen wurden doppelt übergeben.");input(c.value(),request.discount());input(c.courier(),request.discount());}
return db.writeTransaction(()->{
// Global definitions can be shared between headquarters: one deterministic lock order.
db.lockRows("SELECT hq_id FROM headquarters ORDER BY hq_id");
var receipts=db.lockRows("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='srvprice' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.userId(),"ng_price_"+token);
if(!receipts.isEmpty()){if(receipts.size()!=1||!text(receipts.getFirst(),"gdc_content").equals(hash))throw bad("Vorgangskennung gehört zu einer anderen Änderung.");return fields("saved",true,"repeated",true);}
var q=request.query();var s=state(u,q,true);if(!s.revision().equals(request.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Tarife wurden zwischenzeitlich geändert. Bitte neu laden und die Änderungen vergleichen.");
boolean future=q.date().isAfter(today());int count=0;
for(var changed:request.cells()){
var old=s.cells().stream().filter(c->id(c,"service")==changed.service()&&id(c,"type")==changed.type()).findFirst().orElseThrow(()->bad("Tarifzelle ist nicht freigegeben."));
if(Boolean.TRUE.equals(old.get("ambiguous")))throw bad("Für diese Tarifzelle gibt es mehrere widersprüchliche Bestandszeilen. Bitte zuerst im Bestand klären.");
var price=amount(old.get("price"));var discount=amount(old.get("discount"));var courier=amount(old.get("courier"));var crDiscount=amount(old.get("courierDiscount"));
var value=input(changed.value(),request.discount());var cr=input(changed.courier(),request.discount());
if(value.compareTo(request.discount()?discount:price)==0&&cr.compareTo(request.discount()?crDiscount:courier)==0)continue;
if(request.discount()){discount=value;crDiscount=cr;}else{price=value;courier=cr;}
Object[] key={changed.service(),changed.type(),s.hq(),q.customer()};String where="srv_id=? AND srvt_id=? AND hq_id=? AND cs_id=?";
if(!future){boolean exists=s.current().stream().anyMatch(c->id(c,"service")==changed.service()&&id(c,"type")==changed.type());
if(exists)db.update("UPDATE servicecustomer SET srvc_price=?,srvc_discount=?,srvc_cr_price=?,srvc_cr_discount=? WHERE "+where,price,discount,courier,crDiscount,key[0],key[1],key[2],key[3]);
else db.update("INSERT INTO servicecustomer (srv_id,srvt_id,hq_id,cs_id,srvc_price,srvc_discount,srvc_cr_price,srvc_cr_discount) VALUES (?,?,?,?,?,?,?,?)",key[0],key[1],key[2],key[3],price,discount,courier,crDiscount);
}
var existing=future?s.history().stream().filter(c->id(c,"service")==changed.service()&&id(c,"type")==changed.type()).toList():List.<Map<String,Object>>of();
if(!existing.isEmpty())db.update("UPDATE servicehistory SET srvh_price=?,srvh_discount=?,srvh_cr_price=?,srvh_cr_discount=?,usr_id=? WHERE "+where+" AND srvh_validfrom=?",price,discount,courier,crDiscount,u.userId(),key[0],key[1],key[2],key[3],existing.getFirst().get("validFrom"));
else db.update("INSERT INTO servicehistory (srv_id,srvt_id,hq_id,cs_id,usr_id,srvh_validfrom,srvh_price,srvh_discount,srvh_cr_price,srvh_cr_discount) VALUES (?,?,?,?,?,?,?,?,?,?)",key[0],key[1],key[2],key[3],u.userId(),future?q.date().atStartOfDay():nextTimestamp(s,changed),price,discount,courier,crDiscount);
count++;
}
db.insert("genericdatacontainer",fields("gdc_obj_type","srvprice","gdc_obj_id",u.userId(),"gdc_gen_fieldname","ng_price_"+token,"gdc_content",hash,"gdc_context","DONE"));return fields("saved",true,"changed",count);
});
}
LocalDateTime nextTimestamp(State s,Cell c){LocalDateTime now=now().withNano(0);for(var h:s.history())if(id(h,"service")==c.service()&&id(h,"type")==c.type()&&now.equals(dateTime(h,"validFrom")))throw bad("Dieser Tarif wurde gerade gespeichert. Bitte nach einer Sekunde neu laden und erneut speichern.");return now;}
}
@@ -0,0 +1,18 @@
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/administration/service-prices")
class ServicePriceController {
private final ServicePriceAdministration service;
private final ServiceDefinitionAdministration definitions;
ServicePriceController(ServicePriceAdministration service,ServiceDefinitionAdministration definitions){this.service=service;this.definitions=definitions;}
@GetMapping Map<String,Object> options(Authentication a){return service.options(user(a));}
@GetMapping("/customers") List<Map<String,Object>> customers(Authentication a,@RequestParam(defaultValue="") String q){return service.customers(user(a),q);}
@PostMapping("/query") Map<String,Object> query(Authentication a,@RequestBody ServicePriceAdministration.Query q){return service.grid(user(a),q);}
@PutMapping Map<String,Object> save(Authentication a,@RequestBody ServicePriceAdministration.Save s){return service.save(user(a),s);}
@PostMapping("/definitions/query") Map<String,Object> definitions(Authentication a,@RequestBody ServicePriceAdministration.Query q){return definitions.options(user(a),q);}
@PutMapping("/definitions") Map<String,Object> definition(Authentication a,@RequestBody ServiceDefinitionAdministration.Change c){return definitions.change(user(a),c);}
@PostMapping("/definitions/{token}/resume") Map<String,Object> resume(Authentication a,@PathVariable String token){return definitions.resume(user(a),token);}
}
@@ -0,0 +1,32 @@
package de.stadtbote.votian;
import java.io.ByteArrayOutputStream;
import java.nio.charset.*;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
/** Paginated PDF statement, using a standard embedded encoding and no external resources. */
final class StatementPdf {
static final Charset WIN=Charset.forName("windows-1252");
private StatementPdf(){}
static byte[] create(Map<String,Object> data,BillingService.Query query){
var lines=new ArrayList<String>();lines.add(query.mode().equals("COURIER")?"Kurierabrechnung / Leistungsübersicht":"Rechnungsanlage / Leistungsübersicht");lines.add(query.from()+" bis "+query.to());lines.add("");
for(var row:(List<Map<String,Object>>)data.get("rows")){
lines.add("Auftrag "+text(row,"id")+" Rechnung "+text(row,"invoice")+" Kurier "+text(row,"courier"));
wrap(lines,text(row,"customer")+" / "+text(row,"centerName"));
lines.add("Erledigt: "+text(row,"finished")+" Referenz: "+text(row,"reference"));
lines.add("Netto "+text(row,"net")+" EUR MwSt "+text(row,"tax")+" EUR Brutto "+text(row,"gross")+" EUR");
wrap(lines,org.jsoup.Jsoup.parse(text(row,"description")).text());lines.add("");
}
lines.add("Summe netto: "+data.get("net")+" EUR");lines.add("Summe MwSt: "+data.get("tax")+" EUR");lines.add("Summe brutto: "+data.get("gross")+" EUR");
var wrapped=new ArrayList<String>();for(String line:lines){if(line.isEmpty())wrapped.add("");else wrap(wrapped,line);}return render(wrapped);
}
static void wrap(List<String> lines,String text){for(String paragraph:text.replaceAll("[\\p{Cntrl}&&[^\\n]]"," ").split("\\n")){while(paragraph.length()>92){int at=paragraph.lastIndexOf(' ',92);if(at<20)at=92;lines.add(paragraph.substring(0,at));paragraph=paragraph.substring(at).stripLeading();}if(!paragraph.isEmpty())lines.add(paragraph);}}
static byte[] render(List<String> lines){
int pages=Math.max(1,(lines.size()+49)/50);var objects=new ArrayList<byte[]>();objects.add(bytes("<< /Type /Catalog /Pages 2 0 R >>"));var children=new StringBuilder();for(int i=0;i<pages;i++)children.append(4+2*i).append(" 0 R ");objects.add(bytes("<< /Type /Pages /Kids ["+children+"] /Count "+pages+" >>"));objects.add(bytes("<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>"));
for(int i=0;i<pages;i++){objects.add(bytes("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents "+(5+2*i)+" 0 R >>"));StringBuilder content=new StringBuilder("BT /F1 9 Tf 40 800 Td 14 TL\n");for(int n=i*50;n<Math.min((i+1)*50,lines.size());n++)content.append('(').append(escape(lines.get(n))).append(") Tj T*\n");content.append("ET BT /F1 9 Tf 40 30 Td (Seite ").append(i+1).append(" / ").append(pages).append(") Tj ET\n");byte[] stream=bytes(content.toString());var b=new ByteArrayOutputStream();b.writeBytes(bytes("<< /Length "+stream.length+" >>\nstream\n"));b.writeBytes(stream);b.writeBytes(bytes("endstream"));objects.add(b.toByteArray());}
var out=new ByteArrayOutputStream();out.writeBytes(bytes("%PDF-1.4\n%âãÏÓ\n"));var offsets=new ArrayList<Integer>();offsets.add(0);for(int i=0;i<objects.size();i++){offsets.add(out.size());out.writeBytes(bytes((i+1)+" 0 obj\n"));out.writeBytes(objects.get(i));out.writeBytes(bytes("\nendobj\n"));}int start=out.size();out.writeBytes(bytes("xref\n0 "+offsets.size()+"\n0000000000 65535 f \n"));for(int i=1;i<offsets.size();i++)out.writeBytes(bytes(String.format(Locale.ROOT,"%010d 00000 n \n",offsets.get(i))));out.writeBytes(bytes("trailer\n<< /Size "+offsets.size()+" /Root 1 0 R >>\nstartxref\n"+start+"\n%%EOF\n"));return out.toByteArray();
}
static String escape(String s){return s.replace("\\","\\\\").replace("(","\\(").replace(")","\\)").replaceAll("[\\p{Cntrl}]"," ");}
static byte[] bytes(String s){return s.getBytes(WIN);}
}
@@ -0,0 +1,12 @@
package de.stadtbote.votian;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/statistics")
class StatisticsController {
private final StatisticsService service;
StatisticsController(StatisticsService service){this.service=service;}
@GetMapping("/options") Map<String,Object> options(Authentication auth){return service.options(ApiController.user(auth));}
@PostMapping("/query") Map<String,Object> calculate(Authentication auth,@RequestBody StatisticsService.Query query){return service.calculate(ApiController.user(auth),query);}
}
@@ -0,0 +1,125 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import org.springframework.security.access.AccessDeniedException;
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;
/** Server-side statistics, independent of the paged order overview. No writes or archive guessing. */
@Service
public class StatisticsService {
private final DatabaseService db;
private final OrderEntryCatalog catalog;
private final String logTable;
public StatisticsService(DatabaseService db,OrderEntryCatalog catalog,@org.springframework.beans.factory.annotation.Value("${app.history-schema:phoenix_log}") String schema){
this.db=db;this.catalog=catalog;
if(!schema.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Protokollschema.");
this.logTable="`"+schema+"`.log";
}
enum DateBasis { CREATED("jb_createtime"),BOOKED("jb_booktime"),FINISHED("jb_finishtime"),EXPORTED("jb_export_time"),ORDERED("jb_ordertime");final String column;DateBasis(String column){this.column=column;} }
enum State { ALL,FINISHED,EXPORTED }
enum Amount { ALL,ORDERS,CREDITS }
enum Payment { ALL,INVOICE,CASH }
enum Period { TOTAL,DAY,WEEK,MONTH,YEAR,HALF_HOUR,HOUR,TWO_HOURS }
enum Split { CUSTOMER,COURIER,HEADQUARTERS,COST_CENTER,VEHICLE_TYPE,ACTUAL_VEHICLE_TYPE,VEHICLE,EMPLOYEE,ORDER_TYPE,START_POSTAL,END_POSTAL,PARTNER_COMMISSION }
record Interval(LocalDate from,LocalDate to) {}
record Query(Interval interval,List<Interval> comparisons,List<Long> headquarters,DateBasis dateBasis,State state,Amount amount,Payment payment,Period period,List<Split> splits,
String customerNumber,String courierNumber,Long customerGroup,Long courierGroup,Boolean retrospective,Boolean permanent,Boolean longhaul,LocalTime timeFrom,LocalTime timeTo,boolean excludeFreeCancellations,boolean relatedCustomer) {}
record Sql(String text,Object[] values) {}
public Map<String,Object> options(LegacyUser user) {
user.require(8);
var hqs=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()});
var groups=db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0) ORDER BY grp_name",mandator(db,user),user.headquartersId());
return fields("headquarters",hqs,"currentHeadquarters",user.headquartersId(),"groups",groups,"excludeFreeCancellations",catalog.enabled(user.headquartersId(),"STATISTIC_NO_STORNOJOBS"),"relatedCustomer",catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"));
}
private List<Long> headquarters(LegacyUser user,List<Long> requested) {
if(requested==null||requested.isEmpty())return List.of(user.headquartersId());
if(requested.size()>100||requested.stream().anyMatch(Objects::isNull))throw bad("Ungültige Niederlassungsauswahl.");
var ids=requested.stream().distinct().sorted().toList();
if(ids.stream().allMatch(id->id==user.headquartersId()))return ids;
user.require(10);
var allowed=db.queryForList("SELECT DISTINCT target.hq_id FROM mandatorheadquarters target JOIN mandatorheadquarters own ON own.md_id=target.md_id WHERE own.hq_id=? AND own.md_id>0",user.headquartersId()).stream().map(r->id(r,"hq_id")).toList();
if(!allowed.containsAll(ids))throw new AccessDeniedException("Eine Niederlassung ist nicht für diesen Mandanten freigegeben.");
return ids;
}
public Map<String,Object> calculate(LegacyUser user,Query query) {
user.require(8);validate(query);
return db.readTransaction(()->{
List<Long> hqs=headquarters(user,query.headquarters());
for(Long id:Arrays.asList(query.customerGroup(),query.courierGroup()))if(id!=null && db.queryForList("SELECT grp_id FROM groups WHERE grp_id=? AND md_id=? AND (hq_id=0 OR hq_id IN ("+String.join(",",Collections.nCopies(hqs.size(),"?"))+"))",groupArgs(id,mandator(db,user),hqs)).isEmpty())throw bad("Gruppe ist nicht für die gewählten Niederlassungen freigegeben.");
var intervals=new ArrayList<Interval>();intervals.add(query.interval());if(query.comparisons()!=null)intervals.addAll(query.comparisons());
var results=new ArrayList<Map<String,Object>>();
for(var interval:intervals) {
var sql=sql(query,interval,hqs,false,logTable);var rows=db.queryForList(sql.text(),sql.values());
if(rows.size()>5000)throw bad("Mehr als 5000 Statistikzeilen. Bitte Gruppierung oder Zeitraum eingrenzen.");
var total=sql(query,interval,hqs,true,logTable);
results.add(fields("interval",interval,"rows",rows,"totals",one(db.queryForList(total.text(),total.values()))));
}
return fields("periods",results,"dateBasis",query.dateBasis(),"period",query.period(),"splits",query.splits()==null?List.of():query.splits(),"source","Bestandsaufträge","completePeriod",true);
});
}
private static Object[] groupArgs(long id,long md,List<Long> hqs){var args=new ArrayList<Object>(List.of(id,md));args.addAll(hqs);return args.toArray();}
static void validate(Query q) {
if(q==null||q.interval()==null||q.dateBasis()==null||q.state()==null||q.amount()==null||q.payment()==null||q.period()==null)throw bad("Statistikoptionen fehlen.");
range(q.interval().from(),q.interval().to());
if(q.comparisons()!=null){if(q.comparisons().size()>5)throw bad("Höchstens fünf Vergleichszeiträume sind möglich.");for(var i:q.comparisons()){if(i==null)throw bad("Vergleichszeitraum fehlt.");range(i.from(),i.to());}}
if(q.splits()!=null && (q.splits().size()>5 || q.splits().stream().anyMatch(Objects::isNull) || q.splits().stream().distinct().count()!=q.splits().size()))throw bad("Bitte höchstens fünf unterschiedliche Aufteilungen wählen.");
for(var value:Arrays.asList(q.customerNumber(),q.courierNumber()))if(value!=null && value.length()>50)throw bad("Kunden-/Transporteursnummer ist zu lang.");
if((q.timeFrom()==null)!=(q.timeTo()==null))throw bad("Bitte beide Uhrzeiten angeben.");
}
static Sql sql(Query q,Interval interval,List<Long> headquarters,boolean total,String logTable) {
String at="j."+q.dateBasis().column;
var args=new ArrayList<Object>(headquarters);
String where="j.hq_id IN ("+String.join(",",Collections.nCopies(headquarters.size(),"?"))+") AND "+at+">=? AND "+at+"<?";
args.add(interval.from().atStartOfDay());args.add(interval.to().plusDays(1).atStartOfDay());
if(q.state()!=State.ALL)where+=" AND j.jb_status=2";
if(q.state()==State.EXPORTED)where+=" AND j.jb_export_time IS NOT NULL AND j.jb_export_time>'0000-00-00 00:00:00' AND j.jb_export_time<'9999-12-31 23:59:59'";
if(q.amount()==Amount.ORDERS)where+=" AND j.jb_totalprice>0";
if(q.amount()==Amount.CREDITS)where+=" AND j.jb_totalprice<0";
if(q.excludeFreeCancellations())where+=" AND COALESCE(j.jb_storno,0) IN (0,1,3)";
if(q.payment()==Payment.CASH)where+=" AND j.jb_payment=2";
if(q.payment()==Payment.INVOICE)where+=" AND j.jb_payment IN (0,1)";
if(q.customerNumber()!=null&&!q.customerNumber().isBlank()){where+=" AND cs.cs_eid=?";args.add(q.customerNumber().strip());}
if(q.courierNumber()!=null&&!q.courierNumber().isBlank()){where+=" AND cr.cr_eid=?";args.add(q.courierNumber().strip());}
if(q.customerGroup()!=null){where+=" AND cs.cs_group LIKE ?";args.add("%,"+q.customerGroup()+",%");}
if(q.courierGroup()!=null){where+=" AND cr.cr_group LIKE ?";args.add("%,"+q.courierGroup()+",%");}
if(q.retrospective()!=null)where+=" AND "+(q.retrospective()?"":"NOT ")+"EXISTS (SELECT 1 FROM "+logTable+" log WHERE log.jb_id=j.jb_id AND log.logo_id IN (1,25) AND log.logo_description LIKE 'Nacherfassung%')";
if(q.permanent()!=null)where+=" AND COALESCE(j.jb_permanent,0)"+(q.permanent()?">0":"=0");
if(q.longhaul()!=null)where+=" AND COALESCE(j.jb_longhaul,0)="+(q.longhaul()?1:0);
if(q.timeFrom()!=null){where+=" AND (TIME("+at+")>=? "+(q.timeFrom().isAfter(q.timeTo())?"OR":"AND")+" TIME("+at+")<=?)";args.add(q.timeFrom());args.add(q.timeTo().withSecond(59));}
String period=switch(q.period()){
case TOTAL->"'Gesamt'";case DAY->"DATE_FORMAT("+at+",'%Y-%m-%d')";case WEEK->"DATE_FORMAT("+at+",'%x-W%v')";case MONTH->"DATE_FORMAT("+at+",'%Y-%m')";case YEAR->"YEAR("+at+")";
case HALF_HOUR->"CONCAT(DATE_FORMAT("+at+",'%H:'),IF(MINUTE("+at+")<30,'00','30'))";case HOUR->"DATE_FORMAT("+at+",'%H:00')";case TWO_HOURS->"CONCAT(LPAD(FLOOR(HOUR("+at+")/2)*2,2,'0'),':00')";};
var columns=new ArrayList<String>();columns.add(total?"'Gesamt' AS period":period+" AS period");
var groups=new ArrayList<String>();groups.add("period");
if(!total&&q.splits()!=null)for(int i=0;i<q.splits().size();i++) {
String expr=switch(q.splits().get(i)){
case CUSTOMER->"CONCAT_WS(' · ',cs.cs_id,cs.cs_eid,cmp.cmp_comp)";case COURIER->"CONCAT_WS(' · ',cr.cr_id,cr.cr_eid,CONCAT_WS(' ',cu.usr_firstname,cu.usr_name))";
case HEADQUARTERS->"CONCAT_WS(' · ',j.hq_id,h.hq_mnemonic)";case COST_CENTER->"CONCAT_WS(' · ',cc.csc_id,cc.csc_name)";case VEHICLE_TYPE->"COALESCE(CONVERT((SELECT MIN(mt_value) FROM metatype WHERE mt_type='vehicletype' AND mt_sort=j.vht_id) USING utf8mb4) COLLATE utf8mb4_unicode_ci,CAST(j.vht_id AS CHAR))";
case ACTUAL_VEHICLE_TYPE->"COALESCE(CONVERT((SELECT MIN(mt_value) FROM metatype WHERE mt_type='vehicletype' AND mt_sort=j.vht_id_real) USING utf8mb4) COLLATE utf8mb4_unicode_ci,CAST(j.vht_id_real AS CHAR))";
case VEHICLE->"j.cr_sid";case EMPLOYEE->"CONCAT_WS(' · ',j.emp_id,CONCAT_WS(' ',eu.usr_firstname,eu.usr_name))";case ORDER_TYPE->"CAST(j.jb_type AS CHAR)";
case START_POSTAL->"(SELECT a.ad_zipcode FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=j.jb_id ORDER BY t.tr_sort,t.tr_id LIMIT 1)";
case END_POSTAL->"(SELECT a.ad_zipcode FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=j.jb_id ORDER BY t.tr_sort DESC,t.tr_id DESC LIMIT 1)";
case PARTNER_COMMISSION->"CAST((SELECT MAX(p.jbprc_price) FROM jobprice p WHERE p.jb_id=j.jb_id AND p.mt_sort=14) AS CHAR)";
};
columns.add("COALESCE("+expr+",'–') AS split"+i);groups.add("split"+i);
}
// Scalar lookups do not multiply jobs when legacy auxiliary rows are duplicated.
String courier="IF(j.jb_cr_price IS NOT NULL AND j.jb_cr_price<>0,j.jb_cr_price,ROUND(j.jb_totalprice*(100-(SELECT CASE WHEN COUNT(*)=1 THEN MAX(v.crvh_prov) ELSE NULL END FROM couriervehicle v WHERE v.crvh_sid=j.cr_sid AND v.cr_id=j.cr_id))/100,2))";
columns.add("COUNT(*) AS orders");columns.add("COALESCE(SUM(j.jb_totalprice),0) AS revenue");
columns.add("SUM(CASE WHEN ("+courier+") IS NULL THEN 1 ELSE 0 END) AS missingCourierValues");
columns.add("CASE WHEN COUNT("+courier+")=COUNT(*) THEN COALESCE(SUM("+courier+"),0) ELSE NULL END AS courierRevenue");
columns.add("COALESCE(SUM(j.jb_serviceprice),0) AS serviceRevenue");columns.add("COALESCE(SUM(j.jb_totalprice-j.jb_subtotalprice-j.jb_serviceprice),0) AS fuelMarkup");
columns.add("COALESCE(SUM((SELECT SUM(p.jbprc_price) FROM jobprice p WHERE p.jb_id=j.jb_id AND p.mt_sort=3)),0) AS courierService");
for(var measure:Map.of("kilometers",11,"co2eKg",12).entrySet()) {
columns.add("SUM((SELECT SUM(p.jbprc_price) FROM jobprice p WHERE p.jb_id=j.jb_id AND p.mt_sort="+measure.getValue()+")) AS "+measure.getKey());
columns.add("SUM(CASE WHEN EXISTS (SELECT 1 FROM jobprice p WHERE p.jb_id=j.jb_id AND p.mt_sort="+measure.getValue()+") THEN 1 ELSE 0 END) AS "+measure.getKey()+"Orders");
}
String from=" FROM job j LEFT JOIN costcenter cc ON cc.csc_id=j."+(q.relatedCustomer()?"csc_id_related":"csc_id_payer")+" LEFT JOIN customer cs ON cs.cs_id=cc.cs_id LEFT JOIN company cmp ON cmp.cmp_id=cs.cmp_id LEFT JOIN courier cr ON cr.cr_id=j.cr_id LEFT JOIN user cu ON cu.usr_id=cr.usr_id LEFT JOIN user eu ON eu.usr_id=j.emp_id LEFT JOIN headquarters h ON h.hq_id=j.hq_id ";
return new Sql("SELECT "+String.join(",",columns)+from+" WHERE "+where+(total?"":" GROUP BY "+String.join(",",groups)+" ORDER BY "+String.join(",",groups)+" LIMIT 5001"),args.toArray());
}
}
@@ -0,0 +1,86 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
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.ServicePriceAdministration.amount;
/** Directed PLZ/area matrices and symmetric, non-transitive neighborhood relations from PHP. */
@Service
class ZonePriceAdministration {
enum Kind {
POSTAL("serviceplz","srvp"), AREA("serviceplzarea","srvpa");
final String table,p; Kind(String table,String p){this.table=table;this.p=p;}
String first(){return p+"_id";} String second(){return p+"2_id";}
}
record Query(Kind kind,long customer,long vehicle,String rowStart,String columnStart,int rowOffset,int columnOffset) {}
record Cell(long from,long to,String value,String courier) {}
record Save(String requestId,Query query,String revision,boolean discount,List<Cell> cells) {}
enum Action { CREATE, RENAME, LINK, UNLINK, NEIGHBOR_ADD, NEIGHBOR_REMOVE }
record Change(String requestId,Kind kind,long id,String revision,Action action,String name,List<Long> targets) {}
private final DatabaseService db; private final OrderEntryCatalog catalog; private final ObjectMapper json;
ZonePriceAdministration(DatabaseService db,OrderEntryCatalog catalog,ObjectMapper json){this.db=db;this.catalog=catalog;this.json=json;}
LocalDateTime now(){return LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0);}
void access(LegacyUser u){u.require(2);}
String encode(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
List<Map<String,Object>> vehicles(){return db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='vehicletype' ORDER BY mt_sort");}
Set<String> excluded(LegacyUser u){return new HashSet<>(Arrays.asList(catalog.parameter(u.headquartersId(),"MASK_EXCLUDE_VHT_IDS").replace(" ","").split(",")));}
Map<String,Object> options(LegacyUser u){access(u);boolean enabled=catalog.enabled(u.headquartersId(),"SERVICE_VEHICLE_TYPE_ENABLED");return fields("customers",u.hasRight(0),"vehicleEnabled",enabled,"vehicles",enabled?vehicles().stream().filter(v->!excluded(u).contains(text(v,"id"))).toList():List.of());}
long scope(LegacyUser u,Query q){access(u);if(q==null||q.kind()==null||q.customer()<0||q.vehicle()<0||q.rowOffset()<0||q.columnOffset()<0||q.rowOffset()>100000||q.columnOffset()>100000)throw bad("Ungültige Tarifauswahl.");
start(q.rowStart());start(q.columnStart());if(q.customer()>0){u.require(0);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",q.customer(),u.headquartersId()));}
if(q.vehicle()==0)return 0;
if(!catalog.enabled(u.headquartersId(),"SERVICE_VEHICLE_TYPE_ENABLED")||excluded(u).contains(""+q.vehicle()))throw bad("Dieser Fahrzeugtarif ist nicht freigegeben.");
var all=vehicles();if(all.stream().noneMatch(v->id(v,"id")==q.vehicle()))throw bad("Unbekannter Fahrzeugtyp.");
for(String pair:catalog.parameter(u.headquartersId(),"SERVICE_VEHICLE_TYPE_MAPPING").split("\\|")){String[] parts=pair.split("=",2);if(parts.length==2&&parts[0].strip().equals(""+q.vehicle())&&parts[1].strip().matches("[0-9]{1,9}")){long mapped=Long.parseLong(parts[1].strip());if(all.stream().anyMatch(v->id(v,"id")==mapped))return mapped;}}
return q.vehicle();
}
static String start(String s){s=Objects.requireNonNullElse(s,"").strip();if(s.length()>10)throw bad("Startwert: höchstens zehn Zeichen.");return s;}
String label(Kind k){return k==Kind.AREA?"srvpa_name":"CONCAT_WS(' · ',srvp_plz,NULLIF(srvp_country,''))";}
List<Map<String,Object>> page(LegacyUser u,Kind k,String start,int offset){String name=k==Kind.AREA?"srvpa_name":"srvp_plz";return k==Kind.AREA?db.queryForList("SELECT srvpa_id AS id,srvpa_name AS name FROM serviceplzarea WHERE hq_id=? AND srvpa_name>=? ORDER BY srvpa_name,srvpa_id LIMIT 11 OFFSET ?",u.headquartersId(),start(start),offset):db.queryForList("SELECT srvp_id AS id,"+label(k)+" AS name FROM serviceplz WHERE srvp_valid=1 AND srvp_plz>=? ORDER BY "+name+",srvp_country,srvp_id LIMIT 11 OFFSET ?",start(start),offset);}
List<Map<String,Object>> search(LegacyUser u,Kind k,String term){access(u);if(k==null)throw bad("Art fehlt.");term=AddressAdministration.like(start(term))+"%";return k==Kind.AREA?db.queryForList("SELECT srvpa_id AS id,srvpa_name AS name FROM serviceplzarea WHERE hq_id=? AND srvpa_name LIKE ? ESCAPE '!' ORDER BY srvpa_name,srvpa_id LIMIT 100",u.headquartersId(),term):db.queryForList("SELECT srvp_id AS id,"+label(k)+" AS name FROM serviceplz WHERE srvp_plz LIKE ? ESCAPE '!' ORDER BY srvp_plz,srvp_country,srvp_id LIMIT 100",term);}
Map<String,Object> item(LegacyUser u,Kind k,long id){if(k==null)throw bad("Art fehlt.");return one(k==Kind.AREA?db.queryForList("SELECT srvpa_id AS id,srvpa_name AS name FROM serviceplzarea WHERE srvpa_id=? AND hq_id=?",id,u.headquartersId()):db.queryForList("SELECT srvp_id AS id,"+label(k)+" AS name FROM serviceplz WHERE srvp_id=?",id));}
static String key(long from,long to){return from+":"+to;}
static String marks(int count){return String.join(",",Collections.nCopies(count,"?"));}
record State(long vehicle,List<Map<String,Object>> rows,List<Map<String,Object>> columns,boolean moreRows,boolean moreColumns,List<Map<String,Object>> current,List<Map<String,Object>> cells,String revision) {}
State state(LegacyUser u,Query q,boolean lock){long vehicle=scope(u,q);Kind k=q.kind();var rp=page(u,k,q.rowStart(),q.rowOffset());var cp=page(u,k,q.columnStart(),q.columnOffset());var rows=rp.stream().limit(10).toList();var cols=cp.stream().limit(10).toList();
var current=matrix(u,q,vehicle,rows,cols,q.customer(),lock);var general=q.customer()>0?matrix(u,q,vehicle,rows,cols,0,false):List.<Map<String,Object>>of();var cells=new ArrayList<Map<String,Object>>();
for(var r:rows)for(var c:cols){long from=id(r,"id"),to=id(c,"id");var matching=current.stream().filter(v->id(v,"fromId")==from&&id(v,"toId")==to).toList();var base=general.stream().filter(v->id(v,"fromId")==from&&id(v,"toId")==to).toList();var cell=new LinkedHashMap<String,Object>(fields("from",from,"to",to,"ambiguous",matching.size()>1,"generalAmbiguous",base.size()>1));var v=ServicePriceAdministration.values(matching.isEmpty()?Map.of():matching.getFirst());var b=ServicePriceAdministration.values(base.isEmpty()?Map.of():base.getFirst());String[] names={"price","discount","courier","courierDiscount"};for(int i=0;i<4;i++){cell.put(names[i],v.get(i));cell.put("general"+Character.toUpperCase(names[i].charAt(0))+names[i].substring(1),b.get(i));}cells.add(cell);}
return new State(vehicle,rows,cols,rp.size()>10,cp.size()>10,current,cells,revision(fields("query",q,"hq",u.headquartersId(),"vehicle",vehicle,"rows",rows,"columns",cols,"current",current,"general",general)));
}
List<Map<String,Object>> matrix(LegacyUser u,Query q,long vehicle,List<Map<String,Object>> rows,List<Map<String,Object>> cols,long customer,boolean lock){if(rows.isEmpty()||cols.isEmpty())return List.of();var k=q.kind();String p=k.p+"c";String sql="SELECT "+k.first()+" AS fromId,"+k.second()+" AS toId,"+p+"_price AS price,"+p+"_discount AS discount,"+p+"_cr_price AS courier,"+p+"_cr_discount AS courierDiscount FROM "+k.table+"customer WHERE hq_id=? AND cs_id=? AND vht_id=? AND "+k.first()+" IN ("+marks(rows.size())+") AND "+k.second()+" IN ("+marks(cols.size())+") ORDER BY "+k.first()+","+k.second();var args=new ArrayList<Object>(List.of(u.headquartersId(),customer,vehicle));rows.forEach(r->args.add(id(r,"id")));cols.forEach(c->args.add(id(c,"id")));return lock?db.lockRows(sql,args.toArray()):db.queryForList(sql,args.toArray());}
Map<String,Object> grid(LegacyUser u,Query q){access(u);return db.readTransaction(()->{var s=state(u,q,false);return fields("rows",s.rows(),"columns",s.columns(),"moreRows",s.moreRows(),"moreColumns",s.moreColumns(),"cells",s.cells(),"revision",s.revision(),"vehicle",s.vehicle());});}
BigDecimal input(String value,Kind kind,boolean discount,boolean courier){var n=ServicePriceAdministration.input(value,discount);String max=discount||kind==Kind.AREA&&!courier?"999.99":"9999.99";if(n.abs().compareTo(new BigDecimal(max))>0)throw bad("Der Wert überschreitet den Wertebereich der Tarifdatenbank ("+max+").");return n;}
boolean receipt(LegacyUser u,String token,String hash){var r=db.lockRows("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='zoneadmin' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.userId(),"ng_zone_"+token);if(r.isEmpty())return false;if(r.size()!=1||!text(r.getFirst(),"gdc_content").equals(hash))throw bad("Vorgangskennung gehört zu einer anderen Änderung.");return true;}
void done(LegacyUser u,String token,String hash){db.insert("genericdatacontainer",fields("gdc_obj_type","zoneadmin","gdc_obj_id",u.userId(),"gdc_gen_fieldname","ng_zone_"+token,"gdc_content",hash,"gdc_context","DONE"));}
Map<String,Object> save(LegacyUser u,Save r){access(u);if(r==null||r.cells()==null||r.cells().isEmpty()||r.cells().size()>100)throw bad("Bitte 1 bis 100 geänderte Tarifzellen übergeben.");scope(u,r.query());String token=FilterAdministration.token(r.requestId()),hash=revision(fields("hq",u.headquartersId(),"request",encode(r)));var seen=new HashSet<String>();for(var c:r.cells()){if(c==null||!seen.add(key(c.from(),c.to())))throw bad("Tarifzelle doppelt übergeben.");input(c.value(),r.query().kind(),r.discount(),false);input(c.courier(),r.query().kind(),r.discount(),true);}
return db.writeTransaction(()->{lockHeadquarters(db,u);if(receipt(u,token,hash))return fields("saved",true,"repeated",true);var q=r.query();var s=state(u,q,true);if(!s.revision().equals(r.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Tarife wurden zwischenzeitlich geändert. Bitte neu laden.");var k=q.kind();int changed=0;
for(var c:r.cells()){var old=s.cells().stream().filter(v->id(v,"from")==c.from()&&id(v,"to")==c.to()).findFirst().orElseThrow(()->bad("Tarifzelle ist nicht freigegeben."));if(Boolean.TRUE.equals(old.get("ambiguous")))throw bad("Mehrere Bestandszeilen für diese Tarifzelle. Bitte zuerst im Bestand klären.");var value=input(c.value(),k,r.discount(),false);var courier=input(c.courier(),k,r.discount(),true);var price=amount(old.get("price"));var discount=amount(old.get("discount"));var cr=amount(old.get("courier"));var crDiscount=amount(old.get("courierDiscount"));if(value.compareTo(r.discount()?discount:price)==0&&courier.compareTo(r.discount()?crDiscount:cr)==0)continue;if(r.discount()){discount=value;crDiscount=courier;}else{price=value;cr=courier;}
String where=k.first()+"=? AND "+k.second()+"=? AND hq_id=? AND cs_id=? AND vht_id=?";Object[] keys={c.from(),c.to(),u.headquartersId(),q.customer(),s.vehicle()};String h=k.p+"h",p=k.p+"c";LocalDateTime at=now();var history=db.lockRows("SELECT "+h+"_validfrom AS validFrom FROM "+k.table+"history WHERE "+where+" AND "+h+"_validfrom>=?",c.from(),c.to(),u.headquartersId(),q.customer(),s.vehicle(),at);if(!history.isEmpty())throw bad("Dieser Tarif wurde gerade gespeichert oder hat künftige Historieneinträge. Bitte neu laden bzw. den Bestand klären.");
boolean exists=s.current().stream().anyMatch(v->id(v,"fromId")==c.from()&&id(v,"toId")==c.to());if(exists)db.update("UPDATE "+k.table+"customer SET "+p+"_price=?,"+p+"_discount=?,"+p+"_cr_price=?,"+p+"_cr_discount=? WHERE "+where,price,discount,cr,crDiscount,keys[0],keys[1],keys[2],keys[3],keys[4]);else insertRow(k.table+"customer",fields(k.first(),c.from(),k.second(),c.to(),"hq_id",u.headquartersId(),"cs_id",q.customer(),"vht_id",s.vehicle(),p+"_price",price,p+"_discount",discount,p+"_cr_price",cr,p+"_cr_discount",crDiscount));
insertRow(k.table+"history",fields(k.first(),c.from(),k.second(),c.to(),"hq_id",u.headquartersId(),"cs_id",q.customer(),"vht_id",s.vehicle(),"usr_id",u.userId(),h+"_validfrom",at,h+"_price",price,h+"_discount",discount,h+"_cr_price",cr,h+"_cr_discount",crDiscount));changed++;
}done(u,token,hash);return fields("saved",true,"changed",changed);
});
}
Map<String,Object> details(LegacyUser u,Kind k,long id){access(u);return db.readTransaction(()->detail(u,k,id,false));}
Map<String,Object> detail(LegacyUser u,Kind k,long id,boolean lock){if(lock&&k==Kind.AREA){one(db.lockRows("SELECT srvpa_id FROM serviceplzarea WHERE srvpa_id=? AND hq_id=?",id,u.headquartersId()));db.lockRows("SELECT srvpa_id,srvp_id FROM serviceplzareamapping WHERE srvpa_id=?",id);}var selected=item(u,k,id);String where=k==Kind.POSTAL?"n.hq_id=? AND ":"";var args=new ArrayList<Object>();if(k==Kind.POSTAL)args.add(u.headquartersId());args.add(id);args.add(id);
String sql="SELECT n."+k.first()+" AS fromId,n."+k.second()+" AS toId,n."+k.p+"n_sort AS sort FROM "+k.table+"neighbour n WHERE "+where+"(n."+k.first()+"=? OR n."+k.second()+"=?) ORDER BY n."+k.first()+",n."+k.second()+",n."+k.p+"n_sort";var links=lock?db.lockRows(sql,args.toArray()):db.queryForList(sql,args.toArray());var neighbors=new ArrayList<Map<String,Object>>();var ids=new TreeSet<Long>();for(var link:links)ids.add(id(link,"fromId")==id?id(link,"toId"):id(link,"fromId"));for(long other:ids){var it=item(u,k,other);neighbors.add(fields("id",other,"name",it.get("name"),"symmetric",links.stream().anyMatch(v->id(v,"fromId")==id&&id(v,"toId")==other)&&links.stream().anyMatch(v->id(v,"fromId")==other&&id(v,"toId")==id)));}
var mappings=k==Kind.AREA?db.queryForList("SELECT p.srvp_id AS id,"+label(Kind.POSTAL)+" AS name FROM serviceplzareamapping m JOIN serviceplz p ON p.srvp_id=m.srvp_id WHERE m.srvpa_id=? ORDER BY p.srvp_plz,p.srvp_country,p.srvp_id",id):List.<Map<String,Object>>of();return fields("item",selected,"neighbors",neighbors,"postals",mappings,"revision",revision(fields("item",selected,"links",links,"mappings",mappings)));
}
Map<String,Object> change(LegacyUser u,Change c){access(u);if(c==null||c.kind()==null||c.action()==null)throw bad("Änderung fehlt.");if((c.action()==Action.CREATE||c.action()==Action.RENAME||c.action()==Action.LINK||c.action()==Action.UNLINK)&&c.kind()!=Kind.AREA)throw bad("Diese Änderung gilt nur für Bereiche.");String token=FilterAdministration.token(c.requestId()),hash=revision(fields("hq",u.headquartersId(),"request",encode(c)));
return db.writeTransaction(()->{lockHeadquarters(db,u);if(receipt(u,token,hash))return fields("saved",true,"repeated",true);if(c.action()!=Action.CREATE){var d=detail(u,c.kind(),c.id(),true);if(!Objects.equals(d.get("revision"),c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Bereich oder Verknüpfungen wurden geändert. Bitte neu laden.");}
if(c.action()==Action.CREATE||c.action()==Action.RENAME){String name=required(c.name(),10,"Bereichsname");var duplicates=db.queryForList("SELECT srvpa_id FROM serviceplzarea WHERE hq_id=? AND srvpa_name=? AND srvpa_id<>?",u.headquartersId(),name,c.action()==Action.CREATE?0:c.id());if(!duplicates.isEmpty())throw bad("Dieser Bereichsname existiert bereits.");if(c.action()==Action.CREATE)db.insert("serviceplzarea",fields("hq_id",u.headquartersId(),"srvpa_name",name));else db.update("UPDATE serviceplzarea SET srvpa_name=? WHERE srvpa_id=? AND hq_id=?",name,c.id(),u.headquartersId());
}else{if(c.targets()==null||c.targets().isEmpty()||c.targets().size()>100||new HashSet<>(c.targets()).size()!=c.targets().size()||c.targets().stream().anyMatch(v->v==null||v<=0))throw bad("Bitte 1 bis 100 eindeutige Verknüpfungen auswählen.");for(long target:c.targets()){if(c.action()==Action.LINK||c.action()==Action.UNLINK){item(u,Kind.POSTAL,target);if(c.action()==Action.LINK){var assigned=db.lockRows("SELECT m.srvpa_id FROM serviceplzareamapping m JOIN serviceplzarea a ON a.srvpa_id=m.srvpa_id WHERE a.hq_id=? AND m.srvp_id=?",u.headquartersId(),target);if(assigned.stream().anyMatch(v->id(v,"srvpa_id")!=c.id()))throw bad("Diese PLZ gehört bereits zu einem anderen Bereich dieser Niederlassung. Bitte dort zuerst die Verknüpfung entfernen.");if(assigned.isEmpty())insertRow("serviceplzareamapping",fields("srvpa_id",c.id(),"srvp_id",target));}else db.update("DELETE FROM serviceplzareamapping WHERE srvpa_id=? AND srvp_id=?",c.id(),target);
}else{item(u,c.kind(),target);if(target==c.id())throw bad("Ein Bereich bzw. eine PLZ kann nicht der eigene Nachbar sein.");neighbor(u,c.kind(),c.id(),target,c.action()==Action.NEIGHBOR_ADD);neighbor(u,c.kind(),target,c.id(),c.action()==Action.NEIGHBOR_ADD);}}
}done(u,token,hash);return fields("saved",true);
});
}
// These legacy relation/history tables have no generated primary key.
void insertRow(String table,Map<String,Object> values){db.update("INSERT INTO "+table+" ("+String.join(",",values.keySet())+") VALUES ("+marks(values.size())+")",values.values().toArray());}
void neighbor(LegacyUser u,Kind k,long from,long to,boolean add){String where=k.first()+"=? AND "+k.second()+"=?"+(k==Kind.POSTAL?" AND hq_id=?":"");Object[] args=k==Kind.POSTAL?new Object[]{from,to,u.headquartersId()}:new Object[]{from,to};if(add){if(db.queryForList("SELECT "+k.first()+" FROM "+k.table+"neighbour WHERE "+where,args).isEmpty()){var values=fields(k.first(),from,k.second(),to,k.p+"n_sort",0);if(k==Kind.POSTAL)values.put("hq_id",u.headquartersId());insertRow(k.table+"neighbour",values);}}else db.update("DELETE FROM "+k.table+"neighbour WHERE "+where,args);}
}
@@ -0,0 +1,16 @@
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/administration/zone-prices")
class ZonePriceController {
private final ZonePriceAdministration service;
ZonePriceController(ZonePriceAdministration service){this.service=service;}
@GetMapping Map<String,Object> options(Authentication a){return service.options(user(a));}
@PostMapping("/query") Map<String,Object> query(Authentication a,@RequestBody ZonePriceAdministration.Query q){return service.grid(user(a),q);}
@PutMapping Map<String,Object> save(Authentication a,@RequestBody ZonePriceAdministration.Save s){return service.save(user(a),s);}
@GetMapping("/items") List<Map<String,Object>> items(Authentication a,@RequestParam ZonePriceAdministration.Kind kind,@RequestParam(defaultValue="") String q){return service.search(user(a),kind,q);}
@GetMapping("/details") Map<String,Object> details(Authentication a,@RequestParam ZonePriceAdministration.Kind kind,@RequestParam long id){return service.details(user(a),kind,id);}
@PutMapping("/management") Map<String,Object> change(Authentication a,@RequestBody ZonePriceAdministration.Change c){return service.change(user(a),c);}
}
@@ -9,6 +9,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionException;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import java.sql.Statement;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
@@ -24,6 +27,8 @@ public class DatabaseService {
private static final Pattern SIDE_EFFECT = Pattern.compile("\\b(?:INTO|FOR\\s+UPDATE|LOCK\\s+IN\\s+SHARE\\s+MODE)\\b", Pattern.CASE_INSENSITIVE);
private final JdbcTemplate jdbc;
private final TransactionTemplate readTransaction;
private final TransactionTemplate writeTransaction;
private final ThreadLocal<Boolean> writing = ThreadLocal.withInitial(() -> false);
@Autowired
public DatabaseService(JdbcTemplate jdbc, DataSourceTransactionManager transactions) {
@@ -37,6 +42,7 @@ public class DatabaseService {
this.jdbc = jdbc;
this.readTransaction = new TransactionTemplate(transactions);
this.readTransaction.setReadOnly(true);
this.writeTransaction = new TransactionTemplate(transactions);
}
/** SQL is application-owned; values must be supplied as bound parameters. No raw JDBC is exposed. */
@@ -50,8 +56,40 @@ public class DatabaseService {
return readTransaction(() -> jdbc.queryForObject(sql, type, parameters));
}
@org.springframework.beans.factory.annotation.Value("${app.meta-database-url:}")
private String metaUrl = "";
@org.springframework.beans.factory.annotation.Value("${app.meta-database-user:${spring.datasource.username:}}")
private String metaUser = "";
@org.springframework.beans.factory.annotation.Value("${app.meta-database-password:${spring.datasource.password:}}")
private String metaPassword = "";
/** Read-only access to PHP's existing central metaobject database (personal newsletter hashes). */
public List<Map<String,Object>> queryMetaForList(String sql, Object... parameters) {
requireReadStatement(sql);
String url = metaUrl;
if (url == null || url.isBlank()) {
var configured = queryForList("SELECT par_value FROM parameter WHERE hq_id=0 AND emp_id=0 AND par_key='EXTERNAL_DB_METAOBJECT'");
if (configured.size() > 1) throw new DataAccessResourceFailureException("Mehrdeutige Metaobjekt-Datenbank.");
String host = configured.isEmpty() ? "172.16.0.111:3711" : Objects.toString(configured.getFirst().get("par_value"), "");
if (host.isBlank()) host = "172.16.0.111:3711";
if (!host.matches("[A-Za-z0-9.-]+:[0-9]{1,5}")) throw new DataAccessResourceFailureException("Ungültige Metaobjekt-Datenbankadresse.");
url = "jdbc:mariadb://" + host + "/meta_object?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false";
}
if (!url.startsWith("jdbc:mariadb://")) throw new DataAccessResourceFailureException("Ungültige Metaobjekt-Datenbankkonfiguration.");
var source = new org.springframework.jdbc.datasource.DriverManagerDataSource(url, metaUser, metaPassword);
var manager = new DataSourceTransactionManager(source);
manager.setEnforceReadOnly(true);
var transaction = new TransactionTemplate(manager);
transaction.setReadOnly(true);
try { return transaction.execute(status -> new JdbcTemplate(source).queryForList(sql, parameters)); }
catch (DataAccessException | TransactionException e) {
throw new DataAccessResourceFailureException("Die Metaobjekt-Datenbank für persönliche Newsletter-Abmeldelinks ist nicht erreichbar oder verweigert den Zugriff. META_DB_URL und Zugangsdaten prüfen.", e);
}
}
/** Groups dependent reads (count, orders and stops) in one database transaction. */
public <T> T readTransaction(Supplier<T> work) {
if (writing.get()) return work.get();
try {
return readTransaction.execute(status -> work.get());
} catch (TransactionException exception) {
@@ -59,9 +97,110 @@ public class DatabaseService {
}
}
/** Central entry point for future writes. No incomplete legacy workflow may write today. */
/** Writes are available only inside an explicit, atomic business transaction. */
public <T> T writeTransaction(Supplier<T> work) {
if (writing.get() || TransactionSynchronizationManager.isCurrentTransactionReadOnly())
throw new IllegalStateException("Ein Schreibvorgang benötigt eine eigene Transaktionsgrenze.");
try {
return writeTransaction.execute(status -> {
writing.set(true);
try { return work.get(); }
finally { writing.remove(); }
});
} catch (TransactionException exception) {
throw new DataAccessResourceFailureException(DATABASE_UNAVAILABLE, exception);
}
}
/** Legacy contact IDs use MAX+1 and have no unique constraint. A table lock also
* serializes PHP writers. Explicit commit/rollback must precede UNLOCK TABLES
* (which itself commits in MariaDB). No other business work may run here. */
public <T> T contactWriteTransaction(Supplier<T> work) {
return metafieldWriteTransaction(work, false);
}
/** Keep the MyISAM report and its access scope stable while writing its EAV form. */
public <T> T reportFormWriteTransaction(Supplier<T> work) {
return metafieldWriteTransaction(work, true);
}
private <T> T metafieldWriteTransaction(Supplier<T> work, boolean report) {
Integer transactional=queryForObject("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('metafieldvalue','metafieldvaluehistory','metafieldcategorykey','metafieldkey') AND ENGINE='InnoDB'",Integer.class);
if(!Objects.equals(transactional,4))throw new DataAccessResourceFailureException("Kontaktänderungen benötigen die vorhandenen vier Metafeldtabellen mit InnoDB.");
return writeTransaction(() -> {
jdbc.execute("LOCK TABLES metafieldvalue WRITE, metafieldvaluehistory WRITE, metafieldcategorykey AS ck READ, metafieldkey AS k READ"+(report?", `phoenix_group`.report_process AS r READ, headquarters AS h READ, mandatorheadquarters AS m READ, mandatorheadquarters AS own READ":""));
try {
T result=work.get();
jdbc.execute("COMMIT");
return result;
} catch (RuntimeException | Error e) {
jdbc.execute("ROLLBACK");
throw e;
} finally { jdbc.execute("UNLOCK TABLES"); }
});
}
/** Group deletion spans MyISAM and InnoDB memberships. A short exclusive lock
* prevents the legacy PHP UI from attaching a new member during final cleanup.
* Call only after the durable deletion plan has been committed. */
public <T> T groupDeleteTransaction(Supplier<T> work) {
return writeTransaction(() -> {
jdbc.execute("LOCK TABLES groups WRITE, groups AS member_grp WRITE, customer WRITE, customer AS member_cs WRITE, courier WRITE, courier AS member_cr WRITE, courier AS cr READ, couriervehicle WRITE, couriervehicle AS member_crvh WRITE, employee WRITE, employee AS member_emp WRITE, article WRITE, article AS member_at WRITE, service WRITE, service AS member_srv WRITE, servicetype WRITE, servicetype AS member_srvt WRITE, branch WRITE, branch AS member_br WRITE, metafieldcategory WRITE, metafieldcategory AS member_mtfc WRITE, metafieldtemplate WRITE, metafieldtemplate AS member_mtft WRITE, messagegroup WRITE, genericdatacontainer WRITE, mandator WRITE, mandatorheadquarters READ, mandatorheadquarters AS m READ, headquarters AS h READ, user AS u READ");
try {T result=work.get();jdbc.execute("COMMIT");return result;}
catch(RuntimeException | Error e){jdbc.execute("ROLLBACK");throw e;}
finally {jdbc.execute("UNLOCK TABLES");}
});
}
/** Customer service assignments include legacy MyISAM tables. The durable
* operation is created before this lock; retries validate the saved prefix. */
public <T> T customerServiceWriteTransaction(Supplier<T> work) {
return writeTransaction(() -> {
jdbc.execute("LOCK TABLES customer READ, service READ, servicetype READ, parameter READ, customerservice WRITE, customerservicetype WRITE, genericdatacontainer WRITE");
try {T result=work.get();jdbc.execute("COMMIT");return result;}
catch(RuntimeException | Error e){jdbc.execute("ROLLBACK");throw e;}
finally {jdbc.execute("UNLOCK TABLES");}
});
}
public int update(String sql, Object... parameters) {
throw new DatabaseWritesDisabledException();
requireWriting();
String statement = sql == null ? "" : sql.strip();
if (!statement.matches("(?is)^(INSERT\\s+INTO|UPDATE|DELETE\\s+FROM)\\b.*")
|| statement.contains(";") || statement.contains("--") || statement.contains("/*"))
throw new IllegalArgumentException("Nur einzelne DML-Anweisungen sind zulässig.");
return jdbc.update(sql, parameters);
}
/** Identifiers are application constants, never request input. Values are always bound. */
public long insert(String table, Map<String,Object> values) {
requireWriting();
if (!table.matches("(?:`[A-Za-z0-9_]{1,64}`\\.)?[a-z][a-z0-9_]*") || values.isEmpty()
|| values.keySet().stream().anyMatch(key -> !key.matches("[a-z][a-z0-9_]*")))
throw new IllegalArgumentException("Ungültiger Tabellen- oder Spaltenname.");
String sql = "INSERT INTO " + table + " (" + String.join(",", values.keySet()) + ") VALUES ("
+ String.join(",", java.util.Collections.nCopies(values.size(), "?")) + ")";
var keys = new GeneratedKeyHolder();
jdbc.update(connection -> {
var statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
int index = 1;
for (Object value : values.values()) statement.setObject(index++, value);
return statement;
}, keys);
Number key = keys.getKey();
if (key == null || key.longValue() <= 0)
throw new DataAccessResourceFailureException("Der Datensatz konnte nicht eindeutig angelegt werden.");
return key.longValue();
}
public List<Map<String,Object>> lockRows(String sql, Object... parameters) {
requireWriting();
requireReadStatement(sql);
return jdbc.queryForList(sql + " FOR UPDATE", parameters);
}
private void requireWriting() {
if (!writing.get()) throw new DatabaseWritesDisabledException();
}
public record ConnectionCheck(boolean connected, String reason, String message) {}
@@ -70,7 +209,7 @@ public class DatabaseService {
try {
boolean connected = Objects.equals(queryForObject("SELECT 1", Integer.class), 1);
return new ConnectionCheck(connected, connected ? "CONNECTED" : "UNAVAILABLE", connected
? "Mit der vorhandenen VoTiAn-Datenbank verbunden. Diese Ausbaustufe liest Bestandsdaten; Erfassung und Änderungen erfolgen im bestehenden VoTiAn."
? "Mit der vorhandenen VoTiAn-Datenbank verbunden."
: DATABASE_UNAVAILABLE);
} catch (DataAccessException exception) {
for (Throwable cause = exception; cause != null; cause = cause.getCause()) {
@@ -1,8 +1,8 @@
package de.stadtbote.votian.database;
/** Business write workflows have not yet been ported; the central database boundary enforces this. */
/** A write was attempted outside an explicit business transaction. */
public final class DatabaseWritesDisabledException extends UnsupportedOperationException {
public DatabaseWritesDisabledException() {
super("Schreibzugriffe sind in dieser Ausbaustufe gesperrt. Bitte die bestehende VoTiAn-Anwendung verwenden.");
super("Schreibzugriffe sind nur innerhalb eines freigegebenen Fachvorgangs zulässig.");
}
}
+25 -1
View File
@@ -27,7 +27,8 @@ spring:
validation-timeout: 1000
maximum-pool-size: 4
minimum-idle: 0
read-only: true
# Reads are still enforced as READ ONLY by DatabaseService. Order entry uses explicit write transactions.
read-only: false
data-source-properties:
# Preserve numeric legacy codes, including values above 1 in TINYINT(1).
tinyInt1isBit: false
@@ -35,12 +36,35 @@ spring:
init:
mode: never
jackson:
deserialization:
fail-on-unknown-properties: true
serialization:
write-dates-as-timestamps: false
app:
meta-database-url: ${META_DB_URL:}
meta-database-user: ${META_DB_USER:${DB_USER:}}
meta-database-password: ${META_DB_PASSWORD:${DB_PASSWORD:}}
form-images-directory: ${FORM_IMAGES_DIRECTORY:data/form-images}
groupware-schema: ${GROUPWARE_DB_SCHEMA:phoenix_group}
mail:
host: ${SMTP_HOST:}
port: ${SMTP_PORT:25}
username: ${SMTP_USERNAME:}
password: ${SMTP_PASSWORD:}
starttls: ${SMTP_STARTTLS:true}
ssl: ${SMTP_SSL:false}
from: ${SMTP_FROM:}
sustainability:
url: ${CO2_URL:}
username: ${CO2_USERNAME:}
password: ${CO2_PASSWORD:}
cors-origins: ${CORS_ORIGINS:http://localhost:5173,http://127.0.0.1:5173}
legacy-url: ${LEGACY_APP_URL:}
max-results: ${MAX_RESULTS:500}
routing:
pzm-url: ${PZM_URL:}
fallback-url: ${PZM_FALLBACK_URL:}
google-api-key: ${GOOGLE_MAPS_API_KEY:}
history-schema: ${HISTORY_DB_SCHEMA:phoenix_log}
logging:
level:
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,18 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.AddressAdministration.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class AddressAdministrationTest {
final DatabaseService db=mock(DatabaseService.class);final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);final AddressAdministration service=new AddressAdministration(db,new ObjectMapper(),catalog);
@Test void rightsApplyToEveryEndpoint(){var u=AppointmentServiceTest.user(0);assertThatThrownBy(()->service.options(u,Kind.POSTAL)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.query(u,Kind.AREA,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.create(u,Kind.POSTAL,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.remove(u,Kind.POSTAL,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.resume(u,Kind.POSTAL,"bad")).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verifyNoInteractions(db);}
@Test void validatesRangesAndEscapesSearchWildcards(){assertThat(house("")).isNull();assertThat(house("0012")).isEqualTo(12);assertThatThrownBy(()->house("12a")).hasMessageContaining("Hausnummer");assertThat(like("A_%!B")).isEqualTo("A!_!%!!B%");var s=new Save(UUID.randomUUID().toString(),"10000","Straße","10000","Ort","DE","20","10",0);assertThatThrownBy(()->service.create(AppointmentServiceTest.user(2),Kind.POSTAL,s)).hasMessageContaining("größer");verifyNoInteractions(db);}
@Test void rangeAndParityChooseSpecificPostalMapping(){var station=OrderEntryFixtures.request().stops().getFirst();when(db.queryForList(contains("FROM serviceplzaddress m"),any(Object[].class))).thenReturn(List.of(fields("id",1,"targetId",101,"houseFrom",null,"houseTo",null,"mode",0),fields("id",2,"targetId",102,"houseFrom",0,"houseTo","10","mode",2),fields("id",3,"targetId",103,"houseFrom",1,"houseTo","3","mode",1)));assertThat(LegacyAddressTariffs.postal(db,7,station)).isEqualTo(103);verify(db).queryForList(contains("ESCAPE '!'"),eq(7L),eq("20095"),eq("DE"),eq("Teststraße%"));}
@Test void areaHonorsExactHouseSuffixOverride(){var original=OrderEntryFixtures.request().stops().getFirst();var tree=new ObjectMapper().valueToTree(original);((com.fasterxml.jackson.databind.node.ObjectNode)tree).put("houseNumber","1 a");var station=new ObjectMapper().convertValue(tree,OrderEntryModels.Station.class);when(db.queryForList(contains("FROM serviceplzareaaddress m"),any(Object[].class))).thenReturn(List.of(fields("id",5,"targetId",201,"houseFrom",null,"houseTo","9","mode",1)));when(catalog.parameter(0,"MASK_AREA_ID_FROM_ADDRESS_5")).thenReturn("1a,202;1b,203");when(db.queryForList(startsWith("SELECT srvpa_id FROM serviceplzarea"),eq(202L),eq(7L))).thenReturn(List.of(Map.of("srvpa_id",202)));assertThat(LegacyAddressTariffs.area(db,catalog,7,101,station)).isEqualTo(202);}
@Test void zeroUpperBoundIsNotAnUnlimitedRange(){assertThat(LegacyAddressTariffs.bound(Map.of("to","0"),"to",99999)).isZero();assertThat(LegacyAddressTariffs.bound(Map.of("to",""),"to",99999)).isEqualTo(99999);}
}
@@ -0,0 +1,27 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.AddressAdministration.*;
public class AddressIntegrationAssertions {
public static void verify(DatabaseService db){
var json=new ObjectMapper();var user=AppointmentServiceTest.user(2);var service=new AddressAdministration(db,json,new OrderEntryCatalog(db));
var query=new Query("","","","","","","",null,"street",0);
var save=new Save(UUID.randomUUID().toString(),"10000","Musterstraße","20095","Hamburg","DE","1","20",1);
assertThat(service.create(user,Kind.POSTAL,save)).containsEntry("state","DONE");assertThat(service.create(user,Kind.POSTAL,save)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzaddress",Integer.class)).isEqualTo(1);
assertThat(service.query(user,Kind.POSTAL,query)).containsEntry("total",1L);
var row=db.queryForList(service.select(Kind.POSTAL),7).getFirst();assertThat(row).containsEntry("target","10000").containsEntry("street","Musterstraße");
assertThatThrownBy(()->service.remove(user,Kind.POSTAL,new Delete(UUID.randomUUID().toString(),List.of(new Selection(LegacyMapping.id(row,"id"),"stale"))))).hasMessageContaining("409");
var wrong=new LegacyUser(user.userId(),user.employeeId(),8,user.username(),user.rights());assertThat(service.query(wrong,Kind.POSTAL,query)).containsEntry("total",0L);assertThatThrownBy(()->service.remove(wrong,Kind.POSTAL,new Delete(UUID.randomUUID().toString(),List.of(new Selection(LegacyMapping.id(row,"id"),AdminSupport.revision(row)))))).hasMessageContaining("404");
var failing=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("commit status unavailable")).when(failing).update(startsWith("UPDATE genericdatacontainer SET gdc_context='DONE'"),any(Object[].class));var interrupted=new AddressAdministration(failing,json,new OrderEntryCatalog(failing));
var area=new Save(UUID.randomUUID().toString(),"42","Neue Straße","20095","Hamburg","DE","","",2);
assertThat(interrupted.create(user,Kind.AREA,area)).containsEntry("state","PENDING");assertThat(db.queryForObject("SELECT COUNT(*) FROM address WHERE ad_street='Neue Straße'",Integer.class)).isEqualTo(1);assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaaddress",Integer.class)).isEqualTo(1);
assertThat(service.resume(user,Kind.AREA,area.requestId())).containsEntry("state","DONE");assertThat(service.create(user,Kind.AREA,area)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaaddress",Integer.class)).isEqualTo(1);
var first=db.queryForList(service.select(Kind.AREA),7).getFirst();assertThat(first).containsEntry("target","0042");assertThat(service.exceptions(user,LegacyMapping.id(first,"id"))).containsExactly(Map.of("house","1a","area","0043"));
var remove=new Delete(UUID.randomUUID().toString(),List.of(new Selection(LegacyMapping.id(first,"id"),AdminSupport.revision(first))));assertThat(interrupted.remove(user,Kind.AREA,remove)).containsEntry("state","PENDING");assertThat(service.resume(user,Kind.AREA,remove.requestId())).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaaddress",Integer.class)).isZero();assertThat(db.queryForObject("SELECT COUNT(*) FROM address WHERE ad_street='Neue Straße'",Integer.class)).isEqualTo(1);
assertThat(service.query(user,Kind.POSTAL,new Query("NOPE","","","","","","",null,"street",0))).containsEntry("total",0L);
}
}
@@ -64,6 +64,87 @@ class ApiSecurityTest {
return (MockHttpSession) login(csrf(null),username,password,200).getRequest().getSession(false);
}
@Test void crmAndMailMutationsRequireSessionAndCsrf() throws Exception {
mvc.perform(get("/api/crm/options")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
for(String path:List.of("/api/crm/contacts","/api/crm/targets","/api/appointments/44/notifications/preview","/api/appointments/notifications/test/send"))
mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());verify(database,never()).contactWriteTransaction(any());
}
@Test void messagesAndFormMutationsRequireSessionAndCsrf() throws Exception {
for(String path:List.of("/api/messages/options","/api/forms","/api/newsletters/options","/api/newsletters/mail/options","/api/evaluations/options"))
mvc.perform(get(path)).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
for(String path:List.of("/api/messages/bulletins","/api/messages/preview","/api/messages/send/token","/api/messages/groups","/api/forms/categories","/api/forms/fields","/api/forms/templates","/api/newsletters","/api/newsletters/images","/api/newsletters/12/mail/preview","/api/newsletters/mail/token/send","/api/evaluations","/api/evaluations/12/mail/preview","/api/evaluations/mail/token/send"))
mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(put("/api/forms/categories/10/fields").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
var token=csrf(session);
mvc.perform(get("/api/forms").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/newsletters/options").session(session)).andExpect(status().isForbidden());
mvc.perform(post("/api/messages/bulletins").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void zoneTariffsAndManagementRequireSessionCsrfAndAdministrationRight() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password"))).thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","1")));
mvc.perform(get("/api/administration/zone-prices")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
for(String path:List.of("/api/administration/zone-prices","/api/administration/zone-prices/management"))
mvc.perform(put(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
var token=csrf(session);
mvc.perform(put("/api/administration/zone-prices/management").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void tariffMutationsRequireSessionCsrfAndGeneralPriceRight() throws Exception {
mvc.perform(get("/api/administration/service-prices")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(put("/api/administration/service-prices").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(put("/api/administration/service-prices/definitions").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
var token=csrf(session);
mvc.perform(post("/api/administration/service-prices/query").session(session).header(token.header(),token.token()).contentType("application/json").content("{\"mode\":0,\"customer\":0,\"date\":\"2026-10-13\"}")).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void filterAdministrationRequiresSessionCsrfAndManageRight() throws Exception {
mvc.perform(get("/api/administration/filters")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(get("/api/administration/filters").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.manage").value(false));
mvc.perform(post("/api/administration/filters").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
var token=csrf(session);
mvc.perform(post("/api/administration/filters").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(delete("/api/administration/filters/1").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(post("/api/administration/filters/operations/00000000-0000-0000-0000-000000000000/resume").session(session).header(token.header(),token.token())).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void addressAdministrationRequiresSessionCsrfAndHeadquartersRight() throws Exception {
mvc.perform(get("/api/administration/addresses/POSTAL")).andExpect(status().isUnauthorized());
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","1")));
var session=authenticated("existing-user","existing-password");var token=csrf(session);
mvc.perform(get("/api/administration/addresses/POSTAL").session(session)).andExpect(status().isForbidden());
for(String path:List.of("/api/administration/addresses/POSTAL","/api/administration/addresses/AREA/remove","/api/administration/addresses/AREA/query","/api/administration/addresses/AREA/operations/00000000-0000-0000-0000-000000000000/resume")) {
mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
mvc.perform(post(path).session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
}
verify(database,never()).writeTransaction(any());
}
@Test void businessWorkflowsRequireSessionCsrfAndDomainRights() throws Exception {
for(String path:List.of("/api/master-data/customers/1","/api/orders/1/workbench","/api/orders/1/cancellation","/api/orders/1/articles","/api/orders/1/calculation","/api/billing/options"))mvc.perform(get(path)).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
for(String path:List.of("/api/orders/1/workbench","/api/orders/1/price","/api/master-data/customers/1"))mvc.perform(put(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
for(String path:List.of("/api/orders/1/actions","/api/orders/1/cancellation","/api/orders/1/cancellation/preview","/api/orders/1/articles/movement","/api/orders/1/articles/movement/preview","/api/billing/assign","/api/billing/operations/test/resume"))mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("none"),eq("password"),eq("password"))).thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","none","emp_rights","0000000000")));
var restricted=authenticated("none","password");var token=csrf(restricted);
mvc.perform(put("/api/master-data/customers/1").session(restricted).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(post("/api/orders/1/actions").session(restricted).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(post("/api/billing/assign").session(restricted).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void healthAndSystemArePublicButDataRequiresSession() throws Exception {
mvc.perform(get("/api/health").with(httpBasic("proxy-fixture","proxy-password")))
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("UP"));
@@ -80,6 +161,20 @@ class ApiSecurityTest {
.andExpect(status().isUnauthorized());
}
@Test void reportsRequireSessionCsrfAndTheirSeparateLegacyRight() throws Exception {
mvc.perform(get("/api/administration/reports/CUSTOMER/options")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(get("/api/administration").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.customerReports").value(false)).andExpect(jsonPath("$.courierReports").value(false));
mvc.perform(get("/api/administration/reports/CUSTOMER/options").session(session)).andExpect(status().isForbidden());
mvc.perform(post("/api/administration/reports/CUSTOMER").session(session).contentType("application/json").content("{}"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
var token=csrf(session);
mvc.perform(post("/api/administration/reports/CUSTOMER").session(session).header(token.header(),token.token()).contentType("application/json").content("{}"))
.andExpect(status().isForbidden());
verify(database,never()).insert(anyString(),anyMap());
}
@Test void existingLoginPersistsSessionRotatesIdAndStoresNoPassword() throws Exception {
Csrf anonymous = csrf(null);
String previousId = anonymous.session().getId();
@@ -94,7 +189,7 @@ class ApiSecurityTest {
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.headquartersId").value(7)).andExpect(jsonPath("$.roles[0]").value("VIEWER"))
.andExpect(jsonPath("$.password").doesNotExist()).andExpect(jsonPath("$.capabilities.exportOrders").value(true))
.andExpect(jsonPath("$.capabilities.writeOrders").value(false));
.andExpect(jsonPath("$.capabilities.writeOrders").value(true));
mvc.perform(get("/api/me").session(session).with(httpBasic("proxy-fixture","proxy-password")))
.andExpect(status().isOk()).andExpect(jsonPath("$.username").value("existing-user"));
verify(database,times(1)).queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),any(),any(),any());
@@ -148,7 +243,7 @@ class ApiSecurityTest {
verify(database,never()).queryForList(anyString(),any(),any(),any());
}
@Test void noWriteEndpointCanModifyTheDatabase() throws Exception {
@Test void unsupportedMutationsRemainDisabled() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
Csrf csrf = csrf(session);
mvc.perform(post("/api/orders/12/cancel").session(session).header(csrf.header(),csrf.token())
@@ -157,6 +252,46 @@ class ApiSecurityTest {
verify(database,never()).update(anyString(),any(Object[].class));
}
@Test void orderEntryHasItsOwnRightAndStillRequiresCsrf() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("entry"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","entry","emp_rights","0000000100")));
var session=authenticated("entry","password");
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.capabilities.createOrders").value(true)).andExpect(jsonPath("$.capabilities.readCustomers").value(false))
.andExpect(jsonPath("$.capabilities.writeOrders").value(false));
mvc.perform(get("/api/orders/entry/options").session(session)).andExpect(status().isOk());
mvc.perform(get("/api/orders/entry/cost-centers").session(session)).andExpect(status().isOk());
mvc.perform(get("/api/orders/entry/vehicles").session(session)).andExpect(status().isOk());
mvc.perform(get("/api/customers").session(session)).andExpect(status().isForbidden());
for(String endpoint:List.of("/api/orders","/api/orders/quote"))
mvc.perform(post(endpoint).session(session).contentType("application/json").content(json.writeValueAsString(OrderEntryFixtures.request())))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
verify(database,never()).writeTransaction(any());
}
@Test void orderEntryRejectsUnknownServerOwnedFieldsAndInvalidCommands() throws Exception {
var csrf=csrf(authenticated("existing-user","existing-password"));
var input=json.valueToTree(OrderEntryFixtures.request());
((com.fasterxml.jackson.databind.node.ObjectNode)input).put("hq_id",999);
mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token())
.contentType("application/json").content(json.writeValueAsString(input))).andExpect(status().isBadRequest());
mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token())
.contentType("application/json").content("{}")).andExpect(status().isBadRequest());
verify(database,never()).writeTransaction(any());
}
@Test void orderEntryIsForbiddenWithoutRightSeven() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("viewer"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","viewer","emp_rights","1111111011")));
var csrf=csrf(authenticated("viewer","password"));
mvc.perform(get("/api/orders/entry/options").session(csrf.session())).andExpect(status().isForbidden());
for(String endpoint:List.of("/api/orders","/api/orders/quote"))
mvc.perform(post(endpoint).session(csrf.session()).header(csrf.header(),csrf.token())
.contentType("application/json").content(json.writeValueAsString(OrderEntryFixtures.request())))
.andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void backendDatabaseFailureIs503NotFakeLoginOrEmptyDataset() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("offline"),eq("password"),eq("password")))
.thenThrow(new DataAccessResourceFailureException("server secret must not be exposed"));
@@ -238,4 +373,48 @@ class ApiSecurityTest {
.andExpect(jsonPath("$.reason").value("AUTHENTICATION_FAILED"))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Hostfreigabe")));
}
@Test void inventoryAndMapEndpointsRequireEntryRightsAndStockRights() throws Exception {
mvc.perform(get("/api/orders/entry/articles")).andExpect(status().isUnauthorized());
mvc.perform(get("/api/orders/entry/features")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(get("/api/orders/entry/features").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.bookStock").value(false));
mvc.perform(get("/api/orders/entry/stocks").session(session).param("payerId","41").param("articleId","3")).andExpect(status().isForbidden());
mvc.perform(post("/api/orders/entry/map/address").session(session).contentType("application/json").content("{\"latitude\":53,\"longitude\":9}")).andExpect(status().isForbidden());
var csrf=csrf(session);
mvc.perform(post("/api/orders/entry/map/address").session(session).header(csrf.header(),csrf.token()).contentType("application/json").content("{\"latitude\":91,\"longitude\":9}")).andExpect(status().isBadRequest());
}
@Test void administrationAndStatisticsRequireSessionRightsAndCsrf() throws Exception {
mvc.perform(get("/api/administration")).andExpect(status().isUnauthorized());
mvc.perform(get("/api/administration/holidays/2026")).andExpect(status().isUnauthorized());
mvc.perform(get("/api/statistics/options")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(get("/api/administration").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.holidays").value(true)).andExpect(jsonPath("$.groups").value(false));
mvc.perform(put("/api/administration/holidays/2026").session(session)
.contentType("application/json").content("{}"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
var token=csrf(session);
mvc.perform(post("/api/administration/groups").session(session).header(token.header(),token.token())
.contentType("application/json").content("{\"name\":\"x\",\"shared\":false}"))
.andExpect(status().isForbidden());
mvc.perform(get("/api/administration/travel-times").session(session).param("mode","unknown").param("from","1").param("to","2"))
.andExpect(status().isBadRequest());
verify(database,never()).update(anyString(),any(Object[].class));
}
@Test void appointmentsRequireSessionSalesRightsAndCsrf() throws Exception {
mvc.perform(get("/api/appointments/options")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.capabilities.readAppointments").value(true));
for(String endpoint:List.of("/api/appointments","/api/appointments/query","/api/appointments/44/confirm","/api/appointments/44/finish","/api/appointments/44/resubmit"))
mvc.perform(post(endpoint).session(session).contentType("application/json").content("{}"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("sales-disabled"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","sales-disabled","emp_rights","100000000000")));
var restricted=authenticated("sales-disabled","password");
mvc.perform(get("/api/appointments/options").session(restricted)).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
}
@@ -0,0 +1,28 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.Test;
import java.net.*;import java.io.*;import java.nio.charset.StandardCharsets;import java.util.*;import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
class AppointmentMailTransportTest {
@Test void deliversUtf8TextToOnlyExplicitEnvelopeRecipientsOnLocalSmtp() throws Exception {
try(var server=new ServerSocket(0,1,InetAddress.getByName("127.0.0.1"));var executor=Executors.newSingleThreadExecutor()) {
server.setSoTimeout(5000);var received=executor.submit(()->{var lines=new ArrayList<String>();try(var socket=server.accept()){socket.setSoTimeout(5000);var reader=new BufferedReader(new InputStreamReader(socket.getInputStream(),StandardCharsets.UTF_8));var writer=new PrintWriter(new OutputStreamWriter(socket.getOutputStream(),StandardCharsets.UTF_8),true);writer.print("220 localhost test\r\n");writer.flush();boolean data=false;for(String line;(line=reader.readLine())!=null;){lines.add(line);String response;if(data){if(!line.equals("."))continue;data=false;response="250 accepted";}else if(line.startsWith("DATA")){data=true;response="354 end with dot";}else if(line.startsWith("QUIT")){writer.print("221 bye\r\n");writer.flush();break;}else response="250 OK";writer.print(response+"\r\n");writer.flush();}}return lines;});
var transport=new AppointmentMailTransport("127.0.0.1",server.getLocalPort(),"","",false,false,"");transport.send("actor@example.test","reply@example.test",List.of("one@example.test","two@example.test"),"Termin geändert","Grüße aus Berlin");var lines=received.get(10,TimeUnit.SECONDS);assertThat(lines).contains("RCPT TO:<one@example.test>","RCPT TO:<two@example.test>","MAIL FROM:<actor@example.test>");assertThat(lines).anyMatch(l->l.startsWith("Content-Type: text/plain;")&&l.contains("UTF-8"));assertThat(lines).noneMatch(l->l.startsWith("Bcc:"));
}
}
@Test void deliversHtmlWithInlineImageAndPrivateBccThroughLocalSmtp() throws Exception {
try(var server=new ServerSocket(0,1,InetAddress.getByName("127.0.0.1"));var executor=Executors.newSingleThreadExecutor()) {
server.setSoTimeout(5000);var received=executor.submit(()->{var lines=new ArrayList<String>();try(var socket=server.accept()){socket.setSoTimeout(5000);var reader=new BufferedReader(new InputStreamReader(socket.getInputStream(),StandardCharsets.UTF_8));var writer=new PrintWriter(new OutputStreamWriter(socket.getOutputStream(),StandardCharsets.UTF_8),true);writer.print("220 localhost test\r\n");writer.flush();boolean data=false;for(String line;(line=reader.readLine())!=null;){lines.add(line);String response;if(data){if(!line.equals("."))continue;data=false;response="250 accepted";}else if(line.startsWith("DATA")){data=true;response="354 end with dot";}else if(line.startsWith("QUIT")){writer.print("221 bye\r\n");writer.flush();break;}else response="250 OK";writer.print(response+"\r\n");writer.flush();}}return lines;});
var transport=new AppointmentMailTransport("127.0.0.1",server.getLocalPort(),"","",false,false,"");
transport.sendHtml("actor@example.test","reply@example.test","to@example.test",List.of("cc@example.test"),List.of("hidden@example.test"),"Newsletter", "<h1>Abholung</h1><img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='>");
var lines=received.get(10,TimeUnit.SECONDS);assertThat(lines).contains("RCPT TO:<to@example.test>","RCPT TO:<cc@example.test>","RCPT TO:<hidden@example.test>","MAIL FROM:<actor@example.test>");assertThat(lines).noneMatch(l->l.startsWith("Bcc:"));
int begin=lines.indexOf("DATA")+1;int end=lines.subList(begin,lines.size()).indexOf(".")+begin;
var message=new jakarta.mail.internet.MimeMessage(jakarta.mail.Session.getInstance(new Properties()),new ByteArrayInputStream(String.join("\r\n",lines.subList(begin,end)).getBytes(StandardCharsets.UTF_8)));
assertThat(message.getRecipients(jakarta.mail.Message.RecipientType.TO)).hasSize(1);assertThat(message.getRecipients(jakarta.mail.Message.RecipientType.CC)).hasSize(1);assertThat(message.getRecipients(jakarta.mail.Message.RecipientType.BCC)).isNull();
var parts=new ArrayList<jakarta.mail.Part>();collect(message,parts);
assertThat(parts.stream().filter(p->{try{return p.isMimeType("text/html");}catch(Exception e){throw new RuntimeException(e);}}).toList()).singleElement().satisfies(p->{try{assertThat(p.getContent().toString()).contains("cid:newsletter-image-0").doesNotContain("data:image");}catch(Exception e){throw new RuntimeException(e);}});
assertThat(parts.stream().filter(p->{try{return p.isMimeType("image/png");}catch(Exception e){throw new RuntimeException(e);}}).toList()).singleElement().satisfies(p->{try{assertThat(p.getHeader("Content-ID")).containsExactly("<newsletter-image-0>");assertThat(p.getInputStream().readAllBytes()).hasSizeGreaterThan(50);}catch(Exception e){throw new RuntimeException(e);}});
}
}
static void collect(jakarta.mail.Part part,List<jakarta.mail.Part> parts)throws Exception{if(part.isMimeType("multipart/*")){var multi=(jakarta.mail.Multipart)part.getContent();for(int i=0;i<multi.getCount();i++)collect(multi.getBodyPart(i),parts);}else parts.add(part);}
}
@@ -0,0 +1,21 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import java.util.*;import java.time.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class AppointmentNotificationsTest {
final DatabaseService db=mock(DatabaseService.class);final AppointmentService appointments=mock(AppointmentService.class);final AppointmentMailTransport transport=spy(new AppointmentMailTransport("127.0.0.1",1,"","",false,false,""));final AppointmentNotifications service=new AppointmentNotifications(db,appointments,transport);final LegacyUser user=AppointmentServiceTest.user(0,11);
final Map<String,Object> row=fields("ap_id",44L,"usr_id",11L,"ap_participants",",11,22,33,","ap_text","<script>Plain text only</script>","ap_execdate",LocalDateTime.of(2026,10,13,9,0),"ap_enddate",LocalDateTime.of(2026,10,13,10,0));
AppointmentNotifications.Draft draft(){return new AppointmentNotifications.Draft(AppointmentNotifications.Kind.CHANGED,true,"TEST@example.test,external@example.test","Hallo","Viele Grüße");}
@BeforeEach void setup(){when(appointments.appointment(user,44)).thenReturn(row);when(db.queryForList(startsWith("SELECT usr_email"),any(Object[].class))).thenReturn(List.of(fields("usr_email","test@example.test")));when(db.queryForList(startsWith("SELECT DISTINCT u.usr_id"),any(Object[].class))).thenReturn(List.of(fields("usr_id",11L,"usr_email","test@example.test","label","Ich"),fields("usr_id",22L,"usr_email","person@example.test","label","Teilnehmer"),fields("usr_id",33L,"usr_email","","label","Ohne Mail")));doNothing().when(transport).send(anyString(),anyString(),anyList(),anyString(),anyString());}
@Test void previewDeduplicatesRecipientsAndReportsMissingAddressesWithoutSending(){var p=service.preview(user,44,draft());assertThat((List<?>)p.get("recipients")).hasSize(3);assertThat((List<String>)p.get("missing")).containsExactly("Ohne Mail");assertThat(p.get("body").toString()).contains("Plain text only");verify(transport,never()).send(anyString(),anyString(),anyList(),anyString(),anyString());}
@Test void uncheckedParticipantsAreNotSentExternalOnlyNotification(){var p=service.preview(user,44,new AppointmentNotifications.Draft(AppointmentNotifications.Kind.NEW,false,"external@example.test","",""));assertThat((List<String>)p.get("recipients")).containsExactly("external@example.test");}
@Test void headerInjectionAndAddressListsInSingleAddressAreRejected(){for(String value:List.of("x@example.test\r\nBcc:evil@example.test","a@example.test,b@example.test","Name <a@example.test>","not-an-address"))assertThatThrownBy(()->AppointmentNotifications.address(value)).hasMessageContaining("400");}
@Test void repeatedSendOfSamePreviewDoesNotDuplicateMail(){var p=service.preview(user,44,draft());String token=(String)p.get("token");assertThat(service.send(user,token)).containsEntry("sent",true);service.send(user,token);verify(transport,times(1)).send(anyString(),anyString(),anyList(),anyString(),anyString());verify(db,never()).update(anyString(),any(Object[].class));}
@Test void failedSendNeverRewritesAppointmentOrAutomaticallyRetries(){doThrow(new org.springframework.mail.MailSendException("private relay details")).when(transport).send(anyString(),anyString(),anyList(),anyString(),anyString());String token=(String)service.preview(user,44,draft()).get("token");var r=service.send(user,token);assertThat(r).containsEntry("sent",false);assertThat(r.toString()).doesNotContain("private relay");service.send(user,token);verify(transport,times(1)).send(anyString(),anyString(),anyList(),anyString(),anyString());verify(db,never()).writeTransaction(any());}
@Test void changedAppointmentRequiresNewPreview(){String token=(String)service.preview(user,44,draft()).get("token");row.put("ap_text","changed");assertThatThrownBy(()->service.send(user,token)).hasMessageContaining("409");verify(transport,never()).send(anyString(),anyString(),anyList(),anyString(),anyString());}
@Test void previewCannotBeUsedByOtherAccountAndCancellationRequiresDeleteRight(){String token=(String)service.preview(user,44,draft()).get("token");var other=new LegacyUser(99,99,7,"other",user.rights());assertThatThrownBy(()->service.send(other,token)).hasMessageContaining("400");row.put("usr_id",22L);assertThatThrownBy(()->service.preview(user,44,new AppointmentNotifications.Draft(AppointmentNotifications.Kind.CANCELLED,true,"","",""))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);}
}
@@ -0,0 +1,161 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.time.*;
import java.util.*;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.AppointmentService.*;
class AppointmentServiceTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final AppointmentService service=new AppointmentService(db,catalog);
final LegacyUser user=user(0,11),master=user(0,3,10,11);
final LocalDate date=LocalDate.of(2026,10,12);
final List<String> queries=new ArrayList<>();
final List<List<Object>> arguments=new ArrayList<>();
Map<String,Object> row=fields("ap_id",44L,"hq_id",7L,"usr_id",11L,"ap_type",0,"ap_category_1",1,"ap_category_2",1,"ap_category_3",2,"ap_category_4",1,"ap_participants",",11,22,","ap_text","Besprechung","ap_execdate",date.atTime(9,0),"ap_enddate",date.atTime(10,0),"ap_confirmed",",22,","ap_modifytime",date.atTime(8,0),"cs_id",9L,"csc_id",0,"pt_id",0,"customer_label","Testkunde");
boolean missing=false,reportExists=false;
Map<String,Object> inserted;String updatedSql;List<Object> updatedArgs;
static LegacyUser user(int... rights) { char[] bits="0".repeat(70).toCharArray();for(int r:rights)bits[r]='1';return new LegacyUser(11,12,7,"test",new String(bits)); }
Query query() { return new Query(date,date,List.of(7L),null,null,null,null,null,List.of(Visibility.values()),""); }
Save save() { return new Save(AdminSupport.revision(version(row)),date.atTime(9,0),date.atTime(10,0),"Neu",1,1,2,1,9L,List.of(11L,22L)); }
Save with(Save r,LocalDateTime from,LocalDateTime to,int monitor,Long customer,List<Long> people,String text) { return new Save(r.revision(),from,to,text,r.status(),r.visibility(),r.kind(),monitor,customer,people); }
@BeforeEach void setup() {
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{
String sql=c.getArgument(0);var args=Arrays.asList(c.getArguments()).subList(1,c.getArguments().length);queries.add(sql);arguments.add(args);
if(sql.startsWith("SELECT mt_sort"))return args.getFirst().equals("appointment_category_4")?List.of(fields("id",0L,"label","Keine"),fields("id",1L,"label","Aktiv")):List.of(fields("id",1L,"label","Eins"),fields("id",2L,"label","Zwei"),fields("id",3L,"label","Drei"));
if(sql.startsWith("SELECT DISTINCT h.hq_id"))return sql.contains(" AND h.hq_id=?")?List.of(fields("id",7L,"label","B")):List.of(fields("id",7L,"label","B"),fields("id",8L,"label","HH"));
if(sql.startsWith("SELECT u.usr_id AS id"))return List.of(fields("id",11L,"label","Tester","groups",",24,"),fields("id",22L,"label","Teilnehmer","groups",",24,"));
if(sql.startsWith("SELECT DISTINCT u.usr_id"))return List.of(fields("id",11L,"label","Tester"),fields("id",22L,"label","Teilnehmer"));
if(sql.startsWith("SELECT cs_id FROM"))return List.of(fields("cs_id",9L));
if(sql.startsWith("SELECT md_id"))return List.of(fields("md_id",1L));
if(sql.startsWith("SELECT grp_id"))return List.of(fields("id",24L,"label","Dispo"));
if(sql.startsWith("SELECT a.ap_category_1"))return List.of(fields("ap_category_1",1,"ap_category_2",1,"ap_category_3",2,"ap_category_4",1,"total",3));
if(sql.startsWith("SELECT a.ap_id"))return missing?List.of():List.of(row);
if(sql.startsWith("SELECT rp_id"))return reportExists?List.of(fields("rp_id",1L)):List.of();
return List.of();
});
when(db.insert(anyString(),anyMap())).thenAnswer(c->{inserted=new LinkedHashMap<>(c.getArgument(1));return 45L;});
when(db.update(anyString(),any(Object[].class))).thenAnswer(c->{updatedSql=c.getArgument(0);updatedArgs=Arrays.asList(c.getArguments()).subList(1,c.getArguments().length);return 1;});
}
@Test void bothSalesAndCustomerRightsRequiredBeforeDataAccess() {
for(var who:List.of(user(0),user(11))) {
assertThatThrownBy(()->service.list(who,query())).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.create(who,save())).isInstanceOf(AccessDeniedException.class);
assertThat(who.capabilities().readAppointments()).isFalse();
}
verifyNoInteractions(db,catalog);assertThat(user.capabilities().readAppointments()).isTrue();
}
@Test void privacyRemainsMandatoryEvenForMasterAndAllVisibilityFilters() {
for(var who:List.of(user,master)) {service.list(who,query());service.statistics(who,query());}
assertThat(queries.stream().filter(q->q.contains("FROM "+TABLE))).hasSize(4).allMatch(q->q.contains("(a.usr_id=? OR a.ap_category_2=3 OR (a.ap_category_2=1 AND a.ap_participants LIKE ?))"));
assertThat(arguments.stream().filter(a->a.contains("%,11,%"))).hasSize(4);
}
@Test void overlapsAndPersonFiltersUseBoundWholeIds() {
var q=new Query(date,date,List.of(7L),22L,9L,1,2,1,List.of(Visibility.INVITED),"' OR 1=1");
service.list(user,q);
int i=queries.size()-2; // participant name lookup follows the calendar SELECT
assertThat(queries.get(i)).contains("a.ap_execdate<? AND (a.ap_execdate>=? OR a.ap_enddate>=?)","a.cs_id=?","a.ap_category_3=?","a.ap_text LIKE ?").doesNotContain("' OR 1=1");
assertThat(arguments.get(i)).contains("%,22,%","%' OR 1=1%",date.plusDays(1).atStartOfDay(),date.atStartOfDay());
}
@Test void crossHeadquartersRequiresMasterWithinSameMandator() {
var q=new Query(date,date,List.of(8L),null,null,null,null,null,query().visibility(),"");
assertThatThrownBy(()->service.list(user,q)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.list(master,new Query(date,date,List.of(99L),null,null,null,null,null,query().visibility(),""))).isInstanceOf(AccessDeniedException.class);
assertThat(queries).noneMatch(s->s.contains("FROM "+TABLE));
service.list(master,q);
}
@Test void emptyAndInvalidFiltersNeverBroadenToAllAppointments() {
for(var q:List.of(new Query(date,date,List.of(),null,null,null,null,null,query().visibility(),""),new Query(date,date,List.of(7L),null,null,null,null,null,List.of(),""),new Query(date,date.plusDays(366),List.of(7L),null,null,null,null,null,query().visibility(),"")))assertThatThrownBy(()->service.list(user,q)).hasMessageContaining("400");
assertThat(queries).noneMatch(s->s.contains("FROM "+TABLE));
validate(new Query(LocalDate.of(2024,1,1),LocalDate.of(2024,12,31),List.of(7L),null,null,null,null,null,query().visibility(),""),false);
}
@Test void statisticsCountsAppointmentsOnceAndIncludesUnusedCategories() {
var result=service.statistics(user,query());
assertThat(result).containsEntry("total",3L);
@SuppressWarnings("unchecked") var categories=(List<Map<String,Object>>)result.get("categories");
assertThat(categories).hasSize(4);assertThat(categories.get(0).get("items").toString()).contains("total=0","total=3");
assertThat(queries).anyMatch(s->s.contains("COUNT(*) AS total")&&s.contains("GROUP BY a.ap_category_1"));
}
@Test void optionsIncludesRealEmployeeGroupsAndCompletedControl() {
var result=service.options(user);
assertThat(result.get("groups").toString()).contains("Dispo","11","22");
assertThat(result.get("categories").toString()).contains("Erledigt");
}
@Test void createsInSessionHeadquartersWithNoInheritedConfirmations() {
assertThat(service.create(user,save())).containsEntry("id",45L);
assertThat(inserted).containsEntry("usr_id",11L).containsEntry("hq_id",7L).containsEntry("ap_confirmed","").containsEntry("ap_participants",",11,22,").containsEntry("csc_id",null);
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void malformedTimesUnknownParticipantsAndLargeUtf8TextCannotBeSaved() {
var s=save();
for(var invalid:List.of(with(s,s.endsAt(),s.startsAt(),1,9L,s.participants(),"x"),with(s,s.startsAt().plusSeconds(1),s.endsAt(),1,9L,s.participants(),"x"),with(s,s.startsAt(),s.endsAt(),1,9L,List.of(999L),"x"),with(s,s.startsAt(),s.endsAt(),1,9L,s.participants(),"€".repeat(22000)),with(s,s.startsAt(),s.endsAt(),2,9L,s.participants(),"x")))assertThatThrownBy(()->service.create(user,invalid)).isInstanceOf(RuntimeException.class);
verify(db,never()).insert(anyString(),anyMap());
}
@Test void participantMayEditButCannotDeleteAnotherAuthorsAppointment() {
row.put("usr_id",22L);service.save(user,44,save());
assertThatThrownBy(()->service.remove(user,44,save().revision())).isInstanceOf(AccessDeniedException.class);
row.put("ap_participants",",22,");assertThatThrownBy(()->service.save(user,44,save())).isInstanceOf(AccessDeniedException.class);
}
@Test void editorPreservesAuthorHeadquartersAndHiddenContactLinks() {
row.put("csc_id",81L);row.put("pt_id",83L);service.save(master,44,save());
String set=updatedSql.substring(0,updatedSql.indexOf(" WHERE "));
assertThat(set).doesNotContain("usr_id=","hq_id=","csc_id=","pt_id=");
var s=save();assertThatThrownBy(()->service.save(master,44,with(s,s.startsAt(),s.endsAt(),1,null,s.participants(),"x"))).hasMessageContaining("Kontaktverknüpfung");
}
@Test void movingTimeClearsConfirmationButTextOnlyEditRetainsIt() {
service.save(user,44,save());assertThat(updatedArgs.get(9)).isEqualTo(",22,");
var s=save();service.save(user,44,with(s,s.startsAt().plusDays(1),s.endsAt().plusDays(1),1,9L,s.participants(),"x"));assertThat(updatedArgs.get(9)).isEqualTo("");
}
@Test void removedParticipantLosesConfirmationAndOldUnavailableParticipantsCanBePreserved() {
var s=save();service.save(user,44,with(s,s.startsAt(),s.endsAt(),1,9L,List.of(11L),"x"));assertThat(updatedArgs.get(9)).isEqualTo("");
row.put("ap_participants",",11,999,");s=save();service.save(user,44,with(s,s.startsAt(),s.endsAt(),1,9L,List.of(11L,999L),"x"));assertThat(updatedArgs.get(8)).isEqualTo(",11,999,");
}
@Test void staleRevisionOrConcurrentLegacyMutationCannotOverwriteData() {
var s=save();assertThatThrownBy(()->service.save(user,44,new Save("old",s.startsAt(),s.endsAt(),"x",1,1,2,1,9L,s.participants()))).hasMessageContaining("409");
when(db.update(anyString(),any(Object[].class))).thenReturn(0);
assertThatThrownBy(()->service.save(user,44,save())).hasMessageContaining("409");
assertThatThrownBy(()->service.remove(user,44,save().revision())).hasMessageContaining("409");
verify(db,times(2)).update(contains("BINARY ap_text <=> BINARY ?"),any(Object[].class)); // UPDATE and DELETE both use every original column
}
@Test void inaccessibleAppointmentsCannotBeEditedEvenWithAdministrativeRights() {
missing=true;assertThatThrownBy(()->service.save(master,44,save())).hasMessageContaining("404");
assertThat(queries).anyMatch(s->s.contains("a.ap_id=? AND a.hq_id IN (?,?) AND (a.usr_id=? OR a.ap_category_2=3"));
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void confirmsOnlyCurrentParticipantAndKeepsOtherConfirmations() {
service.confirm(user,44,save().revision());assertThat(updatedArgs.getFirst()).isEqualTo(",11,22,");
row.put("ap_participants",",22,");assertThatThrownBy(()->service.confirm(master,44,save().revision())).isInstanceOf(AccessDeniedException.class);
}
@Test void finishRequiresReportWhenConfiguredAndRejectsInactiveAppointments() {
when(catalog.enabled(0,"MASK_AP_WARNING_DISABLE_QUESTION_NO_REPORT_OF_SAME_DAY")).thenReturn(true);
assertThatThrownBy(()->service.finish(user,44,save().revision())).hasMessageContaining("Kundenbericht");
verify(db,never()).update(anyString(),any(Object[].class));
reportExists=true;service.finish(user,44,save().revision());assertThat(updatedArgs.getFirst()).isEqualTo(2);
assertThat(queries).anyMatch(s->s.contains("rp_objid=? AND hq_id=?")&&s.contains("rp_confidential=0 OR usr_id=?"));
row.put("ap_category_4",0);assertThatThrownBy(()->service.finish(user,44,save().revision())).hasMessageContaining("überwachter Termin");
}
@Test void editorCannotBypassReportRequirementByFinishingAnUnmonitoredAppointment() {
row.put("ap_category_4",0);when(catalog.enabled(0,"MASK_AP_WARNING_DISABLE_QUESTION_NO_REPORT_OF_SAME_DAY")).thenReturn(true);
var s=save();assertThatThrownBy(()->service.save(user,44,with(s,s.startsAt(),s.endsAt(),2,9L,s.participants(),"x"))).hasMessageContaining("Kundenbericht");
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void resubmissionPreservesSourceRelationsResetsConfirmationsAndReactivatesMonitoring() {
row.put("ap_category_4",2);row.put("csc_id",8L);row.put("pt_id",6L);
var result=service.resubmit(user,44,new Resubmission(save().revision(),date.plusDays(7).atTime(9,0),date.plusDays(7).atTime(10,0)));
assertThat(result).containsEntry("id",45L);assertThat(inserted).doesNotContainKey("ap_id").containsEntry("csc_id",8L).containsEntry("pt_id",6L).containsEntry("ap_confirmed","").containsEntry("ap_category_4",1).containsEntry("usr_id",11L);
verify(db,never()).update(anyString(),any(Object[].class));assertThat(row).containsEntry("ap_confirmed",",22,");
}
@Test void legacyZeroCustomerIsReturnedAsNoAssignment() {
row.put("cs_id",0);@SuppressWarnings("unchecked") var items=(List<Map<String,Object>>)service.list(user,query()).get("items");assertThat(items.getFirst()).containsEntry("customerId",null);
}
}
@@ -0,0 +1,71 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** End-to-end business writes against disposable MariaDB, never the legacy host. */
public class BusinessIntegrationAssertions {
static String token(){return UUID.randomUUID().toString();}
@SuppressWarnings("unchecked") static Map<String,Object> values(Map<String,Object> view){return new LinkedHashMap<>((Map<String,Object>)view.get("values"));}
static MasterData.Save save(Map<String,Object> view,Map<String,Object> values){return new MasterData.Save(token(),text(view,"revision"),values);}
@SuppressWarnings("unchecked") public static void verify(DatabaseService db){
var u=AppointmentServiceTest.user(0,1,2,4,5,7,38,41);var support=new BusinessSupport(db,new ObjectMapper().findAndRegisterModules());var masters=new MasterData(db,support);
var blank=masters.view(u,"customers",0,0);var input=values(blank);input.putAll(fields("cmp_comp","QA Kunde","cs_eid","QA001","ad_street","Teststraße","cmp_hsno","1","ad_zipcode","10115","ad_city","Berlin","usr_name","Test","usr_phone","030123","usr_inv_email","test@example.invalid"));
var create=save(blank,input);var customer=masters.save(u,"customers",0,0,create);long cs=id(customer,"id");assertThat(masters.save(u,"customers",0,0,create)).containsEntry("id",cs);
assertThat(db.queryForObject("SELECT COUNT(*) FROM customer",Integer.class)).isEqualTo(1);
long center=id(db.queryForList("SELECT csc_id FROM customer WHERE cs_id=?",cs).getFirst(),"csc_id");
assertThat(db.queryForObject("SELECT COUNT(*) FROM costcenteraddress WHERE csc_id=?",Integer.class,center)).isEqualTo(3);
long originalAddress=id(db.queryForList("SELECT ad_id FROM company WHERE cmp_comp='QA Kunde'").getFirst(),"ad_id");input=values(customer);input.put("ad_street","Neue Straße");var changed=masters.save(u,"customers",cs,0,save(customer,input));assertThat(text(db.queryForList("SELECT ad_street FROM address WHERE ad_id=?",originalAddress).getFirst(),"ad_street")).isEqualTo("Teststraße");
final var oldCustomer=customer;final var staleValues=input;assertThatThrownBy(()->masters.save(u,"customers",cs,0,save(oldCustomer,staleValues))).hasMessageContaining("409");
var cview=masters.view(u,"centers",center,cs);var cvalues=values(cview);cvalues.put("addr2_cscad_comp","Rechnungsempfänger");masters.save(u,"centers",center,cs,save(cview,cvalues));assertThat(text(db.queryForList("SELECT cscad_comp FROM costcenteraddress WHERE csc_id=? AND adt_id=2",center).getFirst(),"cscad_comp")).isEqualTo("Rechnungsempfänger");
var childBlank=masters.view(u,"centers",0,cs);var childValues=values(childBlank);childValues.put("csc_name","Zweigstelle");var child=masters.save(u,"centers",0,cs,save(childBlank,childValues));assertThat(db.queryForList("SELECT csc_path FROM costcenter WHERE csc_id=?",id(child,"id")).getFirst().get("csc_path")).isEqualTo("//QA Kunde//");
var listing=new LegacyReadService(db,500);
assertThat(listing.customers(u,"QA",false,null)).hasSize(1);
assertThat(listing.customers(u,"QA",true,null)).hasSize(2);
assertThat(listing.customers(u,"QA",true,"Zweig")).singleElement().satisfies(row->{assertThat(row.id).isEqualTo(cs);assertThat(row.costCenterId).isEqualTo(id(child,"id"));assertThat(row.costCenterName).isEqualTo("Zweigstelle");});
assertThat(listing.customers(u,"QA",true,"' OR 1=1 --")).isEmpty();
assertThat(listing.customers(new LegacyUser(22,22,8,"foreign","1".repeat(70)),"QA",true,"Zweig")).isEmpty();
childValues=values(child);childValues.put("csc_pre_id",id(child,"id"));var cycle=save(child,childValues);assertThatThrownBy(()->masters.save(u,"centers",id(child,"id"),cs,cycle)).hasMessageContaining("Kreis");
var crblank=masters.view(u,"couriers",0,0);var crvalues=values(crblank);crvalues.putAll(fields("cmp_comp","QA Kurier","cr_eid","QACR01","cmp_hsno","2","usr_name","Kurier","usr_phone","030123","ad_street","Testweg","ad_zipcode","10115","ad_city","Berlin"));var courier=masters.save(u,"couriers",0,0,save(crblank,crvalues));long cr=id(courier,"id");
var vblank=masters.view(u,"vehicles",0,cr);var vvalues=values(vblank);vvalues.putAll(fields("crvh_sid","QA01","vht_id",1));var vehicle=masters.save(u,"vehicles",0,cr,save(vblank,vvalues));assertThat(id(vehicle,"id")).isPositive();
long address=originalAddress;long job=db.writeTransaction(()->{long j=db.insert("job",fields("hq_id",7,"csc_id_payer",center,"jb_status",1,"cr_id",cr,"cr_id_order",cr,"cr_sid","QA01","jb_createtime",LocalDateTime.now(),"jb_ordertime",LocalDateTime.now().minusHours(1),"jb_freetext_1","","jb_service","","jb_orderer","","jb_scrm","","jb_totalprice",100,"jb_cr_price",80,"jb_sales_tax_rate",19,"jb_payment",0,"jb_postage",0));for(int i=1;i<=2;i++)db.insert("tour",fields("jb_id",j,"tr_sort",i,"ad_id",address,"tr_comp","Station "+i,"tr_ware_from_to",i==1?2:1,"tr_status",i==1?1:0,"tr_signname",i==1?"Originalempfänger":"","tr_cs_freetext",""));BusinessSupport.insertWithoutKey(db,"tourservice",fields("jb_id",j,"csc_id",center,"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Fixpreis","trs_srvt_name","","trs_price",100,"trs_discount",0));return j;});
var catalog=new OrderEntryCatalog(db);var ops=new OrderOperations(db,support,catalog,new LegacyOrderPricing(db,catalog));var work=ops.view(u,job);var vals=values(work);vals.put("jb_orderer","Geändert");var stations=((List<Map<String,Object>>)work.get("stations")).stream().map(st->new OrderOperations.Station(id(st,"id"),values(st))).toList();var edit=new OrderOperations.Edit(token(),text(work,"revision"),vals,stations);ops.edit(u,job,edit);ops.edit(u,job,edit);assertThatThrownBy(()->ops.edit(u,job,new OrderOperations.Edit(token(),text(work,"revision"),vals,stations))).hasMessageContaining("409");
var recalc=new OrderRecalculation(db,support,ops,catalog,new LegacyOrderPricing(db,catalog),mock(OrderRoutingService.class),jakarta.validation.Validation.buildDefaultValidatorFactory().getValidator());
db.writeTransaction(()->{OrderPaymentTerms.capture(db,job,cs);db.update("INSERT INTO jobprice(jb_id,mt_sort,jbprc_price,jbprc_remark) VALUES (?,16,12.5,'')",job);return null;});
var recalcInput=new OrderRecalculation.Input(new OrderEntryModels.Pricing(OrderEntryModels.PriceMode.FIXED,new BigDecimal("130.00"),BigDecimal.ZERO,new BigDecimal("85.00"),BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,false,1L),1,List.of(),false,false);
var recalcDraft=new OrderRecalculation.Command(token(),text(ops.view(u,job),"revision"),recalcInput,null);var recalcPreview=recalc.preview(u,job,recalcDraft);
var recalcSave=new OrderRecalculation.Command(recalcDraft.requestId(),recalcDraft.revision(),recalcInput,text(recalcPreview,"fingerprint"));
recalc.save(u,job,recalcSave);recalc.save(u,job,recalcSave);
assertThat(decimal(db.queryForList("SELECT jb_totalprice FROM job WHERE jb_id=?",job).getFirst(),"jb_totalprice")).isEqualByComparingTo("130");
assertThat(db.queryForObject("SELECT COUNT(*) FROM job",Integer.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT COUNT(*) FROM tour WHERE jb_id=?",Integer.class,job)).isEqualTo(2);
assertThat(db.queryForObject("SELECT jbprc_price FROM jobprice WHERE jb_id=? AND mt_sort=16",BigDecimal.class,job)).isEqualByComparingTo("12.5");
assertThat(OrderPaymentTerms.read(db,job)).containsEntry("label","10 Tage netto");
var recalcOptions=recalc.options(u,job);
var fresh=ops.view(u,job);var price=new OrderOperations.Price(token(),text(fresh,"revision"),new BigDecimal("125.50"),new BigDecimal("85.00"),"Testkorrektur");ops.price(u,job,price);ops.price(u,job,price);assertThat(decimal(db.queryForList("SELECT jb_totalprice FROM job WHERE jb_id=?",job).getFirst(),"jb_totalprice")).isEqualByComparingTo("125.50");
try{support.json.writeValue(new java.io.File("target/business-ui-fixture.json"),fields("customer",masters.view(u,"customers",cs,0),"courier",masters.view(u,"couriers",cr,0),"center",masters.view(u,"centers",center,cs),"child",masters.view(u,"centers",id(child,"id"),cs),"vehicle",masters.view(u,"vehicles",id(vehicle,"id"),cr),"order",ops.view(u,job),"calculation",recalcOptions,"calculationPreview",recalcPreview));}catch(java.io.IOException e){throw new IllegalStateException(e);}
var done=new OrderOperations.Action(token(),text(ops.view(u,job),"revision"),"COMPLETE",null,null,"Neuer Empfänger",null,false,false);ops.action(u,job,done);ops.action(u,job,done);assertThat(text(db.queryForList("SELECT tr_signname FROM tour WHERE jb_id=? AND tr_sort=1",job).getFirst(),"tr_signname")).isEqualTo("Originalempfänger");assertThat(id(db.queryForList("SELECT jb_status FROM job WHERE jb_id=?",job).getFirst(),"jb_status")).isEqualTo(2);
var billing=new BillingService(db,support);var q=new BillingService.Query(LocalDate.now().minusDays(1),LocalDate.now(),"CUSTOMER",cs,null,true,null,"",false);var statement=billing.query(u,q);assertThat((BigDecimal)statement.get("net")).isEqualByComparingTo("125.50");assertThat((BigDecimal)statement.get("gross")).isEqualByComparingTo("149.35");
var assignment=new BillingService.Assign(token(),q,text(statement,"revision"),List.of(job),"QA-2026-001",LocalDate.now());
// Invoice uses MyISAM: simulate losing acknowledgement after the insert, then safely resume.
var failing=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("lost acknowledgement")).when(failing).update(startsWith("UPDATE genericdatacontainer SET gdc_context='DONE'"),any(Object[].class));var broken=new BillingService(failing,new BusinessSupport(failing,support.json));assertThatThrownBy(()->broken.assign(u,assignment)).hasMessageContaining("lost acknowledgement");assertThat(db.queryForObject("SELECT COUNT(*) FROM invoice WHERE jb_id=?",Integer.class,job)).isEqualTo(1);billing.assign(u,assignment);billing.assign(u,assignment);assertThat(db.queryForObject("SELECT COUNT(*) FROM invoice WHERE jb_id=?",Integer.class,job)).isEqualTo(1);assertThat(db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_gen_fieldname='ng_invoice_lock'",Integer.class)).isZero();
assertThatThrownBy(()->ops.price(u,job,new OrderOperations.Price(token(),text(ops.view(u,job),"revision"),BigDecimal.ONE,BigDecimal.ONE,"Nach Rechnung"))).hasMessageContaining("bereits exportiert");
try{java.nio.file.Files.write(java.nio.file.Path.of("target/business-statement.pdf"),StatementPdf.create(billing.query(u,q),q));java.nio.file.Files.write(java.nio.file.Path.of("target/business-statement.csv"),billing.csv(u,q));}catch(java.io.IOException e){throw new IllegalStateException(e);}
var credit=new OrderOperations.Action(token(),text(ops.view(u,job),"revision"),"CREDIT",null,null,null,"Testgutschrift",false,false);var credited=ops.action(u,job,credit);long negative=id(credited,"resultId");assertThat(negative).isNotEqualTo(job);assertThat(ops.action(u,job,credit)).containsEntry("resultId",negative);assertThat(decimal(db.queryForList("SELECT jb_totalprice FROM job WHERE jb_id=?",negative).getFirst(),"jb_totalprice")).isEqualByComparingTo("-125.50");assertThat((BigDecimal)billing.query(u,q).get("net")).isEqualByComparingTo("0.00");
assertThatThrownBy(()->masters.view(new LegacyUser(22,22,8,"foreign","1".repeat(70)),"customers",cs,0)).hasMessageContaining("404");
support.publish();long logs=db.queryForObject("SELECT COUNT(*) FROM phoenix_log.log",Long.class);assertThat(logs).isPositive();support.publish();assertThat(db.queryForObject("SELECT COUNT(*) FROM phoenix_log.log",Long.class)).isEqualTo(logs);
// A main-transaction failure must not leave a changed master record or a partial price rewrite.
var rejectReceipt=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("write failure")).when(rejectReceipt).insert(eq("genericdatacontainer"),anyMap());var rollbackMasters=new MasterData(rejectReceipt,new BusinessSupport(rejectReceipt,support.json));var currentMaster=masters.view(u,"customers",cs,0);var revisedValues=values(currentMaster);revisedValues.put("cmp_comp","Must roll back");assertThatThrownBy(()->rollbackMasters.save(u,"customers",cs,0,save(currentMaster,revisedValues))).hasMessageContaining("write failure");assertThat(values(masters.view(u,"customers",cs,0)).get("cmp_comp")).isEqualTo("QA Kunde");
// Detect an old-PHP edit between durable intent and a resumed MyISAM assignment.
var before=billing.query(u,q);var next=new BillingService.Assign(token(),q,text(before,"revision"),List.of(negative),"QA-CREDIT",LocalDate.now());var interrupted=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("invoice unavailable")).when(interrupted).update(startsWith("INSERT INTO invoice"),any(Object[].class));var interruptedBilling=new BillingService(interrupted,new BusinessSupport(interrupted,support.json));assertThatThrownBy(()->interruptedBilling.assign(u,next)).hasMessageContaining("invoice unavailable");db.writeTransaction(()->db.update("UPDATE job SET jb_incomplete=1 WHERE jb_id=?",negative));assertThatThrownBy(()->billing.resume(u,next.requestId())).hasMessageContaining("seit Beginn");assertThat(db.queryForObject("SELECT COUNT(*) FROM invoice WHERE jb_id=?",Integer.class,negative)).isZero();billing.stop(u,next.requestId());assertThat(db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_gen_fieldname='ng_invoice_lock'",Integer.class)).isZero();
assertThat(new String(billing.csv(u,q),java.nio.charset.StandardCharsets.UTF_8)).contains("QA-2026-001","125.50");var pdf=StatementPdf.create(billing.query(u,q),q);assertThat(new String(pdf,StatementPdf.WIN)).startsWith("%PDF-1.4").contains("xref","Rechnungsanlage");
}
}
@@ -0,0 +1,85 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.time.*;
import java.util.*;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.CalendarAdministration.*;
class CalendarAdministrationTest {
final DatabaseService db=mock(DatabaseService.class);
final LegacyUser user=new LegacyUser(11,12,7,"test","1".repeat(70));
final CalendarAdministration service=new CalendarAdministration(db,Clock.fixed(Instant.parse("2026-10-12T09:00:00Z"),ZoneId.of("Europe/Berlin")));
final List<Map<String,Object>> holidays=new ArrayList<>();
final Map<String,Object> travel=fields("id",1L,"postalCode","10115","country","DE","globalMinutes",30,"localMinutes",null);
@BeforeEach void setup(){
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.lockRows(anyString(),any(Object[].class))).thenAnswer(c->((String)c.getArgument(0)).startsWith("SELECT ph_id")?holidays:List.of(fields("hq_id",7)));
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.startsWith("SELECT ph_id"))return holidays;if(sql.startsWith("SELECT p.srvp_id"))return List.of(travel);return List.of();});
when(db.update(anyString(),any(Object[].class))).thenReturn(1);
}
@Test void rightsAreCheckedBeforeAnyDatabaseAccess(){
var denied=new LegacyUser(11,12,7,"test","0".repeat(70));
assertThatThrownBy(()->service.holidays(denied,2026)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.saveTravelTime(denied,TravelMode.BWV,1,new TravelSave("x",20))).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(db);
}
@Test void historicalRowsAndTimeWindowsSurviveAnEmptyFutureCalendar(){
holidays.add(fields("ph_id",1,"ph_month",1,"ph_day",1,"ph_name","Neujahr","ph_time_from","10:00","ph_time_to","12:00"));
holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","Weihnachten","ph_time_from","00:00","ph_time_to","00:00"));
service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of()));
verify(db).update("DELETE FROM publicholiday WHERE ph_id=? AND hq_id=?",2L,7L);
verify(db,never()).update(eq("DELETE FROM publicholiday WHERE ph_id=? AND hq_id=?"),eq(1L),any());
}
@Test void staleCalendarCannotOverwriteAnotherEditorsChanges(){
assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave("outdated",List.of()))).hasMessageContaining("409");
verify(db,never()).update(anyString(),any(Object[].class));verify(db,never()).insert(anyString(),anyMap());
}
@Test void validatesDateAndRejectsModificationOfToday(){
assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave("x",List.of(new Holiday(2,29,"x"))))).hasMessageContaining("Feiertagsdatum");
assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(10,12,"x"))))).hasMessageContaining("heute");
verify(db,never()).insert(anyString(),anyMap());
}
@Test void futureNamesAreUpdatedWithoutReplacingTimeWindows(){
holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","alt","ph_time_from","10:00","ph_time_to","12:00"));
service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(12,25,"neu"))));
verify(db).update("UPDATE publicholiday SET ph_name=? WHERE ph_id=? AND hq_id=?","neu",2L,7L);
verify(db,never()).insert(anyString(),anyMap());
}
@Test void multipleTimeWindowsRepresentOneDayAndSurviveAnUnchangedSave(){
holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","vormittags","ph_time_from","10:00","ph_time_to","12:00"));
holidays.add(fields("ph_id",3,"ph_month",12,"ph_day",25,"ph_name","nachmittags","ph_time_from","14:00","ph_time_to","16:00"));
assertThat((List<?>)service.holidays(user,2026).get("days")).hasSize(1);
service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(12,25,"vormittags"))));
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void classicCreatesOnlyThisHeadquartersOverride(){
service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.CLASSIC,null)),45));
verify(db).update("INSERT INTO serviceplztraveltime (srvp_id,hq_id,srvpt_traveltime) VALUES (?,?,?)",1L,7L,45);
verify(db,never()).update(startsWith("UPDATE serviceplz SET"),any(Object[].class));
}
@Test void bwvIsOneConditionalWriteAndReportsConcurrentUpdates(){
when(db.update(startsWith("UPDATE serviceplz SET"),any(Object[].class))).thenReturn(0);
assertThatThrownBy(()->service.saveTravelTime(user,TravelMode.BWV,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.BWV,30)),45))).hasMessageContaining("409");
verify(db).update("UPDATE serviceplz SET srvp_traveltime=? WHERE srvp_id=? AND srvp_valid=1 AND srvp_traveltime <=> ?",45,1L,30);
verify(db,never()).insert(anyString(),anyMap());
}
@Test void zeroAndEmptyTravelTimeUseTheConfiguredHeadquartersDefault() {
when(db.queryForList(startsWith("SELECT par_value FROM parameter"),eq(7L),eq("LATEST_TAKETIME_IN_MINUTES"))).thenReturn(List.of(fields("par_value","35")));
service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.CLASSIC,null)),0));
verify(db).update("INSERT INTO serviceplztraveltime (srvp_id,hq_id,srvpt_traveltime) VALUES (?,?,?)",1L,7L,35);
service.saveTravelTime(user,TravelMode.BWV,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.BWV,30)),null));
verify(db).update(startsWith("UPDATE serviceplz SET"),eq(35),eq(1L),eq(30));
}
@Test void missingDefaultNeverWritesAnInventedTravelTime() {
assertThatThrownBy(()->service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave("",0))).hasMessageContaining("LATEST_TAKETIME_IN_MINUTES");
verify(db,never()).update(anyString(),any(Object[].class));
}
}
@@ -0,0 +1,23 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import java.util.*;import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
public final class ContactIntegrationAssertions {
public static void verifyContactWrites(DatabaseService db) throws Exception {
var scope=mock(CrmScope.class);when(scope.checked(any(),any())).thenReturn(List.of(7L));var service=new ContactService(db,scope);var user=AppointmentServiceTest.user(0,11);String schema=(String)service.schema(user,7).get("revision");
try(var executor=Executors.newFixedThreadPool(2)){var calls=new ArrayList<Callable<Map<String,Object>>>();for(int i=0;i<2;i++){int index=i;calls.add(()->service.save(user,null,new ContactService.Save(7,null,schema,Map.of(1L,"Kontakt "+index,2L,"Notiz"))));}var result=executor.invokeAll(calls);assertThat(result.get(0).get().get("id")).isNotEqualTo(result.get(1).get().get("id"));}
assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue",Integer.class)).isEqualTo(4);assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvaluehistory",Integer.class)).isEqualTo(4);
var detail=service.get(user,7,1);service.save(user,1L,new ContactService.Save(7,(String)detail.get("revision"),schema,Map.of(1L,"Geändert",2L,"Notiz")));assertThatThrownBy(()->service.save(user,1L,new ContactService.Save(7,(String)detail.get("revision"),schema,Map.of(1L,"Veraltet")))).hasMessageContaining("409");
assertThatThrownBy(()->db.contactWriteTransaction(()->{db.update("DELETE FROM metafieldvalue WHERE mtfv_id=?",1);throw new IllegalStateException("rollback");})).hasMessage("rollback");assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue WHERE mtfv_id=1",Integer.class)).isEqualTo(2);
var updated=service.get(user,7,1);service.remove(user,7,1,(String)updated.get("revision"));assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue",Integer.class)).isEqualTo(2);assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvaluehistory",Integer.class)).isEqualTo(5);
// Exercise the shared category-scoped editor against the same real InnoDB locks.
db.writeTransaction(()->{db.update("INSERT INTO metafieldkey VALUES (1701,'Beginn','date_begin'),(1702,'Ende','date_end'),(1703,'Niederlassungen','checkboxes_hq')");db.update("INSERT INTO metafieldcategorykey VALUES (60,15,1701,0,1,1),(61,15,1702,0,2,1),(62,15,1703,0,3,0)");return null;});
var forms=mock(FormAdministration.class);var messages=mock(MessageAdministration.class);var actor=AppointmentServiceTest.user(0,8);when(messages.headquarters(actor)).thenReturn(List.of(OrderEntryService.fields("id",7L,"label","B")));var evaluations=new EvaluationService(db,forms,messages);String evalSchema=(String)evaluations.schema(actor,7).get("revision");
var saved=evaluations.save(actor,null,new MetafieldRecords.Save(7,null,evalSchema,Map.of(60L,"01.10.2026",61L,"31.10.2026",62L,"7")));long evalId=((Number)saved.get("id")).longValue();assertThat(evalId).isGreaterThan(2);var detailEval=evaluations.get(actor,7,evalId);assertThat((Map<?,?>)detailEval.get("values")).hasSize(3);
evaluations.save(actor,evalId,new MetafieldRecords.Save(7,(String)detailEval.get("revision"),evalSchema,Map.of(60L,"02.10.2026")));assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue WHERE mtfv_id=?",Integer.class,evalId)).isEqualTo(3);assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvaluehistory WHERE mtfv_id=?",Integer.class,evalId)).isEqualTo(3);
var updatedEval=evaluations.get(actor,7,evalId);evaluations.remove(actor,7,evalId,(String)updatedEval.get("revision"));assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue WHERE mtfv_id=?",Integer.class,evalId)).isZero();assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue",Integer.class)).isEqualTo(2);
}
}
@@ -0,0 +1,28 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import java.util.*;import java.util.function.Supplier;import java.time.*;import java.math.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class CrmServiceTest {
final DatabaseService db=mock(DatabaseService.class);final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);final CrmScope scope=new CrmScope(db);final ContactService contacts=new ContactService(db,scope);final SalesAnalytics sales=new SalesAnalytics(db,scope,catalog,"phoenix_log");final LegacyUser user=AppointmentServiceTest.user(0,11),master=AppointmentServiceTest.user(0,10,11);
final List<Map<String,Object>> defs=List.of(fields("id",1L,"keyId",101,"label","Name","type","varchar","history",1),fields("id",2L,"keyId",104,"label","Geburtstag","type","birthdate","history",0),fields("id",3L,"keyId",300,"label","Kunde","type","co2cs","history",0));
List<Map<String,Object>> values=List.of(fields("mtfck_id",1L,"mtfv_id",44L,"mtfv_value","Alt"));final List<String> queries=new ArrayList<>(),writes=new ArrayList<>();final List<List<Object>> arguments=new ArrayList<>();List<Map<String,Object>> targetRows=List.of();boolean huge=false,brokenSchema=false;
@BeforeEach void setup(){when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.contactWriteTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.queryForObject(startsWith("SELECT COALESCE(MAX"),eq(Long.class))).thenReturn(99L);when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);queries.add(sql);arguments.add(Arrays.asList(c.getArguments()).subList(1,c.getArguments().length));if(sql.startsWith("SELECT DISTINCT h.hq_id"))return sql.contains(" AND h.hq_id=?")?List.of(fields("id",7L,"label","B")):List.of(fields("id",7L,"label","B"),fields("id",8L,"label","HH"));if(sql.startsWith("SELECT ck.mtfck_id"))return brokenSchema?List.of():defs;if(sql.startsWith("SELECT mtfck_id,mtfv_id"))return values;if(sql.startsWith("SELECT cs_id"))return List.of(fields("cs_id",9L));if(sql.startsWith("SELECT DISTINCT v.mtfv_id"))return huge?Collections.nCopies(501,fields("id",44L,"headquartersId",7L)):List.of();if(sql.startsWith("SELECT jb.hq_id"))return List.of(fields("id",7L,"jobs",2L,"amount",new BigDecimal("100.00")));if(sql.startsWith("SELECT l.usr_id"))return List.of(fields("userId",11L,"person","Test","headquarters","B","customerId",9L,"description","SRC=jb|OLD=1|NEW=0"),fields("userId",11L,"person","Test","headquarters","B","customerId",9L,"description","SRC=cs|OLD=|NEW=0"));if(sql.startsWith("SELECT cs.cs_id AS id"))return List.of(fields("id",9L,"amount",new BigDecimal("20.00")));if(sql.startsWith("SELECT par_id"))return targetRows;return List.of();});when(db.update(anyString(),any(Object[].class))).thenAnswer(c->{writes.add(c.getArgument(0));return 1;});when(db.lockRows(startsWith("SELECT hq_id"),any(Object[].class))).thenReturn(List.of(fields("hq_id",7L)));when(db.lockRows(startsWith("SELECT par_id"),any(Object[].class))).thenAnswer(c->targetRows);when(catalog.parameter(anyLong(),anyString())).thenReturn("");}
ContactService.Save save(String rev,Map<Long,String> v){return new ContactService.Save(7,rev,AdminSupport.revision(defs),v);}
@Test void scopeCannotExpandToOtherTenantOrMissingSalesRight(){for(var u:List.of(AppointmentServiceTest.user(0),AppointmentServiceTest.user(11)))assertThatThrownBy(()->contacts.schema(u,7)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verifyNoInteractions(db);assertThatThrownBy(()->contacts.schema(user,8)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->contacts.schema(master,99)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);}
@Test void creationUsesLockedGlobalIdAndLegacyHistory(){assertThat(contacts.save(user,null,save(null,Map.of(1L,"Neu")))).containsEntry("id",99L);verify(db).contactWriteTransaction(any());assertThat(writes).filteredOn(s->s.startsWith("INSERT INTO metafieldvalue ")).hasSize(3);verify(db).update(startsWith("INSERT INTO metafieldvaluehistory"),eq(1L),eq(99L),eq("Neu"),any(LocalDateTime.class));}
@Test void staleValuesAndDefinitionChangesCannotDeleteExistingContact(){assertThatThrownBy(()->contacts.save(user,44L,save("stale",Map.of(1L,"Neu")))).hasMessageContaining("409");assertThatThrownBy(()->contacts.save(user,44L,new ContactService.Save(7,"x","stale",Map.of(1L,"Neu")))).hasMessageContaining("409");assertThat(writes).isEmpty();}
@Test void contactsRejectInvalidDatesUnknownFieldsAndOversizeUtf8(){for(var vals:List.of(Map.of(2L,"31.02.2026"),Map.of(999L,"unbekannt"),Map.of(1L,"€".repeat(22000)),Map.of(1L," ")))assertThatThrownBy(()->contacts.save(user,null,save(null,vals))).hasMessageContaining("400");assertThat(writes).isEmpty();}
@Test void updatesAndDeletesAreScopedToDefinitionIdsNotObjectIdAlone(){contacts.save(user,44L,save(AdminSupport.revision(values),Map.of(1L,"Neu")));contacts.remove(user,7,44,AdminSupport.revision(values));assertThat(writes).filteredOn(s->s.startsWith("DELETE")).hasSize(2).allMatch(s->s.contains("mtfv_id=? AND mtfck_id IN (?,?,?)"));}
@Test void historyRequiresAnAccessibleCurrentContact(){values=List.of();assertThatThrownBy(()->contacts.history(user,7,44)).hasMessageContaining("404");assertThat(queries).noneMatch(s->s.contains("FROM metafieldvaluehistory"));}
@Test void contactSearchBindsWildcardsAndCustomerScope(){contacts.list(user,new ContactService.Query(List.of(7L),"%_x'",false,"A",9L));assertThat(queries).anyMatch(s->s.contains("sv.mtfv_value LIKE ?")&&s.contains("cv.mtfv_value=?"));assertThat(arguments.stream().flatMap(Collection::stream)).contains("%\\%\\_x'%","A%","9");huge=true;assertThatThrownBy(()->contacts.list(user,new ContactService.Query(List.of(7L),"",false,"",null))).hasMessageContaining("500 Kontakte");}
@Test void statusCounterIncludesOnlyConversionsToCustomers(){var r=sales.switches(user,new SalesAnalytics.Query(LocalDate.of(2026,1,1),LocalDate.of(2026,10,13),List.of(7L)));assertThat((List<?>)r.get("conversions")).hasSize(1);assertThat(r.get("summary").toString()).contains("jb1=1");assertThat(queries).anyMatch(s->s.contains("l.hq_id IN (?) AND c.hq_id IN (?)"));assertThat(queries).anyMatch(s->s.contains("jb_status=2")&&s.contains("jb_storno IN (0,1,3)")&&s.contains("jb.hq_id=cs.hq_id"));}
@Test void statusParserAndPriorYearMatchLegacyLeapHandling(){assertThat(SalesAnalytics.description("SRC=jb|OLD=2|NEW=0")).containsEntry("NEW","0");assertThat(SalesAnalytics.previousYear(LocalDate.of(2024,2,29))).isEqualTo(LocalDate.of(2023,2,28));assertThat(SalesAnalytics.previousYear(LocalDate.of(2025,2,28))).isEqualTo(LocalDate.of(2024,2,29));}
@Test void motivationHandlesMissingTargetsAndEmptyBranchWithoutDividingByZero(){var r=sales.motivation(master,YearMonth.now().toString(),List.of(7L,8L));var rows=(List<Map<String,Object>>)r.get("items");assertThat(rows.getFirst()).containsEntry("actual",new BigDecimal("100.00")).containsEntry("percent",null);assertThat(rows.get(1)).containsEntry("actual",BigDecimal.ZERO).containsEntry("jobs",0L);assertThat(SalesAnalytics.percent(new BigDecimal("75"),new BigDecimal("100"))).isEqualByComparingTo("75");}
@Test void exclusionsAreBoundAndRelatedCustomerSettingIsHonored(){when(catalog.parameter(0,"MOTIVATIONCOUNTER_EXCLUDED_CS")).thenReturn("TEST-1, TEST-2'");sales.motivation(user,YearMonth.now().toString(),List.of(7L));assertThat(queries).anyMatch(s->s.contains("cs.cs_eid NOT IN (?,?)")&&!s.contains("TEST-2"));assertThat(arguments.stream().flatMap(Collection::stream)).contains("TEST-1","TEST-2'");when(catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")).thenReturn(true);sales.switches(user,new SalesAnalytics.Query(LocalDate.now(),LocalDate.now(),List.of(7L)));assertThat(queries).anyMatch(s->s.contains("csc.csc_id=jb.csc_id_related"));}
@Test void targetsRequireConfiguredPasswordAndLockOutRepeatedFailures(){assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"x"))).hasMessageContaining("kein Motivation");when(catalog.parameter(7,"MOTIVATIONCOUNTER_PASSWORD")).thenReturn("test-only");for(int i=0;i<5;i++)assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"wrong"))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"test-only"))).hasMessageContaining("fünf Minuten");assertThat(writes).isEmpty();}
@Test void targetEditingPreservesHistoryAndChecksRevision(){when(catalog.parameter(7,"MOTIVATIONCOUNTER_PASSWORD")).thenReturn("test-only");String m=YearMonth.now().toString();targetRows=List.of(fields("id",5L,"name","MOTIVATIONCOUNTER_"+m.replace('-','_'),"amount","100.00"));var t=new SalesAnalytics.Target(7,m,new BigDecimal("150.00"),"test-only",AdminSupport.revision(targetRows.getFirst()));assertThat(sales.targets(user,new SalesAnalytics.TargetAccess(7,"test-only")).getFirst()).containsEntry("month",m);sales.target(user,t);assertThat(writes).contains("UPDATE parameter SET par_value=? WHERE par_id=?");assertThatThrownBy(()->sales.target(user,new SalesAnalytics.Target(7,m,t.amount(),t.password(),"stale"))).hasMessageContaining("409");assertThatThrownBy(()->sales.target(user,new SalesAnalytics.Target(7,YearMonth.now().minusMonths(1).toString(),t.amount(),t.password(),t.revision()))).hasMessageContaining("vergangener");}
}
@@ -0,0 +1,45 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.LegacyMapping.*;
public class CustomerServicesIntegrationAssertions {
public static List<String> setup(){return List.of(
"DROP TABLE IF EXISTS customer,service,servicetype,customerservice,customerservicetype,parameter,genericdatacontainer",
"CREATE TABLE customer(cs_id INT PRIMARY KEY,hq_id INT) ENGINE=InnoDB",
"INSERT INTO customer VALUES(9,7),(10,8)",
"CREATE TABLE service(srv_id INT PRIMARY KEY,hq_id INT,cs_id INT,srv_name VARCHAR(100),srv_mode INT) ENGINE=InnoDB",
"INSERT INTO service VALUES(1,0,0,'PKW',1),(2,0,0,'Kombi',1),(3,8,0,'Fremd',1),(4,0,0,'Wartezeit',0)",
"CREATE TABLE servicetype(srvt_id INT PRIMARY KEY,hq_id INT,cs_id INT,srvt_name VARCHAR(100),srvt_mode INT) ENGINE=InnoDB",
"INSERT INTO servicetype VALUES(11,0,0,'Kilometer',1),(12,0,0,'Stopps',1)",
"CREATE TABLE customerservice(cs_id INT,srv_id INT,cssrv_sort INT) ENGINE=MyISAM",
"INSERT INTO customerservice VALUES(9,1,0),(10,3,0)",
"CREATE TABLE customerservicetype(cs_id INT,srvt_id INT,cssrvt_sort INT) ENGINE=MyISAM",
"INSERT INTO customerservicetype VALUES(9,11,0)",
"CREATE TABLE parameter(hq_id INT,emp_id INT,par_key VARCHAR(100),par_value VARCHAR(100)) ENGINE=InnoDB",
"CREATE TABLE genericdatacontainer(gdc_id INT PRIMARY KEY AUTO_INCREMENT,gdc_obj_type VARCHAR(10),gdc_obj_id INT,gdc_gen_fieldname VARCHAR(50),gdc_content MEDIUMTEXT,gdc_context TEXT) ENGINE=InnoDB"
);}
public static void verify(DatabaseService db)throws Exception{
var u=new LegacyUser(11,12,7,"QA","1".repeat(70));var support=new BusinessSupport(db,new ObjectMapper());var service=new CustomerServices(db,support,new OrderEntryCatalog(db));var initial=service.view(u,9);
assertThat((List<?>)initial.get("serviceOptions")).hasSize(3);
assertThatThrownBy(()->service.view(u,10)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThatThrownBy(()->service.view(new LegacyUser(11,12,7,"QA","110"+"1".repeat(67)),9)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
assertThatThrownBy(()->service.save(u,9,new CustomerServices.Save(UUID.randomUUID().toString(),text(initial,"revision"),List.of(3L),List.of(11L)))).hasMessageContaining("nicht freigegeben");
var input=new CustomerServices.Save(UUID.randomUUID().toString(),text(initial,"revision"),List.of(2L,1L,4L),List.of(12L,11L));
var failing=spy(db);doAnswer(c->{c.callRealMethod();throw new org.springframework.dao.DataAccessResourceFailureException("lost MyISAM insert acknowledgement");}).when(failing).update(startsWith("INSERT INTO customerservicetype"),any(Object[].class));
var interrupted=new CustomerServices(failing,new BusinessSupport(failing,new ObjectMapper()),new OrderEntryCatalog(failing));
assertThat(interrupted.save(u,9,input)).containsEntry("state","PENDING");
assertThat(db.queryForList("SELECT srvt_id FROM customerservicetype WHERE cs_id=9")).hasSize(1);
assertThat(service.view(u,9).get("operations")).asList().hasSize(1);
assertThat(service.resume(u,9,input.requestId())).containsEntry("state","DONE");assertThat(service.save(u,9,input)).containsEntry("state","DONE");
assertThat(service.view(u,9)).containsEntry("services",List.of(2L,1L,4L)).containsEntry("types",List.of(12L,11L));
assertThat(db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_obj_type='ngaudit'",Integer.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT srv_id FROM customerservice WHERE cs_id=10",Long.class)).isEqualTo(3L);
assertThatThrownBy(()->service.save(u,9,new CustomerServices.Save(UUID.randomUUID().toString(),text(initial,"revision"),List.of(1L),List.of(11L)))).hasMessageContaining("409");
assertThat(CustomerServices.recoverable(List.of(new CustomerServices.Assignment(4,0)),List.of(new CustomerServices.Assignment(1,0)),List.of(2L,1L))).isFalse();
support.json.writeValue(java.nio.file.Path.of("target/customer-services-ui-fixture.json").toFile(),service.view(u,9));
}
}
@@ -0,0 +1,25 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import java.util.function.Supplier;
import static de.stadtbote.votian.OrderEntryService.fields;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class EvaluationServiceTest {
final DatabaseService db=mock(DatabaseService.class);final FormAdministration forms=mock(FormAdministration.class);final MessageAdministration messages=mock(MessageAdministration.class);final EvaluationService service=new EvaluationService(db,forms,messages);final LegacyUser user=AppointmentServiceTest.user(0,1,8);
final List<Map<String,Object>> defs=List.of(fields("id",60L,"keyId",1701L,"type","date_begin","label","Beginn","history",1),fields("id",61L,"keyId",1702L,"type","date_end","label","Ende","history",0),fields("id",62L,"keyId",1703L,"type","checkboxes_hq","label","Niederlassungen","history",0));
final List<Map<String,Object>> raw=List.of(fields("mtfck_id",60L,"mtfv_id",12L,"mtfv_value","01.10.2026"),fields("mtfck_id",61L,"mtfv_id",12L,"mtfv_value","31.10.2026"),fields("mtfck_id",62L,"mtfv_id",12L,"mtfv_value","7"));
@BeforeEach void setup(){when(messages.headquarters(user)).thenReturn(List.of(fields("id",7L,"label","B")));when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.equals(MetafieldRecords.definitionsSql(15)))return defs;if(sql.startsWith("SELECT mtfck_id,mtfv_id"))return raw;return List.of();});when(db.contactWriteTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.queryForObject(anyString(),eq(Long.class),any(Object[].class))).thenReturn(13L);}
MetafieldRecords.Save draft(Map<Long,String> values){return new MetafieldRecords.Save(7,AdminSupport.revision(raw),AdminSupport.revision(defs),values);}
@Test void evaluationRequiresBothCustomerAndStatisticsRights(){assertThatThrownBy(()->service.schema(AppointmentServiceTest.user(0),7)).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.schema(AppointmentServiceTest.user(8),7)).isInstanceOf(AccessDeniedException.class);}
@Test void globalCategoryHasIndependentBindings(){assertThat(service.schema(user,7)).containsEntry("headquarters",0L);verify(forms).fieldHeadquarters(user,15);verify(db).queryForList(MetafieldRecords.definitionsSql(15),0L);}
@Test void datesRemainPhpCompatibleAndValuesAreWrittenUnderMetafieldLock(){service.save(user,12L,draft(Map.of(60L,"02.10.2026")));verify(db).contactWriteTransaction(any());verify(db).update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",60L,12L,"02.10.2026");verify(db).update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",62L,12L,"7");}
@Test void invalidDatesAndReverseRangeAreRejectedBeforeWriting(){assertThatThrownBy(()->service.save(user,12L,draft(Map.of(60L,"31.02.2026")))).hasMessageContaining("Datum");assertThatThrownBy(()->service.save(user,12L,draft(Map.of(60L,"01.11.2026")))).hasMessageContaining("Enddatum");verify(db,never()).contactWriteTransaction(any());}
@Test void branchSelectionCannotExpandScope(){assertThatThrownBy(()->service.save(user,12L,draft(Map.of(62L,"7,8")))).hasMessageContaining("freigegeben");assertThatThrownBy(()->service.lookup(user,8,"customer","")).isInstanceOf(AccessDeniedException.class);verify(db,never()).contactWriteTransaction(any());}
@Test void foreignOrUnknownLinkedObjectsCannotBeSaved(){assertThatThrownBy(()->service.validateValue(user,7,fields("type","button:cs:cs_eid"),"OTHER")).hasMessageContaining("404");assertThatThrownBy(()->service.validateValue(user,7,fields("type","select:crvh:crvh_sid"),"OTHER")).hasMessageContaining("nicht gefunden");}
@Test void lookupUsesSessionScopeAndEscapesWildcards(){service.lookup(user,7,"customer","10%_");verify(db).queryForList(contains("WHERE cs.hq_id=?"),eq(7L),eq("%10\\%\\_%"),eq("%10\\%\\_%"));}
@Test void deletionNeverTouchesOtherCategoriesOrHistory(){service.remove(user,7,12,AdminSupport.revision(raw));verify(db).update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN (?,?,?)",12L,60L,61L,62L);verify(db,never()).update(startsWith("DELETE FROM metafieldvaluehistory"),any(Object[].class));}
}
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more