commit 1a3023326958bf1884c6236fd0af4fcca324fa89 Author: Sven Carstensen Date: Mon Oct 5 17:45:38 2026 +0200 Initialisiere VoTiAn NG mit Docker-Build, .env-Konfiguration und Cookie-Sitzungen diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..118c817 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Nur das gebaute JAR wird für das Laufzeit-Image benötigt. +** +!Dockerfile +!backend/ +backend/** +!backend/target/ +backend/target/** +!backend/target/*.jar diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9240ee5 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Als .env im Projektstamm speichern und die vorhandenen Datenbankwerte eintragen. +# 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. +DB_URL=jdbc:mariadb://DB-HOST:3306/DB-NAME?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false +DB_USER=CHANGE_ME +DB_PASSWORD=CHANGE_ME diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a40928f --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +frontend/node_modules/ +# Eigenständiges Repository der PHP-Altanwendung. +/votian/ +frontend/dist/ +backend/target/ +backend/data/ +data/ +work/ +.env +.env.* +!.env.example +.DS_Store +*.log +node_modules/ +__pycache__/ +*.pyc +connection.local.json +*.tsbuildinfo diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d53ecaf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..81c8eac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM eclipse-temurin:21-jre + +WORKDIR /app +ARG JAR_FILE=backend/target/*.jar +COPY --chown=10001:10001 ${JAR_FILE} app.jar + +ENV SERVER_ADDRESS=0.0.0.0 +USER 10001:10001 +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8145058 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# 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. + +## 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 vollständige Analyse und Abdeckungsmatrix stehen in [docs/ALTSYSTEM-ANALYSE.md](docs/ALTSYSTEM-ANALYSE.md). + +## Start + +Benötigt: Java 21, Node.js 22.12 oder neuer, Python 3 sowie der Netzwerk-/VPN-Zugang zur bestehenden Datenbank. Maven wird über den mitgelieferten Wrapper verwendet. + +```bash +cd /Users/svencarstensen/Documents/Entwicklung/Source/Kunden/Assecutor/votianNG +./dev.sh +``` + +Die Oberfläche ist anschließend unter `http://localhost:5173` erreichbar. `Strg+C` beendet die gestarteten Prozesse. Das Backend-Protokoll liegt in `work/backend.log`. + +`dev.sh`, `run.sh` und die Datenbankdiagnose laden `DB_URL`, `DB_USER` und `DB_PASSWORD` aus der lokalen `.env` im Projektstamm. Bereits gesetzte Umgebungsvariablen haben Vorrang. Die Zugangsdaten werden ausschließlich als Prozessumgebung an Java übergeben, ohne sie auszugeben oder in Prozessargumente zu schreiben. + +Die vorhandenen lokalen Datenbankwerte wurden nach `.env` übernommen. Bei einer neuen Einrichtung `.env.example` nach `.env` kopieren und die drei Werte eintragen. `DB_URL` enthält den vom jeweiligen Rechner erreichbaren Host bzw. die VPN-Adresse, Port und Datenbanknamen: + +```bash +DB_URL=jdbc:mariadb://DB-HOST:3306/DB-NAME?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false +``` + +Diese Zeile gehört in `.env`. Werte werden wörtlich gelesen; `$`, `#`, `=` und Backslashes im Passwort bleiben erhalten. Die Datei wird nicht als Shellskript ausgeführt und sollte nicht mit `source` geladen werden. Für die zusätzliche Verwendung mit Docker die Werte ohne umschließende Anführungszeichen schreiben. `.env` und lokale Varianten sind per `.gitignore` ausgeschlossen; `.env.example` enthält ausschließlich Platzhalter. + +Nur die Konfiguration ohne Datenbankverbindung prüfen: + +```bash +python3 scripts/run-backend.py --check-config +``` + +Die neue Anwendung liest `connection.local.json` und die PHP-Konfiguration nicht mehr als Zugangsdatenquelle. Der PHP-Altbestand bleibt eigenständig; seine Konfiguration wird durch diese Umstellung nicht verändert. + +## Gebündelte Anwendung + +```bash +./build.sh +./run.sh +``` + +`build.sh` baut React, integriert die gebauten Dateien in das Spring-Boot-JAR und führt die Backend-Tests aus. Die gebündelte Oberfläche ist über `http://localhost:8080` erreichbar. `run.sh` übernimmt dieselbe `.env` wie der Entwicklungsstart. Build und Unit-Tests laden keine lokalen Datenbankzugangsdaten. + +## Docker-Image veröffentlichen + +```bash +docker login registry.assecutor.org +./docker_push.sh +# Alternativer Image-Tag: +./docker_push.sh 0.1 +``` + +Das Skript führt `build.sh` einschließlich Tests aus und pusht das Java-21-Image mit gebündeltem React-Frontend für `linux/amd64` nach `registry.assecutor.org/votianng`. Ohne Argument verwendet es die Version aus `backend/pom.xml`; ein expliziter Tag ändert nur den Image-Tag. Das Ziel lässt sich über `REGISTRY_IMAGE` überschreiben. + +Im Container lauscht die Anwendung auf Port 8080 an `0.0.0.0`. Die `.env` wird beim Containerstart übergeben: + +```bash +docker run --rm --env-file .env -p 127.0.0.1:8080:8080 registry.assecutor.org/votianng:0.1 +``` + +Die vorhandene PHP-Anwendung und `.env` sind nicht im Image enthalten. Bei Bedarf `LEGACY_APP_URL` zusätzlich als Umgebungsvariable übergeben. Beim direkten Start über Maven oder `java -jar` wird `.env` über den gemeinsamen Wrapper geladen, beispielsweise aus `backend/`: `python3 ../scripts/env_config.py ./mvnw spring-boot:run`. + +## Anmeldung und Reverse Proxy + +VoTiAn verwendet serverseitige Sitzungen mit dem Cookie `VOTIANSESSION`. Die Systemzugangsdaten werden einmal beim Anmelden übertragen; die Oberfläche speichert sie nicht im Browser und setzt keinen `Authorization`-Header. Ein zusätzlicher HTTP-Basic-Passwortschutz im Nginx Proxy Manager kann deshalb bestehen bleiben: Dessen Zugangsdaten verwaltet der Browser unabhängig von der VoTiAn-Sitzung. Die Abmeldung in VoTiAn beendet nur die Sitzung der Anwendung. + +Für `https://votian.app` muss der Proxy die Oberfläche und `/api/**` an denselben VoTiAn-Dienst weiterleiten, Cookies durchlassen und das ursprüngliche HTTPS-Protokoll als `X-Forwarded-Proto: https` übermitteln. Die Anwendung wertet diese Header mit der nativen Tomcat-Unterstützung aus. Nur der vertrauenswürdige Proxy sollte den Backend-Port erreichen; er muss vom Client übermittelte Forwarded-Header ersetzen. Tomcat vertraut standardmäßig internen Proxyadressen. Bei anderer Netzwerkstruktur die erlaubten Proxyadressen gezielt über `SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES` konfigurieren; die Prüfung nicht durch einen leeren Wert abschalten. Siehe [Spring Boot: Reverse Proxy](https://docs.spring.io/spring-boot/3.5/how-to/webserver.html#howto.webserver.use-behind-a-proxy-server). + +Im HTTPS-Produktionsbetrieb zusätzlich `SERVER_SERVLET_SESSION_COOKIE_SECURE=true` als Container-Umgebungsvariable setzen, zum Beispiel mit `docker run --env-file .env -e SERVER_SERVLET_SESSION_COOKIE_SECURE=true ...`. Damit wird das Sitzungscookie ausschließlich über HTTPS übertragen. Der direkte HTTP-Aufruf per Server-IP ist dann kein Anmeldeweg mehr. Für die lokale Entwicklung über HTTP diese Einstellung weglassen; ohne explizite Vorgabe richtet sich `Secure` nach dem vom Backend erkannten Request-Protokoll. + +Das Sitzungscookie ist `HttpOnly` und `SameSite=Lax`. Standardmäßig ist nach 30 Minuten ohne Anfrage an die Sitzung eine erneute Anmeldung erforderlich. Sitzungen liegen im Arbeitsspeicher des Backends und gehen bei einem Neustart verloren; mehrere Instanzen benötigen Sitzungsaffinität oder eine später einzurichtende gemeinsame Sitzungsablage. Benutzerrechte werden bei der Anmeldung geladen und gelten bis zur Abmeldung bzw. zum Sitzungsablauf. Änderungen an Konten oder Rechten beenden vorhandene Sitzungen nicht sofort. + +## Konfiguration + +| Variable | Bedeutung | +|---|---| +| `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 | +| `PORT` | Spring-Boot-Port, Standard 8080 | +| `SERVER_ADDRESS` | Bind-Adresse, Standard `127.0.0.1` | +| `FORWARD_HEADERS_STRATEGY` | Auswertung der Proxy-Header, Standard `native` | +| `SERVER_SERVLET_SESSION_COOKIE_SECURE` | Im HTTPS-Produktionsbetrieb `true` setzen; lokal über HTTP weglassen | +| `SESSION_TIMEOUT` | Ablauf bei Inaktivität, Standard `30m` | +| `MAX_RESULTS` | Obergrenze des geladenen Listenausschnitts, Standard 500 | + +Die neue Anwendung ist standardmäßig nur lokal erreichbar. Für einen Serverbetrieb sind TLS und die korrekte Weiterleitung über einen Reverse Proxy zu konfigurieren. Es werden keine Standardkonten angelegt. Kunden- und Kurierlogins sind noch keine portierten Zugangswege; vorhandene Zentralenrechte werden nicht pauschal erweitert. + +## Prüfungen + +```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. + +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. + +## Datenbank-Serviceschicht + +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. + +## Projektstruktur + +```text +votian/ Bestehende PHP-Anwendung, unverändert weiter nutzbar +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 +``` diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..aef1542 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +/target/ +/data/ +*.log diff --git a/backend/.mvn/wrapper/maven-wrapper.properties b/backend/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/backend/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..61fe825 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,114 @@ +# 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. + +## Zentrale Datenbankserviceschicht + +`database.DatabaseService` ist die einzige Komponente mit Zugriff auf `JdbcTemplate` und den Transaktionsmanager. `LegacyAuthenticationProvider`, `LegacyReadService` 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. + +- `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. + +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. + +## 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`. + +Direkte Maven-Aufrufe laden `.env` nicht automatisch. Für den eigenständigen Backend-Start mit der lokalen Konfiguration im Verzeichnis `backend/` ausführen: + +```sh +python3 ../scripts/env_config.py ./mvnw spring-boot:run +``` + +Verfügbare Konfiguration: + +| Variable | Bedeutung | +| --- | --- | +| `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 | +| `LEGACY_APP_URL` | Optionale HTTP(S)-Basisadresse der vorhandenen PHP-Anwendung | +| `SERVER_ADDRESS` | Standard `127.0.0.1` | +| `PORT` | Standard `8080` | +| `CORS_ORIGINS` | Standard localhost/127.0.0.1 auf Port 5173 | +| `FORWARD_HEADERS_STRATEGY` | Standard `native`; Proxy-Header werden durch Tomcat ausgewertet | +| `SERVER_SERVLET_SESSION_COOKIE_SECURE` | Bei HTTPS in Produktion explizit `true`; ohne Vorgabe vom erkannten Request-Protokoll abhängig | +| `SESSION_TIMEOUT` | Ablauf bei Inaktivität, Standard `30m` | +| `MAX_RESULTS` | Abfragegrenze, Standard 500, maximal 5000 | + +Die Anwendung startet auch ohne erreichbare Datenbank. `/api/health` prüft die Anwendung; `/api/system` meldet die tatsächliche Datenbankerreichbarkeit. Eine fehlgeschlagene Datenabfrage oder Anmeldung bei fehlender DB-Verbindung liefert 503 und niemals erfundene oder leere Ersatzdaten. Die SQL-Kompatibilität muss zusätzlich am tatsächlichen Bestandsschema geprüft werden. + +## Anmeldung und Umfang + +Die Anmeldung prüft ausschließlich vorhandene VoTiAn-Konten über `user.usr_account`, `PASSWORD()` und `OLD_PASSWORD()` und speichert die authentifizierte Identität in einer serverseitigen HTTP-Sitzung. Es erfolgt keine Passwortmigration und kein Update. Unterstützt sind vorhandene Zentralenbenutzer (`usr_type=1`) mit Mitarbeiter-, Niederlassungs- und Mandantenzuordnung. Keine Demo-Anmeldung und kein automatisch angelegtes Konto. + +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. + +### Ablauf für API-Clients + +1. `GET /api/csrf` aufrufen und das dabei gesetzte Sitzungscookie behalten. Die JSON-Antwort enthält `token`, `headerName` und `parameterName`. +2. `POST /api/login` mit `Content-Type: application/x-www-form-urlencoded`, den Formularfeldern `username` und `password`, dem Sitzungscookie und dem CSRF-Token im angegebenen Header senden. Erfolgreich: `200` mit denselben Benutzerinformationen wie `/api/me`; die Antwort erneuert die Sitzungskennung. Falsche Zugangsdaten liefern `401`, eine nicht verfügbare Datenbank `503`. +3. Für folgende API-Aufrufe das erneuerte Sitzungscookie verwenden. `GET /api/me` prüft die bestehende Anmeldung; ohne gültige Anmeldung liefert es `401` und keine HTTP-Basic-Aufforderung. +4. Nach erfolgreicher Anmeldung und nach Abmeldung mit `GET /api/csrf` einen neuen Token beziehen, bevor erneut ein schreibender HTTP-Aufruf erfolgt. Die bisherigen Token werden dabei ungültig. Der Token gehört zur Sitzung und muss bei `POST`, `PUT`, `PATCH` und `DELETE` im angegebenen Header mitgeschickt werden. +5. Zum Abmelden `POST /api/logout` mit gültigem CSRF-Token und Sitzungscookie senden. Die Antwort ist `204`; Sitzung und Cookie werden gelöscht. `GET /api/logout` meldet nicht ab. + +Ein fehlender oder falscher CSRF-Token führt zu `403`, auch bei Anmeldung und Abmeldung. Die React-Oberfläche übernimmt diesen Ablauf einschließlich erneuter Anmeldung bei abgelaufener Sitzung. Die CSRF-Token bleiben getrennt vom `HttpOnly`-Sitzungscookie. Hinter einem zusätzlichen Proxy-Passwortschutz bleiben dessen Zugangsdaten für alle Aufrufe notwendig; die VoTiAn-Abmeldung beendet den Proxy-Passwortschutz nicht. Siehe [Spring Security: CSRF für JavaScript-Clients](https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript). + +### Betrieb hinter Nginx / Nginx Proxy Manager + +Frontend und API unter derselben HTTPS-Origin bereitstellen und Cookies unverändert weiterleiten. Der Proxy muss `X-Forwarded-Proto: https` setzen und vom Client gelieferte Forwarded-Header ersetzen. Das Backend sollte nur vom vertrauenswürdigen Proxy erreichbar sein. `FORWARD_HEADERS_STRATEGY=native` verwendet Tomcats Proxy-Auswertung einschließlich seiner Vertrauensprüfung für interne Proxyadressen. Falls nötig, diese gezielt mit `SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES` anpassen; keine leere Einstellung verwenden, die alle Proxies als vertrauenswürdig behandelt. + +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. + +## API + +| Route | Verhalten | +| --- | --- | +| `GET /api/health` | Öffentlich, Anwendungsbereitschaft | +| `GET /api/system` | Öffentlich, Verbindung, Funktionsumfang, Legacy-Link, Ergebnisgrenze | +| `GET /api/csrf` | Öffentlich, CSRF-Token und Header-/Parametername für die aktuelle Sitzung | +| `POST /api/login` | CSRF-geschützte Anmeldung mit Formularfeldern `username` und `password`; `200` mit Benutzerinformationen | +| `POST /api/logout` | CSRF-geschützte Abmeldung, Sitzung ungültig machen und Cookie löschen; `204` | +| `GET /api/me` | Bestehendes Konto, Niederlassung, verfügbare Lesebereiche | +| `GET /api/orders` | Aufträge, Filter `q`, `status`, `customerId`, `courierId`, `from`, `to` | +| `GET /api/orders/{id}` | Auftrag derselben Erfassungsniederlassung mit Tourstationen | +| `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/invoices` | 501, Bestands-Abrechnungsprozess noch nicht portiert | +| Übrige Schreibmethoden unter `/api/**` | 501 nach erfolgreicher Anmeldung und gültiger CSRF-Prüfung; keine Datenänderung | + +Listen liefern Arrays. Auftragslisten senden zusätzlich `X-Total-Count` und `X-Result-Limit`. `from`/`to` sind einschließlich, Format `YYYY-MM-DD`, bezogen auf `jb_ordertime`. Nicht belegte API-Felder bleiben `null`, insbesondere Stammdatenversionen, Rechnungs-ID, Lieferfrist, Packstückzahl und Distanz. Die UI soll diese als unbekannt anzeigen. + +## Fachliche Zuordnung und offene Arbeit + +- Auftraggeber aus `job.csc_id → costcenter.cs_id`; Rechnungskunde separat als `payerCustomerId` aus `csc_id_payer`. +- 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. +- Leistungen aus der Bitmaske `jb_service`, wobei `metatype.mt_sort` die Bitposition ist. Fahrzeugtypen werden über `metatype` gelesen. +- Historie liegt im Bestand in einer separaten Log-Datenbank. Der Adapter erfindet keine Audit-Ereignisse; das Feld bleibt aktuell leer. +- 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. +- 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. + +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 und die numerische JDBC-Abbildung von `TINYINT(1)`. 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. + +Für diesen bewusst aktivierten Integrationstest mit der lokalen `.env` im Verzeichnis `backend/` ausführen: + +```sh +VOTIAN_VERIFY_EXISTING_DB=true python3 ../scripts/env_config.py ./mvnw test +``` diff --git a/backend/mvnw b/backend/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/backend/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/backend/mvnw.cmd b/backend/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/backend/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..708cf35 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,17 @@ + + + 4.0.0 + org.springframework.bootspring-boot-starter-parent3.5.16 + de.stadtbotevotian-backend0.1.1 + Votian NG Stadtbote21 + + org.springframework.bootspring-boot-starter-web + org.springframework.bootspring-boot-starter-jdbc + org.springframework.bootspring-boot-starter-validation + org.springframework.bootspring-boot-starter-security + org.mariadb.jdbcmariadb-java-clientruntime + org.springframework.bootspring-boot-starter-testtest + org.springframework.securityspring-security-testtest + + src/main/resources../frontend/diststaticorg.springframework.bootspring-boot-maven-plugin + diff --git a/backend/src/main/java/de/stadtbote/votian/ApiController.java b/backend/src/main/java/de/stadtbote/votian/ApiController.java new file mode 100644 index 0000000..383c8fd --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ApiController.java @@ -0,0 +1,72 @@ +package de.stadtbote.votian; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.*; +import static de.stadtbote.votian.Models.*; + +@RestController +@RequestMapping("/api") +public class ApiController { + private final LegacyReadService legacy; + private final String legacyUrl; + ApiController(LegacyReadService legacy,@Value("${app.legacy-url:}") String legacyUrl) { this.legacy=legacy;this.legacyUrl=safeLegacyUrl(legacyUrl); } + static String safeLegacyUrl(String raw) { + if(raw==null || raw.isBlank())return null; + URI uri=URI.create(raw.trim()); + if(!Set.of("http","https").contains(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null || uri.getQuery()!=null || uri.getFragment()!=null) throw new IllegalArgumentException("LEGACY_APP_URL muss eine HTTP(S)-Basisadresse ohne Zugangsdaten, Query oder Fragment sein."); + return raw.trim().replaceAll("/+$",""); + } + static LegacyUser user(Authentication authentication) { + if(authentication==null || !(authentication.getPrincipal() instanceof LegacyUser u))throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"Bitte anmelden."); + return u; + } + @GetMapping("/health") public Map health() { return Map.of("status","UP"); } + @GetMapping("/csrf") public Map csrf(CsrfToken csrf) { + return Map.of("token",csrf.getToken(),"headerName",csrf.getHeaderName(),"parameterName",csrf.getParameterName()); + } + @GetMapping("/system") public Map system(Authentication authentication) { + var connection=legacy.connectionCheck(); + boolean connected=connection.connected(); + LegacyUser u=authentication!=null&&authentication.getPrincipal() instanceof LegacyUser lu?lu:null; + Map result=new LinkedHashMap<>(); + result.put("connected",connected);result.put("database","legacy-mysql");result.put("readOnly",true);result.put("schemaChanges",false); + result.put("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","Historie aus separater Log-Datenbank","Archivtabellen")); + return result; + } + @GetMapping("/me") public Map me(Authentication authentication) { + return userDetails(authentication); + } + static Map 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()); + } + @GetMapping("/orders") public ResponseEntity> 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); + return ResponseEntity.ok().header("X-Total-Count",Long.toString(result.total())).header("X-Result-Limit",Integer.toString(result.limit())).body(result.orders()); + } + @GetMapping("/orders/{id}") public DeliveryOrder order(Authentication auth,@PathVariable long id) { return legacy.order(user(auth),id); } + @GetMapping("/customers") public List customers(Authentication auth,@RequestParam(required=false) String q) { return legacy.customers(user(auth),q); } + @GetMapping("/couriers") public List couriers(Authentication auth,@RequestParam(required=false) String q) { return legacy.couriers(user(auth),q); } + @GetMapping("/orders/export.csv") public ResponseEntity csv(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to) { + LegacyUser u=user(auth);u.require(5); + var result=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"); + 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)); + } + 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(); } + @RequestMapping(value="/**",method={RequestMethod.POST,RequestMethod.PUT,RequestMethod.PATCH,RequestMethod.DELETE}) public Object mutation() { throw unsupported(); } + static ResponseStatusException unsupported() { return new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED,"Dieser Schreib-/Abrechnungsworkflow ist noch nicht auf die vorhandene VoTiAn-Datenbank portiert. Bitte das bestehende VoTiAn verwenden. Es werden keine Ersatzdatensätze oder Tabellen angelegt."); } +} diff --git a/backend/src/main/java/de/stadtbote/votian/ApiErrors.java b/backend/src/main/java/de/stadtbote/votian/ApiErrors.java new file mode 100644 index 0000000..57d6ce6 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ApiErrors.java @@ -0,0 +1,30 @@ +package de.stadtbote.votian; + +import org.springframework.dao.DataAccessException; +import de.stadtbote.votian.database.DatabaseService; +import de.stadtbote.votian.database.DatabaseWritesDisabledException; +import org.springframework.http.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; +import java.util.Map; + +@RestControllerAdvice +public class ApiErrors { + static final String DATABASE_UNAVAILABLE=DatabaseService.DATABASE_UNAVAILABLE; + static Map body(String message) { return Map.of("message",message,"timestamp",Instant.now().toString()); } + @ExceptionHandler(DataAccessException.class) + ResponseEntity database(DataAccessException exception) { return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body(DATABASE_UNAVAILABLE)); } + @ExceptionHandler(DatabaseWritesDisabledException.class) + ResponseEntity writesDisabled(DatabaseWritesDisabledException exception) { return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(body(exception.getMessage())); } + @ExceptionHandler(AccessDeniedException.class) + ResponseEntity denied(AccessDeniedException exception) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body(exception.getMessage())); } + @ExceptionHandler(ResponseStatusException.class) + 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(Exception.class) + ResponseEntity unknown(Exception exception) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body("Die Anfrage konnte nicht verarbeitet werden. Bitte die Konfiguration des Legacy-Adapters prüfen.")); } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyAuthenticationProvider.java b/backend/src/main/java/de/stadtbote/votian/LegacyAuthenticationProvider.java new file mode 100644 index 0000000..5978d41 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyAuthenticationProvider.java @@ -0,0 +1,39 @@ +package de.stadtbote.votian; + +import org.springframework.dao.DataAccessException; +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.authentication.*; +import org.springframework.security.core.*; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; +import java.util.*; + +@Component +public class LegacyAuthenticationProvider implements AuthenticationProvider { + private final DatabaseService database; + static final String AUTH_SQL = """ + SELECT u.usr_id, u.hq_id, u.usr_account, e.emp_id, e.emp_rights + 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_account = ? AND u.usr_type = 1 AND u.hq_id > 0 + AND EXISTS (SELECT 1 FROM mandatorheadquarters mh WHERE mh.hq_id=u.hq_id AND mh.md_id>0) + AND (u.usr_password = PASSWORD(?) OR u.usr_password_old = OLD_PASSWORD(?)) + """; + public LegacyAuthenticationProvider(DatabaseService database) { this.database = database; } + @Override public Authentication authenticate(Authentication authentication) { + String username = authentication.getName(); + String password = String.valueOf(authentication.getCredentials()); + if (username.isBlank() || password.isBlank() || username.length()>200 || password.length()>1024) throw new BadCredentialsException("Benutzername oder Passwort ist falsch."); + try { + List> rows = database.queryForList(AUTH_SQL,username,password,password); + if (rows.size() != 1) throw new BadCredentialsException("Benutzername oder Passwort ist falsch, oder das Konto hat keinen Zentralenzugang."); + Map row = rows.getFirst(); + LegacyUser user = new LegacyUser(LegacyMapping.id(row,"usr_id"),LegacyMapping.id(row,"emp_id"),LegacyMapping.id(row,"hq_id"),LegacyMapping.text(row,"usr_account"),LegacyMapping.text(row,"emp_rights")); + if (user.headquartersId() <= 0 || user.employeeId() <= 0) throw new BadCredentialsException("Dem Konto fehlt eine eindeutige Niederlassung oder Mitarbeiterzuordnung."); + return UsernamePasswordAuthenticationToken.authenticated(user,null,List.of(new SimpleGrantedAuthority("ROLE_VIEWER"))); + } catch (DataAccessException ex) { + throw new AuthenticationServiceException(ApiErrors.DATABASE_UNAVAILABLE); + } + } + @Override public boolean supports(Class authentication) { return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyMapping.java b/backend/src/main/java/de/stadtbote/votian/LegacyMapping.java new file mode 100644 index 0000000..d1079b3 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyMapping.java @@ -0,0 +1,75 @@ +package de.stadtbote.votian; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import static de.stadtbote.votian.Models.*; + +/** Mappings traced to include/mcglobal.inc.php and the corresponding PHP job/customer/courier modules. */ +final class LegacyMapping { + private LegacyMapping() {} + static Object value(Map row,String key) { + Object result = row.get(key); + if (result == null) result = row.get(key.toUpperCase(java.util.Locale.ROOT)); + return result; + } + static String text(Map row,String key) { Object v=value(row,key); return v==null ? "" : v.toString(); } + static long id(Map row,String key) { + Object v=value(row,key); + if(v==null || v.toString().isBlank()) return 0; + // JDBC may return Boolean for legacy TINYINT(1) flags, such as cmp_archived. + if(v instanceof Boolean flag) return flag ? 1 : 0; + return v instanceof Number n ? n.longValue() : Long.parseLong(v.toString()); + } + static Long nullableId(Map row,String key) { long n=id(row,key); return n>0?n:null; } + static BigDecimal decimal(Map row,String key) { Object v=value(row,key); if(v==null || v.toString().isBlank()) return null; return v instanceof BigDecimal d ? d : new BigDecimal(v.toString()); } + static LocalDateTime dateTime(Map row,String key) { + Object v=value(row,key); if(v==null) return null; + if(v instanceof Timestamp t) return t.toLocalDateTime(); + if(v instanceof LocalDateTime t) return t; + String s=v.toString(); if(s.isBlank() || s.startsWith("0000-")) return null; + if(s.matches("\\d{14}")) return LocalDateTime.parse(s,DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + return LocalDateTime.parse(s.replace(' ','T')); + } + static String join(String a,String b) { return (a+" "+b).trim(); } + static Customer customer(Map row) { + Customer c=new Customer();c.id=id(row,"cs_id");c.number=text(row,"cs_eid");c.name=text(row,"cmp_comp"); + c.street=join(text(row,"ad_street"),text(row,"cmp_hsno"));c.postalCode=text(row,"ad_zipcode");c.city=text(row,"ad_city"); + c.contact=join(text(row,"usr_firstname"),text(row,"usr_name"));c.phone=text(row,"usr_phone");c.email=text(row,"usr_email"); + String days=text(row,"cs_payment_term");c.paymentDays=days.matches("\\d+")?Integer.valueOf(days):null; + c.active=id(row,"cmp_archived")==0 && id(row,"cmp_visible")==1 && id(row,"cmp_authenticated")==1; + return c; + } + static Courier courier(Map row) { + Courier c=new Courier();c.id=id(row,"cr_id");c.number=text(row,"cr_eid");c.name=text(row,"cmp_comp"); + if(c.name.isBlank()) c.name=join(text(row,"usr_firstname"),text(row,"usr_name")); + c.phone=text(row,"usr_phone");c.vehicle=text(row,"vehicle_label");c.plate=text(row,"crvh_vh_sign"); + c.active=id(row,"cmp_archived")==0 && id(row,"cmp_visible")==1 && id(row,"cmp_authenticated")==1; + c.status=!c.active?CourierStatus.OFFLINE:(id(row,"active_jobs")>0?CourierStatus.BUSY:(id(row,"cr_available")==1?CourierStatus.AVAILABLE:CourierStatus.OFFLINE)); + return c; + } + static DeliveryOrder order(Map row) { + DeliveryOrder o=new DeliveryOrder();o.id=id(row,"jb_id");o.number=Long.toString(o.id);o.version=id(row,"jb_version"); + o.customerId=nullableId(row,"app_customer_id");o.payerCustomerId=nullableId(row,"app_payer_customer_id");o.courierId=nullableId(row,"cr_id"); + o.legacyStatus=(int)id(row,"jb_status");int storno=(int)id(row,"jb_storno");o.legacyStorno=storno;o.isCreditNote=storno==3||storno==4; + o.cancelMode=storno==1 || storno==3?CancelMode.CHARGEABLE:storno==2 || storno==4?CancelMode.FREE:null; + o.status=o.cancelMode!=null?OrderStatus.CANCELLED:switch(o.legacyStatus){case 0->OrderStatus.OFFERED;case 1->OrderStatus.IN_PROGRESS;case 2->OrderStatus.COMPLETED;default->OrderStatus.DISPOSITION;}; + o.legacyStatusLabel=switch(o.legacyStatus){case 9->"Disposition";case 8->"Autoranking";case 0->"Offen / angeboten";case 1->"Laufend";case 2->"Erledigt";default->"Legacy-Status "+o.legacyStatus;}; + if(o.cancelMode!=null) o.legacyStatusLabel=switch(storno){case 1->"Storniert · kostenpflichtig";case 2->"Storniert · kostenfrei";case 3->"Storno-Gutschrift · kostenpflichtiges Storno";case 4->"Storno-Gutschrift · kostenfreies Storno";default->"Storno";}; + o.legacyService=id(row,"jb_service");o.service=text(row,"service_label");o.vehicle=text(row,"vehicle_label"); + o.pickupAt=dateTime(row,"jb_ordertime");o.reference=text(row,"jb_commission_no");o.costCenter=text(row,"app_cost_center");o.notes=text(row,"jb_dispoinfo"); + o.weight=decimal(row,"jb_weight");o.netPrice=decimal(row,"jb_totalprice");o.courierPrice=decimal(row,"jb_cr_price");o.vatRate=decimal(row,"jb_sales_tax_rate"); + o.createdAt=dateTime(row,"jb_createtime");o.updatedAt=dateTime(row,"jb_modify");o.deliveredAt=dateTime(row,"jb_finishtime"); + return o; + } + static Stop stop(Map row) { + Stop s=new Stop();s.id=id(row,"tr_id");s.sequence=(int)id(row,"tr_sort"); + // Existing VoTiAn tour field is preserved through the explicit warehouse direction mapping. + s.type=switch((int)id(row,"tr_ware_from_to")){case 1->StopType.PICKUP;case 2->StopType.DELIVERY;case 3->StopType.BOTH;default->s.sequence==1?StopType.PICKUP:StopType.DELIVERY;}; + s.name=join(text(row,"tr_comp"),text(row,"tr_comp2"));s.street=join(text(row,"ad_street"),text(row,"tr_hsno")); + s.postalCode=text(row,"ad_zipcode");s.city=text(row,"ad_city");s.contact=text(row,"tr_person");s.phone=text(row,"tr_phone");s.note=text(row,"tr_remark"); + s.completedAt=id(row,"tr_status")==1?dateTime(row,"tr_finishtime"):null;s.recipientName=text(row,"tr_signname");return s; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyReadService.java b/backend/src/main/java/de/stadtbote/votian/LegacyReadService.java new file mode 100644 index 0000000..39236e4 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyReadService.java @@ -0,0 +1,104 @@ +package de.stadtbote.votian; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; +import java.util.*; +import static de.stadtbote.votian.Models.*; + +/** SELECT-only adapter. Never writes data or manages the legacy schema. */ +@Service +public class LegacyReadService { + private final DatabaseService database; + private final int maxResults; + public LegacyReadService(DatabaseService database,@Value("${app.max-results:500}") int maxResults) { this.database=database;this.maxResults=Math.max(1,Math.min(maxResults,5000)); } + static final String ORDER_FROM=""" + FROM job jb LEFT JOIN costcenter cc ON cc.csc_id = jb.csc_id + LEFT JOIN costcenter payer ON payer.csc_id=jb.csc_id_payer + LEFT JOIN customer cs ON cs.cs_id = cc.cs_id + LEFT JOIN company cmp ON cmp.cmp_id = cs.cmp_id + """; + static final String ORDER_SELECT=""" + SELECT jb.*, cc.cs_id AS app_customer_id, payer.cs_id AS app_payer_customer_id, cc.csc_name AS app_cost_center, + (SELECT MIN(mt.mt_value) FROM metatype mt WHERE mt.mt_type='vehicletype' AND mt.mt_sort=jb.vht_id) AS vehicle_label, + (SELECT GROUP_CONCAT(DISTINCT mt.mt_value ORDER BY mt.mt_sort SEPARATOR ', ') FROM metatype mt WHERE mt.mt_type='service' AND (jb.jb_service & (1 << mt.mt_sort)) <> 0) AS service_label + """; + public record OrderResult(List orders,long total,int limit) {} + public OrderResult orders(LegacyUser user,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to) { + user.require(4); + if(from!=null && to!=null && from.isAfter(to)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Das Startdatum liegt nach dem Enddatum."); + if(q!=null && !q.isBlank() && q.length()>200) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Suchtext ist zu lang."); + return database.readTransaction(() -> readOrders(user,q,status,customerId,courierId,from,to)); + } + private OrderResult readOrders(LegacyUser user,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to) { + StringBuilder where=new StringBuilder(" WHERE jb.hq_id = ? ");List parameters=new ArrayList<>(List.of(user.headquartersId())); + if(from!=null){where.append(" AND jb.jb_ordertime >= ?");parameters.add(from.atStartOfDay());} + if(to!=null){where.append(" AND jb.jb_ordertime < ?");parameters.add(to.plusDays(1).atStartOfDay());} + if(customerId!=null){where.append(" AND cc.cs_id = ?");parameters.add(customerId);} + if(courierId!=null){where.append(" AND jb.cr_id = ?");parameters.add(courierId);} + if(status!=null) { + if(status==OrderStatus.CANCELLED) where.append(" AND jb.jb_storno IN (1,2,3,4)"); + else { + where.append(" AND COALESCE(jb.jb_storno,0) = 0"); + switch(status) {case DISPOSITION->where.append(" AND jb.jb_status IN (8,9,99)");case OFFERED->where.append(" AND jb.jb_status=0");case IN_PROGRESS->where.append(" AND jb.jb_status=1");case COMPLETED->where.append(" AND jb.jb_status=2");default->throw new IllegalStateException();} + } + } + if(q!=null && !q.isBlank()) { + where.append(" AND (CAST(jb.jb_id AS CHAR) LIKE ? OR jb.jb_commission_no LIKE ? OR cmp.cmp_comp LIKE ? OR EXISTS (SELECT 1 FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=jb.jb_id AND (t.tr_comp LIKE ? OR a.ad_city LIKE ? OR a.ad_street LIKE ?)))"); + String term="%"+q.trim()+"%";for(int i=0;i<6;i++)parameters.add(term); + } + Long total=database.queryForObject("SELECT COUNT(*) "+ORDER_FROM+where,Long.class,parameters.toArray()); + List result=database.queryForList(ORDER_SELECT+ORDER_FROM+where+" ORDER BY jb.jb_ordertime DESC,jb.jb_id DESC LIMIT "+maxResults,parameters.toArray()).stream().map(LegacyMapping::order).toList(); + addStops(result); + return new OrderResult(result,total==null?0:total,maxResults); + } + public DeliveryOrder order(LegacyUser user,long id) { + user.require(4); + return database.readTransaction(() -> readOrder(user,id)); + } + private DeliveryOrder readOrder(LegacyUser user,long id) { + List rows=database.queryForList(ORDER_SELECT+ORDER_FROM+" WHERE jb.jb_id=? AND jb.hq_id=?",id,user.headquartersId()).stream().map(LegacyMapping::order).toList(); + if(rows.isEmpty())throw new ResponseStatusException(HttpStatus.NOT_FOUND,"Auftrag in dieser Niederlassung nicht gefunden."); + addStops(rows);return rows.getFirst(); + } + void addStops(List orders) { + if(orders.isEmpty())return; + Map byId=new HashMap<>();orders.forEach(o->byId.put(o.id,o)); + String placeholders=String.join(",",Collections.nCopies(orders.size(),"?")); + database.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 IN ("+placeholders+") ORDER BY t.jb_id,t.tr_sort,t.tr_id",orders.stream().map(o->o.id).toArray()).forEach(row->{ + DeliveryOrder o=byId.get(LegacyMapping.id(row,"jb_id"));if(o!=null){Stop s=LegacyMapping.stop(row);o.stops.add(s);if(s.recipientName!=null&&!s.recipientName.isBlank())o.recipientName=s.recipientName;} + }); + } + public List customers(LegacyUser user,String q) { + user.require(0); + String sql=""" + SELECT cs.*,cmp.cmp_comp,cmp.cmp_hsno,cmp.cmp_visible,cmp.cmp_authenticated,cmp.cmp_archived, + a.ad_street,a.ad_zipcode,a.ad_city,u.usr_firstname,u.usr_name,u.usr_phone,u.usr_email + FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id + LEFT JOIN address a ON a.ad_id=cmp.ad_id LEFT JOIN employee e ON e.emp_id=cs.cs_admin + LEFT JOIN user u ON u.usr_id=e.usr_id WHERE cs.hq_id=? + """; + List args=new ArrayList<>(List.of(user.headquartersId())); + if(q!=null&&!q.isBlank()){sql+=" AND (cmp.cmp_comp LIKE ? OR cs.cs_eid LIKE ? OR a.ad_city LIKE ?)";String term="%"+q.trim()+"%";args.add(term);args.add(term);args.add(term);} + return database.queryForList(sql+" ORDER BY cmp.cmp_comp LIMIT "+maxResults,args.toArray()).stream().map(LegacyMapping::customer).toList(); + } + public List couriers(LegacyUser user,String q) { + user.require(1); + String sql=""" + SELECT cr.*,cmp.cmp_comp,cmp.cmp_visible,cmp.cmp_authenticated,cmp.cmp_archived, + u.usr_firstname,u.usr_name,u.usr_phone, + (SELECT MIN(mt.mt_value) FROM metatype mt WHERE mt.mt_type='vehicletype' AND mt.mt_sort=cr.vht_id) AS vehicle_label, + (SELECT MIN(cv.crvh_vh_sign) FROM couriervehicle cv WHERE cv.cr_id=cr.cr_id AND cv.crvh_sid=cr.cr_sid) AS crvh_vh_sign, + (SELECT COUNT(*) FROM job jb WHERE jb.cr_id=cr.cr_id AND jb.jb_status=1 AND jb.hq_id=u.hq_id) AS active_jobs + FROM courier cr JOIN user u ON u.usr_id=cr.usr_id JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE u.hq_id=? AND cr.hq_id=u.hq_id + """; + List args=new ArrayList<>(List.of(user.headquartersId())); + if(q!=null&&!q.isBlank()){sql+=" AND (cmp.cmp_comp LIKE ? OR cr.cr_eid LIKE ? OR u.usr_name LIKE ?)";String term="%"+q.trim()+"%";args.add(term);args.add(term);args.add(term);} + return database.queryForList(sql+" ORDER BY cmp.cmp_comp LIMIT "+maxResults,args.toArray()).stream().map(LegacyMapping::courier).toList(); + } + public DatabaseService.ConnectionCheck connectionCheck() { return database.checkConnection(); } + public int maxResults() { return maxResults; } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyUser.java b/backend/src/main/java/de/stadtbote/votian/LegacyUser.java new file mode 100644 index 0000000..aabec26 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyUser.java @@ -0,0 +1,15 @@ +package de.stadtbote.votian; + +import org.springframework.security.access.AccessDeniedException; +import java.security.Principal; +import static de.stadtbote.votian.Models.*; + +/** Scope is the original headquarters. Cross-headquarters and customer access are never widened implicitly. */ +public record LegacyUser(long userId, long employeeId, long headquartersId, String username, String rights) implements Principal { + @Override public String getName() { return username; } + public boolean hasRight(int position) { return rights != null && rights.length() > position && rights.charAt(position) == '1'; } + public void require(int position) { + if (!hasRight(position)) throw new AccessDeniedException("Für diesen Bereich fehlt die Berechtigung im vorhandenen VoTiAn-Konto."); + } + public Capabilities capabilities() { return new Capabilities(hasRight(4),hasRight(0),hasRight(1),false,false,false,false,false,false,hasRight(4) && hasRight(5)); } +} diff --git a/backend/src/main/java/de/stadtbote/votian/Models.java b/backend/src/main/java/de/stadtbote/votian/Models.java new file mode 100644 index 0000000..a39de63 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/Models.java @@ -0,0 +1,60 @@ +package de.stadtbote.votian; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; + +/** API projections only. These classes neither define nor create database tables. */ +public final class Models { + private Models() {} + public enum OrderStatus { DISPOSITION, OFFERED, IN_PROGRESS, COMPLETED, CANCELLED } + public enum StopType { PICKUP, DELIVERY, BOTH } + public enum CourierStatus { AVAILABLE, BUSY, OFFLINE } + public enum CancelMode { FREE, CHARGEABLE } + public static class Customer { + public Long id, version; + public String number, name, street, postalCode, city, contact, phone, email; + public Integer paymentDays; + public boolean active; + } + public static class Courier { + public Long id, version; + public String number, name, phone, vehicle, plate; + public CourierStatus status; + public boolean active; + } + public static class DeliveryOrder { + public Long id, version, customerId, payerCustomerId, courierId, invoiceId; + public String number; + public OrderStatus status; + public int legacyStatus, legacyStorno; + public long legacyService; + public boolean isCreditNote; + public String legacyStatusLabel; + public String service, vehicle; + public LocalDateTime pickupAt, deliveryDeadline; + public String reference, costCenter, notes; + public Integer packages; + public BigDecimal weight, distanceKm, netPrice, courierPrice, vatRate; + public String recipientName; + public LocalDateTime deliveredAt, createdAt, updatedAt; + public CancelMode cancelMode; + public List stops = new ArrayList<>(); + public List history = new ArrayList<>(); + } + public static class Stop { + public Long id; + public int sequence; + public StopType type; + public String name, street, postalCode, city, contact, phone, note; + public LocalDateTime completedAt; + public String recipientName; + } + public static class History { + public Long id; + public LocalDateTime at; + public String action, detail, actor; + } + public record Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices, + boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders) {} +} diff --git a/backend/src/main/java/de/stadtbote/votian/SecurityConfig.java b/backend/src/main/java/de/stadtbote/votian/SecurityConfig.java new file mode 100644 index 0000000..9738ade --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/SecurityConfig.java @@ -0,0 +1,101 @@ +package de.stadtbote.votian; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.csrf.CsrfException; +import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; + +@Configuration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +public class SecurityConfig { + @Bean + SecurityFilterChain security(HttpSecurity http, LegacyAuthenticationProvider provider, ObjectMapper json) throws Exception { + return http.authenticationProvider(provider) + .csrf(csrf -> csrf.csrfTokenRepository(new HttpSessionCsrfTokenRepository())) + .cors(cors -> {}) + // Authorization belongs to any upstream Basic authentication, not to VoTiAn. + .httpBasic(AbstractHttpConfigurer::disable) + .requestCache(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + .sessionFixation(fixation -> fixation.changeSessionId())) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/login", "/api/logout").permitAll() + .requestMatchers("/api/login", "/api/logout").denyAll() + .requestMatchers("/api/health", "/api/system", "/api/csrf", + "/", "/index.html", "/assets/**", "/stadtbote-logo.png", + "/votian-logo.png", "/favicon.ico", "/error").permitAll() + .requestMatchers("/api/**").authenticated() + .anyRequest().denyAll()) + .formLogin(form -> form + .loginPage("/") + .loginProcessingUrl("/api/login") + .successHandler((request, response, authentication) -> { + response.setContentType("application/json;charset=UTF-8"); + json.writeValue(response.getOutputStream(), ApiController.userDetails(authentication)); + }) + .failureHandler((request, response, exception) -> { + boolean unavailable = exception instanceof AuthenticationServiceException; + response.setStatus(unavailable ? 503 : 401); + response.setContentType("application/json;charset=UTF-8"); + json.writeValue(response.getOutputStream(), ApiErrors.body(unavailable + ? ApiErrors.DATABASE_UNAVAILABLE + : "Bitte mit einem bestehenden VoTiAn-Zentralenkonto anmelden. Benutzername oder Passwort ist ungültig.")); + })) + .logout(logout -> logout + .logoutUrl("/api/logout") + .invalidateHttpSession(true) + .clearAuthentication(true) + .deleteCookies("VOTIANSESSION") + .logoutSuccessHandler((request, response, authentication) -> response.setStatus(204))) + .exceptionHandling(errors -> errors + .authenticationEntryPoint((request, response, exception) -> { + response.setStatus(401); + response.setContentType("application/json;charset=UTF-8"); + json.writeValue(response.getOutputStream(), ApiErrors.body("Bitte anmelden. Die Sitzung fehlt oder ist abgelaufen.")); + }) + .accessDeniedHandler((request, response, exception) -> { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + boolean csrf = exception instanceof CsrfException; + var body = new LinkedHashMap<>(ApiErrors.body(csrf + ? "Sicherheitsprüfung fehlgeschlagen. Bitte die Seite neu laden." + : "Für diesen Bereich fehlt die Berechtigung.")); + if (csrf) body.put("code", "CSRF_INVALID"); + json.writeValue(response.getOutputStream(), body); + })) + .build(); + } + + @Bean + CorsConfigurationSource cors(@Value("${app.cors-origins}") String origins) { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(Arrays.stream(origins.split(",")).map(String::trim).toList()); + config.setAllowedMethods(List.of("GET", "POST", "OPTIONS")); + config.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN")); + config.setExposedHeaders(List.of("Content-Disposition", "X-Total-Count", "X-Result-Limit")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + return source; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/VotianApplication.java b/backend/src/main/java/de/stadtbote/votian/VotianApplication.java new file mode 100644 index 0000000..c2cd69d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/VotianApplication.java @@ -0,0 +1,46 @@ +package de.stadtbote.votian; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.stadtbote.votian.database.DatabaseDiagnostics; +import java.util.Arrays; +import org.springframework.boot.Banner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; + +@SpringBootApplication +public class VotianApplication { + public static void main(String[] args) { + if (Arrays.asList(args).contains("--database-check")) { + System.exit(databaseCheck()); + } + SpringApplication.run(VotianApplication.class, args); + } + + private static int databaseCheck() { + SpringApplication application = new SpringApplication(VotianApplication.class); + application.setWebApplicationType(WebApplicationType.NONE); + application.setBannerMode(Banner.Mode.OFF); + application.setLogStartupInfo(false); + DatabaseDiagnostics.Result result; + // Keep stdout machine-readable and prevent JDBC startup errors exposing connection details. + try (ConfigurableApplicationContext context = application.run( + "--spring.main.web-application-type=none", "--spring.main.banner-mode=off", + "--logging.level.root=OFF", "--logging.level.com.zaxxer.hikari=OFF", + "--logging.level.org.mariadb.jdbc=OFF")) { + result = context.getBean(DatabaseDiagnostics.class).check(); + } catch (RuntimeException error) { + result = DatabaseDiagnostics.failure("CONFIGURATION_ERROR", + "Backend-Konfiguration oder Datenbankverbindung prüfen.", null); + } + try { + System.out.println(new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(result.details())); + } catch (JsonProcessingException error) { + System.out.println("{\"connected\":false,\"reason\":\"DIAGNOSTIC_ERROR\",\"message\":\"Diagnose konnte nicht ausgegeben werden.\",\"writes\":0}"); + return 1; + } + return result.exitCode(); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/database/DatabaseDiagnostics.java b/backend/src/main/java/de/stadtbote/votian/database/DatabaseDiagnostics.java new file mode 100644 index 0000000..f6efa71 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/database/DatabaseDiagnostics.java @@ -0,0 +1,95 @@ +package de.stadtbote.votian.database; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; + +/** Checks only connectivity and schema metadata through the shared database service. */ +@Service +public class DatabaseDiagnostics { + private static final Map> REQUIRED = Map.ofEntries( + Map.entry("job", columns("jb_id hq_id csc_id csc_id_payer cr_id jb_status jb_storno jb_version jb_ordertime jb_totalprice jb_cr_price jb_service jb_commission_no vht_id")), + Map.entry("tour", columns("tr_id jb_id ad_id tr_sort tr_ware_from_to tr_status tr_finishtime tr_signname")), + Map.entry("customer", columns("cs_id cmp_id hq_id cs_eid cs_admin")), + Map.entry("company", columns("cmp_id cmp_comp ad_id cmp_hsno cmp_archived cmp_visible cmp_authenticated")), + Map.entry("costcenter", columns("csc_id cs_id csc_name")), + Map.entry("address", columns("ad_id ad_street ad_zipcode ad_city")), + Map.entry("courier", columns("cr_id hq_id usr_id cmp_id cr_sid cr_eid vht_id cr_available")), + Map.entry("couriervehicle", columns("cr_id crvh_sid crvh_vh_sign")), + Map.entry("user", columns("usr_id hq_id usr_account usr_type usr_password usr_password_old usr_firstname usr_name usr_phone usr_email")), + Map.entry("employee", columns("emp_id usr_id emp_rights")), + Map.entry("headquarters", columns("hq_id")), + Map.entry("mandatorheadquarters", columns("hq_id md_id")), + Map.entry("metatype", columns("mt_type mt_sort mt_value"))); + + private final DatabaseService database; + + public DatabaseDiagnostics(DatabaseService database) { + this.database = database; + } + + public record Result(Map details, int exitCode) {} + + public Result check() { + try { + DatabaseService.ConnectionCheck connection = database.checkConnection(); + if (!connection.connected()) { + Integer errorCode = switch (connection.reason()) { + case "AUTHENTICATION_FAILED" -> 1045; + case "DATABASE_ACCESS_DENIED" -> 1044; + default -> null; + }; + return failure(connection.reason(), connection.message(), errorCode); + } + String version = database.queryForObject("SELECT VERSION()", String.class); + List> metadata = database.queryForList( + "SELECT TABLE_NAME AS table_name,COLUMN_NAME AS column_name " + + "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE()"); + Map> actual = new HashMap<>(); + for (Map row : metadata) { + String table = row.get("table_name").toString().toLowerCase(Locale.ROOT); + String column = row.get("column_name").toString().toLowerCase(Locale.ROOT); + actual.computeIfAbsent(table, ignored -> new HashSet<>()).add(column); + } + Map> missing = new LinkedHashMap<>(); + REQUIRED.keySet().stream().sorted().forEach(table -> { + List absent = new ArrayList<>(REQUIRED.get(table)); + absent.removeAll(actual.getOrDefault(table, Set.of())); + absent.sort(String::compareTo); + if (!absent.isEmpty()) missing.put(table, absent); + }); + Map details = new LinkedHashMap<>(); + details.put("connected", true); + details.put("serverVersion", version); + details.put("checkedTables", REQUIRED.size()); + details.put("missingColumns", missing); + details.put("applicationRowsRead", 0); + details.put("writes", 0); + return new Result(details, missing.isEmpty() ? 0 : 1); + } catch (RuntimeException error) { + // JDBC exception text can contain connection details; never expose it. + return failure("UNAVAILABLE", "Datenbankverbindung oder Zugriff auf Schemametadaten prüfen.", null); + } + } + + public static Result failure(String reason, String message, Integer errorCode) { + Map details = new LinkedHashMap<>(); + details.put("connected", false); + details.put("reason", reason); + details.put("errorCode", errorCode); + details.put("message", message); + details.put("applicationRowsRead", 0); + details.put("writes", 0); + return new Result(details, 1); + } + + private static List columns(String columns) { + return List.of(columns.split(" ")); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/database/DatabaseService.java b/backend/src/main/java/de/stadtbote/votian/database/DatabaseService.java new file mode 100644 index 0000000..aac8ba5 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/database/DatabaseService.java @@ -0,0 +1,96 @@ +package de.stadtbote.votian.database; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.support.TransactionTemplate; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +/** The only application component allowed to execute SQL or control database transactions. */ +@Service +public class DatabaseService { + public static final String DATABASE_UNAVAILABLE = "Die vorhandene VoTiAn-Datenbank ist nicht erreichbar oder das Schema konnte nicht gelesen werden. Bitte Verbindung/VPN und DB-Konfiguration prüfen."; + private static final Pattern SELECT = Pattern.compile("^SELECT\\b", Pattern.CASE_INSENSITIVE); + private static final Pattern SIDE_EFFECT = Pattern.compile("\\b(?:INTO|FOR\\s+UPDATE|LOCK\\s+IN\\s+SHARE\\s+MODE)\\b", Pattern.CASE_INSENSITIVE); + private final JdbcTemplate jdbc; + private final TransactionTemplate readTransaction; + + @Autowired + public DatabaseService(JdbcTemplate jdbc, DataSourceTransactionManager transactions) { + this(jdbc, (PlatformTransactionManager)transactions); + // A JDBC read-only hint alone does not enforce MariaDB's transaction access mode. + transactions.setEnforceReadOnly(true); + } + + // Package-private test seam; production injection always uses the enforcing constructor above. + DatabaseService(JdbcTemplate jdbc, PlatformTransactionManager transactions) { + this.jdbc = jdbc; + this.readTransaction = new TransactionTemplate(transactions); + this.readTransaction.setReadOnly(true); + } + + /** SQL is application-owned; values must be supplied as bound parameters. No raw JDBC is exposed. */ + public List> queryForList(String sql, Object... parameters) { + requireReadStatement(sql); + return readTransaction(() -> jdbc.queryForList(sql, parameters)); + } + + public T queryForObject(String sql, Class type, Object... parameters) { + requireReadStatement(sql); + return readTransaction(() -> jdbc.queryForObject(sql, type, parameters)); + } + + /** Groups dependent reads (count, orders and stops) in one database transaction. */ + public T readTransaction(Supplier work) { + try { + return readTransaction.execute(status -> work.get()); + } catch (TransactionException exception) { + throw new DataAccessResourceFailureException(DATABASE_UNAVAILABLE, exception); + } + } + + /** Central entry point for future writes. No incomplete legacy workflow may write today. */ + public int update(String sql, Object... parameters) { + throw new DatabaseWritesDisabledException(); + } + + public record ConnectionCheck(boolean connected, String reason, String message) {} + + public ConnectionCheck checkConnection() { + try { + boolean connected = Objects.equals(queryForObject("SELECT 1", Integer.class), 1); + return new ConnectionCheck(connected, connected ? "CONNECTED" : "UNAVAILABLE", connected + ? "Mit der vorhandenen VoTiAn-Datenbank verbunden. Diese Ausbaustufe liest Bestandsdaten; Erfassung und Änderungen erfolgen im bestehenden VoTiAn." + : DATABASE_UNAVAILABLE); + } catch (DataAccessException exception) { + for (Throwable cause = exception; cause != null; cause = cause.getCause()) { + if (cause instanceof SQLException sql && (sql.getErrorCode() == 1045 || "28000".equals(sql.getSQLState()))) { + return new ConnectionCheck(false, "AUTHENTICATION_FAILED", "Der MySQL-Server ist erreichbar, weist das Datenbankkonto jedoch ab. Bitte Zugangsdaten und Hostfreigabe prüfen (MySQL 1045)."); + } + if (cause instanceof SQLException sql && sql.getErrorCode() == 1044) { + return new ConnectionCheck(false, "DATABASE_ACCESS_DENIED", "Das Datenbankkonto hat keine Zugriffsrechte auf die vorhandene Datenbank (MySQL 1044)."); + } + } + return new ConnectionCheck(false, "UNAVAILABLE", DATABASE_UNAVAILABLE); + } + } + + private static void requireReadStatement(String sql) { + String statement = sql == null ? "" : sql.strip(); + // Guard against accidental use of the read API for writes, not a parser for untrusted SQL. + if (!SELECT.matcher(statement).find() || statement.contains(";") || statement.contains("--") + || statement.contains("/*") || statement.contains("#") || SIDE_EFFECT.matcher(statement).find()) { + throw new IllegalArgumentException("Für Lesezugriffe sind nur einzelne SELECT-Anweisungen ohne Seiteneffekte zulässig."); + } + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/database/DatabaseWritesDisabledException.java b/backend/src/main/java/de/stadtbote/votian/database/DatabaseWritesDisabledException.java new file mode 100644 index 0000000..8a57f7a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/database/DatabaseWritesDisabledException.java @@ -0,0 +1,8 @@ +package de.stadtbote.votian.database; + +/** Business write workflows have not yet been ported; the central database boundary enforces this. */ +public final class DatabaseWritesDisabledException extends UnsupportedOperationException { + public DatabaseWritesDisabledException() { + super("Schreibzugriffe sind in dieser Ausbaustufe gesperrt. Bitte die bestehende VoTiAn-Anwendung verwenden."); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..c565f85 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,47 @@ +server: + address: ${SERVER_ADDRESS:127.0.0.1} + port: ${PORT:8080} + forward-headers-strategy: ${FORWARD_HEADERS_STRATEGY:native} + servlet: + session: + timeout: ${SESSION_TIMEOUT:30m} + tracking-modes: cookie + cookie: + name: VOTIANSESSION + http-only: true + same-site: lax + error: + include-message: never +spring: + application: + name: votian-ng + datasource: + # Only the existing database; never initialize or replace its schema. + url: ${DB_URL:jdbc:mariadb://127.0.0.1:1/unconfigured?connectTimeout=2000&socketTimeout=5000} + username: ${DB_USER:} + password: ${DB_PASSWORD:} + driver-class-name: org.mariadb.jdbc.Driver + hikari: + initialization-fail-timeout: -1 + connection-timeout: 3000 + validation-timeout: 1000 + maximum-pool-size: 4 + minimum-idle: 0 + read-only: true + data-source-properties: + # Preserve numeric legacy codes, including values above 1 in TINYINT(1). + tinyInt1isBit: false + sql: + init: + mode: never + jackson: + serialization: + write-dates-as-timestamps: false +app: + cors-origins: ${CORS_ORIGINS:http://localhost:5173,http://127.0.0.1:5173} + legacy-url: ${LEGACY_APP_URL:} + max-results: ${MAX_RESULTS:500} +logging: + level: + com.zaxxer.hikari: WARN + org.mariadb.jdbc: ERROR diff --git a/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java b/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java new file mode 100644 index 0000000..b7895c9 --- /dev/null +++ b/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java @@ -0,0 +1,191 @@ +package de.stadtbote.votian; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.stadtbote.votian.database.DatabaseService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(properties={"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"}) +@AutoConfigureMockMvc +class ApiSecurityTest { + @Autowired MockMvc mvc; + @Autowired ObjectMapper json; + @MockitoBean DatabaseService database; + + record Csrf(MockHttpSession session, String token, String header) {} + + @BeforeEach void configure() { + when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true,"CONNECTED","Verbunden")); + when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password"))) + .thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","111111111111"))); + } + + Csrf csrf(MockHttpSession session) throws Exception { + var request = get("/api/csrf"); + if (session != null) request.session(session); + MvcResult response = mvc.perform(request).andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", org.hamcrest.Matchers.containsString("no-store"))) + .andReturn(); + var body = json.readTree(response.getResponse().getContentAsString()); + return new Csrf((MockHttpSession) response.getRequest().getSession(false), body.get("token").asText(), body.get("headerName").asText()); + } + + MvcResult login(Csrf csrf, String username, String password, int expectedStatus) throws Exception { + // Represents upstream htaccess authentication: VoTiAn must ignore this header. + return mvc.perform(post("/api/login").session(csrf.session()).header(csrf.header(),csrf.token()) + .with(httpBasic("proxy-fixture","proxy-password")) + .contentType("application/x-www-form-urlencoded").param("username",username).param("password",password)) + .andExpect(status().is(expectedStatus)).andExpect(header().doesNotExist("WWW-Authenticate")).andReturn(); + } + + MockHttpSession authenticated(String username, String password) throws Exception { + return (MockHttpSession) login(csrf(null),username,password,200).getRequest().getSession(false); + } + + @Test void healthAndSystemArePublicButDataRequiresSession() throws Exception { + mvc.perform(get("/api/health").with(httpBasic("proxy-fixture","proxy-password"))) + .andExpect(status().isOk()).andExpect(jsonPath("$.status").value("UP")); + mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.readOnly").value(true)) + .andExpect(jsonPath("$.schemaChanges").value(false)).andExpect(jsonPath("$.capabilities.writeOrders").value(false)); + MvcResult unauthorized = mvc.perform(get("/api/orders")).andExpect(status().isUnauthorized()) + .andExpect(header().doesNotExist("WWW-Authenticate")).andReturn(); + assertThat(unauthorized.getRequest().getSession(false)).isNull(); + Csrf csrf = csrf(null); + mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token()) + .contentType("application/json").content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test void existingLoginPersistsSessionRotatesIdAndStoresNoPassword() throws Exception { + Csrf anonymous = csrf(null); + String previousId = anonymous.session().getId(); + MvcResult login = login(anonymous,"existing-user","existing-password",200); + var body = json.readTree(login.getResponse().getContentAsString()); + assertThat(body.get("username").asText()).isEqualTo("existing-user"); + assertThat(body.has("password")).isFalse(); + MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false); + assertThat(session.getId()).isNotEqualTo(previousId); + SecurityContext context = (SecurityContext) session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); + assertThat(context.getAuthentication().getCredentials()).isNull(); + mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()) + .andExpect(jsonPath("$.headquartersId").value(7)).andExpect(jsonPath("$.roles[0]").value("VIEWER")) + .andExpect(jsonPath("$.password").doesNotExist()).andExpect(jsonPath("$.capabilities.exportOrders").value(true)) + .andExpect(jsonPath("$.capabilities.writeOrders").value(false)); + mvc.perform(get("/api/me").session(session).with(httpBasic("proxy-fixture","proxy-password"))) + .andExpect(status().isOk()).andExpect(jsonPath("$.username").value("existing-user")); + verify(database,times(1)).queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),any(),any(),any()); + } + + @Test void basicCredentialsAloneCannotAuthenticateTheApplication() throws Exception { + mvc.perform(get("/api/me").with(httpBasic("existing-user","existing-password"))) + .andExpect(status().isUnauthorized()).andExpect(header().doesNotExist("WWW-Authenticate")); + verify(database,never()).queryForList(anyString(),any(),any(),any()); + } + + @Test void loginRequiresCsrfAndDoesNotAcceptCredentialsInGetQuery() throws Exception { + mvc.perform(post("/api/login").param("username","existing-user").param("password","existing-password")) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID")); + mvc.perform(get("/api/login").param("username","existing-user").param("password","existing-password")) + .andExpect(status().isUnauthorized()); + verify(database,never()).queryForList(anyString(),any(),any(),any()); + } + + @Test void loginRotatesCsrfAndLogoutInvalidatesSession() throws Exception { + Csrf anonymous = csrf(null); + MockHttpSession session = (MockHttpSession) login(anonymous,"existing-user","existing-password",200).getRequest().getSession(false); + mvc.perform(post("/api/logout").session(session).header(anonymous.header(),anonymous.token())) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID")); + mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()); + Csrf authenticated = csrf(session); + mvc.perform(post("/api/logout").session(session).header(authenticated.header(),authenticated.token())) + .andExpect(status().isNoContent()).andExpect(cookie().maxAge("VOTIANSESSION",0)); + assertThat(session.isInvalid()).isTrue(); + mvc.perform(get("/api/me")).andExpect(status().isUnauthorized()); + Csrf afterLogout = csrf(null); + mvc.perform(post("/api/login").session(afterLogout.session()).header(authenticated.header(),authenticated.token()) + .param("username","existing-user").param("password","existing-password")) + .andExpect(status().isForbidden()); + login(afterLogout,"existing-user","existing-password",200); + } + + @Test void logoutRequiresCsrfAndGetCannotLogOut() throws Exception { + MockHttpSession session = authenticated("existing-user","existing-password"); + mvc.perform(post("/api/logout").session(session)).andExpect(status().isForbidden()); + mvc.perform(get("/api/logout").session(session)).andExpect(status().isForbidden()); + mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()); + } + + @Test void csrfCannotBeReusedAcrossSessions() throws Exception { + Csrf first = csrf(null); + Csrf second = csrf(null); + mvc.perform(post("/api/login").session(second.session()).header(first.header(),first.token()) + .param("username","existing-user").param("password","existing-password")) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID")); + verify(database,never()).queryForList(anyString(),any(),any(),any()); + } + + @Test void noWriteEndpointCanModifyTheDatabase() throws Exception { + MockHttpSession session = authenticated("existing-user","existing-password"); + Csrf csrf = csrf(session); + mvc.perform(post("/api/orders/12/cancel").session(session).header(csrf.header(),csrf.token()) + .contentType("application/json").content("{\"version\":1,\"cancelMode\":\"FREE\",\"reason\":\"test\"}")) + .andExpect(status().isNotImplemented()); + verify(database,never()).update(anyString(),any(Object[].class)); + } + + @Test void backendDatabaseFailureIs503NotFakeLoginOrEmptyDataset() throws Exception { + when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("offline"),eq("password"),eq("password"))) + .thenThrow(new DataAccessResourceFailureException("server secret must not be exposed")); + var result = login(csrf(null),"offline","password",503); + assertThat(json.readTree(result.getResponse().getContentAsString()).get("message").asText()).isEqualTo(ApiErrors.DATABASE_UNAVAILABLE); + assertThat(result.getRequest().getSession(false).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNull(); + when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"UNAVAILABLE",ApiErrors.DATABASE_UNAVAILABLE)); + mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false)); + } + + @Test void wrongOrUnscopedAccountDoesNotAuthenticate() throws Exception { + Csrf wrong = csrf(null); + login(wrong,"unknown","incorrect",401); + mvc.perform(get("/api/me").session(wrong.session())).andExpect(status().isUnauthorized()); + when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("invalid"),eq("password"),eq("password"))) + .thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",0,"usr_account","invalid","emp_rights","111111"))); + login(csrf(null),"invalid","password",401); + } + + @Test void rightsAreEnforcedBeyondAuthentication() throws Exception { + when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("restricted"),eq("password"),eq("password"))) + .thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","restricted","emp_rights","0000000000"))); + MockHttpSession session = authenticated("restricted","password"); + mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.capabilities.exportOrders").value(false)); + mvc.perform(get("/api/orders").session(session)).andExpect(status().isForbidden()); + mvc.perform(get("/api/customers").session(session)).andExpect(status().isForbidden()); + mvc.perform(get("/api/orders/export.csv").session(session)).andExpect(status().isForbidden()); + } + + @Test void databaseAccountDenialIsDistinguishedFromNetworkFailure() throws Exception { + when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"AUTHENTICATION_FAILED","Bitte Hostfreigabe prüfen.")); + mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false)) + .andExpect(jsonPath("$.reason").value("AUTHENTICATION_FAILED")) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Hostfreigabe"))); + } +} diff --git a/backend/src/test/java/de/stadtbote/votian/DatabaseAccessArchitectureTest.java b/backend/src/test/java/de/stadtbote/votian/DatabaseAccessArchitectureTest.java new file mode 100644 index 0000000..8190948 --- /dev/null +++ b/backend/src/test/java/de/stadtbote/votian/DatabaseAccessArchitectureTest.java @@ -0,0 +1,243 @@ +package de.stadtbote.votian; + +import org.junit.jupiter.api.Test; +import org.springframework.asm.AnnotationVisitor; +import org.springframework.asm.ClassReader; +import org.springframework.asm.ClassVisitor; +import org.springframework.asm.ClassWriter; +import org.springframework.asm.ConstantDynamic; +import org.springframework.asm.FieldVisitor; +import org.springframework.asm.Handle; +import org.springframework.asm.MethodVisitor; +import org.springframework.asm.Opcodes; +import org.springframework.asm.RecordComponentVisitor; +import org.springframework.asm.Type; +import net.bytebuddy.jar.asm.signature.SignatureReader; +import net.bytebuddy.jar.asm.signature.SignatureVisitor; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Enforces the database boundary using compiled dependencies, including fully qualified references. */ +class DatabaseAccessArchitectureTest { + private static final String BASE = "de/stadtbote/votian/"; + private static final String SERVICE = BASE + "database/DatabaseService"; + private static final String SERVICE_TEST = BASE + "database/DatabaseServiceTest"; + private static final String THIS_TEST = BASE + "DatabaseAccessArchitectureTest"; + private static final List FORBIDDEN_PREFIXES = List.of( + "java/sql/", "javax/sql/", "org/springframework/jdbc/", + "org/springframework/transaction/", "org/springframework/orm/", + "org/springframework/data/", "jakarta/persistence/", "javax/persistence/", + "org/hibernate/", "com/zaxxer/hikari/", "org/mariadb/jdbc/", "com/mysql/", + "org/postgresql/", "org/h2/", "org/apache/commons/dbcp", "org/apache/tomcat/jdbc/" + ); + // Timestamp is a value already returned by JDBC, not a connection or execution API. + private static final Set ALLOWED_VALUE_TYPES = Set.of("java/sql/Timestamp"); + + @Test void productionDatabaseAccessGoesThroughOneService() throws Exception { + assertNoBypasses(classesDirectory(VotianApplication.class), false); + } + + @Test void testsUseTheSameBoundaryExceptTheServiceOwnUnitTests() throws Exception { + assertNoBypasses(classesDirectory(DatabaseAccessArchitectureTest.class), true); + } + + @Test void bytecodeGuardDetectsConstructorFieldsCallsAndReflectiveTypeNames() { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ForbiddenAccess", null, "java/lang/Object", null); + writer.visitField(Opcodes.ACC_PRIVATE, "template", "Lorg/springframework/jdbc/core/JdbcTemplate;", null, null).visitEnd(); + MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "read", "(Ljavax/sql/DataSource;)V", null, null); + method.visitCode(); + method.visitMethodInsn(Opcodes.INVOKESTATIC, "java/sql/DriverManager", "getConnection", "(Ljava/lang/String;)Ljava/sql/Connection;", false); + method.visitLdcInsn("jakarta.persistence.EntityManager"); + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(2, 2); + method.visitEnd(); + writer.visitEnd(); + var findings = dependencies(writer.toByteArray()); + assertThat(findings).contains("org/springframework/jdbc/core/JdbcTemplate", "javax/sql/DataSource", + "java/sql/DriverManager", "java/sql/Connection", "jakarta/persistence/EntityManager"); + } + + @Test void timestampValueMappingRemainsAllowedButGenericJdbcDependenciesDoNot() { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ValueMapping", null, "java/lang/Object", null); + writer.visitField(Opcodes.ACC_PRIVATE, "timestamp", "Ljava/sql/Timestamp;", null, null).visitEnd(); + writer.visitEnd(); + assertThat(dependencies(writer.toByteArray())).isEmpty(); + + writer = new ClassWriter(0); + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "GenericBypass", null, "java/lang/Object", null); + writer.visitField(Opcodes.ACC_PRIVATE, "connections", "Ljava/util/List;", "Ljava/util/List;", null).visitEnd(); + writer.visitEnd(); + assertThat(dependencies(writer.toByteArray())).containsExactly("java/sql/Connection"); + } + + @Test void pythonLaunchersDoNotOpenTheirOwnDatabaseConnections() throws Exception { + Path scripts = projectRoot().resolve("scripts"); + assertThat(scripts).isDirectory(); + // Parse source only: do not execute/import launchers or read PHP/local connection files. + String checker = """ + import ast, pathlib, sys + drivers = {'pymysql', 'MySQLdb', 'mysql', 'mariadb', 'psycopg', 'psycopg2', + 'sqlite3', 'aiosqlite', 'sqlalchemy', 'asyncpg', 'pg8000', 'pyodbc', + 'jaydebeapi', 'adodbapi', 'peewee', 'tortoise', 'databases', 'duckdb'} + db_commands = {'mysql', 'mariadb', 'psql', 'sqlite3', 'sqlcmd', 'isql'} + def forbidden(module): + return bool(module) and module.split('.')[0] in drivers + def violations(source): + problems = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): + problems += [(node.lineno, item.name) for item in node.names if forbidden(item.name)] + elif isinstance(node, ast.ImportFrom) and forbidden(node.module): + problems.append((node.lineno, node.module)) + elif isinstance(node, ast.Call) and node.args: + function = node.func.id if isinstance(node.func, ast.Name) else node.func.attr if isinstance(node.func, ast.Attribute) else '' + first = node.args[0] + if function in {'__import__', 'import_module'} and isinstance(first, ast.Constant) and isinstance(first.value, str) and forbidden(first.value): + problems.append((node.lineno, first.value)) + elif function in {'run', 'Popen', 'call', 'check_call', 'check_output', 'system', 'execvp', 'execvpe'}: + command = first.elts[0] if isinstance(first, (ast.List, ast.Tuple)) and first.elts else first + if isinstance(command, ast.Constant) and isinstance(command.value, str): + executable = pathlib.Path(command.value.split()[0]).name if command.value.split() else '' + if executable in db_commands: + problems.append((node.lineno, executable)) + return problems + assert violations('import pymysql as database') + assert violations('from mysql import connector') + assert violations("importlib.import_module('mariadb')") + assert violations("subprocess.run(['mysql', '--version'])") + assert not violations("import json; subprocess.run(['java', '-jar', 'app.jar'])") + findings = [] + for path in sorted(pathlib.Path(sys.argv[1]).rglob('*.py')): + for line, name in violations(path.read_text(encoding='utf-8')): + findings.append(f'{path.name}:{line}: direct database dependency {name}') + print('\\n'.join(findings)) + sys.exit(1 if findings else 0) + """; + Process process = new ProcessBuilder("python3", "-c", checker, scripts.toString()).redirectErrorStream(true).start(); + boolean finished = process.waitFor(30, TimeUnit.SECONDS); + if (!finished) process.destroyForcibly(); + assertThat(finished).as("Python AST architecture check must terminate without starting the application").isTrue(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertThat(process.exitValue()).as("Python launchers must delegate database work to the Java DatabaseService:%n%s", output).isZero(); + } + + private static Path projectRoot() { + Path directory = Path.of("").toAbsolutePath().normalize(); + for (int level = 0; directory != null && level < 5; level++, directory = directory.getParent()) { + if (Files.isDirectory(directory.resolve("backend")) && Files.isDirectory(directory.resolve("scripts"))) return directory; + } + throw new AssertionError("Cannot locate the project scripts directory from Maven working directory"); + } + + private static Path classesDirectory(Class anchor) throws Exception { + Path classes = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI()); + assertThat(classes).as("Architecture check expects compiled class directories").isDirectory(); + return classes; + } + + private static boolean belongsTo(String name, String owner) { return name.equals(owner) || name.startsWith(owner + "$"); } + + private static void assertNoBypasses(Path classes, boolean tests) throws IOException { + List violations = new ArrayList<>(); + if (!tests) assertThat(classes.resolve(SERVICE + ".class")).as("The central database service must exist").isRegularFile(); + try (var paths = Files.walk(classes)) { + for (Path path : paths.filter(p -> p.toString().endsWith(".class")).sorted().toList()) { + byte[] bytes = Files.readAllBytes(path); + String name = new ClassReader(bytes).getClassName(); + boolean exempt = belongsTo(name, SERVICE) || (tests && (belongsTo(name, SERVICE_TEST) || belongsTo(name, THIS_TEST))); + if (!exempt) for (String dependency : dependencies(bytes)) violations.add(name.replace('/', '.') + " -> " + dependency.replace('/', '.')); + } + } + assertThat(violations).as("Every DB-access type must remain inside DatabaseService (only DatabaseServiceTest may mock raw JDBC)").isEmpty(); + } + + private static Set dependencies(byte[] bytes) { + Set forbidden = new TreeSet<>(); + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + void name(String name) { + if (name == null) return; + if (name.startsWith("[")) { descriptor(name); return; } + if (!ALLOWED_VALUE_TYPES.contains(name) && FORBIDDEN_PREFIXES.stream().anyMatch(name::startsWith)) forbidden.add(name); + } + void type(Type type) { + switch (type.getSort()) { + case Type.OBJECT -> name(type.getInternalName()); + case Type.ARRAY -> type(type.getElementType()); + case Type.METHOD -> { type(type.getReturnType()); for (Type arg : type.getArgumentTypes()) type(arg); } + default -> { } + } + } + void descriptor(String descriptor) { if (descriptor != null) type(Type.getType(descriptor)); } + void signature(String signature, boolean field) { + if (signature == null) return; + var visitor = new SignatureVisitor(Opcodes.ASM9) { + @Override public void visitClassType(String name) { name(name); } + }; + if (field) new SignatureReader(signature).acceptType(visitor); else new SignatureReader(signature).accept(visitor); + } + void handle(Handle handle) { name(handle.getOwner()); descriptor(handle.getDesc()); } + void constant(Object value) { + if (value instanceof Type type) type(type); + else if (value instanceof Handle handle) handle(handle); + else if (value instanceof ConstantDynamic dynamic) { + descriptor(dynamic.getDescriptor());handle(dynamic.getBootstrapMethod()); + for (int i=0;i(Map.of("cs_id",1,"cmp_archived",false,"cmp_visible",true,"cmp_authenticated",true)); + assertThat(LegacyMapping.customer(row).active).isTrue(); + row.put("cmp_archived",true); + assertThat(LegacyMapping.customer(row).active).isFalse(); + row.put("cmp_archived",false);row.put("cmp_visible",false); + assertThat(LegacyMapping.customer(row).active).isFalse(); + row.put("cmp_visible",true);row.put("cmp_authenticated",false); + assertThat(LegacyMapping.customer(row).active).isFalse(); + } + @Test void mapsBooleanCompanyFlagsAndAvailabilityForCouriers() { + var row=new HashMap(Map.of("cr_id",1,"cmp_archived",false,"cmp_visible",true,"cmp_authenticated",true,"cr_available",true)); + var courier=LegacyMapping.courier(row); + assertThat(courier.active).isTrue(); + assertThat(courier.status).isEqualTo(CourierStatus.AVAILABLE); + row.put("cr_available",false); + assertThat(LegacyMapping.courier(row).status).isEqualTo(CourierStatus.OFFLINE); + row.put("active_jobs",1); + assertThat(LegacyMapping.courier(row).status).isEqualTo(CourierStatus.BUSY); + row.put("cmp_archived",true); + courier=LegacyMapping.courier(row); + assertThat(courier.active).isFalse(); + assertThat(courier.status).isEqualTo(CourierStatus.OFFLINE); + row.put("cmp_archived",false);row.put("cmp_visible",false); + assertThat(LegacyMapping.courier(row).active).isFalse(); + row.put("cmp_visible",true);row.put("cmp_authenticated",false); + assertThat(LegacyMapping.courier(row).active).isFalse(); + } + @Test void preservesNumericStatusCancellationAndWarehouseDirectionValues() { + var row=new HashMap(Map.of("jb_id",1,"jb_status",2,"jb_storno",3)); + var order=LegacyMapping.order(row); + assertThat(order.legacyStatus).isEqualTo(2); + assertThat(order.legacyStorno).isEqualTo(3); + assertThat(order.cancelMode).isEqualTo(CancelMode.CHARGEABLE); + assertThat(order.isCreditNote).isTrue(); + row.put("jb_storno",4); + order=LegacyMapping.order(row); + assertThat(order.legacyStatus).isEqualTo(2); + assertThat(order.legacyStorno).isEqualTo(4); + assertThat(order.cancelMode).isEqualTo(CancelMode.FREE); + assertThat(order.isCreditNote).isTrue(); + row.put("jb_storno",0);row.put("jb_status",9); + assertThat(LegacyMapping.order(row).legacyStatus).isEqualTo(9); + assertThat(LegacyMapping.order(row).status).isEqualTo(OrderStatus.DISPOSITION); + assertThat(LegacyMapping.stop(Map.of("tr_id",1,"tr_sort",2,"tr_ware_from_to",3)).type).isEqualTo(StopType.BOTH); + } + @Test void mapsLegacyStatusesWithoutLosingAutorankingOrCreditNote() { + var row=new HashMap(Map.of("jb_id",12,"jb_status",8,"jb_storno",0,"jb_totalprice",new BigDecimal("119.42"),"jb_subtotalprice",new BigDecimal("110.00"),"app_customer_id",15,"app_payer_customer_id",19,"jb_service",5)); + var order=LegacyMapping.order(row); + assertThat(order.status).isEqualTo(OrderStatus.DISPOSITION); + assertThat(order.legacyStatusLabel).isEqualTo("Autoranking"); + assertThat(order.netPrice).isEqualByComparingTo("119.42"); + assertThat(order.customerId).isEqualTo(15L); + assertThat(order.payerCustomerId).isEqualTo(19L); + assertThat(order.legacyService).isEqualTo(5); + row.put("jb_storno",3);row.put("jb_status",2); + order=LegacyMapping.order(row); + assertThat(order.isCreditNote).isTrue(); + assertThat(order.cancelMode).isEqualTo(CancelMode.CHARGEABLE); + assertThat(order.legacyStatusLabel).contains("Storno-Gutschrift"); + } + @Test void respectsExplicitWarehouseDirectionBeforeStationPosition() { + var row=new HashMap(Map.of("tr_id",1,"tr_sort",3,"tr_ware_from_to",1)); + assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.PICKUP); + row.put("tr_ware_from_to",3); + assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.BOTH); + row.put("tr_ware_from_to",2);row.put("tr_sort",1); + assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.DELIVERY); + row.put("tr_ware_from_to",0); + assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.PICKUP); + } + @Test void missingValuesAndMysqlZeroDatesRemainUnknown() { + var order=LegacyMapping.order(Map.of("jb_id",7,"jb_ordertime","0000-00-00 00:00:00","jb_modify","20261001123159")); + assertThat(order.pickupAt).isNull(); + assertThat(order.netPrice).isNull(); + assertThat(order.distanceKm).isNull(); + assertThat(order.packages).isNull(); + assertThat(order.updatedAt.toString()).isEqualTo("2026-10-01T12:31:59"); + } + @Test void csvEscapesFormulaAndQuotes() { + assertThat(ApiController.csvCell("=HYPERLINK(\"test\")")).isEqualTo("\"'=HYPERLINK(\"\"test\"\")\""); + assertThat(ApiController.csvCell(null)).isEqualTo("\"\""); + } + @Test void doesNotPublishCredentialsFromLegacyUrl() { + assertThatThrownBy(()->ApiController.safeLegacyUrl("https://user:secret@example.org/votian")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(()->ApiController.safeLegacyUrl("javascript:alert(1)")).isInstanceOf(IllegalArgumentException.class); + assertThat(ApiController.safeLegacyUrl("https://example.org/votian/")).isEqualTo("https://example.org/votian"); + } +} diff --git a/backend/src/test/java/de/stadtbote/votian/LegacyReadServiceTest.java b/backend/src/test/java/de/stadtbote/votian/LegacyReadServiceTest.java new file mode 100644 index 0000000..73036c7 --- /dev/null +++ b/backend/src/test/java/de/stadtbote/votian/LegacyReadServiceTest.java @@ -0,0 +1,60 @@ +package de.stadtbote.votian; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import java.util.function.Supplier; +import org.mockito.ArgumentCaptor; +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class LegacyReadServiceTest { + final DatabaseService database=mock(DatabaseService.class); + final LegacyReadService service=new LegacyReadService(database,500); + final LegacyUser allowed=new LegacyUser(12,13,7,"disponent","111111111111"); + @BeforeEach void configureTransactions() { + when(database.readTransaction(any())).thenAnswer(call -> ((Supplier)call.getArgument(0)).get()); + } + @Test void refusesUnpermittedDataWithoutTouchingDatabase() { + var denied=new LegacyUser(1,2,7,"restricted","0000000000"); + assertThatThrownBy(()->service.orders(denied,null,null,null,null,null,null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->service.customers(denied,null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->service.couriers(denied,null)).isInstanceOf(AccessDeniedException.class); + verifyNoInteractions(database); + } + @Test void parameterizesScopeAndSearchAndLimitsOnlyAfterSqlFilters() { + when(database.queryForObject(anyString(),eq(Long.class),any(Object[].class))).thenReturn(0L); + when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of()); + service.orders(allowed,"' OR 1=1 --",null,23L,42L,LocalDate.of(2026,10,1),LocalDate.of(2026,10,5)); + ArgumentCaptor sql=ArgumentCaptor.forClass(String.class); + ArgumentCaptor args=ArgumentCaptor.forClass(Object[].class); + verify(database).queryForList(sql.capture(),args.capture()); + assertThat(sql.getValue()).contains("jb.hq_id = ?","cc.cs_id = ?","jb.cr_id = ?","LIMIT 500").doesNotContain("OR 1=1"); + assertThat(args.getValue()[0]).isEqualTo(7L); + assertThat(Arrays.asList(args.getValue())).contains("%' OR 1=1 --%"); + } + @Test void singleOrderCannotEscapeHeadquarters() { + when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of()); + assertThatThrownBy(()->service.order(allowed,999)).isInstanceOf(ResponseStatusException.class); + verify(database).queryForList(contains("WHERE jb.jb_id=? AND jb.hq_id=?"),eq(999L),eq(7L)); + } + @Test void customerAndCourierQueriesRemainScopedToHomeBranch() { + when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of()); + service.customers(allowed,null);service.couriers(allowed,null); + verify(database).queryForList(contains("WHERE cs.hq_id=?"),eq(7L)); + verify(database).queryForList(contains("WHERE u.hq_id=? AND cr.hq_id=u.hq_id"),eq(7L)); + } + @Test void invalidDateRangeNeverHitsDatabase() { + assertThatThrownBy(()->service.orders(allowed,null,null,null,null,LocalDate.of(2026,10,5),LocalDate.of(2026,10,1))).isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(database); + } + @Test void invalidSearchIsRejectedBeforeOpeningATransaction() { + assertThatThrownBy(()->service.orders(allowed,"x".repeat(201),null,null,null,null,null)).isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(database); + } +} diff --git a/backend/src/test/java/de/stadtbote/votian/database/DatabaseDiagnosticsTest.java b/backend/src/test/java/de/stadtbote/votian/database/DatabaseDiagnosticsTest.java new file mode 100644 index 0000000..466388a --- /dev/null +++ b/backend/src/test/java/de/stadtbote/votian/database/DatabaseDiagnosticsTest.java @@ -0,0 +1,62 @@ +package de.stadtbote.votian.database; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class DatabaseDiagnosticsTest { + private final DatabaseService database = mock(DatabaseService.class); + private final DatabaseDiagnostics diagnostics = new DatabaseDiagnostics(database); + + @Test void rejectedConnectionDoesNotReadMetadata() { + when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck( + false, "AUTHENTICATION_FAILED", "Anmeldung abgewiesen.")); + + DatabaseDiagnostics.Result result = diagnostics.check(); + + assertThat(result.exitCode()).isEqualTo(1); + assertThat(result.details()).containsEntry("connected", false).containsEntry("errorCode", 1045) + .containsEntry("applicationRowsRead", 0).containsEntry("writes", 0); + verify(database).checkConnection(); + verifyNoMoreInteractions(database); + } + + @Test void reportsMissingColumnsWithoutQueryingApplicationTables() { + when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true, "CONNECTED", "Verbunden.")); + when(database.queryForObject("SELECT VERSION()", String.class)).thenReturn("test-server"); + String metadataSql = "SELECT TABLE_NAME AS table_name,COLUMN_NAME AS column_name " + + "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE()"; + when(database.queryForList(metadataSql)).thenReturn(List.of( + Map.of("table_name", "headquarters", "column_name", "hq_id"), + Map.of("table_name", "job", "column_name", "jb_id"))); + + DatabaseDiagnostics.Result result = diagnostics.check(); + + assertThat(result.exitCode()).isEqualTo(1); + assertThat(result.details()).containsEntry("connected", true).containsEntry("serverVersion", "test-server") + .containsEntry("checkedTables", 13).containsEntry("applicationRowsRead", 0).containsEntry("writes", 0); + @SuppressWarnings("unchecked") + Map> missing = (Map>) result.details().get("missingColumns"); + assertThat(missing).doesNotContainKey("headquarters"); + assertThat(missing.get("job")).contains("jb_totalprice", "csc_id_payer").doesNotContain("jb_id"); + verify(database).checkConnection(); + verify(database).queryForObject("SELECT VERSION()", String.class); + verify(database).queryForList(metadataSql); + verifyNoMoreInteractions(database); + } + + @Test void neverReturnsJdbcExceptionDetails() { + when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true, "CONNECTED", "Verbunden.")); + when(database.queryForObject("SELECT VERSION()", String.class)) + .thenThrow(new IllegalStateException("jdbc:vendor://private-host password=secret")); + + DatabaseDiagnostics.Result result = diagnostics.check(); + + assertThat(result.exitCode()).isEqualTo(1); + assertThat(result.details()).containsEntry("connected", false).containsEntry("reason", "UNAVAILABLE") + .containsEntry("writes", 0); + assertThat(result.details().toString()).doesNotContain("private-host", "secret", "jdbc:"); + } +} diff --git a/backend/src/test/java/de/stadtbote/votian/database/DatabaseServiceTest.java b/backend/src/test/java/de/stadtbote/votian/database/DatabaseServiceTest.java new file mode 100644 index 0000000..ce7a2ff --- /dev/null +++ b/backend/src/test/java/de/stadtbote/votian/database/DatabaseServiceTest.java @@ -0,0 +1,119 @@ +package de.stadtbote.votian.database; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.CannotGetJdbcConnectionException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.CannotCreateTransactionException; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.SimpleTransactionStatus; +import java.sql.SQLException; +import java.sql.Connection; +import java.sql.Statement; +import javax.sql.DataSource; +import java.util.List; +import java.util.Map; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class DatabaseServiceTest { + final JdbcTemplate jdbc=mock(JdbcTemplate.class); + final PlatformTransactionManager transactions=mock(PlatformTransactionManager.class); + final SimpleTransactionStatus transaction=new SimpleTransactionStatus(); + final DatabaseService database=new DatabaseService(jdbc,transactions); + + DatabaseServiceTest() { + when(transactions.getTransaction(any())).thenReturn(transaction); + clearInvocations(transactions); + } + + @Test void enforcesReadOnlyOnTheServerBeforeExecutingQueries() throws Exception { + var source=mock(DataSource.class); + var connection=mock(Connection.class); + var statement=mock(Statement.class); + when(source.getConnection()).thenReturn(connection); + when(connection.getAutoCommit()).thenReturn(true); + when(connection.createStatement()).thenReturn(statement); + var manager=new DataSourceTransactionManager(source); + var service=new DatabaseService(jdbc,manager); + when(jdbc.queryForObject("SELECT 1",Integer.class,new Object[0])).thenReturn(1); + + assertThat(service.queryForObject("SELECT 1",Integer.class)).isEqualTo(1); + assertThat(manager.isEnforceReadOnly()).isTrue(); + var order=inOrder(statement,jdbc,connection); + order.verify(statement).executeUpdate("SET TRANSACTION READ ONLY"); + order.verify(jdbc).queryForObject("SELECT 1",Integer.class,new Object[0]); + order.verify(connection).commit(); + verify(connection).close(); + } + + @Test void bindsValuesWithoutInsertingThemIntoSqlAndUsesReadOnlyTransactions() { + String sql="SELECT jb_id FROM job WHERE jb_commission_no=? AND jb_storno=?"; + Object[] values={"'; DELETE FROM job; --",null}; + var rows=List.of(Map.of("jb_id",17)); + when(jdbc.queryForList(sql,values)).thenReturn(rows); + assertThat(database.queryForList(sql,values)).isSameAs(rows); + verify(jdbc).queryForList(sql,values); + var definition=ArgumentCaptor.forClass(TransactionDefinition.class); + verify(transactions).getTransaction(definition.capture()); + assertThat(definition.getValue().isReadOnly()).isTrue(); + verify(transactions).commit(transaction); + } + + @Test void typedQueriesPreserveNullResultsAndBoundParameters() { + when(jdbc.queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L)).thenReturn(null); + assertThat(database.queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L)).isNull(); + verify(jdbc).queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L); + } + + @ParameterizedTest + @ValueSource(strings={"UPDATE job SET jb_status=2", "INSERT INTO job VALUES (?)", "DELETE FROM job", "CREATE TABLE test(id INT)", "CALL procedure_name()", "SET autocommit=1", "SELECT 1; DELETE FROM job", "SELECT 1 INTO OUTFILE '/tmp/data'", "SELECT jb_id FROM job FOR UPDATE", "/* read */ DELETE FROM job", "SELECT 1 -- comment"}) + void rejectsNonReadStatementsBeforeJdbcOrTransactions(String sql) { + assertThatIllegalArgumentException().isThrownBy(()->database.queryForList(sql)); + assertThatIllegalArgumentException().isThrownBy(()->database.queryForObject(sql,Integer.class)); + verifyNoInteractions(jdbc,transactions); + } + + @Test void writeEntryPointIsClosedBeforeOpeningAnyConnection() { + assertThatThrownBy(()->database.update("UPDATE job SET jb_status=? WHERE jb_id=?",2,17)) + .isInstanceOf(DatabaseWritesDisabledException.class); + verifyNoInteractions(jdbc,transactions); + } + + @Test void failedQueriesRollbackAndDoNotReturnAnEmptyReplacement() { + var failure=new DataAccessResourceFailureException("private database detail"); + when(jdbc.queryForList("SELECT jb_id FROM job",new Object[0])).thenThrow(failure); + assertThatThrownBy(()->database.queryForList("SELECT jb_id FROM job")).isSameAs(failure); + verify(transactions).rollback(transaction); + verify(transactions,never()).commit(any()); + } + + @Test void transactionConnectionFailuresUseTheSameDatabaseErrorContract() { + when(transactions.getTransaction(any())).thenThrow(new CannotCreateTransactionException("private details")); + assertThatThrownBy(()->database.queryForObject("SELECT 1",Integer.class)) + .isInstanceOf(DataAccessResourceFailureException.class) + .hasMessage(DatabaseService.DATABASE_UNAVAILABLE); + } + + @Test void connectionCheckRecognizesLoginDenialInsideTransactionFailure() { + when(transactions.getTransaction(any())).thenThrow(new CannotCreateTransactionException("private detail",new SQLException("password must not be exposed","28000",1045))); + var result=database.checkConnection(); + assertThat(result.connected()).isFalse(); + assertThat(result.reason()).isEqualTo("AUTHENTICATION_FAILED"); + assertThat(result.message()).doesNotContain("password must not be exposed"); + } + + @Test void connectionCheckDistinguishesMissingDatabaseRightsAndNetworkFailure() { + when(jdbc.queryForObject("SELECT 1",Integer.class,new Object[0])) + .thenThrow(new CannotGetJdbcConnectionException("private",new SQLException("private","42000",1044))) + .thenThrow(new CannotGetJdbcConnectionException("private")); + assertThat(database.checkConnection().reason()).isEqualTo("DATABASE_ACCESS_DENIED"); + assertThat(database.checkConnection().reason()).isEqualTo("UNAVAILABLE"); + } +} diff --git a/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..2d55040 --- /dev/null +++ b/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT/frontend" +npm ci +npm run build +cd "$ROOT/backend" +./mvnw clean verify +echo 'Build und Tests abgeschlossen. Start mit Datenbankkonfiguration aus .env: ./run.sh' diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..4cbde71 --- /dev/null +++ b/dev.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" +command -v java >/dev/null || { echo 'Java 21 wird benötigt.'; exit 1; } +command -v npm >/dev/null || { echo 'Node.js 22.12+ und npm werden benötigt.'; exit 1; } +mkdir -p work +if [[ ! -d frontend/node_modules ]]; then + (cd frontend && npm ci) +fi +for port in 8080 5173; do + if command -v lsof >/dev/null && lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then + echo "Port $port ist bereits belegt. Bitte die dort laufende Anwendung zuerst beenden." + exit 1 + fi +done +BACKEND_PID='' +FRONTEND_PID='' +cleanup() { + [[ -z "$FRONTEND_PID" ]] || kill "$FRONTEND_PID" 2>/dev/null || true + [[ -z "$BACKEND_PID" ]] || kill "$BACKEND_PID" 2>/dev/null || true +} +trap cleanup EXIT INT TERM +echo 'Backend wird gebaut …' +(cd backend && ./mvnw -q -DskipTests package) +python3 scripts/run-backend.py > work/backend.log 2>&1 & +BACKEND_PID=$! +echo 'Spring Boot startet. Protokoll: work/backend.log' +READY=false +for attempt in {1..120}; do + if curl --fail --silent http://127.0.0.1:8080/api/health >/dev/null 2>&1; then READY=true; break; fi + if ! kill -0 "$BACKEND_PID" 2>/dev/null; then cat work/backend.log; exit 1; fi + sleep 1 +done +if [[ "$READY" != true ]]; then echo 'Backend-Start dauert zu lange. Siehe work/backend.log'; exit 1; fi +(cd frontend && exec node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5173 --strictPort) & +FRONTEND_PID=$! +echo 'VoTiAn NG: http://localhost:5173 — Beenden mit Strg+C' +echo 'Anmeldung mit einem vorhandenen VoTiAn-Zentralenbenutzer.' +while kill -0 "$BACKEND_PID" 2>/dev/null && kill -0 "$FRONTEND_PID" 2>/dev/null; do sleep 2; done diff --git a/docker_push.sh b/docker_push.sh new file mode 100755 index 0000000..41a6c28 --- /dev/null +++ b/docker_push.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly REGISTRY_IMAGE="${REGISTRY_IMAGE:-registry.assecutor.org/votianng}" +readonly BACKEND_DIR="${SCRIPT_DIR}/backend" + +usage() { + cat <<'EOF' +Verwendung: + ./docker_push.sh [tag] + +Beispiel: + ./docker_push.sh 0.1 + ./docker_push.sh + +Voraussetzungen: + - Java 21 sowie Node.js 22.12 oder neuer mit npm + - Docker läuft und Docker Buildx ist installiert + - Login zur Registry wurde bereits ausgeführt: + docker login registry.assecutor.org + +Ohne Tag wird die Version aus backend/pom.xml verwendet (auch SNAPSHOT-Versionen). +Ein expliziter Tag ändert nur den Image-Tag, nicht die Maven-Projektversion. +REGISTRY_IMAGE überschreibt das Standardziel registry.assecutor.org/votianng. +Gebaut werden React und das Backend einschließlich Tests für linux/amd64. +EOF +} + +fail() { + echo "Fehler: $*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "'$1' wurde nicht gefunden." +} + +resolve_pom_value() { + local value + value="$( + cd "${BACKEND_DIR}" && ./mvnw -q -Dstyle.color=never -DforceStdout help:evaluate -Dexpression="$1" \ + | sed $'s/\033\\[[0-9;]*m//g' \ + | awk 'NF { last = $0 } END { print last }' + )" || fail "Maven-Wert '$1' konnte nicht ermittelt werden." + + [[ -n "${value}" && "${value}" != *"null object or invalid expression"* ]] \ + || fail "Maven-Wert '$1' ist leer oder ungültig." + printf '%s\n' "${value}" +} + +[[ $# -le 1 ]] || fail "Erwartet wird höchstens ein Image-Tag. Siehe --help." + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +require_command java +require_command npm +require_command docker +[[ -x "${BACKEND_DIR}/mvnw" ]] || fail "'${BACKEND_DIR}/mvnw' wurde nicht gefunden oder ist nicht ausführbar." +[[ -x "${SCRIPT_DIR}/build.sh" ]] || fail "'${SCRIPT_DIR}/build.sh' wurde nicht gefunden oder ist nicht ausführbar." +[[ -f "${SCRIPT_DIR}/Dockerfile" ]] || fail "'${SCRIPT_DIR}/Dockerfile' wurde nicht gefunden." + +VERSION="${1-$(resolve_pom_value project.version)}" +if [[ ! "${VERSION}" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ ]]; then + fail "Ungültiger Image-Tag: erlaubt sind maximal 128 Zeichen aus Buchstaben, Ziffern, _, . und -; am Anfang weder . noch -." +fi + +docker buildx version >/dev/null 2>&1 || fail "Docker Buildx ist nicht verfügbar." +docker info >/dev/null 2>&1 || fail "Docker-Daemon ist nicht erreichbar. Bitte Docker starten." + +JAR_FILE_REL="backend/target/$(resolve_pom_value project.build.finalName).jar" +JAR_FILE_ABS="${SCRIPT_DIR}/${JAR_FILE_REL}" + +echo "Verwende Image-Tag ${VERSION}." +echo "Baue React-Frontend und Spring-Boot-Backend einschließlich Tests ..." +"${SCRIPT_DIR}/build.sh" + +[[ -f "${JAR_FILE_ABS}" ]] || fail "Backend-JAR wurde nicht gefunden: ${JAR_FILE_ABS}" + +echo "Pushe Image ${REGISTRY_IMAGE}:${VERSION} ..." +docker buildx build \ + --platform linux/amd64 \ + -f "${SCRIPT_DIR}/Dockerfile" \ + --build-arg "JAR_FILE=${JAR_FILE_REL}" \ + -t "${REGISTRY_IMAGE}:${VERSION}" \ + --push \ + "${SCRIPT_DIR}" + +echo "Fertig: ${REGISTRY_IMAGE}:${VERSION}" diff --git a/docs/ALTSYSTEM-ANALYSE.md b/docs/ALTSYSTEM-ANALYSE.md new file mode 100644 index 0000000..01c47e5 --- /dev/null +++ b/docs/ALTSYSTEM-ANALYSE.md @@ -0,0 +1,376 @@ +# Analyse des PHP-Altsystems votian + +Stand: 01.10.2026. Auftraggeberkontext: Stadtbote GmbH. Grundlage ist eine statische, lesende Analyse des Quellbestands im Verzeichnis `votian/`. Es wurden keine produktiven Datenbanken, Fahrergeräte oder externen Dienste angesprochen. Dieser Bericht enthält keine produktiven Kundendatensätze oder Zugangsdaten. + +**Verbindliche Datenbankvorgabe:** Die Neuentwicklung verwendet die bereits vorhandene MySQL-Datenbank und deren bestehendes Schema. Es wird keine eigene Datenbank angelegt und keine Schemaänderung vorgenommen. DDL, automatische Schemaerzeugung, Schemaaktualisierung und ungefragte Initialbefüllung sind ausgeschlossen. + +Die Neuentwicklung soll die tägliche Auftragserfassung und Disposition in React und Spring Boot abbilden. Der Bestand umfasst jedoch zusätzlich eine umfangreiche Tarif-, Berechtigungs-, Abrechnungs- und Integrationslogik. Die nachfolgend als **Ziel erster Umsetzung** bezeichneten Funktionen sind ein geplanter Umfang und kein Nachweis ihres Implementierungsstands. Vollständige fachliche Parität oder Produktionsreife wird mit diesem Bericht nicht behauptet. + +**Aktuelle Umsetzungsentscheidung:** Die erste Anbindung soll über lesende JDBC-Adapter auf das unveränderte Bestandsschema, vorhandene Benutzerkonten und Verweise auf bestehende PHP-Aktionen erfolgen. Das betrifft insbesondere Erfassung, Bearbeitung und weitere schreibende Geschäftsprozesse, deren Regeln nicht ungeprüft durch direkte SQL-Schreibzugriffe ersetzt werden. Ein PHP-Verweis ist keine React-/Spring-Portierung dieses Ablaufs. Wegen des derzeit nicht nachgewiesenen Datenbankzugriffs bleiben Live-Funktion, Spaltenverträglichkeit, Authentisierung und vollständige Portierung offen; dieser Bericht weist sie nicht als getestet aus. + +## 1. Quellen und Aussagekraft + +Quellenangaben haben die Form `votian/pfad/datei.php:Zeile` und beziehen sich auf den untersuchten Bestand. Zeilennummern markieren den Einstieg in die betreffende Funktion oder den relevanten Codeblock. Historische Kopien und nummerierte Dateivarianten kommen häufig vor; als Grundlage wurden überwiegend die unnummerierten, von den Hauptseiten referenzierten Dateien verwendet. Aus dem bloßen Vorhandensein einer Datei folgt nicht, dass die Funktion heute produktiv genutzt wird. + +Die tatsächliche Ausprägung hängt von Datenbankparametern, Niederlassung, Kunde, Fahrzeugtyp, Zeitpunkt und Mitarbeiterrechten ab. Ohne diese Konfiguration und fachlich freigegebene Beispieldaten lassen sich die aktiven Regeln nicht vollständig bestimmen. Einzelne Codekommentare dienen als Hinweis; entscheidende Aussagen wurden soweit möglich mit ausführbarem Code abgeglichen. + +Zentrale Quellen: + +| Bereich | Quellen | +|---|---| +| Auftragsmaske und Benutzerkontext | `votian/jobs/job_edit.php:20`, `:38`, `:81` | +| Auftrags- und Stationsspeicherung | `votian/jobs/job_options.php:208`, `:421`, `:2290` | +| Disposition und Listen | `votian/admin/jb_list.php:505`, `:708`, `:957`, `:1376` | +| Fachliche Feldlisten | `votian/include/mcglobal.inc.php:145`, `:169`, `:177`, `:198`, `:217` | +| Tarifberechnung | `votian/include/services_func.inc.php:24`, `:89`, `:168`, `:371`, `:538` | +| Storno | `votian/include/inc_job.inc.php:1242` | +| Berechtigungen | `votian/include/auth.inc.php:208`, `:264`, `:285`, `:315` | +| Abrechnung | `votian/invoice/inv.inc.php:28`, `votian/invoice/inv_aux.inc.php:264` | + +## 2. Fachliches Datenmodell + +### Kunden, Kostenstellen und Niederlassungen + +Die Struktur ist `company → customer → costcenter`. Eine Firma enthält den Unternehmensstamm; der Kunde ergänzt die kaufmännischen Einstellungen. Kostenstellen bilden eine Hierarchie über `csc_pre_id` und `csc_path`. Sie haben eigene Sichtbarkeit, Sperren und Rechnungsparameter. Kostenstellenadressen unterscheiden unter anderem Abhol-, Rechnungs- und Serviceadressen. + +Wichtige Felder: + +- Kunde: `cs_id`, `cmp_id`, `hq_id`, externe/interne Kundennummer, Hauptkostenstelle, Standardzahler, übergeordneter/zugehöriger Kunde, Rabatt, Festpreisrabatt, Provision, Rechnungsmodus, Kommissionsnummer, Zuschlag, Zahlungsziel, Auftrags-/Tracking-Einstellungen. +- Kostenstelle: `csc_id`, `cs_id`, Name, Elternreferenz, Pfad, extern/intern, gesperrt, sichtbar, Rechnungsintervall und Rechnungsart. +- Adresse: Straße, Hausnummer, PLZ, Ort, Land, Firmenzusätze, Ansprechpartner, Telefon und Bemerkung. + +Quellen: `votian/include/mcglobal.inc.php:130`, `:145`, `:147`, `:200`, `:217`. + +**Konsequenz:** Ein einzelner Kundenname am Auftrag ersetzt die Kostenstellenstruktur nicht. Auftraggeber und Rechnungsempfänger müssen unterscheidbar bleiben. Niederlassungszugehörigkeit ist außerdem eine fachliche Zugriffsgrenze. + +### Kuriere und Fahrzeuge + +Kurier und Fahrzeug sind getrennte Entitäten. In der Disposition wird häufig die Fahrzeug-SID verwendet. Ein angebotener Kurier (`cr_id_order`) ist vom tatsächlich annehmenden Kurier (`cr_id`) zu unterscheiden. + +- Kurier: Unternehmen und Benutzer, SID/Nummer, Fahrzeugtyp, Merkmale/Filter, Maximalgewicht, mögliche Auslage, Verfügbarkeit, Verfügbarkeitszeit und Standort-PLZ. +- Fahrzeug: Kurierbezug, Fahrzeug-SID, Kennzeichen, Fahrzeugtyp, Nutzlast, Gesamtgewicht, Länge/Breite/Höhe, Stellplätze, Eigenschaften, Provision und Aktivierung. + +Quellen: `votian/include/mcglobal.inc.php:198`, `:206`; Zuweisung über SID in `votian/admin/jb_list.php:515`. + +### Auftrag, Stationen und Leistungen + +| Entität | Fachlich relevante Felder | +|---|---| +| Auftrag `job` | Nummer, Version, Auftraggeber-Kostenstelle, Zahler-Kostenstelle, Niederlassungen für Disposition/Ausführung/Vertrieb, Besteller, Kommission, gewünschter/tatsächlicher Fahrzeugtyp, Gewicht/Maße, Auftrags-/Annahme-/Abschluss-/Warnzeit, Reservierung, Dauerauftrag, Tourname, Status, Storno, Angebot, unvollständig, Fernfahrt, Freitext, Dispositionshinweis, übergeordneter Auftrag, Preise, Buchungs-/Exportdaten | +| Station `tour` | Auftragsbezug, Reihenfolge, Kostenstelle, Firma/Firmenzusatz, Adresse, Person, Telefon, Bemerkung, Kommission, Zusatzkilometer/-leistung, Abhol-/Lieferart, Status, Signatur, Empfängername, Abschlusszeit | +| Stationsleistung `tourservice` | Station, Leistung/Leistungsart, Leistungsbezeichnung, Preis, Rabatt, zahlende Kostenstelle | +| Artikelposition `tourarticle` | Artikel, Menge, Sortierung, Preis, Packstücke, Gewicht, Seriennummer, Zustand, Bemerkung | +| Artikelbearbeitung `tourarticleprocess` | Positionsbezug, Vorgangsart, Status, Bemerkung, Zeitpunkt | +| Preiskomponente `jobprice` | Auftragsbezug, Art der Komponente und Betrag; zusätzliche Informationen zu Kunden-/Kurierberechnung | + +Quellen: `votian/include/mcglobal.inc.php:169`, `:177`, `:179`, `:181`, `:183`; weitere Maße und Bearbeitungsfelder in `votian/jobs/job_options.php:208`; Preiskomponenten in `votian/include/caglobal.inc.php:1663`. + +## 3. Statusmodell und Arbeitsabläufe + +### Auftragsstatus + +| Legacywert | Bedeutung | Übertragung | +|---|---|---| +| `9` | Manuelle Disposition, noch zuzuteilen | Eigener fachlicher Status | +| `8` | Autoranking | Eigener Zustand, dessen automatische Bearbeitung einen separaten Dienst benötigt | +| `0` | Offen / anzunehmen | Kurierangebot und Annahme unterscheiden | +| `1` | Angenommen / laufend | Laufende Ausführung | +| `2` | Erledigt | Regulärer Abschluss oder Abschluss durch Storno; Stornoflag zusätzlich prüfen | +| `99` | Interner Zustand im Zusammenhang mit Börsen-/Globalauftrag | Kein normaler Benutzerstatus ohne weitere Prüfung des Börsenablaufs | +| `10` bzw. `ALL` | Alle Aufträge | Ausschließlich Listenfilter | + +Quellen: `votian/jobs/job_options.php:421`, `:439`, `:451`; `votian/admin/jb_list.php:79`, `:516`. + +**Storno ist eine eigene Dimension:** `jb_storno=1` bedeutet kostenpflichtig, `2` kostenfrei. Stornierung setzt `jb_status=2`, den Abschlusszeitpunkt und den stornierenden Benutzer. Ein vereinfachtes neues Enum darf diese Information nicht verlieren. Quelle: `votian/include/inc_job.inc.php:1242`. + +Die dazugehörigen Gegenbuchungsaufträge verwenden `jb_storno=3` für die Gutschrift zu einem kostenpflichtigen Storno bzw. `4` für die Gutschrift zu einem kostenfreien Storno. Der Code kopiert und negiert dabei Preise/Leistungen und verknüpft den Originalauftrag über `jb_id_copy`. Solche Datensätze sind nicht einfach weitere reguläre Touren. Quelle: `votian/jobs/job_options.php:662`. + +### Stationsstatus und Stationsart + +Stationen sind einzeln offen (`tr_status=0`) oder erledigt (`1`). Bei vollständig erledigten Stationen kann der Gesamtauftrag abgeschlossen werden. Das Entfernen einer noch offenen Station kann ebenfalls einen Abschluss auslösen. Bei nachträglichen Stationsänderungen sind vorhandene Abschlussdaten zu erhalten. + +`tr_ware_from_to` unterscheidet `1=Abholung`, `2=Anlieferung`, `3=Anlieferung und Abholung`. `0` leitet die Standardart aus der Stationsposition ab. Die neue Oberfläche sollte eindeutige, explizite Arten verwenden. + +Quellen: `votian/jobs/job_options.php:2290`, `:2335`, `:2356`; `votian/include/caglobal.inc.php:1355`. + +### Kernablauf + +1. **Erfassen:** Die Zentrale wählt eine Kostenstelle. Beim Kundenbenutzer wird die auftraggebende Kostenstelle aus dem Mitarbeiterkonto abgeleitet. Die Auftragsmaske erlaubt Kurieren und Systemadministratoren in diesem Kontext keine normale Erfassung. Quelle: `votian/jobs/job_edit.php:38`. +2. **Route anlegen:** Mehrere Stationen mit Abhol-/Lieferkennzeichen, Adressen, Ansprechpartnern, Hinweisen und optionalen Artikeln/Leistungen erfassen. Quellen: `votian/include/mcglobal.inc.php:177`, `votian/jobs/job_options.php:236`. +3. **Zeit und Leistung festlegen:** Sofortfahrt, Reservierung oder Dauerauftrag; Fahrzeuganforderung, Gewicht, Maße und besondere Leistungen festlegen. +4. **Preis bestimmen:** Tarifberechnung oder manueller Festpreis; Kundenpreis und Kuriervergütung separat behandeln. Quellen: `votian/jobs/job_options.php:942`, `:1811`. +5. **Disponieren:** Ohne Kurier abhängig vom Niederlassungsmodus Status `9` oder `8`; mit Kurier üblicherweise `0`. Offline-Disposition kann direkt `1` setzen. Quellen: `votian/jobs/job_options.php:421`, `votian/admin/jb_list.php:516`. +6. **Ausführen:** Annahme, Stationsabschlüsse und Zustellnachweis; anschließend Gesamtabschluss. Quellen: `votian/include/mcglobal.inc.php:169`, `:177`, `votian/jobs/job_options.php:2335`. +7. **Nachbearbeiten:** Kopieren/Nacherfassen, Versionsänderung, Änderungsprotokoll und gegebenenfalls Information an das Fahrergerät. Quellen: `votian/jobs/job_options.php:185`, `:2366`. +8. **Stornieren und abrechnen:** Kostenpflicht prüfen, bestehende Buchungen/Exporte beachten und erforderlichenfalls Gegenbuchungen erzeugen. Quelle: `votian/include/inc_job.inc.php:1242`. + +### Reservierungen, Daueraufträge und Vorlagen + +Die Wiederholungslogik unterstützt Werktage, Werktage mit Samstag, täglich, wöchentlich, vierzehntäglich, monatlich, Monatsende, 15./Monatsende, 1./16., vierwöchentlich und Feiertage. Eine siebenstellige Maske enthält die ausgewählten Wochentage Montag bis Sonntag. Feiertagsverschiebungen unterscheiden sich je nach Wiederholungsart. Ein Enddatum begrenzt Serien. Quelle: `votian/include/caglobal.inc.php:2304`. + +Ein gespeicherter Tourname dient auch dem Wiederverwenden einer Tour; die Preisermittlung kann beim Wiederverwenden auf aktuelle Tarife zugreifen. Quellen: `votian/jobs/job_options.php:231`, `:1799`. + +## 4. Preislogik und Abrechnung + +Der Bestand besitzt keine einzelne universelle Formel. Wesentliche Einflussgrößen sind Niederlassung, Kunde, Fahrzeugtyp, Auftragszeit, Leistung, PLZ-Paar, PLZ-Gebiet, Zusatzleistungen und Zahlungsart. + +| Regelbereich | Beleg | Für Parität benötigter Nachweis | +|---|---|---| +| Zeitlich gültige Leistungs-/Kundentarife | `votian/include/services_func.inc.php:24`, `:371` | Aktive Tarifstände und erwartete Auswahl bei Überschneidungen | +| PLZ-Paar-/Gebietstarife | `votian/include/services_func.inc.php:89`, `:168` | Gebietsdefinitionen, Richtung, Prioritäten, Sonderadressen | +| Treibstoffzuschlag Kunde/Kurier | `votian/include/services_func.inc.php:430`, `:494` | Gültigkeitsstände und zuschlagsfähige Komponenten | +| Leistungspreise, Rabatt, Kostenaufteilung | `votian/include/services_func.inc.php:538` | Reihenfolge, Zahlerzuordnung, Rundung und Ausnahmen | +| Manuelle Fest-/Servicepreise | `votian/jobs/job_options.php:1050`, `:1067`, `:1828` | Einbeziehung von Rabatt und Zuschlag; Eingabepreis netto/brutto | +| Separate Kuriervergütung | `votian/include/services_func.inc.php:766`, `:794`, `:813` | Provision, Fernfahrt-, Fahrzeug- und Unternehmerregeln | +| Summen-/Rundungsfolge | `votian/jobs/job_options.php:1811` | Referenzfälle mit identischen Einzel- und Endsummen | +| Umsatzsteuer/Maut | `votian/jobs/job_options.php:2140` | Satz, Leistungsdatum und steuerliche Zuordnung je Fall | +| Storno und bereits exportierte Buchungen | `votian/include/inc_job.inc.php:1286` | Gegenbuchungs- und Exportregeln, keine Doppelabrechnung | + +Bei Festpreisen wirken im untersuchten Zweig ein Zuschlag, ein separat gerundeter Selbstbedienungsrabatt und anschließend ein Kundenrabatt. Servicepreise erhalten nicht automatisch denselben Zuschlag; ihre Rabattfähigkeit ist konfigurierbar. Die Kuriervergütung wird gesondert ermittelt. Deshalb reicht ein pauschales Feld „Gesamtpreis“ nicht zum Nachweis gleicher Abrechnung. + +Für eine spätere native Schreibumsetzung ist manuelle Preisangabe mit nachvollziehbaren Werten ein sinnvoller begrenzter Umfang. Im aktuellen lesenden Ansatz sollen Preise aus vorhandenen Aufträgen angezeigt und Preisänderungen über die geprüfte PHP-Funktion vorgenommen werden. Es müssen jedoch Währung, Netto-/Bruttobedeutung, Steuersatz und Rundung klar sein. Beispielpreise sind als Beispieldaten zu behandeln. Die vollständige Altpreisberechnung ist damit nicht ersetzt. + +Für Spring Boot sollten Geldbeträge als `BigDecimal` und passend zu den vorhandenen Datenbanktypen gelesen und geschrieben werden; Schemaänderungen sind ausgeschlossen. Der Auftrag benötigt einen unveränderlich nachvollziehbaren Preisstand mit Herkunft und Komponenten. Eine spätere Tarifänderung darf historische Beträge nicht stillschweigend verändern. Dies sind Empfehlungen für die Neuentwicklung, keine Behauptungen über deren aktuellen Code. + +Die Rechnungsansicht unterscheidet Kunden- und Kurierabrechnung und berücksichtigt Kostenstellen, Barzahlungen und bereits exportierte Aufträge. Barzahlungen werden im betreffenden Zweig nicht als gewöhnliche Kundenrechnung behandelt. Quellen: `votian/invoice/inv.inc.php:28`, `:200`; `votian/invoice/inv_aux.inc.php:264`, `:319`, `:357`. + +Eine neue Rechnungsübersicht oder ein CSV-Export ist daher zunächst eine Übersicht/Ausgabe. Gleichwertige Fakturierung, Korrekturbelege, Buchhaltungsexporte oder rechtliche Rechnungsanforderungen sind damit noch nicht nachgewiesen. + +## 5. Benutzerrechte und fachliche Grenzen + +Die Benutzertypen sind `1=Zentrale`, `2=Kunde`, `3=Kurier`, `4=Lager`. Darüber hinaus existiert gesonderte Systemadministratorbehandlung in verschiedenen Seiten. Quelle: `votian/include/dbglobal.inc.php:786`; Beispiel für Typ `0` in `votian/invoice/inv.inc.php:52`. + +Mitarbeiterrechte werden zusätzlich über eine positionsbasierte Rechtemaske ausgewertet. Die Zugriffsprüfung unterscheidet Zentrale, Kunde und Kurier; Kundenrechte sind an Kostenstellen gebunden. Im Rechnungsmodul wird zusätzlich das Rechnungsrecht geprüft und die ausgewählte Kostenstelle auf den erlaubten Teilbaum beschränkt. + +Quellen: `votian/include/auth.inc.php:208`, `:214`, `:264`, `:285`, `:315`; `votian/invoice/inv.inc.php:36`. + +Erforderliche Nachweise vor produktiver Ablösung: + +- Rollen- und Aktionsmatrix für Lesen, Erfassen, Ändern, Disponieren, Stornieren, Preise, Rechnungen und Export. +- Mandanten-/Niederlassungsgrenzen und erlaubte Kostenstellen-Teilbäume. +- Nachweis serverseitiger Prüfung bei direkten API-Aufrufen, nicht nur ausgeblendeter Navigation. +- Trennung zwischen Stammdaten eines Kuriers, angemeldetem Fahrer und zugewiesenem Fahrzeug. +- Historie mit Akteur, Zeitpunkt und relevanter Änderung; Schutz vor konkurrierenden Status-/Preisänderungen. + +Eine Anmeldung mit wenigen Rollen kann die erste Umsetzung absichern, ersetzt aber die vorhandene Rechte- und Teilbaummatrix noch nicht. + +## 6. Schnittstellen und Hintergrundverarbeitung + +| Bereich | Im Quellbestand erkennbar | Quelle | Grenze der ersten Umsetzung | +|---|---|---|---| +| Fahrer-/Mobile-Kommunikation | XML-Verarbeitung mit Modulen für Aufträge, Kuriere, Artikel, Fahrzeuge und weitere Funktionen | `votian/mobile/serviceXML.php:15`, `:1668` | Eine neue REST-API ist nicht automatisch kompatibel zu vorhandenen Fahrergeräten | +| Änderungen laufender Aufträge | Erzeugung zeitgesteuerter Gerätekommandos | `votian/jobs/job_options.php:2366` | Bestätigung, Wiederholung, Offlinebetrieb und Konfliktbehandlung benötigen eigene Tests | +| Routing/Geokodierung | Adresssuche und Tour-/Zeitfensterplanung; PTV-Bezug im XML | `votian/import/xServer.inc.php:35`, `:101`, `:195` | Lizenz, Endpunkt, Koordinatensystem, Distanz-/Zeitgrundlage und Fehlerfälle sind offen | +| Kilometer-/Routenpreis | Geokodierung, gespeicherte Routen, Optimierungsinformationen | `votian/include/ajaxReqKmPrice.php:5`, `:161`, `:259`, `:376` | Keine belastbare automatische Distanz- oder Preisberechnung ohne Dienst und Tarifabnahme | +| Dateiimporte | Allgemeine und kundenspezifische Importstrecken | `votian/import/import_STANDARD_01.php:12`, `votian/import/import_TIMS.php:18` | Feldmapping, Dublettenschutz und Fehlerrückmeldung je aktivem Format prüfen | +| Dateitransfer | FTP-bezogene Funktionen für Import-/Exportdateien | `votian/import/data_transfer.php:94`, `:767`, `:772` | Transporteinstellungen werden nicht aus dem Altsystem übernommen | +| Datenexport | Eigenständiges Exportmodul mit Rechteprüfung über Menüeinträge | `votian/admin/menu.php:265`, `:331` | CSV-Ausgabe ist kein Ersatz aller bestehenden Vertragsschnittstellen | +| Wiederholungsaufträge | Datums-/Feiertagslogik für nächste Ausführung | `votian/include/caglobal.inc.php:2304` | Ausführung, Eindeutigkeit und Neustartverhalten eines neuen Schedulers belegen | + +Für jede tatsächlich genutzte Integration sind Verantwortlicher, Format-/Protokollversion, Testzugang, Beispieldaten, Authentisierung, Fehlerrückmeldung, Wiederholungsstrategie und Abnahmekriterium zu erfassen. Alte Zugangsdaten werden nicht migriert oder in die neue Anwendung kopiert. Der Bericht dokumentiert keine geheimen Konfigurationswerte. + +## 7. Optik und Bedienung + +Das Altsystem nutzt überwiegend kompakte, tabellarische Masken. Die Auftragserfassung ist in Kopf, Stationsmaske, Optionen und Stationsliste gegliedert; im Bestand erfolgt dies über Frames. Quelle: `votian/jobs/job_edit.php:81`. + +Die erkennbaren Standardfarben sind Hellblau `#CDD9FD`, dunkles Blau `#1b12b9` und Akzentblau `#4e45ec`. Die Standardschrift ist Verdana mit Arial/Helvetica als Ersatz, häufig in 8 pt. Quellen: `votian/css/navigation.css.php:28`, `:33`, `:35`, `:52`; `votian/css/phoenix.css.php:22`. + +Ziel der React-Oberfläche ist eine wiedererkennbare blaue Gestaltung mit klarer Navigation, dichten Auftragstabellen, sichtbaren Statusangaben und kurzer Erfassung. Lesbarkeit, Tastaturbedienung und responsive Darstellung können modernisiert werden. Die Übernahme der Frames, der kleinen historischen Schriftgröße oder jedes pixelgenauen Abstands ist kein fachliches Erfordernis. Visuelle Gleichheit ist ohne Screenshots der aktiven Installation nicht nachgewiesen. + +## 8. Modul-Abdeckungsmatrix + +Diese Matrix trennt vorhandene Fachlichkeit von dem vorgesehenen Einstieg. **Sie ersetzt keine Prüfung des tatsächlich implementierten Codes und der Tests.** + +| Modul/Funktion | Bestand | Ziel erster Umsetzung | Noch zu belegende Parität / Priorität | +|---|---|---|---| +| Aufträge | Umfangreiche Felder, Listen, Kopien, Nacherfassung | Lesen/Filtern über JDBC; Erstellen/Bearbeiten als PHP-Verweis | Pflichtfelder, abgeschlossene/exportierte Aufträge und Historie; P0 | +| Mehrstopp-Tour | Beliebige Stationsfolge mit Einzelstatus und Nachweisen | Vorhandene Stationsfolge lesen; Bearbeitung im PHP-Ablauf | Gemischte Stationen, Stationsabschluss, Empfänger/Signatur; P0 | +| Disposition | Manuell, Autoranking, Börse, Offline-Zuweisung | Dispositionsdaten lesen; Zuweisung/Status als PHP-Aktion | Angebot gegenüber Annahme, Rücknahme, Konkurrenz; P0 | +| Storno | Kostenpflichtig/kostenfrei, Buchungsfolgen | Storno anzeigen; Aktion im bestehenden PHP-Prozess | Altflags, Beträge, Export-/Gegenbuchungsfälle; P0 | +| Kunden | Firmen, Kostenstellenhierarchie, Zahler, Parameter | Kundenstammdaten lesen; Pflege über PHP-Verweis | Kostenstellen und abweichender Rechnungsempfänger; P0/P1 | +| Kuriere/Fahrzeuge | Getrennte Modelle, SID, Verfügbarkeit und Merkmale | Kurierstammdaten lesen; Pflege über PHP-Verweis | Fahrzeugzuordnung, Eignung, Verfügbarkeit; P1 | +| Preise | Historisierte Tarife, PLZ-/Gebiete, Leistungen, Rabatte, Zuschläge | Vorhandene Preiswerte lesen; Kalkulation/Änderung im PHP-Prozess | Tarifmatrix und getrennte Kurierabrechnung; vor automatischer Preisnutzung P0 | +| Rechnungen | Kunden-/Kurieransicht, Kostenstellen, Bar-/Exportregeln | Vorhandene Abrechnungswerte lesen bzw. PHP-Rechnungsansicht öffnen | Fakturierung, Korrekturen, Nummernkreise, Steuern und Exportstatus; vor Abrechnung P0 | +| Export | Rechtegebundene und kundenspezifische Ausgaben | Lesende Ausgabe bzw. vorhandener PHP-Export; Umsetzung separat belegen | Schnittstellenverträge, Kodierung, Dubletten und Wiederholung; P1 | +| Reservierungen | Zukünftige Auftragszeit und Dispositionsregeln | Vorhandenen Termin anzeigen; Änderung im PHP-Prozess | Zeitzone, Warn-/Vorbereitungszeit; P1 | +| Daueraufträge/Vorlagen | Wiederholungsmuster, Feiertage, Enddatum, gespeicherte Tour | Nicht als abgedeckt voraussetzen | Scheduler und Serien-/Vorlagenabnahme; P1 | +| Zugriffsrechte | Rolle, Einzelaktion, Niederlassung und Kostenstelle | Bestehende Benutzerkonten, HQ-Mitarbeiterbezug und Leserechte; noch live nachzuweisen | Vollständige serverseitige Rechte-/Sichtbarkeitsmatrix; P0 | +| Kunden-/Fahrerportal | Unterschiedliche Ansichten und mobile Kommunikation | Nicht als abgedeckt voraussetzen | Eigene Datenbegrenzung, Geräteprotokoll und Offlinefälle; P1 | +| Artikel/Lager | Artikelpositionen, Packstücke, Bearbeitungsstatus | Nicht als abgedeckt voraussetzen | Lager-/Scanprozesse und Bestandsintegration; P2 nach Nutzungsnachweis | +| Routing/GPS/Autoranking | Externe Routen- und Ortungsinformationen | Nicht als abgedeckt voraussetzen | Dienstintegration, Rankingregeln, Ausfallbetrieb; P2/P1 je Betrieb | +| Bestandsanbindung | Bestehendes MySQL-Schema mit verknüpften Bestands-/Archivdaten | Direkte, kompatible Anbindung ohne eigene Datenbank | Feld-, Status-, Transaktions- und Berechtigungsabgleich; keine DDL/Schemaänderung; P0 | + +Prioritäten: **P0** verhindert die fachlich sichere Ablösung des betroffenen Kernablaufs; **P1** ist als nächster operativer Ausbau einzuplanen; **P2** hängt von der nachgewiesenen produktiven Nutzung ab. Nicht jede Funktion muss vor einer begrenzten Pilotnutzung verfügbar sein; der Pilotumfang muss die vorhandenen Einschränkungen ausdrücklich berücksichtigen. + +Die besonders dringenden Ergänzungen über ein einfaches CRUD hinaus sind: getrennte Kostenstellen/Zahler, einzelne Stationsabschlüsse, echte Stornobedeutung, Trennung Angebot/Annahme, serverseitige Zugriffsgrenzen und nachvollziehbare Änderungen. Diese Aspekte beeinflussen den täglichen Kernprozess unmittelbar. + +## 9. Bestehende Datenbank anbinden, ohne Schemaänderung + +Die ausdrückliche Vorgabe lautet, die vorhandene Datenbank zu verwenden. Es gibt deshalb keinen Plan zur Anlage einer neuen Datenbank, zur Erstellung neuer Tabellen oder zum Import der Bestandsdaten in ein neues Schema. „Mapping“ bezeichnet ausschließlich die Abbildung bestehender Spalten auf Java-/API-Modelle. Eine rein lesende Schemaanalyse ist von Datensatzänderungen zu unterscheiden. + +1. **Verbindung und Bestand feststellen.** Autorisiert bereitgestellte Verbindungsparameter außerhalb des Quellcodes konfigurieren. Datenbankversion, Tabellen, Spaltentypen, Schlüssel, Archivtabellen, Kodierung und aktive Parameter lesend ermitteln. Zugangsdaten nicht in Ausgaben oder Repository schreiben. +2. **Automatische Strukturänderungen abschalten.** Keine H2-Ersatzdatenbank, keine neue MySQL-Datenbank, keine DDL, keine Flyway-/Liquibase-Migrationen und keine automatische Seed-Befüllung. Bei JPA darf die DDL-Einstellung keine Erzeugung oder Änderung auslösen. Explizites JDBC mit kontrolliertem SQL ist ebenfalls möglich. +3. **API-/Feldmapping erstellen.** Für jede verwendete API-Eigenschaft die vorhandene Tabelle, Spalte, Join-Bedingung, Bedeutung, Nullbehandlung und Schreibregel dokumentieren. Auftraggeber/Zahler, Kunde/Kostenstelle, Kurier/Fahrzeug und Auftrag/Station bleiben unterscheidbar. +4. **Bestand lesend prüfen.** Fachliche Stichproben, Objektanzahlen, Fremdschlüsselbeziehungen, Statuswerte und Summen abgleichen. Verwaiste Referenzen, doppelte Nummern und widersprüchliche Daten melden, ohne sie automatisch zu korrigieren. Die alte Oberfläche deklariert ISO-8859-1 (`votian/jobs/job_edit.php:67`); der tatsächliche Datenbankzeichensatz und JDBC-Transport sind separat zu prüfen. +5. **Kompatibles Schreiben entwickeln.** Bestehende Schlüsselerzeugung, erforderliche Spalten, Status-/Stornofelder, Zeitstempel, Historie und Nebentabellen berücksichtigen. Ein fachlicher Speichervorgang benötigt eine Transaktion. Unbekannte Altspalten nicht durch vollständiges Ersetzen eines Datensatzes verlieren. Die Existenz aller SQL-Spalten vor Schreibfreigabe nachweisen. +6. **Schreiben gezielt verifizieren.** Autorisierte Testdatensätze oder eine ausdrücklich bereitgestellte Testinstanz des unveränderten Bestandsschemas verwenden. Keine eigenmächtige Testdatenbankanlage. Produktive Datensätze nicht als Testmaterial ändern. Auftragserfassung ist mehr als ein `INSERT INTO job`: Stations-, Preis-, Zuweisungs- und eventuell Historieneinträge müssen fachlich zusammenpassen. +7. **Parallelbetrieb prüfen.** PHP und Spring Boot können dieselben Datensätze sehen. Konkurrierende Bearbeitung, Nummernvergabe, Statuswechsel und Hintergrundprozesse dürfen sich nicht überschreiben. Bereits vorhandene Versions-/Sperrkonzepte sind auf Kompatibilität zu untersuchen. Serienaufträge, Benachrichtigungen und Exporte dürfen nicht doppelt ausgelöst werden. +8. **Abgleich und Freigabe.** Lesen über PHP und neue API anhand identischer IDs vergleichen. Nach erlaubten Testschreibvorgängen Sichtbarkeit und Bedeutung im Altsystem prüfen. Disposition und Abrechnung bestätigen die relevanten Fälle; verbleibende Grenzen bleiben dokumentiert. + +Falls ein bestehender Datentyp oder eine fehlende Spalte die gewünschte Funktion nicht abbildet, darf daraus keine automatische Schemaänderung folgen. Zunächst vorhandene fachliche Speicherorte untersuchen, dann die Einschränkung konkret benennen. Ein neuer API-Begriff ist kein Auftrag, eine gleichnamige Spalte anzulegen. + +Authentisierung muss zum vorhandenen Benutzermodell passen. Alte Passwortwerte dürfen weder in API-Antworten noch in Logs gelangen. Eine Änderung von Passwortformaten, Kontoaktivierungen oder Benutzerrechten ist eine eigene fachliche Änderung und nicht Teil der bloßen Anbindung. Signaturen, Fotos und Dokumente bleiben an ihren vorhandenen Daten-/Dateibeziehungen; sie werden nicht ungefragt kopiert oder verschoben. + +## 10. Fachliche Abnahme und technische Nachweise + +| Abnahmeszenario | Erwartung | +|---|---| +| Sofortauftrag mit zwei Stationen | Pflichtfelder, Preisbedeutung und initialer Status stimmen; Auftrag nach Neustart vorhanden | +| Mehrstopp mit Abholung, Lieferung und gemischter Station | Reihenfolge bleibt erhalten; Teilabschluss beendet nicht vorzeitig den Gesamtauftrag | +| Zuweisen, annehmen, zurücknehmen | Kurierangebot und Annahme sind nachvollziehbar; ungültige Übergänge werden serverseitig abgewiesen | +| Zwei Disponenten bearbeiten denselben Auftrag | Keine unbemerkte Überschreibung; Versionskonflikt ist verständlich lösbar | +| Reservierung und Warnzeit | Lokale Zeitdarstellung und Speicherung stimmen, auch bei Zeitumstellung | +| Kostenpflichtiges/kostenfreies Storno | Zustand, Preisfolgen und Historie bleiben eindeutig; abgeschlossene/exportierte Fälle geprüft | +| Abweichender Zahler und Unterkostenstelle | Rechnungszuordnung korrekt; unberechtigter Benutzer kann fremde Daten auch per API nicht erreichen | +| Festpreis mit Rabatt, Zuschlag, Service und Steuer | Beträge und Rundungsfolge anhand freigegebener Referenzfälle exakt geprüft | +| Historischer Auftrag nach Tarifänderung | Historischer Preis bleibt nachvollziehbar und unverändert, sofern keine ausdrückliche Neukalkulation erfolgt | +| Abrechnung und Wiederholung des Exports | Keine Doppelabrechnung; Exportinhalt und Status sind reproduzierbar | +| Fehlende externe Dienste | Verständliche Fehler, keine stillen Fantasiepreise oder falschen Statusbestätigungen | +| Bestandsanbindung und Parallelbetrieb | Identische fachliche Daten in PHP und neuer API; keine DDL, keine verlorenen Felder und keine doppelten Hintergrundaktionen | + +Die erste Lieferung ist anhand ihrer tatsächlich vorhandenen Endpunkte, Bedienwege und Tests gegen diese Matrix zu prüfen. Bestehende Backend-Tests, ein erfolgreicher Frontend-Build und ein erfolgreicher Start sind technische Nachweise; sie ersetzen weder Tarifvergleich noch Benutzerrechteprüfung, Schnittstellenabnahme und Datenabgleich. + +Ein zunächst auf `job.hq_id = user.hq_id` begrenzter Lesezugriff zeigt nur Aufträge der eigenen **Erfassungsniederlassung**. Die Legacy-Liste berücksichtigt zusätzlich Ausführungs-, Dispositions- und Vertriebsniederlassung über eine Vereinigung erlaubter HQ-Mengen (`votian/admin/jb_list.php:1101`, `:1363`). Die engere neue Sicht ist als Einschränkung auszuweisen: Ein in einer anderen Niederlassung erfasster, der eigenen Disposition zugehöriger Auftrag kann fehlen. Zugriffsbereiche dürfen ohne passende Rechteabbildung nicht pauschal erweitert werden. + +Eine vollständige Ablösung kann erst bestätigt werden, wenn Stadtbote die aktiv genutzten Module bestimmt hat, die zugehörigen Regeln in der neuen Anwendung kompatibel umgesetzt oder ausdrücklich stillgelegt sind und die relevanten Referenzfälle bestanden wurden. + +## Anhang A. Konkrete Zuordnung bestehender Tabellen zu API-Modellen + +Die API-Feldnamen in der linken Spalte sind Vorschläge für die Java-/JSON-Abbildung. Die rechten Spalten sind im PHP-Code belegte bestehende Datenbanknamen. Die tatsächlichen Typen, Nullmöglichkeiten und Indizes müssen über die vorhandene Datenbank lesend geprüft werden. Die folgenden SQL-Formen dienen nur der Implementierungsanalyse und wurden nicht gegen eine Datenbank ausgeführt. + +### Kunde und Firmenstamm + +| API-Feld | Bestehende Spalte / Ausdruck | Beziehung | +|---|---|---| +| `customer.id` | `customer.cs_id` | Fachlicher Kundenschlüssel, nicht `cmp_id` | +| `customer.number` | `customer.cs_eid` | Im Bearbeitungsablauf auf Eindeutigkeit geprüft | +| `customer.internalNumber` | `customer.cs_sid` | Weitere interne Nummer | +| `customer.companyId` | `customer.cmp_id` | `customer.cmp_id = company.cmp_id` | +| `customer.name` | `company.cmp_comp` | Firmenzusätze `cmp_comp2` bis `cmp_comp4` separat | +| `customer.headquarterId` | `customer.hq_id` | Sichtbarkeit nach erlaubtem Niederlassungs-/Mandantenbereich | +| `customer.defaultCostCenterId` | `customer.csc_id` | Hauptkostenstelle | +| `customer.payerCostCenterId` | `customer.csc_id_payer` | Abweichender Standardzahler | +| `customer.contact.firstName/lastName` | `user.usr_firstname`, `user.usr_name` | `customer.cs_admin = employee.emp_id`, dann `employee.usr_id = user.usr_id` | +| `customer.phone/email` | `user.usr_phone`, `user.usr_email` | Wie Kontakt; Telefon nicht aus `company` erfinden | +| `customer.address.street` | `address.ad_street` | `company.ad_id = address.ad_id` | +| `customer.address.houseNumber` | `company.cmp_hsno` | Hausnummer liegt hier außerhalb der Adresstabelle | +| `customer.address.postalCode/city/country` | `address.ad_zipcode`, `ad_city`, `ad_country` | Wie Straße | +| `customer.visible/archived/authorized` | `company.cmp_visible`, `cmp_archived`, `cmp_authenticated` | Drei unterschiedliche Zustände | +| `customer.discount/fixedPriceDiscount` | `customer.cs_discount`, `cs_fixprice_discount` | Prozentwerte; historische Tarif-/Änderungslogik zusätzlich prüfen | +| `customer.paymentTerm` | `customer.cs_payment_term` | Vorhandene Bedeutung/Einheit am Bestand verifizieren | + +Lese-Joins: `votian/include/inc_customer.inc.php:31`, `:70`; vollständige Feldliste `votian/include/mcglobal.inc.php:217`. Eindeutigkeitsprüfung `votian/admin/customer_special.php:797`. + +Bestehende Anlagefolge in `votian/include/inc_customer.inc.php:678`: `costcenter` anlegen, `user` vom Typ `2`, `employee` mit Kostenstellen-/Rechtebezug, `company`, `customer`, anschließend `costcenter.cs_id` zurückverknüpfen, Adresse verknüpfen und mehrere `costcenteraddress`-Arten anlegen (`:746`, `:757`, `:762`). Eine alleinige Anlage in `customer` wäre zu dieser Fachlichkeit nicht kompatibel. Die Feldlisten für Änderungen stehen in `votian/admin/customer_special.php:823`, `:840`, `:844`; sie aktualisieren Firma, Kunde und Kontaktbenutzer separat. Das ist eine Analyse der Altlogik, keine Aufforderung zu Teständerungen am Bestand. + +### Kostenstelle und Kostenstellenadresse + +| API-Feld | Bestehende Spalte | +|---|---| +| `costCenter.id/customerId/name` | `costcenter.csc_id`, `cs_id`, `csc_name` | +| `costCenter.parentId/path` | `costcenter.csc_pre_id`, `csc_path` | +| `costCenter.visible/blocked/external` | `costcenter.csc_visible`, `csc_blocked`, `csc_is_extern` | +| `costCenter.invoiceInterval/invoiceType` | `costcenter.csc_inv_interval`, `csc_inv_type` | +| `costCenterAddress.typeId/addressId` | `costcenteraddress.adt_id`, `ad_id` | +| `costCenterAddress.company/houseNumber/contact/phone/email` | `cscad_comp`, `cscad_hsno`, `cscad_person`, `cscad_phone`, `cscad_email` | +| `costCenterAddress.street/postalCode/city` | `address.ad_street`, `ad_zipcode`, `ad_city` über `costcenteraddress.ad_id = address.ad_id` | + +Leseabfragen: `votian/include/inc_customer.inc.php:973`, `:998`. Anlage in `votian/customer/costcenter.php:77` prüft kundenspezifisch eindeutige Namen, setzt `cs_id`, `csc_pre_id`, `csc_name`, `csc_path`, `csc_visible` und übernimmt Adressen/Rechnungsintervall der Hauptkostenstelle. Umbenennen/Anzeige/Rechnungsdaten werden separat aktualisiert (`:172`, `:193`, `:375`). `csc_path` enthält im untersuchten Aufbau Namen und Trenner; nicht ohne Nachweis als reine ID-Liste interpretieren. + +### Kurier, Kontakt und Fahrzeug + +| API-Feld | Bestehende Spalte / Beziehung | +|---|---| +| `courier.id/number/dispatchSid` | `courier.cr_id`, `cr_eid`, `cr_sid` | +| `courier.companyId/userId/headquarterId` | `courier.cmp_id`, `usr_id`, `hq_id` | +| `courier.companyName` | `company.cmp_comp` über `courier.cmp_id = company.cmp_id` | +| `courier.firstName/lastName/phone/email` | `user.usr_firstname`, `usr_name`, `usr_phone`, `usr_email` über `courier.usr_id = user.usr_id` | +| `courier.vehicleTypeId` | `courier.vht_id`; nicht mit `vht_id_inv` für Abrechnung verwechseln | +| `courier.available/availableAt/locationPostalCode` | `courier.cr_available`, `cr_availabletime`, `cr_locationzipcode` | +| `courier.maxWeight/outlayLimit` | `courier.cr_maxweight`, `cr_outlay` | +| `courier.disposition/appBlocked` | `courier.cr_disposition`, `cr_app_blocked` | +| `vehicle.id/courierId/dispatchSid/typeId` | `couriervehicle.crvh_id`, `cr_id`, `crvh_sid`, `vht_id` | +| `vehicle.licensePlate/payload` | `couriervehicle.crvh_vh_sign`, `crvh_payload` | + +Lese-Joins: `votian/include/inc_courier.inc.php:32`, `:68`; Fahrzeugfelder: `votian/include/mcglobal.inc.php:206`. + +Anlage erzeugt zuerst `user` vom Typ `3`, dann `company`, `courier` und Adresse (`votian/include/inc_courier.inc.php:645`, `:653`, `:682`, `:694`). Die Änderungsmaske schreibt Firma, Kurier und Benutzer getrennt (`votian/admin/courier_special.php:486`, `:525`, `:549`). Der aktuelle Kommentar vor dem Kurier-Update weist ausdrücklich darauf hin, dass `cr_sid` aus diesem allgemeinen Update entfernt wurde (`:524`); SID deshalb nicht ungeprüft als frei editierbares Stammdatenfeld behandeln. Änderungen an der Gerätenummer können weitere Zuordnungen beeinflussen; für einen einfachen Kontakt-Edit ist `usr_phone` maßgeblich, nicht automatisch `cr_mobile_pda`. + +### Authentisierung und Autorisierung + +| Zweck | Bestehende Felder / Beziehungen | +|---|---| +| Kontosuche | `user.usr_account`; Ergebnis `usr_id`, `hq_id`, `usr_type` | +| Passwortprüfung | `user.usr_password`, gegebenenfalls `usr_password_old`; nur intern verwenden, nie API-Ausgabe | +| Mitarbeiterbezug | `employee.usr_id = user.usr_id`; `emp_id`, `csc_id`, `emp_rights`, `emp_heritable_rights` | +| Rollen | `usr_type`: `1` Zentrale, `2` Kunde, `3` Kurier, `4` Lager; Typ `0` separat prüfen | +| Niederlassungszugriff | `user.hq_id`, Mitarbeiterkontext und besondere Freigaben; nicht nur Rolle | +| Erweiterte Niederlassungsrechte | `employeerights.emp_id`, `hq_id`, `rgt_id` | +| Kundenzulassung | `employee.csc_id → costcenter.cs_id → customer.cmp_id → company.cmp_authenticated` | +| Kurierzuordnung | `courier.usr_id = user.usr_id` | + +Der untersuchte Login prüft gegen die Datenbankfunktionen `PASSWORD()` und `OLD_PASSWORD()` (`votian/admin/login.php:484`, `:494`), und `cryptDbPassword` ruft ebenfalls `PASSWORD()` auf (`votian/include/dbglobal.inc.php:458`). Ein pauschaler BCrypt-Vergleich wäre deshalb nicht mit diesem Legacyzweig kompatibel. Passwortwerte oder Formate wurden hier nicht aus der Datenbank gelesen. Die konkret eingesetzte Datenbankversion und die vorhandenen Hashformate müssen vor Implementierung der Prüfung sicher geklärt werden. Die im Altlogin enthaltene automatische Passwortänderung wird bei einer rein lesenden Anbindungsprüfung nicht übernommen. + +Die Benutzer-/Mitarbeiterbeziehung wird beim Login aufgelöst (`votian/admin/login.php:531`). Kunden müssen zusätzlich freigeschaltet sein (`:545`). Aktionsrechte werden über `employee.emp_rights` geprüft, können beim Niederlassungswechsel eingeschränkt werden und werden durch eine separate Niederlassungsrechtematrix ergänzt (`votian/include/dbglobal.inc.php:815`, `:841`; `votian/include/auth.inc.php:315`). Ein erfolgreiches Passwort allein berechtigt daher nicht zum Zugriff auf sämtliche Aufträge. + + +### Präzisierungen für einen lesenden HQ-Zugang + +Für einen Zugang als Zentralenmitarbeiter (`usr_type=1`) benötigt der Legacy-Prüfpfad eine existierende, positive `emp_id` mit `employee.usr_id = user.usr_id`. Der Niederlassungsschlüssel stammt aus `user.hq_id`, nicht aus einem unbelegten Feld `employee.hq_id`. Zunächst ist der eigene HQ-Bereich zu verwenden; Mehrfachzugriff setzt besondere Freigaben und Rechtebeschränkungen voraus (`votian/include/auth.inc.php:214`; `votian/include/dbglobal.inc.php:815`). + +Der Login-Join fordert eine existierende `headquarters`-Zeile (`votian/admin/login.php:494`). Die allgemeine Authentisierungsinitialisierung verlangt außerdem einen gültigen Mandantenbezug über `mandatorheadquarters.hq_id → md_id` (`votian/include/auth.inc.php:115`). In den untersuchten Dateien `admin/login.php`, `include/auth.inc.php`, `admin/employee.php`, `admin/user_list.php` und `include/dbglobal.inc.php` wurde keine Loginprüfung anhand von `usr_active`, `usr_disabled`, `usr_deleted`, `emp_active` oder `emp_disabled` gefunden; diese Felder dürfen nicht in SQL erfunden werden. Das belegt nicht ihre Abwesenheit in jedem Datenbankstand. `hq_disabled` existiert als Auswahlfilter (`votian/jobs/job_header.php:79`), wird aber im untersuchten Passwort-Login nicht geprüft. Ob die neue Anmeldung eine deaktivierte HQ ausdrücklich ablehnt, ist als eigene Regel festzulegen und gegen den Bestand zu prüfen. + +Die folgende HQ-Rechtematrix verwendet **nullbasierte** Indizes in `employee.emp_rights`. Sie gilt nicht automatisch für Kundenbenutzer: + +| Zugriff | Bitindex | Quelle | +|---|---|---| +| Kunden | `0` | `votian/admin/menu.php:214` | +| Kuriere/Transporteure | `1` | `votian/admin/menu.php:215` | +| Auftragslisten | `4` | `votian/admin/menu.php:206` | +| Rechnungsübersicht, PDF-/CSV-Ausgabe | `5` | `votian/admin/menu.php:254`, `:258`, `:259` | +| Allgemeiner Datenexport | `6` | `votian/admin/menu.php:265` | +| Auftragserfassung | `7` | `votian/admin/menu.php:246` | +| Statistik | `8` | `votian/admin/menu.php:211` | + +Diese Zugriffe müssen auch bei einer lesenden API serverseitig geprüft werden. Dashboard-Zahlen und Preiszusammenfassungen unterliegen ebenfalls den passenden Lese-/Abrechnungsrechten. Vorhandene PHP-Links besitzen ihre eigene Sitzung und Authentisierung; aus einem erfolgreichen Login der neuen Oberfläche folgt noch keine automatisch angemeldete PHP-Sitzung. + +## Anhang B. Geprüfte PHP-Modulziele + +Die folgenden Pfade sind relativ zur konfigurierten Basis-URL der vorhandenen `votian`-Installation. Sie öffnen Module und übermitteln keine Speicher-, Lösch-, Storno- oder Importausführungsaktion. Die Funktionsprüfung erfolgte am Quelltext; eine aktive PHP-Sitzung, Berechtigungen und eine laufende Installation bleiben Voraussetzung. + +| Ziel | Relativer Pfad | Quellbeleg | +|---|---|---| +| Neuer Auftrag | `jobs/job_edit.php` | `votian/admin/menu.php:248` | +| Auftrag auswählen/bearbeiten | `admin/jb_list.php` | `votian/admin/jb_list.php:33` | +| Kundenverwaltung | `admin/customer_list.php` | `votian/admin/menu.php:214` | +| Kurierverwaltung | `admin/courier_list.php` | `votian/admin/menu.php:215` | +| Rechnungsübersicht | `invoice/invoice.php` | `votian/admin/menu.php:254` | +| Servicepreise | `admin/services.php?mode=price` | `votian/admin/menu.php:268` | +| PLZ-Preise | `admin/services_plz.php?mode=price` | `votian/admin/menu.php:269` | +| Datenimport/-transfer | `import/data_transfer.php` | `votian/admin/menu.php:266` | +| Datenexport | `export/export.php` | `votian/admin/menu.php:265` | +| Statistik | `statistic/statistic.php` | `votian/admin/menu.php:211` | + +Objektbezogene Links werden im Bestand normalerweise als `jobs/job_edit.php?jb_id=ec(jb_id)`, `admin/customer_special.php?companyId=ec(cmp_id)` und `admin/courier_special.php?companyId=ec(cmp_id)` erzeugt. `companyId` ist der Firmenschlüssel `cmp_id`, nicht die Kunden-ID `cs_id` und nicht die Kurier-ID `cr_id`. Quellen: `votian/include/jb_list_defineoutput.inc.php:665`, `votian/admin/customer_list.php:854`, `votian/admin/courier_list.php:712`. + +`ec()` hängt von `HTTP_VARS_SEC_STATE`, einer Markierungssequenz und dem PHP-Sitzungskontext ab. `getSecHttpVars("1", …)` ruft `dc()` auf. Die untersuchte Implementierung von `dc()` lässt unmarkierte Werte unverändert; Klartext-IDs sind daher auf Quelltextebene nicht grundsätzlich ungültig. Eine eigene Nachbildung der Codierung oder ein beliebig erzeugter codierter Wert ist trotzdem kein nachgewiesen funktionsfähiger Link. Quellen: `votian/include/global.inc.php:57`, `:846`, `:868`. + +Für die erste Anbindung sind Module ohne Objektparameter die belastbarere Option. Direkte Objektlinks benötigen eine Prüfung mit der tatsächlichen PHP-Konfiguration und einem erlaubten Testkonto. Es werden keine schreibenden `f_act`-Parameter konstruiert. + +## Anhang C. Bei der Adapterprüfung besonders zu beachtende Bedeutungen + +- `job.jb_totalprice` enthält den Kundenpreis einschließlich der dort angewendeten Zuschläge, während `jb_subtotalprice` im untersuchten Festpreiszweig den Zuschlag nicht enthält. Die Steuer wird gesondert berechnet. Eine Anzeige des vollständigen Nettopreises darf deshalb nicht ungeprüft auf `jb_subtotalprice` zugreifen (`votian/jobs/job_options.php:1828`, `:1886`, `:2140`). +- `job.jb_service` wird als Bitmaske verwendet. Im Filter wird `2^mt_sort` getestet; ein einfacher Gleichheitsjoin `metatype.mt_sort = job.jb_service` ist für Kombinationen falsch (`votian/admin/jb_list.php:1158`). Fahrzeugtypen verwenden hingegen tatsächlich `metatype.mt_sort = job.vht_id` (`:1177`). +- Kilometer werden im untersuchten Speicherpfad als `jobprice.jbprc_price` mit `mt_sort=11` gespeichert. Ein Feld `job.jb_km` darf daraus nicht abgeleitet werden (`votian/jobs/job_options.php:224`, `:2097`). +- Packstücke werden über `SUM(tourarticle.trat_packingpieces)` ermittelt, Artikelgewichte über `SUM(tourarticle.trat_weight)`; `job.jb_weight` ist daneben ein vorhandenes Auftragsfeld (`votian/include/inc_job.inc.php:91`, `:112`). +- Die Historie liegt im untersuchten Zugriff unter `phoenix_log.log`. Eine nicht verfügbare Historienanbindung darf nicht als nachgewiesen leere Historie erscheinen; sie benötigt eine ausdrückliche Kennzeichnung (`votian/include/caglobal.inc.php:2254`). diff --git a/docs/PRUEFSTATUS.md b/docs/PRUEFSTATUS.md new file mode 100644 index 0000000..25a8b27 --- /dev/null +++ b/docs/PRUEFSTATUS.md @@ -0,0 +1,70 @@ +# Prüfstatus der ersten Bestandsanbindung + +Stand: 1. Oktober 2026. + +## Aktuelle Umstellung auf den zentralen Datenbankservice + +Die neue Anwendung verwendet für jeden Datenbankzugriff `database.DatabaseService`, einschließlich Anmeldung, Listen, CSV-Leseabfragen, Verbindungstest und Schemadiagnose. Python ist bei der Diagnose nur noch der Launcher des Java-JARs. Der zentrale Schreibzugang bleibt gesperrt. Architekturtests erzwingen die Grenze auch für die Integrationstests; die PHP-Altanwendung bleibt unverändert. + +Nach wiederhergestellter Datenbankverbindung wurden am 1. Oktober 2026 um 17:00 Uhr **49 Backend-Tests einschließlich des Integrationstests gegen die Bestandsdatenbank** erfolgreich ausgeführt: keine Fehler, keine übersprungenen Tests. Außerdem bestanden alle **10 Python-Tests**. Der Integrationstest lädt Aufträge, Kunden und Kuriere über die API und den zentralen Service mit simuliertem Anwendungskonto und echten SELECT-Abfragen. + +Die erneute Java-CLI-Diagnose war ebenfalls erfolgreich: MariaDB `11.7.2-MariaDB-ubu2404-log`, 13 geprüfte Tabellen, keine fehlenden erforderlichen Spalten. Es wurden keine Daten oder Tabellen geändert. Die Anwendung bleibt gestoppt; nach den Tests waren die Ports 8080 und 5173 frei. + +Im ersten erreichbaren Testlauf fiel eine ungeeignete Prüfung von `@@session.tx_read_only` auf: Dieser Wert beschreibt den Sessionstandard, nicht den Modus der einzelnen Transaktion. Die Prüfung wurde ersetzt und der Service aktiviert nun ausdrücklich `DataSourceTransactionManager.setEnforceReadOnly(true)`. Ein zusätzlicher Test mit echtem Transaktionsmanager und gemockter Verbindung prüft die Reihenfolge `SET TRANSACTION READ ONLY → SELECT → COMMIT`; es gibt keine Schreibversuche gegen den Bestand. Grundlagen: [MariaDB SET TRANSACTION](https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/set-commands/set-transaction), [Spring DataSourceTransactionManager](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jdbc/datasource/DataSourceTransactionManager.html#setEnforceReadOnly(boolean)). + +Der zuvor bei unterbrochener Verbindung geprüfte Fehlerfall bleibt gültig: Der CLI-Modus liefert bereinigtes JSON mit `reason: UNAVAILABLE`, Exitcode 1 und startet keinen Webserver. + +## Durchgeführt + +| Prüfung | Ergebnis | +|---|---| +| React-/TypeScript-Produktionsbuild | Erfolgreich | +| Spring-Boot `clean verify`, inklusive gebündeltem React | Erfolgreich | +| 20 Backend-Tests ohne Bestandszugriff | Erfolgreich: Legacy-Mapping einschließlich Boolean-Flags und mehrwertiger Statuscodes, Rechte, Niederlassung, CSV, gesperrte Schreibmethoden | +| 1 optionaler Integrationstest mit vorhandener Datenbank | Erfolgreich: Auftrags-, Kunden- und Kurier-API über MockMvc mit echten JDBC-Abfragen; Test-Authentifizierung, keine Datenänderungen | +| 7 Python-Konfigurationstests | Erfolgreich: Kommentare, Escaping, Umgebungspriorität, keine PHP-Ausführung | +| 1 HTTP-Integrationstest gegen das gebaute JAR | Erfolgreich: Health, Offline-Systemzustand, geschützte API, öffentliche Logo-Datei | +| Manuelle Browserprüfung im integrierten Browser | Anmeldung mit Testantworten, Auftragsliste, Mehrstoppdetails, Disposition, Touren, Kunden, Kuriere, Abrechnungshinweis, Auswertung, serverseitige Suche, Logout und eingeschränktes Folgekonto geprüft | +| Browserprüfung mit fehlenden Preisen und Terminen | Keine Browserfehler; unbekannte Werte bleiben „–“, Summen als unvollständig gekennzeichnet | +| Abhängigkeiten des Frontends | `npm install` meldet keine bekannten Sicherheitslücken | +| Verbindung und Schemametadaten der Bestandsdatenbank | Erfolgreich mit `scripts/check-database.py`: 13 Tabellen geprüft, keine erforderlichen Spalten fehlen; keine Anwendungsdatensätze gelesen und keine Schreibzugriffe | +| Backend nach Neustart mit dem konfigurierten Datenbankkonto | Erfolgreich: `GET /api/health` liefert `status: UP`; `GET /api/system` liefert `connected: true`, `reason: CONNECTED`, `readOnly: true`, `schemaChanges: false` | + +Für die Browserprüfung diente ausschließlich ein separater lokaler HTTP-Testserver mit ausdrücklich markierten Testantworten. Er ist kein Bestandteil der Anwendung und hat keine Datenbank. Es wurden keine Testdatensätze in MySQL angelegt. Die vollständige automatisierte Playwright-Browsersuite ist vorbereitet, konnte hier wegen eines abgewiesenen Chrome-Prozessstarts nicht ausgeführt werden; sie wird nicht als bestanden gewertet. Die entsprechende Bedienung wurde im integrierten Browser geprüft. + +## Verbindung zur Bestandsdatenbank + +Die lokale Verbindungskonfiguration steht in der `.env` im Projektstamm: `DB_URL` enthält Host, Port und Datenbanknamen; `DB_USER` und `DB_PASSWORD` enthalten das Datenbankkonto. Die Prüfung am 1. Oktober 2026 mit `scripts/check-database.py` war erfolgreich: + +- Verbindung: `connected: true` +- Serverversion: `11.7.2-MariaDB-ubu2404-log` +- Geprüfte Tabellen: `checkedTables: 13` +- Fehlende erforderliche Spalten: `missingColumns: {}` +- Gelesene Anwendungsdatensätze: `applicationRowsRead: 0` +- Schreibzugriffe: `writes: 0` + +Die mit dem zuvor verwendeten Datenbankkonto aufgetretene Anmeldungssperre (MySQL-Fehler **1045**, auch beim Versuch mit TLS) blockiert den Zugriff mit dem anschließend konfigurierten Datenbankkonto nicht mehr. Die Prüfung hat ausschließlich Server- und Schemametadaten gelesen; sie bestätigt die Existenz der vom Prüfskript benötigten Tabellen und Spalten. + +## Noch nicht erfolgreich geprüft + +Die SQL-Zuordnungen beruhen auf dem vorhandenen PHP-Quellcode. Die drei Listen wurden mit jeweils 500 Bestandsdatensätzen erfolgreich gelesen. Der Fehler beim Kunden-/Kurierlesen wurde zuvor als `NumberFormatException` bei `company.cmp_archived` reproduziert: JDBC lieferte einen Boolean, der Mapper erwartete eine Zahl. Der Mapper akzeptiert nun Boolean-Flags; `tinyInt1isBit=false` erhält außerdem mehrwertige numerische Legacy-Codes. Alle 21 Backend-Tests einschließlich der optionalen Bestandsprüfung bestanden nach der Korrektur. + +Die Bestandsprüfung verwendet ausschließlich für den Test eine simulierte Authentifizierung innerhalb einer vorhandenen Niederlassung. Sie bestätigt weder die reale Benutzeranmeldung noch sämtliche Filter und Berechtigungsgrenzen. Diese sowie die fachlichen Ergebnisse sind weiterhin gegen die PHP-Anwendung zu vergleichen. + +### Tagesansicht der offenen Aufträge + +Am 1. Oktober 2026 wurde die Auswahl für das vorhandene Zentralenkonto `admin_b` zusätzlich mit dessen Niederlassung und Rechten über den echten Leseadapter geprüft: Status `DISPOSITION` und Abholdatum 1. Oktober 2026 liefern genau einen Auftrag, `21891299`. Die zuvor fehlende Datumseinschränkung hatte auch historische und weit in der Zukunft geplante Aufträge geladen. Die Oberfläche verwendet wie die Standardliste im PHP-Bestand den heutigen lokalen Kalendertag als vorbelegten Zeitraum; andere Zeiträume bleiben über die Filter auswählbar. Es gibt keine fest programmierte Konto- oder Auftragsausnahme und keine Statusänderung in der Datenbank. + +Frontend- und Backend-Build nach dieser Änderung erfolgreich (20 reguläre Backend-Tests bestanden; die optionale Bestands-Testsuite in diesem Build deaktiviert). Im Browser mit isolierten Testantworten geprüft: Schon die erste Listenanfrage enthält beide Tagesgrenzen; ein bewusst erweiterter Zeitraum zeigt zusätzlich einen Zukunftsauftrag; „Heute“ reduziert die Liste wieder auf den Tagesbestand. Das laufende Backend wurde neu gestartet und liefert den aktualisierten Frontend-Build. + +## Bewusst noch offene Fachlichkeit + +Die neue Oberfläche und der JDBC-Leseadapter sind eine erste Ausbaustufe, keine vollständige fachliche Ablösung. Noch offen sind insbesondere: + +- Native Auftragserfassung/-bearbeitung, Disposition mit Fahrerkommunikation, Stationsabschluss und Storno einschließlich Gegenbuchungen. +- Originale Tarife, Preisgültigkeiten, Rabatte, Zuschläge, Kurierabrechnung, Rechnungserstellung und Buchhaltungsexporte. +- Kostenstellenhierarchie, separate Fahrzeuge, Daueraufträge, Tourvorlagen, Lager/Artikel und kundenspezifische Schnittstellen. +- Kunden-/Kurierportale, zusätzliche Niederlassungsrechte, Archiv- und Log-Datenbanken. +- Konfiguration der tatsächlich vorhandenen PHP-Basisadresse `LEGACY_APP_URL`, um noch nicht portierte Fachabläufe gezielt aufzurufen. + +Die Detailanalyse steht in `ALTSYSTEM-ANALYSE.md`. Es gibt keine neue Datenbank, keine automatische Schemaänderung und keinen Seed-Mechanismus. Die bisherige PHP-Anwendung wurde durch diese Umsetzung nicht verändert. diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..62ccde4 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3dcb364 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + Votian NG · Stadtbote + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..3157759 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,944 @@ +{ + "name": "votian-ng-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "votian-ng-frontend", + "version": "1.0.0", + "dependencies": { + "lucide-react": "^0.468.0", + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "6.1.1", + "typescript": "~5.8.3", + "vite": "8.3.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9ffba48 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "votian-ng-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -b && vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "react": "19.3.0", + "react-dom": "19.3.0", + "lucide-react": "^0.468.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "6.1.1", + "typescript": "~5.8.3", + "vite": "8.3.1" + } +} diff --git a/frontend/public/stadtbote-logo.png b/frontend/public/stadtbote-logo.png new file mode 100644 index 0000000..ef96446 Binary files /dev/null and b/frontend/public/stadtbote-logo.png differ diff --git a/frontend/public/votian-logo.png b/frontend/public/votian-logo.png new file mode 100644 index 0000000..3b7c9a6 Binary files /dev/null and b/frontend/public/votian-logo.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..a771d80 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,3585 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { FormEvent, ReactNode } from "react"; +import { + ArrowDownToLine, + ArrowRight, + ArrowUpRight, + BarChart3, + Check, + CheckCheck, + ChevronDown, + ChevronLeft, + ChevronRight, + Clock3, + Copy, + CreditCard, + FileText, + Filter, + LayoutGrid, + List, + LoaderCircle, + LogOut, + MapPin, + MoreHorizontal, + Package, + Pencil, + Plus, + RefreshCw, + Route, + Search, + ShieldCheck, + Truck, + Users, + X, + XCircle, +} from "lucide-react"; +import { + ApiError, + exportCsv, + getSession, + login, + logout, + onSessionExpired, + patch, + post, + put, + request, +} from "./api"; +import type { + Courier, + Customer, + Invoice, + Order, + OrderStatus, + Stop, + SystemStatus, + User, +} from "./types"; +const STATUS: Record = { + DISPOSITION: { label: "Offen", className: "pending" }, + OFFERED: { label: "Angeboten", className: "offered" }, + IN_PROGRESS: { label: "Unterwegs", className: "progress" }, + COMPLETED: { label: "Erledigt", className: "complete" }, + CANCELLED: { label: "Storniert", className: "cancelled" }, +}; +const COURIER_STATUS = { + AVAILABLE: "Verfügbar", + BUSY: "Im Einsatz", + OFFLINE: "Offline", +}; +const modules = [ + { id: "orders", label: "Aufträge", icon: Package }, + { id: "dispatch", label: "Disposition", icon: LayoutGrid }, + { id: "tours", label: "Touren", icon: Route }, + { id: "customers", label: "Kunden", icon: Users }, + { id: "couriers", label: "Kuriere", icon: Truck }, + { id: "invoices", label: "Abrechnung", icon: FileText }, + { id: "reports", label: "Auswertung", icon: BarChart3 }, +] as const; +type Module = (typeof modules)[number]["id"]; +const euro = (n: number | null | undefined) => + n == null + ? "–" + : new Intl.NumberFormat("de-DE", { + style: "currency", + currency: "EUR", + }).format(n); +const number = (n: number | null | undefined) => + n == null + ? "–" + : new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }).format(n); +const date = (s?: string | null) => + s ? new Date(s).toLocaleDateString("de-DE") : "–"; +const time = (s?: string | null) => + s + ? new Date(s).toLocaleTimeString("de-DE", { + hour: "2-digit", + minute: "2-digit", + }) + : "–"; +const datePart = (s?: string | null) => s?.slice(0, 10) || ""; +const sumValues = (values: (number | null | undefined)[]) => + values.some((v) => v == null) + ? null + : values.reduce((total, v) => total + (v ?? 0), 0); +const gross = (o: Order) => + o.netPrice == null || o.vatRate == null + ? null + : o.netPrice * (1 + o.vatRate / 100); +const day = (d = new Date()) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +const dateTimeInput = (s?: string | null) => + s ? s.slice(0, 16) : `${day()}T${new Date().toTimeString().slice(0, 5)}`; +const initials = (s: string) => + s + .split(" ") + .slice(0, 2) + .map((v) => v[0]) + .join("") + .toUpperCase(); +const emptyStop = (sequence: number): Stop => ({ + sequence, + type: sequence === 1 ? "PICKUP" : "DELIVERY", + name: "", + street: "", + postalCode: "", + city: "", + contact: "", + phone: "", + note: "", +}); +function Badge({ status, label }: { status: OrderStatus; label?: string }) { + const s = STATUS[status] || { label: status, className: "cancelled" }; + return ( + + + {label || s.label} + + ); +} +function Field({ + label, + children, + wide = false, +}: { + label: string; + children: ReactNode; + wide?: boolean; +}) { + return ( + + ); +} +function Empty({ + title, + detail, + children, +}: { + title: string; + detail: string; + children?: ReactNode; +}) { + return ( +
+
+ +
+

{title}

+

{detail}

+ {children} +
+ ); +} +function Modal({ + title, + subtitle, + children, + onClose, + wide = false, +}: { + title: string; + subtitle?: string; + children: ReactNode; + onClose: () => void; + wide?: boolean; +}) { + const ref = useRef(null); + useEffect(() => { + const previous = document.activeElement as HTMLElement; + const target = ref.current; + target?.querySelector("input,select,button")?.focus(); + const key = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + if (e.key === "Tab" && target) { + const items = Array.from( + target.querySelectorAll( + "button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),a[href]", + ), + ); + if (!items.length) return; + const first = items[0], + last = items[items.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + document.addEventListener("keydown", key); + return () => { + document.removeEventListener("keydown", key); + previous?.focus(); + }; + }, [onClose]); + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ +
+ {children} +
+
+ ); +} +function Login({ + onLogin, + notice, +}: { + onLogin: (u: User) => void; + notice: string; +}) { + const [system, setSystem] = useState(null), + [connectionError, setConnectionError] = useState(""); + const checkConnection = useCallback(() => { + setConnectionError(""); + request("/system") + .then(setSystem) + .catch(() => + setConnectionError( + "Der Spring-Boot-Server ist nicht erreichbar. Bitte den Backend-Dienst starten.", + ), + ); + }, []); + useEffect(() => checkConnection(), [checkConnection]); + const [username, setUsername] = useState(""), + [password, setPassword] = useState(""), + [error, setError] = useState(""), + [busy, setBusy] = useState(false); + async function submit(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + try { + onLogin(await login(username, password)); + setPassword(""); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + return ( +
+
+
+ votianNG +
+ STADTBOTE · TRANSPORTMANAGEMENT +

+ Alles auf Kurs. +
+ Von Auftrag +
+ bis Ankunft. +

+

+ Die gemeinsame Arbeitsfläche für Ihre Aufträge, Kuriere und + Disposition. +

+
+ + + + + + + +
+
Vertraute Abläufe. Neue Technik.
+
+
+
+ Stadtbote + WILLKOMMEN ZURÜCK +

Bei Votian NG anmelden

+

+ Verwenden Sie Ihr vorhandenes VoTiAn-Konto der Zentrale. +

+ + setUsername(e.target.value)} + required + /> + + + setPassword(e.target.value)} + required + /> + + {(error || notice) && ( +
+ {error || notice} +
+ )} + +
+ +
+ + {system?.connected + ? "Bestehende VoTiAn-Datenbank verbunden" + : connectionError + ? "Backend nicht erreichbar" + : system + ? "Datenbankverbindung nicht verfügbar" + : "Verbindung wird geprüft …"} + +

+ {connectionError || + system?.message || + "Die Verbindung zum bestehenden System wird geprüft."} +

+ {system && !system.connected && ( +

Bitte prüfen Sie die angezeigte Ursache.

+ )} + + {system?.legacyUrl && ( + + Bestehende Anwendung öffnen + + )} +
+
+
+ Stadtbote GmbH · Votian NG +
+
+ ); +} +export default function App() { + const [user, setUser] = useState(null), + [checking, setChecking] = useState(true), + [notice, setNotice] = useState(""); + const currentUser = useRef(null); + const acceptUser = useCallback((value: User) => { + currentUser.current = value; + setUser(value); + setNotice(""); + }, []); + useEffect(() => { + let active = true; + const unsubscribe = onSessionExpired(() => { + if (!active || !currentUser.current) return; + currentUser.current = null; + setUser(null); + setNotice("Die Sitzung ist abgelaufen. Bitte erneut anmelden."); + }); + getSession() + .then((value) => { + if (active) acceptUser(value); + }) + .catch((error: unknown) => { + if (active && !(error instanceof ApiError && error.status === 401)) + setNotice((error as Error).message); + }) + .finally(() => { + if (active) setChecking(false); + }); + return () => { + active = false; + unsubscribe(); + }; + }, [acceptUser]); + async function signOut() { + await logout(); + currentUser.current = null; + setUser(null); + setNotice(""); + } + if (checking) + return ( +
+ + Votian NG wird geladen … +
+ ); + if (!user) return ; + // Unmounting discards all user data and callbacks from the previous session. + return ; +} + +function Workspace({ + user, + onLogout, +}: { + user: User; + onLogout: () => Promise; +}) { + const [loggingOut, setLoggingOut] = useState(false), + [logoutError, setLogoutError] = useState(""); + const [system, setSystem] = useState(null), + [orderTotal, setOrderTotal] = useState(null); + const initialDate = useRef(day()).current; + const queryRef = useRef( + new URLSearchParams({ from: initialDate, to: initialDate }).toString(), + ); + const masterQueryRef = useRef({ module: "orders", search: "" }); + const requestId = useRef(0); + const [module, setModule] = useState("orders"), + [orders, setOrders] = useState([]), + [customers, setCustomers] = useState([]), + [couriers, setCouriers] = useState([]), + [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(false), + [loaded, setLoaded] = useState(false), + [busy, setBusy] = useState(false), + [error, setError] = useState(""), + [toast, setToast] = useState(""), + [lastSync, setLastSync] = useState(""); + const [search, setSearch] = useState(""), + [status, setStatus] = useState(""), + [customerId, setCustomerId] = useState(""), + [courierId, setCourierId] = useState(""), + [from, setFrom] = useState(initialDate), + [to, setTo] = useState(initialDate), + [autoRefresh, setAutoRefresh] = useState(false), + [showFilters, setShowFilters] = useState(false), + [page, setPage] = useState(1); + const [selected, setSelected] = useState(null), + [orderEditor, setOrderEditor] = useState(null), + [masterEditor, setMasterEditor] = useState<{ + kind: "customers" | "couriers"; + value: Customer | Courier | null; + } | null>(null), + [cancelOrder, setCancelOrder] = useState(null), + [invoiceEditor, setInvoiceEditor] = useState(false), + [selectedInvoice, setSelectedInvoice] = useState(null), + [tourDay, setTourDay] = useState(day()); + const caps = system?.capabilities; + const moduleRights: Record = { + orders: !!caps?.readOrders, + dispatch: !!caps?.readOrders, + tours: !!caps?.readOrders, + customers: !!caps?.readCustomers, + couriers: !!caps?.readCouriers, + invoices: !!caps?.readInvoices, + reports: !!caps?.readOrders, + }; + const moduleAllowed = moduleRights[module]; + const legacyPaths: Record = { + orders: "admin/jb_list.php", + dispatch: "admin/jb_list.php", + tours: "admin/jb_list.php", + customers: "admin/customer_list.php", + couriers: "admin/courier_list.php", + invoices: "invoice/invoice.php", + reports: "statistic/statistic.php", + }; + const legacyLink = system?.legacyUrl + ? system.legacyUrl.replace(/\/?$/, "/") + legacyPaths[module] + : null; + const canEdit = !!system?.capabilities.writeOrders; + const canWriteModule = + module === "customers" + ? !!system?.capabilities.writeCustomers + : module === "couriers" + ? !!system?.capabilities.writeCouriers + : module === "invoices" + ? !!system?.capabilities.writeInvoices + : canEdit; + const customerName = useCallback( + (id: number) => customers.find((c) => c.id === id)?.name || `Kunde #${id}`, + [customers], + ); + const courierName = useCallback( + (id: number | null) => + couriers.find((c) => c.id === id)?.name || + (id ? `Kurier #${id}` : "Noch nicht zugewiesen"), + [couriers], + ); + const load = useCallback(async () => { + setLoading(true); + const seq = ++requestId.current; + try { + // /system is public and returns no capabilities for an expired session. + // Check authentication first so expiry always unmounts the workspace. + await request("/me"); + if (seq !== requestId.current) return; + const state = await request("/system"); + if (seq !== requestId.current) return; + setSystem(state); + if (!state.connected) { + setLoaded(false); + setError(""); + return; + } + const caps = state.capabilities; + const [o, c, k, i] = await Promise.all([ + caps.readOrders + ? request( + "/orders" + (queryRef.current ? "?" + queryRef.current : ""), + {}, + (response) => { + if (seq === requestId.current) + setOrderTotal( + response.headers.has("X-Total-Count") + ? Number(response.headers.get("X-Total-Count")) + : null, + ); + }, + ) + : Promise.resolve([]), + caps.readCustomers + ? request( + "/customers" + + (masterQueryRef.current.module === "customers" && + masterQueryRef.current.search + ? "?q=" + encodeURIComponent(masterQueryRef.current.search) + : ""), + ) + : Promise.resolve([]), + caps.readCouriers + ? request( + "/couriers" + + (masterQueryRef.current.module === "couriers" && + masterQueryRef.current.search + ? "?q=" + encodeURIComponent(masterQueryRef.current.search) + : ""), + ) + : Promise.resolve([]), + caps.readInvoices + ? request("/invoices") + : Promise.resolve([]), + ]); + if (seq !== requestId.current) return; + setOrders(o); + setCustomers(c); + setCouriers(k); + setInvoices(i); + setLastSync( + new Date().toLocaleTimeString("de-DE", { + hour: "2-digit", + minute: "2-digit", + }), + ); + setLoaded(true); + setError(""); + setSelected((s) => (s ? o.find((x) => x.id === s.id) || null : null)); + setSelectedInvoice((s) => + s ? i.find((x) => x.id === s.id) || null : null, + ); + } catch (e) { + if (seq !== requestId.current) return; + setLoaded(false); + setError((e as Error).message); + } finally { + if (seq === requestId.current) setLoading(false); + } + }, []); + const orderQuery = useMemo(() => { + const p = new URLSearchParams(); + if (search && ["orders", "dispatch"].includes(module)) p.set("q", search); + if (status) p.set("status", status); + if (customerId) p.set("customerId", customerId); + if (courierId) p.set("courierId", courierId); + if (from) p.set("from", from); + if (to) p.set("to", to); + if (module === "tours") { + p.set("from", tourDay); + p.set("to", tourDay); + } + return p.toString(); + }, [search, status, customerId, courierId, from, to, module, tourDay]); + queryRef.current = orderQuery; + masterQueryRef.current = { module, search }; + const dataQuery = ["customers", "couriers"].includes(module) + ? module + ":" + search + : orderQuery; + const queryEffect = useRef(""); + useEffect(() => { + if (!user || queryEffect.current === dataQuery) return; + queryEffect.current = dataQuery; + const timer = setTimeout(() => void load(), 350); + return () => clearTimeout(timer); + }, [dataQuery, user, load]); + useEffect(() => { + if (user) void load(); + }, [user, load]); + useEffect(() => { + if (!autoRefresh || !user) return; + const id = setInterval(() => void load(), 30000); + return () => clearInterval(id); + }, [autoRefresh, user, load]); + useEffect(() => { + if (!toast) return; + const id = setTimeout(() => setToast(""), 4500); + return () => clearTimeout(id); + }, [toast]); + useEffect( + () => setPage(1), + [search, status, customerId, courierId, from, to, module], + ); + const run = useCallback( + async ( + action: () => Promise, + message: string, + onSuccess?: () => void, + ) => { + setBusy(true); + try { + await action(); + await load(); + onSuccess?.(); + setToast(message); + } catch (e) { + setError((e as Error).message); + throw e; + } finally { + setBusy(false); + } + }, + [load], + ); + const safeRun = ( + action: () => Promise, + message: string, + onSuccess?: () => void, + ) => void run(action, message, onSuccess).catch(() => {}); + const filtered = useMemo( + () => + orders + .slice() + .sort((a, b) => (b.pickupAt || "").localeCompare(a.pickupAt || "")), + [orders], + ); + const todayOrders = orders.filter((o) => datePart(o.pickupAt) === day()); + const activeOrders = orders.filter( + (o) => !["COMPLETED", "CANCELLED"].includes(o.status), + ); + const tourCouriers = [ + ...new Set( + orders + .filter( + (o) => datePart(o.pickupAt) === tourDay && o.status !== "CANCELLED", + ) + .map((o) => o.courierId || 0), + ), + ].map( + (id) => + couriers.find((c) => c.id === id) || { + id, + name: id ? `Kurier #${id}` : "Noch nicht zugewiesen", + vehicle: id ? "Fahrzeug nicht geladen" : "Disposition", + number: "", + status: "OFFLINE", + }, + ); + function resetDateRange() { + const today = day(); + setFrom(today); + setTo(today); + } + function switchModule(m: Module) { + setModule(m); + setSearch(""); + setSelected(null); + setStatus(""); + setCustomerId(""); + setCourierId(""); + resetDateRange(); + } + async function download() { + try { + const p = new URLSearchParams(); + if (search) p.set("q", search); + if (status) p.set("status", status); + if (customerId) p.set("customerId", customerId); + if (courierId) p.set("courierId", courierId); + if (from) p.set("from", from); + if (to) p.set("to", to); + await exportCsv(p); + setToast("CSV-Export wurde erstellt."); + } catch (e) { + setError((e as Error).message); + } + } + const currentModule = modules.find((m) => m.id === module)!; + const subtitles: Record = { + orders: "Aufträge aus dem bestehenden VoTiAn-System im Blick.", + dispatch: + "Aufträge verteilen, Fortschritt verfolgen und Kuriere koordinieren.", + tours: "Die Tagesplanung Ihrer Kuriere mit allen Stationen.", + customers: "Auftraggeber, Ansprechpartner und Abrechnungsdaten.", + couriers: "Ihre Kuriere, Fahrzeuge und aktuelle Verfügbarkeit.", + invoices: "Erledigte Leistungen bündeln und Rechnungen verwalten.", + reports: + "Kennzahlen aus dem aktuell geladenen Ausschnitt der Bestandsdaten.", + }; + return ( +
+
+
+ + votianNG + + + KURIER & LOGISTIK +
+
+ + + {system?.connected ? "VOTIAN VERBUNDEN" : "KEINE VERBINDUNG"} + + Stadtbote GmbH + Stadtbote +
{initials(user.username)}
+ +
+
+ +
+
+ Arbeitsplatz {currentModule.label} + + {new Date().toLocaleDateString("de-DE", { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + })} + +
+
+
+
+ STADTBOTE ·{" "} + {module === "orders" + ? "AUFTRAGSMANAGEMENT" + : currentModule.label.toUpperCase()} +
+

+ {currentModule.label} + {module === "orders" && loaded && caps?.readOrders && ( + {orders.length} + )} +

+

{subtitles[module]}

+
+
+ + {canWriteModule && + (["orders", "dispatch", "tours"].includes(module) ? ( + + ) : module === "customers" || module === "couriers" ? ( + + ) : module === "invoices" ? ( + + ) : null)} +
+
+ {(logoutError || error) && ( +
+ + {logoutError || error} + +
+ )} + {!loaded ? ( +
+ {loading ? ( + <> + +

Verbindung wird geprüft

+

+ Die Daten werden aus der bestehenden VoTiAn-Datenbank gelesen. +

+ + ) : ( + <> +
+ +
+

+ {system?.connected + ? "Daten konnten nicht geladen werden" + : "Keine Verbindung zur VoTiAn-Datenbank"} +

+

+ {system?.message || + "Bitte prüfen Sie, ob der Backend-Dienst und die vorhandene Datenbank erreichbar sind."} +

+

+ Es werden keine Ersatzdaten angezeigt. Sobald die Verbindung + verfügbar ist, können die Bestandsdaten geladen werden. +

+ + + )} +
+ ) : !moduleAllowed ? ( +
+ + {legacyLink && ( + + In VoTiAn öffnen + + )} + +
+ ) : ( + <> + {!canWriteModule && ( +
+ + + Bestandsdaten · Lesezugriff Änderungen und + Buchungen erfolgen weiterhin in der bestehenden + VoTiAn-Anwendung. + + {legacyLink && ( + + VoTiAn öffnen + + )} +
+ )} + + {["orders", "dispatch", "tours", "reports"].includes(module) && ( +
+ + Geladener Ausschnitt: {orders.length} + {orderTotal !== null ? ` von ${orderTotal}` : ""} Aufträge · + höchstens {system?.maxResults || 500} Einträge je Abfrage. + + + Filter eingrenzen. Kennzahlen beziehen sich nur auf diesen + Ausschnitt. + +
+ )} + {["orders", "dispatch", "tours", "reports"].includes(module) && ( +
+ } + /> + o.status === "DISPOSITION").length, + )} + detail="Bereit für die Zuweisung" + icon={} + tone="amber" + /> + o.status === "IN_PROGRESS").length, + )} + detail={ + caps?.readCouriers + ? `${couriers.filter((c) => c.active && c.status === "AVAILABLE").length} Kuriere verfügbar` + : "Kurierdaten nicht freigegeben" + } + icon={} + tone="blue" + /> + + o.status === "COMPLETED" && + o.deliveredAt?.slice(0, 10) === day(), + ).length, + )} + detail="Erfolgreich abgeschlossen" + icon={} + tone="green" + /> +
+ )} + {(module === "orders" || module === "dispatch") && ( +
+
+
+ {[ + { value: "", label: "Geladene Aufträge" }, + { value: "DISPOSITION", label: "Offen" }, + { value: "IN_PROGRESS", label: "Unterwegs" }, + { value: "COMPLETED", label: "Erledigt" }, + ].map((t) => ( + + ))} +
+
+ + +
+
+
+ Abholzeitraum + + setFrom(e.target.value)} + /> + + + setTo(e.target.value)} + /> + + + + {from === day() && to === day() + ? "Heutige Aufträge" + : from && to + ? `${date(`${from}T00:00:00`)} – ${date(`${to}T00:00:00`)}` + : from + ? `Ab ${date(`${from}T00:00:00`)}` + : to + ? `Bis ${date(`${to}T00:00:00`)}` + : "Alle Abholtermine"} + +
+
+
+ + setSearch(e.target.value)} + aria-label="Aufträge suchen" + /> + {search && ( + + )} +
+ + + + +
+ {showFilters && ( +
+ + + + +
+ )} + {module === "orders" ? ( + <> +
+ + + + + + + + + + + + + + + {filtered + .slice((page - 1) * 12, page * 12) + .map((o) => ( + setSelected(o)} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter") setSelected(o); + }} + aria-label={`Auftrag ${o.number} öffnen`} + > + + + + + + + + + + + ))} + +
AuftragStatusKunde / Referenz + Abholung → Zustellung + AbholterminKurierFahrzeugNetto +
+ + {o.number} + + {o.service} + + + + + {customerName(o.customerId)} + + + {o.reference || "Keine Referenz"} + + +
+ + + {o.stops[0]?.city || "–"} + {o.stops[0]?.street} + + + + {o.stops.at(-1)?.city || "–"} + {o.stops.at(-1)?.street} + +
+
+ + {time(o.pickupAt)}{" "} + Uhr + + + {date(o.pickupAt)} + + + {o.courierId ? ( + + + {initials(courierName(o.courierId))} + + + {courierName(o.courierId)} + + { + couriers.find( + (c) => c.id === o.courierId, + )?.number + } + + + + ) : ( + + Nicht zugewiesen + + )} + + + + {o.vehicle} + + + {euro(o.netPrice)} + + +
+
+ {!filtered.length && ( + + )} +
+ + {filtered.length + ? `${(page - 1) * 12 + 1}–${Math.min(page * 12, filtered.length)} von ${filtered.length}` + : "0"}{" "} + Aufträgen + + +
+ + + {page} /{" "} + {Math.max(1, Math.ceil(filtered.length / 12))} + + +
+
+ + ) : ( +
+ {( + [ + "DISPOSITION", + "OFFERED", + "IN_PROGRESS", + "COMPLETED", + ] as OrderStatus[] + ) + .filter((s) => !status || s === status) + .map((s) => ( +
+

+ + + {filtered.filter((o) => o.status === s).length} + +

+ {filtered + .filter((o) => o.status === s) + .map((o) => ( + + ))} +
+ ))} + {status === "CANCELLED" && ( + + + + )} +
+ )} +
+ )} + {module === "tours" && ( +
+
+
+

Tagesübersicht

+ + Nach Kurier gruppiert · Reihenfolge nach Abholtermin + +
+ setTourDay(e.target.value)} + /> +
+
+ {tourCouriers.map((c) => { + const items = orders + .filter( + (o) => + (o.courierId || 0) === c.id && + datePart(o.pickupAt) === tourDay && + o.status !== "CANCELLED", + ) + .sort((a, b) => + (a.pickupAt || "").localeCompare(b.pickupAt || ""), + ); + if (!items.length) return null; + return ( +
+
+ + {c.id ? initials(c.name) : } + +
+

{c.name}

+ + {c.vehicle} · {items.length} Aufträge ·{" "} + {items.reduce((n, o) => n + o.stops.length, 0)}{" "} + Stationen + +
+
+ {items.map((o) => ( + + ))} +
+ ); + })} +
+ {!orders.some( + (o) => + datePart(o.pickupAt) === tourDay && + o.status !== "CANCELLED", + ) && ( + + )} +
+ )} + {(module === "customers" || module === "couriers") && ( +
+
+
+

+ {module === "customers" + ? "Kundenverzeichnis" + : "Kurierverzeichnis"}{" "} + + {module === "customers" + ? customers.length + : couriers.length} + +

+ + Geladene Stammdaten · höchstens{" "} + {system?.maxResults || 500} Einträge je Abfrage + +
+
+ + setSearch(e.target.value)} + placeholder={ + module === "customers" + ? "Kunden suchen …" + : "Kuriere suchen …" + } + aria-label="Stammdaten suchen" + /> +
+
+
+ + + {module === "customers" ? ( + + + + + + + + + ) : ( + + + + + + + + + )} + + + {module === "customers" + ? customers.map((c) => ( + + + + + + + + + + )) + : couriers.map((c) => ( + + + + + + + + + + ))} + +
KundeAdresseAnsprechpartnerKontaktZahlungszielStatus +
KurierFahrzeugKennzeichenTelefonVerfügbarkeitAktiv im geladenen Ausschnitt +
+ {c.name} + {c.number} + + {c.street} + + {c.postalCode} {c.city} + + {c.contact || "–"} + {c.email || "–"} + {c.phone} + + {c.paymentDays == null + ? "–" + : c.paymentDays + " Tage"} + + + + {c.active ? "Aktiv" : "Inaktiv"} + + + {canWriteModule && ( + + )} +
+ + + {initials(c.name)} + + + {c.name} + {c.number} + + + + + + {c.vehicle} + + + + {c.plate || "–"} + + {c.phone || "–"} + + + {c.active + ? COURIER_STATUS[c.status] + : "Inaktiv"} + + + {caps?.readOrders + ? activeOrders.filter( + (o) => o.courierId === c.id, + ).length + : "–"} + + {canWriteModule && ( + + )} +
+
+ {(module === "customers" ? customers : couriers).length === + 0 && ( + + )} +
+ )} + {module === "invoices" && ( + <> +
+ i.status === "ISSUED").length, + )} + detail={ + euro( + sumValues( + invoices + .filter((i) => i.status === "ISSUED") + .map((i) => i.grossAmount), + ), + ) + " brutto" + } + icon={} + /> + i.status === "PAID") + .map((i) => i.grossAmount), + ), + )} + detail={`${invoices.filter((i) => i.status === "PAID").length} Rechnungen`} + icon={} + tone="green" + /> + } + tone="blue" + /> +
+
+
+
+

Rechnungsübersicht

+ + Geladener Ausschnitt · höchstens{" "} + {system?.maxResults || 500} Einträge + +
+
+ + setSearch(e.target.value)} + placeholder="Rechnung oder Kunde suchen …" + aria-label="Rechnungen suchen" + /> +
+
+
+ + + + + + + + + + + + + + {invoices + .filter((i) => + [i.number, customerName(i.customerId)] + .join(" ") + .toLowerCase() + .includes(search.toLowerCase()), + ) + .map((i) => ( + setSelectedInvoice(i)} + tabIndex={0} + onKeyDown={(e) => + e.key === "Enter" && setSelectedInvoice(i) + } + > + + + + + + + + + + ))} + +
RechnungKundeRechnungsdatumFällig amAufträgeStatusBrutto +
+ + {i.number} + + {customerName(i.customerId)}{date(i.issuedAt)}{date(i.dueAt)}{i.orderIds.length} + + + {i.status === "PAID" ? "Bezahlt" : "Offen"} + + + {euro(i.grossAmount)} + + +
+
+ {!invoices.length && ( + + )} +
+ + )} + {module === "reports" && ( + + )} + + )} +
+ + Votian NG Stadtbote GmbH + + + {loading + ? "Daten werden aktualisiert …" + : `Zuletzt synchronisiert: ${lastSync || "–"} Uhr`}{" "} + + +
+
+ {toast && ( +
+ + {toast} +
+ )} + {selected && ( + c.id === selected.customerId)} + couriers={couriers} + canEdit={canEdit} + busy={busy} + onClose={() => setSelected(null)} + onEdit={() => setOrderEditor(selected)} + onCopy={() => + safeRun( + () => post("/orders/" + selected.id + "/copy"), + "Auftrag wurde kopiert.", + ) + } + onCancel={() => setCancelOrder(selected)} + onStatus={(s, c, r) => + safeRun( + () => + patch("/orders/" + selected.id + "/status", { + version: selected.version, + status: s, + courierId: c, + recipientName: r, + }), + "Auftragsstatus wurde aktualisiert.", + ) + } + onAssign={(c) => + safeRun( + () => + put("/orders/" + selected.id, { ...selected, courierId: c }), + "Kurier wurde zugewiesen.", + ) + } + onCompleteStop={(s, r) => + safeRun( + () => + patch(`/orders/${selected.id}/stops/${s.id}/complete`, { + version: selected.version, + recipientName: r, + }), + "Station wurde abgeschlossen.", + ) + } + /> + )} + {orderEditor && ( + setOrderEditor(null)} + onSave={async (value) => { + await run( + () => + orderEditor === "new" + ? post("/orders", value) + : put("/orders/" + orderEditor.id, value), + "Auftrag wurde gespeichert.", + () => setOrderEditor(null), + ); + }} + /> + )} + {masterEditor && ( + setMasterEditor(null)} + onSave={async (value) => { + await run( + () => + masterEditor.value + ? put(`/${masterEditor.kind}/${masterEditor.value.id}`, value) + : post("/" + masterEditor.kind, value), + "Stammdaten wurden gespeichert.", + () => setMasterEditor(null), + ); + }} + /> + )} + {cancelOrder && ( + setCancelOrder(null)} + onSave={async (mode, reason) => { + await run( + () => + post(`/orders/${cancelOrder.id}/cancel`, { + version: cancelOrder.version, + cancelMode: mode, + reason, + }), + "Auftrag wurde storniert.", + () => setCancelOrder(null), + ); + }} + /> + )} + {invoiceEditor && ( + setInvoiceEditor(false)} + onSave={async (customerId, orderIds) => { + await run( + () => post("/invoices", { customerId, orderIds }), + "Rechnung wurde erstellt.", + () => setInvoiceEditor(false), + ); + }} + /> + )} + {selectedInvoice && ( + c.id === selectedInvoice.customerId)} + orders={orders.filter((o) => selectedInvoice.orderIds.includes(o.id))} + canEdit={!!system?.capabilities.writeInvoices} + busy={busy} + onClose={() => setSelectedInvoice(null)} + onPaid={() => + safeRun( + () => + patch(`/invoices/${selectedInvoice.id}/status`, { + version: selectedInvoice.version, + status: selectedInvoice.status === "PAID" ? "ISSUED" : "PAID", + }), + "Rechnungsstatus wurde aktualisiert.", + ) + } + /> + )} +
+ ); +} +function Metric({ + label, + value, + detail, + icon, + tone = "purple", +}: { + label: string; + value: string; + detail: string; + icon: ReactNode; + tone?: string; +}) { + return ( +
+
+ {label} + {value} + {detail} +
+ {icon} +
+ ); +} +const isInvoiceable = (o: Order) => + !o.invoiceId && + (o.status === "COMPLETED" || + (o.status === "CANCELLED" && o.cancelMode === "CHARGEABLE")); +function OrderDetails({ + order: o, + customer, + couriers, + canEdit, + busy, + onClose, + onEdit, + onCopy, + onCancel, + onStatus, + onAssign, + onCompleteStop, +}: { + order: Order; + customer?: Customer; + couriers: Courier[]; + canEdit: boolean; + busy: boolean; + onClose: () => void; + onEdit: () => void; + onCopy: () => void; + onCancel: () => void; + onStatus: (s: OrderStatus, c: number | null, r: string) => void; + onAssign: (id: number | null) => void; + onCompleteStop: (s: Stop, r: string) => void; +}) { + useEffect(() => { + const key = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", key); + return () => document.removeEventListener("keydown", key); + }, [onClose]); + const [tab, setTab] = useState("details"), + [courier, setCourier] = useState(String(o.courierId || "")), + [recipient, setRecipient] = useState(""); + useEffect(() => setCourier(String(o.courierId || "")), [o.courierId]); + const actions: Record = + { + DISPOSITION: [{ status: "OFFERED", label: "Auftrag anbieten" }], + OFFERED: [ + { status: "IN_PROGRESS", label: "Fahrt starten" }, + { status: "DISPOSITION", label: "Zurück in Disposition" }, + ], + IN_PROGRESS: [ + { status: "COMPLETED", label: "Auftrag abschließen" }, + { status: "OFFERED", label: "Zurück auf angeboten" }, + ], + COMPLETED: [], + CANCELLED: [], + }; + return ( +
e.target === e.currentTarget && onClose()} + > + +
+ ); +} +function OrderForm({ + order, + customers, + couriers, + busy, + onClose, + onSave, +}: { + order: Order | null; + customers: Customer[]; + couriers: Courier[]; + busy: boolean; + onClose: () => void; + onSave: (value: unknown) => Promise; +}) { + const [stops, setStops] = useState( + order?.stops.map((s) => ({ ...s })) || [emptyStop(1), emptyStop(2)], + ), + [formError, setFormError] = useState(""); + const [net, setNet] = useState(order?.netPrice || 0), + [vat, setVat] = useState(order?.vatRate ?? 19); + const update = (i: number, key: keyof Stop, value: string) => + setStops((items) => + items.map((s, n) => (n === i ? { ...s, [key]: value } : s)), + ); + const move = (i: number, delta: number) => + setStops((items) => { + const result = [...items]; + [result[i], result[i + delta]] = [result[i + delta], result[i]]; + return result; + }); + async function submit(e: FormEvent) { + e.preventDefault(); + setFormError(""); + if ( + stops.length < 2 || + stops[0].type !== "PICKUP" || + !stops.some((s) => s.type === "DELIVERY") + ) { + setFormError( + "Die Tour benötigt eine Abholung als erste Station und mindestens eine Zustellung.", + ); + return; + } + const f = new FormData(e.currentTarget); + const text = (k: string) => String(f.get(k) || ""); + const num = (k: string) => Number(f.get(k) || 0); + const pickupAt = text("pickupAt"); + const deliveryDeadline = text("deliveryDeadline"); + if (deliveryDeadline && deliveryDeadline < pickupAt) { + setFormError("Die Zustellfrist muss nach dem Abholtermin liegen."); + return; + } + try { + await onSave({ + ...order, + customerId: num("customerId"), + courierId: text("courierId") ? num("courierId") : null, + service: text("service"), + vehicle: text("vehicle"), + pickupAt: pickupAt + ":00", + deliveryDeadline: deliveryDeadline ? deliveryDeadline + ":00" : null, + reference: text("reference"), + costCenter: text("costCenter"), + notes: text("notes"), + packages: num("packages"), + weight: num("weight"), + distanceKm: num("distanceKm"), + netPrice: net, + courierPrice: num("courierPrice"), + vatRate: vat, + stops: stops.map((s, i) => ({ ...s, sequence: i + 1 })), + }); + } catch (e) { + setFormError((e as Error).message); + } + } + return ( + +
+
+
+

+ 01Auftragsdaten +

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+

+ 02Transportstationen +

+ +
+ {stops.map((s, i) => ( +
+
+ + {i + 1} + + +
+ + + +
+
+
+ + update(i, "name", e.target.value)} + required + maxLength={200} + /> + + + update(i, "street", e.target.value)} + required + maxLength={200} + /> + +
+ + + update(i, "postalCode", e.target.value) + } + required + maxLength={12} + /> + + + update(i, "city", e.target.value)} + required + maxLength={100} + /> + +
+ + update(i, "contact", e.target.value)} + maxLength={100} + /> + + + update(i, "phone", e.target.value)} + maxLength={50} + /> + + + update(i, "note", e.target.value)} + maxLength={1000} + /> + +
+
+ ))} +
+
+

+ 03Disposition & Sendung +

+
+ + + + + + + + + + + + + + + + + + + + + + +
+ +