Compare commits
4
Commits
42c87282ee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33aa4b3682 | ||
|
|
9de2358480 | ||
|
|
781a2b82dc | ||
|
|
363bc27c26 |
No files matched your search
+154
@@ -1,4 +1,24 @@
|
||||
# Als .env im Projektstamm speichern und die vorhandenen Datenbankwerte eintragen.
|
||||
# Native Spring-Tasks (siehe docs/CRON-MIGRATION.md); kein Cron-Daemon erforderlich.
|
||||
# Das Docker-Image aktiviert alle fünf Stadtbote-Gruppen über SPRING_TASK_GROUPS.
|
||||
# Optional die Image-Vorgabe überschreiben, z. B. nur Diagnose:
|
||||
# SPRING_TASK_GROUPS=mysql-status
|
||||
# Alle zeitgesteuerten Gruppen ausdrücklich deaktivieren: SPRING_TASK_GROUPS=
|
||||
# Gruppen können einzeln mit Komma gewählt werden:
|
||||
# courier-logout,stb-compliance,sb-master-export,mysql-status,replication-monitor
|
||||
# Kompatibilitätsfallback für direkten Java-Start ohne SPRING_TASK_GROUPS.
|
||||
LEGACY_CRON_JOBS=
|
||||
# JSON: STB -> [Primärserver:Port,Replikat:Port,...]; ausschließlich Stadtbote.
|
||||
LEGACY_CRON_REPLICATION_TARGETS={}
|
||||
LEGACY_CRON_REPLICATION_USER=
|
||||
LEGACY_CRON_REPLICATION_PASSWORD=
|
||||
LEGACY_CRON_REPLICATION_MAIL_TO=
|
||||
# Die Vorgabezeiten stehen in application.yml, jeweils Europe/Berlin (Spring: sechs Felder).
|
||||
# LEGACY_CRON_LOGOUT_SCHEDULE=0 */30 * * * *
|
||||
# LEGACY_CRON_STB_MAIL_SCHEDULE=0 0 4 * * MON-FRI
|
||||
# LEGACY_CRON_SB_EXPORT_SCHEDULE=0 45 20 * * *
|
||||
# LEGACY_CRON_DB_STATUS_SCHEDULE=0 */5 * * * *
|
||||
# LEGACY_CRON_REPLICATION_SCHEDULE=0 */15 * * * *
|
||||
# Prozessumgebungsvariablen haben Vorrang vor dieser Datei.
|
||||
# Für docker run --env-file .env: Werte ohne umschließende Anführungszeichen.
|
||||
# $, #, Backslashes und = in Werten bleiben wörtlich erhalten. Nicht per Shell source laden.
|
||||
@@ -8,3 +28,137 @@ 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=
|
||||
# Fallbacks for confirmed order mail; headquarters MAIL_CC_ADDRESS / MAIL_BCC_ADDRESS take precedence.
|
||||
ORDER_MAIL_CC=
|
||||
ORDER_MAIL_BCC=
|
||||
|
||||
# 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=
|
||||
|
||||
# PZM-Preisvergleich: separate Auftragsquelle (nur SELECT), Tarife aus DB_URL.
|
||||
COMPARISON_DB_URL=
|
||||
COMPARISON_DB_USER=
|
||||
COMPARISON_DB_PASSWORD=
|
||||
|
||||
# Kartenübersicht: Browser-Kacheldienst, keine geheimen API-Schlüssel.
|
||||
FLEET_MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
FLEET_MAP_ATTRIBUTION=© OpenStreetMap contributors
|
||||
|
||||
# Gemeinsame PHP-Dateiablage: Root ist html/import/upload, kein lokaler PHP-Quellstand.
|
||||
# local = eingebundener Dateipfad; sftp = Server mit geprüftem SSH-Hostschlüssel.
|
||||
DOCUMENTS_MODE=local
|
||||
DOCUMENTS_ROOT=
|
||||
DOCUMENTS_HOST=
|
||||
DOCUMENTS_PORT=22
|
||||
DOCUMENTS_USER=
|
||||
DOCUMENTS_PASSWORD=
|
||||
DOCUMENTS_KEY=
|
||||
DOCUMENTS_KNOWN_HOSTS=
|
||||
|
||||
# Enable only after retiring the BWV return block in the legacy permanent-job worker.
|
||||
# Default off during parallel operation; never run both consumers.
|
||||
BWV_RETOUR_WORKER_ENABLED=false
|
||||
# Enable only after disabling PHP check_jb_permanent.php continuation for this database.
|
||||
RECURRING_ORDER_WORKER_ENABLED=false
|
||||
|
||||
# Native Angebotsrückrufe: erst nach Abschalten des PHP-Rückrufblocks aktivieren.
|
||||
# Die fachlichen Fristen und Modi bleiben in den Parametern der Dispositionsniederlassung.
|
||||
OFFER_REVOCATION_WORKER_ENABLED=false
|
||||
|
||||
# Status mails: leave disabled while PHP mail processes are active. Before enabling,
|
||||
# stop the old automailer processes and set AUTOMAILER_ENABLED=0 in the legacy settings.
|
||||
# Explicitly list the headquarters to migrate. No automatic retry after uncertain SMTP.
|
||||
ORDER_STATUS_MAIL_WORKER_ENABLED=false
|
||||
ORDER_STATUS_MAIL_HEADQUARTERS=
|
||||
|
||||
# Enable only after retiring the legacy PHP automatic assignment worker.
|
||||
AUTO_OFFER_WORKER_ENABLED=false
|
||||
|
||||
# Historische Auftragssnapshots: SSH-Referenz include/glob_defs.inc.php. Nur lesend.
|
||||
ARCHIVE_DB_URL=jdbc:mariadb://172.16.0.123:3381/phoenix?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false
|
||||
# Leer/ungesetzt: vorhandene DB_USER/DB_PASSWORD verwenden.
|
||||
# ARCHIVE_DB_USER=
|
||||
# ARCHIVE_DB_PASSWORD=
|
||||
|
||||
# Gemeinsame Exportdateien: separat auf html/export/download begrenzen.
|
||||
EXPORTS_MODE=local
|
||||
EXPORTS_ROOT=
|
||||
EXPORTS_HOST=
|
||||
EXPORTS_PORT=22
|
||||
EXPORTS_USER=
|
||||
EXPORTS_PASSWORD=
|
||||
EXPORTS_KEY=
|
||||
EXPORTS_KNOWN_HOSTS=
|
||||
# GnuPG-Schlüsselbund mit vertrauenswürdigem Empfängerschlüssel aus company.cmp_keyuser.
|
||||
# Nur nötig, wenn ENCRYPT_EXPORTDATA=1; keine automatische Aktivierung.
|
||||
EXPORTS_GPG_EXECUTABLE=
|
||||
EXPORTS_GPG_HOME=
|
||||
|
||||
# Mobile Fotos im gemeinsamen PHP-Verzeichnis documents/photos (nur Lesen).
|
||||
PHOTOS_MODE=local
|
||||
PHOTOS_ROOT=
|
||||
PHOTOS_HOST=
|
||||
PHOTOS_PORT=22
|
||||
PHOTOS_USER=
|
||||
PHOTOS_PASSWORD=
|
||||
PHOTOS_KEY=
|
||||
PHOTOS_KNOWN_HOSTS=
|
||||
|
||||
# Persönliche Links: neue Kunden-/Transporteur-/Mitarbeiterobjekte registrieren;
|
||||
# nach Mitarbeiterlöschung den zugehörigen zentralen Benutzerschlüssel entfernen.
|
||||
# Nur gespeicherte NG-Neuanlagen; keine nachträgliche Massenanlage für Bestandsdaten.
|
||||
META_OBJECT_SYNC_ENABLED=true
|
||||
|
||||
# Zentrale VPA/PDA-Telefone; leer: as_intern aus der Metaobjekt-Datenbank.
|
||||
APP_DB_URL=
|
||||
# APP_DB_USER= (Standard: META_DB_USER bzw. DB_USER)
|
||||
# APP_DB_PASSWORD= (Standard: META_DB_PASSWORD bzw. DB_PASSWORD)
|
||||
DEVICE_PHONE_SYNC_ENABLED=true
|
||||
|
||||
# Abschlussaufbereitung erst nach Stilllegung des PHP-Prozesses aktivieren.
|
||||
# Zusätzlich ausdrückliche Freigabe der Niederlassungen, z. B. 7,8.
|
||||
ORDER_COMPLETION_WORKER_ENABLED=false
|
||||
ORDER_COMPLETION_PHP_STOPPED=false
|
||||
ORDER_COMPLETION_HEADQUARTERS=
|
||||
|
||||
# Externe Foto-Workflow-Verwaltung; URL ohne Query. Leer: /wf/workflows/index.html am Ursprung von LEGACY_APP_URL.
|
||||
WORKFLOW_MANAGEMENT_URL=
|
||||
# Gemeinsamer HMAC-SHA256-Schlüssel des Workflow-Editors (nicht in Git speichern).
|
||||
WORKFLOW_MANAGEMENT_SECRET=
|
||||
Vendored
+15
@@ -1,5 +1,20 @@
|
||||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"java.configuration.updateBuildConfiguration": "automatic",
|
||||
"java.import.exclusions": [
|
||||
"**/node_modules/**",
|
||||
"**/.metadata/**",
|
||||
"**/archetype-resources/**",
|
||||
"**/META-INF/maven/**",
|
||||
"**/work/**"
|
||||
],
|
||||
"maven.excludedFolders": [
|
||||
"**/.*",
|
||||
"**/node_modules",
|
||||
"**/target",
|
||||
"**/bin",
|
||||
"**/archetype-resources",
|
||||
"**/work"
|
||||
],
|
||||
"git.ignoredRepositories": ["votian"]
|
||||
}
|
||||
+4
-1
@@ -4,7 +4,10 @@ WORKDIR /app
|
||||
ARG JAR_FILE=backend/target/*.jar
|
||||
COPY --chown=10001:10001 ${JAR_FILE} app.jar
|
||||
|
||||
ENV SERVER_ADDRESS=0.0.0.0
|
||||
# Native Spring scheduling only; Java remains PID 1, with no PHP or cron daemon.
|
||||
# Override SPRING_TASK_GROUPS to select groups; an explicit empty value disables them.
|
||||
ENV SERVER_ADDRESS=0.0.0.0 \
|
||||
SPRING_TASK_GROUPS=courier-logout,stb-compliance,sb-master-export,mysql-status,replication-monitor
|
||||
USER 10001:10001
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
# 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.
|
||||
|
||||
Kunden- und Transporteurstamm → Portal-Hauptzugang verwaltet Aktivierung, Anmeldename, Passwort, Sperre und 2FA-Rücksetzung der vorhandenen PHP-Portalzugänge. Kontakt- und Abrechnungsdaten bleiben bei einer Zugangssperre erhalten. Zusätzliche Kundenmitarbeiter, eigene Portalrechte und Kostenstellenfreigaben sind unter „Kundenmitarbeiter / Portalrechte“ pflegbar. Fehlt die PHP-Matrixtabelle, bleibt nur die Matrix gesperrt; zur expliziten Einrichtung liegt `backend/sql/portal-costcenter-matrix.sql` bereit.
|
||||
|
||||
Unter Verwaltung → Mitarbeiter → Lagerrechte sind Hauptlagerzugänge, Unterlagereinschränkungen, Lesemodus und das PHP-Artikelverwaltungsflag pflegbar. Lagerbuchungen prüfen diese Zugänge erneut; bestehende fremde Freigaben und geschützte Konten bleiben geschützt.
|
||||
|
||||
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. Rechnungserstellung, Buchhaltungsläufe und die sechs nativen Automatikdienste sind inzwischen implementiert. Die Betriebsumschaltung der Automatikdienste ist noch ausstehend; der Kategorie-9-Einnahmenexport benötigt weiterhin die Originaldefinition. Der aktuelle Stand und die Prüfgrenzen stehen in [docs/PRUEFSTATUS.md](docs/PRUEFSTATUS.md). 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).
|
||||
|
||||
@@ -93,6 +105,8 @@ Das Sitzungscookie ist `HttpOnly` und `SameSite=Lax`. Standardmäßig ist nach 3
|
||||
|---|---|
|
||||
| `DB_URL`, `DB_USER`, `DB_PASSWORD` | Datenbankzugang aus `.env`; Prozessumgebung hat Vorrang |
|
||||
| `LEGACY_APP_URL` | Basis-URL der vorhandenen PHP-Anwendung, damit Aktionen die bestehenden Fachabläufe öffnen |
|
||||
| `WORKFLOW_MANAGEMENT_URL` | URL des externen Foto-Workflow-Editors ohne Query; leer: `/wf/workflows/index.html` am Ursprung von `LEGACY_APP_URL` |
|
||||
| `WORKFLOW_MANAGEMENT_SECRET` | Gemeinsamer HMAC-SHA256-Schlüssel des Workflow-Editors; ausschließlich als Laufzeit-Secret setzen |
|
||||
| `PORT` | Spring-Boot-Port, Standard 8080 |
|
||||
| `SERVER_ADDRESS` | Bind-Adresse, Standard `127.0.0.1` |
|
||||
| `FORWARD_HEADERS_STRATEGY` | Auswertung der Proxy-Header, Standard `native` |
|
||||
@@ -104,12 +118,16 @@ Die neue Anwendung ist standardmäßig nur lokal erreichbar. Für einen Serverbe
|
||||
|
||||
## Prüfungen
|
||||
|
||||
Die [Unit-Tests der PHP-Altanwendung](legacy-php-tests/README.md) prüfen den aktuellen Serverquellstand in einem separaten Container mit dem Image der Altanwendung. Auf `172.16.0.158` starten sie mit `/data/0/votian-test/tests/unit/run-server.sh`. Testumfang, Isolation und bekannte Kalenderfehler sind in der verlinkten Anleitung dokumentiert.
|
||||
|
||||
Die [Oberflächentests der PHP-Altanwendung](legacy-ui-tests/README.md) prüfen die echte Auftragserfassung auf `test.sb.assecutor.de` mit Playwright und Firefox. Start auf dem Server: `/data/0/votian-test/tests/ui/run-server.sh`; lokal: `npm run test:legacy-ui`. Ein tatsächlicher Speichertest wird mit `--create-order` aktiviert und verwendet ausschließlich die bezeichneten Assecutor-Testkunden.
|
||||
|
||||
```bash
|
||||
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,15 +135,37 @@ 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
|
||||
scripts/ Lokaler Start mit vorhandener Konfiguration
|
||||
tests/ Zusätzliche Konfigurations- und Browserprüfungen
|
||||
```
|
||||
|
||||
|
||||
### Zeitgesteuerte Angebotsrückrufe
|
||||
|
||||
`OFFER_REVOCATION_WORKER_ENABLED=false` hält den nativen Rückruf-/Sperrbereinigungsdienst im PHP-Parallelbetrieb aus. Zur Umschaltung zuerst die Rückruf- und Sperrbereinigungsblöcke in `include/autoranking_main.php` außer Betrieb nehmen, dann den Schalter setzen und NG neu starten. Der Angebotslauf kann separat mit dem nachfolgend beschriebenen nativen Dienst abgelöst werden. Die Fachparameter `AUTORANKING_REVOCATION_ENABLED`, `AUTORANKING_REVOKE_JOB_MODE` und die automatischen/manuellen Minuten-/Sekundenfristen stammen direkt aus der Dispositionsniederlassung. Keine globale Vererbung für diesen PHP-Pfad. Nur transaktionale Schreibziele werden verarbeitet; ungültige Zuordnungen und Fristen bleiben zur Klärung stehen. Die Fristanzeige unter Kurierauswahl → Vermittlung ändern liest diese Einstellungen auch bei ausgeschaltetem NG-Worker.
|
||||
|
||||
|
||||
### Automatische Angebote
|
||||
|
||||
`AUTO_OFFER_WORKER_ENABLED=false` hält den nativen Angebotslauf aus. Die lesende Kandidatenvorschau ist bereits unter Kurierauswahl → Vermittlung ändern verfügbar. Vor Aktivierung zuerst den Angebotsblock von `include/autoranking_main.php` beenden; anschließend NG mit `AUTO_OFFER_WORKER_ENABLED=true` neu starten. Rückrufe und Freigabe überholter Kuriersemaphoren müssen weiterhin durch genau einen zuständigen Dienst verarbeitet werden (separater Schalter `OFFER_REVOCATION_WORKER_ENABLED`). Der bestehende Geräteabruf und Auditverbraucher bleiben erforderlich; der vorhandene `keepalive`-Eintrag `autoranking` wird vom aktivierten Angebotslauf fortgeschrieben.
|
||||
|
||||
Der Angebotslauf liest die Fachparameter der Dispositionsniederlassung: Aktivierung, Versuchslimit, Rundenanzahl, Vermittlungsmodus/-tiefe und Fahrzeugregeln. Kundenbezogene Favoriten-/Sperrschalter verwenden den jeweiligen PHP-Parameterfallback. Die Schreibziele `job`, `courier`, `autoranking` und `genericdatacontainer` benötigen InnoDB. Aufträge mit unvollständiger oder mehrdeutiger Konfiguration bleiben zur Klärung stehen. Ausführung ist alle 15 Sekunden nach Abschluss des vorherigen Durchlaufs, mit 40 Aufträgen pro Seite. Es erfolgt keine Schemaumstellung durch den Dienst.
|
||||
|
||||
### Betriebsstatus
|
||||
|
||||
Verwaltung → Betriebsstatus zeigt die sechs nativen Automatikdienste mit Aktivierung, Umfang, letztem abgeschlossenen Lauf und Fehler-/Ergebniszahlen. Die Laufwerte gelten für die gesamte NG-Instanz und beginnen nach einem Neustart neu. Ein Lauf ohne Abschluss seit mehr als drei Minuten wird als überfällig gekennzeichnet; daraus folgt nicht automatisch, dass der Prozess beendet ist. Gemeinsame `keepalive`-Zeitstempel können von PHP oder NG stammen und sind kein Nachweis der Zuständigkeit.
|
||||
|
||||
Auftrags-/Kurierkennzahlen und Geräte-Abschlussmeldungen sind auf die ursprüngliche Niederlassung begrenzt. Erforderlich ist das vorhandene Mitarbeiterverwaltungsrecht (Position 3). Das Laden der Ansicht liest ausschließlich; es startet keine Dienste und versendet keine Nachrichten. Die gesonderte Prüffreigabe eines unterbrochenen Cron-Laufs wird protokolliert und erlaubt dessen nächsten regulären Termin. Fehlende Datenquellen werden einzeln als nicht verfügbar angezeigt.
|
||||
|
||||
### Zeitgesteuerte Spring-Tasks
|
||||
|
||||
Die bestätigte PHP-Crontab wird über native Spring-Tasks abgelöst. Das Docker-Image aktiviert beim Start alle fünf Stadtbote-Gruppen (111 Tasks) über `SPRING_TASK_GROUPS`; feste Termine gelten nach Europe/Berlin. Der Container startet ausschließlich Java und benötigt weder Cron-Daemon noch PHP. `SPRING_TASK_GROUPS` kann auf einzelne Gruppen oder einen leeren Wert gesetzt werden. Beim direkten Java-Start bleibt `LEGACY_CRON_JOBS` als Fallback erhalten. Der Betriebsstatus zeigt dauerhafte Laufbelege. HHA-/PTnova-Funktionen, fremde Replikationsgruppen und die Wartung alter PHP-Serverdateien wurden am 27.10.2026 entfernt. Die Replikationsüberwachung ist auf Stadtbote (`STB`) begrenzt. Zeitpläne, Konfiguration, Änderungen gegenüber PHP und Wiederanlauf nach einem unterbrochenen Versand stehen in [CRON-MIGRATION.md](docs/CRON-MIGRATION.md).
|
||||
+36
-10
@@ -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.
|
||||
+39
-2
@@ -2,9 +2,15 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.16</version><relativePath/></parent>
|
||||
<groupId>de.stadtbote</groupId><artifactId>votian-backend</artifactId><version>0.1.2</version>
|
||||
<groupId>de.stadtbote</groupId><artifactId>votian-backend</artifactId><version>0.2.1</version>
|
||||
<name>Votian NG Stadtbote</name><properties><java.version>21</java.version></properties>
|
||||
<dependencies>
|
||||
<dependency><groupId>commons-net</groupId><artifactId>commons-net</artifactId><version>3.13.0</version></dependency>
|
||||
<dependency><groupId>com.github.mwiede</groupId><artifactId>jsch</artifactId><version>2.28.5</version></dependency>
|
||||
<dependency><groupId>com.google.zxing</groupId><artifactId>core</artifactId><version>3.5.4</version></dependency>
|
||||
<dependency><groupId>org.apache.pdfbox</groupId><artifactId>pdfbox</artifactId><version>3.0.8</version></dependency>
|
||||
<dependency><groupId>org.jsoup</groupId><artifactId>jsoup</artifactId><version>1.20.1</version></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-mail</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
|
||||
@@ -13,5 +19,36 @@
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
<build><resources><resource><directory>src/main/resources</directory></resource><resource><directory>../frontend/dist</directory><targetPath>static</targetPath></resource></resources><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
<build>
|
||||
<resources>
|
||||
<resource><directory>src/main/resources</directory></resource>
|
||||
<resource><directory>${project.build.directory}/frontend-resources</directory></resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-antrun-plugin</artifactId><version>3.1.0</version>
|
||||
<executions><execution>
|
||||
<id>stage-frontend</id><phase>generate-resources</phase><goals><goal>run</goal></goals>
|
||||
<configuration><target>
|
||||
<!-- Keep Maven/Eclipse resources inside this project, while Vite owns frontend/dist. -->
|
||||
<delete dir="${project.build.directory}/frontend-resources"/>
|
||||
<copy todir="${project.build.directory}/frontend-resources/static">
|
||||
<fileset dir="${project.basedir}/../frontend/dist" erroronmissingdir="false"/>
|
||||
</copy>
|
||||
</target></configuration>
|
||||
</execution></executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
<pluginManagement><plugins><plugin>
|
||||
<groupId>org.eclipse.m2e</groupId><artifactId>lifecycle-mapping</artifactId><version>1.0.0</version>
|
||||
<configuration><lifecycleMappingMetadata><pluginExecutions><pluginExecution>
|
||||
<pluginExecutionFilter>
|
||||
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-antrun-plugin</artifactId>
|
||||
<versionRange>[3.1.0,)</versionRange><goals><goal>run</goal></goals>
|
||||
</pluginExecutionFilter>
|
||||
<action><execute><runOnIncremental>false</runOnIncremental><runOnConfiguration>true</runOnConfiguration></execute></action>
|
||||
</pluginExecution></pluginExecutions></lifecycleMappingMetadata></configuration>
|
||||
</plugin></plugins></pluginManagement>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Optional PHP-compatible schema preparation. Review and apply explicitly to the intended database.
|
||||
-- No account, permission, or configuration changes; does not enable MASK_EMP_CSC_MATRIX_ENABLED.
|
||||
-- Deliberately fails when a table already exists: inspect its engine, columns and duplicates first.
|
||||
CREATE TABLE employeecostcenter (
|
||||
emp_id INT NOT NULL,
|
||||
csc_id INT NOT NULL,
|
||||
PRIMARY KEY (emp_id, csc_id),
|
||||
KEY costcenter_employee (csc_id, emp_id)
|
||||
) ENGINE=InnoDB;
|
||||
@@ -0,0 +1,97 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.security.SecureRandom;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
@RestController
|
||||
class AccountTwoFactor {
|
||||
final DatabaseService db;final BusinessSupport support;final EmployeeAccounts accounts;final Clock clock;
|
||||
static final String PENDING="votian.twoFactor.pending";
|
||||
record Policy(boolean enabled,boolean noSelfDeactivation){}
|
||||
record State(EmployeeAccounts.State account,Policy policy){}
|
||||
record Setup(String requestId,String revision,String password){@Override public String toString(){return "Setup[redacted]";}}
|
||||
record Verify(String requestId,String setupId,String code){@Override public String toString(){return "Verify[redacted]";}}
|
||||
record Reset(String requestId,String revision,String password,String code,String account){@Override public String toString(){return "Reset[redacted]";}}
|
||||
record Cancel(String setupId){}
|
||||
static final class Pending implements java.io.Serializable {
|
||||
private static final long serialVersionUID = 5618186135847531088L;
|
||||
final long user;final String id,revision,secret,setupRequest;final Instant expires;int attempts;
|
||||
Pending(long user,String id,String revision,String secret,String setupRequest,Instant expires){this.user=user;this.id=id;this.revision=revision;this.secret=secret;this.setupRequest=setupRequest;this.expires=expires;}
|
||||
@Override public String toString(){return "Pending[redacted]";}
|
||||
}
|
||||
@org.springframework.beans.factory.annotation.Autowired AccountTwoFactor(DatabaseService db,BusinessSupport support,EmployeeAccounts accounts){this(db,support,accounts,Clock.systemUTC());}
|
||||
AccountTwoFactor(DatabaseService db,BusinessSupport support,EmployeeAccounts accounts,Clock clock){this.db=db;this.support=support;this.accounts=accounts;this.clock=clock;}
|
||||
@GetMapping("/api/account/two-factor") Object own(Authentication a){var u=ApiController.user(a);return view(u,u.employeeId(),true);}
|
||||
@GetMapping("/api/administration/employees/{employee}/two-factor") Object other(Authentication a,@PathVariable long employee){return view(ApiController.user(a),employee,false);}
|
||||
@PostMapping("/api/account/two-factor/setup") Object setup(Authentication a,HttpSession session,@RequestBody Setup r){return setup(ApiController.user(a),session,r);}
|
||||
@PostMapping("/api/account/two-factor/activate") Object activate(Authentication a,HttpSession session,@RequestBody Verify r){return activate(ApiController.user(a),session,r);}
|
||||
@PostMapping("/api/account/two-factor/cancel") Object cancel(Authentication a,HttpSession session,@RequestBody Cancel r){var u=ApiController.user(a);synchronized(session){Object raw=session.getAttribute(PENDING);if(raw instanceof Pending p&&p.user==u.userId()&&r!=null&&p.id.equals(r.setupId()))session.removeAttribute(PENDING);return fields("cancelled",true);}}
|
||||
@PostMapping("/api/account/two-factor/reset") Object ownReset(Authentication a,@RequestBody Reset r){var u=ApiController.user(a);return reset(u,u.employeeId(),true,r);}
|
||||
@PostMapping("/api/administration/employees/{employee}/two-factor/reset") Object otherReset(Authentication a,@PathVariable long employee,@RequestBody Reset r){return reset(ApiController.user(a),employee,false,r);}
|
||||
Policy policy(){
|
||||
var rows=db.queryForList("SELECT par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id=0 AND par_key IN ('USERTYPE_2FA_ENABLED','USER_2FA_NO_DEACTIVATION') ORDER BY par_key");
|
||||
for(String key:List.of("USERTYPE_2FA_ENABLED","USER_2FA_NO_DEACTIVATION"))if(rows.stream().filter(r->text(r,"par_key").equals(key)).count()>1)throw bad("Die Zwei-Faktor-Vorgaben sind nicht eindeutig.");
|
||||
String enabled=EmployeeAdministration.setting(rows,"USERTYPE_2FA_ENABLED").split(",",-1)[0].strip(),disabled=EmployeeAdministration.setting(rows,"USER_2FA_NO_DEACTIVATION").strip();
|
||||
if(!Set.of("","0","1").contains(enabled)||!Set.of("","0","1").contains(disabled))throw bad("Die Zwei-Faktor-Vorgaben sind ungültig.");return new Policy(enabled.equals("1"),disabled.equals("1"));
|
||||
}
|
||||
State state(LegacyUser u,long employee,boolean self){return new State(accounts.state(u,employee,self),policy());}
|
||||
static boolean active(State s){return id(s.account().credentials(),"usr_totp_activated")==1;}
|
||||
boolean canReset(LegacyUser u,State s,boolean self){
|
||||
if(self)return s.policy().enabled()&&!s.policy().noSelfDeactivation();
|
||||
var employee=s.account().employee();boolean own=id(employee.person(),"emp_id")==u.employeeId();
|
||||
return employee.access().root()&&(own||!employee.protectedAccount())||!employee.protectedAccount()&&EmployeeAdministration.bit(text(employee.access().actor(),"emp_rights"),43);
|
||||
}
|
||||
Map<String,Object> view(LegacyUser u,long employee,boolean self){return db.readTransaction(()->{var s=state(u,employee,self);return fields("revision",revision(s),"active",active(s),"canSetup",self&&s.policy().enabled()&&!active(s),"canReset",active(s)&&canReset(u,s,self),"selfDeactivationBlocked",s.policy().noSelfDeactivation(),"self",self,"account",text(s.account().employee().person(),"usr_account"));});}
|
||||
void authenticateActor(LegacyUser u,String password){if(!accounts.matches(u.userId(),password,true))throw bad("Das aktuelle Passwort ist ungültig.");}
|
||||
Map<String,Object> setup(LegacyUser u,HttpSession session,Setup r){
|
||||
if(r==null)throw bad("Einrichtungsangaben fehlen.");BusinessSupport.token(r.requestId());synchronized(session){
|
||||
return db.readTransaction(()->{var s=state(u,u.employeeId(),true);unchanged(s,r.revision());if(!s.policy().enabled()||active(s))throw EmployeeAdministration.denied();authenticateActor(u,r.password());
|
||||
Pending pending=session.getAttribute(PENDING) instanceof Pending p?p:null;
|
||||
if(pending!=null&&pending.user==u.userId()&&pending.setupRequest.equals(r.requestId())&&pending.revision.equals(r.revision())&&clock.instant().isBefore(pending.expires))return setupView(s,pending);
|
||||
byte[] entropy=new byte[20];new SecureRandom().nextBytes(entropy);String secret=base32(entropy);
|
||||
var next=new Pending(u.userId(),UUID.randomUUID().toString(),r.revision(),secret,r.requestId(),clock.instant().plusSeconds(600));session.setAttribute(PENDING,next);return setupView(s,next);
|
||||
});
|
||||
}
|
||||
}
|
||||
static String base32(byte[] bytes){var out=new StringBuilder();int buffer=0,bits=0;for(byte b:bytes){buffer=(buffer<<8)|(b&255);bits+=8;while(bits>=5){bits-=5;out.append("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".charAt((buffer>>bits)&31));}}if(bits>0)out.append("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".charAt((buffer<<(5-bits))&31));return out.toString();}
|
||||
static String encode(String value){return java.net.URLEncoder.encode(value,java.nio.charset.StandardCharsets.UTF_8).replace("+","%20");}
|
||||
Map<String,Object> setupView(State s,Pending p){String account=text(s.account().employee().person(),"usr_account");String uri="otpauth://totp/"+encode("Stadtbote:"+account)+"?secret="+p.secret+"&issuer=Stadtbote&algorithm=SHA1&digits=6&period=30";return fields("setupId",p.id,"secret",p.secret,"qrDataUrl",qr(uri),"expiresAt",p.expires.toString(),"account",account);}
|
||||
static String qr(String uri){
|
||||
try{var matrix=new com.google.zxing.qrcode.QRCodeWriter().encode(uri,com.google.zxing.BarcodeFormat.QR_CODE,0,0,Map.of(com.google.zxing.EncodeHintType.MARGIN,4,com.google.zxing.EncodeHintType.ERROR_CORRECTION,com.google.zxing.qrcode.decoder.ErrorCorrectionLevel.M));int n=matrix.getWidth();var path=new StringBuilder();for(int y=0;y<n;y++)for(int x=0;x<n;x++)if(matrix.get(x,y))path.append("M").append(x).append(',').append(y).append("h1v1h-1z");String svg="<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 "+n+" "+n+"\" shape-rendering=\"crispEdges\"><path fill=\"white\" d=\"M0 0h"+n+"v"+n+"H0z\"/><path fill=\"black\" d=\""+path+"\"/></svg>";return "data:image/svg+xml;base64,"+Base64.getEncoder().encodeToString(svg.getBytes(java.nio.charset.StandardCharsets.UTF_8));}catch(com.google.zxing.WriterException e){throw new IllegalStateException("QR-Code konnte nicht erzeugt werden.");}
|
||||
}
|
||||
Map<String,Object> activate(LegacyUser u,HttpSession session,Verify r){
|
||||
if(r==null)throw bad("Bestätigung fehlt.");BusinessSupport.token(r.requestId());BusinessSupport.token(r.setupId());support.transactional("user","genericdatacontainer");
|
||||
synchronized(session){
|
||||
Object raw=session.getAttribute(PENDING);if(!(raw instanceof Pending p)||p.user!=u.userId()||!p.id.equals(r.setupId())||!clock.instant().isBefore(p.expires))throw bad("Die Einrichtung ist abgelaufen. Bitte erneut beginnen.");
|
||||
if(p.attempts>=5)throw bad("Zu viele ungültige Codes. Bitte die Einrichtung erneut beginnen.");
|
||||
return db.employeeWriteTransaction(()->{var s=state(u,u.employeeId(),true);Object intent=List.of("EMPLOYEE_2FA_ACTIVATE",p.id,p.revision);Long previous=support.receipt(u,r.requestId(),intent);if(previous!=null)return fields("id",previous,"reauthenticate",true);
|
||||
unchanged(s,p.revision);if(active(s)||!s.policy().enabled())throw EmployeeAdministration.denied();
|
||||
long seconds=clock.instant().getEpochSecond();
|
||||
if(!LegacyTotp.matches(p.secret,r.code(),seconds)){p.attempts++;throw bad("Der Code ist ungültig. Bitte den aktuellen sechsstelligen App-Code eingeben.");}
|
||||
db.update("UPDATE user SET usr_totp_secret=?,usr_totp_activated=1,usr_totp_sessionkey=? WHERE usr_id=?",p.secret,UUID.randomUUID().toString().replace("-",""),u.userId());
|
||||
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngtotp' AND gdc_obj_id=? AND gdc_gen_fieldname='login'",u.userId());
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","ngtotp","gdc_obj_id",u.userId(),"gdc_gen_fieldname","login","gdc_content",Long.toString(seconds/30),"gdc_context",revision(p.secret)));
|
||||
support.auditEmployee(u,u.employeeId(),74,"EMPLOYEE="+u.employeeId()+" | TWO_FACTOR_ACTIVATED",r.requestId());support.receipt(u,r.requestId(),intent,u.employeeId());return fields("id",u.employeeId(),"reauthenticate",true);
|
||||
});
|
||||
}
|
||||
}
|
||||
Map<String,Object> reset(LegacyUser u,long employee,boolean self,Reset r){
|
||||
if(!self)u.require(3);if(r==null)throw bad("Rücksetzungsangaben fehlen.");BusinessSupport.token(r.requestId());support.transactional("user","genericdatacontainer");
|
||||
return db.employeeWriteTransaction(()->{var s=state(u,employee,self);if(!canReset(u,s,self))throw EmployeeAdministration.denied();authenticateActor(u,r.password());
|
||||
Object intent=List.of("EMPLOYEE_2FA_RESET",employee,self,Objects.toString(r.revision(),""),Objects.toString(r.account(),""));Long previous=support.receipt(u,r.requestId(),intent);if(previous!=null)return fields("id",previous,"reauthenticate",employee==u.employeeId());
|
||||
unchanged(s,r.revision());if(!active(s))throw bad("Die Zwei-Faktor-Anmeldung ist nicht aktiviert.");if(!text(s.account().employee().person(),"usr_account").equals(r.account()))throw bad("Bitte den Anmeldenamen zur Bestätigung eingeben.");long uid=id(s.account().employee().person(),"usr_id");
|
||||
if(self){var secret=one(db.queryForList("SELECT usr_totp_secret FROM user WHERE usr_id=?",uid));if(!LegacyTotp.matches(text(secret,"usr_totp_secret"),r.code(),clock.instant().getEpochSecond()))throw bad("Der aktuelle Zwei-Faktor-Code ist ungültig.");}
|
||||
db.update("UPDATE user SET usr_totp_secret='',usr_totp_activated=0,usr_totp_sessionkey=? WHERE usr_id=?",UUID.randomUUID().toString().replace("-",""),uid);
|
||||
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngtotp' AND gdc_obj_id=? AND gdc_gen_fieldname='login'",uid);
|
||||
support.auditEmployee(u,employee,184,"EMPLOYEE="+employee+" | TWO_FACTOR_RESET",r.requestId());support.receipt(u,r.requestId(),intent,employee);return fields("id",employee,"reauthenticate",employee==u.employeeId());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.io.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.zip.Deflater;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.OrderCustomerMailContent.*;
|
||||
|
||||
/** Capture under the export lock, then publish PHP-compatible job2 blobs from a transactional outbox. */
|
||||
@Service
|
||||
final class AccountingExportArchive {
|
||||
record Payload(long order,byte[] raw,byte[] headquarters,byte[] mail,byte[] customer) {}
|
||||
record Pending(String request,long order,String digest,int parts) {}
|
||||
final DatabaseService db;final BusinessSupport support;
|
||||
AccountingExportArchive(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
|
||||
static String htmlRow(String name,Object value){return "<tr><td>"+escape(name)+"</td><td>"+escape(value).replace("\n","<br>").replace("-;-","-;-")+"</td></tr>";}
|
||||
static String rawValue(Object value){return Objects.toString(value,"").replace('|','/').replace("-;-","- ; -").replace('\0',' ');}
|
||||
static String pairs(Map<String,?> values){var out=new StringJoiner("|");values.forEach((k,v)->out.add(k+"="+rawValue(v)));return out.toString();}
|
||||
static byte[] compress(String value){
|
||||
if(value.length()>OrderArchive.MAX_INFLATED)throw bad("Der historische Abrechnungsstand ist zu groß.");
|
||||
byte[] input=MasterExportFormat.bytes(value);var compressor=new Deflater(6,true);try{compressor.setInput(input);compressor.finish();var out=new ByteArrayOutputStream();byte[] block=new byte[8192];while(!compressor.finished()){int n=compressor.deflate(block);out.write(block,0,n);if(out.size()>65535)throw bad("Der historische Abrechnungsstand überschreitet die bestehende Archivspalte (64 KB). Der Auftrag wurde nicht als exportiert markiert.");}return out.toByteArray();}finally{compressor.end();}
|
||||
}
|
||||
List<Map<String,Object>> rows(String sql,List<Long> ids){if(ids.isEmpty())return List.of();var out=db.queryForList(sql.replace("{ids}",AccountingExportData.marks(ids.size()))+" LIMIT 100001",ids.toArray());if(out.size()>100000)throw bad("Zu viele Daten für die historische Auftragskopie. Bitte den Export eingrenzen.");return out;}
|
||||
static Map<Long,Map<String,Object>> unique(List<Map<String,Object>> rows,String key){AccountingExportData.unique(rows,key,"historische Zuordnung");var out=new HashMap<Long,Map<String,Object>>();rows.forEach(r->out.put(id(r,key),r));return out;}
|
||||
void capture(LegacyUser user,String request,Set<Long> selected,LocalDateTime at){
|
||||
if(selected.isEmpty())return;var ids=new ArrayList<>(selected);
|
||||
var existing=rows("SELECT jb_id,OCTET_LENGTH(jb2_history_rawdata) AS rawSize,OCTET_LENGTH(jb2_history_hq) AS hqSize,OCTET_LENGTH(jb2_history_mail) AS mailSize,OCTET_LENGTH(jb2_history_cs) AS csSize FROM job2 WHERE jb_id IN ({ids}) ORDER BY jb_id",ids);
|
||||
unique(existing,"jb_id");for(var row:existing){if(List.of("rawSize","hqSize","mailSize","csSize").stream().anyMatch(k->id(row,k)==0))throw bad("Auftrag #"+id(row,"jb_id")+": vorhandener Archivstand ist unvollständig.");ids.remove(id(row,"jb_id"));}if(ids.isEmpty())return;
|
||||
var jobs=rows("SELECT * FROM job WHERE jb_id IN ({ids}) ORDER BY jb_id",ids);if(jobs.size()!=ids.size())throw bad("Ein zu archivierender Auftrag fehlt.");
|
||||
var stops=rows("SELECT t.*,archive_a.ad_street,archive_a.ad_zipcode,archive_a.ad_city,archive_c.csc_name,archive_cs.cs_eid FROM tour t LEFT JOIN address archive_a ON archive_a.ad_id=t.ad_id LEFT JOIN costcenter archive_c ON archive_c.csc_id=t.csc_id LEFT JOIN customer archive_cs ON archive_cs.cs_id=archive_c.cs_id WHERE t.jb_id IN ({ids}) ORDER BY t.jb_id,t.tr_sort,t.tr_id",ids);var byJob=CustomerExportData.group(stops,"jb_id");
|
||||
var costs=rows("SELECT * FROM tourservice WHERE jb_id IN ({ids}) ORDER BY jb_id,csc_id,tr_sort,srv_id,srvt_id,trs_srv_name",ids);var jobCosts=CustomerExportData.group(costs,"jb_id");
|
||||
var prices=CustomerExportData.group(rows("SELECT * FROM jobprice WHERE jb_id IN ({ids}) ORDER BY jb_id,mt_sort,jbprc_price,jbprc_remark",ids),"jb_id");
|
||||
var payers=jobs.stream().map(j->id(j,"csc_id_payer")).filter(k->k>0).distinct().sorted().toList();
|
||||
var customers=unique(rows("SELECT archive_c.csc_id,archive_c.csc_name,archive_cs.cs_id,archive_cs.cs_eid,archive_cs.cs_jbstatusmail_fields,archive_cs.cs_jbstatusmail_price FROM costcenter archive_c JOIN customer archive_cs ON archive_cs.cs_id=archive_c.cs_id WHERE archive_c.csc_id IN ({ids}) ORDER BY archive_c.csc_id",payers),"csc_id");
|
||||
var couriers=unique(rows("SELECT archive_cr.cr_id,archive_cr.cr_eid,archive_cr.cr_sid,archive_u.usr_phone,archive_u.usr_name,archive_u.usr_firstname FROM courier archive_cr LEFT JOIN user archive_u ON archive_u.usr_id=archive_cr.usr_id WHERE archive_cr.cr_id IN ({ids}) ORDER BY archive_cr.cr_id",jobs.stream().map(j->id(j,"cr_id")).filter(k->k>0).distinct().sorted().toList()),"cr_id");
|
||||
var vehicles=rows("SELECT archive_cv.cr_id,archive_cv.crvh_sid,archive_cv.crvh_vh_sign FROM couriervehicle archive_cv WHERE archive_cv.cr_id IN ({ids}) ORDER BY archive_cv.cr_id,archive_cv.crvh_sid",jobs.stream().map(j->id(j,"cr_id")).filter(k->k>0).distinct().sorted().toList());
|
||||
var employees=unique(rows("SELECT archive_e.emp_id,archive_u.usr_name,archive_u.usr_firstname,archive_u.usr_phone,archive_u.usr_email FROM employee archive_e JOIN user archive_u ON archive_u.usr_id=archive_e.usr_id WHERE archive_e.emp_id IN ({ids}) ORDER BY archive_e.emp_id",jobs.stream().map(j->id(j,"emp_id")).filter(k->k>0).distinct().sorted().toList()),"emp_id");
|
||||
var jobMeta=rows("SELECT gdc_obj_id,gdc_gen_fieldname,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id IN ({ids}) ORDER BY gdc_obj_id,gdc_gen_fieldname,gdc_id",ids);
|
||||
var stopMeta=rows("SELECT gdc_obj_id,gdc_gen_fieldname,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id IN ({ids}) ORDER BY gdc_obj_id,gdc_gen_fieldname,gdc_id",stops.stream().map(s->id(s,"tr_id")).toList());
|
||||
var metadata=CustomerExportData.group(jobMeta,"gdc_obj_id");var stopData=CustomerExportData.group(stopMeta,"gdc_obj_id");
|
||||
var types=db.queryForList("SELECT mt_type,mt_sort,mt_value FROM metatype WHERE mt_type IN ('vehicletype','internal_remark') ORDER BY mt_type,mt_sort");
|
||||
// No passwords or account rows enter the snapshot. Preserve the exact structured source separately from PHP's lossy delimiter format.
|
||||
var content=new OrderCustomerMailContent(db,user.headquartersId(),"");
|
||||
for(var original:jobs){long order=id(original,"jb_id");var job=new LinkedHashMap<>(original);job.put("jb_export_time",at);var tour=byJob.getOrDefault(order,List.of());if(tour.isEmpty()||tour.size()>100||tour.stream().map(t->id(t,"tr_sort")).distinct().count()!=tour.size())throw bad("Auftrag #"+order+": Stationen fehlen oder sind nicht eindeutig.");
|
||||
var payer=customers.getOrDefault(id(job,"csc_id_payer"),Map.of());if(id(job,"csc_id_payer")>0&&payer.isEmpty())throw bad("Auftrag #"+order+": Rechnungskunde fehlt.");var courier=couriers.getOrDefault(id(job,"cr_id"),Map.of());var employee=employees.getOrDefault(id(job,"emp_id"),Map.of());
|
||||
var vehicle=vehicles.stream().filter(v->id(v,"cr_id")==id(job,"cr_id")&&text(v,"crvh_sid").equals(text(job,"cr_sid"))).toList();if(vehicle.size()>1)throw bad("Mehrdeutiges Archivfahrzeug.");String plate=vehicle.isEmpty()?"":text(vehicle.getFirst(),"crvh_vh_sign");
|
||||
var type=types.stream().filter(t->text(t,"mt_type").equals("vehicletype")&&id(t,"mt_sort")==id(job,"vht_id")).toList();if(type.size()>1)throw bad("Mehrdeutige Fahrzeugart.");String vehicleName=type.isEmpty()?text(job,"vht_id"):text(type.getFirst(),"mt_value");
|
||||
|
||||
String flags=text(payer,"cs_jbstatusmail_fields");long customer=id(payer,"cs_id");var meta=metadata.getOrDefault(order,List.of());var raw=new LinkedHashMap<String,Object>();job.forEach((k,v)->raw.put("jb_"+k,v));courier.forEach((k,v)->raw.put("cr_"+k,v));employee.forEach((k,v)->raw.put("emp_"+k,v));
|
||||
var par=new LinkedHashMap<String,Object>();for(var key:List.of("status","tourname","export_time","reserv","incomplete","longhaul","id_parent","subtotalprice","markup","ordertime"))par.put("job_"+key,job.get("jb_"+key));par.put("job_cr_sid",job.get("cr_sid"));par.put("jobCourierId",id(job,"cr_id"));par.put("cscIdPayer",id(job,"csc_id_payer"));par.put("csIdPayer",customer);
|
||||
String jobHtml=htmlRow("Nr.",order)+htmlRow("Re.-Ko.-Nr.",text(job,"jb_commission_no"))+htmlRow("Fahrzeug",vehicleName)+htmlRow("Auftragszeit",time(job,"jb_ordertime"))+htmlRow("Annahme",time(job,"jb_taketime"))+htmlRow("Erledigung",time(job,"jb_finishtime"));
|
||||
var hqJob=new StringBuilder(jobHtml);for(var key:Map.of("jb_orderer","Besteller","jb_tourname","Tourenname","jb_dispoinfo","Dispo-Info","jb_cr_filter","Filter","jb_cr_filter_opt","Opt. Filter","jb_weight","Gewicht","jb_outlay","Auslage").entrySet())hqJob.append(htmlRow(key.getValue(),text(job,key.getKey())));hqJob.append(htmlRow("Abrechnungsexport",at));
|
||||
var hqTour=new StringBuilder();var mailTour=new StringBuilder();var csTour=new StringBuilder();
|
||||
for(var stop:tour){long position=id(stop,"tr_sort");if(position<1||position>1000)throw bad("Ungültige Stationsnummer im Archiv.");stop.forEach((k,v)->raw.put("tr_"+position+"_"+k,v));var extra=stopData.getOrDefault(id(stop,"tr_id"),List.of());String publicRemark=OrderDeliveryData.publicRemark(text(stop,"tr_remark"));
|
||||
var common=new StringBuilder(htmlRow("Firma",text(stop,"tr_comp"))+htmlRow("",text(stop,"tr_comp2"))+htmlRow("Person",text(stop,"tr_person"))+htmlRow("Straße + Hausnr.",text(stop,"ad_street")+" "+text(stop,"tr_hsno"))+htmlRow("PLZ + Ort",text(stop,"ad_zipcode")+" "+text(stop,"ad_city"))+htmlRow("EID",text(stop,"cs_eid"))+htmlRow("Tel.",text(stop,"tr_phone"))+htmlRow("Kommissionsnummer",text(stop,"tr_commission_no"))+htmlRow("Erledigungszeit",time(stop,"tr_finishtime"))+htmlRow("Quittungsgeber",text(stop,"tr_signname")));
|
||||
var signature=OrderDeliveryData.signature(text(stop,"tr_sign"));common.append(signature==null?htmlRow("Unterschrift",""):OrderMailEvidence.image("Unterschrift",signature));
|
||||
// PHP keeps photos outside job2; the native archive reader resolves this row from the station evidence store.
|
||||
common.append(htmlRow("Abliefernachweis Foto",""));
|
||||
var allowed=new StringBuilder(common);if(content.object("JOBDETAILS_STATIONS_STATION_NUMBER",customer).equals("1"))allowed.append(htmlRow("Station",position));if(!content.object("JOB_MAIL_STATION_COSTCENTER_DISABLED",customer).equals("1"))allowed.append(htmlRow("Kostenstelle",text(stop,"csc_name")));if(content.object("JOB_MAIL_STATION_ARRIVAL_TIME",customer).equals("1"))allowed.append(htmlRow("Ankunftszeit",OrderDeliveryData.arrival(meta(extra,"arr_tour"))));
|
||||
String publicTour=allowed.toString();if(bit(flags,0))allowed.append(htmlRow("Bemerkung",publicRemark));
|
||||
if(bit(flags,8)&&content.fallback("MASK_JOBDETAILS_STATIONS_POD_SCAN_EVENTS").equals("1"))allowed.append(htmlRow("Bemerkung Kurier",meta(extra,"del_code")+" · "+meta(extra,"del_rem")+"\n"+meta(extra,"tr_hint")));
|
||||
if(bit(flags,9)&&content.fallback("MASK_JOBDETAILS_INTERNAL_REMARK_ITEMS").equals("1"))allowed.append(htmlRow("Bemerkung Innendienst",meta(extra,"tr_int_rem")));
|
||||
mailTour.append("<table>").append(allowed).append("</table>");csTour.append("<table>").append(publicTour).append("</table>");hqTour.append("<table>").append(common).append(htmlRow("Station",position)).append(htmlRow("Kostenstelle",text(stop,"csc_name"))).append(htmlRow("Bemerkung",text(stop,"tr_remark"))).append(htmlRow("Ankunftszeit",OrderDeliveryData.arrival(meta(extra,"arr_tour")))).append(htmlRow("Bemerkung Kurier",meta(extra,"tr_hint"))).append(htmlRow("Bemerkung Innendienst",meta(extra,"tr_int_rem"))).append("</table>");
|
||||
}
|
||||
var charges=jobCosts.getOrDefault(order,List.of());var costsHtml=new StringBuilder();for(var cost:charges)costsHtml.append(htmlRow("Kostenstelle "+text(cost,"csc_id")+" · "+text(cost,"trs_srv_name"),money(AccountingExportData.number(cost,"trs_price").multiply(java.math.BigDecimal.valueOf(100).subtract(AccountingExportData.number(cost,"trs_discount"))).movePointLeft(2))));
|
||||
costsHtml.append(htmlRow("KUNDENPREIS",money(AccountingExportData.number(job,"jb_totalprice")))).append(htmlRow("Ohne Aufschlag",money(AccountingExportData.number(job,"jb_subtotalprice")))).append(htmlRow("Maut / Auslagen",meta(meta,"jb_toll")));String publicCosts=costsHtml.toString();if(bit(flags,2))costsHtml.append(htmlRow("Rechnungstext",text(job,"jb_invtext")));
|
||||
String crMail=htmlRow("SID",text(job,"cr_sid"))+(bit(flags,1)?htmlRow("Mobil-Nr.",text(courier,"usr_phone")):"")+(bit(flags,6)?htmlRow("Kennzeichen",plate):"");String crHq=htmlRow("EID",text(courier,"cr_eid"))+htmlRow("SID",text(job,"cr_sid"))+htmlRow("Name",text(courier,"usr_firstname")+" "+text(courier,"usr_name"))+htmlRow("Mobil-Nr.",text(courier,"usr_phone"))+htmlRow("Kennzeichen",plate);
|
||||
raw.put("tr_numberofstations",tour.size());raw.put("costs_sum",String.format(Locale.GERMANY,"%,.2f",AccountingExportData.number(job,"jb_totalprice")));raw.put("costs_sum_withoutmarkup",String.format(Locale.GERMANY,"%,.2f",AccountingExportData.number(job,"jb_subtotalprice")));raw.put("costs_toll",meta(meta,"jb_toll"));
|
||||
raw.put("ng_source",Base64.getEncoder().encodeToString(support.encode(fields("job",job,"stations",tour,"costs",charges,"prices",prices.getOrDefault(order,List.of()),"metadata",meta,"stationMetadata",tour.stream().flatMap(t->stopData.getOrDefault(id(t,"tr_id"),List.of()).stream()).toList(),"courier",courier,"employee",employee)).getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
String parameters=pairs(par);var payload=new Payload(order,compress(pairs(raw)),compress(String.join("-;-",parameters,hqJob,hqTour,costsHtml+htmlRow("FRACHTVERGÜTUNG",money(AccountingExportData.number(job,"jb_cr_price")))+htmlRow("Frachtratentext",text(job,"jb_invtext_cr")),crHq,htmlRow("Mitarbeiter",text(employee,"usr_firstname")+" "+text(employee,"usr_name")))),compress(String.join("-;-",parameters,jobHtml,mailTour,id(payer,"cs_jbstatusmail_price")==1?costsHtml:"",crMail,"")),compress(String.join("-;-",parameters,jobHtml,csTour,publicCosts,htmlRow("SID",text(job,"cr_sid")),"")));
|
||||
enqueue(request,payload);
|
||||
}
|
||||
}
|
||||
static String meta(List<Map<String,Object>> rows,String key){var values=rows.stream().filter(r->text(r,"gdc_gen_fieldname").equals(key)).toList();if(values.size()>1)throw bad("Mehrdeutiges Archivfeld: "+key);return values.isEmpty()?"":text(values.getFirst(),"gdc_content");}
|
||||
void enqueue(String request,Payload payload){
|
||||
if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngarchive' AND gdc_obj_id=? AND gdc_context='PENDING'",payload.order()).isEmpty())throw bad("Die historische Auftragskopie wird noch gespeichert.");String encoded=support.encode(payload);if(encoded.length()>400000)throw bad("Historischer Auftragsstand zu umfangreich.");int count=(encoded.length()+49999)/50000;var pending=new Pending(request,payload.order(),DocumentStore.digest(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8)),count);
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","ngarchive","gdc_obj_id",payload.order(),"gdc_gen_fieldname",request,"gdc_content",support.encode(pending),"gdc_context","PENDING"));for(int n=0;n<count;n++)db.insert("genericdatacontainer",fields("gdc_obj_type","ngarcpart","gdc_obj_id",payload.order(),"gdc_gen_fieldname",request+"_"+n,"gdc_content",encoded.substring(n*50000,Math.min(encoded.length(),(n+1)*50000)),"gdc_context",String.format(Locale.ROOT,"%04d",n)));
|
||||
}
|
||||
Payload payload(Map<String,Object> row){try{var p=support.json.readValue(text(row,"gdc_content"),Pending.class);BusinessSupport.token(p.request());if(p.order()!=id(row,"gdc_obj_id")||p.parts()<1||p.parts()>8)throw bad("Ungültiger Archivauftrag.");var parts=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngarcpart' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!' ORDER BY gdc_context",p.order(),p.request()+"!_%");if(parts.size()!=p.parts())throw bad("Unvollständiger Archivauftrag.");String encoded=String.join("",parts.stream().map(v->text(v,"gdc_content")).toList());if(encoded.length()>400000||!DocumentStore.digest(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8)).equals(p.digest()))throw bad("Der Archivauftrag wurde verändert.");var value=support.json.readValue(encoded,Payload.class);if(value.order()!=p.order())throw bad("Abweichende Archivkennung.");for(byte[] bytes:List.of(value.raw(),value.headquarters(),value.mail(),value.customer()))OrderArchive.inflate(bytes);return value;}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw bad("Ungültiger Archivauftrag.");}}
|
||||
@org.springframework.scheduling.annotation.Scheduled(initialDelay=60000,fixedDelay=30000)
|
||||
public void publish(){
|
||||
List<Map<String,Object>> candidates;try{candidates=db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngarchive' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 30");}catch(RuntimeException e){return;}
|
||||
for(var candidate:candidates)try{db.accountingArchiveWriteTransaction(()->{long key=id(candidate,"gdc_id");var rows=db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type='ngarchive' AND gdc_context='PENDING'",key);if(rows.isEmpty())return null;var row=one(rows);var p=payload(row);var existing=db.queryForList("SELECT * FROM job2 WHERE jb_id=?",p.order());if(existing.isEmpty())BusinessSupport.insertWithoutKey(db,"job2",fields("jb_id",p.order(),"jb2_history_rawdata",p.raw(),"jb2_history_hq",p.headquarters(),"jb2_history_mail",p.mail(),"jb2_history_cs",p.customer()));else{var old=one(existing);for(var pair:Map.of("jb2_history_rawdata",p.raw(),"jb2_history_hq",p.headquarters(),"jb2_history_mail",p.mail(),"jb2_history_cs",p.customer()).entrySet())if(!(old.get(pair.getKey()) instanceof byte[] bytes)||!Arrays.equals(bytes,pair.getValue()))throw bad("Ein anderer historischer Abrechnungsstand ist bereits vorhanden.");}
|
||||
db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",key);db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngarcpart' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!'",p.order(),text(row,"gdc_gen_fieldname")+"!_%");return null;});}catch(RuntimeException ignored){/* Keep exact blobs for retry; never rebuild from changed live orders. */}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
|
||||
|
||||
/** Central PHP billing exports 4/5/6. The field catalog describes output, never executable SQL. */
|
||||
final class AccountingExportData {
|
||||
record Filter(String customer,Long job,Integer payment,List<Long> vehicles) {}
|
||||
record Query(int category,int status,LocalDate from,LocalDate to,Filter filter) {}
|
||||
static final List<String> PARAMETERS=List.of("EXPORT_CONST_PRODUCT","EXPORT_CONST_PRICE_SPECIAL","EXPORT_CONST_STAFFER","EXPORT_CONST_CONTACT_PERSON","EXPORT_CONST_01","EXPORT_CONST_02","EXPORT_CONST_03","JB_PAYMODE_CASH","JB_TAX_RATE_SIGN","EXPORT_ADD_PAYING_CSCAD_DATA_ADT_1","EXPORT_ADD_PAYING_CSCAD_DATA_ADT_2","EXPORT_ADD_PAYING_CSCAD_DATA_ADT_3","EXPORT_ADD_CS_MASTER_DATA","EXPORT_FILTER_CALCULATOR_NO_BOOKINGNOTES");
|
||||
final DatabaseService db;
|
||||
AccountingExportData(DatabaseService db){this.db=db;}
|
||||
static Filter checked(Query q){
|
||||
if(q==null||q.category()<4||q.category()>6||q.status()<0||q.status()>2||q.to()==null||q.to().getYear()<1900||q.to().getYear()>9998||q.from()!=null&&(q.from().getYear()<1900||q.from().isAfter(q.to())))throw bad("Bitte eine Abrechnungskategorie und einen gültigen Exportzeitraum auswählen.");
|
||||
var f=q.filter()==null?new Filter("",null,null,List.of()):q.filter();String customer=Objects.toString(f.customer(),"").strip();
|
||||
if(customer.length()>20||customer.chars().anyMatch(Character::isISOControl)||f.job()!=null&&(f.job()<1||customer.isEmpty())||f.payment()!=null&&(f.payment()<0||f.payment()>9999))throw bad("Bitte gültige Kunden-, Auftrags- und Zahlartenfilter angeben. Eine einzelne Auftragsnummer benötigt die Kundennummer.");
|
||||
var vehicles=f.vehicles()==null?List.<Long>of():f.vehicles();if(vehicles.size()>100||vehicles.stream().anyMatch(v->v==null||v<0||v>99999))throw bad("Ungültiger Fahrzeugfilter.");return new Filter(customer,f.job(),f.payment(),vehicles.stream().distinct().sorted().toList());
|
||||
}
|
||||
Map<String,String> parameters(long hq,List<Object> evidence){
|
||||
var values=db.queryForList("SELECT hq_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN ("+marks(PARAMETERS.size())+") ORDER BY hq_id,par_key",concat(List.of(hq),PARAMETERS).toArray());evidence.add(values);
|
||||
var result=new TreeMap<String,String>();var seen=new HashSet<String>();for(var p:values){String key=text(p,"par_key");if(!seen.add(id(p,"hq_id")+":"+key))throw bad("Mehrdeutige Exportvorgabe: "+key);if(key.equals("EXPORT_FILTER_CALCULATOR_NO_BOOKINGNOTES")&&id(p,"hq_id")!=0)continue;if(id(p,"hq_id")==0&&Set.of("EXPORT_CONST_PRODUCT","EXPORT_CONST_PRICE_SPECIAL","EXPORT_CONST_STAFFER","EXPORT_CONST_CONTACT_PERSON").contains(key))continue;if(!text(p,"par_value").isBlank())result.put(key,text(p,"par_value"));}
|
||||
result.putIfAbsent("EXPORT_CONST_PRODUCT","K ");result.putIfAbsent("EXPORT_CONST_PRICE_SPECIAL","FP");return result;
|
||||
}
|
||||
MasterExportData.Selection select(LegacyUser user,Query q){
|
||||
var f=checked(q);var evidence=new ArrayList<Object>();var params=parameters(user.headquartersId(),evidence);var master=new MasterExportData(db);var common=master.parameters(user.headquartersId());evidence.add(common);LocalDate today=LocalDate.now(ZoneId.of("Europe/Berlin"));evidence.add(today);
|
||||
String scope="jb.hq_id_sales=?";var args=new ArrayList<Object>();args.add(user.headquartersId());if(f.job()!=null){scope="("+scope+" OR (jb.hq_id=? AND jb.jb_id=?))";args.add(user.headquartersId());args.add(f.job());}
|
||||
scope+=" AND jb.jb_status=2 AND COALESCE(jb.jb_incomplete,0)=0 AND (jb.jb_export_time IS NULL OR jb.jb_export_time='0000-00-00 00:00:00')";
|
||||
if(q.category()!=4){scope+=" AND COALESCE(jb.jb_storno,0) IN (0,"+(q.category()==5?"1":"3")+") AND jb.jb_totalprice"+(q.category()==5?">0":"<0");if(f.payment()==null)scope+=" AND jb.jb_payment IN (0,1,2)";else{scope+=" AND jb.jb_payment=?";args.add(f.payment());}}
|
||||
int cashStatus=f.customer().isBlank()?q.status():2;if(cashStatus==1)scope+=" AND jb.csc_id_payer=-1";else if(cashStatus==2)scope+=" AND jb.csc_id_payer<>-1";
|
||||
String eligible="EXISTS (SELECT 1 FROM tourservice filter_ts JOIN costcenter filter_c ON filter_c.csc_id=filter_ts.csc_id JOIN customer filter_cs ON filter_cs.cs_id=filter_c.cs_id JOIN headquarters filter_hq ON filter_hq.hq_id=filter_cs.hq_id WHERE filter_ts.jb_id=jb.jb_id";
|
||||
if(!f.customer().isBlank()){eligible+=" AND filter_cs.cs_eid=?";args.add(f.customer());}eligible+=")";
|
||||
scope+=f.customer().isBlank()?" AND ((jb.csc_id_payer=-1 AND EXISTS (SELECT 1 FROM tourservice cash_ts WHERE cash_ts.jb_id=jb.jb_id)) OR (jb.csc_id_payer<>-1 AND "+eligible+"))":" AND "+eligible;
|
||||
if(q.from()!=null){scope+=" AND jb.jb_finishtime>=?";args.add(q.from().atStartOfDay());}scope+=" AND jb.jb_finishtime<?";args.add(q.to().plusDays(1).atStartOfDay());
|
||||
if(f.job()!=null){scope+=" AND jb.jb_id=?";args.add(f.job());}if(!f.vehicles().isEmpty()){scope+=" AND cr.vht_id_inv IN ("+marks(f.vehicles().size())+")";args.addAll(f.vehicles());}
|
||||
if(params.getOrDefault("EXPORT_FILTER_CALCULATOR_NO_BOOKINGNOTES","").equals("1"))scope+=" AND (COALESCE(jb.jb_id_parent,0) IN (0,-1) OR jb.jb_id_parent>0 AND jb.jb_totalprice*jb2.jb_totalprice>0)";
|
||||
var jobs=db.queryForList("SELECT jb.*,cr.cr_eid,cr.cr_id_parent,cr.vht_id_inv,cr_hq.hq_mnemonic AS cr_hq_mnemonic,cr_parent.cr_eid AS cr_eid_parent,cr_parent.cr_sid AS cr_sid_parent,cr_parent_hq.hq_mnemonic AS cr_parent_hq_mnemonic,hq.hq_mnemonic,hq.hq_gln FROM job jb JOIN headquarters hq ON hq.hq_id=jb.hq_id LEFT JOIN courier cr ON cr.cr_id=jb.cr_id LEFT JOIN headquarters cr_hq ON cr_hq.hq_id=cr.hq_id LEFT JOIN courier cr_parent ON cr_parent.cr_id=cr.cr_id_parent LEFT JOIN headquarters cr_parent_hq ON cr_parent_hq.hq_id=cr_parent.hq_id LEFT JOIN job jb2 ON jb2.jb_id=jb.jb_id_parent WHERE "+scope+" ORDER BY jb.jb_id LIMIT 2001",args.toArray());
|
||||
if(jobs.size()>2000)throw bad("Mehr als 2.000 Exportaufträge. Bitte Zeitraum oder Kunde eingrenzen.");unique(jobs,"jb_id","Auftragszuordnung");evidence.add(jobs);if(jobs.isEmpty())return new MasterExportData.Selection(List.of(),List.of(),evidence,0);
|
||||
var ids=jobs.stream().map(j->id(j,"jb_id")).toList();
|
||||
var priceRows=bounded("SELECT * FROM tourservice WHERE jb_id IN ("+marks(ids.size())+") ORDER BY jb_id,csc_id,tr_sort,srv_id,srvt_id,trs_srv_name,trs_srvt_name,trs_price,trs_discount",ids,evidence);
|
||||
var prices=CustomerExportData.group(priceRows,"jb_id");
|
||||
var centerIds=new TreeSet<Long>();priceRows.forEach(r->centerIds.add(id(r,"csc_id")));jobs.forEach(r->centerIds.add(id(r,"csc_id_payer")));centerIds.removeIf(k->k<=0);
|
||||
var centers=centerIds.isEmpty()?List.<Map<String,Object>>of():bounded("SELECT csc.csc_id,csc.csc_name,cs.cs_id,cs.cs_eid,cs.csc_id AS csc_id_root,cs.hq_id AS cs_hq_id,cs_hq.hq_mnemonic AS cs_hq_mnemonic,cmp2.cmp_inv2hq,cmp2.cmp_comp AS cmp2_comp,cmp2.cmp_comp2 AS cmp2_comp2,cmp2.cmp_comp3 AS cmp2_comp3,cmp2.cmp_comp4 AS cmp2_comp4,cmp2.cmp_hsno AS ad_mstrdt_hsno,ad_mstrdt.ad_street AS ad_mstrdt_street,ad_mstrdt.ad_zipcode AS ad_mstrdt_zipcode,ad_mstrdt.ad_city AS ad_mstrdt_city FROM costcenter csc JOIN customer cs ON cs.cs_id=csc.cs_id JOIN headquarters cs_hq ON cs_hq.hq_id=cs.hq_id LEFT JOIN company cmp2 ON cmp2.cmp_id=cs.cmp_id LEFT JOIN address ad_mstrdt ON ad_mstrdt.ad_id=cmp2.ad_id WHERE csc.csc_id IN ("+marks(centerIds.size())+") ORDER BY csc.csc_id",new ArrayList<>(centerIds),evidence);
|
||||
unique(centers,"csc_id","Rechnungskostenstelle");var byCenter=new HashMap<Long,Map<String,Object>>();centers.forEach(c->byCenter.put(id(c,"csc_id"),c));
|
||||
var addresses=centerIds.isEmpty()?List.<Map<String,Object>>of():bounded("SELECT cscad.*,ad.ad_street,ad.ad_zipcode,ad.ad_city FROM costcenteraddress cscad LEFT JOIN address ad ON ad.ad_id=cscad.ad_id WHERE cscad.csc_id IN ("+marks(centerIds.size())+") AND cscad.adt_id IN (1,2,3) ORDER BY cscad.csc_id,cscad.adt_id",new ArrayList<>(centerIds),evidence);
|
||||
var stops=bounded("SELECT t.jb_id,t.tr_id,t.tr_sort,t.tr_commission_no FROM tour t WHERE t.jb_id IN ("+marks(ids.size())+") AND t.tr_sort=1 ORDER BY t.jb_id,t.tr_id",ids,evidence);unique(stops,"jb_id","erste Auftragsstation");var first=new HashMap<Long,Map<String,Object>>();stops.forEach(s->first.put(id(s,"jb_id"),s));
|
||||
var metadata=bounded("SELECT gdc_obj_id,gdc_gen_fieldname,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id IN ("+marks(ids.size())+") AND gdc_gen_fieldname IN ('jb_toll','jb_third_country','collection_number','ng_payment_term','ng_payment_term_label') ORDER BY gdc_obj_id,gdc_gen_fieldname,gdc_id",ids,evidence);
|
||||
var meta=new HashMap<Long,Map<String,String>>();for(var r:metadata)if(meta.computeIfAbsent(id(r,"gdc_obj_id"),k->new HashMap<>()).put(text(r,"gdc_gen_fieldname"),text(r,"gdc_content"))!=null)throw bad("Mehrdeutiger Auftragsexport-Zusatzwert.");
|
||||
var legacyTerms=bounded("SELECT jb_id,jbprc_remark FROM jobprice WHERE jb_id IN ("+marks(ids.size())+") AND mt_sort=16 AND TRIM(COALESCE(jbprc_remark,''))<>'' ORDER BY jb_id,jbprc_remark",ids,evidence);unique(legacyTerms,"jb_id","Zahlungsbedingung");var terms=new HashMap<Long,String>();legacyTerms.forEach(t->terms.put(id(t,"jb_id"),text(t,"jbprc_remark")));
|
||||
var keys=db.queryForList("SELECT hq_id,par_value FROM parameter WHERE emp_id=0 AND par_key='EXPORT_HQ_KEY' ORDER BY hq_id LIMIT 1001");if(keys.size()>1000)throw bad("Zu viele Niederlassungs-Exportkennungen.");unique(keys,"hq_id","Niederlassungs-Exportkennung");evidence.add(keys);var hqKeys=new HashMap<Long,String>();keys.forEach(k->hqKeys.put(id(k,"hq_id"),text(k,"par_value")));
|
||||
long truck=7;for(String item:common.get("MASK_VEHICLE_TYPE_MAPPING").split("\\|")){var p=item.split("=",-1);if(p.length==2&&p[0].equals("truck_01")&&p[1].matches("[0-9]{1,9}")){var types=db.queryForList("SELECT mt_sort FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",Long.parseLong(p[1]));evidence.add(types);if(types.size()==1)truck=Long.parseLong(p[1]);}}
|
||||
var raw=new ArrayList<Map<String,Object>>();var rows=new ArrayList<Map<String,Object>>();
|
||||
for(var job:jobs){long jobId=id(job,"jb_id"),payer=id(job,"csc_id_payer");boolean cash=payer==-1;int status=f.customer().isBlank()?q.status():2;if(cash&&status==2||!cash&&status==1)continue;
|
||||
var groups=prices.getOrDefault(jobId,List.of()).stream().map(p->id(p,"csc_id")).distinct().sorted().toList();
|
||||
for(long centerId:groups){var center=byCenter.get(centerId);if(!cash&&(center==null||!f.customer().isBlank()&&!text(center,"cs_eid").equals(f.customer())))continue;
|
||||
var v=new LinkedHashMap<String,Object>(job);v.put("object_id",jobId);v.put("export_csc_id",centerId);v.put("tr_commission_no",text(first.getOrDefault(jobId,Map.of()),"tr_commission_no"));var payerData=byCenter.getOrDefault(payer,Map.of());
|
||||
v.put("cmp_inv2hq",id(payerData,"cmp_inv2hq"));v.put("cmp_single_invoice",id(payerData,"cmp_inv2hq"));v.put("price",job.get("jb_totalprice"));v.put("cash",payer);v.put("tax_rate",job.get("jb_sales_tax_rate"));v.put("tax_rate_sign",job.get("jb_sales_tax_rate_sign"));v.put("gross_price",number(job,"jb_totalprice").multiply(new BigDecimal("1.19")));
|
||||
for(String key:List.of("csc_name","csc_id_root","cs_hq_mnemonic"))v.put(key,cash?"":center.get(key));v.put("cs_eid1",cash?"":text(center,"cs_eid"));v.put("cs_eid2",v.get("cs_eid1"));
|
||||
if(!cash)for(int role=1;role<=3;role++)if(params.getOrDefault("EXPORT_ADD_PAYING_CSCAD_DATA_ADT_"+role,"").equals("1")){int r=role;var matches=addresses.stream().filter(a->id(a,"csc_id")==payer&&id(a,"adt_id")==r).toList();if(matches.size()>1)throw bad("Mehrdeutige Zahleradresse im Export.");var a=matches.isEmpty()?Map.<String,Object>of():matches.getFirst();for(String key:List.of("comp","comp2","comp3","comp4","gln"))v.put("cscad"+role+"_"+key,text(a,"cscad_"+key));for(String key:List.of("street","zipcode","city"))v.put("ad_cscad"+role+"_"+key,text(a,"ad_"+key));v.put("ad_cscad"+role+"_hsno",text(a,"cscad_hsno"));}
|
||||
if(!cash&¶ms.getOrDefault("EXPORT_ADD_CS_MASTER_DATA","").equals("1"))for(String key:List.of("cmp2_comp","cmp2_comp2","cmp2_comp3","cmp2_comp4","ad_mstrdt_street","ad_mstrdt_hsno","ad_mstrdt_zipcode","ad_mstrdt_city"))v.put(key,payerData.get(key));
|
||||
raw.add(new LinkedHashMap<>(v));var m=meta.getOrDefault(jobId,Map.of());v.put("jb_toll",m.getOrDefault("jb_toll",""));v.put("jb_third_country",m.getOrDefault("jb_third_country",""));v.put("jb_payment_term",m.getOrDefault("ng_payment_term_label",m.getOrDefault("ng_payment_term",terms.getOrDefault(jobId,""))));
|
||||
String key=hqKeys.getOrDefault(id(job,"hq_id"),"");v.put("cmp_no",key.isBlank()?common.get("EXPORT_HQ_KEY"):key);transform(v,user.headquartersId(),q.category(),params,common,truck);
|
||||
if(Objects.equals(f.payment(),4)&&!m.getOrDefault("collection_number","").isBlank())v.put("jb_id",m.get("collection_number"));rows.add(v);
|
||||
}
|
||||
}
|
||||
if(rows.size()>10000)throw bad("Mehr als 10.000 Exportzeilen. Bitte die Auswahl eingrenzen.");return new MasterExportData.Selection(List.copyOf(raw),List.copyOf(rows),List.copyOf(evidence),0);
|
||||
}
|
||||
static void transform(Map<String,Object> v,long hq,int category,Map<String,String> p,Map<String,String> common,long truck){
|
||||
for(var t:Map.of("order","jb_ordertime","finish","jb_finishtime").entrySet()){v.put(t.getKey()+"date",CustomerExportData.date(v,t.getValue(),"yyyyMMdd"));v.put(t.getKey()+"time",CustomerExportData.date(v,t.getValue(),"HH:mm"));}
|
||||
v.put("cr_sid"," "+right(text(v,"cr_sid"),3));v.put("cr_sid_parent"," "+right(text(v,"cr_sid_parent"),3));v.put("cr_eid",right(text(v,"cr_eid"),5));v.put("cr_eid_parent",right(text(v,"cr_eid_parent"),5));if(!text(v,"cr_eid_parent").isBlank())v.put("cr_eid",v.get("cr_eid_parent"));
|
||||
long payer=id(v,"cash");v.put("cash",payer==-1||Long.toString(payer).equals(common.get("CSC_ID_PAYER_CASH"))?p.getOrDefault("JB_PAYMODE_CASH",""):" ");
|
||||
String tax=text(v,"tax_rate_sign");v.put("tax_rate_sign",tax.equals("0")||hq>=100&&hq<300&&Set.of("3","4").contains(tax)?p.getOrDefault("JB_TAX_RATE_SIGN",""):" ");
|
||||
v.put("cmp_inv2hq",Set.of(1L,3L).contains(id(v,"cmp_inv2hq"))?"RZ":" ");v.put("cmp_single_invoice",Set.of(2L,3L).contains(id(v,"cmp_single_invoice"))?"ER":" ");v.put("price_special",p.getOrDefault("EXPORT_CONST_PRICE_SPECIAL","FP"));String filter=text(v,"jb_cr_filter_opt");if(filter.contains("RZ"))v.put("cmp_inv2hq","RZ");if(filter.contains("EZ"))v.put("cmp_single_invoice","ER");if(filter.contains(",BL,"))v.put("price_special","BL");
|
||||
v.put("VAT_special"," ");v.put("prod_descr",p.getOrDefault("EXPORT_CONST_PRODUCT","K "));for(String key:List.of("contact_staffer","contact_person"))v.put(key,p.getOrDefault(key.equals("contact_staffer")?"EXPORT_CONST_STAFFER":"EXPORT_CONST_CONTACT_PERSON",""));for(int n=1;n<=3;n++)v.put("const_0"+n,p.getOrDefault("EXPORT_CONST_0"+n,""));
|
||||
long type=id(v,"vht_id"),kind=id(v,"jb_type"),parent=id(v,"jb_id_parent");String vehicle=hq>0&&hq<100&&type==999||type<truck?"K ":"G ";if(kind>=1&&kind<=9)vehicle=List.of("K ","G ","M1","M3","M4","M5","M6","L ","G3").get((int)kind-1);if(hq>0&&hq<100&&(parent==-1||parent>0)&&Set.of("B ","K ","G ").contains(vehicle))vehicle=vehicle.charAt(0)+"K";v.put("vht_id",vehicle);
|
||||
if(category==6)for(String key:List.of("price","jb_subtotalprice","jb_cr_price","jb_cr_subprice","jb_hq_id_exec_price"))v.put(key,number(v,key).abs());
|
||||
String toll=text(v,"jb_toll").strip().replace(',','.');try{v.put("jb_toll",toll.isBlank()?BigDecimal.ZERO:new BigDecimal(toll).abs());}catch(NumberFormatException e){v.put("jb_toll",BigDecimal.ZERO);}
|
||||
for(String key:List.of("jb_third_country","jb_longhaul","jb_longhaul_export")){boolean flag;try{flag=key.equals("jb_third_country")?new BigDecimal(text(v,key).strip()).signum()>0:number(v,key).compareTo(BigDecimal.ONE)==0;}catch(NumberFormatException e){flag=false;}v.put(key,flag?"X":" ");}
|
||||
String tour=text(v,"jb_tourdata");v.put("country_codes",tour.substring(tour.lastIndexOf('|')+1));if(text(v,"jb_commission_no").isBlank())v.put("jb_commission_no",v.get("tr_commission_no"));if(payer>0&&payer==id(v,"csc_id_root"))v.put("csc_name","");
|
||||
}
|
||||
static String right(String s,int n){return s.substring(Math.max(0,s.length()-n));}
|
||||
static BigDecimal number(Map<String,Object> row,String key){return BillingService.nz(decimal(row,key));}
|
||||
static String marks(int size){return String.join(",",Collections.nCopies(size,"?"));}
|
||||
static List<Object> concat(List<?> a,List<?> b){var out=new ArrayList<Object>(a);out.addAll(b);return out;}
|
||||
static void unique(List<Map<String,Object>> rows,String key,String label){if(rows.stream().map(r->id(r,key)).distinct().count()!=rows.size())throw bad("Mehrdeutige "+label+" im Export.");}
|
||||
List<Map<String,Object>> bounded(String sql,List<Long> ids,List<Object> evidence){var rows=db.queryForList(sql+" LIMIT 50001",ids.toArray());if(rows.size()>50000)throw bad("Zu viele Exportpositionen. Bitte die Auswahl eingrenzen.");evidence.add(rows);return rows;}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.stadtbote.votian;
|
||||
import java.util.*;
|
||||
/** Current server catalog 4/5/6; stored SQL is validated but never executed. */
|
||||
final class AccountingExportFields {
|
||||
static final Map<String,String> SQL=Map.ofEntries(Map.entry("inv.cmp_no","inv.cmp_no"),Map.entry("inv.prod_descr","inv.prod_descr"),Map.entry("inv.cr_eid","inv.cr_eid"),Map.entry("inv.cr_sid","inv.cr_sid"),Map.entry("inv.orderdate","inv.orderdate"),Map.entry("inv.ordertime","inv.ordertime"),Map.entry("inv.finishdate","inv.finishdate"),Map.entry("inv.finishtime","inv.finishtime"),Map.entry("inv.jb_id","inv.jb_id"),Map.entry("inv.VAT_special","inv.VAT_special"),Map.entry("inv.price_special","inv.price_special"),Map.entry("inv.price","inv.price"),Map.entry("inv.cs_eid1","inv.cs_eid1"),Map.entry("inv.cs_eid2","inv.cs_eid2"),Map.entry("inv.jb_invtext","inv.jb_invtext"),Map.entry("inv.cash","inv.cash"),Map.entry("inv.tax_rate","inv.tax_rate"),Map.entry("inv.tax_rate_sign","inv.tax_rate_sign"),Map.entry("inv.jb_freetext_1","inv.jb_freetext_1"),Map.entry("inv.jb_freetext_2","inv.jb_freetext_2"),Map.entry("inv.jb_freetext_3","inv.jb_freetext_3"),Map.entry("inv.jb_postage","inv.jb_postage"),Map.entry("inv.cmp_inv2hq","inv.cmp_inv2hq"),Map.entry("inv.cmp_single_invoice","inv.cmp_single_invoice"),Map.entry("inv.csc_name","inv.csc_name"),Map.entry("inv.csc_id_root","inv.csc_id_root"),Map.entry("inv.cr_eid_parent","inv.cr_eid_parent"),Map.entry("inv.cr_sid_parent","inv.cr_sid_parent"),Map.entry("inv.vht_id","inv.vht_id"),Map.entry("inv.jb_cr_filter","inv.jb_cr_filter"),Map.entry("inv.jb_cr_filter_opt","inv.jb_cr_filter_opt"),Map.entry("inv.jb_type","inv.jb_type"),Map.entry("inv.jb_id_parent","inv.jb_id_parent"),Map.entry("inv.const_01","inv.const_01"),Map.entry("inv.const_02","inv.const_02"),Map.entry("inv.const_03","inv.const_03"),Map.entry("inv.jb_cr_price","inv.jb_cr_price"),Map.entry("inv.jb_subtotalprice","inv.jb_subtotalprice"),Map.entry("inv.jb_markup","inv.jb_markup"),Map.entry("inv.jb_cr_subprice","inv.jb_cr_subprice"),Map.entry("inv.country_codes","inv.country_codes"),Map.entry("inv.jb_tourdata","inv.jb_tourdata"),Map.entry("inv.tr_commission_no","inv.tr_commission_no"),Map.entry("inv.jb_toll","inv.jb_toll"),Map.entry("inv.cscad1_comp","inv.cscad1_comp"),Map.entry("inv.cscad1_comp2","inv.cscad1_comp2"),Map.entry("inv.ad_cscad1_street","inv.ad_cscad1_street"),Map.entry("inv.ad_cscad1_hsno","inv.ad_cscad1_hsno"),Map.entry("inv.ad_cscad1_zipcode","inv.ad_cscad1_zipcode"),Map.entry("inv.ad_cscad1_city","inv.ad_cscad1_city"),Map.entry("inv.cscad1_comp3","inv.cscad1_comp3"),Map.entry("inv.cscad1_comp4","inv.cscad1_comp4"),Map.entry("inv.jb_third_country","inv.jb_third_country"),Map.entry("inv.jb_invtext_cr","inv.jb_invtext_cr"),Map.entry("inv.hq_mnemonic","inv.hq_mnemonic"),Map.entry("inv.cs_hq_mnemonic","inv.cs_hq_mnemonic"),Map.entry("inv.cr_hq_mnemonic","inv.cr_hq_mnemonic"),Map.entry("inv.jb_longhaul_export","inv.jb_longhaul_export"),Map.entry("inv.jb_hq_id_exec_price","inv.jb_hq_id_exec_price"),Map.entry("inv.jb_commission_no","inv.jb_commission_no"),Map.entry("inv.cscad1_gln","inv.cscad1_gln"),Map.entry("inv.cscad2_gln","inv.cscad2_gln"),Map.entry("inv.hq_gln","inv.hq_gln"),Map.entry("inv.jb_payment_term","inv.jb_payment_term"));
|
||||
static String type(String key){return Set.of("price","gross_price","tax_rate","jb_postage","jb_cr_price","jb_subtotalprice","jb_markup","jb_cr_subprice","jb_toll","jb_hq_id_exec_price").contains(key)?"float":"text";}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;import org.springframework.web.bind.annotation.*;
|
||||
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.*;
|
||||
/** Zusatzmontage: explicit child accounting document instead of the missing PHP copy_job_flat. */
|
||||
@RestController
|
||||
@RequestMapping("/api/orders/{job}/added-installation")
|
||||
public final class AddedInstallation {
|
||||
record Input(BigDecimal hours,Integer mode){}
|
||||
record Command(String requestId,String revision,Input input,boolean confirmed){}
|
||||
record State(OrderPayments.State source,List<Map<String,Object>> links,List<Map<String,Object>> childLinks,OrderOperations.Snapshot child,List<Map<String,Object>> calculator){}
|
||||
record Prepared(State state,Input input,AddedInstallationRates.Rates rates,BigDecimal net,BigDecimal courierNet,int mode,String tan){}
|
||||
final OrderPayments payments;final OrderOperations orders;final DatabaseService db;final BusinessSupport support;
|
||||
public AddedInstallation(OrderPayments payments){this.payments=payments;orders=payments.orders;db=payments.db;support=payments.support;}
|
||||
static void access(LegacyUser u){u.require(4);u.require(7);}
|
||||
static long link(List<Map<String,Object>> rows,String key){var found=rows.stream().filter(r->text(r,"gdc_gen_fieldname").equals(key)).toList();if(found.size()>1)throw bad("Mehrdeutige Zusatzmontage-Zuordnung.");if(found.isEmpty()||text(found.getFirst(),"gdc_content").isBlank())return 0;try{long id=Long.parseLong(text(found.getFirst(),"gdc_content"));if(id<=0)throw new NumberFormatException();return id;}catch(Exception e){throw bad("Ungültige Zusatzmontage-Zuordnung.");}}
|
||||
State state(LegacyUser u,long job,boolean lock){var s=payments.state(u,job,lock);String sql="SELECT gdc_id,gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_id_addmont','jb_id_addmont_parent') ORDER BY gdc_id";var links=lock?db.lockRows(sql,job):db.queryForList(sql,job);long child=link(links,"jb_id_addmont");if(child==job)throw bad("Eine Zusatzmontage darf nicht auf denselben Auftrag verweisen.");var childLinks=child==0?List.<Map<String,Object>>of():lock?db.lockRows(sql,child):db.queryForList(sql,child);if(child>0&&link(childLinks,"jb_id_addmont_parent")!=job)throw bad("Die Rückverknüpfung des Zusatzauftrags stimmt nicht.");String calc="SELECT * FROM jobcalculator WHERE jb_id=? AND srvt_name='ident_9999:par_01' ORDER BY srv_name,srvt_name";return new State(s,links,childLinks,child==0?null:orders.snapshot(u,child,lock),lock?db.lockRows(calc,job):db.queryForList(calc,job));}
|
||||
String blocked(LegacyUser u,State s){var source=s.source();var j=source.snapshot().job();if(!orders.catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"))return "Die zugehörige Kundenabrechnung ist nicht aktiviert.";if(!OrderPayments.has(source,"jb_addmont")||(id(j,"jb_service")&2)!=2)return "Der Auftrag ist nicht für zusätzliche Montagezeit vorgesehen.";if(link(s.links(),"jb_id_addmont_parent")>0)return "Zusatzmontage bitte am ursprünglichen Montageauftrag bearbeiten.";if(id(j,"jb_status")!=2||id(j,"cr_id")<=0)return "Der Montageauftrag muss erledigt und einem Kurier zugeordnet sein.";if(id(j,"jb_offer")!=0)return "Angebote können nicht als Zusatzmontage gebucht werden.";if(OrderPayments.meta(source,"jb_no_payment_job").equals("1"))return "Der Auftrag wird vom Transporteur bezahlt.";if(source.allowed().isEmpty())return "Für diesen Kunden sind keine Montage-Zahlarten freigegeben.";if(id(source.customer(),"cs_id")<=0)return "Der zugehörige Kunde fehlt.";try{orders.writable(source.snapshot());if(s.child()!=null){orders.writable(s.child());if(id(s.child().job(),"jb_offer")!=0||id(s.child().job(),"csc_id_related")!=id(j,"csc_id_related"))return "Der Zusatzauftrag gehört nicht zur selben Abrechnung.";if(s.child().payments().size()>1)return "Der Zusatzauftrag enthält mehrere Zahlungen. Bitte zuerst die Zahlungszuordnung prüfen.";if(s.child().payments().stream().anyMatch(p->id(p,"jbpc_id")>0||OrderPayments.exportedPayment(p)))return "Die Zusatzmontage enthält bereits gesammelte oder exportierte Zahlungen.";}}catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();}return "";}
|
||||
static String hours(State s){var m=s.source().metadata().stream().filter(r->text(r,"gdc_gen_fieldname").equals("jb_addmont")).findFirst().orElse(Map.of());String value=text(m,"gdc_content");return value.isBlank()?text(m,"gdc_context"):value;}
|
||||
@GetMapping Map<String,Object> view(Authentication a,@PathVariable long job){return view(ApiController.user(a),job);}
|
||||
Map<String,Object> view(LegacyUser u,long job){access(u);return db.readTransaction(()->{var s=state(u,job,false);String reason=blocked(u,s);return fields("hours",hours(s),"child",s.child()==null?0:id(s.child().job(),"jb_id"),"childNet",s.child()==null?BigDecimal.ZERO:OrderPayments.net(s.child().job().get("jb_totalprice")),"choices",s.source().allowed().stream().map(m->fields("id",m,"label",OrderPayments.label(s.source(),m))).toList(),"canEdit",reason.isEmpty(),"blocked",reason);});}
|
||||
Prepared prepare(LegacyUser u,long job,Input i,boolean lock){access(u);if(i==null||i.hours()==null||i.hours().signum()<0||i.hours().compareTo(new BigDecimal("9999.99"))>0||i.hours().stripTrailingZeros().scale()>2||i.mode()==null)throw bad("Bitte 0 bis 9.999,99 Montagestunden und eine Zahlart auswählen.");var s=state(u,job,lock);String reason=blocked(u,s);if(!reason.isEmpty())throw bad(reason);if(!s.source().allowed().contains(i.mode()))throw bad("Die Montage-Zahlart ist nicht für diesen Kunden freigegeben.");boolean service=OrderPayments.meta(s.source(),"jb_service_job").equals("1"),online=!OrderPayments.meta(s.source(),"acquisitiontime").isBlank();int mode=service?15:i.mode();if(online){String name=service?"OS sad":"OS happy";var rows=db.queryForList("SELECT DISTINCT mt_sort FROM metatype WHERE mt_type='payment_type' AND mt_value=?",name);if(rows.size()!=1||id(rows.getFirst(),"mt_sort")<0||id(rows.getFirst(),"mt_sort")>127)throw bad("Die Online-Zahlart "+name+" ist nicht eindeutig konfiguriert.");mode=(int)id(rows.getFirst(),"mt_sort");}var rates=AddedInstallationRates.read(db,id(s.source().customer(),"cs_id"),service);var net=AddedInstallationRates.net(rates.customer(),i.hours(),false);var courier=AddedInstallationRates.net(rates.courier(),i.hours(),u.headquartersId()==1020);if(net.abs().compareTo(new BigDecimal("999999.99"))>0||courier.abs().compareTo(new BigDecimal("999999.99"))>0)throw bad("Der Montagebetrag überschreitet den zulässigen Auftragsbetrag.");return new Prepared(s,i,rates,net,courier,mode,online?Integer.toString(i.mode()):"");}
|
||||
@PostMapping("/preview") Map<String,Object> preview(Authentication a,@PathVariable long job,@RequestBody Input i){return preview(ApiController.user(a),job,i);}
|
||||
Map<String,Object> preview(LegacyUser u,long job,Input i){access(u);return db.readTransaction(()->{var p=prepare(u,job,i,false);return fields("revision",revision(p),"input",i,"hours",i.hours(),"child",p.state().child()==null?0:id(p.state().child().job(),"jb_id"),"customerRate",p.rates().customer().gross(),"courierRate",p.rates().courier().gross(),"customerCategory",p.rates().customer().category(),"courierCategory",p.rates().courier().category(),"net",p.net(),"gross",Cashflow.gross(p.net()),"courierNet",p.courierNet(),"mode",p.mode(),"modeLabel",OrderPayments.label(p.state().source(),p.mode()),"intendedMode",p.tan(),"fractionalCalculator",i.hours().stripTrailingZeros().scale()>0&&!p.state().calculator().isEmpty());});}
|
||||
@PostMapping Map<String,Object> save(Authentication a,@PathVariable long job,@RequestBody Command c){return save(ApiController.user(a),job,c);}
|
||||
Map<String,Object> save(LegacyUser u,long job,Command c){access(u);if(c==null||!c.confirmed()||c.revision()==null||!c.revision().matches("[0-9a-f]{64}"))throw bad("Bitte die Zusatzmontage prüfen und bestätigen.");token(c.requestId());support.transactional("headquarters","job","tour","tourservice","jobpayment","jobcalculator","genericdatacontainer");return db.writeTransaction(()->{lockHeadquarters(db,u);var intent=List.of("ADDED_INSTALLATION",job,c);Long prior=support.receipt(u,c.requestId(),intent);if(prior!=null)return fields("saved",true,"child",prior,"replayed",true);var p=prepare(u,job,c.input(),true);unchanged(p,c.revision());long child=p.state().child()==null?create(u,job,p):id(p.state().child().job(),"jb_id");var now=LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0);update(db,"job","jb_id",child,fields("jb_id_parent",0,"jb_fixprice",p.net(),"jb_totalprice",p.net(),"jb_subtotalprice",p.net(),"jb_cr_price",p.courierNet(),"jb_cr_subprice",p.courierNet(),"jb_incomplete",0,"jb_status",2));db.update("UPDATE job SET jb_finishtime=COALESCE(NULLIF(jb_finishtime,'0000-00-00 00:00:00'),?),jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",now,child);db.update("UPDATE tourservice SET trs_price=? WHERE jb_id=?",p.net(),child);
|
||||
var old=p.state().child()==null?List.<Map<String,Object>>of():p.state().child().payments();var payment=fields("jbp_mode",p.mode(),"jbp_price",p.net(),"jbp_tan",p.tan(),"jbp_bookingtime",now);if(old.isEmpty()){payment.putAll(fields("jb_id",child,"tr_sort",2,"csc_id",id(p.state().source().snapshot().job(),"csc_id_related"),"jbp_counter",0,"jbpc_id",0));db.insert("jobpayment",payment);}else update(db,"jobpayment","jbp_id",id(old.getFirst(),"jbp_id"),payment);
|
||||
db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_addmont'",c.input().hours().toPlainString(),job);db.update("UPDATE jobcalculator SET jbc_amount=?,jbc_price=?,jbc_totalprice=? WHERE jb_id=? AND srvt_name='ident_9999:par_01'",c.input().hours(),c.input().hours().signum()==0?BigDecimal.ZERO:p.net().divide(c.input().hours(),2,RoundingMode.HALF_UP),p.net(),job);db.update("UPDATE job SET jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",job);support.audit(u,job,id(p.state().source().customer(),"cs_id"),id(p.state().source().snapshot().job(),"cr_id"),118,"Zusatzmontage #"+child+" · Stunden "+c.input().hours()+" · Netto "+p.net()+" · Frachtvergütung "+p.courierNet()+" · Zahlart "+p.mode(),c.requestId());support.receipt(u,c.requestId(),intent,child);return fields("saved",true,"child",child);});}
|
||||
long create(LegacyUser u,long parent,Prepared p){var source=p.state().source().snapshot().job();var copy=new LinkedHashMap<>(source);for(String key:List.of("jb_id","jb_modify","jb_export_time","jb_locktime","jb_lockuser","jb_id_copy"))copy.remove(key);copy.putAll(fields("jb_id_parent",0,"jb_id_copy",0,"jb_storno",0,"jb_offer",0,"jb_autoranking",0,"jb_globaljob",0,"jb_permanent",0,"jb_reserv",0,"jb_version",0,"jb_createtime",LocalDateTime.now(),"jb_booktime",LocalDateTime.now(),"emp_id",u.userId(),"jb_invtext","Zusätzliche Montage zu Auftrag "+parent,"jb_invtext_cr","Zusätzliche Montage zu Auftrag "+parent,"jb_serviceprice",0,"jb_markup",0,"jb_postage",0,"jb_automailsent",0,"jb_automailsent2",0,"jb_automailsent3",0));long child=db.insert("job",copy);for(var t:db.queryForList("SELECT * FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",parent)){var next=new LinkedHashMap<>(t);next.remove("tr_id");next.remove("tr_modify");next.put("jb_id",child);db.insert("tour",next);}insertWithoutKey(db,"tourservice",fields("jb_id",child,"tr_sort",0,"csc_id",id(source,"csc_id_related"),"srv_id",0,"srvt_id",0,"trs_srv_name","Zusätzliche Montage","trs_srvt_name","ident_9999:par_01","trs_price",p.net(),"trs_discount",0));metadata(parent,"jb_id_addmont",Long.toString(child));metadata(child,"jb_id_addmont_parent",Long.toString(parent));if(p.mode()==15)metadata(child,"jb_service_job","1");OrderPaymentTerms.copy(db,parent,child);return child;}
|
||||
void metadata(long job,String key,String value){var old=db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",job,key);if(old.size()>1)throw bad("Mehrdeutiges Montagefeld.");if(!old.isEmpty()){db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_id=?",value,id(old.getFirst(),"gdc_id"));return;}db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname",key,"gdc_content",value,"gdc_context",""));}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.math.*;import java.time.*;import java.time.format.DateTimeFormatter;import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
/** ident_0001 and the five promotion slots from current inc_mtf_func.inc.php. */
|
||||
final class AddedInstallationRates {
|
||||
record Rate(int category,BigDecimal gross,BigDecimal repayment){}
|
||||
record Rates(List<Map<String,Object>> evidence,LocalDate day,Rate customer,Rate courier){}
|
||||
static Rates read(DatabaseService db,long customer,boolean service){var rows=db.queryForList("SELECT ck.mtfc_id,k.mtfk_type,v.mtfv_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 v.mtfv_id=? AND ck.hq_id=0 AND ck.mtfc_id BETWEEN 20 AND 45 AND k.mtfk_type LIKE 'func:ident_0001:%' ORDER BY ck.mtfc_id,k.mtfk_type,v.mtfv_value",customer);var day=LocalDate.now(ZoneId.of("Europe/Berlin"));return new Rates(rows,day,select(rows,service?40:20,day),select(rows,service?40:30,day));}
|
||||
static BigDecimal number(String v){try{if(!v.strip().matches("[+-]?[0-9]+(?:[.,][0-9]+)?"))throw new NumberFormatException();return new BigDecimal(v.strip().replace(',','.'));}catch(Exception e){throw bad("Ein Betrag im Montagepreiskatalog ist ungültig.");}}
|
||||
static Rate select(List<Map<String,Object>> rows,int base,LocalDate day){for(int category=base+1;category<=base+5;category++){var r=one(rows,category,day);if(r!=null)return r;}var r=one(rows,base,day);if(r==null)throw bad("Kein aktiver Montage-Stundensatz in Kategorie "+base+" vorhanden.");return r;}
|
||||
static Rate one(List<Map<String,Object>> rows,int category,LocalDate day){var values=new LinkedHashMap<String,String>();for(var row:rows)if(id(row,"mtfc_id")==category){String[] parts=text(row,"mtfk_type").split(":",-1);if(parts.length<3||!parts[1].equals("ident_0001"))continue;if(values.put(parts[2],text(row,"mtfv_value"))!=null)throw bad("Mehrdeutige Montagepreisvorgabe in Kategorie "+category+".");}
|
||||
if(values.isEmpty()||values.containsKey("activated")&&!values.get("activated").equals("1"))return null;
|
||||
// PHP uses numeric dmY comparisons here, not a chronological date comparison.
|
||||
if(values.containsKey("date_valid")){String[] range=values.get("date_valid").split("\\|",-1);if(range.length!=2||!range[0].matches("[0-9]{8}")||!range[1].matches("[0-9]{8}"))throw bad("Ungültiger Montage-Aktionszeitraum in Kategorie "+category+".");long current=Long.parseLong(day.format(DateTimeFormatter.ofPattern("ddMMyyyy")));if(current<Long.parseLong(range[0])||current>Long.parseLong(range[1]))return null;}
|
||||
String value=values.getOrDefault("01","");if(value.isBlank())return null;var gross=number(value);if(gross.signum()<0||gross.compareTo(new BigDecimal("999999.99"))>0)throw bad("Ungültiger Montage-Stundensatz.");String percent=values.getOrDefault("global_repayment","");return new Rate(category,gross,percent.isBlank()?BigDecimal.ZERO:gross.multiply(number(percent)).movePointLeft(2));
|
||||
}
|
||||
static BigDecimal net(Rate r,BigDecimal hours,boolean deduct){return (deduct?r.gross().subtract(r.repayment()):r.gross()).multiply(hours).divide(new BigDecimal("1.19"),2,RoundingMode.HALF_UP);}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Address-to-tariff mappings from srvpa_list.php and srvpaa_list.php. */
|
||||
@Service
|
||||
class AddressAdministration {
|
||||
enum Kind {
|
||||
POSTAL("serviceplzaddress","srvpxa","srvp_id","serviceplz","srvp_plz"),
|
||||
AREA("serviceplzareaaddress","srvpaa","srvpa_id","serviceplzarea","srvpa_name");
|
||||
final String table,prefix,target,targetTable,label;
|
||||
Kind(String table,String prefix,String target,String targetTable,String label){this.table=table;this.prefix=prefix;this.target=target;this.targetTable=targetTable;this.label=label;}
|
||||
}
|
||||
record Query(String target,String street,String postalCode,String city,String country,String from,String to,Integer mode,String sort,int page){}
|
||||
record Save(String requestId,String target,String street,String postalCode,String city,String country,String from,String to,int mode){}
|
||||
record Selection(long id,String revision){}
|
||||
record Delete(String requestId,List<Selection> items){}
|
||||
record Operation(String kind,long hq,long user,String request,List<Map<String,Object>> rows){}
|
||||
private final DatabaseService db;
|
||||
private final ObjectMapper json;
|
||||
private final OrderEntryCatalog catalog;
|
||||
AddressAdministration(DatabaseService db,ObjectMapper json,OrderEntryCatalog catalog){this.db=db;this.json=json;this.catalog=catalog;}
|
||||
void access(LegacyUser u){u.require(2);}
|
||||
static String clean(String value,int max){value=Objects.requireNonNullElse(value,"").strip();if(value.length()>max)throw bad("Such- oder Eingabefeld ist zu lang.");return value;}
|
||||
static String like(String value){return value.replace("!","!!").replace("%","!%").replace("_","!_")+"%";}
|
||||
String targetName(LegacyUser u,Kind k,String value){value=clean(value,10);if(k==Kind.AREA&&!value.isEmpty()){
|
||||
String pad=catalog.parameter(u.headquartersId(),"ZIPCODEAREA_PADLENGTH");
|
||||
if(pad.matches("[1-9]|10"))value="0".repeat(Math.max(0,Integer.parseInt(pad)-value.length()))+value;
|
||||
}return value;}
|
||||
String select(Kind k){return "SELECT m."+k.prefix+"_id AS id,m.hq_id,m."+k.target+" AS targetId,m.ad_id,m."+k.prefix+"_hsno_from AS houseFrom,m."+k.prefix+"_hsno_to AS houseTo,m."+k.prefix+"_mode AS mode,t."+k.label+" AS target,a.ad_street AS street,a.ad_zipcode AS postalCode,a.ad_city AS city,a.ad_country AS country"+from(k);}
|
||||
String from(Kind k){return " FROM "+k.table+" m JOIN address a ON a.ad_id=m.ad_id JOIN "+k.targetTable+" t ON t."+k.target+"=m."+k.target+" WHERE m.hq_id=?"+(k==Kind.AREA?" AND t.hq_id=m.hq_id":"");}
|
||||
Map<String,Object> query(LegacyUser u,Kind k,Query q){access(u);if(q==null||q.page()<0||q.page()>100000)throw bad("Ungültige Suchanfrage.");
|
||||
var args=new ArrayList<Object>();args.add(u.headquartersId());StringBuilder where=new StringBuilder();
|
||||
String target=targetName(u,k,q.target());if(!target.isEmpty()){where.append(" AND t.").append(k.label).append("=?");args.add(target);}
|
||||
String[] values={q.street(),q.postalCode(),q.city(),q.country()};String[] columns={"ad_street","ad_zipcode","ad_city","ad_country"};int[] lengths={50,10,50,10};
|
||||
for(int i=0;i<values.length;i++){String v=clean(values[i],lengths[i]);if(!v.isEmpty()){where.append(" AND a.").append(columns[i]).append(" LIKE ? ESCAPE '!'");args.add(like(v));}}
|
||||
for(var entry:Map.of("from",clean(q.from(),4),"to",clean(q.to(),4)).entrySet())if(!entry.getValue().isEmpty()){where.append(" AND m.").append(k.prefix).append("_hsno_").append(entry.getKey()).append("=?");args.add(entry.getValue());}
|
||||
if(q.mode()!=null){if(q.mode()<0||q.mode()>2)throw bad("Ungültiger Hausnummernmodus.");where.append(" AND m.").append(k.prefix).append("_mode=?");args.add(q.mode());}
|
||||
String sort=switch(Objects.requireNonNullElse(q.sort(),"street")){case "target"->"t."+k.label;case "from"->"m."+k.prefix+"_hsno_from";case "to"->"m."+k.prefix+"_hsno_to";case "mode"->"m."+k.prefix+"_mode";case "postalCode"->"a.ad_zipcode";case "city"->"a.ad_city";case "street"->"a.ad_street";default->throw bad("Ungültige Sortierung.");};
|
||||
long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*)"+from(k)+where,Long.class,args.toArray())).orElse(0L);
|
||||
args.add(q.page()*100);var rows=db.queryForList(select(k)+where+" ORDER BY "+sort+",m."+k.prefix+"_id LIMIT 100 OFFSET ?",args.toArray());
|
||||
var items=new ArrayList<Map<String,Object>>();for(var r:rows){var v=new LinkedHashMap<>(r);v.put("revision",revision(r));v.put("exceptions",k==Kind.AREA?exceptions(u,id(r,"id")):List.of());items.add(v);}
|
||||
return fields("items",items,"total",total);
|
||||
}
|
||||
List<Map<String,Object>> exceptions(LegacyUser u,long id){
|
||||
var result=new ArrayList<Map<String,Object>>();
|
||||
for(var row:db.queryForList("SELECT par_value FROM parameter WHERE par_key=? AND hq_id IN (0,?) AND emp_id=0","MASK_AREA_ID_FROM_ADDRESS_"+id,u.headquartersId()))
|
||||
for(String pair:text(row,"par_value").split(";")){String[] parts=pair.split(",");if(parts.length!=2||!parts[1].matches("[0-9]{1,9}"))continue;var targets=db.queryForList("SELECT srvpa_name FROM serviceplzarea WHERE srvpa_id=? AND hq_id=?",Long.parseLong(parts[1]),u.headquartersId());result.add(fields("house",parts[0],"area",targets.isEmpty()?parts[1]:text(targets.getFirst(),"srvpa_name")));}
|
||||
return result;
|
||||
}
|
||||
Map<String,Object> options(LegacyUser u,Kind k){access(u);return fields("headquartersId",u.headquartersId(),"operations",db.queryForList("SELECT gdc_id AS id,SUBSTRING(gdc_gen_fieldname,9) AS token FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_obj_id=? AND LEFT(gdc_gen_fieldname,8)='ng_addr_' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 100",k.prefix,u.headquartersId()));}
|
||||
static Integer house(String value){String v=clean(value,4);if(v.isEmpty())return null;if(!v.matches("[0-9]{1,4}"))throw bad("Hausnummerngrenzen müssen ganze Zahlen von 0 bis 9999 sein.");return Integer.parseInt(v);}
|
||||
long targetId(LegacyUser u,Kind k,String value,String country){var rows=db.queryForList("SELECT "+k.target+" AS id FROM "+k.targetTable+" WHERE "+k.label+"=?"+(k==Kind.AREA?" AND hq_id=?":" AND COALESCE(NULLIF(srvp_country,''),'DE')=?"),targetName(u,k,value),k==Kind.AREA?u.headquartersId():country);if(rows.size()!=1)throw bad("Abrechnungs-PLZ bzw. Bereich existiert nicht eindeutig in der ausgewählten Niederlassung / im Land.");return id(rows.getFirst(),"id");}
|
||||
String encode(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("Vorgang konnte nicht gesichert werden.",e);}}
|
||||
Operation decode(Map<String,Object> row){try{return json.readValue(text(row,"gdc_content"),Operation.class);}catch(Exception e){throw bad("Gespeicherter Adressvorgang ist beschädigt.");}}
|
||||
Map<String,Object> operation(LegacyUser u,Kind k,String token,boolean lock){String sql="SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_obj_id=? AND gdc_gen_fieldname=?";var rows=lock?db.lockRows(sql,k.prefix,u.headquartersId(),"ng_addr_"+token):db.queryForList(sql,k.prefix,u.headquartersId(),"ng_addr_"+token);if(rows.size()>1)throw bad("Mehrdeutige Vorgangskennung.");return rows.isEmpty()?null:rows.getFirst();}
|
||||
void persist(LegacyUser u,Kind k,String token,Operation op){String content=encode(op);if(content.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65000)throw bad("Bitte weniger Zuordnungen auf einmal auswählen.");db.insert("genericdatacontainer",fields("gdc_obj_type",k.prefix,"gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname","ng_addr_"+token,"gdc_content",content,"gdc_context","PENDING"));}
|
||||
boolean repeated(Map<String,Object> row,LegacyUser u,String request){if(row==null)return false;var op=decode(row);if(op.user()!=u.userId()||!op.request().equals(request))throw bad("Vorgangskennung wurde bereits für eine andere Änderung verwendet.");return true;}
|
||||
Map<String,Object> create(LegacyUser u,Kind k,Save s){access(u);if(s==null)throw bad("Adressdaten fehlen.");String token=FilterAdministration.token(s.requestId());String request=encode(s);
|
||||
String street=required(s.street(),50,"Straße"),postal=required(s.postalCode(),10,"PLZ"),country=clean(s.country(),10).toUpperCase(Locale.ROOT);if(country.isEmpty())country="DE";final String nation=country;
|
||||
Integer start=house(s.from()),end=house(s.to());if(start!=null&&end!=null&&start>end)throw bad("Hausnummer von darf nicht größer als bis sein.");if(s.mode()<0||s.mode()>2)throw bad("Ungültiger Hausnummernmodus.");
|
||||
db.writeTransaction(()->{lockHeadquarters(db,u);if(repeated(operation(u,k,token,true),u,request))return null;
|
||||
long target=targetId(u,k,required(s.target(),10,"Abrechnungsziel"),nation);
|
||||
if(db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=? AND COALESCE(NULLIF(srvp_country,''),'DE')=?",postal,nation).isEmpty())throw bad("Die Straßen-PLZ existiert nicht im gewählten Land.");
|
||||
String city=clean(s.city(),50);if(city.isEmpty()){var cities=db.queryForList("SELECT DISTINCT ad_city FROM address WHERE ad_zipcode=? AND ad_country=? AND ad_city<>'' ORDER BY ad_city LIMIT 2",postal,nation);if(cities.size()==1)city=text(cities.getFirst(),"ad_city");else throw bad("Bitte den Ort ergänzen; zur PLZ ist kein eindeutiger Ort hinterlegt.");}
|
||||
var addresses=db.queryForList("SELECT ad_id FROM address WHERE ad_street=? AND ad_zipcode=? AND ad_city=? AND ad_country=? ORDER BY ad_id LIMIT 1",street,postal,city,nation);
|
||||
long address=addresses.isEmpty()?db.insert("address",fields("ad_street",street,"ad_zipcode",postal,"ad_city",city,"ad_country",nation)):id(addresses.getFirst(),"ad_id");
|
||||
var row=fields("targetId",target,"ad_id",address,"houseFrom",start,"houseTo",end==null?null:end.toString(),"mode",s.mode());
|
||||
// Commit address and intent before touching the nontransactional association table.
|
||||
persist(u,k,token,new Operation("CREATE",u.headquartersId(),u.userId(),request,List.of(row)));return null;});
|
||||
return resume(u,k,token);
|
||||
}
|
||||
Map<String,Object> remove(LegacyUser u,Kind k,Delete s){access(u);if(s==null||s.items()==null||s.items().isEmpty()||s.items().size()>100)throw bad("Bitte 1 bis 100 Zuordnungen auswählen.");String token=FilterAdministration.token(s.requestId()),request=encode(s);
|
||||
db.writeTransaction(()->{lockHeadquarters(db,u);if(repeated(operation(u,k,token,true),u,request))return null;var rows=new ArrayList<Map<String,Object>>();var seen=new HashSet<Long>();for(var selection:s.items()){if(selection==null||!seen.add(selection.id()))throw bad("Ungültige oder doppelte Auswahl.");var r=one(db.queryForList(select(k)+" AND m."+k.prefix+"_id=?",u.headquartersId(),selection.id()));unchanged(r,selection.revision());rows.add(r);}persist(u,k,token,new Operation("DELETE",u.headquartersId(),u.userId(),request,rows));return null;});return resume(u,k,token);
|
||||
}
|
||||
String match(Kind k){return k.target+"=? AND ad_id=? AND "+k.prefix+"_hsno_from <=> ? AND "+k.prefix+"_hsno_to <=> ? AND "+k.prefix+"_mode <=> ?";}
|
||||
List<Object> args(Map<String,Object> r){return new ArrayList<>(Arrays.asList(id(r,"targetId"),id(r,"ad_id"),r.get("houseFrom"),r.get("houseTo"),r.get("mode")));}
|
||||
Map<String,Object> resume(LegacyUser u,Kind k,String token){access(u);FilterAdministration.token(token);if(operation(u,k,token,false)==null)throw bad("Adressvorgang nicht gefunden.");
|
||||
try{return db.writeTransaction(()->{lockHeadquarters(db,u);var saved=operation(u,k,token,true);if(text(saved,"gdc_context").equals("DONE"))return fields("state","DONE","message","Adressvorgang abgeschlossen.");var op=decode(saved);
|
||||
for(var r:op.rows())if(op.kind().equals("CREATE")){var parameters=args(r);parameters.add(0,op.hq());var insertArgs=new ArrayList<>(parameters);insertArgs.addAll(parameters);db.update("INSERT INTO "+k.table+" (hq_id,"+k.target+",ad_id,"+k.prefix+"_hsno_from,"+k.prefix+"_hsno_to,"+k.prefix+"_mode) SELECT ?,?,?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM "+k.table+" WHERE hq_id=? AND "+match(k)+")",insertArgs.toArray());}
|
||||
else if(op.kind().equals("DELETE")){var parameters=args(r);parameters.add(0,op.hq());parameters.add(0,id(r,"id"));int removed=db.update("DELETE FROM "+k.table+" WHERE "+k.prefix+"_id=? AND hq_id=? AND "+match(k),parameters.toArray());if(removed==0&&!db.queryForList("SELECT "+k.prefix+"_id FROM "+k.table+" WHERE "+k.prefix+"_id=?",id(r,"id")).isEmpty())throw bad("Eine Zuordnung wurde zwischenzeitlich geändert. Bereits entfernte Zuordnungen bleiben entfernt.");}
|
||||
else throw bad("Unbekannter Adressvorgang.");
|
||||
db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(saved,"gdc_id"));return fields("state","DONE","message",op.kind().equals("CREATE")?"Adresszuordnung gespeichert.":"Ausgewählte Zuordnungen entfernt.");});}
|
||||
catch(RuntimeException e){return fields("state","PENDING","message","Der Adressvorgang ist gesichert, aber noch nicht abgeschlossen. Bitte den vorhandenen Vorgang fortsetzen."+(e instanceof org.springframework.web.server.ResponseStatusException r&&r.getReason()!=null?" "+r.getReason():""));}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController @RequestMapping("/api/administration/addresses/{kind}")
|
||||
class AddressController {
|
||||
private final AddressAdministration service;
|
||||
AddressController(AddressAdministration service){this.service=service;}
|
||||
@GetMapping Map<String,Object> options(Authentication a,@PathVariable AddressAdministration.Kind kind){return service.options(user(a),kind);}
|
||||
@PostMapping("/query") Map<String,Object> query(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Query q){return service.query(user(a),kind,q);}
|
||||
@PostMapping Map<String,Object> create(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Save s){return service.create(user(a),kind,s);}
|
||||
@PostMapping("/remove") Map<String,Object> remove(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Delete s){return service.remove(user(a),kind,s);}
|
||||
@PostMapping("/operations/{token}/resume") Map<String,Object> resume(Authentication a,@PathVariable AddressAdministration.Kind kind,@PathVariable String token){return service.resume(user(a),kind,token);}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
|
||||
/** Shared validation for the explicitly ported administration workflows. */
|
||||
final class AdminSupport {
|
||||
private AdminSupport() {}
|
||||
static String revision(Object value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical(value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
private static String canonical(Object value) {
|
||||
if(value instanceof Map<?,?> map) {
|
||||
var sorted=new TreeMap<String,String>();map.forEach((k,v)->sorted.put(String.valueOf(k),canonical(v)));
|
||||
return sorted.toString();
|
||||
}
|
||||
if(value instanceof Collection<?> list)return list.stream().map(AdminSupport::canonical).toList().toString();
|
||||
return value==null?"<null>":value.getClass().getSimpleName()+":"+value;
|
||||
}
|
||||
static void unchanged(Object current,String expected) {
|
||||
if(expected==null || !revision(current).equals(expected))throw new ResponseStatusException(HttpStatus.CONFLICT,"Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderungen prüfen.");
|
||||
}
|
||||
static String required(String value,int max,String label) {
|
||||
value=value==null?"":value.strip();
|
||||
if(value.isEmpty() || value.length()>max)throw bad(label+": Bitte 1 bis "+max+" Zeichen eingeben.");
|
||||
return value;
|
||||
}
|
||||
static void range(LocalDate from,LocalDate to) {
|
||||
if(from==null || to==null || from.isAfter(to) || from.plusYears(10).isBefore(to))throw bad("Bitte einen gültigen Zeitraum von höchstens zehn Jahren wählen.");
|
||||
}
|
||||
static Map<String,Object> one(List<Map<String,Object>> rows) {
|
||||
if(rows.size()!=1)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"Der Datensatz ist nicht vorhanden oder nicht freigegeben.");
|
||||
return rows.getFirst();
|
||||
}
|
||||
static void lockHeadquarters(DatabaseService db,LegacyUser user) {
|
||||
one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",user.headquartersId()));
|
||||
}
|
||||
static long mandator(DatabaseService db,LegacyUser user) {
|
||||
var rows=db.queryForList("SELECT md_id FROM mandatorheadquarters WHERE hq_id=? AND md_id>0",user.headquartersId());
|
||||
return id(one(rows),"md_id");
|
||||
}
|
||||
static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
import static de.stadtbote.votian.CalendarAdministration.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/administration")
|
||||
public class AdministrationController {
|
||||
private final CalendarAdministration calendar;
|
||||
private final GroupAdministration groups;
|
||||
private final GroupDeletion deletion;
|
||||
public AdministrationController(CalendarAdministration calendar,GroupAdministration groups,GroupDeletion deletion){this.calendar=calendar;this.groups=groups;this.deletion=deletion;}
|
||||
@GetMapping Map<String,Object> sections(Authentication auth) {
|
||||
var u=user(auth);
|
||||
return OrderEntryService.fields("operations",OperationsMonitoring.allowed(u),"warehouseUsers",u.hasRight(3)&&u.headquartersId()==u.originalHeadquartersId(),"warehouses",u.hasRight(14),"articles",u.hasRight(14),"headquartersSettings",u.headquartersId()==u.originalHeadquartersId(),"employees",u.hasRight(3),"pdaSettings",u.hasRight(9),"vehicleDisposition",u.hasRight(27)||u.hasRight(28),"fleetMap",u.hasRight(19),"extras",u.hasRight(46),"postalPrices",u.hasRight(2),"postalDiscounts",u.hasRight(2),"areaPrices",u.hasRight(2),"areaDiscounts",u.hasRight(2),"servicePrices",u.hasRight(2)&&(u.hasRight(29)||u.hasRight(0)),"serviceDiscounts",u.hasRight(2)&&(u.hasRight(29)||u.hasRight(0)),"postalAddresses",u.hasRight(2),"areaAddresses",u.hasRight(2),"headquartersId",u.headquartersId(),"holidays",u.hasRight(2),"travelTimes",u.hasRight(2),"groups",u.hasRight(8)&&u.hasRight(25),"sharedGroups",u.hasRight(10),"evaluations",u.hasRight(0)&&u.hasRight(8),"filters",u.hasRight(0),"customerReports",u.hasRight(0)&&u.hasRight(16),"courierReports",u.hasRight(1)&&u.hasRight(16),"groupKinds",Arrays.stream(GroupAdministration.Kind.values()).filter(k->u.hasRight(k.right)&&(k.hq!=null||u.hasRight(10))).map(k->Map.of("id",k.name(),"label",k.label)).toList());
|
||||
}
|
||||
@GetMapping("/holidays/{year}") Map<String,Object> holidays(Authentication auth,@PathVariable int year){return calendar.holidays(user(auth),year);}
|
||||
@PutMapping("/holidays/{year}") Map<String,Object> holidays(Authentication auth,@PathVariable int year,@RequestBody HolidaySave request){return calendar.saveHolidays(user(auth),year,request);}
|
||||
@GetMapping("/travel-times") Map<String,Object> travel(Authentication auth,@RequestParam TravelMode mode,@RequestParam String from,@RequestParam String to){return calendar.travelTimes(user(auth),mode,from,to);}
|
||||
@PutMapping("/travel-times/{id}") Map<String,Object> travel(Authentication auth,@PathVariable long id,@RequestParam TravelMode mode,@RequestBody TravelSave request){return calendar.saveTravelTime(user(auth),mode,id,request);}
|
||||
@GetMapping("/groups") List<Map<String,Object>> groups(Authentication auth,@RequestParam(required=false) Long headquartersId){return groups.list(groups.scoped(user(auth),headquartersId));}
|
||||
@PostMapping("/groups") Map<String,Object> createGroup(Authentication auth,@RequestParam(required=false) Long headquartersId,@RequestBody GroupAdministration.GroupSave request){return groups.save(groups.scoped(user(auth),headquartersId),0,request);}
|
||||
@PutMapping("/groups/{id}") Map<String,Object> saveGroup(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@RequestBody GroupAdministration.GroupSave request){return groups.save(groups.scoped(user(auth),headquartersId),id,request);}
|
||||
record Revision(String revision) {}
|
||||
@GetMapping("/groups/headquarters") Object groupHeadquarters(Authentication auth){return groups.headquarters(user(auth));}
|
||||
@GetMapping("/groups/operations") Object groupOperations(Authentication auth,@RequestParam(required=false) Long headquartersId){return deletion.pending(groups.scoped(user(auth),headquartersId));}
|
||||
@GetMapping("/groups/{id}/deletion") Object groupDeletion(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId){return deletion.preview(groups.scoped(user(auth),headquartersId),id);}
|
||||
@DeleteMapping("/groups/{id}") Object removeGroup(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@RequestBody GroupDeletion.Delete request){return deletion.remove(groups.scoped(user(auth),headquartersId),id,request);}
|
||||
@PostMapping("/groups/operations/{token}") Object resumeGroupDeletion(Authentication auth,@PathVariable String token,@RequestParam(required=false) Long headquartersId){return deletion.resume(groups.scoped(user(auth),headquartersId),token);}
|
||||
@GetMapping("/groups/{id}/members") Map<String,Object> members(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@RequestParam GroupAdministration.Kind kind,@RequestParam(defaultValue="") String q,@RequestParam(defaultValue="true") boolean onlyMembers){return groups.members(groups.scoped(user(auth),headquartersId),id,kind,q,onlyMembers);}
|
||||
@PutMapping("/groups/{id}/members/{kind}/{objectId}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void membership(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@PathVariable GroupAdministration.Kind kind,@PathVariable long objectId,@RequestBody GroupAdministration.MembershipSave request){groups.membership(groups.scoped(user(auth),headquartersId),id,kind,objectId,request);}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.time.*;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;
|
||||
|
||||
/** ALLERGO's active 46-column shipment import, with customer-scoped name transfer. */
|
||||
@RestController @RequestMapping("/api/allergo-import/{customer}")
|
||||
class AllergoImport {
|
||||
record Command(String requestId,String csv,LocalDate date,String revision,boolean replaceNames){}
|
||||
record Address(String name,String street,String postal,String city,String country,String contact,String phone,String signer){}
|
||||
record Shipment(int line,String transaction,Address sender,Address recipient,String remark){}
|
||||
record Item(Shipment shipment,boolean existing){}
|
||||
record Plan(long headquarters,long customer,LocalDate date,TourCsvImport.State state,List<Item> items,List<Map<String,Object>> markers,List<Map<String,Object>> renamed){}
|
||||
final DatabaseService db;final BusinessSupport support;final TourCsvImport tours;final Clock clock;
|
||||
@org.springframework.beans.factory.annotation.Autowired AllergoImport(DatabaseService db,BusinessSupport support,TourCsvImport tours){this(db,support,tours,Clock.system(ZoneId.of("Europe/Berlin")));}
|
||||
AllergoImport(DatabaseService db,BusinessSupport support,TourCsvImport tours,Clock clock){this.db=db;this.support=support;this.tours=tours;this.clock=clock;}
|
||||
@GetMapping Object options(Authentication a,@PathVariable long customer){return options(ApiController.user(a),customer);}
|
||||
@PostMapping("/preview") Object preview(Authentication a,@PathVariable long customer,@RequestBody Command c){return preview(ApiController.user(a),customer,c);}
|
||||
@PostMapping Object save(Authentication a,@PathVariable long customer,@RequestBody Command c){return save(ApiController.user(a),customer,c);}
|
||||
static String value(List<String> v,int col,int max,int line){return ZoneJobImport.value(v.get(col),max,"Spalte "+(col+1),line);}
|
||||
static Address address(List<String> v,boolean sender,int line){String name=value(v,sender?2:13,50,line),street=(value(v,sender?5:15,50,line)+" "+value(v,sender?6:16,50,line)).trim(),postal=value(v,sender?8:19,10,line),city=value(v,sender?7:17,50,line),country=value(v,sender?10:18,10,line).toUpperCase(Locale.ROOT);if(country.equals("D"))country="DE";if(name.isBlank()||street.isBlank()||street.length()>50||postal.isBlank()||city.isBlank()||!country.matches("[A-Z]{2}"))throw bad("Zeile "+line+": "+(sender?"Absender":"Empfänger")+" benötigt Name, Straße (maximal 50 Zeichen), PLZ, Ort und zweistelliges Land.");if(List.of(street,postal,city,country).stream().anyMatch(s->s.contains("|")||s.contains(";")))throw bad("Zeile "+line+": ungültiges Adresstrennzeichen.");return new Address(name,street,postal,city,country,value(v,sender?4:14,50,line),value(v,sender?11:20,20,line),sender?value(v,12,50,line):"");}
|
||||
static List<Shipment> parse(String csv){if(csv==null||csv.getBytes(StandardCharsets.UTF_8).length>1_000_000)throw bad("CSV fehlt oder ist größer als 1 MB.");var rows=CustomerServiceImports.parse(csv);if(rows.size()<2||rows.getFirst().line()!=1)throw bad("Eine Kopfzeile und mindestens eine Sendung sind erforderlich.");if(rows.size()>501)throw bad("Höchstens 500 Sendungen je Datei.");var seen=new HashSet<String>();var out=new ArrayList<Shipment>();for(int n=1;n<rows.size();n++){var row=rows.get(n);var v=row.values();int line=row.line();if(v.size()!=46&&!(v.size()==47&&v.get(46).isEmpty()))throw bad("Zeile "+line+": 46 ALLERGO-Spalten und optional eine leere Abschlussspalte erwartet.");for(int i=0;i<46;i++)value(v,i,i==32?148:500,line);String transaction=TourCsvImport.clean(value(v,1,30,line));if(transaction.isBlank()||!seen.add(transaction))throw bad("Zeile "+line+": fehlende oder doppelte Transaktionskennung.");var sender=address(v,true,line);var recipient=address(v,false,line);var remark=new ArrayList<String>();String[] labels={"Sender ID","Kd-Nr. Abs.","Kd-Nr. Fz.","Gewichtseinheit","Frachtzahler","Versanddatum","Referenz"};int[] cols={3,9,21,22,23,24,25};for(int i=0;i<cols.length;i++)remark.add(labels[i]+": "+v.get(cols[i]));remark.add("Paketmaße (LxBxH): "+v.get(28)+" x "+v.get(27)+" x "+v.get(26));String[] tail={"Gewichtseinheit","Warengewicht","Produktbeschreibung","Rechnungs-Flag","Anz. Packstücke","Dokumentensendung","Verpackung","Serviceart","Paketnr. Versand-UN","Letzte Änderung"};int[] extra={30,31,32,33,34,35,36,37,40,45};for(int i=0;i<extra.length;i++)remark.add(tail[i]+": "+v.get(extra[i]));String text=String.join("\n",remark);if(text.length()>8000)throw bad("Zeile "+line+": Sendungsbemerkung zu lang.");out.add(new Shipment(line,transaction,sender,recipient,text));}return List.copyOf(out);}
|
||||
Map<String,Object> options(LegacyUser u,long customer){tours.zone.access(u,customer);var state=tours.state(u,customer,TourCsvImport.Format.TOURS);return fields("customer",text(state.customer(),"cs_eid"),"payer",id(state.payer(),"csc_id"),"date",LocalDate.now(clock).toString(),"maxRows",500,"maxNew",99);}
|
||||
Plan plan(LegacyUser u,long customer,Command c){if(c==null)throw bad("Importdaten fehlen.");BusinessSupport.token(c.requestId());if(!LocalDate.now(clock).equals(c.date()))throw bad("Der Importtag hat sich geändert. Bitte den Import neu öffnen.");var rows=parse(c.csv());var state=tours.state(u,customer,TourCsvImport.Format.TOURS);var keys=rows.stream().map(entryValue -> entryValue.transaction()).toList();var markers=db.queryForList("SELECT g.gdc_id,g.gdc_obj_id,g.gdc_content,j.hq_id,cc.cs_id FROM genericdatacontainer g LEFT JOIN tour t ON t.tr_id=g.gdc_obj_id LEFT JOIN job j ON j.jb_id=t.jb_id LEFT JOIN costcenter cc ON cc.csc_id=j.csc_id_payer WHERE g.gdc_obj_type='tr' AND g.gdc_gen_fieldname='info_0' AND g.gdc_context='ALLERGO' AND g.gdc_content IN ("+String.join(",",Collections.nCopies(keys.size(),"?"))+") ORDER BY g.gdc_id",keys.toArray());var existing=new HashSet<String>();for(var m:markers){if(id(m,"hq_id")!=u.headquartersId()||id(m,"cs_id")!=customer)throw bad("Eine Transaktionskennung ist bereits außerhalb dieses Kunden belegt oder nicht eindeutig zugeordnet.");if(!existing.add(text(m,"gdc_content")))throw bad("Mehrdeutige vorhandene ALLERGO-Transaktionskennung.");}var items=rows.stream().map(s->new Item(s,existing.contains(s.transaction()))).toList();var fresh=items.stream().filter(i->!i.existing()).toList();if(fresh.size()>99)throw bad("Höchstens 99 neue Sendungen und eine Abholstation je Auftrag. Bitte Datei aufteilen.");if(fresh.stream().map(i->i.shipment().sender()).distinct().count()>1)throw bad("Unterschiedliche Absender in den neuen Sendungen. Bitte je Absender eine Datei importieren.");var named=fresh.isEmpty()?List.<Map<String,Object>>of():db.queryForList("SELECT j.jb_id,j.jb_tourname,j.jb_ordertime,j.csc_id_payer FROM job j JOIN costcenter cc ON cc.csc_id=j.csc_id_payer WHERE j.hq_id=? AND cc.cs_id=? AND j.jb_tourname='ALLERGO' ORDER BY j.jb_id LIMIT 5001",u.headquartersId(),customer);if(named.size()>5000)throw bad("Zu viele bestehende ALLERGO-Tourennamen.");return new Plan(u.headquartersId(),customer,c.date(),state,items,markers,named);}
|
||||
Map<String,Object> preview(LegacyUser u,long customer,Command c){tours.zone.access(u,customer);return db.readTransaction(()->{var p=plan(u,customer,c);var fresh=p.items().stream().filter(i->!i.existing()).map(entryValue -> entryValue.shipment()).toList();return fields("revision",revision(p),"date",p.date().toString(),"sender",fresh.isEmpty()?null:fresh.getFirst().sender(),"shipments",fresh,"existing",p.items().stream().filter(entryValue -> entryValue.existing()).map(entryValue -> entryValue.shipment()).toList(),"renamed",p.renamed());});}
|
||||
Map<String,Object> saved(LegacyUser u,long customer,Command c){var row=one(db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngallergo' AND gdc_obj_id=? AND gdc_gen_fieldname=? AND gdc_context=?",u.userId(),c.requestId(),Long.toString(customer)));try{return fields("saved",true,"repeated",true,"jobs",support.json.readValue(text(row,"gdc_content"),List.class));}catch(com.fasterxml.jackson.core.JsonProcessingException ex){throw bad("Importbestätigung nicht lesbar.");}}
|
||||
long station(LegacyUser u,long job,int sort,long center,Address a,boolean pickup,String remark){var found=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",a.street(),a.postal(),a.city(),a.country());long address=found.isEmpty()?db.insert("address",fields("ad_street",a.street(),"ad_zipcode",a.postal(),"ad_city",a.city(),"ad_country",a.country())):id(found.getFirst(),"ad_id");long tr=tours.zone.saveStation(job,sort,address,center,fields("name",a.name(),"house","."),"",a.phone(),remark);db.update("UPDATE tour SET tr_person=?,tr_signname=?,tr_status=? WHERE tr_id=?",a.contact(),pickup?a.signer():"",pickup?1:0,tr);return tr;}
|
||||
Map<String,Object> save(LegacyUser u,long customer,Command c){tours.zone.access(u,customer);if(c==null)throw bad("Importdaten fehlen.");BusinessSupport.token(c.requestId());var intent=List.of("ALLERGO",customer,c);if(support.receipt(u,c.requestId(),intent)!=null)return saved(u,customer,c);support.transactional("job","tour","tourservice","address","genericdatacontainer","headquarters");return db.tourCsvImportTransaction(()->{tours.zone.access(u,customer);lockHeadquarters(db,u);if(support.receipt(u,c.requestId(),intent)!=null)return saved(u,customer,c);var p=plan(u,customer,c);unchanged(p,c.revision());if(!p.renamed().isEmpty()&&!c.replaceNames())throw bad("Die Übertragung bestehender ALLERGO-Tourennamen muss ausdrücklich bestätigt werden.");for(var old:p.renamed()){db.update("UPDATE job SET jb_tourname='' WHERE jb_id=? AND hq_id=? AND jb_tourname='ALLERGO'",id(old,"jb_id"),u.headquartersId());support.audit(u,id(old,"jb_id"),customer,0,103,"ALLERGO: Tourname an importierten Auftrag übertragen",c.requestId());}var fresh=p.items().stream().filter(i->!i.existing()).map(entryValue -> entryValue.shipment()).toList();var jobs=new ArrayList<Map<String,Object>>();if(!fresh.isEmpty()){long payer=id(p.state().payer(),"csc_id"),job=OrderImportPersistence.create(db,u,payer,p.date().atTime(16,0),LocalDateTime.now(clock).withNano(0),"ALLERGO");var sender=fresh.getFirst().sender();station(u,job,1,payer,sender,true,"");int sort=1;var addresses=new ArrayList<Address>();addresses.add(sender);for(var row:fresh){long tr=station(u,job,++sort,id(p.state().external(),"csc_id"),row.recipient(),false,row.remark());addresses.add(row.recipient());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,'Fixpreis',0,'',0,0)",job,payer,sort);db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",tr,"gdc_gen_fieldname","info_0","gdc_content",row.transaction(),"gdc_context","ALLERGO"));}String compact=addresses.stream().map(entryValue -> entryValue.postal()).collect(java.util.stream.Collectors.joining(";"))+"|"+addresses.stream().map(entryValue -> entryValue.city()).collect(java.util.stream.Collectors.joining(";"))+"|"+addresses.stream().map(entryValue -> entryValue.country()).collect(java.util.stream.Collectors.joining(";"));db.update("UPDATE job SET jb_tourdata=? WHERE jb_id=?",compact,job);support.audit(u,job,customer,0,102,"B2B_IMPORT=1 | ALLERGO",c.requestId());jobs.add(fields("id",job,"name","ALLERGO","stations",sort));}db.insert("genericdatacontainer",fields("gdc_obj_type","ngallergo","gdc_obj_id",u.userId(),"gdc_gen_fieldname",c.requestId(),"gdc_content",support.encode(jobs),"gdc_context",Long.toString(customer)));support.receipt(u,c.requestId(),intent,jobs.size());return fields("saved",true,"repeated",false,"jobs",jobs);});}
|
||||
}
|
||||
@@ -38,34 +38,54 @@ public class ApiController {
|
||||
boolean connected=connection.connected();
|
||||
LegacyUser u=authentication!=null&&authentication.getPrincipal() instanceof LegacyUser lu?lu:null;
|
||||
Map<String,Object> result=new LinkedHashMap<>();
|
||||
result.put("connected",connected);result.put("database","legacy-mysql");result.put("readOnly",true);result.put("schemaChanges",false);
|
||||
result.put("connected",connected);result.put("database","legacy-mysql");result.put("readOnly",u==null||!(u.hasRight(0)||u.hasRight(1)||u.hasRight(7)||u.hasRight(2)||u.hasRight(4)||u.hasRight(18)||u.hasRight(15)||(u.hasRight(0)&&u.hasRight(32))||(u.hasRight(0)&&u.hasRight(8))||(u.hasRight(0)&&u.hasRight(17))||(u.hasRight(0)&&u.hasRight(11))||(u.hasRight(8)&&u.hasRight(25))||(u.hasRight(16)&&(u.hasRight(0)||u.hasRight(1)))));result.put("schemaChanges",false);
|
||||
result.put("message",connection.message());result.put("reason",connection.reason());
|
||||
result.put("legacyUrl",legacyUrl);result.put("maxResults",legacy.maxResults());
|
||||
result.put("capabilities",u==null?new Capabilities(false,false,false,false,false,false,false,false,false,false):u.capabilities());
|
||||
result.put("unavailableModules",List.of("Rechnungserstellung und Zahlungsstatus","Änderungen an Aufträgen und Stammdaten","Archivtabellen"));
|
||||
result.put("cashflow",u!=null&&u.hasRight(26));
|
||||
result.put("unavailableModules",List.of("Fibu-Zahlungsabgleich","Archivtabellen"));
|
||||
return result;
|
||||
}
|
||||
@GetMapping("/me") public Map<String,Object> me(Authentication authentication) {
|
||||
return userDetails(authentication);
|
||||
}
|
||||
static Map<String,Object> userDetails(Authentication authentication) {
|
||||
LegacyUser u=user(authentication);return Map.of("username",u.username(),"roles",List.of("VIEWER"),"headquartersId",u.headquartersId(),"userId",u.userId(),"capabilities",u.capabilities());
|
||||
return userDetails(user(authentication));
|
||||
}
|
||||
@GetMapping("/orders") public ResponseEntity<List<DeliveryOrder>> orders(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) {
|
||||
var result=legacy.orders(user(auth),q,status,customerId,courierId,from,to);
|
||||
static Map<String,Object> userDetails(LegacyUser u) {
|
||||
return Map.of("originalHeadquartersId",u.originalHeadquartersId(),"username",u.username(),"accountType",u.accountType(),"roles",List.of(u.transferAccount()?"TRANSFER":u.customerAccount()?"CUSTOMER":u.courierAccount()?"COURIER":u.warehouseAccount()?"WAREHOUSE":"VIEWER"),"headquartersId",u.headquartersId(),"userId",u.userId(),"capabilities",u.capabilities());
|
||||
}
|
||||
public ResponseEntity<List<DeliveryOrder>> orders(Authentication auth,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to,boolean personal,Long numberFrom,Long numberTo,Integer vehicleType,int offset,Integer listStatus) {return orders(auth,q,status,customerId,courierId,from,to,personal,numberFrom,numberTo,vehicleType,offset,listStatus,null);}
|
||||
@GetMapping("/orders") public ResponseEntity<List<DeliveryOrder>> orders(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,@RequestParam(defaultValue="false") boolean personal,@RequestParam(required=false) Long numberFrom,@RequestParam(required=false) Long numberTo,@RequestParam(required=false) Integer vehicleType,@RequestParam(defaultValue="0") int offset,@RequestParam(required=false) Integer listStatus,@RequestParam(required=false) String filters) {
|
||||
if(filters!=null&&!filters.isBlank()&&!personal)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Spezialfilter benötigen persönliche Listen.");
|
||||
if(listStatus!=null&&!personal)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Listenstatus benötigt persönliche Listenfilter.");
|
||||
var result=personal?legacy.personalOrders(user(auth),q,status,customerId,courierId,from,to,new LegacyReadService.ListQuery(numberFrom,numberTo,vehicleType,offset,listStatus,null,false,false,OrderListFilters.parse(filters))):legacy.orders(user(auth),q,status,customerId,courierId,from,to);
|
||||
return ResponseEntity.ok().header("X-Total-Count",Long.toString(result.total())).header("X-Result-Limit",Integer.toString(result.limit())).body(result.orders());
|
||||
}
|
||||
public LegacyReadService.OrderResult orderList(Authentication auth,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to,Long numberFrom,Long numberTo,Integer vehicleType,int offset,Integer listStatus,String sort,boolean descending) {return orderList(auth,q,status,customerId,courierId,from,to,numberFrom,numberTo,vehicleType,offset,listStatus,sort,descending,null);}
|
||||
@GetMapping("/orders/list") public LegacyReadService.OrderResult orderList(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,@RequestParam(required=false) Long numberFrom,@RequestParam(required=false) Long numberTo,@RequestParam(required=false) Integer vehicleType,@RequestParam(defaultValue="0") int offset,@RequestParam(required=false) Integer listStatus,@RequestParam(required=false) String sort,@RequestParam(defaultValue="false") boolean descending,@RequestParam(required=false) String filters) {
|
||||
return legacy.personalOrders(user(auth),q,status,customerId,courierId,from,to,new LegacyReadService.ListQuery(numberFrom,numberTo,vehicleType,offset,listStatus,sort,descending,true,OrderListFilters.parse(filters)));
|
||||
}
|
||||
@GetMapping("/orders/{id}") public DeliveryOrder order(Authentication auth,@PathVariable long id) { return legacy.order(user(auth),id); }
|
||||
@GetMapping("/orders/{id}/history") public List<History> history(Authentication auth,@PathVariable long id) { return history.history(user(auth),id); }
|
||||
@GetMapping("/customers") public List<Customer> customers(Authentication auth,@RequestParam(required=false) String q) { return legacy.customers(user(auth),q); }
|
||||
@GetMapping("/customers") public List<Customer> customers(Authentication auth,@RequestParam(required=false) String q,@RequestParam(defaultValue="false") boolean showCostCenters,@RequestParam(required=false) String costCenterName) { return legacy.customers(user(auth),q,showCostCenters,costCenterName); }
|
||||
@GetMapping("/couriers") public List<Courier> couriers(Authentication auth,@RequestParam(required=false) String q) { return legacy.couriers(user(auth),q); }
|
||||
@GetMapping("/orders/export.csv") public ResponseEntity<byte[]> csv(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to) {
|
||||
LegacyUser u=user(auth);u.require(5);
|
||||
var result=legacy.orders(u,q,status,customerId,courierId,from,to);
|
||||
public ResponseEntity<byte[]> csv(Authentication auth,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to,boolean personal,Long numberFrom,Long numberTo,Integer vehicleType,Integer listStatus,boolean columns,String sort,boolean descending) {return csv(auth,q,status,customerId,courierId,from,to,personal,numberFrom,numberTo,vehicleType,listStatus,columns,sort,descending,null);}
|
||||
@GetMapping("/orders/export.csv") public ResponseEntity<byte[]> csv(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to,@RequestParam(defaultValue="false") boolean personal,@RequestParam(required=false) Long numberFrom,@RequestParam(required=false) Long numberTo,@RequestParam(required=false) Integer vehicleType,@RequestParam(required=false) Integer listStatus,@RequestParam(defaultValue="false") boolean columns,@RequestParam(required=false) String sort,@RequestParam(defaultValue="false") boolean descending,@RequestParam(required=false) String filters) {
|
||||
LegacyUser u=user(auth);u.require(5);if(filters!=null&&!filters.isBlank()&&!personal)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Spezialfilter benötigen persönliche Listen.");
|
||||
if(listStatus!=null&&!personal)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Listenstatus benötigt persönliche Listenfilter.");
|
||||
var result=personal?legacy.personalOrders(u,q,status,customerId,courierId,from,to,new LegacyReadService.ListQuery(numberFrom,numberTo,vehicleType,0,listStatus,sort,descending,columns,OrderListFilters.parse(filters))):legacy.orders(u,q,status,customerId,courierId,from,to);
|
||||
if(result.total()>result.limit())throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Der Export umfasst mehr als "+result.limit()+" Aufträge. Bitte den Zeitraum oder die Filter eingrenzen; ein unvollständiger Export wird nicht erstellt.");
|
||||
StringBuilder csv=new StringBuilder("\uFEFFAuftrag;Status;Kunde-ID;Kurier-ID;Abholung;Referenz;Netto;Kurierpreis;MwSt\n");
|
||||
StringBuilder csv;
|
||||
if(personal&&columns){
|
||||
csv=new StringBuilder("\uFEFF");
|
||||
csv.append(result.columns().stream().map(c->csvCell(c.label())).collect(java.util.stream.Collectors.joining(";"))).append('\n');
|
||||
for(var o:result.orders())csv.append(result.columns().stream().map(c->csvCell(OrderListCells.ACTIONS.contains(c.key())?"":o.listCells.get(c.key()))).collect(java.util.stream.Collectors.joining(";"))).append('\n');
|
||||
} else {
|
||||
csv=new StringBuilder("\uFEFFAuftrag;Status;Kunde-ID;Kurier-ID;Abholung;Referenz;Netto;Kurierpreis;MwSt\n");
|
||||
for(DeliveryOrder o:result.orders())csv.append(csvCell(o.number)).append(';').append(csvCell(o.legacyStatusLabel)).append(';').append(csvCell(o.customerId)).append(';').append(csvCell(o.courierId)).append(';').append(csvCell(o.pickupAt)).append(';').append(csvCell(o.reference)).append(';').append(csvCell(o.netPrice)).append(';').append(csvCell(o.courierPrice)).append(';').append(csvCell(o.vatRate)).append('\n');
|
||||
return ResponseEntity.ok().contentType(new MediaType("text","csv",StandardCharsets.UTF_8)).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=stadtbote-auftraege.csv").body(csv.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return ResponseEntity.ok().contentType(new MediaType("text","csv",java.util.Objects.requireNonNull(StandardCharsets.UTF_8))).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=stadtbote-auftraege.csv").body(csv.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
static String csvCell(Object value) { String s=value==null?"":value.toString();String trimmed=s.stripLeading();if(!trimmed.isEmpty()&&"=+-@".indexOf(trimmed.charAt(0))>=0)s="'"+s;return '"'+s.replace("\"","\"\"")+'"'; }
|
||||
@GetMapping("/invoices") public Object invoices(Authentication auth) { user(auth).require(5);throw unsupported(); }
|
||||
|
||||
@@ -25,6 +25,15 @@ public class ApiErrors {
|
||||
ResponseEntity<?> status(ResponseStatusException exception) { return ResponseEntity.status(exception.getStatusCode()).body(body(exception.getReason()==null?"Die Anfrage konnte nicht verarbeitet werden.":exception.getReason())); }
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
ResponseEntity<?> argument(MethodArgumentTypeMismatchException exception) { return ResponseEntity.badRequest().body(body("Ungültiger Parameter: "+exception.getName())); }
|
||||
@ExceptionHandler(org.springframework.web.bind.MethodArgumentNotValidException.class)
|
||||
ResponseEntity<?> validation(org.springframework.web.bind.MethodArgumentNotValidException exception) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message","Bitte die Eingaben prüfen.","errors",exception.getBindingResult().getFieldErrors().stream()
|
||||
.map(error -> error.getField()+": "+error.getDefaultMessage()).distinct().toList()));
|
||||
}
|
||||
@ExceptionHandler(org.springframework.http.converter.HttpMessageNotReadableException.class)
|
||||
ResponseEntity<?> malformed(org.springframework.http.converter.HttpMessageNotReadableException exception) {
|
||||
return ResponseEntity.badRequest().body(body("Ungültige Eingaben. Bitte Feldwerte, Datum und Uhrzeit prüfen."));
|
||||
}
|
||||
@ExceptionHandler(Exception.class)
|
||||
ResponseEntity<?> unknown(Exception exception) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body("Die Anfrage konnte nicht verarbeitet werden. Bitte die Konfiguration des Legacy-Adapters prüfen.")); }
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
import static de.stadtbote.votian.AppointmentService.*;
|
||||
@RestController
|
||||
@RequestMapping("/api/appointments")
|
||||
public class AppointmentController {
|
||||
private final AppointmentService service;
|
||||
public AppointmentController(AppointmentService service) { this.service=service; }
|
||||
@GetMapping("/options") Map<String,Object> options(Authentication auth) { return service.options(user(auth)); }
|
||||
@GetMapping("/customers") List<Map<String,Object>> customers(Authentication auth,@RequestParam(defaultValue="") String q) { return service.customers(user(auth),q); }
|
||||
@GetMapping("/customers/{id}/context") Map<String,Object> customerContext(Authentication auth,@PathVariable long id) { return service.customerContext(user(auth),id); }
|
||||
@GetMapping("/{id}") Map<String,Object> get(Authentication auth,@PathVariable long id) { return service.get(user(auth),id); }
|
||||
@PostMapping("/query") Map<String,Object> list(Authentication auth,@RequestBody Query query) { return service.list(user(auth),query); }
|
||||
@PostMapping("/statistics") Map<String,Object> statistics(Authentication auth,@RequestBody Query query) { return service.statistics(user(auth),query); }
|
||||
@PostMapping Map<String,Object> create(Authentication auth,@RequestBody Save request) { return service.create(user(auth),request); }
|
||||
@PutMapping("/{id}") Map<String,Object> save(Authentication auth,@PathVariable long id,@RequestBody Save request) { return service.save(user(auth),id,request); }
|
||||
@PostMapping("/{id}/resubmit") Map<String,Object> resubmit(Authentication auth,@PathVariable long id,@RequestBody Resubmission request) { return service.resubmit(user(auth),id,request); }
|
||||
record Revision(String revision) {}
|
||||
@DeleteMapping("/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void remove(Authentication auth,@PathVariable long id,@RequestBody Revision request) { service.remove(user(auth),id,request.revision()); }
|
||||
@PostMapping("/{id}/confirm") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void confirm(Authentication auth,@PathVariable long id,@RequestBody Revision request) { service.confirm(user(auth),id,request.revision()); }
|
||||
@PostMapping("/{id}/finish") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void finish(Authentication auth,@PathVariable long id,@RequestBody Revision request) { service.finish(user(auth),id,request.revision()); }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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(){String host=sender.getHost();return host!=null&&!host.isBlank();}
|
||||
void send(String from,String replyTo,java.util.List<String> recipients,String subject,String body){var msg=new SimpleMailMessage();msg.setFrom(from);msg.setReplyTo(replyTo);msg.setBcc(recipients.toArray(String[]::new));msg.setSubject(subject);msg.setText(body);sender.send(msg);}
|
||||
void sendVcard(String from,java.util.List<String> recipients,String filename,byte[] content){
|
||||
try{var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(java.util.Objects.requireNonNull(from));helper.setReplyTo(java.util.Objects.requireNonNull(from));helper.setTo(java.util.Objects.requireNonNull(recipients.toArray(String[]::new)));helper.setSubject("VCard");helper.setText("Die ausgewählte Kontaktkarte finden Sie im Anhang.",false);helper.addAttachment(java.util.Objects.requireNonNull(filename),new org.springframework.core.io.ByteArrayResource(java.util.Objects.requireNonNull(content)),"text/vcard; charset=UTF-8");sender.send(mime);}
|
||||
catch(jakarta.mail.MessagingException e){throw new org.springframework.mail.MailPreparationException("Die vCard-Mail konnte nicht vorbereitet werden.",e);}
|
||||
}
|
||||
void sendPdf(String from,java.util.List<String> to,java.util.List<String> cc,java.util.List<String> bcc,String subject,String body,String filename,byte[] pdf){
|
||||
try{var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(java.util.Objects.requireNonNull(from));helper.setReplyTo(java.util.Objects.requireNonNull(from));helper.setTo(java.util.Objects.requireNonNull(to.toArray(String[]::new)));if(!cc.isEmpty())helper.setCc(java.util.Objects.requireNonNull(cc.toArray(String[]::new)));if(!bcc.isEmpty())helper.setBcc(java.util.Objects.requireNonNull(bcc.toArray(String[]::new)));helper.setSubject(java.util.Objects.requireNonNull(subject));helper.setText(java.util.Objects.requireNonNull(body),false);helper.addAttachment(java.util.Objects.requireNonNull(filename),new org.springframework.core.io.ByteArrayResource(java.util.Objects.requireNonNull(pdf)),"application/pdf");sender.send(mime);}
|
||||
catch(jakarta.mail.MessagingException e){throw new org.springframework.mail.MailPreparationException("Rollkartenmail konnte nicht vorbereitet werden.",e);}
|
||||
}
|
||||
void sendHtml(String from,String replyTo,String recipient,java.util.List<String> cc,java.util.List<String> bcc,String subject,String html){
|
||||
try {var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(java.util.Objects.requireNonNull(from));helper.setReplyTo(java.util.Objects.requireNonNull(replyTo));helper.setTo(java.util.Objects.requireNonNull(recipient));if(!cc.isEmpty())helper.setCc(java.util.Objects.requireNonNull(cc.toArray(String[]::new)));if(!bcc.isEmpty())helper.setBcc(java.util.Objects.requireNonNull(bcc.toArray(String[]::new)));helper.setSubject(java.util.Objects.requireNonNull(subject));
|
||||
var document=org.jsoup.Jsoup.parse(html);var embedded=new java.util.LinkedHashMap<String,String>();for(var img:document.select("img[src]")){String data=img.attr("src");if(data.matches("data:image/(png|jpeg|gif);base64,[A-Za-z0-9+/=]+")){String cid="newsletter-image-"+embedded.size();embedded.put(cid,data);img.attr("src","cid:"+cid);}}helper.setText(java.util.Objects.requireNonNull(document.text()),java.util.Objects.requireNonNull(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(java.util.Objects.requireNonNull(item.getKey()),new org.springframework.core.io.ByteArrayResource(java.util.Objects.requireNonNull(bytes)),java.util.Objects.requireNonNull(type));}sender.send(mime);
|
||||
}catch(jakarta.mail.MessagingException e){throw new org.springframework.mail.MailPreparationException("Newsletter konnte nicht vorbereitet werden.",e);}
|
||||
}
|
||||
void sendOrderHtml(String from,java.util.List<String> recipients,java.util.List<String> cc,java.util.List<String> bcc,String subject,String html){sendOrderHtml(from,recipients,cc,bcc,subject,html,"",null);}
|
||||
void sendOrderHtml(String from,java.util.List<String> recipients,java.util.List<String> cc,java.util.List<String> bcc,String subject,String html,String filename,byte[] pdf){
|
||||
try{var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(java.util.Objects.requireNonNull(from));helper.setReplyTo(java.util.Objects.requireNonNull(from));helper.setTo(java.util.Objects.requireNonNull(recipients.toArray(String[]::new)));if(!cc.isEmpty())helper.setCc(java.util.Objects.requireNonNull(cc.toArray(String[]::new)));if(!bcc.isEmpty())helper.setBcc(java.util.Objects.requireNonNull(bcc.toArray(String[]::new)));helper.setSubject(java.util.Objects.requireNonNull(subject));
|
||||
var document=org.jsoup.Jsoup.parse(html);var images=new java.util.LinkedHashMap<String,String>();for(var img:document.select("img[src]")){String data=img.attr("src");if(data.matches("data:image/(png|jpeg);base64,[A-Za-z0-9+/=]+")){String cid="order-evidence-"+images.size();images.put(cid,data);img.attr("src","cid:"+cid);}}
|
||||
helper.setText(java.util.Objects.requireNonNull(document.text()),java.util.Objects.requireNonNull(document.html()));for(var item:images.entrySet()){String data=item.getValue();helper.addInline(java.util.Objects.requireNonNull(item.getKey()),new org.springframework.core.io.ByteArrayResource(java.util.Objects.requireNonNull(java.util.Base64.getDecoder().decode(data.substring(data.indexOf(',')+1)))),java.util.Objects.requireNonNull(data.substring(5,data.indexOf(';'))));}
|
||||
if(pdf!=null)helper.addAttachment(java.util.Objects.requireNonNull(filename),new org.springframework.core.io.ByteArrayResource(pdf),"application/pdf");sender.send(mime);
|
||||
}catch(jakarta.mail.MessagingException e){throw new org.springframework.mail.MailPreparationException("Auftragsmail konnte nicht vorbereitet werden.",e);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController
|
||||
@RequestMapping("/api/appointments")
|
||||
class AppointmentNotificationController {
|
||||
private final AppointmentNotifications service;
|
||||
AppointmentNotificationController(AppointmentNotifications service){this.service=service;}
|
||||
@PostMapping("/{id}/notifications/preview") Map<String,Object> preview(Authentication a,@PathVariable long id,@RequestBody AppointmentNotifications.Draft d){return service.preview(user(a),id,d);}
|
||||
@PostMapping("/notifications/{token}/send") Map<String,Object> send(Authentication a,@PathVariable String token){return service.send(user(a),token);}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
@Service
|
||||
class AppointmentNotifications {
|
||||
enum Kind {NEW,CHANGED,CANCELLED}
|
||||
record Draft(Kind kind,boolean participants,String external,String salutation,String greeting){}
|
||||
private final DatabaseService db;private final AppointmentService appointments;private final AppointmentMailTransport transport;
|
||||
private final Map<String,Pending> pending=new LinkedHashMap<>();
|
||||
private static class Pending {long user,hq,appointment;String revision,from,reply,subject,body;List<String> recipients;Instant expires;Map<String,Object> result;boolean sending;}
|
||||
AppointmentNotifications(DatabaseService db,AppointmentService appointments,AppointmentMailTransport transport){this.db=db;this.appointments=appointments;this.transport=transport;}
|
||||
static String address(String raw){if(raw==null||raw.isBlank()||raw.length()>254||raw.contains("\r")||raw.contains("\n"))throw bad("Ungültige E-Mail-Adresse.");try{var a=new InternetAddress(raw.strip(),true);a.validate();if(a.getPersonal()!=null||!a.getAddress().contains("@"))throw bad("Bitte reine E-Mail-Adressen ohne Anzeigenamen eingeben.");return a.getAddress();}catch(jakarta.mail.internet.AddressException e){throw bad("Ungültige E-Mail-Adresse.");}}
|
||||
Map<String,Object> preview(LegacyUser u,long appointment,Draft d){var row=appointments.appointment(u,appointment);AppointmentService.editable(u,row);if(d==null||d.kind()==null)throw bad("Benachrichtigungsart fehlt.");if(d.kind()==Kind.CANCELLED&&id(row,"usr_id")!=u.userId())u.require(3);if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert. SMTP_HOST in der .env ergänzen.");
|
||||
String external=d.external()==null?"":d.external().strip();if(external.length()>2000)throw bad("Zu viele externe Adressen.");var recipients=new TreeMap<String,String>();var missing=new ArrayList<String>();String actor=address(text(one(db.queryForList("SELECT usr_email FROM user WHERE usr_id=? AND hq_id=?",u.userId(),u.headquartersId())),"usr_email"));
|
||||
if(d.participants()){recipients.put(actor.toLowerCase(Locale.ROOT),actor);var ids=GroupAdministration.memberships(text(row,"ap_participants"));if(!ids.isEmpty()){var args=new ArrayList<Object>(ids);args.add(u.headquartersId());var found=new HashSet<Long>();for(var person:db.queryForList("SELECT DISTINCT u.usr_id,u.usr_email,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM user u JOIN mandatorheadquarters m ON m.hq_id=u.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE u.usr_id IN ("+CrmScope.marks(ids.size())+") AND own.hq_id=? AND own.md_id>0",args.toArray())){found.add(id(person,"usr_id"));try{String a=address(text(person,"usr_email"));recipients.put(a.toLowerCase(Locale.ROOT),a);}catch(org.springframework.web.server.ResponseStatusException e){missing.add(text(person,"label"));}}for(long id:ids)if(!found.contains(id))missing.add("Benutzer "+id+" (nicht verfügbar)");}}
|
||||
if(!external.isEmpty())for(String part:external.split("[,;]",-1)){String a=address(part);recipients.put(a.toLowerCase(Locale.ROOT),a);}if(recipients.isEmpty()||recipients.size()>100)throw bad("Bitte 1 bis 100 Empfänger auswählen.");
|
||||
String salutation=d.salutation()==null?"":d.salutation().strip(),greeting=d.greeting()==null?"":d.greeting().strip();if(salutation.length()>1000||greeting.length()>1000)throw bad("Anrede oder Grußformel ist zu lang.");String label=switch(d.kind()){case NEW->"Neuer Termin";case CHANGED->"Termin geändert";case CANCELLED->"Termin abgesagt";};if(dateTime(row,"ap_execdate")==null)throw bad("Der gespeicherte Termin hat keinen gültigen Beginn.");String start=dateTime(row,"ap_execdate").format(DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm"));var p=new Pending();p.user=u.userId();p.hq=u.headquartersId();p.appointment=appointment;p.revision=revision(AppointmentService.version(row));p.from=transport.from.isBlank()?actor:address(transport.from);p.reply=actor;p.subject=label+": "+start;p.body=salutation+"\n\n"+label+"\nBeginn: "+start+"\nEnde: "+(dateTime(row,"ap_enddate")==null?"offen":dateTime(row,"ap_enddate").format(DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm")))+"\n\n"+text(row,"ap_text")+"\n\n"+greeting;p.recipients=List.copyOf(recipients.values());p.expires=Instant.now().plusSeconds(900);String token=UUID.randomUUID().toString();synchronized(pending){pending.values().removeIf(v->v.expires.isBefore(Instant.now())&&!v.sending);if(pending.size()>=1000)throw bad("Zu viele Versandvorschauen. Bitte später versuchen.");pending.put(token,p);}return fields("token",token,"from",p.from,"replyTo",p.reply,"recipients",p.recipients,"missing",missing,"subject",p.subject,"body",p.body);}
|
||||
Map<String,Object> send(LegacyUser u,String token){u.require(0);u.require(11);Pending p;synchronized(pending){p=pending.get(token);if(p==null||p.user!=u.userId()||p.hq!=u.headquartersId()||p.expires.isBefore(Instant.now()))throw bad("Versandvorschau abgelaufen. Bitte neu erstellen.");if(p.result!=null)return p.result;if(p.sending)throw bad("Versand läuft bereits. Bitte warten.");var row=appointments.appointment(u,p.appointment);AppointmentService.editable(u,row);unchanged(AppointmentService.version(row),p.revision);p.sending=true;}
|
||||
Map<String,Object> result;try{transport.send(p.from,p.reply,p.recipients,p.subject,p.body);result=fields("sent",true,"message","E-Mail vom SMTP-Server angenommen. Der Termin bleibt gespeichert.");}catch(org.springframework.mail.MailException e){result=fields("sent",false,"message","Der Versand konnte nicht vollständig bestätigt werden. Der Termin bleibt gespeichert. Bitte Zustellung prüfen, bevor Sie eine neue Nachricht senden.");}synchronized(pending){p.result=result;p.sending=false;}return result;}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.*;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Port of groupware/appointment.php. Notifications use the separate preview/send workflow. */
|
||||
@Service
|
||||
public class AppointmentService {
|
||||
static final String TABLE="`phoenix_group`.appointment";
|
||||
static final List<String> COLUMNS=List.of("ap_id","hq_id","usr_id","ap_type","ap_category_1","ap_category_2","ap_category_3","ap_category_4","ap_participants","ap_text","ap_execdate","ap_enddate","ap_confirmed","ap_modifytime","cs_id","csc_id","pt_id");
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
public AppointmentService(DatabaseService db,OrderEntryCatalog catalog) { this.db=db;this.catalog=catalog; }
|
||||
enum Visibility { OWN_PRIVATE,OWN_PARTICIPANTS,OWN_PUBLIC,INVITED,OTHER_PUBLIC }
|
||||
record Query(LocalDate from,LocalDate to,List<Long> headquarters,Long personId,Long customerId,Integer status,Integer kind,Integer monitoring,List<Visibility> visibility,String search) {}
|
||||
record Save(String revision,LocalDateTime startsAt,LocalDateTime endsAt,String text,int status,int visibility,int kind,int monitoring,Long customerId,List<Long> participants) {}
|
||||
record Resubmission(String revision,LocalDateTime startsAt,LocalDateTime endsAt) {}
|
||||
record Sql(String where,Object[] args) {}
|
||||
private void access(LegacyUser user) { user.require(0);user.require(11); }
|
||||
private static String marks(int size) { return String.join(",",Collections.nCopies(size,"?")); }
|
||||
private static String participant(long userId) { return "%,"+userId+",%"; }
|
||||
private List<Map<String,Object>> headquarters(LegacyUser user) {
|
||||
return db.queryForList("SELECT DISTINCT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE own.hq_id=? AND own.md_id>0"+(user.hasRight(10)?"":" AND h.hq_id=?")+" ORDER BY h.hq_mnemonic",user.hasRight(10)?new Object[]{user.headquartersId()}:new Object[]{user.headquartersId(),user.headquartersId()});
|
||||
}
|
||||
private List<Long> scope(LegacyUser user,List<Long> requested) {
|
||||
if(requested==null||requested.isEmpty()||requested.size()>100||requested.stream().anyMatch(Objects::isNull))throw bad("Bitte mindestens eine Niederlassung wählen.");
|
||||
var ids=requested.stream().distinct().sorted().toList();
|
||||
if(ids.size()==1&&ids.getFirst()==user.headquartersId())return ids;
|
||||
user.require(10);
|
||||
var allowed=headquarters(user).stream().map(r->id(r,"id")).toList();
|
||||
if(!allowed.containsAll(ids))throw new AccessDeniedException("Niederlassung ist für diesen Mandanten nicht freigegeben.");
|
||||
return ids;
|
||||
}
|
||||
private List<Long> allowedHeadquarters(LegacyUser user) {
|
||||
var ids=headquarters(user).stream().map(r->id(r,"id")).toList();
|
||||
if(ids.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben.");return ids;
|
||||
}
|
||||
private List<Map<String,Object>> employees(List<Long> hqs) {
|
||||
var rows=db.queryForList("SELECT u.usr_id AS id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label,u.hq_id AS headquartersId,h.hq_mnemonic AS headquarters,e.emp_group AS groups FROM user u JOIN employee e ON e.usr_id=u.usr_id JOIN headquarters h ON h.hq_id=u.hq_id WHERE u.usr_type=1 AND u.hq_id IN ("+marks(hqs.size())+") ORDER BY u.usr_name,u.usr_firstname,u.usr_id LIMIT 5001",hqs.toArray());
|
||||
if(rows.size()>5000)throw bad("Zu viele Mitarbeiter. Bitte die Niederlassungsauswahl eingrenzen.");
|
||||
return rows;
|
||||
}
|
||||
private Map<String,List<Map<String,Object>>> categories() {
|
||||
var result=new LinkedHashMap<String,List<Map<String,Object>>>();
|
||||
var labels=new HashMap<String,String>();
|
||||
for(var p:db.queryForList("SELECT par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id=0 AND par_key LIKE 'MASK_AP_CAT_%'"))labels.put(text(p,"par_key"),text(p,"par_value"));
|
||||
for(int category=1;category<=4;category++) {
|
||||
var options=new ArrayList<Map<String,Object>>();
|
||||
for(var r:db.queryForList("SELECT mt_sort AS id,MIN(mt_value) AS label FROM metatype WHERE mt_type=? GROUP BY mt_sort ORDER BY mt_sort","appointment_category_"+category)) {
|
||||
long id=id(r,"id");String override=labels.get("MASK_AP_CAT_"+category+"_"+id);
|
||||
options.add(fields("id",id,"label",override==null||override.isBlank()?text(r,"label"):override));
|
||||
}
|
||||
if(category==4&&options.stream().noneMatch(o->id(o,"id")==2))options.add(fields("id",2L,"label",labels.getOrDefault("MASK_AP_CAT_4_9","Erledigt")));
|
||||
result.put("category"+category,options);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
public Map<String,Object> options(LegacyUser user) {
|
||||
access(user);var hqs=headquarters(user);if(hqs.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben.");
|
||||
var people=employees(hqs.stream().map(r->id(r,"id")).toList());
|
||||
var groups=db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? ORDER BY grp_name",mandator(db,user));
|
||||
var groupViews=new ArrayList<Map<String,Object>>();
|
||||
for(var g:groups) {
|
||||
var members=people.stream().filter(p->GroupAdministration.memberships(text(p,"groups")).contains(id(g,"id"))).map(p->id(p,"id")).toList();
|
||||
if(!members.isEmpty())groupViews.add(fields("id",id(g,"id"),"label",text(g,"label"),"members",members));
|
||||
}
|
||||
return fields("headquarters",hqs,"currentHeadquarters",user.headquartersId(),"currentUser",user.userId(),"now",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0),"categories",categories(),"people",people,"groups",groupViews,
|
||||
"defaultMonitoring",catalog.enabled(0,"MASK_AP_CATEGORY_4_PRESET")?1:0,"miscType",catalog.parameter(0,"MASK_AP_CATEGORY_3_MISC_ITEM"),"canReadReports",user.hasRight(16),"canStatistics",user.hasRight(8),"showCustomerReports",catalog.enabled(0,"MASK_AP_BTN_CS_REPORT"),"canCreateCustomer",user.hasRight(0)&&catalog.enabled(0,"MASK_AP_BTN_CS_NEW"));
|
||||
}
|
||||
public List<Map<String,Object>> customers(LegacyUser user,String q) {
|
||||
access(user);if(q==null||q.length()>100)throw bad("Suchtext zu lang.");var hqs=allowedHeadquarters(user);
|
||||
var args=new ArrayList<Object>(hqs);args.add("%"+q.strip()+"%");
|
||||
return db.queryForList("SELECT c.cs_id AS id,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer c LEFT JOIN company co ON co.cmp_id=c.cmp_id WHERE c.hq_id IN ("+marks(hqs.size())+") AND CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) LIKE ? ORDER BY label,id LIMIT 50",args.toArray());
|
||||
}
|
||||
public Map<String,Object> customerContext(LegacyUser user,long customerId) {
|
||||
access(user);return db.readTransaction(()->{
|
||||
var hqs=allowedHeadquarters(user);var args=new ArrayList<Object>(List.of(customerId));args.addAll(hqs);
|
||||
var row=one(db.queryForList("SELECT c.cs_id AS id,c.cs_eid AS number,c.hq_id AS headquartersId,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.cs_id=? AND c.hq_id IN ("+marks(hqs.size())+")",args.toArray()));
|
||||
boolean own=id(row,"headquartersId")==user.headquartersId();
|
||||
return fields("id",id(row,"id"),"number",text(row,"number"),"headquartersId",id(row,"headquartersId"),"label",text(row,"label"),"canOpenMaster",own,"canStatistics",user.hasRight(8),"masterUnavailableReason",own?"":"Der Kundenstamm wird in der Niederlassung des Kunden gepflegt.");
|
||||
});
|
||||
}
|
||||
private void customer(LegacyUser user,long customerId) {
|
||||
var hqs=allowedHeadquarters(user);var args=new ArrayList<Object>(List.of(customerId));args.addAll(hqs);
|
||||
one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id IN ("+marks(hqs.size())+")",args.toArray()));
|
||||
}
|
||||
static void validate(Query q,boolean statistics) {
|
||||
if(q==null)throw bad("Terminfilter fehlen.");range(q.from(),q.to());
|
||||
if(q.from().getYear()<1000||q.to().getYear()>9998||!statistics&&ChronoUnit.DAYS.between(q.from(),q.to())>365)throw bad("Kalenderabfragen dürfen höchstens ein Jahr umfassen.");
|
||||
if(q.visibility()==null||q.visibility().isEmpty()||q.visibility().stream().anyMatch(Objects::isNull)||q.visibility().size()>5)throw bad("Bitte mindestens eine Sichtbarkeitsgruppe wählen.");
|
||||
if(q.search()==null||q.search().length()>200)throw bad("Suchtext darf höchstens 200 Zeichen enthalten.");
|
||||
for(Long id:Arrays.asList(q.personId(),q.customerId()))if(id!=null&&id<=0)throw bad("Ungültiger Personen-/Kundenfilter.");
|
||||
for(Integer code:Arrays.asList(q.status(),q.kind(),q.monitoring()))if(code!=null&&(code<0||code>127))throw bad("Ungültiger Kategoriefilter.");
|
||||
}
|
||||
/** Visibility is authorization, not merely a UI search option. No master bypass for private appointments. */
|
||||
private static String visible(LegacyUser user,List<Object> args) {
|
||||
args.add(user.userId());args.add(participant(user.userId()));
|
||||
return "(a.usr_id=? OR a.ap_category_2=3 OR (a.ap_category_2=1 AND a.ap_participants LIKE ?))";
|
||||
}
|
||||
private Sql selection(LegacyUser user,Query q,boolean statistics) {
|
||||
validate(q,statistics);var hqs=scope(user,q.headquarters());
|
||||
var args=new ArrayList<Object>(hqs);
|
||||
String where="a.hq_id IN ("+marks(hqs.size())+") AND "+visible(user,args)+" AND a.ap_execdate<? AND (a.ap_execdate>=? OR a.ap_enddate>=?)";
|
||||
args.add(q.to().plusDays(1).atStartOfDay());args.add(q.from().atStartOfDay());args.add(q.from().atStartOfDay());
|
||||
if(q.personId()!=null){where+=" AND (a.usr_id=? OR a.ap_participants LIKE ?)";args.add(q.personId());args.add(participant(q.personId()));}
|
||||
if(q.customerId()!=null){customer(user,q.customerId());where+=" AND a.cs_id=?";args.add(q.customerId());}
|
||||
Integer[] codes={q.status(),q.kind(),q.monitoring()};int[] categories={1,3,4};
|
||||
for(int i=0;i<codes.length;i++)if(codes[i]!=null){where+=" AND a.ap_category_"+categories[i]+"=?";args.add(codes[i]);}
|
||||
if(!q.search().isBlank()){where+=" AND a.ap_text LIKE ?";args.add("%"+q.search().strip()+"%");}
|
||||
if(!new HashSet<>(q.visibility()).containsAll(List.of(Visibility.values()))) {
|
||||
var clauses=new ArrayList<String>();
|
||||
for(var v:new LinkedHashSet<>(q.visibility()))switch(v) {
|
||||
case OWN_PRIVATE,OWN_PARTICIPANTS,OWN_PUBLIC -> { clauses.add("(a.usr_id=? AND a.ap_category_2=?)");args.add(user.userId());args.add(v==Visibility.OWN_PRIVATE?2:v==Visibility.OWN_PARTICIPANTS?1:3); }
|
||||
case INVITED -> { clauses.add("(a.usr_id<>? AND a.ap_participants LIKE ?)");args.add(user.userId());args.add(participant(user.userId())); }
|
||||
case OTHER_PUBLIC -> { clauses.add("(a.usr_id<>? AND a.ap_category_2=3 AND (a.ap_participants IS NULL OR a.ap_participants NOT LIKE ?))");args.add(user.userId());args.add(participant(user.userId())); }
|
||||
}
|
||||
where+=" AND ("+String.join(" OR ",clauses)+")";
|
||||
}
|
||||
return new Sql(where,args.toArray());
|
||||
}
|
||||
private static String selectColumns() { return String.join(",",COLUMNS.stream().map(c->"a."+c).toList()); }
|
||||
private static String joins() { return " FROM "+TABLE+" a LEFT JOIN user u ON u.usr_id=a.usr_id LEFT JOIN headquarters h ON h.hq_id=a.hq_id LEFT JOIN customer c ON c.cs_id=a.cs_id LEFT JOIN company co ON co.cmp_id=c.cmp_id"; }
|
||||
public Map<String,Object> list(LegacyUser user,Query query) {
|
||||
access(user);return db.readTransaction(()->{
|
||||
var sql=selection(user,query,false);
|
||||
var rows=db.queryForList("SELECT "+selectColumns()+",CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS author,h.hq_mnemonic AS headquarters,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS customer_label"+joins()+" WHERE "+sql.where()+" ORDER BY a.ap_execdate,a.ap_id LIMIT 5001",sql.args());
|
||||
if(rows.size()>5000)throw bad("Mehr als 5000 Termine. Bitte Zeitraum oder Filter eingrenzen.");
|
||||
var names=participantNames(user,rows);
|
||||
return fields("items",rows.stream().map(r->view(user,r,names)).toList(),"total",rows.size(),"from",query.from(),"to",query.to());
|
||||
});
|
||||
}
|
||||
public Map<String,Object> get(LegacyUser user,long appointmentId) {
|
||||
access(user);return db.readTransaction(()->{
|
||||
var hqs=allowedHeadquarters(user);var args=new ArrayList<Object>(List.of(appointmentId));args.addAll(hqs);
|
||||
String where="a.ap_id=? AND a.hq_id IN ("+marks(hqs.size())+") AND "+visible(user,args);
|
||||
var row=one(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 "+where,args.toArray()));
|
||||
return view(user,row,participantNames(user,List.of(row)));
|
||||
});
|
||||
}
|
||||
private Map<Long,String> participantNames(LegacyUser user,List<Map<String,Object>> rows) {
|
||||
var ids=new LinkedHashSet<Long>();for(var row:rows)ids.addAll(GroupAdministration.memberships(text(row,"ap_participants")));
|
||||
var names=new HashMap<Long,String>();if(ids.isEmpty())return names;
|
||||
if(ids.size()>10000)throw bad("Zu viele Teilnehmer. Bitte den Zeitraum eingrenzen.");
|
||||
var all=new ArrayList<>(ids);
|
||||
for(int i=0;i<all.size();i+=500) {
|
||||
var batch=all.subList(i,Math.min(i+500,all.size()));var args=new ArrayList<Object>(batch);args.add(user.headquartersId());
|
||||
for(var r:db.queryForList("SELECT DISTINCT u.usr_id AS id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM user u JOIN mandatorheadquarters m ON m.hq_id=u.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE u.usr_id IN ("+marks(batch.size())+") AND own.hq_id=? AND own.md_id>0",args.toArray()))names.put(id(r,"id"),text(r,"label"));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
static Map<String,Object> version(Map<String,Object> row) { var result=new LinkedHashMap<String,Object>();for(String key:COLUMNS)result.put(key,value(row,key));return result; }
|
||||
private static boolean canEdit(LegacyUser user,Map<String,Object> row) { return id(row,"usr_id")==user.userId()||user.hasRight(3)||GroupAdministration.memberships(text(row,"ap_participants")).contains(user.userId()); }
|
||||
private Map<String,Object> view(LegacyUser user,Map<String,Object> row,Map<Long,String> names) {
|
||||
var people=GroupAdministration.memberships(text(row,"ap_participants"));var confirmed=GroupAdministration.memberships(text(row,"ap_confirmed"));
|
||||
return fields("id",id(row,"ap_id"),"headquartersId",id(row,"hq_id"),"headquarters",text(row,"headquarters"),"authorId",id(row,"usr_id"),"author",text(row,"author"),"text",text(row,"ap_text"),"startsAt",dateTime(row,"ap_execdate"),"endsAt",dateTime(row,"ap_enddate"),
|
||||
"status",id(row,"ap_category_1"),"visibility",id(row,"ap_category_2"),"kind",id(row,"ap_category_3"),"monitoring",id(row,"ap_category_4"),"customerId",positiveId(row,"cs_id"),"customerLabel",text(row,"customer_label"),
|
||||
"participants",people.stream().map(id->fields("id",id,"label",names.getOrDefault(id,"Benutzer "+id),"confirmed",confirmed.contains(id))).toList(),"revision",revision(version(row)),"canEdit",canEdit(user,row),"canDelete",id(row,"usr_id")==user.userId()||user.hasRight(3),"canConfirm",people.contains(user.userId())&&!confirmed.contains(user.userId()),"canFinish",canEdit(user,row)&&id(row,"ap_category_4")==1,
|
||||
"hasLegacyRelation",id(row,"csc_id")>0||id(row,"pt_id")>0);
|
||||
}
|
||||
public Map<String,Object> statistics(LegacyUser user,Query query) {
|
||||
access(user);return db.readTransaction(()->{
|
||||
var sql=selection(user,query,true);
|
||||
var rows=db.queryForList("SELECT a.ap_category_1,a.ap_category_2,a.ap_category_3,a.ap_category_4,COUNT(*) AS total FROM "+TABLE+" a WHERE "+sql.where()+" GROUP BY a.ap_category_1,a.ap_category_2,a.ap_category_3,a.ap_category_4 LIMIT 5001",sql.args());
|
||||
if(rows.size()>5000)throw bad("Zu viele Statistikgruppen. Bitte Filter eingrenzen.");
|
||||
var metadata=categories();var categories=new ArrayList<Map<String,Object>>();
|
||||
for(int n=1;n<=4;n++) {
|
||||
var counts=new TreeMap<Long,Long>();for(var row:rows)counts.merge(id(row,"ap_category_"+n),id(row,"total"),(entryValue, otherValue) -> Long.sum(entryValue, otherValue));
|
||||
var labels=new HashMap<Long,String>();for(var option:metadata.get("category"+n)){long id=id(option,"id");labels.put(id,text(option,"label"));counts.putIfAbsent(id,0L);}
|
||||
categories.add(fields("category",n,"items",counts.entrySet().stream().map(e->fields("id",e.getKey(),"label",labels.getOrDefault(e.getKey(),"Unbekannt ("+e.getKey()+")"),"total",e.getValue())).toList()));
|
||||
}
|
||||
return fields("total",rows.stream().mapToLong(r->id(r,"total")).sum(),"categories",categories,"from",query.from(),"to",query.to());
|
||||
});
|
||||
}
|
||||
Map<String,Object> appointment(LegacyUser user,long appointmentId) {
|
||||
access(user);var hqs=allowedHeadquarters(user);var args=new ArrayList<Object>(List.of(appointmentId));args.addAll(hqs);
|
||||
String where="a.ap_id=? AND a.hq_id IN ("+marks(hqs.size())+") AND "+visible(user,args);
|
||||
return one(db.queryForList("SELECT "+selectColumns()+" FROM "+TABLE+" a WHERE "+where,args.toArray()));
|
||||
}
|
||||
static void editable(LegacyUser user,Map<String,Object> old) { if(!canEdit(user,old))throw new AccessDeniedException("Nur Ersteller, Teilnehmer oder Mitarbeiterverwaltung dürfen diesen Termin ändern."); }
|
||||
static void interval(LocalDateTime starts,LocalDateTime ends) {
|
||||
if(starts==null||ends==null||starts.getYear()<1000||ends.getYear()>9998||ends.isBefore(starts)||starts.plusYears(10).isBefore(ends))throw bad("Bitte einen gültigen Beginn und ein Ende ab Beginn angeben (höchstens zehn Jahre).");
|
||||
if(starts.getSecond()!=0||starts.getNano()!=0||ends.getSecond()!=0||ends.getNano()!=0)throw bad("Termine bitte minutengenau eingeben.");
|
||||
}
|
||||
private Map<String,Object> values(LegacyUser user,Save request,Map<String,Object> old) {
|
||||
if(request==null)throw bad("Termin fehlt.");interval(request.startsAt(),request.endsAt());
|
||||
String text=required(request.text(),65535,"Termintext");if(text.getBytes(StandardCharsets.UTF_8).length>65535)throw bad("Termintext ist zu lang (65535 UTF-8-Bytes).");
|
||||
int[] values={request.status(),request.visibility(),request.kind(),request.monitoring()};var metadata=categories();
|
||||
for(int i=0;i<values.length;i++){int code=values[i];if(code<0||code>127||metadata.get("category"+(i+1)).stream().noneMatch(v->id(v,"id")==code))throw bad("Unbekannte Terminkategorie.");}
|
||||
if(!Set.of(1,2).contains(request.status())||!Set.of(1,2,3).contains(request.visibility())||!Set.of(0,1,2).contains(request.monitoring()))throw bad("Ungültiger Status, Sichtbarkeit oder Kontrolle.");
|
||||
if(request.customerId()!=null) {
|
||||
if(request.customerId()<=0)throw bad("Ungültige Kundenzuordnung.");
|
||||
if(old==null||request.customerId()!=id(old,"cs_id"))customer(user,request.customerId());
|
||||
}
|
||||
if(old!=null&&(id(old,"csc_id")>0||id(old,"pt_id")>0)&&!Objects.equals(request.customerId(),positiveId(old,"cs_id")))throw bad("Die vorhandene Kostenstellen-/Kontaktverknüpfung verhindert eine andere Kundenzuordnung.");
|
||||
if(request.participants()==null||request.participants().size()>500||request.participants().stream().anyMatch(id->id==null||id<=0))throw bad("Bitte höchstens 500 gültige Teilnehmer auswählen.");
|
||||
var people=new TreeSet<>(request.participants());var allowed=new HashSet<Long>();for(var p:employees(allowedHeadquarters(user)))allowed.add(id(p,"id"));
|
||||
if(old!=null)allowed.addAll(GroupAdministration.memberships(text(old,"ap_participants")));
|
||||
if(!allowed.containsAll(people))throw new AccessDeniedException("Ein Teilnehmer ist nicht für die freigegebenen Niederlassungen verfügbar.");
|
||||
var confirmed=old==null?new TreeSet<Long>():new TreeSet<>(GroupAdministration.memberships(text(old,"ap_confirmed")));
|
||||
if(old!=null&&(!request.startsAt().equals(dateTime(old,"ap_execdate"))||!request.endsAt().equals(dateTime(old,"ap_enddate"))))confirmed.clear();
|
||||
confirmed.retainAll(people);
|
||||
return fields("ap_text",text,"ap_execdate",request.startsAt(),"ap_enddate",request.endsAt(),"ap_category_1",request.status(),"ap_category_2",request.visibility(),"ap_category_3",request.kind(),"ap_category_4",request.monitoring(),"cs_id",request.customerId(),"ap_participants",ids(people),"ap_confirmed",ids(confirmed),"ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0));
|
||||
}
|
||||
private static Long positiveId(Map<String,Object> row,String key) { long value=id(row,key);return value>0?value:null; }
|
||||
static String ids(Collection<Long> ids) { return ids.isEmpty()?"":","+String.join(",",ids.stream().map(String::valueOf).toList())+","; }
|
||||
public Map<String,Object> create(LegacyUser user,Save request) {
|
||||
access(user);var fields=values(user,request,null);
|
||||
if(request.monitoring()==2)throw bad("Neue Termine bitte zunächst ohne Kontrolle oder mit aktiver Überwachung anlegen.");
|
||||
fields.putAll(fields("usr_id",user.userId(),"hq_id",user.headquartersId(),"ap_type",0,"csc_id",null,"pt_id",null));
|
||||
long id=db.writeTransaction(()->db.insert(TABLE,fields));return fields("id",id);
|
||||
}
|
||||
public Map<String,Object> save(LegacyUser user,long appointmentId,Save request) {
|
||||
access(user);return db.writeTransaction(()->{
|
||||
var old=appointment(user,appointmentId);editable(user,old);if(request==null)throw bad("Termin fehlt.");unchanged(version(old),request.revision());
|
||||
var values=values(user,request,old);
|
||||
if(id(old,"ap_category_4")!=2&&request.monitoring()==2) {
|
||||
// Check the resulting customer/status too; changing the assignment must not bypass the report rule.
|
||||
var completed=new LinkedHashMap<>(old);completed.putAll(values);requireReport(user,completed);
|
||||
}
|
||||
update(old,values);return fields("id",appointmentId);
|
||||
});
|
||||
}
|
||||
public void remove(LegacyUser user,long appointmentId,String revision) {
|
||||
access(user);db.writeTransaction(()->{
|
||||
var old=appointment(user,appointmentId);if(id(old,"usr_id")!=user.userId())user.require(3);unchanged(version(old),revision);
|
||||
changed(db.update("DELETE FROM "+TABLE+" WHERE "+cas(),casArgs(old).toArray()));return null;
|
||||
});
|
||||
}
|
||||
public Map<String,Object> resubmit(LegacyUser user,long appointmentId,Resubmission request) {
|
||||
access(user);if(request==null)throw bad("Wiedervorlage fehlt.");interval(request.startsAt(),request.endsAt());
|
||||
var old=appointment(user,appointmentId);unchanged(version(old),request.revision());
|
||||
// A resubmission creates a new appointment. Its source and source confirmations stay untouched.
|
||||
var values=new LinkedHashMap<>(version(old));values.remove("ap_id");values.put("usr_id",user.userId());values.put("hq_id",user.headquartersId());
|
||||
values.put("ap_execdate",request.startsAt());values.put("ap_enddate",request.endsAt());values.put("ap_confirmed","");values.put("ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0));
|
||||
if(id(old,"ap_category_4")==2)values.put("ap_category_4",1);
|
||||
long id=db.writeTransaction(()->db.insert(TABLE,values));return fields("id",id);
|
||||
}
|
||||
public void confirm(LegacyUser user,long appointmentId,String revision) {
|
||||
access(user);db.writeTransaction(()->{
|
||||
var old=appointment(user,appointmentId);
|
||||
if(!GroupAdministration.memberships(text(old,"ap_participants")).contains(user.userId()))throw new AccessDeniedException("Nur eigene Teilnahme kann bestätigt werden.");
|
||||
unchanged(version(old),revision);var confirmed=new TreeSet<>(GroupAdministration.memberships(text(old,"ap_confirmed")));confirmed.add(user.userId());
|
||||
update(old,fields("ap_confirmed",ids(confirmed)));return null;
|
||||
});
|
||||
}
|
||||
public void finish(LegacyUser user,long appointmentId,String revision) {
|
||||
access(user);db.writeTransaction(()->{
|
||||
var old=appointment(user,appointmentId);editable(user,old);unchanged(version(old),revision);
|
||||
if(id(old,"ap_category_4")!=1)throw bad("Nur ein überwachter Termin kann abgeschlossen werden.");requireReport(user,old);
|
||||
update(old,fields("ap_category_4",2,"ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0)));return null;
|
||||
});
|
||||
}
|
||||
private void requireReport(LegacyUser user,Map<String,Object> appointment) {
|
||||
long customerId=id(appointment,"cs_id");
|
||||
if(customerId>0&&id(appointment,"ap_category_1")==1&&catalog.enabled(0,"MASK_AP_WARNING_DISABLE_QUESTION_NO_REPORT_OF_SAME_DAY")) {
|
||||
var today=LocalDate.now(ZoneId.of("Europe/Berlin"));var args=new ArrayList<Object>(List.of(customerId,id(appointment,"hq_id"),today.atStartOfDay(),today.plusDays(1).atStartOfDay()));
|
||||
String sql="SELECT rp_id FROM `phoenix_group`.report_process WHERE rp_objtype='cs' AND rp_objid=? AND hq_id=? AND rp_createtime>=? AND rp_createtime<?";
|
||||
if(!user.hasRight(10)){sql+=" AND (rp_confidential=0 OR usr_id=?)";args.add(user.userId());}
|
||||
if(db.queryForList(sql+" LIMIT 1",args.toArray()).isEmpty())throw bad("Bitte zuerst einen Kundenbericht für den heutigen Tag anlegen.");
|
||||
}
|
||||
}
|
||||
private void update(Map<String,Object> old,Map<String,Object> values) {
|
||||
var args=new ArrayList<>(values.values());args.addAll(casArgs(old));
|
||||
changed(db.update("UPDATE "+TABLE+" SET "+String.join(",",values.keySet().stream().map(c->c+"=?").toList())+" WHERE "+cas(),args.toArray()));
|
||||
}
|
||||
// MyISAM does not support transactional row locks. Include all source values in each mutation.
|
||||
private static String cas() { return String.join(" AND ",COLUMNS.stream().map(c->Set.of("ap_text","ap_participants","ap_confirmed").contains(c)?"BINARY "+c+" <=> BINARY ?":c+" <=> ?").toList()); }
|
||||
private static List<Object> casArgs(Map<String,Object> old) { var args=new ArrayList<Object>();for(String c:COLUMNS)args.add(value(old,c));return args; }
|
||||
private static void changed(int rows) { if(rows!=1)throw new ResponseStatusException(HttpStatus.CONFLICT,"Der Termin wurde zwischenzeitlich geändert oder gelöscht. Bitte neu laden."); }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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.math.BigInteger;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Standalone PHP article_special/article_list workflows; no warehouse movement is implicit. */
|
||||
@RestController
|
||||
@RequestMapping("/api/administration/articles")
|
||||
class ArticleAdministration {
|
||||
final DatabaseService db;final BusinessSupport support;
|
||||
ArticleAdministration(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
|
||||
record Values(String eid,String name,String additional,String description,String barcode,boolean serial,boolean mountable,int packages,String packageCode,int stockItems,int stockPlaces,long group,boolean enabled,boolean visible){}
|
||||
record Command(String requestId,long id,String action,Values values,String childEid,int quantity,String revision,String planRevision){}
|
||||
record State(Map<String,Object> row,List<Map<String,Object>> links,List<Map<String,Object>> bundles,List<Map<String,Object>> stock,List<Map<String,Object>> serials,Map<String,Object> settings){}
|
||||
record Plan(Map<String,Object> before,Map<String,Object> after,List<Map<String,Object>> changes,List<Object> evidence,String action){}
|
||||
@GetMapping Map<String,Object> get(Authentication a,@RequestParam(required=false) Long headquarters,@RequestParam(defaultValue="") String eid,@RequestParam(defaultValue="") String name,@RequestParam(defaultValue="") String additional,@RequestParam(defaultValue="") String description,@RequestParam(defaultValue="") String barcode,@RequestParam(defaultValue="false") boolean contains,@RequestParam(defaultValue="false") boolean hidden,@RequestParam(defaultValue="0") int offset,@RequestParam(defaultValue="at_name") String sort){return list(ApiController.user(a),headquarters,List.of(eid,name,additional,description,barcode),contains,hidden,offset,sort);}
|
||||
@GetMapping("/options") Map<String,Object> options(Authentication a,@RequestParam(required=false) Long headquarters){return options(ApiController.user(a),headquarters);}
|
||||
@GetMapping("/{id}") Map<String,Object> detail(Authentication a,@PathVariable long id,@RequestParam(required=false) Long headquarters){return view(ApiController.user(a),headquarters,id);}
|
||||
@GetMapping("/{id}/history") Map<String,Object> history(Authentication a,@PathVariable long id,@RequestParam(required=false) Long headquarters,@RequestParam(defaultValue="0") int offset){return history(ApiController.user(a),headquarters,id,offset);}
|
||||
@PostMapping("/preview") Map<String,Object> preview(Authentication a,@RequestParam(required=false) Long headquarters,@RequestBody Command c){return preview(ApiController.user(a),headquarters,c);}
|
||||
@PostMapping("/save") Map<String,Object> save(Authentication a,@RequestParam(required=false) Long headquarters,@RequestBody Command c){return save(ApiController.user(a),headquarters,c);}
|
||||
LegacyUser scoped(LegacyUser u,Long target){if(u.warehouseAccount()){WarehousePortalScope.articles(db,u);if(target!=null&&target!=u.headquartersId())throw MasterDirectoryScope.denied();return u;}var access=MasterDirectoryScope.access(db,u,14);long hq=target==null?u.headquartersId():target;MasterDirectoryScope.select(u,access,List.of(hq));return new LegacyUser(u.userId(),u.employeeId(),hq,u.username(),u.rights(),u.originalHeadquartersId());}
|
||||
List<Map<String,Object>> branches(LegacyUser u){if(!u.warehouseAccount())return MasterDirectoryScope.access(db,u,14).branches();WarehousePortalScope.articles(db,u);return db.queryForList("SELECT hq_id AS id,hq_mnemonic AS label FROM headquarters WHERE hq_id=?",u.headquartersId());}
|
||||
Map<String,Object> settings(LegacyUser u){var result=new LinkedHashMap<String,Object>();for(String key:List.of("AT_EID_PREFIX","AT_EID_GENERATION","AT_EID_EDITABLE")){var rows=db.queryForList("SELECT par_value FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key=?",u.headquartersId(),key);if(rows.size()>1)throw bad("Mehrdeutige Artikelvorgabe: "+key);result.put(key,rows.isEmpty()?"":text(rows.getFirst(),"par_value"));}if(!Set.of("","0","1").contains(text(result,"AT_EID_EDITABLE")))throw bad("Ungültige Vorgabe zur Artikelnummer.");return result;}
|
||||
List<Map<String,Object>> groups(LegacyUser u){return db.queryForList("SELECT atg_id AS id,atg_key AS code,atg_name AS name,hq_id AS headquarters FROM articlegroup WHERE md_id=? AND hq_id IN (0,?) ORDER BY atg_key,atg_id",mandator(db,u),u.headquartersId());}
|
||||
String suggestion(Map<String,Object> settings){String bound=text(settings,"AT_EID_GENERATION"),prefix=text(settings,"AT_EID_PREFIX");if(bound.isBlank())return "";String max=db.queryForObject("SELECT MAX(at_eid) FROM article WHERE at_eid<?",String.class,bound);if(max==null||!max.startsWith(prefix))max=prefix+"100000";String digits=max.replaceAll("[^0-9]","");if(digits.isEmpty())throw bad("Die vorhandenen Artikelnummern erlauben keinen eindeutigen Nummernvorschlag.");String next=max.replaceAll("[^A-Za-z+]","")+new BigInteger(digits).add(BigInteger.ONE);if(next.length()>20)throw bad("Der Nummernvorschlag überschreitet 20 Zeichen.");return next;}
|
||||
Map<String,Object> options(LegacyUser user,Long hq){return db.readTransaction(()->{var u=scoped(user,hq);var s=settings(u);return fields("headquarters",u.headquartersId(),"branches",branches(user),"groups",groups(u),"settings",s,"suggestedEid",suggestion(s));});}
|
||||
Map<String,Object> list(LegacyUser user,Long hq,List<String> filters,boolean contains,boolean hidden,int offset){return list(user,hq,filters,contains,hidden,offset,"at_name");}
|
||||
Map<String,Object> list(LegacyUser user,Long hq,List<String> filters,boolean contains,boolean hidden,int offset,String sort){return db.readTransaction(()->{
|
||||
var u=scoped(user,hq);page(offset);if(filters.size()!=5)throw bad("Ungültige Artikelsuche.");
|
||||
if(!ArticleColumns.KEYS.contains(sort)||Set.of("at_id_history","at_id_edit").contains(sort))throw bad("Ungültige Artikelsortierung.");
|
||||
var layout=ArticleColumns.runtime(db,user);var args=new ArrayList<Object>();args.add(mandator(db,u));args.add(u.headquartersId());args.add(u.headquartersId());String where="a.hq_id=?"+(hidden?"":" AND a.at_visible=1");
|
||||
var columns=List.of("at_eid","at_name","at_match","at_description","at_barcode");for(int i=0;i<5;i++){String q=clean(filters.get(i),100,"Suchtext");if(!q.isEmpty()){where+=" AND a."+columns.get(i)+" LIKE ? ESCAPE '!'";args.add((contains?"%":"")+q.replace("!","!!").replace("%","!%").replace("_","!_")+"%");}}args.add(offset);
|
||||
String group="(SELECT GROUP_CONCAT(DISTINCT CONCAT(g.atg_key,' · ',g.atg_name) ORDER BY g.atg_key SEPARATOR ' / ') FROM articlegroupitem gi JOIN articlegroup g ON g.md_id=gi.md_id AND g.atg_id=gi.atg_id WHERE gi.at_id=a.at_id AND gi.md_id=? AND gi.hq_id IN (0,?) AND g.hq_id IN (0,a.hq_id))";
|
||||
var rows=db.queryForList("SELECT a.at_id AS id,a.at_eid AS eid,a.at_name AS name,a.at_match AS additional,a.at_description AS description,a.at_barcode AS barcode,a.at_serialno AS serial,a.at_mountable AS mountable,a.at_visible AS visible,a.at_authenticated AS enabled,DATE_FORMAT(a.at_createtime,'%Y-%m-%d %H:%i') AS created,"+group+" AS atg_data FROM article a WHERE "+where+" ORDER BY "+(sort.equals("atg_data")?"atg_data":"a."+sort)+",a.at_id LIMIT 51 OFFSET ?",args.toArray());
|
||||
return fields("rows",rows.stream().limit(50).toList(),"hasMore",rows.size()>50,"offset",offset,"headquarters",u.headquartersId(),"sort",sort,"columns",layout.columns(),"definitions",ArticleColumns.definitions(),"warning",layout.warning());
|
||||
});}
|
||||
State state(LegacyUser u,long article){var row=article==0?Map.<String,Object>of():one(db.queryForList("SELECT * FROM article WHERE at_id=? AND hq_id=?",article,u.headquartersId()));return new State(row,article==0?List.of():db.queryForList("SELECT * FROM articlegroupitem WHERE at_id=? AND md_id=? AND hq_id IN (0,?) ORDER BY hq_id,atg_id",article,mandator(db,u),u.headquartersId()),article==0?List.of():db.queryForList("SELECT * FROM articlebundle WHERE at_id=? OR at_id2=? ORDER BY at_id,at_id2",article,article),article==0?List.of():db.queryForList("SELECT * FROM stockarticle WHERE at_id=? ORDER BY stk_id",article),article==0?List.of():db.queryForList("SELECT ati_id FROM articleitem WHERE at_id=? ORDER BY ati_id",article),settings(u));}
|
||||
Values values(State s){var r=s.row();long group=s.links().isEmpty()?0:id(s.links().getFirst(),"atg_id");if(r.isEmpty())return new Values(text(s.settings(),"AT_EID_PREFIX"),"","","","",false,false,1,"",1,1,0,true,true);return new Values(text(r,"at_eid"),text(r,"at_name"),text(r,"at_match"),text(r,"at_description"),text(r,"at_barcode"),id(r,"at_serialno")==1,id(r,"at_mountable")==1,(int)id(r,"at_bundlequantity"),text(r,"at_bundlecode"),(int)id(r,"at_stk_itemquantity"),(int)id(r,"at_stk_areaquantity"),group,id(r,"at_authenticated")==1,id(r,"at_visible")==1);}
|
||||
Map<String,Object> view(LegacyUser user,Long hq,long article){return db.readTransaction(()->{var u=scoped(user,hq);if(article<0)throw bad("Ungültiger Artikel.");var s=state(u,article);var children=new ArrayList<Map<String,Object>>();for(var b:s.bundles())if(id(b,"at_id")==article){var child=db.queryForList("SELECT at_id,at_eid,at_name,hq_id FROM article WHERE at_id=?",id(b,"at_id2"));boolean allowed=child.size()==1&&branches(user).stream().anyMatch(h->id(h,"id")==id(child.getFirst(),"hq_id"));children.add(fields("id",id(b,"at_id2"),"eid",allowed?text(child.getFirst(),"at_eid"):"","name",allowed?text(child.getFirst(),"at_name"):"Nicht zugänglicher Bestandteil","quantity",id(b,"atb_bundlequantity")));}return fields("id",article,"headquarters",u.headquartersId(),"values",values(s),"revision",revision(s),"settings",s.settings(),"groups",groups(u),"bundle",children,"groupAmbiguous",s.links().size()>1,"groupShared",!s.links().isEmpty()&&id(s.links().getFirst(),"hq_id")==0);});}
|
||||
static String clean(String s,int limit,String label){s=s==null?"":s.strip();if(s.length()>limit||s.indexOf('\u0000')>=0)throw bad(label+": höchstens "+limit+" Zeichen.");return s;}
|
||||
Values validated(Values v){if(v==null)throw bad("Artikeldaten fehlen.");String description=v.description()==null?"":v.description();if(description.getBytes(StandardCharsets.UTF_8).length>65535||description.indexOf('\u0000')>=0)throw bad("Beschreibung überschreitet die Datenbankgrenze von 65535 Bytes.");if(v.packages()<0||v.packages()>32767||v.stockItems()<1||v.stockItems()>8388607||v.stockPlaces()<1||v.stockPlaces()>127||v.group()<0)throw bad("Ungültige Gebinde- oder Lagereinheiten (Lagerplätze 1–127).");return new Values(required(clean(v.eid(),20,"ExtID"),20,"ExtID"),required(clean(v.name(),50,"Bezeichnung"),50,"Bezeichnung"),clean(v.additional(),100,"Zusatz"),description,clean(v.barcode(),50,"Barcode"),v.serial(),v.mountable(),v.packages(),clean(v.packageCode(),50,"Gebindecode"),v.stockItems(),v.stockPlaces(),v.group(),v.enabled(),v.visible());}
|
||||
Map<String,Object> data(Values v){return fields("at_eid",v.eid(),"at_name",v.name(),"at_match",v.additional(),"at_description",v.description(),"at_barcode",v.barcode(),"at_serialno",v.serial()?1:0,"at_mountable",v.mountable()?1:0,"at_bundlequantity",v.packages(),"at_bundlecode",v.packageCode(),"at_stk_itemquantity",v.stockItems(),"at_stk_areaquantity",v.stockPlaces(),"at_authenticated",v.enabled()?1:0,"at_visible",v.visible()?1:0,"group",v.group());}
|
||||
Plan plan(LegacyUser user,LegacyUser u,Command c){if(c==null||c.id()<0)throw bad("Ungültiger Artikelvorgang.");BusinessSupport.token(c.requestId());var s=state(u,c.id());unchanged(s,c.revision());var before=c.id()==0?Map.<String,Object>of():data(values(s));var after=new LinkedHashMap<String,Object>(before);var evidence=new ArrayList<Object>();evidence.add(s);String action=c.action();
|
||||
if("SAVE".equals(action)){
|
||||
var v=validated(c.values());after=new LinkedHashMap<>(data(v));if(v.eid().equals(text(s.settings(),"AT_EID_PREFIX")))throw bad("Bitte eine vollständige Artikelnummer eingeben.");
|
||||
if(c.id()>0&&!text(s.settings(),"AT_EID_EDITABLE").equals("1")&&!v.eid().equals(text(s.row(),"at_eid")))throw bad("Die Artikelnummer darf laut Niederlassungsvorgabe nicht geändert werden.");
|
||||
if(!db.queryForList("SELECT at_id FROM article WHERE at_eid=? AND at_id<>?",v.eid(),c.id()).isEmpty())throw bad("Diese Artikelnummer ist bereits vergeben.");
|
||||
if(!v.barcode().isEmpty()&&!db.queryForList("SELECT at_id FROM article WHERE at_barcode=? AND at_id<>?",v.barcode(),c.id()).isEmpty())throw bad("Dieser Barcode ist bereits vergeben.");
|
||||
if(c.id()==0&&!db.queryForList("SELECT at_id FROM article WHERE at_name=?",v.name()).isEmpty())throw bad("Diese Artikelbezeichnung existiert bereits.");
|
||||
if(s.links().size()>1)throw bad("Die Warengruppenzuordnung ist mehrdeutig. Bitte den Bestand klären.");
|
||||
if(v.group()>0){var g=one(groups(u).stream().filter(x->id(x,"id")==v.group()).toList());evidence.add(g);if(!s.links().isEmpty()&&id(s.links().getFirst(),"hq_id")==0&&id(g,"headquarters")!=0)throw bad("Eine gemeinsame Zuordnung benötigt eine gemeinsame Warengruppe.");}
|
||||
if(v.group()==0&&!s.links().isEmpty()&&id(s.links().getFirst(),"hq_id")==0)throw bad("Die gemeinsame Warengruppenzuordnung kann in dieser Maske nicht entfernt werden.");
|
||||
if(c.id()>0&&v.serial()!=values(s).serial()){if(v.serial()&&(!s.bundles().isEmpty()||s.stock().stream().anyMatch(r->id(r,"stkat_itemquantity")>0)))throw bad("Seriennummern sind bei Lagerbestand oder Gebindezuordnung nicht einschaltbar.");if(!v.serial()&&!s.serials().isEmpty())throw bad("Es sind noch Seriennummern vorhanden.");}
|
||||
}else if(Set.of("BUNDLE_ADD","BUNDLE_REMOVE").contains(Objects.toString(action,""))){
|
||||
if(c.id()==0)throw bad("Bitte den Artikel zuerst anlegen.");String eid=required(c.childEid(),20,"Bestandteil-ExtID");var child=one(db.queryForList("SELECT * FROM article WHERE at_eid=?",eid));long childId=id(child,"at_id");if(branches(user).stream().noneMatch(h->id(h,"id")==id(child,"hq_id")))throw MasterDirectoryScope.denied();if(childId==c.id())throw bad("Ein Artikel kann nicht sein eigener Bestandteil sein.");evidence.add(child);
|
||||
var links=db.queryForList("SELECT * FROM articlebundle WHERE at_id=? AND at_id2=?",c.id(),childId);if(links.size()>1)throw bad("Mehrdeutiger Gebindebestandteil.");var descendants=descendants(childId);evidence.add(descendants);
|
||||
if(action.equals("BUNDLE_REMOVE")){if(links.size()!=1)throw bad("Dieser Bestandteil ist nicht mehr zugeordnet.");if(!descendants.isEmpty())throw bad("Ein Bestandteil, der selbst ein Gebinde ist, kann wie in PHP nicht entfernt werden.");}
|
||||
else {if(c.quantity()<1||c.quantity()>32767)throw bad("Die hinzuzufügende Menge muss zwischen 1 und 32767 liegen.");if(values(s).serial()||id(child,"at_serialno")==1)throw bad("Artikel mit Seriennummern können keinem Gebinde zugeordnet werden.");if(descendants.stream().anyMatch(b->id(b,"at_id2")==c.id()))throw bad("Die Zuordnung würde einen Gebindekreislauf erzeugen.");if(s.bundles().stream().noneMatch(b->id(b,"at_id")==c.id())&&s.stock().stream().anyMatch(r->id(r,"stkat_itemquantity")>0))throw bad("Ein Artikel mit Lagerbestand kann nicht in ein Gebinde umgewandelt werden.");}
|
||||
long old=links.isEmpty()?0:id(links.getFirst(),"atb_bundlequantity"),next=action.equals("BUNDLE_REMOVE")?0:old+c.quantity();if(next>32767||old<0)throw bad("Die Gesamtmenge überschreitet 32767 oder der Bestand ist ungültig.");before=fields("child",childId,"eid",eid,"quantity",old);after=new LinkedHashMap<>(fields("child",childId,"eid",eid,"quantity",next));
|
||||
}else throw bad("Unbekannte Artikelaktion.");
|
||||
var changes=new ArrayList<Map<String,Object>>();for(String k:after.keySet())if(!Objects.equals(before.get(k),after.get(k)))changes.add(fields("field",k,"before",before.get(k),"after",after.get(k)));if(changes.isEmpty())throw bad("Keine Änderungen vorhanden.");return new Plan(before,after,changes,evidence,action);
|
||||
}
|
||||
List<Map<String,Object>> descendants(long root){var rows=new ArrayList<Map<String,Object>>();var visited=new HashSet<Long>();var queue=new ArrayDeque<Long>();queue.add(root);while(!queue.isEmpty()){long next=queue.remove();if(!visited.add(next))continue;if(visited.size()>2000||rows.size()>10000)throw bad("Gebinde zu umfangreich für eine sichere Prüfung.");var children=db.queryForList("SELECT * FROM articlebundle WHERE at_id=? ORDER BY at_id2",next);rows.addAll(children);for(var r:children)queue.add(id(r,"at_id2"));}
|
||||
var indegree=new HashMap<Long,Integer>();indegree.put(root,0);var edges=new HashSet<String>();for(var r:rows){long a=id(r,"at_id"),b=id(r,"at_id2");if(b<=0||id(r,"atb_bundlequantity")<=0||!edges.add(a+":"+b))throw bad("Ungültige oder doppelte Gebindebestandteile im Bestand.");indegree.putIfAbsent(a,0);indegree.merge(b,1,(entryValue, otherValue) -> Integer.sum(entryValue, otherValue));}
|
||||
var open=new ArrayDeque<Long>();indegree.forEach((k,v)->{if(v==0)open.add(k);});int count=0;while(!open.isEmpty()){long n=open.remove();count++;for(var r:rows)if(id(r,"at_id")==n){long child=id(r,"at_id2");if(indegree.merge(child,-1,(entryValue, otherValue) -> Integer.sum(entryValue, otherValue))==0)open.add(child);}}if(count!=indegree.size())throw bad("Der Bestand enthält bereits einen Gebindekreislauf.");return rows;}
|
||||
Map<String,Object> preview(LegacyUser user,Long hq,Command c){return db.readTransaction(()->{var p=plan(user,scoped(user,hq),c);return fields("planRevision",revision(p),"changes",p.changes(),"action",p.action());});}
|
||||
Map<String,Object> save(LegacyUser user,Long hq,Command c){scoped(user,hq);if(c==null)throw bad("Artikelvorgang fehlt.");BusinessSupport.token(c.requestId());support.transactional("article","articlebundle","articlegroupitem","genericdatacontainer");return db.articleWriteTransaction(user.warehouseAccount(),()->{var u=scoped(user,hq);Object intent=List.of("ARTICLE",c);Long receipt=support.receipt(u,c.requestId(),intent);if(receipt!=null)return fields("id",receipt,"repeated",true);var p=plan(user,u,c);unchanged(p,c.planRevision());long article=c.id();
|
||||
if(p.action().equals("SAVE")){var v=validated(c.values());var values=new LinkedHashMap<>(p.after());values.remove("group");if(article==0){values.putAll(fields("hq_id",u.headquartersId(),"at_createtime",db.queryForObject("SELECT NOW()",java.sql.Timestamp.class),"atg_id",v.group()));article=db.insert("article",values);}else BusinessSupport.update(db,"article","at_id",article,values);
|
||||
var links=db.queryForList("SELECT * FROM articlegroupitem WHERE at_id=? AND md_id=? AND hq_id IN (0,?)",article,mandator(db,u),u.headquartersId());if(v.group()==0)db.update("DELETE FROM articlegroupitem WHERE at_id=? AND md_id=? AND hq_id=?",article,mandator(db,u),u.headquartersId());else if(links.isEmpty())BusinessSupport.insertWithoutKey(db,"articlegroupitem",fields("at_id",article,"atg_id",v.group(),"md_id",mandator(db,u),"hq_id",u.headquartersId()));else db.update("UPDATE articlegroupitem SET atg_id=? WHERE at_id=? AND md_id=? AND hq_id=?",v.group(),article,mandator(db,u),id(links.getFirst(),"hq_id"));
|
||||
}else {long child=id(p.after(),"child"),quantity=id(p.after(),"quantity");if(quantity==0)db.update("DELETE FROM articlebundle WHERE at_id=? AND at_id2=?",article,child);else if(id(p.before(),"quantity")==0)BusinessSupport.insertWithoutKey(db,"articlebundle",fields("at_id",article,"at_id2",child,"atb_bundlequantity",quantity));else db.update("UPDATE articlebundle SET atb_bundlequantity=? WHERE at_id=? AND at_id2=?",quantity,article,child);}
|
||||
var details=p.changes().stream().map(r->fields("field",r.get("field"),"before",auditValue(r.get("before")),"after",auditValue(r.get("after")))).toList();if(c.id()==0||!p.action().equals("SAVE")||p.changes().stream().anyMatch(r->!Set.of("at_authenticated","at_visible").contains(text(r,"field"))))support.auditArticle(u,article,c.id()==0?61:62,"ARTICLE="+article+" | ACTION="+p.action()+" | CHANGES="+support.encode(details),c.requestId());
|
||||
if(c.id()>0&&p.action().equals("SAVE"))for(var flag:List.of("at_authenticated","at_visible"))if(p.changes().stream().anyMatch(r->text(r,"field").equals(flag)))support.auditArticle(u,article,flag.equals("at_authenticated")?63:64,(flag.equals("at_authenticated")?"STATUS_AUTHENTICATION=":"STATUS_VISIBILITY=")+p.after().get(flag),c.requestId());support.receipt(u,c.requestId(),intent,article);return fields("id",article,"repeated",false);});}
|
||||
static Object auditValue(Object v){if(v instanceof String s&&s.length()>1000)return s.substring(0,1000)+" … [SHA256="+revision(s)+"]";return v;}
|
||||
Map<String,Object> history(LegacyUser user,Long hq,long article,int offset){return db.readTransaction(()->{var u=scoped(user,hq);state(u,article);if(article<=0)throw bad("Artikel fehlt.");page(offset);var rows=db.queryForList("SELECT log_id AS id,DATE_FORMAT(log_createtime,'%Y-%m-%d %H:%i:%s') AS at,logo_id AS operation,usr_id AS actor,logo_description AS description FROM "+support.historySchema+".log WHERE at_id=? AND hq_id=? ORDER BY log_createtime DESC,log_id DESC LIMIT 51 OFFSET ?",article,u.headquartersId(),offset);var pending=new ArrayList<Object>();if(offset==0)for(var q:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngaudit' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id DESC",u.headquartersId()))try{var e=support.json.readValue(text(q,"gdc_content"),BusinessSupport.Audit.class);if(e.article()==article&&rows.stream().noneMatch(r->text(r,"description").equals(e.description())))pending.add(fields("at",e.at(),"operation",e.operation(),"actor",e.user(),"description",e.description()));}catch(Exception ignored){}return fields("rows",rows.stream().limit(50).toList(),"pending",pending,"hasMore",rows.size()>50,"offset",offset);});}
|
||||
static void page(int offset){if(offset<0||offset>100000||offset%50!=0)throw bad("Ungültige Seite.");}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** PHP MASK_AT_LIST_COLS, stored with the existing employee list preferences. */
|
||||
@RestController
|
||||
class ArticleColumns {
|
||||
static final String KEY="MASK_AT_LIST_COLS";
|
||||
static final List<String> KEYS=List.of("at_eid","at_name","at_serialno","at_mountable","at_match","at_barcode","at_createtime","at_id_history","at_id_edit","atg_data","at_description");
|
||||
static final List<String> LABELS=List.of("ExtID","Bezeichnung","Seriennummern","Montierbar","Zusatz","Barcode","Angelegt","Historie","Bearbeiten","Warengruppe","Beschreibung");
|
||||
record Layout(List<String> columns,String warning){}
|
||||
record State(EmployeeListPreferences.State access,List<Map<String,Object>> rows){}
|
||||
final DatabaseService db;final BusinessSupport support;
|
||||
ArticleColumns(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
|
||||
@GetMapping("/api/account/article-columns") Object get(Authentication a){var u=ApiController.user(a);return view(u,u.employeeId());}
|
||||
@PutMapping("/api/account/article-columns") Object put(Authentication a,@RequestBody MasterListColumns.Command c){var u=ApiController.user(a);return save(u,u.employeeId(),c);}
|
||||
@GetMapping("/api/administration/employees/{employee}/article-columns") Object get(Authentication a,@PathVariable long employee){return view(ApiController.user(a),employee);}
|
||||
@PutMapping("/api/administration/employees/{employee}/article-columns") Object put(Authentication a,@PathVariable long employee,@RequestBody MasterListColumns.Command c){return save(ApiController.user(a),employee,c);}
|
||||
static List<Map<String,Object>> rows(DatabaseService db,LegacyUser u,long employee,long home,long md){return db.queryForList("SELECT par_id,par_key,par_value,par_text,md_id,hq_id,emp_id FROM parameter WHERE ((emp_id=? AND hq_id IN (?,?)) OR (emp_id=0 AND hq_id=0 AND md_id IN (0,?))) AND par_key=? ORDER BY emp_id,hq_id,par_id",employee,u.headquartersId(),home,md,KEY);}
|
||||
static Layout layout(List<Map<String,Object>> rows,long hq){String raw=MasterListColumns.value(rows,KEY,hq);if(raw.isBlank())return new Layout(KEYS,"");var parts=Arrays.stream(raw.split(",",-1)).map(entryValue -> entryValue.strip()).toList();var selected=parts.stream().filter(KEYS::contains).limit(KEYS.size()).toList();return new Layout(selected.isEmpty()?KEYS:selected,selected.size()!=parts.size()?"Unbekannte alte Spalten werden nicht angezeigt. Bitte die Belegung prüfen.":"");}
|
||||
static Layout runtime(DatabaseService db,LegacyUser u){long md=mandator(db,u);var rows=rows(db,u,u.employeeId(),u.originalHeadquartersId(),md);if(!MasterListColumns.writable(rows,md))throw bad("Die Artikellistenspalten sind mehrfach vorhanden oder falsch zugeordnet.");return layout(rows,u.headquartersId());}
|
||||
State state(LegacyUser u,long employee){var a=new EmployeeListPreferences(db,support).state(u,employee);return new State(a,rows(db,u,employee,id(a.person(),"hq_id"),a.mandator()));}
|
||||
static String version(State s){return revision(List.of(s.access().person(),s.access().access().actor(),s.access().access().root(),s.access().mandator(),s.rows()));}
|
||||
static List<Map<String,Object>> definitions(){return KEYS.stream().map(k->(Map<String,Object>)fields("key",k,"label",LABELS.get(KEYS.indexOf(k)))).toList();}
|
||||
Map<String,Object> view(LegacyUser u,long employee){return db.readTransaction(()->{var s=state(u,employee);var l=layout(s.rows(),u.headquartersId());boolean own=u.headquartersId()==u.originalHeadquartersId(),valid=MasterListColumns.writable(s.rows(),s.access().mandator());return fields("employee",EmployeeAdministration.profile(s.access().person()),"headquartersId",u.headquartersId(),"columns",l.columns(),"definitions",definitions(),"defaults",KEYS,"max",KEYS.size(),"revision",version(s),"canSave",own&&valid,"warning",!own?"Persönliche Einstellungen können in der eigenen Niederlassung geändert werden.":valid?l.warning():"Die Artikellistenspalten sind nicht eindeutig zugeordnet.");});}
|
||||
Map<String,Object> save(LegacyUser u,long employee,MasterListColumns.Command c){if(u.headquartersId()!=u.originalHeadquartersId())throw EmployeeAdministration.denied();if(c==null||c.columns()==null||c.columns().isEmpty()||c.columns().size()>KEYS.size()||c.columns().stream().anyMatch(k->k==null||!KEYS.contains(k)))throw bad("Bitte gültige Artikellistenspalten auswählen.");BusinessSupport.token(c.requestId());support.transactional("parameter","genericdatacontainer");return db.employeePreferencesWriteTransaction(()->{var s=state(u,employee);var intent=List.of("ARTICLE_COLUMNS",employee,c);if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true);if(!MasterListColumns.writable(s.rows(),s.access().mandator()))throw bad("Die Artikellistenspalten sind nicht eindeutig zugeordnet.");if(!version(s).equals(c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Artikellistenspalten wurden zwischenzeitlich geändert. Bitte neu laden.");var old=s.rows().stream().filter(r->id(r,"emp_id")==employee&&id(r,"hq_id")==u.headquartersId()).toList();String value=String.join(",",c.columns());if(old.isEmpty())db.insert("parameter",fields("par_key",KEY,"par_value",value,"par_text","","emp_id",employee,"hq_id",u.headquartersId(),"md_id",s.access().mandator()));else db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(old.getFirst(),"par_id"));support.auditEmployee(u,employee,203,"ARTICLE_COLUMNS_BEFORE="+support.encode(layout(s.rows(),u.headquartersId()).columns())+" | ARTICLE_COLUMNS_AFTER="+support.encode(c.columns()),c.requestId());support.receipt(u,c.requestId(),intent,employee);return fields("completed",true);});}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** PHP stock/atg_list.php: keyed groups, rename and branch/shared visibility. */
|
||||
@RestController
|
||||
@RequestMapping("/api/administration/article-groups")
|
||||
class ArticleGroups {
|
||||
final ArticleAdministration articles;final DatabaseService db;final BusinessSupport support;
|
||||
ArticleGroups(ArticleAdministration articles,DatabaseService db,BusinessSupport support){this.articles=articles;this.db=db;this.support=support;}
|
||||
record Command(String requestId,long id,String revision,String code,String name,boolean shared){}
|
||||
@GetMapping Object get(Authentication a,@RequestParam(required=false) Long headquarters){return list(ApiController.user(a),headquarters);}
|
||||
@PostMapping Object post(Authentication a,@RequestParam(required=false) Long headquarters,@RequestBody Command c){return save(ApiController.user(a),headquarters,c);}
|
||||
List<Map<String,Object>> rows(LegacyUser u){return db.queryForList("SELECT * FROM articlegroup WHERE md_id=? AND hq_id IN (0,?) ORDER BY atg_key,atg_id",mandator(db,u),u.headquartersId());}
|
||||
Map<String,Object> list(LegacyUser user,Long hq){if(user.warehouseAccount())throw MasterDirectoryScope.denied();return db.readTransaction(()->{var u=articles.scoped(user,hq);return fields("rows",rows(u).stream().map(r->fields("id",id(r,"atg_id"),"code",text(r,"atg_key"),"name",text(r,"atg_name"),"shared",id(r,"hq_id")==0,"revision",revision(r))).toList());});}
|
||||
Map<String,Object> save(LegacyUser user,Long hq,Command c){if(user.warehouseAccount())throw MasterDirectoryScope.denied();if(c==null||c.id()<0)throw bad("Ungültige Warengruppe.");articles.scoped(user,hq);BusinessSupport.token(c.requestId());String code=required(ArticleAdministration.clean(c.code(),10,"Schlüssel"),10,"Schlüssel"),name=required(ArticleAdministration.clean(c.name(),100,"Bezeichnung"),100,"Bezeichnung");support.transactional("articlegroup","genericdatacontainer");return db.articleWriteTransaction(()->{var u=articles.scoped(user,hq);var intent=List.of("ARTICLE_GROUP",c);Long receipt=support.receipt(u,c.requestId(),intent);if(receipt!=null)return fields("id",receipt);long md=mandator(db,u);var before=c.id()==0?Map.<String,Object>of():one(rows(u).stream().filter(r->id(r,"atg_id")==c.id()).toList());if(c.id()>0){unchanged(before,c.revision());if(!code.equals(text(before,"atg_key")))throw bad("Der Schlüssel einer bestehenden Warengruppe bleibt unverändert.");}
|
||||
if(!db.queryForList("SELECT atg_id FROM articlegroup WHERE md_id=? AND atg_key=? AND atg_id<>?",md,code,c.id()).isEmpty())throw bad("Dieser Warengruppenschlüssel ist bereits vergeben.");
|
||||
if(!c.shared()&&c.id()>0&&id(before,"hq_id")==0&&!db.queryForList("SELECT at_id FROM articlegroupitem WHERE md_id=? AND atg_id=? AND (hq_id=0 OR hq_id<>?) LIMIT 1",md,c.id(),u.headquartersId()).isEmpty())throw bad("Die Warengruppe ist noch gemeinsam oder in anderen Niederlassungen zugeordnet.");
|
||||
long id=c.id();var values=fields("atg_key",code,"atg_name",name,"hq_id",c.shared()?0:u.headquartersId());if(id==0){values.putAll(fields("md_id",md,"atg_group",""));id=db.insert("articlegroup",values);}else BusinessSupport.update(db,"articlegroup","atg_id",id,values);support.audit(u,0,0,0,203,"ARTICLE_GROUP="+id+" | BEFORE="+support.encode(before)+" | AFTER="+support.encode(values),c.requestId());support.receipt(u,c.requestId(),intent,id);return fields("id",id);});}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Minimal dispatch context across headquarters; full order details keep their existing HQ boundary. */
|
||||
@RestController
|
||||
@RequestMapping("/api/orders/{job}/courier-assignment/vehicles/{vehicle}/orders")
|
||||
public class AssignmentOrders {
|
||||
static final int PAGE_SIZE=20;
|
||||
final DatabaseService db;final OrderOperations orders;final OrderEntryCatalog catalog;
|
||||
public AssignmentOrders(DatabaseService db,OrderOperations orders,OrderEntryCatalog catalog){this.db=db;this.orders=orders;this.catalog=catalog;}
|
||||
@GetMapping public Object get(Authentication auth,@PathVariable long job,@PathVariable long vehicle,@RequestParam long courierId,@RequestParam(defaultValue="1") int page){return list(ApiController.user(auth),job,vehicle,courierId,page);}
|
||||
Map<String,Object> list(LegacyUser u,long job,long vehicle,long courier,int page){
|
||||
OrderCourierAssignment.access(u);
|
||||
if(courier<=0||page<1||page>100000)throw bad("Bitte einen Fahrer und eine gültige Auftragsseite wählen.");
|
||||
return db.readTransaction(()->{
|
||||
var s=orders.snapshot(u,job,false);
|
||||
var v=AssignmentVehicles.resolve(db,catalog,u,vehicle,courier,true,false);
|
||||
if(id(s.job(),"jb_longhaul")!=1&&id(v,"courier_hq")!=u.headquartersId()){
|
||||
var rows=ClassicCandidates.vehicles(db,catalog,u,vehicle);
|
||||
var geo=ClassicCandidates.geography(db,catalog,u,s);var rules=ClassicCandidates.rules(db,catalog,u,s);
|
||||
if(rows.size()!=1||!ClassicCandidates.visible(u,ClassicCandidates.candidate(u,s,rows.getFirst(),geo,rules,false),rules))throw bad("Dieses Fahrzeug ist in der aktuellen Vermittlung nicht sichtbar.");
|
||||
}
|
||||
String sid=text(v,"crvh_sid");
|
||||
String from=" FROM job j JOIN tour t ON t.jb_id=j.jb_id AND t.tr_sort=1 JOIN address a ON a.ad_id=t.ad_id LEFT JOIN serviceplz p ON p.srvp_plz=a.ad_zipcode LEFT JOIN serviceplztraveltime lt ON lt.srvp_id=p.srvp_id AND lt.hq_id=j.hq_id LEFT JOIN headquarters h ON h.hq_id=j.hq_id WHERE j.jb_id<>? AND j.cr_sid=? AND j.jb_status IN (0,1) AND COALESCE(j.jb_storno,0)=0";
|
||||
long total=db.queryForObject("SELECT COUNT(DISTINCT j.jb_id)"+from,Long.class,job,sid);
|
||||
var rows=db.queryForList("SELECT j.jb_id,j.jb_ordertime,j.jb_status,j.jb_longhaul,j.hq_id,h.hq_name,MAX(CASE WHEN "+ClassicCandidates.OCCUPIED+" THEN 1 ELSE 0 END) AS occupied"+from+" GROUP BY j.jb_id,j.jb_ordertime,j.jb_status,j.jb_longhaul,j.hq_id,h.hq_name ORDER BY j.jb_id LIMIT ? OFFSET ?",dateTime(s.job(),"jb_ordertime"),dateTime(s.job(),"jb_ordertime"),job,sid,PAGE_SIZE,(page-1)*PAGE_SIZE);
|
||||
var stops=new HashMap<Long,List<Map<String,Object>>>();
|
||||
if(!rows.isEmpty())for(var t:db.queryForList("SELECT t.jb_id,t.tr_id,t.tr_sort,t.tr_status,a.ad_zipcode FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id IN ("+String.join(",",Collections.nCopies(rows.size(),"?"))+") ORDER BY t.jb_id,t.tr_sort,t.tr_id",rows.stream().map(r->id(r,"jb_id")).toArray()))
|
||||
stops.computeIfAbsent(id(t,"jb_id"),k->new ArrayList<>()).add(fields("id",id(t,"tr_id"),"position",id(t,"tr_sort"),"postalCode",text(t,"ad_zipcode"),"completed",id(t,"tr_status")==1));
|
||||
return fields("sid",sid,"total",total,"page",page,"pageSize",PAGE_SIZE,"items",rows.stream().map(r->fields("id",id(r,"jb_id"),"at",text(r,"jb_ordertime"),"status",id(r,"jb_status"),"longhaul",id(r,"jb_longhaul")==1,"headquarters",text(r,"hq_name"),"canOpen",id(r,"hq_id")==u.headquartersId(),"occupied",id(r,"occupied")!=0,"stations",stops.getOrDefault(id(r,"jb_id"),List.of()))).toList());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
|
||||
/** Assignment identity is the logged-in courier, not necessarily the vehicle owner. */
|
||||
final class AssignmentVehicles {
|
||||
static final String SETTING="RANKING_CR2CRVH_MULTI_RELATION";
|
||||
static final String IDENTITY="cv.cr_id AS owner_courier_id,cr.cr_id AS assignment_courier_id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS driver_name,(SELECT COUNT(*) FROM courier logged WHERE logged.cr_sid=cv.crvh_sid) AS logged_drivers";
|
||||
static boolean multiple(OrderEntryCatalog catalog,LegacyUser u){return "1".equals(LonghaulCandidates.setting(catalog,u,SETTING));}
|
||||
static String from(boolean crossHeadquarters,boolean multiple){
|
||||
String from=OrderEntryCatalog.vehicleFrom(crossHeadquarters);
|
||||
// An occupied login must not silently fall back to an offline owner, even if that login is blocked.
|
||||
return multiple?from.replace("cr.cr_id=cv.cr_id","(cr.cr_sid=cv.crvh_sid OR (cr.cr_id=cv.cr_id AND NOT EXISTS(SELECT 1 FROM courier logged WHERE logged.cr_sid=cv.crvh_sid)))"):from;
|
||||
}
|
||||
static String joins(String from,String joins){int index=from.lastIndexOf("WHERE ");return from.substring(0,index)+joins+" "+from.substring(index);}
|
||||
static Map<String,Object> identity(Map<String,Object> row){var result=new LinkedHashMap<>(row);result.put("cr_id",row.get("assignment_courier_id"));return result;}
|
||||
static List<Map<String,Object>> identities(List<Map<String,Object>> rows){return rows.stream().map(AssignmentVehicles::identity).toList();}
|
||||
static boolean ambiguous(Map<String,Object> row,boolean multiple){return multiple&&id(row,"logged_drivers")>1;}
|
||||
static Map<String,Object> resolve(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,long vehicle,long expectedCourier,boolean crossHeadquarters,boolean lock){
|
||||
if(vehicle<=0||expectedCourier<0)throw bad("Bitte ein gültiges Fahrzeug und einen Fahrer wählen.");
|
||||
boolean multiple=multiple(catalog,u);
|
||||
String sql="SELECT cv.crvh_id,cv.crvh_sid,cv.vht_id,cr.cr_sid AS current_sid,cr.hq_id AS courier_hq,"+IDENTITY+" "+from(crossHeadquarters,multiple)+" AND cv.crvh_id=?";
|
||||
Object[] args=crossHeadquarters?new Object[]{vehicle}:new Object[]{u.headquartersId(),vehicle};
|
||||
var rows=lock?db.lockRows(sql,args):db.queryForList(sql,args);
|
||||
if(rows.size()!=1||ambiguous(rows.getFirst(),multiple))throw bad("Fahrzeug/Fahrer ist nicht verfügbar, nicht eindeutig angemeldet oder gehört zu einer anderen Niederlassung. Bitte die Anmeldung prüfen.");
|
||||
var result=new LinkedHashMap<>(identity(rows.getFirst()));
|
||||
if(expectedCourier>0&&id(result,"cr_id")!=expectedCourier)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Der angemeldete Fahrer hat sich geändert. Bitte die Auswahl aktualisieren.");
|
||||
if(expectedCourier==0&&id(result,"cr_id")!=id(result,"owner_courier_id"))throw bad("Bitte die Fahrerauswahl aktualisieren und den angemeldeten Fahrer ausdrücklich wählen.");
|
||||
result.put("multiple_drivers_enabled",multiple);return result;
|
||||
}
|
||||
private AssignmentVehicles(){}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
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.OrderCompletionProblems.*;
|
||||
|
||||
/** Operational get_job_distance alerts, with a durable claim before any SMTP handoff. */
|
||||
class AutomaticCompletionMail {
|
||||
record Decision(Kind kind,Problem problem,String state,String reason,List<String> recipients,String revision) {
|
||||
String key(){return problem==null?"COMPLETION_"+kind:problem.key();}
|
||||
}
|
||||
record Prepared(Decision decision,OrderEntryRequestMail.Envelope envelope,String subject,String html,String revision) {}
|
||||
final AutomaticOrderMail service;
|
||||
AutomaticCompletionMail(AutomaticOrderMail service){this.service=service;}
|
||||
static String label(Kind kind){return kind==Kind.COURIER?"Abschlussprüfung: Kurier fehlt":"Auftragsprüfung: Stationsadresse";}
|
||||
List<Map<String,Object>> stops(long job){return service.db.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",job);}
|
||||
boolean reportRequired(long payer){
|
||||
var rows=service.db.queryForList("SELECT cs.cs_id_parent,cmp.cmp_sustainability_email 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=?",payer);
|
||||
if(rows.size()>1)throw bad("Der Nachhaltigkeitskunde ist nicht eindeutig.");if(rows.isEmpty())return false;
|
||||
var row=rows.getFirst();if(!text(row,"cmp_sustainability_email").isBlank())return true;
|
||||
if(id(row,"cs_id_parent")<=0)return false;
|
||||
var parents=service.db.queryForList("SELECT cmp.cmp_sustainability_email FROM customer parent JOIN company cmp ON cmp.cmp_id=parent.cmp_id WHERE parent.cs_id=?",id(row,"cs_id_parent"));
|
||||
if(parents.size()>1)throw bad("Der übergeordnete Nachhaltigkeitskunde ist nicht eindeutig.");return parents.stream().anyMatch(p->!text(p,"cmp_sustainability_email").isBlank());
|
||||
}
|
||||
Decision decide(Map<String,Object> job,Kind kind){
|
||||
long order=id(job,"jb_id"),hq=id(job,"hq_id");Problem problem=null;String state="DISABLED",reason="Kein entsprechender Abschlussfehler vorgemerkt.";var source=new ArrayList<Object>();source.add(job);
|
||||
var rows=service.db.queryForList("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",order,OrderCompletionProcessing.RESULT);source.add(rows);
|
||||
if(rows.size()>1)throw bad("Die Abschlussfehlermeldung ist mehrdeutig.");
|
||||
if(rows.size()==1&&text(rows.getFirst(),"gdc_context").equals("FAILED"))try{
|
||||
var node=service.support.json.readTree(text(rows.getFirst(),"gdc_content")).path("problem");
|
||||
if(!node.isMissingNode()&&!node.isNull()){var parsed=service.support.json.treeToValue(node,Problem.class);if(parsed.kind()==null||parsed.basis()==null||!parsed.basis().matches("[a-f0-9]{64}")||parsed.station()<0)throw new IllegalArgumentException();if(parsed.kind()==kind)problem=parsed;}
|
||||
}catch(Exception e){throw bad("Die Abschlussfehlermeldung ist unlesbar.");}
|
||||
List<String> to=List.of();
|
||||
if(problem!=null){
|
||||
var stops=stops(order);source.add(stops);
|
||||
if(!eligible(job)||!service.db.queryForList("SELECT jb_id FROM invoice WHERE jb_id=?",order).isEmpty()){state="EXCLUDED";reason="Auftragsstatus oder Abrechnung erlaubt keine Abschlussfehlermail.";}
|
||||
else if(!problem.basis().equals(basis(job,stops))){state="WAITING";reason="Der Auftrag wurde seit dem Fehler geändert. Eine neue Abschlussprüfung steht aus.";}
|
||||
else if(service.completion==null||!service.completion.active(hq)){reason="Die native Abschlussaufbereitung ist für diese Niederlassung ausgeschaltet.";}
|
||||
else if(!service.db.queryForList("SELECT sp_id FROM "+service.support.historySchema+".semaphor WHERE sp_obj_type='jb' AND sp_obj_id=? AND sp_fieldname='get_job_distance' LIMIT 1",order).isEmpty()){state="WAITING";reason="Die PHP-Abschlussaufbereitung arbeitet noch an diesem Auftrag.";}
|
||||
else if(service.completion.pending(order,false).isEmpty()&&service.completion.queue(order,false).isEmpty()){state="DONE";reason="Es besteht keine offene Abschlussvormerkung mehr.";}
|
||||
else if(kind==Kind.COURIER&&(id(job,"jb_status")!=2||id(job,"cr_id")>0)||kind==Kind.ADDRESS&&(problem.station()<1||problem.station()>stops.size())){state="EXCLUDED";reason="Der gespeicherte Fehler passt nicht zum aktuellen Auftrag.";}
|
||||
else if(kind==Kind.ADDRESS&&!reportRequired(id(job,"csc_id_payer"))){reason="Für Zahler und Elternkunde ist kein Nachhaltigkeitsreport eingerichtet.";}
|
||||
else{
|
||||
var renderer=new OrderCustomerMailContent(service.db,hq,service.legacy);
|
||||
to=OrderEntryMail.addresses(renderer.local("MAIL_SURVEY_ADDRESS_TO"));
|
||||
if(to.isEmpty())reason="Für Abschlussfehler fehlt MAIL_SURVEY_ADDRESS_TO in dieser Niederlassung.";
|
||||
else{state="READY";reason="Ein bestätigter Abschlussfehler wartet auf die Betriebsbenachrichtigung.";}
|
||||
}
|
||||
}
|
||||
return new Decision(kind,problem,state,reason,to,revision(List.of(source,Objects.toString(problem,""),state,to)));
|
||||
}
|
||||
List<Map<String,Object>> view(Map<String,Object> job){
|
||||
if(service.completion==null)return List.of();var out=new ArrayList<Map<String,Object>>();long hq=id(job,"hq_id"),order=id(job,"jb_id");
|
||||
for(var kind:Kind.values())try{
|
||||
var decision=decide(job,kind);var receipt=decision.problem()==null?null:service.receipt(hq,order,decision.key());
|
||||
if(receipt==null&&decision.problem()==null){var previous=service.db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!' ORDER BY gdc_id DESC LIMIT 1",order,"COMPLETION!_"+kind+"!_%");if(!previous.isEmpty())receipt=service.decode(previous.getFirst(),hq,order);}
|
||||
out.add(fields("key",decision.key(),"event",label(kind),"recipientLabel","Betriebsüberwachung","additional",false,"state",receipt==null?decision.state():receipt.state(),"reason",receipt==null?decision.reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?decision.recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));
|
||||
}catch(RuntimeException e){out.add(fields("key","COMPLETION_"+kind,"event",label(kind),"recipientLabel","Betriebsüberwachung","additional",false,"state","BLOCKED","reason",AutomaticOrderMail.safe(e),"recipients",List.of(),"startedAt","","completedAt",""));}
|
||||
return out;
|
||||
}
|
||||
Prepared prepare(long hq,long order,Kind kind){
|
||||
var decision=decide(service.job(hq,order),kind);if(!decision.state().equals("READY"))return null;
|
||||
var renderer=new OrderCustomerMailContent(service.db,hq,service.legacy);String from=renderer.local("MAIL_SENDER_ADDRESS");if(from.isBlank())from=service.transport.from;
|
||||
// Use the reviewed operational recipient setting. Never inherit customer mail copies or PHP's hardcoded developer address.
|
||||
var envelope=new OrderEntryRequestMail.Envelope(AppointmentNotifications.address(from),decision.recipients(),List.of(),List.of());
|
||||
String subject=kind==Kind.COURIER?"Kurier-ID in Auftrag "+order+" fehlt":"CO₂e-Ermittlung in Auftrag "+order+": Adresse in Station "+decision.problem().station()+" prüfen";
|
||||
String body=kind==Kind.COURIER?"Auftrag "+order+" hat keine Kurier-ID. Die Abschlussaufbereitung ist fehlgeschlagen; der Auftrag ist zur Nachbearbeitung gekennzeichnet. Bitte den ausführenden Kurier korrigieren.":"Für Auftrag "+order+" ist ein Nachhaltigkeitsreport erforderlich. Die Adresse in Station "+decision.problem().station()+" konnte nicht eindeutig ermittelt werden. Bitte die Stationsadresse korrigieren und die Abschlussaufbereitung erneut prüfen.";
|
||||
String html="<p>"+OrderCustomerMailContent.escape(body)+"</p>";return new Prepared(decision,envelope,subject,html,revision(List.of(decision.revision(),envelope,subject,html)));
|
||||
}
|
||||
String process(long hq,long order,Kind kind){
|
||||
if(service.completion==null||!service.completion.active(hq))return "SKIPPED";service.available(hq);
|
||||
var prepared=service.db.readTransaction(()->prepare(hq,order,kind));if(prepared==null)return "SKIPPED";
|
||||
String key=prepared.decision().key();if(service.receipt(hq,order,key)!=null)return "ALREADY_STARTED";service.support.transactional("genericdatacontainer");String token=UUID.randomUUID().toString();
|
||||
var claim=service.db.automaticOrderMailWriteTransaction(service.support.historySchema.replace("`",""),()->{
|
||||
service.available(hq);if(!service.completion.active(hq))throw bad("Die Abschlussaufbereitung ist ausgeschaltet.");if(service.receipt(hq,order,key)!=null)return null;
|
||||
var current=prepare(hq,order,kind);if(current==null||!current.revision().equals(prepared.revision()))throw OrderEntryService.conflict("Die Abschlussfehlermail wurde durch eine Änderung überholt.");
|
||||
var d=new AutomaticOrderMail.Delivery(token,hq,order,key,label(kind),false,"STARTED",service.now().toString(),"",prepared.subject(),prepared.envelope(),prepared.revision());
|
||||
service.db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",key,"gdc_context","STARTED","gdc_content",service.support.encode(d)));service.support.audit(service.actor(hq),order,0,0,203,label(kind)+": Übergabe vorgemerkt",token);return d;
|
||||
});if(claim==null)return "ALREADY_STARTED";
|
||||
String state="SENT";try{var e=claim.envelope();service.transport.sendOrderHtml(e.from(),e.to(),e.cc(),e.bcc(),prepared.subject(),prepared.html(),"",null);}catch(RuntimeException e){state="UNCERTAIN";}
|
||||
finish(claim,state);return state;
|
||||
}
|
||||
void finish(AutomaticOrderMail.Delivery claim,String state){service.db.writeTransaction(()->{
|
||||
var rows=service.db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),claim.key());var current=service.decode(one(rows),claim.headquarters(),claim.order());
|
||||
if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Abschlussfehlermail-Beleg wurde verändert.");
|
||||
var done=new AutomaticOrderMail.Delivery(claim.token(),claim.headquarters(),claim.order(),claim.key(),claim.event(),false,state,claim.startedAt(),service.now().toString(),claim.subject(),claim.envelope(),claim.revision());
|
||||
service.db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",service.support.encode(done),state,id(rows.getFirst(),"gdc_id"));service.support.audit(service.actor(claim.headquarters()),claim.order(),0,0,state.equals("SENT")?22:23,claim.event()+": "+AutomaticOrderMail.message(state),claim.token()+":result");return null;
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.stadtbote.votian;
|
||||
import java.time.*;
|
||||
import java.time.format.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** PHP automailer6/8: overdue mediation and its bounded success notification. */
|
||||
class AutomaticMediationMail {
|
||||
enum Event {
|
||||
JAM("Vermittlungsstau","jb_job_jam","automailer6"),RECOVERED("Vermittlung nach Stau","jb_job_jam_ctrl","automailer8");
|
||||
final String label,field,semaphore;Event(String label,String field,String semaphore){this.label=label;this.field=field;this.semaphore=semaphore;}String key(){return "MEDIATION_"+name();}
|
||||
}
|
||||
record Decision(Event event,String state,String reason,List<String> recipients,long marker,long customer,String revision) {}
|
||||
record Prepared(Decision decision,OrderCustomerMailContent.Content content,OrderEntryRequestMail.Envelope envelope,int minutes,String revision) {}
|
||||
final AutomaticOrderMail service;
|
||||
AutomaticMediationMail(AutomaticOrderMail service){this.service=service;}
|
||||
List<Map<String,Object>> marker(long order,String field){var rows=service.db.queryForList("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",order,field);if(rows.size()>1)throw bad("Die Vermittlungsmail-Vormerkung ist mehrdeutig.");return rows;}
|
||||
static LocalDateTime deadline(String raw){try{return LocalDateTime.parse(raw,DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT));}catch(DateTimeException e){throw bad("Die Vermittlungsmail-Frist ist ungültig.");}}
|
||||
static int minutes(String raw){if(raw.isBlank())return 10;if(!raw.matches("[0-9]{1,5}"))throw bad("Die Nachfrist für Vermittlungsmails ist ungültig.");return Integer.parseInt(raw);}
|
||||
static boolean window(Map<String,Object> job,Event event,LocalDateTime due,LocalDateTime now){
|
||||
var at=dateTime(job,"jb_ordertime");if(at==null||!at.isAfter(now.toLocalDate().atStartOfDay())||id(job,"jb_storno")!=0)return false;
|
||||
if(event==Event.RECOVERED)return id(job,"jb_status")==1&&!now.isAfter(due);
|
||||
return !Set.of(1L,2L).contains(id(job,"jb_status"))&&id(job,"jb_offer")==0&&id(job,"jb_automailsent")==0&&id(job,"jb_automailsent2")==0&&!due.isAfter(now);
|
||||
}
|
||||
static boolean arrived(String raw){if(raw.length()<14)return false;try{LocalDateTime.parse(raw.substring(0,14),DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withResolverStyle(ResolverStyle.STRICT));return true;}catch(DateTimeException e){return false;}}
|
||||
Decision decide(Map<String,Object> job,Event event,LocalDateTime now){
|
||||
long order=id(job,"jb_id"),marker=0,customer=0;String state="DISABLED",reason="Keine Vermittlungsmail-Vormerkung vorhanden.";var sources=new ArrayList<Object>();var recipients=new ArrayList<String>();var rows=marker(order,event.field);sources.add(rows);
|
||||
if(!rows.isEmpty()&&!text(rows.getFirst(),"gdc_content").isBlank()){
|
||||
var row=rows.getFirst();marker=id(row,"gdc_id");String context=text(row,"gdc_context");
|
||||
if(row.get("gdc_context")==null||Set.of("MAIL_SENT","MAIL_NOT_SENT").contains(context)){state="DONE";reason="Die vorhandene Vermittlungsmail-Kennzeichnung lässt keinen Versand zu.";}
|
||||
else if(!window(job,event,deadline(text(row,"gdc_content")),now)){state="WAITING";reason="Auftragsstatus oder Vermittlungsfrist passt noch nicht beziehungsweise nicht mehr.";}
|
||||
else{
|
||||
state="READY";reason="Die PHP-Bedingungen für die Vermittlungsmail sind erfüllt.";
|
||||
if(event==Event.RECOVERED){var jam=marker(order,Event.JAM.field);sources.add(jam);if(jam.size()!=1||!text(jam.getFirst(),"gdc_context").equals("MAIL_SENT")){state="WAITING";reason="Eine erfolgreich übergebene Stau-Mail fehlt.";}}
|
||||
else{var stops=service.db.queryForList("SELECT t.tr_id,t.tr_status FROM tour t WHERE t.jb_id=? AND t.tr_sort=1",order);sources.add(stops);if(stops.size()!=1)throw bad("Die erste Auftragsstation ist nicht eindeutig.");var stop=stops.getFirst();var arrivals=service.db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='arr_tour'",id(stop,"tr_id"));sources.add(arrivals);if(arrivals.size()>1)throw bad("Die Ankunftszeit ist mehrdeutig.");if(id(stop,"tr_status")==1||!arrivals.isEmpty()&&arrived(text(arrivals.getFirst(),"gdc_content"))){state="WAITING";reason="Die erste Station wurde bereits erreicht oder erledigt.";}}
|
||||
if(state.equals("READY")){var customers=new AutomaticOrderMailPolicy(service.db).customer(id(job,"csc_id_payer"),id(job,"hq_id"));sources.add(customers);if(customers.size()>1)throw bad("Die Kunden-Mailvorgaben sind mehrdeutig.");if(customers.isEmpty()||id(customers.getFirst(),"cs_jb_jam_waittime")<=0){state="DISABLED";reason="Beim Kunden sind keine Vermittlungsstau-Mails eingeschaltet.";}else{var c=customers.getFirst();customer=id(c,"cs_id");long selection=id(c,"cs_jbstatusmail2csc");if(!Set.of(0L,1L).contains(selection))throw bad("Die Empfängerauswahl des Kunden ist ungültig.");recipients.addAll(OrderEntryMail.addresses(text(c,selection==1?"cscad_email":"usr_email").replace(" ","")));if(recipients.isEmpty()){state="DISABLED";reason="Die ausgewählte Kundenadresse ist leer.";}}}
|
||||
}
|
||||
}
|
||||
return new Decision(event,state,reason,List.copyOf(recipients),marker,customer,revision(List.of(job,event,sources,recipients)));
|
||||
}
|
||||
List<Map<String,Object>> view(Map<String,Object> job,LocalDateTime now){var out=new ArrayList<Map<String,Object>>();for(var event:Event.values()){try{var receipt=service.receipt(id(job,"hq_id"),id(job,"jb_id"),event.key());var d=receipt==null?decide(job,event,now):null;out.add(fields("key",event.key(),"event",event.label,"additional",false,"state",receipt==null?java.util.Objects.requireNonNull(d).state():receipt.state(),"reason",receipt==null?java.util.Objects.requireNonNull(d).reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?java.util.Objects.requireNonNull(d).recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));}catch(RuntimeException e){out.add(fields("key",event.key(),"event",event.label,"additional",false,"state","BLOCKED","reason",AutomaticOrderMail.safe(e),"recipients",List.of(),"startedAt","","completedAt",""));}}return out;}
|
||||
Prepared prepare(long hq,long order,Event event,LocalDateTime now){
|
||||
var job=service.job(hq,order);var decision=decide(job,event,now);if(!decision.state().equals("READY"))return null;
|
||||
var locks=service.db.queryForList("SELECT sp_content FROM "+service.support.historySchema+".semaphor WHERE sp_obj_type='jb' AND sp_obj_id=? AND sp_fieldname=?",order,event.semaphore);if(locks.size()>1||locks.stream().anyMatch(r->!text(r,"sp_content").isBlank()))throw bad("Ein PHP-Vermittlungsmailvorgang ist noch gesperrt.");
|
||||
if(event==Event.JAM&&!marker(order,Event.RECOVERED.field).isEmpty())throw bad("Eine Nachfrist existiert bereits. Bitte den Vermittlungsmail-Bestand prüfen.");
|
||||
var renderer=new OrderCustomerMailContent(service.db,hq,service.legacy);int minutes=event==Event.JAM?minutes(renderer.fallback("CS_JB_JAM_WAITTIME_CTRL")):0;String from=renderer.local("MAIL_SENDER_ADDRESS");if(from.isBlank())from="support@assecutor.de";
|
||||
// sendExternalMailExtended is called without copies; configured order Cc/Bcc do not apply here.
|
||||
var envelope=new OrderEntryRequestMail.Envelope(AppointmentNotifications.address(from),decision.recipients(),List.of(),List.of());var content=AutomaticMediationMailContent.build(renderer,job,decision.customer(),event==Event.RECOVERED);
|
||||
return new Prepared(decision,content,envelope,minutes,revision(List.of(decision.revision(),content.revision(),envelope,minutes)));
|
||||
}
|
||||
String process(long hq,long order,Event event){service.available(hq);if(service.receipt(hq,order,event.key())!=null)return "ALREADY_STARTED";var time=service.now();String token=UUID.randomUUID().toString();var prepared=service.db.readTransaction(()->prepare(hq,order,event,time));if(prepared==null)return "SKIPPED";service.support.transactional("genericdatacontainer");
|
||||
var claim=service.db.automaticOrderMailWriteTransaction(service.support.historySchema.replace("`",""),()->{service.available(hq);if(service.receipt(hq,order,event.key())!=null)return null;var current=prepare(hq,order,event,service.now());if(current==null||!current.revision().equals(prepared.revision()))throw OrderEntryService.conflict("Die Vermittlungsmaildaten wurden geändert.");
|
||||
var d=new AutomaticOrderMail.Delivery(token,hq,order,event.key(),event.label,false,"STARTED",time.toString(),"",prepared.content().subject(),prepared.envelope(),prepared.revision());service.db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",event.key(),"gdc_context","STARTED","gdc_content",service.support.encode(d)));service.db.update("UPDATE genericdatacontainer SET gdc_context='MAIL_NOT_SENT' WHERE gdc_id=?",current.decision().marker());service.support.audit(service.actor(hq),order,0,0,203,event.label+": automatische Mail zur Übergabe vorgemerkt",token);return d;});if(claim==null)return "ALREADY_STARTED";
|
||||
String state="SENT";try{var e=prepared.envelope();service.transport.sendOrderHtml(e.from(),e.to(),e.cc(),e.bcc(),prepared.content().subject(),prepared.content().html(),"",null);}catch(RuntimeException e){state="UNCERTAIN";}finish(claim,prepared,state);return state;
|
||||
}
|
||||
void finish(AutomaticOrderMail.Delivery claim,Prepared prepared,String state){service.db.writeTransaction(()->{
|
||||
var rows=service.db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),claim.key());var current=service.decode(one(rows),claim.headquarters(),claim.order());if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Vermittlungsmailbeleg wurde verändert.");var now=service.now();
|
||||
if(state.equals("SENT")){if(service.db.update("UPDATE genericdatacontainer SET gdc_context='MAIL_SENT' WHERE gdc_id=? AND gdc_context='MAIL_NOT_SENT'",prepared.decision().marker())!=1)throw bad("Die Vermittlungsmail-Kennzeichnung wurde verändert.");if(prepared.decision().event()==Event.JAM){var prior=service.db.lockRows("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_job_jam_ctrl'",claim.order());if(!prior.isEmpty())throw bad("Die Vermittlungsnachfrist wurde inzwischen angelegt.");service.db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",claim.order(),"gdc_gen_fieldname","jb_job_jam_ctrl","gdc_content",now.plusMinutes(prepared.minutes()).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),"gdc_context",""));}}
|
||||
var done=new AutomaticOrderMail.Delivery(claim.token(),claim.headquarters(),claim.order(),claim.key(),claim.event(),false,state,claim.startedAt(),now.toString(),claim.subject(),claim.envelope(),claim.revision());service.db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",service.support.encode(done),state,id(rows.getFirst(),"gdc_id"));service.support.audit(service.actor(claim.headquarters()),claim.order(),0,0,state.equals("SENT")?22:23,claim.event()+": "+AutomaticOrderMail.message(state),claim.token()+":result");return null;
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.stadtbote.votian;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.OrderCustomerMailContent.*;
|
||||
|
||||
/** Text and common wrapper of automailer6/8 and sendExternalMailExtended. */
|
||||
final class AutomaticMediationMailContent {
|
||||
static Content build(OrderCustomerMailContent c,Map<String,Object> job,long customer,boolean recovered){
|
||||
String language=c.parameter(0,"JOBDETAILS_EMAIL_LANGUAGE_"+customer),global=c.parameter(0,"SYSTEM_LANGUAGE_DEFAULT");if(language.isBlank())language="0";if(global.isBlank())global="0";
|
||||
c.language=OrderMailLanguage.load(c.db,language,false,language.equals(global)?"include/mcglobal.inc.php":"include/automailer"+(recovered?8:6)+".php");
|
||||
String at=dateTime(job,"jb_ordertime").format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"));
|
||||
String subject=recovered?c.t("Ihr Auftrag")+" "+id(job,"jb_id")+" "+c.t("wurde erfolgreich vermittelt"):c.t("Verzögerung bei Ihrem Auftrag Nr.: ")+" "+id(job,"jb_id");
|
||||
var body=new StringBuilder("<div>").append(rich(c.mailText("MAIL_PRE_SALUTATION_TEXT","Sehr geehrte Damen und Herren,",false))).append("</div><p>");
|
||||
if(recovered){body.append(escape(c.t("wir möchten Sie darüber informieren, dass Ihr aktueller Auftrag, geplant für"))).append(' ').append(escape(at)).append(", ").append(escape(c.t("nun erfolgreich an einen Boten vermittelt wurde."))).append("<br>")
|
||||
.append(escape(c.t("Unser Fahrer befindet sich bereits auf dem Weg zu Ihnen und wird die Lieferung schnellstmöglich zustellen."))).append("<br>").append(escape(c.t("Sollten Sie weitere Fragen haben, stehen wir Ihnen gerne zur Verfügung."))).append("</p><p>").append(escape(c.t("Wir danken Ihnen für Ihr Vertrauen in STADTBOTE und wünschen Ihnen einen angenehmen Tag.")));}
|
||||
else{body.append(escape(c.t("wir möchten Sie darüber informieren, dass es bei Ihrem aktuellen Auftrag geplant für"))).append(' ').append(escape(at)).append(' ').append(escape(c.t("zu einer Verzögerung in der Vermittlung kommt."))).append("<br>")
|
||||
.append(escape(c.t("Unser Team arbeitet aktiv daran, einen verfügbaren Boten für Ihren Auftrag zu finden. Wir prüfen alle Optionen, um Ihre Lieferung schnellstmöglich zu vermitteln."))).append("<br>").append(escape(c.t("Sobald Ihr Auftrag erfolgreich vermittelt wurde, werden wir Sie per Mail informieren."))).append("</p><p>").append(escape(c.t("Wir hoffen auf Ihr Verständnis und bedanken uns für Ihr Vertrauen in STADTBOTE.")));}
|
||||
body.append("</p><p>").append(rich(c.mailText("MAIL_TEXT_REGARDS","Mit freundlichem Gruß,",false))).append("</p><div>").append(rich(c.t(c.local("MAIL_SALUTATION_TEXT")))).append("</div>");
|
||||
var warnings=new ArrayList<String>();String logo=c.local("IMG_LOGO_NAME"),height=c.local("IMG_LOGO_HEIGHT"),width=c.local("IMG_LOGO_WIDTH");
|
||||
if(!logo.isBlank()&&!height.isBlank()&&!width.isBlank()&&!c.legacy.isBlank()){
|
||||
if(!logo.matches("[A-Za-z0-9_. -]{1,150}")||Set.of(".","..").contains(logo)||!height.matches("[1-9][0-9]{0,3}")||!width.matches("[1-9][0-9]{0,3}"))throw bad("Die Logo-Vorgaben der Vermittlungsmail sind ungültig.");
|
||||
String web=c.local("MAIL_FOOTER_WEB_PAGE"),link=web.isBlank()?"":Objects.toString(ApiController.safeLegacyUrl(web.contains("://")?web:"http://"+web),"");if(!web.isBlank()&&link.isBlank())throw bad("Die Homepage der Mailfußzeile ist ungültig.");
|
||||
body.append("<p>");if(!link.isBlank())body.append("<a href=\"").append(escape(link)).append("\">");body.append("<img alt=\"Logo\" src=\"").append(escape(c.legacy+"/images/external/"+org.springframework.web.util.UriUtils.encodePathSegment(logo,java.util.Objects.requireNonNull(java.nio.charset.StandardCharsets.UTF_8)))).append("\" width=\"").append(width).append("\" height=\"").append(height).append("\">");if(!link.isBlank())body.append("</a>");body.append("</p>");warnings.add("Das Logo wird beim Empfänger von der bisherigen Anwendungsadresse geladen.");
|
||||
}
|
||||
if(c.local("MAIL_FOOTER_ENABLED").equals("1")){
|
||||
for(String key:List.of("MAIL_FOOTER_ADDRESS","MAIL_FOOTER_CONTACT","MAIL_FOOTER_IMPRESSUM"))body.append("<div>").append(rich(c.local(key))).append("</div>");
|
||||
for(int i=1;i<=5;i++){String value=c.fallback("MAIL_FOOTER_RESPONSIBILITY"+(i==1?"":"_"+i));if(value.isBlank())break;body.append("<p>").append(rich(value)).append("</p>");}body.append("<p>").append(rich(c.local("MAIL_FOOTER_THINK_GREEN"))).append("</p>");
|
||||
}
|
||||
String html="<!doctype html><html lang=\""+c.language.tag+"\"><head><meta charset=\"utf-8\"><style>body{"+NewsletterTemplate.safeStyle("font-family:"+c.defaulted("MAIL_CSS_FONT_TYPE","Arial, sans-serif"))+"font-size:12px;margin:20px}img{max-width:100%}</style></head><body>"+body+"</body></html>";
|
||||
if(html.length()>400000)throw bad("Die Vermittlungsmail ist zu umfangreich.");warnings.addAll(c.language.warnings());return new Content(subject,html,List.copyOf(warnings),revision(List.of(job,customer,recovered,c.parameters,html,subject,c.language.revision)),false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
|
||||
/** Rules of the current PHP autoranking_single.php, independent of scheduling and persistence. */
|
||||
final class AutomaticOfferPolicy {
|
||||
record Settings(Map<String,String> local,Map<String,String> global){
|
||||
String local(String key){return local.getOrDefault(key,"").trim();}
|
||||
String inherited(String key){String v=local(key);return v.isEmpty()?global.getOrDefault(key,"").trim():v;}
|
||||
int number(String key,int fallback,int max){String v=local(key);if(v.isEmpty())return fallback;if(!v.matches("[0-9]{1,6}"))throw bad(key+" ist ungültig.");int n=Integer.parseInt(v);if(n>max)throw bad(key+" überschreitet "+max+".");return n;}
|
||||
}
|
||||
record Candidate(long courier,long vehicle,String sid,String name,String postal,String availableAt,int group,String groupName,int challenges,List<String> blockers){boolean eligible(){return blockers.isEmpty();}}
|
||||
record Plan(String state,String message,LocalDateTime startsAt,int iterations,boolean reserved,boolean onlyFavoured,boolean ignoreFavoured,List<Candidate> candidates){
|
||||
List<String> rounds(){if(reserved)return List.of("RESERVED");var r=new ArrayList<String>();if(!ignoreFavoured)for(int i=0;i<iterations;i++)r.add("FAVOURITES");if(!onlyFavoured)for(int i=0;i<iterations;i++)r.add("AREAS");return r;}
|
||||
Candidate chosen(String round){return candidates.stream().filter(entryValue -> entryValue.eligible()).filter(c->round.equals("RESERVED")?c.group()==0:round.equals("FAVOURITES")?c.group()==1:c.group()>=2).findFirst().orElse(null);}
|
||||
}
|
||||
static String commonBlock(Map<String,Object> j,Settings s,LocalDateTime now){
|
||||
if(id(j,"jb_status")!=8)return "Der Auftrag steht nicht im Autoranking.";
|
||||
String guard=OrderRankingBatch.blocked(j,now);if(!guard.isEmpty())return guard;
|
||||
if(id(j,"jb_longhaul")==1)return "Fernaufträge verwenden die BWV-Vermittlung.";
|
||||
if(id(j,"jb_globaljob")==1)return "Der Auftrag wird bereits allen Kurieren angeboten.";
|
||||
if(id(j,"hq_id_dispo")<=0)return "Die Dispositionsniederlassung fehlt.";
|
||||
if(!s.local("AUTORANKING_ASSIGNMENT_ENABLED").equals("1"))return "Automatische Angebote sind für die Dispositionsniederlassung ausgeschaltet.";
|
||||
return "";
|
||||
}
|
||||
static List<String> blockers(Map<String,Object> j,Map<String,Object> v,Settings s,int group,int challenges,int historyCount,boolean occupied,boolean quarantine,Set<String> countries,List<Map<String,Object>> filters,boolean blocked){
|
||||
var b=new ArrayList<String>();boolean reserved=group==0,fav=group==1;
|
||||
if(text(v,"cr_sid").isBlank()||id(v,"vehicle_count")!=1||id(v,"logged_drivers")!=1)b.add("Fahrer und angemeldetes Fahrzeug sind nicht eindeutig");
|
||||
if(id(v,"cr_app_blocked")==1||id(v,"cmp_visible")!=1||id(v,"cmp_archived")!=0||id(v,"driver_user_id")<=0||id(v,"driver_user_hq")!=id(v,"hq_id"))b.add("Fahrerzugang ist gesperrt");
|
||||
if(!reserved&&id(v,"cr_available")!=1)b.add("Nicht verfügbar gemeldet");
|
||||
if(!reserved&&!fav&&id(v,"cmp_authenticated")!=1)b.add("Unternehmen ist nicht freigegeben");
|
||||
if(!reserved&&id(v,"vht_id")<id(j,"vht_id"))b.add("Fahrzeugklasse kleiner als bestellt");
|
||||
if(!reserved&&!fav&&id(v,"cr_outlay")<id(j,"jb_outlay"))b.add("Auslagenlimit reicht nicht aus");
|
||||
if(!reserved&&!fav&&blocked)b.add("Für einen beteiligten Kunden gesperrt");
|
||||
if(!reserved&&(!fav||s.inherited("RANKING_FAVOURED_COURIER_CRVH").equals("1"))&&s.local("RANKING_JB2CRVH_MEASURE").equals("1"))
|
||||
for(var pair:List.of(new String[]{"jb_weight","crvh_payload","Zuladung"},new String[]{"jb_crvh_length","crvh_length","Länge"},new String[]{"jb_crvh_width","crvh_width","Breite"},new String[]{"jb_crvh_height","crvh_height","Höhe"},new String[]{"jb_crvh_position","crvh_position","Stellplätze"}))if(id(j,pair[0])>id(v,pair[1]))b.add(pair[2]+" nicht ausreichend");
|
||||
if(!reserved&&(!fav||s.inherited("RANKING_FAVOURED_COURIER_FILTER").equals("1")))for(var filter:filters){String f=text(filter,"crf_short");long type=id(filter,"crf_type");if((type==0||type==1)&&FleetAssignment.tokens(text(j,"jb_cr_filter")).contains(f)&&!FleetAssignment.tokens(text(v,type==0?"crvh_filter":"cr_filter")).contains(f))b.add("Filterbedingung fehlt: "+f);}
|
||||
int threshold=s.number("AUTORANKING_VEHICLE_TYPE_LEVEL_BEING_LKW",7,999),minimum=s.number("AUTORANKING_VEHICLE_LKW",5,999);boolean exception=false;
|
||||
for(String rule:s.local("AUTORANKING_CR_VHT_GETS_JB_VHT").split("\\|")){String[] parts=rule.split("=",2);if(parts.length==2&&parts[0].trim().equals(Long.toString(id(v,"vht_id")))&&FleetAssignment.tokens(parts[1]).contains(Long.toString(id(j,"vht_id"))))exception=true;}
|
||||
if(!exception&&id(v,"vht_id")>=threshold&&id(j,"vht_id")<minimum)b.add("LKW-Klasse ist für diese Auftragsklasse ausgeschlossen");
|
||||
int max=s.number("AUTORANKING_MAXNUMBER_OF_CHALLENGES",0,32767);if(max<=0)b.add("Kein gültiges Versuchslimit konfiguriert");else if(challenges>=max)b.add("Versuchslimit erreicht");
|
||||
if(historyCount>1||challenges<0)b.add("Rankinghistorie ist nicht eindeutig");
|
||||
if(id(v,"cr_ar_jb_id")!=0)b.add("Ein anderes automatisches Angebot ist noch offen");
|
||||
if(occupied)b.add("Bereits durch einen zeitlich überlappenden Auftrag belegt");
|
||||
if(quarantine)b.add("Quarantänezuordnung schließt diesen Kunden aus");
|
||||
String base=s.global().getOrDefault("GLOBAL_NATIONALITY_DEFAULT","DE");if(base.isBlank())base="DE";String nationality=ClassicCandidates.country(text(v,"usr_country"));
|
||||
if(!nationality.isBlank()&&!nationality.equals(base))for(String rule:s.global().getOrDefault("GLOBAL_NATIONALITY_WHITELIST","").split("\\|")){var parts=rule.split("=",2);if(parts.length==2&&countries.contains(parts[0].trim())&&!parts[0].trim().equals(base)&&!parts[0].trim().equals(nationality)&&!FleetAssignment.tokens(parts[1]).contains(nationality))b.add("Länderfreigabe ist nicht erfüllt");}
|
||||
String eu=s.global().getOrDefault("EU_COUNTRYCODES","");if(id(v,"crvh_totalweight")>=2800&&!FleetAssignment.tokens(text(v,"crvh_filter")).contains("EG")&&countries.stream().anyMatch(c->!c.isBlank()&&!c.equals("DE")&&eu.contains(c)))b.add("EG-Lizenz für Auslandstour fehlt");
|
||||
return List.copyOf(b);
|
||||
}
|
||||
static boolean locating(Settings s,LocalDateTime now){
|
||||
if(!s.inherited("LOCATING_PDA_ENABLED").equals("1"))return false;String interval=s.inherited("LOCATING_PDA_INTERVAL");if(interval.isEmpty())return true;
|
||||
try{var p=interval.split(",");if(p.length!=4)return false;var start=LocalTime.of(Integer.parseInt(p[0].trim()),Integer.parseInt(p[1].trim()));var end=LocalTime.of(Integer.parseInt(p[2].trim()),Integer.parseInt(p[3].trim()));return !now.toLocalTime().withSecond(0).withNano(0).isBefore(start)&&!now.toLocalTime().withSecond(0).withNano(0).isAfter(end);}catch(RuntimeException e){return false;}
|
||||
}
|
||||
private AutomaticOfferPolicy(){}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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.AutomaticOfferPolicy.*;
|
||||
|
||||
/** Native automatic offer rounds. Each round re-reads PHP data under a short table lock. */
|
||||
@RestController
|
||||
@RequestMapping("/api/orders/{job}/mediation/automatic")
|
||||
public class AutomaticOffers {
|
||||
final DatabaseService db;final BusinessSupport support;final boolean enabled;
|
||||
private LocalDateTime cursorTime=LocalDateTime.of(1970,1,1,0,0);private long cursorId;
|
||||
volatile Run lastRun=new Run(null,0,0,0,0);
|
||||
record Run(LocalDateTime at,int checked,int offered,int manualOrGlobal,int failed){}
|
||||
record State(Map<String,Object> job,Settings settings,Plan plan){}
|
||||
enum Result {STOP,NEXT,OFFERED,FALLBACK}
|
||||
public AutomaticOffers(DatabaseService db,BusinessSupport support,@Value("${app.mediation.assignment-enabled:false}") boolean enabled){this.db=db;this.support=support;this.enabled=enabled;}
|
||||
@GetMapping public Object get(Authentication auth,@PathVariable long job){return view(ApiController.user(auth),job);}
|
||||
Map<String,Object> view(LegacyUser user,long job){OrderCourierAssignment.access(user);return db.readTransaction(()->{if(db.queryForList("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",job,user.headquartersId()).isEmpty())throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"Auftrag nicht vorhanden oder nicht freigegeben.");var state=state(job);if(state==null||id(state.job(),"hq_id")!=user.headquartersId())throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"Auftrag nicht vorhanden oder nicht freigegeben.");return fields("workerEnabled",enabled,"plan",state.plan());});}
|
||||
LocalDateTime now(){return db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime();}
|
||||
Settings settings(long hq){
|
||||
var local=new TreeMap<String,String>();var global=new TreeMap<String,String>();
|
||||
for(var p:db.queryForList("SELECT hq_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND (par_key LIKE 'AUTORANKING_%' OR par_key LIKE 'RANKING_%' OR par_key IN ('MODE_INTERMEDIATION','CSC_ID_PAYER_CASH','LOCATING_PDA_ENABLED','LOCATING_PDA_INTERVAL','GLOBAL_NATIONALITY_DEFAULT','GLOBAL_NATIONALITY_WHITELIST','EU_COUNTRYCODES'))",hq)){
|
||||
var target=id(p,"hq_id")==0?global:local;if(target.put(text(p,"par_key"),text(p,"par_value"))!=null)throw bad("Mehrdeutige Vermittlungseinstellung: "+text(p,"par_key"));
|
||||
}return new Settings(local,global);
|
||||
}
|
||||
State reason(Map<String,Object> j,Settings s,String state,String message,LocalDateTime starts){return new State(j,s,new Plan(state,message,starts,0,false,false,false,List.of()));}
|
||||
State state(long job){
|
||||
var rows=db.queryForList("SELECT job.*,EXISTS(SELECT 1 FROM invoice i WHERE i.jb_id=job.jb_id) AS ng_invoiced,EXISTS(SELECT 1 FROM job credit WHERE credit.jb_id_copy=job.jb_id AND credit.jb_storno IN (3,4)) AS ng_credited,EXISTS(SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=job.jb_id AND g.gdc_gen_fieldname='ng_invoice_lock') AS ng_invoice_lock FROM job WHERE job.jb_id=?",job);
|
||||
if(rows.isEmpty())return null;var j=rows.getFirst();var s=settings(id(j,"hq_id_dispo"));var time=now();String common=commonBlock(j,s,time);
|
||||
if(!common.isEmpty())return reason(j,s,id(j,"jb_status")!=8?"NONE":!s.local("AUTORANKING_ASSIGNMENT_ENABLED").equals("1")?"DISABLED":"BLOCKED",common,null);
|
||||
var stops=db.queryForList("SELECT t.tr_sort,t.csc_id,a.ad_zipcode,a.ad_country FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",job);
|
||||
if(stops.isEmpty()||id(stops.getFirst(),"tr_sort")!=1||stops.stream().filter(t->id(t,"tr_sort")==1).count()!=1||text(stops.getFirst(),"ad_zipcode").isBlank())return reason(j,s,"BLOCKED","Die erste Abholstation ist nicht eindeutig oder ohne PLZ.",null);
|
||||
LocalDateTime starts=null;
|
||||
if(id(j,"jb_reserv")==1&&id(j,"jb_globaljob")!=2){
|
||||
var travel=db.queryForList("SELECT lt.srvpt_traveltime FROM serviceplz p JOIN serviceplztraveltime lt ON lt.srvp_id=p.srvp_id WHERE p.srvp_plz=? AND lt.hq_id=?",text(stops.getFirst(),"ad_zipcode"),id(j,"hq_id_dispo"));
|
||||
if(travel.size()!=1||travel.getFirst().get("srvpt_traveltime")==null||id(travel.getFirst(),"srvpt_traveltime")<0||dateTime(j,"jb_ordertime")==null)return reason(j,s,"BLOCKED","Für die Reservierung fehlt eine eindeutige Anfahrtszeit oder Auftragszeit.",null);
|
||||
starts=dateTime(j,"jb_ordertime").minusMinutes(Math.max(30,id(travel.getFirst(),"srvpt_traveltime")));if(time.isBefore(starts))return reason(j,s,"WAITING","Die Reservierung wird erst im vorgesehenen Anfahrtsfenster angeboten.",starts);
|
||||
}
|
||||
if(s.number("AUTORANKING_MAXNUMBER_OF_CHALLENGES",0,32767)<=0)return reason(j,s,"BLOCKED","Ein positives Versuchslimit muss konfiguriert sein.",starts);
|
||||
int iterations=s.number("AUTORANKING_NUMBER_OF_ITERATIONS",2,100);if(iterations==0)iterations=2;
|
||||
boolean reserved=(id(j,"jb_reserv")==1||id(j,"jb_permanent")==1)&&id(j,"cr_id_order")>0;
|
||||
boolean ignored=!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_ignore_fav_only' AND gdc_content='1'",job).isEmpty();
|
||||
var station=customers(stops.stream().map(t->id(t,"csc_id")).toList());
|
||||
long payer=id(j,"csc_id_payer");String cash=s.local("CSC_ID_PAYER_CASH");boolean cashPayer=payer==-1||!cash.isBlank()&&cash.equals(Long.toString(payer));
|
||||
var servicePayers=customers(db.queryForList("SELECT csc_id FROM tourservice WHERE jb_id=?",job).stream().map(t->id(t,"csc_id")).toList());
|
||||
var payers=cashPayer?customers(List.of(id(j,"csc_id_payer_cash"))):servicePayers;
|
||||
// PHP's payer switch supersedes the station-only query for this particular policy.
|
||||
var onlyRows=s.local("RANKING_FAVOURED_COURIER_FOR_PAYER").equals("1")?servicePayers:s.local("RANKING_FAVOURED_COURIER_FOR_STATION").equals("1")?station:List.<Map<String,Object>>of();
|
||||
boolean only=!ignored&&onlyRows.stream().anyMatch(c->id(c,"cs_cr_fav_only")==1)||id(j,"jb_globaljob")==2;
|
||||
var favStation=relations(station,s.inherited("RANKING_FAVOURED_COURIER_FOR_STATION"),1);var favPayer=relations(payers,s.inherited("RANKING_FAVOURED_COURIER_FOR_PAYER"),1);
|
||||
var blocked=relations(station,s.inherited("RANKING_BLOCKED_COURIER_FOR_STATION"),0);blocked.addAll(relations(payers,s.inherited("RANKING_BLOCKED_COURIER_FOR_PAYER"),0));
|
||||
long hq=id(j,"hq_id_dispo");String mode=s.local("MODE_INTERMEDIATION");if(mode.isBlank())mode=text(one(db.queryForList("SELECT hq_invmode FROM headquarters WHERE hq_id=?",hq)),"hq_invmode");
|
||||
if(!reserved&&!Set.of("1","2","3").contains(mode))return reason(j,s,"BLOCKED","Der Vermittlungsmodus ist nicht konfiguriert.",starts);
|
||||
var geo=new Geography(db,hq,mode,s.number("AUTORANKING_NEIGHBOUR_LEVEL",0,1000));String postal=text(stops.getFirst(),"ad_zipcode");
|
||||
var zones=reserved?Map.<String,Integer>of():geo.zones(postal,false);var history=db.queryForList("SELECT * FROM autoranking WHERE jb_id=?",job);
|
||||
var occupied=occupied(j);var filters=db.queryForList("SELECT crf_short,crf_type FROM courierfilter");var groups=db.queryForList("SELECT grp_id,grp_name,grp_group FROM groups WHERE hq_id=?",id(j,"hq_id"));
|
||||
var payerRows=db.queryForList("SELECT cs.cs_group,br.br_group FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id LEFT JOIN branch br ON br.br_id=cmp.br_id WHERE cc.csc_id=?",payer);
|
||||
var quarantineGroups=new HashSet<String>();groups.stream().filter(g->text(g,"grp_name").equals("Quarantäne")).forEach(g->quarantineGroups.add(Long.toString(id(g,"grp_id"))));
|
||||
boolean payerQuarantine=payerRows.stream().anyMatch(p->intersects(text(p,"cs_group"),quarantineGroups)||intersects(text(p,"br_group"),quarantineGroups)||groups.stream().anyMatch(g->intersects(text(g,"grp_group"),quarantineGroups)&&FleetAssignment.tokens(text(p,"cs_group")).contains(Long.toString(id(g,"grp_id")))));
|
||||
var countries=new HashSet<String>();stops.forEach(t->countries.add(ClassicCandidates.country(text(t,"ad_country"))));
|
||||
var candidates=new ArrayList<Candidate>();boolean multiple=s.local(AssignmentVehicles.SETTING).equals("1");
|
||||
for(var v:db.queryForList("SELECT cr.*,cv.crvh_id,cv.crvh_payload,cv.crvh_length,cv.crvh_width,cv.crvh_height,cv.crvh_position,cv.crvh_totalweight,cv.crvh_filter,u.usr_id AS driver_user_id,u.hq_id AS driver_user_hq,u.usr_country,cmp.cmp_visible,cmp.cmp_archived,cmp.cmp_comp,cmp.cmp_authenticated,(SELECT COUNT(*) FROM courier logged WHERE logged.cr_sid=cr.cr_sid) AS logged_drivers,(SELECT COUNT(*) FROM couriervehicle counted WHERE counted.crvh_sid=cr.cr_sid"+(multiple?"":" AND counted.cr_id=cr.cr_id")+") AS vehicle_count FROM courier cr LEFT JOIN couriervehicle cv ON cv.crvh_sid=cr.cr_sid"+(multiple?"":" AND cv.cr_id=cr.cr_id")+" LEFT JOIN user u ON u.usr_id=cr.usr_id LEFT JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE "+(reserved?"cr.cr_id=?":"cr.cr_available=1"),reserved?new Object[]{id(j,"cr_id_order")}:new Object[]{})){
|
||||
long courier=id(v,"cr_id");boolean favourite=!ignored&&(favStation.contains(courier)||favPayer.contains(courier));
|
||||
if(favourite&&s.local("RANKING_FAVOURED_COURIER_AREA_RESTRICTION").equals("1"))favourite=geo.zones(text(v,"cr_locationzipcode"),true).containsKey(postal);
|
||||
int group=reserved?0:favourite?1:only?-1:zones.getOrDefault(text(v,"cr_locationzipcode"),-1);if(group<0)continue;
|
||||
var prior=history.stream().filter(a->id(a,"cr_id")==courier).toList();int challenges=prior.isEmpty()?0:(int)id(prior.getFirst(),"ar_challenge");
|
||||
var reasons=blockers(j,v,s,group,challenges,prior.size(),occupied.contains(courier),payerQuarantine&&intersects(text(v,"cr_group"),quarantineGroups),countries,filters,blocked.contains(courier));
|
||||
candidates.add(new Candidate(courier,id(v,"crvh_id"),text(v,"cr_sid"),text(v,"cmp_comp"),text(v,"cr_locationzipcode"),text(v,"cr_availabletime"),group,group==0?"Fester Reservierungskurier":group==1?"Bevorzugter Kurier":group==2?"Vermittlungsbereich":"Nachbarschaft "+(group-2),challenges,reasons));
|
||||
}
|
||||
if(reserved&&candidates.isEmpty())return reason(j,s,"BLOCKED","Der feste Reservierungskurier fehlt im Bestand.",starts);
|
||||
candidates.sort(Comparator.comparingInt((Candidate entryValue) -> entryValue.group()).thenComparingInt(c->c.group()==1&&favStation.contains(c.courier())?0:1).thenComparing(entryValue -> entryValue.availableAt()).thenComparingLong(entryValue -> entryValue.courier()));
|
||||
boolean any=candidates.stream().anyMatch(entryValue -> entryValue.eligible());return new State(j,s,new Plan("READY",any?"Ein geeigneter Kurier kann das nächste Angebot erhalten.":id(j,"jb_globaljob")==2?"Ohne geeigneten Favoriten wird der Auftrag allen Kurieren angeboten.":"Ohne geeigneten Kurier geht der Auftrag in die manuelle Vermittlung.",starts,iterations,reserved,only,ignored,List.copyOf(candidates)));
|
||||
}
|
||||
static boolean intersects(String csv,Set<String> values){return FleetAssignment.tokens(csv).stream().anyMatch(values::contains);}
|
||||
List<Map<String,Object>> customers(List<Long> centers){var ids=centers.stream().filter(x->x>0).distinct().toList();if(ids.isEmpty())return List.of();return db.queryForList("SELECT DISTINCT cs.cs_id,cs.cs_cr_fav_only FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id WHERE cc.csc_id IN ("+String.join(",",Collections.nCopies(ids.size(),"?"))+")",ids.toArray());}
|
||||
Set<Long> relations(List<Map<String,Object>> customers,String toggle,int relation){var result=new HashSet<Long>();if(!toggle.equals("1")||customers.isEmpty())return result;var ids=customers.stream().map(c->id(c,"cs_id")).toList();var args=new ArrayList<Object>(ids);args.add(relation);db.queryForList("SELECT DISTINCT cr_id FROM customercourier WHERE cs_id IN ("+String.join(",",Collections.nCopies(ids.size(),"?"))+") AND cscr_relation=?",args.toArray()).forEach(r->result.add(id(r,"cr_id")));return result;}
|
||||
Set<Long> occupied(Map<String,Object> target){var result=new HashSet<Long>();db.queryForList("SELECT DISTINCT active.cr_id_order FROM job active JOIN tour ot ON ot.jb_id=active.jb_id AND ot.tr_sort=1 JOIN address oa ON oa.ad_id=ot.ad_id LEFT JOIN serviceplz op ON op.srvp_plz=oa.ad_zipcode LEFT JOIN serviceplztraveltime olt ON olt.srvp_id=op.srvp_id AND olt.hq_id=active.hq_id WHERE active.jb_id<>? AND active.jb_status IN (0,1) AND COALESCE(active.jb_storno,0)=0 AND (DATE_SUB(active.jb_ordertime,INTERVAL GREATEST(olt.srvpt_traveltime,30) MINUTE)<=NOW() OR (DATE_SUB(active.jb_ordertime,INTERVAL GREATEST(olt.srvpt_traveltime,30) MINUTE)<=? AND active.jb_ordertime>=?))",id(target,"jb_id"),dateTime(target,"jb_ordertime"),dateTime(target,"jb_ordertime")).forEach(r->result.add(id(r,"cr_id_order")));return result;}
|
||||
static final class Geography {
|
||||
final DatabaseService db;final long hq;final String mode;final int depth;
|
||||
Geography(DatabaseService db,long hq,String mode,int depth){this.db=db;this.hq=hq;this.mode=mode;this.depth=depth;}
|
||||
Map<String,Integer> zones(String postal,boolean reverse){
|
||||
var result=new HashMap<String,Integer>();if(postal.isBlank())return result;var source=db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=?",postal);if(source.size()>1)throw bad("PLZ im Vermittlungsbestand ist mehrdeutig: "+postal);if(reverse||mode.equals("1"))result.put(postal,2);if(source.isEmpty())return result;long zip=id(source.getFirst(),"srvp_id");
|
||||
if(mode.equals("1")){db.queryForList("SELECT p.srvp_plz FROM serviceplzneighbour pn JOIN serviceplz p ON p.srvp_id=pn.srvp2_id WHERE pn.hq_id=? AND pn.srvp_id=?",hq,zip).forEach(r->result.putIfAbsent(text(r,"srvp_plz"),3));return result;}
|
||||
var main=db.queryForList("SELECT ga.srvpa_id FROM serviceplzareamapping m JOIN serviceplzarea ga ON ga.srvpa_id=m.srvpa_id WHERE ga.hq_id=? AND m.srvp_id=?",hq,zip);if(main.size()>1)throw bad("PLZ gehört zu mehreren Vermittlungsbereichen.");if(!main.isEmpty())add(result,id(main.getFirst(),"srvpa_id"),2);
|
||||
if(mode.equals("2")&&!main.isEmpty())for(var area:db.queryForList("SELECT ga.srvpa_id FROM serviceplzareaneighbour an JOIN serviceplzarea ga ON ga.srvpa_id=an.srvpa2_id WHERE an.srvpa_id=? AND ga.hq_id=?",id(main.getFirst(),"srvpa_id"),hq))add(result,id(area,"srvpa_id"),3);
|
||||
if(mode.equals("3"))for(var area:db.queryForList("SELECT ga.srvpa_id,nan.srvpna_sort FROM serviceplzneighbourarea nan JOIN serviceplzarea ga ON ga.srvpa_id=nan.srvpa_id WHERE nan.hq_id=? AND ga.hq_id=? AND nan.srvp_id=?"+(reverse?"":" AND nan.srvpna_sort BETWEEN 1 AND ?")+" ORDER BY nan.srvpna_sort",reverse?new Object[]{hq,hq,zip}:new Object[]{hq,hq,zip,depth}))add(result,id(area,"srvpa_id"),2+(int)id(area,"srvpna_sort"));
|
||||
return result;
|
||||
}
|
||||
void add(Map<String,Integer> result,long area,int rank){db.queryForList("SELECT p.srvp_plz FROM serviceplzareamapping m JOIN serviceplz p ON p.srvp_id=m.srvp_id WHERE m.srvpa_id=?",area).forEach(r->result.merge(text(r,"srvp_plz"),rank,(entryValue, otherValue) -> Math.min(entryValue, otherValue)));}
|
||||
}
|
||||
Result step(long job,int round){return db.automaticOfferWriteTransaction(()->{
|
||||
var state=state(job);if(state==null||!state.plan().state().equals("READY"))return Result.STOP;var p=state.plan();var rounds=p.rounds();var j=state.job();var s=state.settings();Candidate selected=round<rounds.size()?p.chosen(rounds.get(round)):null;var time=now();long hq=id(j,"hq_id_dispo");String token=UUID.randomUUID().toString();
|
||||
if(selected!=null){
|
||||
if(db.update("UPDATE courier SET cr_ar_jb_id=? WHERE cr_id=? AND cr_ar_jb_id=0",job,selected.courier())!=1)return Result.NEXT;
|
||||
if(db.update("UPDATE job SET cr_id_order=?,cr_sid=?,jb_status=0,jb_autoranking=1,jb_version=jb_version+1 WHERE jb_id=? AND jb_status=8",selected.courier(),selected.sid(),job)!=1)throw bad("Der Vermittlungsstand hat sich geändert.");
|
||||
var values=fields("ar_challenge",selected.challenges()+1,"ar_lastassigntime",time,"ar_looseranking",1,"ar_locating",selected.group()>=2&&locating(s,time)?1:0,"ar_cr_order",p.reserved()?1:0);
|
||||
int changed=db.update("UPDATE autoranking SET ar_challenge=?,ar_lastassigntime=?,ar_looseranking=?,ar_locating=?,ar_cr_order=? WHERE jb_id=? AND cr_id=?",selected.challenges()+1,time,1,values.get("ar_locating"),p.reserved()?1:0,job,selected.courier());
|
||||
if(changed==0){values.putAll(fields("jb_id",job,"cr_id",selected.courier()));BusinessSupport.insertWithoutKey(db,"autoranking",values);}else if(changed!=1)throw bad("Rankinghistorie ist nicht eindeutig.");
|
||||
support.audit(new LegacyUser(0,0,hq,"Automatische Vermittlung",""),job,0,selected.courier(),6,"ASSIGN_STATUS="+(selected.group()==0?"RESERVED":selected.group()==1?"FAVOURED":selected.group()==2?"AREA":"NEIGHBOUR_"+(selected.group()-2))+"|SID="+selected.sid(),token);
|
||||
receipt(job,token,fields("at",time,"courier",selected.courier(),"vehicle",selected.vehicle(),"group",selected.groupName(),"round",round+1,"challenge",selected.challenges()+1));return Result.OFFERED;
|
||||
}
|
||||
if(round+1<rounds.size())return Result.NEXT;
|
||||
boolean global=id(j,"jb_globaljob")==2;boolean keep=(id(j,"jb_reserv")==1||id(j,"jb_permanent")==1)&&s.local("RANKING_NORESET_CRSID").equals("1");
|
||||
db.update("UPDATE job SET jb_status=?,jb_version=jb_version+1"+(global?",jb_globaljob=1":"")+(keep?"":",cr_sid=''")+" WHERE jb_id=? AND jb_status=8",global?0:9,job);
|
||||
support.audit(new LegacyUser(0,0,hq,"Automatische Vermittlung",""),job,0,0,12,global?"CONVERTED_TO_GLOBAL_JOB":"NOT_ASSOCIATED_PER_AUTORANKING",token);receipt(job,token,fields("at",time,"result",global?"GLOBAL":"MANUAL","rounds",rounds.size()));return Result.FALLBACK;
|
||||
});}
|
||||
void receipt(long job,String token,Object data){db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname","ng_auto_offer","gdc_content",support.encode(data),"gdc_context",token));}
|
||||
Result process(long job){support.transactional("job","courier","autoranking","genericdatacontainer");for(int round=0;round<=200;round++){var result=step(job,round);if(result!=Result.NEXT)return result;}throw bad("Die Angebotsrunden wurden zwischenzeitlich verändert.");}
|
||||
// PHP keepalive is MyISAM: this single idempotent timestamp update is deliberately separate
|
||||
// from the atomic offer transaction and never changes another process's heartbeat.
|
||||
void heartbeat(){db.writeTransaction(()->db.update("UPDATE keepalive SET ka_lastexecutiontime=NOW() WHERE ka_process='autoranking'"));}
|
||||
@Scheduled(initialDelay=60000,fixedDelay=15000) public synchronized void run(){
|
||||
if(!enabled)return;int checked=0,offered=0,fallback=0,failed=0;
|
||||
try{var jobs=db.readTransaction(()->db.queryForList("SELECT jb_id,COALESCE(jb_ordertime,'1970-01-01 00:00:00') AS queue_time FROM job WHERE jb_status=8 AND EXISTS(SELECT 1 FROM parameter qp WHERE qp.hq_id=job.hq_id_dispo AND qp.emp_id=0 AND qp.par_key='AUTORANKING_ASSIGNMENT_ENABLED' AND qp.par_value='1') AND (COALESCE(jb_ordertime,'1970-01-01 00:00:00')>? OR (COALESCE(jb_ordertime,'1970-01-01 00:00:00')=? AND jb_id>?)) ORDER BY queue_time,jb_id LIMIT 40",cursorTime,cursorTime,cursorId));if(jobs.isEmpty()){cursorTime=LocalDateTime.of(1970,1,1,0,0);cursorId=0;}for(var row:jobs){cursorTime=dateTime(row,"queue_time");cursorId=id(row,"jb_id");checked++;try{var r=process(cursorId);if(r==Result.OFFERED)offered++;else if(r==Result.FALLBACK)fallback++;}catch(RuntimeException e){failed++;}}heartbeat();}catch(RuntimeException e){failed++;}
|
||||
lastRun=new Run(LocalDateTime.now().withNano(0),checked,offered,fallback,failed);if(failed>0)org.slf4j.LoggerFactory.getLogger(AutomaticOffers.class).warn("Automatische Angebote: {} fehlgeschlagene Prüfungen; erneuter Versuch im nächsten Umlauf.",failed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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.AutomaticOrderMailPolicy.*;
|
||||
|
||||
/** Durable at-most-once SMTP handoff. Disabled until the PHP mail processes have been retired. */
|
||||
@RestController
|
||||
@RequestMapping("/api/orders/{order}/automatic-mail")
|
||||
class AutomaticOrderMail {
|
||||
record Delivery(String token,long headquarters,long order,String key,String event,boolean additional,String state,String startedAt,String completedAt,String subject,OrderEntryRequestMail.Envelope envelope,String revision) {}
|
||||
record Prepared(Decision decision,OrderCustomerMailContent.Content content,OrderEntryRequestMail.Envelope envelope,String revision) {}
|
||||
record Run(String at,int checked,int sent,int uncertain,int failed,String state) {}
|
||||
final DatabaseService db;final BusinessSupport support;final AppointmentMailTransport transport;final boolean enabled;final Set<Long> headquarters;
|
||||
final OrderCompletionProcessing completion;final OrderRoutingService routing;final String legacy,cc,bcc;final Map<Long,Long> cursors=new HashMap<>();volatile Run lastRun=new Run("",0,0,0,0,"NOT_RUN");
|
||||
AutomaticOrderMail(DatabaseService db,BusinessSupport support,AppointmentMailTransport transport,boolean enabled,String headquarters,String legacy,String cc,String bcc){this(db,support,transport,enabled,headquarters,legacy,cc,bcc,null);}
|
||||
AutomaticOrderMail(DatabaseService db,BusinessSupport support,AppointmentMailTransport transport,boolean enabled,String headquarters,String legacy,String cc,String bcc,OrderRoutingService routing){this(db,support,transport,enabled,headquarters,legacy,cc,bcc,routing,null);}
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
AutomaticOrderMail(DatabaseService db,BusinessSupport support,AppointmentMailTransport transport,
|
||||
@Value("${app.automatic-mail.enabled:false}")boolean enabled,@Value("${app.automatic-mail.headquarters:}")String headquarters,
|
||||
@Value("${app.legacy-url:}")String legacy,@Value("${app.mail.order-cc:}")String cc,@Value("${app.mail.order-bcc:}")String bcc,OrderRoutingService routing,OrderCompletionProcessing completion){
|
||||
this.completion=completion;this.routing=routing;this.db=db;this.support=support;this.transport=transport;this.enabled=enabled;this.headquarters=parseHeadquarters(headquarters);this.legacy=legacy;this.cc=cc;this.bcc=bcc;
|
||||
}
|
||||
static Set<Long> parseHeadquarters(String raw){if(raw.isBlank())return Set.of();var ids=new LinkedHashSet<Long>();for(String part:raw.split(",",-1)){String s=part.strip();if(!s.matches("[1-9][0-9]{0,9}"))throw new IllegalArgumentException("Ungültige Niederlassung für automatische Auftragsmails.");ids.add(Long.parseLong(s));}if(ids.size()>100)throw new IllegalArgumentException("Zu viele Mail-Niederlassungen.");return Collections.unmodifiableSet(ids);}
|
||||
LocalDateTime now(){return db.queryForObject("SELECT CURRENT_TIMESTAMP",java.sql.Timestamp.class).toLocalDateTime();}
|
||||
Map<String,Object> job(long hq,long order){return one(db.queryForList("SELECT * FROM job WHERE jb_id=? AND hq_id=?",order,hq));}
|
||||
LegacyUser actor(long hq){return new LegacyUser(0,0,hq,"Automatische Auftragsmail","");}
|
||||
void available(long hq){if(!enabled||!headquarters.contains(hq))throw bad("Der native Statusmaildienst ist für diese Niederlassung ausgeschaltet.");if(!transport.configured())throw bad("SMTP ist nicht konfiguriert.");if(new AutomaticOrderMailPolicy(db).settings().legacyEnabled())throw bad("Der PHP-Automailer ist noch freigegeben. Zuerst den alten Dienst vollständig stilllegen.");var rows=db.queryForList("SELECT hq_id,hq_disabled FROM headquarters WHERE hq_id=?",hq);if(rows.size()!=1||id(rows.getFirst(),"hq_disabled")!=0)throw bad("Die Niederlassung ist nicht aktiv.");}
|
||||
Delivery decode(Map<String,Object> row,long hq,long order){try{var d=support.json.readValue(text(row,"gdc_content"),Delivery.class);if(d.headquarters()!=hq||d.order()!=order||!Set.of("STARTED","SENT","UNCERTAIN").contains(d.state()))throw new IllegalArgumentException();return d;}catch(Exception e){throw bad("Der automatische Versandbeleg ist ungültig.");}}
|
||||
Delivery receipt(long hq,long order,String key){var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",order,key);if(rows.size()>1)throw bad("Der automatische Versandbeleg ist mehrdeutig.");return rows.isEmpty()?null:decode(rows.getFirst(),hq,order);}
|
||||
@GetMapping Map<String,Object> view(Authentication auth,@PathVariable long order){return view(ApiController.user(auth),order);}
|
||||
Map<String,Object> view(LegacyUser actor,long order){actor.require(4);return db.readTransaction(()->{
|
||||
var user=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());user.require(4);var job=job(user.headquartersId(),order);var policy=new AutomaticOrderMailPolicy(db);var settings=policy.settings();var now=now();var rows=new ArrayList<Map<String,Object>>();
|
||||
for(var event:Event.values())for(boolean additional:List.of(false,true)){String key=(additional?"ADDITIONAL_":"STANDARD_")+event.name();try{var receipt=receipt(user.headquartersId(),order,key);var decision=receipt==null?policy.decide(job,event,additional,settings,now):null;rows.add(fields("key",key,"event",event.label,"additional",additional,"state",receipt==null?java.util.Objects.requireNonNull(decision).state():receipt.state(),"reason",receipt==null?java.util.Objects.requireNonNull(decision).reason():message(receipt.state()),"recipients",receipt==null?java.util.Objects.requireNonNull(decision).recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));}catch(RuntimeException e){rows.add(fields("key",key,"event",event.label,"additional",additional,"state","BLOCKED","reason",safe(e),"recipients",List.of(),"startedAt","","completedAt",""));}}
|
||||
rows.addAll(new AutomaticStationMail(this).view(job,settings,now));
|
||||
rows.addAll(new AutomaticMediationMail(this).view(job,now));
|
||||
rows.add(new AutomaticTakenMail(this).view(job,settings,now));
|
||||
rows.addAll(new AutomaticTrackingMail(this).view(job,now));
|
||||
rows.addAll(new AutomaticCompletionMail(this).view(job));
|
||||
return fields("enabled",enabled&&headquarters.contains(user.headquartersId()),"legacyEnabled",settings.legacyEnabled(),"smtpConfigured",transport.configured(),"items",rows,"scope","Erfassung, Abholung und Erledigung; Standard- und zusätzliche Auftragsempfänger sowie Stationsabschlüsse, Vermittlungsstau mit Rückmeldung und Fahrzeuginfo nach Annahme und Endkundentracking; Betriebsbenachrichtigungen zu bestätigten Abschlussfehlern.");
|
||||
});}
|
||||
static String message(String state){return state.equals("SENT")?"Vom SMTP-Server angenommen; die endgültige Zustellung ist damit noch nicht bestätigt.":"Versand begonnen, aber nicht sicher bestätigt. Bitte SMTP-Protokolle prüfen; kein automatischer Neuversand.";}
|
||||
static String safe(RuntimeException e){return e instanceof org.springframework.web.server.ResponseStatusException r?Objects.toString(r.getReason(),"Mailvorgaben konnten nicht gelesen werden."):"Mailvorgaben oder Versandbelege konnten nicht vollständig gelesen werden.";}
|
||||
OrderEntryRequestMail.Envelope envelope(OrderCustomerMailContent renderer,List<String> recipients){
|
||||
String from=renderer.local("MAIL_SENDER_ADDRESS");if(from.isBlank())from=transport.from;from=AppointmentNotifications.address(from);
|
||||
String copy=renderer.local("MAIL_CC_ADDRESS"),hidden=renderer.local("MAIL_BCC_ADDRESS");var seen=new HashSet<String>();recipients.forEach(v->seen.add(v.toLowerCase(Locale.ROOT)));var copies=OrderEntryMail.addresses(copy.isBlank()?cc:copy).stream().filter(v->seen.add(v.toLowerCase(Locale.ROOT))).toList();var hiddenCopies=OrderEntryMail.addresses(hidden.isBlank()?bcc:hidden).stream().filter(v->seen.add(v.toLowerCase(Locale.ROOT))).toList();if(seen.size()>20)throw bad("Zu viele Empfänger einschließlich Cc/Bcc.");
|
||||
return new OrderEntryRequestMail.Envelope(from,recipients,copies,hiddenCopies);
|
||||
}
|
||||
Prepared prepare(long hq,long order,Event event,boolean additional,LocalDateTime now,String token){
|
||||
var policy=new AutomaticOrderMailPolicy(db);var job=job(hq,order);var decision=policy.decide(job,event,additional,policy.settings(),now);if(!decision.state().equals("READY")||decision.recipients().isEmpty())return null;
|
||||
String semaphore=additional?"automailer5":event.semaphore;var locks=db.queryForList("SELECT sp_content FROM "+support.historySchema+".semaphor WHERE sp_obj_type='jb' AND sp_obj_id=? AND sp_fieldname=?",order,semaphore);if(locks.size()>1||locks.stream().anyMatch(r->!text(r,"sp_content").isBlank()))throw bad("Ein PHP-Mailvorgang ist noch gesperrt oder nicht eindeutig abgeschlossen.");
|
||||
var renderer=new OrderCustomerMailContent(db,hq,legacy);var envelope=envelope(renderer,decision.recipients());var archive=dateTime(job,"jb_export_time")==null?null:new OrderArchive(db).load(order);
|
||||
var content=renderer.automatic(job,now.toLocalDate(),new OrderDeliveryData(db,support.historySchema),token,archive,event);
|
||||
return new Prepared(decision,content,envelope,revision(List.of(decision.revision(),content.revision(),envelope)));
|
||||
}
|
||||
String process(long hq,long order,Event event,boolean additional){
|
||||
available(hq);String key=(additional?"ADDITIONAL_":"STANDARD_")+event.name();if(receipt(hq,order,key)!=null)return "ALREADY_STARTED";
|
||||
LocalDateTime time=now();String token=UUID.randomUUID().toString();var prepared=db.readTransaction(()->prepare(hq,order,event,additional,time,token));if(prepared==null)return "SKIPPED";
|
||||
byte[] pdf=prepared.content().pdf()?OrderMailPdf.create(order,prepared.content().html()):null;
|
||||
support.transactional("job","genericdatacontainer");
|
||||
Delivery claim=db.automaticOrderMailWriteTransaction(support.historySchema.replace("`",""),()->{
|
||||
available(hq);if(receipt(hq,order,key)!=null)return null;var current=prepare(hq,order,event,additional,now(),token);if(current==null||!current.revision().equals(prepared.revision()))throw OrderEntryService.conflict("Die automatischen Maildaten wurden geändert.");
|
||||
var d=new Delivery(token,hq,order,key,event.label,additional,"STARTED",time.toString(),"",prepared.content().subject(),prepared.envelope(),prepared.revision());
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",key,"gdc_context","STARTED","gdc_content",support.encode(d)));
|
||||
// Consume the PHP event in the same commit as the durable claim. Ambiguous SMTP is never retried automatically.
|
||||
if(additional){var row=one(db.queryForList("SELECT gdc_context FROM genericdatacontainer WHERE gdc_id=?",current.decision().marker()));db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_id=?",Integer.toString(context(text(row,"gdc_context"))|1024|event.bit),current.decision().marker());}
|
||||
else db.update("UPDATE job SET "+event.field+"=998 WHERE jb_id=? AND hq_id=?",order,hq);
|
||||
support.audit(actor(hq),order,0,0,203,"Automatische "+event.label+"smail zur Übergabe vorgemerkt",token);return d;
|
||||
});
|
||||
if(claim==null)return "ALREADY_STARTED";
|
||||
String state="SENT";try{var e=prepared.envelope();transport.sendOrderHtml(e.from(),e.to(),e.cc(),e.bcc(),prepared.content().subject(),prepared.content().html(),"Auftrag_"+order+".pdf",pdf);}catch(RuntimeException e){state="UNCERTAIN";}
|
||||
finish(claim,state,event);return state;
|
||||
}
|
||||
void finish(Delivery claim,String state,Event event){
|
||||
db.writeTransaction(()->{var rows=db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),claim.key());var current=decode(one(rows),claim.headquarters(),claim.order());if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Versandbeleg wurde verändert.");
|
||||
var done=new Delivery(claim.token(),claim.headquarters(),claim.order(),claim.key(),claim.event(),claim.additional(),state,claim.startedAt(),now().toString(),claim.subject(),claim.envelope(),claim.revision());db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",support.encode(done),state,id(rows.getFirst(),"gdc_id"));
|
||||
if(state.equals("SENT")&&!claim.additional())db.update("UPDATE job SET "+event.field+"=999 WHERE jb_id=? AND hq_id=? AND "+event.field+"=998",claim.order(),claim.headquarters());
|
||||
support.audit(actor(claim.headquarters()),claim.order(),0,0,state.equals("SENT")?22:23,"Automatische "+claim.event()+"smail: "+message(state),claim.token()+":result");return null;
|
||||
});
|
||||
}
|
||||
@Scheduled(initialDelay=60000,fixedDelay=30000)
|
||||
public synchronized void run(){if(!enabled||headquarters.isEmpty())return;int checked=0,sent=0,uncertain=0,failed=0;String state="OK";
|
||||
for(long hq:headquarters){try{available(hq);var config=new AutomaticOrderMailPolicy(db).settings();LocalDateTime from=now().minusDays(Math.max(1,Math.max(config.completionDays(),config.pickupDays()))).minusHours(2);
|
||||
var batch=db.queryForList("SELECT jb_id FROM job WHERE hq_id=? AND jb_id>? AND COALESCE(jb_storno,0)=0 AND (jb_ordertime>? OR jb_taketime>? OR jb_finishtime>? OR EXISTS(SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=job.jb_id AND g.gdc_gen_fieldname='ng_completion_result' AND g.gdc_context='FAILED')) ORDER BY jb_id LIMIT 30",hq,cursors.getOrDefault(hq,0L),from,from,from);if(batch.isEmpty())cursors.put(hq,0L);
|
||||
for(var row:batch){long order=id(row,"jb_id");cursors.put(hq,order);checked++;
|
||||
for(var kind:OrderCompletionProblems.Kind.values())try{String outcome=new AutomaticCompletionMail(this).process(hq,order,kind);if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;}
|
||||
for(var event:Event.values())for(boolean additional:List.of(false,true)){try{String outcome=process(hq,order,event,additional);if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;}}
|
||||
for(var event:AutomaticMediationMail.Event.values()){try{String outcome=new AutomaticMediationMail(this).process(hq,order,event);if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;}}
|
||||
try{String outcome=new AutomaticTakenMail(this).process(hq,order);if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;}
|
||||
try{var tracking=new AutomaticTrackingMail(this);for(long station:tracking.stationIds(order)){try{String outcome=tracking.process(hq,order,station);if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;}}}catch(RuntimeException e){failed++;}
|
||||
try{var stations=new AutomaticStationMail(this);for(var stop:stations.stops(order)){try{String outcome=stations.process(hq,order,id(stop,"tr_id"));if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;}}}catch(RuntimeException e){failed++;}
|
||||
}
|
||||
}catch(RuntimeException e){failed++;state="BLOCKED";}}
|
||||
lastRun=new Run(LocalDateTime.now().withNano(0).toString(),checked,sent,uncertain,failed,state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
|
||||
/** Eligibility from the current server's inc_automailer.inc.php (workers 1, 2, 3 and 5). */
|
||||
final class AutomaticOrderMailPolicy {
|
||||
enum Event {
|
||||
CREATED("Erfassung","jb_automailsent3","cs_jbstatusmail3","automailer3","DISPOSITION",1),
|
||||
PICKUP("Abholung","jb_automailsent2","cs_jbstatusmail2","automailer2","PICKUP",2),
|
||||
COMPLETION("Erledigung","jb_automailsent","cs_jbstatusmail","automailer","COMPLETION",4);
|
||||
final String label,field,customerField,semaphore,mode;final int bit;
|
||||
Event(String label,String field,String customerField,String semaphore,String mode,int bit){this.label=label;this.field=field;this.customerField=customerField;this.semaphore=semaphore;this.mode=mode;this.bit=bit;}
|
||||
}
|
||||
record Settings(boolean legacyEnabled,int completionDays,int pickupDays,Set<String> excluded) {}
|
||||
record Decision(Event event,boolean additional,String state,String reason,List<String> recipients,long marker,String revision) {
|
||||
String key(){return (additional?"ADDITIONAL_":"STANDARD_")+event.name();}
|
||||
}
|
||||
final DatabaseService db;
|
||||
AutomaticOrderMailPolicy(DatabaseService db){this.db=db;}
|
||||
String global(String key){var rows=db.queryForList("SELECT par_value FROM parameter WHERE par_key=? AND hq_id=0 AND emp_id=0",key);if(rows.size()>1)throw bad("Mehrdeutige automatische Mailvorgabe: "+key);return rows.isEmpty()?"":text(rows.getFirst(),"par_value");}
|
||||
Settings settings(){String enabled=global("AUTOMAILER_ENABLED");if(!Set.of("","0","1").contains(enabled))throw bad("Die globale Automailer-Freigabe ist ungültig.");return new Settings(enabled.equals("1"),days(global("AUTOMAILER_STARTTIME_IN_DAYS")),days(global("AUTOMAILER_STARTTIME_PICKUP_IN_DAYS")),excluded(global("EMAIL_CRVHSID_NO_MAIL")));}
|
||||
static int days(String value){if(value.isBlank())return 0;if(!value.matches("[0-9]{1,4}")||Integer.parseInt(value)>3660)throw bad("Der Zeitraum für automatische Mails ist ungültig.");return Integer.parseInt(value);}
|
||||
static Set<String> excluded(String value){if(value.isBlank())return Set.of();var result=new LinkedHashSet<String>();for(String s:value.split(",",-1)){s=s.strip();if(!s.matches("[A-Za-z0-9_.-]{1,32}"))throw bad("Die Fahrzeug-Ausschlussliste für automatische Mails ist ungültig.");result.add(s.toUpperCase(Locale.ROOT));}if(result.size()>500)throw bad("Die Fahrzeug-Ausschlussliste ist zu umfangreich.");return Collections.unmodifiableSet(result);}
|
||||
static boolean recent(LocalDateTime value,LocalDateTime lower){return value!=null&&value.isAfter(lower);}
|
||||
static boolean statusEligible(Event event,Map<String,Object> job,Settings settings,LocalDateTime now,boolean additional){
|
||||
if(id(job,"jb_storno")!=0||id(job,"jb_offer")!=0)return false;
|
||||
return switch(event){
|
||||
case CREATED -> Set.of(0L,1L,8L,9L).contains(id(job,"jb_status"))&&recent(dateTime(job,"jb_ordertime"),additional?now.minusDays(settings.completionDays()):now.minusHours(2));
|
||||
case PICKUP -> id(job,"jb_status")==1&&recent(dateTime(job,"jb_taketime"),now.minusDays(additional?settings.completionDays():settings.pickupDays()));
|
||||
case COMPLETION -> id(job,"jb_status")==2&&recent(dateTime(job,"jb_finishtime"),now.minusDays(settings.completionDays()));
|
||||
};
|
||||
}
|
||||
static int context(String value){if(value.matches("[0-9]{1,9}"))return Integer.parseInt(value);return 1024;}
|
||||
static boolean additionalDone(String value,Event event){return Set.of("MAIL_SENT","MAIL_NOT_SENT").contains(value)||(context(value)&(1024|event.bit))==(1024|event.bit);}
|
||||
List<Map<String,Object>> customer(long center,long hq){return db.queryForList("""
|
||||
SELECT cc.csc_id,cs.cs_id,cs.cs_jbstatusmail,cs.cs_jbstatusmail2,cs.cs_jbstatusmail3,
|
||||
cs.cs_jbstatusmail2csc,cs.cs_jbstatusmail_fields,cs.cs_jb_jam_waittime,u.usr_email,ca.cscad_email
|
||||
FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id
|
||||
JOIN employee e ON e.emp_id=cs.cs_admin JOIN user u ON u.usr_id=e.usr_id
|
||||
JOIN costcenteraddress ca ON ca.csc_id=cc.csc_id AND ca.adt_id=2
|
||||
WHERE cc.csc_id=? AND cs.hq_id=?
|
||||
""",center,hq);}
|
||||
Decision decide(Map<String,Object> job,Event event,boolean additional,Settings settings,LocalDateTime now){
|
||||
String state="WAITING",reason="Auftragsstatus oder Versandzeitraum passt noch nicht.";var recipients=new LinkedHashMap<String,String>();var sources=new ArrayList<Object>();long marker=0;
|
||||
if(statusEligible(event,job,settings,now,additional)){
|
||||
state="READY";reason="Die PHP-Versandbedingungen sind erfüllt.";
|
||||
if(!additional){String sent=text(job,event.field);if(sent.isBlank()||Set.of("996","998","999").contains(sent)||event==Event.COMPLETION&&sent.equals("997")){state="DONE";reason=sent.equals("996")?"Die Standardempfänger wurden für diesen Auftrag ersetzt.":"Das bestehende Versandkennzeichen lässt keinen automatischen Versand zu.";}
|
||||
if(event!=Event.CREATED&&settings.excluded().contains(text(job,"cr_sid").toUpperCase(Locale.ROOT))){state="EXCLUDED";reason="Das Fahrzeug ist vom automatischen Statusversand ausgeschlossen.";}}
|
||||
if(state.equals("READY")&&event!=Event.CREATED){var stops=db.queryForList("SELECT t.tr_id,t.tr_status FROM tour t WHERE t.jb_id=? AND t.tr_sort=1",id(job,"jb_id"));sources.add(stops);if(stops.size()!=1)throw bad("Die erste Auftragsstation ist nicht eindeutig.");if(id(stops.getFirst(),"tr_status")!=1){state="WAITING";reason="Die erste Station ist noch nicht erledigt.";}}
|
||||
if(state.equals("READY")&&!additional&&event==Event.COMPLETION){var photos=db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='photos_last_station'",id(job,"jb_id"));sources.add(photos);if(photos.size()>1)throw bad("Die Fotovormerkung ist mehrdeutig.");String count=photos.isEmpty()?"":text(photos.getFirst(),"gdc_content").strip();if(!count.isEmpty()&&!count.matches("[0-9]{1,6}"))throw bad("Die Fotovormerkung ist ungültig.");if(!count.isEmpty()&&Integer.parseInt(count)>0&&!dateTime(job,"jb_finishtime").isBefore(now.minusMinutes(1))){state="WAITING";reason="Die Minute Wartezeit für Fotos der letzten Station läuft noch.";}}
|
||||
if(state.equals("READY")){
|
||||
if(additional){var rows=db.queryForList("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_cashpayer_mail'",id(job,"jb_id"));sources.add(rows);if(rows.size()>1)throw bad("Die zusätzlichen Auftragsempfänger sind mehrdeutig.");
|
||||
if(rows.isEmpty()||text(rows.getFirst(),"gdc_content").isBlank()){state="DISABLED";reason="Keine zusätzlichen Auftragsempfänger hinterlegt.";}
|
||||
else {var row=rows.getFirst();marker=id(row,"gdc_id");if(additionalDone(text(row,"gdc_context"),event)){state="DONE";reason="Dieser Status wurde für die zusätzlichen Empfänger bereits verarbeitet.";}
|
||||
else {var customer=db.queryForList("SELECT cs.* FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id WHERE cc.csc_id=? AND cs.hq_id=?",id(job,"csc_id_payer"),id(job,"hq_id"));sources.add(customer);if(customer.size()!=1)throw bad("Die Kunden-Mailvorgaben sind nicht eindeutig verknüpft.");if(id(customer.getFirst(),event.customerField)!=1){state="DISABLED";reason="Dieser Statusversand ist beim Rechnungskunden ausgeschaltet.";}else for(String address:OrderEntryMail.addresses(text(row,"gdc_content").replace(" ","")))recipients.putIfAbsent(address.toLowerCase(Locale.ROOT),address);}}
|
||||
}else{
|
||||
var centers=new LinkedHashSet<Long>();centers.add(id(job,"csc_id_payer"));if(id(job,"csc_id_payer_cash")>0)centers.add(id(job,"csc_id_payer_cash"));
|
||||
for(long center:centers){var customer=customer(center,id(job,"hq_id"));sources.add(customer);if(customer.size()>1)throw bad("Die Kunden-Mailvorgaben sind mehrdeutig verknüpft.");if(customer.isEmpty()||id(customer.getFirst(),event.customerField)!=1)continue;var row=customer.getFirst();long selection=id(row,"cs_jbstatusmail2csc");if(!Set.of(0L,1L).contains(selection))throw bad("Die Empfängerauswahl des Kunden ist ungültig.");String raw=text(row,selection==1&&!text(row,"cscad_email").isBlank()?"cscad_email":"usr_email");
|
||||
// PHP only selects pickup/creation if the explicitly selected address exists.
|
||||
if(event!=Event.COMPLETION&&text(row,selection==1?"cscad_email":"usr_email").isBlank())continue;
|
||||
for(String address:OrderEntryMail.addresses(raw.replace(" ","")))recipients.putIfAbsent(address.toLowerCase(Locale.ROOT),address);}
|
||||
if(recipients.isEmpty()){state="DISABLED";reason="Kein eingeschalteter Kundenversand mit gültiger Empfängeradresse.";}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Decision(event,additional,state,reason,List.copyOf(recipients.values()),marker,revision(List.of(job,event,additional,settings,sources,recipients)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
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;
|
||||
|
||||
/** Station completion notifications from current PHP automailer4, with durable SMTP claims. */
|
||||
class AutomaticStationMail {
|
||||
record Decision(String state,String reason,List<String> recipients,String revision) {}
|
||||
record Prepared(Decision decision,OrderCustomerMailContent.Content content,OrderEntryRequestMail.Envelope envelope,String revision) {}
|
||||
final AutomaticOrderMail service;
|
||||
AutomaticStationMail(AutomaticOrderMail service){this.service=service;}
|
||||
static String key(long station){return "STATION_"+station;}
|
||||
List<Map<String,Object>> stops(long order){
|
||||
var rows=service.db.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id LIMIT 1001",order);
|
||||
if(rows.size()>1000)throw bad("Zu viele Stationen für die automatische Mailprüfung.");return rows;
|
||||
}
|
||||
Map<String,Object> stop(long order,long station){return one(service.db.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? AND t.tr_id=?",order,station));}
|
||||
static boolean eligible(Map<String,Object> job,Map<String,Object> stop,LocalDateTime now){
|
||||
var orderTime=dateTime(job,"jb_ordertime");var finish=dateTime(stop,"tr_finishtime");
|
||||
return id(job,"jb_status")==1&&id(job,"jb_storno")==0&&id(stop,"tr_status")==1&&orderTime!=null&&!orderTime.isBefore(now.minusDays(1))&&finish!=null&&finish.isAfter(now.minusDays(1));
|
||||
}
|
||||
static boolean photoReady(String raw,LocalDateTime finish,LocalDateTime now){
|
||||
if(raw.isBlank())return true;int separator=raw.indexOf('|');if(separator<1)return false;
|
||||
String count=raw.substring(0,separator).strip();return count.matches("[0-9]{1,6}")&&Integer.parseInt(count)>0&&finish.isBefore(now.minusMinutes(1));
|
||||
}
|
||||
Decision decide(Map<String,Object> job,Map<String,Object> stop,AutomaticOrderMailPolicy.Settings settings,LocalDateTime now){
|
||||
String state="WAITING",reason="Die Station muss erledigt sein; Auftrag und Stationsabschluss dürfen höchstens einen Tag zurückliegen.";
|
||||
var sources=new ArrayList<Object>();var recipients=new LinkedHashMap<String,String>();long station=id(stop,"tr_id");
|
||||
var markers=service.db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='auto_mail_station'",station);sources.add(markers);
|
||||
if(!markers.isEmpty()){state="DONE";reason="Für diese Station besteht bereits ein PHP-Versandkennzeichen.";}
|
||||
else if(eligible(job,stop,now)){
|
||||
state="READY";reason="Die PHP-Bedingungen für die Stationsabschluss-Mail sind erfüllt.";
|
||||
if(text(job,"jb_automailsent").isBlank()||Set.of("996","997","998","999").contains(text(job,"jb_automailsent"))){state="DONE";reason="Das Auftrags-Versandkennzeichen sperrt weitere Stationsmails.";}
|
||||
else if(settings.excluded().contains(text(job,"cr_sid").toUpperCase(Locale.ROOT))){state="EXCLUDED";reason="Das Fahrzeug ist vom automatischen Statusversand ausgeschlossen.";}
|
||||
if(state.equals("READY")){
|
||||
var photos=service.db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_photo'",station);sources.add(photos);
|
||||
if(photos.size()>1)throw bad("Die Fotovormerkung dieser Station ist mehrdeutig.");
|
||||
if(!photoReady(photos.isEmpty()?"":text(photos.getFirst(),"gdc_content"),dateTime(stop,"tr_finishtime"),now)){state="WAITING";reason="Die Fotovormerkung oder die Minute Wartezeit lässt noch keinen Stationsversand zu.";}
|
||||
}
|
||||
if(state.equals("READY")){
|
||||
var policy=new AutomaticOrderMailPolicy(service.db);var centers=new LinkedHashSet<Long>();centers.add(id(job,"csc_id_payer"));if(id(job,"csc_id_payer_cash")>0)centers.add(id(job,"csc_id_payer_cash"));
|
||||
for(long center:centers){var customers=policy.customer(center,id(job,"hq_id"));sources.add(customers);if(customers.size()>1)throw bad("Die Kunden-Mailvorgaben sind mehrdeutig verknüpft.");if(customers.isEmpty())continue;var customer=customers.getFirst();
|
||||
if(!OrderCustomerMailContent.bit(text(customer,"cs_jbstatusmail_fields"),4))continue;long selection=id(customer,"cs_jbstatusmail2csc");if(!Set.of(0L,1L).contains(selection))throw bad("Die Empfängerauswahl des Kunden ist ungültig.");
|
||||
String raw=text(customer,selection==1&&!text(customer,"cscad_email").isBlank()?"cscad_email":"usr_email");for(String address:OrderEntryMail.addresses(raw.replace(" ","")))recipients.putIfAbsent(address.toLowerCase(Locale.ROOT),address);
|
||||
}
|
||||
if(recipients.isEmpty()){state="DISABLED";reason="Keine eingeschaltete Stationsmail mit gültiger Kundenadresse.";}
|
||||
}
|
||||
}
|
||||
return new Decision(state,reason,List.copyOf(recipients.values()),revision(List.of(job,stop,settings,sources,recipients)));
|
||||
}
|
||||
List<Map<String,Object>> view(Map<String,Object> job,AutomaticOrderMailPolicy.Settings settings,LocalDateTime now){
|
||||
var rows=new ArrayList<Map<String,Object>>();long hq=id(job,"hq_id"),order=id(job,"jb_id");
|
||||
for(var stop:stops(order)){long station=id(stop,"tr_id");String key=key(station),event="Station "+id(stop,"tr_sort");try{
|
||||
var receipt=service.receipt(hq,order,key);var decision=receipt==null?decide(job,stop,settings,now):null;
|
||||
rows.add(fields("key",key,"event",event,"additional",false,"state",receipt==null?java.util.Objects.requireNonNull(decision).state():receipt.state(),"reason",receipt==null?java.util.Objects.requireNonNull(decision).reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?java.util.Objects.requireNonNull(decision).recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));
|
||||
}catch(RuntimeException e){rows.add(fields("key",key,"event",event,"additional",false,"state","BLOCKED","reason",AutomaticOrderMail.safe(e),"recipients",List.of(),"startedAt","","completedAt",""));}}
|
||||
return rows;
|
||||
}
|
||||
Prepared prepare(long hq,long order,long station,LocalDateTime now){
|
||||
var job=service.job(hq,order);var stop=stop(order,station);var policy=new AutomaticOrderMailPolicy(service.db);var decision=decide(job,stop,policy.settings(),now);if(!decision.state().equals("READY"))return null;
|
||||
var locks=service.db.queryForList("SELECT sp_content FROM "+service.support.historySchema+".semaphor WHERE sp_obj_type='tr' AND sp_obj_id=? AND sp_fieldname='automailer4'",station);
|
||||
if(locks.size()>1||locks.stream().anyMatch(r->!text(r,"sp_content").isBlank()))throw bad("Ein PHP-Stationsmailvorgang ist noch gesperrt oder nicht eindeutig abgeschlossen.");
|
||||
var renderer=new OrderCustomerMailContent(service.db,hq,service.legacy);String from=AppointmentNotifications.address(renderer.fallback("MAIL_SENDER_ADDRESS"));
|
||||
String hidden=renderer.parameter(0,"MAIL_BCC_STATION_ADDRESS");if(hidden.isBlank())hidden=renderer.fallback("MAIL_BCC_ADDRESS");var seen=new HashSet<String>();decision.recipients().forEach(v->seen.add(v.toLowerCase(Locale.ROOT)));
|
||||
var copies=OrderEntryMail.addresses(renderer.fallback("MAIL_CC_ADDRESS")).stream().filter(v->seen.add(v.toLowerCase(Locale.ROOT))).toList();var hiddenCopies=OrderEntryMail.addresses(hidden).stream().filter(v->seen.add(v.toLowerCase(Locale.ROOT))).toList();if(seen.size()>20)throw bad("Zu viele Empfänger einschließlich Cc/Bcc.");
|
||||
var envelope=new OrderEntryRequestMail.Envelope(from,decision.recipients(),copies,hiddenCopies);var content=AutomaticStationMailContent.build(renderer,job,stop,now.toLocalDate(),service.support.historySchema);
|
||||
return new Prepared(decision,content,envelope,revision(List.of(decision.revision(),content.revision(),envelope)));
|
||||
}
|
||||
String process(long hq,long order,long station){
|
||||
service.available(hq);String key=key(station);if(service.receipt(hq,order,key)!=null)return "ALREADY_STARTED";var time=service.now();String token=UUID.randomUUID().toString();var prepared=service.db.readTransaction(()->prepare(hq,order,station,time));if(prepared==null)return "SKIPPED";
|
||||
service.support.transactional("genericdatacontainer");var claim=service.db.automaticOrderMailWriteTransaction(service.support.historySchema.replace("`",""),()->{
|
||||
service.available(hq);if(service.receipt(hq,order,key)!=null)return null;var current=prepare(hq,order,station,service.now());if(current==null||!current.revision().equals(prepared.revision()))throw OrderEntryService.conflict("Die Stationsmaildaten wurden geändert.");
|
||||
var delivery=new AutomaticOrderMail.Delivery(token,hq,order,key,"Station "+id(stop(order,station),"tr_sort"),false,"STARTED",time.toString(),"",prepared.content().subject(),prepared.envelope(),prepared.revision());
|
||||
service.db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",key,"gdc_context","STARTED","gdc_content",service.support.encode(delivery)));
|
||||
service.db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",station,"gdc_gen_fieldname","auto_mail_station","gdc_context",token,"gdc_content","998"));
|
||||
service.support.audit(service.actor(hq),order,0,0,203,"Automatische Stationsmail zur Übergabe vorgemerkt",token);return delivery;
|
||||
});if(claim==null)return "ALREADY_STARTED";
|
||||
String state="SENT";try{var e=prepared.envelope();service.transport.sendOrderHtml(e.from(),e.to(),e.cc(),e.bcc(),prepared.content().subject(),prepared.content().html(),"",null);}catch(RuntimeException e){state="UNCERTAIN";}
|
||||
finish(claim,station,state);return state;
|
||||
}
|
||||
void finish(AutomaticOrderMail.Delivery claim,long station,String state){
|
||||
service.db.writeTransaction(()->{
|
||||
var rows=service.db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),claim.key());var current=service.decode(one(rows),claim.headquarters(),claim.order());if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Stationsmailbeleg wurde verändert.");
|
||||
var done=new AutomaticOrderMail.Delivery(claim.token(),claim.headquarters(),claim.order(),claim.key(),claim.event(),false,state,claim.startedAt(),service.now().toString(),claim.subject(),claim.envelope(),claim.revision());service.db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",service.support.encode(done),state,id(rows.getFirst(),"gdc_id"));
|
||||
if(state.equals("SENT"))service.db.update("UPDATE genericdatacontainer SET gdc_content='999' WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='auto_mail_station' AND gdc_context=? AND gdc_content='998'",station,claim.token());
|
||||
service.support.audit(service.actor(claim.headquarters()),claim.order(),0,0,state.equals("SENT")?22:23,"Automatische Stationsmail: "+AutomaticOrderMail.message(state),claim.token()+":result");return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.OrderCustomerMailContent.*;
|
||||
|
||||
/** The station-only body of current automailer4.php. It contains neither prices nor other stops. */
|
||||
final class AutomaticStationMailContent {
|
||||
static Content build(OrderCustomerMailContent c,Map<String,Object> job,Map<String,Object> stop,LocalDate day,String historySchema){
|
||||
c.language=OrderMailLanguage.load(c.db,c.parameter(0,"SYSTEM_LANGUAGE_DEFAULT"),false,"include/mcglobal.inc.php");
|
||||
var customer=one(c.db.queryForList("SELECT cs.cs_id,cs.cs_jbstatusmail_fields FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id WHERE cc.csc_id=? AND cs.hq_id=?",id(job,"csc_id_payer"),c.hq));
|
||||
String date=c.parameter(0,"MAIL_JOBDATE").equals("1")?time(job,"jb_ordertime"):day.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"));
|
||||
StringBuilder details=new StringBuilder("<h3>").append(escape(c.t("Transportdatum:"))).append(' ').append(escape(date)).append("</h3><table>")
|
||||
.append(c.row("AUFTRAG:",id(job,"jb_id"))).append(c.row("KOMMISSIONSNUMMER:",text(stop,"tr_commission_no"))).append(c.row("ERLEDIGUNGSZEITPUNKT:",time(stop,"tr_finishtime")))
|
||||
.append(c.row("ERLEDIGTE STATION:",text(stop,"tr_comp")+" "+text(stop,"tr_comp2"))).append(c.row("Annahme",text(stop,"tr_signname")))
|
||||
.append(c.row("Straße + Hausnr.",text(stop,"ad_street")+" "+text(stop,"tr_hsno"))).append(c.row("PLZ + Ort",text(stop,"ad_zipcode")+" "+text(stop,"ad_city")));
|
||||
// The PHP station mail exposes only the delivery event and its remark, not general station/internal remarks.
|
||||
if(c.fallback("MASK_JOBDETAILS_STATIONS_POD_SCAN_EVENTS").equals("1")&&bit(text(customer,"cs_jbstatusmail_fields"),8)){
|
||||
var data=new OrderDeliveryData(c.db,historySchema);String code=data.value("tr",id(stop,"tr_id"),"del_code"),remark=data.value("tr",id(stop,"tr_id"),"del_rem");
|
||||
String label=code.isBlank()?"":OrderDeliveryData.deliveryLabel(code,0,c.object("JOBDETAILS_STATIONS_DELIVERY_CODES",id(customer,"cs_id")));
|
||||
details.append(c.row("EVENT-BEMERKUNG:",label+" "+remark));
|
||||
}
|
||||
details.append("</table>");boolean early=c.parameter(0,"MAIL_JOBDATA").equals("1");var warnings=new ArrayList<String>();
|
||||
StringBuilder body=new StringBuilder("<div>").append(rich(c.mailText("MAIL_PRE_SALUTATION_TEXT","Sehr geehrte Damen und Herren,",false))).append("</div><p>").append(escape(c.t("die Erledigung der Station erfolgte."))).append("</p>");if(early)body.append(details);
|
||||
body.append("<p>").append(rich(c.mailText("MAIL_TEXT_REGARDS","Mit freundlichem Gruß,",false))).append("</p><div>").append(rich(c.t(c.local("MAIL_SALUTATION_TEXT")))).append("</div>");
|
||||
String logo=c.local("IMG_LOGO_NAME"),height=c.local("IMG_LOGO_HEIGHT"),width=c.local("IMG_LOGO_WIDTH");
|
||||
if(!logo.isBlank()&&!c.legacy.isBlank()&&!height.isBlank()&&!width.isBlank()){
|
||||
if(!logo.matches("[A-Za-z0-9_. -]{1,150}")||Set.of(".","..").contains(logo)||!height.matches("[1-9][0-9]{0,3}")||!width.matches("[1-9][0-9]{0,3}"))throw bad("Die Logo-Vorgaben der Stationsmail sind ungültig.");
|
||||
String web=c.local("MAIL_FOOTER_WEB_PAGE"),link=web.isBlank()?"":Objects.toString(ApiController.safeLegacyUrl(web.contains("://")?web:"http://"+web),"");if(!web.isBlank()&&link.isBlank())throw bad("Die Homepage der Mailfußzeile ist ungültig.");
|
||||
body.append("<p>");if(!link.isBlank())body.append("<a href=\"").append(escape(link)).append("\">");body.append("<img alt=\"Logo\" src=\"").append(escape(c.legacy+"/images/external/"+org.springframework.web.util.UriUtils.encodePathSegment(logo,java.util.Objects.requireNonNull(java.nio.charset.StandardCharsets.UTF_8)))).append("\" width=\"").append(width).append("\" height=\"").append(height).append("\">");if(!link.isBlank())body.append("</a>");body.append("</p>");warnings.add("Das Logo wird beim Empfänger von der bisherigen Anwendungsadresse geladen.");
|
||||
}
|
||||
if(c.local("MAIL_FOOTER_ENABLED").equals("1")){
|
||||
for(String key:List.of("MAIL_FOOTER_ADDRESS","MAIL_FOOTER_CONTACT","MAIL_FOOTER_IMPRESSUM"))body.append("<div>").append(rich(c.local(key))).append("</div>");
|
||||
for(int i=1;i<=5;i++){String value=c.fallback("MAIL_FOOTER_RESPONSIBILITY"+(i==1?"":"_"+i));if(value.isBlank())break;body.append("<p>").append(rich(value)).append("</p>");}body.append("<p>").append(rich(c.local("MAIL_FOOTER_THINK_GREEN"))).append("</p>");
|
||||
}
|
||||
if(!early)body.append(details);String style=NewsletterTemplate.safeStyle("font-family:"+c.defaulted("MAIL_CSS_FONT_TYPE","Arial, sans-serif"));
|
||||
String html="<!doctype html><html lang=\""+c.language.tag+"\"><head><meta charset=\"utf-8\"><style>body{"+style+"font-size:12px;margin:20px}table{border-collapse:collapse;width:100%}td,th{padding:5px;border:1px solid #ddd;text-align:left;vertical-align:top}img{max-width:100%}</style></head><body>"+body+"</body></html>";
|
||||
if(html.length()>400000)throw bad("Die Stationsmail ist zu umfangreich.");String subject=c.t("Station")+" "+id(stop,"tr_sort")+" "+c.t("des Transportauftrags!")+" "+id(job,"jb_id")+" "+c.t("wurde erledigt!");warnings.addAll(c.language.warnings());
|
||||
return new Content(subject,html,List.copyOf(warnings),revision(List.of(job,stop,customer,c.parameters,html,subject,c.language.revision)),false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import java.math.*;
|
||||
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;
|
||||
|
||||
/** automailer9: vehicle information after acceptance while pickup is still outstanding. */
|
||||
class AutomaticTakenMail {
|
||||
static final String KEY="TAKEN_DELAY",LABEL="Fahrzeuginfo nach Annahme",FIELD="jb_email_by_delay";
|
||||
record Decision(String state,String reason,List<String> recipients,long marker,String revision) {}
|
||||
record Prepared(Decision decision,OrderCustomerMailContent.Content content,OrderEntryRequestMail.Envelope envelope,String revision) {}
|
||||
final AutomaticOrderMail service;
|
||||
AutomaticTakenMail(AutomaticOrderMail service){this.service=service;}
|
||||
static long delayMicros(String raw){
|
||||
BigDecimal value;
|
||||
try{value=new BigDecimal(raw.strip());}catch(NumberFormatException e){return 0;}
|
||||
if(value.abs().compareTo(BigDecimal.valueOf(31_536_000))>0)throw bad("Die Wartezeit der Fahrzeuginfo-Mail überschreitet ein Jahr.");
|
||||
return value.movePointRight(6).setScale(0,RoundingMode.DOWN).longValueExact();
|
||||
}
|
||||
static boolean window(Map<String,Object> job,AutomaticOrderMailPolicy.Settings settings,long delay,LocalDateTime now){
|
||||
var ordered=dateTime(job,"jb_ordertime");var taken=dateTime(job,"jb_taketime");
|
||||
return id(job,"jb_status")==1&&id(job,"jb_storno")==0&&ordered!=null&&ordered.isAfter(now.minusDays(settings.completionDays()))&&taken!=null&&taken.plus(delay,ChronoUnit.MICROS).isBefore(now);
|
||||
}
|
||||
Decision decide(Map<String,Object> job,AutomaticOrderMailPolicy.Settings settings,LocalDateTime now){
|
||||
var policy=new AutomaticOrderMailPolicy(service.db);long delay=delayMicros(policy.global("AUTOMAILER_TIME_AFTER_TAKEN_BEFORE_PICKUP_IN_SECONDS"));
|
||||
String state="WAITING",reason="Annahmezeit oder Auftragsstatus erfüllt die Wartezeit vor Abholung noch nicht.";long marker=0;var sources=new ArrayList<Object>();var recipients=new ArrayList<String>();
|
||||
if(window(job,settings,delay,now)){
|
||||
state="READY";reason="Der Auftrag ist angenommen, die Wartezeit abgelaufen und die erste Station noch offen.";
|
||||
if(settings.excluded().contains(text(job,"cr_sid").toUpperCase(Locale.ROOT))){state="EXCLUDED";reason="Das Fahrzeug ist vom automatischen Statusversand ausgeschlossen.";}
|
||||
if(state.equals("READY")){
|
||||
var markers=service.db.queryForList("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",id(job,"jb_id"),FIELD);sources.add(markers);
|
||||
if(markers.size()>1)throw bad("Die Fahrzeuginfo-Mailkennzeichnung ist mehrdeutig.");
|
||||
if(!markers.isEmpty()){marker=id(markers.getFirst(),"gdc_id");if(Set.of("SENT_OK","SENT_NOK").contains(text(markers.getFirst(),"gdc_context"))){state="DONE";reason="Die bestehende Fahrzeuginfo-Mailkennzeichnung lässt keinen Versand zu.";}}
|
||||
}
|
||||
if(state.equals("READY")){
|
||||
var stops=service.db.queryForList("SELECT t.tr_id,t.tr_status FROM tour t WHERE t.jb_id=? AND t.tr_sort=1",id(job,"jb_id"));sources.add(stops);
|
||||
if(stops.size()!=1)throw bad("Die erste Auftragsstation ist nicht eindeutig.");
|
||||
if(stops.getFirst().get("tr_status")==null||id(stops.getFirst(),"tr_status")!=0){state="WAITING";reason="Die erste Station ist nicht mehr offen.";}
|
||||
}
|
||||
if(state.equals("READY")){
|
||||
var customer=policy.customer(id(job,"csc_id_payer"),id(job,"hq_id"));sources.add(customer);
|
||||
if(customer.size()>1)throw bad("Die Kunden-Mailvorgaben sind mehrdeutig.");
|
||||
if(customer.isEmpty()||!OrderCustomerMailContent.bit(text(customer.getFirst(),"cs_jbstatusmail_fields"),6)){state="DISABLED";reason="Die Kundenoption KFZ-Kennzeichen für Auftragsmails ist ausgeschaltet.";}
|
||||
else{var row=customer.getFirst();long selection=id(row,"cs_jbstatusmail2csc");if(!Set.of(0L,1L).contains(selection))throw bad("Die Empfängerauswahl des Kunden ist ungültig.");recipients.addAll(OrderEntryMail.addresses(text(row,selection==1?"cscad_email":"usr_email").replace(" ","")));if(recipients.isEmpty()){state="DISABLED";reason="Die ausgewählte Kundenadresse ist leer.";}}
|
||||
}
|
||||
if(state.equals("READY")){
|
||||
var courier=service.db.queryForList("SELECT cr.cr_id,cv.crvh_sid,cv.crvh_vh_sign,u.usr_phone FROM courier cr JOIN user u ON u.usr_id=cr.usr_id JOIN couriervehicle cv ON cv.cr_id=cr.cr_id AND cv.crvh_sid=? WHERE cr.cr_id=?",text(job,"cr_sid"),id(job,"cr_id"));sources.add(courier);
|
||||
if(courier.size()>1)throw bad("Der ausführende Transporteur oder sein Fahrzeug ist mehrdeutig.");
|
||||
if(courier.isEmpty()){state="WAITING";reason="Ein eindeutig zugeordneter Transporteur mit Fahrzeug und Benutzerkonto fehlt.";}
|
||||
}
|
||||
}
|
||||
return new Decision(state,reason,List.copyOf(recipients),marker,revision(List.of(job,settings,delay,sources,recipients)));
|
||||
}
|
||||
Map<String,Object> view(Map<String,Object> job,AutomaticOrderMailPolicy.Settings settings,LocalDateTime now){
|
||||
try{var receipt=service.receipt(id(job,"hq_id"),id(job,"jb_id"),KEY);var d=receipt==null?decide(job,settings,now):null;
|
||||
return fields("key",KEY,"event",LABEL,"additional",false,"state",receipt==null?java.util.Objects.requireNonNull(d).state():receipt.state(),"reason",receipt==null?java.util.Objects.requireNonNull(d).reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?java.util.Objects.requireNonNull(d).recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt());
|
||||
}catch(RuntimeException e){return fields("key",KEY,"event",LABEL,"additional",false,"state","BLOCKED","reason",AutomaticOrderMail.safe(e),"recipients",List.of(),"startedAt","","completedAt","");}
|
||||
}
|
||||
Prepared prepare(long hq,long order,LocalDateTime now,String token){
|
||||
var job=service.job(hq,order);var decision=decide(job,new AutomaticOrderMailPolicy(service.db).settings(),now);if(!decision.state().equals("READY"))return null;
|
||||
var locks=service.db.queryForList("SELECT sp_content FROM "+service.support.historySchema+".semaphor WHERE sp_obj_type='jb' AND sp_obj_id=? AND sp_fieldname='automailer9'",order);
|
||||
if(locks.size()>1||locks.stream().anyMatch(r->!text(r,"sp_content").isBlank()))throw bad("Ein PHP-Fahrzeuginfo-Mailvorgang ist noch gesperrt.");
|
||||
var renderer=new OrderCustomerMailContent(service.db,hq,service.legacy);var envelope=service.envelope(renderer,decision.recipients());
|
||||
var archive=dateTime(job,"jb_export_time")==null?null:new OrderArchive(service.db).load(order);
|
||||
var content=renderer.taken(job,now.toLocalDate(),new OrderDeliveryData(service.db,service.support.historySchema),token,archive);
|
||||
return new Prepared(decision,content,envelope,revision(List.of(decision.revision(),content.revision(),envelope)));
|
||||
}
|
||||
String process(long hq,long order){
|
||||
service.available(hq);if(service.receipt(hq,order,KEY)!=null)return "ALREADY_STARTED";
|
||||
var time=service.now();String token=UUID.randomUUID().toString();var prepared=service.db.readTransaction(()->prepare(hq,order,time,token));if(prepared==null)return "SKIPPED";
|
||||
byte[] pdf=prepared.content().pdf()?OrderMailPdf.create(order,prepared.content().html()):null;service.support.transactional("genericdatacontainer");
|
||||
var claim=service.db.automaticOrderMailWriteTransaction(service.support.historySchema.replace("`",""),()->{
|
||||
service.available(hq);if(service.receipt(hq,order,KEY)!=null)return null;var current=prepare(hq,order,service.now(),token);
|
||||
if(current==null||!current.revision().equals(prepared.revision()))throw OrderEntryService.conflict("Die Fahrzeuginfo-Maildaten wurden geändert.");
|
||||
var d=new AutomaticOrderMail.Delivery(token,hq,order,KEY,LABEL,false,"STARTED",time.toString(),"",prepared.content().subject(),prepared.envelope(),prepared.revision());
|
||||
service.db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",KEY,"gdc_context","STARTED","gdc_content",service.support.encode(d)));
|
||||
if(current.decision().marker()==0)service.db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",order,"gdc_gen_fieldname",FIELD,"gdc_content","","gdc_context","SENT_NOK"));
|
||||
else if(service.db.update("UPDATE genericdatacontainer SET gdc_context='SENT_NOK' WHERE gdc_id=?",current.decision().marker())!=1)throw bad("Die Fahrzeuginfo-Mailkennzeichnung wurde verändert.");
|
||||
service.support.audit(service.actor(hq),order,0,0,203,LABEL+": automatische Mail zur Übergabe vorgemerkt",token);return d;
|
||||
});if(claim==null)return "ALREADY_STARTED";
|
||||
String state="SENT";try{var e=prepared.envelope();service.transport.sendOrderHtml(e.from(),e.to(),e.cc(),e.bcc(),prepared.content().subject(),prepared.content().html(),"Auftrag_"+order+".pdf",pdf);}catch(RuntimeException e){state="UNCERTAIN";}
|
||||
finish(claim,state);return state;
|
||||
}
|
||||
void finish(AutomaticOrderMail.Delivery claim,String state){service.db.writeTransaction(()->{
|
||||
var rows=service.db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),KEY);var current=service.decode(one(rows),claim.headquarters(),claim.order());
|
||||
if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Fahrzeuginfo-Mailbeleg wurde verändert.");
|
||||
var markers=service.db.lockRows("SELECT gdc_id,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),FIELD);
|
||||
var marker=one(markers);if(!text(marker,"gdc_context").equals("SENT_NOK"))throw bad("Die Fahrzeuginfo-Mailkennzeichnung wurde verändert.");
|
||||
if(state.equals("SENT"))service.db.update("UPDATE genericdatacontainer SET gdc_context='SENT_OK' WHERE gdc_id=?",id(marker,"gdc_id"));
|
||||
var done=new AutomaticOrderMail.Delivery(claim.token(),claim.headquarters(),claim.order(),KEY,LABEL,false,state,claim.startedAt(),service.now().toString(),claim.subject(),claim.envelope(),claim.revision());
|
||||
service.db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",service.support.encode(done),state,id(rows.getFirst(),"gdc_id"));
|
||||
service.support.audit(service.actor(claim.headquarters()),claim.order(),0,0,193,LABEL+": "+AutomaticOrderMail.message(state),claim.token()+":result");return null;
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
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;
|
||||
|
||||
/** automailer7: one end-customer mail per station, with central PHP-compatible tracking links. */
|
||||
class AutomaticTrackingMail {
|
||||
static final String FIELD="tr_tracking";
|
||||
record Decision(String state,String reason,List<String> recipients,Map<String,Object> marker,Map<String,Object> customer) {}
|
||||
record Prepared(Decision decision,Map<String,Object> stop,List<Map<String,Object>> stops,Map<String,Object> courier,TrackingMailContent.Config config,String revision) {}
|
||||
final AutomaticOrderMail service;
|
||||
AutomaticTrackingMail(AutomaticOrderMail service){this.service=service;}
|
||||
static String key(long station){return "TRACKING_"+station;}
|
||||
static boolean window(Map<String,Object> job,LocalDateTime now){var ordered=dateTime(job,"jb_ordertime");return id(job,"jb_status")==1&&id(job,"jb_storno")==0&&ordered!=null&&!ordered.isBefore(now.minusDays(1));}
|
||||
List<Map<String,Object>> stops(long order){var rows=service.db.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id LIMIT 1001",order);if(rows.size()>1000)throw bad("Zu viele Tracking-Stationen.");return rows;}
|
||||
List<Long> stationIds(long order){return service.db.queryForList("SELECT DISTINCT t.tr_id FROM tour t JOIN genericdatacontainer ON gdc_obj_id=t.tr_id AND gdc_obj_type='tr' AND gdc_gen_fieldname='tr_tracking' WHERE t.jb_id=? ORDER BY t.tr_id LIMIT 1001",order).stream().map(r->id(r,"tr_id")).toList();}
|
||||
Decision decide(Map<String,Object> job,List<Map<String,Object>> stops,long station,LocalDateTime now){
|
||||
var markers=service.db.queryForList("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_tracking'",station);
|
||||
if(markers.size()>1)throw bad("Die Tracking-Kennzeichnung der Station ist mehrdeutig.");
|
||||
Map<String,Object> marker=markers.isEmpty()?Map.of():markers.getFirst(),customer=Map.of();String state="READY",reason="Der Auftrag ist abgeholt und für die Tracking-Mail freigegeben.";var recipients=new ArrayList<String>();
|
||||
if(marker.isEmpty()||text(marker,"gdc_content").isBlank()){state="DISABLED";reason="Für diese Station ist kein Tracking-Empfänger hinterlegt.";}
|
||||
else if(marker.get("gdc_context")==null||Set.of("SENT_OK","SENT_NOK").contains(text(marker,"gdc_context"))){state="DONE";reason="Die vorhandene Tracking-Kennzeichnung lässt keinen Versand zu.";}
|
||||
else if(!new AutomaticOrderMailPolicy(service.db).global("AUTOMAILER_2_TRACKING_ENABLED").equals("1")){state="DISABLED";reason="Automatische Endkunden-Tracking-Mails sind ausgeschaltet.";}
|
||||
else if(!window(job,now)){state="WAITING";reason="Der Auftrag muss angenommen, stornofrei und höchstens einen Tag alt sein.";}
|
||||
if(state.equals("READY")){
|
||||
var first=stops.stream().filter(r->id(r,"tr_sort")==1).toList();if(first.size()!=1)throw bad("Die erste Auftragsstation ist nicht eindeutig.");
|
||||
if(id(first.getFirst(),"tr_status")!=1){state="WAITING";reason="Die erste Station muss abgeholt sein.";}
|
||||
}
|
||||
if(state.equals("READY")){
|
||||
var customers=service.db.queryForList("SELECT cs.cs_id,cs.cs_tracking,cs.cs_blur_markup_time,cs.cs_charging_time,cmp.cmp_comp 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=?",id(job,"csc_id_payer"));
|
||||
if(customers.size()>1)throw bad("Der Tracking-Kunde ist mehrdeutig.");
|
||||
if(customers.isEmpty()||id(customers.getFirst(),"cs_tracking")!=1){state="DISABLED";reason="Endkundentracking ist für den zahlenden Kunden nicht eingeschaltet.";}else customer=customers.getFirst();
|
||||
}
|
||||
if(state.equals("READY")){
|
||||
String address=text(marker,"gdc_content").strip().split("\\|",-1)[0].replace(" ","");
|
||||
recipients.add(AppointmentNotifications.address(address));
|
||||
if(!service.db.queryForList("SELECT sp_content FROM "+service.support.historySchema+".semaphor WHERE sp_obj_type='jb' AND sp_obj_id=? AND sp_fieldname='automailer7' AND sp_content=?",id(job,"jb_id"),address).isEmpty())throw bad("Ein PHP-Tracking-Mailvorgang für diesen Empfänger ist noch gesperrt.");
|
||||
}
|
||||
return new Decision(state,reason,List.copyOf(recipients),marker,customer);
|
||||
}
|
||||
Prepared prepare(long hq,long order,long station,LocalDateTime now){
|
||||
var job=service.job(hq,order);var stops=stops(order);var stop=one(stops.stream().filter(r->id(r,"tr_id")==station).toList());var decision=decide(job,stops,station,now);if(!decision.state().equals("READY"))return null;
|
||||
if(stops.stream().map(r->id(r,"tr_sort")).distinct().count()!=stops.size())throw bad("Die Tracking-Tourreihenfolge ist mehrdeutig.");
|
||||
var courierRows=service.db.queryForList("SELECT cr.cr_id,cr.cr_gps_lat,cr.cr_gps_long,cr.cr_gps_time FROM courier cr WHERE cr.cr_id=?",id(job,"cr_id"));if(courierRows.size()>1)throw bad("Der Tracking-Transporteur ist mehrdeutig.");Map<String,Object> courier=courierRows.isEmpty()?Map.of():courierRows.getFirst();
|
||||
long charging=id(decision.customer(),"cs_charging_time"),blur=id(decision.customer(),"cs_blur_markup_time");if(charging<0||charging>60||blur<0||blur>60)throw bad("Die Tracking-Zeitvorgaben des Kunden sind ungültig.");
|
||||
var config=TrackingMailContent.config(new OrderCustomerMailContent(service.db,hq,service.legacy));
|
||||
return new Prepared(decision,stop,stops,courier,config,revision(List.of(job,stops,courier,decision,config.revision())));
|
||||
}
|
||||
List<Map<String,Object>> view(Map<String,Object> job,LocalDateTime now){
|
||||
var rows=new ArrayList<Map<String,Object>>();long hq=id(job,"hq_id"),order=id(job,"jb_id");var stops=stops(order);
|
||||
for(long station:stationIds(order)){String key=key(station),label="Tracking · Station "+id(one(stops.stream().filter(r->id(r,"tr_id")==station).toList()),"tr_sort");try{
|
||||
var receipt=service.receipt(hq,order,key);var d=receipt==null?decide(job,stops,station,now):null;
|
||||
if(receipt==null&&java.util.Objects.requireNonNull(d).state().equals("READY"))prepare(hq,order,station,now); // configuration check only; no ETA or token side effects
|
||||
rows.add(fields("key",key,"event",label,"additional",false,"recipientLabel","Stationsempfänger","state",receipt==null?java.util.Objects.requireNonNull(d).state():receipt.state(),"reason",receipt==null?java.util.Objects.requireNonNull(d).reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?java.util.Objects.requireNonNull(d).recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));
|
||||
}catch(RuntimeException e){rows.add(fields("key",key,"event",label,"additional",false,"recipientLabel","Stationsempfänger","state","BLOCKED","reason",AutomaticOrderMail.safe(e),"recipients",List.of(),"startedAt","","completedAt",""));}}
|
||||
return rows;
|
||||
}
|
||||
String process(long hq,long order,long station){
|
||||
service.available(hq);String key=key(station);if(service.receipt(hq,order,key)!=null)return "ALREADY_STARTED";
|
||||
var time=service.now();var prepared=service.db.readTransaction(()->prepare(hq,order,station,time));if(prepared==null)return "SKIPPED";
|
||||
var estimate=TrackingMailEta.estimate(text(prepared.decision().marker(),"gdc_content"),prepared.courier(),prepared.stops(),id(prepared.stop(),"tr_sort"),(int)id(prepared.decision().customer(),"cs_charging_time"),time,service.routing);
|
||||
service.support.transactional("genericdatacontainer");service.available(hq);
|
||||
String tracking=service.db.ensureStationTrackingToken(prepared.config().instance(),station);
|
||||
var content=TrackingMailContent.build(prepared.config(),prepared.decision().customer(),prepared.stop(),estimate,tracking);
|
||||
var envelope=new OrderEntryRequestMail.Envelope(prepared.config().from(),prepared.decision().recipients(),List.of(),List.of());String token=UUID.randomUUID().toString();
|
||||
var claim=service.db.automaticOrderMailWriteTransaction(service.support.historySchema.replace("`",""),()->{
|
||||
service.available(hq);if(service.receipt(hq,order,key)!=null)return null;var now=service.now();var current=prepare(hq,order,station,now);
|
||||
if(current==null||!current.revision().equals(prepared.revision())||now.isBefore(time)||!now.isBefore(time.plusMinutes(15)))throw OrderEntryService.conflict("Die Tracking-Maildaten wurden geändert oder die Fahrzeit ist veraltet.");
|
||||
var d=new AutomaticOrderMail.Delivery(token,hq,order,key,"Tracking · Station "+id(prepared.stop(),"tr_sort"),false,"STARTED",time.toString(),"",content.subject(),envelope,prepared.revision());
|
||||
service.db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",key,"gdc_context","STARTED","gdc_content",service.support.encode(d)));
|
||||
if(service.db.update("UPDATE genericdatacontainer SET gdc_context='SENT_NOK',gdc_content=? WHERE gdc_id=?",estimate.cacheContent(),id(current.decision().marker(),"gdc_id"))!=1)throw bad("Die Tracking-Kennzeichnung wurde verändert.");
|
||||
service.support.audit(service.actor(hq),order,0,0,203,"Endkunden-Tracking-Mail zur Übergabe vorgemerkt",token);return d;
|
||||
});if(claim==null)return "ALREADY_STARTED";
|
||||
String state="SENT";try{service.transport.sendOrderHtml(envelope.from(),envelope.to(),List.of(),List.of(),content.subject(),content.html(),"",null);}catch(RuntimeException e){state="UNCERTAIN";}
|
||||
finish(claim,station,state);return state;
|
||||
}
|
||||
void finish(AutomaticOrderMail.Delivery claim,long station,String state){service.db.writeTransaction(()->{
|
||||
var rows=service.db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),claim.key());var current=service.decode(one(rows),claim.headquarters(),claim.order());if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Tracking-Mailbeleg wurde verändert.");
|
||||
var marker=one(service.db.lockRows("SELECT gdc_id,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_tracking'",station));if(!text(marker,"gdc_context").equals("SENT_NOK"))throw bad("Die Tracking-Kennzeichnung wurde verändert.");
|
||||
if(state.equals("SENT"))service.db.update("UPDATE genericdatacontainer SET gdc_context='SENT_OK' WHERE gdc_id=?",id(marker,"gdc_id"));
|
||||
var done=new AutomaticOrderMail.Delivery(claim.token(),claim.headquarters(),claim.order(),claim.key(),claim.event(),false,state,claim.startedAt(),service.now().toString(),claim.subject(),claim.envelope(),claim.revision());service.db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",service.support.encode(done),state,id(rows.getFirst(),"gdc_id"));
|
||||
service.support.audit(service.actor(claim.headquarters()),claim.order(),0,0,193,"Endkunden-Tracking-Mail: "+AutomaticOrderMail.message(state),claim.token()+":result");return null;
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Same-server, transactional invoice rows consumed by the existing BI/B2B integration. */
|
||||
final class BatchInvoiceExports {
|
||||
private final DatabaseService db;private final BusinessSupport support;private final String table;
|
||||
BatchInvoiceExports(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;this.table=support.historySchema+".b2b_objects";}
|
||||
String schema(){return support.historySchema.replace("`","");}
|
||||
void requireTransactional(){support.transactional(schema()+".b2b_objects");}
|
||||
void insert(long job,long hq,List<BatchInvoiceTexts.Item> items,LocalDateTime at) {
|
||||
// Only called for freshly allocated jobs, inside the booking transaction.
|
||||
// No broad PHP-style DELETE: type 300 is also used for unrelated scan/status records.
|
||||
for(var item:items)BusinessSupport.insertWithoutKey(db,table,fields("bo_ver",0,"bo_type",300,"bo_state",0,
|
||||
"bo_ext_id0",job,"bo_ext_id1",hq,"bo_ext_id2",0,"bo_obj_data",support.encode(item.legacy()),"bo_createtime",at));
|
||||
}
|
||||
void attach(long hq,List<Map<String,Object>> jobs) {
|
||||
if(jobs.isEmpty())return;
|
||||
var args=new ArrayList<Object>();args.add(hq);jobs.forEach(j->args.add(id(j,"id")));
|
||||
var byJob=new HashMap<Long,List<BatchInvoiceTexts.Item>>();
|
||||
for(var row:db.queryForList("SELECT bo_ext_id0,bo_obj_data FROM "+table+" WHERE bo_type=300 AND bo_ext_id1=? AND bo_ext_id0 IN ("+CrmScope.marks(jobs.size())+") ORDER BY bo_id",args.toArray())) {
|
||||
var item=decode(text(row,"bo_obj_data"));if(item!=null)byJob.computeIfAbsent(id(row,"bo_ext_id0"),k->new ArrayList<>()).add(item);
|
||||
}
|
||||
for(var job:jobs)job.put("exportItems",byJob.getOrDefault(id(job,"id"),List.of()));
|
||||
}
|
||||
List<Map<String,Object>> rows(long job,long hq,boolean lock) {
|
||||
String sql="SELECT * FROM "+table+" WHERE bo_ext_id0=? AND bo_ext_id1=? AND bo_type=300 ORDER BY bo_id";
|
||||
return lock?db.lockRows(sql,job,hq):db.queryForList(sql,job,hq);
|
||||
}
|
||||
void replace(long job,long hq,List<Map<String,Object>> previous,List<BatchInvoiceTexts.Item> items,LocalDateTime at) {
|
||||
for(var row:previous)if(decode(text(row,"bo_obj_data"))!=null) {
|
||||
if(id(row,"bo_state")!=0||id(row,"bo_ver")!=0||id(row,"bo_ext_id2")!=0)throw AdminSupport.bad("Bereits verarbeitete B2B-Rechnungspositionen können nicht ersetzt werden.");
|
||||
db.update("DELETE FROM "+table+" WHERE bo_id=? AND bo_ext_id0=? AND bo_ext_id1=? AND bo_type=300",id(row,"bo_id"),job,hq);
|
||||
}
|
||||
insert(job,hq,items,at);
|
||||
}
|
||||
BatchInvoiceTexts.Item decode(String raw) {
|
||||
try {
|
||||
var n=support.json.readTree(raw);
|
||||
if(!n.isArray()||n.size()!=8||!"c".equals(n.get(0).asText())||!"s".equals(n.get(2).asText())||!"p".equals(n.get(4).asText())||!"t".equals(n.get(6).asText()))return null;
|
||||
return new BatchInvoiceTexts.Item(new BigDecimal(n.get(1).asText()),n.get(3).asText(),new BigDecimal(n.get(5).asText()),new BigDecimal(n.get(7).asText()));
|
||||
}catch(Exception e){return null;/* Non-invoice type-300 records are owned by other PHP processes. */}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Rebuilds invoice output from saved list-booking prices, without changing the prices. */
|
||||
@RestController
|
||||
@RequestMapping("/api/orders/{id}/invoice-items")
|
||||
public class BatchInvoiceRefresh {
|
||||
private final DatabaseService db;private final BusinessSupport support;private final OrderOperations orders;
|
||||
private final OrderEntryCatalog catalog;private final BatchInvoiceExports exports;
|
||||
public BatchInvoiceRefresh(DatabaseService db,BusinessSupport support,OrderOperations orders,OrderEntryCatalog catalog) {
|
||||
this.db=db;this.support=support;this.orders=orders;this.catalog=catalog;this.exports=new BatchInvoiceExports(db,support);
|
||||
}
|
||||
public record Command(String requestId,String revision,String fingerprint,boolean confirmReplace) {}
|
||||
record Prepared(OrderOperations.Snapshot snapshot,Map<String,Object> customer,List<Map<String,Object>> previous,
|
||||
BatchInvoiceTexts.Texts texts,boolean writeCourier,String fingerprint) {}
|
||||
@PostMapping public Object preview(Authentication a,@PathVariable long id,@RequestBody Command c){return preview(ApiController.user(a),id,c);}
|
||||
@PutMapping public Object save(Authentication a,@PathVariable long id,@RequestBody Command c){return save(ApiController.user(a),id,c);}
|
||||
|
||||
private void access(LegacyUser u){u.require(4);u.require(7);}
|
||||
private void transactional(){support.transactional("headquarters","job","genericdatacontainer");exports.requireTransactional();}
|
||||
private Prepared prepare(LegacyUser u,long jobId,Command c,boolean lock) {
|
||||
if(c==null||c.revision()==null)throw bad("Bitte den Auftrag neu laden.");
|
||||
var s=orders.snapshot(u,jobId,lock);unchanged(s,c.revision());orders.writable(s);
|
||||
if(id(s.job(),"jb_origin")!=7)throw bad("Diese Aktualisierung ist für Aufträge aus der Listenerfassung vorgesehen.");
|
||||
if(id(s.job(),"jb_id_parent")!=0||!db.queryForList("SELECT jb_id FROM job WHERE jb_id_parent=? LIMIT 1",jobId).isEmpty())throw bad("Verbundene Unteraufträge benötigen eine gemeinsame Rechnungsaufbereitung.");
|
||||
var customer=catalog.billingCenter(u,id(s.job(),"csc_id_payer"),false);
|
||||
var lists=db.queryForList("SELECT bl.jbb_id,bl.jbbl_lfd FROM jobbatchlist bl JOIN jobbatch b ON b.jbb_id=bl.jbb_id WHERE bl.jb_id=? AND b.cs_id=?",jobId,id(customer,"cs_id"));
|
||||
if(lists.size()!=1)throw bad("Der Auftrag ist keiner eindeutigen Kundenliste zugeordnet.");
|
||||
var settings=BatchInvoiceTexts.settings(db,catalog,u.headquartersId(),customer);
|
||||
var tolls=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",jobId);
|
||||
if(tolls.size()>1)throw bad("Mehrdeutige Mautposition.");
|
||||
BigDecimal toll=BigDecimal.ZERO;
|
||||
try{if(!tolls.isEmpty())toll=new BigDecimal(text(tolls.getFirst(),"gdc_content"));}catch(NumberFormatException e){throw bad("Ungültige Mautposition.");}
|
||||
// checkDiscountStr uses the first nonzero rate from this unsorted PHP query.
|
||||
// Do not substitute the UI's sorted price rows when their rates differ.
|
||||
var discountOrder=db.queryForList("SELECT trs_discount FROM tourservice WHERE jb_id=?",jobId);
|
||||
var texts=fromSaved(s,settings,toll,firstDiscount(discountOrder));
|
||||
var rows=exports.rows(jobId,u.headquartersId(),lock);
|
||||
for(var row:rows)if(exports.decode(text(row,"bo_obj_data"))!=null&&(id(row,"bo_state")!=0||id(row,"bo_ver")!=0||id(row,"bo_ext_id2")!=0))throw bad("Bereits verarbeitete B2B-Rechnungspositionen können nicht ersetzt werden.");
|
||||
return new Prepared(s,customer,rows,texts,settings.courier(),revision(List.of(s,customer,lists,settings,tolls,rows,texts,discountOrder)));
|
||||
}
|
||||
Map<String,Object> preview(LegacyUser u,long id,Command c) {
|
||||
access(u);
|
||||
return db.readTransaction(()->{transactional();var p=prepare(u,id,c,false);
|
||||
return fields("fingerprint",p.fingerprint(),"previous",fields("customer",text(p.snapshot().job(),"jb_invtext"),"courier",text(p.snapshot().job(),"jb_invtext_cr"),"exportItems",p.previous().stream().map(r->exports.decode(text(r,"bo_obj_data"))).filter(Objects::nonNull).toList()),"next",p.texts(),"writeCourier",p.writeCourier(),"warnings",warnings(p.snapshot(),p.texts()));});
|
||||
}
|
||||
Map<String,Object> save(LegacyUser u,long id,Command c) {
|
||||
access(u);if(c==null)throw bad("Aktualisierungsdaten fehlen.");BusinessSupport.token(c.requestId());
|
||||
if(!c.confirmReplace())throw bad("Bitte das Ersetzen der angezeigten Rechnungstexte und Positionen bestätigen.");
|
||||
transactional();
|
||||
db.writeTransaction(()->{lockHeadquarters(db,u);Object receipt=List.of("batch-invoice-refresh",id,c);
|
||||
if(support.receipt(u,c.requestId(),receipt)!=null)return null;
|
||||
var p=prepare(u,id,c,true);
|
||||
if(!p.fingerprint().equals(c.fingerprint()))throw new ResponseStatusException(HttpStatus.CONFLICT,"Auftrag, Rechnungspositionen oder Druckvorgaben haben sich geändert. Bitte die Vorschau erneuern.");
|
||||
exports.replace(id,u.headquartersId(),p.previous(),p.texts().exportItems(),LocalDateTime.now().withNano(0));
|
||||
var values=fields("jb_invtext",p.texts().customer(),"jb_version",id(p.snapshot().job(),"jb_version")+1);
|
||||
if(p.writeCourier())values.put("jb_invtext_cr",p.texts().courier());
|
||||
BusinessSupport.update(db,"job","jb_id",id,values);
|
||||
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_invtext_dirty'",id);
|
||||
support.audit(u,id,id(p.customer(),"cs_id"),id(p.snapshot().job(),"cr_id"),2,"Rechnungstexte und B2B-Positionen der Listenbuchung aktualisiert",c.requestId());
|
||||
support.receipt(u,c.requestId(),receipt,id);return null;
|
||||
});return orders.view(u,id);
|
||||
}
|
||||
static List<String> warnings(OrderOperations.Snapshot snapshot,BatchInvoiceTexts.Texts texts) {
|
||||
boolean serviceDiscount=snapshot.prices().stream().anyMatch(p->text(p,"trs_srv_name").equals("Servicepreis")&&decimal(p,"trs_discount").signum()!=0);
|
||||
var warnings=new ArrayList<String>();
|
||||
boolean shown=texts.exportItems().stream().anyMatch(i->i.description().equals("Rabatt"));
|
||||
if(serviceDiscount&&shown)warnings.add("Der bisherige Rechnungsausweis berücksichtigt den rabattierten Servicepreis in der Rabattzeile, verwendet dort aber nur den Fuhrpreis als Basis. Der ausgewiesene Rabattbetrag kann deshalb vom rechnerischen Preisnachlass abweichen. Bitte die Vorschau prüfen; die gespeicherte Auftragssumme wird nicht geändert.");
|
||||
long rates=snapshot.prices().stream().map(p->decimal(p,"trs_discount").stripTrailingZeros()).filter(r->r.signum()!=0).distinct().count();
|
||||
if(shown&&rates>1)warnings.add("Fuhr- und Servicepreis haben unterschiedliche Rabattsätze. Wie in PHP weist die gemeinsame Rabattzeile nur den Satz der ersten gelesenen rabattierten Preisposition aus. Die Einzelpositionen verwenden ihre jeweiligen Rabatte; die Auftragssumme bleibt unverändert.");
|
||||
return List.copyOf(warnings);
|
||||
}
|
||||
/** One fixed freight position and optional service, with validated saved discounts. */
|
||||
static BigDecimal firstDiscount(List<Map<String,Object>> prices){return prices.stream().map(p->decimal(p,"trs_discount")).filter(r->r.signum()!=0).findFirst().orElse(BigDecimal.ZERO);}
|
||||
static BatchInvoiceTexts.Texts fromSaved(OrderOperations.Snapshot s,BatchInvoiceTexts.Settings settings,BigDecimal toll) {
|
||||
return fromSaved(s,settings,toll,firstDiscount(s.prices()));
|
||||
}
|
||||
static BatchInvoiceTexts.Texts fromSaved(OrderOperations.Snapshot s,BatchInvoiceTexts.Settings settings,BigDecimal toll,BigDecimal firstDiscount) {
|
||||
var j=s.job();
|
||||
String calculator=text(j,"jb_freetext_2").strip();
|
||||
if(id(j,"jb_payment")!=0||!(calculator.isBlank()||calculator.equals("²²²")))
|
||||
throw bad("Diese Listenbuchung enthält eine Sonderkalkulation. Bitte die Rechnungsaufbereitung in PHP durchführen.");
|
||||
BigDecimal selfRate=BigDecimal.ZERO,selfGross=null;boolean hasSelf=false;
|
||||
for(var p:s.priceDetails()) {
|
||||
if(id(p,"mt_sort")==13) {
|
||||
if(hasSelf)throw bad("Mehrdeutige Selbsterfasserrabatt-Metadaten.");
|
||||
hasSelf=true;
|
||||
String rawRate=text(p,"jbprc_price");
|
||||
if(rawRate.isBlank())throw bad("Ungültiger Selbsterfasserrabatt.");
|
||||
BigDecimal rate;
|
||||
try{rate=new BigDecimal(rawRate);}catch(NumberFormatException e){throw bad("Ungültiger Selbsterfasserrabatt.");}
|
||||
if(rate.signum()==0) {
|
||||
selfRate=BigDecimal.ZERO;
|
||||
} else {
|
||||
if(rate.compareTo(BigDecimal.ZERO)<=0||rate.compareTo(new BigDecimal("100"))>=0)throw bad("Ungültiger Selbsterfasserrabatt.");
|
||||
selfRate=rate;
|
||||
String remark=text(p,"jbprc_remark");
|
||||
if(remark.isBlank())throw bad("Ungültige Selbsterfasserrabatt-Metadaten.");
|
||||
BigDecimal parsedGross;
|
||||
try{parsedGross=new BigDecimal(remark);}catch(NumberFormatException e){throw bad("Ungültige Selbsterfasserrabatt-Metadaten.");}
|
||||
if(parsedGross.signum()<=0)throw bad("Ungültige Selbsterfasserrabatt-Metadaten.");
|
||||
if(parsedGross.stripTrailingZeros().scale()>2)throw bad("Ungültige Selbsterfasserrabatt-Metadaten.");
|
||||
selfGross=parsedGross;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(hasSelf&&selfRate.signum()!=0&&selfGross==null)throw bad("Die Basis des Selbsterfasserrabatts fehlt.");
|
||||
BigDecimal freight=BigDecimal.ZERO,service=BigDecimal.ZERO,discount=BigDecimal.ZERO,serviceDiscount=BigDecimal.ZERO;boolean hasFreight=false,hasService=false;
|
||||
String freightName="Fixpreis";
|
||||
for(var p:s.prices()) {
|
||||
String name=text(p,"trs_srv_name");
|
||||
if(id(p,"csc_id")!=id(j,"csc_id_payer")||!text(p,"trs_srvt_name").isBlank()||!Set.of("Fixpreis","Fixpreis (manuell)","Servicepreis").contains(name))throw bad("Diese Listenbuchung enthält zusätzliche Tarif- oder Leistungspositionen. Bitte die Rechnungsaufbereitung in PHP durchführen.");
|
||||
BigDecimal amount=decimal(p,"trs_price"),d=decimal(p,"trs_discount");
|
||||
if(amount==null||d==null||amount.signum()<0||d.signum()<0||d.compareTo(BigDecimal.valueOf(100))>0)throw bad("Ungültige gespeicherte Preisposition.");
|
||||
if(name.equals("Servicepreis")){if(hasService)throw bad("Mehrdeutige Serviceposition.");hasService=true;service=amount;serviceDiscount=d;}
|
||||
else{if(hasFreight)throw bad("Mehrdeutige Fuhrpreisposition.");hasFreight=true;freight=amount;discount=d;freightName=name;}
|
||||
}
|
||||
BigDecimal freightNet=freight.multiply(BigDecimal.ONE.subtract(discount.movePointLeft(2))).setScale(2,RoundingMode.HALF_UP);
|
||||
BigDecimal serviceNet=service.multiply(BigDecimal.ONE.subtract(serviceDiscount.movePointLeft(2))).setScale(2,RoundingMode.HALF_UP);
|
||||
if(service.compareTo(decimal(j,"jb_serviceprice"))!=0||freightNet.add(serviceNet).compareTo(decimal(j,"jb_totalprice"))!=0)throw bad("Die gespeicherten Preispositionen passen nicht zur Auftragssumme. Bitte zuerst den Preis prüfen.");
|
||||
if(hasSelf&&selfRate.signum()!=0&&selfGross!=null) {
|
||||
String markupRaw=text(j,"jb_markup");
|
||||
if(markupRaw.isBlank())throw bad("Ungültiger Zuschlag.");
|
||||
BigDecimal markupVal;
|
||||
try{markupVal=new BigDecimal(markupRaw);}catch(NumberFormatException e){throw bad("Ungültiger Zuschlag.");}
|
||||
if(markupVal.compareTo(new BigDecimal("-100"))<=0)throw bad("Ungültiger Zuschlag.");
|
||||
if(!hasFreight||!freightName.equals("Fixpreis"))throw bad("Die gespeicherte Fuhrpreisposition passt nicht zum Selbsterfasserrabatt.");
|
||||
BigDecimal deduction=selfGross.multiply(selfRate.movePointLeft(2)).setScale(2,RoundingMode.HALF_UP);
|
||||
BigDecimal expected=selfGross.subtract(deduction);
|
||||
if(expected.compareTo(freight)!=0)throw bad("Die gespeicherte Fuhrpreisposition passt nicht zum Selbsterfasserrabatt.");
|
||||
}
|
||||
StringBuilder base=new StringBuilder(text(j,"jb_freetext_1"));
|
||||
if(!base.isEmpty()&&base.charAt(base.length()-1)!='\n')base.append('\n');
|
||||
if(toll.signum()!=0)base.append("Maut: ").append(toll.setScale(2,RoundingMode.HALF_UP).toPlainString().replace('.',',')).append(" EUR\n");
|
||||
for(var stop:s.stops())if(!text(stop,"tr_commission_no").isBlank())base.append("# ").append(text(stop,"tr_commission_no")).append('\n');
|
||||
var row=new OrderBatches.Row(1,null,base.toString(),"",0,BigDecimal.ZERO,service,BigDecimal.ZERO,false,false);
|
||||
var amounts=new LegacyOrderPricing.BatchAmounts(freight,BigDecimal.ZERO,BigDecimal.ZERO,discount,decimal(j,"jb_markup"),BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO);
|
||||
BatchInvoiceTexts.SelfDiscount self=null;
|
||||
if(hasSelf&&selfRate.signum()!=0&&selfGross!=null) {
|
||||
BigDecimal deduction=selfGross.multiply(selfRate.movePointLeft(2)).setScale(2,RoundingMode.HALF_UP);
|
||||
self=new BatchInvoiceTexts.SelfDiscount(selfRate,selfGross,deduction);
|
||||
}
|
||||
var result=BatchInvoiceTexts.create(row,amounts,settings,freightName,self,OrderInvoiceNotes.value(s.invoiceNotes()),serviceDiscount,firstDiscount);
|
||||
return new BatchInvoiceTexts.Texts(result.customer(),settings.courier()?result.courier():text(j,"jb_invtext_cr"),result.exportItems());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.math.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
|
||||
/** List-booking branch of the current server's mk_jb_invtext/checkDiscountStr. */
|
||||
final class BatchInvoiceTexts {
|
||||
record Settings(int columns,int lines,int exportColumns,boolean omitBlankLines,boolean discount,
|
||||
boolean markup,boolean service,boolean euro,boolean courier,boolean services) {
|
||||
Settings(int columns,int lines,int exportColumns,boolean omitBlankLines,boolean discount,boolean markup,boolean service,boolean euro,boolean courier){this(columns,lines,exportColumns,omitBlankLines,discount,markup,service,euro,courier,false);}
|
||||
}
|
||||
record Item(BigDecimal factor,String description,BigDecimal price,BigDecimal total) {
|
||||
List<String> legacy(){return List.of("c",number(factor),"s",description,"p",number(price),"t",number(total));}
|
||||
private static String number(BigDecimal n){return n.round(new MathContext(10,RoundingMode.HALF_UP)).stripTrailingZeros().toPlainString();}
|
||||
}
|
||||
record Texts(String customer,String courier,List<Item> exportItems) {}
|
||||
record SelfDiscount(BigDecimal rate,BigDecimal gross,BigDecimal deduction) {}
|
||||
static Settings settings(DatabaseService db,OrderEntryCatalog catalog,long hq,Map<String,Object> customerData) {
|
||||
long customer=LegacyMapping.id(customerData,"cs_id");
|
||||
boolean service=false;
|
||||
if(catalog.enabled(hq,"INV_SHOW_MARKUP_BATCH")) {
|
||||
var rows=db.queryForList("SELECT par_key,md_id,hq_id,emp_id,par_value FROM parameter WHERE par_key=?",ExtrasCustomerSettings.priceKey(customer));
|
||||
service=catalog.enabled(hq,"INV_PRINT_SRVPRICE") | Boolean.TRUE.equals(ExtrasCustomerSettings.decode(ExtrasCustomerSettings.Type.SERVICE_PRICE,customer,rows).servicePrice());
|
||||
}
|
||||
return new Settings(limit(catalog,hq,"INV_MAXCOLS",80,1,500),limit(catalog,hq,"INV_MAXLINES",20,1,200),
|
||||
limit(catalog,hq,"INV_MAXCOLS_EXPORT",0,0,500),catalog.enabled(hq,"INV_PRINT_REMARK"),
|
||||
catalog.enabled(hq,"INV_PRINT_DISCOUNT"),printMarkup(catalog.parameter(hq,"MASK_MARKUP_MODE")),service,
|
||||
catalog.enabled(hq,"INV_USE_EURO_SIGN"),catalog.enabled(hq,"INV_JB_INVOICE_CR"),
|
||||
LegacyMapping.id(customerData,"cs_invmode")!=0&&globalFlag(db,"MASK_INVOICE_SHOW_SERVICES_"+customer));
|
||||
}
|
||||
static boolean printMarkup(String raw){
|
||||
if(raw==null||raw.isBlank())return false;
|
||||
try{return new BigDecimal(raw).signum()!=0;}catch(NumberFormatException e){throw bad("MASK_MARKUP_MODE: Ungültige Zuschlagsvorgabe.");}
|
||||
}
|
||||
static boolean globalFlag(DatabaseService db,String key) {
|
||||
var rows=db.queryForList("SELECT md_id,hq_id,emp_id,par_value FROM parameter WHERE par_key=?",key);
|
||||
if(rows.isEmpty())return false;
|
||||
if(rows.size()!=1)throw bad(key+": Mehrdeutige Kundenvorgabe.");
|
||||
var r=rows.getFirst();String value=LegacyMapping.text(r,"par_value");
|
||||
if(LegacyMapping.id(r,"md_id")!=0||LegacyMapping.id(r,"hq_id")!=0||LegacyMapping.id(r,"emp_id")!=0||!Set.of("0","1").contains(value))throw bad(key+": Ungültige globale Kundenvorgabe.");
|
||||
return value.equals("1");
|
||||
}
|
||||
private static int limit(OrderEntryCatalog c,long hq,String key,int fallback,int min,int max) {
|
||||
String raw=c.parameter(hq,key);if(raw.isBlank())return fallback;
|
||||
try{int n=Integer.parseInt(raw);if(n>=min&&n<=max)return n;}catch(NumberFormatException ignored){}
|
||||
throw bad(key+": Ungültige Rechnungsformatierung ("+min+"–"+max+").");
|
||||
}
|
||||
static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s) {
|
||||
return create(row,a,s,"Fixpreis",null);
|
||||
}
|
||||
static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s,String freightName) {
|
||||
return create(row,a,s,freightName,null);
|
||||
}
|
||||
static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s,String freightName,SelfDiscount self) {
|
||||
return create(row,a,s,freightName,self,"");
|
||||
}
|
||||
static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s,String freightName,SelfDiscount self,String invoiceOnlyText) {
|
||||
return create(row,a,s,freightName,self,invoiceOnlyText,BigDecimal.ZERO);
|
||||
}
|
||||
static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s,String freightName,SelfDiscount self,String invoiceOnlyText,BigDecimal serviceDiscount) {
|
||||
return create(row,a,s,freightName,self,invoiceOnlyText,serviceDiscount,a.discount().signum()!=0?a.discount():serviceDiscount);
|
||||
}
|
||||
static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s,String freightName,SelfDiscount self,String invoiceOnlyText,BigDecimal serviceDiscount,BigDecimal discountRate) {
|
||||
String base=row.text().endsWith("\n")?row.text():row.text()+"\n",text=base,currency=s.euro()?"€":"EUR";
|
||||
if(!invoiceOnlyText.isBlank())text+=invoiceOnlyText+(invoiceOnlyText.endsWith("\n")?"":"\n");
|
||||
BigDecimal freight=a.freightWithMarkup().setScale(4,RoundingMode.HALF_UP),rounded=money(freight);
|
||||
var items=new ArrayList<Item>();
|
||||
// PHP's getJobCosts branch includes these rows only for non-PZM customers
|
||||
// with the customer-specific detailed-service setting. They are already discounted.
|
||||
if(s.services()) {
|
||||
if(freight.signum()!=0){BigDecimal amount=freight.multiply(BigDecimal.ONE.subtract(a.discount().movePointLeft(2)));items.add(new Item(BigDecimal.ONE,freightName,amount,amount));text+=freightName+": "+decimal(amount)+" EUR\n";}
|
||||
if(row.service().signum()!=0){BigDecimal amount=row.service().multiply(BigDecimal.ONE.subtract(serviceDiscount.movePointLeft(2)));items.add(new Item(BigDecimal.ONE,"Servicepreis",amount,amount));text+="Servicepreis: "+decimal(amount)+" EUR\n";}
|
||||
if(!items.isEmpty())text=text.stripTrailing();
|
||||
}
|
||||
if(s.markup()&&a.markup().signum()!=0) {
|
||||
BigDecimal divisor=BigDecimal.ONE.add(a.markup().movePointLeft(2));
|
||||
if(divisor.signum()==0)throw bad("Rechnungstext: Zuschlag -100 % ist nicht berechenbar.");
|
||||
BigDecimal thatGross=(self!=null)?self.gross():rounded;
|
||||
BigDecimal without=thatGross.divide(divisor,2,RoundingMode.HALF_UP);
|
||||
BigDecimal total=thatGross.subtract(without);
|
||||
items.add(new Item(a.markup().movePointLeft(2),"Treibstoffzuschlag",without,total));
|
||||
text+="\nTreibstoffzuschlag "+decimal(a.markup())+"% auf "+decimal(without)+" "+currency+" = "+decimal(total)+" "+currency;
|
||||
}
|
||||
if(self!=null&&self.rate().signum()!=0) {
|
||||
BigDecimal markup=a.markup();
|
||||
if(markup.signum()<0&&markup.compareTo(new BigDecimal("-100"))<=0)throw bad("Rechnungstext: Zuschlag -100 % ist nicht berechenbar.");
|
||||
items.add(new Item(self.rate().movePointLeft(2),"Selbsterfasserrabatt",self.gross(),self.deduction().negate()));
|
||||
text+="\nSelbsterfasserrabatt "+decimal(self.rate())+"% auf "+decimal(self.gross())+" "+currency+" = "+decimal(self.deduction().negate())+" "+currency;
|
||||
}
|
||||
// PHP checkDiscountStr sums rounded (price * (1 + discount)) for every
|
||||
// discounted row, including service, but subtracts that sum from freight only.
|
||||
// This legacy display is not the actual price deduction; preview explains it.
|
||||
if(s.discount()&&discountRate.signum()!=0&&(freight.signum()!=0||serviceDiscount.signum()!=0)) {
|
||||
BigDecimal withDiscount=a.discount().signum()!=0?money(freight.multiply(BigDecimal.ONE.add(a.discount().movePointLeft(2)))):BigDecimal.ZERO;
|
||||
if(serviceDiscount.signum()!=0)withDiscount=withDiscount.add(money(row.service().multiply(BigDecimal.ONE.add(serviceDiscount.movePointLeft(2)))));
|
||||
items.add(new Item(discountRate.movePointLeft(2),"Rabatt",freight,freight.subtract(withDiscount)));
|
||||
text+="\nRabatt "+decimal(discountRate)+"% auf "+decimal(freight)+" "+currency+" = "+decimal(freight.subtract(withDiscount))+" "+currency;
|
||||
}
|
||||
if(s.service()&&row.service().signum()!=0){text+="\nServicepreis "+decimal(row.service())+" "+currency;items.add(new Item(BigDecimal.ONE,"Servicepreis",row.service(),row.service()));}
|
||||
try{return new Texts(format(text,s),s.courier()?format(base,s):"",List.copyOf(items));}
|
||||
catch(org.springframework.web.server.ResponseStatusException e){throw bad("Zeile "+row.line()+": "+e.getReason());}
|
||||
}
|
||||
private static BigDecimal money(BigDecimal n){return n.setScale(2,RoundingMode.HALF_UP);}
|
||||
private static String decimal(BigDecimal n){return money(n).toPlainString().replace('.',',');}
|
||||
static String format(String text,Settings s) {
|
||||
var lines=new ArrayList<String>();
|
||||
for(String line:text.replace("\r","").split("\n",-1))if(!s.omitBlankLines()||!line.isBlank())lines.addAll(wrap(line,s));
|
||||
// As in mkLineBreaksWithBreaks, preserve explicit breaks if they fit; otherwise compact them.
|
||||
if(lines.size()+1>s.lines())lines=new ArrayList<>(wrap(text.strip().replace("\r","").replaceAll("\n+","; "),s));
|
||||
if(lines.size()>s.lines())throw bad("Der Rechnungstext überschreitet "+s.lines()+" Zeilen zu "+s.columns()+" Zeichen. Bitte Tourenverlauf kürzen oder Rechnungsparameter anpassen.");
|
||||
while(lines.size()<s.lines())lines.add("");
|
||||
String formatted=String.join("\n",lines)+"\n";
|
||||
if(formatted.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65535)throw bad("Der formatierte Rechnungstext ist zu groß. Bitte Exportbreite oder Zeilenanzahl verkleinern.");
|
||||
return formatted;
|
||||
}
|
||||
private static List<String> wrap(String text,Settings s) {
|
||||
var result=new ArrayList<String>();String rest=text.strip();
|
||||
while(rest.codePointCount(0,rest.length())>s.columns()) {
|
||||
int end=rest.offsetByCodePoints(0,s.columns());int space=rest.lastIndexOf(' ',end);
|
||||
if(space<=0)throw bad("Ein Wort im Rechnungstext überschreitet "+s.columns()+" Zeichen. Bitte einen Zeilenumbruch oder ein Leerzeichen einfügen.");
|
||||
result.add(pad(rest.substring(0,space),s));rest=rest.substring(space+1);
|
||||
}
|
||||
result.add(pad(rest,s));return result;
|
||||
}
|
||||
private static String pad(String s,Settings settings){return s+" ".repeat(Math.max(0,settings.exportColumns()-s.codePointCount(0,s.length())));}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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,List<Integer> vehicleTypes){
|
||||
public Query(LocalDate from,LocalDate to,String mode,Long customer,Long center,boolean descendants,Long courier,String invoice,boolean unassigned){this(from,to,mode,customer,center,descendants,courier,invoice,unassigned,List.of());}
|
||||
boolean association(){return "INVOICE".equals(mode);}
|
||||
}
|
||||
public record Assign(String requestId,Query query,String revision,List<Long> orders,String number,LocalDate date,boolean replaceExisting){
|
||||
public Assign(String requestId,Query query,String revision,List<Long> orders,String number,LocalDate date){this(requestId,query,revision,orders,number,date,false);}
|
||||
}
|
||||
record InvoiceValue(String number,String date){}
|
||||
record Assignment(long id,String revision,InvoiceValue previous){}
|
||||
record Operation(long user,long hq,String number,String date,List<Assignment> items,String hash){}
|
||||
static BigDecimal nz(BigDecimal v){return v==null?BigDecimal.ZERO:v;}
|
||||
static BigDecimal money(BigDecimal v){return v.setScale(2,RoundingMode.HALF_UP);}
|
||||
static BigDecimal net(Map<String,Object> p){return money(nz(decimal(p,"trs_price")).multiply(BigDecimal.valueOf(100).subtract(nz(decimal(p,"trs_discount")))).movePointLeft(2));}
|
||||
void check(LegacyUser u,Query q){u.require(5);if(q==null)throw bad("Abrechnungsfilter fehlen.");range(q.from(),q.to());if(q.from().plusYears(1).isBefore(q.to()))throw bad("Bitte einen Zeitraum von höchstens einem Jahr wählen.");if(q.mode()==null||!Set.of("CUSTOMER","COURIER","INVOICE").contains(q.mode()))throw bad("Ungültiger Abrechnungsbereich.");
|
||||
if(q.vehicleTypes()!=null&&(q.vehicleTypes().size()>50||q.vehicleTypes().stream().anyMatch(v->v==null||v<0||v>999)||new HashSet<>(q.vehicleTypes()).size()!=q.vehicleTypes().size()))throw bad("Ungültiger Fahrzeugfilter.");
|
||||
if(q.association()&&q.customer()==null)throw bad("Für die Rechnungszuordnung bitte einen Kunden auswählen.");
|
||||
}
|
||||
public Map<String,Object> options(LegacyUser u){u.require(5);return fields("customers",db.queryForList("SELECT cs.cs_id AS id,CONCAT(cs.cs_eid,' · ',c.cmp_comp) AS label FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=? ORDER BY c.cmp_comp",u.headquartersId()),"couriers",db.queryForList("SELECT cr.cr_id AS id,CONCAT(cr.cr_eid,' · ',c.cmp_comp) AS label FROM courier cr JOIN company c ON c.cmp_id=cr.cmp_id WHERE cr.hq_id=? ORDER BY c.cmp_comp",u.headquartersId()),"vehicleTypes",db.queryForList("SELECT mt_sort AS id,MIN(mt_value) AS label FROM metatype WHERE mt_type='vehicletype' GROUP BY mt_sort ORDER BY mt_sort"),"canAssign",u.hasRight(7));}
|
||||
public List<Map<String,Object>> centers(LegacyUser u,long customer){u.require(5);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));return db.queryForList("SELECT csc_id AS id,csc_name AS label,csc_pre_id AS parent FROM costcenter WHERE cs_id=? ORDER BY csc_path,csc_name",customer);}
|
||||
public Map<String,Object> query(LegacyUser u,Query q){check(u,q);return db.readTransaction(()->statement(u,q));}
|
||||
Map<String,Object> statement(LegacyUser u,Query q){
|
||||
Set<Long> centers=new HashSet<>();if(q.center()!=null){var cs=one(db.queryForList("SELECT c.csc_id,c.cs_id FROM costcenter c JOIN customer s ON s.cs_id=c.cs_id WHERE c.csc_id=? AND s.hq_id=?",q.center(),u.headquartersId()));if(q.customer()!=null&&q.customer()!=id(cs,"cs_id"))throw bad("Die Kostenstelle gehört zu einem anderen Kunden.");centers.add(q.center());if(q.descendants()){var all=db.queryForList("SELECT csc_id,csc_pre_id FROM costcenter WHERE cs_id=?",id(cs,"cs_id"));boolean added;do{added=false;for(var r:all)if(centers.contains(id(r,"csc_pre_id")))added|=centers.add(id(r,"csc_id"));}while(added);}}
|
||||
String sql="SELECT j.*,i.jb_id AS ng_invoice_id,i.inv_no,i.inv_datetime,CAST(i.inv_datetime AS CHAR) AS ng_invoice_date,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 "+(q.association()?"j.jb_ordertime":"j.jb_finishtime")+">=? AND "+(q.association()?"j.jb_ordertime":"j.jb_finishtime")+"<?";
|
||||
var args=new ArrayList<Object>(List.of(u.headquartersId(),q.from().atStartOfDay(),q.to().plusDays(1).atStartOfDay()));
|
||||
if(q.association())sql+=" AND j.jb_export_time IS NOT NULL";
|
||||
if(q.vehicleTypes()!=null&&!q.vehicleTypes().isEmpty()){sql+=" AND j.vht_id IN ("+String.join(",",Collections.nCopies(q.vehicleTypes().size(),"?"))+")";args.addAll(q.vehicleTypes());}
|
||||
if(q.mode().equals("CUSTOMER"))sql+=" AND j.jb_payment<>2 AND j.csc_id_payer<>-1";
|
||||
if(q.association()){sql+=" AND c.cs_id=?";args.add(q.customer());}
|
||||
else 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+(q.association()?" ORDER BY j.jb_id":" ORDER BY j.jb_finishtime,j.jb_id")+" LIMIT 2001",args.toArray());if(jobs.size()>2000)throw bad("Mehr als 2.000 Aufträge gefunden. Bitte den Zeitraum oder den Kundenfilter eingrenzen.");
|
||||
var rows=new ArrayList<Map<String,Object>>();var revisionData=new ArrayList<Object>();revisionData.add(jobs);
|
||||
for(var j:jobs){long job=id(j,"jb_id");var prices=db.queryForList("SELECT t.*,c.cs_id,c.csc_name,s.cs_eid,cmp.cmp_comp FROM tourservice t LEFT JOIN costcenter c ON c.csc_id=t.csc_id LEFT JOIN customer s ON s.cs_id=c.cs_id LEFT JOIN company cmp ON cmp.cmp_id=s.cmp_id WHERE t.jb_id=? ORDER BY t.csc_id,t.tr_sort,t.srv_id,t.srvt_id",job);revisionData.add(prices);
|
||||
var tollRows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",job);revisionData.add(tollRows);if(tollRows.size()>1)throw bad("Auftrag #"+job+": Maut ist mehrfach hinterlegt.");BigDecimal toll=tollRows.isEmpty()?BigDecimal.ZERO:new BigDecimal(text(tollRows.getFirst(),"gdc_content"));
|
||||
if(q.mode().equals("COURIER")){BigDecimal amount=courierNet(j,prices);rows.add(line(j,id(j,"csc_id_payer"),text(j,"csc_name"),amount,toll,text(j,"jb_invtext_cr")));continue;}
|
||||
var byCenter=new TreeMap<Long,List<Map<String,Object>>>();for(var p:prices)byCenter.computeIfAbsent(id(p,"csc_id"),k->new ArrayList<>()).add(p);
|
||||
if(byCenter.isEmpty())byCenter.put(id(j,"csc_id_payer"),List.of());int index=0;for(var e:byCenter.entrySet()){BigDecimal share=OrderEntryAccounting.portion(toll,byCenter.size(),index++);if(!centers.isEmpty()&&!centers.contains(e.getKey()))continue;
|
||||
var context=e.getValue().isEmpty()?j:e.getValue().getFirst();if(q.customer()!=null&&id(context,"cs_id")!=q.customer())continue;
|
||||
BigDecimal amount=e.getValue().isEmpty()?nz(decimal(j,"jb_totalprice")):e.getValue().stream().map(BillingService::net).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue));
|
||||
var line=line(j,e.getKey(),text(context,"csc_name"),amount,share,text(j,"jb_invtext"));line.put("customer",text(context,"cmp_comp"));line.put("customerId",id(context,"cs_id"));line.put("components",e.getValue().stream().map(p->fields("name",text(p,"trs_srv_name")+" "+text(p,"trs_srvt_name"),"net",net(p))).toList());rows.add(line);
|
||||
}
|
||||
}
|
||||
var terms=new HashMap<Long,Map<String,Object>>();
|
||||
for(var row:rows){var term=terms.computeIfAbsent(id(row,"id"),key->OrderPaymentTerms.read(db,key));row.put("paymentTerm",text(term,"label"));row.put("paymentTermCode",term.getOrDefault("value",text(term,"label")));row.put("paymentTermSource",text(term,"source"));}
|
||||
revisionData.add(terms);
|
||||
BigDecimal net=rows.stream().map(r->(BigDecimal)r.get("net")).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)),tax=rows.stream().map(r->(BigDecimal)r.get("tax")).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue));
|
||||
var pending=db.queryForList("SELECT gdc_gen_fieldname AS token FROM genericdatacontainer WHERE gdc_obj_type='nginv' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id",u.headquartersId());
|
||||
return fields("rows",rows,"net",money(net),"tax",money(tax),"gross",money(net.add(tax)),"revision",revision(revisionData),"pending",pending);
|
||||
}
|
||||
BigDecimal courierNet(Map<String,Object> job,List<Map<String,Object>> prices){
|
||||
var catalog=new OrderEntryCatalog(db);long hq=id(job,"hq_id");var vehicle=db.queryForList("SELECT crvh_prov FROM couriervehicle WHERE crvh_sid=?",text(job,"cr_sid"));BigDecimal commission=vehicle.isEmpty()?BigDecimal.ZERO:nz(decimal(one(vehicle),"crvh_prov"));
|
||||
BigDecimal stored=nz(decimal(job,hq>100&&hq<=1000?"jb_cr_subprice":"jb_cr_price"));if(hq>1000)return money(stored.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2));
|
||||
boolean mode=catalog.parameter(hq,"MASK_CR_PRICE_MODE").equals("1");String from=catalog.parameter(hq,"MASK_CR_PRICE_MODE_DATE");LocalDateTime at=dateTime(job,"jb_ordertime");boolean after=false;if(mode&&at!=null){try{after=CourierPriceModeDate.active(from,at,false);}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,(entryValue, otherValue) -> entryValue.add(otherValue));
|
||||
}
|
||||
Map<String,Object> line(Map<String,Object> j,long center,String centerName,BigDecimal amount,BigDecimal toll,String description){BigDecimal net=money(amount.add(toll)),rate=nz(decimal(j,"jb_sales_tax_rate")),tax=money(net.multiply(rate).movePointLeft(2));return fields("id",id(j,"jb_id"),"center",center,"centerName",centerName,"customer",text(j,"cmp_comp"),"customerId",id(j,"cs_id"),"courier",text(j,"cr_sid"),"finished",dateTime(j,"jb_finishtime"),"ordered",dateTime(j,"jb_ordertime"),"booked",dateTime(j,"jb_booktime"),"reference",text(j,"jb_commission_no"),"description",description,"net",net,"toll",money(toll),"taxRate",rate,"tax",tax,"gross",net.add(tax),"storno",id(j,"jb_storno"),"incomplete",id(j,"jb_incomplete")==1,"exported",OrderOperations.exported(j),"invoice",text(j,"inv_no"),"invoiceDate",dateTime(j,"inv_datetime"),"invoicePresent",id(j,"ng_invoice_id")>0,"invoicePrevious",id(j,"ng_invoice_id")==0?null:new InvoiceValue((String)j.get("inv_no"),(String)j.get("ng_invoice_date")),"vehicleType",id(j,"vht_id"));}
|
||||
public Map<String,Object> assign(LegacyUser u,Assign a){u.require(5);u.require(7);if(a==null)throw bad("Rechnungsangaben fehlen.");check(u,a.query());token(a.requestId());String number=required(a.number(),30,"Rechnungsnummer");if(a.date()==null||a.orders()==null||a.orders().isEmpty()||a.orders().size()>2000||a.orders().stream().anyMatch(id->id==null||id<=0)||new HashSet<>(a.orders()).size()!=a.orders().size())throw bad("Bitte Rechnungsdatum und eindeutige Aufträge auswählen.");if(!Set.of("CUSTOMER","INVOICE").contains(a.query().mode())||a.query().customer()==null)throw bad("Rechnungszuordnungen benötigen genau einen ausgewählten Kunden.");
|
||||
if(a.replaceExisting()&&!a.query().association())throw bad("Vorhandene Rechnungsangaben können nur im Bereich Rechnungszuordnung korrigiert werden.");
|
||||
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=TypedValues.rows(current.get("rows"));Set<Long> eligible=new HashSet<>();for(var row:lines)if(!(Boolean)row.get("incomplete")&&(a.query().association()||!(Boolean)row.get("exported"))&&(!(Boolean)row.get("invoicePresent")||a.replaceExisting()))eligible.add(id(row,"id"));
|
||||
var items=new ArrayList<Assignment>();for(long job:a.orders()){if(!eligible.contains(job))throw bad("Auftrag #"+job+" ist nicht für diese Zuordnung freigegeben. Bitte Auswahl und Korrekturfreigabe prüfen.");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.");var previous=invoiceValue(job);var shown=lines.stream().filter(line->id(line,"id")==job).findFirst().orElseThrow();if(!Objects.equals(previous,shown.get("invoicePrevious")))throw bad("Rechnungsangaben wurden inzwischen geändert. Bitte neu laden.");if(previous!=null&&!a.replaceExisting())throw bad("Auftrag bereits zugeordnet. Korrektur bitte ausdrücklich freigeben.");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),previous));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());
|
||||
}
|
||||
InvoiceValue invoiceValue(long job){var rows=db.queryForList("SELECT inv_no,CAST(inv_datetime AS CHAR) AS ng_invoice_date FROM invoice WHERE jb_id=?",job);if(rows.size()>1)throw bad("Auftrag #"+job+": mehrere Rechnungszuordnungen vorhanden.");return rows.isEmpty()?null:new InvoiceValue((String)rows.getFirst().get("inv_no"),(String)rows.getFirst().get("ng_invoice_date"));}
|
||||
String assignmentRevision(long job){var j=new LinkedHashMap<>(one(db.queryForList("SELECT * FROM job WHERE jb_id=?",job)));j.remove("jb_locktime");j.remove("jb_lockuser");j.remove("jb_modify");return revision(List.of(j,db.queryForList("SELECT * FROM tourservice WHERE jb_id=? ORDER BY csc_id,tr_sort,srv_id,srvt_id",job),db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",job)));}
|
||||
Map<String,Object> operation(String token){var rows=db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='nginv' AND gdc_gen_fieldname=?",token(token));return rows.isEmpty()?null:one(rows);}
|
||||
Operation decode(Map<String,Object> row){try{return support.json.readValue(text(row,"gdc_content"),Operation.class);}catch(Exception e){throw new IllegalStateException(e);}}
|
||||
public Map<String,Object> resume(LegacyUser u,String token){u.require(5);u.require(7);token(token);return db.writeTransaction(()->{lockHeadquarters(db,u);var row=operation(token);if(row==null)throw bad("Rechnungsvorgang nicht gefunden.");var op=decode(row);if(op.hq()!=u.headquartersId())throw bad("Andere Niederlassung.");if(text(row,"gdc_context").equals("STOPPED"))throw bad("Der Vorgang wurde beendet. Bitte die aktuelle Zuordnung neu laden.");if(text(row,"gdc_context").equals("DONE"))return fields("state","DONE","number",op.number());
|
||||
for(var item:op.items()){
|
||||
one(db.lockRows("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",item.id(),u.headquartersId()));
|
||||
if(!item.revision().equals(assignmentRevision(item.id())))throw bad("Auftrag #"+item.id()+" wurde seit Beginn der Zuordnung geändert. Der Vorgang bleibt zur Prüfung offen.");
|
||||
var target=new InvoiceValue(op.number(),LocalDate.parse(op.date()).atTime(12,0).format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
var before=invoiceValue(item.id());
|
||||
if(!Objects.equals(before,target)){
|
||||
if(!Objects.equals(before,item.previous()))throw bad("Auftrag #"+item.id()+" wurde anderweitig zugeordnet. Der Vorgang bleibt zur Prüfung offen.");
|
||||
// MyISAM writes survive a rollback. Compare the complete old value in the same statement;
|
||||
// after an uncertain response an already matching target is a completed item.
|
||||
if(item.previous()==null)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(),target.date(),item.id());
|
||||
else db.update("UPDATE invoice SET inv_no=?,inv_datetime=? WHERE jb_id=? AND BINARY inv_no <=> BINARY ? AND CAST(inv_datetime AS CHAR) <=> ?",op.number(),target.date(),item.id(),item.previous().number(),item.previous().date());
|
||||
}
|
||||
if(!Objects.equals(invoiceValue(item.id()),target))throw bad("Auftrag #"+item.id()+" wurde anderweitig zugeordnet. Der Vorgang bleibt zur Prüfung offen.");
|
||||
}
|
||||
for(var item:op.items())db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock' AND gdc_content=?",item.id(),token);
|
||||
support.audit(u,0,0,0,2,"Rechnungsnummer "+op.number()+" zugeordnet · "+op.items().size()+" Aufträge",token);db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(row,"gdc_id"));return fields("state","DONE","number",op.number());});}
|
||||
public Map<String,Object> stop(LegacyUser u,String token){u.require(5);u.require(7);token(token);return db.writeTransaction(()->{lockHeadquarters(db,u);var row=operation(token);if(row==null||decode(row).hq()!=u.headquartersId())throw bad("Vorgang nicht gefunden.");if(text(row,"gdc_context").equals("DONE"))throw bad("Vorgang ist bereits abgeschlossen.");for(var item:decode(row).items())db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock' AND gdc_content=?",item.id(),token);db.update("UPDATE genericdatacontainer SET gdc_context='STOPPED' WHERE gdc_id=?",id(row,"gdc_id"));support.audit(u,0,0,0,2,"Rechnungszuordnung beendet; bereits gespeicherte Zuordnungen bleiben erhalten",token);return fields("state","STOPPED");});}
|
||||
public byte[] csv(LegacyUser u,Query q){var data=query(u,q);StringBuilder csv=new StringBuilder("\uFEFFAuftrag;Kostenstelle;Kunde;Fahrer;Erledigt;Rechnungsnummer;Netto;MwSt;Brutto;Zahlungsbedingung;Zahlungsbedingung-Code;Text\n");for(var r:TypedValues.rows(data.get("rows"))){for(String k:List.of("id","centerName","customer","courier","finished","invoice","net","tax","gross","paymentTerm","paymentTermCode","description"))csv.append(ApiController.csvCell(r.get(k))).append(';');csv.append('\n');}return csv.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.http.*;
|
||||
|
||||
|
||||
@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("/master-data/vehicles/{id}/copy") public Object vehicleCopy(Authentication a,@PathVariable long id){return new VehicleCopy(masters).view(ApiController.user(a),id);}
|
||||
@GetMapping("/master-data/vehicles/{id}/copy/draft") public Object vehicleCopyDraft(Authentication a,@PathVariable long id,@RequestParam long headquarters,@RequestParam String eid){return new VehicleCopy(masters).draft(ApiController.user(a),id,headquarters,eid);}
|
||||
@PostMapping("/master-data/vehicles/{id}/copy") public Object saveVehicleCopy(Authentication a,@PathVariable long id,@RequestBody VehicleCopy.Save value){return new VehicleCopy(masters).save(ApiController.user(a),id,value);}
|
||||
@GetMapping("/master-data/customers/{id}/copy") public Object copyCustomer(Authentication a,@PathVariable long id){return new CustomerCopy(masters).draft(ApiController.user(a),id);}
|
||||
@GetMapping("/master-data/{kind}/identifier") public Object identifier(Authentication a,@PathVariable String kind){return masters.suggestIdentifier(ApiController.user(a),kind);}
|
||||
@GetMapping("/orders/{id}/workbench") public Object order(Authentication a,@PathVariable long id){return orders.view(ApiController.user(a),id);}
|
||||
@PutMapping("/orders/{id}/workbench") public Object edit(Authentication a,@PathVariable long id,@RequestBody OrderOperations.Edit value){return orders.edit(ApiController.user(a),id,value);}
|
||||
@PutMapping("/orders/{id}/price") public Object price(Authentication a,@PathVariable long id,@RequestBody OrderOperations.Price value){return orders.price(ApiController.user(a),id,value);}
|
||||
@PostMapping("/orders/{id}/actions") public Object action(Authentication a,@PathVariable long id,@RequestBody OrderOperations.Action value){return orders.action(ApiController.user(a),id,value);}
|
||||
@GetMapping("/billing/options") public Object options(Authentication a){return billing.options(ApiController.user(a));}
|
||||
@GetMapping("/billing/centers") public Object centers(Authentication a,@RequestParam long customer){return billing.centers(ApiController.user(a),customer);}
|
||||
@PostMapping("/billing/query") public Object query(Authentication a,@RequestBody BillingService.Query q){return billing.query(ApiController.user(a),q);}
|
||||
@PostMapping("/billing/assign") public Object assign(Authentication a,@RequestBody BillingService.Assign value){return billing.assign(ApiController.user(a),value);}
|
||||
@PostMapping("/billing/operations/{token}/resume") public Object resume(Authentication a,@PathVariable String token){return billing.resume(ApiController.user(a),token);}
|
||||
@PostMapping("/billing/operations/{token}/stop") public Object stop(Authentication a,@PathVariable String token){return billing.stop(ApiController.user(a),token);}
|
||||
@PostMapping("/billing/export.csv") public ResponseEntity<byte[]> csv(Authentication a,@RequestBody BillingService.Query q){return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=abrechnung.csv").body(billing.csv(ApiController.user(a),q));}
|
||||
@PostMapping("/billing/statement.pdf") public ResponseEntity<byte[]> pdf(Authentication a,@RequestBody BillingService.Query q){return ResponseEntity.ok().contentType(java.util.Objects.requireNonNull(MediaType.APPLICATION_PDF)).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=rechnungsanlage.pdf").body(StatementPdf.create(billing.query(ApiController.user(a),q),q));}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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,long employee,int operation,String description,String at,long article){
|
||||
Audit(long headquarters,long user,long job,long customer,long courier,long employee,int operation,String description,String at){this(headquarters,user,job,customer,courier,employee,operation,description,at,0);}
|
||||
}
|
||||
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){
|
||||
audit(user,job,customer,courier,0,operation,description,token);
|
||||
}
|
||||
void auditEmployee(LegacyUser user,long employee,int operation,String description,String token){audit(user,0,0,0,employee,operation,description,token);}
|
||||
void audit(LegacyUser user,long job,long customer,long courier,long employee,int operation,String description,String token){
|
||||
var event=new Audit(user.headquartersId(),user.userId(),job,customer,courier,employee,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"));
|
||||
}
|
||||
void auditArticle(LegacyUser user,long article,int operation,String description,String token){
|
||||
var event=new Audit(user.headquartersId(),user.userId(),0,0,0,0,operation,description+" | NG="+token,LocalDateTime.now().withNano(0).toString(),article);
|
||||
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,?,?,?)",e.operation(),e.headquarters(),e.job(),e.user(),e.courier(),e.customer(),e.article(),employeeId(e),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. */}
|
||||
}
|
||||
static long employeeId(Audit e){
|
||||
if(e.employee()>0)return e.employee();
|
||||
// Earlier NG employee events already carried an exact prefix, before emp_id was added.
|
||||
if(Set.of(73,74,75,76,115,116,184).contains(e.operation())&&e.description()!=null){var m=java.util.regex.Pattern.compile("^EMPLOYEE=([1-9][0-9]*) \\|").matcher(e.description());if(m.find())try{return Long.parseLong(m.group(1));}catch(NumberFormatException ignored){}}
|
||||
return 0;
|
||||
}
|
||||
void transactional(String... tables) {
|
||||
for(String table:tables){String[] parts=table.split("\\.");String schema=parts.length==2?parts[0]:null;String name=parts[parts.length-1];
|
||||
Integer count=db.queryForObject("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA="+(schema==null?"DATABASE()":"?")+" AND TABLE_NAME=? AND ENGINE='InnoDB'",Integer.class,schema==null?new Object[]{name}:new Object[]{schema,name});
|
||||
if(count==null||count!=1)throw bad("Speichern benötigt eine transaktionale InnoDB-Tabelle: "+table);
|
||||
}
|
||||
}
|
||||
static void update(DatabaseService db,String table,String key,long id,Map<String,Object> values){if(values.isEmpty())return;var args=new ArrayList<>(values.values());args.add(id);db.update("UPDATE "+table+" SET "+String.join(",",values.keySet().stream().map(k->k+"=?").toList())+" WHERE "+key+"=?",args.toArray());}
|
||||
static void insertWithoutKey(DatabaseService db,String table,Map<String,Object> values){db.update("INSERT INTO "+table+" ("+String.join(",",values.keySet())+") VALUES ("+String.join(",",Collections.nCopies(values.size(),"?"))+")",values.values().toArray());}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Native counterparts of admin/public_holiday.php and admin/traveltime.php. */
|
||||
@Service
|
||||
public class CalendarAdministration {
|
||||
private final DatabaseService db;
|
||||
private final Clock clock;
|
||||
@Autowired public CalendarAdministration(DatabaseService db){this(db,Clock.system(ZoneId.of("Europe/Berlin")));}
|
||||
CalendarAdministration(DatabaseService db,Clock clock){this.db=db;this.clock=clock;}
|
||||
record Holiday(int month,int day,String name) {}
|
||||
record HolidaySave(String revision,List<Holiday> days) {}
|
||||
record TravelSave(String revision,Integer minutes) {}
|
||||
enum TravelMode { CLASSIC,BWV }
|
||||
private List<Map<String,Object>> holidayRows(LegacyUser user,int year) {return holidayRows(user,year,false);}
|
||||
private List<Map<String,Object>> holidayRows(LegacyUser user,int year,boolean lock) {
|
||||
if(year!=0 && (year<1900 || year>2200))throw bad("Ungültiges Jahr.");
|
||||
String sql="SELECT ph_id,ph_month,ph_day,ph_name,ph_time_from,ph_time_to FROM publicholiday WHERE hq_id=? AND ph_year=? ORDER BY ph_month,ph_day,ph_time_from,ph_id";
|
||||
return lock?db.lockRows(sql,user.headquartersId(),year):db.queryForList(sql,user.headquartersId(),year);
|
||||
}
|
||||
public Map<String,Object> holidays(LegacyUser user,int year) {
|
||||
user.require(2);
|
||||
return db.readTransaction(()->{
|
||||
var rows=holidayRows(user,year);
|
||||
var days=new LinkedHashMap<String,Holiday>();
|
||||
for(var r:rows)days.putIfAbsent(id(r,"ph_month")+"-"+id(r,"ph_day"),new Holiday((int)id(r,"ph_month"),(int)id(r,"ph_day"),text(r,"ph_name")));
|
||||
return fields("year",year,"today",LocalDate.now(clock),"revision",revision(rows),"days",List.copyOf(days.values()),
|
||||
"years",db.queryForList("SELECT DISTINCT ph_year AS year FROM publicholiday WHERE hq_id=? ORDER BY ph_year DESC",user.headquartersId()));
|
||||
});
|
||||
}
|
||||
public Map<String,Object> saveHolidays(LegacyUser user,int year,HolidaySave request) {
|
||||
user.require(2);
|
||||
LocalDate today=LocalDate.now(clock);
|
||||
if(year<today.getYear() || year>2200)throw bad("Vergangene Jahre und die Default-Vorlage können nicht geändert werden.");
|
||||
if(request.days()==null || request.days().size()>366)throw bad("Ungültige Feiertagsliste.");
|
||||
var requested=new LinkedHashMap<LocalDate,String>();
|
||||
for(var day:request.days()) {
|
||||
if(day==null)throw bad("Ungültiger Feiertag.");
|
||||
LocalDate date;
|
||||
try {date=LocalDate.of(year,day.month(),day.day());}catch(DateTimeException e){throw bad("Ungültiges Feiertagsdatum.");}
|
||||
String name=day.name()==null?"":day.name().strip();
|
||||
if(name.length()>50)throw bad("Feiertagsname: höchstens 50 Zeichen.");
|
||||
if(requested.putIfAbsent(date,name)!=null)throw bad("Ein Feiertag ist mehrfach enthalten.");
|
||||
}
|
||||
return db.writeTransaction(()->{
|
||||
lockHeadquarters(db,user);
|
||||
var rows=holidayRows(user,year,true);unchanged(rows,request.revision());
|
||||
var existing=new HashMap<LocalDate,List<Map<String,Object>>>();
|
||||
for(var row:rows)existing.computeIfAbsent(LocalDate.of(year,(int)id(row,"ph_month"),(int)id(row,"ph_day")),x->new ArrayList<>()).add(row);
|
||||
// Preserve every historical row, including optional time windows. The PHP UI disables these days.
|
||||
for(var item:requested.entrySet())if(!item.getKey().isAfter(today)) {
|
||||
var old=existing.get(item.getKey());
|
||||
if(old==null || old.stream().noneMatch(r->text(r,"ph_name").equals(item.getValue())))throw bad("Vergangene Tage und heute dürfen nicht geändert werden.");
|
||||
}
|
||||
for(var item:existing.entrySet()) {
|
||||
if(!item.getKey().isAfter(today))continue;
|
||||
String name=requested.get(item.getKey());
|
||||
for(var row:item.getValue()) {
|
||||
if(name==null)db.update("DELETE FROM publicholiday WHERE ph_id=? AND hq_id=?",id(row,"ph_id"),user.headquartersId());
|
||||
else if(!name.equals(text(item.getValue().getFirst(),"ph_name")) && !name.equals(text(row,"ph_name")))db.update("UPDATE publicholiday SET ph_name=? WHERE ph_id=? AND hq_id=?",name,id(row,"ph_id"),user.headquartersId());
|
||||
}
|
||||
}
|
||||
for(var item:requested.entrySet())if(item.getKey().isAfter(today) && !existing.containsKey(item.getKey()))
|
||||
db.insert("publicholiday",fields("hq_id",user.headquartersId(),"ph_year",year,"ph_month",item.getKey().getMonthValue(),"ph_day",item.getKey().getDayOfMonth(),"ph_name",item.getValue()));
|
||||
return holidays(user,year);
|
||||
});
|
||||
}
|
||||
public Map<String,Object> travelTimes(LegacyUser user,TravelMode mode,String from,String to) {
|
||||
user.require(2);required(from,10,"PLZ von");required(to,10,"PLZ bis");
|
||||
if(from.compareTo(to)>0)throw bad("Der PLZ-Bereich ist umgekehrt.");
|
||||
var rows=db.queryForList("SELECT p.srvp_id AS id,p.srvp_plz AS postalCode,p.srvp_country AS country,p.srvp_traveltime AS globalMinutes,t.srvpt_traveltime AS localMinutes FROM serviceplz p LEFT JOIN serviceplztraveltime t ON t.srvp_id=p.srvp_id AND t.hq_id=? WHERE p.srvp_valid=1 AND p.srvp_plz>=? AND p.srvp_plz<=? ORDER BY p.srvp_plz,p.srvp_id LIMIT 4001",user.headquartersId(),from,to);
|
||||
if(rows.size()>4000)throw bad("Mehr als 4000 PLZ gefunden. Bitte den Bereich eingrenzen.");
|
||||
return fields("mode",mode,"defaultMinutes",defaultTravelTime(user),"items",rows.stream().map(r->travel(r,mode)).toList());
|
||||
}
|
||||
private Map<String,Object> travel(Map<String,Object> row,TravelMode mode) {
|
||||
Object minutes=value(row,mode==TravelMode.BWV?"globalMinutes":"localMinutes");
|
||||
return fields("id",id(row,"id"),"postalCode",text(row,"postalCode"),"country",text(row,"country"),"minutes",minutes,"revision",revision(Arrays.asList(id(row,"id"),mode,minutes)));
|
||||
}
|
||||
Integer defaultTravelTime(LegacyUser user) {
|
||||
String configured=new OrderEntryCatalog(db).parameter(user.headquartersId(),"LATEST_TAKETIME_IN_MINUTES");
|
||||
try {int minutes=Integer.parseInt(configured.strip());return minutes>=1&&minutes<=9999?minutes:null;}
|
||||
catch(NumberFormatException e){return null;}
|
||||
}
|
||||
public Map<String,Object> saveTravelTime(LegacyUser user,TravelMode mode,long postalId,TravelSave request) {
|
||||
user.require(2);
|
||||
if(request==null||request.minutes()!=null&&(request.minutes()<0||request.minutes()>9999))throw bad("Anfahrtszeit: Bitte 0 bis 9999 Minuten eingeben.");
|
||||
Integer minutes=request.minutes()==null||request.minutes()==0?defaultTravelTime(user):request.minutes();
|
||||
if(minutes==null)throw bad("Für diese Niederlassung fehlt ein gültiger Standard LATEST_TAKETIME_IN_MINUTES. Bitte eine Anfahrtszeit eingeben.");
|
||||
return db.writeTransaction(()->{
|
||||
lockHeadquarters(db,user);
|
||||
if(mode==TravelMode.CLASSIC)db.lockRows("SELECT srvp_id FROM serviceplztraveltime WHERE srvp_id=? AND hq_id=?",postalId,user.headquartersId());
|
||||
var row=one(db.queryForList("SELECT p.srvp_id AS id,p.srvp_plz AS postalCode,p.srvp_country AS country,p.srvp_traveltime AS globalMinutes,t.srvpt_traveltime AS localMinutes FROM serviceplz p LEFT JOIN serviceplztraveltime t ON t.srvp_id=p.srvp_id AND t.hq_id=? WHERE p.srvp_valid=1 AND p.srvp_id=?",user.headquartersId(),postalId));
|
||||
Object old=value(row,mode==TravelMode.BWV?"globalMinutes":"localMinutes");
|
||||
unchanged(Arrays.asList(postalId,mode,old),request.revision());
|
||||
if(mode==TravelMode.BWV) {
|
||||
// MyISAM cannot roll back a batch. One conditional UPDATE is the complete business write.
|
||||
if(db.update("UPDATE serviceplz SET srvp_traveltime=? WHERE srvp_id=? AND srvp_valid=1 AND srvp_traveltime <=> ?",minutes,postalId,old)!=1)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Anfahrtszeit wurde zwischenzeitlich geändert.");
|
||||
}else {
|
||||
if(old==null)db.update("INSERT INTO serviceplztraveltime (srvp_id,hq_id,srvpt_traveltime) VALUES (?,?,?)",postalId,user.headquartersId(),minutes);
|
||||
else db.update("UPDATE serviceplztraveltime SET srvpt_traveltime=? WHERE srvp_id=? AND hq_id=?",minutes,postalId,user.headquartersId());
|
||||
}
|
||||
row.put(mode==TravelMode.BWV?"globalMinutes":"localMinutes",minutes);
|
||||
return travel(row,mode);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.bad;
|
||||
|
||||
/** PHP's global CC24 employee list; separate from ordinary HQ permission vectors. */
|
||||
final class CallCenterAccess {
|
||||
static final String EMPLOYEES="HEADQUARTERS_MULTIPLE_ACCESS_EMPLOYEES";
|
||||
static final String GLOBAL_JOBS="HEADQUARTERS_MULTIPLE_ACCESS_EMPLOYEES_GLOBALJOBS";
|
||||
static final String FOREIGN_MASK="1110100111000000000000000000000000000000000000000000000000000000000000";
|
||||
private CallCenterAccess() {}
|
||||
static List<Map<String,Object>> rows(DatabaseService db,String key){
|
||||
var rows=db.queryForList("SELECT par_id,par_value FROM parameter WHERE emp_id=0 AND hq_id=0 AND par_key=? ORDER BY par_id",key);
|
||||
if(rows.size()>1)throw bad("Der CC24-Parameter ist mehrfach vorhanden. Bitte den Bestand zuerst klären.");
|
||||
if(!rows.isEmpty())ids(text(rows.getFirst(),"par_value"));
|
||||
return rows;
|
||||
}
|
||||
static String value(List<Map<String,Object>> rows){return rows.isEmpty()?"":text(rows.getFirst(),"par_value");}
|
||||
static Set<Long> ids(String value){
|
||||
if(value.length()>65535)throw bad("Der CC24-Parameter ist zu lang.");
|
||||
var ids=new LinkedHashSet<Long>();
|
||||
for(String part:value.split("\\|",-1)){
|
||||
String token=part.strip();if(token.isEmpty()||token.equals("0"))continue;
|
||||
if(!token.matches("[1-9][0-9]{0,9}"))throw bad("Der CC24-Parameter enthält eine ungültige Kennung.");
|
||||
ids.add(Long.parseLong(token));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
static boolean enabled(DatabaseService db,LegacyUser user){return ids(value(rows(db,EMPLOYEES))).contains(user.employeeId());}
|
||||
static boolean entrySpecial(DatabaseService db,LegacyUser user){return user.employeeId()==300001||enabled(db,user);}
|
||||
static boolean allCouriers(DatabaseService db,LegacyUser user){
|
||||
if(!entrySpecial(db,user))return true;
|
||||
String configured=value(rows(db,GLOBAL_JOBS));
|
||||
return configured.isBlank()||ids(configured).contains(user.headquartersId());
|
||||
}
|
||||
static String effective(LegacyUser user,String live){
|
||||
var result=new StringBuilder();for(int i=0;i<live.length();i++)result.append(live.charAt(i)=='1'&&user.hasRight(i)?'1':'0');return result.toString();
|
||||
}
|
||||
static String foreignRights(String rights){
|
||||
if(EmployeeAdministration.bit(rights,10))return rights;
|
||||
var result=new StringBuilder();for(int i=0;i<rights.length();i++)result.append(i<FOREIGN_MASK.length()&&FOREIGN_MASK.charAt(i)=='1'&&rights.charAt(i)=='1'?'1':'0');return result.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.CustomerExportFormat.*;
|
||||
|
||||
/** Category 9's fixed PHP projection. Catalog SQL is never executed. */
|
||||
final class CashExportFormat {
|
||||
static final Set<String> KEYS=Set.of("jbpc_id","cmp_no","currentdate","bookingdate","jbpc_mode","price","jbpc_invtext","cs_eid1","cs_eid2","invtext","freetext_1","freetext_2","freetext_3","postage","tax_rate","tax_rate_sign","cr_price","subtotalprice","markup","cr_subprice","const_901","const_902","const_903","const_904","const_905","VAT_special","vht_id","prod_descr","price_special");
|
||||
record Definition(MasterExports.State state,Settings settings) {}
|
||||
static String key(String expression){
|
||||
String key=expression.replaceFirst("^(inv|jbpc)\\.","");
|
||||
if(!KEYS.contains(key))throw bad("Das Einnahmenexportfeld „"+expression+"“ benötigt einen Abgleich mit der PHP-Felddefinition.");
|
||||
return key;
|
||||
}
|
||||
static Definition definition(CashExportPlan plan,LegacyUser u,long profile){
|
||||
if(profile<=0)throw bad("Bitte ein Einnahmenexportprofil auswählen.");
|
||||
var db=plan.db;
|
||||
if(db.queryForList("SELECT expc_id FROM exportcategory WHERE expc_id=9 AND expc_usrtype=1 AND expc_type=0").size()!=1)throw bad("Die Felddefinition der Exportkategorie 9 fehlt.");
|
||||
var definitions=db.queryForList("SELECT expcf_id,expcf_field,expcf_field_sql,expcf_title FROM exportcategoryfields WHERE expc_id=9 ORDER BY expcf_id LIMIT 301");
|
||||
if(definitions.isEmpty()||definitions.size()>300)throw bad("Die Felddefinition der Exportkategorie 9 fehlt oder ist zu umfangreich.");
|
||||
var fields=new ArrayList<Field>();var ids=new HashSet<Long>();
|
||||
for(var row:definitions){String name=text(row,"expcf_field"),key=key(name);if(!name.equals(text(row,"expcf_field_sql"))||!ids.add(id(row,"expcf_id")))throw bad("Die Einnahmenexport-Felddefinition benötigt einen aktualisierten Abgleich.");fields.add(new Field(id(row,"expcf_id"),key,text(row,"expcf_title"),key.equals("price")?"float":"text"));}
|
||||
var rows=db.queryForList("SELECT * FROM exportparameters WHERE expp_id=? AND expc_id=9 AND hq_id=? AND cs_id=0",profile,u.headquartersId());
|
||||
if(rows.size()!=1)throw bad("Das Einnahmenexportprofil ist für diese Niederlassung nicht vorhanden.");
|
||||
var state=new MasterExports.State(u.headquartersId(),fields,List.of(),rows.getFirst());var settings=MasterExports.settings(state);validate(settings,fields);return new Definition(state,settings);
|
||||
}
|
||||
static Map<String,String> constants(CashExportPlan plan,long hq,Map<String,Object> customer){
|
||||
var out=new LinkedHashMap<String,String>();String hqKey=plan.catalog.parameter(hq,"EXPORT_HQ_KEY");out.put("cmp_no",(hqKey.isBlank()?plan.catalog.parameter(0,"EXPORT_HQ_KEY"):hqKey)+" ");
|
||||
long group=OrderPayments.group(customer);
|
||||
for(int n=1;n<=5;n++){String parameter=String.format(Locale.ROOT,"EXPORT_CAT_09_CONST_%02d",n);String v=plan.object(0,parameter+"_CS",id(customer,"cs_id"));if(v.isEmpty()&&group>0)v=plan.object(hq,parameter,group);out.put("const_90"+n,v);}
|
||||
for(var item:Map.of("VAT_special","VAT_SPECIAL","vht_id","VHT_ID","prod_descr","PRODUCT","price_special","PRICE_SPECIAL").entrySet())out.put(item.getKey(),plan.catalog.parameter(0,"EXPORT_CAT_09_CONST_"+item.getValue()));
|
||||
return out;
|
||||
}
|
||||
static List<Map<String,Object>> rows(List<Map<String,Object>> collections,Map<String,Object> customer,Map<String,String> constants,LocalDate day){
|
||||
var result=new ArrayList<Map<String,Object>>();for(var c:collections){var row=new LinkedHashMap<String,Object>();KEYS.forEach(k->row.put(k,""));row.putAll(constants);row.put("currentdate",day.format(DateTimeFormatter.BASIC_ISO_DATE));String date=text(c,"jbpc_bookingdate");try{row.put("bookingdate",LocalDate.parse(date.substring(0,10)).format(DateTimeFormatter.BASIC_ISO_DATE));}catch(Exception e){throw bad("Eine Zahlungssammlung hat kein gültiges Buchungsdatum.");}row.put("jbpc_id",id(c,"jbpc_id"));row.put("jbpc_mode",id(c,"jbpc_mode"));row.put("price",Cashflow.net(c.get("jbpc_price")));row.put("jbpc_invtext",text(c,"jbpc_invtext"));row.put("cs_eid1",text(customer,"cs_eid"));row.put("cs_eid2",text(customer,"cs_eid"));result.add(row);}return result;
|
||||
}
|
||||
static byte[] render(Definition d,List<Map<String,Object>> rows){return MasterExportFormat.bytes(MasterExportFormat.render(d.settings(),d.state().fields(),rows,"|",false,null).replace("|"+" ".repeat(70),"|"));}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** The current PHP category-9 collection selection, without creating collections during preview. */
|
||||
@RestController @RequestMapping("/api/cashflow/export-plan")
|
||||
public final class CashExportPlan {
|
||||
record Input(Cashflow.Query query,int paymentFilter) {}
|
||||
record Group(LocalDate date,int counter,int mode,int installation,String invoice,List<Long> payments,BigDecimal net) {}
|
||||
record Key(LocalDate date,int counter,int mode,int installation) implements Comparable<Key> {
|
||||
public int compareTo(Key other){int c=date.compareTo(other.date);if(c==0)c=Integer.compare(counter,other.counter);if(c==0)c=Integer.compare(installation,other.installation);return c==0?Integer.compare(mode,other.mode):c;}
|
||||
}
|
||||
final Cashflow cash;final DatabaseService db;final OrderEntryCatalog catalog;
|
||||
public CashExportPlan(Cashflow cash){this.cash=cash;db=cash.db;catalog=cash.catalog;}
|
||||
@PostMapping Map<String,Object> preview(Authentication a,@RequestBody Input i){return preview(ApiController.user(a),i);}
|
||||
String object(long hq,String key,long object){for(var item:List.of(Map.entry(hq,key+"_"+object),Map.entry(0L,key+"_"+object),Map.entry(hq,key),Map.entry(0L,key))){String value=catalog.parameter(item.getKey(),item.getValue());if(!value.isEmpty())return value;}return "";}
|
||||
String setting(long hq,Map<String,Object> customer,String key){String value=object(hq,key+"_CS",id(customer,"cs_id"));long group=OrderPayments.group(customer);return value.isEmpty()&&group>0?object(hq,key+"_GRP",group):value;}
|
||||
static List<Integer> modes(String value){if(value==null||!value.strip().matches("[0-9]{1,3}(?:\\s*,\\s*[0-9]{1,3}){0,127}"))throw bad("Für diesen Kunden fehlt eine gültige Auswahl der Export-Zahlarten.");var result=Arrays.stream(value.strip().split("\\s*,\\s*")).map(Integer::valueOf).distinct().sorted().toList();if(result.stream().anyMatch(v->v>127))throw bad("Die Export-Zahlarten liegen außerhalb des gültigen Bereichs.");return result;}
|
||||
static boolean special(List<Integer> modes){return modes.stream().anyMatch(Set.of(11,15,16,17,18,19)::contains);}
|
||||
static List<Group> group(List<Map<String,Object>> payments,List<Integer> modes,Map<Integer,String> texts,LocalDate end){
|
||||
boolean special=special(modes),split=modes.contains(15);var groups=new TreeMap<Key,List<Map<String,Object>>>();
|
||||
for(var p:payments){var booked=dateTime(p,"jbp_bookingtime");int mode=(int)id(p,"jbp_mode"),counter=(int)id(p,"jbp_counter");if(!modes.contains(mode)||!CashflowActions.open(p)||Cashflow.net(p.get("jbp_price")).signum()==0)continue;if(booked==null)throw bad("Eine Zahlung besitzt kein gültiges Buchungsdatum.");
|
||||
if(special&&(id(p,"jb_status")!=2||id(p,"jb_incomplete")!=0))continue;if(!special&&counter!=0&&counter!=1)continue;
|
||||
var key=new Key(special?end:booked.toLocalDate(),special?-1:counter,mode,split?((id(p,"jb_service")&2)==2?1:0):-1);groups.computeIfAbsent(key,k->new ArrayList<>()).add(p);
|
||||
}
|
||||
var result=new ArrayList<Group>();for(var item:groups.entrySet()){var k=item.getKey();String invoice=split?"Wir berechnen Ihnen für die SAMS-"+(k.installation()==0?"Lieferungen":"Montagen")+" gemäß beiliegender|Anlage bis einschließlich":texts.getOrDefault(k.mode(),"");result.add(new Group(k.date(),k.counter(),k.mode(),k.installation(),invoice,item.getValue().stream().map(p->id(p,"jbp_id")).sorted().toList(),Cashflow.sum(item.getValue(),"jbp_price")));}return result;
|
||||
}
|
||||
Map<String,Object> preview(LegacyUser original,Input input){return db.readTransaction(()->{
|
||||
original.require(6);if(input==null||input.query()==null||input.paymentFilter()<1||input.paymentFilter()>99)throw bad("Bitte eine gültige Einnahmenexport-Auswahl wählen.");var q=cash.check(original,input.query());if(q.customer()<=0)throw bad("Für den Einnahmenexport bitte einen einzelnen Kunden auswählen.");var u=cash.scope(original,q.headquarters());var customer=cash.customer(u,q.customer());var selected=modes(setting(u.headquartersId(),customer,String.format(Locale.ROOT,"EXPORT_CAT_09_JBPMODES_%02d",input.paymentFilter())));var texts=new TreeMap<Integer,String>();for(int mode:selected)texts.put(mode,setting(u.headquartersId(),customer,"EXPORT_CAT_09_PAYMENTTYPE_"+mode));
|
||||
// PHP uses the payment's cost centre, which may differ from the order payer.
|
||||
var args=new ArrayList<Object>(List.of(q.customer(),u.headquartersId(),Cashflow.start(q),Cashflow.end(q)));args.addAll(selected);
|
||||
var payments=db.queryForList("SELECT p.*,j.jb_status,j.jb_incomplete,j.jb_service FROM jobpayment p JOIN costcenter cc ON cc.csc_id=p.csc_id JOIN job j ON j.jb_id=p.jb_id WHERE cc.cs_id=? AND j.hq_id=? AND p.jbp_bookingtime>=? AND p.jbp_bookingtime<? AND p.jbpc_id=0 AND p.jbp_price<>0 AND (p.jbp_export_time IS NULL OR p.jbp_export_time='0000-00-00 00:00:00') AND p.jbp_mode IN ("+CrmScope.marks(selected.size())+") ORDER BY p.jbp_id LIMIT 10001",args.toArray());Cashflow.bound(payments,10000);var groups=group(payments,selected,texts,q.to());
|
||||
// Already prepared collections remain export candidates even outside the selected booking period.
|
||||
var collectionArgs=new ArrayList<Object>(List.of(q.customer()));collectionArgs.addAll(selected);
|
||||
var collections=db.queryForList("SELECT cashCollection.* FROM jobpaymentcollection cashCollection WHERE cashCollection.cs_id=? AND (cashCollection.jbpc_export_time IS NULL OR cashCollection.jbpc_export_time='0000-00-00 00:00:00') AND cashCollection.jbpc_mode IN ("+CrmScope.marks(selected.size())+") AND EXISTS (SELECT 1 FROM jobpayment p JOIN job j ON j.jb_id=p.jb_id WHERE p.jbpc_id=cashCollection.jbpc_id) ORDER BY cashCollection.jbpc_id LIMIT 2001",collectionArgs.toArray());Cashflow.bound(collections,2000);
|
||||
var existing=new ArrayList<Map<String,Object>>();for(var collection:collections){var members=db.queryForList("SELECT p.jbp_id,p.jbp_price,p.jbp_export_time,p.jbp_mode,j.hq_id,cc.cs_id FROM jobpayment p LEFT JOIN job j ON j.jb_id=p.jb_id LEFT JOIN costcenter cc ON cc.csc_id=p.csc_id WHERE p.jbpc_id=? ORDER BY p.jbp_id LIMIT 10001",id(collection,"jbpc_id"));Cashflow.bound(members,10000);if(members.stream().anyMatch(p->id(p,"hq_id")!=u.headquartersId()||id(p,"cs_id")!=q.customer()))throw bad("Eine offene Sammlung enthält Zahlungen anderer Kunden oder Niederlassungen. Bitte die Zuordnung vor dem Export klären.");if(members.stream().anyMatch(OrderPayments::exportedPayment))throw bad("Eine offene Sammlung enthält bereits exportierte Zahlungen. Bitte die Zuordnung klären.");existing.add(fields("id",id(collection,"jbpc_id"),"date",text(collection,"jbpc_bookingdate"),"mode",id(collection,"jbpc_mode"),"invoice",text(collection,"jbpc_invtext"),"net",Cashflow.net(collection.get("jbpc_price")),"payments",members.stream().map(p->id(p,"jbp_id")).toList()));}
|
||||
var profiles=db.queryForList("SELECT expp_id AS id,expp_name AS label FROM exportparameters WHERE expc_id=9 AND hq_id=? AND cs_id=0 ORDER BY expp_id",u.headquartersId());boolean catalog=db.queryForList("SELECT expc_id FROM exportcategory WHERE expc_id=9 AND expc_usrtype=1 AND expc_type=0").size()==1;long fieldsCount=db.queryForObject("SELECT COUNT(*) FROM exportcategoryfields WHERE expc_id=9",Long.class);
|
||||
String notice=!catalog||fieldsCount==0||profiles.isEmpty()?"Das Dateiprofil samt Felddefinition für Kategorie 9 fehlt. Die Vorschau verändert keine Zahlungen; eine Exportdatei und Exportkennzeichnung sind noch nicht möglich.":"Das hinterlegte Dateiprofil kann jetzt geprüft werden. Erst der bestätigte Export erstellt Sammlungen und kennzeichnet die Zahlungen.";
|
||||
return fields("query",q,"paymentFilter",input.paymentFilter(),"modes",selected,"special",special(selected),"groups",groups,"existing",existing,"net",groups.stream().map(entryValue -> entryValue.net()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)).add(Cashflow.sum(existing,"net")),"profiles",profiles,"notice",notice,"formatAvailable",catalog&&fieldsCount>0&&!profiles.isEmpty(),"revision",revision(List.of(input,customer,selected,texts,payments,collections,existing,profiles)));
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.BusinessSupport.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Collections and payload are reserved atomically; export flags follow durable file installation. */
|
||||
@RestController @RequestMapping("/api/cashflow/export-runs")
|
||||
final class CashExportRuns {
|
||||
record Input(CashExportPlan.Input selection,long profile,String filename) {}
|
||||
record Command(String requestId,Input input,String revision,boolean confirmed) {}
|
||||
record Operation(String requestId,long actor,long headquarters,Command command,List<Long> created,List<Long> collections,String dataRevision,String digest,int bytes,ExportEncryption.Policy encryption,String status,long file,LocalDateTime at) {
|
||||
String name(){return encryption.filename(command.input().filename());}
|
||||
String physical(){return "ngcash-"+requestId+encryption.suffix();}
|
||||
}
|
||||
record Snapshot(Map<String,Object> plan,CashExportFormat.Definition definition,Map<String,Object> customer,Map<String,String> constants,LocalDate day,byte[] preview,String revision) {}
|
||||
record Data(List<Map<String,Object>> collections,List<Map<String,Object>> payments) {}
|
||||
final CashExportPlan plan;final DatabaseService db;final BusinessSupport support;final ExportFileStore files;
|
||||
CashExportRuns(CashExportPlan plan,ExportFileStore files){this.plan=plan;this.db=plan.db;this.support=plan.cash.support;this.files=files;}
|
||||
LegacyUser scope(LegacyUser original,long hq){original.require(6);return plan.cash.scope(original,hq);}
|
||||
@PostMapping("/preview") Object preview(Authentication a,@RequestBody Input input){return preview(ApiController.user(a),input);}
|
||||
@PostMapping Object start(Authentication a,@RequestBody Command command){return start(ApiController.user(a),command);}
|
||||
@GetMapping Object operations(Authentication a,@RequestParam long headquarters){return operations(ApiController.user(a),headquarters);}
|
||||
@PostMapping("/{requestId}/resume") Object resume(Authentication a,@PathVariable String requestId,@RequestParam long headquarters){return resume(ApiController.user(a),headquarters,requestId);}
|
||||
@PostMapping("/{requestId}/cancel") Object cancel(Authentication a,@PathVariable String requestId,@RequestParam long headquarters){return cancel(ApiController.user(a),headquarters,requestId);}
|
||||
@SuppressWarnings("unchecked") Snapshot snapshot(LegacyUser original,Input input){
|
||||
if(input==null||input.selection()==null||input.selection().query()==null)throw bad("Die Exportauswahl fehlt.");MasterExports.filename(input.filename());
|
||||
var p=plan.preview(original,input.selection());var q=(Cashflow.Query)p.get("query");var u=scope(original,q.headquarters());var customer=plan.cash.customer(u,q.customer());var definition=CashExportFormat.definition(plan,u,input.profile());var constants=CashExportFormat.constants(plan,u.headquartersId(),customer);var day=LocalDate.now(ZoneId.of("Europe/Berlin"));
|
||||
var collections=new ArrayList<Map<String,Object>>();for(var c:(List<Map<String,Object>>)p.get("existing"))collections.add(fields("jbpc_id",c.get("id"),"jbpc_mode",c.get("mode"),"jbpc_price",c.get("net"),"jbpc_bookingdate",c.get("date"),"jbpc_invtext",c.get("invoice")));
|
||||
for(var g:(List<CashExportPlan.Group>)p.get("groups"))collections.add(collection(q.customer(),0,g));
|
||||
if(collections.isEmpty())throw bad("Keine offenen Zahlungssammlungen für diesen Export.");Cashflow.bound(collections,2000);
|
||||
byte[] preview=CashExportFormat.render(definition,CashExportFormat.rows(collections,customer,constants,day));
|
||||
return new Snapshot(p,definition,customer,constants,day,preview,revision(List.of(input,p.get("revision"),definition,customer,constants,day)));
|
||||
}
|
||||
static Map<String,Object> collection(long customer,long id,CashExportPlan.Group g){return fields("jbpc_id",id,"cs_id",customer,"jbpc_mode",g.mode(),"jbpc_price",g.net(),"jbpc_bookingdate",g.date(),"jbpc_invtext",g.invoice());}
|
||||
Map<String,Object> preview(LegacyUser original,Input input){return db.readTransaction(()->{var s=snapshot(original,input);var u=scope(original,((Cashflow.Query)s.plan().get("query")).headquarters());boolean canStore=true;String notice="Neue Sammelnummern werden beim Start vergeben und erscheinen in der Dateivorschau als 0.";try{writable(u);}catch(org.springframework.web.server.ResponseStatusException e){canStore=false;notice+=" "+e.getReason();}String content=new String(s.preview(),java.nio.charset.Charset.forName("windows-1252"));return fields("revision",s.revision(),"bytes",s.preview().length,"content",content.substring(0,Math.min(20000,content.length())),"truncated",content.length()>20000,"filename",input.filename(),"canStore",canStore,"notice",notice);});}
|
||||
void writable(LegacyUser u){u.require(6);if(!files.store.configured())throw bad("Die gemeinsame Exportablage ist noch nicht eingerichtet.");String path=plan.catalog.parameter(0,"EXPORT_PATH");if(!path.isBlank()&&!path.replaceAll("/+$","").equals("../export/download"))throw bad("Die konfigurierte Exportablage muss abgeglichen werden.");files.encryption.policy(db,u);}
|
||||
Operation decode(Map<String,Object> row){try{var op=support.json.readValue(text(row,"gdc_content"),Operation.class);token(op.requestId());if(op.command()==null||op.command().input()==null||op.collections().isEmpty()||op.collections().size()>2000||!op.collections().containsAll(op.created())||op.bytes()<1||op.bytes()>CustomerExportFormat.MAX_OUTPUT||!op.digest().matches("[0-9a-f]{64}")||!Set.of("PENDING","DONE","CANCELLED").contains(op.status()))throw new IllegalArgumentException();return op;}catch(Exception e){throw bad("Der gespeicherte Einnahmenexport ist unvollständig.");}}
|
||||
Operation find(LegacyUser u,String requestId){var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcashrun' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.headquartersId(),token(requestId));if(rows.isEmpty())return null;var op=decode(one(rows));if(op.actor()!=u.userId()||op.headquarters()!=u.headquartersId())throw MasterDirectoryScope.denied();plan.cash.check(u,op.command().input().selection().query());return op;}
|
||||
Map<String,Object> operations(LegacyUser original,long hq){return db.readTransaction(()->{var u=scope(original,hq);var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcashrun' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id DESC LIMIT 101",u.headquartersId());Cashflow.bound(rows,100);return fields("operations",rows.stream().map(this::decode).filter(o->o.actor()==u.userId()).map(o->fields("requestId",o.requestId(),"filename",o.name(),"customer",o.command().input().selection().query().customer(),"collections",o.collections().size())).toList());});}
|
||||
void free(LegacyUser u,Command command,String name){
|
||||
if(!db.queryForList("SELECT expf_id FROM exportfiles WHERE hq_id=? AND cs_id=0 AND expf_name=? LIMIT 1",u.headquartersId(),name).isEmpty())throw bad("Dieser Exportdateiname ist bereits vergeben.");
|
||||
for(var row:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcashrun' AND gdc_context='PENDING'")){var op=decode(row);if(op.requestId().equals(command.requestId()))continue;if(op.command().input().selection().query().customer()==command.input().selection().query().customer()||op.headquarters()==u.headquartersId()&&op.name().equalsIgnoreCase(name))throw bad("Für diesen Kunden oder Dateinamen ist bereits ein Einnahmenexport offen. Bitte zuerst wiederaufnehmen oder verwerfen.");}
|
||||
}
|
||||
@SuppressWarnings("unchecked") Map<String,Object> start(LegacyUser original,Command command){
|
||||
if(command==null||!command.confirmed()||command.input()==null||command.input().selection()==null||command.input().selection().query()==null)throw bad("Bitte die Dateierstellung und Exportkennzeichnung bestätigen.");token(command.requestId());var u=scope(original,command.input().selection().query().headquarters());
|
||||
var prior=db.readTransaction(()->find(u,command.requestId()));if(prior!=null){if(!prior.command().equals(command))throw bad("Diese Vorgangskennung wurde bereits anders verwendet.");return resume(original,u.headquartersId(),command.requestId());}
|
||||
db.cashExportWriteTransaction(()->{
|
||||
var existing=find(u,command.requestId());if(existing!=null){if(!existing.command().equals(command))throw bad("Diese Vorgangskennung wurde bereits anders verwendet.");return null;}
|
||||
writable(u);var s=snapshot(original,command.input());if(!s.revision().equals(command.revision()))throw bad("Die Vorschau ist nicht mehr aktuell. Bitte erneut prüfen.");var policy=files.encryption.policy(db,u);free(u,command,policy.filename(command.input().filename()));
|
||||
var created=new ArrayList<Long>();var ids=new TreeSet<Long>();for(var c:(List<Map<String,Object>>)s.plan().get("existing"))ids.add(id(c,"id"));
|
||||
for(var group:(List<CashExportPlan.Group>)s.plan().get("groups")){var c=collection(command.input().selection().query().customer(),0,group);c.remove("jbpc_id");long key=db.insert("jobpaymentcollection",c);created.add(key);ids.add(key);for(long payment:group.payments())if(db.update("UPDATE jobpayment SET jbpc_id=? WHERE jbp_id=? AND jbpc_id=0 AND (jbp_export_time IS NULL OR jbp_export_time='0000-00-00 00:00:00')",key,payment)!=1)throw bad("Eine Zahlung wurde zwischenzeitlich gesammelt oder exportiert.");}
|
||||
var data=data(u,new ArrayList<>(ids),command.input().selection().query().customer());byte[] payload=files.encryption.encrypt(CashExportFormat.render(s.definition(),CashExportFormat.rows(data.collections(),s.customer(),s.constants(),s.day())),policy);
|
||||
var op=new Operation(command.requestId(),u.userId(),u.headquartersId(),command,created,new ArrayList<>(ids),revision(data),DocumentStore.digest(payload),payload.length,policy,"PENDING",0,null);String encoded=support.encode(op);if(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65000)throw bad("Zu viele Sammlungen für einen Einnahmenexport.");db.insert("genericdatacontainer",fields("gdc_obj_type","ngcashrun","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",command.requestId(),"gdc_content",encoded,"gdc_context","PENDING"));
|
||||
String text=Base64.getEncoder().encodeToString(payload);for(int offset=0,part=0;offset<text.length();offset+=50000,part++)db.insert("genericdatacontainer",fields("gdc_obj_type","ngcashpart","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",op.requestId()+"_"+part,"gdc_content",text.substring(offset,Math.min(offset+50000,text.length())),"gdc_context",String.format(Locale.ROOT,"%04d",part)));return null;
|
||||
});return resume(original,u.headquartersId(),command.requestId());
|
||||
}
|
||||
Data data(LegacyUser u,List<Long> ids,long customer){
|
||||
var collections=new ArrayList<Map<String,Object>>();var payments=new ArrayList<Map<String,Object>>();
|
||||
for(int i=0;i<ids.size();i+=500){var batch=ids.subList(i,Math.min(i+500,ids.size()));collections.addAll(db.queryForList("SELECT * FROM jobpaymentcollection WHERE jbpc_id IN ("+CrmScope.marks(batch.size())+") ORDER BY jbpc_id",batch.toArray()));payments.addAll(db.queryForList("SELECT p.*,j.hq_id,cc.cs_id FROM jobpayment p LEFT JOIN job j ON j.jb_id=p.jb_id LEFT JOIN costcenter cc ON cc.csc_id=p.csc_id WHERE p.jbpc_id IN ("+CrmScope.marks(batch.size())+") ORDER BY p.jbp_id LIMIT 10001",batch.toArray()));}
|
||||
Cashflow.bound(payments,10000);if(collections.size()!=ids.size()||collections.stream().anyMatch(c->id(c,"cs_id")!=customer||dateTime(c,"jbpc_export_time")!=null)||payments.stream().anyMatch(p->id(p,"hq_id")!=u.headquartersId()||id(p,"cs_id")!=customer||OrderPayments.exportedPayment(p)))throw bad("Die Zahlungssammlungen wurden geändert oder sind nicht mehr für diesen Export freigegeben.");
|
||||
for(var c:collections)if(payments.stream().noneMatch(p->id(p,"jbpc_id")==id(c,"jbpc_id")))throw bad("Eine Zahlungssammlung besitzt keine Zahlungen mehr.");
|
||||
collections.sort(Comparator.comparingLong(c->id(c,"jbpc_id")));payments.sort(Comparator.comparingLong(p->id(p,"jbp_id")));return new Data(collections,payments);
|
||||
}
|
||||
byte[] payload(Operation op){var text=new StringBuilder();for(var row:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcashpart' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!' ORDER BY gdc_context",op.headquarters(),op.requestId()+"!_%")){text.append(text(row,"gdc_content"));if(text.length()>13_333_336)throw bad("Die gespeicherte Exportdatei ist zu groß.");}byte[] bytes;try{bytes=Base64.getDecoder().decode(text.toString());}catch(Exception e){throw bad("Die Exportdatei ist unvollständig.");}if(bytes.length!=op.bytes()||!DocumentStore.digest(bytes).equals(op.digest()))throw bad("Die Exportdatei wurde verändert oder unvollständig gespeichert.");return bytes;}
|
||||
void store(Operation op){var bytes=payload(op);try(var fs=files.store.open()){String stage=".ng-staging/"+op.requestId()+".part";if(fs.exists(op.physical())){if(!DocumentStore.digest(fs.read(op.physical(),CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))throw bad("Die reservierte Exportdatei wurde verändert.");}else{fs.mkdir(".ng-staging");if(fs.exists(stage)&&!DocumentStore.digest(fs.read(stage,CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))fs.remove(stage);if(!fs.exists(stage))fs.create(stage,bytes);fs.move(stage,op.physical());}if(fs.exists(stage))fs.remove(stage);}}
|
||||
void update(Operation op){db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_obj_type='ngcashrun' AND gdc_obj_id=? AND gdc_gen_fieldname=?",support.encode(op),op.status().equals("DONE")?"LOG_PENDING":op.status(),op.headquarters(),op.requestId());}
|
||||
void clear(Operation op){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngcashpart' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!'",op.headquarters(),op.requestId()+"!_%");}
|
||||
Map<String,Object> resume(LegacyUser original,long headquarters,String requestId){var u=scope(original,headquarters);var result=db.cashExportWriteTransaction(()->{
|
||||
var op=find(u,requestId);if(op==null)throw bad("Einnahmenexport nicht gefunden.");if(op.status().equals("DONE"))return fields("completed",true,"file",op.file(),"replayed",true);if(!op.status().equals("PENDING"))throw bad("Der Einnahmenexport wurde verworfen.");writable(u);files.encryption.unchanged(db,u,op.encryption());free(u,op.command(),op.name());var data=data(u,op.collections(),op.command().input().selection().query().customer());if(!revision(data).equals(op.dataRevision()))throw bad("Die gesammelten Zahlungen wurden nach der Vorbereitung geändert. Bitte die Zuordnung klären.");
|
||||
store(op);var at=LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0);for(long collection:op.collections()){db.update("UPDATE jobpayment SET jbp_export_time=? WHERE jbpc_id=?",at,collection);db.update("UPDATE jobpaymentcollection SET jbpc_export_time=? WHERE jbpc_id=?",at,collection);}long file=db.insert("exportfiles",fields("hq_id",u.headquartersId(),"cs_id",0,"expf_name",op.name(),"expf_cryptname",op.physical(),"expf_storetime",at,"expf_ftpupload",0));update(new Operation(op.requestId(),op.actor(),op.headquarters(),op.command(),op.created(),op.collections(),op.dataRevision(),op.digest(),op.bytes(),op.encryption(),"DONE",file,at));clear(op);support.audit(u,0,op.command().input().selection().query().customer(),0,203,"CASH_EXPORT="+file+"|COLLECTIONS="+op.collections().size()+"|PAYMENTS="+data.payments().size()+"|SHA256="+op.digest(),op.requestId());return fields("completed",true,"file",file,"collections",op.collections().size(),"payments",data.payments().size());
|
||||
});publishLogs();return result;}
|
||||
Map<String,Object> cancel(LegacyUser original,long headquarters,String requestId){var u=scope(original,headquarters);return db.cashExportWriteTransaction(()->{var op=find(u,requestId);if(op==null)throw bad("Einnahmenexport nicht gefunden.");if(op.status().equals("CANCELLED"))return fields("cancelled",true);if(!op.status().equals("PENDING"))throw bad("Ein abgeschlossener Export kann nicht verworfen werden.");if(!revision(data(u,op.collections(),op.command().input().selection().query().customer())).equals(op.dataRevision()))throw bad("Die gesammelten Zahlungen wurden geändert. Bitte die Zuordnung vor dem Verwerfen klären.");if(!db.queryForList("SELECT expf_id FROM exportfiles WHERE expf_cryptname=?",op.physical()).isEmpty())throw bad("Die Datei wurde bereits veröffentlicht.");try(var fs=files.store.open()){if(fs.exists(op.physical())){if(!DocumentStore.digest(fs.read(op.physical(),CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))throw bad("Die reservierte Exportdatei wurde verändert.");fs.remove(op.physical());}String stage=".ng-staging/"+op.requestId()+".part";if(fs.exists(stage))fs.remove(stage);}for(long key:op.created()){db.update("UPDATE jobpayment SET jbpc_id=0 WHERE jbpc_id=?",key);db.update("DELETE FROM jobpaymentcollection WHERE jbpc_id=?",key);}update(new Operation(op.requestId(),op.actor(),op.headquarters(),op.command(),op.created(),op.collections(),op.dataRevision(),op.digest(),op.bytes(),op.encryption(),"CANCELLED",0,null));clear(op);return fields("cancelled",true);});}
|
||||
@org.springframework.scheduling.annotation.Scheduled(initialDelay=60000,fixedDelay=30000)
|
||||
public void publishLogs(){try{for(var row:db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngcashrun' AND gdc_context='LOG_PENDING' ORDER BY gdc_id LIMIT 30"))db.writeTransaction(()->{long key=id(row,"gdc_id");var rows=db.lockRows("SELECT gdc_content FROM genericdatacontainer WHERE gdc_id=? AND gdc_context='LOG_PENDING'",key);if(rows.isEmpty())return null;var op=decode(one(rows));if(!op.status().equals("DONE")||op.at()==null)throw bad("Ungültiger Exportprotokollauftrag.");String ids=String.join(",",op.collections().stream().map(String::valueOf).toList());if(db.queryForList("SELECT exl_id FROM exportlog WHERE expc_id=9 AND exl_rows=? AND exl_exporttime=? AND usr_id=? LIMIT 1",ids,op.at(),op.actor()).isEmpty())db.insert("exportlog",fields("expc_id",9,"exl_rows",ids,"exl_exporttime",op.at(),"usr_id",op.actor()));db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",key);return null;});}catch(RuntimeException ignored){/* The durable export log is retried without repeating the export. */}}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** Kasse: the five current statistic_cashflow.php views and their shared selection rules. */
|
||||
@RestController
|
||||
@RequestMapping("/api/cashflow")
|
||||
public final class Cashflow {
|
||||
final DatabaseService db;final BusinessSupport support;final OrderEntryCatalog catalog;final OrderPayments paymentCatalog;
|
||||
public Cashflow(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog,OrderPayments paymentCatalog){this.db=db;this.support=support;this.catalog=catalog;this.paymentCatalog=paymentCatalog;}
|
||||
record Query(int view,long headquarters,long customer,LocalDate from,LocalDate to,LocalTime fromTime,LocalTime toTime,int counter,int service,Integer mode,boolean calculator,boolean articles){}
|
||||
record Job(Map<String,Object> job,List<Map<String,Object>> payments,List<Map<String,Object>> calculation,List<Map<String,Object>> articles,List<Map<String,Object>> metadata){}
|
||||
record Data(Query query,List<Map<String,Object>> rows,List<Map<String,Object>> dayTotals,Map<Long,Job> jobs,List<Map<String,Object>> modes,String customer,boolean canEdit){}
|
||||
static final List<String> VIEWS=List.of("Kassenübersicht mit Umbuchungen","Einnahmen pro Auftrag","Einnahmen pro Auftrag und Zahlungsart","Einzelzahlungen","Einnahmen pro Zahlungsart");
|
||||
static BigDecimal net(Object n){return OrderPayments.net(n);}
|
||||
static BigDecimal gross(BigDecimal n){return n.multiply(new BigDecimal("1.19")).setScale(2,RoundingMode.HALF_UP);}
|
||||
static BigDecimal sum(List<Map<String,Object>> rows,String column){return rows.stream().map(r->net(r.get(column))).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue));}
|
||||
List<Map<String,Object>> headquarters(LegacyUser u){u.require(26);var result=new ArrayList<Map<String,Object>>(db.queryForList("SELECT hq_id AS id,hq_mnemonic AS label FROM headquarters WHERE hq_id=?",u.headquartersId()));if(u.hasRight(10))for(var row: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 AND COALESCE(h.hq_disabled,0)=0 ORDER BY h.hq_mnemonic",u.headquartersId()))if(result.stream().noneMatch(r->id(r,"id")==id(row,"id")))result.add(row);return result;}
|
||||
LegacyUser scope(LegacyUser u,long hq){u.require(26);if(hq<=0)hq=u.headquartersId();long selected=hq;if(headquarters(u).stream().noneMatch(r->id(r,"id")==selected))throw new org.springframework.security.access.AccessDeniedException("Diese Niederlassung ist für die Kasse nicht freigegeben.");return new LegacyUser(u.userId(),u.employeeId(),selected,u.username(),u.rights(),u.originalHeadquartersId(),u.accountType());}
|
||||
long defaultCustomer(LegacyUser u){var rows=db.queryForList("SELECT par_value FROM parameter WHERE emp_id=? AND hq_id=? AND par_key='MASK_DISPOSITION_INIT_CS'",u.employeeId(),u.headquartersId());if(rows.size()>1)throw bad("Der Startkunde ist mehrfach hinterlegt.");return rows.isEmpty()?0:VehicleDispositionPreferences.number(text(rows.getFirst(),"par_value"));}
|
||||
Map<String,Object> customer(LegacyUser u,long customer){return one(db.queryForList("SELECT cs.cs_id,cs.cs_eid,cs.cs_group,c.cmp_comp FROM customer cs LEFT JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.cs_id=? AND cs.hq_id=?",customer,u.headquartersId()));}
|
||||
List<Map<String,Object>> modes(Map<String,Object> customer){List<Map<String,Object>> modes=List.of();if(!customer.isEmpty()){modes=paymentCatalog.choices("cs",id(customer,"cs_id"));long group=OrderPayments.group(customer);if(modes.isEmpty()&&group>0)modes=paymentCatalog.choices("grp",group);}return modes.isEmpty()?paymentCatalog.choices("",0):modes;}
|
||||
static String label(List<Map<String,Object>> modes,long id){return modes.stream().filter(r->id(r,"mt_sort")==id).map(r->text(r,"mt_value")).findFirst().orElse("Zahlart #"+id);}
|
||||
Query check(LegacyUser original,Query q){if(q==null||q.view()<0||q.view()>4||q.counter()<1||q.counter()>2||q.service()<0||q.service()>2||q.customer()<0||q.mode()!=null&&(q.mode()<0||q.mode()>127))throw bad("Ungültige Kassenauswahl.");var u=scope(original,q.headquarters());range(q.from(),q.to());if(!u.hasRight(8)){if(!q.from().equals(q.to()))throw bad("Ohne Statistikrecht ist nur ein einzelner Kassentag freigegeben.");if(q.customer()==0||q.customer()!=defaultCustomer(u))throw bad("Für dieses Konto ist nur der hinterlegte Startkunde freigegeben.");}if(q.view()==0&&q.customer()==0)throw bad("Für die Kassenübersicht bitte einen Kunden auswählen.");if(q.customer()>0)customer(u,q.customer());return new Query(q.view(),u.headquartersId(),q.customer(),q.from(),q.to(),q.fromTime()==null?LocalTime.MIN:q.fromTime().withSecond(0).withNano(0),q.toTime()==null?LocalTime.of(23,59,59):q.toTime().withSecond(59).withNano(0),q.view()==0?1:q.counter(),q.view()==0?0:q.service(),q.mode(),q.calculator(),q.articles());}
|
||||
static LocalDateTime start(Query q){return q.from().atTime(q.fromTime());}static LocalDateTime end(Query q){return q.to().atTime(q.toTime()).plusSeconds(1);}
|
||||
@GetMapping("/options") Map<String,Object> options(Authentication a,@RequestParam(required=false) Long headquarters){return options(ApiController.user(a),headquarters);}
|
||||
Map<String,Object> options(LegacyUser original,Long selected){var u=scope(original,selected==null?original.headquartersId():selected);long preset=defaultCustomer(u);var customers=db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' · ',NULLIF(cs.cs_eid,''),NULLIF(c.cmp_comp,'')) AS label FROM customer cs LEFT JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=?"+(u.hasRight(8)?"":" AND cs.cs_id=?")+" ORDER BY c.cmp_comp,cs.cs_id",u.hasRight(8)?new Object[]{u.headquartersId()}:new Object[]{u.headquartersId(),preset});return fields("headquarters",headquarters(original),"currentHeadquarters",u.headquartersId(),"customers",customers,"defaultCustomer",customers.stream().anyMatch(c->id(c,"id")==preset)?preset:0,"canChooseCustomer",u.hasRight(8),"canChoosePeriod",u.hasRight(8),"canEdit",u.hasRight(7),"canExport",u.hasRight(6),"canOpenOrders",u.hasRight(4)&&u.headquartersId()==original.headquartersId(),"views",java.util.stream.IntStream.range(0,5).mapToObj(i->fields("id",i,"label",VIEWS.get(i))).toList());}
|
||||
@PostMapping("/query") Map<String,Object> query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);}
|
||||
Map<String,Object> query(LegacyUser original,Query raw){return db.readTransaction(()->view(read(original,raw)));}
|
||||
Data read(LegacyUser original,Query raw){Query q=check(original,raw);if(!start(q).isBefore(end(q)))throw bad("Der Beginn muss vor dem Ende des Zeitraums liegen.");var u=scope(original,q.headquarters());var cs=q.customer()>0?customer(u,q.customer()):Map.<String,Object>of();var modes=modes(cs);var jobs=new LinkedHashMap<Long,Job>();
|
||||
String where="j.hq_id=? AND cs.hq_id=?";var args=new ArrayList<Object>(List.of(u.headquartersId(),u.headquartersId()));if(q.customer()>0){where+=" AND cs.cs_id=?";args.add(q.customer());}
|
||||
String base=" FROM job j JOIN costcenter cc ON cc.csc_id=j.csc_id_related JOIN customer cs ON cs.cs_id=cc.cs_id WHERE "+where;
|
||||
if(q.view()==0){String filter=" AND j.jb_createtime>=? AND j.jb_createtime<? AND COALESCE(j.jb_offer,0)=0 AND NOT EXISTS(SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=j.jb_id AND g.gdc_gen_fieldname='jb_service_job' AND g.gdc_content='1') AND (COALESCE(j.jb_storno,0) IN (0,1) OR COALESCE((SELECT SUM(p.jbp_price) FROM jobpayment p WHERE p.jb_id=j.jb_id AND p.jbp_counter=1),0)<>0)";var values=new ArrayList<>(args);values.add(start(q));values.add(end(q));var selected=db.queryForList("SELECT j.*"+base+filter+" ORDER BY j.jb_id LIMIT 2001",values.toArray());bound(selected,2000);for(var row:selected)jobs.put(id(row,"jb_id"),job(row,q));
|
||||
if(!jobs.isEmpty()){var values2=new ArrayList<>(args);values2.addAll(jobs.keySet());var children=db.queryForList("SELECT j.*"+base+" AND j.jb_id_parent IN ("+CrmScope.marks(jobs.size())+") ORDER BY j.jb_id LIMIT 2001",values2.toArray());bound(children,2000);for(var child:children)if(!jobs.containsKey(id(child,"jb_id")))jobs.put(id(child,"jb_id"),job(child,q));}
|
||||
}else{
|
||||
var values=new ArrayList<>(args);String filter=" AND EXISTS(SELECT 1 FROM jobpayment p WHERE p.jb_id=j.jb_id AND p.csc_id=j.csc_id_related AND p.jbp_bookingtime>=? AND p.jbp_bookingtime<?";values.add(start(q));values.add(end(q));
|
||||
if(!(q.view()==4&&q.counter()==2)){filter+=" AND p.jbp_counter=?";values.add(q.counter()==1?1:0);}if(q.mode()!=null){filter+=" AND p.jbp_mode=?";values.add(q.mode());}filter+=")";if(q.service()>0){filter+=" AND (j.jb_service & ?)=?";values.add(q.service());values.add(q.service());}
|
||||
var selected=db.queryForList("SELECT j.*"+base+filter+" ORDER BY j.jb_id LIMIT 2001",values.toArray());bound(selected,2000);for(var row:selected)jobs.put(id(row,"jb_id"),job(row,q));
|
||||
}
|
||||
var rows=q.view()==0?overview(jobs,modes):statistics(q,jobs,modes);
|
||||
if(q.view()==4&&q.counter()==2){var values=new ArrayList<>(args);values.add(start(q));values.add(end(q));var target=one(db.queryForList("SELECT COALESCE(SUM(j.jb_totalprice),0) AS expected"+base+" AND j.jb_ordertime>=? AND j.jb_ordertime<? AND COALESCE(j.jb_storno,0) IN (0,1) AND j.jb_status IN (1,2) AND (j.jb_service & 2)=2 AND NOT EXISTS(SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=j.jb_id AND g.gdc_gen_fieldname='jb_service_job' AND g.gdc_content='1')",values.toArray()));for(var row:rows)if(text(row,"label").equals("Montage-Inkasso"))row.put("due",gross(net(target.get("expected"))));}
|
||||
var dayTotals=new ArrayList<Map<String,Object>>();if(q.view()==0){var values=new ArrayList<>(args);values.add(start(q));values.add(end(q));for(var row:db.queryForList("SELECT p.jbp_mode,SUM(p.jbp_price) AS amount FROM jobpayment p JOIN job j ON j.jb_id=p.jb_id JOIN costcenter cc ON cc.csc_id=j.csc_id_related JOIN customer cs ON cs.cs_id=cc.cs_id WHERE "+where+" AND p.csc_id=j.csc_id_related AND p.jbp_counter=1 AND p.jbp_bookingtime>=? AND p.jbp_bookingtime<? AND NOT EXISTS(SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=j.jb_id AND g.gdc_gen_fieldname='jb_service_job' AND g.gdc_content='1') GROUP BY p.jbp_mode ORDER BY p.jbp_mode",values.toArray()))dayTotals.add(fields("label",label(modes,id(row,"jbp_mode")),"amount",gross(net(row.get("amount")))));}
|
||||
for(var r:rows){long number=id(r,"job");if(number>0){var j=jobs.get(number).job();r.put("displayNumber",q.view()==0?(id(j,"jb_id_parent")>0?id(j,"jb_id_parent")+"/2":number+"/1"):OrderDetailOptions.displayNumber(catalog,j));}}
|
||||
return new Data(q,rows,dayTotals,jobs,modes,cs.isEmpty()?"Alle Kunden":text(cs,"cs_eid")+" · "+text(cs,"cmp_comp"),u.hasRight(7));
|
||||
}
|
||||
static void bound(List<?> rows,int maximum){if(rows.size()>maximum)throw bad("Zu viele Kassenpositionen. Bitte Zeitraum oder Kunde eingrenzen.");}
|
||||
Job job(Map<String,Object> row,Query q){long id=id(row,"jb_id");var payments=db.queryForList("SELECT * FROM jobpayment WHERE jb_id=? ORDER BY jbp_bookingtime,jbp_id LIMIT 2001",id);bound(payments,2000);return new Job(row,payments,q.calculator()?db.queryForList("SELECT srv_name,srvt_name,jbc_amount,jbc_price,jbc_totalprice FROM jobcalculator WHERE jb_id=? ORDER BY srv_name,srvt_name",id):List.of(),q.articles()?db.queryForList("SELECT trat_name,trat_quantity,trat_price,trat_serialno,trat_remark FROM tourarticle WHERE jb_id=? ORDER BY trat_sort,trat_id",id):List.of(),db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_service_job','jb_id_addmont_parent','jb_search') ORDER BY gdc_id",id));}
|
||||
static boolean service(Job job){return job.metadata().stream().anyMatch(m->text(m,"gdc_gen_fieldname").equals("jb_service_job")&&text(m,"gdc_content").equals("1"));}
|
||||
static Map<String,Object> row(Job job,List<Map<String,Object>> payments,String label){var j=job.job();return fields("job",id(j,"jb_id"),"parent",id(j,"jb_id_parent"),"installation",(id(j,"jb_service")&2)==2,"label",label,"paid",gross(sum(payments,"jbp_price")),"due",gross(net(j.get("jb_subtotalprice"))),"remaining",gross(net(j.get("jb_totalprice")).subtract(sum(job.payments(),"jbp_price"))),"booked",payments.size()==1?text(payments.getFirst(),"jbp_bookingtime"):"","payments",payments.stream().map(p->fields("id",id(p,"jbp_id"),"mode",id(p,"jbp_mode"),"net",net(p.get("jbp_price")),"gross",gross(net(p.get("jbp_price"))),"counter",id(p,"jbp_counter")==1,"collection",id(p,"jbpc_id"),"booked",text(p,"jbp_bookingtime"),"exported",OrderPayments.exportedPayment(p)?text(p,"jbp_export_time"):"")).toList(),"collections",payments.stream().map(p->id(p,"jbpc_id")).filter(v->v>0).distinct().sorted().toList(),"calculation",job.calculation(),"articles",job.articles(),"reference",job.metadata().stream().filter(m->text(m,"gdc_gen_fieldname").equals("jb_search")).map(m->text(m,"gdc_content")).findFirst().orElse(""));}
|
||||
static List<Map<String,Object>> overview(Map<Long,Job> jobs,List<Map<String,Object>> modes){var rows=new ArrayList<Map<String,Object>>();for(var j:jobs.values()){var payments=j.payments().stream().filter(p->id(p,"jbp_counter")==1).toList();var r=row(j,payments,payments.stream().map(p->label(modes,id(p,"jbp_mode"))).distinct().reduce((a,b)->a+" / "+b).orElse("Ohne Kassenzahlung"));r.put("allPayments",row(j,j.payments(),"").get("payments"));rows.add(r);}return rows;}
|
||||
static boolean selected(Query q,Job j,Map<String,Object> p){var time=dateTime(p,"jbp_bookingtime");return id(p,"csc_id")==id(j.job(),"csc_id_related")&&time!=null&&!time.isBefore(start(q))&&time.isBefore(end(q))&&(q.mode()==null||id(p,"jbp_mode")==q.mode())&&(q.view()==4&&q.counter()==2||id(p,"jbp_counter")== (q.counter()==1?1:0));}
|
||||
static List<Map<String,Object>> statistics(Query q,Map<Long,Job> jobs,List<Map<String,Object>> modes){var result=new ArrayList<Map<String,Object>>();var groups=new TreeMap<String,List<Map<String,Object>>>();var due=new HashMap<String,Set<Long>>();var expected=new HashMap<String,BigDecimal>();
|
||||
for(var j:jobs.values()){var selected=j.payments().stream().filter(p->selected(q,j,p)).toList();if(q.view()==1){if(!selected.isEmpty())result.add(row(j,selected,"Alle Zahlarten"));continue;}
|
||||
var perMode=new TreeMap<Long,List<Map<String,Object>>>();for(var p:selected)perMode.computeIfAbsent(id(p,"jbp_mode"),x->new ArrayList<>()).add(p);
|
||||
if(q.view()==2){perMode.forEach((m,p)->result.add(row(j,p,label(modes,m))));continue;}if(q.view()==3){for(var p:selected)result.add(row(j,List.of(p),label(modes,id(p,"jbp_mode"))));continue;}
|
||||
for(var p:selected){String key;if(q.counter()==2){if(service(j))key="Serviceaufträge";else if((id(j.job(),"jb_service")&2)==2&&id(p,"jbp_counter")==0)key="Montage-Inkasso";else continue;}else key=Long.toString(id(p,"jbp_mode"));groups.computeIfAbsent(key,x->new ArrayList<>()).add(p);if(due.computeIfAbsent(key,x->new HashSet<>()).add(id(j.job(),"jb_id")))expected.merge(key,net(j.job().get("jb_subtotalprice")),(entryValue, otherValue) -> entryValue.add(otherValue));}
|
||||
}
|
||||
if(q.view()==4){if(q.counter()==2){groups.putIfAbsent("Montage-Inkasso",List.of());groups.putIfAbsent("Serviceaufträge",List.of());}groups.forEach((key,p)->result.add(fields("job",0,"parent",0,"label",q.counter()==2?key:label(modes,Long.parseLong(key)),"paid",gross(sum(p,"jbp_price")),"due",key.equals("Serviceaufträge")?BigDecimal.ZERO:gross(expected.getOrDefault(key,BigDecimal.ZERO)),"remaining",BigDecimal.ZERO,"booked","","payments",List.of(),"collections",p.stream().map(r->id(r,"jbpc_id")).filter(v->v>0).distinct().sorted().toList(),"calculation",List.of(),"articles",List.of(),"reference","")));}
|
||||
return result;
|
||||
}
|
||||
Map<String,Object> view(Data d){return fields("query",d.query(),"title",VIEWS.get(d.query().view()),"customer",d.customer(),"rows",d.rows(),"dayTotals",d.dayTotals(),"paid",sum(d.rows(),"paid"),"due",d.query().view()==2?gross(d.jobs().values().stream().map(j->net(j.job().get("jb_subtotalprice"))).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))):sum(d.rows(),"due"),"modes",d.modes().stream().map(m->fields("id",id(m,"mt_sort"),"label",text(m,"mt_value"))).toList(),"revision",revision(d),"canEdit",d.canEdit());}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.math.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Reviewed, atomic equivalents of changeJbPayment/changeCounter/moveJbPayment. */
|
||||
@RestController
|
||||
@RequestMapping("/api/cashflow/actions")
|
||||
public final class CashflowActions {
|
||||
final Cashflow cash;final DatabaseService db;final BusinessSupport support;
|
||||
public CashflowActions(Cashflow cash){this.cash=cash;db=cash.db;support=cash.support;}
|
||||
enum Kind { MODE, COUNTER, BOOK_INSTALLATION, REMOVE_INSTALLATION }
|
||||
record Input(Cashflow.Query query,Kind kind,long job,long payment,Integer mode){}
|
||||
record Command(String requestId,String revision,Input input,boolean confirmed){}
|
||||
record State(Cashflow.Data data,Map<String,Object> job,List<Map<String,Object>> payments,List<Map<String,Object>> invoices,List<Map<String,Object>> metadata,boolean locked){}
|
||||
Input check(LegacyUser u,Input i){u.require(26);u.require(7);if(i==null||i.kind()==null||i.job()<=0||i.payment()<0||i.query()==null||i.query().view()!=0)throw bad("Bitte eine gültige Änderung aus der Kassenübersicht wählen.");if((i.kind()==Kind.MODE||i.kind()==Kind.REMOVE_INSTALLATION)&&i.payment()<=0)throw bad("Bitte eine Zahlung auswählen.");if((i.kind()==Kind.MODE||i.kind()==Kind.BOOK_INSTALLATION)&&(i.mode()==null||i.mode()<0||i.mode()>127))throw bad("Bitte eine gültige Zahlart auswählen.");return i;}
|
||||
State state(LegacyUser original,Input i,boolean lock){var d=cash.read(original,i.query());if(!d.jobs().containsKey(i.job()))throw bad("Der Auftrag gehört nicht mehr zur gewählten Kassenübersicht.");var u=cash.scope(original,d.query().headquarters());String sql="SELECT * FROM job WHERE jb_id=? AND hq_id=?";var job=one(lock?db.lockRows(sql,i.job(),u.headquartersId()):db.queryForList(sql,i.job(),u.headquartersId()));String pay="SELECT * FROM jobpayment WHERE jb_id=? ORDER BY jbp_id";var payments=lock?db.lockRows(pay,i.job()):db.queryForList(pay,i.job());var invoices=db.queryForList("SELECT inv_no,inv_datetime FROM invoice WHERE jb_id=?",i.job());var metadata=db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('ng_invoice_lock','jb_no_payment_job') ORDER BY gdc_id",i.job());var settings=OrderMetadata.settings(db,u.headquartersId());boolean locked=!db.queryForList("SELECT jb_id FROM job WHERE jb_id=? AND jb_locktime>DATE_SUB(NOW(),INTERVAL ? MINUTE)",i.job(),settings.lockMinutes()).isEmpty();return new State(d,job,payments,invoices,metadata,locked);}
|
||||
static boolean open(Map<String,Object> payment){return id(payment,"jbpc_id")==0&&!OrderPayments.exportedPayment(payment);}
|
||||
static BigDecimal remaining(State s){return Cashflow.net(s.job().get("jb_totalprice")).subtract(Cashflow.sum(s.payments(),"jbp_price"));}
|
||||
static List<Map<String,Object>> affected(State s,Input i){return s.payments().stream().filter(p->i.kind()==Kind.COUNTER?id(p,"jbp_counter")==0&&open(p)&&Cashflow.net(p.get("jbp_price")).signum()!=0:id(p,"jbp_id")==i.payment()).toList();}
|
||||
void editable(State s,Input i){var j=s.job();if(s.locked())throw bad("Der Auftrag wird gerade in der alten Anwendung bearbeitet.");if(OrderOperations.exported(j)||!s.invoices().isEmpty()||s.metadata().stream().anyMatch(m->text(m,"gdc_gen_fieldname").equals("ng_invoice_lock")))throw bad("Der Auftrag ist exportiert oder einer Rechnung zugeordnet.");if(id(j,"jb_storno")!=0||id(j,"jb_offer")!=0)throw bad("Stornos und Angebote können hier nicht umgebucht werden.");if(s.metadata().stream().anyMatch(m->text(m,"gdc_gen_fieldname").equals("jb_no_payment_job")&&text(m,"gdc_content").equals("1")))throw bad("Der Auftrag wird vom Transporteur bezahlt.");if(!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",i.job()).isEmpty())throw bad("Zum Auftrag besteht bereits eine Gegenbuchung.");
|
||||
if(i.kind()==Kind.MODE||i.kind()==Kind.BOOK_INSTALLATION)if(s.data().modes().stream().noneMatch(m->id(m,"mt_sort")==i.mode()))throw bad("Die Zahlart ist im Kundenkatalog nicht freigegeben.");
|
||||
if(i.kind()==Kind.BOOK_INSTALLATION||i.kind()==Kind.REMOVE_INSTALLATION)if((id(j,"jb_service")&2)!=2)throw bad("Diese Umbuchung ist nur für einen Montageauftrag möglich.");
|
||||
var affected=affected(s,i);if(i.kind()!=Kind.BOOK_INSTALLATION){if(affected.isEmpty())throw bad("Es gibt keine passende, noch offene Zahlung.");if(affected.stream().anyMatch(p->!open(p)))throw bad("Gesammelte oder exportierte Zahlungen können nicht geändert werden.");if(i.kind()==Kind.REMOVE_INSTALLATION&&affected.stream().anyMatch(p->id(p,"jbp_counter")!=1))throw bad("Nur eine Montagebuchung der Kasse kann zurückgenommen werden.");}
|
||||
else{if(!s.data().query().from().equals(s.data().query().to()))throw bad("Für eine Montagebuchung bitte einen einzelnen Kassentag wählen.");BigDecimal amount=remaining(s);if(amount.signum()==0)throw bad("Der Montageauftrag ist bereits vollständig bezahlt.");if(amount.abs().compareTo(new BigDecimal("999999.9999"))>0)throw bad("Der offene Betrag überschreitet den zulässigen Zahlungsbetrag.");}
|
||||
}
|
||||
@PostMapping("/preview") Map<String,Object> preview(Authentication a,@RequestBody Input i){return preview(ApiController.user(a),i);}
|
||||
Map<String,Object> preview(LegacyUser u,Input raw){var i=check(u,raw);return db.readTransaction(()->{var s=state(u,i,false);editable(s,i);BigDecimal amount=i.kind()==Kind.BOOK_INSTALLATION?remaining(s):Cashflow.sum(affected(s,i),"jbp_price");String action=switch(i.kind()){case MODE->"Gebuchte Zahlart ändern";case COUNTER->"Monteur-Inkasso in Kasse umwandeln";case BOOK_INSTALLATION->"Offenen Montagebetrag in die Kasse einbuchen";case REMOVE_INSTALLATION->"Montagebuchung aus der Kasse zurücknehmen";};return fields("input",i,"revision",revision(List.of(s,i)),"action",action,"job",i.job(),"mode",i.mode()==null?"":Cashflow.label(s.data().modes(),i.mode()),"net",amount,"gross",Cashflow.gross(amount),"booked",i.kind()==Kind.BOOK_INSTALLATION?s.data().query().from().atTime(23,59,59).toString():"","payments",affected(s,i).stream().map(p->fields("id",id(p,"jbp_id"),"label",Cashflow.label(s.data().modes(),id(p,"jbp_mode")),"gross",Cashflow.gross(Cashflow.net(p.get("jbp_price"))))).toList());});}
|
||||
@PostMapping Map<String,Object> save(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);}
|
||||
Map<String,Object> save(LegacyUser original,Command c){if(c==null||!c.confirmed()||c.revision()==null||!c.revision().matches("[a-f0-9]{64}"))throw bad("Bitte die Kassenänderung prüfen und bestätigen.");var i=check(original,c.input());BusinessSupport.token(c.requestId());var u=cash.scope(original,i.query().headquarters());support.transactional("headquarters","job","jobpayment","genericdatacontainer");
|
||||
db.writeTransaction(()->{lockHeadquarters(db,u);var intent=List.of("CASHFLOW",c);if(support.receipt(u,c.requestId(),intent)!=null)return null;var s=state(original,i,true);unchanged(List.of(s,i),c.revision());editable(s,i);var affected=affected(s,i);long payment=i.payment();
|
||||
switch(i.kind()){
|
||||
case MODE->db.update("UPDATE jobpayment SET jbp_mode=? WHERE jbp_id=? AND jb_id=?",i.mode(),i.payment(),i.job());
|
||||
case COUNTER->{for(var p:affected)db.update("UPDATE jobpayment SET jbp_counter=1 WHERE jbp_id=? AND jb_id=?",id(p,"jbp_id"),i.job());}
|
||||
case BOOK_INSTALLATION->payment=db.insert("jobpayment",fields("jb_id",i.job(),"tr_sort",2,"csc_id",id(s.job(),"csc_id_related"),"jbp_mode",i.mode(),"jbp_price",remaining(s),"jbp_bookingtime",s.data().query().from().atTime(23,59,59),"jbp_counter",1,"jbp_tan","","jbpc_id",0));
|
||||
case REMOVE_INSTALLATION->db.update("DELETE FROM jobpayment WHERE jbp_id=? AND jb_id=?",i.payment(),i.job());
|
||||
}
|
||||
db.update("UPDATE job SET jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=? AND hq_id=?",i.job(),u.headquartersId());support.audit(u,i.job(),s.data().query().customer(),0,i.kind()==Kind.BOOK_INSTALLATION||i.kind()==Kind.REMOVE_INSTALLATION?121:118,"CASHFLOW="+i.kind()+" PAYMENT="+payment+" MODE="+i.mode()+" BEFORE="+support.encode(affected),c.requestId());support.receipt(u,c.requestId(),intent,payment);return null;
|
||||
});return cash.query(original,i.query());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.http.*;
|
||||
import org.apache.pdfbox.pdmodel.*;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.bad;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cashflow")
|
||||
public final class CashflowDocuments {
|
||||
final Cashflow cash;public CashflowDocuments(Cashflow cash){this.cash=cash;}
|
||||
record Request(Cashflow.Query query,String revision){}
|
||||
Cashflow.Data snapshot(LegacyUser u,Request r){if(r==null||r.revision()==null)throw bad("Bitte die Kassenübersicht neu laden.");return cash.db.readTransaction(()->{var d=cash.read(u,r.query());AdminSupport.unchanged(d,r.revision());return d;});}
|
||||
@PostMapping("/export.csv") ResponseEntity<byte[]> csv(Authentication a,@RequestBody Request r){return response(csv(snapshot(ApiController.user(a),r)),"text/csv;charset=UTF-8","Kasse.csv");}
|
||||
@PostMapping("/statement.pdf") ResponseEntity<byte[]> pdf(Authentication a,@RequestBody Request r){return response(pdf(snapshot(ApiController.user(a),r)),"application/pdf","Kasse.pdf");}
|
||||
static ResponseEntity<byte[]> response(byte[] data,String type,String filename){return ResponseEntity.ok().contentType(MediaType.parseMediaType(java.util.Objects.requireNonNull(type))).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=\""+filename+"\"").cacheControl(CacheControl.noStore()).body(data);}
|
||||
static String amount(Object value){return Cashflow.net(value).setScale(2,java.math.RoundingMode.HALF_UP).toPlainString().replace('.',',');}
|
||||
static byte[] csv(Cashflow.Data d){var csv=new StringBuilder("\uFEFFAuftrag;Interne Nummer;Hauptauftrag;Zahlart / Bereich;Einnahmen brutto;Soll brutto;Rest brutto;Buchung;Sammelnummern;Referenz;Auftragsposten;Artikel\r\n");for(var row:d.rows()){for(Object v:List.of(text(row,"displayNumber"),text(row,"job"),text(row,"parent"),text(row,"label"),amount(row.get("paid")),amount(row.get("due")),amount(row.get("remaining")),text(row,"booked"),text(row,"collections"),text(row,"reference"),details(row,"calculation"),details(row,"articles")))csv.append(ApiController.csvCell(v)).append(';');csv.setLength(csv.length()-1);csv.append("\r\n");}return csv.toString().getBytes(StandardCharsets.UTF_8);}
|
||||
static String details(Map<String,Object> row,String key){var list=TypedValues.rows(row.get(key));if(list==null)return "";return list.stream().map(r->key.equals("calculation")?text(r,"srv_name")+" / "+text(r,"srvt_name")+": "+text(r,"jbc_amount")+" × "+amount(r.get("jbc_price"))+" = "+amount(r.get("jbc_totalprice"))+" netto":text(r,"trat_quantity")+" × "+text(r,"trat_name")+" · "+text(r,"trat_serialno")+" · "+text(r,"trat_remark")).reduce((a,b)->a+"\n"+b).orElse("");}
|
||||
static byte[] pdf(Cashflow.Data data){try(var pdf=new Pdf()){
|
||||
pdf.line("KASSE",18);pdf.line(Cashflow.VIEWS.get(data.query().view()),12);pdf.line(data.customer(),11);pdf.line(data.query().from()+" "+data.query().fromTime()+" bis "+data.query().to()+" "+data.query().toTime(),10);pdf.line("Niederlassung "+data.query().headquarters()+" · "+(data.query().counter()==1?"Counter":"Monteur-Inkasso"),10);pdf.line("Bruttobeträge nach PHP-Kassenregel: Nettobetrag × 1,19.",9);pdf.space();
|
||||
for(var row:data.rows()){pdf.room(80);pdf.line((id(row,"job")>0?"Auftrag #"+text(row,"displayNumber")+(id(row,"parent")>0?" · Hauptauftrag #"+id(row,"parent"):"")+" · ":"")+text(row,"label"),11);pdf.line("Einnahmen "+amount(row.get("paid"))+" EUR · Soll "+amount(row.get("due"))+" EUR · Rest "+amount(row.get("remaining"))+" EUR",10);if(!text(row,"booked").isBlank())pdf.line("Buchung: "+text(row,"booked"),9);if(!text(row,"reference").isBlank())pdf.line("Referenz: "+text(row,"reference"),9);if(!Objects.toString(row.get("collections"),"[]").equals("[]"))pdf.line("Sammelnummern: "+row.get("collections"),9);for(String key:List.of("calculation","articles")){String text=details(row,key);if(!text.isEmpty()){pdf.line(key.equals("calculation")?"Auftragsposten (netto)":"Artikel",9);pdf.line(text,9);}}pdf.space();}
|
||||
pdf.room(50);pdf.line("Summe Einnahmen: "+amount(Cashflow.sum(data.rows(),"paid"))+" EUR",12);if(!data.dayTotals().isEmpty()){pdf.space();pdf.line("Kasseneinnahmen nach Buchungszeitraum",11);for(var row:data.dayTotals())pdf.line(text(row,"label")+": "+amount(row.get("amount"))+" EUR",10);}return pdf.finish();
|
||||
}catch(IOException e){throw new IllegalStateException("Kassen-PDF konnte nicht erstellt werden.",e);}catch(IllegalArgumentException e){throw bad("Ein Zeichen in den Kassendaten wird von der PDF-Schrift nicht unterstützt.");}}
|
||||
static final class Pdf implements AutoCloseable {
|
||||
final PDDocument document=new PDDocument();final PDType0Font font;PDPageContentStream stream;float y;
|
||||
Pdf()throws IOException{try(var in=PDDocument.class.getResourceAsStream("/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf")){font=PDType0Font.load(document,in,true);}document.getDocumentInformation().setTitle("Kassenübersicht");document.getDocumentInformation().setCreator("Votian NG");page();}
|
||||
void page()throws IOException{if(stream!=null)stream.close();if(document.getNumberOfPages()>=200)throw bad("Die Kassenausgabe überschreitet 200 Seiten. Bitte Auswahl eingrenzen.");var page=new PDPage(PDRectangle.A4);document.addPage(page);stream=new PDPageContentStream(document,page);y=795;}
|
||||
void room(float height)throws IOException{if(y-height<55)page();}void space(){y-=12;}
|
||||
void line(String raw,float size)throws IOException{for(String paragraph:raw.replace('\u00a0',' ').replaceAll("[\\p{Cntrl}&&[^\\n]]"," ").split("\n")){String value=paragraph.strip();while(!value.isEmpty()){float width=0;int end=0,space=-1;for(int i=0;i<value.length();){int next=i+Character.charCount(value.codePointAt(i));float w=font.getStringWidth(value.substring(i,next))*size/1000;if(width+w>505&&end>0)break;width+=w;end=next;if(Character.isWhitespace(value.charAt(i)))space=i;i=next;}if(end<value.length()&&space>0)end=space;room(size+5);draw(stream,value.substring(0,end).stripTrailing(),42,y,size);y-=size+5;value=value.substring(end).stripLeading();}}}
|
||||
void draw(PDPageContentStream target,String text,float x,float y,float size)throws IOException{target.beginText();target.setFont(font,size);target.newLineAtOffset(x,y);target.showText(text);target.endText();}
|
||||
byte[] finish()throws IOException{stream.close();stream=null;for(int i=0;i<document.getNumberOfPages();i++)try(var out=new PDPageContentStream(document,document.getPage(i),PDPageContentStream.AppendMode.APPEND,true,true)){draw(out,"Kasse · Seite "+(i+1)+" / "+document.getNumberOfPages(),42,28,9);}var out=new ByteArrayOutputStream();document.save(out);return out.toByteArray();}
|
||||
public void close()throws IOException{if(stream!=null)stream.close();document.close();}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Current cashflow defaults, reviewed PDF attachment, and durable at-most-once SMTP delivery. */
|
||||
@RestController
|
||||
@RequestMapping("/api/cashflow/mail")
|
||||
public final class CashflowMail {
|
||||
record Draft(Cashflow.Query query,String revision,String subject,String body){}
|
||||
record Confirm(boolean recipientsChecked,boolean contentChecked,boolean exportChecked){}
|
||||
record Envelope(String from,List<String> to){}
|
||||
record Snapshot(Cashflow.Data data,Envelope envelope,String subject,String body,String revision){}
|
||||
record Delivery(long headquarters,Cashflow.Query query,String subject,Envelope envelope,String startedAt,String state){}
|
||||
static class Pending {long user,originalHeadquarters;Draft draft;Snapshot snapshot;byte[] pdf;Instant expires;}
|
||||
final Cashflow cash;final AppointmentMailTransport transport;final Map<String,Pending> pending=new LinkedHashMap<>();
|
||||
public CashflowMail(Cashflow cash,AppointmentMailTransport transport){this.cash=cash;this.transport=transport;}
|
||||
static String context(Cashflow.Query q){return revision(q);}
|
||||
String fallback(long hq,String key){String v=cash.catalog.parameter(hq,key);return v.isBlank()?cash.catalog.parameter(0,key):v;}
|
||||
String customerParameter(Cashflow.Query q,String key){return new OrderCustomerMailContent(cash.db,q.headquarters(),"").object(key,q.customer());}
|
||||
Envelope envelope(Cashflow.Query q){var to=NewsletterDispatch.addresses(customerParameter(q,"MASK_STAT_CASHFLOW_MAILTO_DEFAULT"));if(to.isEmpty()||to.size()>100)throw bad("Bitte 1 bis 100 Empfänger in MASK_STAT_CASHFLOW_MAILTO_DEFAULT hinterlegen.");return new Envelope(AppointmentNotifications.address(fallback(q.headquarters(),"MAIL_SENDER_ADDRESS")),to);}
|
||||
static String subject(Cashflow.Data d){var f=DateTimeFormatter.ofPattern("dd.MM.yyyy");var q=d.query();return (q.customer()==0?"NUR INFO :: ":"")+"KASSE :: "+q.from().format(f)+(q.from().equals(q.to())?"":" - "+q.to().format(f))+" :: "+d.customer();}
|
||||
static void textCheck(String subject,String body){if(subject==null||subject.isBlank()||subject.length()>250||subject.chars().anyMatch(Character::isISOControl))throw bad("Bitte einen Betreff mit höchstens 250 Zeichen ohne Steuerzeichen eingeben.");if(body==null||body.length()>20000||body.indexOf('\0')>=0)throw bad("Der Mailtext darf höchstens 20.000 Zeichen enthalten.");}
|
||||
void exported(LegacyUser actor,Cashflow.Query q){
|
||||
String modes="";if(q.customer()>0){modes=customerParameter(q,"EXPORT_CAT_09_JBPMODES_01_CS");if(modes.isBlank()){var u=cash.scope(actor,q.headquarters());long group=OrderPayments.group(cash.customer(u,q.customer()));if(group>0)modes=new OrderCustomerMailContent(cash.db,q.headquarters(),"").object("EXPORT_CAT_09_JBPMODES_01_GRP",group);}}
|
||||
var args=new ArrayList<Object>(List.of(q.headquarters(),q.headquarters(),Cashflow.start(q),Cashflow.end(q)));String where="j.hq_id=? AND cs.hq_id=? AND p.jbp_bookingtime>=? AND p.jbp_bookingtime<? AND p.jbp_counter=1 AND p.jbpc_id=0 AND p.jbp_price<>0 AND (p.jbp_export_time IS NULL OR p.jbp_export_time='0000-00-00 00:00:00')";if(q.customer()>0){where+=" AND cs.cs_id=?";args.add(q.customer());}if(!modes.isBlank()){if(!modes.matches("\\s*\\d+(\\s*,\\s*\\d+)*\\s*"))throw bad("Die Export-Zahlarten für diesen Kunden sind ungültig.");var ids=Arrays.stream(modes.split(",")).map(entryValue -> entryValue.trim()).map(Integer::valueOf).distinct().toList();if(ids.size()>128||ids.stream().anyMatch(i->i<0||i>127))throw bad("Ungültige Export-Zahlarten.");where+=" AND p.jbp_mode IN ("+CrmScope.marks(ids.size())+")";args.addAll(ids);}
|
||||
if(!cash.db.queryForList("SELECT p.jbp_id FROM jobpayment p JOIN job j ON j.jb_id=p.jb_id JOIN costcenter cc ON cc.csc_id=p.csc_id JOIN customer cs ON cs.cs_id=cc.cs_id WHERE "+where+" LIMIT 1",args.toArray()).isEmpty())throw bad("Für diesen Zeitraum sind noch Kassenzahlungen zu exportieren. Bitte zuerst den Einnahmenexport abschließen.");
|
||||
}
|
||||
@PostMapping("/options") Map<String,Object> options(Authentication a,@RequestBody Cashflow.Query q){return options(ApiController.user(a),q);}
|
||||
Map<String,Object> options(LegacyUser u,Cashflow.Query raw){return cash.db.readTransaction(()->{var d=cash.read(u,raw);var q=d.query();var e=envelope(q);String body=fallback(q.headquarters(),q.customer()==0?"MASK_STAT_CASHFLOW_MAILINFOTEXT_DEFAULT":"MASK_STAT_CASHFLOW_MAILTEXT_DEFAULT");if(body.isBlank())body="Bitte beachten Sie das Dokument in der Anlage!";return fields("from",e.from(),"to",e.to(),"subject",subject(d),"body",body,"configured",transport.configured(),"history",history(u,q));});}
|
||||
Snapshot snapshot(LegacyUser u,Draft draft){if(draft==null)throw bad("Die Versandvorschau fehlt.");textCheck(draft.subject(),draft.body());var d=cash.read(u,draft.query());unchanged(d,draft.revision());if(d.rows().isEmpty())throw bad("Keine Kassenpositionen zum Versenden.");if(!transport.configured())throw bad("Der SMTP-Versand ist nicht konfiguriert.");exported(u,d.query());var e=envelope(d.query());return new Snapshot(d,e,draft.subject().strip(),draft.body(),revision(List.of(d,e,draft.subject().strip(),draft.body())));}
|
||||
@PostMapping("/preview") Map<String,Object> preview(Authentication a,@RequestBody Draft d){return preview(ApiController.user(a),d);}
|
||||
Map<String,Object> preview(LegacyUser u,Draft d){u.require(26);var s=cash.db.readTransaction(()->snapshot(u,d));var p=new Pending();p.user=u.userId();p.originalHeadquarters=u.originalHeadquartersId();p.draft=d;p.snapshot=s;p.pdf=CashflowDocuments.pdf(s.data());p.expires=Instant.now().plusSeconds(900);String token=UUID.randomUUID().toString();synchronized(pending){pending.values().removeIf(x->x.expires.isBefore(Instant.now()));if(pending.size()>=20)throw bad("Zu viele offene Mailvorschauen. Bitte später erneut versuchen.");pending.put(token,p);}return fields("token",token,"from",s.envelope().from(),"to",s.envelope().to(),"subject",s.subject(),"body",s.body(),"filename","Kasse.pdf","pdf",Base64.getEncoder().encodeToString(p.pdf),"page",VehicleRollcardPdf.page(p.pdf,0));}
|
||||
Pending pending(LegacyUser u,String token){u.require(26);BusinessSupport.token(token);Pending p;synchronized(pending){p=pending.get(token);}if(p==null||p.user!=u.userId()||p.originalHeadquarters!=u.originalHeadquartersId()||p.expires.isBefore(Instant.now()))throw bad("Die Versandvorschau ist abgelaufen oder nach einem Neustart nicht verfügbar. Bitte den Versandverlauf prüfen.");cash.check(u,p.draft.query());return p;}
|
||||
@GetMapping("/{token}/page") Map<String,Object> page(Authentication a,@PathVariable String token,@RequestParam int page){var p=pending(ApiController.user(a),token);return VehicleRollcardPdf.page(p.pdf,page);}
|
||||
Delivery decode(Map<String,Object> r){try{return cash.support.json.readValue(text(r,"gdc_content"),Delivery.class);}catch(Exception e){throw bad("Der Versandstatus ist nicht lesbar. Bitte vor erneutem Versand prüfen lassen.");}}
|
||||
Delivery stored(LegacyUser u,String token){u.require(26);BusinessSupport.token(token);var rows=cash.db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcashmail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.userId(),token);if(rows.isEmpty())return null;var d=decode(one(rows));cash.check(u,d.query());return d;}
|
||||
static Map<String,Object> result(String token,Delivery d){return fields("token",token,"state",d.state(),"startedAt",d.startedAt(),"subject",d.subject(),"to",d.envelope().to(),"message",d.state().equals("SENT")?"Der SMTP-Server hat die E-Mail angenommen. Dies bestätigt noch nicht die Zustellung.":"Die Übergabe wurde gestartet, aber nicht sicher bestätigt. Es erfolgt kein automatischer Neuversand. Bitte Zustellung oder SMTP-Protokoll prüfen.");}
|
||||
List<Map<String,Object>> history(LegacyUser u,Cashflow.Query q){return cash.db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcashmail' AND gdc_obj_id=? AND gdc_context=? ORDER BY gdc_id DESC LIMIT 20",u.userId(),context(q)).stream().map(r->result(text(r,"gdc_gen_fieldname"),decode(r))).toList();}
|
||||
@PostMapping("/history") List<Map<String,Object>> history(Authentication a,@RequestBody Cashflow.Query q){var u=ApiController.user(a);return history(u,cash.check(u,q));}
|
||||
@GetMapping("/{token}") Map<String,Object> status(Authentication a,@PathVariable String token){return status(ApiController.user(a),token);}
|
||||
Map<String,Object> status(LegacyUser u,String token){var d=stored(u,token);if(d==null)throw bad("Für diese Kennung wurde noch keine Übergabe gestartet.");return result(token,d);}
|
||||
@PostMapping("/{token}") Map<String,Object> send(Authentication a,@PathVariable String token,@RequestBody Confirm c){return send(ApiController.user(a),token,c);}
|
||||
Map<String,Object> send(LegacyUser u,String token,Confirm c){u.require(26);if(c==null||!c.recipientsChecked()||!c.contentChecked()||!c.exportChecked())throw bad("Bitte Empfänger, PDF-Inhalt und abgeschlossenen Einnahmenexport bestätigen.");var done=stored(u,token);if(done!=null)return result(token,done);var p=pending(u,token);cash.support.transactional("headquarters","genericdatacontainer");boolean claimed=cash.db.writeTransaction(()->{var actor=cash.scope(u,p.snapshot.data().query().headquarters());lockHeadquarters(cash.db,actor);if(stored(u,token)!=null)return false;var now=snapshot(u,p.draft);if(!now.revision().equals(p.snapshot.revision()))throw VehicleDisposition.conflict();var delivery=new Delivery(actor.headquartersId(),now.data().query(),now.subject(),now.envelope(),LocalDateTime.now(ZoneId.of("Europe/Berlin")).toString(),"STARTED");cash.db.insert("genericdatacontainer",fields("gdc_obj_type","ngcashmail","gdc_obj_id",u.userId(),"gdc_gen_fieldname",token,"gdc_context",context(delivery.query()),"gdc_content",cash.support.encode(delivery)));cash.support.audit(actor,0,delivery.query().customer(),0,203,"Kassenmail zur Übergabe vorgemerkt · "+delivery.query().from()+" bis "+delivery.query().to(),token);return true;});if(!claimed)return result(token,stored(u,token));
|
||||
String state="SENT";try{var s=p.snapshot;transport.sendPdf(s.envelope().from(),s.envelope().to(),List.of(),List.of(),s.subject(),s.body(),"Kasse.pdf",p.pdf);}catch(RuntimeException failure){state="UNCERTAIN";}var started=stored(u,token);var finished=new Delivery(started.headquarters(),started.query(),started.subject(),started.envelope(),started.startedAt(),state);cash.db.writeTransaction(()->cash.db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_obj_type='ngcashmail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",cash.support.encode(finished),u.userId(),token));return result(token,finished);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.AdminSupport.bad;
|
||||
/** The central PHP mask exposes per-stop recipients according to the chosen payer. */
|
||||
final class CentralEntryTracking {
|
||||
final DatabaseService db;
|
||||
CentralEntryTracking(DatabaseService db){this.db=db;}
|
||||
static List<String> emails(Request r){return r.stops().stream().map(s->Objects.toString(s.trackingEmail(),"")).toList();}
|
||||
static boolean hasEmails(Request r){return emails(r).stream().anyMatch(v->!v.isBlank());}
|
||||
void lock(Request r){if(!hasEmails(r))return;
|
||||
Integer engines=db.queryForObject("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('customer','costcenter') AND ENGINE='InnoDB'",Integer.class);
|
||||
if(!Objects.equals(engines,2))throw bad("Tracking-E-Mails benötigen transaktionsfähige Kunden und Kostenstellen.");
|
||||
db.lockRows("SELECT cs.cs_id FROM customer cs JOIN costcenter cc ON cc.cs_id=cs.cs_id WHERE cc.csc_id=?",r.payerCostCenterId());}
|
||||
void validate(LegacyUser u,Request r,long customer){
|
||||
if(!hasEmails(r))return;
|
||||
if(u.customerAccount())throw bad("Tracking-E-Mails im Kundenzugang bitte über die freigegebenen Kundeneingaben übermitteln.");
|
||||
if(!new CustomerEntryTracking(db).enabled(customer))throw bad("Tracking-E-Mails sind für den gewählten Zahler nicht freigegeben.");
|
||||
CustomerEntryTracking.validate(emails(r),r.stops().size(),true);
|
||||
}
|
||||
static Quote apply(Quote q,Request r){return hasEmails(r)?q.withMediationDeadline(q.mediationDeadline(),LegacyOrderPricing.hash(q.fingerprint()+"|tracking|"+OrderEntryExtras.json(emails(r)))):q;}
|
||||
void created(long job,Request r,boolean showHint){if(!hasEmails(r))return;var tracking=new CustomerEntryTracking(db);tracking.created(job,CustomerEntryTracking.validate(emails(r),r.stops().size(),true));tracking.hint(job,showHint);}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** PHP CLASSIC groups, kept separate from the autonomous mediation worker. Discovery is read-only. */
|
||||
@RestController
|
||||
@RequestMapping("/api/orders/{job}/courier-assignment/classic-candidates")
|
||||
public class ClassicCandidates {
|
||||
final DatabaseService db;final OrderOperations orders;final OrderEntryCatalog catalog;
|
||||
public ClassicCandidates(DatabaseService db,OrderOperations orders,OrderEntryCatalog catalog){this.db=db;this.orders=orders;this.catalog=catalog;}
|
||||
record Zone(int rank,String group,String name){}
|
||||
record Geography(String mode,String postalCode,String main,Map<String,Zone> zones,List<String> warnings,List<Object> evidence){}
|
||||
record Candidate(Map<String,Object> vehicle,Zone zone,boolean favourite,boolean eligible,List<String> blockers,List<String> warnings){}
|
||||
static final Zone REST=new Zone(10000,"Anderweitige verfügbare Kuriere","");
|
||||
static final Zone FAVOURITE=new Zone(0,"Bevorzugte Kuriere","");
|
||||
@GetMapping public Object get(Authentication auth,@PathVariable long job){return list(ApiController.user(auth),job);}
|
||||
Map<String,Object> list(LegacyUser u,long job){OrderCourierAssignment.access(u);return db.readTransaction(()->{
|
||||
var s=orders.snapshot(u,job,false);if(id(s.job(),"jb_longhaul")!=0)throw bad("Bitte für Fernaufträge die BWV-Auswahl verwenden.");
|
||||
var geo=geography(db,catalog,u,s);var rules=rules(db,catalog,u,s);String restriction=LonghaulCandidates.setting(catalog,u,"RANKING_USE_CRVH_RESTRICTION");
|
||||
var rows=vehicles(db,catalog,u,0);var assignments=assignments(db,s);var previous=new HashSet<Long>();db.queryForList("SELECT cr_id FROM autoranking WHERE jb_id=?",job).forEach(r->previous.add(id(r,"cr_id")));
|
||||
var candidates=new ArrayList<Candidate>();for(var v:rows){var c=candidate(u,s,v,geo,rules,"1".equals(restriction));if(visible(u,c,rules))candidates.add(c);}
|
||||
boolean occupiedFirst="1".equals(rules.values().get("MASK_COURIER_SORT_BY_OCCUPIED"));
|
||||
candidates.sort(ranking(occupiedFirst,assignments));
|
||||
return fields("mode",geo.mode(),"startPostalCode",geo.postalCode(),"area",geo.main(),"warnings",geo.warnings(),"occupiedFirst",occupiedFirst,"orderRevision",revision(s),"items",candidates.stream().map(c->view(c,assignments.getOrDefault(text(c.vehicle(),"crvh_sid"),Map.of()),previous)).toList());
|
||||
});}
|
||||
static List<Map<String,Object>> vehicles(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,long vehicle){
|
||||
String sql="SELECT cv.*,"+AssignmentVehicles.IDENTITY+",cr.hq_id AS courier_hq,cr.cr_sid,cr.cr_available,cr.cr_locationzipcode,cr.cr_availabletime,cr.cr_occupied,cr.cr_outlay,cr.cr_filter,cr.vht_id AS ranking_vehicle_type,u.usr_country,cmp.cmp_comp,h.hq_mnemonic,mt.mt_value AS vehicle_type "+AssignmentVehicles.joins(AssignmentVehicles.from(true,AssignmentVehicles.multiple(catalog,u)),"JOIN headquarters h ON h.hq_id=cr.hq_id JOIN metatype mt ON mt.mt_type='vehicletype' AND mt.mt_sort=cv.vht_id")+" AND cr.cr_sid=cv.crvh_sid"+(vehicle>0?" AND cv.crvh_id=?":"")+" ORDER BY cv.crvh_sid,cv.crvh_id";
|
||||
return AssignmentVehicles.identities(db.queryForList(sql,vehicle>0?new Object[]{vehicle}:new Object[]{}));
|
||||
}
|
||||
static void requireForeignSelection(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,OrderOperations.Snapshot s,long vehicle,long courier,List<Object> evidence){
|
||||
if(id(s.job(),"jb_longhaul")!=0)throw bad("Für diesen Auftrag ist die gewählte Niederlassung nicht zulässig.");
|
||||
var rows=vehicles(db,catalog,u,vehicle);if(rows.size()!=1||id(rows.getFirst(),"cr_id")!=courier)throw bad("Das Fahrzeug der anderen Niederlassung ist nicht mehr angemeldet.");
|
||||
var geo=geography(db,catalog,u,s);var rules=rules(db,catalog,u,s);var c=candidate(u,s,rows.getFirst(),geo,rules,true);
|
||||
if(c.zone().rank()==REST.rank()||!c.eligible())throw bad("Das Fahrzeug der anderen Niederlassung gehört nicht mehr zu den freigegebenen Favoriten oder Vermittlungsbereichen.");
|
||||
evidence.add(List.of(geo,rules,c));
|
||||
}
|
||||
static LonghaulCandidates.Rules rules(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,OrderOperations.Snapshot s){
|
||||
var base=LonghaulCandidates.rules(db,catalog,u,s);var values=new TreeMap<>(base.values());for(String key:List.of("GLOBAL_NATIONALITY_DEFAULT","GLOBAL_NATIONALITY_WHITELIST","EU_COUNTRYCODES"))values.put(key,catalog.parameter(0,key));return new LonghaulCandidates.Rules(values,base.favourites(),base.blocked(),base.filters());
|
||||
}
|
||||
static boolean visible(LegacyUser u,Candidate c,LonghaulCandidates.Rules rules){if(id(c.vehicle(),"cr_available")!=1&&!rules.blocked().contains(id(c.vehicle(),"cr_id")))return false;return id(c.vehicle(),"courier_hq")==u.headquartersId()||c.zone().rank()!=REST.rank()||rules.blocked().contains(id(c.vehicle(),"cr_id"));}
|
||||
static Candidate candidate(LegacyUser u,OrderOperations.Snapshot s,Map<String,Object> v,Geography geo,LonghaulCandidates.Rules rules,boolean restrictRest){
|
||||
var preference=LonghaulCandidates.preference(rules,id(v,"cr_id"));var area=geo.zones().getOrDefault(text(v,"cr_locationzipcode"),REST);var zone=preference.favourite()?FAVOURITE:area;
|
||||
var blocks=new ArrayList<String>();var warnings=new ArrayList<String>();
|
||||
boolean limits=zone.rank()!=REST.rank()||restrictRest;
|
||||
if(AssignmentVehicles.ambiguous(v,"1".equals(rules.values().get(AssignmentVehicles.SETTING))))blocks.add("Mehrere Fahrer sind auf diesem Fahrzeug angemeldet");
|
||||
if(id(v,"cr_available")!=1)blocks.add("Nicht verfügbar gemeldet");
|
||||
if(rules.blocked().contains(id(v,"cr_id")))blocks.add("Für einen beteiligten Kunden gesperrt");
|
||||
if(id(v,"ranking_vehicle_type")<id(s.job(),"vht_id"))(limits?blocks:warnings).add("Angemeldete Fahrzeugklasse kleiner als bestellt");
|
||||
if(!preference.favourite()&&id(v,"cr_outlay")<id(s.job(),"jb_outlay"))(limits?blocks:warnings).add("Auslagenlimit reicht nicht aus");
|
||||
for(var pair:List.of(new String[]{"jb_weight","crvh_payload","Zuladung"},new String[]{"jb_crvh_length","crvh_length","Länge"},new String[]{"jb_crvh_width","crvh_width","Breite"},new String[]{"jb_crvh_height","crvh_height","Höhe"},new String[]{"jb_crvh_position","crvh_position","Stellplätze"}))if(id(s.job(),pair[0])>id(v,pair[1]))(limits&&preference.measuresRequired()&&"1".equals(rules.values().get("RANKING_JB2CRVH_MEASURE"))?blocks:warnings).add(pair[2]+" nicht ausreichend");
|
||||
for(String needed:FleetAssignment.tokens(text(s.job(),"jb_cr_filter"))){var types=rules.filters().stream().filter(f->text(f,"crf_short").equals(needed)).map(f->id(f,"crf_type")).distinct().toList();boolean missing=types.isEmpty()?!FleetAssignment.tokens(text(v,"cr_filter")+","+text(v,"crvh_filter")).contains(needed):types.stream().anyMatch(t->!FleetAssignment.tokens(text(v,t==0?"crvh_filter":"cr_filter")).contains(needed));if(missing)(types.isEmpty()||!preference.filterRequired()?warnings:blocks).add("Filterbedingung nicht erfüllt: "+needed);}
|
||||
var countries=new TreeSet<String>();s.stops().forEach(t->countries.add(country(text(t,"ad_country"))));String defaultCountry=rules.values().getOrDefault("GLOBAL_NATIONALITY_DEFAULT","");if(defaultCountry.isBlank())defaultCountry="DE";String nationality=country(text(v,"usr_country"));
|
||||
if(!nationality.isBlank()&&!nationality.equals(defaultCountry))for(String entry:rules.values().getOrDefault("GLOBAL_NATIONALITY_WHITELIST","").split("\\|")){var parts=entry.split("=",2);if(parts.length==2&&countries.contains(parts[0].strip())&&!parts[0].strip().equals(defaultCountry)&&!parts[0].strip().equals(nationality)&&!FleetAssignment.tokens(parts[1]).contains(nationality))blocks.add("Die hinterlegte Länderfreigabe ist für diese Tour nicht erfüllt.");}
|
||||
String eu=rules.values().getOrDefault("EU_COUNTRYCODES","");if(id(v,"crvh_totalweight")>=2800&&!FleetAssignment.tokens(text(v,"crvh_filter")).contains("EG")&&countries.stream().anyMatch(c->!c.equals("DE")&&!c.isEmpty()&&eu.contains(c)))warnings.add("Für diese Auslandstour fehlt die EG-Lizenz des Fahrzeugs.");
|
||||
return new Candidate(v,zone,preference.favourite(),blocks.isEmpty(),List.copyOf(blocks),List.copyOf(warnings));
|
||||
}
|
||||
static String country(String value){return value.equals("D")?"DE":value;}
|
||||
static Geography geography(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,OrderOperations.Snapshot s){
|
||||
if(s.stops().isEmpty())throw bad("Dem Auftrag fehlt die Abholstation.");String postal=text(s.stops().getFirst(),"ad_zipcode");if(postal.isBlank())throw bad("Der Abholstation fehlt die PLZ.");
|
||||
var evidence=new ArrayList<Object>();var warnings=new ArrayList<String>();var zones=new TreeMap<String,Zone>();
|
||||
String mode=LonghaulCandidates.setting(catalog,u,"MODE_INTERMEDIATION");if(mode.isBlank())mode=text(one(db.queryForList("SELECT hq_invmode FROM headquarters WHERE hq_id=?",u.headquartersId())),"hq_invmode");evidence.add(mode);
|
||||
if(!Set.of("1","2","3").contains(mode)){warnings.add("Für diese Niederlassung ist kein CLASSIC-Vermittlungsbereich konfiguriert.");return new Geography(mode,postal,"",zones,warnings,evidence);}
|
||||
var zip=db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=? ORDER BY srvp_id",postal);evidence.add(zip);if(zip.size()>1)throw bad("Die Abhol-PLZ ist im Bestand mehrdeutig.");
|
||||
if(mode.equals("1"))zones.put(postal,new Zone(10,"In der Abhol-PLZ",postal));
|
||||
if(zip.isEmpty()){warnings.add("Für die Abhol-PLZ fehlen Bereichs-/Nachbarschaftszuordnungen.");return new Geography(mode,postal,mode.equals("1")?postal:"",zones,warnings,evidence);}
|
||||
long source=id(zip.getFirst(),"srvp_id");String main=postal;
|
||||
if(mode.equals("1")){
|
||||
var neighbours=db.queryForList("SELECT p.srvp_plz FROM serviceplzneighbour n JOIN serviceplz p ON p.srvp_id=n.srvp2_id WHERE n.hq_id=? AND n.srvp_id=? ORDER BY p.srvp_plz",u.headquartersId(),source);evidence.add(neighbours);
|
||||
neighbours.forEach(n->zones.putIfAbsent(text(n,"srvp_plz"),new Zone(21,"In der PLZ-Nachbarschaft",text(n,"srvp_plz"))));
|
||||
}else{
|
||||
var areas=db.queryForList("SELECT a.srvpa_id,a.srvpa_name,a.srvpa_longname FROM serviceplzareamapping m JOIN serviceplzarea a ON a.srvpa_id=m.srvpa_id WHERE m.srvp_id=? AND a.hq_id=? ORDER BY a.srvpa_id",source,u.headquartersId());evidence.add(areas);
|
||||
if(areas.size()>1)throw bad("Die Abhol-PLZ gehört zu mehreren Vermittlungsbereichen.");
|
||||
if(areas.isEmpty()){warnings.add("Die Abhol-PLZ ist keinem Vermittlungsbereich zugeordnet.");main="";}
|
||||
else{
|
||||
var a=areas.getFirst();main=text(a,"srvpa_name");addArea(db,u,zones,a,new Zone(10,"Im Vermittlungsbereich "+main,main),evidence);
|
||||
int levels=levels(LonghaulCandidates.setting(catalog,u,"AUTORANKING_NEIGHBOUR_LEVEL"));evidence.add(levels);
|
||||
var neighbours=mode.equals("2")?db.queryForList("SELECT a.srvpa_id,a.srvpa_name,a.srvpa_longname,n.srvpan_sort AS neighbour_level FROM serviceplzareaneighbour n JOIN serviceplzarea a ON a.srvpa_id=n.srvpa2_id WHERE n.srvpa_id=? AND a.hq_id=? AND n.srvpan_sort BETWEEN 1 AND ? ORDER BY n.srvpan_sort,a.srvpa_id",id(a,"srvpa_id"),u.headquartersId(),levels):db.queryForList("SELECT a.srvpa_id,a.srvpa_name,a.srvpa_longname,n.srvpna_sort AS neighbour_level FROM serviceplzneighbourarea n JOIN serviceplzarea a ON a.srvpa_id=n.srvpa_id WHERE n.srvp_id=? AND n.hq_id=? AND a.hq_id=? AND n.srvpna_sort BETWEEN 1 AND ? ORDER BY n.srvpna_sort,a.srvpa_id",source,u.headquartersId(),u.headquartersId(),levels);evidence.add(neighbours);
|
||||
for(var n:neighbours)addArea(db,u,zones,n,new Zone(20+(int)id(n,"neighbour_level"),"Nachbarschaft "+id(n,"neighbour_level")+" · "+text(n,"srvpa_name"),text(n,"srvpa_name")),evidence);
|
||||
}
|
||||
}
|
||||
return new Geography(mode,postal,main,Collections.unmodifiableMap(zones),List.copyOf(warnings),List.copyOf(evidence));
|
||||
}
|
||||
static int levels(String value){if(value.isBlank())return 0;try{int n=Integer.parseInt(value);if(n>=0&&n<=1000)return n;}catch(NumberFormatException ignored){}throw bad("AUTORANKING_NEIGHBOUR_LEVEL muss zwischen 0 und 1000 liegen.");}
|
||||
static void addArea(DatabaseService db,LegacyUser u,Map<String,Zone> zones,Map<String,Object> area,Zone zone,List<Object> evidence){var codes=db.queryForList("SELECT p.srvp_plz FROM serviceplzareamapping m JOIN serviceplz p ON p.srvp_id=m.srvp_id WHERE m.srvpa_id=? ORDER BY p.srvp_plz",id(area,"srvpa_id"));evidence.add(codes);codes.forEach(c->zones.putIfAbsent(text(c,"srvp_plz"),zone));}
|
||||
static final String OCCUPIED="""
|
||||
DATE(j.jb_ordertime)=CURRENT_DATE AND
|
||||
(DATE_SUB(j.jb_ordertime,INTERVAL GREATEST(lt.srvpt_traveltime,30) MINUTE)<=NOW()
|
||||
OR (DATE_SUB(j.jb_ordertime,INTERVAL GREATEST(lt.srvpt_traveltime,30) MINUTE)<=? AND j.jb_ordertime>=?))
|
||||
""";
|
||||
static Map<String,Map<String,Object>> assignments(DatabaseService db,OrderOperations.Snapshot s){
|
||||
var rows=db.queryForList("""
|
||||
SELECT j.cr_sid,COUNT(DISTINCT j.jb_id) AS active_count,MIN(j.jb_ordertime) AS earliest_order,
|
||||
MAX(CASE WHEN %s THEN 1 ELSE 0 END) AS occupied
|
||||
FROM job j JOIN tour t ON t.jb_id=j.jb_id AND t.tr_sort=1 JOIN address a ON a.ad_id=t.ad_id
|
||||
LEFT JOIN serviceplz p ON p.srvp_plz=a.ad_zipcode LEFT JOIN serviceplztraveltime lt ON lt.srvp_id=p.srvp_id AND lt.hq_id=j.hq_id
|
||||
WHERE j.jb_id<>? AND j.jb_status IN (0,1) AND COALESCE(j.jb_storno,0)=0 AND j.cr_sid<>'' GROUP BY j.cr_sid
|
||||
""".formatted(OCCUPIED),dateTime(s.job(),"jb_ordertime"),dateTime(s.job(),"jb_ordertime"),id(s.job(),"jb_id"));
|
||||
var result=new HashMap<String,Map<String,Object>>();rows.forEach(r->result.put(text(r,"cr_sid"),r));return result;
|
||||
}
|
||||
static Comparator<Candidate> ranking(boolean occupiedFirst,Map<String,Map<String,Object>> assignments){return Comparator.<Candidate>comparingInt(c->c.eligible()?0:1).thenComparingInt(c->occupiedFirst&&id(assignments.getOrDefault(text(c.vehicle(),"crvh_sid"),Map.of()),"occupied")!=0?1:0).thenComparingInt(c->c.zone().rank()).thenComparing(c->c.zone()==FAVOURITE||c.zone()==REST?text(c.vehicle(),"cr_locationzipcode"):c.zone().name()).thenComparing(c->text(c.vehicle(),"cr_availabletime")).thenComparing(c->text(c.vehicle(),"crvh_sid"));}
|
||||
static Map<String,Object> view(Candidate c,Map<String,Object> assignment,Set<Long> previous){var v=c.vehicle();return fields("id",id(v,"crvh_id"),"courierId",id(v,"cr_id"),"ownerCourierId",id(v,"owner_courier_id"),"driverName",text(v,"driver_name"),"sid",text(v,"crvh_sid"),"name",text(v,"cmp_comp"),"plate",text(v,"crvh_vh_sign"),"vehicleType",id(v,"vht_id"),"vehicleTypeName",text(v,"vehicle_type"),"headquarters",text(v,"hq_mnemonic"),"online",true,"available",id(v,"cr_available")==1,"occupied",id(assignment,"occupied")!=0,"reportedOccupied",id(v,"cr_occupied")!=0,"postalCode",text(v,"cr_locationzipcode"),"availableTime",text(v,"cr_availabletime"),"group",c.zone().group(),"groupRank",c.zone().rank(),"favourite",c.favourite(),"previouslyOffered",previous.contains(id(v,"cr_id")),"activeOrders",id(assignment,"active_count"),"earliestOrder",text(assignment,"earliest_order"),"blockers",c.blockers(),"warnings",c.warnings());}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** MASK_CS2CO_PRESET addresses metafield keys, not headquarters-specific category-key IDs. */
|
||||
final class ContactCustomerPresets {
|
||||
static final String PARAMETER="MASK_CS2CO_PRESET";
|
||||
record Result(Map<Long,String> values,List<String> warnings){}
|
||||
static Map<Long,String> mappings(String raw){
|
||||
var result=new LinkedHashMap<Long,String>();if(raw==null||raw.isBlank())return result;
|
||||
for(String part:raw.split("\\|",-1)){
|
||||
String[] pair=part.split("=",2);if(pair.length!=2||!pair[0].strip().matches("[1-9][0-9]{0,9}"))throw bad("Die Kontaktvorbelegung ist ungültig ("+PARAMETER+").");
|
||||
long key=Long.parseLong(pair[0].strip());if(result.put(key,pair[1].strip())!=null)throw bad("Die Kontaktvorbelegung enthält doppelte Feldschlüssel.");
|
||||
}return result;
|
||||
}
|
||||
static Result resolve(DatabaseService db,LegacyUser u,Map<String,Object> customer,List<Map<String,Object>> definitions){
|
||||
var mapping=mappings(new OrderEntryCatalog(db).parameter(0,PARAMETER));var mapped=new ArrayList<Map<String,Object>>();var links=new LinkedHashMap<Long,String>();var warnings=new ArrayList<String>();
|
||||
for(var d:definitions){String type=mapping.get(id(d,"keyId"));if(type==null)continue;long field=id(d,"id");
|
||||
if(text(d,"type").equals("co2cs")&&type.equals("db:cs:eid")){links.put(field,Long.toString(id(customer,"cs_id")));continue;}
|
||||
if(MasterForms.dbKey("customers",type)==null){warnings.add(text(d,"label")+": Vorbelegung "+type+" wird nicht unterstützt.");continue;}
|
||||
mapped.add(fields("id",field,"type",type));
|
||||
}
|
||||
var values=new LinkedHashMap<>(new FormAutomaticValues(db,new FormFieldSupport(db)).values(u,"customers",customer,mapped,List.of()));values.putAll(links);
|
||||
return new Result(values,List.copyOf(warnings));
|
||||
}
|
||||
private ContactCustomerPresets(){}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Explicit PHP contact-column settings; native defaults remain available without configuration. */
|
||||
final class ContactPresentation {
|
||||
record Settings(List<Integer> header,List<Integer> body,List<Integer> sort){}
|
||||
static Settings settings(DatabaseService db){var c=new OrderEntryCatalog(db);return new Settings(ContactVcard.positions(c.parameter(0,"MASK_CONTACT_HEADER_FIELDS_DISPLAYED"),2),ContactVcard.positions(c.parameter(0,"MASK_CONTACT_BODY_FIELDS_DISPLAYED"),200),ContactVcard.positions(c.parameter(0,"MASK_CONTACT_SORT_FIELDS"),200));}
|
||||
static List<Map<String,Object>> ordered(List<Map<String,Object>> definitions,List<Map<String,Object>> values){
|
||||
var byId=new HashMap<Long,Map<String,Object>>();for(var value:values)if(byId.put(id(value,"fieldId"),value)!=null)throw bad("Doppelte Kontaktfelder im Bestand. Bitte zuerst bereinigen.");
|
||||
return definitions.stream().map(d->{var row=byId.get(id(d,"id"));return row!=null?row:fields("fieldId",id(d,"id"),"label",text(d,"label"),"type",text(d,"type"),"value","");}).toList();
|
||||
}
|
||||
static String value(List<Map<String,Object>> fields,int index){return index<fields.size()?text(fields.get(index),"value"):"";}
|
||||
static String title(Settings s,List<Map<String,Object>> values){return s.header().isEmpty()?"":String.join(", ",s.header().stream().map(i->value(values,i)).filter(v->!v.isBlank()).toList());}
|
||||
static List<Long> body(Settings s,List<Map<String,Object>> values){return s.body().isEmpty()?null:java.util.stream.IntStream.range(0,values.size()).filter(s.body()::contains).mapToObj(i->id(values.get(i),"fieldId")).toList();}
|
||||
static String sort(Settings s,List<Map<String,Object>> values,long contact){return s.sort().isEmpty()?ContactService.contactName(values).toLowerCase(Locale.ROOT):String.join(":",s.sort().stream().map(i->value(values,i)).toList())+":"+contact;}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.*;
|
||||
import java.time.*;
|
||||
import java.time.format.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.CrmScope.marks;
|
||||
@Service
|
||||
class ContactService {
|
||||
private final DatabaseService db;private final CrmScope scope;
|
||||
ContactService(DatabaseService db,CrmScope scope){this.db=db;this.scope=scope;}
|
||||
record Query(List<Long> headquarters,String search,boolean prefix,String letter,Long customerId){}
|
||||
record Save(long headquarters,String revision,String schemaRevision,Map<Long,String> values){}
|
||||
private List<Map<String,Object>> definitions(long hq){return db.queryForList("SELECT ck.mtfck_id AS id,ck.mtfk_id AS keyId,ck.mtfck_history AS history,k.mtfk_name AS label,k.mtfk_type AS type FROM metafieldcategorykey ck JOIN metafieldkey k ON k.mtfk_id=ck.mtfk_id WHERE ck.mtfc_id=1 AND ck.hq_id=? ORDER BY ck.mtfck_sort,ck.mtfck_id",hq);}
|
||||
Map<String,Object> schema(LegacyUser u,long hq){scope.checked(u,List.of(hq));var d=definitions(hq);if(d.isEmpty())throw bad("Für diese Niederlassung sind keine Kontaktfelder eingerichtet.");return fields("fields",d,"revision",revision(d));}
|
||||
Map<String,Object> draft(LegacyUser u,long hq,Long customer){return db.readTransaction(()->{
|
||||
scope.checked(u,List.of(hq));var defs=definitions(hq);if(defs.isEmpty())throw bad("Für diese Niederlassung sind keine Kontaktfelder eingerichtet.");
|
||||
var preset=new ContactCustomerPresets.Result(Map.of(),List.of());
|
||||
if(customer!=null){scope.customer(u,customer);var owner=one(db.queryForList("SELECT * FROM customer WHERE cs_id=?",customer));preset=ContactCustomerPresets.resolve(db,u,owner,defs);}
|
||||
var defaults=preset;
|
||||
return fields("id",0,"headquartersId",hq,"values",preset.values(),"presetWarnings",preset.warnings(),"customerLabels",customerLabels(u,defs.stream().filter(d->text(d,"type").equals("co2cs")).map(d->presetValue(d,defaults.values())).toList()),"revision","","schema",fields("fields",defs,"revision",revision(defs)));
|
||||
});}
|
||||
private static String presetValue(Map<String,Object> field,Map<Long,String> values){return values.get(id(field,"id"));}
|
||||
private List<Map<String,Object>> raw(long id,List<Map<String,Object>> defs){var args=new ArrayList<Object>(List.of(id));args.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id,mtfv_id,mtfv_value FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfck_id,mtfv_value",args.toArray());}
|
||||
private Map<String,String> customerLabels(LegacyUser u,Collection<String> ids){var valid=ids.stream().filter(v->v!=null&&v.matches("[1-9][0-9]{0,9}")).distinct().toList();if(valid.isEmpty())return Map.of();var hqs=scope.headquarters(u).stream().map(h->id(h,"id")).toList();if(hqs.isEmpty())return Map.of();var args=new ArrayList<Object>(valid);args.addAll(hqs);var result=new HashMap<String,String>();for(var r:db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' ',cs.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer cs JOIN company co ON co.cmp_id=cs.cmp_id AND co.cmp_visible=1 AND co.cmp_authenticated=1 WHERE cs.cs_id IN ("+marks(valid.size())+") AND cs.hq_id IN ("+marks(hqs.size())+")",args.toArray()))result.put(Long.toString(id(r,"id")),text(r,"label"));return result;}
|
||||
Map<String,Object> get(LegacyUser u,long hq,long contact){scope.checked(u,List.of(hq));var defs=definitions(hq);if(defs.isEmpty())throw bad("Keine Kontaktfelder eingerichtet.");var rows=raw(contact,defs);if(rows.isEmpty())one(List.of());var vals=new LinkedHashMap<Long,String>();for(var r:rows)if(vals.put(id(r,"mtfck_id"),text(r,"mtfv_value"))!=null)throw bad("Doppelte Kontaktfelder im Bestand. Bitte zuerst bereinigen.");return fields("id",contact,"headquartersId",hq,"values",vals,"customerLabels",customerLabels(u,defs.stream().filter(d->text(d,"type").equals("co2cs")).map(d->vals.get(id(d,"id"))).toList()),"revision",revision(rows),"schema",fields("fields",defs,"revision",revision(defs)));}
|
||||
List<Map<String,Object>> list(LegacyUser u,Query q){if(q==null)throw bad("Kontaktfilter fehlen.");var h=scope.checked(u,q.headquarters());String term=q.search()==null?"":q.search().strip();if(term.length()>100||q.letter()!=null&&!q.letter().matches("[A-ZÄÖÜ]?"))throw bad("Ungültige Kontaktsuche.");if(q.customerId()!=null)scope.customer(u,q.customerId());
|
||||
var args=new ArrayList<Object>(h);String where="ck.mtfc_id=1 AND ck.hq_id IN ("+marks(h.size())+")";
|
||||
if(!term.isEmpty()){var allowed=scope.headquarters(u).stream().map(r->id(r,"id")).toList();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 IN ("+marks(allowed.size())+") LEFT JOIN company co ON co.cmp_id=c.cmp_id AND co.cmp_visible=1 AND co.cmp_authenticated=1 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 (co.cmp_id IS NOT NULL AND (c.cs_eid LIKE ? OR co.cmp_comp LIKE ? OR co.cmp_comp2 LIKE ?))))";String like=(q.prefix()?"":"%")+term.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%";args.addAll(allowed);for(int i=0;i<4;i++)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 presentation=ContactPresentation.settings(db);var result=new ArrayList<Map<String,Object>>();for(long hq:h){var ids=matches.stream().filter(m->id(m,"headquartersId")==hq).map(m->id(m,"id")).toList();if(ids.isEmpty())continue;var p=new ArrayList<Object>(List.of(hq));p.addAll(ids);var rows=db.queryForList("SELECT v.mtfv_id AS id,ck.mtfck_id AS fieldId,k.mtfk_name AS label,k.mtfk_type AS type,v.mtfv_value AS value FROM metafieldvalue v JOIN metafieldcategorykey ck ON ck.mtfck_id=v.mtfck_id JOIN metafieldkey k ON k.mtfk_id=ck.mtfk_id WHERE ck.mtfc_id=1 AND ck.hq_id=? AND v.mtfv_id IN ("+marks(ids.size())+") ORDER BY ck.mtfck_sort,ck.mtfck_id",p.toArray());var defs=definitions(hq);for(long cid:ids){var values=ContactPresentation.ordered(defs,rows.stream().filter(r->id(r,"id")==cid).toList());result.add(fields("id",cid,"headquartersId",hq,"fields",values,"header",presentation.header().isEmpty()?null:ContactPresentation.title(presentation,values),"cardFieldIds",ContactPresentation.body(presentation,values),"sortKey",ContactPresentation.sort(presentation,values,cid)));}}
|
||||
var linked=customerLabels(u,result.stream().flatMap(r->TypedValues.rows(r.get("fields")).stream()).filter(f->text(f,"type").equals("co2cs")).map(f->text(f,"value")).toList());for(var r:result)for(var f:TypedValues.rows(r.get("fields")))if(text(f,"type").equals("co2cs"))f.put("displayValue",linked.getOrDefault(text(f,"value"),"Kunde "+text(f,"value")));
|
||||
if(presentation.sort().isEmpty())result.sort(Comparator.comparing(r->contactName(TypedValues.rows(r.get("fields"))),String.CASE_INSENSITIVE_ORDER));else result.sort(Comparator.comparing(r->text(r,"sortKey")));result.forEach(r->r.remove("sortKey"));return result;
|
||||
}
|
||||
static String contactName(List<Map<String,Object>> fields){return String.join(" ",fields.stream().filter(f->Set.of("Name","Vorname").contains(text(f,"label"))).map(f->text(f,"value")).toList());}
|
||||
Map<String,Object> save(LegacyUser u,Long cid,Save s){if(s==null||s.values()==null)throw bad("Kontaktdaten fehlen.");scope.checked(u,List.of(s.headquarters()));var defs=definitions(s.headquarters());unchanged(defs,s.schemaRevision());if(defs.isEmpty()||s.values().size()>200)throw bad("Ungültige Kontaktfelder.");var known=defs.stream().map(d->id(d,"id")).toList();if(!known.containsAll(s.values().keySet()))throw bad("Unbekanntes Kontaktfeld.");var vals=new LinkedHashMap<Long,String>();var before=cid==null?List.<Map<String,Object>>of():raw(cid,defs);if(cid!=null){if(before.isEmpty())one(before);unchanged(before,s.revision());}
|
||||
for(var d:defs){String v=s.values().getOrDefault(id(d,"id"),"");if(v==null)v="";String oldValue=before.stream().filter(r->id(r,"mtfck_id")==id(d,"id")).map(r->text(r,"mtfv_value")).findFirst().orElse("");if(!v.equals(oldValue))v=v.strip();if(v.getBytes(StandardCharsets.UTF_8).length>65535)throw bad("Kontaktfeld ist zu lang.");String type=text(d,"type");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)FormContactValues.validate(d,v);
|
||||
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(entryValue -> entryValue.isBlank()))throw bad("Bitte mindestens ein Kontaktfeld ausfüllen.");
|
||||
return db.contactWriteTransaction(()->{unchanged(definitions(s.headquarters()),s.schemaRevision());List<Map<String,Object>> old=cid==null?List.of():raw(cid,defs);if(cid!=null){if(old.isEmpty())one(old);unchanged(old,s.revision());}long contact=cid==null?db.queryForObject("SELECT COALESCE(MAX(mtfv_id),0)+1 FROM metafieldvalue",Long.class):cid;if(contact<=0||contact>Integer.MAX_VALUE)throw bad("Kontakt-ID-Bereich ausgeschöpft.");
|
||||
if(cid!=null){var a=new ArrayList<Object>(List.of(contact));a.addAll(known);db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(known.size())+")",a.toArray());}
|
||||
for(var d:defs){long key=id(d,"id");String v=vals.get(key);db.update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",key,contact,v);String previous=id(d,"history")==1?db.queryForList("SELECT mtfv_value FROM metafieldvaluehistory WHERE mtfck_id=? AND mtfv_id=? ORDER BY mtfvh_createtime DESC LIMIT 1",key,contact).stream().map(r->text(r,"mtfv_value")).findFirst().orElse(""):v;if(id(d,"history")==1&&!previous.equals(v))db.update("INSERT INTO metafieldvaluehistory (mtfck_id,mtfv_id,mtfv_value,mtfvh_createtime) VALUES (?,?,?,?)",key,contact,v,LocalDateTime.now(ZoneId.of("Europe/Berlin")));}
|
||||
return fields("id",contact);});
|
||||
}
|
||||
void remove(LegacyUser u,long hq,long cid,String rev){scope.checked(u,List.of(hq));var defs=definitions(hq);if(defs.isEmpty())throw bad("Keine Kontaktfelder.");db.contactWriteTransaction(()->{var current=definitions(hq);unchanged(current,revision(defs));var old=raw(cid,defs);if(old.isEmpty())one(old);unchanged(old,rev);var a=new ArrayList<Object>(List.of(cid));a.addAll(defs.stream().map(d->id(d,"id")).toList());db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+")",a.toArray());return null;});}
|
||||
List<Map<String,Object>> history(LegacyUser u,long hq,long cid){get(u,hq,cid);var defs=definitions(hq);var a=new ArrayList<Object>(List.of(cid));a.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id AS fieldId,mtfv_value AS value,mtfvh_createtime AS time FROM metafieldvaluehistory WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfvh_createtime DESC LIMIT 1000",a.toArray());}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.format.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
|
||||
/** vCard 3.0 export of the reviewed contact, independent of an SMTP connection. */
|
||||
final class ContactVcard {
|
||||
record Field(String label,String type,String value){}
|
||||
record Layout(List<Integer> header,List<Integer> address){}
|
||||
static List<Integer> positions(String value,int maximum){
|
||||
if(value==null||value.isBlank())return List.of();
|
||||
if(!value.matches("[0-9]{1,3}(?:,[0-9]{1,3})*")||value.split(",").length>maximum)throw bad("Ungültige Kontakt-Feldzuordnung.");
|
||||
return Arrays.stream(value.split(",")).map(Integer::parseInt).toList();
|
||||
}
|
||||
static String label(String value){return org.jsoup.Jsoup.parse(value==null?"":value).text().replace('\u00a0',' ').strip();}
|
||||
static String named(List<Field> fields,String... names){return fields.stream().filter(f->Arrays.asList(names).contains(label(f.label()))).map(entryValue -> entryValue.value()).findFirst().orElse("");}
|
||||
static String at(List<Field> fields,List<Integer> indexes,int position){return position<indexes.size()&&indexes.get(position)<fields.size()?fields.get(indexes.get(position)).value():"";}
|
||||
static String escape(String s){return s.replace("\\","\\\\").replace("\r\n","\n").replace('\r','\n').replace("\n","\\n").replace(";","\\;").replace(",","\\,").replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]","");}
|
||||
static String birthday(String value){try{return LocalDate.parse(value,DateTimeFormatter.ofPattern("dd.MM.uuuu").withResolverStyle(ResolverStyle.STRICT)).toString();}catch(DateTimeException e){return "";}}
|
||||
static boolean email(String type){return Set.of("mail","email","mail_business","mail_private","email_business","email_private").contains(type);}
|
||||
static String create(long id,List<Field> fields,Layout layout,List<String> companies){
|
||||
String family=layout.header().isEmpty()?named(fields,"Name","Nachname"):at(fields,layout.header(),0);
|
||||
String given=layout.header().isEmpty()?named(fields,"Vorname"):at(fields,layout.header(),1);
|
||||
String full=(given+" "+family).strip();if(full.isEmpty())full="Kontakt "+id;
|
||||
var lines=new ArrayList<String>(List.of("BEGIN:VCARD","VERSION:3.0","N:"+escape(family)+";"+escape(given)+";;;","FN:"+escape(full)));
|
||||
String[] address=layout.address().isEmpty()?new String[]{named(fields,"Strasse","Straße"),named(fields,"Hausnr.","Hausnummer"),named(fields,"PLZ"),named(fields,"Ort"),named(fields,"Land")}:java.util.stream.IntStream.range(0,5).mapToObj(i->at(fields,layout.address(),i)).toArray(String[]::new);
|
||||
if(Arrays.stream(address).anyMatch(v->!v.isBlank()))lines.add("ADR;TYPE=WORK:;;"+escape((address[0]+" "+address[1]).strip())+";"+escape(address[3])+";;"+escape(address[2])+";"+escape(address[4]));
|
||||
companies.stream().filter(v->!v.isBlank()).distinct().forEach(v->lines.add("ORG:"+escape(v)));
|
||||
String title=named(fields,"Position"),note=named(fields,"Bemerkung");if(!title.isBlank())lines.add("TITLE:"+escape(title));if(!note.isBlank())lines.add("NOTE:"+escape(note));
|
||||
var seen=new HashSet<String>();boolean bday=false;
|
||||
for(var f:fields){String v=f.value(),t=f.type(),kind=t.endsWith("private")?"HOME":"WORK";if(v.isBlank())continue;
|
||||
if(email(t)){for(String item:v.split(",")){String mail=item.strip();if(!mail.isBlank()){String line="EMAIL;TYPE="+kind+":"+escape(mail);if(seen.add(line))lines.add(line);}}}
|
||||
if(t.matches("(?:phone|mobile|fax)(?:_business|_private)?"))lines.add("TEL;TYPE="+kind+","+(t.startsWith("mobile")?"CELL":t.startsWith("fax")?"FAX":"VOICE")+":"+escape(v));
|
||||
if(Set.of("url","url_business","url_private").contains(t)&&!MasterForms.safeLink("",v).isEmpty()&&!v.contains("\r")&&!v.contains("\n"))lines.add("URL:"+MasterForms.safeLink("",v));
|
||||
if(t.equals("birthdate")&&!bday){String date=birthday(v);if(!date.isEmpty()){lines.add("BDAY:"+date);bday=true;}}
|
||||
}
|
||||
lines.add("END:VCARD");return lines.stream().map(ContactVcard::fold).collect(java.util.stream.Collectors.joining("\r\n"))+"\r\n";
|
||||
}
|
||||
// Fold at 75 UTF-8 octets, never through a multibyte character.
|
||||
static String fold(String line){var out=new StringBuilder();int length=0;for(int cp:line.codePoints().toArray()){String text=new String(Character.toChars(cp));int bytes=text.getBytes(StandardCharsets.UTF_8).length;if(length+bytes>75){out.append("\r\n ");length=1;}out.append(text);length+=bytes;}return out.toString();}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.time.*;
|
||||
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;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/crm/contacts/{contact}/vcard")
|
||||
class ContactVcards {
|
||||
record Document(long headquarters,long contact,String filename,String content,String revision){}
|
||||
record Draft(String revision,String recipients){}
|
||||
record Confirmation(boolean confirmed){}
|
||||
record Pending(long actor,long home,long workplace,Document document,List<String> recipients,String from,Instant expires){}
|
||||
record Delivery(long home,long workplace,long headquarters,long contact,String filename,List<String> recipients,String from,String startedAt,String state){}
|
||||
final DatabaseService db;final ContactService contacts;final BusinessSupport support;final AppointmentMailTransport transport;
|
||||
final Map<String,Pending> pending=new LinkedHashMap<>();
|
||||
ContactVcards(DatabaseService db,ContactService contacts,BusinessSupport support,AppointmentMailTransport transport){this.db=db;this.contacts=contacts;this.support=support;this.transport=transport;}
|
||||
@GetMapping Map<String,Object> view(Authentication auth,@PathVariable long contact,@RequestParam long headquarters){return view(ApiController.user(auth),headquarters,contact);}
|
||||
@PostMapping("/mail/preview") Map<String,Object> preview(Authentication auth,@PathVariable long contact,@RequestParam long headquarters,@RequestBody Draft draft){return preview(ApiController.user(auth),headquarters,contact,draft);}
|
||||
@PostMapping("/mail/{token}") Map<String,Object> send(Authentication auth,@PathVariable long contact,@RequestParam long headquarters,@PathVariable String token,@RequestBody Confirmation confirmation){return send(ApiController.user(auth),headquarters,contact,token,confirmation);}
|
||||
@GetMapping("/mail") Map<String,Object> history(Authentication auth,@PathVariable long contact,@RequestParam long headquarters){return history(ApiController.user(auth),headquarters,contact);}
|
||||
@GetMapping("/mail/{token}") Map<String,Object> status(Authentication auth,@PathVariable long contact,@RequestParam long headquarters,@PathVariable String token){return status(ApiController.user(auth),headquarters,contact,token);}
|
||||
@SuppressWarnings("unchecked") Document document(LegacyUser u,long headquarters,long contact){
|
||||
var detail=contacts.get(u,headquarters,contact);var schema=(Map<String,Object>)detail.get("schema");var defs=(List<Map<String,Object>>)schema.get("fields");var values=(Map<Long,String>)detail.get("values");
|
||||
var fs=defs.stream().map(d->new ContactVcard.Field(text(d,"label"),text(d,"type"),values.getOrDefault(id(d,"id"),""))).toList();var companies=new ArrayList<String>();
|
||||
var hqs=new CrmScope(db).headquarters(u).stream().map(r->id(r,"id")).toList();
|
||||
for(String customer:fs.stream().filter(f->f.type().equals("co2cs")).map(entryValue -> entryValue.value()).filter(v->v.matches("[1-9][0-9]{0,9}")).distinct().toList()){
|
||||
var rows=db.queryForList("SELECT cs.hq_id,co.cmp_comp FROM customer cs JOIN company co ON co.cmp_id=cs.cmp_id WHERE cs.cs_id=? AND co.cmp_visible=1 AND co.cmp_authenticated=1",Long.parseLong(customer));
|
||||
if(rows.size()>1)throw bad("Mehrdeutiger Firmenverweis im Kontakt.");if(rows.size()==1&&hqs.contains(id(rows.getFirst(),"hq_id")))companies.add(text(rows.getFirst(),"cmp_comp").strip());
|
||||
}
|
||||
var layout=new ContactVcard.Layout(ContactVcard.positions(new OrderEntryCatalog(db).parameter(0,"MASK_CONTACT_HEADER_FIELDS_DISPLAYED"),2),ContactVcard.positions(new OrderEntryCatalog(db).parameter(0,"MASK_CONTACT_ADDRESS_FIELDS_DISPLAYED"),5));
|
||||
String content=ContactVcard.create(contact,fs,layout,companies);if(content.getBytes(StandardCharsets.UTF_8).length>1_000_000)throw bad("Die vCard ist zu groß. Bitte Kontaktfelder prüfen.");
|
||||
return new Document(headquarters,contact,"Kontakt-"+contact+".vcf",content,revision(List.of(detail.get("revision"),schema.get("revision"),content,layout)));
|
||||
}
|
||||
Map<String,Object> view(LegacyUser u,long headquarters,long contact){return db.readTransaction(()->{
|
||||
var d=document(u,headquarters,contact);var users=db.queryForList("SELECT usr_email FROM user WHERE usr_id=?",u.userId());if(users.size()>1)throw bad("Mehrdeutiger Benutzer.");
|
||||
return fields("filename",d.filename(),"content",d.content(),"revision",d.revision(),"defaultRecipient",users.isEmpty()?"":text(users.getFirst(),"usr_email"),"mailConfigured",transport.configured());
|
||||
});}
|
||||
String sender(LegacyUser u){String from=Objects.toString(transport.from,"");if(from.isBlank()){var c=new OrderEntryCatalog(db);from=c.parameter(u.headquartersId(),"MAIL_NEWSLETTER_SENDER_ADDRESS");if(from.isBlank())from=c.parameter(u.headquartersId(),"MAIL_SENDER_ADDRESS");}return AppointmentNotifications.address(from);}
|
||||
Map<String,Object> preview(LegacyUser u,long headquarters,long contact,Draft draft){
|
||||
var document=db.readTransaction(()->document(u,headquarters,contact));if(draft==null||!document.revision().equals(draft.revision()))throw bad("Der Kontakt wurde geändert. Bitte die vCard neu laden.");
|
||||
if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert.");var recipients=NewsletterDispatch.addresses(draft.recipients());if(recipients.isEmpty()||recipients.size()>20)throw bad("Bitte ein bis zwanzig E-Mail-Empfänger eingeben.");
|
||||
String from=sender(u),token=UUID.randomUUID().toString();var p=new Pending(u.userId(),u.originalHeadquartersId(),u.headquartersId(),document,recipients,from,Instant.now().plusSeconds(900));
|
||||
synchronized(pending){pending.values().removeIf(v->v.expires().isBefore(Instant.now()));if(pending.size()>=20)throw bad("Zu viele vCard-Vorschauen. Bitte später erneut versuchen.");pending.put(token,p);}
|
||||
return fields("token",token,"from",from,"recipients",recipients,"subject","VCard","filename",document.filename(),"content",document.content());
|
||||
}
|
||||
static String context(LegacyUser u,long hq,long contact){return revision(List.of(u.originalHeadquartersId(),u.headquartersId(),hq,contact));}
|
||||
Delivery decode(Map<String,Object> row){try{return support.json.readValue(text(row,"gdc_content"),Delivery.class);}catch(Exception e){throw bad("Der Versandstatus ist nicht lesbar. Bitte vor erneutem Versand prüfen lassen.");}}
|
||||
Delivery stored(LegacyUser u,long hq,long contact,String token){
|
||||
BusinessSupport.token(token);var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngvcardmail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.userId(),token);if(rows.isEmpty())return null;var d=decode(one(rows));
|
||||
if(d.home()!=u.originalHeadquartersId()||d.workplace()!=u.headquartersId()||d.headquarters()!=hq||d.contact()!=contact)throw bad("Die Versandkennung gehört zu einem anderen Kontakt oder Arbeitsplatz.");return d;
|
||||
}
|
||||
static Map<String,Object> result(String token,Delivery d){return fields("token",token,"state",d.state(),"recipients",d.recipients(),"from",d.from(),"filename",d.filename(),"startedAt",d.startedAt(),"message",d.state().equals("SENT")?"Die vCard wurde vom Mailserver angenommen. Dies bestätigt noch nicht die Zustellung.":"Der Versand wurde begonnen, aber nicht sicher bestätigt. Er wird nicht automatisch wiederholt. Bitte den Empfang oder das Mailprotokoll prüfen.");}
|
||||
Map<String,Object> history(LegacyUser u,long headquarters,long contact){contacts.get(u,headquarters,contact);return fields("items",db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngvcardmail' AND gdc_obj_id=? AND gdc_context=? ORDER BY gdc_id DESC LIMIT 10",u.userId(),context(u,headquarters,contact)).stream().map(r->result(text(r,"gdc_gen_fieldname"),decode(r))).toList());}
|
||||
Map<String,Object> status(LegacyUser u,long headquarters,long contact,String token){contacts.get(u,headquarters,contact);var d=stored(u,headquarters,contact,token);if(d==null)throw bad("Für diese Kennung wurde noch kein Versand gestartet.");return result(token,d);}
|
||||
Map<String,Object> send(LegacyUser u,long headquarters,long contact,String token,Confirmation confirmation){
|
||||
contacts.get(u,headquarters,contact);BusinessSupport.token(token);if(confirmation==null||!confirmation.confirmed())throw bad("Bitte Empfänger und vCard-Inhalt bestätigen.");
|
||||
var prior=stored(u,headquarters,contact,token);if(prior!=null)return result(token,prior);
|
||||
Pending p;synchronized(pending){p=pending.get(token);}if(p==null||p.actor()!=u.userId()||p.home()!=u.originalHeadquartersId()||p.workplace()!=u.headquartersId()||p.document().headquarters()!=headquarters||p.document().contact()!=contact||p.expires().isBefore(Instant.now()))throw bad("Die Versandvorschau ist abgelaufen. Bitte zuerst den Versandverlauf prüfen und danach neu vorbereiten.");
|
||||
support.transactional("genericdatacontainer");boolean claimed=db.contactVcardMailWriteTransaction(()->{
|
||||
if(stored(u,headquarters,contact,token)!=null)return false;
|
||||
if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert.");
|
||||
var current=document(u,headquarters,contact);if(!current.revision().equals(p.document().revision())||!sender(u).equals(p.from()))throw bad("Kontakt, Feldzuordnung oder Absender wurde geändert. Bitte die vCard neu laden.");
|
||||
var d=new Delivery(p.home(),p.workplace(),headquarters,contact,p.document().filename(),p.recipients(),p.from(),LocalDateTime.now(ZoneId.of("Europe/Berlin")).toString(),"STARTED");
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","ngvcardmail","gdc_obj_id",u.userId(),"gdc_gen_fieldname",token,"gdc_context",context(u,headquarters,contact),"gdc_content",support.encode(d)));
|
||||
support.audit(u,0,0,0,203,"vCard-Versand für Kontakt "+contact+" vorgemerkt",token);return true;
|
||||
});
|
||||
if(!claimed)return result(token,stored(u,headquarters,contact,token));
|
||||
String state="SENT";try{transport.sendVcard(p.from(),p.recipients(),p.document().filename(),p.document().content().getBytes(StandardCharsets.UTF_8));}catch(RuntimeException e){state="UNCERTAIN";}
|
||||
var d=stored(u,headquarters,contact,token);var done=new Delivery(d.home(),d.workplace(),d.headquarters(),d.contact(),d.filename(),d.recipients(),d.from(),d.startedAt(),state);
|
||||
db.writeTransaction(()->db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_obj_type='ngvcardmail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",support.encode(done),u.userId(),token));return result(token,done);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** The stored online indicator from the current courier_special.php. */
|
||||
@RestController
|
||||
@RequestMapping("/api/master-data/couriers/{courier}/availability")
|
||||
class CourierAvailability {
|
||||
final DatabaseService db; final MasterData masters;
|
||||
CourierAvailability(DatabaseService db,MasterData masters){this.db=db;this.masters=masters;}
|
||||
@GetMapping Object get(Authentication a,@PathVariable long courier){return view(ApiController.user(a),courier);}
|
||||
Map<String,Object> view(LegacyUser actor,long courier){actor.require(1);return db.readTransaction(()->{
|
||||
var u=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());u.require(1);
|
||||
var row=masters.load(u,"couriers",courier,false);boolean online=id(row,"cr_available")==1;
|
||||
String vehicle="",warning="";
|
||||
if(online){
|
||||
var types=db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",id(row,"vht_id"));
|
||||
if(types.size()==1&&!text(types.getFirst(),"mt_value").isBlank())vehicle=text(types.getFirst(),"mt_value");
|
||||
else warning="Der aktuelle Fahrzeugtyp ist nicht eindeutig hinterlegt.";
|
||||
}
|
||||
return fields("online",online,"vehicleType",vehicle,"warning",warning);
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** courier/brs_jb_list.php: customer-granted orders in status 9, independent of own assignments. */
|
||||
@RestController
|
||||
@RequestMapping("/api/courier-portal/bourse")
|
||||
class CourierBourse {
|
||||
record Query(LocalDate from,LocalDate to,Long numberFrom,Long numberTo,List<Long> headquarters,long vehicleType,Map<String,String> filters,String sort,boolean descending,int page){}
|
||||
record Detail(long job){}
|
||||
record Context(long courier,List<Long> headquarters,List<OrderListCells.Column> columns,int limit,boolean yesterday,boolean numberMode,boolean refreshMode,String warning,Map<String,String> expressions){}
|
||||
static final String HQ_SQL="SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?";
|
||||
static final List<String> DEFAULT=List.of("jb_id","business_volume","jb_cr_price","tr_comp","tr_comp2","streethsno","jb_tourzipcodes","vht_value","jb_ordertime");
|
||||
static final Map<String,String> LABELS=Map.ofEntries(Map.entry("jb_id","Nr."),Map.entry("business_volume","Preis"),Map.entry("tr_comp","Firma"),Map.entry("tr_comp2","Zusatz"),Map.entry("streethsno","Startadresse"),Map.entry("jb_tourzipcodes","PLZn"),Map.entry("jb_tourcities","Orte"),Map.entry("vht_value","Fahrzeug"),Map.entry("jb_ordertime","Auftragszeit"),Map.entry("cr_sid","Kurier"),Map.entry("jb_latesttaketime","Spät. Annahmezeit"),Map.entry("jb_taketime","Annahme"),Map.entry("jb_cr_price","Fuhrlohn"),Map.entry("ad_zipcode","Start-PLZ"),Map.entry("jb_lastzipcode","Ziel-PLZ"),Map.entry("jb_dispoinfo","Dispo-Info"));
|
||||
static final Map<String,String> FILTERS=Map.of("street","a.ad_street","zip","a.ad_zipcode","city","a.ad_city","company","t.tr_comp","person","t.tr_person","remark","t.tr_remark");
|
||||
final DatabaseService db;
|
||||
CourierBourse(DatabaseService db){this.db=db;}
|
||||
@GetMapping Object options(Authentication a){return options(ApiController.user(a));}
|
||||
@PostMapping("/query") Object query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);}
|
||||
@PostMapping("/detail") Object detail(Authentication a,@RequestBody Detail q){return detail(ApiController.user(a),q);}
|
||||
Context context(LegacyUser u){
|
||||
var account=CourierPortalScope.verify(db,u);var catalog=new OrderEntryCatalog(db);long hq=u.headquartersId();if(!catalog.enabled(hq,"SYSTEM_BOURSE_MODE"))throw CourierPortal.denied();
|
||||
var headquarters=db.queryForList(HQ_SQL,mandator(db,u)).stream().map(r->id(r,"hq_id")).toList();
|
||||
String configured=catalog.parameter(hq,"MASK_BRSJB_LIST_COLS");var keys=configured.isBlank()?DEFAULT:Arrays.stream(configured.split(",")).map(entryValue -> entryValue.strip()).distinct().filter(LABELS::containsKey).toList();boolean unknown=!configured.isBlank()&&Arrays.stream(configured.split(",")).map(entryValue -> entryValue.strip()).anyMatch(k->!LABELS.containsKey(k));if(keys.isEmpty())keys=DEFAULT;
|
||||
int limit=100;try{int n=Integer.parseInt(catalog.parameter(hq,"MASK_BRSJB_LIST_BROWSE_MAX"));if(n>=100&&n<=1000)limit=n;}catch(NumberFormatException ignored){}
|
||||
var params=db.queryForList("SELECT par_key,par_value,hq_id,emp_id FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN ('LATEST_TAKETIME_IN_MINUTES')",hq);
|
||||
var expr=OrderListCells.expressions(params,u,false,false,9,null);expr.put("business_volume","jb.jb_totalprice");expr.put("jb_latesttaketime",expr.get("jb_latesttaketime").replace("tt.hq_id=jb.hq_id_dispo","tt.hq_id=jb.hq_id"));
|
||||
return new Context(id(account,"cr_id"),headquarters,keys.stream().map(k->new OrderListCells.Column(k,LABELS.get(k),OrderListCells.type(k))).toList(),limit,catalog.enabled(hq,"MASK_BRSJB_LIST_STARTDATE_YESTERDAY"),catalog.enabled(hq,"MASK_BRSJB_LIST_MODE_JOB_NUM"),catalog.enabled(hq,"MASK_BRSJB_LIST_MODE_REFRESH"),unknown?"Unbekannte alte Spalten wurden ausgelassen.":"",expr);
|
||||
}
|
||||
Map<String,Object> options(LegacyUser u){return db.readTransaction(()->{var c=context(u);var hq=db.queryForList("SELECT hq_id AS id,hq_mnemonic AS label FROM headquarters WHERE hq_id IN ("+CourierPortal.placeholders(c.headquarters().size())+") ORDER BY hq_mnemonic,hq_id",c.headquarters().toArray());var types=db.queryForList("SELECT mt_sort AS id,MIN(mt_value) AS label FROM metatype WHERE mt_type='vehicletype' GROUP BY mt_sort ORDER BY mt_sort");return fields("headquarters",hq,"vehicleTypes",types,"columns",c.columns(),"limit",c.limit(),"from",LocalDate.now().minusDays(c.yesterday()?1:0),"to",LocalDate.now().plusDays(1),"copyNumber",c.numberMode(),"refreshToggle",c.refreshMode(),"warning",c.warning());});}
|
||||
static String membership(){return " FROM job jb WHERE jb.jb_status=9 AND EXISTS (SELECT 1 FROM metatype vt WHERE vt.mt_type='vehicletype' AND vt.mt_sort=jb.vht_id) AND EXISTS (SELECT 1 FROM tour ft JOIN address fa ON fa.ad_id=ft.ad_id WHERE ft.jb_id=jb.jb_id AND ft.tr_sort=1) AND EXISTS (SELECT 1 FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN customercourier rel ON rel.cs_id=cc.cs_id WHERE cc.csc_id=jb.csc_id_payer AND rel.cr_id=? AND rel.cscr_relation=1 AND cs.hq_id IN ("+HQ_SQL+")) AND jb.hq_id IN ("+HQ_SQL+")";}
|
||||
Map<String,Object> query(LegacyUser u,Query q){return db.readTransaction(()->{
|
||||
var c=context(u);if(q==null||q.page()<0||q.page()>9||q.headquarters()==null||q.headquarters().size()>500||!c.headquarters().containsAll(q.headquarters())||q.filters()==null||!FILTERS.keySet().containsAll(q.filters().keySet())||q.vehicleType()<0||q.sort()==null||!q.sort().equals("jb_ordertime")&&c.columns().stream().noneMatch(col->col.key().equals(q.sort())))throw bad("Ungültige Börsensuche.");
|
||||
boolean numbers=q.numberFrom()!=null||q.numberTo()!=null;if(numbers&&(q.numberFrom()!=null&&q.numberFrom()<1||q.numberTo()!=null&&q.numberTo()<1||q.numberFrom()!=null&&q.numberTo()!=null&&q.numberFrom()>q.numberTo()))throw bad("Ungültiger Auftragsnummernbereich.");
|
||||
if(!numbers&&(q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.from().isAfter(q.to())||q.from().plusYears(1).isBefore(q.to())))throw bad("Bitte einen Zeitraum von höchstens einem Jahr wählen.");
|
||||
if(q.headquarters().isEmpty())return fields("rows",List.of(),"columns",c.columns(),"page",q.page(),"limit",c.limit(),"hasNext",false,"warning",c.warning());
|
||||
long md=mandator(db,u);var args=new ArrayList<Object>(List.of(c.courier(),md,md));var sql=new StringBuilder(membership());sql.append(" AND jb.hq_id IN (").append(CourierPortal.placeholders(q.headquarters().size())).append(')');args.addAll(q.headquarters());
|
||||
if(q.vehicleType()>0){if(db.queryForList("SELECT mt_id FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",q.vehicleType()).size()!=1)throw bad("Ungültiger Fahrzeugtyp.");sql.append(" AND jb.vht_id=?");args.add(q.vehicleType());}else sql.append(" AND jb.vht_id<=100");
|
||||
if(numbers){if(q.numberFrom()!=null){sql.append(" AND jb.jb_id>=?");args.add(q.numberFrom());}if(q.numberTo()!=null){sql.append(" AND jb.jb_id<=?");args.add(q.numberTo());}}else{sql.append(" AND jb.jb_ordertime>=? AND jb.jb_ordertime<?");args.add(q.from().atStartOfDay());args.add(q.to().plusDays(1).atStartOfDay());}
|
||||
for(String key:new TreeSet<>(q.filters().keySet())){String value=q.filters().get(key);if(value!=null&&!value.isBlank()){sql.append(" AND EXISTS (SELECT 1 FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=jb.jb_id AND ").append(FILTERS.get(key)).append(" LIKE ? ESCAPE '!')");args.add(EntryCostCenterSearch.term(value,false));}}
|
||||
var projection=new ArrayList<String>();for(var col:c.columns())projection.add(c.expressions().get(col.key())+" AS cell_"+col.key());
|
||||
args.add(c.limit()+1);args.add(q.page()*c.limit());var data=db.queryForList("SELECT jb.jb_id,"+String.join(",",projection)+sql+" ORDER BY "+c.expressions().get(q.sort())+(q.descending()?" DESC":" ASC")+",jb.jb_id LIMIT ? OFFSET ?",args.toArray());
|
||||
var rows=data.stream().limit(c.limit()).map(r->{var cells=new LinkedHashMap<String,Object>();for(var col:c.columns()){String k=col.key(),v=text(r,"cell_"+k);if(col.type().equals("date"))v=dateTime(r,"cell_"+k)==null?"":dateTime(r,"cell_"+k).toString();else if(k.equals("jb_tourzipcodes")||k.equals("jb_tourcities")){var parts=v.split("\\|",-1);int idx=k.equals("jb_tourcities")?1:0;v=parts.length>idx?parts[idx].replaceAll(";+$","").replace(';',','):"";}cells.put(k,v);}return fields("id",id(r,"jb_id"),"cells",cells);}).toList();return fields("rows",rows,"columns",c.columns(),"page",q.page(),"limit",c.limit(),"hasNext",data.size()>c.limit()&&q.page()<9,"warning",c.warning());
|
||||
});}
|
||||
Map<String,Object> detail(LegacyUser u,Detail q){return db.readTransaction(()->{var c=context(u);if(q==null||q.job()<1)throw bad("Auftrag fehlt.");long md=mandator(db,u);var jobs=db.queryForList("SELECT jb.jb_id,jb.jb_ordertime,jb.jb_taketime,jb.jb_totalprice,jb.jb_cr_price,jb.jb_dispoinfo,jb.vht_id"+membership()+" AND jb.jb_id=?",c.courier(),md,md,q.job());if(jobs.size()!=1)throw CourierPortal.denied();var job=jobs.getFirst();var stops=db.queryForList("SELECT t.tr_sort,t.tr_comp,t.tr_comp2,t.tr_hsno,t.tr_person,t.tr_remark,a.ad_street,a.ad_zipcode,a.ad_city FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",q.job());return fields("id",q.job(),"ordered",dateTime(job,"jb_ordertime"),"price",BillingService.money(decimal(job,"jb_totalprice")),"freight",BillingService.money(decimal(job,"jb_cr_price")),"information",text(job,"jb_dispoinfo"),"stops",stops.stream().map(s->fields("sequence",id(s,"tr_sort"),"company",text(s,"tr_comp"),"additional",text(s,"tr_comp2"),"address",(text(s,"ad_street")+" "+text(s,"tr_hsno")+", "+text(s,"ad_zipcode")+" "+text(s,"ad_city")).strip(),"person",text(s,"tr_person"),"remark",text(s,"tr_remark"))).toList());});}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** PHP's MASK_CR_MOBILE_PDA_EDIT and sysadmin.conf_prop association. */
|
||||
@RestController
|
||||
@RequestMapping("/api/master-data/couriers/{courier}/device-phone")
|
||||
class CourierDevicePhone {
|
||||
final DatabaseService db;final BusinessSupport support;final MasterData masters;final boolean enabled;final Clock clock;
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
CourierDevicePhone(DatabaseService db,BusinessSupport support,MasterData masters,@Value("${app.device-phone-sync.enabled:true}")boolean enabled){this(db,support,masters,enabled,Clock.systemUTC());}
|
||||
CourierDevicePhone(DatabaseService db,BusinessSupport support,MasterData masters,boolean enabled,Clock clock){this.db=db;this.support=support;this.masters=masters;this.enabled=enabled;this.clock=clock;}
|
||||
record Save(String requestId,String revision,long selected,String phone,boolean transfer){}
|
||||
record Retry(String requestId,String revision,long pending){}
|
||||
record Pending(long headquarters,long courier,long user,long actor,long employee,String request,DatabaseService.DevicePhoneChange change,int attempts,String error){}
|
||||
record State(Map<String,Object> courier,boolean editable,Map<String,Object> pending){}
|
||||
@GetMapping Object get(Authentication a,@PathVariable long courier){return view(ApiController.user(a),courier);}
|
||||
@PutMapping Object save(Authentication a,@PathVariable long courier,@RequestBody Save r){return save(ApiController.user(a),courier,r);}
|
||||
@PostMapping("/retry") Object retry(Authentication a,@PathVariable long courier,@RequestBody Retry r){return retry(ApiController.user(a),courier,r);}
|
||||
boolean editable(long hq){return new OrderEntryCatalog(db).parameter(hq,"MASK_CR_MOBILE_PDA_EDIT").equals("1");}
|
||||
Map<String,Object> pending(long courier,boolean lock){String sql="SELECT * FROM genericdatacontainer WHERE gdc_obj_type='ngphone' AND gdc_obj_id=? AND gdc_context<>'DONE' ORDER BY gdc_id";var rows=lock?db.lockRows(sql,courier):db.queryForList(sql,courier);if(rows.size()>1)throw bad("Mehrere offene Gerätetelefon-Vormerkungen. Bitte den Bestand prüfen.");return rows.isEmpty()?Map.of():rows.getFirst();}
|
||||
State state(LegacyUser u,long courier,boolean lock){masters.access(u,"couriers");var raw=masters.load(u,"couriers",courier,lock);var cr=fields("cr_id",courier,"hq_id",u.headquartersId(),"usr_id",raw.get("usr_id"),"cr_eid",raw.get("cr_eid"),"cmp_comp",raw.get("cmp_comp"),"cr_mobile_pda",text(raw,"cr_mobile_pda"));var person=one(db.queryForList("SELECT usr_id,hq_id,usr_type FROM user WHERE usr_id=?",id(cr,"usr_id")));if(id(person,"hq_id")!=u.headquartersId()||id(person,"usr_type")!=3||db.queryForList("SELECT cr_id FROM courier WHERE usr_id=?",id(cr,"usr_id")).size()!=1)throw bad("Das Fahrerkonto ist nicht eindeutig zugeordnet.");return new State(cr,editable(u.headquartersId()),pending(courier,lock));}
|
||||
Pending decode(Map<String,Object> row){try{var p=support.json.readValue(text(row,"gdc_content"),Pending.class);if(p.courier()!=id(row,"gdc_obj_id")||p.headquarters()<=0||p.user()<=0||p.change().user()!=p.user()||p.change().headquarters()!=p.headquarters()||!p.request().equals(text(row,"gdc_gen_fieldname")))throw new IllegalArgumentException();return p;}catch(Exception e){throw bad("Die Gerätetelefon-Vormerkung ist ungültig.");}}
|
||||
List<Map<String,Object>> options(State s){var rows=db.devicePhoneOptions(MetaObjectRegistration.instance(db),id(s.courier(),"hq_id"),id(s.courier(),"usr_id"));if(rows.size()>500||rows.stream().anyMatch(r->text(r,"cp_secval").length()>30)||rows.stream().map(r->text(r,"cp_secval")).distinct().count()!=rows.size())throw bad("Die Gerätetelefon-Auswahl ist zu groß oder mehrdeutig.");return rows;}
|
||||
List<Map<String,Object>> localOwners(long courier,List<String> phones,boolean lock){if(phones.isEmpty())return List.of();var args=new ArrayList<Object>();args.add(courier);args.addAll(phones);String sql="SELECT cr.cr_id,cr.hq_id,cr.usr_id,cr.cr_mobile_pda,cr.cr_eid,cmp.cmp_comp,h.hq_mnemonic FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id JOIN headquarters h ON h.hq_id=cr.hq_id WHERE cr.cr_id<>? AND cr.cr_mobile_pda IN ("+CourierPortal.placeholders(phones.size())+") ORDER BY cr.cr_id";return lock?db.lockRows(sql,args.toArray()):db.queryForList(sql,args.toArray());}
|
||||
Set<Long> allowed(LegacyUser u){return new HashSet<>(MasterDirectoryScope.access(db,u,1).branches().stream().map(r->id(r,"id")).toList());}
|
||||
List<Map<String,Object>> displayOptions(LegacyUser u,long courier,State s){var choices=options(s);if(choices.isEmpty())return List.of();var owners=localOwners(courier,choices.stream().map(r->text(r,"cp_secval")).toList(),false);var permitted=allowed(u);return choices.stream().<Map<String,Object>>map(r->{String phone=text(r,"cp_secval");var matches=owners.stream().filter(x->phone.equals(text(x,"cr_mobile_pda"))).toList();boolean available=matches.stream().allMatch(x->permitted.contains(id(x,"hq_id")));return fields("id",id(r,"cp_id"),"label",phone,"available",available,"requiresTransfer",!matches.isEmpty(),"assignment",available?String.join(", ",matches.stream().map(x->CourierDriverProfile.label(x)+" · "+text(x,"hq_mnemonic")).toList()):"Lokal in einer nicht freigegebenen Niederlassung zugeordnet");}).toList();}
|
||||
Map<String,Object> view(LegacyUser u,long courier){var s=db.readTransaction(()->state(u,courier,false));var result=fields("id",courier,"label",CourierDriverProfile.label(s.courier()),"phone",text(s.courier(),"cr_mobile_pda"),"editable",s.editable(),"revision",revision(s),"enabled",enabled,"options",List.of(),"pending",null,"optionsError","");
|
||||
if(!s.pending().isEmpty()){var p=decode(s.pending());String raw=text(s.pending(),"gdc_context"),status=raw.startsWith("SENDING:")?"SENDING":raw.startsWith("RETRY:")?"RETRY":raw;result.put("pending",fields("id",id(s.pending(),"gdc_id"),"phone",p.change().desired(),"state",status,"attempts",p.attempts(),"message",p.error(),"revision",revision(s.pending())));}
|
||||
else if(!s.editable())try{result.put("options",displayOptions(u,courier,s));}catch(RuntimeException e){result.put("optionsError","Die zentrale Gerätetelefon-Auswahl ist derzeit nicht verfügbar. Bitte erneut laden.");}
|
||||
return result;
|
||||
}
|
||||
Map<String,Object> save(LegacyUser u,long courier,Save r){masters.access(u,"couriers");if(r==null||r.phone()==null||r.selected()<0)throw bad("Gerätetelefon fehlt.");BusinessSupport.token(r.requestId());String phone=EmployeeAdministration.optional(r.phone(),30,"Gerätetelefon");if(!phone.matches("[+0-9() /-]*"))throw bad("Das Gerätetelefon enthält ungültige Zeichen.");Object intent=List.of("COURIER_DEVICE_PHONE",courier,r);
|
||||
var initial=db.readTransaction(()->{if(support.receipt(u,r.requestId(),intent)!=null)return null;var s=state(u,courier,false);unchanged(s,r.revision());if(!s.pending().isEmpty())throw bad("Bitte zuerst den offenen Geräteabgleich abschließen.");return s;});if(initial==null)return view(u,courier);
|
||||
DatabaseService.DevicePhoneChange change;
|
||||
if(initial.editable()){if(r.selected()!=0)throw bad("Die freie Telefonpflege benötigt keine Geräteauswahl.");change=null;}
|
||||
else{if(!enabled)throw bad("Der zentrale Geräteabgleich ist ausgeschaltet.");var choices=options(initial);if(r.selected()==0&&!phone.isBlank()||r.selected()>0&&choices.stream().noneMatch(x->id(x,"cp_id")==r.selected()&&text(x,"cp_secval").equals(phone)))throw bad("Bitte ein verfügbares Gerätetelefon wählen.");change=new DatabaseService.DevicePhoneChange(MetaObjectRegistration.instance(db),u.headquartersId(),id(initial.courier(),"usr_id"),r.selected(),text(initial.courier(),"cr_mobile_pda"),phone,db.devicePhoneSourceIdentity());}
|
||||
support.transactional("headquarters","courier","genericdatacontainer");db.writeTransaction(()->{one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId()));if(support.receipt(u,r.requestId(),intent)!=null)return null;var s=state(u,courier,true);unchanged(s,r.revision());
|
||||
var displaced=phone.isBlank()?List.<Map<String,Object>>of():localOwners(courier,List.of(phone),true);
|
||||
if(!displaced.isEmpty()){
|
||||
var branches=allowed(u);if(displaced.stream().anyMatch(x->!branches.contains(id(x,"hq_id"))))throw bad("Diese Nummer ist einem Transporteur außerhalb der freigegebenen Niederlassungen zugeordnet.");
|
||||
if(!r.transfer())throw bad("Bitte das Lösen der bisherigen lokalen Telefonzuordnung bei "+String.join(", ",displaced.stream().map(CourierDriverProfile::label).toList())+" ausdrücklich bestätigen.");
|
||||
for(var owner:displaced){if(!pending(id(owner,"cr_id"),true).isEmpty())throw bad("Für einen bisherigen Transporteur läuft bereits ein Geräteabgleich.");db.update("UPDATE courier SET cr_mobile_pda='' WHERE cr_id=?",id(owner,"cr_id"));var actor=new LegacyUser(u.userId(),u.employeeId(),id(owner,"hq_id"),u.username(),u.rights());support.audit(actor,0,0,id(owner,"cr_id"),117,"MSISDN_OLD="+phone+"|MSISDN_NEW=|TRANSFER_TO="+courier,r.requestId());}
|
||||
}
|
||||
if(change==null){db.update("UPDATE courier SET cr_mobile_pda=? WHERE cr_id=?",phone,courier);support.audit(u,0,0,courier,117,"MSISDN_OLD="+text(s.courier(),"cr_mobile_pda")+"|MSISDN_NEW="+phone,r.requestId());}
|
||||
else{if(change.instance()!=MetaObjectRegistration.instance(db))throw bad("Die Datenbankinstanz wurde geändert. Bitte neu laden.");var p=new Pending(u.headquartersId(),courier,change.user(),u.userId(),u.employeeId(),r.requestId(),change,0,"");db.insert("genericdatacontainer",fields("gdc_obj_type","ngphone","gdc_obj_id",courier,"gdc_gen_fieldname",r.requestId(),"gdc_context","PENDING","gdc_content",support.encode(p)));}
|
||||
support.receipt(u,r.requestId(),intent,courier);return null;});return view(u,courier);
|
||||
}
|
||||
boolean current(Pending p){var rows=db.queryForList("SELECT usr_id,hq_id,cr_mobile_pda FROM courier WHERE cr_id=?",p.courier());return rows.size()==1&&id(rows.getFirst(),"usr_id")==p.user()&&id(rows.getFirst(),"hq_id")==p.headquarters()&&text(rows.getFirst(),"cr_mobile_pda").equals(p.change().previous())&&!editable(p.headquarters())&&MetaObjectRegistration.instance(db)==p.change().instance();}
|
||||
boolean active(long row,Pending p){var rows=db.queryForList("SELECT gdc_context FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type='ngphone'",row);return rows.size()==1&&text(rows.getFirst(),"gdc_context").startsWith("SENDING:")&¤t(p);}
|
||||
void update(long row,Pending p,String context){db.update("UPDATE genericdatacontainer SET gdc_context=?,gdc_content=? WHERE gdc_id=?",context,support.encode(p),row);}
|
||||
void finish(long row,Pending p){support.transactional("courier","genericdatacontainer");db.writeTransaction(()->{var r=one(db.lockRows("SELECT * FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type='ngphone'",row));if(text(r,"gdc_context").equals("DONE"))return null;if(!text(r,"gdc_context").startsWith("SENDING:"))throw new IllegalStateException("Der Geräteabgleich ist nicht mehr aktiv.");db.lockRows("SELECT cr_id FROM courier WHERE cr_id=?",p.courier());if(!current(p))throw new IllegalStateException("Die lokale Zuordnung wurde zwischenzeitlich geändert.");db.update("UPDATE courier SET cr_mobile_pda=? WHERE cr_id=?",p.change().desired(),p.courier());var actor=new LegacyUser(p.actor(),p.employee(),p.headquarters(),"Geräteabgleich","1".repeat(70));support.audit(actor,0,0,p.courier(),117,"MSISDN_OLD="+p.change().previous()+"|MSISDN_NEW="+p.change().desired(),p.request());update(row,p,"DONE");return null;});}
|
||||
String process(long row){if(!enabled)return "DISABLED";support.transactional("genericdatacontainer");var p=db.writeTransaction(()->{var rows=db.lockRows("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='ngphone' AND gdc_id=?",row);if(rows.isEmpty())return null;var r=rows.getFirst();String status=text(r,"gdc_context");if(!status.equals("PENDING")&&!(status.startsWith("RETRY:")||status.startsWith("SENDING:"))||!status.equals("PENDING")&&status.compareTo(MetaObjectRegistration.prefix(status.startsWith("RETRY:")?"RETRY":"SENDING",clock.instant().getEpochSecond()+1))>=0)return null;var prior=decode(r);var next=new Pending(prior.headquarters(),prior.courier(),prior.user(),prior.actor(),prior.employee(),prior.request(),prior.change(),Math.min(100000,prior.attempts()+1),"");update(row,next,MetaObjectRegistration.prefix("SENDING",clock.instant().plusSeconds(60).getEpochSecond()));return next;});if(p==null)return "SKIPPED";
|
||||
String result="DONE",error="";try{if(!current(p))throw new IllegalStateException();db.applyDevicePhone(p.change(),()->active(row,p));db.confirmDevicePhone(p.change(),()->finish(row,p));}
|
||||
catch(IllegalStateException|IllegalArgumentException e){result="BLOCKED";error="Die lokale oder zentrale Zuordnung wurde geändert oder ist widersprüchlich. Bitte den Bestand prüfen.";}
|
||||
catch(RuntimeException e){result="RETRY";error="Der Geräteabgleich konnte noch nicht bestätigt werden. Der gespeicherte Vorgang wird erneut geprüft.";}
|
||||
if(!result.equals("DONE")){String state=result,message=error;db.writeTransaction(()->{var rows=db.lockRows("SELECT gdc_context FROM genericdatacontainer WHERE gdc_id=?",row);if(rows.size()==1&&text(rows.getFirst(),"gdc_context").startsWith("SENDING:"))update(row,new Pending(p.headquarters(),p.courier(),p.user(),p.actor(),p.employee(),p.request(),p.change(),p.attempts(),message),state.equals("RETRY")?MetaObjectRegistration.prefix("RETRY",clock.instant().plusSeconds(Math.min(3600,60L*(1L<<Math.min(6,p.attempts()-1)))).getEpochSecond()):state);return null;});}return result;
|
||||
}
|
||||
Map<String,Object> retry(LegacyUser u,long courier,Retry r){masters.access(u,"couriers");if(!enabled||r==null)throw bad("Geräteabgleich ist nicht verfügbar.");BusinessSupport.token(r.requestId());support.transactional("headquarters","genericdatacontainer");Object intent=List.of("COURIER_DEVICE_RETRY",courier,r);db.writeTransaction(()->{one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId()));if(support.receipt(u,r.requestId(),intent)!=null)return null;var s=state(u,courier,true);if(s.pending().isEmpty()||id(s.pending(),"gdc_id")!=r.pending())throw bad("Die Vormerkung ist nicht mehr offen.");unchanged(s.pending(),r.revision());String status=text(s.pending(),"gdc_context");if(!status.equals("BLOCKED")&&!status.startsWith("RETRY:"))throw bad("Der Geräteabgleich läuft bereits.");db.update("UPDATE genericdatacontainer SET gdc_context='PENDING' WHERE gdc_id=?",r.pending());support.receipt(u,r.requestId(),intent,courier);return null;});return view(u,courier);}
|
||||
@Scheduled(initialDelay=60000,fixedDelay=15000) public synchronized void run(){if(!enabled)return;long limit=clock.instant().getEpochSecond()+1;for(var r:db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngphone' AND (gdc_context='PENDING' OR (gdc_context LIKE 'RETRY:%' AND gdc_context<?) OR (gdc_context LIKE 'SENDING:%' AND gdc_context<?)) ORDER BY gdc_id LIMIT 20",MetaObjectRegistration.prefix("RETRY",limit),MetaObjectRegistration.prefix("SENDING",limit)))try{process(id(r,"gdc_id"));}catch(RuntimeException ignored){/* Durable state remains available for recovery. */}}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** The PHP courier FDS scope: one login courier, child-driver vehicle SIDs, released days and assigned jobs. */
|
||||
@RestController
|
||||
@RequestMapping("/api/courier-portal/disposition")
|
||||
class CourierDisposition {
|
||||
final DatabaseService db;
|
||||
final BusinessSupport support;
|
||||
CourierDisposition(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
|
||||
@GetMapping Map<String,Object> get(Authentication authentication,@RequestParam(required=false) LocalDate day,@RequestParam(defaultValue="0") int offset,@RequestParam(defaultValue="0") long vehicle){if(vehicle<0)throw bad("Ungültiges Fahrzeug.");return view(ApiController.user(authentication),day,offset,vehicle);}
|
||||
static boolean enabled(DatabaseService db,LegacyUser u,long courier){
|
||||
var rows=db.queryForList("SELECT cr_disposition FROM courier WHERE cr_id=? AND usr_id=? AND hq_id=?",courier,u.userId(),u.originalHeadquartersId());
|
||||
return rows.size()==1&&id(rows.getFirst(),"cr_disposition")==1;
|
||||
}
|
||||
long courier(LegacyUser u){var account=CourierPortalScope.verify(db,u);long courier=id(account,"cr_id");if(!enabled(db,u,courier))throw CourierPortal.denied();return courier;}
|
||||
Map<String,Object> view(LegacyUser u,LocalDate requested,int offset){return view(u,requested,offset,0);}
|
||||
Map<String,Object> view(LegacyUser u,LocalDate requested,int offset,long selected){return db.readTransaction(()->{
|
||||
long courier=courier(u),hq=u.originalHeadquartersId();
|
||||
if(offset<0||offset>500||offset%25!=0)throw bad("Ungültige Fahrzeugseite.");
|
||||
var disposition=new VehicleDisposition(db,support);var today=disposition.now().toLocalDate();var day=requested==null?today:requested;VehicleDisposition.date(day);
|
||||
long mandator=mandator(db,u);
|
||||
var vehicles=db.queryForList("SELECT v.crvh_id AS id,v.crvh_sid AS sid,v.crvh_vh_sign AS registration,c.hq_id AS headquarters FROM courier c JOIN couriervehicle v ON v.crvh_sid=c.cr_sid WHERE c.cr_id_parent=? AND (v.cr_id=? OR v.cr_id=c.cr_id) AND TRIM(v.crvh_sid)<>'' AND COALESCE(v.crvh_modify_status,0)<>3 AND c.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND EXISTS (SELECT 1 FROM vehicleavailability va WHERE va.crvh_id=v.crvh_id AND va.vha_day=? AND (va.vha_blocked & 2)=2) ORDER BY v.crvh_sid,v.crvh_id LIMIT 501",courier,courier,mandator,day);
|
||||
if(vehicles.stream().map(v->id(v,"id")).distinct().count()!=vehicles.size()||vehicles.stream().map(v->text(v,"sid")).distinct().count()!=vehicles.size())throw bad("Die freigegebenen Fahrzeugkennungen sind nicht eindeutig.");
|
||||
if(vehicles.size()>500)throw bad("Mehr als 500 Fahrzeuge. Bitte die Fahrzeugzuordnungen klären.");
|
||||
var shown=selected>0?vehicles.stream().filter(v->id(v,"id")==selected).toList():vehicles.subList(Math.min(offset,vehicles.size()),Math.min(offset+25,vehicles.size()));
|
||||
if(selected>0&&shown.isEmpty())throw CourierPortal.denied();int units=disposition.units();var result=new ArrayList<Map<String,Object>>();
|
||||
if(!shown.isEmpty()){
|
||||
var ids=shown.stream().map(v->id(v,"id")).toList();String marks=CourierPortal.placeholders(ids.size());
|
||||
var dayArgs=new ArrayList<Object>(ids);dayArgs.add(day);
|
||||
var availability=db.queryForList("SELECT crvh_id,vha_id,vha_weight,vha_position,vha_blocked,vha_lock,vha_remark FROM vehicleavailability WHERE crvh_id IN ("+marks+") AND vha_day=? ORDER BY crvh_id,vha_id",dayArgs.toArray());
|
||||
var slotArgs=new ArrayList<Object>(ids);slotArgs.add(day.toString());slotArgs.add(day.plusDays(1).toString());
|
||||
String slotWhere="crvh_id IN ("+marks+") AND vhd_timeslot>=? AND vhd_timeslot<?";
|
||||
var slots=db.queryForList("SELECT crvh_id,vhd_timeslot,jb_id FROM vehicledisposition WHERE "+slotWhere+" ORDER BY crvh_id,vhd_timeslot LIMIT 36001",slotArgs.toArray());
|
||||
if(slots.size()>36000)throw bad("Die Fahrzeugbelegung überschreitet das vollständige Tagesraster.");
|
||||
var jobArgs=new ArrayList<Object>(List.of(mandator));jobArgs.addAll(slotArgs);jobArgs.add(day.atStartOfDay());jobArgs.add(day.plusDays(1).atStartOfDay());jobArgs.addAll(ids);jobArgs.add(courier);jobArgs.add(courier);
|
||||
String visible=new OrderEntryCatalog(db).enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")?"":" AND EXISTS (SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=j.jb_id AND g.gdc_gen_fieldname='hide' AND g.gdc_content='1')";
|
||||
var jobs=db.queryForList("SELECT j.jb_id,j.jb_status,j.jb_offer,j.jb_ordertime,j.jb_timeunits,CASE WHEN j.jb_status=1 THEN (SELECT c.cr_sid FROM courier c WHERE c.cr_id=j.cr_id) ELSE j.cr_sid END AS actual_sid FROM job j WHERE j.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND j.jb_timeunits>0 AND COALESCE(j.jb_storno,0) IN (0,1,3) AND (j.jb_id IN (SELECT jb_id FROM vehicledisposition WHERE "+slotWhere+" AND jb_id>0) OR (j.jb_ordertime>=? AND j.jb_ordertime<? AND EXISTS (SELECT 1 FROM couriervehicle cv WHERE cv.crvh_id IN ("+marks+") AND ((j.jb_status=2 AND j.cr_sid=cv.crvh_sid) OR (j.jb_status=1 AND EXISTS (SELECT 1 FROM courier ac WHERE ac.cr_id=j.cr_id AND ac.cr_sid=cv.crvh_sid)))))) AND (j.cr_id=? OR (j.jb_status=0 AND COALESCE(j.cr_id_order,0)<>0 AND EXISTS (SELECT 1 FROM vehicledisposition d JOIN couriervehicle v ON v.crvh_id=d.crvh_id WHERE d.jb_id=j.jb_id AND v.cr_id=?)))"+visible+" ORDER BY j.jb_id LIMIT 5001",jobArgs.toArray());
|
||||
if(jobs.size()>5000)throw bad("Mehr als 5000 Aufträge auf dieser Fahrzeugseite. Bitte die Zuordnungen klären.");
|
||||
var known=new HashSet<>(jobs.stream().map(j->id(j,"jb_id")).toList());
|
||||
for(var vehicle:shown){
|
||||
long id=id(vehicle,"id");var a=availability.stream().filter(r->id(r,"crvh_id")==id).toList();var cells=slots.stream().filter(r->id(r,"crvh_id")==id).toList();
|
||||
if(a.size()!=1||(id(a.getFirst(),"vha_blocked")&2)!=2)throw bad("Die Tagesfreigabe ist nicht eindeutig. Bitte die Belegung neu laden.");
|
||||
String warning="";List<VehicleDisposition.Segment> segments=List.of();
|
||||
try{segments=VehicleDisposition.segments(cells,day,units,known);if(cells.size()!=24*units)warning="Das Tagesraster ist unvollständig.";}catch(org.springframework.web.server.ResponseStatusException invalid){warning=invalid.getReason();}
|
||||
var row=new LinkedHashMap<>(vehicle);var daily=a.size()==1?a.getFirst():Map.<String,Object>of();
|
||||
var visibleIds=new HashSet<>(segments.stream().map(entryValue -> entryValue.job()).filter(j->j>0).toList());
|
||||
var unreserved=new ArrayList<VehicleDisposition.Segment>();
|
||||
for(var j:jobs){long job=id(j,"jb_id");var ordered=dateTime(j,"jb_ordertime");
|
||||
if(!visibleIds.contains(job)&&Set.of(1L,2L).contains(id(j,"jb_status"))&&ordered!=null&&ordered.toLocalDate().equals(day)&&text(j,"actual_sid").equals(text(vehicle,"sid"))){
|
||||
int start=ordered.getHour()*units+ordered.getMinute()*units/60;int end=(int)Math.min(24L*units,start+id(j,"jb_timeunits"));
|
||||
unreserved.add(VehicleDisposition.segment(start,end,job,units,known));visibleIds.add(job);
|
||||
}
|
||||
}
|
||||
row.putAll(fields("weight",a.size()==1?decimal(daily,"vha_weight"):null,"positions",a.size()==1?id(daily,"vha_position"):null,"blocked",id(daily,"vha_blocked"),"locked",id(daily,"vha_lock")!=0,"remark",text(daily,"vha_remark"),"warning",warning,"segments",segments,"unreserved",unreserved,"jobs",jobs.stream().filter(j->visibleIds.contains(id(j,"jb_id"))).map(j->fields("id",id(j,"jb_id"),"status",id(j,"jb_status"),"offer",id(j,"jb_offer")!=0)).toList()));result.add(row);
|
||||
}
|
||||
}
|
||||
if(courier(u)!=courier)throw CourierPortal.denied();
|
||||
return fields("day",day.toString(),"today",today.toString(),"headquarters",hq,"rows",result,"total",vehicles.size(),"offset",offset,"limit",25,"units",units,"defaultScale",new VehicleTimelines(db,support).defaultScale(hq),"confirmDate",new DispositionTimelineOptions(db).confirmDate(hq),"displayRange",new DispositionTimelineRange(db).read(hq,new VehicleDispositionCards(db,support).courierCustomer(u,courier)));
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** PHP single/rollcard handover under the courier's existing released-day boundary. */
|
||||
@RestController
|
||||
@RequestMapping("/api/courier-portal/disposition/handover")
|
||||
class CourierDispositionHandover {
|
||||
record Command(String requestId,long vehicle,LocalDate day,long job,String action,String revision,String planRevision,boolean confirmed){}
|
||||
record State(long courier,long target,String sid,String action,LocalDateTime now,List<Map<String,Object>> jobs,List<String> blockers,List<String> warnings,String revision){}
|
||||
final DatabaseService db;final BusinessSupport support;final CourierDisposition board;
|
||||
CourierDispositionHandover(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;board=new CourierDisposition(db,support);}
|
||||
@GetMapping Map<String,Object> get(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day,@RequestParam(defaultValue="0") long job){return view(ApiController.user(a),vehicle,day,job);}
|
||||
@PostMapping("/preview") Map<String,Object> preview(Authentication a,@RequestBody Command c){return preview(ApiController.user(a),c);}
|
||||
@PostMapping Map<String,Object> post(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);}
|
||||
void validate(LegacyUser u,Command c){board.courier(u);if(c==null||c.vehicle()<=0||c.job()<0||!Set.of("SEND","RECALL","ROLLCARD").contains(Objects.toString(c.action(),"")))throw bad("Ungültige Übergabe.");VehicleDisposition.date(c.day());BusinessSupport.token(c.requestId());if((c.job()==0)!=c.action().equals("ROLLCARD"))throw bad("Auftragsauswahl und Aktion passen nicht zusammen.");}
|
||||
@SuppressWarnings("unchecked") State inspect(LegacyUser u,long vehicle,LocalDate day,long selected){
|
||||
long courier=board.courier(u);if(vehicle<=0||selected<0)throw bad("Ungültige Fahrzeug- oder Auftragsauswahl.");var overview=board.view(u,day,0,vehicle);var row=((List<Map<String,Object>>)overview.get("rows")).getFirst();var visible=((List<Map<String,Object>>)row.get("jobs")).stream().map(j->id(j,"id")).collect(java.util.stream.Collectors.toSet());
|
||||
var reserved=db.queryForList("SELECT DISTINCT jb_id FROM vehicledisposition WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslot<? AND jb_id>0 ORDER BY jb_id LIMIT 301",vehicle,day.toString(),day.plusDays(1).toString());var ids=selected>0?List.of(selected):reserved.stream().map(j->id(j,"jb_id")).toList();if(selected>0&&!visible.contains(selected))throw CourierPortal.denied();
|
||||
var blockers=new ArrayList<String>();var warnings=new ArrayList<String>();var evidence=new ArrayList<Object>(List.of(courier,overview,reserved));var now=new VehicleDisposition(db,support).now();evidence.add(now.toLocalDate());
|
||||
if(ids.size()>300)throw bad("Mehr als 300 reservierte Aufträge.");if(selected==0&&!visible.containsAll(ids))blockers.add("Die Rollkarte enthält nicht für Ihren Zugang freigegebene Belegungen. Eine gemeinsame Übergabe ist nicht möglich.");
|
||||
var allowed=ids.stream().filter(visible::contains).toList();var jobs=allowed.isEmpty()?List.<Map<String,Object>>of():db.queryForList("SELECT * FROM job WHERE jb_id IN ("+CourierPortal.placeholders(allowed.size())+") ORDER BY jb_id",allowed.toArray());evidence.add(jobs);if(jobs.size()!=allowed.size())throw VehicleDisposition.conflict();
|
||||
String action=selected==0?"ROLLCARD":id(jobs.getFirst(),"jb_status")==1?"RECALL":"SEND";boolean sending=!action.equals("RECALL");String sid="";long target=0;
|
||||
if(day.isBefore(now.toLocalDate()))blockers.add("Vergangene Fahrzeugtage können nicht geändert werden.");if(Boolean.TRUE.equals(row.get("locked")))blockers.add("Der Fahrzeugtag wird gerade verarbeitet.");if((id(row,"blocked")&1)!=0)blockers.add("Der Fahrzeugtag ist gesperrt.");
|
||||
var modes=db.queryForList("SELECT par_id,md_id,par_value FROM parameter WHERE par_key='GLOBAL_USE_RELATED_CUSTOMER' AND hq_id=0 AND emp_id=0 ORDER BY par_id");evidence.add(modes);boolean valid=modes.isEmpty()||modes.size()==1&&id(modes.getFirst(),"md_id")==0&&Set.of("0","1").contains(text(modes.getFirst(),"par_value"));boolean related=valid&&!modes.isEmpty()&&text(modes.getFirst(),"par_value").equals("1");
|
||||
if(sending){
|
||||
if(!valid)blockers.add("Der globale FDS-Betriebsmodus ist nicht eindeutig.");
|
||||
if(selected==0&&!related)blockers.add("Die Classic-Sammelübergabe ist in dieser Konfiguration nicht verfügbar. Bitte Einzelaufträge prüfen.");
|
||||
if(related){sid=text(row,"sid");var devices=db.queryForList("SELECT cr_id,hq_id,cr_sid,cr_id_parent,cr_app_blocked FROM courier WHERE cr_sid=? ORDER BY cr_id",sid);evidence.add(devices);if(devices.size()!=1||id(devices.getFirst(),"cr_id_parent")!=courier)blockers.add("Die Fahrzeugkennung hat keinen eindeutigen zugeordneten Unterfahrer.");else {target=id(devices.getFirst(),"cr_id");if(id(devices.getFirst(),"cr_app_blocked")!=0)blockers.add("Der App-Zugang des Unterfahrers ist gesperrt.");}}
|
||||
}
|
||||
var changed=new ArrayList<Map<String,Object>>();for(var j:jobs){long job=id(j,"jb_id");if(selected==0&&id(j,"jb_offer")!=0)continue;changed.add(j);
|
||||
if(sending&&id(j,"jb_status")!=9)blockers.add("Auftrag #"+job+": Nur reservierte Aufträge (Status 9) können übergeben werden. Der Auftrag hat Status "+id(j,"jb_status")+".");
|
||||
if(!sending&&id(j,"jb_status")!=1)blockers.add("Auftrag #"+job+": Nur angenommene Aufträge können zurückgeholt werden.");if(id(j,"jb_offer")!=0)blockers.add("Auftrag #"+job+": Angebote können nicht übergeben werden.");
|
||||
try{new VehicleDisposition(db,support).writable(j,now,evidence);}catch(org.springframework.web.server.ResponseStatusException e){blockers.add("Auftrag #"+job+": "+e.getReason());}
|
||||
if(id(j,"hq_id_exec")>0&&id(j,"hq_id_exec")!=id(j,"hq_id")||id(j,"jb_longhaul")!=0||id(j,"jb_globaljob")!=0||id(j,"jb_autoranking")!=0)blockers.add("Auftrag #"+job+": gesonderter Vermittlungsablauf erforderlich.");
|
||||
var reservations=db.queryForList("SELECT crvh_id,vhd_timeslot FROM vehicledisposition WHERE jb_id=? ORDER BY crvh_id,vhd_timeslot LIMIT 86401",job);evidence.add(reservations);if(reservations.size()>86400)blockers.add("Auftrag #"+job+": zu viele Reservierungen.");
|
||||
if(sending&&reserved.stream().noneMatch(r->id(r,"jb_id")==job))blockers.add("Auftrag #"+job+": keine Reservierung auf diesem Fahrzeugtag.");
|
||||
var days=new TreeSet<LocalDate>();for(var r:reservations){if(id(r,"crvh_id")!=vehicle){blockers.add("Auftrag #"+job+": auch auf einem anderen Fahrzeug reserviert.");break;}try{var d=LocalDate.parse(text(r,"vhd_timeslot").substring(0,10));VehicleDisposition.slot(text(r,"vhd_timeslot"),d,(int)id(overview,"units"));days.add(d);}catch(RuntimeException e){blockers.add("Auftrag #"+job+": ungültige Reservierung.");}}
|
||||
for(var d:days){var availability=db.queryForList("SELECT vha_blocked,vha_lock FROM vehicleavailability WHERE crvh_id=? AND vha_day=?",vehicle,d);evidence.add(availability);if(availability.size()!=1||(id(availability.getFirst(),"vha_blocked")&3)!=2||id(availability.getFirst(),"vha_lock")!=0)blockers.add("Auftrag #"+job+": Ein weiterer Reservierungstag ist gesperrt oder nicht für Sie freigegeben.");}
|
||||
if(days.size()>1)warnings.add("Auftrag #"+job+": Die Änderung gilt für alle "+days.size()+" Reservierungstage.");
|
||||
}
|
||||
if(changed.isEmpty())blockers.add("Keine geeigneten Aufträge vorhanden.");warnings.add(sending?"Die Aufträge werden auf angenommen gesetzt. Im FDS-Modus erhält der Unterfahrer die Zuweisung; im Classic-Einzelablauf bleibt die Gerätezuordnung leer. Es liegt damit noch keine Empfangsbestätigung vor.":"Kurierzuordnung, Fahrzeugkennung und Annahmezeit werden geleert; der Auftrag wird wieder offen (Status 9). Es wird kein separater Gerätebefehl gesendet.");warnings.add("Reservierungen und Preise bleiben erhalten. Nach der geänderten Zuweisung können Aufträge aus Ihrer persönlichen Sicht verschwinden.");
|
||||
if(board.courier(u)!=courier)throw CourierPortal.denied();evidence.add(List.of(action,target,sid,blockers,warnings));return new State(courier,target,sid,action,now,changed,List.copyOf(blockers),List.copyOf(warnings),revision(evidence));
|
||||
}
|
||||
Map<String,Object> describe(State s){return fields("action",s.action(),"count",s.jobs().size(),"target",s.target(),"targetSid",s.sid(),"jobs",s.jobs().stream().map(j->fields("id",id(j,"jb_id"),"beforeStatus",id(j,"jb_status"),"afterStatus",s.action().equals("RECALL")?9:1,"beforeCourier",id(j,"cr_id"))).toList(),"blockers",s.blockers(),"warnings",s.warnings(),"revision",s.revision());}
|
||||
Map<String,Object> view(LegacyUser u,long vehicle,LocalDate day,long job){return db.readTransaction(()->describe(inspect(u,vehicle,day,job)));}
|
||||
State prepare(LegacyUser u,Command c){validate(u,c);var s=inspect(u,c.vehicle(),c.day(),c.job());if(!s.revision().equals(c.revision())||!s.action().equals(c.action()))throw VehicleDisposition.conflict();if(!s.blockers().isEmpty())throw bad(String.join(" ",s.blockers()));return s;}
|
||||
Map<String,Object> preview(LegacyUser u,Command c){return db.readTransaction(()->{var s=prepare(u,c);var out=describe(s);out.put("planRevision",s.revision());return out;});}
|
||||
Map<String,Object> save(LegacyUser u,Command c){validate(u,c);if(!c.confirmed())throw bad("Bitte die angezeigte Übergabe oder Rückholung bestätigen.");support.transactional("job","genericdatacontainer");return db.courierDispositionWriteTransaction(()->{board.view(u,c.day(),0,c.vehicle());var intent=List.of("COURIER_FDS_HANDOVER",c);if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true);var s=prepare(u,c);if(!s.revision().equals(c.planRevision()))throw VehicleDisposition.conflict();boolean recall=s.action().equals("RECALL");
|
||||
for(var j:s.jobs()){long job=id(j,"jb_id");if(db.update("UPDATE job SET cr_id=?,cr_id_order=?,cr_sid=?,jb_status=?,jb_taketime=?,jb_globaljob=0,jb_autoranking=0,jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=? AND jb_status=? AND jb_offer=0",s.target(),s.target(),s.sid(),recall?9:1,recall?null:s.now(),job,recall?1:9)!=1)throw VehicleDisposition.conflict();support.audit(u,job,0,s.target(),recall?8:2,"COURIER_FDS_HANDOVER | "+s.action()+" | VEHICLE="+c.vehicle()+" | DAY="+c.day(),c.requestId());}
|
||||
support.receipt(u,c.requestId(),intent,c.vehicle());return fields("completed",true);});}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** PHP courier FDS popup: destination articles and station-two remark, under the released-day scope. */
|
||||
@RestController
|
||||
@RequestMapping("/api/courier-portal/disposition/job")
|
||||
class CourierDispositionJob {
|
||||
record Command(String requestId,long vehicle,LocalDate day,long job,String revision,String value){}
|
||||
record Snapshot(long courier,Map<String,Object> job,Map<String,Object> station,Map<String,Object> table,boolean editable,String revision){}
|
||||
final DatabaseService db;final BusinessSupport support;final CourierDisposition board;
|
||||
CourierDispositionJob(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;board=new CourierDisposition(db,support);}
|
||||
@GetMapping Map<String,Object> get(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day,@RequestParam long job){return view(ApiController.user(a),vehicle,day,job);}
|
||||
@PostMapping("/remark") Map<String,Object> post(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);}
|
||||
String parameter(long hq,String key){var cfg=new OrderEntryCatalog(db);String value=cfg.parameter(hq,key);return value.isEmpty()?cfg.parameter(0,key):value;}
|
||||
Map<String,Object> articles(long hq,long job){
|
||||
String raw=parameter(hq,"MASK_JOBDETAILS_ARTICLES");if(raw.isBlank())raw="9,8,1,10,3,7";
|
||||
String titles=parameter(hq,"MASK_JOBDETAILS_ARTICLE_HEADLINE");var names=titles.isBlank()?CustomerOrderItems.LABELS:Arrays.asList(titles.split(",",-1));var indexes=new ArrayList<Integer>();
|
||||
for(String part:raw.split(",",-1)){if(!part.strip().matches("[0-9]{1,2}"))throw bad("Die Artikelspalten sind ungültig.");int i=Integer.parseInt(part.strip());if(i==99)continue;if(i>=CustomerOrderItems.KEYS.size())throw bad("Unbekannte Artikelspalte.");indexes.add(i);if(indexes.size()>30)throw bad("Zu viele Artikelspalten.");}
|
||||
if(parameter(hq,"MASK_JOBDETAILS_ARTICLE_SCAN_EVENTS").equals("1")&&!indexes.contains(4))indexes.add(4);
|
||||
var articles=db.queryForList("SELECT trat_id,tr_id,at_id,trat_quantity,trat_price,trat_sort,trat_packingpieces,trat_weight,trat_int_serialno,trat_serialno,trat_state,trat_remark,trat_name FROM tourarticle WHERE jb_id=? AND tr_sort=2 ORDER BY trat_sort,trat_id LIMIT 1001",job);
|
||||
if(articles.size()>1000)throw bad("Mehr als 1.000 Artikel an Station 2.");
|
||||
var rows=new ArrayList<Map<String,Object>>();BigDecimal weight=BigDecimal.ZERO,price=BigDecimal.ZERO,pieces=BigDecimal.ZERO;
|
||||
for(var article:articles){var cells=new ArrayList<String>();for(int i:indexes){String value=text(article,CustomerOrderItems.KEYS.get(i));if(i==9&&value.codePointCount(0,value.length())>50)value=value.substring(0,value.offsetByCodePoints(0,50));cells.add(value);}rows.add(fields("id",id(article,"trat_id"),"cells",cells));weight=weight.add(BillingService.nz(decimal(article,"trat_weight")));price=price.add(BillingService.nz(decimal(article,"trat_price")));pieces=pieces.add(BillingService.nz(decimal(article,"trat_packingpieces")));}
|
||||
var extra=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_addinfo'",job);if(extra.size()>1)throw bad("Die Artikel-Zusatzinformation ist mehrdeutig.");
|
||||
return fields("columns",indexes.stream().map(i->fields("label",i<names.size()&&!names.get(i).isBlank()?names.get(i):CustomerOrderItems.LABELS.get(i),"type",i==7?"money":i==10?"decimal":"text")).toList(),"articles",rows,"weight",weight,"pieces",pieces,"goodsValue",BillingService.money(price),"additional",articles.isEmpty()||extra.isEmpty()?"":text(extra.getFirst(),"gdc_content"));
|
||||
}
|
||||
@SuppressWarnings("unchecked") Snapshot snapshot(LegacyUser u,long vehicle,LocalDate day,long job){
|
||||
if(vehicle<=0||job<=0)throw bad("Fahrzeug und Auftrag fehlen.");VehicleDisposition.date(day);
|
||||
long courier=board.courier(u);var scope=board.view(u,day,0,vehicle);var vehicles=(List<Map<String,Object>>)scope.get("rows");
|
||||
if(vehicles.size()!=1||((List<Map<String,Object>>)vehicles.getFirst().get("jobs")).stream().noneMatch(j->id(j,"id")==job))throw CourierPortal.denied();
|
||||
var j=one(db.queryForList("SELECT jb_id,hq_id,jb_status,jb_version,jb_storno,jb_offer,cr_id,cr_id_order,cr_sid,jb_export_time,jb_locktime FROM job WHERE jb_id=?",job));
|
||||
var stops=db.queryForList("SELECT tr_id,tr_comp,tr_person,tr_phone,tr_remark FROM tour WHERE jb_id=? AND tr_sort=2 ORDER BY tr_id",job);
|
||||
if(stops.size()!=1)throw bad("Die zweite Station fehlt oder ist mehrdeutig.");
|
||||
var invoices=db.queryForList("SELECT inv_id FROM invoice WHERE jb_id=?",job);var lock=dateTime(j,"jb_locktime");var now=new VehicleDisposition(db,support).now();
|
||||
boolean editable=Set.of(0L,1L,9L).contains(id(j,"jb_status"))&&id(j,"jb_storno")==0&&!OrderOperations.exported(j)&&invoices.isEmpty()&&(lock==null||!lock.isAfter(now.minusMinutes(5)));
|
||||
var table=articles(u.headquartersId(),job);
|
||||
if(board.courier(u)!=courier)throw CourierPortal.denied();
|
||||
return new Snapshot(courier,j,stops.getFirst(),table,editable,revision(List.of(vehicle,day,courier,vehicles,j,stops,table,invoices,editable)));
|
||||
}
|
||||
Map<String,Object> view(LegacyUser u,long vehicle,LocalDate day,long job){return db.readTransaction(()->{var s=snapshot(u,vehicle,day,job);var out=new LinkedHashMap<>(s.table());out.putAll(fields("vehicle",vehicle,"job",job,"day",day.toString(),"status",id(s.job(),"jb_status"),"destination",fields("company",text(s.station(),"tr_comp"),"person",text(s.station(),"tr_person"),"phone",text(s.station(),"tr_phone")),"remark",text(s.station(),"tr_remark"),"canRemark",s.editable(),"revision",s.revision()));return out;});}
|
||||
Map<String,Object> save(LegacyUser u,Command c){
|
||||
if(c==null||c.value()==null||c.value().getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65535||c.value().indexOf('\0')>=0)throw bad("Die Stationsbemerkung ist zu lang oder enthält ungültige Zeichen.");BusinessSupport.token(c.requestId());
|
||||
board.courier(u);support.transactional("job","tour","genericdatacontainer");var intent=List.of("COURIER_FDS_STATION_REMARK",c);
|
||||
return db.courierDispositionWriteTransaction(()->{var s=snapshot(u,c.vehicle(),c.day(),c.job());if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true);
|
||||
if(!s.editable())throw bad("Die Stationsbemerkung dieses Auftrags ist schreibgeschützt.");if(!s.revision().equals(c.revision()))throw VehicleDisposition.conflict();
|
||||
if(!text(s.station(),"tr_remark").equals(c.value())){db.update("UPDATE tour SET tr_remark=? WHERE tr_id=? AND jb_id=? AND tr_sort=2",c.value(),id(s.station(),"tr_id"),c.job());db.update("UPDATE job SET jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",c.job());support.audit(u,c.job(),0,s.courier(),2,"COURIER_FDS_STATION_REMARK | BEFORE="+support.encode(text(s.station(),"tr_remark"))+" | AFTER="+support.encode(c.value()),c.requestId());}
|
||||
support.receipt(u,c.requestId(),intent,c.job());return fields("completed",true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** Explicit time/reordering editor for the courier's released reservation day. */
|
||||
@RestController
|
||||
@RequestMapping("/api/courier-portal/disposition/planning")
|
||||
class CourierDispositionPlanning {
|
||||
record Command(String requestId,long vehicle,LocalDate day,List<VehiclePlanning.Entry> entries,String revision,String planRevision,boolean confirmed){}
|
||||
record State(int units,LocalDateTime now,Map<String,Object> vehicle,Map<String,Object> availability,List<Map<String,Object>> jobs,long[] grid,Map<Long,Long> customers,List<Object> evidence,List<String> blockers,String revision){}
|
||||
record Plan(State state,long[] grid,List<Map<String,Object>> changes,List<List<Object>> blocks,List<String> warnings,String revision){}
|
||||
final DatabaseService db;final BusinessSupport support;final CourierDisposition board;
|
||||
CourierDispositionPlanning(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;board=new CourierDisposition(db,support);}
|
||||
@GetMapping Map<String,Object> get(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day){return view(ApiController.user(a),vehicle,day);}
|
||||
@PostMapping("/preview") Map<String,Object> preview(Authentication a,@RequestBody Command c){return preview(ApiController.user(a),c);}
|
||||
@PostMapping Map<String,Object> post(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);}
|
||||
void validate(LegacyUser u,Command c){board.courier(u);if(c==null||c.vehicle()<=0||c.entries()==null||c.entries().isEmpty()||c.entries().size()>300)throw bad("Eine vollständige Auftragsliste wird benötigt (maximal 300).");BusinessSupport.token(c.requestId());VehicleDisposition.date(c.day());}
|
||||
@SuppressWarnings("unchecked") State inspect(LegacyUser u,long vehicle,LocalDate day){
|
||||
VehicleDisposition.date(day);if(vehicle<=0)throw bad("Bitte ein Fahrzeug wählen.");long courier=board.courier(u);var overview=board.view(u,day,0,vehicle);var row=((List<Map<String,Object>>)overview.get("rows")).getFirst();int units=(int)id(overview,"units");
|
||||
var visible=((List<Map<String,Object>>)row.get("jobs")).stream().map(j->id(j,"id")).collect(java.util.stream.Collectors.toSet());
|
||||
var cells=db.queryForList("SELECT vhd_timeslot,jb_id FROM vehicledisposition WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslot<? ORDER BY vhd_timeslot LIMIT 1441",vehicle,day.toString(),day.plusDays(1).toString());
|
||||
var evidence=new ArrayList<Object>(List.of(courier,overview,cells));var blockers=new ArrayList<String>();var now=new VehicleDisposition(db,support).now();evidence.add(now.toLocalDate());
|
||||
var available=one(db.queryForList("SELECT * FROM vehicleavailability WHERE crvh_id=? AND vha_day=?",vehicle,day));evidence.add(available);
|
||||
var v=one(db.queryForList("SELECT crvh_id,crvh_service FROM couriervehicle WHERE crvh_id=?",vehicle));evidence.add(v);
|
||||
if(day.isBefore(now.toLocalDate()))blockers.add("Vergangene Fahrzeugtage können nicht umgeplant werden.");if((id(available,"vha_blocked")&1)!=0)blockers.add("Der Fahrzeugtag ist gesperrt.");if(id(available,"vha_lock")!=0)blockers.add("Der Fahrzeugtag wird gerade verarbeitet.");
|
||||
if(cells.size()!=24*units)blockers.add("Das Tagesraster ist unvollständig.");VehicleDisposition.segments(cells,day,units,visible);long[] grid=new long[24*units];for(var r:cells)grid[VehicleDisposition.slot(text(r,"vhd_timeslot"),day,units)]=id(r,"jb_id");
|
||||
var ids=Arrays.stream(grid).filter(n->n>0).distinct().boxed().toList();if(ids.size()>300)throw bad("Mehr als 300 reservierte Aufträge.");if(ids.isEmpty())blockers.add("Keine reservierten Aufträge vorhanden.");if(!visible.containsAll(ids))blockers.add("Der Fahrzeugtag enthält nicht für Sie freigegebene Belegungen. Eine gemeinsame Umsortierung ist nicht möglich.");
|
||||
var known=ids.stream().filter(visible::contains).toList();var jobs=known.isEmpty()?List.<Map<String,Object>>of():db.queryForList("SELECT * FROM job WHERE jb_id IN ("+CourierPortal.placeholders(known.size())+") ORDER BY jb_id",known.toArray());evidence.add(jobs);if(jobs.size()!=known.size())throw VehicleDisposition.conflict();var customers=new LinkedHashMap<Long,Long>();
|
||||
long mandator=mandator(db,u);for(var j:jobs){long job=id(j,"jb_id");if(!Set.of(0L,9L).contains(id(j,"jb_status"))||id(j,"jb_offer")!=0)blockers.add("Auftrag #"+job+": Nur offene oder reservierte Aufträge ohne Angebot können umgeplant werden.");
|
||||
try{new VehicleDisposition(db,support).writable(j,now,evidence);}catch(org.springframework.web.server.ResponseStatusException e){blockers.add(e.getReason());}
|
||||
if(id(j,"hq_id_exec")>0&&id(j,"hq_id_exec")!=id(j,"hq_id")||id(j,"jb_longhaul")!=0||id(j,"jb_globaljob")!=0||id(j,"jb_autoranking")!=0)blockers.add("Auftrag #"+job+": gesonderter Vermittlungsablauf erforderlich.");
|
||||
var reservations=db.queryForList("SELECT crvh_id,vhd_timeslot FROM vehicledisposition WHERE jb_id=? ORDER BY crvh_id,vhd_timeslot LIMIT 86401",job);evidence.add(reservations);if(reservations.size()>86400||reservations.stream().anyMatch(r->id(r,"crvh_id")!=vehicle||!text(r,"vhd_timeslot").startsWith(day+"_")))blockers.add("Auftrag #"+job+": Reservierungen an weiteren Tagen oder Fahrzeugen bitte mit „Umplanen“ am ersten Reservierungstag bearbeiten.");
|
||||
int first=VehiclePlanning.first(grid,job);long length=Arrays.stream(grid).filter(n->n==job).count();for(int i=first;i<first+length;i++)if(grid[i]!=job){blockers.add("Auftrag #"+job+": unterbrochene Reservierung.");break;}
|
||||
var cs=db.queryForList("SELECT cs.cs_id,cs.hq_id FROM customer cs JOIN costcenter csc ON csc.cs_id=cs.cs_id WHERE csc.csc_id=? AND cs.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?)",id(j,"csc_id_related"),mandator);evidence.add(cs);if(cs.size()!=1){blockers.add("Auftrag #"+job+": Der zugeordnete Kunde ist nicht eindeutig verfügbar.");continue;}long customer=id(cs.getFirst(),"cs_id");customers.put(job,customer);
|
||||
String key="JB_SERVICE_INSTALLAION_MAX_TIMEUNITS";var params=db.queryForList("SELECT par_id,par_key,hq_id,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN (?,?) ORDER BY par_id",id(j,"hq_id"),key+"_"+customer,key);evidence.add(params);int maximum=48;boolean found=false;
|
||||
for(String k:List.of(key+"_"+customer,key))for(long hq:new long[]{id(j,"hq_id"),0}){var matches=params.stream().filter(r->text(r,"par_key").equals(k)&&id(r,"hq_id")==hq).toList();if(matches.size()>1)throw bad("Mehrdeutige Mehrtagesvorgabe.");if(!found&&!matches.isEmpty()&&!text(matches.getFirst(),"par_value").isBlank()){String value=text(matches.getFirst(),"par_value");if(!value.matches("[1-9][0-9]{0,4}"))throw bad("Ungültige Mehrtagesvorgabe.");maximum=Integer.parseInt(value);found=true;}}
|
||||
long duration=id(j,"jb_timeunits");if(duration<1||duration>maximum||duration>24*units)blockers.add("Auftrag #"+job+": keine gültige eintägige Dauer.");
|
||||
}
|
||||
if(board.courier(u)!=courier)throw CourierPortal.denied();evidence.add(blockers);return new State(units,now,row,available,jobs,grid,customers,evidence,List.copyOf(blockers),revision(evidence));
|
||||
}
|
||||
Map<String,Object> view(LegacyUser u,long vehicle,LocalDate day){return db.readTransaction(()->{var s=inspect(u,vehicle,day);var rows=new ArrayList<Map<String,Object>>();for(var j:s.jobs()){long job=id(j,"jb_id");int at=VehiclePlanning.first(s.grid(),job);var planned=dateTime(j,"jb_ordertime");String time=planned!=null&&planned.toLocalDate().equals(day)?planned.toLocalTime().toString().substring(0,5):VehicleDisposition.time(at,s.units());rows.add(fields("id",job,"status",id(j,"jb_status"),"time",time,"from",VehicleDisposition.time(at,s.units()),"duration",id(j,"jb_timeunits"),"slots",Arrays.stream(s.grid()).filter(n->n==job).count()));}rows.sort(Comparator.comparing(r->text(r,"from")));return fields("vehicle",vehicle,"sid",text(s.vehicle(),"sid"),"day",day.toString(),"step",60/s.units(),"rows",rows,"blockers",s.blockers(),"revision",s.revision());});}
|
||||
Plan prepare(LegacyUser u,Command c){validate(u,c);var s=inspect(u,c.vehicle(),c.day());if(!s.revision().equals(c.revision()))throw VehicleDisposition.conflict();if(!s.blockers().isEmpty())throw bad(String.join(" ",s.blockers()));
|
||||
var evidence=new ArrayList<Object>(s.evidence());var durations=new LinkedHashMap<Long,Integer>();for(var j:s.jobs())durations.put(id(j,"jb_id"),(int)id(j,"jb_timeunits"));var placements=VehiclePlanning.placements(c.entries(),durations,s.units(),true);long[] grid=new long[s.grid().length];var changes=new ArrayList<Map<String,Object>>();var warnings=new ArrayList<String>();var starts=new LinkedHashMap<Long,VehiclePlanningDependencies.Start>();
|
||||
for(var p:placements){for(int i=p.start();i<p.end();i++)grid[i]=p.job();var j=s.jobs().stream().filter(r->id(r,"jb_id")==p.job()).findFirst().orElseThrow();String at=VehicleDisposition.time(p.start(),s.units());String supplied=c.entries().stream().filter(e->e.job()==p.job()).findFirst().orElseThrow().time();changes.add(fields("id",p.job(),"before",text(j,"jb_ordertime"),"after",c.day()+" "+at+":00","from",at,"to",VehicleDisposition.time(p.end(),s.units()),"slots",p.end()-p.start(),"duration",p.requested()));starts.put(p.job(),new VehiclePlanningDependencies.Start(c.vehicle(),c.day().atStartOfDay().plusMinutes(p.start()*60L/s.units())));
|
||||
if(!supplied.equals(at))warnings.add("Auftrag #"+p.job()+": "+supplied+" wird im Raster auf "+at+" aufgerundet.");if(p.end()-p.start()<p.requested())warnings.add("Auftrag #"+p.job()+": Reservierung wird auf "+(p.end()-p.start())+" Zeiteinheiten bis zum nächsten Auftrag gekürzt. Die Auftragsdauer bleibt unverändert.");
|
||||
}
|
||||
if(Arrays.equals(grid,s.grid())&&changes.stream().allMatch(r->Objects.equals(dateTime(r,"before"),dateTime(r,"after"))))throw bad("Keine Änderung vorhanden.");
|
||||
warnings.addAll(dependencies(u,s,starts,evidence));
|
||||
var relations=new VehicleCustomerRelations(db,support);var rows=relations.rows(c.vehicle());evidence.add(rows);var contexts=new HashMap<Long,VehicleCustomerRelations.Context>();long mandator=mandator(db,u);
|
||||
for(var r:rows){long cs=id(r,"cs_id");var customer=db.queryForList("SELECT cs_id,hq_id FROM customer WHERE cs_id=? AND hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?)",cs,mandator);evidence.add(customer);if(customer.size()!=1)throw bad("Eine Fahrzeug-Kunden-Zuordnung ist nicht eindeutig verfügbar.");var ctx=relations.context(cs,s.units());VehicleCustomerRelations.validateSchedule(VehicleAvailability.decode(text(r,"csvh_day_times")),ctx);if(!Set.of(0L,1L).contains(id(r,"csvh_relation")))throw bad("Ungültiger Zuordnungsstatus.");contexts.put(cs,ctx);evidence.add(ctx);}
|
||||
for(var p:placements){long cs=s.customers().get(p.job());var relation=rows.stream().filter(r->id(r,"cs_id")==cs&&id(r,"csvh_relation")==1).findFirst().orElseThrow(()->bad("Auftrag #"+p.job()+": Kunde ist dem Fahrzeug nicht als erwünscht zugeordnet."));var inactive=new HashSet<Integer>();VehicleAvailability.decode(text(relation,"csvh_day_times")).stream().filter(cell->cell.weekday()==c.day().getDayOfWeek().getValue()&&cell.zone().equals("9999")).forEach(cell->inactive.add(cell.daytime()));for(int i=p.start();i<p.end();i++){int at=i;if(contexts.get(cs).windows().stream().noneMatch(w->!inactive.contains(w.id())&&at>=w.start()&&at<w.end()))throw bad("Auftrag #"+p.job()+": Belegung liegt außerhalb der aktiven Kundeneinsatzzeiten.");}}
|
||||
long aid=id(s.availability(),"vha_id");var oldBlocks=db.queryForList("SELECT * FROM vehicleavailabilitytimeunits WHERE vha_id=? ORDER BY cs_id,vhat_daytime,vhat_starttimeunit,vhat_timeunits,vhat_srvz_no",aid);evidence.add(oldBlocks);if(oldBlocks.stream().anyMatch(r->!contexts.containsKey(id(r,"cs_id"))))throw bad("Verfügbarkeitsblöcke ohne Kundenbeziehung vorhanden. Bitte im Fahrzeugstamm klären.");
|
||||
boolean[] free=new boolean[grid.length];for(int i=0;i<grid.length;i++)free[i]=grid[i]==0;int earliest=c.day().equals(s.now().toLocalDate())?s.now().getHour()*s.units()+(s.now().getMinute()*s.units()+59)/60:0;evidence.add(earliest);var blocks=new ArrayList<List<Object>>();for(var r:rows){long cs=id(r,"cs_id");for(var b:VehicleAvailability.blocks(contexts.get(cs).windows(),VehicleAvailability.decode(text(r,"csvh_day_times")),c.day().getDayOfWeek().getValue(),id(r,"csvh_relation")==1,free,earliest))blocks.add(List.of(aid,b.daytime(),b.units(),cs,b.zone(),VehicleAvailability.slotName(b.start(),s.units())));}
|
||||
warnings.add("Alle Reservierungen dieses Fahrzeugtags werden gemeinsam umsortiert. Kurierzuordnung, Auftragsdauer, Preise und Rest-Nutzlast bleiben erhalten. Es werden keine Nachrichten versendet.");evidence.add(changes);evidence.add(blocks);evidence.add(warnings);return new Plan(s,grid,changes,blocks,warnings,revision(evidence));
|
||||
}
|
||||
List<String> dependencies(LegacyUser u,State s,Map<Long,VehiclePlanningDependencies.Start> starts,List<Object> evidence){return dependencies(u,s.jobs(),s.units(),starts,evidence);}
|
||||
@SuppressWarnings("unchecked") List<String> dependencies(LegacyUser u,List<Map<String,Object>> sourceJobs,int units,Map<Long,VehiclePlanningDependencies.Start> starts,List<Object> evidence){
|
||||
var links=new LinkedHashSet<VehiclePlanningDependencies.Link>();var jobs=new HashMap<Long,Map<String,Object>>();var warnings=new ArrayList<String>();for(var j:sourceJobs){long job=id(j,"jb_id");jobs.put(job,j);if(id(j,"jb_id_parent")>0)links.add(new VehiclePlanningDependencies.Link(id(j,"jb_id_parent"),job));var children=db.queryForList("SELECT jb_id,hq_id,jb_id_parent,jb_service FROM job WHERE jb_id_parent=? ORDER BY jb_id LIMIT 301",job);evidence.add(children);if(children.size()>300)throw bad("Zu viele verknüpfte Aufträge.");for(var child:children)links.add(new VehiclePlanningDependencies.Link(job,id(child,"jb_id")));if((id(j,"jb_service")&2)!=0){var vehicle=one(db.queryForList("SELECT crvh_service FROM couriervehicle WHERE crvh_id=?",starts.get(job).vehicle()));evidence.add(vehicle);if((id(vehicle,"crvh_service")&2)==0)warnings.add("Auftrag #"+job+": Montage erforderlich, Fahrzeug ist nicht als Montagefahrzeug gekennzeichnet.");}}
|
||||
var helper=new VehiclePlanningDependencies(db);for(var link:links){for(long job:List.of(link.parent(),link.child()))if(!jobs.containsKey(job)){
|
||||
var start=helper.start(job,starts,units,evidence);if(start==null)throw bad("Ein verknüpfter Auftrag hat keine überprüfbare Reservierung. Bitte die Reihenfolge in der Zentrale klären.");
|
||||
Map<String,Object> other;try{other=board.view(u,start.time().toLocalDate(),0,start.vehicle());}catch(org.springframework.security.access.AccessDeniedException e){throw bad("Ein verknüpfter Auftrag ist nicht für Ihre Disposition freigegeben. Bitte die Reihenfolge in der Zentrale klären.");}
|
||||
var row=((List<Map<String,Object>>)other.get("rows")).getFirst();if(((List<Map<String,Object>>)row.get("jobs")).stream().noneMatch(j->id(j,"id")==job))throw bad("Ein verknüpfter Auftrag ist nicht für Ihre Disposition freigegeben. Bitte die Reihenfolge in der Zentrale klären.");evidence.add(other);var j=one(db.queryForList("SELECT jb_id,hq_id,jb_id_parent,jb_service FROM job WHERE jb_id=?",job));jobs.put(job,j);evidence.add(j);
|
||||
}
|
||||
if(link.parent()==link.child()||links.contains(new VehiclePlanningDependencies.Link(link.child(),link.parent())))throw bad("Die Auftragsverknüpfung enthält einen Kreis.");var parent=starts.get(link.parent());var child=starts.get(link.child());
|
||||
var params=db.queryForList("SELECT par_id,hq_id,par_value FROM parameter WHERE emp_id=0 AND par_key='MASK_DISPOSITION_CHECK_DELIVERY_BEFORE_SERVICE_DISABLED' AND hq_id IN (0,?) ORDER BY par_id",id(jobs.get(link.child()),"hq_id"));evidence.add(params);boolean warningOnly=false;for(long hq:new long[]{id(jobs.get(link.child()),"hq_id"),0}){var local=params.stream().filter(r->id(r,"hq_id")==hq).toList();if(local.size()>1)throw bad("Mehrdeutige Vorgabe zur Liefer-/Montagereihenfolge.");if(!local.isEmpty()&&!text(local.getFirst(),"par_value").isBlank()){String value=text(local.getFirst(),"par_value");if(!Set.of("0","1").contains(value))throw bad("Ungültige Vorgabe zur Liefer-/Montagereihenfolge.");warningOnly=value.equals("1");break;}}
|
||||
if(!parent.time().isBefore(child.time())){String problem="Lieferung #"+link.parent()+" muss vor dem verknüpften Montage-/Folgeauftrag #"+link.child()+" beginnen.";if(!warningOnly)throw bad(problem);warnings.add(problem+" Die Niederlassungsvorgabe erlaubt diese Reihenfolge nach Bestätigung.");}if(parent.vehicle()!=child.vehicle())warnings.add("Verknüpfte Aufträge #"+link.parent()+" / #"+link.child()+" bleiben auf unterschiedlichen Fahrzeugen.");
|
||||
}return warnings;
|
||||
}
|
||||
Map<String,Object> preview(LegacyUser u,Command c){return db.readTransaction(()->{var p=prepare(u,c);return fields("planRevision",p.revision(),"changes",p.changes(),"warnings",p.warnings());});}
|
||||
Map<String,Object> save(LegacyUser u,Command c){validate(u,c);if(!c.confirmed())throw bad("Bitte die geprüfte Reihenfolge und Uhrzeiten bestätigen.");support.transactional("job","vehicledisposition","vehicleavailabilitytimeunits","genericdatacontainer");return db.courierPlanningWriteTransaction(()->{
|
||||
board.view(u,c.day(),0,c.vehicle());var intent=List.of("COURIER_FDS_PLANNING",c);if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true);var p=prepare(u,c);if(!p.revision().equals(c.planRevision()))throw VehicleDisposition.conflict();
|
||||
db.update("UPDATE vehicledisposition SET jb_id=0 WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslot<?",c.vehicle(),c.day().toString(),c.day().plusDays(1).toString());int from=0;while(from<p.grid().length){int end=from+1;while(end<p.grid().length&&p.grid()[end]==p.grid()[from])end++;if(p.grid()[from]>0)db.update("UPDATE vehicledisposition SET jb_id=? WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslot<?",p.grid()[from],c.vehicle(),c.day()+"_"+VehicleAvailability.slotName(from,p.state().units()),end==p.grid().length?c.day().plusDays(1).toString():c.day()+"_"+VehicleAvailability.slotName(end,p.state().units()));from=end;}
|
||||
db.update("DELETE FROM vehicleavailabilitytimeunits WHERE vha_id=?",id(p.state().availability(),"vha_id"));VehicleAvailability.insertMany(db,"vehicleavailabilitytimeunits",List.of("vha_id","vhat_daytime","vhat_timeunits","cs_id","vhat_srvz_no","vhat_starttimeunit"),p.blocks());
|
||||
for(var change:p.changes()){long job=id(change,"id");db.update("UPDATE job SET jb_ordertime=?,jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",text(change,"after"),job);support.audit(u,job,0,0,124,"COURIER_FDS_PLANNING | VEHICLE="+c.vehicle()+" | DAY="+c.day()+" | "+text(change,"from")+"–"+text(change,"to"),c.requestId());}support.receipt(u,c.requestId(),intent,c.vehicle());return fields("completed",true);
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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.VehicleMultiDayPlanning.*;
|
||||
import de.stadtbote.votian.VehicleMultiDayPlanning.Calendar;
|
||||
|
||||
/** Courier drag/replanning preserves assignment and validates all released reservation days. */
|
||||
@RestController
|
||||
@RequestMapping("/api/courier-portal/disposition/replanning")
|
||||
class CourierDispositionReplanning {
|
||||
record Command(String requestId,long vehicle,LocalDate day,long job,long target,LocalDate startDay,String time,String revision,String planRevision,boolean confirmed,boolean capacityConfirmed){
|
||||
Command(String requestId,long vehicle,LocalDate day,long job,long target,LocalDate startDay,String time,String revision,String planRevision,boolean confirmed){this(requestId,vehicle,day,job,target,startDay,time,revision,planRevision,confirmed,false);}
|
||||
}
|
||||
final DatabaseService db;final BusinessSupport support;final CourierDisposition board;final VehicleCustomerRelations relations;
|
||||
CourierDispositionReplanning(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;board=new CourierDisposition(db,support);relations=new VehicleCustomerRelations(db,support);}
|
||||
@GetMapping Map<String,Object> get(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day,@RequestParam long job){return view(ApiController.user(a),vehicle,day,job);}
|
||||
@PostMapping("/preview") Map<String,Object> preview(Authentication a,@RequestBody Command c){return preview(ApiController.user(a),c);}
|
||||
@PostMapping Map<String,Object> post(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);}
|
||||
@SuppressWarnings("unchecked") Map<String,Object> row(LegacyUser u,long vehicle,LocalDate day){return ((List<Map<String,Object>>)board.view(u,day,0,vehicle).get("rows")).getFirst();}
|
||||
@SuppressWarnings("unchecked") Set<Long> visible(Map<String,Object> row){return ((List<Map<String,Object>>)row.get("jobs")).stream().map(j->id(j,"id")).collect(java.util.stream.Collectors.toSet());}
|
||||
void validate(LegacyUser u,Command c){board.courier(u);if(c==null||c.vehicle()<=0||c.target()<=0||c.job()<=0)throw bad("Fahrzeug und Auftrag fehlen.");VehicleDisposition.date(c.day());VehicleDisposition.date(c.startDay());if(!c.day().equals(c.startDay()))throw bad("Die Umplanung beginnt am ausgewählten Starttag. Bitte diesen Fahrzeugtag öffnen.");BusinessSupport.token(c.requestId());}
|
||||
State state(LegacyUser u,long vehicle,LocalDate day,long job){
|
||||
VehicleDisposition.date(day);if(vehicle<=0||job<=0)throw bad("Fahrzeug und Auftrag fehlen.");long courier=board.courier(u);var source=row(u,vehicle,day);if(!visible(source).contains(job))throw CourierPortal.denied();
|
||||
var evidence=new ArrayList<Object>(List.of(courier,source));var j=one(db.queryForList("SELECT * FROM job WHERE jb_id=?",job));evidence.add(j);var v=one(db.queryForList("SELECT crvh_id,cr_id,crvh_sid,crvh_vh_sign FROM couriervehicle WHERE crvh_id=?",vehicle));evidence.add(v);
|
||||
int units=new VehicleDisposition(db,support).units();evidence.add(units);var reservations=db.queryForList("SELECT crvh_id,vhd_timeslot FROM vehicledisposition WHERE jb_id=? ORDER BY crvh_id,vhd_timeslot LIMIT 86401",job);evidence.add(reservations);var before=reservationRanges(reservations,vehicle,units);
|
||||
for(var r:before){Map<String,Object> released;try{released=r.day().equals(day)?source:row(u,vehicle,r.day());}catch(org.springframework.security.access.AccessDeniedException e){throw bad("Nicht alle bisherigen Reservierungstage sind für Ihre Disposition freigegeben.");}if(!visible(released).contains(job))throw CourierPortal.denied();evidence.add(released);}
|
||||
if(!before.getFirst().day().equals(day))throw bad("Ein mehrtägiger Auftrag kann nur an seinem ersten Reservierungstag umgeplant werden: "+before.getFirst().day()+".");
|
||||
var customers=db.queryForList("SELECT cs.cs_id,cs.hq_id FROM customer cs JOIN costcenter csc ON csc.cs_id=cs.cs_id WHERE csc.csc_id=? AND cs.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?)",id(j,"csc_id_related"),mandator(db,u));if(customers.size()!=1)throw bad("Der zugeordnete Kunde ist nicht eindeutig verfügbar.");evidence.add(customers);long customer=id(customers.getFirst(),"cs_id");
|
||||
String maximumValue=parameter(id(j,"hq_id"),customer,"JB_SERVICE_INSTALLAION_MAX_TIMEUNITS",evidence);if(!maximumValue.isBlank()&&!maximumValue.matches("[1-9][0-9]{0,4}"))throw bad("Ungültige Mehrtagesvorgabe.");int maximum=maximumValue.isBlank()?48:Integer.parseInt(maximumValue);
|
||||
var ctx=relations.context(customer,units);evidence.add(ctx);int following=followingStart(parameter(id(j,"hq_id"),customer,"CS_DAY_TIMEUNIT_START",evidence),units,ctx.windows());
|
||||
var articles=db.queryForList("SELECT trat_id,jb_id,trat_weight FROM tourarticle WHERE jb_id=? ORDER BY trat_id LIMIT 10001",job);evidence.add(articles);var weight=articleWeight(articles,Set.of(job));var now=new VehicleDisposition(db,support).now();evidence.add(now.toLocalDate());if(board.courier(u)!=courier)throw CourierPortal.denied();return new State(j,v,customer,units,maximum,following,before,weight,evidence,now,revision(evidence));
|
||||
}
|
||||
@SuppressWarnings("unchecked") Map<String,Object> view(LegacyUser u,long vehicle,LocalDate day,long job){return db.readTransaction(()->{
|
||||
var s=state(u,vehicle,day,job);var targets=new ArrayList<Map<String,Object>>();int offset=0;while(true){var page=board.view(u,day,offset);for(var r:(List<Map<String,Object>>)page.get("rows"))targets.add(fields("id",id(r,"id"),"label",text(r,"sid"),"registration",text(r,"registration")));offset+=25;if(offset>=id(page,"total"))break;}
|
||||
var at=dateTime(s.job(),"jb_ordertime");String time=at!=null&&at.toLocalDate().equals(day)?at.toLocalTime().toString().substring(0,5):VehicleDisposition.time(s.before().getFirst().start(),s.units());
|
||||
return fields("job",job,"vehicle",vehicle,"sid",text(s.vehicle(),"crvh_sid"),"today",s.now().toLocalDate().toString(),"initial",false,"startDay",day.toString(),"time",time,"durationMinutes",id(s.job(),"jb_timeunits")*60/s.units(),"dailyMinutes",s.maximum()*60/s.units(),"followingStart",VehicleDisposition.time(s.followingStart(),s.units()),"step",60/s.units(),"articleWeight",s.articleWeight(),"before",describe(s.before(),s.units()),"targets",targets,"revision",s.revision());
|
||||
});}
|
||||
Calendar calendar(LegacyUser u,Key key,State s,List<Object> evidence){
|
||||
if(key.day().isBefore(s.now().toLocalDate()))throw bad("Vergangene Reservierungstage können nicht umgeplant werden.");Map<String,Object> scoped;try{scoped=row(u,key.vehicle(),key.day());}catch(org.springframework.security.access.AccessDeniedException e){throw bad("Nicht alle betroffenen Fahrzeugtage sind für Ihre Disposition freigegeben.");}evidence.add(scoped);
|
||||
var a=one(db.queryForList("SELECT * FROM vehicleavailability WHERE crvh_id=? AND vha_day=?",key.vehicle(),key.day()));if(id(a,"vha_lock")!=0)throw bad("Ein Fahrzeugtag wird gerade verarbeitet.");if((id(a,"vha_blocked")&1)!=0)throw bad("Ein betroffener Fahrzeugtag ist gesperrt.");
|
||||
var slots=db.queryForList("SELECT vhd_timeslot,jb_id FROM vehicledisposition WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslot<? ORDER BY vhd_timeslot LIMIT 1441",key.vehicle(),key.day().toString(),key.day().plusDays(1).toString());
|
||||
if(!visible(scoped).containsAll(slots.stream().map(r->id(r,"jb_id")).filter(n->n>0).toList()))throw bad("Ein Fahrzeugtag enthält nicht für Sie freigegebene Belegungen. Bitte die Planung in der Zentrale klären.");
|
||||
VehicleDisposition.segments(slots,key.day(),s.units(),Set.of());if(slots.size()!=24*s.units())throw bad("Ein betroffenes Tagesraster ist unvollständig.");evidence.add(key);evidence.add(a);evidence.add(slots);long[] cells=new long[24*s.units()];for(var r:slots)cells[VehicleDisposition.slot(text(r,"vhd_timeslot"),key.day(),s.units())]=id(r,"jb_id");return new Calendar(a,cells,jobs(cells));
|
||||
}
|
||||
String parameter(long headquarters,long customer,String key,List<Object> evidence){
|
||||
var rows=db.queryForList("SELECT par_id,par_key,hq_id,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN (?,?) ORDER BY par_id",headquarters,key+"_"+customer,key);evidence.add(rows);
|
||||
for(String k:List.of(key+"_"+customer,key))for(long hq:new long[]{headquarters,0}){
|
||||
var matches=rows.stream().filter(r->text(r,"par_key").equals(k)&&id(r,"hq_id")==hq).toList();
|
||||
if(matches.size()>1)throw bad("Mehrdeutige Mehrtagesvorgabe: "+key);
|
||||
if(!matches.isEmpty()&&!text(matches.getFirst(),"par_value").isBlank())return text(matches.getFirst(),"par_value");
|
||||
}return "";
|
||||
}
|
||||
|
||||
Plan prepare(LegacyUser u,Command c){
|
||||
validate(u,c);var s=state(u,c.vehicle(),c.day(),c.job());if(!s.revision().equals(c.revision()))throw VehicleDisposition.conflict();var evidence=new ArrayList<>(s.evidence());var j=s.job();
|
||||
if(!Set.of(0L,9L).contains(id(j,"jb_status"))||id(j,"jb_offer")!=0)throw bad("Nur offene oder reservierte Aufträge ohne Angebot können umgeplant werden.");
|
||||
if(id(j,"hq_id_exec")>0&&id(j,"hq_id_exec")!=id(j,"hq_id")||id(j,"jb_longhaul")!=0||id(j,"jb_autoranking")!=0||id(j,"jb_globaljob")!=0)throw bad("Dieser Auftrag benötigt einen gesonderten Vermittlungsablauf.");new VehicleDisposition(db,support).writable(j,s.now(),evidence);
|
||||
var target=row(u,c.target(),c.day());evidence.add(target);var targetOwner=one(db.queryForList("SELECT crvh_id,cr_id FROM couriervehicle WHERE crvh_id=?",c.target()));evidence.add(targetOwner);if(id(j,"cr_id")!=board.courier(u)&&id(targetOwner,"cr_id")!=board.courier(u))throw bad("Bei unveränderter Kurierzuordnung ist dieser Auftrag auf dem Zielfahrzeug nicht freigegeben.");
|
||||
var after=chunks(c.startDay(),VehiclePlanning.start(c.time(),s.units()),s.followingStart(),id(j,"jb_timeunits"),s.maximum(),s.units());var keys=new TreeSet<Key>(Comparator.comparingLong((VehicleMultiDayPlanning.Key entryValue) -> entryValue.vehicle()).thenComparing(entryValue -> entryValue.day()));s.before().forEach(r->keys.add(new Key(c.vehicle(),r.day())));after.forEach(r->keys.add(new Key(c.target(),r.day())));
|
||||
var calendars=new LinkedHashMap<Key,Calendar>();for(var key:keys)calendars.put(key,calendar(u,key,s,evidence));for(var r:s.before()){var cells=calendars.get(new Key(c.vehicle(),r.day())).cells();for(int i=r.start();i<r.end();i++){if(cells[i]!=c.job())throw VehicleDisposition.conflict();cells[i]=0;}}
|
||||
for(var r:after){var cal=calendars.get(new Key(c.target(),r.day()));for(int i=r.start();i<r.end();i++){if(cal.cells()[i]!=0)throw bad("Das Zielfahrzeug ist am "+r.day()+" um "+VehicleDisposition.time(i,s.units())+" belegt. Es wird nichts gespeichert.");cal.cells()[i]=c.job();}}
|
||||
var planned=c.startDay().atStartOfDay().plusMinutes(after.getFirst().start()*60L/s.units());var starts=new LinkedHashMap<Long,VehiclePlanningDependencies.Start>();starts.put(c.job(),new VehiclePlanningDependencies.Start(c.target(),planned));
|
||||
var warnings=new CourierDispositionPlanning(db,support).dependencies(u,List.of(j),s.units(),starts,evidence);if(c.target()==c.vehicle()&&after.equals(s.before())&&Objects.equals(dateTime(j,"jb_ordertime"),planned))throw bad("Keine Änderung vorhanden.");
|
||||
var blocks=new LinkedHashMap<Key,List<List<Object>>>();
|
||||
for(long vehicle:keys.stream().map(entryValue -> entryValue.vehicle()).distinct().toList()){
|
||||
var rows=relations.rows(vehicle);evidence.add(rows);var contexts=new LinkedHashMap<Long,VehicleCustomerRelations.Context>();
|
||||
for(var r:rows){long customer=id(r,"cs_id");evidence.add(one(db.queryForList("SELECT cs_id,hq_id FROM customer WHERE cs_id=? AND hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?)",customer,mandator(db,u))));var ctx=relations.context(customer,s.units());evidence.add(ctx);contexts.put(customer,ctx);VehicleCustomerRelations.validateSchedule(VehicleAvailability.decode(text(r,"csvh_day_times")),ctx);if(id(r,"csvh_relation")<0||id(r,"csvh_relation")>1)throw bad("Ungültige Kundenbeziehung.");}
|
||||
if(vehicle==c.target()){
|
||||
var relation=rows.stream().filter(r->id(r,"cs_id")==s.customer()&&id(r,"csvh_relation")==1).findFirst().orElseThrow(()->bad("Der Kunde ist dem Zielfahrzeug nicht als erwünscht zugeordnet."));var schedule=VehicleAvailability.decode(text(relation,"csvh_day_times"));
|
||||
for(var r:after){var inactive=new HashSet<Integer>();schedule.stream().filter(cell->cell.weekday()==r.day().getDayOfWeek().getValue()&&cell.zone().equals("9999")).forEach(cell->inactive.add(cell.daytime()));for(int i=r.start();i<r.end();i++){int at=i;if(contexts.get(s.customer()).windows().stream().noneMatch(w->!inactive.contains(w.id())&&at>=w.start()&&at<w.end()))throw bad("Reservierung am "+r.day()+" liegt außerhalb der aktiven Kundeneinsatzzeiten.");}}
|
||||
}
|
||||
for(var key:keys.stream().filter(k->k.vehicle()==vehicle).toList()){
|
||||
var cal=calendars.get(key);long availability=id(cal.availability(),"vha_id");var old=db.queryForList("SELECT * FROM vehicleavailabilitytimeunits WHERE vha_id=? ORDER BY cs_id,vhat_daytime,vhat_starttimeunit,vhat_timeunits,vhat_srvz_no",availability);evidence.add(old);if(old.stream().anyMatch(r->!contexts.containsKey(id(r,"cs_id"))))throw bad("Verfügbarkeitsblöcke ohne Kundenbeziehung vorhanden.");
|
||||
boolean[] free=new boolean[cal.cells().length];for(int i=0;i<free.length;i++)free[i]=cal.cells()[i]==0;
|
||||
int earliest=key.day().equals(s.now().toLocalDate())?s.now().getHour()*s.units()+(s.now().getMinute()*s.units()+59)/60:0;evidence.add(earliest);
|
||||
var computed=new ArrayList<List<Object>>();for(var r:rows){long customer=id(r,"cs_id");for(var b:VehicleAvailability.blocks(contexts.get(customer).windows(),VehicleAvailability.decode(text(r,"csvh_day_times")),key.day().getDayOfWeek().getValue(),id(r,"csvh_relation")==1,free,earliest))computed.add(List.of(availability,b.daytime(),b.units(),customer,b.zone(),VehicleAvailability.slotName(b.start(),s.units())));}blocks.put(key,computed);
|
||||
}
|
||||
}
|
||||
var capacities=new VehicleMultiDayPlanning(db,support).capacities(s,calendars,evidence);
|
||||
evidence.add(c.target());evidence.add(after);evidence.add(blocks.entrySet().stream().map(e->List.of(e.getKey(),e.getValue())).toList());evidence.add(capacities.entrySet().stream().map(e->List.of(e.getKey(),e.getValue())).toList());return new Plan(s,calendars,blocks,capacities,after,warnings,revision(evidence));
|
||||
}
|
||||
Map<String,Object> preview(LegacyUser u,Command c){validate(u,c);return db.readTransaction(()->{
|
||||
var p=prepare(u,c);var s=p.state();var warnings=new ArrayList<>(List.of("Alle bisherigen Reservierungen dieses Auftrags werden durch die angezeigten Kalendertage ersetzt. Wochenenden werden nicht automatisch übersprungen.","Stellplätze, Tagesbemerkungen und Freigaben bleiben erhalten. Freie Kundenzeitfenster werden für alle betroffenen Tage neu berechnet."));
|
||||
warnings.addAll(p.warnings());
|
||||
var capacityWarnings=VehicleCapacityWarning.messages(p.capacities());warnings.addAll(capacityWarnings);
|
||||
if(s.articleWeight().signum()>0)warnings.add("Die Rest-Nutzlast wird je betroffenem Fahrzeugtag aus der Fahrzeugnutzlast abzüglich der Artikelgewichte aller dort reservierten Aufträge berechnet. Jeder Auftrag zählt pro Tag einmal; Stückzahlen vervielfachen das Positionsgewicht nicht.");
|
||||
if(p.capacities().values().stream().anyMatch(entryValue -> entryValue.correction()))warnings.add("Der gespeicherte Gewichtsbestand weicht von den heutigen Artikel- und Fahrzeugdaten ab. Die markierten Bestandsabweichungen werden beim Speichern ebenfalls berichtigt.");
|
||||
return fields("capacityWarning",!capacityWarnings.isEmpty(),"planRevision",p.revision(),"articleWeight",s.articleWeight(),"before",describe(s.before(),s.units()),"after",describe(p.after(),s.units()),"beforeOrdertime",text(s.job(),"jb_ordertime"),"afterOrdertime",c.startDay()+" "+VehicleDisposition.time(p.after().getFirst().start(),s.units()),"days",p.calendars().entrySet().stream().map(e->{var capacity=p.capacities().get(e.getKey());return fields("vehicle",e.getKey().vehicle(),"day",e.getKey().day().toString(),"beforeWeight",capacity.stored(),"expectedWeight",capacity.expected(),"afterWeight",capacity.after(),"payload",capacity.payload(),"beforeLoad",capacity.beforeLoad(),"afterLoad",capacity.afterLoad(),"correction",capacity.correction(),"positions",id(e.getValue().availability(),"vha_position"));}).toList(),"warnings",warnings);
|
||||
});}
|
||||
Map<String,Object> save(LegacyUser u,Command c){validate(u,c);if(!c.confirmed())throw bad("Bitte alle Reservierungstage, Gewichtsänderungen und Hinweise bestätigen.");support.transactional("job","vehicledisposition","vehicleavailability","vehicleavailabilitytimeunits","genericdatacontainer");return db.courierReplanningWriteTransaction(()->{
|
||||
board.view(u,c.day(),0,c.vehicle());board.view(u,c.day(),0,c.target());var receipt=List.of("COURIER_FDS_REPLANNING",c);if(support.receipt(u,c.requestId(),receipt)!=null)return fields("completed",true);
|
||||
var p=prepare(u,c);if(!p.revision().equals(c.planRevision()))throw VehicleDisposition.conflict();
|
||||
VehicleCapacityWarning.require(!VehicleCapacityWarning.messages(p.capacities()).isEmpty(),c.capacityConfirmed());
|
||||
db.update("UPDATE vehicledisposition SET jb_id=0 WHERE jb_id=?",c.job());
|
||||
for(var r:p.after()){
|
||||
int changed=db.update("UPDATE vehicledisposition SET jb_id=? WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslot<? AND jb_id=0",c.job(),c.target(),r.day()+"_"+VehicleAvailability.slotName(r.start(),p.state().units()),r.end()==24*p.state().units()?r.day().plusDays(1).toString():r.day()+"_"+VehicleAvailability.slotName(r.end(),p.state().units()));
|
||||
if(changed!=r.end()-r.start())throw VehicleDisposition.conflict();
|
||||
}
|
||||
for(var e:p.blocks().entrySet()){long availability=id(p.calendars().get(e.getKey()).availability(),"vha_id");var capacity=p.capacities().get(e.getKey());if(p.state().articleWeight().signum()>0)db.update("UPDATE vehicleavailability SET vha_weight=? WHERE vha_id=?",capacity.after(),availability);db.update("DELETE FROM vehicleavailabilitytimeunits WHERE vha_id=?",availability);VehicleAvailability.insertMany(db,"vehicleavailabilitytimeunits",List.of("vha_id","vhat_daytime","vhat_timeunits","cs_id","vhat_srvz_no","vhat_starttimeunit"),e.getValue());}
|
||||
db.update("UPDATE job SET jb_ordertime=?,jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",c.startDay().atStartOfDay().plusMinutes(p.after().getFirst().start()*60L/p.state().units()),c.job());
|
||||
support.audit(u,c.job(),p.state().customer(),board.courier(u),124,"Transporteur-Umplanung · Fahrzeug "+c.vehicle()+" → "+c.target()+" · bisher "+p.state().before()+" · danach "+p.after()+" · Artikelgewicht "+p.state().articleWeight()+" kg · Tageskapazitäten "+p.capacities()+" · Nutzlastausnahme bestätigt: "+c.capacityConfirmed(),c.requestId());support.receipt(u,c.requestId(),receipt,c.job());return fields("completed",true);
|
||||
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** The standard PHP rollcard within the courier's released vehicle/day/job scope. */
|
||||
@RestController
|
||||
@RequestMapping("/api/courier-portal/disposition/rollcard")
|
||||
class CourierDispositionRollcard {
|
||||
final DatabaseService db;final BusinessSupport support;
|
||||
CourierDispositionRollcard(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
|
||||
@GetMapping Map<String,Object> get(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day,@RequestParam(defaultValue="ALL") String selection){return view(ApiController.user(a),vehicle,day,selection);}
|
||||
@GetMapping("/pdf") org.springframework.http.ResponseEntity<byte[]> pdf(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day,@RequestParam(defaultValue="ALL") String selection,@RequestParam String revision){
|
||||
var data=view(ApiController.user(a),vehicle,day,selection);if(!Objects.equals(data.get("revision"),revision))throw VehicleDisposition.conflict();
|
||||
return org.springframework.http.ResponseEntity.ok().header("Cache-Control","no-store").header("Content-Disposition","attachment; filename=Rollkarte-"+vehicle+"-"+day+".pdf").contentType(java.util.Objects.requireNonNull(org.springframework.http.MediaType.APPLICATION_PDF)).body(VehicleRollcardPdf.create(data));
|
||||
}
|
||||
@SuppressWarnings("unchecked") Map<String,Object> view(LegacyUser u,long vehicle,LocalDate day,String selection){return db.readTransaction(()->{
|
||||
if(vehicle<=0)throw bad("Fahrzeug fehlt.");VehicleDisposition.date(day);if(selection==null||!Set.of("ALL","RESERVED","ACCEPTED").contains(selection))throw bad("Ungültige Rollkartenauswahl.");
|
||||
var board=new CourierDisposition(db,support);long courier=board.courier(u);var scope=board.view(u,day,0,vehicle);var vehicles=(List<Map<String,Object>>)scope.get("rows");if(vehicles.size()!=1)throw CourierPortal.denied();var row=vehicles.getFirst();
|
||||
var allowed=((List<Map<String,Object>>)row.get("jobs")).stream().map(j->id(j,"id")).toList();
|
||||
var jobs=List.<Map<String,Object>>of();
|
||||
if(!allowed.isEmpty()){
|
||||
String reserved="(j.jb_status IN (9,0) AND j.jb_offer=0 AND EXISTS (SELECT 1 FROM vehicledisposition d WHERE d.crvh_id=? AND d.jb_id=j.jb_id AND d.vhd_timeslot>=? AND d.vhd_timeslot<?))";
|
||||
String accepted="(j.jb_status=1 AND j.jb_ordertime>=? AND j.jb_ordertime<? AND EXISTS (SELECT 1 FROM courier c WHERE c.cr_id=j.cr_id AND c.cr_sid=? AND c.cr_sid<>''))";
|
||||
var args=new ArrayList<Object>(allowed);args.add(mandator(db,u));String clause;
|
||||
if(selection.equals("RESERVED")){clause=reserved;args.addAll(List.of(vehicle,day.toString(),day.plusDays(1).toString()));}
|
||||
else if(selection.equals("ACCEPTED")){clause=accepted;args.addAll(List.of(day,day.plusDays(1),text(row,"sid")));}
|
||||
else {clause="("+reserved+" OR "+accepted+")";args.addAll(List.of(vehicle,day.toString(),day.plusDays(1).toString(),day,day.plusDays(1),text(row,"sid")));}
|
||||
jobs=db.queryForList("SELECT j.jb_id,j.jb_id_parent,j.jb_ordertime,j.jb_status,j.jb_service,j.jb_totalprice,csc.cs_id,csc.csc_name,cs.cs_eid AS customer_label FROM job j JOIN costcenter csc ON csc.csc_id=j.csc_id_related JOIN customer cs ON cs.cs_id=csc.cs_id WHERE j.jb_id IN ("+CourierPortal.placeholders(allowed.size())+") AND (j.jb_storno IS NULL OR j.jb_storno IN (0,1,3)) AND j.jb_timeunits>0 AND cs.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND "+clause+" ORDER BY CASE WHEN j.jb_status=1 THEN 1 ELSE 0 END,TIME(j.jb_ordertime),j.jb_id LIMIT 301",args.toArray());
|
||||
if(jobs.size()>300)throw bad("Mehr als 300 Aufträge auf der Rollkarte. Bitte die Auswahl eingrenzen.");
|
||||
}
|
||||
var cards=new VehicleRollcards(db);var warnings=new LinkedHashSet<String>();warnings.add("Die Rollkarte enthält ausschließlich die für Ihren Zugang freigegebenen Aufträge dieses Fahrzeugs.");
|
||||
String format=cards.parameter("MASK_DISPOSITION_FUNCTIONNAME_CARTAGE_NOTE",u.headquartersId());if(format.isBlank())format=cards.parameter("MASK_DISPOSITION_FUNCTIONNAME_CARTAGE_NOTE",0);
|
||||
if(!format.isBlank()&&!Set.of("getCartageNote","getServiceCartageNote").contains(format))warnings.add("Für diese Niederlassung ist ein besonderes PHP-Rollkartenformat eingerichtet. Diese Vorschau verwendet die Standardrollkarte; zusätzliche Felder des Sonderformats sind nicht enthalten.");
|
||||
var parents=new HashSet<>(allowed);var v=fields("crvh_id",vehicle,"crvh_sid",text(row,"sid"),"crvh_vh_sign",text(row,"registration"));
|
||||
var result=cards.document(u,vehicle,day,selection,v,jobs,parents,warnings);
|
||||
if(board.courier(u)!=courier)throw CourierPortal.denied();return result;
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
|
||||
/** Uses the shared durable mail lifecycle with a courier-only authorization boundary. */
|
||||
@RestController
|
||||
@RequestMapping("/api/courier-portal/disposition/{vehicle}/rollcard/mail")
|
||||
class CourierDispositionRollcardMail extends VehicleRollcardMail {
|
||||
CourierDispositionRollcardMail(DatabaseService db,BusinessSupport support,AppointmentMailTransport transport){super(db,support,transport);}
|
||||
@Override void validateAccess(LegacyUser u){new CourierDisposition(db,support).courier(u);}
|
||||
@Override LegacyUser scoped(LegacyUser original,Long headquarters){if(headquarters!=null)throw CourierPortal.denied();validateAccess(original);return original;}
|
||||
@Override void vehicleAccess(LegacyUser u,long vehicle,LocalDate day){new CourierDisposition(db,support).view(u,day,0,vehicle);}
|
||||
@Override void customerAccess(LegacyUser u,long customer){one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?)",customer,mandator(db,u)));}
|
||||
@Override Map<String,Object> rollcard(LegacyUser u,long vehicle,LocalDate day,String selection){return new CourierDispositionRollcard(db,support).view(u,vehicle,day,selection);}
|
||||
@Override boolean claim(LegacyUser original,java.util.function.Supplier<Boolean> work){return db.courierRollcardMailWriteTransaction(work);}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** Exact PHP FDS identifiers, restricted to the courier's released vehicle days. */
|
||||
@RestController
|
||||
@RequestMapping("/api/courier-portal/disposition/job-search")
|
||||
class CourierDispositionSearch {
|
||||
final DatabaseService db;final CourierDisposition board;
|
||||
CourierDispositionSearch(DatabaseService db,BusinessSupport support){this.db=db;board=new CourierDisposition(db,support);}
|
||||
@GetMapping Map<String,Object> get(Authentication a,@RequestParam String q,@RequestParam(defaultValue="0") int offset){return search(ApiController.user(a),q,offset);}
|
||||
@SuppressWarnings("unchecked") Map<String,Object> search(LegacyUser u,String input,int offset){return db.readTransaction(()->{
|
||||
long courier=board.courier(u),mandator=mandator(db,u);
|
||||
if(input==null||input.isBlank()||input.length()>100||input.indexOf('\0')>=0||offset<0||offset>100000||offset%50!=0)throw bad("Bitte eine Auftragsnummer oder Suchkennung mit höchstens 100 Zeichen eingeben.");
|
||||
String q=input.trim();var modes=db.queryForList("SELECT md_id,par_value FROM parameter WHERE par_key='GLOBAL_USE_RELATED_CUSTOMER' AND hq_id=0 AND emp_id=0");
|
||||
if(modes.size()>1||!modes.isEmpty()&&(id(modes.getFirst(),"md_id")!=0||!Set.of("0","1").contains(text(modes.getFirst(),"par_value"))))throw bad("Der globale FDS-Betriebsmodus ist nicht eindeutig konfiguriert.");
|
||||
boolean related=!modes.isEmpty()&&text(modes.getFirst(),"par_value").equals("1");
|
||||
String suffix=q.endsWith("/1")?"1":q.endsWith("/2")?"2":"",number=related&&!suffix.isEmpty()?q.substring(0,q.length()-2):q;
|
||||
long job=0;try{if(number.matches("[0-9]{1,10}"))job=Long.parseLong(number);}catch(NumberFormatException ignored){}
|
||||
var match=new ArrayList<String>();var args=new ArrayList<Object>();
|
||||
args.add(courier);args.add(courier);args.add(mandator);args.add(mandator);args.add(courier);args.add(courier);
|
||||
if(job>0){
|
||||
if(!related||!suffix.equals("2")){match.add("j.jb_id=?");args.add(job);}
|
||||
if(related&&!suffix.equals("1")){match.add("(j.jb_id_parent=? AND EXISTS (SELECT 1 FROM job parent WHERE parent.jb_id=j.jb_id_parent AND parent.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?)))");args.add(job);args.add(mandator);}
|
||||
}
|
||||
if(related||job==0){
|
||||
match.add("EXISTS (SELECT 1 FROM tour t WHERE t.jb_id=j.jb_id AND t.tr_commission_no=?)");args.add(q);
|
||||
match.add("EXISTS (SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=j.jb_id AND g.gdc_gen_fieldname IN ('jb_search','info_0') AND g.gdc_content=?)");args.add(q);
|
||||
}
|
||||
if(match.isEmpty())return fields("q",q,"related",related,"rows",List.of(),"offset",offset,"hasMore",false);
|
||||
String hidden=related?"":" AND EXISTS (SELECT 1 FROM genericdatacontainer h WHERE h.gdc_obj_type='jb' AND h.gdc_obj_id=j.jb_id AND h.gdc_gen_fieldname='hide' AND h.gdc_content='1')";
|
||||
args.add(offset);
|
||||
var found=db.queryForList("SELECT DISTINCT j.jb_id AS id,j.jb_status AS status,DATE_FORMAT(j.jb_ordertime,'%Y-%m-%d %H:%i') AS ordertime,v.crvh_id AS vehicle,v.crvh_sid AS sid,DATE_FORMAT(a.vha_day,'%Y-%m-%d') AS day FROM courier driver JOIN couriervehicle v ON v.crvh_sid=driver.cr_sid JOIN vehicleavailability a ON a.crvh_id=v.crvh_id JOIN job j ON (EXISTS (SELECT 1 FROM vehicledisposition d WHERE d.jb_id=j.jb_id AND d.crvh_id=v.crvh_id AND d.vhd_timeslot>=CAST(a.vha_day AS CHAR) AND d.vhd_timeslot<CAST(DATE_ADD(a.vha_day,INTERVAL 1 DAY) AS CHAR)) OR (DATE(j.jb_ordertime)=a.vha_day AND ((j.jb_status=2 AND j.cr_sid=v.crvh_sid) OR (j.jb_status=1 AND EXISTS (SELECT 1 FROM courier ac WHERE ac.cr_id=j.cr_id AND ac.cr_sid=v.crvh_sid))))) WHERE driver.cr_id_parent=? AND (v.cr_id=? OR v.cr_id=driver.cr_id) AND TRIM(v.crvh_sid)<>'' AND COALESCE(v.crvh_modify_status,0)<>3 AND driver.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND j.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND (a.vha_blocked & 2)=2 AND a.vha_day BETWEEN '2000-01-01' AND '2100-12-31' AND j.jb_timeunits>0 AND COALESCE(j.jb_storno,0) IN (0,1,3) AND (j.cr_id=? OR (j.jb_status=0 AND COALESCE(j.cr_id_order,0)<>0 AND EXISTS (SELECT 1 FROM vehicledisposition d JOIN couriervehicle owned ON owned.crvh_id=d.crvh_id WHERE d.jb_id=j.jb_id AND owned.cr_id=?))) AND ("+String.join(" OR ",match)+")"+hidden+" ORDER BY day,sid,vehicle,id LIMIT 51 OFFSET ?",args.toArray());
|
||||
var rows=new ArrayList<Map<String,Object>>();var cache=new HashMap<String,Map<String,Object>>();
|
||||
for(var row:found.stream().limit(50).toList()){
|
||||
long vehicle=id(row,"vehicle");LocalDate day=LocalDate.parse(text(row,"day"));String key=vehicle+"/"+day;
|
||||
var scoped=cache.computeIfAbsent(key,k->((List<Map<String,Object>>)board.view(u,day,0,vehicle).get("rows")).getFirst());
|
||||
if(((List<Map<String,Object>>)scoped.get("jobs")).stream().anyMatch(j->id(j,"id")==id(row,"id")))rows.add(row);
|
||||
}
|
||||
if(board.courier(u)!=courier)throw CourierPortal.denied();
|
||||
return fields("q",q,"related",related,"rows",rows,"offset",offset,"hasMore",found.size()>50);
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** Driver controls from the current admin/courier_special.php. */
|
||||
@RestController
|
||||
@RequestMapping("/api/master-data/couriers/{courier}/driver-profile")
|
||||
class CourierDriverProfile {
|
||||
final DatabaseService db;final BusinessSupport support;final MasterData masters;
|
||||
CourierDriverProfile(DatabaseService db,BusinessSupport support,MasterData masters){this.db=db;this.support=support;this.masters=masters;}
|
||||
record Input(String nationality,LocalDate birthdate,Long parent,Boolean appBlocked){}
|
||||
record Save(String requestId,String revision,Input values){}
|
||||
record Logout(String requestId,String revision,boolean confirmed){}
|
||||
record Snapshot(Map<String,Object> courier,Map<String,Object> person,List<Map<String,Object>> countries,List<Map<String,Object>> settings,List<Map<String,Object>> branches){}
|
||||
@GetMapping Object get(Authentication a,@PathVariable long courier){return view(ApiController.user(a),courier);}
|
||||
@GetMapping("/parents") Object parents(Authentication a,@PathVariable long courier,@RequestParam(defaultValue="")String q,@RequestParam(defaultValue="0")long headquarters){return parents(ApiController.user(a),courier,q,headquarters);}
|
||||
@PutMapping Object save(Authentication a,@PathVariable long courier,@RequestBody Save r){return save(ApiController.user(a),courier,r);}
|
||||
@PostMapping("/logout") Object logout(Authentication a,@PathVariable long courier,@RequestBody Logout r){return logout(ApiController.user(a),courier,r);}
|
||||
Snapshot state(LegacyUser u,long courier,boolean lock){
|
||||
masters.access(u,"couriers");if(courier<=0)throw bad("Bitte zuerst den Transporteur anlegen.");
|
||||
var raw=masters.load(u,"couriers",courier,lock);var cr=new LinkedHashMap<String,Object>();for(String key:List.of("cr_id","hq_id","usr_id","cr_eid","cr_id_parent","cr_filter","cr_app_blocked","cmp_comp"))cr.put(key,raw.get(key));
|
||||
String sql="SELECT usr_id,hq_id,usr_type,usr_country,usr_birthdate,usr_modify FROM user WHERE usr_id=?";
|
||||
var person=one(lock?db.lockRows(sql,id(cr,"usr_id")):db.queryForList(sql,id(cr,"usr_id")));
|
||||
if(id(person,"hq_id")!=u.headquartersId()||id(person,"usr_type")!=3||db.queryForList("SELECT cr_id FROM courier WHERE usr_id=?",id(cr,"usr_id")).size()!=1||!db.queryForList("SELECT emp_id FROM employee WHERE usr_id=?",id(cr,"usr_id")).isEmpty())throw bad("Das Fahrer-Benutzerkonto ist nicht eindeutig diesem Transporteur zugeordnet.");
|
||||
var settings=db.queryForList("SELECT par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key IN ('MASK_COUNTRY_GROUP','MASK_COUNTRY_DEFAULT') ORDER BY par_key",u.headquartersId());for(var r:settings)if(settings.stream().filter(x->text(x,"par_key").equals(text(r,"par_key"))).count()!=1)throw bad("Die Ländervorgaben sind mehrfach vorhanden.");
|
||||
String group=settings.stream().filter(x->text(x,"par_key").equals("MASK_COUNTRY_GROUP")).map(x->text(x,"par_value")).findFirst().orElse("1");if(group.isBlank())group="1";if(!group.matches("[0-9]{1,9}"))throw bad("Die Ländergruppe ist ungültig.");
|
||||
var countries=db.queryForList("SELECT cou_iso_2 AS code,cou_name AS label FROM phoenix_special.country WHERE cou_nation=? ORDER BY cou_name,cou_iso_2",Integer.parseInt(group));
|
||||
if(countries.size()>500||countries.stream().anyMatch(c->!text(c,"code").matches("[A-Z]{2}"))||countries.stream().map(c->text(c,"code")).distinct().count()!=countries.size())throw bad("Die Nationalitätenliste ist ungültig.");
|
||||
return new Snapshot(cr,person,countries,settings,MasterDirectoryScope.access(db,u,1).branches());
|
||||
}
|
||||
static Input values(Snapshot s){String date=text(s.person(),"usr_birthdate");return new Input(text(s.person(),"usr_country"),date.isBlank()||date.startsWith("0000-")?null:LocalDate.parse(date.substring(0,10)),id(s.courier(),"cr_id_parent"),id(s.courier(),"cr_app_blocked")==1);}
|
||||
String parentLabel(Snapshot s,long parent){if(parent<=0)return "Kein Unternehmer zugeordnet";var branches=s.branches().stream().map(r->id(r,"id")).toList();var args=new ArrayList<Object>();args.add(parent);args.addAll(branches);var rows=db.queryForList("SELECT cr.cr_id,cr.cr_eid,cmp.cmp_comp,h.hq_mnemonic FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id JOIN headquarters h ON h.hq_id=cr.hq_id WHERE cr.cr_id=? AND cr.hq_id IN ("+CourierPortal.placeholders(branches.size())+")",args.toArray());return rows.size()==1?label(rows.getFirst())+" · "+text(rows.getFirst(),"hq_mnemonic"):"Bestehende Zuordnung #"+parent+" (hier nicht verfügbar)";}
|
||||
static String label(Map<String,Object> r){return text(r,"cr_eid")+" · "+text(r,"cmp_comp");}
|
||||
Map<String,Object> view(LegacyUser u,long courier){return db.readTransaction(()->{var s=state(u,courier,false);var commands=db.queryForList("SELECT cmde_exec_time AS requestedAt,cmde_exec_timelimit AS validUntil FROM phoenix_pda.commandexec WHERE cr_id=? AND hq_id=? AND cmd_command='2' AND cmde_mode=1 ORDER BY cmde_id DESC LIMIT 1",courier,u.headquartersId());return fields("id",courier,"label",label(s.courier()),"values",values(s),"revision",revision(s),"countries",s.countries(),"parentLabel",parentLabel(s,id(s.courier(),"cr_id_parent")),"headquarters",s.branches(),"currentHeadquarters",u.headquartersId(),"lastLogout",commands.isEmpty()?null:fields("requestedAt",text(commands.getFirst(),"requestedAt"),"validUntil",text(commands.getFirst(),"validUntil")));});}
|
||||
Map<String,Object> parents(LegacyUser u,long courier,String query){return parents(u,courier,query,u.headquartersId());}
|
||||
Map<String,Object> parents(LegacyUser u,long courier,String query,long headquarters){return db.readTransaction(()->{var s=state(u,courier,false);long hq=headquarters==0?u.headquartersId():headquarters;if(s.branches().stream().noneMatch(b->id(b,"id")==hq))throw MasterDirectoryScope.denied();String q=EmployeeAdministration.optional(query,100,"Unternehmersuche");var rows=db.queryForList("SELECT cr.cr_id AS id,cr.cr_eid,cmp.cmp_comp FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE cr.hq_id=? AND cr.cr_id<>? AND (LOCATE(?,cr.cr_eid)>0 OR LOCATE(?,cmp.cmp_comp)>0) ORDER BY cmp.cmp_comp,cr.cr_eid,cr.cr_id LIMIT 51",hq,courier,q,q);String branch=text(s.branches().stream().filter(b->id(b,"id")==hq).findFirst().orElseThrow(),"label");return fields("items",rows.stream().limit(50).map(r->fields("id",id(r,"id"),"label",label(r)+" · "+branch)).toList(),"truncated",rows.size()>50);});}
|
||||
void parent(Snapshot s,long courier,long parent){var allowed=s.branches().stream().map(b->id(b,"id")).toList();var seen=new HashSet<Long>();seen.add(courier);long current=parent;for(int level=0;current>0;level++){if(level>=64||!seen.add(current))throw bad("Die Unternehmerzuordnung darf keinen Kreis bilden.");var rows=db.lockRows("SELECT cr_id_parent,hq_id FROM courier WHERE cr_id=?",current);if(rows.size()!=1||!allowed.contains(id(rows.getFirst(),"hq_id")))throw bad("Der Unternehmer muss zu einer freigegebenen Niederlassung gehören.");current=id(rows.getFirst(),"cr_id_parent");}}
|
||||
// The first statement must be a locking read: a prior plain SELECT would establish a
|
||||
// repeatable-read snapshot that misses a receipt committed while this transaction waits.
|
||||
void writeLock(LegacyUser u){if(db.lockRows("SELECT member.hq_id FROM mandatorheadquarters member JOIN mandatorheadquarters origin ON origin.md_id=member.md_id WHERE origin.hq_id=? ORDER BY member.hq_id",u.originalHeadquartersId()).isEmpty())throw MasterDirectoryScope.denied();}
|
||||
static String filter(String before,boolean blocked){var tokens=new ArrayList<String>();for(String value:before.split(","))if(!value.isBlank()&&!value.equals("VA"))tokens.add(value);if(!blocked)tokens.add("VA");String result=tokens.isEmpty()?"":","+String.join(",",tokens)+",";if(result.length()>255)throw bad("Die Fahrerfilter sind zu lang.");return result;}
|
||||
void logout(LegacyUser u,long courier,String requestId){var now=LocalDateTime.now().withNano(0);db.update("UPDATE courier SET cr_availabletime=?,cr_locationzipcode='LOGOUT' WHERE cr_id=? AND hq_id=?",now,courier,u.headquartersId());db.insert("`phoenix_pda`.commandexec",fields("cr_id",courier,"hq_id",u.headquartersId(),"cmd_command","2","cmde_mode",1,"cmde_param","","cmde_exec_time",now,"cmde_exec_timelimit",now.plusHours(16)));support.audit(u,0,0,courier,50,"STATUS_LOGOUT=PDA-LOGOUT DURCH WEB",requestId);}
|
||||
Map<String,Object> save(LegacyUser u,long courier,Save r){
|
||||
masters.access(u,"couriers");if(r==null||r.values()==null)throw bad("Fahrerprofil fehlt.");BusinessSupport.token(r.requestId());var v=r.values();if(v.nationality()==null||v.parent()==null||v.parent()<0||v.appBlocked()==null||!v.nationality().matches("[A-Z]{1,10}|"))throw bad("Fahrerprofil ist ungültig.");if(v.birthdate()!=null&&(v.birthdate().isBefore(LocalDate.of(1900,1,1))||v.birthdate().isAfter(LocalDate.now())))throw bad("Bitte einen gültigen Geburtstag eingeben.");
|
||||
support.transactional("mandatorheadquarters","headquarters","courier","user","genericdatacontainer");if(v.appBlocked())support.transactional("phoenix_pda.commandexec");Object intent=List.of("COURIER_DRIVER_PROFILE",courier,r);
|
||||
db.writeTransaction(()->{writeLock(u);var s=state(u,courier,true);if(support.receipt(u,r.requestId(),intent)!=null)return null;unchanged(s,r.revision());
|
||||
if(!v.nationality().equals(text(s.person(),"usr_country"))&&!v.nationality().isBlank()&&s.countries().stream().noneMatch(c->text(c,"code").equals(v.nationality())))throw bad("Bitte eine Nationalität aus der aktuellen Länderliste wählen.");if(v.parent()!=id(s.courier(),"cr_id_parent"))parent(s,courier,v.parent());
|
||||
db.update("UPDATE user SET usr_country=?,usr_birthdate=? WHERE usr_id=?",v.nationality(),v.birthdate(),id(s.person(),"usr_id"));db.update("UPDATE courier SET cr_id_parent=?,cr_app_blocked=?,cr_filter=? WHERE cr_id=?",v.parent(),v.appBlocked()?1:0,filter(text(s.courier(),"cr_filter"),v.appBlocked()),courier);
|
||||
if(v.appBlocked())logout(u,courier,r.requestId());support.audit(u,0,0,courier,37,"DRIVER_PROFILE="+support.encode(v),r.requestId());support.receipt(u,r.requestId(),intent,courier);return null;});return view(u,courier);
|
||||
}
|
||||
Map<String,Object> logout(LegacyUser u,long courier,Logout r){masters.access(u,"couriers");if(r==null||!r.confirmed())throw bad("Bitte die Geräte-Abmeldung bestätigen.");BusinessSupport.token(r.requestId());support.transactional("mandatorheadquarters","headquarters","courier","genericdatacontainer","phoenix_pda.commandexec");Object intent=List.of("COURIER_DRIVER_LOGOUT",courier,r);db.writeTransaction(()->{writeLock(u);var s=state(u,courier,true);if(support.receipt(u,r.requestId(),intent)!=null)return null;unchanged(s,r.revision());logout(u,courier,r.requestId());support.receipt(u,r.requestId(),intent,courier);return null;});return view(u,courier);}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
|
||||
/** Visible fields and vehicle exclusions in the current courier_special.php. */
|
||||
final class CourierMaskRules {
|
||||
final Set<String> hidden;
|
||||
final Set<Long> excluded;
|
||||
CourierMaskRules(Function<String,String> local,Function<String,String> global){
|
||||
Function<String,String> fallback=k->{String v=local.apply(k);return v.isEmpty()?global.apply(k):v;};
|
||||
var fields=new LinkedHashSet<String>();
|
||||
if(fallback.apply("MASK_HIDE_CR_VHT_INV").equals("1"))fields.add("vht_id_inv");
|
||||
if(fallback.apply("MASK_HIDE_CR_OUTLAY").equals("1"))fields.add("cr_outlay");
|
||||
if(fallback.apply("MASK_HIDE_CR_DISPOSITION").equals("1"))fields.add("cr_disposition");
|
||||
if(fields.contains("cr_outlay")&&fields.contains("cr_disposition"))fields.add("cmp_no_longhaul");
|
||||
hidden=Set.copyOf(fields);
|
||||
// Unlike the hide flags, PHP reads this list from the current HQ only.
|
||||
String raw=local.apply("MASK_EXCLUDE_VHT_IDS").trim();var ids=new LinkedHashSet<Long>();
|
||||
if(!raw.isEmpty())for(String token:raw.split(",",-1)){
|
||||
String value=token.trim();if(!value.matches("[0-9]{1,9}")||ids.size()>=500)throw bad("Die ausgeschlossenen Fahrzeugtypen der Niederlassung sind ungültig.");
|
||||
ids.add(Long.parseLong(value));
|
||||
}
|
||||
excluded=Set.copyOf(ids);
|
||||
}
|
||||
static CourierMaskRules read(DatabaseService db,LegacyUser u){var c=new OrderEntryCatalog(db);return new CourierMaskRules(k->c.parameter(u.headquartersId(),k),k->c.parameter(0,k));}
|
||||
void decorate(Map<String,Object> view){
|
||||
@SuppressWarnings("unchecked") var fields=(List<MasterData.Field>)view.get("fields");
|
||||
view.put("fields",fields.stream().filter(f->!hidden.contains(f.key())).toList());
|
||||
@SuppressWarnings("unchecked") var options=(Map<String,Object>)view.get("options");
|
||||
@SuppressWarnings("unchecked") var types=(List<Map<String,Object>>)options.get("vehicle");
|
||||
options.put("vehicle",types.stream().filter(t->!excluded.contains(id(t,"id"))).toList());
|
||||
}
|
||||
void validate(Map<String,Object> old,Map<String,Object> values){
|
||||
for(String key:hidden)if(id(old,key)!=id(values,key))throw bad("Dieses Kurierfeld ist durch die Niederlassung ausgeblendet. Bitte den gespeicherten Stand neu laden.");
|
||||
long type=id(values,"vht_id_inv");
|
||||
if(type!=id(old,"vht_id_inv")&&excluded.contains(type))throw bad("Dieser Abrechnungsfahrzeugtyp ist in der Niederlassung nicht zur Auswahl freigegeben.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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.BillingService.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Separate type-3 workspace based on invoice/inv_details_cr.php. */
|
||||
@RestController
|
||||
@RequestMapping("/api/courier-portal")
|
||||
class CourierPortal {
|
||||
record Query(LocalDate from,LocalDate to,int status,String vehicle,boolean invoiceText){}
|
||||
record Remark(String requestId,long job,String revision,String value){}
|
||||
record Password(String requestId,String revision,String currentPassword,String password,String confirmation){@Override public String toString(){return "CourierPassword[redacted]";}}
|
||||
record Scope(Map<String,Object> account,long mandator,List<Long> couriers,List<Map<String,Object>> vehicles,boolean running,boolean invoiceText,boolean ownInvoiceText,boolean commissionColumns,boolean remarks,boolean parent,CourierStatementPricing.Policy pricing,BigDecimal perJob){}
|
||||
final DatabaseService db;final BusinessSupport support;
|
||||
CourierPortal(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
|
||||
@GetMapping Object get(Authentication a){return options(ApiController.user(a));}
|
||||
@PostMapping("/query") Object query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);}
|
||||
@PostMapping("/remark") Object remark(Authentication a,@RequestBody Remark r){return remark(ApiController.user(a),r);}
|
||||
@GetMapping("/account") Object account(Authentication a){return account(ApiController.user(a));}
|
||||
@PostMapping("/password") Object password(Authentication a,@RequestBody Password p){return password(ApiController.user(a),p);}
|
||||
|
||||
Scope scope(LegacyUser u){
|
||||
var account=CourierPortalScope.verify(db,u);long md=mandator(db,u);var catalog=new OrderEntryCatalog(db);long hq=u.originalHeadquartersId();
|
||||
boolean parent=catalog.enabled(hq,"INV_SHOW_CR_ID_PARENT"),remarks=catalog.enabled(hq,"INV_SHOW_JB_CR_REMARK"),columns=catalog.enabled(hq,"INV_JB_CR_PRICE");
|
||||
var couriers=new ArrayList<Long>();couriers.add(id(account,"cr_id"));if(parent)for(var row:db.queryForList("SELECT cr_id FROM courier WHERE cr_id_parent=? AND hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) ORDER BY cr_id",id(account,"cr_id"),md))couriers.add(id(row,"cr_id"));
|
||||
if(couriers.size()>5000||new HashSet<>(couriers).size()!=couriers.size())throw bad("Die Transporteurzuordnung ist nicht eindeutig oder zu groß.");
|
||||
var vehicles=db.queryForList("SELECT crvh_sid AS sid,cr_id,crvh_prov FROM couriervehicle WHERE cr_id IN ("+placeholders(couriers.size())+") ORDER BY crvh_sid",couriers.toArray());
|
||||
if(vehicles.stream().map(r->text(r,"sid")).distinct().count()!=vehicles.size())throw bad("Die Fahrzeugnummern sind nicht eindeutig.");
|
||||
boolean stored=catalog.enabled(hq,"MASK_CR_PRICE_MODE");LocalDateTime from=null;String value=catalog.parameter(hq,"MASK_CR_PRICE_MODE_DATE");if(stored&&!value.isBlank())try{from=CourierPriceModeDate.parse(value);}catch(RuntimeException e){throw bad("MASK_CR_PRICE_MODE_DATE ist ungültig.");}
|
||||
var branch=one(db.queryForList("SELECT hq_cr_prov,hq_prov FROM headquarters WHERE hq_id=?",hq));BigDecimal perJob=nz(decimal(branch,"hq_cr_prov")).add(nz(decimal(branch,"hq_prov")));
|
||||
return new Scope(account,md,couriers,vehicles,remarks,catalog.enabled(hq,"INV_SHOW_INVOICE_TEXT"),catalog.enabled(hq,"INV_JB_INVOICE_CR"),columns,remarks&&columns,parent,new CourierStatementPricing.Policy(stored,from,columns),perJob.max(BigDecimal.ZERO));
|
||||
}
|
||||
static String placeholders(int size){return String.join(",",Collections.nCopies(size,"?"));}
|
||||
Map<String,Object> options(LegacyUser u){return db.readTransaction(()->{
|
||||
var s=scope(u);var names=db.queryForList("SELECT cmp_comp FROM company WHERE cmp_id=?",id(s.account(),"cmp_id"));
|
||||
return fields("name",names.isEmpty()?u.username():text(one(names),"cmp_comp"),"canRunning",s.running(),"canInvoiceText",s.invoiceText(),"canRemarks",s.remarks(),"commissionColumns",s.commissionColumns(),"canVehicleFilter",s.parent(),"vehicles",s.parent()?s.vehicles().stream().map(v->fields("id",text(v,"sid"),"label",text(v,"sid"))).toList():List.of(),"bourse",new OrderEntryCatalog(db).enabled(u.headquartersId(),"SYSTEM_BOURSE_MODE"),"disposition",CourierDisposition.enabled(db,u,id(s.account(),"cr_id")),"archiveAvailable",false);
|
||||
});}
|
||||
static void check(Query q,Scope s){if(q==null||q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.to().isBefore(q.from())||q.from().plusYears(1).isBefore(q.to()))throw bad("Bitte einen Zeitraum von höchstens einem Jahr wählen.");if(q.status()!=2&&(q.status()!=1||!s.running()))throw denied();if(q.vehicle()!=null&&!q.vehicle().isBlank()&&(!s.parent()||s.vehicles().stream().noneMatch(v->text(v,"sid").equals(q.vehicle()))))throw denied();if(q.invoiceText()&&!s.invoiceText())throw denied();}
|
||||
static org.springframework.security.access.AccessDeniedException denied(){return new org.springframework.security.access.AccessDeniedException("Diese Transporteurdaten oder Aktion sind nicht freigegeben.");}
|
||||
List<Map<String,Object>> jobs(Scope s,Query q){var args=new ArrayList<Object>(s.couriers());args.add(s.mandator());args.add(q.status());args.add(q.from().atStartOfDay());args.add(q.to().plusDays(1).atStartOfDay());String date=q.status()==1?"jb_ordertime":"jb_finishtime";
|
||||
String sql="SELECT * FROM job WHERE cr_id IN ("+placeholders(s.couriers().size())+") AND hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND jb_status=? AND COALESCE(jb_offer,0)=0 AND "+date+">=? AND "+date+"<?";
|
||||
if(q.vehicle()!=null&&!q.vehicle().isBlank()){sql+=" AND cr_sid=?";args.add(q.vehicle());}var rows=db.queryForList(sql+" ORDER BY jb_finishtime,jb_id LIMIT 2001",args.toArray());if(rows.size()>2000)throw bad("Die Auswahl enthält mehr als 2000 Aufträge. Bitte den Zeitraum eingrenzen.");return rows;
|
||||
}
|
||||
Map<String,Object> query(LegacyUser u,Query q){return db.readTransaction(()->{
|
||||
var s=scope(u);check(q,s);var jobs=jobs(s,q);var result=new ArrayList<Map<String,Object>>();BigDecimal normal=BigDecimal.ZERO,free=BigDecimal.ZERO;
|
||||
var mandatory=Arrays.stream(new OrderEntryCatalog(db).parameter(u.headquartersId(),"MASK_MANDATORY_FILTERS").split(",")).map(entryValue -> entryValue.strip()).filter(v->!v.isEmpty()).collect(java.util.stream.Collectors.toSet());
|
||||
for(var j:jobs){long jid=id(j,"jb_id");var creatorRows=db.queryForList("SELECT u.usr_name,c.csc_name FROM employee e JOIN user u ON u.usr_id=e.usr_id LEFT JOIN costcenter c ON c.csc_id=? WHERE e.usr_id=?",id(j,"csc_id"),id(j,"emp_id"));String creator=creatorRows.size()==1?(text(creatorRows.getFirst(),"csc_name")+", "+text(creatorRows.getFirst(),"usr_name")).replaceAll("^, ",""):"(System)";var stations=db.queryForList("SELECT t.tr_sort,t.tr_comp,t.tr_person,t.tr_remark,t.tr_hsno,a.ad_street,a.ad_zipcode,a.ad_city FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort",jid);
|
||||
var services=db.queryForList("SELECT tr_sort,trs_srv_name,trs_srvt_name,trs_price,trs_discount FROM tourservice WHERE jb_id=? ORDER BY tr_sort,srv_id,srvt_id,csc_id",jid);
|
||||
var vehicle=s.vehicles().stream().filter(v->text(v,"sid").equals(text(j,"cr_sid"))).toList();if(vehicle.size()!=1||id(vehicle.getFirst(),"cr_id")!=id(j,"cr_id"))throw bad("Auftrag #"+jid+": Die Fahrzeugzuordnung ist nicht eindeutig.");
|
||||
var payments=id(j,"hq_id")>1000?db.queryForList("SELECT jbp_price,jbp_counter,jbp_mode FROM jobpayment WHERE jb_id=?",jid):List.<Map<String,Object>>of();
|
||||
var amount=CourierStatementPricing.calculate(j,services,nz(decimal(vehicle.getFirst(),"crvh_prov")),s.pricing(),stations.size(),payments);
|
||||
var types=db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",id(j,"vht_id"));
|
||||
if(types.size()>1)throw bad("Der Fahrzeugtyp ist mehrdeutig.");
|
||||
var detail=db.queryForList("SELECT mt_sort,jbprc_price FROM jobprice WHERE jb_id=? AND mt_sort IN (1,3,9)",jid);
|
||||
var service=detail.stream().filter(p->id(p,"mt_sort")==3).toList();if(service.size()>1)throw bad("Die Servicevergütung ist mehrdeutig.");
|
||||
BigDecimal serviceFee=service.isEmpty()?BigDecimal.ZERO:nz(decimal(service.getFirst(),"jbprc_price"));
|
||||
String vehicleType=types.isEmpty()?"":text(types.getFirst(),"mt_value");boolean fuelIncluded=detail.stream().anyMatch(p->Set.of(1L,9L).contains(id(p,"mt_sort"))&&!text(p,"jbprc_price").isBlank());
|
||||
boolean freeColumn=s.commissionColumns()&&amount.commissionFree();if(freeColumn)free=free.add(amount.total());else normal=normal.add(amount.total());
|
||||
String invoice=text(j,s.ownInvoiceText()?"jb_invtext_cr":"jb_invtext");var stops=stations.stream().map(t->fields("company",text(t,"tr_comp"),"address",java.util.stream.Stream.of((text(t,"ad_street")+" "+text(t,"tr_hsno")).strip(),(text(t,"ad_zipcode")+" "+text(t,"ad_city")).strip()).filter(v->!v.isEmpty()).collect(java.util.stream.Collectors.joining(", ")),"person",text(t,"tr_person"),"remark",text(t,"tr_remark"))).toList();
|
||||
var filters=Arrays.stream(text(j,"jb_cr_filter").split(",")).map(entryValue -> entryValue.strip()).filter(v->!v.isEmpty()&&!mandatory.contains(v)).toList();
|
||||
result.add(fields("id",jid,"creator",creator,"vehicle",s.parent()?text(j,"cr_sid"):"","ordered",dateTime(j,"jb_ordertime"),"finished",dateTime(j,"jb_finishtime"),"booked",dateTime(j,"jb_booktime"),"stops",id(j,"jb_storno")==3||id(j,"jb_storno")==4?List.of():stops,"correction",id(j,"jb_storno")==3||id(j,"jb_storno")==4?invoice:"","invoiceText",q.invoiceText()?invoice:"","filters",filters,"lines",amount.lines(),"vehicleType",vehicleType,"serviceFee",money(serviceFee),"fuelIncluded",fuelIncluded,"incomplete",id(j,"jb_incomplete")==1,"amount",amount.total(),"commissionFree",freeColumn,"remark",s.remarks()?text(j,"jb_cr_remark"):"","canRemark",s.remarks()&&!OrderOperations.exported(j),"revision",remarkRevision(s,j),"settlement",id(j,"hq_id")>1000));
|
||||
}
|
||||
BigDecimal fee=money(s.perJob().multiply(BigDecimal.valueOf(jobs.size())));
|
||||
return fields("rows",result,"normal",money(normal),"commissionFree",money(free),"perJob",money(s.perJob()),"commission",fee,"settlement",money(normal.subtract(fee)),"commissionColumns",s.commissionColumns(),"from",q.from(),"to",q.to());
|
||||
});}
|
||||
static String remarkRevision(Scope s,Map<String,Object> j){return revision(List.of(id(s.account(),"cr_id"),s.couriers(),s.remarks(),id(j,"jb_id"),id(j,"cr_id"),id(j,"hq_id"),id(j,"jb_status"),id(j,"jb_offer"),OrderOperations.exported(j),text(j,"jb_cr_remark")));}
|
||||
Map<String,Object> remark(LegacyUser u,Remark r){if(r==null||r.job()<=0||r.value()==null||r.value().length()>255||r.value().indexOf('\0')>=0)throw bad("Die Bemerkung darf höchstens 255 Zeichen enthalten.");BusinessSupport.token(r.requestId());support.transactional("job","genericdatacontainer");Object intent=List.of("COURIER_REMARK",r);
|
||||
return db.courierPortalWriteTransaction(()->{var s=scope(u);if(!s.remarks())throw denied();var j=one(db.queryForList("SELECT * FROM job WHERE jb_id=?",r.job()));if(!s.couriers().contains(id(j,"cr_id"))||!List.of(1L,2L).contains(id(j,"jb_status"))||id(j,"jb_offer")!=0||OrderOperations.exported(j)||db.queryForList("SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=? AND hq_id=?",s.mandator(),id(j,"hq_id")).size()!=1)throw denied();if(support.receipt(u,r.requestId(),intent)!=null)return fields("completed",true);
|
||||
if(!remarkRevision(s,j).equals(r.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Zuordnung oder Bemerkung wurde zwischenzeitlich geändert. Bitte neu laden.");
|
||||
db.update("UPDATE job SET jb_cr_remark=? WHERE jb_id=?",r.value(),r.job());support.audit(u,r.job(),0,id(j,"cr_id"),2,"COURIER_PORTAL_REMARK | BEFORE="+support.encode(text(j,"jb_cr_remark"))+" | AFTER="+support.encode(r.value()),r.requestId());support.receipt(u,r.requestId(),intent,r.job());return fields("completed",true);
|
||||
});
|
||||
}
|
||||
static String accountRevision(Map<String,Object> a){return revision(List.of(id(a,"usr_id"),id(a,"cr_id"),text(a,"credential_stamp")));}
|
||||
Map<String,Object> account(LegacyUser u){return db.readTransaction(()->{var a=CourierPortalScope.verify(db,u);return fields("account",text(a,"usr_account"),"revision",accountRevision(a),"twoFactorActive",id(a,"usr_totp_activated")==1,"passwordChanged",text(a,"usr_password_modify"));});}
|
||||
boolean matches(long user,String password){return password!=null&&password.length()<=1024&&!db.queryForList("SELECT usr_id FROM user WHERE usr_id=? AND (usr_password=PASSWORD(?) OR usr_password_old=OLD_PASSWORD(?))",user,password,password).isEmpty();}
|
||||
Map<String,Object> password(LegacyUser u,Password p){if(p==null)throw bad("Passwortangaben fehlen.");BusinessSupport.token(p.requestId());EmployeeAccounts.validatePassword(p.password(),p.confirmation());support.transactional("user","genericdatacontainer");Object intent=List.of("COURIER_PASSWORD",Objects.toString(p.revision(),""));
|
||||
return db.courierPortalWriteTransaction(()->{var a=CourierPortalScope.verify(db,u);if(support.receipt(u,p.requestId(),intent)!=null){if(!matches(u.userId(),p.password()))throw bad("Der Vorgang wurde abgeschlossen; das Passwort weicht ab.");return fields("reauthenticate",true);}
|
||||
if(!accountRevision(a).equals(p.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Zugangsdaten wurden geändert. Bitte neu anmelden.");if(!matches(u.userId(),p.currentPassword()))throw bad("Das aktuelle Passwort ist ungültig.");
|
||||
db.update("UPDATE user SET usr_password=PASSWORD(?),usr_password_old='',usr_password_modify=NOW(),usr_totp_sessionkey=? WHERE usr_id=?",p.password(),PortalAccounts.sessionKey(),u.userId());support.audit(u,0,0,id(a,"cr_id"),84,"COURIER_PORTAL_PASSWORD_CHANGED | PREVIOUS_SESSIONS_REVOKED",p.requestId());support.receipt(u,p.requestId(),intent,u.userId());return fields("reauthenticate",true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** invoice/crHistoryUN.php: vehicle selection resolves the transporteur, not a central history right. */
|
||||
@RestController
|
||||
class CourierPortalHistory {
|
||||
record Query(LocalDate from,LocalDate to,String vehicle){}
|
||||
final DatabaseService db;final BusinessSupport support;final CourierPortal portal;
|
||||
CourierPortalHistory(DatabaseService db,BusinessSupport support,CourierPortal portal){this.db=db;this.support=support;this.portal=portal;}
|
||||
@PostMapping("/api/courier-portal/history") Object get(Authentication a,@RequestBody Query q){return history(ApiController.user(a),q);}
|
||||
Map<String,Object> history(LegacyUser u,Query q){if(q==null||q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.from().isAfter(q.to())||q.from().plusYears(1).isBefore(q.to())||q.vehicle()==null||q.vehicle().isBlank())throw bad("Bitte ein Fahrzeug und einen Zeitraum von höchstens einem Jahr auswählen.");
|
||||
return db.readTransaction(()->{var scope=portal.scope(u);if(!scope.parent())throw CourierPortal.denied();var vehicle=scope.vehicles().stream().filter(v->text(v,"sid").equals(q.vehicle())).toList();if(vehicle.size()!=1)throw CourierPortal.denied();long cr=id(vehicle.getFirst(),"cr_id");
|
||||
try{var data=db.queryForList("SELECT l.log_id,l.logo_id,l.log_createtime,l.jb_id,l.cr_sid,l.logo_description,o.logo_op_shortname,u.usr_name,u.usr_firstname,u.usr_phone FROM "+support.historySchema+".log l LEFT JOIN "+support.historySchema+".logoperation o ON o.logo_id=l.logo_id LEFT JOIN user u ON u.usr_id=l.usr_id WHERE l.cr_id=? AND l.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND l.logo_id IN (3,6,7,8,11,35,37,86) AND l.log_createtime>=? AND l.log_createtime<? ORDER BY l.log_createtime,l.log_id LIMIT 4001",cr,scope.mandator(),q.from().atStartOfDay(),q.to().plusDays(1).atStartOfDay());
|
||||
return fields("vehicle",q.vehicle(),"from",q.from(),"to",q.to(),"truncated",data.size()>4000,"rows",data.stream().limit(4000).map(r->fields("id",id(r,"log_id"),"at",dateTime(r,"log_createtime"),"job",id(r,"jb_id"),"vehicle",text(r,"cr_sid"),"action",text(r,"logo_op_shortname").isBlank()?"Vorgang "+id(r,"logo_id"):text(r,"logo_op_shortname"),"name",text(r,"usr_name"),"firstName",text(r,"usr_firstname"),"phone",text(r,"usr_phone"),"detail",details((int)id(r,"logo_id"),text(r,"logo_description")))).toList());
|
||||
}catch(org.springframework.dao.DataAccessException e){throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"Die Transporteurhistorie ist derzeit nicht erreichbar.");}
|
||||
});
|
||||
}
|
||||
static String details(int operation,String raw){
|
||||
if(Set.of(35,37,86).contains(operation))return MasterHistory.details(MasterListColumns.Kind.couriers,operation,raw);
|
||||
String clean=raw.replace("<postparser>","").replace("</postparser>","");var lines=new ArrayList<String>();
|
||||
var labels=Map.ofEntries(Map.entry("ASSIGN_STATUS","Vermittlung"),Map.entry("ZIP_FREE_OLD","Alte Freimeldung"),Map.entry("ZIP_FREE","Freimeldung"),Map.entry("ZIP_JOB","Auftrags-PLZ"),Map.entry("ZIP_REAL","Geortete PLZ"),Map.entry("LRANK","Laderang"),Map.entry("CASH_STATION","Barzahlung"),Map.entry("REASON","Grund"),Map.entry("ZIPCODE","Rankingabfrage"),Map.entry("LOCOLD","Ortung veraltet"));
|
||||
var translated=Map.ofEntries(Map.entry("RESERVED","Direkte Anforderung"),Map.entry("FAVOURED","Bevorzugt"),Map.entry("AREA","Hauptbereich"),Map.entry("NEIGHBOUR_AREA","Nachbarbereich"),Map.entry("QUIT","Client beendet ohne Ausloggen"),Map.entry("CONNLOST","Verbindungsabbruch"),Map.entry("Driver_Occupied","Fahrer besetzt"),Map.entry("Num_Of_Challenges_Reached","Maximale Anzahl Vermittlungsversuche erreicht"),Map.entry("No_Match_Vehicle_Type","Fahrzeugtyp passt nicht"),Map.entry("Third_Country_Check","Drittlandprüfung"),Map.entry("Quarantine_Check","Quarantäneprüfung"),Map.entry("Nationality_Check","Nationalitätenprüfung"));
|
||||
for(String part:clean.split("\\|")){var p=part.strip().split("=",2);if(p.length!=2||!labels.containsKey(p[0])||p[1].length()>500)continue;String value=p[1].strip();if(value.isEmpty())continue;if(p[0].equals("ASSIGN_STATUS")&&value.matches("NEIGHBOUR_AREA_[2-7]"))value=value.substring(value.length()-1)+". Nachbarbereich";else value=translated.getOrDefault(value,value);lines.add(labels.get(p[0])+": "+org.springframework.web.util.HtmlUtils.htmlUnescape(java.util.Objects.requireNonNull(value)));}
|
||||
return lines.isEmpty()?"Keine aufbereiteten Detailangaben verfügbar.":String.join("\n",lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
|
||||
|
||||
/** A transporteur login has no employee identity or headquarters privileges. */
|
||||
final class CourierPortalScope {
|
||||
static final String ACCOUNT_SQL="SELECT u.usr_id,u.hq_id,u.usr_account,u.usr_name,u.usr_firstname,u.usr_password_modify,u.usr_totp_activated,u.usr_totp_secret,cr.cr_id,cr.cmp_id,"+LegacyCredentialState.EXPRESSION+" AS credential_stamp FROM user u JOIN courier cr ON cr.usr_id=u.usr_id AND cr.hq_id=u.hq_id JOIN headquarters h ON h.hq_id=u.hq_id WHERE u.usr_id=? AND ?=0 AND u.hq_id=? AND u.usr_type=3 AND TRIM(u.usr_account)<>'' AND COALESCE(h.hq_disabled,0)=0 AND (SELECT COUNT(*) FROM courier allcr WHERE allcr.usr_id=u.usr_id)=1 AND NOT EXISTS (SELECT 1 FROM employee e WHERE e.usr_id=u.usr_id) AND (SELECT COUNT(*) FROM mandatorheadquarters mh WHERE mh.hq_id=u.hq_id AND mh.md_id>0)=1";
|
||||
static Map<String,Object> verify(DatabaseService db,LegacyUser u){
|
||||
if(!u.courierAccount()||u.employeeId()!=0||u.headquartersId()!=u.originalHeadquartersId())throw MasterDirectoryScope.denied();
|
||||
var rows=db.queryForList(ACCOUNT_SQL,u.userId(),u.employeeId(),u.originalHeadquartersId());if(rows.size()!=1)throw MasterDirectoryScope.denied();return rows.getFirst();
|
||||
}
|
||||
static boolean allowed(String method,String path){if(method.equals("GET")&&path.equals("/api/historical-orders"))return true;if(method.equals("POST")&&Set.of("/api/historical-orders/query","/api/historical-orders/detail").contains(path))return true;
|
||||
String mail="/api/courier-portal/disposition/[1-9][0-9]*/rollcard/mail",token="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
||||
if(method.equals("GET")&&path.matches(mail+"(/"+token+"(/page)?)?"))return true;
|
||||
if(method.equals("POST")&&path.matches(mail+"/(preview|"+token+")"))return true;
|
||||
|
||||
if(method.equals("GET"))return Set.of("/api/me","/api/system","/api/csrf","/api/health","/api/courier-portal","/api/courier-portal/account","/api/courier-portal/bourse","/api/courier-portal/disposition","/api/courier-portal/disposition/job","/api/courier-portal/disposition/job-search","/api/courier-portal/disposition/cards","/api/courier-portal/disposition/handover","/api/courier-portal/disposition/planning","/api/courier-portal/disposition/replanning","/api/courier-portal/disposition/rollcard","/api/courier-portal/disposition/rollcard/pdf").contains(path);
|
||||
return method.equals("POST")&&Set.of("/api/logout","/api/courier-portal/query","/api/courier-portal/remark","/api/courier-portal/password","/api/courier-portal/history","/api/courier-portal/bourse/query","/api/courier-portal/bourse/detail","/api/courier-portal/disposition/job/remark","/api/courier-portal/disposition/handover","/api/courier-portal/disposition/planning","/api/courier-portal/disposition/handover/preview","/api/courier-portal/disposition/planning/preview","/api/courier-portal/disposition/replanning","/api/courier-portal/disposition/replanning/preview").contains(path);
|
||||
}
|
||||
private CourierPortalScope(){}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import java.time.*;
|
||||
import java.time.format.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Absolute PHP mode dates, including their time; callers retain their own < / <= boundary. */
|
||||
final class CourierPriceModeDate {
|
||||
static final DateTimeFormatter GERMAN=DateTimeFormatter.ofPattern("dd.MM.uuuu").withResolverStyle(ResolverStyle.STRICT);
|
||||
static LocalDateTime parse(String value){
|
||||
String raw=Objects.toString(value,"").strip();
|
||||
if(raw.isEmpty())return null;
|
||||
for(var format:List.of(DateTimeFormatter.ISO_LOCAL_DATE,GERMAN)){
|
||||
try{return LocalDate.parse(raw,format).atStartOfDay();}catch(DateTimeParseException ignored){}
|
||||
var timestamp=new DateTimeFormatterBuilder().append(format).appendLiteral('T').append(DateTimeFormatter.ISO_LOCAL_TIME).toFormatter().withResolverStyle(ResolverStyle.STRICT);
|
||||
try{return LocalDateTime.parse(raw.replace(' ','T'),timestamp);}catch(DateTimeParseException ignored){}
|
||||
}
|
||||
throw new DateTimeParseException("Ungültiger Stichtag des Frachtvergütungsmodus",raw,0);
|
||||
}
|
||||
static boolean active(String value,LocalDateTime ordered,boolean inclusive){return active(parse(value),ordered,inclusive);}
|
||||
static boolean active(LocalDateTime start,LocalDateTime ordered,boolean inclusive){
|
||||
if(ordered==null)return false;
|
||||
return start==null||ordered.isAfter(start)||inclusive&&ordered.equals(start);
|
||||
}
|
||||
private CourierPriceModeDate(){}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import java.math.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.BillingService.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Current PHP services_func.inc.php::getJobCosts, courier view only. */
|
||||
final class CourierStatementPricing {
|
||||
record Policy(boolean storedMode,LocalDateTime storedFrom,boolean commissionColumns){}
|
||||
record Amount(List<Map<String,Object>> lines,BigDecimal total,boolean commissionFree){}
|
||||
static Map<String,Object> line(String label,BigDecimal amount){return fields("label",label,"amount",money(amount));}
|
||||
static Amount calculate(Map<String,Object> job,List<Map<String,Object>> services,BigDecimal commission,Policy policy,int stationCount,List<Map<String,Object>> payments){
|
||||
long hq=id(job,"hq_id");BigDecimal stored=nz(decimal(job,hq>100&&hq<=1000?"jb_cr_subprice":"jb_cr_price"));
|
||||
if(hq>1000){
|
||||
BigDecimal tax=new BigDecimal("1.19"),freight=money(stored.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2));
|
||||
BigDecimal counter=BigDecimal.ZERO,cash=BigDecimal.ZERO,ec=BigDecimal.ZERO;
|
||||
for(var p:payments){BigDecimal amount=nz(decimal(p,"jbp_price"));if(id(p,"jbp_counter")==1)counter=counter.add(amount);else if(id(p,"jbp_mode")==0)cash=cash.add(amount);else if(id(p,"jbp_mode")==1)ec=ec.add(amount);}
|
||||
BigDecimal due=money(nz(decimal(job,"jb_totalprice")).multiply(tax)).subtract(money(counter.multiply(tax))),cashGross=money(cash.multiply(tax)),ecGross=money(ec.multiply(tax)),open=due.subtract(cashGross).subtract(ecGross);
|
||||
return new Amount(List.of(line("Fuhrlohn",freight.multiply(tax)),line("Soll",due),line("EC",ecGross),line("Bar",cashGross)),money(open),false);
|
||||
}
|
||||
LocalDateTime ordered=dateTime(job,"jb_ordertime");boolean after=policy.storedMode()&&CourierPriceModeDate.active(policy.storedFrom(),ordered,false);
|
||||
if(stored.signum()!=0&&(hq>100||!policy.storedMode())||after)return new Amount(List.of(line("Fuhrlohn Fixpreis",stored)),money(stored),true);
|
||||
var lines=new ArrayList<Map<String,Object>>();BigDecimal sum=BigDecimal.ZERO;long previous=-1;
|
||||
for(var p:services){if(text(p,"trs_srvt_name").equals("z")&&nz(decimal(p,"trs_price")).signum()==0)continue;
|
||||
boolean split=id(job,"csc_id_payer")==0&&stationCount>1;long sort=id(p,"tr_sort");if(split&&previous==sort)continue;
|
||||
BigDecimal amount=nz(decimal(p,"trs_price")).multiply(BigDecimal.valueOf(100).subtract(nz(decimal(p,"trs_discount")))).movePointLeft(2);
|
||||
if(policy.commissionColumns())amount=money(amount.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2));
|
||||
sum=sum.add(amount);previous=sort;String label=(text(p,"trs_srv_name").replace("<br>"," ")+" "+text(p,"trs_srvt_name")).strip();
|
||||
if(split)label+=" (anteilig 1/"+stationCount+")";lines.add(line(label,amount));
|
||||
}
|
||||
return new Amount(lines,money(sum),false);
|
||||
}
|
||||
private CourierStatementPricing(){}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController
|
||||
@RequestMapping("/api/crm")
|
||||
class CrmController {
|
||||
private final ContactService contacts;private final SalesAnalytics sales;
|
||||
CrmController(ContactService contacts,SalesAnalytics sales){this.contacts=contacts;this.sales=sales;}
|
||||
@GetMapping("/options") Map<String,Object> options(Authentication a){return sales.options(user(a));}
|
||||
@GetMapping("/contacts/schema") Map<String,Object> schema(Authentication a,@RequestParam long headquarters){return contacts.schema(user(a),headquarters);}
|
||||
@GetMapping("/contacts/draft") Map<String,Object> draft(Authentication a,@RequestParam long headquarters,@RequestParam(required=false) Long customerId){return contacts.draft(user(a),headquarters,customerId);}
|
||||
@PostMapping("/contacts/query") List<Map<String,Object>> contacts(Authentication a,@RequestBody ContactService.Query q){return contacts.list(user(a),q);}
|
||||
@GetMapping("/contacts/{id}") Map<String,Object> contact(Authentication a,@PathVariable long id,@RequestParam long headquarters){return contacts.get(user(a),headquarters,id);}
|
||||
@GetMapping("/contacts/{id}/history") List<Map<String,Object>> history(Authentication a,@PathVariable long id,@RequestParam long headquarters){return contacts.history(user(a),headquarters,id);}
|
||||
@PostMapping("/contacts") Map<String,Object> create(Authentication a,@RequestBody ContactService.Save s){return contacts.save(user(a),null,s);}
|
||||
@PutMapping("/contacts/{id}") Map<String,Object> save(Authentication a,@PathVariable long id,@RequestBody ContactService.Save s){return contacts.save(user(a),id,s);}
|
||||
record Delete(long headquarters,String revision){}
|
||||
@DeleteMapping("/contacts/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void remove(Authentication a,@PathVariable long id,@RequestBody Delete s){contacts.remove(user(a),s.headquarters(),id,s.revision());}
|
||||
@PostMapping("/switches") Map<String,Object> switches(Authentication a,@RequestBody SalesAnalytics.Query q){return sales.switches(user(a),q);}
|
||||
record Motivation(String month,List<Long> headquarters){}
|
||||
@PostMapping("/motivation") Map<String,Object> motivation(Authentication a,@RequestBody Motivation q){return sales.motivation(user(a),q.month(),q.headquarters());}
|
||||
@PostMapping("/targets/query") List<Map<String,Object>> targets(Authentication a,@RequestBody SalesAnalytics.TargetAccess s){return sales.targets(user(a),s);}
|
||||
@PostMapping("/targets") Map<String,Object> target(Authentication a,@RequestBody SalesAnalytics.Target s){return sales.target(user(a),s);}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
@Service
|
||||
class CrmScope {
|
||||
final DatabaseService db;
|
||||
CrmScope(DatabaseService db){this.db=db;}
|
||||
void access(LegacyUser u){u.require(0);u.require(11);}
|
||||
List<Map<String,Object>> headquarters(LegacyUser u){access(u);return db.queryForList("SELECT DISTINCT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE own.hq_id=? AND own.md_id>0"+(u.hasRight(10)?"":" AND h.hq_id=?")+" ORDER BY h.hq_mnemonic",u.hasRight(10)?new Object[]{u.headquartersId()}:new Object[]{u.headquartersId(),u.headquartersId()});}
|
||||
List<Long> checked(LegacyUser u,List<Long> hqs){access(u);if(hqs==null||hqs.isEmpty()||hqs.size()>100||hqs.stream().anyMatch(Objects::isNull))throw bad("Bitte Niederlassungen auswählen.");var allowed=headquarters(u).stream().map(r->id(r,"id")).toList();if(!allowed.containsAll(hqs))throw new AccessDeniedException("Niederlassung nicht freigegeben.");return hqs.stream().distinct().sorted().toList();}
|
||||
static String marks(int n){return String.join(",",Collections.nCopies(n,"?"));}
|
||||
void customer(LegacyUser u,long cs){var h=checked(u,headquarters(u).stream().map(r->id(r,"id")).toList());var args=new ArrayList<Object>(h);args.add(cs);one(db.queryForList("SELECT cs_id FROM customer WHERE hq_id IN ("+marks(h.size())+") AND cs_id=?",args.toArray()));}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** customer_special.php: independently copy company names and postal address to existing costcenter addresses. */
|
||||
@RestController
|
||||
@RequestMapping("/api/master/customers/{customer}/address-propagation")
|
||||
class CustomerAddressPropagation {
|
||||
final DatabaseService db;final BusinessSupport support;final MasterData master;
|
||||
CustomerAddressPropagation(DatabaseService db,BusinessSupport support,MasterData master){this.db=db;this.support=support;this.master=master;}
|
||||
enum Scope { NONE, ROOT, ALL }
|
||||
record Selection(Scope scope,List<Integer> types){}
|
||||
record Draft(String revision,Selection company,Selection address){}
|
||||
record Command(String requestId,Draft draft,String fingerprint){}
|
||||
record View(long customer,String label,String revision,Map<String,Object> source,int centers){}
|
||||
record Change(long center,String label,int type,Map<String,Object> before,Map<String,Object> after,boolean company,boolean address){}
|
||||
record Preview(String fingerprint,List<Change> changes){}
|
||||
record State(View view,long root,List<Map<String,Object>> addresses){}
|
||||
static final List<String> COMPANY=List.of("cscad_comp","cscad_comp2","cscad_comp3","cscad_comp4");
|
||||
static final List<String> ADDRESS=List.of("ad_street","cscad_hsno","ad_zipcode","ad_city","ad_country");
|
||||
@GetMapping View get(Authentication a,@PathVariable long customer){return view(ApiController.user(a),customer);}
|
||||
@PostMapping("/preview") Preview preview(Authentication a,@PathVariable long customer,@RequestBody Draft d){return preview(ApiController.user(a),customer,d);}
|
||||
@PostMapping Map<String,Object> save(Authentication a,@PathVariable long customer,@RequestBody Command c){return save(ApiController.user(a),customer,c);}
|
||||
State state(LegacyUser u,long customer,boolean lock){
|
||||
if(customer<=0)throw bad("Ungültiger Kunde.");
|
||||
var owner=master.load(u,"customers",customer,lock);
|
||||
String cs="SELECT csc_id,csc_name,csc_path FROM costcenter WHERE cs_id=? ORDER BY csc_id";
|
||||
var centers=lock?db.lockRows(cs,customer):db.queryForList(cs,customer);
|
||||
long root=id(owner,"csc_id");if(centers.stream().noneMatch(c->id(c,"csc_id")==root))throw bad("Die Hauptkostenstelle gehört nicht zu diesem Kunden.");
|
||||
String sql="SELECT ca.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country,c.csc_name,c.csc_path FROM costcenter c JOIN costcenteraddress ca ON ca.csc_id=c.csc_id LEFT JOIN address a ON a.ad_id=ca.ad_id WHERE c.cs_id=? ORDER BY c.csc_id,ca.adt_id";
|
||||
var rows=lock?db.lockRows(sql,customer):db.queryForList(sql,customer);
|
||||
var seen=new HashSet<String>();for(var row:rows)if(!seen.add(id(row,"csc_id")+":"+id(row,"adt_id")))throw bad("Mehrdeutige Kostenstellenanschriften. Bitte doppelte Einträge klären lassen.");
|
||||
var source=new LinkedHashMap<String,Object>();source.put("ad_id",id(owner,"ad_id"));
|
||||
for(String k:COMPANY)source.put(k,text(owner,k.replace("cscad_","cmp_")));
|
||||
for(String k:ADDRESS)source.put(k,text(owner,k.equals("cscad_hsno")?"cmp_hsno":k));
|
||||
return new State(new View(customer,text(owner,"cs_eid")+" · "+text(owner,"cmp_comp"),revision(List.of(customer,root,source,centers,rows)),source,centers.size()),root,rows);
|
||||
}
|
||||
View view(LegacyUser u,long customer){u.require(0);return db.readTransaction(()->state(u,customer,false).view());}
|
||||
static Selection selection(Selection s){
|
||||
if(s==null||s.scope()==null||s.types()==null)throw bad("Bitte Geltungsbereich und Adressarten wählen.");
|
||||
if(s.types().stream().anyMatch(t->t==null||!Set.of(1,2,4).contains(t))||new HashSet<>(s.types()).size()!=s.types().size())throw bad("Ungültige Adressart.");
|
||||
if(s.scope()==Scope.NONE&&!s.types().isEmpty()||s.scope()!=Scope.NONE&&s.types().isEmpty())throw bad("Bitte mindestens eine Adressart für die gewählte Übernahme wählen.");
|
||||
return new Selection(s.scope(),s.types().stream().sorted().toList());
|
||||
}
|
||||
static Draft validate(Draft d){if(d==null||d.revision()==null||!d.revision().matches("[a-f0-9]{64}"))throw bad("Bestandsrevision fehlt.");var result=new Draft(d.revision(),selection(d.company()),selection(d.address()));if(result.company().scope()==Scope.NONE&&result.address().scope()==Scope.NONE)throw bad("Bitte eine Übernahme wählen.");return result;}
|
||||
static boolean includes(Selection s,long center,long root,int type){return s.types().contains(type)&&(s.scope()==Scope.ALL||s.scope()==Scope.ROOT&¢er==root);}
|
||||
static Map<String,Object> display(Map<String,Object> row){var out=new LinkedHashMap<String,Object>();for(var keys:List.of(COMPANY,ADDRESS))for(String k:keys)out.put(k,text(row,k));return out;}
|
||||
Preview prepare(LegacyUser u,long customer,Draft d,State s){
|
||||
if(!s.view().revision().equals(d.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Kunden- oder Kostenstellendaten wurden inzwischen geändert. Bitte neu laden.");
|
||||
var changes=new ArrayList<Change>();for(var row:s.addresses()){
|
||||
long center=id(row,"csc_id");int type=(int)id(row,"adt_id");boolean company=includes(d.company(),center,s.root(),type),address=includes(d.address(),center,s.root(),type);if(!company&&!address)continue;
|
||||
var before=display(row);var after=new LinkedHashMap<>(before);if(company)for(String k:COMPANY)after.put(k,s.view().source().get(k));if(address)for(String k:ADDRESS)after.put(k,s.view().source().get(k));
|
||||
if(!before.equals(after))changes.add(new Change(center,text(row,"csc_name"),type,before,after,company,address));
|
||||
}
|
||||
return new Preview(revision(List.of(u.headquartersId(),customer,d,changes)),List.copyOf(changes));
|
||||
}
|
||||
Preview preview(LegacyUser u,long customer,Draft input){u.require(0);var d=validate(input);return db.readTransaction(()->prepare(u,customer,d,state(u,customer,false)));}
|
||||
Map<String,Object> save(LegacyUser u,long customer,Command c){
|
||||
u.require(0);if(c==null)throw bad("Übernahme fehlt.");BusinessSupport.token(c.requestId());var d=validate(c.draft());if(c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte zuerst die Vorschau prüfen.");
|
||||
var intent=List.of("CUSTOMER_ADDRESS_PROPAGATION",customer,d,c.fingerprint());
|
||||
support.transactional("headquarters","customer","company","address","employee","user","costcenter","costcenteraddress","genericdatacontainer");
|
||||
return db.writeTransaction(()->{
|
||||
one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId()));
|
||||
master.load(u,"customers",customer,true);Long receipt=support.receipt(u,c.requestId(),intent);if(receipt!=null)return fields("changed",receipt,"repeated",true);
|
||||
var state=state(u,customer,true);var p=prepare(u,customer,d,state);if(!p.fingerprint().equals(c.fingerprint()))throw bad("Die Vorschau passt nicht zur gewählten Übernahme.");
|
||||
for(var change:p.changes()){
|
||||
var values=new LinkedHashMap<String,Object>();if(change.company())for(String k:COMPANY)values.put(k,change.after().get(k));
|
||||
if(change.address()){values.put("ad_id",state.view().source().get("ad_id"));values.put("cscad_hsno",change.after().get("cscad_hsno"));}
|
||||
var args=new ArrayList<>(values.values());args.add(change.center());args.add(change.type());
|
||||
int affected=db.update("UPDATE costcenteraddress SET "+String.join(",",values.keySet().stream().map(k->k+"=?").toList())+" WHERE csc_id=? AND adt_id=?",args.toArray());if(affected!=1)throw bad("Kostenstellenanschrift konnte nicht eindeutig geändert werden.");
|
||||
}
|
||||
if(!p.changes().isEmpty())support.audit(u,0,customer,0,36,"Kundenangaben in "+p.changes().size()+" Kostenstellenanschriften übernommen · Firma "+d.company()+" · Adresse "+d.address(),c.requestId());
|
||||
support.receipt(u,c.requestId(),intent,p.changes().size());return fields("changed",p.changes().size(),"repeated",false);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** The customer cancellation branch preserves payments, disposition and standing-order rules. */
|
||||
@RestController
|
||||
@RequestMapping("/api/customer-portal/orders/{job}/cancellation")
|
||||
class CustomerCancellation {
|
||||
record Command(String requestId,String revision,boolean confirmed){}
|
||||
record State(CustomerPortalScope.Context context,long customer,OrderOperations.Snapshot order,List<Map<String,Object>> metadata,List<Map<String,Object>> exports,boolean enabled,boolean related){}
|
||||
final DatabaseService db;final BusinessSupport support;final OrderOperations orders;
|
||||
CustomerCancellation(DatabaseService db,BusinessSupport support,OrderOperations orders){this.db=db;this.support=support;this.orders=orders;}
|
||||
@GetMapping Object options(Authentication a,@PathVariable long job){return options(ApiController.user(a),job);}
|
||||
@PostMapping Object save(Authentication a,@PathVariable long job,@RequestBody Command command){return save(ApiController.user(a),job,command);}
|
||||
State state(LegacyUser u,long job){
|
||||
var scope=CustomerPortalScope.context(db,u);scope.require(4);var portal=new CustomerPortal(db);var centers=new TreeSet<Long>();portal.orderCustomers(u,scope).forEach(cs->centers.addAll(cs.allowed()));var args=new ArrayList<Object>(centers);args.add(mandator(db,u));args.add(job);
|
||||
var jobs=db.queryForList("SELECT jb.*,jb.jb_origin+0 AS ng_origin_code,("+portal.payer(u)+") AS visible_payer"+portal.membership(portal.payer(u),centers)+" AND jb.jb_id=?",args.toArray());if(jobs.size()!=1)throw CustomerPortalScope.denied();var row=new LinkedHashMap<>(jobs.getFirst());long payer=id(row,"visible_payer");row.remove("visible_payer");row.put("jb_origin",row.remove("ng_origin_code"));
|
||||
long customer=scope.customers().values().stream().filter(cs->cs.allowed().contains(payer)).mapToLong(entryValue -> entryValue.id()).findFirst().orElseThrow(CustomerPortalScope::denied);
|
||||
var snap=new OrderOperations.Snapshot(row,db.queryForList("SELECT * FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",job),db.queryForList("SELECT * FROM tourservice WHERE jb_id=? ORDER BY tr_sort,csc_id,srv_id,srvt_id",job),db.queryForList("SELECT * FROM invoice WHERE jb_id=?",job),List.of(),db.queryForList("SELECT * FROM jobprice WHERE jb_id=? ORDER BY mt_sort,jbprc_reference",job));
|
||||
boolean related=portal.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER");var meta=db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('ng_invoice_lock','jb_toll','ng_payment_term','ng_payment_term_label') ORDER BY gdc_id",job);
|
||||
return new State(scope,customer,snap,meta,related?List.of():new BatchInvoiceExports(db,support).rows(job,id(row,"hq_id"),false),portal.enabled(0,"CUSTOMER_MASK_JOBLIST_CANCELLATION_ENABLED_"+customer),related);
|
||||
}
|
||||
String blocked(State s){
|
||||
var j=s.order().job();long job=id(j,"jb_id");if(!s.enabled())return "Die Kundenstornierung ist für diesen Kunden nicht freigegeben.";
|
||||
if(!Set.of(8L,9L).contains(id(j,"jb_status"))||id(j,"jb_storno")!=0)return "Nur noch nicht angenommene Kundenaufträge mit Status 8 oder 9 können hier storniert werden.";
|
||||
if(id(j,"csc_id")<=0&&db.queryForList("SELECT usr_id FROM user WHERE usr_id=? AND usr_type=2",id(j,"emp_id")).isEmpty())return "Dieser Auftrag wurde nicht über einen Kundenzugang erfasst.";
|
||||
if(OrderOperations.exported(j)||!s.order().invoices().isEmpty()||s.metadata().stream().anyMatch(m->text(m,"gdc_gen_fieldname").equals("ng_invoice_lock")))return "Der Auftrag ist bereits exportiert oder einer Rechnung zugeordnet.";
|
||||
if(!s.related()&&id(j,"jb_id_parent")>0)return "Unteraufträge werden über den zugehörigen Hauptauftrag bearbeitet.";
|
||||
if(!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",job).isEmpty())return "Für den Auftrag besteht bereits eine Gegenbuchung.";
|
||||
var lock=dateTime(j,"jb_locktime");if(lock!=null&&lock.isAfter(LocalDateTime.now().minusMinutes(5)))return "Der Auftrag wird gerade in der alten Anwendung bearbeitet.";
|
||||
return "";
|
||||
}
|
||||
Map<String,Object> options(LegacyUser u,long job){return db.readTransaction(()->{var s=state(u,job);String reason=blocked(s);return fields("id",job,"revision",revision(s),"canCancel",reason.isEmpty(),"reason",reason,"createCredit",!s.related(),"amount",BillingService.money(decimal(s.order().job(),"jb_totalprice")),"series",id(s.order().job(),"jb_permanent")>0);});}
|
||||
Map<String,Object> save(LegacyUser u,long job,Command c){
|
||||
if(c==null||!c.confirmed())throw bad("Bitte die kostenfreie Stornierung bestätigen.");BusinessSupport.token(c.requestId());support.transactional("job","tour","tourservice","jobprice","genericdatacontainer");var exports=new BatchInvoiceExports(db,support);exports.requireTransactional();
|
||||
return db.customerCancellationWriteTransaction(exports.schema(),()->{
|
||||
var s=state(u,job);if(!s.enabled())throw CustomerPortalScope.denied();Object intent=List.of("CUSTOMER_CANCEL",job,c.revision(),c.confirmed());Long previous=support.receipt(u,c.requestId(),intent);if(previous!=null)return fields("id",job,"credit",previous);
|
||||
unchanged(s,c.revision());String reason=blocked(s);if(!reason.isEmpty())throw bad(reason);long credit=0;
|
||||
if(!s.related()){
|
||||
credit=orders.credit(u,job,s.order(),new OrderOperations.Action(c.requestId(),null,"CANCEL",null,null,null,"Storno durch Kunde im Kundenzugang",false,false),"Storno durch Kunde im Kundenzugang");
|
||||
// PHP reverses the stored total exactly, even when historical service rows differ from it.
|
||||
db.update("UPDATE job SET jb_totalprice=?,jb_invtext=? WHERE jb_id=?",decimal(s.order().job(),"jb_totalprice").negate(),"Storno von Auftrag Nr. "+job,credit);
|
||||
for(var row:s.exports()){var item=exports.decode(text(row,"bo_obj_data"));if(item==null)continue;var reversed=new BatchInvoiceTexts.Item(item.factor(),item.description(),item.price().negate(),item.total().negate());BusinessSupport.insertWithoutKey(db,support.historySchema+".b2b_objects",fields("bo_ver",0,"bo_type",300,"bo_state",0,"bo_ext_id0",credit,"bo_ext_id1",id(s.order().job(),"hq_id"),"bo_ext_id2",job,"bo_obj_data",support.encode(reversed.legacy()),"bo_createtime",LocalDateTime.now()));}
|
||||
}
|
||||
var changes=fields("jb_storno",2,"usr_id_storno",u.userId(),"jb_status",2,"jb_finishtime",LocalDateTime.now(),"jb_freetext_3","Storno durch Kunde im Kundenzugang!","jb_version",id(s.order().job(),"jb_version")+1);if(credit>0)changes.put("jb_id_copy",credit);BusinessSupport.update(db,"job","jb_id",job,changes);db.update("UPDATE tour SET tr_commission_no='' WHERE jb_id=?",job);
|
||||
support.audit(u,job,s.customer(),0,10,"COSTS=NO|JBP_REMOVAL=NO|FDS_REMOVAL=NO|CUSTOMER_STORNO=YES",c.requestId());support.receipt(u,c.requestId(),intent,credit);return fields("id",job,"credit",credit);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.MasterData.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Active company controls in the current customer_special.php. */
|
||||
final class CustomerCompanySettings {
|
||||
static final List<MasterData.Field> FIELDS=List.of(
|
||||
f("br_id","Branche","Vertrieb","branch",0,false),
|
||||
f("emp_id_sales","Vertriebsmitarbeiter","Vertrieb","salesEmployee",0,false),
|
||||
choice("cmp_sales_ranking_man","Subjektives Ranking","Vertrieb","0","Keine Einstufung","1","A","2","B","3","C"),
|
||||
f("cmp_newsletter","Newsletter-Abonnement","Kontakt","check",1,false),
|
||||
f("cmp_dsgvo","DSGVO-Zustimmung","Kontakt","check",1,false),
|
||||
f("ng_single_invoice","Einzelrechnung","Abrechnung","check",1,false));
|
||||
static final Set<String> KEYS=Set.of("br_id","emp_id_sales","cmp_sales_ranking_man","cmp_newsletter","cmp_dsgvo","ng_single_invoice");
|
||||
final DatabaseService db;
|
||||
CustomerCompanySettings(DatabaseService db){this.db=db;}
|
||||
static long oldValue(Map<String,Object> old,String key){return key.equals("ng_single_invoice")?(id(old,"cmp_inv2hq")&2)/2:id(old,key);}
|
||||
static long invoiceMode(long old,long enabled){if(old<0||old>3||enabled<0||enabled>1)throw bad("Ungültige Einzelrechnungs-Vorgabe.");return (old&1)|(enabled*2);}
|
||||
void decorate(LegacyUser user,Map<String,Object> row,Map<String,Object> values,Map<String,Object> result){
|
||||
values.put("ng_single_invoice",oldValue(row,"ng_single_invoice"));
|
||||
var disabled=new ArrayList<String>();if(!user.hasRight(24))disabled.add("emp_id_sales");if(!user.hasRight(33))disabled.add("cmp_dsgvo");result.put("disabledFields",disabled);
|
||||
@SuppressWarnings("unchecked") var options=(Map<String,Object>)result.get("options");
|
||||
var branches=new ArrayList<Map<String,Object>>();branches.add(fields("id",0,"label","Keine Branche"));
|
||||
branches.addAll(db.queryForList("SELECT br_id AS id,CONCAT_WS(' · ',br_key,br_name) AS label FROM branch WHERE br_visible=1 ORDER BY br_key,br_name,br_id"));
|
||||
long current=id(row,"br_id");if(current>0&&branches.stream().noneMatch(r->id(r,"id")==current))branches.add(fields("id",current,"label",storedBranch(current)+" (bisherige Zuordnung)"));options.put("branch",branches);
|
||||
var employees=new ArrayList<Map<String,Object>>();employees.add(fields("id",0,"label","Kein Vertriebsmitarbeiter"));
|
||||
if(user.hasRight(24))employees.addAll(employees(user,false));
|
||||
long selected=id(row,"emp_id_sales");if(selected>0&&employees.stream().noneMatch(r->id(r,"id")==selected)){
|
||||
var names=db.queryForList("SELECT CONCAT_WS(', ',u.usr_name,u.usr_firstname) AS label FROM employee e JOIN user u ON u.usr_id=e.usr_id WHERE e.emp_id=?",selected);
|
||||
employees.add(fields("id",selected,"label",(names.isEmpty()?"Mitarbeiter "+selected:text(names.getFirst(),"label"))+" (bisherige Zuordnung)"));
|
||||
}
|
||||
options.put("salesEmployee",employees);
|
||||
}
|
||||
String storedBranch(long branch){var rows=db.queryForList("SELECT CONCAT_WS(' · ',br_key,br_name) AS label FROM branch WHERE br_id=?",branch);return rows.isEmpty()?"Branche "+branch:text(rows.getFirst(),"label");}
|
||||
List<Map<String,Object>> employees(LegacyUser user,boolean lock){
|
||||
String sql="SELECT e.emp_id AS id,CONCAT_WS(', ',u.usr_name,u.usr_firstname) AS label FROM employee e JOIN user u ON u.usr_id=e.usr_id WHERE u.usr_type=1 AND e.csc_id=0 AND u.hq_id=?"+(user.hasRight(3)?"":" AND e.emp_id=?")+" ORDER BY u.usr_name,u.usr_firstname,e.emp_id";
|
||||
Object[] args=user.hasRight(3)?new Object[]{user.headquartersId()}:new Object[]{user.headquartersId(),user.employeeId()};return lock?db.lockRows(sql,args):db.queryForList(sql,args);
|
||||
}
|
||||
void prepare(LegacyUser user,Map<String,Object> old,Map<String,Object> clean){
|
||||
for(String key:KEYS)clean.putIfAbsent(key,oldValue(old,key));
|
||||
if(KEYS.stream().allMatch(k->id(clean,k)==oldValue(old,k)))return;
|
||||
// Re-read privileges at the write boundary, including a changed working HQ.
|
||||
var current=new WorkspaceHeadquarters(db).select(user,user.headquartersId());current.require(0);
|
||||
if(id(clean,"emp_id_sales")!=id(old,"emp_id_sales")){
|
||||
user.require(24);current.require(24);long employee=id(clean,"emp_id_sales");
|
||||
if(employee>0&&employees(current,true).stream().noneMatch(r->id(r,"id")==employee))throw bad("Vertriebsmitarbeiter ist in dieser Niederlassung nicht auswählbar.");
|
||||
}
|
||||
if(id(clean,"cmp_dsgvo")!=id(old,"cmp_dsgvo")){user.require(33);current.require(33);}
|
||||
long branch=id(clean,"br_id");if(branch>0&&branch!=id(old,"br_id")&&db.lockRows("SELECT br_id FROM branch WHERE br_id=? AND br_visible=1",branch).isEmpty())throw bad("Bitte eine sichtbare Branche wählen.");
|
||||
}
|
||||
static void company(Map<String,Object> old,Map<String,Object> clean,Map<String,Object> company){
|
||||
company.put("br_id",clean.get("br_id"));company.put("emp_id_sales",clean.get("emp_id_sales"));
|
||||
company.put("cmp_inv2hq",invoiceMode(id(old,"cmp_inv2hq"),id(clean,"ng_single_invoice")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Read-only copy draft; the normal customer creation transaction owns every local write. */
|
||||
final class CustomerCopy {
|
||||
record Source(long id,String revision,@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) String photoRevision){Source(long id,String revision){this(id,revision,null);}}
|
||||
record State(Map<String,Object> owner,List<Map<String,Object>> parameters,boolean removePayer,String revision){}
|
||||
static final List<String> ENTITY=List.of("cs_jbedit","cs_ar","cs_joblist_disabled","cs_jbstatusmail_fields","cs_jb_jam_waittime","cs_jb_jam_waittime_minutes");
|
||||
static final String POLICY="CS_COPY_NO_THIRD_PARTY",STATION="JOBDETAILS_STATIONS_STATION_NUMBER_";
|
||||
static final List<String> PREFIXES;
|
||||
static {var p=new ArrayList<>(CustomerEntrySettings.PARAMETERS.values());p.addAll(CustomerMailSettings.PARAMETERS);p.add(CustomerEntrySettings.COLOR);PREFIXES=List.copyOf(p);}
|
||||
final MasterData masters;final DatabaseService db;
|
||||
CustomerCopy(MasterData masters){this.masters=masters;this.db=masters.db;}
|
||||
LegacyUser access(LegacyUser actor){actor.require(0);actor.require(2);var current=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());current.require(0);current.require(2);return current;}
|
||||
State state(LegacyUser u,long source,boolean lock){
|
||||
if(source<=0)throw bad("Bitte einen gespeicherten Kunden kopieren.");
|
||||
var owner=masters.load(u,"customers",source,lock);
|
||||
var names=new ArrayList<String>();for(String prefix:PREFIXES)names.add(prefix+source);names.add(CustomerEntrySettings.MEASURES);names.add(POLICY);
|
||||
String sql="SELECT par_id,par_key,par_value,md_id,hq_id,emp_id FROM parameter WHERE par_key IN ("+CrmScope.marks(names.size())+") ORDER BY par_key,hq_id,par_id";
|
||||
var all=lock?db.lockRows(sql,names.toArray()):db.queryForList(sql,names.toArray());var rows=new ArrayList<Map<String,Object>>();var seen=new HashSet<String>();boolean remove=false;
|
||||
for(var row:all){String key=text(row,"par_key");boolean local=key.equals(POLICY)||key.equals(CustomerEntrySettings.MEASURES);
|
||||
if(local&&id(row,"hq_id")!=u.headquartersId())continue;
|
||||
if(id(row,"md_id")!=0||id(row,"emp_id")!=0||!local&&id(row,"hq_id")!=0||!seen.add(key))throw bad("Die Kopiervorgaben sind mehrdeutig oder haben einen abweichenden Geltungsbereich.");
|
||||
if(key.equals(POLICY))remove=CustomerMailSettings.binary(text(row,"par_value"))==1;
|
||||
if(key.equals(CustomerEntrySettings.MEASURES))CustomerEntrySettings.measureIds(text(row,"par_value"));
|
||||
rows.add(row);
|
||||
}
|
||||
return new State(owner,List.copyOf(rows),remove,revision(List.of(u.userId(),u.headquartersId(),owner,rows)));
|
||||
}
|
||||
@SuppressWarnings("unchecked") Map<String,Object> draft(LegacyUser actor,long source){var draft=db.readTransaction(()->{
|
||||
var u=access(actor);var s=state(u,source,false);var original=masters.view(u,"customers",source,0);var result=masters.view(u,"customers",0,0);
|
||||
var values=(Map<String,Object>)result.get("values");values.putAll((Map<String,Object>)original.get("values"));values.put("cs_eid","");values.put("cmp_archived",0);values.put("cmp_contract_date","");
|
||||
if(s.removePayer())values.put("csc_id_payer",0);
|
||||
if(!u.hasRight(33))values.put("cmp_dsgvo",0);if(!u.hasRight(24))values.put("emp_id_sales",0);
|
||||
result.put("copySource",new Source(source,s.revision()));
|
||||
result.put("copyLabel",String.join(" · ",List.of(text(s.owner(),"cs_eid"),text(s.owner(),"cmp_comp"))));
|
||||
result.put("copyPayerRemoved",s.removePayer());
|
||||
result.put("copyDetails",List.of("Stammblatt, Kundenerfassungs- und Mailvorgaben werden übernommen.","Kundennummer, Portalzugang, Vorgängerhistorie, Filter, Services, zusätzliche Kostenstellen und Dokumente werden nicht übernommen.","Foto-Sets und Workflows für den neuen Kunden werden nach dem Speichern in der Workflow-Verwaltung eingerichtet."));
|
||||
result.remove("creationDefaults");
|
||||
var type=(Map<String,Object>)result.get("customerType");type.put("storedType",id(values,"cmp_type"));
|
||||
return result;
|
||||
});
|
||||
return draft;
|
||||
}
|
||||
State prepare(LegacyUser actor,Source source,Map<String,Object> values){
|
||||
if(source==null||source.revision()==null||!source.revision().matches("[a-f0-9]{64}"))throw bad("Die Kopierquelle fehlt.");
|
||||
var u=access(actor);var s=state(u,source.id(),true);
|
||||
if(!s.revision().equals(source.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Kopierquelle oder Kopiervorgabe wurde inzwischen geändert. Bitte die Kopie neu öffnen.");
|
||||
if(s.parameters().stream().anyMatch(r->text(r,"par_key").equals(CustomerEntrySettings.PARAMETERS.get("favoriteDispatch")+source.id())&&text(r,"par_value").equals("1")))u.require(41);
|
||||
return s;
|
||||
}
|
||||
void entity(State s,Map<String,Object> entity){for(String key:ENTITY)if(s.owner().containsKey(key))entity.put(key,s.owner().get(key));}
|
||||
void created(LegacyUser u,long customer,State state,Map<String,Object> values){
|
||||
masters.support.transactional("parameter");long source=id(state.owner(),"cs_id");
|
||||
for(var row:state.parameters()){
|
||||
String key=text(row,"par_key");if(key.equals(POLICY))continue;String value=text(row,"par_value");
|
||||
if(key.equals(CustomerEntrySettings.MEASURES)){
|
||||
var members=CustomerEntrySettings.measureIds(value);if(!members.contains(source))continue;members.add(customer);db.update("UPDATE parameter SET par_value=? WHERE par_id=?",String.join(",",members.stream().map(entryValue -> entryValue.toString()).toList()),id(row,"par_id"));continue;
|
||||
}
|
||||
String prefix=PREFIXES.stream().filter(p->key.equals(p+source)).findFirst().orElseThrow();String destination=prefix+customer;
|
||||
if(id(values,"cs_commission_no")==0&&(prefix.equals(CustomerEntrySettings.PARAMETERS.get("missingReferenceIncomplete"))||prefix.equals(CustomerEntrySettings.PARAMETERS.get("referenceEveryStation"))))value="0";
|
||||
if(!db.lockRows("SELECT par_id FROM parameter WHERE par_key=?",destination).isEmpty())throw bad("Für die neue Kunden-ID existieren bereits Vorgaben.");
|
||||
db.insert("parameter",fields("par_key",destination,"md_id",0,"hq_id",0,"emp_id",0,"par_value",value,"par_text",""));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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-facing subset of customer/costcenter.php. Billing settings remain central-only. */
|
||||
@RestController
|
||||
@RequestMapping("/api/customer-portal/centers")
|
||||
class CustomerCostCenters {
|
||||
record Command(String requestId,long customer,long center,String kind,String revision,String planRevision,
|
||||
String name,long child,boolean visible,int addressType,Map<String,Object> address,boolean skipPostal) {}
|
||||
record State(CustomerPortalScope.Context context,CustomerPortalScope.Customer customer,Map<String,Object> current,
|
||||
List<Map<String,Object>> tree,List<Map<String,Object>> addresses,List<Map<String,Object>> defaults,
|
||||
List<Map<String,Object>> parentAddresses,List<Map<String,Object>> types,boolean external,boolean companyLocked) {}
|
||||
record Plan(State state,Command command,Map<String,Object> values,List<Map<String,Object>> changes) {}
|
||||
record Reference(String table,String column) {}
|
||||
final DatabaseService db;
|
||||
final BusinessSupport support;
|
||||
final OrderRoutingService routing;
|
||||
final MasterAddressSuggestions suggestions;
|
||||
CustomerCostCenters(DatabaseService db,BusinessSupport support,OrderRoutingService routing,MasterAddressSuggestions suggestions){this.db=db;this.support=support;this.routing=routing;this.suggestions=suggestions;}
|
||||
|
||||
@GetMapping("/{customer}/{center}") Object view(Authentication a,@PathVariable long customer,@PathVariable long center){return view(ApiController.user(a),customer,center);}
|
||||
@GetMapping("/{customer}/{center}/address-suggestions") Object suggest(Authentication a,@PathVariable long customer,@PathVariable long center,@RequestParam String q,@RequestParam String country,@RequestParam String sessionToken){addressAccess(ApiController.user(a),customer,center);return suggestions.suggestions(q,country,sessionToken);}
|
||||
@GetMapping("/{customer}/{center}/address-details") Object details(Authentication a,@PathVariable long customer,@PathVariable long center,@RequestParam String placeId,@RequestParam String sessionToken){addressAccess(ApiController.user(a),customer,center);return suggestions.details(placeId,sessionToken);}
|
||||
@PostMapping("/preview") Object preview(Authentication a,@RequestBody Command c){return preview(ApiController.user(a),c);}
|
||||
@PostMapping("/save") Object save(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);}
|
||||
|
||||
void addressAccess(LegacyUser u,long customer,long center){db.readTransaction(()->{var s=state(u,customer,center);s.context().require(3);return null;});}
|
||||
State state(LegacyUser u,long customer,long center){
|
||||
var context=CustomerPortalScope.context(db,u);var cs=CustomerPortalScope.customer(context,customer);
|
||||
if(!cs.allowed().contains(center))throw CustomerPortalScope.denied();
|
||||
var tree=db.queryForList("SELECT csc_id,csc_pre_id,csc_name,csc_path,csc_visible,csc_inv_interval,csc_modify FROM costcenter WHERE cs_id=? ORDER BY csc_id",customer);
|
||||
var current=one(tree.stream().filter(r->id(r,"csc_id")==center).toList());
|
||||
var byId=new HashMap<Long,Map<String,Object>>();tree.forEach(r->byId.put(id(r,"csc_id"),r));
|
||||
boolean external=false;var visited=new HashSet<Long>();long cursor=center;
|
||||
while(cursor>0){if(!visited.add(cursor)||!byId.containsKey(cursor))throw bad("Die Kostenstellenhierarchie ist unvollständig.");var r=byId.get(cursor);external|=text(r,"csc_name").equalsIgnoreCase("EXTERN");cursor=id(r,"csc_pre_id");}
|
||||
String sql="SELECT ca.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM costcenteraddress ca LEFT JOIN address a ON a.ad_id=ca.ad_id WHERE ca.csc_id=? ORDER BY ca.adt_id";
|
||||
var addresses=db.queryForList(sql,center);var defaults=center==cs.root()?addresses:db.queryForList(sql,cs.root());
|
||||
var parentAddresses=id(current,"csc_pre_id")>0?db.queryForList(sql,id(current,"csc_pre_id")):List.<Map<String,Object>>of();
|
||||
for(var list:List.of(addresses,defaults,parentAddresses))if(list.stream().map(r->id(r,"adt_id")).distinct().count()!=list.size())throw bad("Ein Adresstyp ist mehrfach hinterlegt.");
|
||||
var types=db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='addresstype' AND mt_sort BETWEEN 1 AND 4 ORDER BY mt_sort");
|
||||
if(types.isEmpty())types=List.of(fields("id",1,"label","Leistungsanschrift"),fields("id",2,"label","Rechnungsanschrift"),fields("id",4,"label","Abhol-/Lieferadresse"));
|
||||
if(types.stream().map(r->id(r,"id")).distinct().count()!=types.size())throw bad("Die Adresstypen sind nicht eindeutig.");
|
||||
boolean locked=!new OrderEntryCatalog(db).parameter(0,"MASK_CSC_CMP_FIELDS_EDIT_DISABLED").isBlank();
|
||||
return new State(context,cs,current,tree,addresses,defaults,parentAddresses,types,external,locked);
|
||||
}
|
||||
static Object version(State s){return List.of(s.customer().id(),s.customer().root(),s.customer().base(),new TreeSet<>(s.customer().allowed()),s.context().rights(),s.current(),s.tree(),s.addresses(),s.defaults(),s.parentAddresses(),s.types(),s.external(),s.companyLocked());}
|
||||
Map<String,Object> view(LegacyUser u,long customer,long center){return db.readTransaction(()->{
|
||||
var s=state(u,customer,center);boolean manage=s.context().can(1)&&!s.external();
|
||||
return fields("customer",customer,"center",center,"base",s.customer().base(),"name",text(s.current(),"csc_name"),"visible",id(s.current(),"csc_visible")==1,
|
||||
"revision",revision(version(s)),"canManage",manage,"canVisibility",manage&¢er!=s.customer().base(),"canAddress",s.context().can(3),"companyLocked",s.companyLocked(),"external",s.external(),
|
||||
"centers",s.customer().centers(),"children",s.customer().centers().stream().filter(r->id(r,"parent")==center).toList(),"types",s.types(),"addressFields",MasterData.CENTER_ADDRESS,
|
||||
"addresses",s.context().can(3)?s.types().stream().map(r->fields("type",id(r,"id"),"values",displayAddress(s,id(r,"id")))).toList():List.of());
|
||||
});}
|
||||
static Map<String,Object> addressValues(Map<String,Object> row){var values=new LinkedHashMap<String,Object>();for(var f:MasterData.CENTER_ADDRESS){String value=text(row,f.key());if(f.key().equals("ad_country")&&(value.isEmpty()||value.equals("D")))value="DE";values.put(f.key(),value);}return values;}
|
||||
static Map<String,Object> displayAddress(State s,long type){var values=addressValues(s.addresses().stream().filter(r->id(r,"adt_id")==type).findFirst().orElse(Map.of()));if(text(values,"cscad_comp").isEmpty()){var parent=s.parentAddresses().stream().filter(r->id(r,"adt_id")==type).findFirst().orElse(Map.of());for(String k:List.of("cscad_comp","cscad_comp2","cscad_comp3","cscad_comp4"))values.put(k,text(parent,k));}return values;}
|
||||
static String name(String value){value=required(ArticleAdministration.clean(value,50,"Kostenstellenname"),50,"Kostenstellenname");if(value.contains("//")||value.contains("\n")||value.contains("\r"))throw bad("Kostenstellennamen dürfen keine Pfadtrenner oder Zeilenumbrüche enthalten.");return value;}
|
||||
void authorize(State s,String kind){if(kind.equals("ADDRESS"))s.context().require(3);else{ s.context().require(1);if(s.external())throw CustomerPortalScope.denied();}}
|
||||
List<Reference> references(){return db.queryForList("SELECT c.TABLE_NAME AS tbl,c.COLUMN_NAME AS col FROM information_schema.COLUMNS c JOIN information_schema.TABLES t ON t.TABLE_SCHEMA=c.TABLE_SCHEMA AND t.TABLE_NAME=c.TABLE_NAME WHERE c.TABLE_SCHEMA=DATABASE() AND t.TABLE_TYPE='BASE TABLE' AND c.COLUMN_NAME IN ('csc_id','csc_id_payer','csc_id_related','csc_pre_id') AND c.TABLE_NAME NOT IN ('costcenter','costcenteraddress') ORDER BY c.TABLE_NAME,c.COLUMN_NAME").stream().map(r->{String t=text(r,"tbl"),c=text(r,"col");if(!t.matches("[A-Za-z0-9_]+")||!c.matches("[A-Za-z0-9_]+"))throw bad("Ungültiger Kostenstellenbezug im Datenbankschema.");return new Reference(t,c);}).toList();}
|
||||
void deletable(State s,long child,List<Reference> refs){
|
||||
if(child==s.customer().root()||child==s.customer().base()||!s.customer().allowed().contains(child)||s.tree().stream().noneMatch(r->id(r,"csc_id")==child&&id(r,"csc_pre_id")==id(s.current(),"csc_id")))throw CustomerPortalScope.denied();
|
||||
if(s.tree().stream().anyMatch(r->id(r,"csc_pre_id")==child))throw bad("Die Kostenstelle hat noch Unterkostenstellen.");
|
||||
for(var ref:refs)if(!db.queryForList("SELECT 1 FROM `"+ref.table()+"` WHERE `"+ref.column()+"`=? LIMIT 1",child).isEmpty())throw bad("Die Kostenstelle wird noch verwendet und kann nicht gelöscht werden.");
|
||||
}
|
||||
Plan plan(LegacyUser u,Command c,boolean validate,List<Reference> refs){
|
||||
if(c==null||!Set.of("CREATE","RENAME","VISIBILITY","DELETE","ADDRESS").contains(Objects.toString(c.kind(),"")))throw bad("Ungültige Kostenstellenänderung.");token(c.requestId());
|
||||
var s=state(u,c.customer(),c.center());authorize(s,c.kind());unchanged(version(s),c.revision());
|
||||
var values=new LinkedHashMap<String,Object>();var changes=new ArrayList<Map<String,Object>>();
|
||||
switch(c.kind()){
|
||||
case "CREATE","RENAME"->{String n=name(c.name());if(c.kind().equals("RENAME")&&n.equals(text(s.current(),"csc_name")))throw bad("Keine Namensänderung vorhanden.");
|
||||
if(!db.queryForList("SELECT csc_id FROM costcenter WHERE cs_id=? AND csc_name=? AND csc_id<>?",c.customer(),n,c.kind().equals("RENAME")?c.center():0).isEmpty())throw bad("Dieser Kostenstellenname ist beim Kunden bereits vorhanden.");values.put("name",n);changes.add(fields("label",c.kind().equals("CREATE")?"Neue Unterkostenstelle":"Kostenstellenname","before",c.kind().equals("CREATE")?"Unter "+text(s.current(),"csc_name"):text(s.current(),"csc_name"),"after",n));}
|
||||
case "VISIBILITY"->{if(c.center()==s.customer().base())throw bad("Die persönliche Hauptkostenstelle kann nicht ausgeblendet werden.");if((id(s.current(),"csc_visible")==1)==c.visible())throw bad("Die Sichtbarkeit ist unverändert.");values.put("visible",c.visible()?1:0);changes.add(fields("label","Sichtbarkeit","before",id(s.current(),"csc_visible")==1?"Sichtbar":"Ausgeblendet","after",c.visible()?"Sichtbar":"Ausgeblendet"));}
|
||||
case "DELETE"->{deletable(s,c.child(),refs);changes.add(fields("label","Unterkostenstelle löschen","before",text(one(s.tree().stream().filter(r->id(r,"csc_id")==c.child()).toList()),"csc_name"),"after","Wird entfernt"));}
|
||||
case "ADDRESS"->{if(c.address()==null||s.types().stream().noneMatch(r->id(r,"id")==c.addressType()))throw bad("Ungültiger Adresstyp.");
|
||||
c.address().forEach((k,v)->ArticleAdministration.clean(Objects.toString(v,""),10000,k));values.putAll(MasterData.validateFields(MasterData.CENTER_ADDRESS,"centers",c.address()));
|
||||
var old=addressValues(s.addresses().stream().filter(r->id(r,"adt_id")==c.addressType()).findFirst().orElse(Map.of()));
|
||||
if(s.companyLocked())for(String k:List.of("cscad_comp","cscad_comp2","cscad_comp3","cscad_comp4"))if(!Objects.equals(displayAddress(s,c.addressType()).get(k),values.get(k)))throw CustomerPortalScope.denied();
|
||||
for(var f:MasterData.CENTER_ADDRESS)if(!Objects.equals(old.get(f.key()),values.get(f.key())))changes.add(fields("label",f.label(),"before",old.get(f.key()),"after",values.get(f.key())));
|
||||
if(changes.isEmpty())throw bad("Keine Adressänderung vorhanden.");if(validate)validateAddress(values,c.skipPostal());
|
||||
}
|
||||
}
|
||||
return new Plan(s,c,values,changes);
|
||||
}
|
||||
void validateAddress(Map<String,Object> v,boolean skipPostal){
|
||||
routing.geocode(new OrderEntryModels.Station(null,Models.StopType.PICKUP,text(v,"cscad_comp"),text(v,"ad_street"),text(v,"cscad_hsno"),text(v,"ad_zipcode"),text(v,"ad_city"),text(v,"ad_country"),"","","","",null,null,List.of()));
|
||||
if(!skipPostal&&db.queryForList("SELECT st_zipcode FROM phoenix_special.street WHERE st_zipcode=? AND st_city LIKE ? ESCAPE '!' AND st_street LIKE ? ESCAPE '!' LIMIT 1",text(v,"ad_zipcode"),EntryCostCenterSearch.term(text(v,"ad_city"),true),EntryCostCenterSearch.term(WarehouseDetails.streetPrefix(text(v,"ad_street")),true)).isEmpty())throw bad("Postleitzahl, Ort und Straße passen nicht zum Straßenverzeichnis. Bitte korrigieren oder die zusätzliche PLZ-Prüfung ausdrücklich ausnehmen.");
|
||||
}
|
||||
static Object planVersion(Plan p){return List.of(version(p.state()),p.command().kind(),p.command().child(),p.command().addressType(),p.command().skipPostal(),p.values(),p.changes());}
|
||||
Map<String,Object> preview(LegacyUser u,Command c){return db.readTransaction(()->{var refs=c!=null&&"DELETE".equals(c.kind())?references():List.<Reference>of();var p=plan(u,c,true,refs);return fields("changes",p.changes(),"planRevision",revision(planVersion(p)));});}
|
||||
Map<String,Object> save(LegacyUser u,Command c){
|
||||
if(c==null)throw bad("Kostenstellenänderung fehlt.");token(c.requestId());var intent=List.of("CUSTOMER_COSTCENTER",c);support.transactional("costcenter","costcenteraddress","address","company","genericdatacontainer");
|
||||
var refs="DELETE".equals(c.kind())?references():List.<Reference>of();
|
||||
boolean repeated=db.readTransaction(()->{var s=state(u,c.customer(),c.center());s.context().require("ADDRESS".equals(c.kind())?3:1);return support.receipt(u,c.requestId(),intent)!=null;});
|
||||
if(!repeated)db.readTransaction(()->plan(u,c,true,refs));
|
||||
return db.customerCostCenterWriteTransaction(refs.stream().map(entryValue -> entryValue.table()).distinct().toList(),()->{
|
||||
var s=state(u,c.customer(),c.center());s.context().require("ADDRESS".equals(c.kind())?3:1);Long done=support.receipt(u,c.requestId(),intent);if(done!=null)return fields("completed",true,"center",done);
|
||||
var p=plan(u,c,false,refs);unchanged(planVersion(p),c.planRevision());long result=c.center();int operation;
|
||||
switch(c.kind()){
|
||||
case "CREATE"->{var root=one(s.tree().stream().filter(r->id(r,"csc_id")==s.customer().root()).toList());result=db.insert("costcenter",fields("cs_id",c.customer(),"csc_pre_id",c.center(),"csc_name",p.values().get("name"),"csc_visible",1,"csc_inv_interval",id(root,"csc_inv_interval")));
|
||||
for(int type=1;type<=4;type++){int selected=type;var original=s.defaults().stream().filter(r->id(r,"adt_id")==selected).findFirst().orElse(Map.of());var copy=fields("csc_id",result,"adt_id",type,"ad_id",id(original,"ad_id"),"cscad_hsno",text(original,"cscad_hsno"),"cscad_gln","");for(String k:List.of("cscad_comp","cscad_comp2","cscad_comp3","cscad_comp4"))copy.put(k,text(original,k));insertWithoutKey(db,"costcenteraddress",copy);}rebuildPaths(c.customer(),s.customer().root());operation=45;}
|
||||
case "RENAME"->{db.update("UPDATE costcenter SET csc_name=? WHERE csc_id=?",p.values().get("name"),c.center());rebuildPaths(c.customer(),s.customer().root());operation=47;}
|
||||
case "VISIBILITY"->{db.update("UPDATE costcenter SET csc_visible=? WHERE csc_id=?",p.values().get("visible"),c.center());operation=51;}
|
||||
case "DELETE"->{db.update("DELETE FROM costcenteraddress WHERE csc_id=?",c.child());db.update("DELETE FROM costcenter WHERE csc_id=?",c.child());operation=46;}
|
||||
case "ADDRESS"->{var v=p.values();var matches=db.queryForList("SELECT ad_id FROM address WHERE ad_street=? AND ad_zipcode=? AND ad_city=? AND ad_country=? ORDER BY ad_id",v.get("ad_street"),v.get("ad_zipcode"),v.get("ad_city"),v.get("ad_country"));long address=matches.isEmpty()?db.insert("address",MasterData.prefix(v,"ad_")):id(matches.getFirst(),"ad_id");var data=MasterData.prefix(v,"cscad_");data.put("ad_id",address);
|
||||
if(s.addresses().stream().anyMatch(r->id(r,"adt_id")==c.addressType())){var args=new ArrayList<>(data.values());args.add(c.center());args.add(c.addressType());db.update("UPDATE costcenteraddress SET "+String.join(",",data.keySet().stream().map(k->k+"=?").toList())+" WHERE csc_id=? AND adt_id=?",args.toArray());}else{data.put("csc_id",c.center());data.put("adt_id",c.addressType());insertWithoutKey(db,"costcenteraddress",data);}
|
||||
if(c.center()==s.customer().root()){var customer=one(db.queryForList("SELECT cmp_id FROM customer WHERE cs_id=?",c.customer()));db.update("UPDATE company SET cmp_modify_status=2 WHERE cmp_id=? AND COALESCE(cmp_modify_status,0) NOT IN (1,2)",id(customer,"cmp_id"));}operation=48;}
|
||||
default->throw bad("Unbekannte Kostenstellenaktion.");
|
||||
}
|
||||
support.audit(u,0,c.customer(),0,operation,"CUSTOMER_COSTCENTER="+result+"|ACTION="+c.kind()+"|CHANGES="+support.encode(p.changes()),c.requestId());support.receipt(u,c.requestId(),intent,result);return fields("completed",true,"center",result);
|
||||
});
|
||||
}
|
||||
void rebuildPaths(long customer,long root){
|
||||
var rows=db.queryForList("SELECT csc_id AS id,csc_pre_id AS parent,csc_name AS label FROM costcenter WHERE cs_id=? ORDER BY csc_id",customer);var valid=PortalEmployees.validCenters(rows,root);var byId=new HashMap<Long,Map<String,Object>>();rows.forEach(r->byId.put(id(r,"id"),r));
|
||||
for(long center:valid){var names=new ArrayList<String>();long cursor=id(byId.get(center),"parent");while(cursor>0){var p=byId.get(cursor);names.addFirst(text(p,"label"));cursor=id(p,"parent");}db.update("UPDATE costcenter SET csc_path=? WHERE csc_id=?",names.isEmpty()?"":"//"+String.join("//",names)+"//",center);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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;
|
||||
|
||||
/** customer/jb_list.php: a customer may dispatch only to its own favoured couriers. */
|
||||
@RestController
|
||||
@RequestMapping("/api/customer-portal/orders/{job}/courier-assignment")
|
||||
class CustomerCourierAssignment {
|
||||
record Selection(long vehicle,long courier,boolean resetPreviousAvailableTime){}
|
||||
record Command(String requestId,String revision,Selection selection,boolean confirmed){}
|
||||
record State(long customer,String accessRevision,Map<String,Object> job,List<Map<String,Object>> stops,List<Map<String,Object>> vehicles,List<String> blockers){}
|
||||
record Prepared(State state,Selection selection,Map<String,Object> vehicle,boolean loseRanking,String rankingAfter,List<String> warnings,String revision){}
|
||||
final DatabaseService db;final BusinessSupport support;final OrderEntryCatalog catalog;
|
||||
CustomerCourierAssignment(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;catalog=new OrderEntryCatalog(db);}
|
||||
@GetMapping Object get(Authentication a,@PathVariable long job){return options(ApiController.user(a),job);}
|
||||
@PostMapping("/preview") Object preview(Authentication a,@PathVariable long job,@RequestBody Selection s){return preview(ApiController.user(a),job,s);}
|
||||
@PostMapping Object post(Authentication a,@PathVariable long job,@RequestBody Command c){return save(ApiController.user(a),job,c);}
|
||||
|
||||
State state(LegacyUser u,long job){
|
||||
var context=CustomerPortalScope.context(db,u);context.require(4);long customer=id(context.account(),"cs_id");
|
||||
var own=CustomerPortalScope.customer(context,customer);var args=new ArrayList<Object>(new TreeSet<>(own.allowed()));args.add(job);args.add(u.headquartersId());
|
||||
var portal=new CustomerPortal(db);String related=catalog.parameter(0,"GLOBAL_USE_RELATED_CUSTOMER"),enabled=catalog.parameter(0,"CUSTOMER_MASK_JOBLIST_DISPO_FAV_ENABLED_"+customer);
|
||||
if(!enabled.equals("1")||related.equals("1"))throw CustomerPortalScope.denied();
|
||||
var rows=db.queryForList("SELECT jb.* FROM job jb WHERE ("+portal.payer(u)+") IN ("+CourierPortal.placeholders(own.allowed().size())+") AND jb.jb_id=? AND jb.hq_id=?",args.toArray());
|
||||
if(rows.size()!=1||id(rows.getFirst(),"jb_id_parent")>0)throw CustomerPortalScope.denied();var j=rows.getFirst();var blockers=new ArrayList<String>();
|
||||
if(!Set.of(0L,1L,8L,9L).contains(id(j,"jb_status"))||id(j,"jb_storno")!=0)blockers.add("Nur offene, nicht stornierte Aufträge können zugewiesen werden.");
|
||||
if(OrderOperations.exported(j)||!db.queryForList("SELECT jb_id FROM invoice WHERE jb_id=?",job).isEmpty()||!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())blockers.add("Der Auftrag ist exportiert oder einer Rechnung zugeordnet.");
|
||||
if(!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",job).isEmpty())blockers.add("Für den Auftrag besteht bereits eine Gegenbuchung.");
|
||||
var lock=dateTime(j,"jb_locktime");if(lock!=null&&lock.isAfter(db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime().minusMinutes(5)))blockers.add("Der Auftrag wird gerade in der alten Anwendung bearbeitet.");
|
||||
if(id(j,"hq_id_dispo")>0&&id(j,"hq_id_dispo")!=u.headquartersId())blockers.add("Die Zuweisung erfolgt durch eine andere Niederlassung.");
|
||||
var stops=db.queryForList("SELECT t.tr_id,t.tr_sort,stopaddr.ad_zipcode FROM tour t LEFT JOIN address stopaddr ON stopaddr.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",job);
|
||||
if(stops.isEmpty()||text(stops.getFirst(),"ad_zipcode").isBlank())blockers.add("Die Startadresse ist unvollständig.");
|
||||
boolean multiple=AssignmentVehicles.multiple(catalog,u);
|
||||
String sql="SELECT cv.crvh_id,cv.crvh_sid,cv.vht_id,cr.cr_sid,cr.cr_available,cr.cr_occupied,cr.cr_locationzipcode,cr.cr_availabletime,"+AssignmentVehicles.IDENTITY+" "+AssignmentVehicles.from(false,multiple)+" AND cr.cr_sid=cv.crvh_sid AND EXISTS (SELECT 1 FROM customercourier fav WHERE fav.cs_id=? AND fav.cr_id=cr.cr_id AND fav.cscr_relation=1) AND NOT EXISTS (SELECT 1 FROM customercourier blocked WHERE blocked.cs_id=? AND blocked.cr_id=cr.cr_id AND blocked.cscr_relation<>1) ORDER BY cv.crvh_sid,cv.crvh_id LIMIT 1001";
|
||||
var vehicles=AssignmentVehicles.identities(db.queryForList(sql,u.headquartersId(),customer,customer)).stream().filter(v->!AssignmentVehicles.ambiguous(v,multiple)&&id(v,"logged_drivers")==1).toList();
|
||||
if(vehicles.size()>1000)throw bad("Zu viele bevorzugte Fahrzeuge. Bitte die Zuordnungen prüfen.");
|
||||
String access=revision(List.of(customer,own.allowed(),context.rights(),enabled,related,multiple));
|
||||
return new State(customer,access,j,stops,vehicles,List.copyOf(blockers));
|
||||
}
|
||||
Map<String,Object> options(LegacyUser u,long job){return db.readTransaction(()->{var s=state(u,job);return fields("id",job,"current",text(s.job(),"cr_sid"),"canResetPrevious",id(s.job(),"cr_id")>0,"blockers",s.blockers(),"vehicles",s.vehicles().stream().map(v->fields("id",id(v,"crvh_id"),"courier",id(v,"cr_id"),"sid",text(v,"crvh_sid"),"available",id(v,"cr_available")==1,"occupied",id(v,"cr_occupied")!=0)).toList());});}
|
||||
Prepared prepare(LegacyUser u,long job,Selection selection){
|
||||
if(selection==null||selection.vehicle()<=0||selection.courier()<=0)throw bad("Bitte einen bevorzugten Kurier wählen.");
|
||||
var s=state(u,job);if(!s.blockers().isEmpty())throw bad(String.join(" · ",s.blockers()));
|
||||
var matches=s.vehicles().stream().filter(v->id(v,"crvh_id")==selection.vehicle()&&id(v,"cr_id")==selection.courier()).toList();
|
||||
if(matches.size()!=1)throw CustomerPortalScope.denied();var v=matches.getFirst();var j=s.job();
|
||||
if(id(j,"cr_id_order")==selection.courier()||id(j,"cr_id")==selection.courier())throw bad("Dieser Kurier ist dem Auftrag bereits zugewiesen.");
|
||||
if(selection.resetPreviousAvailableTime()&&(id(j,"cr_id")<=0||db.queryForList("SELECT cr_id FROM courier WHERE cr_id=? AND hq_id=?",id(j,"cr_id"),u.headquartersId()).size()!=1))throw bad("Es gibt keinen bisherigen Kurier mit zurücksetzbarer Freimeldezeit.");
|
||||
var warnings=new ArrayList<String>();if(id(v,"cr_available")!=1)warnings.add("Der Kurier ist nicht als verfügbar gemeldet.");if(id(v,"cr_occupied")!=0)warnings.add("Der Kurier ist bereits als belegt gemeldet.");if(id(v,"vht_id")!=id(j,"vht_id"))warnings.add("Die Fahrzeugklasse weicht vom Auftrag ab.");
|
||||
var evidence=new ArrayList<Object>();evidence.add(s);evidence.add(selection);String start=text(s.stops().getFirst(),"ad_zipcode");long hq=id(j,"hq_id_dispo")>0?id(j,"hq_id_dispo"):u.headquartersId();
|
||||
var lead=db.queryForList("SELECT p.srvp_id,lt.srvpt_traveltime FROM serviceplz p JOIN serviceplztraveltime lt ON lt.srvp_id=p.srvp_id WHERE p.srvp_plz=? AND lt.hq_id=? ORDER BY p.srvp_id",start,hq);evidence.add(lead);
|
||||
if(lead.size()>1)throw bad("Die Anfahrtszeit für den Rankingverlust ist mehrdeutig.");
|
||||
String after="";boolean deferred=false;var time=dateTime(j,"jb_ordertime");
|
||||
if(time!=null&&lead.size()==1&&lead.getFirst().get("srvpt_traveltime")!=null){var threshold=time.minusMinutes(Math.max(30,id(lead.getFirst(),"srvpt_traveltime")));after=threshold.toString();deferred=!db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime().isAfter(threshold);}
|
||||
boolean lose=!deferred&&new FleetRanking(db,catalog).area(u,text(v,"cr_locationzipcode"),start,evidence).contains();
|
||||
if(deferred)warnings.add("Künftiger Auftrag: Rankingverlust wird erst nach "+after.replace('T',' ')+" geprüft.");
|
||||
evidence.add(after);evidence.add(deferred);evidence.add(lose);return new Prepared(s,selection,v,lose,after,List.copyOf(warnings),revision(evidence));
|
||||
}
|
||||
Map<String,Object> preview(LegacyUser u,long job,Selection selection){return db.readTransaction(()->{var p=prepare(u,job,selection);return fields("id",job,"revision",p.revision(),"sid",text(p.vehicle(),"crvh_sid"),"previous",text(p.state().job(),"cr_sid"),"loseRanking",p.loseRanking(),"rankingAfter",p.rankingAfter(),"warnings",p.warnings(),"resetPreviousAvailableTime",selection.resetPreviousAvailableTime());});}
|
||||
Map<String,Object> save(LegacyUser u,long job,Command c){
|
||||
if(c==null||!c.confirmed()||c.selection()==null||c.revision()==null||!c.revision().matches("[a-f0-9]{64}"))throw bad("Bitte die Zuweisung zuerst prüfen und bestätigen.");BusinessSupport.token(c.requestId());
|
||||
support.transactional("job","courier","autoranking","genericdatacontainer","phoenix_pda.commandexec");
|
||||
return db.customerAssignmentWriteTransaction(()->{
|
||||
state(u,job);Object intent=List.of("CUSTOMER_ASSIGN",job,c);Long old=support.receipt(u,c.requestId(),intent);if(old!=null)return fields("id",job,"replayed",true);
|
||||
var p=prepare(u,job,c.selection());if(!p.revision().equals(c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Auftrag, Fahrer oder Freigabe wurden geändert. Bitte erneut prüfen.");
|
||||
var j=p.state().job();long next=c.selection().courier();var now=LocalDateTime.now().withNano(0);
|
||||
for(long previous:new TreeSet<>(List.of(id(j,"cr_id"),id(j,"cr_id_order"))))if(previous>0&&previous!=next)db.insert("`phoenix_pda`.commandexec",fields("cr_id",previous,"hq_id",u.headquartersId(),"cmd_command","4","cmde_mode","1","cmde_param",Long.toString(job),"cmde_exec_time",now.plusSeconds(30),"cmde_exec_timelimit",now.plusHours(36)));
|
||||
if(c.selection().resetPreviousAvailableTime())db.update("UPDATE courier SET cr_availabletime=? WHERE cr_id=? AND hq_id=?",now,id(j,"cr_id"),u.headquartersId());
|
||||
var ranks=db.queryForList("SELECT cr_id FROM autoranking WHERE jb_id=? AND cr_id=?",job,next);if(ranks.size()>1)throw bad("Die bisherige Kurierzuweisung ist mehrdeutig.");
|
||||
if(ranks.isEmpty())BusinessSupport.insertWithoutKey(db,"autoranking",fields("jb_id",job,"cr_id",next,"ar_challenge",0,"ar_lastassigntime",now,"ar_looseranking",p.loseRanking()?1:0,"ar_locating",0));
|
||||
else db.update("UPDATE autoranking SET ar_challenge=0,ar_lastassigntime=?,ar_looseranking=?,ar_locating=0 WHERE jb_id=? AND cr_id=?",now,p.loseRanking()?1:0,job,next);
|
||||
var changes=fields("cr_id",0,"cr_id_order",next,"cr_sid",text(p.vehicle(),"crvh_sid"),"jb_status",0,"jb_globaljob",0,"jb_autoranking",0,"jb_taketime",null,"vht_id_real",id(p.vehicle(),"vht_id"),"jb_version",id(j,"jb_version")+1);BusinessSupport.update(db,"job","jb_id",job,changes);
|
||||
var assigned=new LinkedHashMap<>(j);assigned.putAll(changes);new OrderSurvey(db,catalog).apply(u,assigned,c.requestId(),support);
|
||||
support.audit(u,job,p.state().customer(),next,7,"Kundenzuweisung · LOST_RANKING="+(p.loseRanking()?"YES":"NO"),c.requestId());support.receipt(u,c.requestId(),intent,job);return fields("id",job,"replayed",false);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.math.*;
|
||||
import java.time.*;
|
||||
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;
|
||||
|
||||
/** customer_courier.php/courier_customer.php, with durable recovery for customercourier (MyISAM). */
|
||||
@RestController
|
||||
@RequestMapping("/api/master/relations")
|
||||
class CustomerCourierRelations {
|
||||
final DatabaseService db;final BusinessSupport support;final Clock clock;
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
CustomerCourierRelations(DatabaseService db,BusinessSupport support){this(db,support,Clock.systemDefaultZone());}
|
||||
CustomerCourierRelations(DatabaseService db,BusinessSupport support,Clock clock){this.db=db;this.support=support;this.clock=clock;}
|
||||
record Row(long customer,long courier,int relation,int position,String commission,String description){}
|
||||
record Target(long customer,long courier,String revision){}
|
||||
record Command(String requestId,String action,List<Target> targets,Integer relation,String description,String commission,Integer position,String planRevision){}
|
||||
record Change(long customer,List<Row> before,List<Row> after){}
|
||||
record History(String eid,String value,String at){}
|
||||
record Commission(long customer,long courier,String eid,String value,List<History> before){}
|
||||
record Prepared(List<Change> changes,List<Commission> commissions,List<String> descriptions){}
|
||||
record Plan(long user,long headquarters,Command command,Prepared prepared,String at){}
|
||||
@GetMapping("/{kind}/{id}") Map<String,Object> get(Authentication a,@PathVariable String kind,@PathVariable long id){return view(ApiController.user(a),kind,id);}
|
||||
@GetMapping("/search") Map<String,Object> search(Authentication a,@RequestParam String kind,@RequestParam(defaultValue="") String q){return search(ApiController.user(a),kind,q);}
|
||||
@GetMapping("/history") List<History> history(Authentication a,@RequestParam long customer,@RequestParam long courier){var u=ApiController.user(a);pair(u,customer,courier);return history(customer,courier);}
|
||||
@PostMapping("/preview") Map<String,Object> preview(Authentication a,@RequestBody Command c){return preview(ApiController.user(a),c);}
|
||||
@PostMapping Map<String,Object> save(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);}
|
||||
@PostMapping("/operations/{token}") Map<String,Object> resume(Authentication a,@PathVariable String token){return resume(ApiController.user(a),token);}
|
||||
void access(LegacyUser u,String kind,long target){u.require(1);if(target<=0)throw bad("Ungültiger Stammdatensatz.");String table=kind.equals("customers")?"customer":kind.equals("couriers")?"courier":null;if(table==null)throw bad("Unbekannter Beziehungsbereich.");String key=kind.equals("customers")?"cs_id":"cr_id";one(db.queryForList("SELECT "+key+" FROM "+table+" WHERE "+key+"=? AND hq_id=?",target,u.headquartersId()));}
|
||||
void pair(LegacyUser u,long customer,long courier){access(u,"customers",customer);access(u,"couriers",courier);}
|
||||
List<Row> rows(long customer){var rows=db.queryForList("SELECT cs_id,cr_id,cscr_relation,cscr_sort,cscr_prov,cscr_description FROM customercourier WHERE cs_id=? ORDER BY cr_id LIMIT 1001",customer);if(rows.size()>1000)throw bad("Mehr als 1000 Beziehungen bei einem Kunden. Bitte den Bestand eingrenzen lassen.");var result=rows.stream().map(r->new Row(id(r,"cs_id"),id(r,"cr_id"),(int)id(r,"cscr_relation"),(int)id(r,"cscr_sort"),amount(r.get("cscr_prov")),(String)r.get("cscr_description"))).toList();if(result.stream().map(entryValue -> entryValue.courier()).distinct().count()!=result.size())throw bad("Doppelte Kunden-/Transporteurbeziehungen im Bestand. Bitte zuerst klären.");return result;}
|
||||
static String amount(Object value){return new BigDecimal(value.toString()).setScale(2,RoundingMode.HALF_UP).toPlainString();}
|
||||
List<Map<String,Object>> pending(long hq){return db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcscr' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 101",hq);}
|
||||
List<Map<String,Object>> operation(long hq,String token){return db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='ngcscr' AND gdc_obj_id=? AND gdc_gen_fieldname=?",hq,token);}
|
||||
Plan decode(String json){try{return support.json.readValue(json,Plan.class);}catch(Exception e){throw bad("Der gespeicherte Beziehungsvorgang ist nicht lesbar.");}}
|
||||
Map<String,Object> view(LegacyUser u,String kind,long target){access(u,kind,target);return db.readTransaction(()->{
|
||||
String column=kind.equals("customers")?"r.cs_id":"r.cr_id";
|
||||
var records=db.queryForList("SELECT r.cs_id,r.cr_id,r.cscr_relation,r.cscr_sort,r.cscr_prov,r.cscr_description,cs.cs_eid,CONCAT_WS(' ',co.cmp_comp,co.cmp_comp2) AS customerName,cr.cr_eid,cr.cr_sid,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS courierName FROM customercourier r JOIN customer cs ON cs.cs_id=r.cs_id JOIN courier cr ON cr.cr_id=r.cr_id LEFT JOIN company co ON co.cmp_id=cs.cmp_id LEFT JOIN user usr ON usr.usr_id=cr.usr_id WHERE "+column+"=? AND cs.hq_id=? AND cr.hq_id=? ORDER BY r.cscr_relation DESC,r.cscr_sort,r.cs_id,r.cr_id LIMIT 1001",target,u.headquartersId(),u.headquartersId());
|
||||
if(records.size()>1000)throw bad("Mehr als 1000 Beziehungen. Bitte den Bestand eingrenzen lassen.");
|
||||
var revisions=new HashMap<Long,String>();if(kind.equals("customers"))revisions.put(target,revision(rows(target)));
|
||||
var list=new ArrayList<Map<String,Object>>();for(var r:records){long customer=id(r,"cs_id");String rev=revisions.computeIfAbsent(customer,k->revision(rows(k)));list.add(fields("customer",customer,"courier",id(r,"cr_id"),"customerLabel",text(r,"cs_eid")+" · "+text(r,"customerName"),"courierLabel",text(r,"cr_eid")+" / "+text(r,"cr_sid")+" · "+text(r,"courierName"),"relation",id(r,"cscr_relation"),"position",id(r,"cscr_sort"),"commission",amount(r.get("cscr_prov")),"description",text(r,"cscr_description"),"revision",rev));}
|
||||
var operations=new ArrayList<Map<String,Object>>();for(var r:pending(u.headquartersId())){var p=decode(text(r,"gdc_content"));if(p.command().targets().stream().anyMatch(t->kind.equals("customers")?t.customer()==target:t.courier()==target)||p.prepared().changes().stream().anyMatch(ch->revisions.containsKey(ch.customer())))operations.add(fields("token",text(r,"gdc_gen_fieldname"),"canResume",p.user()==u.userId(),"description",String.join(" ",p.prepared().descriptions())));}
|
||||
return fields("rows",list,"revision",revisions.getOrDefault(target,""),"canEditCommission",u.hasRight(39),"operations",operations);
|
||||
});}
|
||||
Map<String,Object> search(LegacyUser u,String kind,String query){u.require(1);if(query==null||query.length()>100)throw bad("Suchtext darf höchstens 100 Zeichen lang sein.");String q="%"+query.trim()+"%";List<Map<String,Object>> found;
|
||||
if(kind.equals("customers"))found=db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' · ',cs.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer cs JOIN company co ON co.cmp_id=cs.cmp_id WHERE cs.hq_id=? AND (cs.cs_eid LIKE ? OR co.cmp_comp LIKE ? OR co.cmp_comp2 LIKE ?) ORDER BY co.cmp_comp,cs.cs_id LIMIT 51",u.headquartersId(),q,q,q);
|
||||
else if(kind.equals("couriers"))found=db.queryForList("SELECT cr.cr_id AS id,CONCAT_WS(' · ',cr.cr_eid,cr.cr_sid,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name)) AS label FROM courier cr LEFT JOIN user usr ON usr.usr_id=cr.usr_id WHERE cr.hq_id=? AND (cr.cr_eid LIKE ? OR cr.cr_sid LIKE ? OR usr.usr_name LIKE ? OR usr.usr_firstname LIKE ?) ORDER BY cr.cr_sid,cr.cr_id LIMIT 51",u.headquartersId(),q,q,q,q);
|
||||
else throw bad("Unbekannter Suchbereich.");return fields("items",found.stream().limit(50).toList(),"more",found.size()>50);
|
||||
}
|
||||
List<History> history(long customer,long courier){var rows=db.queryForList("SELECT srvprch_obj_eid,srvprch_price,srvprch_validfrom FROM servicepricehistory WHERE srvprch_obj_type='cr' AND srvprch_obj_id=? AND srvprch_fieldname='cscr_prov' AND srvprch_context=? ORDER BY srvprch_validfrom DESC,srvprch_price,srvprch_obj_eid LIMIT 1001",courier,Long.toString(customer));if(rows.size()>1000)throw bad("Mehr als 1000 Provisionsstände. Bitte den Bestand prüfen lassen.");return rows.stream().map(r->new History(text(r,"srvprch_obj_eid"),amount(r.get("srvprch_price")),r.get("srvprch_validfrom").toString())).toList();}
|
||||
void validate(LegacyUser u,Command c){u.require(1);if(c==null||c.action()==null||!Set.of("ADD","UPDATE","REMOVE","MOVE").contains(c.action()))throw bad("Unbekannte Beziehungsaktion.");token(c.requestId());if(c.targets()==null||c.targets().isEmpty()||c.targets().size()>100||!c.action().equals("REMOVE")&&c.targets().size()!=1)throw bad("Bitte 1 bis 100 Beziehungen auswählen.");var keys=new HashSet<String>();for(var t:c.targets()){if(t==null||!keys.add(t.customer()+":"+t.courier()))throw bad("Doppelte oder ungültige Auswahl.");pair(u,t.customer(),t.courier());}}
|
||||
Prepared prepare(LegacyUser u,Command c){
|
||||
validate(u,c);var customers=new TreeMap<Long,List<Row>>();for(var t:c.targets()){var r=customers.computeIfAbsent(t.customer(),entryValue -> this.rows(entryValue));unchanged(r,t.revision());}
|
||||
var after=new TreeMap<Long,List<Row>>();customers.forEach((k,v)->after.put(k,new ArrayList<>(v)));var commissions=new ArrayList<Commission>();var descriptions=new ArrayList<String>();
|
||||
for(var t:c.targets()){
|
||||
var rows=after.get(t.customer());var old=rows.stream().filter(r->r.courier()==t.courier()).findFirst().orElse(null);String name="Kunde #"+t.customer()+" / Transporteur #"+t.courier();
|
||||
if(c.action().equals("ADD")&&old!=null)throw bad("Zwischen diesem Kunden und Transporteur besteht bereits eine Beziehung.");
|
||||
if(!c.action().equals("ADD")&&old==null)throw bad("Die ausgewählte Beziehung besteht nicht mehr.");
|
||||
if(old!=null&&old.relation()!=0&&old.relation()!=1)throw bad("Unbekannter Beziehungsstatus im Bestand. Bitte zuerst klären.");
|
||||
if(c.action().equals("REMOVE")){rows.remove(old);descriptions.add(name+": Beziehung und Bemerkung entfernen. Vorhandene Provisionshistorie bleibt erhalten.");continue;}
|
||||
if(c.action().equals("MOVE")){
|
||||
if(old==null)throw bad("Die vorhandene Transporteurbeziehung fehlt.");
|
||||
if(old.relation()!=1)throw bad("Nur bevorzugte Transporteure haben eine Rangfolge.");var favourites=sorted(rows);if(c.position()==null||c.position()<1||c.position()>favourites.size())throw bad("Ungültige Position in der Favoritenliste.");favourites.remove(old);favourites.add(c.position()-1,old);renumber(rows,favourites);descriptions.add(name+": bevorzugten Transporteur auf Rang "+c.position()+" setzen.");continue;
|
||||
}
|
||||
if(c.relation()==null||c.relation()<0||c.relation()>1)throw bad("Bitte Gesperrt oder Besonders erwünscht auswählen.");String description=c.description()==null?"":c.description().strip();if(description.length()>255||description.indexOf('\u0000')>=0)throw bad("Bemerkung darf höchstens 255 Zeichen enthalten.");
|
||||
String rate=old==null?"0.00":old.commission();
|
||||
if(c.commission()!=null){
|
||||
String requested=rate(c.commission());if(!requested.equals(rate)){u.require(39);if(c.relation()!=1)throw bad("Provision kann nur bei bevorzugten Transporteuren geändert werden.");rate=requested;}
|
||||
}
|
||||
int position=old!=null&&old.relation()==1&&c.relation()==1?old.position():c.relation()==1?rows.stream().filter(r->r.relation()==1).mapToInt(entryValue -> entryValue.position()).max().orElse(0)+1:0;
|
||||
var changed=new Row(t.customer(),t.courier(),c.relation(),position,rate,description);if(old!=null)rows.remove(old);rows.add(changed);
|
||||
if(old==null&&!rate.equals("0.00")||old!=null&&!rate.equals(old.commission())){
|
||||
var h=history(t.customer(),t.courier());String eid=text(one(db.queryForList("SELECT cr_eid FROM courier WHERE cr_id=? AND hq_id=?",t.courier(),u.headquartersId())),"cr_eid");commissions.add(new Commission(t.customer(),t.courier(),eid,rate,h));
|
||||
}
|
||||
descriptions.add(name+": "+(c.relation()==1?"Besonders erwünscht":"Gesperrt")+" · Bemerkung: "+(description.isBlank()?"–":description)+(c.relation()==1?" · Provision "+rate+" %":"")+".");
|
||||
}
|
||||
var changes=new ArrayList<Change>();for(var entry:after.entrySet()){renumber(entry.getValue(),sorted(entry.getValue()));entry.getValue().sort(Comparator.comparingLong(entryValue -> entryValue.courier()));if(entry.getValue().size()>1000)throw bad("Höchstens 1000 Beziehungen je Kunde.");changes.add(new Change(entry.getKey(),customers.get(entry.getKey()),List.copyOf(entry.getValue())));}
|
||||
return new Prepared(List.copyOf(changes),List.copyOf(commissions),List.copyOf(descriptions));
|
||||
}
|
||||
static List<Row> sorted(List<Row> rows){return new ArrayList<>(rows.stream().filter(r->r.relation()==1).sorted(Comparator.comparingInt((Row entryValue) -> entryValue.position()).thenComparingLong(entryValue -> entryValue.courier())).toList());}
|
||||
static void renumber(List<Row> all,List<Row> ordered){for(int k=0;k<ordered.size();k++){var r=ordered.get(k);all.set(all.indexOf(r),new Row(r.customer(),r.courier(),r.relation(),k+1,r.commission(),r.description()));}}
|
||||
static String rate(String raw){try{raw=raw.trim().replace(',','.');if(!raw.matches("[0-9]{1,3}(\\.[0-9]{1,2})?"))throw new NumberFormatException();var value=new BigDecimal(raw);if(value.compareTo(BigDecimal.valueOf(100))>0)throw new NumberFormatException();return value.setScale(2).toPlainString();}catch(NumberFormatException e){throw bad("Provision muss zwischen 0 und 100 % liegen und darf höchstens zwei Nachkommastellen haben.");}}
|
||||
Map<String,Object> preview(LegacyUser u,Command c){return db.readTransaction(()->{var prepared=prepare(u,c);ensureAvailable(u,prepared.changes());return fields("descriptions",prepared.descriptions(),"planRevision",revision(prepared),"commissionChanges",prepared.commissions().size());});}
|
||||
void ensureAvailable(LegacyUser u,List<Change> changes){var pending=pending(u.headquartersId());if(pending.size()>100)throw bad("Zu viele offene Beziehungsvorgänge. Bitte zuerst abschließen.");for(var row:pending){var plan=decode(text(row,"gdc_content"));if(plan.prepared().changes().stream().anyMatch(c->changes.stream().anyMatch(n->n.customer()==c.customer())))throw bad("Für einen betroffenen Kunden ist noch ein gesicherter Beziehungsvorgang offen. Bitte zuerst fortsetzen.");}}
|
||||
Map<String,Object> save(LegacyUser u,Command c){
|
||||
validate(u,c);support.transactional("genericdatacontainer","servicepricehistory");
|
||||
db.customerRelationWriteTransaction(()->{
|
||||
var existing=operation(u.headquartersId(),c.requestId());if(!existing.isEmpty()){var p=decode(text(one(existing),"gdc_content"));if(p.user()!=u.userId()||!p.command().equals(c))throw bad("Diese Vorgangskennung gehört zu anderen Daten.");return null;}
|
||||
var prepared=prepare(u,c);unchanged(prepared,c.planRevision());ensureAvailable(u,prepared.changes());var at=LocalDateTime.now(clock).withNano(0);
|
||||
for(var commission:prepared.commissions())if(commission.before().stream().anyMatch(h->!LocalDateTime.parse(h.at().replace(' ','T')).isBefore(at)))throw bad("Für diese Provision existiert bereits ein Stand in dieser Sekunde oder in der Zukunft. Bitte später erneut prüfen.");
|
||||
var plan=new Plan(u.userId(),u.headquartersId(),c,prepared,at.toString());String json=support.encode(plan);if(json.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65000)throw bad("Zu viele Beziehungen für einen Vorgang. Bitte die Auswahl verkleinern.");
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","ngcscr","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",c.requestId(),"gdc_content",json,"gdc_context","PENDING"));return null;
|
||||
});return resume(u,c.requestId());
|
||||
}
|
||||
Map<String,Object> resume(LegacyUser u,String token){u.require(1);token(token);support.transactional("genericdatacontainer","servicepricehistory");var p=decode(text(one(operation(u.headquartersId(),token)),"gdc_content"));if(p.user()!=u.userId()||p.headquarters()!=u.headquartersId())throw bad("Nur der ursprüngliche Bearbeiter kann den Vorgang fortsetzen.");validate(u,p.command());if(!p.prepared().commissions().isEmpty())u.require(39);
|
||||
try{return db.customerRelationWriteTransaction(()->{
|
||||
if(text(one(operation(u.headquartersId(),token)),"gdc_context").equals("DONE"))return fields("state","DONE","token",token);
|
||||
validate(u,p.command());for(var change:p.prepared().changes())if(!recoverable(rows(change.customer()),change))throw bad("Die Beziehungen wurden außerhalb dieses Vorgangs geändert. Bitte den Konflikt im Bestand klären.");
|
||||
for(var commission:p.prepared().commissions())if(!history(commission.customer(),commission.courier()).equals(commission.before()))throw bad("Die Provisionshistorie wurde zwischenzeitlich geändert. Bitte den Konflikt im Bestand klären.");
|
||||
for(var change:p.prepared().changes())apply(change);
|
||||
for(var commission:p.prepared().commissions())insertWithoutKey(db,"servicepricehistory",fields("srvprch_obj_type","cr","srvprch_obj_id",commission.courier(),"srvprch_obj_eid",commission.eid(),"srvprch_fieldname","cscr_prov","srvprch_context",Long.toString(commission.customer()),"srvprch_price",new BigDecimal(commission.value()),"srvprch_validfrom",LocalDateTime.parse(p.at())));
|
||||
int operation=p.command().action().equals("ADD")?181:p.command().action().equals("REMOVE")?182:p.prepared().commissions().isEmpty()?37:180;
|
||||
for(int k=0;k<p.command().targets().size();k++){var t=p.command().targets().get(k);support.audit(u,0,t.customer(),t.courier(),operation,p.prepared().descriptions().get(k),token);}
|
||||
db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_obj_type='ngcscr' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.headquartersId(),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 Vorgang ist gesichert, aber noch nicht abgeschlossen. "+(e instanceof org.springframework.web.server.ResponseStatusException r?r.getReason():"Die Datenbank hat den Abschluss nicht bestätigt. Bitte denselben Vorgang fortsetzen."));}
|
||||
}
|
||||
static boolean recoverable(List<Row> current,Change change){var before=index(change.before());var after=index(change.after());var actual=index(current);var keys=new HashSet<>(before.keySet());keys.addAll(after.keySet());keys.addAll(actual.keySet());return keys.stream().allMatch(k->Objects.equals(actual.get(k),before.get(k))||Objects.equals(actual.get(k),after.get(k)));}
|
||||
static Map<Long,Row> index(List<Row> rows){var out=new HashMap<Long,Row>();rows.forEach(r->out.put(r.courier(),r));return out;}
|
||||
void apply(Change change){var actual=index(rows(change.customer()));var after=index(change.after());var keys=new TreeSet<>(actual.keySet());keys.addAll(after.keySet());for(long key:keys){var before=actual.get(key);var desired=after.get(key);if(Objects.equals(before,desired))continue;
|
||||
if(desired==null)db.update("DELETE FROM customercourier WHERE cs_id=? AND cr_id=?",change.customer(),key);
|
||||
else if(before==null)db.update("INSERT INTO customercourier (cs_id,cr_id,cscr_relation,cscr_sort,cscr_prov,cscr_description) VALUES (?,?,?,?,?,?)",desired.customer(),desired.courier(),desired.relation(),desired.position(),new BigDecimal(desired.commission()),desired.description());
|
||||
else db.update("UPDATE customercourier SET cscr_relation=?,cscr_sort=?,cscr_prov=?,cscr_description=? WHERE cs_id=? AND cr_id=?",desired.relation(),desired.position(),new BigDecimal(desired.commission()),desired.description(),desired.customer(),desired.courier());
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** PHP's HQ-specific defaults are applied atomically with a new customer. */
|
||||
record CustomerCreationDefaults(boolean automaticDispatch,boolean cargoRequired,Map<String,Integer> mailDefaults,String mailFields,String revision) {
|
||||
static final List<String> KEYS=List.of("MASK_MANUAL_DISPOSITION","MASK_MANUAL_DISPOSITION_CUSTOMER_MANDATORY","MASK_CS_CARGO_DATA_ENABLED","CS_JBSTATUSMAIL_FIELDS_ENABLED","CS_JBSTATUSMAIL_FIELD3_DEFAULT","CS_JBSTATUSMAIL_FIELD9_DEFAULT","CS_JBSTATUSMAIL_FIELDA_DEFAULT");
|
||||
CustomerCreationDefaults(boolean automaticDispatch,boolean cargoRequired,String revision){this(automaticDispatch,cargoRequired,Map.of(),"0000000000",revision);}
|
||||
static CustomerCreationDefaults read(DatabaseService db,LegacyUser u){
|
||||
var args=new ArrayList<Object>();args.add(u.headquartersId());args.addAll(KEYS);
|
||||
var rows=db.queryForList("SELECT par_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key IN ("+String.join(",",Collections.nCopies(KEYS.size(),"?"))+") ORDER BY par_key,par_id",args.toArray());
|
||||
var config=new HashMap<String,String>();for(var r:rows){String key=text(r,"par_key");if(config.put(key,text(r,"par_value"))!=null)throw bad("Mehrdeutige Startvorgaben für neue Kunden.");}
|
||||
String[] events=config.getOrDefault(KEYS.get(3),"").split("\\|",-1);if(events.length>3)throw bad("Die E-Mail-Startvorgabe enthält mehr als drei Ereignisse. Bitte den Bestand klären.");
|
||||
var mail=new LinkedHashMap<String,Integer>();var eventKeys=List.of("cs_jbstatusmail","cs_jbstatusmail2","cs_jbstatusmail3");
|
||||
for(int i=0;i<eventKeys.size();i++)mail.put(eventKeys.get(i),CustomerMailSettings.binary(i<events.length?events[i]:""));
|
||||
mail.put("cs_jbstatusmail_price",1);
|
||||
var bits=new StringBuilder("0000000000");int[] positions={2,8,9};for(int i=0;i<positions.length;i++)bits.setCharAt(positions[i],CustomerMailSettings.binary(config.get(KEYS.get(i+4)))==1?'1':'0');
|
||||
int manual=CustomerMailSettings.binary(config.get(KEYS.get(0))),mandatory=CustomerMailSettings.binary(config.get(KEYS.get(1))),cargo=CustomerMailSettings.binary(config.get(KEYS.get(2)));
|
||||
return new CustomerCreationDefaults(manual==0&&mandatory==0,cargo==1,Map.copyOf(mail),bits.toString(),AdminSupport.revision(List.of(u.headquartersId(),rows)));
|
||||
}
|
||||
static void validateMailFields(String value){if(value==null)return;if(!value.matches("[01]{10}")||value.charAt(1)!='0'||value.charAt(5)!='0')throw bad("Die zusätzlichen E-Mail-Felder sind unvollständig oder enthalten unbekannte Optionen.");}
|
||||
void check(String expected){if(expected!=null&&!revision.equals(expected))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Startvorgaben für neue Kunden haben sich geändert. Bitte die Neuanlage erneut öffnen.");}
|
||||
void created(DatabaseService db,BusinessSupport support,LegacyUser u,long customer){
|
||||
if(!cargoRequired)return;
|
||||
support.transactional("parameter");
|
||||
var rows=db.lockRows("SELECT par_id,md_id,emp_id,hq_id,par_value FROM parameter WHERE par_key=? AND hq_id=?",CustomerEntrySettings.MEASURES,u.headquartersId());
|
||||
if(rows.size()>1||rows.stream().anyMatch(r->id(r,"md_id")!=0||id(r,"emp_id")!=0))throw bad("Mehrdeutige Lademaß-Liste der Niederlassung.");
|
||||
var ids=CustomerEntrySettings.measureIds(rows.isEmpty()?null:text(rows.getFirst(),"par_value"));ids.add(customer);String value=String.join(",",ids.stream().map(entryValue -> entryValue.toString()).toList());
|
||||
if(rows.isEmpty())db.insert("parameter",fields("par_key",CustomerEntrySettings.MEASURES,"md_id",0,"hq_id",u.headquartersId(),"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"));
|
||||
String key=CustomerEntrySettings.PARAMETERS.get("cargoRequired")+customer;
|
||||
if(!db.lockRows("SELECT par_id FROM parameter WHERE par_key=?",key).isEmpty())throw bad("Für die neue Kunden-ID existiert bereits eine Lademaß-Vorgabe. Bitte den Bestand klären.");
|
||||
db.insert("parameter",fields("par_key",key,"md_id",0,"hq_id",0,"emp_id",0,"par_value","1","par_text",""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Native customer file, corresponding to dashb_cs.php and its current GET service contracts. */
|
||||
@RestController
|
||||
@RequestMapping("/api/crm/customer-file")
|
||||
class CustomerDashboard {
|
||||
final DatabaseService db;final BusinessSupport support;final OrderEntryCatalog catalog;
|
||||
CustomerDashboard(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog){this.db=db;this.support=support;this.catalog=catalog;}
|
||||
static void access(LegacyUser u){u.require(0);u.require(11);u.require(36);}
|
||||
@GetMapping("/customers") List<Map<String,Object>> search(Authentication a,@RequestParam(defaultValue="")String q){return search(ApiController.user(a),q);}
|
||||
@GetMapping("/{customer}") Map<String,Object> view(Authentication a,@PathVariable long customer){return view(ApiController.user(a),customer,LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0));}
|
||||
List<Map<String,Object>> search(LegacyUser u,String q){access(u);if(q==null||q.length()>100)throw bad("Suchtext darf höchstens 100 Zeichen lang sein.");String like="%"+q.strip().replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%";return db.queryForList("SELECT c.cs_id AS id,CONCAT_WS(' · ',c.cs_eid,co.cmp_comp,NULLIF(co.cmp_comp2,'')) AS label FROM customer c JOIN company co ON co.cmp_id=c.cmp_id WHERE c.hq_id=? AND co.cmp_visible=1 AND co.cmp_authenticated=1 AND (c.cs_eid LIKE ? OR co.cmp_comp LIKE ? OR co.cmp_comp2 LIKE ? OR co.cmp_match LIKE ?) ORDER BY co.cmp_comp,c.cs_eid,c.cs_id LIMIT 50",u.headquartersId(),like,like,like,like);}
|
||||
static Map<String,Object> unavailable(String message){return fields("available",false,"message",message);}
|
||||
static Map<String,Object> section(Supplier<Map<String,Object>> source){try{var result=new LinkedHashMap<>(source.get());result.put("available",true);return result;}catch(org.springframework.dao.DataAccessException e){return unavailable("Dieser Teil der Kundenakte konnte nicht gelesen werden. Bitte erneut laden.");}}
|
||||
Map<String,Object> readSection(Supplier<Map<String,Object>> source){return section(()->db.readTransaction(source));}
|
||||
record Window(LocalDateTime year,LocalDateTime reportsSince,LocalDateTime appointmentsSince,boolean previousLogin){}
|
||||
static Window window(LocalDateTime now,List<LocalDateTime> logins){LocalDateTime year=now.minusDays(now.getDayOfYear()-1);LocalDateTime prior=logins.size()>1?logins.get(1):null;return new Window(year,prior==null?year:prior,prior==null?now:prior,prior!=null);}
|
||||
Window window(LegacyUser u,LocalDateTime now){return window(now,LoginActivity.recent(db,support,u,now.minusDays(now.getDayOfYear()-1),now));}
|
||||
Map<String,Object> view(LegacyUser u,long customer,LocalDateTime now){access(u);if(customer<=0)throw bad("Bitte einen Kunden auswählen.");
|
||||
var owner=one(db.queryForList("SELECT c.cs_id,c.cs_eid,c.cmp_id,c.cs_admin,c.cs_become_cs_date,co.cmp_comp,co.cmp_comp2,co.cmp_comp3,co.cmp_comp4,co.cmp_hsno,co.cmp_bank,co.cmp_bankno,co.cmp_bankacc,co.cmp_iban,co.cmp_swift,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country,usr.usr_firstname,usr.usr_name,usr.usr_email,usr.usr_phone,usr.usr_phone2,usr.usr_fax FROM customer c JOIN company co ON co.cmp_id=c.cmp_id LEFT JOIN address a ON a.ad_id=co.ad_id LEFT JOIN employee e ON e.emp_id=c.cs_admin LEFT JOIN user usr ON usr.usr_id=e.usr_id WHERE c.cs_id=? AND c.hq_id=? AND co.cmp_visible=1 AND co.cmp_authenticated=1",customer,u.headquartersId()));
|
||||
var profile=new LinkedHashMap<String,Object>();for(String key:List.of("cs_eid","cs_become_cs_date","cmp_comp","cmp_comp2","cmp_comp3","cmp_comp4","cmp_hsno","cmp_bank","cmp_bankno","cmp_bankacc","cmp_iban","cmp_swift","ad_street","ad_zipcode","ad_city","ad_country","usr_firstname","usr_name","usr_email","usr_phone","usr_phone2","usr_fax"))profile.put(key,text(owner,key));
|
||||
var result=fields("customer",customer,"headquarters",u.headquartersId(),"label",text(owner,"cs_eid")+" · "+text(owner,"cmp_comp"),"asOf",now,"profile",profile,"canOrders",u.hasRight(4),"canReports",u.hasRight(16),"canStatistics",u.hasRight(8),"canCouriers",u.hasRight(1));
|
||||
result.put("centers",readSection(()->centers(customer)));
|
||||
result.put("drivers",u.hasRight(1)?readSection(()->drivers(u,customer)):unavailable("Für Fahrerbeziehungen fehlt das Transporteurrecht."));
|
||||
result.put("statistics",u.hasRight(8)?readSection(()->statistics(u,customer,now.getYear())):unavailable("Für Umsatz und Auftragsanzahl fehlt das Statistikrecht."));
|
||||
result.put("lastOrder",u.hasRight(4)?readSection(()->lastOrder(u,customer)):unavailable("Für den letzten Auftrag fehlt das Auftragsrecht."));
|
||||
if(u.hasRight(16))result.put("yearReports",readSection(()->reports(u,customer,now.minusDays(now.getDayOfYear()-1),now)));else result.put("yearReports",unavailable("Für die Berichte fehlt das Berichtsrecht."));
|
||||
try{var window=window(u,now);result.put("loginAvailable",true);result.put("previousLogin",window.previousLogin());result.put("sinceReports",u.hasRight(16)?readSection(()->reports(u,customer,window.reportsSince(),now)):unavailable("Für die Berichte fehlt das Berichtsrecht."));result.put("appointments",readSection(()->appointments(u,customer,window.appointmentsSince())));}
|
||||
catch(org.springframework.dao.DataAccessException e){result.put("loginAvailable",false);result.put("previousLogin",false);result.put("sinceReports",unavailable("Der vorige Anmeldezeitpunkt konnte nicht gelesen werden."));result.put("appointments",unavailable("Der vorige Anmeldezeitpunkt konnte nicht gelesen werden."));}
|
||||
return result;
|
||||
}
|
||||
Map<String,Object> centers(long customer){var rows=db.queryForList("SELECT csc_id AS id,csc_name AS label,csc_pre_id AS parent,csc_visible AS visible,csc_blocked AS blocked FROM costcenter WHERE cs_id=? ORDER BY csc_path,csc_name,csc_id LIMIT 2001",customer);return fields("rows",rows.stream().limit(2000).toList(),"truncated",rows.size()>2000);}
|
||||
Map<String,Object> drivers(LegacyUser u,long customer){
|
||||
var rows=db.queryForList("SELECT r.cr_id AS id,r.cscr_relation AS relation,r.cscr_sort AS position,r.cscr_description AS description,c.cr_eid AS number,c.cr_sid AS vehicle,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS name,CONCAT_WS(' ',co.cmp_comp,co.cmp_comp2) AS company,CONCAT_WS(' ',a.ad_street,co.cmp_hsno) AS street,a.ad_zipcode AS postal,a.ad_city AS city,a.ad_country AS country FROM customercourier r JOIN courier c ON c.cr_id=r.cr_id LEFT JOIN company co ON co.cmp_id=c.cmp_id LEFT JOIN address a ON a.ad_id=co.ad_id LEFT JOIN user usr ON usr.usr_id=c.usr_id WHERE r.cs_id=? AND c.hq_id=? AND r.cscr_relation IN (0,1) ORDER BY r.cscr_relation DESC,r.cscr_sort,c.cr_eid,c.cr_id LIMIT 1001",customer,u.headquartersId());
|
||||
// Keep one row per relation even when a courier has several vehicles.
|
||||
var vehicles=db.queryForList("SELECT v.cr_id,v.crvh_sid FROM couriervehicle v JOIN courier c ON c.cr_id=v.cr_id WHERE c.hq_id=? AND EXISTS (SELECT 1 FROM customercourier r WHERE r.cs_id=? AND r.cr_id=c.cr_id AND r.cscr_relation IN (0,1)) ORDER BY v.cr_id,v.crvh_sid,v.crvh_id LIMIT 10001",u.headquartersId(),customer);
|
||||
var result=rows.stream().limit(1000).map(r->{var copy=new LinkedHashMap<>(r);copy.put("vehicles",vehicles.stream().filter(v->id(v,"cr_id")==id(r,"id")).map(v->text(v,"crvh_sid")).distinct().toList());return copy;}).toList();return fields("rows",result,"truncated",rows.size()>1000||vehicles.size()>10000);
|
||||
}
|
||||
Map<String,Object> lastOrder(LegacyUser u,long customer){var rows=db.queryForList("SELECT j.jb_id AS id,j.jb_ordertime AS ordered,j.jb_status AS status FROM job j JOIN costcenter c ON c.csc_id=j.csc_id_payer WHERE c.cs_id=? AND j.hq_id=? AND COALESCE(j.jb_storno,0) IN (0,1) ORDER BY j.jb_id DESC LIMIT 1",customer,u.headquartersId());return fields("item",rows.isEmpty()?null:rows.getFirst());}
|
||||
Map<String,Object> reports(LegacyUser u,long customer,LocalDateTime from,LocalDateTime to){var args=new ArrayList<Object>(List.of(customer,u.headquartersId(),from,to));String where="r.rp_objtype='cs' AND r.rp_objid=? AND r.hq_id=? AND r.rp_createtime>=? AND r.rp_createtime<=?";if(!u.hasRight(10)){where+=" AND (r.rp_confidential=0 OR r.usr_id=?)";args.add(u.userId());}
|
||||
long count=db.queryForObject("SELECT COUNT(*) FROM "+ReportAdministration.TABLE+" r WHERE "+where,Long.class,args.toArray());var rows=db.queryForList("SELECT r.rp_id AS id,r.rp_createtime AS created,r.rp_text AS text,r.rp_confidential AS confidential,r.rp_reporttype AS type,(SELECT MIN(mt_value) FROM metatype WHERE mt_type='report_type' AND mt_sort=r.rp_reporttype) AS typeLabel,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS author FROM "+ReportAdministration.TABLE+" r LEFT JOIN user usr ON usr.usr_id=r.usr_id WHERE "+where+" ORDER BY r.rp_id DESC LIMIT 10",args.toArray());return fields("from",from,"to",to,"total",count,"rows",rows);}
|
||||
Map<String,Object> appointments(LegacyUser u,long customer,LocalDateTime from){var args=new Object[]{customer,u.headquartersId(),from,u.userId(),"%,"+u.userId()+",%"};String where="a.cs_id=? AND a.hq_id=? AND a.ap_execdate>=? AND (a.usr_id=? OR a.ap_category_2=3 OR (a.ap_category_2=1 AND a.ap_participants LIKE ?))";
|
||||
long count=db.queryForObject("SELECT COUNT(*) FROM "+AppointmentService.TABLE+" a WHERE "+where,Long.class,args);var rows=db.queryForList("SELECT a.ap_id AS id,a.ap_execdate AS startsAt,a.ap_enddate AS endsAt,a.ap_text AS text,a.ap_category_3 AS kind,(SELECT MIN(mt_value) FROM metatype WHERE mt_type='appointment_category_3' AND mt_sort=a.ap_category_3) AS kindLabel,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS author FROM "+AppointmentService.TABLE+" a LEFT JOIN user usr ON usr.usr_id=a.usr_id WHERE "+where+" ORDER BY a.ap_execdate DESC,a.ap_id DESC LIMIT 10",args);return fields("from",from,"total",count,"rows",rows);}
|
||||
Map<String,Object> statistics(LegacyUser u,long customer,int year){boolean related=catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"),exclude=catalog.enabled(u.headquartersId(),"STATISTIC_NO_STORNOJOBS");String payer=related?"csc_id_related":"csc_id_payer";var rows=db.queryForList("SELECT DATE_FORMAT(j.jb_finishtime,'%Y-%m') AS month,COUNT(*) AS orders,COALESCE(SUM(j.jb_totalprice),0) AS amount FROM job j JOIN costcenter c ON c.csc_id=j."+payer+" WHERE c.cs_id=? AND j.hq_id=? AND j.jb_status=2 AND j.jb_finishtime>=? AND j.jb_finishtime<?"+(exclude?" AND COALESCE(j.jb_storno,0) IN (0,1,3)":"")+" GROUP BY DATE_FORMAT(j.jb_finishtime,'%Y-%m') ORDER BY month",customer,u.headquartersId(),LocalDate.of(year-2,1,1).atStartOfDay(),LocalDate.of(year+1,1,1).atStartOfDay());var months=new ArrayList<Map<String,Object>>();var years=new ArrayList<Map<String,Object>>();for(int y=year-2;y<=year;y++){long orders=0;BigDecimal amount=BigDecimal.ZERO;for(int m=1;m<=12;m++){String key=YearMonth.of(y,m).toString();var found=rows.stream().filter(r->key.equals(text(r,"month"))).toList();if(found.size()>1)throw bad("Mehrdeutiger Statistikmonat.");long n=found.isEmpty()?0:id(found.getFirst(),"orders");BigDecimal v=found.isEmpty()?BigDecimal.ZERO:decimal(found.getFirst(),"amount");orders+=n;amount=amount.add(v);months.add(fields("month",key,"orders",n,"amount",BillingService.money(v)));}years.add(fields("year",y,"orders",orders,"amount",BillingService.money(amount)));}return fields("years",years,"months",months,"relatedCustomer",related,"excludeFreeCancellations",exclude);}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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 variants of admin/employee_list.php, employee.php and employee_csc_rights.php. */
|
||||
@RestController
|
||||
@RequestMapping("/api/customer-portal/employees")
|
||||
class CustomerEmployees {
|
||||
record Query(long customer,long center,boolean descendants,String search,String sort,boolean descending,int page) {}
|
||||
record Command(String requestId,long customer,long employee,long center,String kind,String revision,
|
||||
PortalEmployees.Profile profile,List<Integer> rights,List<Long> centers,
|
||||
String account,String currentPassword,String password,String confirmation,String code) {
|
||||
@Override public String toString(){return "CustomerEmployeeCommand[redacted]";}
|
||||
}
|
||||
record Context(CustomerPortalScope.Context scope,CustomerPortalScope.Customer customer,long headquarters,long root,
|
||||
boolean editable,boolean matrix,boolean matrixEnabled,boolean noSelfReset,List<Long> actorGrants,String heritage) {}
|
||||
record State(Context context,Map<String,Object> person,List<Long> grants,boolean root,boolean self) {}
|
||||
static final Map<String,String> SORTS=Map.of("name","user.usr_name","firstName","user.usr_firstname","email","user.usr_email","phone","user.usr_phone","mobile","user.usr_phone2","account","user.usr_account","center","costcenter.csc_name");
|
||||
final DatabaseService db;final BusinessSupport support;final PortalEmployees shared;
|
||||
CustomerEmployees(DatabaseService db,BusinessSupport support,PortalEmployees shared){this.db=db;this.support=support;this.shared=shared;}
|
||||
@PostMapping("/query") Object query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);}
|
||||
@GetMapping("/{customer}/{employee}") Object view(Authentication a,@PathVariable long customer,@PathVariable long employee){return view(ApiController.user(a),customer,employee);}
|
||||
@PostMapping("/save") Object save(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);}
|
||||
|
||||
Context context(LegacyUser u,long customer){
|
||||
var scope=CustomerPortalScope.context(db,u);var cs=CustomerPortalScope.customer(scope,customer);
|
||||
var entity=one(db.queryForList("SELECT customer.hq_id,customer.cs_admin FROM customer JOIN headquarters ON headquarters.hq_id=customer.hq_id WHERE customer.cs_id=? AND COALESCE(headquarters.hq_disabled,0)=0",customer));
|
||||
var flags=db.queryForList("SELECT hq_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN ('GLOBAL_CUSTOMER_READONLY_DISABLED','MASK_EMP_CSC_MATRIX_ENABLED','USER_2FA_NO_DEACTIVATION') ORDER BY hq_id,par_key",u.headquartersId());
|
||||
for(var r:flags)if(flags.stream().filter(x->id(x,"hq_id")==id(r,"hq_id")&&text(x,"par_key").equals(text(r,"par_key"))).count()>1)throw bad("Die Vorgaben der Kundenmitarbeiterverwaltung sind mehrfach vorhanden.");
|
||||
String matrix=setting(flags,u.headquartersId(),"MASK_EMP_CSC_MATRIX_ENABLED");if(matrix.isEmpty())matrix=setting(flags,0,"MASK_EMP_CSC_MATRIX_ENABLED");
|
||||
boolean present=shared.matrixPresent();String heritage=text(one(db.queryForList("SELECT emp_heritable_rights FROM employee WHERE emp_id=?",u.employeeId())),"emp_heritable_rights");
|
||||
return new Context(scope,cs,id(entity,"hq_id"),id(entity,"cs_admin"),scope.can(10)||setting(flags,0,"GLOBAL_CUSTOMER_READONLY_DISABLED").equals("1"),present,matrix.equals("1"),setting(flags,0,"USER_2FA_NO_DEACTIVATION").equals("1"),present?shared.grants(u.employeeId()):List.of(),heritage);
|
||||
}
|
||||
static String setting(List<Map<String,Object>> rows,long hq,String key){return rows.stream().filter(r->id(r,"hq_id")==hq&&text(r,"par_key").equals(key)).map(r->text(r,"par_value")).findFirst().orElse("");}
|
||||
State state(LegacyUser u,Context c,long employee){
|
||||
if(employee!=u.employeeId())c.scope().require(2);
|
||||
var p=one(db.queryForList(PortalEmployees.PERSON+" AND employee.emp_id=?",c.customer().id(),c.headquarters(),employee));
|
||||
if(!c.customer().allowed().contains(id(p,"csc_id")))throw CustomerPortalScope.denied();
|
||||
if(db.queryForList("SELECT emp_id FROM employee WHERE usr_id=?",id(p,"usr_id")).size()!=1||!db.queryForList("SELECT cr_id FROM courier WHERE usr_id=?",id(p,"usr_id")).isEmpty()||!db.queryForList("SELECT hq_id FROM headquarters WHERE hq_admin=?",employee).isEmpty())throw bad("Der Benutzer ist nicht eindeutig als Kundenmitarbeiter zugeordnet.");
|
||||
var roots=db.queryForList("SELECT cs_id FROM customer WHERE cs_admin=?",employee);boolean root=employee==c.root();
|
||||
if(!roots.isEmpty()&&(!root||roots.size()!=1))throw bad("Der Mitarbeiter ist einem anderen Kundenhauptzugang zugeordnet.");
|
||||
return new State(c,p,c.matrix()?shared.grants(employee):List.of(),root,employee==u.employeeId());
|
||||
}
|
||||
static Map<String,Object> profile(Map<String,Object> p){var out=PortalEmployees.profile(p);out.remove("birthdate");return out;}
|
||||
static boolean canProfile(State s){return s.context().scope().can(2)&&s.context().editable()&&(!s.root()||s.self());}
|
||||
static boolean canRights(State s){return s.context().scope().can(2)&&!s.root()&&!s.self();}
|
||||
static boolean canRight(State s,int i){return canRights(s)&&s.context().scope().can(i)&&EmployeeAdministration.bit(s.context().heritage(),i);}
|
||||
static boolean rootActor(LegacyUser u,Context c){return u.employeeId()==c.root();}
|
||||
static boolean canPassword(LegacyUser u,State s){return s.context().editable()&&(s.self()||rootActor(u,s.context()));}
|
||||
static boolean canReset(LegacyUser u,State s){return s.self()||rootActor(u,s.context());}
|
||||
static boolean canMatrix(State s){var c=s.context();return c.matrix()&&c.matrixEnabled()&&c.scope().can(2)&&c.scope().can(10)&&c.scope().can(12)&&!s.self()&&!s.root();}
|
||||
static boolean canCenter(LegacyUser u,State s,long center){return canMatrix(s)&¢er!=s.context().customer().root()&&s.context().customer().allowed().contains(center)&&(rootActor(u,s.context())||s.context().actorGrants().contains(center));}
|
||||
static Object version(State s){var c=s.context();return List.of(s.person(),s.grants(),s.root(),s.self(),c.scope().rights(),c.heritage(),c.root(),c.headquarters(),c.editable(),c.matrix(),c.matrixEnabled(),c.noSelfReset(),c.actorGrants(),c.customer().centers());}
|
||||
|
||||
Map<String,Object> query(LegacyUser u,Query q){return db.readTransaction(()->{
|
||||
if(q==null||q.page()<0||q.page()>10000||!SORTS.containsKey(q.sort()))throw bad("Ungültige Mitarbeitersuche.");var c=context(u,q.customer());
|
||||
var centers=CustomerPortalScope.centers(c.scope(),q.customer(),q.center(),q.descendants());String search=EmployeeAdministration.optional(q.search(),100,"Suche");
|
||||
var args=new ArrayList<Object>(List.of(q.customer(),c.headquarters()));args.addAll(new TreeSet<>(centers));String own=c.scope().can(2)?"":" AND employee.emp_id=?";if(!own.isEmpty())args.add(u.employeeId());for(int i=0;i<4;i++)args.add(search);args.add(q.page()*50);
|
||||
var rows=db.queryForList(PortalEmployees.PERSON+" AND employee.csc_id IN ("+CourierPortal.placeholders(centers.size())+")"+own+" AND (LOCATE(?,user.usr_name)>0 OR LOCATE(?,user.usr_firstname)>0 OR LOCATE(?,user.usr_account)>0 OR LOCATE(?,user.usr_email)>0) ORDER BY "+SORTS.get(q.sort())+(q.descending()?" DESC":" ASC")+",employee.emp_id LIMIT 51 OFFSET ?",args.toArray());
|
||||
return fields("items",rows.stream().limit(50).map(p->{var out=profile(p);out.put("root",id(p,"emp_id")==c.root());out.put("centerLabel",c.customer().centers().stream().filter(x->id(x,"id")==id(p,"csc_id")).map(x->text(x,"label")).findFirst().orElse(""));return out;}).toList(),"hasNext",rows.size()>50,"page",q.page(),"canManage",c.scope().can(2),"canCreate",c.scope().can(2)&&c.editable(),"self",u.employeeId(),"centers",c.customer().centers());
|
||||
});}
|
||||
Map<String,Object> view(LegacyUser u,long customer,long employee){return db.readTransaction(()->{
|
||||
var s=state(u,context(u,customer),employee);var rights=new ArrayList<Map<String,Object>>();for(int i=0;i<16;i++)rights.add(fields("id",i,"label",PortalEmployees.RIGHTS.get(i),"enabled",EmployeeAdministration.bit(text(s.person(),"emp_rights"),i),"canEdit",canRight(s,i)));
|
||||
return fields("profile",profile(s.person()),"revision",revision(version(s)),"root",s.root(),"self",s.self(),"canProfile",canProfile(s),"canRights",canRights(s),"canMatrix",canMatrix(s),"canPassword",canPassword(u,s),"canReset",canReset(u,s),"twoFactorActive",id(s.person(),"usr_totp_activated")==1,"canDelete",s.context().scope().can(2)&&!s.root()&&!s.self(),"rights",rights,"centers",s.context().customer().centers().stream().map(r->fields("id",id(r,"id"),"label",text(r,"label"),"selected",s.grants().contains(id(r,"id")),"canEdit",canCenter(u,s,id(r,"id")))).toList());
|
||||
});}
|
||||
void audit(LegacyUser u,long cs,long emp,int op,String detail,String token){shared.audit(u,cs,emp,op,"CUSTOMER_PORTAL | "+detail,token);}
|
||||
static Object intent(Command c){return fields("kind",c.kind(),"customer",c.customer(),"employee",c.employee(),"center",c.center(),"revision",Objects.toString(c.revision(),""),"profile",c.profile(),"rights",c.rights(),"centers",c.centers(),"account",c.account());}
|
||||
void authorize(LegacyUser u,State s,String kind){boolean ok=switch(kind){case "PROFILE"->canProfile(s);case "RIGHTS"->canRights(s);case "MATRIX"->canMatrix(s);case "PASSWORD"->canPassword(u,s);case "RESET"->canReset(u,s);case "DELETE"->s.context().scope().can(2)&&!s.root()&&!s.self();default->false;};if(!ok)throw CustomerPortalScope.denied();}
|
||||
Map<String,Object> save(LegacyUser u,Command r){
|
||||
if(r==null||!Set.of("CREATE","PROFILE","RIGHTS","MATRIX","PASSWORD","RESET","DELETE").contains(Objects.toString(r.kind(),"")))throw bad("Ungültige Mitarbeiteränderung.");token(r.requestId());
|
||||
support.transactional("user","employee","parameter","employeerights","genericdatacontainer");boolean matrix=shared.matrixPresent();if(matrix)support.transactional("employeecostcenter");
|
||||
return db.customerEmployeeWriteTransaction(matrix,()->{
|
||||
if(shared.matrixPresent()!=matrix)throw bad("Die Kostenstellenmatrix wurde geändert. Bitte neu laden.");var c=context(u,r.customer());
|
||||
if(r.kind().equals("CREATE"))return create(u,c,r);
|
||||
if(r.kind().equals("DELETE")){c.scope().require(2);Long prior=support.receipt(u,r.requestId(),intent(r));if(prior!=null)return fields("id",prior,"reauthenticate",false);}
|
||||
var s=state(u,c,r.employee());authorize(u,s,r.kind());long uid=id(s.person(),"usr_id");
|
||||
Long prior=support.receipt(u,r.requestId(),intent(r));if(prior!=null){if(r.kind().equals("PASSWORD")&&!shared.portal.accounts.matches(uid,r.password(),false))throw bad("Bereits gespeichert; das Passwort weicht ab oder wurde geändert.");return fields("id",prior,"reauthenticate",s.self()&&(r.kind().equals("PASSWORD")||r.kind().equals("RESET")||r.kind().equals("PROFILE")&&!Objects.equals(r.profile().account(),u.username())));}
|
||||
unchanged(version(s),r.revision());int operation=74;
|
||||
switch(r.kind()){
|
||||
case "PROFILE"->{var p=PortalEmployees.clean(r.profile());if(p.center()!=id(s.person(),"csc_id")||p.birthdate()!=null)throw bad("Kostenstelle und Geburtstag werden hier nicht geändert.");shared.unique(p.account(),uid);var values=PortalEmployees.values(p);values.remove("usr_birthdate");if(!p.account().equals(text(s.person(),"usr_account")))values.put("usr_totp_sessionkey",PortalAccounts.sessionKey());BusinessSupport.update(db,"user","usr_id",uid,values);}
|
||||
case "RIGHTS"->{var selected=EmployeeAdministration.distinct(r.rights(),16);if(selected.stream().anyMatch(i->i<0||i>=16))throw bad("Unbekanntes Kundenrecht.");for(int i=0;i<16;i++)if(selected.contains(i)!=EmployeeAdministration.bit(text(s.person(),"emp_rights"),i)&&!canRight(s,i))throw CustomerPortalScope.denied();String next=PortalEmployees.vector(text(s.person(),"emp_rights"),selected);var inheritedRights=new HashSet<Integer>();for(int i=0;i<16;i++)if(canRight(s,i)?selected.contains(i):EmployeeAdministration.bit(text(s.person(),"emp_heritable_rights"),i))inheritedRights.add(i);String inherited=PortalEmployees.vector(text(s.person(),"emp_heritable_rights"),inheritedRights);db.update("UPDATE employee SET emp_rights=?,emp_heritable_rights=? WHERE emp_id=?",next,inherited,r.employee());operation=115;}
|
||||
case "MATRIX"->{var selected=EmployeeAdministration.distinct(r.centers(),10000);var visible=c.customer().allowed();if(!visible.containsAll(selected))throw CustomerPortalScope.denied();for(long center:visible){boolean before=s.grants().contains(center),after=selected.contains(center);if(before==after)continue;if(!canCenter(u,s,center))throw CustomerPortalScope.denied();if(after)db.update("INSERT INTO employeecostcenter (emp_id,csc_id) VALUES (?,?)",r.employee(),center);else db.update("DELETE FROM employeecostcenter WHERE emp_id=? AND csc_id=?",r.employee(),center);}operation=115;}
|
||||
case "PASSWORD"->{EmployeeAccounts.validatePassword(r.password(),r.confirmation());if(!shared.portal.accounts.matches(u.userId(),r.currentPassword(),true))throw bad("Das aktuelle eigene Passwort ist ungültig.");db.update("UPDATE user SET usr_password=PASSWORD(?),usr_password_old='',usr_password_modify=NOW(),usr_totp_sessionkey=? WHERE usr_id=?",r.password(),PortalAccounts.sessionKey(),uid);operation=76;}
|
||||
case "RESET"->{if(!shared.portal.accounts.matches(u.userId(),r.currentPassword(),true))throw bad("Das aktuelle eigene Passwort ist ungültig.");if(!text(s.person(),"usr_account").equals(r.account())||id(s.person(),"usr_totp_activated")!=1)throw bad("Bitte den aktiven Zwei-Faktor-Zugang korrekt bestätigen.");if(s.self()){String secret=db.queryForObject("SELECT usr_totp_secret FROM user WHERE usr_id=?",String.class,uid);if(!LegacyTotp.matches(secret,r.code(),java.time.Instant.now().getEpochSecond()))throw bad("Der aktuelle Zwei-Faktor-Code ist ungültig.");}db.update("UPDATE user SET usr_totp_secret='',usr_totp_activated=0,usr_totp_sessionkey=? WHERE usr_id=?",PortalAccounts.sessionKey(),uid);db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngtotp' AND gdc_obj_id=? AND gdc_gen_fieldname='login'",uid);operation=184;}
|
||||
case "DELETE"->{if(!text(s.person(),"usr_account").equals(r.account()))throw bad("Bitte den Anmeldenamen zur Löschbestätigung eingeben.");if(matrix)db.update("DELETE FROM employeecostcenter WHERE emp_id=?",r.employee());db.update("DELETE FROM employeerights WHERE emp_id=?",r.employee());db.update("DELETE FROM parameter WHERE emp_id=?",r.employee());db.update("DELETE FROM employee WHERE emp_id=?",r.employee());db.update("DELETE FROM user WHERE usr_id=?",uid);MetaObjectRegistration.enqueueRemoval(db,support,s.context().headquarters(),uid);db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngtotp' AND gdc_obj_id=? AND gdc_gen_fieldname='login'",uid);operation=75;}
|
||||
}
|
||||
String detail="ACTION="+r.kind()+" | PROFILE_BEFORE="+support.encode(profile(s.person()));if(r.kind().equals("PROFILE"))detail+=" | PROFILE_AFTER="+support.encode(r.profile());if(r.kind().equals("RIGHTS"))detail+=" | RIGHTS_BEFORE="+text(s.person(),"emp_rights")+" | RIGHTS_AFTER="+r.rights();if(r.kind().equals("MATRIX"))detail+=" | CENTERS_BEFORE="+s.grants()+" | CENTERS_AFTER="+r.centers();audit(u,r.customer(),r.employee(),operation,detail,r.requestId());support.receipt(u,r.requestId(),intent(r),r.employee());return fields("id",r.employee(),"reauthenticate",s.self()&&(r.kind().equals("PASSWORD")||r.kind().equals("RESET")||r.kind().equals("PROFILE")&&!text(s.person(),"usr_account").equals(r.profile().account())));
|
||||
});
|
||||
}
|
||||
Map<String,Object> create(LegacyUser u,Context c,Command r){
|
||||
c.scope().require(2);if(!c.editable()||!c.customer().allowed().contains(r.center()))throw CustomerPortalScope.denied();
|
||||
var p=PortalEmployees.clean(r.profile());if(p.center()!=r.center()||p.birthdate()!=null)throw bad("Ungültige neue Mitarbeiterzuordnung.");EmployeeAccounts.validatePassword(r.password(),r.confirmation());Long prior=support.receipt(u,r.requestId(),intent(r));
|
||||
if(prior!=null){var s=state(u,c,prior);if(!shared.portal.accounts.matches(id(s.person(),"usr_id"),r.password(),false))throw bad("Bereits gespeichert; das Passwort weicht ab oder wurde geändert.");return fields("id",prior,"reauthenticate",false);}
|
||||
shared.unique(p.account(),0);var values=PortalEmployees.values(p);values.putAll(fields("hq_id",c.headquarters(),"usr_type",2,"usr_country","","usr_inv_email","","usr_reminder_email","","usr_password_old","","usr_totp_secret","","usr_totp_sessionkey","","usr_totp_activated",0));long uid=db.insert("user",values);db.update("UPDATE user SET usr_password=PASSWORD(?),usr_password_modify=NOW() WHERE usr_id=?",r.password(),uid);
|
||||
long emp=db.insert("employee",fields("usr_id",uid,"csc_id",r.center(),"emp_rights","0".repeat(70),"emp_heritable_rights","0".repeat(70),"emp_headquarters",Long.toString(c.headquarters())));long md=mandator(db,u);EmployeeAccounts.DEFAULTS.forEach((k,v)->db.insert("parameter",fields("par_key",k,"par_value",v,"emp_id",emp,"hq_id",c.headquarters(),"md_id",md)));
|
||||
MetaObjectRegistration.enqueue(db,support,c.headquarters(),"usr",uid);
|
||||
audit(u,r.customer(),emp,73,"PROFILE_CREATED="+support.encode(p),r.requestId());support.receipt(u,r.requestId(),intent(r),emp);return fields("id",emp,"reauthenticate",false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
|
||||
|
||||
/** jobs2 stores the selected acceptance clock time on the current Berlin date. */
|
||||
final class CustomerEntryAcceptance {
|
||||
static LocalDateTime resolve(String time, boolean enabled, Clock clock) {
|
||||
if(time==null || time.isBlank())return null;
|
||||
if(!enabled)throw invalid("Die Annahmezeit ist für diese Niederlassung nicht freigegeben.");
|
||||
if(!time.matches("(?:[01][0-9]|2[0-3]):[0-5][0-9]"))throw invalid("Bitte die Annahmezeit im Format HH:mm angeben.");
|
||||
return LocalDate.now(clock.withZone(ZoneId.of("Europe/Berlin"))).atTime(LocalTime.parse(time));
|
||||
}
|
||||
static OrderEntryModels.Quote quote(OrderEntryModels.Request request,OrderEntryModels.Quote quote) {
|
||||
return request.acceptUntil()==null?quote:quote.withMediationDeadline(quote.mediationDeadline(),LegacyOrderPricing.hash(quote.fingerprint()+"|customer-acceptance|"+request.acceptUntil()));
|
||||
}
|
||||
static String load(Map<String,Object> job,boolean enabled) {
|
||||
var at=dateTime(job,"jb_waittime");
|
||||
return enabled&&at!=null?at.format(DateTimeFormatter.ofPattern("HH:mm")):null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
|
||||
|
||||
/** jobs2: the caller remains a type-2 customer throughout pricing and persistence. */
|
||||
final class CustomerEntryAccess {
|
||||
static final ObjectMapper JSON=new ObjectMapper().findAndRegisterModules();
|
||||
final DatabaseService db;final OrderEntryCatalog catalog;final java.time.Clock clock;
|
||||
CustomerEntryAccess(DatabaseService db){this(db,java.time.Clock.system(java.time.ZoneId.of("Europe/Berlin")));}
|
||||
CustomerEntryAccess(DatabaseService db,java.time.Clock clock){this.db=db;this.clock=clock;catalog=new OrderEntryCatalog(db);}
|
||||
record State(long customer,long base,long payer,Set<Long> centers,Map<String,Object> customerRow,List<Map<String,Object>> centerRows,List<Map<String,Object>> vehicles,List<Map<String,Object>> filters,boolean payerSelectable,boolean remarks,boolean countryLocked,boolean dimensions,boolean showPrice,String orderer,String revision,List<Map<String,Object>> vehicleTypes,int defaultVehicleType,boolean dimensionsRequired,boolean cashPayerSelect,long cashPayer,boolean acceptanceEnabled){}
|
||||
State state(LegacyUser u){
|
||||
var c=CustomerPortalScope.context(db,u);long customer=id(c.account(),"cs_id"),base=id(c.account(),"csc_id");
|
||||
var cs=OrderEntryCatalog.one(db.queryForList("SELECT cs.cs_id,cs.cs_jbedit,cs.cs_jbedit_cr,cs.cs_filter_jbedit,cs.cs_ar,cs.cs_invmode,cs.cs_jb_commission_no,cs.cs_jobbatch,h.hq_workmode,h.hq_vht_default FROM customer cs JOIN headquarters h ON h.hq_id=cs.hq_id WHERE cs.cs_id=? AND cs.hq_id=?",customer,u.headquartersId()),"Die Kundenerfassung ist nicht verfügbar.");
|
||||
if(id(cs,"hq_workmode")!=0&&id(cs,"cs_jbedit")==0)throw CustomerPortalScope.denied();
|
||||
var own=CustomerPortalScope.customer(c,customer);var allowed=new TreeSet<Long>();var centers=new ArrayList<Map<String,Object>>();
|
||||
var args=new ArrayList<Object>();args.add(u.headquartersId());args.addAll(new TreeSet<>(own.allowed()));
|
||||
var active=db.queryForList(OrderEntryCatalog.COST_CENTER_SELECT+OrderEntryCatalog.COST_CENTER_FROM+" AND cc.csc_id IN ("+CourierPortal.placeholders(own.allowed().size())+") ORDER BY cc.csc_name,cc.csc_id",args.toArray());
|
||||
for(var row:active){long center=id(row,"csc_id");if(!allowed.add(center))throw invalid("Mehrdeutige Kostenstelle in der Kundenerfassung.");centers.add(fields("id",center,"label",text(row,"csc_name"),"external",id(row,"csc_is_extern")==1));}
|
||||
if(!allowed.contains(base))throw CustomerPortalScope.denied();
|
||||
boolean select=unscoped("MASK_CUST_SET_PAYER_"+customer).equals("1");
|
||||
// jobs2/job_options.php initializes the customer entry with csc_id_orderer.
|
||||
// The master-data third payer is an internal-entry default, not a portal access grant.
|
||||
long payer=base;
|
||||
catalog.costCenter(u,payer,true);
|
||||
Set<String> excluded=new HashSet<>(CustomerPortal.parts(catalog.parameter(u.headquartersId(),"MASK_EXCLUDE_VHT_IDS_CS")));
|
||||
excluded.remove("");
|
||||
if(excluded.stream().anyMatch(v->!v.matches("[0-9]+")))throw invalid("Ungültige Fahrzeugtyp-Sperre in der Kundenerfassung.");
|
||||
var types=db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='vehicletype' AND mt_sort>0 ORDER BY mt_sort").stream().filter(v->!excluded.contains(Long.toString(id(v,"id")))).toList();
|
||||
if(types.isEmpty()||types.stream().map(v->id(v,"id")).distinct().count()!=types.size())throw invalid("Keine eindeutigen Fahrzeugtypen für die Kundenerfassung verfügbar.");
|
||||
int defaultType=(int)(types.stream().anyMatch(v->id(v,"id")==id(cs,"hq_vht_default"))?id(cs,"hq_vht_default"):id(types.getFirst(),"id"));
|
||||
var vehicles=id(cs,"cs_jbedit_cr")==1?db.queryForList("SELECT DISTINCT cv.crvh_id AS id,cv.crvh_sid AS label,cv.vht_id AS type "+OrderEntryCatalog.VEHICLE_FROM+" AND cv.crvh_sid=cr.cr_sid AND EXISTS (SELECT 1 FROM customercourier rel WHERE rel.cs_id=? AND rel.cr_id=cr.cr_id AND rel.cscr_relation=1) ORDER BY cv.crvh_sid,cv.crvh_id",u.headquartersId(),customer).stream().filter(v->types.stream().anyMatch(t->id(t,"id")==id(v,"type"))).toList():List.<Map<String,Object>>of();
|
||||
Set<String> filterCodes=new HashSet<>(CustomerPortal.parts(text(cs,"cs_filter_jbedit")));
|
||||
var filters=db.queryForList("SELECT crf_short AS code,crf_longtext AS label FROM courierfilter ORDER BY crf_short").stream().filter(r->filterCodes.contains(text(r,"code"))).toList();
|
||||
boolean remarks=catalog.enabled(u.headquartersId(),"MASK_CUSTOMER_SHOW_REMARK"),locked=unscoped("MASK_CUST_AD_COUNTRY_DISABLED_"+customer).equals("1"),dimensions=CustomerPortal.parts(catalog.parameter(u.headquartersId(),"RANKING_JB2CRVH_MEASURE_CS_IDS")).contains(Long.toString(customer));
|
||||
boolean required=unscoped("RANKING_JB2CRVH_MEASURE_CS_MANDATORY_"+customer).equals("1");dimensions|=required;
|
||||
boolean showPrice=catalog.enabled(u.headquartersId(),"MASK_CUSTOMER_SHOW_PRICEQUERY")&&id(cs,"cs_invmode")!=0&&!unscoped("MASK_CS_DONT_SHOW_STANDARD_PRICE_"+u.userId()).equals("1")&&!catalog.enabled(0,"CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"+customer);
|
||||
String orderer=(text(c.account(),"usr_firstname")+" "+text(c.account(),"usr_name")).strip();if(orderer.isBlank())orderer=u.username();if(orderer.length()>50)orderer=orderer.substring(0,50);
|
||||
boolean cashSelect=catalog.enabled(u.headquartersId(),"MASK_CASH_PAYER_SELECT");String cashConfig=catalog.parameter(u.headquartersId(),"CSC_ID_PAYER_CASH");long cashPayer=0;try{cashPayer=Long.parseLong(cashConfig);}catch(NumberFormatException ignored){}if(cashPayer<0)cashPayer=0;
|
||||
var revisionParts=new ArrayList<Object>(List.of(u.userId(),u.employeeId(),u.headquartersId(),customer,base,payer,c.rights(),cs,centers,vehicles,filters,select,remarks,locked,dimensions,showPrice,orderer,selfDiscount(u,customer),types,defaultType,required,cashSelect,cashConfig));
|
||||
var standardPricing=CustomerStandardPricing.read(db,catalog,u,customer);if(standardPricing.disabled())revisionParts.add(standardPricing);
|
||||
var photoBasis=new CustomerEntryPhotos(db).revisionBasis(customer,cashPayer);if(!photoBasis.isEmpty())revisionParts.add(photoBasis);
|
||||
var externalPolicy=new OrderExternalStations(db).policy(u);if(!externalPolicy.mode().isEmpty()||!externalPolicy.configured().isEmpty())revisionParts.add(externalPolicy);
|
||||
boolean acceptanceEnabled=catalog.enabled(u.headquartersId(),"MASK_WAITTIME_ENABLED");if(acceptanceEnabled)revisionParts.add("CUSTOMER_ACCEPTANCE_ENABLED");
|
||||
String revision=LegacyOrderPricing.hash(OrderEntryExtras.json(revisionParts));
|
||||
return new State(customer,base,payer,Set.copyOf(allowed),cs,List.copyOf(centers),vehicles,filters,select,remarks,locked,dimensions,showPrice,orderer,revision,types,defaultType,required,cashSelect,cashPayer,acceptanceEnabled);
|
||||
}
|
||||
// These legacy customer-specific switches are looked up without HQ scope. Ambiguity is an error.
|
||||
String unscoped(String key){var rows=db.queryForList("SELECT par_value FROM parameter WHERE emp_id=0 AND par_key=?",key);var values=rows.stream().map(r->text(r,"par_value")).distinct().toList();if(values.size()>1)throw invalid("Mehrdeutige Kundeneinstellung: "+key);return values.isEmpty()?"":values.getFirst();}
|
||||
BigDecimal selfDiscount(LegacyUser u,long customer){if(!catalog.enabled(0,"CUSTOMER_MASK_JB_CREATOR_DISCOUNT_"+customer))return BigDecimal.ZERO;String raw=catalog.parameter(u.headquartersId(),"MASK_CS_SELF_SERVICE_DISCOUNT");if(raw.isBlank())return BigDecimal.ZERO;try{var rate=new BigDecimal(raw.replace(',','.'));if(rate.signum()<0||rate.compareTo(new BigDecimal("100"))>0)throw new NumberFormatException();return rate;}catch(NumberFormatException e){throw invalid("Der Selbstbedienungsrabatt ist ungültig.");}}
|
||||
Request command(LegacyUser u,CustomerOrderEntry.Draft d){
|
||||
State s=state(u);if(d==null||!s.revision().equals(d.revision()))throw OrderEntryService.conflict("Die Vorgaben für die Kundenerfassung haben sich geändert. Bitte neu laden.");
|
||||
if(d.mode()!=Mode.IMMEDIATE&&d.mode()!=Mode.RESERVATION)throw invalid("Im Kundenzugang sind Sofortauftrag und Vorbestellung verfügbar.");
|
||||
if(d.stops()==null||d.stops().isEmpty()||d.stops().size()>100)throw invalid("Bitte 1 bis 100 Stationen angeben.");
|
||||
if(d.payerCostCenterId()!=s.payer()&&(!s.payerSelectable()||!s.centers().contains(d.payerCostCenterId())))throw CustomerPortalScope.denied();
|
||||
if((d.payment()==Payment.CASH||d.costSplit())&&!s.payerSelectable())throw CustomerPortalScope.denied();
|
||||
if(d.payment()==Payment.CASH&&s.cashPayer()<=0)throw invalid("Für die Niederlassung ist kein Systembarzahler konfiguriert.");
|
||||
if(d.costSplit()&&(d.payment()!=Payment.INVOICE||s.cashPayerSelect()))throw invalid("Kostenteilung ist in diesem Zahlungsmodus nicht verfügbar.");
|
||||
catalog.costCenter(u,d.payerCostCenterId(),d.payment()!=Payment.CASH);
|
||||
if(s.vehicleTypes().stream().noneMatch(v->id(v,"id")==d.vehicleTypeId()))throw invalid("Dieser Fahrzeugtyp ist für den Kundenzugang nicht freigegeben.");
|
||||
if(d.courierVehicleId()!=null&&s.vehicles().stream().noneMatch(v->id(v,"id")==d.courierVehicleId()))throw CustomerPortalScope.denied();
|
||||
if(d.courierVehicleId()!=null&&s.vehicles().stream().anyMatch(v->id(v,"id")==d.courierVehicleId()&&id(v,"type")!=d.vehicleTypeId()))throw invalid("Der Fahrzeugtyp passt nicht zum gewünschten Kurier.");
|
||||
if(d.filters()==null||d.filters().size()>50||!s.filters().stream().map(r->text(r,"code")).toList().containsAll(d.filters()))throw CustomerPortalScope.denied();
|
||||
if(!s.dimensions()&&List.of(nz(d.weight()),nz(d.length()),nz(d.width()),nz(d.height()),nz(d.positions())).stream().anyMatch(n->n.signum()!=0))throw invalid("Transportmaße sind für diesen Kunden nicht freigegeben.");
|
||||
if(s.dimensionsRequired()&&List.of(nz(d.weight()),nz(d.length()),nz(d.width()),nz(d.height())).stream().anyMatch(n->n.signum()<=0))throw invalid("Bitte die Lademaße (Gewicht, Länge, Breite, Höhe) vollständig angeben.");
|
||||
var stops=new ArrayList<Station>();
|
||||
for(var stop:d.stops()){
|
||||
if(stop==null)throw invalid("Eine Station fehlt.");
|
||||
if(stop.costCenterId()!=null&&!s.centers().contains(stop.costCenterId()))throw CustomerPortalScope.denied();
|
||||
if(s.countryLocked()&&!"DE".equals(stop.country()))throw invalid("Die Länderauswahl ist für diesen Kunden gesperrt.");
|
||||
if(!s.remarks()&&stop.note()!=null&&!stop.note().isBlank())throw invalid("Stationsbemerkungen sind für diesen Kunden nicht freigegeben.");
|
||||
if(stop.billingPostalId()!=null||!stop.attachments().isEmpty()||stop.photoWorkflow()!=null)throw invalid("Diese zusätzlichen Stationsfelder sind in der Kundenerfassung nicht freigegeben.");
|
||||
stops.add(stop);
|
||||
}
|
||||
new CustomerEntryTracking(db).validate(s,d);
|
||||
var tree=JSON.valueToTree(fields("mode",d.mode(),"pickupAt",d.pickupAt(),"acceptUntil",CustomerEntryAcceptance.resolve(d.acceptUntilTime(),s.acceptanceEnabled(),clock),"payerCostCenterId",d.payerCostCenterId(),"payment",d.payment(),"cashStop",d.cashStop(),"costSplit",d.costSplit(),"vehicleTypeId",d.vehicleTypeId(),"courierVehicleId",d.courierVehicleId(),"stops",stops,"orderer",s.orderer(),"reference",d.reference(),"notes",d.notes(),"weight",d.weight(),"length",d.length(),"width",d.width(),"height",d.height(),"positions",d.positions(),"manualDispatch",id(s.customerRow(),"cs_ar")==0,"incomplete",catalog.enabled(0,"MASK_CUSTOMER_INCOMPLETE_"+s.customer()),"pricing",new CustomerEntryTours(db).pricing(u,s,d),"sourceOrderId",d.sourceOrderId(),"quoteFingerprint",d.quoteFingerprint(),"requestId",d.requestId(),"origin",4,"courierFilters",d.filters(),"acceptUnverifiedAddresses",false));
|
||||
try{return JSON.treeToValue(tree,Request.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw invalid("Ungültige Kundeneingabe.");}
|
||||
}
|
||||
void verify(LegacyUser u,CustomerOrderEntry.Draft draft,Request expected){if(!command(u,draft).equals(expected))throw OrderEntryService.conflict("Die Kundenvorgaben wurden geändert. Bitte erneut prüfen.");}
|
||||
void lock(LegacyUser u,CustomerOrderEntry.Draft draft){
|
||||
db.lockRows("SELECT emp_id FROM employee WHERE emp_id=? AND usr_id=?",u.employeeId(),u.userId());
|
||||
db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId());
|
||||
var account=CustomerPortalScope.verify(db,u);db.lockRows("SELECT cs_id FROM customer WHERE cs_id=?",id(account,"cs_id"));
|
||||
var ids=new TreeSet<Long>();ids.add(id(account,"csc_id"));ids.add(draft.payerCostCenterId());for(var stop:draft.stops())if(stop.costCenterId()!=null)ids.add(stop.costCenterId());
|
||||
if(draft.payment()==Payment.CASH){long cash=state(u).cashPayer();if(cash>0)ids.add(cash);}
|
||||
if(draft.sourceOrderId()!=null)db.lockRows("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",draft.sourceOrderId(),u.headquartersId());
|
||||
for(long center:ids)db.lockRows("SELECT csc_id FROM costcenter WHERE csc_id=?",center);
|
||||
}
|
||||
static BigDecimal nz(BigDecimal n){return n==null?BigDecimal.ZERO:n;}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Classic jobs2 photo quantities; independent of the external workflow service. */
|
||||
final class CustomerEntryPhotos {
|
||||
record Requirement(int station,int minimum,int maximum){}
|
||||
final DatabaseService db;
|
||||
CustomerEntryPhotos(DatabaseService db){this.db=db;}
|
||||
List<Map<String,Object>> settings(long customer){return db.queryForList("SELECT par_id,par_key,md_id,hq_id,emp_id,par_value FROM parameter WHERE par_key IN (?,?) ORDER BY par_key,par_id",ExtrasCustomerSettings.photoKey(customer),ExtrasCustomerSettings.rangeKey(customer));}
|
||||
long cashCustomer(long payer){if(payer<=0)return 0;var rows=db.queryForList("SELECT cs_id FROM costcenter WHERE csc_id=?",payer);return rows.size()==1?id(rows.getFirst(),"cs_id"):0;}
|
||||
List<Object> revisionBasis(long customer,long cashPayer){var basis=new ArrayList<Object>();var own=settings(customer);if(!own.isEmpty())basis.add(own);long cash=cashCustomer(cashPayer);if(cash>0&&cash!=customer){var rows=settings(cash);if(!rows.isEmpty()){basis.add(cash);basis.add(rows);}}return basis;}
|
||||
ExtrasCustomerSettings.Values config(long customer){var rows=settings(customer).stream().map(r->{var next=new LinkedHashMap<>(r);if(text(r,"par_key").equals(ExtrasCustomerSettings.rangeKey(customer))&&text(r,"par_value").isBlank())next.put("par_value","1|1");return (Map<String,Object>)next;}).toList();return ExtrasCustomerSettings.decode(ExtrasCustomerSettings.Type.PHOTO,customer,rows);}
|
||||
List<Requirement> plan(CustomerEntryAccess.State state,CustomerOrderEntry.Draft draft){
|
||||
var own=config(state.customer());boolean systemCash=draft.payment()==OrderEntryModels.Payment.CASH&&!state.cashPayerSelect();
|
||||
var initial=systemCash?config(cashCustomer(state.cashPayer())):own;
|
||||
return plan(draft.stops().size(),initial,own,systemCash?draft.cashStop():null);
|
||||
}
|
||||
static List<Requirement> plan(int stations,ExtrasCustomerSettings.Values initial,ExtrasCustomerSettings.Values own,Integer cashStop){
|
||||
var result=new ArrayList<Requirement>();
|
||||
for(int i=1;i<stations;i++){var config=cashStop!=null&&i>=cashStop?own:initial;if(config.mode()!=null&&(config.mode()==2||config.mode()==3))result.add(new Requirement(i+1,config.minimum(),config.maximum()));}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
void created(long job,List<Requirement> plan){
|
||||
if(plan.isEmpty())return;
|
||||
var stops=db.queryForList("SELECT tr_id,tr_sort FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",job);
|
||||
for(var r:plan){var matching=stops.stream().filter(t->id(t,"tr_sort")==r.station()).toList();if(matching.size()!=1)throw bad("Die Fotoanforderung kann keiner eindeutigen Station zugeordnet werden.");long station=id(matching.getFirst(),"tr_id");
|
||||
if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_photo'",station).isEmpty())throw bad("Die Station besitzt bereits eine Fotoanforderung.");
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",station,"gdc_gen_fieldname","tr_photo","gdc_content",r.minimum()+"|"+r.maximum(),"gdc_context",Long.toString(job)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Customer entry controls from the current server's customer_special.php. */
|
||||
@RestController
|
||||
@RequestMapping("/api/master/customers/{customer}/entry-settings")
|
||||
class CustomerEntrySettings {
|
||||
static final Map<String,String> PARAMETERS;
|
||||
static {
|
||||
var p=new LinkedHashMap<String,String>();
|
||||
p.put("stationNumbers","JOBDETAILS_STATIONS_STATION_NUMBER_");
|
||||
p.put("creatorDiscount","CUSTOMER_MASK_JB_CREATOR_DISCOUNT_");
|
||||
p.put("cancellation","CUSTOMER_MASK_JOBLIST_CANCELLATION_ENABLED_");
|
||||
p.put("favoriteDispatch","CUSTOMER_MASK_JOBLIST_DISPO_FAV_ENABLED_");
|
||||
p.put("noStandardPrice","MASK_CS_DONT_MAKE_STANDARD_PRICE_");
|
||||
p.put("calculatorOnly","CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_");
|
||||
p.put("unverifiedAddresses","MASK_CUST_ALL_ADDR_ENABLED_");
|
||||
p.put("cargoRequired","RANKING_JB2CRVH_MEASURE_CS_MANDATORY_");
|
||||
p.put("incomplete","CUSTOMER_MASK_JB_INCOMPLETE_");
|
||||
p.put("missingReferenceIncomplete","CS_MASK_COMM_NO_B_");
|
||||
p.put("referenceEveryStation","CS_MASK_COMM_NO_C_");
|
||||
PARAMETERS=Collections.unmodifiableMap(p);
|
||||
}
|
||||
static final String MEASURES="RANKING_JB2CRVH_MEASURE_CS_IDS",COLOR="JOBLIST_CS_JB_COLOR_";
|
||||
static final Set<String> FLAGS;
|
||||
static {var keys=new HashSet<>(PARAMETERS.keySet());keys.addAll(List.of("entryEnabled","automaticDispatch","cargo","stationReference"));FLAGS=Set.copyOf(keys);}
|
||||
record Values(Map<String,Boolean> flags,String color){}
|
||||
record Draft(String revision,Values values){}
|
||||
record Command(String requestId,Draft draft,String fingerprint){}
|
||||
record View(long customer,String label,String revision,Values values,boolean canManageFavoriteDispatch,boolean jobsHidden){}
|
||||
record Preview(String fingerprint,Values before,Values after){}
|
||||
record State(Map<String,Object> owner,List<Map<String,Object>> parameters,View view){}
|
||||
final DatabaseService db;final BusinessSupport support;
|
||||
CustomerEntrySettings(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
|
||||
@GetMapping View get(Authentication a,@PathVariable long customer){return view(ApiController.user(a),customer);}
|
||||
@PostMapping("/preview") Preview check(Authentication a,@PathVariable long customer,@RequestBody Draft d){return preview(ApiController.user(a),customer,d);}
|
||||
@PostMapping Map<String,Object> post(Authentication a,@PathVariable long customer,@RequestBody Command c){return save(ApiController.user(a),customer,c);}
|
||||
LegacyUser access(LegacyUser actor){actor.require(0);actor.require(2);var u=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());u.require(0);u.require(2);return u;}
|
||||
State state(LegacyUser u,long customer){
|
||||
if(customer<=0)throw bad("Ungültiger Kunde.");
|
||||
var owner=one(db.queryForList("SELECT cs_id,cs_eid,customer.hq_id,customer.cmp_id,cmp_comp,cmp_comp2,cs_jbedit,cs_ar,cs_joblist_disabled,cs_commission_no FROM customer JOIN company ON company.cmp_id=customer.cmp_id WHERE cs_id=? AND customer.hq_id=?",customer,u.headquartersId()));
|
||||
var names=new ArrayList<String>();for(String p:PARAMETERS.values())names.add(p+customer);names.add(COLOR+customer);names.add(MEASURES);
|
||||
var all=db.queryForList("SELECT par_id,par_key,md_id,hq_id,emp_id,par_value,par_text FROM parameter WHERE par_key IN ("+CrmScope.marks(names.size())+") ORDER BY par_key,hq_id,par_id",names.toArray());
|
||||
var rows=new ArrayList<Map<String,Object>>();var byKey=new LinkedHashMap<String,String>();
|
||||
for(var r:all){String key=text(r,"par_key");long hq=id(r,"hq_id");
|
||||
if(key.equals(MEASURES)&&hq!=u.headquartersId())continue;
|
||||
if(id(r,"md_id")!=0||id(r,"emp_id")!=0||!key.equals(MEASURES)&&hq!=0)throw bad("Abweichender Geltungsbereich der Kundenerfassungsvorgaben.");
|
||||
if(byKey.put(key,text(r,"par_value"))!=null)throw bad("Mehrdeutige Kundenerfassungsvorgaben.");rows.add(r);
|
||||
}
|
||||
var flags=new LinkedHashMap<String,Boolean>();flags.put("entryEnabled",id(owner,"cs_jbedit")==1);flags.put("automaticDispatch",id(owner,"cs_ar")==1);
|
||||
for(var e:PARAMETERS.entrySet())flags.put(e.getKey(),CustomerMailSettings.binary(byKey.get(e.getValue()+customer))==1);
|
||||
flags.put("stationReference",id(owner,"cs_commission_no")==1);
|
||||
if(!flags.get("stationReference"))flags.put("missingReferenceIncomplete",false);
|
||||
if(!flags.get("missingReferenceIncomplete"))flags.put("referenceEveryStation",false);
|
||||
flags.put("cargo",measureIds(byKey.get(MEASURES)).contains(customer));
|
||||
String color=byKey.getOrDefault(COLOR+customer,"FFFFFF");if(color.isBlank())color="FFFFFF";color=normalizeColor(color);
|
||||
var values=new Values(Collections.unmodifiableMap(flags),color);
|
||||
var view=new View(customer,String.join(" · ",List.of(text(owner,"cs_eid"),text(owner,"cmp_comp"),text(owner,"cmp_comp2")).stream().filter(s->!s.isBlank()).toList()),revision(List.of(owner,rows)),values,u.hasRight(41),id(owner,"cs_joblist_disabled")==1);
|
||||
return new State(owner,List.copyOf(rows),view);
|
||||
}
|
||||
static LinkedHashSet<Long> measureIds(String raw){var result=new LinkedHashSet<Long>();if(raw==null||raw.isBlank())return result;for(String token:raw.split(",",-1)){if(token.isBlank())continue;if(!token.strip().matches("[0-9]{1,18}"))throw bad("Die Kundenliste für Lademaße ist ungültig.");long id=Long.parseLong(token.strip());if(id<=0)throw bad("Die Kundenliste für Lademaße ist ungültig.");result.add(id);}return result;}
|
||||
static String normalizeColor(String color){if(color==null||!color.matches("[A-Fa-f0-9]{6}"))throw bad("Bitte einen sechsstelligen RGB-Farbwert angeben.");return color.toUpperCase(Locale.ROOT);}
|
||||
static Values normalize(Values v){if(v==null||v.flags()==null||!v.flags().keySet().equals(FLAGS)||v.flags().values().stream().anyMatch(Objects::isNull))throw bad("Kundenerfassungsvorgaben fehlen oder enthalten unbekannte Felder.");if(v.flags().get("cargoRequired")&&!v.flags().get("cargo"))throw bad("Pflicht-Lademaße benötigen aktivierte Lademaße.");if((v.flags().get("missingReferenceIncomplete")&&!v.flags().get("stationReference"))||(v.flags().get("referenceEveryStation")&&!v.flags().get("missingReferenceIncomplete")))throw bad("Kommissionsnummern an jeder Station benötigen die Unfertig-Prüfung und aktivierte Stationskommissionsnummern.");return new Values(Map.copyOf(v.flags()),normalizeColor(v.color()));}
|
||||
static void validate(Draft d){if(d==null||d.revision()==null||!d.revision().matches("[a-f0-9]{64}"))throw bad("Bestandsrevision fehlt.");normalize(d.values());}
|
||||
View view(LegacyUser actor,long customer){actor.require(0);actor.require(2);return db.readTransaction(()->state(access(actor),customer).view());}
|
||||
Preview prepare(LegacyUser u,long customer,Draft d,State s){
|
||||
if(!s.view().revision().equals(d.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Kundenerfassungsvorgaben wurden inzwischen geändert. Bitte neu laden.");
|
||||
var v=normalize(d.values());if(!u.hasRight(41)&&!v.flags().get("favoriteDispatch").equals(s.view().values().flags().get("favoriteDispatch")))throw new org.springframework.security.access.AccessDeniedException("Recht 41 für die Vermittlung an bevorzugte Transporteure fehlt.");
|
||||
return new Preview(revision(List.of(u.headquartersId(),customer,d,s.view().revision())),s.view().values(),v);
|
||||
}
|
||||
Preview preview(LegacyUser actor,long customer,Draft d){actor.require(0);actor.require(2);validate(d);return db.readTransaction(()->{var u=access(actor);return prepare(u,customer,d,state(u,customer));});}
|
||||
Map<String,Object> save(LegacyUser actor,long customer,Command c){
|
||||
actor.require(0);actor.require(2);if(c==null)throw bad("Änderung fehlt.");BusinessSupport.token(c.requestId());validate(c.draft());if(c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte die Änderung zuerst prüfen.");access(actor);
|
||||
return db.customerMailSettingsWriteTransaction(()->{
|
||||
var u=access(actor);var s=state(u,customer);var receipt=List.of("CUSTOMER_ENTRY_SETTINGS",customer,c.draft(),c.fingerprint());
|
||||
var previous=support.receipt(u,c.requestId(),receipt);if(previous!=null)return fields("changed",previous,"repeated",true);
|
||||
var p=prepare(u,customer,c.draft(),s);if(!p.fingerprint().equals(c.fingerprint()))throw bad("Die Änderungsvorschau passt nicht zu diesen Angaben.");
|
||||
var v=p.after();boolean changed=!p.before().equals(v);
|
||||
if(changed){
|
||||
db.update("UPDATE customer SET cs_jbedit=?,cs_ar=?,cs_commission_no=? WHERE cs_id=? AND hq_id=?",v.flags().get("entryEnabled")?1:0,v.flags().get("automaticDispatch")?1:0,v.flags().get("stationReference")?1:0,customer,u.headquartersId());
|
||||
var desired=new LinkedHashMap<String,String>();for(var e:PARAMETERS.entrySet())desired.put(e.getValue()+customer,v.flags().get(e.getKey())?"1":"0");desired.put(COLOR+customer,v.color());
|
||||
// Touch the shared HQ list only when this customer's membership changes.
|
||||
if(!p.before().flags().get("cargo").equals(v.flags().get("cargo"))){var old=s.parameters().stream().filter(r->MEASURES.equals(text(r,"par_key"))).findFirst().orElse(null);var ids=measureIds(old==null?null:text(old,"par_value"));if(v.flags().get("cargo"))ids.add(customer);else ids.remove(customer);desired.put(MEASURES,String.join(",",ids.stream().map(entryValue -> entryValue.toString()).toList()));}
|
||||
for(var e:desired.entrySet()){
|
||||
String key=e.getKey(),value=e.getValue();var old=s.parameters().stream().filter(r->key.equals(text(r,"par_key"))).findFirst().orElse(null);
|
||||
boolean defaultValue=key.equals(COLOR+customer)?value.equals("FFFFFF"):key.equals(MEASURES)?value.isEmpty():value.equals("0");
|
||||
if(old==null){if(!defaultValue)db.insert("parameter",fields("par_key",key,"md_id",0,"hq_id",key.equals(MEASURES)?u.headquartersId():0,"emp_id",0,"par_value",value,"par_text",""));}
|
||||
else if(!value.equals(text(old,"par_value")))db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(old,"par_id"));
|
||||
}
|
||||
support.audit(u,0,customer,0,203,"Kundenerfassungsvorgaben geändert",c.requestId());
|
||||
}
|
||||
support.receipt(u,c.requestId(),receipt,changed?1:0);return fields("changed",changed?1:0,"repeated",false);
|
||||
});
|
||||
}
|
||||
static void clearStationReferences(DatabaseService db,BusinessSupport support,long customer){
|
||||
support.transactional("parameter");
|
||||
var rows=db.lockRows("SELECT * FROM parameter WHERE par_key IN (?,?) ORDER BY par_key,par_id",PARAMETERS.get("missingReferenceIncomplete")+customer,PARAMETERS.get("referenceEveryStation")+customer);
|
||||
var seen=new HashSet<String>();for(var r:rows){if(id(r,"md_id")!=0||id(r,"hq_id")!=0||id(r,"emp_id")!=0||!seen.add(text(r,"par_key")))throw bad("Die Kommissionsnummer-Vorgaben sind mehrdeutig. Bitte den Bestand prüfen.");}
|
||||
for(var r:rows)if(!text(r,"par_value").equals("0"))db.update("UPDATE parameter SET par_value='0' WHERE par_id=?",id(r,"par_id"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
|
||||
|
||||
/** Read-only customer projections of get_tournames/get_saved_tour. No PHP edit lock is set. */
|
||||
@RestController
|
||||
@RequestMapping("/api/customer-portal/entry/tours")
|
||||
class CustomerEntryTours {
|
||||
final DatabaseService db;final CustomerEntryAccess access;
|
||||
CustomerEntryTours(DatabaseService db){this.db=db;access=new CustomerEntryAccess(db);}
|
||||
static final String PAYER="CASE WHEN j.jb_payment IN (1,2) AND COALESCE(j.csc_id_payer_cash,0)=0 AND (j.jb_payment=2 OR COALESCE(j.csc_id_payer,0)=0) THEN j.csc_id ELSE IF(j.csc_id_payer_cash>0,j.csc_id_payer_cash,j.csc_id_payer) END";
|
||||
record Source(Map<String,Object> job,List<Station> stops,List<Map<String,Object>> prices,String revision,boolean zone,BigDecimal courierService){}
|
||||
@GetMapping Object search(Authentication a,@RequestParam(defaultValue="") String q,@RequestParam(defaultValue="true") boolean templates,@RequestParam(required=false) Long payer,@RequestParam(defaultValue="0") int page){return search(ApiController.user(a),q,templates,payer,page);}
|
||||
@GetMapping("/{id}") Object load(Authentication a,@PathVariable long id){return load(ApiController.user(a),id);}
|
||||
Map<String,Object> search(LegacyUser u,String q,boolean templates,Long payer,int page){return db.readTransaction(()->{
|
||||
var s=access.state(u);if(!templates)CustomerPortalScope.context(db,u).require(4);
|
||||
if(q==null||q.length()>100||page<0||page>10000)throw invalid("Ungültige Tourensuche.");
|
||||
var centers=new TreeSet<>(s.centers());if(payer!=null)centers.retainAll(Set.of(payer));
|
||||
if(centers.isEmpty())return fields("rows",List.of(),"page",page,"hasNext",false);
|
||||
var args=new ArrayList<Object>();args.add(u.headquartersId());args.addAll(centers);String pattern=OrderEntryCatalog.search(q);args.add(pattern);args.add(pattern);args.add(pattern);args.add(page*50);
|
||||
var rows=db.queryForList("SELECT j.jb_id AS id,j.jb_tourname AS name,j.jb_commission_no AS reference,j.jb_ordertime AS ordered FROM job j WHERE j.hq_id=? AND ("+PAYER+") IN ("+CourierPortal.placeholders(centers.size())+")"+(templates?" AND LENGTH(TRIM(j.jb_tourname))>0":"")+" AND (CAST(j.jb_id AS CHAR) LIKE ? OR j.jb_tourname LIKE ? OR j.jb_commission_no LIKE ?) ORDER BY "+(templates?"j.jb_tourname,":"")+"j.jb_id DESC LIMIT 51 OFFSET ?",args.toArray());
|
||||
return fields("rows",rows.stream().limit(50).map(r->fields("id",id(r,"id"),"name",text(r,"name"),"reference",text(r,"reference"),"ordered",dateTime(r,"ordered"))).toList(),"page",page,"hasNext",rows.size()>50);
|
||||
});}
|
||||
Map<String,Object> require(LegacyUser u,CustomerEntryAccess.State s,long order){
|
||||
var rows=db.queryForList("SELECT jb_id,hq_id,csc_id,csc_id_payer,csc_id_payer_cash,jb_payment+0 AS jb_payment,jb_tourname,jb_waittime,jb_commission_no,jb_freetext_1,vht_id,jb_weight,jb_crvh_length,jb_crvh_width,jb_crvh_height,jb_crvh_position,jb_cr_filter,jb_cr_filter_opt,cr_sid,jb_fixprice,jb_serviceprice,jb_cr_subprice,jb_sales_tax_rate_sign FROM job WHERE jb_id=? AND hq_id=?",order,u.headquartersId());
|
||||
if(rows.size()!=1)throw CustomerPortalScope.denied();var j=rows.getFirst();long payer=payer(j);
|
||||
if(!s.centers().contains(payer))throw CustomerPortalScope.denied();
|
||||
if(text(j,"jb_tourname").isBlank())CustomerPortalScope.context(db,u).require(4);
|
||||
return j;
|
||||
}
|
||||
Source source(LegacyUser u,CustomerEntryAccess.State s,long order){
|
||||
var j=require(u,s,order);
|
||||
var rows=db.queryForList("SELECT t.tr_sort,t.csc_id,t.tr_comp,t.tr_person,t.tr_phone,t.tr_remark,t.tr_commission_no,t.tr_hsno,t.tr_ware_from_to,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",order);
|
||||
if(rows.isEmpty()||rows.size()>100||rows.stream().map(r->id(r,"tr_sort")).distinct().count()!=rows.size())throw invalid("Die gespeicherte Tour hat keine eindeutige Stationsfolge mit 1 bis 100 Stationen.");
|
||||
var stops=rows.stream().map(r->new Station(s.centers().contains(id(r,"csc_id"))?id(r,"csc_id"):null,switch((int)id(r,"tr_ware_from_to")){case 0->Models.StopType.NONE;case 1->Models.StopType.DELIVERY;case 2->Models.StopType.PICKUP;case 3->Models.StopType.BOTH;default->throw invalid("Ungültige Warenbewegung in der gespeicherten Tour.");},text(r,"tr_comp"),text(r,"ad_street"),text(r,"tr_hsno"),text(r,"ad_zipcode"),text(r,"ad_city"),text(r,"ad_country").equals("D")?"DE":text(r,"ad_country"),text(r,"tr_person"),text(r,"tr_phone"),s.remarks()?copyRemark(text(r,"tr_remark")):"",text(r,"tr_commission_no"),null,null,List.of())).toList();
|
||||
var prices=db.queryForList("SELECT trs_srv_name,trs_srvt_name,trs_discount FROM tourservice WHERE jb_id=? ORDER BY tr_sort,csc_id,srv_id,srvt_id",order);
|
||||
var meta=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_cr_serviceprice'",order);
|
||||
if(meta.size()>1)throw invalid("Mehrdeutige Servicevergütung in der gespeicherten Tour.");
|
||||
BigDecimal cr=meta.isEmpty()?BigDecimal.ZERO:amount(text(meta.getFirst(),"gdc_content"));
|
||||
var basis=new ArrayList<Object>(List.of(j,rows,prices,cr));var tracking=new CustomerEntryTracking(db);if(tracking.enabled(s.customer()))basis.add(tracking.load(db.queryForList("SELECT tr_id FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",order).stream().map(t->id(t,"tr_id")).toList()));
|
||||
return new Source(j,stops,prices,LegacyOrderPricing.hash(OrderEntryExtras.json(basis)),prices.stream().anyMatch(r->text(r,"trs_srvt_name").equals("z")),cr);
|
||||
}
|
||||
Map<String,Object> load(LegacyUser u,long order){return db.readTransaction(()->{
|
||||
var s=access.state(u);var source=source(u,s,order);var j=source.job();
|
||||
boolean cash=id(j,"jb_payment")==2,split=id(j,"jb_payment")==1;
|
||||
if((cash||split)&&!s.payerSelectable())throw CustomerPortalScope.denied();
|
||||
if(cash&&s.cashPayer()<=0||split&&s.cashPayerSelect())throw invalid("Der Zahlungsmodus dieser Tour ist in der Niederlassung nicht mehr verfügbar.");
|
||||
Integer cashStop=null;if(cash){var marked=db.queryForList("SELECT tr_sort FROM tour WHERE jb_id=? AND tr_remark LIKE ? ORDER BY tr_sort,tr_id",order,"%*** ZAHLT BAR ***%");if(marked.size()==1){var sorts=db.queryForList("SELECT tr_sort FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",order);for(int n=0;n<sorts.size();n++)if(id(sorts.get(n),"tr_sort")==id(marked.getFirst(),"tr_sort"))cashStop=n+1;}}
|
||||
long payer=payer(j);if(payer!=s.payer()&&!s.payerSelectable())throw invalid("Der gespeicherte Zahler ist in diesem Kundenzugang nicht auswählbar.");
|
||||
var warnings=new ArrayList<String>();warnings.add("Die Kopie wird als neuer Sofortauftrag vorbereitet. Bitte Abholzeit und Angaben prüfen.");
|
||||
if(source.zone())warnings.add("Der gespeicherte Zonenpreis wird neu berechnet.");
|
||||
else if(decimal(j,"jb_fixprice").signum()>0)warnings.add("Der gespeicherte Festpreis gilt für unveränderte Strecke, Fahrzeugart und Zahler. Änderungen führen zu einer neuen Preisermittlung.");
|
||||
int type=(int)id(j,"vht_id");if(s.vehicleTypes().stream().noneMatch(t->id(t,"id")==id(j,"vht_id"))){type=s.defaultVehicleType();warnings.add("Der bisherige Fahrzeugtyp ist nicht mehr freigegeben. Der aktuelle Standardtyp wurde eingesetzt; der Preis wird neu berechnet.");}
|
||||
String reference=text(j,"jb_commission_no");if(reference.isBlank()&&!text(j,"jb_tourname").isBlank())reference=source.stops().stream().map(entryValue -> entryValue.reference()).filter(v->!v.isBlank()).findFirst().orElse("");
|
||||
var originalFilters=new LinkedHashSet<>(CustomerPortal.parts(text(j,"jb_cr_filter")+","+text(j,"jb_cr_filter_opt")));originalFilters.remove("");var filters=originalFilters.stream().filter(f->s.filters().stream().anyMatch(r->text(r,"code").equals(f))).toList();if(filters.size()!=originalFilters.size())warnings.add("Nicht mehr freigegebene Kurierfilter wurden entfernt.");
|
||||
var cr=new CustomerPortal(db).personal(u,"MODE_COPY_JOB");
|
||||
Long vehicle=null;if(cr.equals("1")){var matches=s.vehicles().stream().filter(v->text(v,"label").equals(text(j,"cr_sid"))&&id(v,"type")==id(j,"vht_id")).toList();if(matches.size()==1)vehicle=id(matches.getFirst(),"id");else if(!text(j,"cr_sid").isBlank()&&!text(j,"cr_sid").equals("0"))warnings.add("Der bisherige Kurier ist nicht mehr auswählbar. Die Disposition erfolgt nach Verfügbarkeit.");}
|
||||
var tracking=new CustomerEntryTracking(db);List<String> emails=tracking.enabled(s.customer())?tracking.load(db.queryForList("SELECT tr_id FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",order).stream().map(t->id(t,"tr_id")).toList()):null;
|
||||
return fields("acceptUntilTime",CustomerEntryAcceptance.load(j,s.acceptanceEnabled()),"trackingEmails",emails,"payment",cash?"CASH":"INVOICE","cashStop",cashStop,"costSplit",split,"sourceOrderId",order,"sourceRevision",source.revision(),"sourceName",text(j,"jb_tourname"),"payerCostCenterId",payer,"vehicleTypeId",type,"courierVehicleId",vehicle,"stops",source.stops(),"reference",reference,"notes",OrderDeliveryData.publicRemark(text(j,"jb_freetext_1")),"weight",s.dimensions()?decimal(j,"jb_weight"):BigDecimal.ZERO,"length",s.dimensions()?decimal(j,"jb_crvh_length"):BigDecimal.ZERO,"width",s.dimensions()?decimal(j,"jb_crvh_width"):BigDecimal.ZERO,"height",s.dimensions()?decimal(j,"jb_crvh_height"):BigDecimal.ZERO,"positions",s.dimensions()?decimal(j,"jb_crvh_position"):BigDecimal.ZERO,"filters",filters,"warnings",warnings);
|
||||
});}
|
||||
Pricing pricing(LegacyUser u,CustomerEntryAccess.State s,CustomerOrderEntry.Draft d){
|
||||
if(d.sourceOrderId()==null){if(d.sourceRevision()!=null&&!d.sourceRevision().isBlank())throw invalid("Der Quellauftrag fehlt.");return new Pricing(PriceMode.AUTO,null,null,null,null,null,null,false,4L);}
|
||||
var source=source(u,s,d.sourceOrderId());if(!source.revision().equals(d.sourceRevision()))throw OrderEntryService.conflict("Der Quellauftrag wurde geändert. Bitte die Tour erneut laden.");
|
||||
var j=source.job();
|
||||
boolean same=id(j,"jb_payment")== (d.payment()==Payment.CASH?2:d.costSplit()?1:0)&&payer(j)==d.payerCostCenterId()&&id(j,"vht_id")==d.vehicleTypeId()&&basis(source.stops()).equals(basis(d.stops()));
|
||||
boolean fixed=same&&!source.zone()&&decimal(j,"jb_fixprice").signum()>0;
|
||||
var taxes=db.queryForList("SELECT tx_id FROM tax WHERE tx_sign=?",text(j,"jb_sales_tax_rate_sign"));if(taxes.size()!=1)throw invalid("Der Steuerschlüssel der gespeicherten Tour ist nicht eindeutig.");
|
||||
boolean discount=source.prices().stream().anyMatch(p->Set.of("Fixpreis","Servicepreis").contains(text(p,"trs_srv_name"))&&decimal(p,"trs_discount").signum()>0);
|
||||
return new Pricing(fixed?PriceMode.FIXED:PriceMode.AUTO,fixed?decimal(j,"jb_fixprice"):null,decimal(j,"jb_serviceprice"),fixed?decimal(j,"jb_cr_subprice"):null,access.catalog.enabled(u.headquartersId(),"MASK_CR_PRICE_MODE")?source.courierService():null,null,null,discount,id(taxes.getFirst(),"tx_id"));
|
||||
}
|
||||
static long payer(Map<String,Object> j){if(id(j,"csc_id_payer_cash")>0)return id(j,"csc_id_payer_cash");if(id(j,"jb_payment")==2||id(j,"jb_payment")==1&&id(j,"csc_id_payer")<=0)return id(j,"csc_id");return id(j,"csc_id_payer");}
|
||||
static List<List<String>> basis(List<Station> stops){return stops.stream().map(s->List.of(Objects.toString(s.street(),""),Objects.toString(s.houseNumber(),""),Objects.toString(s.postalCode(),""),Objects.toString(s.city(),""),Objects.toString(s.country(),""))).toList();}
|
||||
static String copyRemark(String note){return OrderDeliveryData.publicRemark(note).replace(CustomerPendingPrice.CASH,"").replace(CustomerEntryTracking.GPS_HINT,"").replace("*** ZAHLT BAR ***","").replaceAll("(?m)\\*\\*\\* Bitte im Auftrag STADTBOTE Bar [^\\r\\n]* kassieren und quittieren\\. \\*\\*\\*","").strip();}
|
||||
static BigDecimal amount(String s){try{return s.isBlank()?BigDecimal.ZERO:new BigDecimal(s.replace(',','.'));}catch(NumberFormatException e){throw invalid("Ungültiger gespeicherter Servicepreis.");}}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** jobs2 stores the recipient before the private tracking token/status, separated by pipes. */
|
||||
final class CustomerEntryTracking {
|
||||
static final String GPS_HINT="~~~\nBitte achten sie auf eine aktive GPS-Verbindung während dieses Auftrags, da der/die Empfänger über ihr voraussichtliches Eintreffen auf dem Laufenden gehalten wird.\n~~~\n";
|
||||
final DatabaseService db;
|
||||
CustomerEntryTracking(DatabaseService db){this.db=db;}
|
||||
boolean enabled(long customer){return id(one(db.queryForList("SELECT cs_tracking FROM customer WHERE cs_id=?",customer)),"cs_tracking")==1;}
|
||||
static List<String> validate(List<String> emails,int stations,boolean enabled){
|
||||
if(emails==null)return null; // Older clients leave existing station metadata untouched.
|
||||
if(!enabled)throw CustomerPortalScope.denied();
|
||||
if(emails.size()!=stations)throw bad("Bitte die Tracking-E-Mail jeder Station zuordnen.");
|
||||
var result=new ArrayList<String>();
|
||||
for(String raw:emails){
|
||||
if(raw==null||raw.length()>60||raw.contains("|")||raw.codePoints().anyMatch(Character::isISOControl))throw bad("Tracking-E-Mail: Bitte eine einzelne Adresse mit höchstens 60 Zeichen eingeben.");
|
||||
String email=raw.strip();if(!email.isEmpty())email=AppointmentNotifications.address(email);
|
||||
if(!email.isEmpty()&&!enabled)throw CustomerPortalScope.denied();result.add(email);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
List<String> validate(CustomerEntryAccess.State state,CustomerOrderEntry.Draft draft){return draft.trackingEmails()==null?null:validate(draft.trackingEmails(),draft.stops().size(),enabled(state.customer()));}
|
||||
List<String> load(List<Long> stations){return stations.stream().map(id->recipient(raw(id))).toList();}
|
||||
String raw(long station){var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_tracking'",station);if(rows.size()>1)throw bad("Die Tracking-E-Mail einer Station ist mehrfach gespeichert. Bitte den Bestand klären.");return rows.isEmpty()?"":text(rows.getFirst(),"gdc_content");}
|
||||
static String recipient(String raw){return raw.split("\\|",-1)[0];}
|
||||
void save(long station,String email){
|
||||
String old=raw(station);if(recipient(old).equals(email))return;
|
||||
if(email.isEmpty()){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_tracking'",station);return;}
|
||||
int separator=old.indexOf('|');String value=email+(separator<0?"":old.substring(separator));
|
||||
int changed=db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_tracking'",value,station);
|
||||
if(changed==0)db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",station,"gdc_gen_fieldname","tr_tracking","gdc_content",value,"gdc_context",""));
|
||||
}
|
||||
void created(long job,List<String> emails){
|
||||
if(emails==null)return;var stops=db.queryForList("SELECT tr_id FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",job);
|
||||
if(stops.size()!=emails.size())throw bad("Die Tracking-E-Mails konnten nicht eindeutig den gespeicherten Stationen zugeordnet werden.");
|
||||
for(int n=0;n<stops.size();n++)save(id(stops.get(n),"tr_id"),emails.get(n));
|
||||
}
|
||||
void hint(long job,boolean show){
|
||||
var stops=db.queryForList("SELECT tr_id,tr_remark FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",job);
|
||||
boolean active=show&&stops.stream().anyMatch(t->!recipient(raw(id(t,"tr_id"))).isBlank());
|
||||
for(int n=0;n<stops.size();n++){
|
||||
var stop=stops.get(n);String old=text(stop,"tr_remark"),value=old.replace(GPS_HINT,"");
|
||||
if(n==0&&active)value=GPS_HINT+value;
|
||||
if(value.length()>10000)throw bad("Die Stationsbemerkung ist mit dem Tracking-Hinweis zu lang.");
|
||||
if(!old.equals(value))db.update("UPDATE tour SET tr_remark=? WHERE tr_id=?",value,id(stop,"tr_id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Typed customer export projections; database-defined SQL is never executed. */
|
||||
final class CustomerExportData {
|
||||
record Scope(long customer,long base,long mandator,Set<Long> ownCenters,Set<Long> childCenters) {
|
||||
Set<Long> allCenters(){var ids=new TreeSet<>(ownCenters);ids.addAll(childCenters);return ids;}
|
||||
}
|
||||
record Query(LocalDate from,LocalDate to,int category) {}
|
||||
static final Set<Integer> CATEGORIES=Set.of(110,115,120);
|
||||
static final Set<String> SERVICE=Set.of("jb_id","tr_sort","srv_name","srvt_name","jbc_amount","jbc_price","jbc_totalprice");
|
||||
static final Set<String> NUMBERS=Set.of("jb_id","tr_sort","price","gross_price","tax_rate","jbc_amount","jbc_price","jbc_totalprice");
|
||||
static final Set<String> DATES=Set.of("jb_ordertime","jb_warningtime","jb_finishtime","tr_finishtime");
|
||||
final DatabaseService db;
|
||||
CustomerExportData(DatabaseService db){this.db=db;}
|
||||
Scope scope(LegacyUser u){
|
||||
var account=CustomerPortalScope.verify(db,u);if(!u.customerRight(7)||!EmployeeAdministration.bit(text(account,"emp_rights"),7))throw CustomerPortalScope.denied();
|
||||
long customer=id(account,"cs_id"),base=id(account,"csc_id"),root=id(account,"customer_root"),md=mandator(db,u);
|
||||
var own=centers(customer,root,base);if(!own.contains(base))throw CustomerPortalScope.denied();var child=new TreeSet<Long>();
|
||||
// The PHP meta-customer export spans direct child customers. A sub-costcenter identity stays in its subtree.
|
||||
var children=base==root?db.queryForList("SELECT cs_id,csc_id FROM customer WHERE cs_id_parent=? AND cs_id<>? AND hq_id IN ("+CourierBourse.HQ_SQL+") ORDER BY cs_id LIMIT 502",customer,customer,md):List.<Map<String,Object>>of();
|
||||
if(children.size()>501)throw bad("Der Kundenverbund ist zu groß für den Export.");
|
||||
for(var row:children){
|
||||
child.addAll(centers(id(row,"cs_id"),id(row,"csc_id"),id(row,"csc_id")));if(child.size()>10000)throw bad("Zu viele Kostenstellen für den Export.");
|
||||
}
|
||||
return new Scope(customer,base,md,Set.copyOf(own),Set.copyOf(child));
|
||||
}
|
||||
Set<Long> centers(long customer,long root,long base){var rows=db.queryForList("SELECT csc_id AS id,csc_pre_id AS parent FROM costcenter WHERE cs_id=? LIMIT 10001",customer);if(rows.size()>10000)throw bad("Zu viele Kostenstellen für den Export.");var valid=new HashSet<>(PortalEmployees.validCenters(rows,root));valid.retainAll(PortalEmployees.validCenters(rows,base));return valid;}
|
||||
static Set<String> keys(int category){
|
||||
if(category==115)return SERVICE;
|
||||
if(category==120)return Set.of("jb_id","jb_ordertime","jb_warningtime","jb_finishtime","cr_sid","cs_eid","cmp_comp","cmp_comp2","cmp_comp3","cmp_comp4","tr_sort","tr_comp","tr_person","tr_commission_no","tr_finishtime","ad_street","tr_hsno","ad_zipcode","ad_city","srv_name","srvt_name","jbc_amount","jbc_price","jbc_totalprice");
|
||||
if(category!=110)return Set.of();
|
||||
var keys=new HashSet<>(List.of("jb_id","cr_sid","price","gross_price","orderdate","ordertime","finishdate","finishtime","bookdate","booktime","jb_invtext","tax_rate","tax_rate_sign","cs_eid","csc_name","inv_no","invdate","invtime","usr_firstname","usr_name","target_address","streethsno","ad_country","vehicletype"));
|
||||
for(int n=1;n<=20;n++){String suffix=n==1?"":Integer.toString(n);for(String f:List.of("comp","comp2","hsno","person","commission_no","cs_freetext","signname"))keys.add("tr"+suffix+"_"+f);for(String f:List.of("street","zipcode","city"))keys.add("ad"+suffix+"_"+f);if(n>2)keys.add("target_address_"+n);}return keys;
|
||||
}
|
||||
List<Map<String,Object>> rows(LegacyUser u,Scope s,Query q){
|
||||
if(q==null||!CATEGORIES.contains(q.category())||q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.from().isAfter(q.to())||q.from().plusYears(1).isBefore(q.to()))throw bad("Bitte eine Exportkategorie und einen Zeitraum von höchstens einem Jahr wählen.");
|
||||
var all=s.allCenters();String marks=CourierPortal.placeholders(all.size());var args=new ArrayList<Object>(all);String condition="jb.csc_id_payer IN ("+marks+")";
|
||||
if(q.category()==110&&!s.childCenters().isEmpty()){condition="("+condition+" OR EXISTS (SELECT 1 FROM tourservice st WHERE st.jb_id=jb.jb_id AND st.csc_id IN ("+CourierPortal.placeholders(s.childCenters().size())+")))";args.addAll(new TreeSet<>(s.childCenters()));}
|
||||
args.add(s.mandator());args.add(q.from().atStartOfDay());args.add(q.to().plusDays(1).atStartOfDay());
|
||||
var jobs=db.queryForList("SELECT jb.jb_id,jb.hq_id,jb.csc_id_payer,jb.jb_payment,jb.emp_id,jb.cr_sid,jb.vht_id,jb.jb_totalprice,jb.jb_sales_tax_rate,jb.jb_sales_tax_rate_sign,jb.jb_ordertime,jb.jb_warningtime,jb.jb_finishtime,jb.jb_booktime,jb.jb_invtext FROM job jb WHERE "+condition+" AND jb.hq_id IN ("+CourierBourse.HQ_SQL+") AND jb.jb_status=2 AND COALESCE(jb.jb_storno,0) IN (0,1,3) AND jb.jb_finishtime>=? AND jb.jb_finishtime<? ORDER BY jb.jb_ordertime,jb.jb_id LIMIT 2001",args.toArray());
|
||||
if(jobs.size()>2000)throw bad("Mehr als 2.000 Exportaufträge. Bitte den Zeitraum eingrenzen.");if(jobs.isEmpty())return List.of();
|
||||
var ids=jobs.stream().map(j->id(j,"jb_id")).toList();String jobMarks=CourierPortal.placeholders(ids.size());
|
||||
var centers=new HashMap<Long,Map<String,Object>>();for(var c:db.queryForList("SELECT cc.csc_id,cc.csc_name,cs.cs_id,cs.cs_eid,cmp.cmp_comp,cmp.cmp_comp2,cmp.cmp_comp3,cmp.cmp_comp4 FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id LEFT JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cc.csc_id IN ("+marks+")",all.toArray()))centers.put(id(c,"csc_id"),c);
|
||||
var tours=group(bounded("SELECT t.tr_id,t.jb_id,t.csc_id,t.tr_sort,t.tr_comp,t.tr_comp2,t.tr_hsno,t.tr_person,t.tr_commission_no,t.tr_cs_freetext,t.tr_signname,t.tr_finishtime,a.ad_id AS valid_address,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id IN ("+jobMarks+") ORDER BY t.jb_id,t.tr_sort,t.tr_id",ids),"jb_id");
|
||||
var prices=q.category()==110?group(bounded("SELECT jb_id,csc_id,trs_price,trs_discount FROM tourservice WHERE jb_id IN ("+jobMarks+") ORDER BY jb_id,csc_id,tr_sort,srv_id,srvt_id",ids),"jb_id"):Map.<Long,List<Map<String,Object>>>of();
|
||||
var calc=q.category()!=110?group(bounded("SELECT jb_id,tr_sort,srv_name,srvt_name,jbc_amount,jbc_price,jbc_totalprice FROM jobcalculator WHERE jb_id IN ("+jobMarks+") ORDER BY jb_id,tr_sort,srv_id,srvt_id,tr_id",ids),"jb_id"):Map.<Long,List<Map<String,Object>>>of();
|
||||
var invoices=q.category()==110?group(bounded("SELECT jb_id,inv_no,inv_datetime FROM invoice WHERE jb_id IN ("+jobMarks+") ORDER BY jb_id,inv_datetime,inv_no",ids),"jb_id"):Map.<Long,List<Map<String,Object>>>of();
|
||||
var vehicleNames=new HashMap<Long,String>();for(var v:db.queryForList("SELECT mt_sort,MIN(mt_value) AS label FROM metatype WHERE mt_type='vehicletype' GROUP BY mt_sort"))vehicleNames.put(id(v,"mt_sort"),text(v,"label"));
|
||||
var creators=new HashMap<Long,Map<String,Object>>();if(q.category()==110){var users=jobs.stream().map(j->id(j,"emp_id")).filter(i->i>0).distinct().toList();if(!users.isEmpty())for(var row:db.queryForList("SELECT u.usr_id,u.usr_name,u.usr_firstname,e.csc_id FROM user u JOIN employee e ON e.usr_id=u.usr_id WHERE u.usr_type=2 AND u.usr_id IN ("+CourierPortal.placeholders(users.size())+")",users.toArray()))if(all.contains(id(row,"csc_id")))creators.put(id(row,"usr_id"),row);}
|
||||
int hops=2;try{hops=Math.max(2,Math.min(20,Integer.parseInt(new OrderEntryCatalog(db).parameter(0,"JOBLIST_NUM_OF_HOPS"))));}catch(NumberFormatException ignored){}
|
||||
var out=new ArrayList<Map<String,Object>>();
|
||||
for(var job:jobs){long jobId=id(job,"jb_id"),payer=id(job,"csc_id_payer");var stops=tours.getOrDefault(jobId,List.of());var items=calc.getOrDefault(jobId,List.of());boolean split=payer==0||id(job,"jb_payment")==1;
|
||||
if(q.category()==110){
|
||||
if(stops.stream().noneMatch(t->id(t,"tr_sort")==1&&id(t,"valid_address")>0)||!vehicleNames.containsKey(id(job,"vht_id")))continue;
|
||||
var groups=new TreeMap<Long,List<Map<String,Object>>>();for(var p:prices.getOrDefault(jobId,List.of())){long center=id(p,"csc_id");if(all.contains(center)&&(center==payer||s.childCenters().contains(center)))groups.computeIfAbsent(center,i->new ArrayList<>()).add(p);}
|
||||
for(var group:groups.entrySet()){var center=centers.get(group.getKey());if(center==null)continue;var row=base(job,center);var sum=BigDecimal.ZERO;for(var p:group.getValue())sum=sum.add(BillingService.nz(decimal(p,"trs_price")).multiply(BigDecimal.valueOf(100).subtract(BillingService.nz(decimal(p,"trs_discount")))).movePointLeft(2));
|
||||
row.put("price",BillingService.money(sum));row.put("gross_price",BillingService.money((split?sum:BillingService.nz(decimal(job,"jb_totalprice"))).multiply(BigDecimal.ONE.add(BillingService.nz(decimal(job,"jb_sales_tax_rate")).movePointLeft(2)))));row.put("tax_rate",job.get("jb_sales_tax_rate"));row.put("tax_rate_sign",text(job,"jb_sales_tax_rate_sign"));row.put("jb_invtext",split?"":text(job,"jb_invtext"));row.put("vehicletype",vehicleNames.get(id(job,"vht_id")));
|
||||
row.put("orderdate",date(job,"jb_ordertime","yyyyMMdd"));row.put("ordertime",date(job,"jb_ordertime","HH:mm"));row.put("finishdate",date(job,"jb_finishtime","ddMMyy"));row.put("finishtime",date(job,"jb_finishtime","HH:mm"));row.put("bookdate",date(job,"jb_booktime","yyyyMMdd"));row.put("booktime",date(job,"jb_booktime","HH:mm"));
|
||||
var creator=creators.get(id(job,"emp_id"));row.put("usr_name",creator==null?"":text(creator,"usr_name"));row.put("usr_firstname",creator==null?"":text(creator,"usr_firstname"));
|
||||
var invoice=invoices.getOrDefault(jobId,List.of());if(invoice.size()>1&&invoice.stream().map(i->text(i,"inv_no")).distinct().count()>1)throw bad("Auftrag #"+jobId+" hat mehrere Rechnungsnummern. Bitte die Zuordnung prüfen.");if(!invoice.isEmpty()){var inv=invoice.getFirst();row.put("inv_no",text(inv,"inv_no"));row.put("invdate",date(inv,"inv_datetime","ddMMyy"));row.put("invtime",date(inv,"inv_datetime","HH:mm"));}
|
||||
for(int n=1;n<=hops;n++){int sort=n;var station=stops.stream().filter(t->id(t,"tr_sort")==sort&&(!split||id(t,"csc_id")==group.getKey())).toList();if(station.size()>1)throw bad("Auftrag #"+jobId+" hat eine mehrdeutige Stationsposition "+n+".");if(!station.isEmpty())station(row,station.getFirst(),n);}
|
||||
out.add(row);
|
||||
}
|
||||
}else{
|
||||
if(!all.contains(payer))continue;var center=centers.get(payer);if(center==null)continue;
|
||||
if(split)throw bad("Auftrag #"+jobId+": Kalkulationspositionen eines Kostensplits sind nicht eindeutig dem Zahler zugeordnet. Bitte den Auftragsdatenexport verwenden.");
|
||||
if(q.category()==115){for(var item:items){var row=new LinkedHashMap<String,Object>();SERVICE.forEach(k->row.put(k,item.get(k)));out.add(row);}}
|
||||
else if(items.isEmpty()){var row=base(job,center);row.putAll(fields("tr_sort",1,"jbc_amount",1,"jbc_price",job.get("jb_totalprice"),"jbc_totalprice",job.get("jb_totalprice")));out.add(row);}
|
||||
else for(var item:items){long sort=id(item,"tr_sort");var row=base(job,center);for(String k:SERVICE)row.put(k,item.get(k));if(sort==0)row.put("tr_sort",1);else{var matches=stops.stream().filter(t->id(t,"tr_sort")==sort).toList();if(matches.isEmpty())continue;if(matches.size()>1)throw bad("Auftrag #"+jobId+" hat eine mehrdeutige Stationsposition "+sort+".");var t=matches.getFirst();for(String k:List.of("tr_comp","tr_person","tr_commission_no","tr_finishtime","ad_street","tr_hsno","ad_zipcode","ad_city"))row.put(k,t.get(k));}out.add(row);}
|
||||
}
|
||||
if(out.size()>10000)throw bad("Mehr als 10.000 Exportzeilen. Bitte den Zeitraum eingrenzen.");
|
||||
}
|
||||
if(q.category()!=110)out.sort(Comparator.comparingLong((Map<String,Object> r)->id(r,"jb_id")).thenComparingLong(r->id(r,"tr_sort")));
|
||||
return out;
|
||||
}
|
||||
List<Map<String,Object>> bounded(String sql,List<Long> ids){var rows=db.queryForList(sql+" LIMIT 50001",ids.toArray());if(rows.size()>50000)throw bad("Zu viele Exportpositionen. Bitte den Zeitraum eingrenzen.");return rows;}
|
||||
static Map<Long,List<Map<String,Object>>> group(List<Map<String,Object>> rows,String key){var grouped=new HashMap<Long,List<Map<String,Object>>>();for(var row:rows)grouped.computeIfAbsent(id(row,key),i->new ArrayList<>()).add(row);return grouped;}
|
||||
static Map<String,Object> base(Map<String,Object> job,Map<String,Object> center){var row=new LinkedHashMap<String,Object>();for(String k:List.of("jb_id","jb_ordertime","jb_warningtime","jb_finishtime","cr_sid"))row.put(k,job.get(k));for(String k:List.of("cs_eid","csc_name","cmp_comp","cmp_comp2","cmp_comp3","cmp_comp4"))row.put(k,center.get(k));return row;}
|
||||
static String date(Map<String,Object> row,String key,String pattern){var d=dateTime(row,key);return d==null?"":d.format(DateTimeFormatter.ofPattern(pattern));}
|
||||
static void station(Map<String,Object> row,Map<String,Object> t,int n){String suffix=n==1?"":Integer.toString(n);for(String k:List.of("comp","comp2","hsno","person","commission_no","cs_freetext","signname"))row.put("tr"+suffix+"_"+k,text(t,"tr_"+k));for(String k:List.of("street","zipcode","city"))row.put("ad"+suffix+"_"+k,text(t,"ad_"+k));if(n==1){row.put("ad_country",text(t,"ad_country"));row.put("streethsno",(text(t,"ad_street")+" "+text(t,"tr_hsno")).strip());}else row.put(n==2?"target_address":"target_address_"+n,(text(t,"ad_street")+" "+text(t,"tr_hsno")+", "+text(t,"ad_zipcode")).strip());}
|
||||
}
|
||||
Loaded 100 of 1294 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user