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

").append(escape(text(d,"label"))).append("

").append(byKey.get(id(d,"keyId"))).append("
");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)").matcher(html);var empty=new HashSet();String upper=html.toUpperCase(Locale.ROOT);while(check.find()){int end=upper.indexOf("",check.end());if(end>=0){String content=html.substring(check.end(),end);if(Jsoup.parse(content).text().isBlank()&&!content.toLowerCase(Locale.ROOT).contains(".*?","");if(html.equals(before))break;} + html=html.replaceAll("(?is)",""); + html=html.replaceAll("(?is)","");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 ""+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();} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderArticles.java b/backend/src/main/java/de/stadtbote/votian/OrderArticles.java new file mode 100644 index 0000000..d6b129b --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderArticles.java @@ -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 data){} + record State(OrderOperations.Snapshot order,List> articles,List> scans,List> moves,List> reversals,List> 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.>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.>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.>of(); + return new State(order,articles,scans,moves,reversals,itemData); + } + List> read(boolean lock,String sql,Object...args){return lock?db.lockRows(sql,args):db.queryForList(sql,args);} + Position position(Map 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 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 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 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>();s.articles().forEach(r->old.put(id(r,"trat_id"),r));var seen=new HashSet(); + 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 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>();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 r)->id(r,"stkmv_id")).reversed()); + var result=new ArrayList(); + 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();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)",-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 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 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 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("
",p.data()))); + if(item.booking().fromStockId()==0&&!Objects.toString(item.serialNumber(),"").isBlank()&&!p.data().isEmpty()){ + var row=new LinkedHashMap();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);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCancellation.java b/backend/src/main/java/de/stadtbote/votian/OrderCancellation.java new file mode 100644 index 0000000..e7bd3ce --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderCancellation.java @@ -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 documents,List> 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();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>(); + 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 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 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> rows(State s,BigDecimal fee,BigDecimal courierFee){ + var result=new ArrayList>();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 preview(LegacyUser u,long id,Command c){return db.readTransaction(()->quote(u,prepare(u,id,c,false),c));} + Map 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)); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderChildRecalculation.java b/backend/src/main/java/de/stadtbote/votian/OrderChildRecalculation.java new file mode 100644 index 0000000..59bd0a7 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderChildRecalculation.java @@ -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 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(); + 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> read(boolean lock,String sql,Object... args){return lock?db.lockRows(sql,args):db.queryForList(sql,args);} + List shares(LegacyUser user,List pairs){var result=new ArrayList();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 ids(List 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> changes(List pairs,OrderRecalculation.Input input){ + var ids=ids(pairs,input);var result=new ArrayList>(); + 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 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();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 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 mainId=id(main.job(),"jb_id"); + for(int index=0;indexid(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",""));} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryAccounting.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryAccounting.java new file mode 100644 index 0000000..979e045 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryAccounting.java @@ -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 articles) { + var allocations=new ArrayList(); + if(request.costSplit()) { + if(request.payment()!=Payment.INVOICE || quote.batch())throw invalid("Kostensplit benötigt einen direkt abgerechneten Rechnungsauftrag."); + var centers=new TreeMap>(); + 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(); + 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(); + 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 children(LegacyUser user,Request request,OrderEntryService.Prepared prepared,long parent,Map parentJob,Quote quote) { + if(quote.courierAllocations()==null || quote.courierAllocations().isEmpty())return List.of(); + var ids=new ArrayList();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 parentJob,Quote quote,List> 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 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);}} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryAudit.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryAudit.java new file mode 100644 index 0000000..a36f0ac --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryAudit.java @@ -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. */ } + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryCatalog.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryCatalog.java new file mode 100644 index 0000000..4b9b410 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryCatalog.java @@ -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 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 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
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 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