diff --git a/.env.example b/.env.example index 9240ee5..e7d43ad 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,6 @@ DB_URL=jdbc:mariadb://DB-HOST:3306/DB-NAME?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false DB_USER=CHANGE_ME DB_PASSWORD=CHANGE_ME +# Protokolltabellen log/logoperation auf demselben Server, gelesen mit DB_USER. +# Nur nötig, wenn das vorhandene Schema anders heißt: +# HISTORY_DB_SCHEMA=phoenix_log diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..89cb393 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,55 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "VoTiAn NG: Backend", + "type": "java", + "request": "launch", + "mainClass": "de.stadtbote.votian.VotianApplication", + "projectName": "votian-backend", + "cwd": "${workspaceFolder}/backend", + "envFile": "${workspaceFolder}/.env", + "args": [ + "--server.address=127.0.0.1", + "--server.port=8080" + ], + "console": "internalConsole" + }, + { + "name": "VoTiAn NG: Frontend + Chrome", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/frontend/node_modules/vite/bin/vite.js", + "cwd": "${workspaceFolder}/frontend", + "args": [ + "--host", "127.0.0.1", + "--port", "5173", + "--strictPort" + ], + "console": "internalConsole", + "outputCapture": "std", + "skipFiles": ["/**"], + "serverReadyAction": { + "pattern": "Local:\\s+(https?://\\S+)", + "uriFormat": "%s", + "action": "debugWithChrome", + "webRoot": "${workspaceFolder}/frontend", + "killOnServerStop": true + } + } + ], + "compounds": [ + { + "name": "VoTiAn NG: Backend + Frontend", + "configurations": [ + "VoTiAn NG: Backend", + "VoTiAn NG: Frontend + Chrome" + ], + "stopAll": true, + "presentation": { + "group": "VoTiAn NG", + "order": 1 + } + } + ] +} diff --git a/README.md b/README.md index 8145058..7ee9957 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,16 @@ 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. +## Debuggen in VS Code + +Den Projektstamm `votianNG` in VS Code öffnen. Benötigt werden das **Extension Pack for Java**, Java 21 und Google Chrome. Die Frontend-Abhängigkeiten einmalig mit `npm ci --prefix frontend` installieren und die lokale `.env` wie oben beschrieben einrichten. + +Unter **Ausführen und Debuggen** die Konfiguration **VoTiAn NG: Backend + Frontend** auswählen und mit `F5` starten. Das Backend wird vom Java-Debugger gebaut und mit der `.env` auf `127.0.0.1:8080` gestartet. Vite läuft auf `127.0.0.1:5173` und öffnet automatisch Chrome mit aktiviertem Browser-Debugger. Haltepunkte sind sowohl in Java als auch in React/TypeScript möglich. Die beiden Komponenten lassen sich über die Einzelkonfigurationen auch separat starten. + +Die Ports sind im Debugstart festgelegt, passend zum Vite-API-Proxy. Ein bereits laufendes `dev.sh` vorher beenden. Beim Stoppen einer Komponente des gemeinsamen Starts werden beide Server und die zugehörige Browser-Debugsitzung beendet. + +Der Java-Debugger liest `.env` mit dem üblichen dotenv-Parser: `$` bleibt wörtlich, ein unquotiertes `#` beginnt dagegen einen Kommentar. Bei solchen Werten sind Anführungszeichen erforderlich; für Docker gegebenenfalls eine separate Datei mit unquotierten Werten verwenden. + ## Gebündelte Anwendung ```bash diff --git a/backend/README.md b/backend/README.md index 61fe825..2b760ee 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,7 +4,7 @@ Java 21, Spring Boot 3.5.16 und MariaDB JDBC. Dieser Adapter verwendet ausschlie ## 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. +`database.DatabaseService` ist die einzige Komponente mit Zugriff auf `JdbcTemplate` und den Transaktionsmanager. `LegacyAuthenticationProvider`, `LegacyReadService`, `LegacyHistoryService` und `database.DatabaseDiagnostics` verwenden ausschließlich diesen Service; auch Integrationstests lesen den Bestand über ihn. Der Service aktiviert `setEnforceReadOnly(true)`, sodass jede Lesetransaktion mit `SET TRANSACTION READ ONLY` serverseitig abgesichert wird. Der JDBC-Hinweis allein reicht hierfür beim verwendeten MariaDB-Treiber nicht aus. - `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. @@ -31,6 +31,7 @@ Verfügbare Konfiguration: | --- | --- | | `DB_URL` | JDBC-Adresse der bestehenden DB, z. B. `jdbc:mariadb://DB-HOST:3306/DB-NAME?connectTimeout=2000&socketTimeout=5000` | | `DB_USER`, `DB_PASSWORD` | Vorhandenes Datenbankkonto; keine Zugangsdaten im Quelltext | +| `HISTORY_DB_SCHEMA` | Bestehendes Protokollschema auf demselben Server, Standard `phoenix_log`; benötigt SELECT-Rechte für `log` und `logoperation` mit demselben Datenbankkonto. Name aus Buchstaben, Ziffern und Unterstrichen, maximal 64 Zeichen | | `LEGACY_APP_URL` | Optionale HTTP(S)-Basisadresse der vorhandenen PHP-Anwendung | | `SERVER_ADDRESS` | Standard `127.0.0.1` | | `PORT` | Standard `8080` | @@ -78,8 +79,9 @@ Die ursprüngliche Niederlassung `user.hq_id` begrenzt alle Abfragen. Bei Auftr | `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` | Aufträge mit Kundennamen; Filter `q`, `status`, `customerId` (effektiver Zahler), `courierId`, `from`, `to` | | `GET /api/orders/{id}` | Auftrag derselben Erfassungsniederlassung mit Tourstationen | +| `GET /api/orders/{id}/history` | Vollständige Historie eines aktuellen Auftrags derselben Erfassungsniederlassung; Recht 4; aufsteigend nach Ereigniszeit und Log-ID | | `GET /api/orders/export.csv` | Gefilterter UTF-8-Export, zusätzlich Recht 5; verweigert einen unvollständigen Export oberhalb der Ergebnisgrenze | | `GET /api/customers`, `GET /api/couriers` | Stammdaten, optionaler Suchparameter `q` | | `GET /api/invoices` | 501, Bestands-Abrechnungsprozess noch nicht portiert | @@ -89,13 +91,16 @@ Listen liefern Arrays. Auftragslisten senden zusätzlich `X-Total-Count` und `X- ## Fachliche Zuordnung und offene Arbeit -- Auftraggeber aus `job.csc_id → costcenter.cs_id`; Rechnungskunde separat als `payerCustomerId` aus `csc_id_payer`. +- Die Kundenanzeige (`customerId`, `customerName`, `costCenter`), Namenssuche und der Kundenfilter folgen `job.csc_id_payer → costcenter → customer → company`. Bei Barzahlung (`jb_payment=2`) mit positiver `csc_id_payer_cash` wird für diese Anzeige der tatsächliche Barzahler verwendet; `payerCustomerId` behält den Kunden aus `csc_id_payer`, also gegebenenfalls den technischen Systembarzahler. +- `job.csc_id` bezeichnet die erfassende Kundenkostenstelle und ist bei zentral erfassten Aufträgen regulär `0`. Dieser Bezug bleibt separat als `ordererCustomerId` erhalten. Er darf nicht als allgemeiner Kundenbezug verwendet werden. Quelle: `jobs/job_edit.php:38–48`, `jobs/job_options.php:125–128`, `:1706–1711` und `admin/jb_list.php:1022–1023`. +- Der Kundenname wird mit jedem sichtbaren Auftrag geliefert; die Anzeige ist damit unabhängig vom Ergebnislimit oder Leserecht der separaten Kundenstammliste. Die Auftragsabfrage bleibt über `job.hq_id` und Recht 4 begrenzt. Nicht zuordenbare Kunden (beispielsweise Kostensplit ohne eindeutigen Zahler) bleiben `null` und werden in der Oberfläche ausdrücklich als nicht zugeordnet angezeigt. - 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. +- Historie wird beim Öffnen von „Verlauf“ separat über `/api/orders/{id}/history` aus `phoenix_log.log` geladen. `logoperation` liefert Vorgangsbezeichnungen, `user` den Bearbeiter. Die Oberfläche zeigt die neuesten Ereignisse zuerst und unterscheidet Laden, eine erfolgreich gelesene leere Historie und einen Zugriffsfehler mit Wiederholungsmöglichkeit. Das bisherige `history`-Feld in Auftragslisten/-details bleibt leer; maßgeblich ist der eigene Historien-Endpunkt. +- Die Historienberechtigung folgt `job.hq_id` und Recht 4; Ereignisse anderer Niederlassungen zum selben sichtbaren Auftrag bleiben erhalten. Fehlende/fremde Aufträge liefern 404, Datenbank-/Schemarechtefehler 503. Die Historie wird weder erfunden noch bei einem Fehler durch ein leeres Ergebnis ersetzt. Legacy-Details werden als Text mit bekannten deutschen Feldnamen angezeigt, unbekannte Angaben bleiben erhalten; gespeicherter HTML-Code wird nicht ausgeführt. Es erfolgen keine Schemaänderungen oder Datenbankschreibzugriffe. - Abrechnung im Bestand ist keine nachgewiesene einfache Invoice-CRUD-Tabelle. Belege, Gegenbuchungen, Zahlungsstatus, Tarifierung, Auftragsanlage/Änderung und Fahrerkommunikation bleiben bis zur vollständigen Portierung im vorhandenen PHP-System. Optional verlinkt die React-Oberfläche diese Module. - Archivtabellen und Mehrniederlassungsrechte sind noch nicht eingebunden. Die bestehende Anwendung bleibt für diese Funktionen erforderlich. @@ -105,10 +110,12 @@ SQL-/Mapping-Quellen im vorhandenen Code: `include/mcglobal.inc.php`, `include/d `./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. +Mit `VOTIAN_VERIFY_EXISTING_DB=true` und den konfigurierten `DB_URL`, `DB_USER`, `DB_PASSWORD` wird zusätzlich `LegacyDatabaseReadIntegrationTest` ausgeführt. Er prüft die drei Listen-APIs mit echten SELECT-Abfragen über MockMvc, die numerische JDBC-Abbildung von `TINYINT(1)` und den Historien-Endpunkt gegen das konfigurierte Protokollschema. Für die Historie muss einer der neuesten 100 aktuellen Aufträge Protokolleinträge besitzen; geprüft werden Anzahl und Antwortformat ohne Ausgabe der Protokollinhalte. Die Authentifizierung wird nur in diesem Test simuliert; die echte Anmeldung wird damit nicht getestet. Die gewählte Niederlassung muss vorhandene Aufträge, Kunden und Kuriere enthalten. Ohne ausdrückliche Aktivierung bleibt dieser Test deaktiviert. 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 ``` + +Der Integrationstest vergleicht außerdem die Kundenprojektion der neuesten 100 Aufträge mit unabhängigen Abfragen der Zahler-, Barzahler- und Erfassungskundenbezüge. Auch hierbei enthalten Testausgaben keine Kunden- oder Auftragsinhalte. diff --git a/backend/src/main/java/de/stadtbote/votian/ApiController.java b/backend/src/main/java/de/stadtbote/votian/ApiController.java index 383c8fd..94acfe9 100644 --- a/backend/src/main/java/de/stadtbote/votian/ApiController.java +++ b/backend/src/main/java/de/stadtbote/votian/ApiController.java @@ -16,8 +16,9 @@ import static de.stadtbote.votian.Models.*; @RequestMapping("/api") public class ApiController { private final LegacyReadService legacy; + private final LegacyHistoryService history; private final String legacyUrl; - ApiController(LegacyReadService legacy,@Value("${app.legacy-url:}") String legacyUrl) { this.legacy=legacy;this.legacyUrl=safeLegacyUrl(legacyUrl); } + ApiController(LegacyReadService legacy,LegacyHistoryService history,@Value("${app.legacy-url:}") String legacyUrl) { this.legacy=legacy;this.history=history;this.legacyUrl=safeLegacyUrl(legacyUrl); } static String safeLegacyUrl(String raw) { if(raw==null || raw.isBlank())return null; URI uri=URI.create(raw.trim()); @@ -41,7 +42,7 @@ public class ApiController { 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")); + result.put("unavailableModules",List.of("Rechnungserstellung und Zahlungsstatus","Änderungen an Aufträgen und Stammdaten","Archivtabellen")); return result; } @GetMapping("/me") public Map me(Authentication authentication) { @@ -55,6 +56,7 @@ public class ApiController { 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("/orders/{id}/history") public List history(Authentication auth,@PathVariable long id) { return history.history(user(auth),id); } @GetMapping("/customers") public List customers(Authentication auth,@RequestParam(required=false) String q) { return legacy.customers(user(auth),q); } @GetMapping("/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) { diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyHistoryMapping.java b/backend/src/main/java/de/stadtbote/votian/LegacyHistoryMapping.java new file mode 100644 index 0000000..2c54cd6 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyHistoryMapping.java @@ -0,0 +1,126 @@ +package de.stadtbote.votian; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; +import org.springframework.web.util.HtmlUtils; + +/** Read-only projection of phoenix_log.log; legacy descriptions are returned as plain text. */ +final class LegacyHistoryMapping { + private static final Pattern KEY = Pattern.compile("\\$?[A-Za-z_][A-Za-z0-9_]*"); + private static final Pattern BREAK = Pattern.compile("(?i)<\\s*/?(?:br|p|div|tr|li)(?:\\s[^>]*)?/?>"); + private static final Pattern TAG = Pattern.compile( + "(?i)]*)?/?>"); + private static final Map LABELS = Map.ofEntries( + Map.entry("SID", "Fahrzeug"), + Map.entry("CR_SID", "Fahrzeug"), + Map.entry("JB_ID_ORG", "Vorlageauftrag"), + Map.entry("JB_ID_ORI", "Dauerauftragskopie von"), + Map.entry("JB_ID_ORI_COPY", "Multiauftragskopie von"), + Map.entry("HQ_ID_EXEC", "Ausführende Niederlassung (ID)"), + Map.entry("ORDERTIME", "Auftragszeit"), + Map.entry("JB_ORDERTIME", "Auftragszeit"), + Map.entry("JB_DISPOINFO", "Dispo-Info"), + Map.entry("JB_WARNINGTIME", "Datum der Dispo-Info"), + Map.entry("JB_TOTALPRICE", "Auftragspreis"), + Map.entry("JB_FIXPRICE", "Fixpreis"), + Map.entry("JB_SERVICEPRICE", "Servicepreis"), + Map.entry("JB_CR_PRICE", "Frachtvergütung"), + Map.entry("JB_CR_SERVICEPRICE", "Servicevergütung"), + Map.entry("JB_WEIGHT", "Gewicht"), + Map.entry("JB_KM", "Kilometer"), + Map.entry("JB_COMMISSION_NO", "Kommissionsnummer"), + Map.entry("STATION_ID", "Station (ID)"), + Map.entry("TR_ADD", "Hinzugefügte Stationen"), + Map.entry("TR_PERSON", "Ansprechpartner"), + Map.entry("TR_REMARK", "Bemerkungen"), + Map.entry("TR_COMMISSION_NO", "Kommissionsnummer"), + Map.entry("REMARK_TEXT", "Bemerkung"), + Map.entry("REMARK_ITEMS", "Bemerkungscodes"), + Map.entry("REASON", "Grund"), + Map.entry("MAIL_STATE", "Versandstatus"), + Map.entry("MAIL_TO", "Empfänger"), + Map.entry("STATUS", "Status"), + Map.entry("STATUS_NEW", "Neuer Status"), + Map.entry("COSTS", "Kostenpflichtig"), + Map.entry("JB_RHYTHM_DEL", "Dauerauftragsregel (Code)"), + Map.entry("JBP_REMOVAL", "Aus Abrechnung entfernt"), + Map.entry("FDS_REMOVAL", "Aus FDS entfernt")); + + private LegacyHistoryMapping() {} + + static Models.History history(Map row) { + Models.History history = new Models.History(); + history.id = LegacyMapping.id(row, "log_id"); + history.at = LegacyMapping.dateTime(row, "log_createtime"); + history.action = firstText(row, "logo_op_shortname", "logo_op_name"); + if (history.action.isBlank()) { + long operationId = LegacyMapping.id(row, "logo_id"); + history.action = operationId > 0 ? "Vorgang " + operationId : "Unbekannter Vorgang"; + } + history.actor = LegacyMapping.join(plainText(LegacyMapping.text(row, "usr_firstname")), + plainText(LegacyMapping.text(row, "usr_name"))); + if (history.actor.isBlank()) history.actor = firstText(row, "usr_account"); + if (history.actor.isBlank()) { + long userId = LegacyMapping.id(row, "usr_id"); + history.actor = userId > 0 ? "Benutzer " + userId : "Nicht hinterlegt"; + } + history.detail = description(LegacyMapping.text(row, "logo_description")); + String vehicle = plainText(LegacyMapping.text(row, "cr_sid")); + if (!vehicle.isBlank()) { + history.detail = "Fahrzeug: " + vehicle + (history.detail.isBlank() ? "" : "\n" + history.detail); + } + return history; + } + + /** The legacy parser splits on pipes and the first equals sign, retaining further equals signs. */ + static String description(String raw) { + if (raw == null || raw.isBlank()) return ""; + List lines = new ArrayList<>(); + for (String part : raw.split("\\|", -1)) { + String fragment = part.strip(); + if (fragment.isEmpty()) continue; + int equals = fragment.indexOf('='); + String key = equals < 0 ? "" : fragment.substring(0, equals).strip(); + if (KEY.matcher(key).matches()) { + // Preserve stored values, including unknown flags and prices, without inferred units or conversions. + lines.add(label(key) + ": " + plainText(fragment.substring(equals + 1))); + } else { + lines.add(plainText(fragment)); + } + } + return String.join("\n", lines).strip(); + } + + private static String label(String raw) { + String key = raw.startsWith("$") ? raw.substring(1) : raw; + key = key.toUpperCase(Locale.ROOT); + String station = ""; + if (key.startsWith("TR_") && key.matches(".*_\\d+$")) { + int underscore = key.lastIndexOf('_'); + station = " (Station " + key.substring(underscore + 1) + ")"; + key = key.substring(0, underscore); + } + boolean old = key.endsWith("_OLD"); + String base = old ? key.substring(0, key.length() - 4) : key; + String known = LABELS.get(base); + return known == null ? raw : known + (old ? " (bisher)" : "") + station; + } + + private static String firstText(Map row, String... keys) { + for (String key : keys) { + String value = plainText(LegacyMapping.text(row, key)); + if (!value.isBlank()) return value; + } + return ""; + } + + private static String plainText(String value) { + // This is a text projection, not HTML for rendering; consumers must continue using text nodes. + String text = BREAK.matcher(value).replaceAll("\n"); + text = TAG.matcher(text).replaceAll(""); + return HtmlUtils.htmlUnescape(text).replace('\u00a0', ' ').replace("\r\n", "\n").strip(); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyHistoryService.java b/backend/src/main/java/de/stadtbote/votian/LegacyHistoryService.java new file mode 100644 index 0000000..343bad6 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyHistoryService.java @@ -0,0 +1,63 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static de.stadtbote.votian.Models.History; + +/** Reads the existing log schema on the same server through the shared read-only service. */ +@Service +public class LegacyHistoryService { + private final DatabaseService database; + private final String historySql; + + public LegacyHistoryService(DatabaseService database, + @Value("${app.history-schema:phoenix_log}") String schema) { + this.database = database; + // SQL identifiers cannot be bound as parameters. Only configuration, never request input, + // supplies this strictly validated schema name; all record IDs remain bound parameters. + if (schema == null || !schema.matches("[A-Za-z0-9_]{1,64}")) { + throw new IllegalArgumentException("HISTORY_DB_SCHEMA muss ein Datenbankname aus Buchstaben, Ziffern und Unterstrichen sein (maximal 64 Zeichen)."); + } + String prefix = "`" + schema + "`."; + historySql = """ + SELECT l.log_id,l.logo_id,CAST(l.log_createtime AS CHAR) AS log_createtime, + l.usr_id,l.cr_sid,l.logo_description, + op.logo_op_shortname,op.logo_op_name,u.usr_firstname,u.usr_name,u.usr_account + FROM %slog l + JOIN job jb ON jb.jb_id=l.jb_id + LEFT JOIN %slogoperation op ON op.logo_id=l.logo_id + LEFT JOIN user u ON u.usr_id=l.usr_id + WHERE jb.jb_id=? AND jb.hq_id=? + ORDER BY l.log_createtime,l.log_id + """.formatted(prefix, prefix); + } + + public List history(LegacyUser user, long orderId) { + user.require(4); + return database.readTransaction(() -> { + // Check visibility before touching the log schema; a missing/foreign order is never + // indistinguishable from an accessible order with no history. + var orders = database.queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?", + orderId, user.headquartersId()); + if (orders.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Auftrag in dieser Niederlassung nicht gefunden."); + } + try { + // Events may have originated in another branch. Scope by the order's home branch, + // not l.hq_id, matching the legacy job history's event selection. + return database.queryForList(historySql, orderId, user.headquartersId()).stream() + .map(LegacyHistoryMapping::history).toList(); + } catch (DataAccessException exception) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Die Auftragshistorie ist derzeit nicht erreichbar. Bitte später erneut versuchen."); + } + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyMapping.java b/backend/src/main/java/de/stadtbote/votian/LegacyMapping.java index d1079b3..cd29233 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyMapping.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyMapping.java @@ -52,7 +52,8 @@ final class LegacyMapping { } 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.customerId=nullableId(row,"app_customer_id");o.customerName=text(row,"app_customer_name"); + o.payerCustomerId=nullableId(row,"app_payer_customer_id");o.ordererCustomerId=nullableId(row,"app_orderer_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;}; diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyReadService.java b/backend/src/main/java/de/stadtbote/votian/LegacyReadService.java index 39236e4..d44a6d7 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyReadService.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyReadService.java @@ -15,14 +15,20 @@ 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)); } + // csc_id records customer-entered orders and is normally 0 for HQ entry (job_edit.php). + // The displayed customer follows the payer; cash payments may name a separate actual payer. 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 costcenter customer_cc ON customer_cc.csc_id = CASE + WHEN jb.jb_payment=2 AND jb.csc_id_payer_cash>0 THEN jb.csc_id_payer_cash + ELSE jb.csc_id_payer END AND customer_cc.csc_id>0 + LEFT JOIN customer cs ON cs.cs_id = customer_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 jb.*, customer_cc.cs_id AS app_customer_id, cmp.cmp_comp AS app_customer_name, + payer.cs_id AS app_payer_customer_id, cc.cs_id AS app_orderer_customer_id, customer_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 """; @@ -37,7 +43,7 @@ public class LegacyReadService { 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(customerId!=null){where.append(" AND customer_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)"); diff --git a/backend/src/main/java/de/stadtbote/votian/Models.java b/backend/src/main/java/de/stadtbote/votian/Models.java index a39de63..aecfb8f 100644 --- a/backend/src/main/java/de/stadtbote/votian/Models.java +++ b/backend/src/main/java/de/stadtbote/votian/Models.java @@ -24,7 +24,8 @@ public final class Models { public boolean active; } public static class DeliveryOrder { - public Long id, version, customerId, payerCustomerId, courierId, invoiceId; + public Long id, version, customerId, payerCustomerId, ordererCustomerId, courierId, invoiceId; + public String customerName; public String number; public OrderStatus status; public int legacyStatus, legacyStorno; diff --git a/backend/src/main/java/de/stadtbote/votian/database/DatabaseDiagnostics.java b/backend/src/main/java/de/stadtbote/votian/database/DatabaseDiagnostics.java index f6efa71..971370c 100644 --- a/backend/src/main/java/de/stadtbote/votian/database/DatabaseDiagnostics.java +++ b/backend/src/main/java/de/stadtbote/votian/database/DatabaseDiagnostics.java @@ -14,7 +14,7 @@ import org.springframework.stereotype.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("job", columns("jb_id hq_id csc_id csc_id_payer csc_id_payer_cash jb_payment 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")), diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c565f85..1a8fd4e 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -41,6 +41,7 @@ app: cors-origins: ${CORS_ORIGINS:http://localhost:5173,http://127.0.0.1:5173} legacy-url: ${LEGACY_APP_URL:} max-results: ${MAX_RESULTS:500} + history-schema: ${HISTORY_DB_SCHEMA:phoenix_log} logging: level: com.zaxxer.hikari: WARN diff --git a/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java b/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java index b7895c9..2c4ffab 100644 --- a/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java +++ b/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java @@ -17,6 +17,7 @@ import org.springframework.test.web.servlet.MvcResult; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; @@ -35,6 +36,7 @@ class ApiSecurityTest { record Csrf(MockHttpSession session, String token, String header) {} @BeforeEach void configure() { + when(database.readTransaction(any())).thenAnswer(call -> ((Supplier)call.getArgument(0)).get()); 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"))); @@ -70,6 +72,8 @@ class ApiSecurityTest { MvcResult unauthorized = mvc.perform(get("/api/orders")).andExpect(status().isUnauthorized()) .andExpect(header().doesNotExist("WWW-Authenticate")).andReturn(); assertThat(unauthorized.getRequest().getSession(false)).isNull(); + mvc.perform(get("/api/orders/42/history")).andExpect(status().isUnauthorized()) + .andExpect(header().doesNotExist("WWW-Authenticate")); Csrf csrf = csrf(null); mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token()) .contentType("application/json").content("{}")) @@ -180,6 +184,52 @@ class ApiSecurityTest { 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()); + mvc.perform(get("/api/orders/42/history").session(session)).andExpect(status().isForbidden()); + verify(database,never()).queryForList(contains("phoenix_log"),any(Object[].class)); + } + + @Test void historyOfMissingOrForeignOrderReturns404WithoutReadingLog() throws Exception { + MockHttpSession session = authenticated("existing-user","existing-password"); + mvc.perform(get("/api/orders/42/history").session(session)).andExpect(status().isNotFound()); + verify(database).queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?",42L,7L); + verify(database,never()).queryForList(contains("phoenix_log"),any(Object[].class)); + } + + @Test void accessibleOrderWithNoLogReturnsEmptyHistory() throws Exception { + MockHttpSession session = authenticated("existing-user","existing-password"); + when(database.queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?",42L,7L)) + .thenReturn(List.of(Map.of("jb_id",42L))); + + mvc.perform(get("/api/orders/42/history").session(session)) + .andExpect(status().isOk()).andExpect(content().json("[]")); + verify(database).queryForList(contains("FROM `phoenix_log`.log l"),eq(42L),eq(7L)); + } + + @Test void historyReturnsExistingEvents() throws Exception { + MockHttpSession session = authenticated("existing-user","existing-password"); + when(database.queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?",42L,7L)) + .thenReturn(List.of(Map.of("jb_id",42L))); + when(database.queryForList(contains("FROM `phoenix_log`.log l"),eq(42L),eq(7L))) + .thenReturn(List.of(Map.of("log_id",81L,"logo_id",1,"log_createtime","2026-10-07 08:15:00", + "logo_op_name","Auftrag angelegt","logo_description","Testhistorie","usr_account","disponent"))); + + mvc.perform(get("/api/orders/42/history").session(session)).andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)).andExpect(jsonPath("$[0].id").value(81)) + .andExpect(jsonPath("$[0].at").value("2026-10-07T08:15:00")) + .andExpect(jsonPath("$[0].action").isString()).andExpect(jsonPath("$[0].actor").isString()); + } + + @Test void logFailureReturns503AndDoesNotDiscloseDatabaseDetails() throws Exception { + MockHttpSession session = authenticated("existing-user","existing-password"); + when(database.queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?",42L,7L)) + .thenReturn(List.of(Map.of("jb_id",42L))); + when(database.queryForList(contains("FROM `phoenix_log`.log l"),eq(42L),eq(7L))) + .thenThrow(new DataAccessResourceFailureException("private database credentials")); + + var response=mvc.perform(get("/api/orders/42/history").session(session)) + .andExpect(status().isServiceUnavailable()).andReturn(); + String message=json.readTree(response.getResponse().getContentAsString()).get("message").asText(); + assertThat(message).contains("Auftragshistorie").doesNotContain("private database credentials"); } @Test void databaseAccountDenialIsDistinguishedFromNetworkFailure() throws Exception { diff --git a/backend/src/test/java/de/stadtbote/votian/DatabaseAccessArchitectureTest.java b/backend/src/test/java/de/stadtbote/votian/DatabaseAccessArchitectureTest.java index 8190948..5b40f24 100644 --- a/backend/src/test/java/de/stadtbote/votian/DatabaseAccessArchitectureTest.java +++ b/backend/src/test/java/de/stadtbote/votian/DatabaseAccessArchitectureTest.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; @@ -42,6 +43,8 @@ class DatabaseAccessArchitectureTest { ); // 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"); + // String constants may be JSON or regexes. Only actual JVM array type names are descriptors. + private static final Pattern ARRAY_DESCRIPTOR = Pattern.compile("\\[{1,255}(?:[BCDFIJSZ]|L[^.;\\[/]+(?:/[^.;\\[/]+)*;)"); @Test void productionDatabaseAccessGoesThroughOneService() throws Exception { assertNoBypasses(classesDirectory(VotianApplication.class), false); @@ -82,6 +85,32 @@ class DatabaseAccessArchitectureTest { assertThat(dependencies(writer.toByteArray())).containsExactly("java/sql/Connection"); } + @Test void arrayLikeTextIsIgnoredButReflectiveJdbcArrayTypesAreDetected() { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ArrayNames", null, "java/lang/Object", null); + MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "read", "()V", null, null); + method.visitCode(); + for (String text : List.of("[A-Za-z0-9_]{1,64}", "[]", "[", "[V", "[I trailing text", + "[Ljava/sql/Connection; trailing text", "[I", "[[Z", "[[Ljava.lang.String;")) { + method.visitLdcInsn(text); + method.visitInsn(Opcodes.POP); + } + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(1, 1); + method.visitEnd(); + writer.visitEnd(); + assertThat(dependencies(writer.toByteArray())).isEmpty(); + + writer = new ClassWriter(0); + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ReflectiveArrayBypass", null, "java/lang/Object", null); + writer.visitField(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL, + "connectionArray", "Ljava/lang/String;", null, "[Ljava.sql.Connection;").visitEnd(); + writer.visitField(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL, + "dataSourceArrays", "Ljava/lang/String;", null, "[[Ljavax/sql/DataSource;").visitEnd(); + writer.visitEnd(); + assertThat(dependencies(writer.toByteArray())).containsExactly("java/sql/Connection", "javax/sql/DataSource"); + } + @Test void pythonLaunchersDoNotOpenTheirOwnDatabaseConnections() throws Exception { Path scripts = projectRoot().resolve("scripts"); assertThat(scripts).isDirectory(); @@ -168,7 +197,7 @@ class DatabaseAccessArchitectureTest { new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { void name(String name) { if (name == null) return; - if (name.startsWith("[")) { descriptor(name); return; } + if (ARRAY_DESCRIPTOR.matcher(name).matches()) { descriptor(name); return; } if (!ALLOWED_VALUE_TYPES.contains(name) && FORBIDDEN_PREFIXES.stream().anyMatch(name::startsWith)) forbidden.add(name); } void type(Type type) { diff --git a/backend/src/test/java/de/stadtbote/votian/LegacyDatabaseReadIntegrationTest.java b/backend/src/test/java/de/stadtbote/votian/LegacyDatabaseReadIntegrationTest.java index 1507e64..5ff81ac 100644 --- a/backend/src/test/java/de/stadtbote/votian/LegacyDatabaseReadIntegrationTest.java +++ b/backend/src/test/java/de/stadtbote/votian/LegacyDatabaseReadIntegrationTest.java @@ -1,26 +1,77 @@ package de.stadtbote.votian; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import de.stadtbote.votian.database.DatabaseService; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import java.util.Objects; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; /** Opt-in SELECT-only check against the configured existing database. No accounts or data are created. */ @EnabledIfEnvironmentVariable(named="VOTIAN_VERIFY_EXISTING_DB", matches="true") -@SpringBootTest +@SpringBootTest(properties={"debug=false","logging.level.org.springframework=INFO"}) @AutoConfigureMockMvc(printOnlyOnFailure=false, print=org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint.NONE) class LegacyDatabaseReadIntegrationTest { @Autowired DatabaseService database; @Autowired MockMvc mvc; + @Autowired ObjectMapper json; + @Value("${app.history-schema:phoenix_log}") String historySchema; + + @Test void latestOrderCustomerProjectionMatchesIndependentLegacyRelations() { + database.readTransaction(() -> { + // Compare the production join with independent scalar lookups. LIMIT applies to + // source jobs here, so an accidental multiplicative join cannot hide missing jobs. + var expected=database.queryForList(""" + SELECT expected.*, expected_company.cmp_comp AS expected_customer_name + FROM ( + SELECT jb.jb_id,jb.jb_ordertime, + CASE WHEN jb.jb_payment=2 AND jb.csc_id_payer_cash>0 + THEN (SELECT cash.cs_id FROM costcenter cash WHERE cash.csc_id=jb.csc_id_payer_cash) + ELSE (SELECT invoice.cs_id FROM costcenter invoice WHERE invoice.csc_id=jb.csc_id_payer AND invoice.csc_id>0) + END AS expected_customer_id, + (SELECT payer.cs_id FROM costcenter payer WHERE payer.csc_id=jb.csc_id_payer) AS expected_payer_customer_id, + (SELECT orderer.cs_id FROM costcenter orderer WHERE orderer.csc_id=jb.csc_id) AS expected_orderer_customer_id + FROM job jb ORDER BY jb.jb_ordertime DESC,jb.jb_id DESC LIMIT 100 + ) expected + LEFT JOIN customer expected_customer ON expected_customer.cs_id=expected.expected_customer_id + LEFT JOIN company expected_company ON expected_company.cmp_id=expected_customer.cmp_id + ORDER BY expected.jb_ordertime DESC,expected.jb_id DESC + """); + assumeFalse(expected.isEmpty(),"Keine vorhandenen Aufträge für die lesende Relationsprüfung"); + var actual=database.queryForList(LegacyReadService.ORDER_SELECT+LegacyReadService.ORDER_FROM + +" ORDER BY jb.jb_ordertime DESC,jb.jb_id DESC LIMIT 100") + .stream().map(LegacyMapping::order).toList(); + + // Boolean equality assertions intentionally keep live customer data out of diffs. + assertThat(actual.size()).as("Anzahl projizierter Aufträge").isEqualTo(expected.size()); + for(int index=0;index(Map.of( + "log_id", 31L, "log_createtime", Timestamp.valueOf("2026-10-07 11:32:54"), + "logo_id", 5, "logo_op_shortname", "Auftrag zugeordnet", + "usr_firstname", "Erika", "usr_name", "Muster", "cr_sid", "A-007", + "logo_description", "REMARK_TEXT=Empfang im 2. OG")); + + var history = LegacyHistoryMapping.history(row); + + assertThat(history.id).isEqualTo(31L); + assertThat(history.at).isEqualTo(LocalDateTime.of(2026, 10, 7, 11, 32, 54)); + assertThat(history.action).isEqualTo("Auftrag zugeordnet"); + assertThat(history.actor).isEqualTo("Erika Muster"); + assertThat(history.detail).isEqualTo("Fahrzeug: A-007\nBemerkung: Empfang im 2. OG"); + } + + @Test void preservesValuesWithEqualsAndUnknownKeysWithoutInferringAmounts() { + String raw = "|$jb_totalprice_old=119.00|$jb_totalprice=125.50|FUTURE_KEY=a=b=c|EMPTY=|"; + + assertThat(LegacyHistoryMapping.description(raw)).isEqualTo( + "Auftragspreis (bisher): 119.00\nAuftragspreis: 125.50\nFUTURE_KEY: a=b=c\nEMPTY:"); + } + + @Test void handlesMixedFreeTextAndLegacyStationChanges() { + assertThat(LegacyHistoryMapping.description( + "Nacherfassung|$tr_remark_old_2=Beim Pförtner|$tr_remark_2=Am Empfang|COSTS=YES")) + .isEqualTo("Nacherfassung\nBemerkungen (bisher) (Station 2): Beim Pförtner\n" + + "Bemerkungen (Station 2): Am Empfang\nKostenpflichtig: YES"); + assertThat(LegacyHistoryMapping.description("2 < 5 und 7 > 3")).isEqualTo("2 < 5 und 7 > 3"); + assertThat(LegacyHistoryMapping.description("Kontakt oder ")) + .isEqualTo("Kontakt oder "); + assertThat(LegacyHistoryMapping.description("Kontakt ")) + .isEqualTo("Kontakt "); + } + + @Test void projectsLegacyMarkupAsTextAndDoesNotGenerateHtml() { + assertThat(LegacyHistoryMapping.description("REMARK_TEXT=Tür links
2. OG & Hof")) + .isEqualTo("Bemerkung: Tür links\n2. OG & Hof"); + assertThat(LegacyHistoryMapping.description("REMARK_TEXT=Empfang")) + .isEqualTo("Bemerkung: Empfang"); + // Entity-encoded markup remains a literal text value, never executable HTML. + assertThat(LegacyHistoryMapping.description("REMARK_TEXT=<script>Text</script>")) + .isEqualTo("Bemerkung: "); + } + + @Test void keepsActorAndOperationFallbacksHonest() { + var row = new HashMap(Map.of( + "log_id", 2, "logo_id", 79, "usr_id", 45, + "logo_op_name", "Auftrag geändert", "usr_account", "dispatch")); + assertThat(LegacyHistoryMapping.history(row).action).isEqualTo("Auftrag geändert"); + assertThat(LegacyHistoryMapping.history(row).actor).isEqualTo("dispatch"); + row.remove("usr_account"); + row.remove("logo_op_name"); + assertThat(LegacyHistoryMapping.history(row).actor).isEqualTo("Benutzer 45"); + assertThat(LegacyHistoryMapping.history(row).action).isEqualTo("Vorgang 79"); + row.remove("usr_id"); + row.remove("logo_id"); + assertThat(LegacyHistoryMapping.history(row).actor).isEqualTo("Nicht hinterlegt"); + assertThat(LegacyHistoryMapping.history(row).action).isEqualTo("Unbekannter Vorgang"); + } + + @Test void supportsLegacyTimestampFormsAndMissingValues() { + assertThat(LegacyHistoryMapping.history(Map.of("LOG_CREATETIME", "20261007113254")).at) + .isEqualTo(LocalDateTime.of(2026, 10, 7, 11, 32, 54)); + assertThat(LegacyHistoryMapping.history(Map.of("log_createtime", "0000-00-00 00:00:00")).at).isNull(); + var history = LegacyHistoryMapping.history(Map.of()); + assertThat(history.at).isNull(); + assertThat(history.detail).isEmpty(); + assertThat(LegacyHistoryMapping.description(null)).isEmpty(); + } +} diff --git a/backend/src/test/java/de/stadtbote/votian/LegacyHistoryServiceTest.java b/backend/src/test/java/de/stadtbote/votian/LegacyHistoryServiceTest.java new file mode 100644 index 0000000..dcd11dc --- /dev/null +++ b/backend/src/test/java/de/stadtbote/votian/LegacyHistoryServiceTest.java @@ -0,0 +1,106 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class LegacyHistoryServiceTest { + final DatabaseService database=mock(DatabaseService.class); + final LegacyHistoryService service=new LegacyHistoryService(database,"phoenix_log"); + final LegacyUser allowed=new LegacyUser(12,13,7,"disponent","000010"); + + @BeforeEach void configureTransactions() { + when(database.readTransaction(any())).thenAnswer(call -> ((Supplier)call.getArgument(0)).get()); + } + + @Test void requiresOrderRightBeforeAnyDatabaseAccess() { + var denied=new LegacyUser(12,13,7,"restricted","111101"); + assertThatThrownBy(()->service.history(denied,42)).isInstanceOf(AccessDeniedException.class); + verifyNoInteractions(database); + } + + @Test void missingOrForeignOrderReturns404BeforeReadingLogSchema() { + when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of()); + assertThatThrownBy(()->service.history(allowed,42)) + .isInstanceOfSatisfying(ResponseStatusException.class, + error -> assertThat(error.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND)); + verify(database).readTransaction(any()); + verify(database).queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?",42L,7L); + verifyNoMoreInteractions(database); + } + + @Test void bindsOrderAndHomeBranchInBothReadsAndSortsChronologicallyWithStableTieBreak() { + when(database.queryForList(anyString(),any(Object[].class))) + .thenReturn(List.of(Map.of("jb_id",42L)),List.of()); + + assertThat(service.history(allowed,42)).isEmpty(); + + ArgumentCaptor sql=ArgumentCaptor.forClass(String.class); + ArgumentCaptor args=ArgumentCaptor.forClass(Object[].class); + verify(database,times(2)).queryForList(sql.capture(),args.capture()); + assertThat(sql.getAllValues().get(0)).contains("WHERE jb.jb_id=? AND jb.hq_id=?"); + assertThat(sql.getAllValues().get(1)).contains("FROM `phoenix_log`.log l", "JOIN job jb ON jb.jb_id=l.jb_id", + "LEFT JOIN `phoenix_log`.logoperation op", "WHERE jb.jb_id=? AND jb.hq_id=?", + "ORDER BY l.log_createtime,l.log_id").doesNotContain("l.hq_id=?", "LIMIT", "DESC"); + for(Object[] parameters:args.getAllValues())assertThat(parameters).containsExactly(42L,7L); + verify(database).readTransaction(any()); + } + + @Test void mapsExistingHistoryRows() { + when(database.queryForList(anyString(),any(Object[].class))).thenReturn( + List.of(Map.of("jb_id",42L)), + List.of(Map.of("log_id",81L,"logo_id",1,"log_createtime","2026-10-07 08:15:00", + "logo_op_name","Auftrag angelegt","usr_account","disponent"))); + + var result=service.history(allowed,42); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().id).isEqualTo(81L); + assertThat(result.getFirst().at).isEqualTo("2026-10-07T08:15:00"); + assertThat(result.getFirst().action).isNotBlank(); + } + + @Test void logDatabaseFailureReturns503InsteadOfAnEmptyHistoryOrDatabaseDetails() { + when(database.queryForList(anyString(),any(Object[].class))) + .thenReturn(List.of(Map.of("jb_id",42L))) + .thenThrow(new DataAccessResourceFailureException("private database credentials")); + + assertThatThrownBy(()->service.history(allowed,42)) + .isInstanceOfSatisfying(ResponseStatusException.class,error -> { + assertThat(error.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(error.getReason()).contains("Auftragshistorie").doesNotContain("credentials"); + }); + } + + @Test void readsBothLogTablesFromConfiguredSchema() { + var configured=new LegacyHistoryService(database,"tenant_42_log"); + when(database.queryForList(anyString(),any(Object[].class))) + .thenReturn(List.of(Map.of("jb_id",42L)),List.of()); + + configured.history(allowed,42); + + verify(database).queryForList(argThat(sql -> sql.contains("FROM `tenant_42_log`.log l") + && sql.contains("LEFT JOIN `tenant_42_log`.logoperation op")),eq(42L),eq(7L)); + } + + @Test void rejectsInvalidOrInjectedSchemaNamesBeforeReadingDatabase() { + for(String schema:List.of("", "log; DELETE FROM job", "log` JOIN user --", "server.log", "log-name", "a".repeat(65))) { + assertThatThrownBy(()->new LegacyHistoryService(database,schema)).isInstanceOf(IllegalArgumentException.class); + } + assertThatThrownBy(()->new LegacyHistoryService(database,null)).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(database); + } +} diff --git a/backend/src/test/java/de/stadtbote/votian/LegacyMappingTest.java b/backend/src/test/java/de/stadtbote/votian/LegacyMappingTest.java index 3756c52..cb0e85b 100644 --- a/backend/src/test/java/de/stadtbote/votian/LegacyMappingTest.java +++ b/backend/src/test/java/de/stadtbote/votian/LegacyMappingTest.java @@ -7,6 +7,39 @@ import static org.assertj.core.api.Assertions.*; import static de.stadtbote.votian.Models.*; class LegacyMappingTest { + @Test void mapsHeadquartersOrderCustomerWithoutCustomerEnteredOrderer() { + var order=LegacyMapping.order(Map.of("jb_id",1,"csc_id",0, + "app_customer_id",15,"app_customer_name","Rechnungskunde", + "app_payer_customer_id",15,"app_cost_center","Zentrale")); + + assertThat(order.customerId).isEqualTo(15L); + assertThat(order.customerName).isEqualTo("Rechnungskunde"); + assertThat(order.payerCustomerId).isEqualTo(15L); + assertThat(order.ordererCustomerId).isNull(); + assertThat(order.costCenter).isEqualTo("Zentrale"); + } + @Test void keepsCashCustomerPayerAndOrdererSeparate() { + var order=LegacyMapping.order(Map.of("jb_id",1,"jb_payment",2, + "app_customer_id",31,"app_customer_name","Barzahler", + "app_payer_customer_id",19,"app_orderer_customer_id",15)); + + assertThat(order.customerId).isEqualTo(31L); + assertThat(order.customerName).isEqualTo("Barzahler"); + assertThat(order.payerCustomerId).isEqualTo(19L); + assertThat(order.ordererCustomerId).isEqualTo(15L); + } + @Test void preservesUnknownCustomerInsteadOfInventingAnIdOrName() { + var row=new HashMap(Map.of("jb_id",1,"app_customer_id",0, + "app_payer_customer_id",0,"app_orderer_customer_id",0)); + row.put("app_customer_name",null); + var order=LegacyMapping.order(row); + + assertThat(order.customerId).isNull(); + assertThat(order.customerName).isEmpty(); + assertThat(order.payerCustomerId).isNull(); + assertThat(order.ordererCustomerId).isNull(); + assertThat(LegacyMapping.order(Map.of("jb_id",2)).customerName).isEmpty(); + } @Test void mapsBooleanCompanyFlagsForActiveAndArchivedCustomers() { var row=new HashMap(Map.of("cs_id",1,"cmp_archived",false,"cmp_visible",true,"cmp_authenticated",true)); assertThat(LegacyMapping.customer(row).active).isTrue(); diff --git a/backend/src/test/java/de/stadtbote/votian/LegacyReadServiceTest.java b/backend/src/test/java/de/stadtbote/votian/LegacyReadServiceTest.java index 73036c7..f58f451 100644 --- a/backend/src/test/java/de/stadtbote/votian/LegacyReadServiceTest.java +++ b/backend/src/test/java/de/stadtbote/votian/LegacyReadServiceTest.java @@ -34,9 +34,48 @@ class LegacyReadServiceTest { 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 --%"); + assertThat(sql.getValue()).contains("jb.hq_id = ?","AND customer_cc.cs_id = ?","jb.cr_id = ?","LIMIT 500", + "LEFT JOIN customer cs ON cs.cs_id = customer_cc.cs_id","cmp.cmp_comp LIKE ?") + .doesNotContain("OR 1=1","AND cc.cs_id = ?"); + assertThat(args.getValue()).containsExactly(7L,LocalDate.of(2026,10,1).atStartOfDay(), + LocalDate.of(2026,10,6).atStartOfDay(),23L,42L, + "%' OR 1=1 --%","%' OR 1=1 --%","%' OR 1=1 --%", + "%' OR 1=1 --%","%' OR 1=1 --%","%' OR 1=1 --%"); + verify(database).queryForObject(eq("SELECT COUNT(*) "+sql.getValue().substring( + sql.getValue().indexOf("FROM job jb"),sql.getValue().indexOf(" ORDER BY jb.jb_ordertime"))), + eq(Long.class),eq(args.getValue())); + } + @Test void listIncludesPayerCustomerForHeadquartersOrderWithoutOrderer() { + when(database.queryForObject(anyString(),eq(Long.class),any(Object[].class))).thenReturn(1L); + when(database.queryForList(startsWith("SELECT jb.*"),any(Object[].class))).thenReturn(List.of(Map.of( + "jb_id",99,"csc_id",0,"app_customer_id",23,"app_customer_name","Rechnungskunde", + "app_payer_customer_id",23,"app_cost_center","Buchhaltung"))); + + var result=service.orders(allowed,null,null,null,null,null,null); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.orders()).hasSize(1); + var order=result.orders().getFirst(); + assertThat(order.customerId).isEqualTo(23L); + assertThat(order.customerName).isEqualTo("Rechnungskunde"); + assertThat(order.payerCustomerId).isEqualTo(23L); + assertThat(order.ordererCustomerId).isNull(); + assertThat(order.costCenter).isEqualTo("Buchhaltung"); + verify(database).queryForList(contains("customer_cc.cs_id AS app_customer_id, cmp.cmp_comp AS app_customer_name"),eq(7L)); + } + @Test void detailUsesSameCustomerProjectionAndKeepsDifferentOrderer() { + when(database.queryForList(startsWith("SELECT jb.*"),eq(99L),eq(7L))).thenReturn(List.of(Map.of( + "jb_id",99,"app_customer_id",23,"app_customer_name","Rechnungskunde", + "app_payer_customer_id",23,"app_orderer_customer_id",42))); + + var order=service.order(allowed,99L); + + assertThat(order.customerId).isEqualTo(23L); + assertThat(order.customerName).isEqualTo("Rechnungskunde"); + assertThat(order.payerCustomerId).isEqualTo(23L); + assertThat(order.ordererCustomerId).isEqualTo(42L); + verify(database).queryForList(eq(LegacyReadService.ORDER_SELECT+LegacyReadService.ORDER_FROM + +" WHERE jb.jb_id=? AND jb.hq_id=?"),eq(99L),eq(7L)); } @Test void singleOrderCannotEscapeHeadquarters() { when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of()); diff --git a/backend/src/test/java/de/stadtbote/votian/database/DatabaseDiagnosticsTest.java b/backend/src/test/java/de/stadtbote/votian/database/DatabaseDiagnosticsTest.java index 466388a..ebe56e3 100644 --- a/backend/src/test/java/de/stadtbote/votian/database/DatabaseDiagnosticsTest.java +++ b/backend/src/test/java/de/stadtbote/votian/database/DatabaseDiagnosticsTest.java @@ -40,7 +40,8 @@ class DatabaseDiagnosticsTest { @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"); + assertThat(missing.get("job")).contains("jb_totalprice", "csc_id_payer", "csc_id_payer_cash", "jb_payment") + .doesNotContain("jb_id"); verify(database).checkConnection(); verify(database).queryForObject("SELECT VERSION()", String.class); verify(database).queryForList(metadataSql); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a771d80..30a35c7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -48,6 +48,7 @@ import { import type { Courier, Customer, + History, Invoice, Order, OrderStatus, @@ -117,6 +118,10 @@ const initials = (s: string) => .map((v) => v[0]) .join("") .toUpperCase(); +const customerLabel = (id: number | null | undefined, name?: string | null) => + name?.trim() || (id != null && id > 0 ? `Kunde #${id}` : "Kunde nicht zugeordnet"); +const orderCustomerLabel = (order: Order, customer?: Customer) => + customerLabel(order.customerId, order.customerName?.trim() || customer?.name); const emptyStop = (sequence: number): Stop => ({ sequence, type: sequence === 1 ? "PICKUP" : "DELIVERY", @@ -540,7 +545,13 @@ function Workspace({ ? !!system?.capabilities.writeInvoices : canEdit; const customerName = useCallback( - (id: number) => customers.find((c) => c.id === id)?.name || `Kunde #${id}`, + (id: number | null | undefined) => + customerLabel(id, customers.find((c) => c.id === id)?.name), + [customers], + ); + const orderCustomerName = useCallback( + (order: Order) => + orderCustomerLabel(order, customers.find((c) => c.id === order.customerId)), [customers], ); const courierName = useCallback( @@ -1264,7 +1275,7 @@ function Workspace({ - {customerName(o.customerId)} + {orderCustomerName(o)} {o.reference || "Keine Referenz"} @@ -1418,7 +1429,7 @@ function Workspace({ {o.number} {time(o.pickupAt)} -

{customerName(o.customerId)}

+

{orderCustomerName(o)}

{o.stops[0]?.city} @@ -1514,7 +1525,7 @@ function Workspace({ label={o.legacyStatusLabel} /> -

{customerName(o.customerId)}

+

{orderCustomerName(o)}

{o.stops.map((s, i) => (

c.id === selected.customerId)} couriers={couriers} @@ -2085,6 +2097,33 @@ function OrderDetails({ const [tab, setTab] = useState("details"), [courier, setCourier] = useState(String(o.courierId || "")), [recipient, setRecipient] = useState(""); + const [history, setHistory] = useState<{ + order: Order; + entries?: History[]; + error?: string; + } | null>(null); + const [historyAttempt, setHistoryAttempt] = useState(0); + // A refreshed order invalidates the displayed history even if its version + // did not change (legacy log entries may be written independently). + const currentHistory = history?.order === o ? history : null; + useEffect(() => { + if (tab !== "history") return; + const controller = new AbortController(); + setHistory({ order: o }); + void request(`/orders/${o.id}/history`, { + signal: controller.signal, + }).then( + (entries) => { + if (!controller.signal.aborted) + setHistory({ order: o, entries: entries.slice().reverse() }); + }, + (error: unknown) => { + if (!controller.signal.aborted) + setHistory({ order: o, error: (error as Error).message }); + }, + ); + return () => controller.abort(); + }, [o, tab, historyAttempt]); useEffect(() => setCourier(String(o.courierId || "")), [o.courierId]); const actions: Record = { @@ -2141,15 +2180,18 @@ function OrderDetails({ className={tab === "history" ? "active" : ""} onClick={() => setTab("history")} > - Verlauf {o.history?.length || "–"} + Verlauf{" "} + {currentHistory?.entries && ( + {currentHistory.entries.length} + )}

{tab === "details" ? ( <>
- AUFTRAGGEBER -

{customer?.name || `Kunde #${o.customerId}`}

+ KUNDE / ZAHLER +

{orderCustomerLabel(o, customer)}

{customer?.contact} {customer?.phone && "· " + customer.phone}

@@ -2351,27 +2393,46 @@ function OrderDetails({ )} ) : ( -
- {o.history?.length ? ( - o.history - .slice() - .reverse() - .map((h) => ( -
- -
- {h.action} -

{h.detail}

- - {date(h.at)} · {time(h.at)} · {h.actor} - -
+
+ {currentHistory?.error ? ( +
+
+ Historie konnte nicht geladen werden. + {currentHistory.error} +
+ +
+ ) : !currentHistory?.entries ? ( +
+ + Historie wird geladen … +
+ ) : currentHistory.entries.length ? ( + currentHistory.entries.map((h) => ( +
+ +
+ {h.action} +

{h.detail}

+ + {h.at + ? `${date(h.at)} · ${time(h.at)} Uhr` + : "Zeitpunkt unbekannt"} + {h.actor && ` · ${h.actor}`} +
- )) +
+ )) ) : ( )}
@@ -3383,15 +3444,21 @@ function Reports({ ), ).sort((a, b) => b[1] - a[1]); const customerRank = [...new Set(data.map((o) => o.customerId))] - .map((id) => ({ - id, - name: customers.find((c) => c.id === id)?.name || `Kunde #${id}`, - number: customers.find((c) => c.id === id)?.number || "", - count: data.filter((o) => o.customerId === id).length, - total: sumValues( - data.filter((o) => o.customerId === id).map((o) => o.netPrice), - ), - })) + .map((id) => { + const customerOrders = data.filter((o) => o.customerId === id); + const customer = customers.find((c) => c.id === id); + return { + id, + name: customerLabel( + id, + customerOrders.find((o) => o.customerName?.trim())?.customerName || + customer?.name, + ), + number: customer?.number || "", + count: customerOrders.length, + total: sumValues(customerOrders.map((o) => o.netPrice)), + }; + }) .sort((a, b) => b.count - a.count) .slice(0, 6); const courierRank = [ @@ -3523,7 +3590,7 @@ function Reports({ {customerRank.map((c) => ( - + {c.name} {c.number} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ced0bae..0a10eb6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1840,6 +1840,18 @@ main { .history-list { padding: 25px 0; } +.history-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px 0; + color: var(--muted); +} +.history-error .alert { + flex-direction: column; + align-items: flex-start; +} .history-item { display: flex; gap: 14px; @@ -1878,6 +1890,8 @@ main { font-size: 10px; color: #b695c6; margin-top: 5px; + white-space: pre-wrap; + overflow-wrap: anywhere; } .history-item small { display: block; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 51a2361..9940057 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -16,7 +16,7 @@ export interface Stop { } export interface History { id: number; - at: string; + at: string | null; action: string; detail: string; actor: string; @@ -31,7 +31,9 @@ export interface Order { legacyStorno?: number; isCreditNote?: boolean; payerCustomerId?: number | null; - customerId: number; + ordererCustomerId?: number | null; + customerId: number | null; + customerName?: string | null; courierId: number | null; service: string; vehicle: string; diff --git a/tests/browser.spec.ts b/tests/browser.spec.ts index b377e81..1ef2c0d 100644 --- a/tests/browser.spec.ts +++ b/tests/browser.spec.ts @@ -29,10 +29,7 @@ const system = { legacyUrl: "https://legacy.example.test/votian", maxResults: 500, capabilities, - unavailableModules: [ - "Rechnungserstellung und Zahlungsstatus", - "Historie aus separater Log-Datenbank", - ], + unavailableModules: ["Rechnungserstellung und Zahlungsstatus"], }; const customers = [ { @@ -192,6 +189,7 @@ async function mockApi( "/couriers": couriers, "/orders": orders, "/orders/880001": orders[0], + "/orders/880001/history": [], ...overrides, }[path]; if (path === "/orders" && url.searchParams.get("q")) { @@ -465,6 +463,236 @@ test("existing account opens order list, details and module navigation with read }); }); +for (const customerAccess of ["empty", "denied", "loaded"] as const) { + test(`order customer names remain available with ${customerAccess} customer master data`, async ({ page }) => { + const order = { ...orders[0], customerName: " Testzahler Westwind " }; + let customerRequests = 0; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/customers") customerRequests++; + }); + await mockApi(page, { + "/system": { + ...system, + capabilities: { ...capabilities, readCustomers: customerAccess !== "denied" }, + }, + "/customers": customerAccess === "loaded" ? customers : [], + "/orders": [order], + "/orders/880001": order, + }); + await page.goto("/"); + await expect(page.getByRole("row", { name: /880001/ })).toContainText("Testzahler Westwind"); + await page.getByText("880001").first().click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByText("KUNDE / ZAHLER", { exact: true })).toBeVisible(); + await expect(dialog.getByRole("heading", { name: "Testzahler Westwind", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Details schließen", exact: true }).click(); + for (const module of ["Disposition", "Touren", "Auswertung"]) { + await page.getByRole("button", { name: module, exact: true }).click(); + await expect(page.locator("main").getByText("Testzahler Westwind", { exact: true })).toBeVisible(); + await expect(page.locator("main").getByText("Testkunde Nordlicht", { exact: true })).toHaveCount(0); + } + if (customerAccess === "denied") expect(customerRequests).toBe(0); + else expect(customerRequests).toBeGreaterThan(0); + }); +} + +test("orders and reports show safe customer fallbacks for missing identities and names", async ({ page }) => { + const variants = [ + { customerId: null, customerName: null, label: "Kunde nicht zugeordnet" }, + { customerId: 0, customerName: "", label: "Kunde nicht zugeordnet" }, + { customerId: undefined, customerName: undefined, label: "Kunde nicht zugeordnet" }, + { customerId: 44, customerName: " ", label: "Kunde #44" }, + { customerId: 11, customerName: " ", label: "Testkunde Nordlicht" }, + ]; + const fixture = variants.map(({ label: _label, ...customer }, index) => ({ + ...orders[0], ...customer, id: 880001 + index, number: String(880001 + index), + })); + await mockApi(page, { "/orders": fixture }); + await page.goto("/"); + for (const [index, { label }] of variants.entries()) { + const orderNumber = fixture[index].number; + const row = page.getByRole("row").filter({ has: page.getByText(orderNumber, { exact: true }) }); + await expect(row).toContainText(label); + await row.getByText(orderNumber, { exact: true }).click(); + await expect(page.getByRole("dialog").getByRole("heading", { name: label, exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Details schließen", exact: true }).click(); + } + await page.getByRole("button", { name: "Auswertung", exact: true }).click(); + await expect(page.locator("main").getByText("Kunde nicht zugeordnet", { exact: true })).toHaveCount(3); + await expect(page.locator("main").getByText("Kunde #44", { exact: true })).toBeVisible(); + await expect(page.locator("main").getByText("Testkunde Nordlicht", { exact: true })).toBeVisible(); + await expect(page.locator("main")).not.toContainText(/Kunde #(null|undefined|0)/); +}); + +test("customer reports aggregate by customer ID and use a name from any matching order", async ({ page }) => { + await mockApi(page, { + "/customers": [], + "/orders": [ + { ...orders[0], customerName: null }, + { ...orders[0], id: 880002, number: "880002", customerName: "Testzahler Westwind" }, + ], + }); + await page.goto("/"); + await page.getByRole("button", { name: "Auswertung", exact: true }).click(); + const row = page.getByRole("row").filter({ hasText: "Testzahler Westwind" }); + await expect(row).toHaveCount(1); + await expect(row.getByRole("cell").nth(1)).toHaveText("2"); + await expect(row.getByRole("cell").nth(2)).toHaveText("85,00 €"); +}); + +test("order history loads on demand and shows newest events with safe multiline details", async ({ + page, +}) => { + await mockApi(page); + const detail = "Alt: 10 €\nNeu: 42,50 €\n"; + const history = [ + { id: 1, at: null, action: "Importiert", detail: "Altdaten", actor: "" }, + { + id: 2, + at: `${today}T08:20:00`, + action: "Angelegt", + detail: "Auftrag erfasst", + actor: "dispo.alt", + }, + { + id: 3, + at: `${today}T09:20:00`, + action: "Geändert", + detail, + actor: "dispo.neu", + }, + { + id: 4, + at: `${today}T09:20:00`, + action: "Geprüft", + detail: "Preis geprüft", + actor: "dispo.neu", + }, + ]; + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + let requests = 0; + await page.route("**/api/orders/880001/history", async (route) => { + requests++; + await pending; + await route.fulfill({ json: history }); + }); + await page.goto("/"); + await page.getByText("880001").first().click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Verlauf", exact: true })).toBeVisible(); + expect(requests).toBe(0); + await dialog.getByRole("button", { name: "Verlauf", exact: true }).click(); + await expect(dialog.getByRole("status")).toHaveText("Historie wird geladen …"); + await expect(dialog.getByText("Keine Historie vorhanden")).toHaveCount(0); + release(); + await expect(dialog.getByRole("button", { name: "Verlauf 4", exact: true })).toBeVisible(); + await expect(dialog.locator(".history-item strong")).toHaveText([ + "Geprüft", "Geändert", "Angelegt", "Importiert", + ]); + const changed = dialog.locator(".history-item").filter({ hasText: "Geändert" }); + expect(await changed.locator("p").textContent()).toBe(detail); + await expect(changed.locator("p")).toHaveCSS("white-space", "pre-wrap"); + await expect(changed.locator("small")).toContainText("09:20 Uhr · dispo.neu"); + await expect(dialog.locator(".history-item").last()).toContainText("Zeitpunkt unbekannt"); + await expect(dialog.locator("script")).toHaveCount(0); + expect(requests).toBe(1); +}); + +test("order history distinguishes unavailable data from empty history and retries", async ({ page }) => { + await mockApi(page); + let attempts = 0; + await page.route("**/api/orders/880001/history", async (route) => { + attempts++; + if (attempts === 1) + return route.fulfill({ + status: 503, + json: { message: "Protokolldatenbank nicht erreichbar." }, + }); + return route.fulfill({ json: [] }); + }); + await page.goto("/"); + await page.getByText("880001").first().click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Verlauf", exact: true }).click(); + await expect(dialog.getByRole("alert")).toContainText("Protokolldatenbank nicht erreichbar."); + await expect(dialog.getByText("Keine Historie vorhanden")).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Verlauf", exact: true })).toBeVisible(); + await dialog.getByRole("button", { name: "Erneut versuchen", exact: true }).click(); + await expect(dialog.getByText("Keine Historie vorhanden", { exact: true })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Verlauf 0", exact: true })).toBeVisible(); + await expect(dialog.getByRole("alert")).toHaveCount(0); + expect(attempts).toBe(2); +}); + +test("order history is refreshed with the selected order even when its version is unchanged", async ({ page }) => { + await mockApi(page); + await page.goto("/"); + await expect(page.getByText("880001").first()).toBeVisible(); + await page.clock.install(); + await page.getByLabel("Automatisch aktualisieren").check(); + let requests = 0; + await page.route("**/api/orders/880001/history", async (route) => { + requests++; + return route.fulfill({ + json: [{ id: requests, at: `${today}T09:00:00`, action: `Stand ${requests}`, detail: "", actor: "dispo" }], + }); + }); + await page.getByText("880001").first().click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Verlauf", exact: true }).click(); + await expect(dialog.locator(".history-item strong")).toHaveText(["Stand 1"]); + await page.clock.fastForward(30_000); + await expect(dialog.locator(".history-item strong")).toHaveText(["Stand 2"]); + expect(requests).toBe(2); +}); + +test("a late history response cannot replace the history of another order", async ({ page }) => { + const secondOrder = { ...orders[0], id: 880002, number: "880002" }; + await mockApi(page, { + "/orders": [orders[0], secondOrder], + "/orders/880002/history": [ + { id: 2, at: null, action: "Zweiter Auftrag", detail: "Aktuelle Historie", actor: "dispo" }, + ], + }); + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + let responded!: () => void; + const responseFinished = new Promise((resolve) => { responded = resolve; }); + await page.route("**/api/orders/880001/history", async (route) => { + await pending; + await route.fulfill({ + json: [{ id: 1, at: null, action: "Veraltete Historie", detail: "Erster Auftrag", actor: "dispo" }], + }); + responded(); + }); + await page.goto("/"); + await page.getByText("880001").first().click(); + await page.getByRole("button", { name: "Verlauf", exact: true }).click(); + await expect(page.getByRole("dialog").getByRole("status")).toBeVisible(); + await page.getByRole("button", { name: "Details schließen", exact: true }).click(); + await page.getByText("880002").first().click(); + await page.getByRole("button", { name: "Verlauf", exact: true }).click(); + await expect(page.getByRole("dialog").locator(".history-item strong")).toHaveText(["Zweiter Auftrag"]); + release(); + await responseFinished; + await expect(page.getByRole("dialog").locator(".history-item strong")).toHaveText(["Zweiter Auftrag"]); + await expect(page.getByText("Veraltete Historie")).toHaveCount(0); +}); + +test("session expiry while loading order history clears the drawer and account data", async ({ page }) => { + const session = await mockApi(page); + await page.goto("/"); + await page.getByText("880001").first().click(); + session.expire(); + await page.getByRole("button", { name: "Verlauf", exact: true }).click(); + await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("880001")).toHaveCount(0); + await expect(page.getByText("Testkunde Nordlicht")).toHaveCount(0); + await expectNoStoredCredentials(page); +}); + test("missing module rights do not trigger forbidden API calls", async ({ page, }) => {