From 9de235848026b2aca604b0eb1063ae6919fd387f Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Tue, 27 Oct 2026 09:53:23 +0100 Subject: [PATCH] =?UTF-8?q?Portiere=20PHP-Cronjobs=20auf=20Spring-Tasks=20?= =?UTF-8?q?und=20erg=C3=A4nze=20Legacy-Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migriert die weiterhin benötigten Zeitaufgaben einschließlich SOAP-Importen, Betriebsstatus und Wiederanlaufkontrolle. Aktiviert 115 native Spring-Tasks im Docker-Image ohne Cron-Daemon oder PHP und dokumentiert die entfallenen HHA-Aufrufe. Ergänzt PHP-/UI-Testwerkzeuge und aktualisiert den Projektstand auf Version 0.2.0. --- .env.example | 37 +++ Dockerfile | 5 +- README.md | 10 +- backend/pom.xml | 3 +- .../votian/LegacyCronAdministration.java | 52 ++++ .../stadtbote/votian/LegacyCronBusiness.java | 75 ++++++ .../votian/LegacyCronCompliance.java | 110 ++++++++ .../stadtbote/votian/LegacyCronExports.java | 50 ++++ .../de/stadtbote/votian/LegacyCronFiles.java | 72 ++++++ .../votian/LegacyCronReplication.java | 45 ++++ .../de/stadtbote/votian/LegacyCronRunner.java | 59 +++++ .../de/stadtbote/votian/LegacyCronSoap.java | 89 +++++++ .../votian/LegacyCronSoapProtocol.java | 110 ++++++++ .../stadtbote/votian/LegacyCronSoapStore.java | 108 ++++++++ .../de/stadtbote/votian/LegacyCronTasks.java | 76 ++++++ .../votian/OperationsMonitoring.java | 4 +- .../votian/database/DatabaseService.java | 50 ++++ backend/src/main/resources/application.yml | 29 +++ .../de/stadtbote/votian/ApiSecurityTest.java | 11 + .../CustomerPortalIntegrationAssertions.java | 2 + .../LegacyCronIntegrationAssertions.java | 72 ++++++ .../LegacyCronSoapIntegrationAssertions.java | 78 ++++++ .../stadtbote/votian/LegacyCronSoapTest.java | 87 +++++++ .../de/stadtbote/votian/LegacyCronTest.java | 154 ++++++++++++ .../votian/database/DatabaseServiceTest.java | 19 ++ docs/CRON-MIGRATION.md | 235 ++++++++++++++++++ docs/PRUEFSTATUS.md | 2 +- frontend/src/OperationsMonitoring.tsx | 16 ++ legacy-php-tests/.gitignore | 3 + legacy-php-tests/README.md | 123 +++++++++ legacy-php-tests/bootstrap.php | 30 +++ legacy-php-tests/phpunit.xml | 10 + legacy-php-tests/run-server.sh | 27 ++ legacy-php-tests/setup-phpunit.sh | 18 ++ legacy-php-tests/support/Fixtures.php | 90 +++++++ legacy-php-tests/support/LegacyFunctions.php | 94 +++++++ legacy-php-tests/support/LegacyTestCase.php | 15 ++ legacy-php-tests/tests/CalendarTest.php | 71 ++++++ legacy-php-tests/tests/ConditionTest.php | 22 ++ legacy-php-tests/tests/FormattingTest.php | 90 +++++++ .../tests/InvoiceDiscountTest.php | 95 +++++++ .../tests/LegacyFunctionsTest.php | 44 ++++ legacy-php-tests/tests/PricingTest.php | 106 ++++++++ legacy-php-tests/tests/RecurringOrderTest.php | 58 +++++ legacy-ui-tests/.gitignore | 6 + legacy-ui-tests/README.md | 95 +++++++ legacy-ui-tests/package-lock.json | 60 +++++ legacy-ui-tests/package.json | 8 + legacy-ui-tests/playwright.config.ts | 36 +++ legacy-ui-tests/run-server.sh | 10 + legacy-ui-tests/run.py | 37 +++ legacy-ui-tests/support/fixtures.ts | 44 ++++ legacy-ui-tests/support/network.ts | 144 +++++++++++ legacy-ui-tests/support/order-page.ts | 70 ++++++ legacy-ui-tests/tests/order-entry.spec.ts | 132 ++++++++++ legacy-ui-tests/tests/order-save.spec.ts | 55 ++++ package.json | 5 +- 57 files changed, 3251 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/de/stadtbote/votian/LegacyCronAdministration.java create mode 100644 backend/src/main/java/de/stadtbote/votian/LegacyCronBusiness.java create mode 100644 backend/src/main/java/de/stadtbote/votian/LegacyCronCompliance.java create mode 100644 backend/src/main/java/de/stadtbote/votian/LegacyCronExports.java create mode 100644 backend/src/main/java/de/stadtbote/votian/LegacyCronFiles.java create mode 100644 backend/src/main/java/de/stadtbote/votian/LegacyCronReplication.java create mode 100644 backend/src/main/java/de/stadtbote/votian/LegacyCronRunner.java create mode 100644 backend/src/main/java/de/stadtbote/votian/LegacyCronSoap.java create mode 100644 backend/src/main/java/de/stadtbote/votian/LegacyCronSoapProtocol.java create mode 100644 backend/src/main/java/de/stadtbote/votian/LegacyCronSoapStore.java create mode 100644 backend/src/main/java/de/stadtbote/votian/LegacyCronTasks.java create mode 100644 backend/src/test/java/de/stadtbote/votian/LegacyCronIntegrationAssertions.java create mode 100644 backend/src/test/java/de/stadtbote/votian/LegacyCronSoapIntegrationAssertions.java create mode 100644 backend/src/test/java/de/stadtbote/votian/LegacyCronSoapTest.java create mode 100644 backend/src/test/java/de/stadtbote/votian/LegacyCronTest.java create mode 100644 docs/CRON-MIGRATION.md create mode 100644 legacy-php-tests/.gitignore create mode 100644 legacy-php-tests/README.md create mode 100644 legacy-php-tests/bootstrap.php create mode 100644 legacy-php-tests/phpunit.xml create mode 100755 legacy-php-tests/run-server.sh create mode 100755 legacy-php-tests/setup-phpunit.sh create mode 100644 legacy-php-tests/support/Fixtures.php create mode 100644 legacy-php-tests/support/LegacyFunctions.php create mode 100644 legacy-php-tests/support/LegacyTestCase.php create mode 100644 legacy-php-tests/tests/CalendarTest.php create mode 100644 legacy-php-tests/tests/ConditionTest.php create mode 100644 legacy-php-tests/tests/FormattingTest.php create mode 100644 legacy-php-tests/tests/InvoiceDiscountTest.php create mode 100644 legacy-php-tests/tests/LegacyFunctionsTest.php create mode 100644 legacy-php-tests/tests/PricingTest.php create mode 100644 legacy-php-tests/tests/RecurringOrderTest.php create mode 100644 legacy-ui-tests/.gitignore create mode 100644 legacy-ui-tests/README.md create mode 100644 legacy-ui-tests/package-lock.json create mode 100644 legacy-ui-tests/package.json create mode 100644 legacy-ui-tests/playwright.config.ts create mode 100755 legacy-ui-tests/run-server.sh create mode 100755 legacy-ui-tests/run.py create mode 100644 legacy-ui-tests/support/fixtures.ts create mode 100644 legacy-ui-tests/support/network.ts create mode 100644 legacy-ui-tests/support/order-page.ts create mode 100644 legacy-ui-tests/tests/order-entry.spec.ts create mode 100644 legacy-ui-tests/tests/order-save.spec.ts diff --git a/.env.example b/.env.example index 9843e8b..f6d630a 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,41 @@ # Als .env im Projektstamm speichern und die vorhandenen Datenbankwerte eintragen. +# Native Spring-Tasks (siehe docs/CRON-MIGRATION.md); kein Cron-Daemon erforderlich. +# Das Docker-Image aktiviert alle neun Gruppen über SPRING_TASK_GROUPS. +# Optional die Image-Vorgabe überschreiben, z. B. nur Diagnose: +# SPRING_TASK_GROUPS=mysql-status +# Alle zeitgesteuerten Gruppen ausdrücklich deaktivieren: SPRING_TASK_GROUPS= +# Gruppen können einzeln mit Komma gewählt werden: +# courier-logout,stb-compliance,sb-master-export,hha-safebags,mysql-status,file-maintenance,replication-monitor,hha-soap-master,hha-soap-status +# Kompatibilitätsfallback für direkten Java-Start ohne SPRING_TASK_GROUPS. +LEGACY_CRON_JOBS= +LEGACY_CRON_HHA_MAIL_TO= +LEGACY_CRON_HHA_MAIL_BCC= +# PTnova: HTTPS-Endpunkt und Zugänge der zu dieser NG-Instanz gehörenden SAP-Umgebung. +LEGACY_CRON_SOAP_URL= +LEGACY_CRON_SOAP_USER= +LEGACY_CRON_SOAP_PASSWORD= +LEGACY_CRON_SOAP_HEADQUARTERS=1 +LEGACY_CRON_SOAP_CONTAINER_STOCK=1000 +# JSON: Gruppen STB/HTG/HTM/HTM_OLD/HHA/INT/MUH -> [Primärserver:Port,Replikat:Port,...] +LEGACY_CRON_REPLICATION_TARGETS={} +LEGACY_CRON_REPLICATION_USER= +LEGACY_CRON_REPLICATION_PASSWORD= +LEGACY_CRON_REPLICATION_MAIL_TO= +# Ausschließlich dedizierte, eingebundene Altverzeichnisse; mit Komma trennen. +# TEMPORARY: alle regulären Dateien werden wie in PHP rekursiv entfernt! +LEGACY_CRON_BINLOG_DIRECTORIES= +LEGACY_CRON_LOG_DIRECTORIES= +LEGACY_CRON_TEMPORARY_DIRECTORIES= +# Die Vorgabezeiten stehen in application.yml, jeweils Europe/Berlin (Spring: sechs Felder). +# LEGACY_CRON_SOAP_MASTER_SCHEDULE=0 10 5 * * * +# LEGACY_CRON_SOAP_STATUS_SCHEDULE=0 20 5 * * * +# LEGACY_CRON_LOGOUT_SCHEDULE=0 */30 * * * * +# LEGACY_CRON_STB_MAIL_SCHEDULE=0 0 4 * * MON-FRI +# LEGACY_CRON_SB_EXPORT_SCHEDULE=0 45 20 * * * +# LEGACY_CRON_HHA_SAFEBAG_SCHEDULE=0 40 9 * * * +# LEGACY_CRON_DB_STATUS_SCHEDULE=0 */5 * * * * +# LEGACY_CRON_FILES_SCHEDULE=0 1 0 * * * +# LEGACY_CRON_REPLICATION_SCHEDULE=0 */15 * * * * # Prozessumgebungsvariablen haben Vorrang vor dieser Datei. # Für docker run --env-file .env: Werte ohne umschließende Anführungszeichen. # $, #, Backslashes und = in Werten bleiben wörtlich erhalten. Nicht per Shell source laden. diff --git a/Dockerfile b/Dockerfile index 81c8eac..5d3fe7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,10 @@ WORKDIR /app ARG JAR_FILE=backend/target/*.jar COPY --chown=10001:10001 ${JAR_FILE} app.jar -ENV SERVER_ADDRESS=0.0.0.0 +# Native Spring scheduling only; Java remains PID 1, with no PHP or cron daemon. +# Override SPRING_TASK_GROUPS to select groups; an explicit empty value disables them. +ENV SERVER_ADDRESS=0.0.0.0 \ + SPRING_TASK_GROUPS=courier-logout,stb-compliance,sb-master-export,hha-safebags,mysql-status,file-maintenance,replication-monitor,hha-soap-master,hha-soap-status USER 10001:10001 EXPOSE 8080 diff --git a/README.md b/README.md index a82435a..be361a9 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ Die neue Anwendung ist standardmäßig nur lokal erreichbar. Für einen Serverbe ## Prüfungen +Die [Unit-Tests der PHP-Altanwendung](legacy-php-tests/README.md) prüfen den aktuellen Serverquellstand in einem separaten Container mit dem Image der Altanwendung. Auf `172.16.0.158` starten sie mit `/data/0/votian-test/tests/unit/run-server.sh`. Testumfang, Isolation und bekannte Kalenderfehler sind in der verlinkten Anleitung dokumentiert. + +Die [Oberflächentests der PHP-Altanwendung](legacy-ui-tests/README.md) prüfen die echte Auftragserfassung auf `test.sb.assecutor.de` mit Playwright und Firefox. Start auf dem Server: `/data/0/votian-test/tests/ui/run-server.sh`; lokal: `npm run test:legacy-ui`. Ein tatsächlicher Speichertest wird mit `--create-order` aktiviert und verwendet ausschließlich die bezeichneten Assecutor-Testkunden. + ```bash python3 -m unittest discover -s tests -p 'test_*.py' -v cd backend && ./mvnw test @@ -158,4 +162,8 @@ Der Angebotslauf liest die Fachparameter der Dispositionsniederlassung: Aktivier Verwaltung → Betriebsstatus zeigt die sechs nativen Automatikdienste mit Aktivierung, Umfang, letztem abgeschlossenen Lauf und Fehler-/Ergebniszahlen. Die Laufwerte gelten für die gesamte NG-Instanz und beginnen nach einem Neustart neu. Ein Lauf ohne Abschluss seit mehr als drei Minuten wird als überfällig gekennzeichnet; daraus folgt nicht automatisch, dass der Prozess beendet ist. Gemeinsame `keepalive`-Zeitstempel können von PHP oder NG stammen und sind kein Nachweis der Zuständigkeit. -Auftrags-/Kurierkennzahlen und Geräte-Abschlussmeldungen sind auf die ursprüngliche Niederlassung begrenzt. Erforderlich ist das vorhandene Mitarbeiterverwaltungsrecht (Position 3). Die Ansicht liest ausschließlich; sie startet keine Dienste und versendet keine Nachrichten. Fehlende Datenquellen werden einzeln als nicht verfügbar angezeigt. +Auftrags-/Kurierkennzahlen und Geräte-Abschlussmeldungen sind auf die ursprüngliche Niederlassung begrenzt. Erforderlich ist das vorhandene Mitarbeiterverwaltungsrecht (Position 3). Das Laden der Ansicht liest ausschließlich; es startet keine Dienste und versendet keine Nachrichten. Die gesonderte Prüffreigabe eines unterbrochenen Cron-Laufs wird protokolliert und erlaubt dessen nächsten regulären Termin. Fehlende Datenquellen werden einzeln als nicht verfügbar angezeigt. + +### Zeitgesteuerte Spring-Tasks + +Die bestätigte PHP-Crontab wird über native Spring-Tasks abgelöst. Das Docker-Image aktiviert beim Start alle neun Gruppen (115 Tasks) über `SPRING_TASK_GROUPS`; feste Termine gelten nach Europe/Berlin. Der Container startet ausschließlich Java und benötigt weder Cron-Daemon noch PHP. `SPRING_TASK_GROUPS` kann auf einzelne Gruppen oder einen leeren Wert gesetzt werden. Beim direkten Java-Start bleibt `LEGACY_CRON_JOBS` als Fallback erhalten. Der Betriebsstatus zeigt dauerhafte Laufbelege. Die beiden nicht mehr benötigten HHA-Aufrufe entfallen nach Betreiberentscheidung vom 27.10.2026; damit sind alle weiterhin benötigten PHP-Cron-Aufrufe implementiert. Zeitpläne, Konfiguration, Änderungen gegenüber PHP und Wiederanlauf nach einem unterbrochenen Versand stehen in [CRON-MIGRATION.md](docs/CRON-MIGRATION.md). diff --git a/backend/pom.xml b/backend/pom.xml index e58520d..71fdba8 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -2,9 +2,10 @@ 4.0.0 org.springframework.bootspring-boot-starter-parent3.5.16 - de.stadtbotevotian-backend0.1.2 + de.stadtbotevotian-backend0.2.0 Votian NG Stadtbote21 + org.apache.commonscommons-compress1.28.0 commons-netcommons-net3.13.0 com.github.mwiedejsch2.28.5 com.google.zxingcore3.5.4 diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronAdministration.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronAdministration.java new file mode 100644 index 0000000..dda0926 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronAdministration.java @@ -0,0 +1,52 @@ +package de.stadtbote.votian; + +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.Authentication; +import java.time.Instant; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Acknowledgment releases a checked interrupted task for its NEXT regular schedule only. */ +@RestController +@RequestMapping("/api/administration/cron") +class LegacyCronAdministration { + record Review(String slot,boolean reviewed) {} + final LegacyCronTasks tasks;final LegacyCronRunner runner; + LegacyCronAdministration(LegacyCronTasks tasks,LegacyCronRunner runner){this.tasks=tasks;this.runner=runner;} + @GetMapping(value="/database-status",produces="text/csv") + org.springframework.http.ResponseEntity databaseStatus(Authentication authentication,@RequestParam String month){ + var user=ApiController.user(authentication);if(!OperationsMonitoring.allowed(user))throw MasterDirectoryScope.denied(); + java.time.YearMonth selected;try{if(!month.matches("20[0-9]{2}-[0-9]{2}"))throw new IllegalArgumentException();selected=java.time.YearMonth.parse(month);}catch(RuntimeException e){throw bad("Ungültiger Berichtsmonat.");} + var from=selected.atDay(1).atStartOfDay(LegacyCronTasks.ZONE).toInstant();var to=selected.plusMonths(1).atDay(1).atStartOfDay(LegacyCronTasks.ZONE).toInstant(); + var rows=runner.db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngdbstatus' AND gdc_obj_id=0 AND gdc_gen_fieldname>=? AND gdc_gen_fieldname10000)throw bad("Zu viele MySQL-Statuswerte für diesen Monat."); + StringBuilder csv=new StringBuilder("Date;Uptime;Queries;Select_no_hit;qps;qps_now;Slow;Slow_qps;Slow_now;Threads;Cache_hits;Hits_s;Hits_now;Ratio;Ratio_now;Bytes_in;Bytes_out;KBps_in;KBps_out;KBps_in_now;KBps_out_now\r\n"); + for(var row:rows){try{ + var values=runner.support.json.readValue(LegacyMapping.text(row,"gdc_content"),new com.fasterxml.jackson.core.type.TypeReference>(){}); + csv.append(Instant.parse(LegacyMapping.text(row,"gdc_gen_fieldname")).atZone(LegacyCronTasks.ZONE).toLocalDateTime()); + for(String key:List.of("Uptime","Questions","Com_select","qps","qps_now","Slow_queries","Slow_qps","Slow_now","Threads_connected","Qcache_hits","Hits_s","Hits_now","Ratio","Ratio_now","Bytes_received","Bytes_sent","KBps_in","KBps_out","KBps_in_now","KBps_out_now")){ + String value=values.getOrDefault(key,"");if(!value.matches("[0-9.]*"))throw new IllegalArgumentException();csv.append(';').append(value); + }csv.append("\r\n"); + }catch(Exception e){throw bad("Ein gespeicherter MySQL-Statuswert ist ungültig.");}} + return org.springframework.http.ResponseEntity.ok().header("Content-Disposition","attachment; filename=\"mysql_status_"+month+".csv\"").body(csv.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + @PostMapping("/{id}/review") + Object review(Authentication authentication,@PathVariable String id,@RequestBody Review review){return review(ApiController.user(authentication),id,review);} + Map review(LegacyUser user,String id,Review review){ + if(!OperationsMonitoring.allowed(user))throw MasterDirectoryScope.denied(); + if(review==null||!review.reviewed())throw bad("Bitte die Prüfung der bisherigen Teilwirkungen bestätigen."); + if(tasks.tasks.stream().noneMatch(t->t.id().equals(id)))throw bad("Unbekannte Cron-Aufgabe."); + var result=runner.db.cronOperation(id,()->{ + var run=runner.find(id);if(run==null||!run.slot().equals(review.slot()))throw bad("Der Cron-Lauf hat sich geändert. Bitte Status neu laden."); + if(!Set.of("RUNNING","UNCERTAIN").contains(run.result().state()))throw bad("Dieser Cron-Lauf benötigt keine Freigabe."); + runner.support.transactional("genericdatacontainer"); + return runner.db.writeTransaction(()->{ + runner.save(new LegacyCronRunner.Run(id,run.slot(),run.started(),Instant.now().toString(),new LegacyCronRunner.Result("REVIEWED","Geprüft; nächster regulärer Termin freigegeben.",run.result().processed())),false); + runner.support.audit(user,0,0,0,203,"CRON_REVIEW="+id+"|SLOT="+run.slot()+"|PREVIOUS="+run.result().state(),UUID.randomUUID().toString()); + return fields("reviewed",true); + }); + }); + if(result==null)throw bad("Diese Cron-Aufgabe läuft noch. Prüfung erst nach Laufende möglich.");return result; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronBusiness.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronBusiness.java new file mode 100644 index 0000000..9b4114a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronBusiness.java @@ -0,0 +1,75 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.stereotype.Component; +import java.time.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +@Component +class LegacyCronBusiness { + final DatabaseService db;final BusinessSupport support;final AppointmentMailTransport mail; + LegacyCronBusiness(DatabaseService db,BusinessSupport support,AppointmentMailTransport mail){this.db=db;this.support=support;this.mail=mail;} + LegacyCronRunner.Result logout(){ + support.transactional("courier","job","genericdatacontainer","phoenix_pda.commandexec"); + String log=support.historySchema+".log"; + var candidates=db.queryForList("SELECT cr.cr_id FROM courier cr JOIN "+log+" l ON l.cr_id=cr.cr_id WHERE cr.cr_available=1 AND cr.hq_id=3 AND cr.cr_locationzipcode REGEXP '^[0-9]+$' AND l.log_createtime>=DATE_SUB(NOW(),INTERVAL 30 MINUTE) AND l.logo_id IN (3,8,11) GROUP BY cr.cr_id HAVING SUM(l.logo_id=8)>=2 AND SUM(l.logo_id IN (3,11))=0"); + int count=0; + for(var row:candidates){long cr=id(row,"cr_id");count+=db.writeTransaction(()->{ + var current=db.lockRows("SELECT cr_id FROM courier WHERE cr_id=? AND hq_id=3 AND cr_available=1 AND cr_locationzipcode REGEXP '^[0-9]+$'",cr);if(current.isEmpty())return 0; + var events=one(db.queryForList("SELECT COALESCE(SUM(logo_id=8),0) AS revoked,COALESCE(SUM(logo_id IN (3,11)),0) AS answered FROM "+log+" WHERE cr_id=? AND log_createtime>=DATE_SUB(NOW(),INTERVAL 30 MINUTE)",cr)); + if(id(events,"revoked")<2||id(events,"answered")>0)return 0; + var occupied=db.lockRows("SELECT j.jb_id FROM job j JOIN tour t ON t.jb_id=j.jb_id AND t.tr_sort=1 JOIN address a ON a.ad_id=t.ad_id LEFT JOIN serviceplz p ON p.srvp_plz=a.ad_zipcode LEFT JOIN serviceplztraveltime tt ON tt.srvp_id=p.srvp_id AND tt.hq_id=j.hq_id WHERE j.cr_id_order=? AND j.jb_status IN (0,1) AND DATE_SUB(j.jb_ordertime,INTERVAL GREATEST(COALESCE(tt.srvpt_traveltime,30),30) MINUTE)<=NOW()",cr); + if(!occupied.isEmpty())return 0; + var now=db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime(); + db.update("UPDATE courier SET cr_availabletime=?,cr_locationzipcode='LOGOUT' WHERE cr_id=?",now,cr); + db.insert("`phoenix_pda`.commandexec",fields("cr_id",cr,"hq_id",3,"cmd_command","2","cmde_mode",1,"cmde_param","","cmde_exec_time",now,"cmde_exec_timelimit",now.plusHours(16))); + support.audit(new LegacyUser(0,0,3,"Cron",""),0,0,cr,143,"STATUS_LOGOUT=PDA-LOGOUT DURCH AUTOREVOKES",UUID.randomUUID().toString());return 1; + });} + return LegacyCronRunner.Result.ok(count); + } + LegacyCronRunner.Result safebags(String to,String bcc){ + if(!mail.configured())throw bad("SMTP ist für den Safebag-Bericht nicht konfiguriert."); + var recipients=OrderEntryMail.addresses(to);var hidden=OrderEntryMail.addresses(bcc);if(recipients.isEmpty())throw bad("HHA-Safebag-Empfänger fehlen."); + var rows=db.queryForList("SELECT c.csc_name,u.usr_name,u.usr_firstname,a.atih_serialno,a.atih_data_04,a.atih_data_05 FROM "+support.historySchema+".articleitemhistory a JOIN costcenter c ON c.csc_id=a.csc_id JOIN user u ON u.usr_id=a.usr_id WHERE a.at_id=4285 AND a.atih_data_28='1' AND a.atih_createtime<=DATE_SUB(NOW(),INTERVAL 7 DAY) ORDER BY a.csc_id,a.atih_data_04"); + if(rows.isEmpty())return LegacyCronRunner.Result.ok(0); + StringBuilder body=new StringBuilder("

HHA :: Vermeintlich vergessene Safebags

"); + for(var r:rows){body.append("");for(String k:List.of("csc_name","atih_serialno","atih_data_04","atih_data_05"))body.append("");body.append("");}body.append("
VerkaufsstelleSafebagDatumBetragMitarbeiter
").append(LegacyCronCompliance.html(text(r,k))).append("").append(LegacyCronCompliance.html(text(r,"usr_name")+", "+text(r,"usr_firstname"))).append("
"); + String from=new OrderEntryCatalog(db).parameter(1,"MAIL_SENDER_ADDRESS");if(from.isBlank())from=mail.from;from=AppointmentNotifications.address(from); + try{mail.sendOrderHtml(from,recipients,List.of(),hidden,"HHA :: Vermeintlich vergessene Safebags",body.toString());} + catch(RuntimeException e){return new LegacyCronRunner.Result("UNCERTAIN","SMTP-Übergabe des Safebag-Berichts nicht bestätigt.",rows.size());} + return LegacyCronRunner.Result.ok(rows.size()); + } + LegacyCronRunner.Result mysqlStatus(){ + var metrics=new TreeMap(); + for(var row:db.globalStatus()){String key=text(row,"Variable_name");if(Set.of("Bytes_received","Bytes_sent","Com_select","Qcache_hits","Questions","Slow_queries","Threads_connected","Uptime").contains(key))metrics.put(key,text(row,"Value"));} + if(!metrics.containsKey("Uptime"))throw bad("MySQL-Status ist unvollständig."); + var previous=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngdbstatus' AND gdc_obj_id=0 ORDER BY gdc_id DESC LIMIT 1"); + Map prior=Map.of(); + if(!previous.isEmpty())try{prior=support.json.readValue(text(previous.getFirst(),"gdc_content"),new com.fasterxml.jackson.core.type.TypeReference>(){});}catch(Exception e){throw bad("Der vorherige MySQL-Status ist ungültig.");} + metrics.putAll(rates(metrics,prior)); + db.writeTransaction(()->{var at=Instant.now();db.insert("genericdatacontainer",fields("gdc_obj_type","ngdbstatus","gdc_obj_id",0,"gdc_gen_fieldname",at.toString(),"gdc_context",at.toString(),"gdc_content",support.encode(metrics))); + db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngdbstatus' AND gdc_obj_id=0 AND gdc_context values,String key){try{return Long.parseLong(values.getOrDefault(key,"0"));}catch(NumberFormatException e){throw bad("Ungültiger MySQL-Statuszähler.");}} + static Map rates(Map current,Map previous){ + long uptime=metric(current,"Uptime"),elapsed=previous.isEmpty()?0:uptime-metric(previous,"Uptime"); + var result=new LinkedHashMap(); + for(var pair:Map.of("Questions","qps","Slow_queries","Slow_qps","Qcache_hits","Hits_s","Bytes_received","KBps_in","Bytes_sent","KBps_out").entrySet()){ + double factor=pair.getKey().startsWith("Bytes_")?1.0/1024:1; + result.put(pair.getValue(),ratio(metric(current,pair.getKey()),uptime,factor)); + String deltaKey=switch(pair.getKey()){case "Slow_queries"->"Slow_now";case "Qcache_hits"->"Hits_now";default->pair.getValue()+"_now";}; + long delta=metric(current,pair.getKey())-metric(previous,pair.getKey()); + result.put(deltaKey,elapsed<=0||delta<0?"":pair.getKey().equals("Slow_queries")?Long.toString(delta):ratio(delta,elapsed,factor)); + } + long hits=metric(current,"Qcache_hits"),selects=metric(current,"Com_select"); + result.put("Ratio",ratio(hits,hits+selects,100)); + long dh=hits-metric(previous,"Qcache_hits"),ds=selects-metric(previous,"Com_select"); + result.put("Ratio_now",elapsed<=0||ds<0?"":ratio(dh,dh+ds,100)); + return result; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronCompliance.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronCompliance.java new file mode 100644 index 0000000..7ca1548 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronCompliance.java @@ -0,0 +1,110 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.stereotype.Component; +import java.time.*; +import java.time.format.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Current STB cron scripts, including their filter updates and the 1629 -> 441 mapping. */ +@Component +class LegacyCronCompliance { + record Rule(int script, int field, String label, String filter, int months, boolean ownVehicles, boolean warningOnly) {} + static final List RULES=List.of( + new Rule(1710,1710,"Paketbotenschutzgesetz","PBSG",0,false,false), + new Rule(1623,1623,"Führerscheinablaufdatum","",0,false,false), + new Rule(1715,1715,"Mindestlohngesetz Nachhaltungsdatum","",0,false,false), + new Rule(1717,1717,"Nachweis Impfschutz / Genesen gültig bis","COV",0,true,true), + new Rule(1718,1718,"Ablauf FZ-Führungszeugnis nach 6 Monaten","FZ",6,true,true), + new Rule(1719,1719,"Good Distribution Practice (gültig bis)","GDP",0,true,true), + new Rule(1720,1720,"t:m spezifische Schulungen (gültig bis)","BMS",0,true,true), + new Rule(1629,441,"EG-Lizenz (Warndatum)","",0,false,false)); + final DatabaseService db; + final BusinessSupport support; + final AppointmentMailTransport mail; + LegacyCronCompliance(DatabaseService db,BusinessSupport support,AppointmentMailTransport mail) {this.db=db;this.support=support;this.mail=mail;} + String parameter(long hq,String key) {var c=new OrderEntryCatalog(db);String value=c.parameter(hq,key);return value.isBlank()?c.parameter(0,key):value;} + static String html(Object value) {return org.springframework.web.util.HtmlUtils.htmlEscape(Objects.toString(value,""));} + static LocalDate expiry(String raw,int months) { + if(raw.isBlank())return null; + try { + LocalDate date=LocalDate.parse(raw,DateTimeFormatter.ofPattern("dd.MM.uuuu").withResolverStyle(ResolverStyle.STRICT)); + // PHP strtotime adds months with overflow (31 August + 6 months can enter March). + return date.withDayOfMonth(1).plusMonths(months).plusDays(date.getDayOfMonth()-1); + } catch(DateTimeException e) {throw bad("Ungültiges Datum im Cron-Zusatzfeld; bitte Stammdaten korrigieren.");} + } + static String color(LocalDate day,LocalDate today,boolean licence) { + if(day==null)return "66CCFF"; + if(licence){long days=java.time.temporal.ChronoUnit.DAYS.between(today,day);return days<=0?"FF0000":days<=30?"FF0044":days<=60?"FF6633":days<=90?"FFCC33":days<=180?"FFFF33":"";} + if(day.isAfter(today.plusMonths(2).withDayOfMonth(1).plusMonths(1).minusDays(1)))return "009900"; + if(day.isAfter(today.plusMonths(1).withDayOfMonth(1).plusMonths(1).minusDays(1)))return "00FF00"; + if(!day.isBefore(today.plusDays(14)))return "FFFF33"; + if(!day.isBefore(today.plusDays(7)))return "FFCC33"; + return day.isBefore(today)?"FF0000":"FF6666"; + } + static String filter(String old,String token,boolean add) { + var values=new LinkedHashSet();for(String part:old.split(","))if(!part.isBlank())values.add(part); + if(add)values.add(token);else values.remove(token); + String value=values.isEmpty()?"":","+String.join(",",values)+","; + if(value.length()>255)throw bad("Die Cron-Filter überschreiten die Feldlänge.");return value; + } + List> rows(long hq,Rule rule) { + boolean scoped=!parameter(0,"SYSTEM_FORM_SINGLE_HQ_3").matches("|0")||!parameter(0,"SYSTEM_FORM_SINGLE_HQ").matches("|0"); + long formHq=scoped?hq:0; + var keys=db.queryForList("SELECT mtfck_id FROM metafieldcategorykey WHERE mtfc_id=3 AND mtfk_id=? AND hq_id=?",rule.field(),formHq); + if(keys.size()!=1)throw bad("Zusatzfeld "+rule.field()+" ist für Niederlassung "+hq+" nicht eindeutig eingerichtet."); + String extra=rule.script()==1710?" AND cr.cr_id_parent=0":""; + if(rule.script()==1629)extra=" AND v.mtfv_id IS NOT NULL AND EXISTS(SELECT 1 FROM courier driver JOIN company dc ON dc.cmp_id=driver.cmp_id JOIN job j ON j.cr_id=driver.cr_id JOIN couriervehicle cv ON cv.cr_id=cr.cr_id WHERE driver.cr_id_parent=cr.cr_id AND dc.cmp_authenticated=1 AND dc.cmp_visible=1 AND j.hq_id=cr.hq_id AND j.jb_taketime>=DATE_SUB(CURRENT_DATE,INTERVAL 180 DAY))"; + var rows=db.queryForList("SELECT cr.cr_id,cr.cr_eid,c.cmp_comp,c.cmp_comp2,u.usr_firstname,u.usr_name,h.hq_mnemonic,COALESCE(v.mtfv_value,'') AS value FROM courier cr JOIN company c ON c.cmp_id=cr.cmp_id JOIN user u ON u.usr_id=cr.usr_id JOIN headquarters h ON h.hq_id=cr.hq_id LEFT JOIN metafieldvalue v ON v.mtfv_id=cr.cr_id AND v.mtfck_id=? WHERE cr.hq_id=? AND c.cmp_archived=0 AND c.cmp_authenticated=1 AND c.cmp_visible=1"+extra+" ORDER BY c.cmp_comp,cr.cr_id",id(keys.getFirst(),"mtfck_id"),hq); + var seen=new HashSet();for(var row:rows)if(!seen.add(id(row,"cr_id")))throw bad("Mehrdeutige Zusatzfeldwerte im Cron-Bericht."); + return rows; + } + record Change(boolean courier,long id,String before,String after) {} + LegacyCronRunner.Result run(long hq,Rule rule,LocalDate today) { + if(!mail.configured())throw bad("SMTP ist für Cron-Berichte nicht konfiguriert."); + String recipients=parameter(hq,rule.script()==1629?"MAIL_CRON_102_TO_ADDRESS":"MAIL_CRON_106_TO_ADDRESS"); + // Do not send test/customer data to PHP's hard-coded developer fallback addresses. + var to=OrderEntryMail.addresses(recipients);if(to.isEmpty())throw bad("Cron-Empfänger fehlen für Niederlassung "+hq); + String from=AppointmentNotifications.address(mail.from); + var changes=new ArrayList();var body=new StringBuilder("

"+html(rule.label())+"

"); + int count=0;boolean warning=false; + int filterType=1; + if(!rule.filter().isEmpty()&&rule.script()!=1710){var types=db.queryForList("SELECT crf_type FROM courierfilter WHERE crf_short=? AND crf_type IN (0,1)",rule.filter());if(types.size()>1)throw bad("Mehrdeutiger Cron-Filter.");filterType=types.isEmpty()?0:(int)id(types.getFirst(),"crf_type");} + var source=rows(hq,rule); + for(var row:source){ + long courier=id(row,"cr_id"); + var vehicles=db.queryForList("SELECT cv.crvh_id,cv.crvh_filter FROM couriervehicle cv JOIN courier owner ON owner.cr_id=cv.cr_id WHERE owner.cr_id=?"+(rule.ownVehicles()?"":" OR owner.cr_id_parent=?"),rule.ownVehicles()?new Object[]{courier}:new Object[]{courier,courier}); + if(vehicles.isEmpty())continue; + LocalDate day=expiry(text(row,"value"),rule.months()); + if(day==null&&(rule.warningOnly()||rule.script()==1715))continue; + String color=color(day,today,rule.script()==1629);if(color.isEmpty())continue; + count++;warning|=!color.equals("009900"); + body.append(""); + for(String value:List.of(text(row,"hq_mnemonic"),text(row,"cr_eid"),rule.script()==1629?text(row,"usr_firstname"):text(row,"cmp_comp"),rule.script()==1629?text(row,"usr_name"):text(row,"cmp_comp2"),text(row,"value")))body.append(""); + body.append(""); + if(day!=null&&!rule.filter().isEmpty()){ + boolean add=!day.isBefore(today); + if(filterType==1){var cr=one(db.queryForList("SELECT cr_filter FROM courier WHERE cr_id=?",courier));String old=text(cr,"cr_filter");changes.add(new Change(true,courier,old,filter(old,rule.filter(),add)));} + else for(var v:vehicles){String old=text(v,"crvh_filter");changes.add(new Change(false,id(v,"crvh_id"),old,filter(old,rule.filter(),add)));} + } + } + body.append("
NiederlassungNr.Name / FirmaZusatzDatum
").append(html(value)).append("
"); + if(!changes.isEmpty()){ + support.transactional("courier","couriervehicle","metafieldvalue","genericdatacontainer"); + db.writeTransaction(()->{ + db.lockRows("SELECT cr_id FROM courier WHERE hq_id=? ORDER BY cr_id",hq); + db.lockRows("SELECT v.mtfv_id,v.mtfv_value FROM metafieldvalue v JOIN metafieldcategorykey k ON k.mtfck_id=v.mtfck_id WHERE k.mtfc_id=3 AND k.mtfk_id=? ORDER BY v.mtfv_id",rule.field()); + if(!source.equals(rows(hq,rule)))throw bad("Die Cron-Zusatzfelder wurden gleichzeitig geändert; Lauf erneut prüfen."); + for(var c:changes){if(c.before().equals(c.after()))continue; + String table=c.courier()?"courier":"couriervehicle",key=c.courier()?"cr_id":"crvh_id",column=c.courier()?"cr_filter":"crvh_filter"; + if(db.update("UPDATE "+table+" SET "+column+"=? WHERE "+key+"=? AND COALESCE("+column+",'')=?",c.after(),c.id(),c.before())!=1)throw bad("Cron-Filter wurden gleichzeitig geändert; Lauf erneut prüfen."); + }support.audit(new LegacyUser(0,0,hq,"Cron",""),0,0,0,203,"CRON_COMPLIANCE="+rule.script()+"|FILTERS="+changes.size(),UUID.randomUUID().toString());return null;}); + } + if(count==0||rule.warningOnly()&&!warning)return LegacyCronRunner.Result.ok(count); + try{mail.sendOrderHtml(from,to,List.of(),List.of(),rule.label(),body.toString());} + catch(RuntimeException e){return new LegacyCronRunner.Result("UNCERTAIN","SMTP-Übergabe nicht bestätigt. Vor Neuversand Versandprotokoll prüfen.",count);} + return LegacyCronRunner.Result.ok(count); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronExports.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronExports.java new file mode 100644 index 0000000..7e9d30d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronExports.java @@ -0,0 +1,50 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.stereotype.Component; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** The active (uncommented) SB debtor/creditor/vehicle exports from cron_export_SB.php. */ +@Component +class LegacyCronExports { + record Branch(long hq,long user,String number) {} + static final List BRANCHES=List.of(new Branch(1,100001,"039"),new Branch(2,200001,"041"),new Branch(3,300002,"077"),new Branch(4,400001,"093"),new Branch(5,500001,"024"),new Branch(6,600001,"055"),new Branch(7,700001,"026"),new Branch(8,800001,"088"),new Branch(9,826760,"043"),new Branch(11,826762,"052")); + final DatabaseService db;final MasterExports exports;final MasterExportRuns runs;final MasterExportTransfer transfer; + LegacyCronExports(DatabaseService db,MasterExports exports,MasterExportRuns runs,MasterExportTransfer transfer){this.db=db;this.exports=exports;this.runs=runs;this.transfer=transfer;} + static String token(String key){return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString();} + static String profile(int category){return switch(category){case 1->"RETRANS Debitorenstammsatz";case 2->"RETRANS Kreditorenstammsatz";case 3->"RETRANS Fahrzeugstammsatz";default->throw new IllegalArgumentException();};} + LegacyUser actor(Branch branch){ + var rows=db.queryForList("SELECT u.usr_id,u.usr_name,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_id=? AND u.hq_id=? AND u.usr_type=1 AND h.hq_disabled=0",branch.user(),branch.hq()); + if(rows.size()!=1)throw bad("Das PHP-Exportkonto für Niederlassung "+branch.hq()+" ist nicht verfügbar."); + var r=rows.getFirst();return new LegacyUser(id(r,"usr_id"),id(r,"emp_id"),branch.hq(),text(r,"usr_name"),text(r,"emp_rights")); + } + LegacyCronRunner.Result run(Branch branch,int category,Instant slot){ + var actor=actor(branch);var date=slot.atZone(ZoneId.of("Europe/Berlin")); + String request=token("cron-sb-"+branch.hq()+"-"+category+"-"+slot); + // Reuse the durable native export and transfer receipts. Never bypass profile validation. + var prior=runs.find(actor,request);long file; + if(prior!=null)file=id(runs.resume(actor,request),"file"); + else{ + var profiles=db.queryForList("SELECT expp_id FROM exportparameters WHERE hq_id=? AND cs_id=0 AND expc_id=? AND expp_name=?",branch.hq(),category,profile(category)); + if(profiles.size()!=1)throw bad("Das RETRANS-Profil für Niederlassung "+branch.hq()+" / Kategorie "+category+" fehlt oder ist mehrdeutig."); + long profile=id(profiles.getFirst(),"expp_id");var state=exports.state(actor,category,profile); + String filename=date.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"))+"."+branch.number()+"."+List.of("deb","kred","fzg").get(category-1); + var selection=new MasterExports.Run(category,profile,revision(MasterExports.version(state)),MasterExports.settings(state),null,date.toLocalDate(),"|",false,filename,3,""); + var snapshot=exports.snapshot(actor,selection);if(snapshot.selection().rows().isEmpty())return LegacyCronRunner.Result.ok(0); + selection=new MasterExports.Run(category,profile,selection.revision(),selection.settings(),selection.from(),selection.to(),selection.delimiter(),selection.headline(),filename,3,snapshot.revision()); + try{file=id(runs.start(actor,new MasterExportRuns.Command(request,selection,true,true)),"file");} + catch(RuntimeException e){return new LegacyCronRunner.Result("UNCERTAIN","Exportvorgang "+request+" wurde begonnen; Datei und Änderungskennzeichen im Datentransfer prüfen.",0);} + } + try{ + var previous=transfer.find(token(request+"-ftp")); + var outcome=previous!=null?transfer.resume(actor,previous.command().requestId()):transfer.start(actor,new MasterExportTransfer.Command(token(request+"-ftp"),file,text(transfer.preview(actor,file),"revision"))); + if(!"DONE".equals(text(outcome,"state")))return new LegacyCronRunner.Result("UNCERTAIN","Export gespeichert; FTP-Abschluss muss im Datentransfer geprüft werden.",1); + return LegacyCronRunner.Result.ok(1); + }catch(RuntimeException e){return new LegacyCronRunner.Result("UNCERTAIN","Exportdatei "+file+" gespeichert; FTP-Übergabe im Datentransfer abschließen.",1);} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronFiles.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronFiles.java new file mode 100644 index 0000000..4090de5 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronFiles.java @@ -0,0 +1,72 @@ +package de.stadtbote.votian; + +import org.springframework.stereotype.Component; +import org.springframework.core.env.Environment; +import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream; +import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream; +import java.io.*; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.channels.FileChannel; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; +import static java.nio.file.LinkOption.NOFOLLOW_LINKS; +import static de.stadtbote.votian.AdminSupport.bad; + +/** Native do_bzip2.php. Only explicitly mounted legacy paths are eligible, never NG document stores. */ +@Component +class LegacyCronFiles { + final Environment env; + LegacyCronFiles(Environment env){this.env=env;} + static List paths(String value) throws IOException { + var result=new ArrayList(); + for(String raw:value.split(",")){if(raw.isBlank())continue;Path p=Path.of(raw.strip()).toAbsolutePath().normalize(); + if(p.getNameCount()<3||!Files.isDirectory(p,NOFOLLOW_LINKS)||!p.equals(p.toRealPath()))throw bad("Cron-Dateipfad muss ein eigenes vorhandenes Verzeichnis ohne symbolische Links sein."); + result.add(p); + }return result; + } + static boolean same(BasicFileAttributes a,BasicFileAttributes b){return a.size()==b.size()&&a.lastModifiedTime().equals(b.lastModifiedTime())&&Objects.equals(a.fileKey(),b.fileKey());} + static String digest(InputStream in) throws IOException {try(in){var md=java.security.MessageDigest.getInstance("SHA-256");byte[] b=new byte[65536];int n;while((n=in.read(b))!=-1)md.update(b,0,n);return HexFormat.of().formatHex(md.digest());}catch(java.security.NoSuchAlgorithmException e){throw new IllegalStateException(e);}} + static void compress(Path source) throws IOException { + var before=Files.readAttributes(source,BasicFileAttributes.class,NOFOLLOW_LINKS);if(!before.isRegularFile())return; + Path target=source.resolveSibling(source.getFileName()+".bz2"); + if(Files.exists(target,NOFOLLOW_LINKS)){ + if(!Files.isRegularFile(target,NOFOLLOW_LINKS))throw bad("Cron-Archivziel ist keine reguläre Datei."); + String original=digest(Files.newInputStream(source,NOFOLLOW_LINKS)); + String archived=digest(new BZip2CompressorInputStream(Files.newInputStream(target,NOFOLLOW_LINKS))); + if(!original.equals(archived))throw bad("Vorhandenes Cron-Archiv passt nicht zur Quelldatei; kein Überschreiben."); + }else{ + Path temp=Files.createTempFile(source.getParent(),".ng-cron-",".part"); + try{ + try(var input=Files.newInputStream(source,NOFOLLOW_LINKS);var output=new BZip2CompressorOutputStream(Files.newOutputStream(temp),9)){input.transferTo(output);} + try(var channel=FileChannel.open(temp,StandardOpenOption.WRITE)){channel.force(true);} + if(!same(before,Files.readAttributes(source,BasicFileAttributes.class,NOFOLLOW_LINKS)))throw bad("Quelldatei wurde während der Kompression geändert."); + // Atomic publication without overwrite; the temporary file is on the same filesystem. + Files.createLink(target,temp); + }finally{Files.deleteIfExists(temp);} + } + if(!same(before,Files.readAttributes(source,BasicFileAttributes.class,NOFOLLOW_LINKS)))throw bad("Quelldatei wurde während der Kompression geändert."); + Files.delete(source); + } + LegacyCronRunner.Result run(LocalDate today){ + try{ + var bin=paths(env.getProperty("app.legacy-cron.files.binlog-directories","")); + var logs=paths(env.getProperty("app.legacy-cron.files.log-directories","")); + var purge=paths(env.getProperty("app.legacy-cron.files.temporary-directories","")); + if(bin.isEmpty()&&logs.isEmpty()&&purge.isEmpty())throw bad("Keine Altdatei-Verzeichnisse für die Cron-Wartung konfiguriert."); + var all=new ArrayList();all.addAll(bin);all.addAll(logs);all.addAll(purge); + for(int i=0;i candidates;try(var stream=Files.list(directory)){candidates=stream.filter(p->Files.isRegularFile(p,NOFOLLOW_LINKS)&&p.getFileName().toString().matches("mps2-bin\\.0[0-9]+" )).toList();} + long newest=Long.MIN_VALUE;for(var p:candidates)newest=Math.max(newest,Files.getLastModifiedTime(p,NOFOLLOW_LINKS).toMillis()); + for(var p:candidates)if(Files.getLastModifiedTime(p,NOFOLLOW_LINKS).toMillis() candidates;try(var stream=Files.list(directory)){candidates=stream.filter(p->Files.isRegularFile(p,NOFOLLOW_LINKS)&&p.getFileName().toString().contains(month)&&p.getFileName().toString().endsWith(".log")).toList();}for(var p:candidates){compress(p);count++;}} + // PHP deletes all regular files recursively in these three temporary trees; links stay untouched. + for(Path directory:purge)try(var stream=Files.walk(directory)){for(var p:stream.filter(p->Files.isRegularFile(p,NOFOLLOW_LINKS)).toList()){Files.delete(p);count++;}} + return LegacyCronRunner.Result.ok(count); + }catch(IOException e){return new LegacyCronRunner.Result("UNCERTAIN","Dateiwartung unterbrochen; Quelldateien und Archive vor Freigabe prüfen.",0);} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronReplication.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronReplication.java new file mode 100644 index 0000000..2ba8512 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronReplication.java @@ -0,0 +1,45 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.stereotype.Component; +import org.springframework.core.env.Environment; +import java.time.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.bad; + +/** PHP compares business counters, not SHOW SLAVE STATUS. Preserve its group thresholds. */ +@Component +class LegacyCronReplication { + record Group(String metric,int tolerance) {} + static final Map GROUPS=Map.of("STB",new Group("FINISHED",4),"HTG",new Group("FINISHED",2),"HTM",new Group("FINISHED",2),"HTM_OLD",new Group("ORDERS",2),"HHA",new Group("ARTICLES",2),"INT",new Group("META",7),"MUH",new Group("PROBES",1)); + final DatabaseService db;final BusinessSupport support;final AppointmentMailTransport mail;final Environment env; + LegacyCronReplication(DatabaseService db,BusinessSupport support,AppointmentMailTransport mail,Environment env){this.db=db;this.support=support;this.mail=mail;this.env=env;} + Map> targets(){ + try{ + var groups=support.json.readValue(env.getProperty("app.legacy-cron.replication.targets","{}"),new com.fasterxml.jackson.core.type.TypeReference>>(){}); + if(groups==null||groups.isEmpty()||!GROUPS.keySet().containsAll(groups.keySet()))throw new IllegalArgumentException(); + for(var nodes:groups.values())if(nodes==null||nodes.size()<2||nodes.size()>20||new HashSet<>(nodes).size()!=nodes.size()||nodes.stream().anyMatch(s->s==null||!s.matches("[A-Za-z0-9.-]+:[0-9]{1,5}")))throw new IllegalArgumentException(); + return groups; + }catch(Exception e){throw bad("Replikationsziele fehlen oder sind ungültig: je Gruppe Primärserver gefolgt von Replikaten konfigurieren.");} + } + LegacyCronRunner.Result run(LocalDate day){ + var targets=targets();var to=OrderEntryMail.addresses(env.getProperty("app.legacy-cron.replication.mail-to","")); + if(!mail.configured()||to.isEmpty())throw bad("Replikationsalarm benötigt SMTP und konfigurierte Empfänger."); + String user=env.getProperty("app.legacy-cron.replication.user",""),password=env.getProperty("app.legacy-cron.replication.password",""); + if(user.isBlank())throw bad("Replikationszugang fehlt."); + var failures=new ArrayList();int count=0; + for(var entry:new TreeMap<>(targets).entrySet()){ + var group=GROUPS.get(entry.getKey());Long primary=null; + for(int i=0;igroup.tolerance())failures.add(entry.getKey()+" Replikat "+i+": "+(primary-value)+" Einträge Rückstand"); + }catch(RuntimeException e){failures.add(entry.getKey()+" "+(i==0?"Primärserver":"Replikat "+i)+": nicht erreichbar");} + } + } + if(failures.isEmpty())return LegacyCronRunner.Result.ok(count); + String text=String.join("; ",failures); + try{mail.sendOrderHtml(AppointmentNotifications.address(mail.from),to,List.of(),List.of(),"REPLICATION ERROR! ["+day+"]","

"+LegacyCronCompliance.html(text)+"

");} + catch(RuntimeException e){return new LegacyCronRunner.Result("UNCERTAIN","Replikationsabweichung erkannt; Alarmübergabe nicht bestätigt.",count);} + return new LegacyCronRunner.Result("ERROR",text,count); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronRunner.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronRunner.java new file mode 100644 index 0000000..ed9c03f --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronRunner.java @@ -0,0 +1,59 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.stereotype.Component; +import java.time.*; +import java.util.*; +import java.util.function.Supplier; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** A durable claim is committed before any external effect. An interrupted run is never resent. */ +@Component +class LegacyCronRunner { + record Result(String state, String message, int processed) { + static Result ok(int count) { return new Result("OK", "Abgeschlossen", count); } + } + record Run(String id, String slot, String started, String finished, Result result) {} + final DatabaseService db; + final BusinessSupport support; + LegacyCronRunner(DatabaseService db, BusinessSupport support) { this.db=db; this.support=support; } + + Run find(String id) { + var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcron' AND gdc_obj_id=0 AND gdc_gen_fieldname=?", id); + return decode(id,rows); + } + Run decode(String id,List> rows) { + if(rows.isEmpty()) return null; + if(rows.size()!=1) throw AdminSupport.bad("Mehrdeutiger Cron-Laufbeleg: "+id); + try { var run=support.json.readValue(text(rows.getFirst(),"gdc_content"),Run.class); + if(!run.id().equals(id)||run.result()==null||!Set.of("RUNNING","OK","UNCERTAIN","ERROR","BLOCKED","REVIEWED").contains(run.result().state())) throw new IllegalArgumentException(); + Instant.parse(run.slot()); return run; + } catch(Exception e) { throw AdminSupport.bad("Ungültiger Cron-Laufbeleg: "+id); } + } + void save(Run run, boolean insert) { + db.writeTransaction(()->{ + if(insert) db.insert("genericdatacontainer",fields("gdc_obj_type","ngcron","gdc_obj_id",0,"gdc_gen_fieldname",run.id(),"gdc_context",run.result().state(),"gdc_content",support.encode(run))); + else db.update("UPDATE genericdatacontainer SET gdc_context=?,gdc_content=? WHERE gdc_obj_type='ngcron' AND gdc_obj_id=0 AND gdc_gen_fieldname=?",run.result().state(),support.encode(run),run.id()); + return null; + }); + } + Result execute(String id, Instant slot, Supplier action) { + if(!id.matches("[a-z0-9-]{1,50}")) throw new IllegalArgumentException("Ungültige Taskkennung"); + return db.cronOperation(id,()->{ + var prior=find(id); + if(prior!=null && !Instant.parse(prior.slot()).isBefore(slot)) return prior.result(); + // Do not overwrite evidence of a crash/ambiguous handoff on the next day either. + if(prior!=null && Set.of("RUNNING","UNCERTAIN").contains(prior.result().state())) + return new Result("UNCERTAIN","Vorheriger Lauf muss geprüft werden; kein automatischer Neuversand.",0); + var run=new Run(id,slot.toString(),Instant.now().toString(),"",new Result("RUNNING","Gestartet",0)); + save(run,prior==null); + Result result; + try { result=action.get(); } + catch(org.springframework.web.server.ResponseStatusException e) { result=new Result("BLOCKED",Objects.toString(e.getReason(),"Voraussetzungen fehlen."),0); } + catch(RuntimeException e) { result=new Result("UNCERTAIN","Aufgabe unterbrochen. Mögliche Teilwirkungen und Betriebsprotokoll vor Freigabe prüfen.",0); } + save(new Run(id,run.slot(),run.started(),Instant.now().toString(),result),false); + return result; + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronSoap.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronSoap.java new file mode 100644 index 0000000..0989c50 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronSoap.java @@ -0,0 +1,89 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; +import java.net.*; +import java.net.http.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.nio.ByteBuffer; +import static de.stadtbote.votian.AdminSupport.bad; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.LegacyCronSoapProtocol.*; + +/** Daily PTnova master/status imports, scheduled separately as in the confirmed crontab. */ +@Component +class LegacyCronSoap { + static final int MAX_BYTES=32*1024*1024; + final DatabaseService db;final BusinessSupport support;final Environment env;final HttpClient http;final Clock clock; + @org.springframework.beans.factory.annotation.Autowired + LegacyCronSoap(DatabaseService db,BusinessSupport support,Environment env){this(db,support,env,HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).followRedirects(HttpClient.Redirect.NEVER).build(),Clock.system(LegacyCronTasks.ZONE));} + LegacyCronSoap(DatabaseService db,BusinessSupport support,Environment env,HttpClient http,Clock clock){this.db=db;this.support=support;this.env=env;this.http=http;this.clock=clock;} + String property(String key){return env.getProperty("app.legacy-cron.soap."+key,"").strip();} + long positive(String key,long fallback){String v=property(key);try{long value=v.isEmpty()?fallback:Long.parseLong(v);if(value<=0)throw new IllegalArgumentException();return value;}catch(RuntimeException e){throw bad("Ungültige PTnova-Konfiguration: "+key);}} + static URI endpoint(String value){try{var uri=URI.create(value);if(!"https".equals(uri.getScheme())||uri.getHost()==null||uri.getUserInfo()!=null||uri.getFragment()!=null)throw new IllegalArgumentException();return uri;}catch(RuntimeException e){throw bad("LEGACY_CRON_SOAP_URL benötigt eine HTTPS-Adresse ohne eingebettete Zugangsdaten.");}} + byte[] fetch(int mode,String since){ + var endpoint=endpoint(property("url"));String user=property("user"),password=env.getProperty("app.legacy-cron.soap.password",""); + if(user.isEmpty()||user.contains(":")||password.isEmpty())throw bad("PTnova-Zugangsdaten fehlen oder sind ungültig."); + String authorization=Base64.getEncoder().encodeToString((user+":"+password).getBytes(StandardCharsets.UTF_8)); + var request=HttpRequest.newBuilder(endpoint).timeout(Duration.ofMinutes(5)).header("Authorization","Basic "+authorization) + .header("Content-Type","text/xml; charset=utf-8").header("Accept","text/xml").header("SOAPAction",action(mode)) + .POST(HttpRequest.BodyPublishers.ofString(request(mode,since),StandardCharsets.UTF_8)).build(); + var pending=http.sendAsync(request,info->limitedBody()); + try{ + var response=pending.get(5,TimeUnit.MINUTES); + if(response.statusCode()!=200)throw bad("PTnova antwortet mit HTTP "+response.statusCode()+". Lesemarke bleibt unverändert."); + return response.body(); + }catch(InterruptedException e){pending.cancel(true);Thread.currentThread().interrupt();throw bad("PTnova-Abfrage wurde unterbrochen.");} + catch(TimeoutException e){pending.cancel(true);throw bad("PTnova-Antwort dauert länger als fünf Minuten.");} + catch(ExecutionException e){throw bad("PTnova-Abfrage fehlgeschlagen. Verbindung, TLS-Zertifikat und Antwortgröße (maximal 32 MiB) prüfen.");} + } + static HttpResponse.BodySubscriber limitedBody(){ + return new HttpResponse.BodySubscriber<>(){ + final HttpResponse.BodySubscriber delegate=HttpResponse.BodySubscribers.ofByteArray(); + java.util.concurrent.Flow.Subscription subscription;long bytes;boolean failed; + public CompletionStage getBody(){return delegate.getBody();} + public void onSubscribe(java.util.concurrent.Flow.Subscription value){subscription=value;delegate.onSubscribe(value);} + public void onNext(List buffers){ + if(failed)return; + for(var buffer:buffers)bytes+=buffer.remaining(); + if(bytes>MAX_BYTES){failed=true;subscription.cancel();delegate.onError(new IOException("PTnova response exceeds limit"));return;} + delegate.onNext(buffers); + } + public void onError(Throwable error){if(!failed){failed=true;delegate.onError(error);}} + public void onComplete(){if(!failed)delegate.onComplete();} + }; + } + + String checkpoint(long hq,int mode){ + var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='hq' AND gdc_obj_id=? AND gdc_gen_fieldname='pt_nova' AND gdc_context=?",hq,Integer.toString(mode)); + if(rows.size()>1)throw bad("PTnova-Lesemarke ist mehrfach vorhanden.");return rows.isEmpty()?"":text(rows.getFirst(),"gdc_content"); + } + LegacyCronRunner.Result run(int mode){ + var result=db.cronOperation("hha-soap-import",()->{ + long hq=positive("headquarters",1),container=positive("container-stock",1000); + if(db.queryForList("SELECT hq_id FROM headquarters WHERE hq_id=? AND hq_disabled=0",hq).size()!=1)throw bad("PTnova-Niederlassung fehlt oder ist gesperrt."); + var started=LocalDateTime.now(clock).withNano(0);String before=checkpoint(hq,mode),since=since(before,started); + var payload=parse(fetch(mode,since),mode); + return apply(mode,hq,container,payload,before,started); + }); + return result==null?new LegacyCronRunner.Result("BLOCKED","Ein anderer PTnova-Import läuft bereits.",0):result; + } + LegacyCronRunner.Result apply(int mode,long hq,long container,Payload payload,String before,LocalDateTime started){ + // No network access while holding table locks. MyISAM partial effects must remain UNCERTAIN. + try{return db.cronSoapWriteTransaction(support.historySchema.replace("`",""),()->{ + if(!checkpoint(hq,mode).equals(before))throw bad("PTnova-Lesemarke wurde zwischenzeitlich verändert."); + var store=new LegacyCronSoapStore(db,support,hq,container,started); + int count=mode==1?store.master(payload):store.status(payload); + if(before.isEmpty()&&db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='hq' AND gdc_obj_id=? AND gdc_gen_fieldname='pt_nova' AND gdc_context=?",hq,Integer.toString(mode)).isEmpty()) + db.insert("genericdatacontainer",fields("gdc_obj_type","hq","gdc_obj_id",hq,"gdc_gen_fieldname","pt_nova","gdc_context",Integer.toString(mode),"gdc_content",started.format(STAMP))); + else db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_obj_type='hq' AND gdc_obj_id=? AND gdc_gen_fieldname='pt_nova' AND gdc_context=?",started.format(STAMP),hq,Integer.toString(mode)); + return new LegacyCronRunner.Result("OK","PTnova importiert: "+count+"; nicht zuordenbar/übersprungen: "+store.skipped+".",count); + });}catch(RuntimeException e){throw new IllegalStateException("PTnova-Import unterbrochen; mögliche Teilwirkungen vor erneutem Lauf prüfen.",e);} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapProtocol.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapProtocol.java new file mode 100644 index 0000000..7274a2f --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapProtocol.java @@ -0,0 +1,110 @@ +package de.stadtbote.votian; + +import org.w3c.dom.*; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.XMLConstants; +import java.io.*; +import java.math.BigDecimal; +import java.time.*; +import java.time.format.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.bad; + +/** PTnova mappings from the current server's sysadmin/soap/soap.php. No PHP execution. */ +final class LegacyCronSoapProtocol { + static final String SOAP="http://schemas.xmlsoap.org/soap/envelope/"; + static final String SAP="urn:sap-com:document:sap:soap:functions:mc-style"; + static final List TYPES=List.of("HUW","H&W","H&WB","HWB","STU","STH","STB","CAGU","STVS"); + static final DateTimeFormatter STAMP=DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withResolverStyle(ResolverStyle.STRICT); + record Device(String type,String serial) {} + record Message(String type,String code,String description,String text) {} + record Notice(String code,String description) {} + record Status(String type,String serial,LocalDate date,List notices,Map money,BigDecimal cards) {} + record Payload(List devices,List messages,List statuses) {} + + static String operation(int mode){if(mode!=0&&mode!=1)throw bad("Ungültiger SOAP-Modus.");return mode==1?"ZPtnWstpMasterdata":"ZPtnWstpStatusdata";} + static String action(int mode){return SAP+":ZPTN_WS_TOURENPLANUNG:"+operation(mode)+"Request";} + static LocalDateTime timestamp(String value){try{return LocalDateTime.parse(value,STAMP);}catch(RuntimeException e){throw bad("Ungültiger PTnova-Zeitstempel.");}} + static String since(String checkpoint,LocalDateTime now){ + var fallback=now.toLocalDate().minusDays(1).atStartOfDay(); + if(checkpoint!=null&&!checkpoint.isBlank()){ + var saved=timestamp(checkpoint); + if(saved.isAfter(now))throw bad("PTnova-Lesemarke liegt in der Zukunft."); + if(saved.isAfter(fallback))return checkpoint; + } + return fallback.format(STAMP); + } + static String request(int mode,String since){ + timestamp(since); + var xml=new StringBuilder(""); + for(String type:TYPES)xml.append("HHA").append(type.replace("&","&")).append(""); + return xml.append("").append(mode==0?"X":"").append("").append(since).append("").toString(); + } + static boolean allowed(String organization,String type){return Set.of("HHA","HDAG").contains(organization)&&(TYPES.contains(type)||type.equals("H+W"));} + static String serial(String value){return value.strip().replaceFirst("^0+","");} + static Integer moneyField(String type,String position){ + if(position.isEmpty())return null; + if(type.equals("BEK"))return 5;if(type.equals("MEK"))return 6; + if(type.equals("ZGS")&&Set.of("0","1","2").contains(position))return 7+Integer.parseInt(position); + if(type.equals("RGS")&&Set.of("10","11","12","13","14","15").contains(position))return Integer.parseInt(position); + return null; + } + static BigDecimal amount(String value){try{var n=new BigDecimal(value.replace(',','.'));if(n.precision()>24||n.scale()>8)throw new IllegalArgumentException();return n;}catch(RuntimeException e){throw bad("Ungültiger PTnova-Bestand.");}} + static List children(Element parent,String name){ + var result=new ArrayList(); + for(Node n=parent.getFirstChild();n!=null;n=n.getNextSibling())if(n instanceof Element e&&name.equals(e.getLocalName()))result.add(e); + return result; + } + static Element child(Element parent,String name,boolean required){ + var matches=children(parent,name);if(matches.size()>1||required&&matches.isEmpty())throw bad("Unvollständige oder mehrdeutige PTnova-Antwort: "+name); + return matches.isEmpty()?null:matches.getFirst(); + } + static String text(Element parent,String name){var e=child(parent,name,false);return e==null?"":e.getTextContent().strip();} + static List items(Element parent,String name){var e=child(parent,name,false);return e==null?List.of():children(e,"item");} + static Payload parse(byte[] bytes,int mode){ + if(bytes.length>32*1024*1024)throw bad("PTnova-Antwort ist zu groß."); + try{ + var factory=DocumentBuilderFactory.newInstance();factory.setNamespaceAware(true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl",true); + factory.setFeature("http://xml.org/sax/features/external-general-entities",false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities",false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD,"");factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA,""); + factory.setXIncludeAware(false);factory.setExpandEntityReferences(false); + var builder=factory.newDocumentBuilder();builder.setErrorHandler(new org.xml.sax.helpers.DefaultHandler(){@Override public void fatalError(org.xml.sax.SAXParseException e)throws org.xml.sax.SAXException{throw e;}}); + var root=builder.parse(new ByteArrayInputStream(bytes)).getDocumentElement(); + if(!"Envelope".equals(root.getLocalName())||!SOAP.equals(root.getNamespaceURI()))throw bad("Keine gültige SOAP-1.1-Antwort."); + var body=child(root,"Body",true);if(child(body,"Fault",false)!=null)throw bad("SAP hat einen SOAP-Fehler gemeldet. Lesemarke bleibt unverändert."); + var response=child(body,operation(mode)+"Response",true); + if(!SAP.equals(response.getNamespaceURI()))throw bad("Unerwarteter SAP-Namensraum."); + var devices=new ArrayList();var messages=new ArrayList();var statuses=new ArrayList(); + if(mode==1){ + var data=child(response,"EsMasterdata",true);child(data,"Devicetypes",true); + for(var type:items(data,"Devicetypes")){ + String kind=text(type,"DevtyhdId");if(!allowed(text(type,"OrgunhdIdPr"),kind))continue; + for(var device:items(type,"Devices")){String serial=serial(text(device,"DevichdId"));if(!serial.isEmpty())devices.add(new Device(kind,serial));} + for(var release:items(type,"Swreleases"))for(var message:items(release,"Messages")){ + String code=text(message,"DevsrmsgId"),description=text(message,"Description"); + if(!code.isEmpty()&&!description.isEmpty())messages.add(new Message(kind,code,description,text(message,"Msgtext"))); + } + } + }else{ + var data=child(response,"EsStatusdata",true);child(data,"Statusdata",true); + for(var status:items(data,"Statusdata")){ + String type=text(status,"DevtyhdId"),serial=serial(text(status,"DevichdId")); + if(!allowed(text(status,"OrgunhdIdPr"),type)||serial.isEmpty())continue; + var date=timestamp(text(status,"TstmpDevsthd")).toLocalDate();var notices=new ArrayList(); + for(var n:items(status,"Devstopn")){ + String end=text(n,"TstmpFinishd"),code=text(n,"DevsrmsgId"); + if(!code.isEmpty()&&(end.isEmpty()||end.equals("00000000000000")))notices.add(new Notice(code,text(n,"DevCatMf"))); + } + var money=new LinkedHashMap(); + for(var m:items(status,"Devstmny")){var field=moneyField(text(m,"CashboxType"),text(m,"PositionDesc"));if(field!=null)money.put(field,amount(text(m,"NominalAmnt")));} + BigDecimal cards=null; + if(Set.of("STU","STH","STB","STVS").contains(type))for(var material:items(status,"Devstmat"))if(text(material,"MatUnitExt").equals("KARTEN"))cards=amount(text(material,"NominalStock")); + statuses.add(new Status(type,serial,date,List.copyOf(notices),Collections.unmodifiableMap(money),cards)); + } + } + return new Payload(List.copyOf(devices),List.copyOf(messages),List.copyOf(statuses)); + }catch(org.springframework.web.server.ResponseStatusException e){throw e;}catch(Exception e){throw bad("PTnova-Antwort konnte nicht sicher gelesen werden.");} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapStore.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapStore.java new file mode 100644 index 0000000..7244a8b --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapStore.java @@ -0,0 +1,108 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.bad; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.LegacyCronSoapProtocol.*; + +/** Persists the fields that the PHP importer actually writes (not its inactive blocks). */ +final class LegacyCronSoapStore { + final DatabaseService db;final BusinessSupport support;final long hq,container;final LocalDateTime now;int skipped; + LegacyCronSoapStore(DatabaseService db,BusinessSupport support,long hq,long container,LocalDateTime now){this.db=db;this.support=support;this.hq=hq;this.container=container;this.now=now;} + Map optional(List> rows,String what){if(rows.size()>1)throw bad("Mehrdeutige PTnova-Zuordnung: "+what);return rows.isEmpty()?null:rows.getFirst();} + Map article(String type){return optional(db.queryForList("SELECT at_id FROM article WHERE at_match=? AND hq_id IN (0,?)",type,hq),"Artikeltyp");} + Map unit(String serial){ + var item=optional(db.queryForList("SELECT ati_id,at_id,stk_id,ati_serialno FROM articleitem WHERE ati_serialno=?",serial),"Seriennummer"); + if(item!=null)stock(id(item,"stk_id"));return item; + } + Map stock(long stock){ + var row=optional(db.queryForList("SELECT stk_id,stk_pre_id,stk_path,hq_id FROM stock WHERE stk_id=?",stock),"Lagerort"); + if(row==null||id(row,"hq_id")!=0&&id(row,"hq_id")!=hq)throw bad("PTnova-Lagerort fehlt oder gehört zu einer anderen Niederlassung.");return row; + } + int master(Payload payload){ + int count=0; + for(var device:payload.devices()){ + if(unit(device.serial())!=null)continue; + var article=article(device.type());if(article==null){skipped++;continue;} + stock(container);long parent=parent(); + long type=id(article,"at_id"); + long item=db.insert("articleitem",fields("at_id",type,"stk_id",container,"ati_serialno",device.serial(),"ati_data_01","","ati_data_02","","ati_data_03","","ati_data_04",now,"ati_data_21","")); + createStock(parent,item,"FA"+device.serial(),false); + var quantities=db.queryForList("SELECT stkat_itemquantity FROM stockarticle WHERE stk_id=? AND at_id=?",container,type); + if(quantities.size()>1)throw bad("Mehrdeutiger PTnova-Lagerbestand."); + if(quantities.isEmpty())db.update("INSERT INTO stockarticle (stk_id,at_id,stkat_quantity,stkat_itemquantity) VALUES (?,?,1,1)",container,type); + else db.update("UPDATE stockarticle SET stkat_quantity=COALESCE(stkat_itemquantity,0)+1,stkat_itemquantity=COALESCE(stkat_itemquantity,0)+1 WHERE stk_id=? AND at_id=?",container,type); + count++; + } + for(var message:payload.messages()){ + var article=article(message.type());if(article==null){skipped++;continue;}long type=id(article,"at_id"); + var matches=db.queryForList("SELECT atm_code FROM articlemessage WHERE at_id=? AND atm_code=?",type,message.code()); + if(matches.size()>1)throw bad("Mehrdeutiger PTnova-Betriebsmeldungscode."); + if(matches.isEmpty())db.update("INSERT INTO articlemessage (at_id,atm_code,atm_desc,atm_text,atm_prio) VALUES (?,?,?,?,0)",type,message.code(),message.description(),message.text()); + else db.update("UPDATE articlemessage SET atm_desc=?,atm_text=? WHERE at_id=? AND atm_code=?",message.description(),message.text(),type,message.code()); + count++; + } + return count; + } + long parent(){ + var catalog=new OrderEntryCatalog(db);String value=catalog.parameter(hq,"SERVICEUNIT_STOCK_PARENT_ID"); + if(!value.matches("[0-9]+"))value=catalog.parameter(0,"SERVICEUNIT_STOCK_PARENT_ID"); + long parent=0;if(value.matches("[0-9]+"))try{parent=Long.parseLong(value);}catch(NumberFormatException e){throw bad("Ungültiges Serviceunit-Hauptlager.");} + if(parent>0){stock(parent);return parent;} + var root=optional(db.queryForList("SELECT stk_id FROM stock WHERE hq_id=? AND stk_pre_id=0 AND stk_obj_type='ati' AND stk_obj_id=0 AND stk_name='SYSTEM-SERVICEUNIT'",hq),"Serviceunit-Hauptlager"); + if(root!=null)return id(root,"stk_id"); + parent=createStock(0,0,"SYSTEM-SERVICEUNIT",true); + for(var entry:Map.of("MASK_STK_DATAFIELDHEADLINES_","01,02,03,04,05,06,07,08,09,10,11,12,13,14,15","MASK_STK_DATAFIELDS_","0").entrySet()) + db.insert("parameter",fields("emp_id",0,"hq_id",0,"par_key",entry.getKey()+parent,"par_value",entry.getValue(),"par_text","")); + return parent; + } + long createStock(long parent,long item,String name,boolean root){ + String path="";if(parent>0){var row=stock(parent);path=(text(row,"stk_path").isEmpty()?"//":text(row,"stk_path"))+parent+"//";} + long id=db.insert("stock",fields("hq_id",hq,"stk_pre_id",parent,"stk_obj_type","ati","stk_obj_id",item,"stk_name",name,"stk_path",path,"stk_barcode",name,"ad_id",0,"cs_id",0,"stk_visible",1,"stk_readonly",root?1:0,"stk_modifytime",now)); + support.audit(new LegacyUser(0,0,hq,"PTnova",""),0,0,0,87,"PTnova-Lager angelegt · STK_ID="+id+" · PARENT="+parent,UUID.randomUUID().toString());return id; + } + int status(Payload payload){ + int count=0; + for(var status:payload.statuses()){ + var item=unit(status.serial());if(item==null){skipped++;continue;} + long article=id(item,"at_id"),stock=id(item,"stk_id"),itemId=id(item,"ati_id"); + var codes=new LinkedHashSet(); + for(var notice:status.notices()){ + String description=notice.description(); + if(description.isEmpty()||description.equals("keine Angabe")){ + var message=optional(db.queryForList("SELECT atm_desc FROM articlemessage WHERE at_id=? AND atm_code=?",article,notice.code()),"Betriebsmeldung"); + description=message==null?"":text(message,"atm_desc"); + } + if(!description.isEmpty())codes.add(notice.code());else skipped++; + } + var values=new LinkedHashMap();values.put("ati_data_17",String.join(",",codes));values.put("ati_data_29",""); + var date=status.date().atTime(3,0); + for(var amount:status.money().entrySet()){values.put(String.format("ati_data_%02d",amount.getKey()),amount.getValue().toPlainString());} + if(!status.money().isEmpty()){values.put("ati_data_04",date);values.put("ati_data_30","");} + if(status.cards()!=null)values.put("ati_data_09",status.cards().toPlainString()); + BusinessSupport.update(db,"articleitem","ati_id",itemId,values); + if(!status.money().isEmpty()||status.cards()!=null){ + String table=support.historySchema+".articleitemhistory"; + var history=db.queryForList("SELECT atih_serialno FROM "+table+" WHERE at_id=? AND stk_id=? AND atih_serialno=? AND atih_data_04=?",article,stock,status.serial(),date); + if(history.size()>1)throw bad("Mehrdeutige PTnova-Tageshistorie."); + var amounts=new LinkedHashMap();for(var amount:status.money().entrySet())amounts.put(String.format("atih_data_%02d",amount.getKey()),amount.getValue().toPlainString()); + if(!status.money().isEmpty())amounts.put("atih_createtime",now); + if(status.cards()!=null)amounts.put("atih_data_09",status.cards().toPlainString()); + if(!history.isEmpty()){ + var arguments=new ArrayList(amounts.values());Collections.addAll(arguments,article,stock,status.serial(),date); + db.update("UPDATE "+table+" SET "+String.join(",",amounts.keySet().stream().map(key->key+"=?").toList())+" WHERE at_id=? AND stk_id=? AND atih_serialno=? AND atih_data_04=?",arguments.toArray()); + } + else if(!status.money().isEmpty()){ + var row=fields("at_id",article,"stk_id",stock,"atih_serialno",status.serial(),"atih_data_01","","atih_data_02","","atih_data_03","","atih_data_04",date);row.putAll(amounts);BusinessSupport.insertWithoutKey(db,table,row); + } + // PHP updates card-only history only if a money/history row already exists. + } + count++; + } + return count; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronTasks.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronTasks.java new file mode 100644 index 0000000..be6cedc --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronTasks.java @@ -0,0 +1,76 @@ +package de.stadtbote.votian; + +import org.springframework.stereotype.Component; +import org.springframework.core.env.Environment; +import org.springframework.context.event.EventListener; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.support.CronTrigger; +import org.springframework.scheduling.support.CronExpression; +import jakarta.annotation.PreDestroy; +import java.time.*; +import java.util.*; +import java.util.function.Function; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Explicit crontab migration. This scheduler never invokes PHP, a shell or a legacy HTTP endpoint. */ +@Component +class LegacyCronTasks { + static final ZoneId ZONE=ZoneId.of("Europe/Berlin"); + record Task(String id,String label,String group,String cron,String source,Function action) {} + final LegacyCronRunner runner;final Environment env;final List tasks=new ArrayList<>(); + final ThreadPoolTaskScheduler scheduler=new ThreadPoolTaskScheduler(); + final Map schedulingErrors=new java.util.concurrent.ConcurrentHashMap<>(); + final Set enabled; + LegacyCronTasks(LegacyCronRunner runner,LegacyCronCompliance compliance,LegacyCronBusiness business,LegacyCronExports exports,LegacyCronFiles files,LegacyCronReplication replication,LegacyCronSoap soap,Environment env){ + this.runner=runner;this.env=env; + enabled=new LinkedHashSet<>();for(String s:env.getProperty("app.legacy-cron.jobs","").split(","))if(!s.isBlank())enabled.add(s.strip()); + add("courier-logout","Abmeldung nach automatischem Auftragsentzug","courier-logout","0 */30 * * * *","tools/logout_cr_by_autorevokes.php",slot->business.logout()); + for(long hq=1;hq<=11;hq++)for(var rule:LegacyCronCompliance.RULES){if(rule.script()==1715&&hq!=8)continue;final long branch=hq; + add("stb-"+hq+"-"+rule.script(),rule.label()+" · NL "+hq,"stb-compliance","0 0 4 * * MON-FRI","tools/auto_trigger_check_metafield_mtfk_"+rule.script()+".php",slot->compliance.run(branch,rule,slot.atZone(ZONE).toLocalDate())); + } + for(var branch:LegacyCronExports.BRANCHES)for(int i=1;i<=3;i++){final int category=i; + add("sb-export-"+branch.hq()+"-"+i,LegacyCronExports.profile(i)+" · NL "+branch.hq(),"sb-master-export","0 45 20 * * *","tools/cron_export_SB.php",slot->exports.run(branch,category,slot)); + } + add("hha-safebags","HHA · Vergessene Safebags","hha-safebags","0 40 9 * * *","illt/cron_special_HHA.php",slot->business.safebags(env.getProperty("app.legacy-cron.hha-mail-to",""),env.getProperty("app.legacy-cron.hha-mail-bcc",""))); + add("mysql-status","MySQL-Status · Anwendungsdatenbank","mysql-status","0 */5 * * * *","sysadmin/votian_admin/mysql_status.php",slot->business.mysqlStatus()); + add("file-maintenance","Altsystem-Dateiwartung","file-maintenance","0 1 0 * * *","sysadmin/votian_admin/do_bzip2.php",slot->files.run(slot.atZone(ZONE).toLocalDate())); + add("replication-monitor","Replikationsüberwachung","replication-monitor","0 */15 * * * *","sysadmin/cron_monitor_replication.php",slot->replication.run(slot.atZone(ZONE).toLocalDate())); + add("hha-soap-master","HHA · PTnova-Stammdaten","hha-soap-master","0 10 5 * * *","sysadmin/soap/soap.php · Modus 1",slot->soap.run(1)); + add("hha-soap-status","HHA · PTnova-Statusdaten","hha-soap-status","0 20 5 * * *","sysadmin/soap/soap.php · Modus 0",slot->soap.run(0)); + var known=Set.of("hha-soap-master","hha-soap-status","courier-logout","stb-compliance","sb-master-export","hha-safebags","mysql-status","file-maintenance","replication-monitor"); + if(!known.containsAll(enabled))throw new IllegalArgumentException("Unbekannte native Cron-Gruppe: "+enabled.stream().filter(s->!known.contains(s)).toList()); + scheduler.setPoolSize(2);scheduler.setThreadNamePrefix("legacy-cron-");scheduler.setRemoveOnCancelPolicy(true); + scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + scheduler.setWaitForTasksToCompleteOnShutdown(true);scheduler.setAwaitTerminationSeconds(30); + } + void add(String id,String label,String group,String defaultCron,String source,Function action){ + String cron=env.getProperty("app.legacy-cron.schedules."+group,defaultCron);CronExpression.parse(cron); + tasks.add(new Task(id,label,group,cron,source,action)); + } + @EventListener(ApplicationReadyEvent.class) + public void start(){scheduler.initialize();for(var task:tasks)if(enabled.contains(task.group())){ + var scheduled=new java.util.concurrent.atomic.AtomicReference();var trigger=new CronTrigger(task.cron(),ZONE); + scheduler.schedule(()->execute(task,scheduled.get()),context->{var next=trigger.nextExecution(context);scheduled.set(next);return next;}); + } + org.slf4j.LoggerFactory.getLogger(LegacyCronTasks.class).info("Spring-Tasks: {} von {} eingeplant ({}; Gruppen: {}).",scheduler.getScheduledThreadPoolExecutor().getQueue().size(),tasks.size(),ZONE,String.join(",",enabled)); + } + void execute(Task task,Instant slot){try{runner.execute(task.id(),slot,()->task.action().apply(slot));schedulingErrors.remove(task.id());} + catch(RuntimeException e){schedulingErrors.put(task.id(),"Task konnte nicht sicher protokolliert werden. Datenbankverbindung und Laufbeleg prüfen.");}} + @PreDestroy public void stop(){scheduler.shutdown();} + List> view(){ + var rows=new ArrayList>();var now=ZonedDateTime.now(ZONE); + Map>> stored=Map.of();String readError=""; + try{stored=runner.db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcron' AND gdc_obj_id=0").stream().collect(java.util.stream.Collectors.groupingBy(r->LegacyMapping.text(r,"gdc_gen_fieldname")));} + catch(RuntimeException e){readError="Cron-Laufbelege konnten nicht gelesen werden.";} + for(var t:tasks){boolean active=enabled.contains(t.group());LegacyCronRunner.Run run=null;String error=schedulingErrors.getOrDefault(t.id(),""); + if(!readError.isEmpty())error=readError; + else try{run=runner.decode(t.id(),stored.getOrDefault(t.id(),List.of()));}catch(RuntimeException e){error="Cron-Laufbeleg konnte nicht gelesen werden.";} + var next=active?CronExpression.parse(t.cron()).next(now):null; + rows.add(fields("id",t.id(),"label",t.label(),"group",t.group(),"cron",t.cron(),"zone",ZONE.toString(),"source",t.source(),"enabled",active,"state",!error.isEmpty()?"ERROR":!active?"DISABLED":run==null?"WAITING":run.result().state(),"message",!error.isEmpty()?error:run==null?"Wartet auf den nächsten Termin.":run.result().message(),"lastRun",run==null?"":run.finished(),"slot",run==null?"":run.slot(),"processed",run==null?0:run.result().processed(),"nextRun",next==null?"":next.toInstant().toString())); + } + return rows; + } + // The two remaining HHA wrappers were explicitly retired by the operator on 2026-10-27. + static List> unresolved(){return List.of();} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OperationsMonitoring.java b/backend/src/main/java/de/stadtbote/votian/OperationsMonitoring.java index ec8f10a..3f621e8 100644 --- a/backend/src/main/java/de/stadtbote/votian/OperationsMonitoring.java +++ b/backend/src/main/java/de/stadtbote/votian/OperationsMonitoring.java @@ -15,6 +15,8 @@ import static de.stadtbote.votian.OrderEntryService.fields; @RestController @RequestMapping("/api/administration/operations") class OperationsMonitoring { + @org.springframework.beans.factory.annotation.Autowired(required=false) + LegacyCronTasks cron; final DatabaseService db; final BusinessSupport support; final OrderCompletionProcessing completion; @@ -85,7 +87,7 @@ class OperationsMonitoring { if(!allowed(u))throw new AccessDeniedException("Der Betriebsstatus benötigt die Mitarbeiterverwaltung in der ursprünglichen Niederlassung."); var now=clock.instant(); return fields("at",now.toString(),"startedAt",started.toString(),"headquartersId",u.headquartersId(), - "workers",workers(now),"signals",read(this::signals), + "workers",workers(now),"cron",cron==null?null:fields("items",cron.view(),"unresolved",LegacyCronTasks.unresolved()),"signals",read(this::signals), "database",read(()->database(u.headquartersId())),"history",read(()->history(u.headquartersId()))); } Map read(Supplier> source) { diff --git a/backend/src/main/java/de/stadtbote/votian/database/DatabaseService.java b/backend/src/main/java/de/stadtbote/votian/database/DatabaseService.java index 8ace63c..9c59870 100644 --- a/backend/src/main/java/de/stadtbote/votian/database/DatabaseService.java +++ b/backend/src/main/java/de/stadtbote/votian/database/DatabaseService.java @@ -319,6 +319,56 @@ public class DatabaseService { } } + /** A connection-scoped mutex for one native cron task, shared by all NG instances. */ + public T cronOperation(String task, Supplier work) { + if(!task.matches("[a-z0-9-]{1,60}")) throw new IllegalArgumentException("Invalid cron task"); + return jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { + String name="ng-cron-"+java.util.UUID.nameUUIDFromBytes((connection.getCatalog()+":"+task).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try(var acquire=connection.prepareStatement("SELECT GET_LOCK(?,0)")) { + acquire.setString(1,name); + try(var result=acquire.executeQuery()) { if(!result.next()||result.getInt(1)!=1) return null; } + } + try { return work.get(); } + finally { try(var release=connection.prepareStatement("SELECT RELEASE_LOCK(?)")){release.setString(1,name);release.execute();} } + }); + } + + /** Serialize PTnova inventory, history and checkpoint changes, including legacy MyISAM tables. */ + public T cronSoapWriteTransaction(String historySchema,Supplier work) { + if(historySchema==null||!historySchema.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Invalid history schema"); + return writeTransaction(() -> { + jdbc.execute("LOCK TABLES headquarters READ,article READ,articleitem WRITE,articlemessage WRITE,stock WRITE,stockarticle WRITE,parameter WRITE,genericdatacontainer WRITE,`"+historySchema+"`.articleitemhistory WRITE"); + try {T result=work.get();jdbc.execute("COMMIT");return result;} + catch(RuntimeException | Error e){jdbc.execute("ROLLBACK");throw e;} + finally {jdbc.execute("UNLOCK TABLES");} + }); + } + + /** Fixed read-only diagnostic query; callers cannot supply arbitrary SHOW statements. */ + public List> globalStatus() { return jdbc.queryForList("SHOW GLOBAL STATUS"); } + + /** Read-only counters used by cron_monitor_replication.php, with bounded connections. */ + public long cronReplicaCount(String host,String user,String password,String metric,java.time.LocalDate day) { + if(host==null||!host.matches("[A-Za-z0-9.-]+:[0-9]{1,5}"))throw new IllegalArgumentException("Invalid replica endpoint"); + String sql=switch(metric){ + case "FINISHED" -> "SELECT COUNT(*) FROM job WHERE jb_status=2 AND COALESCE(jb_storno,0)=0 AND jb_finishtime>=? AND jb_finishtime "SELECT COUNT(*) FROM job WHERE COALESCE(jb_storno,0)=0 AND jb_ordertime>=? AND jb_ordertime "SELECT COUNT(*) FROM phoenix_log.articleitemhistory"; + case "META" -> "SELECT COUNT(*) FROM meta_object.metaobject"; + case "PROBES" -> "SELECT COUNT(*) FROM phoenix_log.probe WHERE pr_date>=? AND pr_date throw new IllegalArgumentException("Invalid replica counter"); + }; + var source=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:mariadb://"+host+"/phoenix?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false",user,password); + try(var connection=source.getConnection()){ + connection.setReadOnly(true); + try(var statement=connection.prepareStatement(sql)){ + statement.setQueryTimeout(5); + if(sql.contains("?")){statement.setObject(1,day.atStartOfDay());statement.setObject(2,day.plusDays(1).atStartOfDay());} + try(var result=statement.executeQuery()){if(!result.next())throw new SQLException("Counter missing");return result.getLong(1);} + } + }catch(SQLException e){throw new DataAccessResourceFailureException("Replikationszähler nicht erreichbar.");} + } + /** Coordinate NG file operations across processes without holding table locks during SFTP. */ public T documentOperation(java.util.function.Supplier work) { return jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 846a75c..f839667 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -41,6 +41,35 @@ spring: serialization: write-dates-as-timestamps: false app: + legacy-cron: + jobs: ${SPRING_TASK_GROUPS:${LEGACY_CRON_JOBS:}} + hha-mail-to: ${LEGACY_CRON_HHA_MAIL_TO:} + hha-mail-bcc: ${LEGACY_CRON_HHA_MAIL_BCC:} + soap: + url: ${LEGACY_CRON_SOAP_URL:} + user: ${LEGACY_CRON_SOAP_USER:} + password: ${LEGACY_CRON_SOAP_PASSWORD:} + headquarters: ${LEGACY_CRON_SOAP_HEADQUARTERS:1} + container-stock: ${LEGACY_CRON_SOAP_CONTAINER_STOCK:1000} + replication: + targets: '${LEGACY_CRON_REPLICATION_TARGETS:{}}' + user: ${LEGACY_CRON_REPLICATION_USER:} + password: ${LEGACY_CRON_REPLICATION_PASSWORD:} + mail-to: ${LEGACY_CRON_REPLICATION_MAIL_TO:} + files: + binlog-directories: ${LEGACY_CRON_BINLOG_DIRECTORIES:} + log-directories: ${LEGACY_CRON_LOG_DIRECTORIES:} + temporary-directories: ${LEGACY_CRON_TEMPORARY_DIRECTORIES:} + schedules: + hha-soap-master: ${LEGACY_CRON_SOAP_MASTER_SCHEDULE:0 10 5 * * *} + hha-soap-status: ${LEGACY_CRON_SOAP_STATUS_SCHEDULE:0 20 5 * * *} + courier-logout: ${LEGACY_CRON_LOGOUT_SCHEDULE:0 */30 * * * *} + stb-compliance: ${LEGACY_CRON_STB_MAIL_SCHEDULE:0 0 4 * * MON-FRI} + sb-master-export: ${LEGACY_CRON_SB_EXPORT_SCHEDULE:0 45 20 * * *} + hha-safebags: ${LEGACY_CRON_HHA_SAFEBAG_SCHEDULE:0 40 9 * * *} + mysql-status: ${LEGACY_CRON_DB_STATUS_SCHEDULE:0 */5 * * * *} + file-maintenance: ${LEGACY_CRON_FILES_SCHEDULE:0 1 0 * * *} + replication-monitor: ${LEGACY_CRON_REPLICATION_SCHEDULE:0 */15 * * * *} completion: enabled: ${ORDER_COMPLETION_WORKER_ENABLED:false} php-stopped: ${ORDER_COMPLETION_PHP_STOPPED:false} diff --git a/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java b/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java index cb334af..65d4caa 100644 --- a/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java +++ b/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java @@ -34,6 +34,17 @@ class ApiSecurityTest { @MockitoBean DatabaseService database; @MockitoBean LoginActivity loginActivity; + @Test void cronReviewAndStatusRequireSessionRightsAndCsrf() throws Exception { + mvc.perform(get("/api/administration/cron/database-status?month=2026-10")).andExpect(status().isUnauthorized()); + var session=authenticated("existing-user","existing-password"); + mvc.perform(post("/api/administration/cron/mysql-status/review").session(session).contentType("application/json").content("{\"reviewed\":true}")).andExpect(status().isForbidden()); + when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("no-cron-rights"),any(),any())).thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","no-cron-rights","emp_rights","00000000"))); + var denied=authenticated("no-cron-rights","pw");var token=csrf(denied); + mvc.perform(get("/api/administration/cron/database-status?month=2026-10").session(denied)).andExpect(status().isForbidden()); + mvc.perform(post("/api/administration/cron/mysql-status/review").session(denied).header(token.header(),token.token()).contentType("application/json").content("{\"reviewed\":true}")).andExpect(status().isForbidden()); + verify(database,never()).cronOperation(anyString(),any()); + } + @Test void customerAddressPropagationRequiresSessionRightsAndCsrf() throws Exception { String base="/api/master/customers/1/address-propagation"; var anon=csrf(null);var session=authenticated("existing-user","existing-password"); diff --git a/backend/src/test/java/de/stadtbote/votian/CustomerPortalIntegrationAssertions.java b/backend/src/test/java/de/stadtbote/votian/CustomerPortalIntegrationAssertions.java index 082669e..653317a 100644 --- a/backend/src/test/java/de/stadtbote/votian/CustomerPortalIntegrationAssertions.java +++ b/backend/src/test/java/de/stadtbote/votian/CustomerPortalIntegrationAssertions.java @@ -11,6 +11,8 @@ public final class CustomerPortalIntegrationAssertions { static void setting(DatabaseService db,long hq,long employee,String key,String value){write(db,"DELETE FROM parameter WHERE hq_id=? AND emp_id=? AND par_key=?",hq,employee,key);write(db,"INSERT INTO parameter (hq_id,emp_id,par_key,par_value) VALUES (?,?,?,?)",hq,employee,key,value);} static LegacyUser login(DatabaseService db,String account){return (LegacyUser)new LegacyAuthenticationProvider(db).authenticate(org.springframework.security.authentication.UsernamePasswordAuthenticationToken.unauthenticated(account,"CustomerTest9")).getPrincipal();} public static void verify(DatabaseService db,Consumer ddl)throws Exception{ + // Login probes the warehouse role before customer accounts, also in a fresh test database. + ddl.accept("CREATE TABLE IF NOT EXISTS stockuser(usr_id INT,stk_id INT) ENGINE=InnoDB"); ddl.accept("ALTER TABLE headquarters ADD hq_disabled TINYINT DEFAULT 0,ADD hq_workmode INT DEFAULT 2,ADD hq_invmode INT DEFAULT 1"); db.writeTransaction(()->{ for(int i=0;i<4;i++){insert(db,"company",fields("cmp_id",600+i,"cmp_comp",i==0?"Kundenportal Testfirma":"Verbundfirma "+i));insert(db,"customer",fields("cs_id",600+i,"hq_id",i==2?9:i==1?8:7,"csc_id",i==0?99:i==1?200:i==2?300:400,"cs_id_parent",i==1||i==2?600:0,"cmp_id",600+i,"cs_admin",i==0?105:0));} diff --git a/backend/src/test/java/de/stadtbote/votian/LegacyCronIntegrationAssertions.java b/backend/src/test/java/de/stadtbote/votian/LegacyCronIntegrationAssertions.java new file mode 100644 index 0000000..8c67186 --- /dev/null +++ b/backend/src/test/java/de/stadtbote/votian/LegacyCronIntegrationAssertions.java @@ -0,0 +1,72 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.MasterDirectoryIntegrationAssertions.insert; + +public class LegacyCronIntegrationAssertions { + public static List setup(){return List.of( + "ALTER TABLE headquarters ADD hq_mnemonic VARCHAR(20)", + "DROP TABLE IF EXISTS metafieldcategorykey,metafieldvalue,courierfilter,serviceplz,serviceplztraveltime", + "CREATE TABLE metafieldcategorykey(mtfck_id INT PRIMARY KEY,mtfc_id INT,mtfk_id INT,hq_id INT) ENGINE=InnoDB", + "CREATE TABLE metafieldvalue(mtfck_id INT,mtfv_id INT,mtfv_value VARCHAR(100)) ENGINE=InnoDB", + "CREATE TABLE courierfilter(crf_short VARCHAR(30),crf_type INT) ENGINE=InnoDB", + "CREATE TABLE serviceplz(srvp_id INT PRIMARY KEY,srvp_plz VARCHAR(10)) ENGINE=InnoDB", + "CREATE TABLE serviceplztraveltime(srvp_id INT,hq_id INT,srvpt_traveltime INT) ENGINE=InnoDB");} + public static void verify(DatabaseService db)throws Exception{ + var support=new BusinessSupport(db,new ObjectMapper().findAndRegisterModules());var runner=new LegacyCronRunner(db,support); + var slot=Instant.parse("2026-10-26T03:00:00Z");var calls=new AtomicInteger(); + try(var pool=Executors.newFixedThreadPool(2)){ + var entered=new CountDownLatch(1);var finish=new CountDownLatch(1); + var first=pool.submit(()->runner.execute("parallel",slot,()->{calls.incrementAndGet();entered.countDown();try{if(!finish.await(5,TimeUnit.SECONDS))throw new IllegalStateException();}catch(InterruptedException e){throw new RuntimeException(e);}return LegacyCronRunner.Result.ok(1);})); + assertThat(entered.await(5,TimeUnit.SECONDS)).isTrue(); + assertThat(new LegacyCronRunner(db,support).execute("parallel",slot,()->{calls.incrementAndGet();return LegacyCronRunner.Result.ok(1);})).isNull();finish.countDown();first.get(5,TimeUnit.SECONDS); + } + var restarted=new LegacyCronRunner(db,support);restarted.execute("parallel",slot,()->{calls.incrementAndGet();return LegacyCronRunner.Result.ok(1);});assertThat(calls).hasValue(1); + runner.execute("crash",slot,()->{throw new RuntimeException("unknown handoff");}); + assertThat(restarted.execute("crash",slot.plusSeconds(86400),()->{calls.incrementAndGet();return LegacyCronRunner.Result.ok(1);}).state()).isEqualTo("UNCERTAIN");assertThat(calls).hasValue(1); + var mail=mock(AppointmentMailTransport.class);when(mail.configured()).thenReturn(true);org.springframework.test.util.ReflectionTestUtils.setField(mail,"from","test@example.invalid"); + db.writeTransaction(()->{ + db.update("INSERT INTO headquarters (hq_id,hq_mnemonic) VALUES (3,'QA')"); + insert(db,"parameter",fields("hq_id",3,"emp_id",0,"par_key","MAIL_CRON_106_TO_ADDRESS","par_value","recipient@example.invalid")); + db.update("INSERT INTO metafieldcategorykey VALUES (1,3,1710,0)"); + for(int i=1;i<=4;i++){ + insert(db,"company",fields("cmp_id",i,"cmp_comp","","cmp_visible",1,"cmp_authenticated",1,"cmp_archived",0)); + insert(db,"user",fields("usr_id",i,"usr_name","QA","usr_firstname","Test","usr_country","DE","hq_id",3,"usr_inv_email","","usr_reminder_email","","usr_password_old","","usr_totp_secret","","usr_totp_sessionkey","")); + insert(db,"courier",fields("cr_id",i,"cr_id_parent",0,"hq_id",3,"cmp_id",i,"usr_id",i,"cr_eid","C"+i,"cr_filter",",VA,PBSG,","cr_available",1,"cr_locationzipcode","12345")); + insert(db,"couriervehicle",fields("crvh_id",i,"cr_id",i)); + db.update("INSERT INTO metafieldvalue VALUES (1,?,?)",i,i==1?"25.10.2026":i==2?"26.10.2026":""); + for(int j=0;j<2;j++)db.update("INSERT INTO phoenix_log.log (logo_id,hq_id,cr_id,log_createtime) VALUES (8,3,?,NOW())",i); + } + // A response in the same time window excludes this courier from automatic logout. + db.update("INSERT INTO phoenix_log.log (logo_id,hq_id,cr_id,log_createtime) VALUES (3,3,2,NOW())"); + // Only the original HQ 3 is eligible. + db.update("UPDATE courier SET hq_id=4 WHERE cr_id=4"); + insert(db,"address",fields("ad_id",1,"ad_zipcode","54321")); + var databaseTime=db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime(); + insert(db,"job",fields("jb_id",1,"hq_id",3,"cr_id_order",3,"jb_status",1,"jb_ordertime",databaseTime,"jb_createtime",databaseTime,"jb_service","","jb_orderer","","jb_scrm","")); + insert(db,"tour",fields("tr_id",1,"jb_id",1,"ad_id",1,"tr_sort",1,"tr_cs_freetext","")); + return null; + }); + var compliance=new LegacyCronCompliance(db,support,mail);var rule=LegacyCronCompliance.RULES.getFirst(); + assertThat(compliance.run(3,rule,LocalDate.of(2026,10,26)).processed()).isEqualTo(3); + assertThat(db.queryForObject("SELECT cr_filter FROM courier WHERE cr_id=1",String.class)).isEqualTo(",VA,"); + assertThat(db.queryForObject("SELECT cr_filter FROM courier WHERE cr_id=2",String.class)).isEqualTo(",VA,PBSG,"); + assertThat(db.queryForObject("SELECT cr_filter FROM courier WHERE cr_id=3",String.class)).isEqualTo(",VA,PBSG,"); // blank dates do not revoke PBSG + org.mockito.Mockito.verify(mail).sendOrderHtml(eq("test@example.invalid"),eq(List.of("recipient@example.invalid")),eq(List.of()),eq(List.of()),eq(rule.label()),contains("<Test")); + var business=new LegacyCronBusiness(db,support,mail);assertThat(business.logout().processed()).isEqualTo(1); + assertThat(business.logout().processed()).isZero();assertThat(db.queryForObject("SELECT COUNT(*) FROM phoenix_pda.commandexec",Integer.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT cr_locationzipcode FROM courier WHERE cr_id=3",String.class)).isEqualTo("12345"); // occupied even without postal travel-time data + assertThat(db.queryForObject("SELECT cr_locationzipcode FROM courier WHERE cr_id=2",String.class)).isEqualTo("12345"); + assertThat(db.queryForObject("SELECT TIMESTAMPDIFF(HOUR,cmde_exec_time,cmde_exec_timelimit) FROM phoenix_pda.commandexec LIMIT 1",Integer.class)).isEqualTo(16); + assertThat(business.mysqlStatus().processed()).isGreaterThan(0);assertThat(db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_obj_type='ngdbstatus'",Integer.class)).isEqualTo(1); + } +} diff --git a/backend/src/test/java/de/stadtbote/votian/LegacyCronSoapIntegrationAssertions.java b/backend/src/test/java/de/stadtbote/votian/LegacyCronSoapIntegrationAssertions.java new file mode 100644 index 0000000..367cfe8 --- /dev/null +++ b/backend/src/test/java/de/stadtbote/votian/LegacyCronSoapIntegrationAssertions.java @@ -0,0 +1,78 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.*; +import java.math.BigDecimal; +import java.util.*; +import java.util.function.Consumer; +import org.springframework.mock.env.MockEnvironment; +import static org.assertj.core.api.Assertions.*; +import static de.stadtbote.votian.LegacyCronSoapProtocol.*; + +public final class LegacyCronSoapIntegrationAssertions { + static final String SCHEMA="crm_qa_soap"; + static final LocalDateTime NOW=LocalDateTime.of(2026,10,26,5,10); + public static void verify(DatabaseService db,Consumer ddl){ + for(String sql:List.of( + "CREATE TABLE headquarters(hq_id INT PRIMARY KEY,hq_disabled INT DEFAULT 0) ENGINE=InnoDB", + "CREATE TABLE article(at_id INT PRIMARY KEY,hq_id INT,at_match VARCHAR(100)) ENGINE=MyISAM", + "CREATE TABLE articlemessage(at_id INT,atm_code VARCHAR(50),atm_desc VARCHAR(255),atm_text TEXT,atm_prio INT) ENGINE=MyISAM", + "CREATE TABLE genericdatacontainer(gdc_id INT AUTO_INCREMENT PRIMARY KEY,gdc_obj_type VARCHAR(10),gdc_obj_id INT,gdc_gen_fieldname VARCHAR(50),gdc_context TEXT,gdc_content MEDIUMTEXT) ENGINE=InnoDB", + "CREATE TABLE parameter(par_id INT AUTO_INCREMENT PRIMARY KEY,emp_id INT,hq_id INT,par_key VARCHAR(100),par_value TEXT,par_text TEXT) ENGINE=MyISAM", + "CREATE TABLE stock(stk_id INT AUTO_INCREMENT PRIMARY KEY,hq_id INT,stk_pre_id INT,stk_obj_type VARCHAR(10),stk_obj_id INT,stk_name VARCHAR(30),stk_path VARCHAR(255),stk_barcode VARCHAR(50),ad_id INT,cs_id INT,stk_visible INT,stk_readonly INT,stk_modifytime DATETIME) ENGINE=MyISAM", + "CREATE TABLE stockarticle(stk_id INT,at_id INT,stkat_quantity INT,stkat_itemquantity INT) ENGINE=MyISAM"))ddl.accept(sql); + StringBuilder item=new StringBuilder("CREATE TABLE articleitem(ati_id INT AUTO_INCREMENT PRIMARY KEY,at_id INT,stk_id INT,ati_serialno VARCHAR(255)"); + StringBuilder history=new StringBuilder("CREATE TABLE articleitemhistory(atih_id INT AUTO_INCREMENT PRIMARY KEY,at_id INT,stk_id INT,atih_serialno VARCHAR(255),atih_createtime DATETIME"); + for(int n=1;n<=30;n++){item.append(String.format(",ati_data_%02d VARCHAR(50) DEFAULT ''",n));history.append(String.format(",atih_data_%02d VARCHAR(50) DEFAULT ''",n));} + ddl.accept(item+") ENGINE=MyISAM");ddl.accept(history+") ENGINE=MyISAM"); + ddl.accept("INSERT INTO headquarters VALUES(1,0),(2,0)");ddl.accept("INSERT INTO article VALUES(10,1,'STU'),(11,0,'H&W'),(12,2,'STU')"); + ddl.accept("INSERT INTO stock(stk_id,hq_id,stk_pre_id,stk_path) VALUES(1000,1,0,''),(2000,2,0,'')"); + var support=new BusinessSupport(db,new ObjectMapper().findAndRegisterModules(),SCHEMA); + var soap=new LegacyCronSoap(db,support,new MockEnvironment()); + var master=new Payload(List.of(new Device("STU","42"),new Device("STU","43"),new Device("MISSING","44")),List.of(new Message("STU","E01","Tür offen","Prüfen")),List.of()); + assertThat(soap.apply(1,1,1000,master,"",NOW).processed()).isEqualTo(3); + assertThat(soap.checkpoint(1,1)).isEqualTo("20261026051000"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitem",Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM stock WHERE stk_obj_type='ati'",Integer.class)).isEqualTo(3); + assertThat(db.queryForObject("SELECT COUNT(*) FROM parameter",Integer.class)).isEqualTo(2); + var root=db.queryForObject("SELECT stk_id FROM stock WHERE stk_name='SYSTEM-SERVICEUNIT'",Long.class); + assertThat(db.queryForObject("SELECT stk_path FROM stock WHERE stk_name='FA42'",String.class)).isEqualTo("//"+root+"//"); + assertThat(db.queryForObject("SELECT stkat_itemquantity FROM stockarticle",Integer.class)).isEqualTo(2); + // Replayed imports keep existing units, stocks and counts stable; messages are refreshed. + soap.apply(1,1,1000,master,soap.checkpoint(1,1),NOW.plusMinutes(1)); + assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitem",Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM articlemessage",Integer.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT stkat_quantity FROM stockarticle",Integer.class)).isEqualTo(2); + var state=new Status("STU","42",NOW.toLocalDate(),List.of(new Notice("E01","keine Angabe"),new Notice("E01","Tür offen"),new Notice("UNKNOWN","")),Map.of(5,new BigDecimal("12.50"),9,new BigDecimal("9")),new BigDecimal("120")); + var payload=new Payload(List.of(),List.of(),List.of(state)); + soap.apply(0,1,1000,payload,"",NOW.plusMinutes(10)); + assertThat(db.queryForObject("SELECT ati_data_17 FROM articleitem WHERE ati_serialno='42'",String.class)).isEqualTo("E01"); + assertThat(db.queryForObject("SELECT ati_data_05 FROM articleitem WHERE ati_serialno='42'",String.class)).isEqualTo("12.50"); + assertThat(db.queryForObject("SELECT ati_data_09 FROM articleitem WHERE ati_serialno='42'",String.class)).isEqualTo("120"); + assertThat(db.queryForObject("SELECT atih_data_04 FROM articleitemhistory",String.class)).isEqualTo("2026-10-26 03:00:00"); + assertThat(db.queryForObject("SELECT atih_data_09 FROM articleitemhistory",String.class)).isEqualTo("120"); + soap.apply(0,1,1000,payload,soap.checkpoint(1,0),NOW.plusMinutes(11)); + assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitemhistory",Integer.class)).isEqualTo(1); + var cards=new Payload(List.of(),List.of(),List.of(new Status("STU","43",NOW.toLocalDate(),List.of(),Map.of(),BigDecimal.TEN))); + soap.apply(0,1,1000,cards,soap.checkpoint(1,0),NOW.plusMinutes(12)); + assertThat(db.queryForObject("SELECT ati_data_09 FROM articleitem WHERE ati_serialno='43'",String.class)).isEqualTo("10"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitemhistory",Integer.class)).isEqualTo(1); // no cash history exists for 43 + // Preflight failure does not advance the checkpoint. + String before=soap.checkpoint(1,1); + assertThatThrownBy(()->soap.apply(1,1,2000,new Payload(List.of(new Device("STU","45")),List.of(),List.of()),before,NOW.plusHours(1))).hasMessageContaining("Teilwirkungen"); + assertThat(soap.checkpoint(1,1)).isEqualTo(before); + assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitem WHERE ati_serialno='45'",Integer.class)).isZero(); + // A late MyISAM error can leave a unit written; retain old checkpoint and require review. + ddl.accept("INSERT INTO stockarticle VALUES(1000,10,2,2)"); + var runner=new LegacyCronRunner(db,support);var slot=NOW.atZone(LegacyCronTasks.ZONE).toInstant(); + var bad=new Payload(List.of(new Device("STU","46")),List.of(),List.of()); + assertThat(runner.execute("soap-partial",slot,()->soap.apply(1,1,1000,bad,before,NOW.plusHours(1))).state()).isEqualTo("UNCERTAIN"); + assertThat(soap.checkpoint(1,1)).isEqualTo(before); + assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitem WHERE ati_serialno='46'",Integer.class)).isEqualTo(1); + assertThat(runner.execute("soap-partial",slot.plusSeconds(86400),()->{throw new AssertionError("Must not retry");}).state()).isEqualTo("UNCERTAIN"); + // Failed parsing/network paths never enter a write transaction or advance the mark. + var broken=new LegacyCronSoap(db,support,new MockEnvironment(),java.net.http.HttpClient.newHttpClient(),Clock.fixed(NOW.plusHours(2).atZone(LegacyCronTasks.ZONE).toInstant(),LegacyCronTasks.ZONE)){@Override byte[] fetch(int mode,String since){return "".getBytes(java.nio.charset.StandardCharsets.UTF_8);}}; + assertThatThrownBy(()->broken.run(1)).hasMessageContaining("SOAP");assertThat(soap.checkpoint(1,1)).isEqualTo(before); + } +} diff --git a/backend/src/test/java/de/stadtbote/votian/LegacyCronSoapTest.java b/backend/src/test/java/de/stadtbote/votian/LegacyCronSoapTest.java new file mode 100644 index 0000000..0541855 --- /dev/null +++ b/backend/src/test/java/de/stadtbote/votian/LegacyCronSoapTest.java @@ -0,0 +1,87 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; +import java.time.*; +import java.net.http.*; +import java.nio.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import java.math.BigDecimal; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static de.stadtbote.votian.LegacyCronSoapProtocol.*; + +class LegacyCronSoapTest { + static byte[] response(int mode,String data){return (""+data+"").getBytes(StandardCharsets.UTF_8);} + @Test void requestAndCheckpointMatchPhp(){ + String request=request(0,"20261025000000"); + assertThat(request).contains("X","H&W","STVS","20261025000000"); + assertThat(request(1,"20261025000000")).doesNotContain("IvActualFg"); + assertThat(action(1)).endsWith(":ZPTN_WS_TOURENPLANUNG:ZPtnWstpMasterdataRequest"); + var now=LocalDateTime.of(2026,10,26,5,10); + assertThat(since("",now)).isEqualTo("20261025000000"); + assertThat(since("20261024051000",now)).isEqualTo("20261025000000"); + assertThat(since("20261025051000",now)).isEqualTo("20261025051000"); + assertThatThrownBy(()->since("20261027051000",now)).hasMessageContaining("Zukunft"); + assertThatThrownBy(()->timestamp("20260230051000")).hasMessageContaining("Zeitstempel"); + assertThatThrownBy(()->request(2,"20261025000000")).hasMessageContaining("Modus"); + } + @Test void masterReadsRepeatedAndSingletonEntriesAndFiltersOrganizations(){ + var result=parse(response(1,""" + HHAH&W + 000042000043 + E01Tür offenPrüfen + OTHERSTU99 + """),1); + assertThat(result.devices()).containsExactly(new Device("H&W","42"),new Device("H&W","43")); + assertThat(result.messages()).containsExactly(new Message("H&W","E01","Tür offen","Prüfen")); + assertThat(parse(response(1,""),1).devices()).isEmpty(); + } + @Test void statusUsesNominalAmountOpenNoticesAndCardStock(){ + var result=parse(response(0,""" + HDAGSTU00004220261026110000 + E01keine Angabe00000000000000 + E0220261026100000 + BEK112,50999 + RGS153 + KARTEN120 + """),0).statuses().getFirst(); + assertThat(result.serial()).isEqualTo("42");assertThat(result.date()).isEqualTo(LocalDate.of(2026,10,26)); + assertThat(result.notices()).containsExactly(new Notice("E01","keine Angabe")); + assertThat(result.money()).containsEntry(5,new BigDecimal("12.50")).containsEntry(15,new BigDecimal("3")); + assertThat(result.cards()).isEqualByComparingTo("120"); + assertThat(moneyField("ZGS","0")).isEqualTo(7);assertThat(moneyField("ZGS","2")).isEqualTo(9); + assertThat(moneyField("MEK","1")).isEqualTo(6);assertThat(moneyField("BEK","")).isNull();assertThat(moneyField("RGS","9")).isNull(); + } + @Test void malformedFaultAndEntityPayloadsNeverBecomeEmptySuccess(){ + for(String xml:List.of("garbage","]>&y;","secret")) + assertThatThrownBy(()->parse(xml.getBytes(StandardCharsets.UTF_8),1)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).hasMessageNotContaining("secret"); + assertThatThrownBy(()->parse(response(1,""),1)).hasMessageContaining("Devicetypes"); + assertThatThrownBy(()->parse(response(0,""),1)).hasMessageContaining("MasterdataResponse"); + assertThatThrownBy(()->amount("NaN")).hasMessageContaining("Bestand"); + } + @Test void boundedBodyCancelsOversizedResponseBeforeBufferingIt(){ + var body=LegacyCronSoap.limitedBody();var subscription=mock(Flow.Subscription.class);body.onSubscribe(subscription); + var buffer=ByteBuffer.allocate(1024*1024); + for(int i=0;i<33;i++)body.onNext(List.of(buffer.duplicate())); + verify(subscription).cancel();assertThat(body.getBody().toCompletableFuture()).isCompletedExceptionally(); + } + static MockEnvironment env(){return new MockEnvironment().withProperty("app.legacy-cron.soap.url","https://sap.example.invalid/import").withProperty("app.legacy-cron.soap.user","qa").withProperty("app.legacy-cron.soap.password","test-only");} + @SuppressWarnings("unchecked") @Test void fetchUsesConfiguredHttpsAndDoesNotAcceptRedirectsOrServerErrors()throws Exception{ + var db=mock(DatabaseService.class);var http=mock(HttpClient.class);var env=env(); + var soap=new LegacyCronSoap(db,mock(BusinessSupport.class),env,http,Clock.systemUTC()); + var response=(HttpResponse)mock(HttpResponse.class);when(response.statusCode()).thenReturn(200);when(response.body()).thenReturn(new byte[]{1}); + when(http.sendAsync(any(HttpRequest.class),any(HttpResponse.BodyHandler.class))).thenReturn(CompletableFuture.completedFuture(response)); + assertThat(soap.fetch(1,"20261025000000")).containsExactly((byte)1); + var capture=org.mockito.ArgumentCaptor.forClass(HttpRequest.class);verify(http).sendAsync(capture.capture(),any(HttpResponse.BodyHandler.class)); + assertThat(capture.getValue().uri().toString()).isEqualTo("https://sap.example.invalid/import");assertThat(capture.getValue().headers().firstValue("SOAPAction").orElseThrow()).isEqualTo(action(1)); + for(int status:List.of(302,401,500)){when(response.statusCode()).thenReturn(status);assertThatThrownBy(()->soap.fetch(0,"20261025000000")).hasMessageContaining("HTTP "+status);} + verifyNoInteractions(db); + for(String url:List.of("http://sap.example.invalid","https://user:secret@sap.example.invalid","https://sap.example.invalid/#fragment",""))assertThatThrownBy(()->LegacyCronSoap.endpoint(url)).hasMessageContaining("HTTPS"); + env.setProperty("app.legacy-cron.soap.password","");assertThatThrownBy(()->soap.fetch(1,"20261025000000")).hasMessageContaining("Zugangsdaten"); + } +} diff --git a/backend/src/test/java/de/stadtbote/votian/LegacyCronTest.java b/backend/src/test/java/de/stadtbote/votian/LegacyCronTest.java new file mode 100644 index 0000000..4389425 --- /dev/null +++ b/backend/src/test/java/de/stadtbote/votian/LegacyCronTest.java @@ -0,0 +1,154 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.scheduling.support.CronExpression; +import java.time.*; +import java.nio.file.*; +import java.nio.file.attribute.FileTime; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +class LegacyCronTest { + static LegacyCronTasks tasks(LegacyCronRunner runner,MockEnvironment env){return new LegacyCronTasks(runner,mock(LegacyCronCompliance.class),mock(LegacyCronBusiness.class),mock(LegacyCronExports.class),mock(LegacyCronFiles.class),mock(LegacyCronReplication.class),mock(LegacyCronSoap.class),env);} + @Test void onlyActivePhpBranchesAndScriptsAreRegistered(){ + var t=tasks(mock(LegacyCronRunner.class),new MockEnvironment()); + assertThat(t.tasks).hasSize(115); // 78 mail checks, 30 exports, seven other scheduled jobs + assertThat(t.enabled).isEmpty(); + assertThat(t.tasks.stream().filter(x->x.group().startsWith("hha-soap")).map(LegacyCronTasks.Task::cron)).containsExactly("0 10 5 * * *","0 20 5 * * *"); + assertThat(LegacyCronTasks.unresolved()).isEmpty(); + assertThat(t.tasks.stream().filter(x->x.id().endsWith("-1715"))).extracting(LegacyCronTasks.Task::id).containsExactly("stb-8-1715"); + assertThat(t.tasks.stream().filter(x->x.group().equals("sb-master-export"))).hasSize(30).noneMatch(x->x.id().startsWith("sb-export-90-")||x.id().startsWith("sb-export-10-")); + assertThat(t.tasks.stream().map(LegacyCronTasks.Task::id).distinct().count()).isEqualTo(t.tasks.size()); + assertThatThrownBy(()->tasks(mock(LegacyCronRunner.class),new MockEnvironment().withProperty("app.legacy-cron.jobs","typo"))).isInstanceOf(IllegalArgumentException.class); + } + @Test void containerGroupsOverrideLegacySettingAndExplicitEmptyDisablesScheduling()throws Exception{ + var yaml=new org.springframework.boot.env.YamlPropertySourceLoader().load("application",new org.springframework.core.io.ClassPathResource("application.yml")); + var env=new MockEnvironment().withProperty("LEGACY_CRON_JOBS","mysql-status"); + yaml.forEach(env.getPropertySources()::addLast); + assertThat(tasks(mock(LegacyCronRunner.class),env).enabled).containsExactly("mysql-status"); + env.setProperty("SPRING_TASK_GROUPS","hha-soap-master,hha-soap-status"); + assertThat(tasks(mock(LegacyCronRunner.class),env).enabled).containsExactly("hha-soap-master","hha-soap-status"); + env.setProperty("SPRING_TASK_GROUPS",""); + assertThat(tasks(mock(LegacyCronRunner.class),env).enabled).isEmpty(); + } + @Test void allNativeGroupsAreScheduledWithoutExecutingActionsAtStartup(){ + var runner=mock(LegacyCronRunner.class);var env=new MockEnvironment(); + var inventory=tasks(runner,env);var groups=inventory.tasks.stream().map(LegacyCronTasks.Task::group).distinct().toList(); + env.setProperty("app.legacy-cron.jobs",String.join(",",groups)); + for(String group:groups)env.setProperty("app.legacy-cron.schedules."+group,"0 0 0 1 1 *"); + var enabled=tasks(runner,env); + try{enabled.start();assertThat(enabled.scheduler.getScheduledThreadPoolExecutor().getQueue()).hasSize(115);verifyNoInteractions(runner);} + finally{enabled.stop();} + assertThat(enabled.scheduler.getScheduledThreadPoolExecutor().isTerminated()).isTrue(); + } + @Test void schedulesUseBerlinAndSkipWeekends(){ + var tasks=tasks(mock(LegacyCronRunner.class),new MockEnvironment());var zone=LegacyCronTasks.ZONE; + var friday=ZonedDateTime.of(2026,10,23,4,0,0,0,zone); + assertThat(CronExpression.parse(tasks.tasks.stream().filter(t->t.group().equals("stb-compliance")).findFirst().orElseThrow().cron()).next(friday)).isEqualTo(ZonedDateTime.of(2026,10,26,4,0,0,0,zone)); + assertThat(CronExpression.parse("0 45 20 * * *").next(friday)).isEqualTo(friday.withHour(20).withMinute(45)); + var transition=ZonedDateTime.of(2026,3,28,20,45,0,0,zone); + assertThat(CronExpression.parse("0 45 20 * * *").next(transition).getOffset()).isEqualTo(ZoneOffset.ofHours(2)); + } + @Test void delayedExecutionRetainsTheScheduledSlot(){ + var runner=mock(LegacyCronRunner.class);var tasks=tasks(runner,new MockEnvironment());var calls=new ArrayList(); + var task=new LegacyCronTasks.Task("x","x","x","0 0 4 * * *","x",slot->{calls.add(slot);return LegacyCronRunner.Result.ok(1);}); + doAnswer(a->{java.util.function.Supplier action=a.getArgument(2);return action.get();}).when(runner).execute(anyString(),any(),any()); + var slot=Instant.parse("2026-10-26T03:00:00Z");tasks.execute(task,slot);assertThat(calls).containsExactly(slot); + } + @Test void monitoringBatchesReceiptsAndUsesBrowserCompatibleTimestamps(){ + var db=mock(DatabaseService.class);var runner=new LegacyCronRunner(db,new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper())); + var service=tasks(runner,new MockEnvironment().withProperty("app.legacy-cron.jobs","mysql-status")); + var active=service.view().stream().filter(r->r.get("id").equals("mysql-status")).findFirst().orElseThrow(); + assertThat(active.get("state")).isEqualTo("WAITING");assertThatCode(()->Instant.parse(active.get("nextRun").toString())).doesNotThrowAnyException(); + verify(db,times(1)).queryForList(anyString()); + } + static class MemoryRunner extends LegacyCronRunner { + Run saved; + MemoryRunner(DatabaseService db){super(db,new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper()));} + @Override Run find(String id){return saved;} + @Override void save(Run r,boolean insert){saved=r;} + } + @Test void persistentClaimPreventsReplayAndBlocksLaterRunsAfterUncertainSmtp(){ + var db=mock(DatabaseService.class);doAnswer(a->{java.util.function.Supplier f=a.getArgument(1);return f.get();}).when(db).cronOperation(anyString(),any()); + var r=new MemoryRunner(db);var count=new AtomicInteger();var slot=Instant.parse("2026-10-26T03:00:00Z"); + r.execute("mail",slot,()->{count.incrementAndGet();return new LegacyCronRunner.Result("UNCERTAIN","SMTP",1);}); + var restarted=new MemoryRunner(db);restarted.saved=r.saved; + restarted.execute("mail",slot,()->{count.incrementAndGet();return LegacyCronRunner.Result.ok(1);}); + restarted.execute("mail",slot.plusSeconds(86400),()->{count.incrementAndGet();return LegacyCronRunner.Result.ok(1);}); + assertThat(count).hasValue(1);assertThat(restarted.saved.result().state()).isEqualTo("UNCERTAIN"); + restarted.saved=new LegacyCronRunner.Run("mail",slot.toString(),"","",new LegacyCronRunner.Result("REVIEWED","",0)); + restarted.execute("mail",slot.plusSeconds(86400),()->{count.incrementAndGet();return LegacyCronRunner.Result.ok(1);});assertThat(count).hasValue(2); + } + @Test void lockBusyAndReceiptWriteFailureNeverInvokeBusinessAction(){ + var db=mock(DatabaseService.class);var action=mock(java.util.function.Supplier.class);var r=new MemoryRunner(db); + r.execute("mail",Instant.now(),action);verifyNoInteractions(action); + doAnswer(a->{java.util.function.Supplier f=a.getArgument(1);return f.get();}).when(db).cronOperation(anyString(),any()); + var broken=new MemoryRunner(db){@Override void save(Run r,boolean insert){throw new IllegalStateException("DB down");}}; + assertThatThrownBy(()->broken.execute("mail",Instant.now(),action)).isInstanceOf(IllegalStateException.class);verifyNoInteractions(action); + } + @Test void compliancePreservesOtherFiltersAndPhpMonthOverflow(){ + assertThat(LegacyCronCompliance.filter(",VA,PBSG,FZ,","PBSG",false)).isEqualTo(",VA,FZ,"); + assertThat(LegacyCronCompliance.filter(",VA,PBSG,","PBSG",true)).isEqualTo(",VA,PBSG,"); + assertThat(LegacyCronCompliance.expiry("31.08.2026",6)).isEqualTo(LocalDate.of(2027,3,3)); + assertThatThrownBy(()->LegacyCronCompliance.expiry("31.02.2026",0)).hasMessageContaining("Ungültiges Datum"); + var date=LocalDate.of(2026,10,26); + assertThat(LegacyCronCompliance.color(date.minusDays(1),date,false)).isEqualTo("FF0000"); + assertThat(LegacyCronCompliance.color(date,date,false)).isEqualTo("FF6666"); + assertThat(LegacyCronCompliance.color(date.plusDays(181),date,true)).isEmpty(); + assertThat(LegacyCronCompliance.RULES.stream().filter(r->r.script()==1629).findFirst().orElseThrow().field()).isEqualTo(441); + } + @Test void mysqlRatesDoNotInventDeltasAfterAReset(){ + var current=Map.of("Uptime","600","Questions","1200","Slow_queries","3","Qcache_hits","20","Com_select","80"); + var first=LegacyCronBusiness.rates(current,Map.of());assertThat(first.get("qps")).isEqualTo("2.0");assertThat(first.get("qps_now")).isEmpty();assertThat(first.get("Ratio")).isEqualTo("20.0"); + assertThat(LegacyCronBusiness.rates(current,Map.of("Uptime","700","Questions","2000")).get("qps_now")).isEmpty(); + assertThat(LegacyCronBusiness.rates(current,Map.of("Uptime","300","Questions","300")).get("qps_now")).isEqualTo("3.0"); + } + @Test void exportFailureAfterStartingStorageRequiresReviewBeforeTheNextRun(){ + var db=mock(DatabaseService.class);var exports=mock(MasterExports.class);var runs=mock(MasterExportRuns.class);var transfer=mock(MasterExportTransfer.class); + when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(a->a.getArgument(0).contains("FROM user")?List.of(fields("usr_id",100001,"emp_id",1,"usr_name","Cron","emp_rights","1111111")):List.of(fields("expp_id",12))); + var state=new MasterExports.State(1,List.of(),List.of(),Map.of());when(exports.state(any(),eq(1),eq(12L))).thenReturn(state); + var selection=new MasterExportData.Selection(List.of(),List.of(Map.of("cmp_id",1)),List.of(),0); + when(exports.snapshot(any(),any())).thenReturn(new MasterExports.Snapshot(state,selection,"payload","revision")); + when(runs.start(any(LegacyUser.class),any())).thenThrow(AdminSupport.bad("partial storage failure")); + var result=new LegacyCronExports(db,exports,runs,transfer).run(LegacyCronExports.BRANCHES.getFirst(),1,Instant.parse("2026-10-26T19:45:00Z")); + assertThat(result.state()).isEqualTo("UNCERTAIN");verifyNoInteractions(transfer); + } + @TempDir Path root; + @Test void fileMaintenanceCompressesOnlyInactiveFilesAndDoesNotFollowLinks()throws Exception{ + var bin=Files.createDirectory(root.resolve("bin"));var logs=Files.createDirectory(root.resolve("logs"));var temp=Files.createDirectory(root.resolve("temp")); + var old=Files.writeString(bin.resolve("mps2-bin.000001"),"old");var active=Files.writeString(bin.resolve("mps2-bin.000002"),"active"); + Files.setLastModifiedTime(old,FileTime.fromMillis(1000));Files.setLastModifiedTime(active,FileTime.fromMillis(2000)); + Files.writeString(logs.resolve("app_202609.log"),"past month");Files.writeString(logs.resolve("app_202610.log"),"current"); + var outside=Files.writeString(root.resolve("preserve"),"keep");Files.createSymbolicLink(temp.resolve("link"),outside);Files.writeString(temp.resolve("download"),"delete"); + var env=new MockEnvironment().withProperty("app.legacy-cron.files.binlog-directories",bin.toRealPath().toString()).withProperty("app.legacy-cron.files.log-directories",logs.toRealPath().toString()).withProperty("app.legacy-cron.files.temporary-directories",temp.toRealPath().toString()); + var result=new LegacyCronFiles(env).run(LocalDate.of(2026,10,26));assertThat(result.processed()).isEqualTo(3); + assertThat(old).doesNotExist();assertThat(bin.resolve("mps2-bin.000001.bz2")).exists();assertThat(active).hasContent("active"); + assertThat(logs.resolve("app_202609.log.bz2")).exists();assertThat(logs.resolve("app_202610.log")).hasContent("current");assertThat(outside).hasContent("keep");assertThat(Files.isSymbolicLink(temp.resolve("link"))).isTrue(); + assertThat(new LegacyCronFiles(env).run(LocalDate.of(2026,10,26)).processed()).isZero(); + } + @Test void compressionRefusesConflictingArchiveAndOverlappingCleanupRoots()throws Exception{ + var file=Files.writeString(root.resolve("history.log"),"original");Files.writeString(root.resolve("history.log.bz2"),"invalid archive"); + assertThatThrownBy(()->LegacyCronFiles.compress(file)).isInstanceOf(java.io.IOException.class);assertThat(file).hasContent("original"); + var dir=root.toRealPath().toString();var env=new MockEnvironment().withProperty("app.legacy-cron.files.log-directories",dir).withProperty("app.legacy-cron.files.temporary-directories",dir); + assertThatThrownBy(()->new LegacyCronFiles(env).run(LocalDate.now())).hasMessageContaining("überlappen");assertThat(file).exists(); + } + @Test void replicaCountersKeepPhpThresholdsAndAlertOnUnavailableReplica(){ + var db=mock(DatabaseService.class);var mail=mock(AppointmentMailTransport.class);when(mail.configured()).thenReturn(true); + var env=new MockEnvironment().withProperty("app.legacy-cron.replication.targets","{\"STB\":[\"primary:3306\",\"replica:3306\"]}").withProperty("app.legacy-cron.replication.user","qa").withProperty("app.legacy-cron.replication.mail-to","qa@example.invalid"); + var service=new LegacyCronReplication(db,new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper()),mail,env); + when(db.cronReplicaCount(eq("primary:3306"),any(),any(),any(),any())).thenReturn(10L);when(db.cronReplicaCount(eq("replica:3306"),any(),any(),any(),any())).thenReturn(6L); + assertThat(service.run(LocalDate.of(2026,10,26)).state()).isEqualTo("OK");verify(mail,never()).sendOrderHtml(any(),any(),any(),any(),any(),any()); + assertThat(LegacyCronReplication.GROUPS.get("INT").tolerance()).isEqualTo(7); + org.springframework.test.util.ReflectionTestUtils.setField(mail,"from","sender@example.invalid"); + when(db.cronReplicaCount(eq("replica:3306"),any(),any(),any(),any())).thenThrow(new IllegalStateException("password=not-for-output")); + var failed=service.run(LocalDate.of(2026,10,26));assertThat(failed.state()).isEqualTo("ERROR");assertThat(failed.message()).contains("nicht erreichbar").doesNotContain("password"); + verify(mail).sendOrderHtml(eq("sender@example.invalid"),eq(List.of("qa@example.invalid")),eq(List.of()),eq(List.of()),anyString(),contains("nicht erreichbar")); + } +} diff --git a/backend/src/test/java/de/stadtbote/votian/database/DatabaseServiceTest.java b/backend/src/test/java/de/stadtbote/votian/database/DatabaseServiceTest.java index d4cdb24..fc54314 100644 --- a/backend/src/test/java/de/stadtbote/votian/database/DatabaseServiceTest.java +++ b/backend/src/test/java/de/stadtbote/votian/database/DatabaseServiceTest.java @@ -44,6 +44,25 @@ class DatabaseServiceTest { } @org.junit.jupiter.api.AfterEach void closeFixturePool(){if(integrationPool!=null)integrationPool.close();} + @Test + @EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa") + void legacyCronSoapAgainstDisposableLocalDatabase() { + var admin=new JdbcTemplate(fixtureDataSource());admin.execute("CREATE DATABASE crm_qa_soap"); + var ds=new DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL").replace("/crm_qa","/crm_qa_soap"),"root","isolated-test-only"); + var jdbc=new JdbcTemplate(ds); + try {de.stadtbote.votian.LegacyCronSoapIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds)),jdbc::execute);} + finally {admin.execute("DROP DATABASE crm_qa_soap");} + } + + @Test + @EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa") + void legacyCronAgainstDisposableLocalDatabase() throws Exception { + var ds=fixtureDataSource();var jdbc=new JdbcTemplate(ds); + try(var connection=ds.getConnection()){org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(connection,new org.springframework.core.io.ClassPathResource("business-schema.sql"));} + for(String sql:de.stadtbote.votian.LegacyCronIntegrationAssertions.setup())jdbc.execute(sql); + de.stadtbote.votian.LegacyCronIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds))); + } + @Test @org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa") void operationsMonitoringAgainstDisposableLocalDatabase() throws Exception { diff --git a/docs/CRON-MIGRATION.md b/docs/CRON-MIGRATION.md new file mode 100644 index 0000000..5857c54 --- /dev/null +++ b/docs/CRON-MIGRATION.md @@ -0,0 +1,235 @@ +# PHP-Crontab → Spring Boot + +Stand: 27.10.2026. Grundlage sind die am 26.10.2026 per SSH gelesenen Dateien +unter `172.16.0.158:/data/0/votian-test/html` sowie ergänzend +`/data/0/votian-test-2/html`. Der Zeitplan stammt aus +`sysadmin/votian_admin/crontab_20211207.sav`. Der Betreiber hat diese Crontab +als aktuell bestätigt und angekündigt, auf dem Testsystem keine PHP-Cronjobs +mehr auszuführen. Der frühere lokale PHP-Bestand wurde nicht verwendet. + +## Native Implementierung + +| PHP-Aufruf | NG-Gruppe | Termin Europe/Berlin | Verhalten | +|---|---|---|---| +| `do_auto_logout_by_autorevokes` | `courier-logout` | Jede halbe Stunde | NL 3, mindestens zwei automatische Entzüge in 30 Minuten, keine Annahme/Ablehnung, numerische Standort-PLZ; Belegungsprüfung, `LOGOUT`, PDA-Kommando mit 16 Stunden Gültigkeit, Audit 143. | +| `do_cron_STB_MAILS` | `stb-compliance` | Mo–Fr 04:00 | 78 getrennte Tasks: 1710, 1623, 1717–1720, 1629 für NL 1–11; 1715 nur NL 8. Datumswarnungen und PBSG/COV/FZ/GDP/BMS-Filterpflege. | +| `do_cron_export_SB` | `sb-master-export` | Täglich 20:45 | 30 Tasks: Debitoren, Kreditoren, Fahrzeuge für NL 1–9 und 11. Originale Benutzer, RETRANS-Profile, Nummern und Dateinamen. Dauerhafte Dateispeicherung, Änderungskennzeichen, FTP-Übergabe über die vorhandenen nativen Exportdienste. | +| `do_cron_special_HHA` | `hha-safebags` | Täglich 09:40 | Safebags Artikel 4285, Kennzeichen 28=1, mindestens sieben Tage alt; HTML-Bericht bei Treffern. | +| `mysql_status.php` | `mysql-status` | Alle fünf Minuten | Zähler der konfigurierten Anwendungsdatenbank, Gesamtraten und Intervallraten; persistente Historie für 62 Tage, monatlicher CSV-Download. | +| `do_bzip2.php` | `file-maintenance` | Täglich 00:01 | Native bzip2-Kompression der älteren `mps2-bin.0*`-Dateien und der Logdateien des Vormonats; Löschung regulärer Dateien aus expliziten temporären Verzeichnissen. | +| `do_cron_monitor_replication` | `replication-monitor` | Alle 15 Minuten | Fachliche Zählervergleiche mit PHP-Toleranzen: STB 4, HTG/HTM/HTM_OLD/HHA 2, INT 7, MUH 1. Alarm bei Rückstand oder Nichterreichbarkeit. | +| `do_cron_soap_1` | `hha-soap-master` | Täglich 05:10 | PTnova-Gerätestammdaten und Betriebsmeldungen; neue Serviceunits, Objektlager und Container-Lagerbestände. | +| `do_cron_soap_0` | `hha-soap-status` | Täglich 05:20 | PTnova-Gerätestatus, offene Betriebsmeldungen, Geld- und Kartenbestände, Tageshistorie um 03:00. | + +Damit sind alle neun weiterhin benötigten PHP-Cron-Aufrufe als +115 einzeln protokollierte Spring-Tasks implementiert. Die zwei weiteren Aufrufe +der bestätigten Crontab entfallen gemäß Betreiberentscheidung vom 27.10.2026. +Kein Task startet PHP, Shell-Kommandos +oder eine alte HTTP-Oberfläche. Die getrennt vorhandenen sechs NG-Dauerdienste +(Auftragsabschluss, Daueraufträge, Statusmail, Angebote, Angebotsentzug, +BWV-Retouren) behalten ihre bisherigen Konfigurationsschalter. + +## Bewusst entfallene HHA-Aufrufe + +Der Betreiber hat am 27.10.2026 bestätigt, dass diese beiden Funktionen keine +Rolle mehr spielen und in NG entfallen dürfen: + +* 02:15 `do_cron_vht_dispo_days_HHA` → `include/cron_vht_dispo_days_HHA.php`. +* 04:30 `do_cron_export_HHA` → `export/export_illt_MC.php`, Modi 101/102/103. + +Sie sind keine offenen Migrationspunkte mehr und erscheinen nicht mehr als +fehlende Funktionen im Betriebsstatus. Für diese Aufrufe werden keine NG-Tasks +registriert. Die Entscheidung betrifft nur diese beiden Aufrufe; die beiden +PTnova-SOAP-Importe und die HHA-Safebag-Prüfung bleiben enthalten. +Die Implementierung der weiterhin benötigten PHP-Cron-Aufrufe ist damit +vollständig. Das Docker-Image aktiviert die Tasks künftig beim Start; Produktionskonfiguration und Bereitstellung auf dem Zielserver stehen noch aus. + +## Grundlage der SOAP-Übernahme und Abgrenzung + +Die beiden SOAP-Modi wurden anhand von +`/data/0/votian-test-2/html/sysadmin/soap/soap.php` und dessen aktiven +Datenbankpfaden portiert (SHA256 +`b4e8580d63c1f2545870ca596dfca820adc452879af05b709b2e4578dc5fb407`). +Die Wrapper nennen zwei frühere Instanzen unter `/home/mps2/hha` und +`/home/mps2/hha_test`. Eine NG-Instanz verarbeitet ihre konfigurierte Datenbank +und SAP-Umgebung; es erfolgt kein zusätzlicher automatischer Produktionslauf. + +`ping`, `certbot`, `/sbin/handle_log` und der aktive Shell-Wrapper `do_rsync` +sind Betriebssystemaufgaben, keine PHP-Cronjobs. Sie werden durch diese Änderung +nicht übernommen oder abgeschaltet. Der auskommentierte `do_rsync.php` und +andere nicht aktive Skriptvarianten werden ebenfalls nicht aktiviert. + +## Konfiguration und Inbetriebnahme + +Das Docker-Image setzt `SPRING_TASK_GROUPS` auf alle neun Gruppen und plant +beim Start 115 Tasks direkt im Spring-`ThreadPoolTaskScheduler` ein. +Der Container startet ausschließlich `java -jar /app/app.jar` als Hauptprozess. +Es werden weder Cron-Daemon noch Crontab, PHP-Prozesse oder Shell-Wrapper +für diese Aufgaben benötigt. Die Cron-Ausdrücke sind nur das Zeitplanformat +innerhalb von Spring Boot. + +`SPRING_TASK_GROUPS` kann als Container-Umgebungsvariable mit einer +kommagetrennten Auswahl überschrieben werden. Beispiel für ausschließlich +Diagnose: `-e SPRING_TASK_GROUPS=mysql-status`. Ein ausdrücklich leerer Wert +(`-e SPRING_TASK_GROUPS=`) deaktiviert alle neun Gruppen. +Die Variable hat Vorrang vor dem bisherigen `LEGACY_CRON_JOBS`; dessen leerer +Eintrag in einer bestehenden `.env` deaktiviert die Image-Vorgabe deshalb nicht. +Beim direkten Java-Start ohne `SPRING_TASK_GROUPS` gilt weiterhin +`LEGACY_CRON_JOBS` (standardmäßig leer). Die sechs gesonderten NG-Dauerdienste +behalten ihre bisherigen eigenen Schalter. + +Die Vorlage `.env.example` enthält die Produktionsparameter. Ohne passende +SAP-Zugänge, SMTP-Konfiguration, Exportprofile, Replikationsziele oder +Dateimounts können die betreffenden Tasks nicht erfolgreich arbeiten und +melden die fehlenden Voraussetzungen im Betriebsstatus. Eine eingeplante +Aufgabe ist noch kein Nachweis einer erfolgreichen fachlichen Ausführung. +Der Startlog nennt Anzahl und Gruppen der tatsächlich eingeplanten Aufgaben. +Beim Herunterfahren werden noch nicht fällige Aufgaben verworfen; laufende +Aufgaben erhalten bis zu 30 Sekunden zum Abschluss. Für Docker deshalb +`--stop-timeout 40` setzen. Unterbrochene Läufe behalten ihren dauerhaften Beleg. + +Die Aktivierung ist für den späteren Docker-Betrieb vorbereitet. Es wurde +keine Produktionsinstanz gestartet und keine vorhandene lokale `.env` +verändert. Echte Empfänger, FTP-/SAP-Endstellen und Bestandsverzeichnisse +wurden nicht für Funktionstests verwendet. + +* **STB:** SMTP muss eingerichtet sein; Empfänger werden aus + `MAIL_CRON_106_TO_ADDRESS` bzw. für EG-Lizenz aus `MAIL_CRON_102_TO_ADDRESS` + gelesen (NL, dann global). Absender ist `SMTP_FROM`. Die in PHP hart + codierten Entwickler-Fallbacks und Bcc-Adressen werden nicht übernommen. +* **HHA-Safebags:** `LEGACY_CRON_HHA_MAIL_TO` und optional + `LEGACY_CRON_HHA_MAIL_BCC` explizit setzen. Absender aus NL-Parameter + `MAIL_SENDER_ADDRESS`, sonst `SMTP_FROM`. +* **Export:** Bestehende Exportablage, FTP-Parameter, Exportkonten und Rechte + müssen verfügbar sein. Fehlende Profile oder Felddefinitionen sperren den + jeweiligen Task. NL 90 bleibt wie im PHP-Quelltext auskommentiert. +* **PTnova/SOAP:** `LEGACY_CRON_SOAP_URL`, `_USER`, `_PASSWORD` für den + zu dieser Instanz gehörenden SAP-Endpunkt setzen. HTTPS-Zertifikate werden + geprüft, Weiterleitungen nicht verfolgt; fünf Minuten Gesamtzeit und 32 MiB + Antwortgröße begrenzen den Abruf. `_HEADQUARTERS` ist standardmäßig 1, + `_CONTAINER_STOCK` 1000 wie in PHP. Der Betriebsdatenbankbenutzer braucht + Zugriff auf `articleitemhistory` im konfigurierten `HISTORY_DB_SCHEMA` sowie + die Bestands-, Artikel-, Parameter- und Laufbelegtabellen. SAP-Zugänge bleiben + außerhalb des Quellcodes. Die beiden Gruppen getrennt aktivieren. +* **Replikation:** `LEGACY_CRON_REPLICATION_TARGETS` ist ein JSON-Objekt, + z. B. `{"STB":["primary.example:3391","replica.example:3391"]}`. + Erster Eintrag ist der Primärserver. Zugänge ausschließlich über + `LEGACY_CRON_REPLICATION_USER` / `_PASSWORD`, Alarmempfänger über `_MAIL_TO`. + Kein automatischer Zugriff auf die fest codierten Produktionsserver aus PHP. + TT ist im aktuellen PHP deaktiviert und wird nicht registriert. +* **Dateiwartung:** Die drei `LEGACY_CRON_*_DIRECTORIES`-Variablen nennen + ausschließlich hierfür eingebundene Altverzeichnisse. Temporäre Verzeichnisse + werden vollständig von regulären Dateien geleert, wie im PHP-Aufruf. + Keine NG-Dokument- oder Exportablage eintragen. Verzeichnisse dürfen sich + nicht überlappen; Symlinks werden nicht verfolgt. Die neueste Binlogdatei + (einschließlich Gleichständen) und aktuelle Monatslogs bleiben erhalten. + Vor dem Entfernen einer Quelle wird das Archiv sicher veröffentlicht; + vorhandene abweichende Archive werden nicht überschrieben. +* **MySQL-Status:** Verwendet `DB_URL` statt PHPs festem lokalen Port 3391. + Originale Zähler und abgeleitete Raten sind als CSV unter + `/api/administration/cron/database-status?month=2026-10` abrufbar. + Nach Serverneustart oder bei fehlender Vorgängermessung bleiben + Intervallraten leer. Werte liegen in `genericdatacontainer` statt unter `/root`. + +Cron-Ausdrücke lassen sich pro Gruppe über die in `.env.example` genannten +Variablen überschreiben. Sie haben sechs Felder inklusive Sekunden. +`Europe/Berlin` gilt unabhängig von der JVM-/Container-Zeitzone. +Verpasste Termine während eines Stillstands werden nicht nachgeholt. + +## Laufkontrolle und Wiederanlauf + +Der Betriebsstatus zeigt die Tasks, feste Termine, nächsten Lauf und Ergebnisse. Laufbelege liegen unter +`genericdatacontainer.gdc_obj_type='ngcron'`. Der vor der Verarbeitung +gespeicherte Beleg und eine datenbankweite Sperre verhindern doppelte +Verarbeitung durch mehrere NG-Instanzen. Wartende Tasks behalten ihren +ursprünglichen Termin auch bei ausgelasteten Scheduler-Threads. + +Ein unterbrochener Lauf bleibt `RUNNING` oder `UNCERTAIN` und wird auch am +nächsten Tag nicht automatisch wiederholt. Vor Freigabe mögliche Teilwirkungen +prüfen (SMTP-Logs, gespeicherte Exportdatei, FTP-Eingang, PTnova-Bestände und Tageshistorie). Exportvorgänge können +über die bestehende Datentransfer-Oberfläche unter dem jeweiligen Exportkonto +fortgesetzt werden. Danach im Betriebsstatus „Bisherige Teilwirkungen und +Versandprotokolle geprüft“ markieren und „Nächsten Termin freigeben“ wählen. +Die entsprechende API lautet: + +`POST /api/administration/cron/{id}/review` + +mit `{"slot":"","reviewed":true}` und gültiger +Sitzung/CSRF-Token. Erfordert Mitarbeiterverwaltungsrecht in der ursprünglichen +Niederlassung, prüft die Laufversion und protokolliert die Freigabe. Ein aktuell +laufender Task lässt sich nicht freigeben. Die Freigabe erlaubt nur den nächsten +regulären Termin, keinen sofortigen Neuversand. + +Bekannte bewusste Korrekturen gegenüber PHP: ungültige Kalenderdaten blockieren +den Lauf; Filterupdates erhalten fremde Tokens und verwenden die richtige +Kurier-ID statt des in den PHP-Prüfskripten vorkommenden `crvid`-Tippfehlers. +Die Belegungsprüfung verwendet bei fehlenden PLZ-Fahrzeiten 30 Minuten, +damit ein aktiver Fahrer nicht wegen eines SQL-NULL-Ergebnisses abgemeldet wird. +Leere Fristen verändern keine Filter. Führungszeugnisdatum plus sechs Monate +verwendet den PHP-Monatsüberlauf. Das Skript mit Dateiname 1629 prüft im +aktuellen Quelltext Feld **441**, was auch NG übernimmt. + +## PTnova-Importverhalten + +Anfrage und SOAPAction entsprechen den SAP-Operationen `ZPtnWstpMasterdata` +und `ZPtnWstpStatusdata`. Die Lesemarken bleiben kompatibel zu PHP: +`genericdatacontainer`, Objekt `hq`, Feld `pt_nova`, Kontext `1` bzw. `0`. +Abgerufen wird ab der neueren Marke aus gespeichertem Lauf und gestern 00:00. +Die neue Marke wird erst nach gültiger Antwort und abgeschlossenen Schreibvorgängen +auf die Startzeit des Abrufs gesetzt. Bei HTTP-/XML-Fehlern bleibt sie unverändert. + +Bestehende Geräte werden über die Seriennummer gefunden und im Stammdatenlauf +nicht überschrieben. Neue Geräte erhalten wie im aktiven PHP-Code das +Containerlager, ein Objektlager und einen erhöhten Stückbestand. Betriebsmeldungen +werden je Artikeltyp/Code aktualisiert. Statusdaten übernehmen offene Meldungen, +`NominalAmnt` für BEK/MEK/ZGS/RGS sowie `NominalStock` für KARTEN bei +STU/STH/STB/STVS. Kartenbestände überschreiben Feld 09 nach der Geldübernahme. +Karten allein erzeugen wie in PHP keine neue Tageshistorie. Unbekannte Artikeltypen +oder Geräte werden gezählt und übersprungen. + +Beide Modi teilen eine datenbankweite Importsperre. Die anschließenden Schreibvorgänge +verwenden Tabellensperren, damit auch MyISAM-Tabellen berücksichtigt sind. +MyISAM-Änderungen sind nicht rückrollbar: Fehler während der Schreibphase markieren +den Lauf deshalb als `UNCERTAIN`, behalten die alte Lesemarke und erfordern die +beschriebene Prüfung. SOAP-Netzwerkzugriffe erfolgen vor den Tabellensperren. + +Bewusste Korrekturen: SOAP-Fehler mit HTTP 200 gelten nicht als Erfolg; +DTD/externe XML-Entitäten sind gesperrt; auch Geräte-Stammdaten werden auf +HHA/HDAG und die angefragten Gerätetypen begrenzt. Mehrdeutige Seriennummern, +Artikelzuordnungen und fremde Niederlassungslager lösen einen Fehler aus. +Die von PHP undefiniert übergebene Meldungspriorität wird bei Neuanlage mit 0 +belegt. Deaktivierte PHP-Blöcke für automatische Standortpflege und weitere +Statusattribute werden nicht als zusätzliche Schreibaktionen aktiviert. + +## Prüfung + +Automatisierte Prüfungen decken Crontab-Zuordnung, Wochenenden/Zeitumstellung, +dauerhafte Laufbelege, parallele Starts, unbestätigten Versand, Datums-/Filterregeln, +Dateikompression mit Konflikten und Symlinks sowie Datenbank- und Replikationszähler +ab. Eine isolierte MariaDB prüft die tatsächlichen SQL-Schreibpfade einschließlich +PDA-Abmeldung, Filterpflege und der Wiederanlaufsperre. Ergänzende SOAP-Tests prüfen +Anfragen, sichere XML-Auswertung, Größenlimit, Importwiederholungen, +Bestands-/Historienzuordnung und einen absichtlich ausgelösten MyISAM-Teilabbruch. Echte SAP-, SMTP- und +FTP-Endstellen sowie produktive Dateimounts sind damit nicht abgenommen. + +Abschlussprüfung am 26.10.2026: vollständiges `mvn verify` erfolgreich mit +1646 Backendtests, keinen Fehlern und 3 umgebungsabhängig übersprungenen Tests. +Die 19 gezielten Cron-/SOAP-Prüfungen einschließlich isolierter MariaDB bestanden +ebenfalls. Die zuvor geprüfte Oberfläche ist unverändert (44 Frontendtests). +Der ausführbare Backend-Build 0.2.0 liegt unter +`backend/target/votian-backend-0.2.0.jar`; der Hashabgleich bestätigte denselben +Quellstand wie beim Testlauf. Es erfolgte keine Aktivierung der Jobs im Test- +oder Produktionsbetrieb. Die lokale MariaDB-Testinstanz wurde danach entfernt. + +Nach Betreiberentscheidung am 27.10.2026: offene HHA-Meldungen entfernt, +12 bestehende Cron-Tests erfolgreich und Backend-Paket neu gebaut. Zu diesem +Zeitpunkt waren die Jobs noch deaktiviert; die PHP-Serverdateien wurden nicht verändert. + +Container-Aktivierung vorbereitet und geprüft am 27.10.2026: Das Image aktiviert +alle neun Gruppen über `SPRING_TASK_GROUPS`. 14 gezielte Tests bestanden, +einschließlich Konfigurationsvorrang und vollständigem Scheduler-Shutdown. +Ein lokal gebauter Container ohne Netzwerk und ohne Produktionszugänge startete +mit Java als PID 1 und meldete „115 von 115 eingeplant“. `cron`, `crond` und +`php` sind im Laufzeit-Image nicht vorhanden. Der Container wurde anschließend +sauber beendet und entfernt. Das Image wurde nicht veröffentlicht und auf dem +späteren Produktionsserver wurde keine Instanz gestartet. diff --git a/docs/PRUEFSTATUS.md b/docs/PRUEFSTATUS.md index eeff89d..1b3f043 100644 --- a/docs/PRUEFSTATUS.md +++ b/docs/PRUEFSTATUS.md @@ -2822,6 +2822,6 @@ Die folgenden Punkte beschreiben die ursprüngliche erste Ausbaustufe. Zahlreich - 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. +- Konfiguration der tatsächlich vorauf dem testdenen 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/src/OperationsMonitoring.tsx b/frontend/src/OperationsMonitoring.tsx index d46292a..f0fa1af 100644 --- a/frontend/src/OperationsMonitoring.tsx +++ b/frontend/src/OperationsMonitoring.tsx @@ -4,8 +4,10 @@ import {request} from './api'; type Worker={id:string;label:string;configured:boolean;state:string;message:string;scope:string;lastRun:string;ageSeconds:number;intervalSeconds:number;values:Record}; type Source={available:boolean;message?:string;data?:T}; +type CronTask={id:string;label:string;group:string;cron:string;zone:string;source:string;enabled:boolean;state:string;message:string;lastRun:string;slot:string;processed:number;nextRun:string}; type Snapshot={ at:string;startedAt:string;headquartersId:number;workers:Worker[]; + cron?:{items:CronTask[];unresolved:{source:string;cron:string;reason:string}[]}; signals:Source<{databaseTime:string;items:{id:string;label:string;state:string;at:string;ageSeconds:number|null}[]}>; database:Source<{day:string;jobs:{total:number;latest:number|null;finished:number;activeToday:number;active:number};couriers:{available:number;reserved:number}}>; history:Source<{day:string;deviceCompletions:number}>; @@ -16,6 +18,8 @@ const date=(value:string)=>value?new Date(value).toLocaleString('de-DE'):'Noch k const number=(value:number|null|undefined)=>value==null?'—':value.toLocaleString('de-DE'); export function OperationsMonitoring({refresh}:{refresh:number}){ const [data,setData]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(true),[version,setVersion]=useState(0); + const [checkedTasks,setCheckedTasks]=useState>(new Set()),[reviewing,setReviewing]=useState(''); + async function reviewTask(task:CronTask){setReviewing(task.id);setError('');try{await request(`/administration/cron/${task.id}/review`,{method:'POST',body:JSON.stringify({slot:task.slot,reviewed:true})});setCheckedTasks(new Set());setVersion(v=>v+1);}catch(e){setError(e instanceof Error?e.message:'Freigabe fehlgeschlagen.');}finally{setReviewing('');}} useEffect(()=>{const controller=new AbortController();setData(null);setError('');setLoading(true); request('/administration/operations',{signal:controller.signal}).then(value=>{if(!controller.signal.aborted)setData(value);}).catch(e=>{if(!controller.signal.aborted)setError(e.message);}).finally(()=>{if(!controller.signal.aborted)setLoading(false);}); return()=>controller.abort(); @@ -31,6 +35,18 @@ export function OperationsMonitoring({refresh}:{refresh:number}){
{data.workers.map(w=>)}
DienstStatusLetzter abgeschlossener LaufErgebnis des letzten Laufs
{w.label}

{w.scope}

Prüfabstand: {w.intervalSeconds} Sekunden nach Laufende
{labels[w.state]||w.state}

{w.message}

{date(w.lastRun)}{w.lastRun?
{Object.entries(w.values).map(([key,value])=>
{counters[key]||key}
{number(value)}
)}
:'—'}
+ {data.cron&&<> +

Zeitgesteuerte Aufgaben

+

Feste Termine nach Europe/Berlin. Laufbelege bleiben nach einem Neustart erhalten. Unbestätigte Vorgänge werden nicht automatisch wiederholt.

+ {[...new Set(data.cron.items.map(t=>t.group))].map(group=>
{group} · {data.cron!.items.filter(t=>t.group===group&&t.enabled).length} aktiv / {data.cron!.items.filter(t=>t.group===group).length} Aufgaben +
+ {data.cron!.items.filter(t=>t.group===group).map(t=>)} +
AufgabeStatusLetzter LaufNächster Termin
{t.label}

{t.source}

{labels[t.state]||({WAITING:'Wartet',RUNNING:'Gestartet / Abschluss offen',UNCERTAIN:'Ergebnis prüfen',REVIEWED:'Geprüft'} as Record)[t.state]||t.state}

{t.message}

{number(t.processed)} verarbeitet + {['RUNNING','UNCERTAIN'].includes(t.state)&&
} +
{date(t.lastRun)}{t.nextRun?date(t.nextRun):'Ausgeschaltet'}

{t.cron} · {t.zone}

+
)} + {data.cron.unresolved.length>0&&
Noch nicht umgestellte Cron-Aufgaben ({data.cron.unresolved.length})
    {data.cron.unresolved.map(t=>
  • {t.source}: {t.reason}
  • )}
} + }

Gemeinsame Lebenszeichen

Diese Zeitstempel stammen aus der gemeinsamen Datenbank. PHP und NG können denselben Eintrag aktualisieren. Ein aktueller Eintrag bestätigt weder den zuständigen Prozess noch die erfolgreiche Verarbeitung.

{!data.signals.available?

{data.signals.message}

:data.signals.data&&<> diff --git a/legacy-php-tests/.gitignore b/legacy-php-tests/.gitignore new file mode 100644 index 0000000..091562a --- /dev/null +++ b/legacy-php-tests/.gitignore @@ -0,0 +1,3 @@ +/tools/*.phar +/reports/ +/.phpunit.cache/ diff --git a/legacy-php-tests/README.md b/legacy-php-tests/README.md new file mode 100644 index 0000000..e7ce587 --- /dev/null +++ b/legacy-php-tests/README.md @@ -0,0 +1,123 @@ +# Unit-Tests der Votian-PHP-Altanwendung + +Die Suite prüft 14 Funktionen direkt aus dem aktuellen PHP-Quellstand. Sie enthält +144 fachliche Testfälle und fünf Tests des isolierten Funktionsladers. +Anwendungsdateien und Composer-Konfiguration werden nicht verändert. + +## Auf dem Server ausführen + +Die Suite liegt auf `172.16.0.158` unter `/data/0/votian-test/tests/unit`, außerhalb +des Webroots. Aufruf auf diesem Server: + +```sh +cd /data/0/votian-test/tests/unit +./run-server.sh +``` + +Einzelnen Bereich ausführen: + +```sh +./run-server.sh --filter PricingTest +./run-server.sh --filter RecurringOrderTest +``` + +Standardquelle ist `/data/0/votian-test/html`. Das Skript ermittelt die unveränderliche +Image-ID des laufenden Containers `votian-test` und startet einen separaten Testcontainer +mit genau diesem Image. Beim ersten geprüften Lauf war dessen PHP-Version **8.2.35**; +das Host-PHP 8.2.30 wird nicht verwendet. + +Andere Quelle bzw. Laufzeit explizit auswählen: + +```sh +VOTIAN_SOURCE_ROOT=/pfad/zum/html VOTIAN_PHP_CONTAINER=votian-test ./run-server.sh +``` + +Ergebnisdateien in `reports/`: + +- `junit.xml`: maschinenlesbare Testergebnisse, mit Fehlermeldungen. +- `source-manifest.json`: PHP-Version, Zeitzone, SHA-256 der fünf Quelldateien sowie + Name, Ursprungszeile und SHA-256 jeder geladenen Funktion. +- `runtime-image.txt`: genaue Docker-Image-ID des geprüften Laufzeitstands. + +Das Skript reicht den PHPUnit-Exitcode durch: **0 = erfolgreich, 1 = Testfehler**. +Lade-, Konfigurations- und Laufzeitfehler werden ebenfalls als Fehler ausgegeben. +JUnit-Bericht und Quellenmanifest werden bei jedem Lauf ersetzt. + +## Installation aus dieser Repository-Kopie + +Benötigt werden Docker mit Zugriff auf das bereits vorhandene Votian-Image und +PHPUnit 11.5.57. Der PHAR-Download ist nicht im Git-Repository enthalten: + +```sh +./setup-phpunit.sh +``` + +Das Installationsskript lädt die feste Version von `phar.phpunit.de` und prüft +SHA-256 `c704fdd37c6c6ab7adb386f31b4e186cd51156801fa9028b5ea8099a2cd7f106`. +Auch der Teststarter prüft diese Prüfsumme. Ein Netzwerkzugriff ist nur für die +Installation nötig. PHPUnit 11 passt zu PHP 8.2; siehe +[offizielle Versionsmatrix](https://phpunit.de/supported-versions.html). + +Die Dateien dieses Ordners einschließlich `tools/phpunit.phar` können anschließend +in das oben genannte Serververzeichnis kopiert werden. Der Teststarter erwartet +Linux/Docker; die Tests selbst können bei passenden PHP-Erweiterungen auch direkt +mit `VOTIAN_SOURCE_ROOT=/pfad/zum/html php tools/phpunit.phar -c phpunit.xml` laufen. +Dieser direkte Aufruf bietet nicht die zusätzliche Container-Isolation. + +## Abgedeckte Regeln + +| Datei | Regeln | +| --- | --- | +| `tests/CalendarTest.php` | Schaltjahre, Jahrhundertregeln, Monatslängen, Jahreswechsel, positive und negative Datumsabstände | +| `tests/RecurringOrderTest.php` | Alle elf Wiederholungsrhythmen, Wochentagsmasken, konfigurierte Feiertage, Monatsende, Schaltjahr, Sommer-/Winterzeit | +| `tests/PricingTest.php` | Kunden-/Standardtarife, Niederlassungsfallback, negative Preiskorrekturen, Rabattkorrekturen, Ein-Cent-Marker, Frachtvergütung, Rundung, Fahrzeug- und Kundenwahl | +| `tests/InvoiceDiscountTest.php` | Bestehende Rabatt-/Zuschlagstexte, Servicepreisabgrenzung, Selbsterfasserrabatt, Reihenfolge und Beträge der BI-Positionen | +| `tests/FormattingTest.php` | Zahl-/Datumsformatierung, Parameterfallback, Telefonnummern, exakte Listeneinträge | +| `tests/ConditionTest.php` | Vergleichsoperatoren und bestehende PHP-Typumwandlung | +| `tests/LegacyFunctionsTest.php` | Funktionsauswahl, Zeichenkettenklammern, keine Methoden-/Nested-Verwechslung, fehlende/mehrdeutige Funktionen, verbotene Includes | + +Die Rechnungstests charakterisieren den bestehenden Ausgabe- und BI-Vertrag; +sie sind keine fachliche Freigabe sämtlicher Rabattregeln. Datenbankabfragen, +Tarifdaten, Parameter und BI-Schreibziele werden durch strikt geprüfte Test-Doubles +ersetzt. Nicht erwartete Zugriffe lassen einen Test fehlschlagen. Die Kalender- und +Dauerauftragsfunktionen sind echte Serverfunktionen; die Feiertagsliste besteht aus +synthetischen Daten und wird vor jedem Test zurückgesetzt. + +## Isolation und Grenzen + +Die großen Legacy-Dateien enthalten Initialisierung mit Verbindungen und anderen +Seiteneffekten. `support/LegacyFunctions.php` liest mit dem PHP-Tokenizer ausschließlich +die benannten globalen Funktionsdefinitionen und führt deren unveränderte Bytes aus. +Top-Level-Code, Includes, App-Bootstrap und Konfigurationsdateien werden nicht geladen. +Fehlende bzw. doppelte Definitionen sowie Includes/Eval/Exit innerhalb ausgewählter +Funktionen brechen den Lauf ab. Änderungen an den Serverfunktionen werden bei jedem +Lauf direkt geprüft; es gibt keine eingefrorene Kopie der Geschäftslogik in den Tests. + +Der separate Container hat kein Netzwerk, nur lesbaren Quellcode, ein nur lesbares +Root-Dateisystem, keine Linux-Capabilities und begrenzte Ressourcen. Schreibbar sind +lediglich sein temporäres Verzeichnis und der Testberichtordner. Mailversand und +Prozessstarts aus PHP sind zusätzlich deaktiviert. Der laufende Anwendungscontainer +wird nicht verändert oder neu gestartet. Es werden keine Bestandsdaten gelesen oder +geschrieben und keine Mails versandt. + +Nicht abgedeckt sind vollständige HTTP-/UI-Abläufe, reale SQL-Abfragen, +Auftragsspeicherung, Rechnungserzeugung, Geräte-/Maildienste und alle übrigen +Legacy-Funktionen. Es wird kein vollständiger Coverage-Prozentsatz behauptet. + +## Befund vom 26. Oktober 2026 + +**149 Tests, 511 Assertions: 145 bestanden, vier fehlgeschlagen; keine Laufzeitfehler, +keine übersprungenen Tests.** Die vier Fehlschläge betreffen denselben vorhandenen +Fehler in `html/include/calendar.inc.php`, Funktion `addDaysComplete`, Zeile 213: +Beim Rückwärtswechsel wird die Tageszahl des Ausgangsmonats statt des Zielmonats +addiert. + +| Eingabe | Erwartet | Tatsächlich | +| --- | --- | --- | +| 01.03.2026 minus 1 Tag | 28.02.2026 | 31.02.2026 | +| 01.03.2024 minus 1 Tag | 29.02.2024 | 31.02.2024 | +| 01.05.2026 minus 1 Tag | 30.04.2026 | 31.04.2026 | +| 01.04.2026 minus 1 Tag | 31.03.2026 | 30.03.2026 | + +Diese Regressionstests bleiben absichtlich aktiv und rot, bis der Anwendungsfehler +behoben ist. Der Altcode wurde im Rahmen der Testerstellung nicht korrigiert. diff --git a/legacy-php-tests/bootstrap.php b/legacy-php-tests/bootstrap.php new file mode 100644 index 0000000..21198ba --- /dev/null +++ b/legacy-php-tests/bootstrap.php @@ -0,0 +1,30 @@ + ['leapYear', 'daysPerMonth', 'addDaysComplete', 'addMonths'], + 'include/caglobal.inc.php' => ['getNextOrderDate', 'isPublicHoliday', 'checkDiscountStr'], + 'include/services_func.inc.php' => ['getServicePrices', 'getCr_price_global', 'get_cr_price_rate'], + 'include/mcglobal.inc.php' => ['formatOutput'], + 'include/global.inc.php' => ['eregPhoneNo', 'removeItemStringList', 'mcCheckCondition'], +]; +$manifest = LegacyFunctions::load($root, $selection); +$reportDir = getenv('VOTIAN_TEST_REPORT_DIR'); +if ($reportDir) { + $report = ['php' => PHP_VERSION, 'timezone' => date_default_timezone_get(), + 'sourceRoot' => realpath($root), 'files' => $manifest]; + if (file_put_contents($reportDir . '/source-manifest.json', + json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n") === false) { + throw new RuntimeException('Could not write source manifest'); + } +} diff --git a/legacy-php-tests/phpunit.xml b/legacy-php-tests/phpunit.xml new file mode 100644 index 0000000..bb55f8f --- /dev/null +++ b/legacy-php-tests/phpunit.xml @@ -0,0 +1,10 @@ + + + + tests + + + diff --git a/legacy-php-tests/run-server.sh b/legacy-php-tests/run-server.sh new file mode 100755 index 0000000..0abf1ea --- /dev/null +++ b/legacy-php-tests/run-server.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu +suite=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +source_root=${VOTIAN_SOURCE_ROOT:-/data/0/votian-test/html} +runtime_container=${VOTIAN_PHP_CONTAINER:-votian-test} +[ -d "$source_root/include" ] || { echo "Missing source directory: $source_root" >&2; exit 2; } +[ -f "$suite/tools/phpunit.phar" ] || { echo 'Run ./setup-phpunit.sh first.' >&2; exit 2; } +expected=c704fdd37c6c6ab7adb386f31b4e186cd51156801fa9028b5ea8099a2cd7f106 +actual=$(sha256sum "$suite/tools/phpunit.phar" | cut -d ' ' -f 1) +[ "$actual" = "$expected" ] || { echo 'PHPUnit checksum mismatch' >&2; exit 2; } +mkdir -p "$suite/reports" +image=$(docker inspect --format '{{.Image}}' "$runtime_container") +printf '%s\n' "$image" > "$suite/reports/runtime-image.txt" +# Use the exact deployed image, but no running application process, network or writable source. +docker run --rm --network none --read-only --cap-drop ALL \ + --security-opt no-new-privileges --pids-limit 64 --memory 256m --cpus 1 \ + --user "$(id -u):$(id -g)" --tmpfs /tmp:rw,nosuid,nodev,size=64m \ + --mount "type=bind,src=$source_root,dst=/legacy,readonly" \ + --mount "type=bind,src=$suite,dst=/suite,readonly" \ + --mount "type=bind,src=$suite/reports,dst=/reports" \ + --env VOTIAN_SOURCE_ROOT=/legacy --env VOTIAN_TEST_REPORT_DIR=/reports \ + --entrypoint php "$image" \ + -d auto_prepend_file= -d auto_append_file= -d allow_url_fopen=0 \ + -d max_execution_time=30 -d memory_limit=192M \ + -d disable_functions=mail,exec,shell_exec,system,passthru,popen,proc_open,pcntl_exec \ + /suite/tools/phpunit.phar --configuration /suite/phpunit.xml \ + --log-junit /reports/junit.xml --testdox "$@" diff --git a/legacy-php-tests/setup-phpunit.sh b/legacy-php-tests/setup-phpunit.sh new file mode 100755 index 0000000..c8195e0 --- /dev/null +++ b/legacy-php-tests/setup-phpunit.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu +suite=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$suite/tools" +expected=c704fdd37c6c6ab7adb386f31b4e186cd51156801fa9028b5ea8099a2cd7f106 +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1 + else shasum -a 256 "$1" | cut -d ' ' -f 1; fi +} +if [ -f "$suite/tools/phpunit.phar" ] && [ "$(hash_file "$suite/tools/phpunit.phar")" = "$expected" ]; then + exit 0 +fi +tmp=$(mktemp "$suite/tools/phpunit.XXXXXX") +trap 'rm -f "$tmp"' EXIT HUP INT TERM +curl --fail --location --silent --show-error --output "$tmp" https://phar.phpunit.de/phpunit-11.5.57.phar +[ "$(hash_file "$tmp")" = "$expected" ] || { echo 'PHPUnit checksum mismatch' >&2; exit 1; } +chmod 644 "$tmp" +mv "$tmp" "$suite/tools/phpunit.phar" diff --git a/legacy-php-tests/support/Fixtures.php b/legacy-php-tests/support/Fixtures.php new file mode 100644 index 0000000..e47db95 --- /dev/null +++ b/legacy-php-tests/support/Fixtures.php @@ -0,0 +1,90 @@ +expected[] = [$method, $sql, $result]; + } + + public function __call(string $method, array $arguments): mixed + { + if (!$this->expected) { + throw new RuntimeException('Unexpected database operation: ' . $method); + } + [$expectedMethod, $expectedSql, $result] = array_shift($this->expected); + if ($method !== $expectedMethod || $arguments !== [$expectedSql]) { + throw new RuntimeException('Unexpected fixture query: ' . $method . ' ' . ($arguments[0] ?? '')); + } + return $result; + } +} + +final class FixtureRows +{ + private int $index = 0; + public bool $freed = false; + public function __construct(private array $rows) {} + public function fetch_assoc(): ?array { return $this->rows[$this->index++] ?? null; } + public function fetchRow(): ?array { return $this->fetch_assoc(); } + public function free(): void { $this->freed = true; } +} + +final class DB +{ + public static function isError(mixed $value): bool { return $value instanceof Throwable; } +} + +function getParameterValue($employee, $key, $hq) +{ + if (!in_array($key, ['DB_FIELDTYPE_WRAPPER_1', 'COUNTRY_FON_PREFIX', + 'INV_PRINT_DISCOUNT', 'INV_USE_EURO_SIGN', 'MASK_MARKUP_MODE'], true)) { + throw new RuntimeException('Unexpected parameter: ' . $key); + } + return FixtureState::$parameters[$hq][$key] ?? ''; +} + +function getServiceValues(...$arguments) +{ + if (!FixtureState::$serviceCalls) { + throw new RuntimeException('Unexpected service lookup'); + } + [$expected, $result] = array_shift(FixtureState::$serviceCalls); + if ($arguments !== $expected) { + throw new RuntimeException('Service lookup arguments differ: ' . json_encode($arguments)); + } + return $result; +} + +function insertStmt($table, $row) +{ + if ($table !== 'phoenix_log.b2b_objects') { + throw new RuntimeException('Unexpected write target: ' . $table); + } + FixtureState::$writes[] = [$table, $row]; +} + +function writeLog_($file, $message) { FixtureState::$logs[] = [$file, $message]; } +function reportDie($message) { throw new RuntimeException($message); } diff --git a/legacy-php-tests/support/LegacyFunctions.php b/legacy-php-tests/support/LegacyFunctions.php new file mode 100644 index 0000000..a4a1e76 --- /dev/null +++ b/legacy-php-tests/support/LegacyFunctions.php @@ -0,0 +1,94 @@ + $names) { + $file = $root . '/' . $relative; + if (!is_file($file) || !is_readable($file)) { + throw new RuntimeException('Missing readable source: ' . $file); + } + $source = file_get_contents($file); + $functions = self::extract($source, $names); + foreach ($functions as $name => $definition) { + if (function_exists($name)) { + throw new RuntimeException('Function already defined: ' . $name); + } + // Preserve the source bytes and weak scalar coercion of the legacy code. + eval($definition['code']); + $manifest[$relative]['functions'][$name] = [ + 'line' => $definition['line'], + 'sha256' => hash('sha256', $definition['code']), + ]; + } + $manifest[$relative]['sha256'] = hash('sha256', $source); + } + return $manifest; + } + + public static function extract(string $source, array $names): array + { + $tokens = token_get_all($source); + $found = []; + $depth = 0; + for ($i = 0, $count = count($tokens); $i < $count; $i++) { + $token = $tokens[$i]; + if ($depth === 0 && is_array($token) && $token[0] === T_FUNCTION) { + $j = $i + 1; + while (isset($tokens[$j]) && is_array($tokens[$j]) && + in_array($tokens[$j][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, + T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG, T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG], true)) { + $j++; + } + $name = is_array($tokens[$j] ?? null) && $tokens[$j][0] === T_STRING + ? $tokens[$j][1] : null; + if ($name !== null && in_array($name, $names, true)) { + if (isset($found[$name])) { + throw new RuntimeException('Duplicate function: ' . $name); + } + $code = ''; + $bodyDepth = 0; + $started = false; + for ($j = $i; $j < $count; $j++) { + $part = $tokens[$j]; + $code .= is_array($part) ? $part[1] : $part; + if (is_array($part) && in_array($part[0], [T_INCLUDE, T_INCLUDE_ONCE, + T_REQUIRE, T_REQUIRE_ONCE, T_EVAL, T_EXIT], true)) { + throw new RuntimeException('Unsafe startup/exit operation in ' . $name); + } + if ($part === '{' || (is_array($part) && in_array($part[0], + [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true))) { + $bodyDepth++; + $started = true; + } elseif ($part === '}') { + $bodyDepth--; + if ($started && $bodyDepth === 0) { + $found[$name] = ['code' => $code, 'line' => $token[2]]; + $i = $j; + break; + } + } + } + if (!isset($found[$name])) { + throw new RuntimeException('Incomplete function: ' . $name); + } + continue; + } + } + if ($token === '{' || (is_array($token) && in_array($token[0], + [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true))) { + $depth++; + } elseif ($token === '}') { + $depth--; + } + } + $missing = array_diff($names, array_keys($found)); + if ($missing) { + throw new RuntimeException('Missing top-level function(s): ' . implode(', ', $missing)); + } + return $found; + } +} diff --git a/legacy-php-tests/support/LegacyTestCase.php b/legacy-php-tests/support/LegacyTestCase.php new file mode 100644 index 0000000..9c5dcbd --- /dev/null +++ b/legacy-php-tests/support/LegacyTestCase.php @@ -0,0 +1,15 @@ +expected, 'Unconsumed database expectations'); + self::assertSame([], FixtureState::$serviceCalls, 'Unconsumed service expectations'); + } +} diff --git a/legacy-php-tests/tests/CalendarTest.php b/legacy-php-tests/tests/CalendarTest.php new file mode 100644 index 0000000..f40b502 --- /dev/null +++ b/legacy-php-tests/tests/CalendarTest.php @@ -0,0 +1,71 @@ + [2026, false], 'leap' => [2024, true], + 'century exception' => [1900, false], '400 years' => [2000, true], + 'future century' => [2100, false]]; + } + + #[DataProvider('monthLengths')] + public function testMonthLength(int $year, int $month, int $expected): void + { + self::assertSame($expected, daysPerMonth($year, $month)); + } + + public static function monthLengths(): array + { + return [[2026, 1, 31], [2026, 2, 28], [2024, 2, 29], [1900, 2, 28], + [2000, 2, 29], [2026, 4, 30], [2026, 12, 31]]; + } + + #[DataProvider('dayOffsets')] + public function testDayArithmeticProducesValidCalendarDates(array $date, int $offset, array $expected): void + { + $actual = addDaysComplete(...[...$date, $offset]); + self::assertSame($expected, $actual); + self::assertTrue(checkdate($actual[1], $actual[2], $actual[0])); + } + + public static function dayOffsets(): array + { + return [ + 'unchanged' => [[2026, 10, 26], 0, [2026, 10, 26]], + 'next day' => [[2026, 10, 26], 1, [2026, 10, 27]], + 'year rollover' => [[2026, 12, 31], 1, [2027, 1, 1]], + 'February non-leap' => [[2026, 2, 28], 1, [2026, 3, 1]], + 'February leap' => [[2024, 2, 28], 1, [2024, 2, 29]], + 'leap rollover' => [[2024, 2, 29], 1, [2024, 3, 1]], + 'multiple months' => [[2026, 1, 31], 60, [2026, 4, 1]], + 'previous day same month' => [[2026, 10, 26], -1, [2026, 10, 25]], + 'previous year' => [[2026, 1, 1], -1, [2025, 12, 31]], + 'backwards into February' => [[2026, 3, 1], -1, [2026, 2, 28]], + 'backwards into leap February' => [[2024, 3, 1], -1, [2024, 2, 29]], + 'backwards into 30-day month' => [[2026, 5, 1], -1, [2026, 4, 30]], + 'backwards from short month' => [[2026, 4, 1], -1, [2026, 3, 31]], + ]; + } + + #[DataProvider('monthOffsets')] + public function testMonthArithmetic(int $year, int $month, int $offset, array $expected): void + { + self::assertSame($expected, addMonths($year, $month, $offset)); + } + + public static function monthOffsets(): array + { + return [[2026, 1, 0, [2026, 1]], [2026, 12, 1, [2027, 1]], + [2026, 1, -1, [2025, 12]], [2026, 1, 25, [2028, 2]], + [2026, 1, -13, [2024, 12]]]; + } +} diff --git a/legacy-php-tests/tests/ConditionTest.php b/legacy-php-tests/tests/ConditionTest.php new file mode 100644 index 0000000..4ecf493 --- /dev/null +++ b/legacy-php-tests/tests/ConditionTest.php @@ -0,0 +1,22 @@ +', 5, true], [5, '>', 5, false], [5, '<=', 5, true], + [6, '<=', 5, false], [5, '>=', 5, true], [4, '>=', 5, false], + ['10', '<', '2', false], ['text', '==', 'text', true], + [1, '===', 1, false], [1, 'invalid', 1, false]]; + } +} diff --git a/legacy-php-tests/tests/FormattingTest.php b/legacy-php-tests/tests/FormattingTest.php new file mode 100644 index 0000000..8a123d5 --- /dev/null +++ b/legacy-php-tests/tests/FormattingTest.php @@ -0,0 +1,90 @@ + ['', 'varchar', '1', '', ' '], + 'trim text' => [' Test ', 'text', '1', '', 'Test'], + 'zero number' => ['0', 'number', '1', '2', '0,00'], + 'positive half cent' => ['1234.565', 'number', '1', '2', '1.234,57'], + 'negative half cent' => ['-1234.565', 'number', '1', '2', '-1.234,57'], + 'integer rounding' => ['1234.5', 'number', '1', '0', '1.235'], + 'numeric string unformatted' => ['001.20', 'number', '1', '', '001.20'], + 'non-numeric preserved' => ['12,30', 'number', '1', '2', '12,30'], + 'invalid number preserved' => ['bad ', 'number', '1', '2', 'bad '], + 'rounded float' => ['1234.565', 'float_rounded_2', '1', '', '1.234,57'], + 'comma decimal normalized' => ['12,3', 'float', '1', '', '12.30'], + 'float with comma' => ['12.345', 'float', '2', '', '12,35'], + 'float as cents' => ['12.3', 'float', '3', '', '1230'], + 'boolean false' => ['0', 'bool', '1', '', ' '], + 'boolean true' => ['1', 'bool', '1', '', ''], + 'datetime time' => ['2026-10-26 14:35:59', 'datetime', '2', '', '14.35.59'], + 'datetime full' => ['2026-10-26 14:35:59', 'datetime', '3', '', '26.10.26 : 14.35.59'], + 'date four digit year' => ['2026-10-26 14:35:59', 'datetime', '4', '', '26.10.2026'], + 'date two digit year' => ['2026-10-26 14:35:59', 'datetime', '5', '', '26.10.26'], + 'sortable date' => ['2026-10-26 14:35:59', 'datetime', '8', '', '2026.10.26'], + 'compact date' => ['2026-10-26 14:35:59', 'datetime', '9', '', '20261026'], + 'hours minutes' => ['2026-10-26 14:35:59', 'datetime', '10', '', '14:35'], + 'month year' => ['2026-10-26 14:35:59', 'datetime', '11', '', '10/26'], + 'compact timestamp' => ['2026-10-26 14:35:59', 'datetime', '12', '', '20261026143559'], + 'full year time display' => ['2026-10-26 14:35:59', 'datetime', '14', '', '14:35 (26.10.2026)'], + 'classic timestamp' => ['20261026143559', 'timestamp_classic', '1', '', '26.10.26 : 14.35.59'], + ]; + } + + public function testGlobalTimestampWrapperParameter(): void + { + FixtureState::$parameters[0]['DB_FIELDTYPE_WRAPPER_1'] = '1'; + self::assertSame('26.10.26 : 14.35.59', formatOutput('2026-10-26 14:35:59', 'timestamp')); + } + + public function testHeadquartersParameterOverridesGlobalTimestampWrapper(): void + { + FixtureState::$parameters[0]['DB_FIELDTYPE_WRAPPER_1'] = '1'; + FixtureState::$parameters[7]['DB_FIELDTYPE_WRAPPER_1'] = '0'; + self::assertSame('26.10.26 : 14.35.59', formatOutput('20261026143559', 'timestamp')); + } + + #[DataProvider('phoneNumbers')] + public function testPhoneNormalization(string $input, array $parameters, string $expected): void + { + FixtureState::$parameters = $parameters; + self::assertSame($expected, eregPhoneNo($input)); + } + + public static function phoneNumbers(): array + { + return ['local' => ['0171 123-456', [], '171123456'], + 'international plus' => ['+49 171 123456', [], '171123456'], + 'international zeros' => ['0049 171 123456', [], '171123456'], + 'foreign prefix kept' => ['0044 20 123456', [], '4420123456'], + 'empty' => ['', [], ''], + 'headquarters prefix' => ['+43 664 123456', [7 => ['COUNTRY_FON_PREFIX' => '43']], '664123456'], + 'global prefix' => ['+43 664 123456', [0 => ['COUNTRY_FON_PREFIX' => '43']], '664123456'], + 'headquarters overrides global' => ['+43 664 123456', [0 => ['COUNTRY_FON_PREFIX' => '49'], 7 => ['COUNTRY_FON_PREFIX' => '43']], '664123456']]; + } + + #[DataProvider('lists')] + public function testRemoveExactListItem(string $list, string $item, string $delimiter, string $expected): void + { + self::assertSame($expected, removeItemStringList($list, $item, $delimiter)); + } + + public static function lists(): array + { + return [['123|23|234', '23', '|', '123|234'], ['23|123', '23', '|', '123'], + ['123|23', '23', '|', '123'], ['23', '23', '|', ''], + ['123|234', '23', '|', '123|234'], ['', '23', '|', ''], + ['a,b,c', 'b', ',', 'a,c'], ['23|23|123', '23', '|', '23|123']]; + } +} diff --git a/legacy-php-tests/tests/InvoiceDiscountTest.php b/legacy-php-tests/tests/InvoiceDiscountTest.php new file mode 100644 index 0000000..ed32a12 --- /dev/null +++ b/legacy-php-tests/tests/InvoiceDiscountTest.php @@ -0,0 +1,95 @@ +expect('getOne', 'SELECT jbprc_price FROM jobprice WHERE jb_id = 123 AND mt_sort = 13', $selfDiscount); + $result = new FixtureRows($rows); + $GLOBALS['db']->expect('query', "SELECT trs_discount, trs_price, trs_srv_name FROM tourservice WHERE jb_id = '123'", $result); + if ($original !== null) { + $GLOBALS['db']->expect('getOne', 'SELECT jbprc_remark FROM jobprice WHERE jb_id = 123 AND mt_sort = 13', $original); + } + return $result; + } + + private function row(string $name, float $price, float $discount = 0): array + { + return ['trs_srv_name' => $name, 'trs_price' => $price, 'trs_discount' => $discount]; + } + + private function item(int $index): array + { + [$table, $row] = FixtureState::$writes[$index]; + self::assertSame('phoenix_log.b2b_objects', $table); + $fields = []; + for ($i = 0; $i < count($row); $i += 2) { + $fields[$row[$i]] = $row[$i + 1]; + } + self::assertSame(300, $fields['bo_type']); + self::assertSame(123, $fields['bo_ext_id0']); + self::assertSame(7, $fields['bo_ext_id1']); + return json_decode($fields['bo_obj_data'], true, 512, JSON_THROW_ON_ERROR); + } + + public function testNoDiscountProducesNoTextOrBiItems(): void + { + $rows = $this->rows([$this->row('Fixpreis', 100)]); + self::assertSame('', checkDiscountStr(123, 0, 100, 0)); + self::assertSame([], FixtureState::$writes); + self::assertTrue($rows->freed); + } + + public function testDiscountTextAndBiItemUseSameAmounts(): void + { + FixtureState::$parameters[7]['INV_PRINT_DISCOUNT'] = '1'; + $this->rows([$this->row('Fixpreis', 90, 10)]); + self::assertSame("\nRabatt 10,00% auf 90,00 EUR = -9,00 EUR", checkDiscountStr(123, 0, 90, 0)); + self::assertCount(1, FixtureState::$writes); + self::assertSame(['c', '0.1', 's', 'Rabatt', 'p', '90', 't', '-9'], $this->item(0)); + } + + public function testHiddenDiscountDoesNotCreateBiItem(): void + { + FixtureState::$parameters[7]['INV_PRINT_DISCOUNT'] = '0'; + $this->rows([$this->row('Fixpreis', 90, 10)]); + self::assertSame('', checkDiscountStr(123, 0, 90, 0)); + self::assertSame([], FixtureState::$writes); + } + + public function testFuelMarkupExcludesServicePrice(): void + { + FixtureState::$parameters[7]['MASK_MARKUP_MODE'] = '1'; + $this->rows([$this->row('Fixpreis', 110), $this->row('Servicepreis', 12)]); + self::assertSame("\nTreibstoffzuschlag 10,00% auf 100,00 EUR = 10,00 EUR", checkDiscountStr(123, 10, 110, 12)); + self::assertCount(1, FixtureState::$writes); + self::assertSame(['c', '0.1', 's', 'Treibstoffzuschlag', 'p', '100', 't', '10'], $this->item(0)); + } + + public function testSelfEntryDiscountUsesStoredOriginalAmount(): void + { + $this->rows([$this->row('Fixpreis', 90), $this->row('Servicepreis', 12)], 10, 100); + self::assertSame("\nSelbsterfasserrabatt 10,00% auf 100,00 EUR = -10,00 EUR", checkDiscountStr(123, 0, 90, 12)); + self::assertCount(1, FixtureState::$writes); + self::assertSame(['c', '0.1', 's', 'Selbsterfasserrabatt', 'p', '100', 't', '-10'], $this->item(0)); + } + + public function testMarkupThenSelfEntryDiscountKeepTextAndBiOrder(): void + { + FixtureState::$parameters[7]['MASK_MARKUP_MODE'] = '1'; + $this->rows([$this->row('Fixpreis', 99)], 10, 110); + self::assertSame("\nTreibstoffzuschlag 10,00% auf 100,00 EUR = 10,00 EUR\nSelbsterfasserrabatt 10,00% auf 110,00 EUR = -11,00 EUR", + checkDiscountStr(123, 10, 99, 0)); + self::assertCount(2, FixtureState::$writes); + self::assertSame(['c', '0.1', 's', 'Treibstoffzuschlag', 'p', '100', 't', '10'], $this->item(0)); + self::assertSame(['c', '0.1', 's', 'Selbsterfasserrabatt', 'p', '110', 't', '-11'], $this->item(1)); + } + + public function testLegacyEuroByteIsPreserved(): void + { + FixtureState::$parameters[7] = ['MASK_MARKUP_MODE' => '1', 'INV_USE_EURO_SIGN' => '1']; + $this->rows([$this->row('Fixpreis', 110)]); + self::assertSame("\nTreibstoffzuschlag 10,00% auf 100,00 " . chr(128) . ' = 10,00 ' . chr(128), + checkDiscountStr(123, 10, 110, 0)); + } +} diff --git a/legacy-php-tests/tests/LegacyFunctionsTest.php b/legacy-php-tests/tests/LegacyFunctionsTest.php new file mode 100644 index 0000000..2cb6e54 --- /dev/null +++ b/legacy-php-tests/tests/LegacyFunctionsTest.php @@ -0,0 +1,44 @@ +expectException(RuntimeException::class); + LegacyFunctions::extract('expectException(RuntimeException::class); + LegacyFunctions::extract('expectException(RuntimeException::class); + LegacyFunctions::extract('expectException(RuntimeException::class); + LegacyFunctions::extract('expectServiceIds(7); + FixtureState::$serviceCalls[] = [[101, 201, 42, 7, self::DATE, 4, 3], $customer]; + if ($standard !== null) { + FixtureState::$serviceCalls[] = [[101, 201, 0, 7, self::DATE, 4, 3], $standard]; + } + self::assertSame($expected, getServicePrices('Transport', 'PKW', 42, 7, self::DATE, 0, 0, 0, false, 3)); + } + + public static function prices(): array + { + $standard = [100, 70, 5, 9, '2026-01-01']; + return [ + 'customer tariff' => [[80, 60, 10, 3, '2026-10-01'], null, [80, 0, 10, 0, 60, 0, true, '2026-10-01']], + 'standard fallback' => [[0, 0, 0, 0, ''], $standard, [100, 0, 5, 0, 70, 0, false, '2026-01-01']], + 'negative customer price is correction' => [[-15, 0, 0, 0, ''], $standard, [100, -15, 5, 0, 70, 0, true, '2026-01-01']], + 'negative discount is correction' => [[0, 0, -3, 0, ''], $standard, [100, 0, 5, -3, 70, 0, true, '2026-01-01']], + 'discount only keeps customer discount' => [[0, 0, 12, 0, ''], $standard, [100, 0, 12, 0, 70, 0, true, '2026-01-01']], + 'one cent denotes free tariff' => [[0.01, 0.01, 0, 0, '2026-10-01'], null, [0, 0, 0, 0, 0, 0, true, '2026-10-01']], + 'two cents remains charged' => [[0.02, 0.02, 0, 0, '2026-10-01'], null, [0.02, 0, 0, 0, 0.02, 0, true, '2026-10-01']], + ]; + } + + public function testMissingHeadquartersTypeSwitchesBothLookupsToGlobalScope(): void + { + $GLOBALS['db']->expect('getOne', "SELECT srvt_id FROM servicetype WHERE srvt_name = 'PKW' AND srvt_mode = 0 AND hq_id = '7'", ''); + $this->expectServiceIds(0); + FixtureState::$serviceCalls[] = [[101, 201, 42, 0, self::DATE, 4, 3], [80, 60, 0, 0, '2026-01-01']]; + self::assertSame([80, 0, 0, 0, 60, 0, true, '2026-01-01'], + getServicePrices('Transport', 'PKW', 42, 7, self::DATE, 0, 0, 0, false, 3)); + } + + public function testExplicitDiscountAndCorrectionsArePreserved(): void + { + $this->expectServiceIds(7); + FixtureState::$serviceCalls[] = [[101, 201, 42, 7, self::DATE, 4, 3], [80, 60, 10, 0, '2026-01-01']]; + self::assertSame([80, -2, 15, -1, 60, 0, true, '2026-01-01'], + getServicePrices('Transport', 'PKW', 42, 7, self::DATE, -2, 15, -1, false, 3)); + } + + private function expectServiceIds(int $hq): void + { + $GLOBALS['db']->expect('getOne', "SELECT srvt_id FROM servicetype WHERE srvt_name = 'PKW' AND srvt_mode = 0 AND hq_id = '$hq'", 201); + $GLOBALS['db']->expect('getOne', "SELECT srv_id FROM service WHERE srv_name = 'Transport' AND srv_mode = 0 AND hq_id = '$hq'", 101); + } + + #[DataProvider('courierPrices')] + public function testGlobalCourierDeduction(mixed $price, mixed $rate, float $expected): void + { + $this->expectCourierIds('Globaler
Frachtverg.-Abschlag [%]'); + FixtureState::$serviceCalls[] = [[301, 201, 42, 7, self::DATE, 4], [0, $rate, 0, 0, '2026-01-01']]; + self::assertSame([$expected, is_numeric($rate) ? $rate : 0], + getCr_price_global($price, 7, 42, self::DATE, 'PKW', 0)); + } + + public static function courierPrices(): array + { + return ['percentage' => [100, 20, 80.0], 'cent rounding' => [10.01, 15, 8.51], + 'negative credit' => [-100, 20, -80.0], 'full deduction' => [100, 100, 0.0], + 'invalid base' => ['invalid', 20, 0.0], 'invalid deduction' => [100, 'invalid', 100.0]]; + } + + public function testZeroCustomerDeductionFallsBackToStandard(): void + { + $this->expectCourierIds('Globaler
Frachtverg.-Abschlag [%]'); + FixtureState::$serviceCalls = [ + [[301, 201, 42, 7, self::DATE, 4], [0, 0, 0, 0, '']], + [[301, 201, 0, 7, self::DATE, 4], [0, 25, 0, 0, '2026-01-01']], + ]; + self::assertSame([75.0, 25], getCr_price_global(100, 7, 42, self::DATE, 'PKW', 0)); + } + + public function testVehicleIdOverridesVehicleNameAndEmptyCustomerIsZero(): void + { + $GLOBALS['db']->expect('getOne', "SELECT mt_value FROM metatype WHERE mt_sort = '3' AND mt_type = 'vehicletype'", 'PKW'); + $this->expectCourierIds('Globaler
Frachtverg.-Abschlag [%]'); + FixtureState::$serviceCalls[] = [[301, 201, 0, 7, self::DATE, 4], [0, 20, 0, 0, '2026-01-01']]; + self::assertSame([80.0, 20], getCr_price_global(100, 7, '', self::DATE, 'ignored', 3)); + } + + public function testCourierRateUsesSeparateTariffAndStandardFallback(): void + { + $this->expectCourierIds('Globaler
Fuhrlohnabschlag [%]'); + FixtureState::$serviceCalls = [ + [[301, 201, 42, 7, self::DATE, 4], [0, 0, 0, 0, '']], + [[301, 201, 0, 7, self::DATE, 4], [0, 17, 0, 0, '2026-01-01']], + ]; + self::assertSame(17, get_cr_price_rate(7, 42, self::DATE, 'PKW', 0)); + } + + private function expectCourierIds(string $service): void + { + $GLOBALS['db']->expect('getOne', "SELECT srvt_id FROM servicetype WHERE srvt_name = 'PKW' AND srvt_mode = 0 AND hq_id = '7'", 201); + $GLOBALS['db']->expect('getOne', "SELECT srv_id FROM service WHERE srv_name = '$service' AND srv_mode = 0 AND hq_id = '7'", 301); + } +} diff --git a/legacy-php-tests/tests/RecurringOrderTest.php b/legacy-php-tests/tests/RecurringOrderTest.php new file mode 100644 index 0000000..c7c06b3 --- /dev/null +++ b/legacy-php-tests/tests/RecurringOrderTest.php @@ -0,0 +1,58 @@ + [[2026, 10, 23], 1, '1111100', [], [2026, 10, 26]], + 'weekdays skip configured holiday' => [[2026, 10, 23], 1, '1111100', [[2026, 10, 26]], [2026, 10, 27]], + 'Saturday enabled' => [[2026, 10, 23], 2, '1111110', [], [2026, 10, 24]], + 'daily includes Sunday' => [[2026, 10, 24], 3, '1111111', [], [2026, 10, 25]], + 'daily excludes configured holiday' => [[2026, 10, 24], 3, '1111111', [[2026, 10, 25]], [2026, 10, 26]], + 'individual weekday mask' => [[2026, 10, 26], 1, '0010000', [], [2026, 10, 28]], + 'weekly' => [[2026, 10, 26], 4, '', [], [2026, 11, 2]], + 'weekly holiday moves forward' => [[2026, 10, 26], 4, '', [[2026, 11, 2], [2026, 11, 3]], [2026, 11, 4]], + 'fortnightly' => [[2026, 10, 26], 5, '', [], [2026, 11, 9]], + 'monthly clips short month' => [[2026, 1, 31], 6, '', [], [2026, 2, 28]], + 'monthly clips leap month' => [[2024, 1, 31], 6, '', [], [2024, 2, 29]], + 'monthly holiday moves forward' => [[2026, 1, 31], 6, '', [[2026, 2, 28]], [2026, 3, 1]], + 'monthly year rollover' => [[2026, 12, 15], 6, '', [], [2027, 1, 15]], + 'month end' => [[2026, 4, 12], 7, '', [], [2026, 5, 31]], + 'month end holiday moves backward' => [[2026, 4, 12], 7, '', [[2026, 5, 31]], [2026, 5, 30]], + 'half month from fifteenth' => [[2026, 4, 15], 8, '', [], [2026, 4, 30]], + 'half month from month end' => [[2026, 4, 30], 8, '', [], [2026, 5, 15]], + 'half month holiday moves backward' => [[2026, 4, 30], 8, '', [[2026, 5, 15]], [2026, 5, 14]], + 'holiday only' => [[2026, 12, 23], 9, '0000000', [[2026, 12, 25]], [2026, 12, 25]], + 'holiday or weekday: weekday sooner' => [[2026, 12, 23], 9, '1111100', [[2026, 12, 25]], [2026, 12, 24]], + 'holiday or weekday: holiday sooner' => [[2026, 12, 23], 9, '0000001', [[2026, 12, 25]], [2026, 12, 25]], + 'sixteenth from first' => [[2026, 4, 1], 10, '', [], [2026, 4, 16]], + 'first from sixteenth' => [[2026, 4, 16], 10, '', [], [2026, 5, 1]], + 'first holiday moves forward' => [[2026, 4, 16], 10, '', [[2026, 5, 1]], [2026, 5, 2]], + 'four weeks over new year' => [[2026, 12, 15], 11, '', [], [2027, 1, 12]], + 'four weeks holiday moves forward' => [[2026, 12, 15], 11, '', [[2027, 1, 12]], [2027, 1, 13]], + 'DST spring keeps calendar day' => [[2026, 3, 28], 3, '1111111', [], [2026, 3, 29]], + 'DST autumn keeps calendar day' => [[2026, 10, 24], 3, '1111111', [], [2026, 10, 25]], + ]; + } + + public function testHolidayLookupUsesExactDateFromConfiguredList(): void + { + $GLOBALS['ph'] = [[2026, 12, 25], [2026, 12, 26]]; + self::assertTrue(isPublicHoliday(2026, 12, 25)); + self::assertFalse(isPublicHoliday(2025, 12, 25)); + self::assertFalse(isPublicHoliday(2026, 11, 25)); + self::assertFalse(isPublicHoliday(2026, 12, 24)); + } +} diff --git a/legacy-ui-tests/.gitignore b/legacy-ui-tests/.gitignore new file mode 100644 index 0000000..6b6e24a --- /dev/null +++ b/legacy-ui-tests/.gitignore @@ -0,0 +1,6 @@ +/reports/ +/test-results/ +/.auth/ +/node_modules/ +.env* +!.env.example diff --git a/legacy-ui-tests/README.md b/legacy-ui-tests/README.md new file mode 100644 index 0000000..314dd55 --- /dev/null +++ b/legacy-ui-tests/README.md @@ -0,0 +1,95 @@ +# Oberflächentests der alten Votian-PHP-Anwendung + +Die Tests bedienen die echte Anwendung unter **https://test.sb.assecutor.de**. Sie verwenden echte Anmeldung, Kundenwahl, Preisberechnung und Auftragsformulare; es gibt keine simulierten Geschäftsdaten. Die Anwendung läuft auf `172.16.0.158` aus `/data/0/votian-test/html`. Die Tests liegen daneben unter `/data/0/votian-test/tests/ui`, außerhalb des Webroots. PHP-Produktivcode wird nicht verändert. + +## Ausführen + +Auf dem Server, mit interaktivem Terminal: + +```sh +/data/0/votian-test/tests/ui/run-server.sh +``` + +Benutzername und Passwort werden abgefragt. Der Standardlauf speichert keine Aufträge; der Speichertest wird ausdrücklich übersprungen. Für den vollständigen Lauf einschließlich **eines neuen Testauftrags**: + +```sh +/data/0/votian-test/tests/ui/run-server.sh --create-order +``` + +Einen bereits angelegten Testauftrag erneut prüfen, ohne einen weiteren anzulegen: + +```sh +/data/0/votian-test/tests/ui/run-server.sh --verify-order 21891310 UI-TEST-20261026T094639-670e98 +``` + +Der Runner startet einen kurzlebigen Docker-Container mit `mcr.microsoft.com/playwright:v1.63.0-noble`, installiert die drei per Lockfile festgelegten npm-Pakete im Testverzeichnis und startet Firefox. Er bindet nur das Testverzeichnis ein, keine Anwendungs- oder Datenbankverzeichnisse. Docker und HTTPS-Zugriff auf die Testanwendung sind erforderlich. Parallel gestartete Läufe im selben Verzeichnis vermeiden. + +Lokal, mit Node.js 20 oder neuer und Python 3: + +```sh +cd legacy-ui-tests +npm ci +npx playwright install firefox +python3 run.py +``` + +Alternativ im Projektstamm: `npm run test:legacy-ui`. Die Servervariante ist auf diesem Arbeitsplatz die geprüfte Ausführungsumgebung: Der lokale Firefox startete hier wegen eines Profilverzeichnisfehlers nicht. + +## Testumfang + +15 Tests prüfen: + +- JavaScript-Fehler beim Öffnen und Aufbau der vier Frames; +- leeres Formular und gesperrte Sendeschaltfläche; +- Bezahlerwahl über die echte Kundenliste; +- Auftragseingang „Sonstiges“ und Rücksetzen des Zusatzfelds; +- Abhängigkeiten zwischen Angebot, manueller Disposition und unvollständigem Status; +- Versicherung ein-/ausschalten, Warenwert und drei ungültige Eingaben; +- Vertauschen und Entfernen von Stationen; +- Vorbereitung mit zwei Testkunden, Besteller, Hinweisen und 10,00 € Festpreis; +- optionales Speichern über „Senden“, Serverbestätigung mit Auftragsnummer und erneutes Laden einer frischen Detailansicht. Dort werden Stationsreihenfolge, Hinweise, Festpreis und „UNFERTIG!“ geprüft. + +Es handelt sich um funktionale Tests der Einzelerfassung, nicht um vollständige Abdeckung der Verwaltung, Disposition oder Abrechnung. Die Unit-Tests stehen separat in `../legacy-php-tests/`. + +## Testdaten und Schreibzugriffe + +Der Speichertest nutzt ausschließlich **Testkunde Assecutor 1** (Kostenstelle `878914`) und **Testkunde Assecutor 2** (`878915`). Beide Stammsätze sind als „Testaufträge Assecutor“ gekennzeichnet. Jeder neue Auftrag erhält einen eindeutigen `UI-TEST-…`-Marker im Besteller und in beiden Stationshinweisen, darunter „NICHT AUSFÜHREN“. + +Vor dem Senden validiert eine Netzwerksperre die tatsächlichen Formulardaten: genau zwei markierte Teststationen, Testkunde 1 als Bezahler, 10,00 €, manuelle Disposition, unvollständig, kein Kurier, keine Serien-/Mehrfachaufträge, keine Höherversicherung und `sendmail=0`. Pro Test wird höchstens ein Speichervorgang zugelassen; Wiederholungen und Retries des Speichertests werden abgewiesen. Ein vor dem Versand angelegter Eintrag in `reports/created-orders.jsonl` verhindert nach einem unklaren Ausgang erneutes Speichern, bis das Ergebnis geprüft wurde. Ein Testauftrag wird nicht automatisch gelöscht oder abgeschlossen. + +Andere fachliche Schreibzugriffe werden vor dem Versand blockiert. Freigegeben sind Anmeldung, gelesene Nachschlagefunktionen, Kunden-/Tourensuche und Preisberechnung. Die automatische News-Ticker-Bestätigung und JavaScript-Fehlertelemetrie werden im Browser beantwortet, ohne sie zu speichern. Google-Maps-Telemetrie wird unterdrückt. Normale serverseitige Sitzungen, Zugriffsprotokolle und Folgearbeiten der PHP-Anwendung bleiben möglich; dies ist keine vollständig schreibfreie Datenbank-Sandbox. + +Zugangsdaten werden nur abgefragt bzw. aus `VOTIAN_UI_USERNAME` und `VOTIAN_UI_PASSWORD` gelesen. Sie werden nicht in Dateien gespeichert. Anmeldung und Sitzungszustand bleiben im Speicher; Traces und Videos sind deaktiviert. Berichte und Screenshots sind von Git ausgeschlossen und können Daten der Testanwendung enthalten. + +## Bekannter Fehler und Browserwahl + +`jobs/job_edit.php:13` führt `top.opener.top.job_edit = this` aus, obwohl die über `target="_blank"` geöffnete Seite kein `window.opener` besitzt. Der Test „Auftragserfassung startet ohne JavaScript-Ausnahme“ bleibt deshalb absichtlich rot. Der exakt erkannte Fehler wird in den anderen Tests als Anhang protokolliert; neue JavaScript-Ausnahmen lassen jeden betroffenen Test scheitern. Es gibt kein `test.fail()` oder stilles Überspringen dieses Anwendungsfehlers. + +Im vollständigen Speichertest trat zusätzlich `TypeError: can't access property "currentTSZ", parent.job_options.document.tourOptions is undefined` auf. Die Preisantwort greift auf ein während des Speicherns neu geladenes Formular zu (erzeugter JavaScript-Code aus `jobs/job_options.php:3357`). Das Speichern und die Prüfung der frischen Detailansicht waren trotzdem erfolgreich. Der Test bleibt wegen der erfassten Ausnahme rot; sie wird nicht als erlaubter Fehler unterdrückt. Der zeitliche Zusammenhang spricht für eine Race Condition, deren genauer Auslöser gesondert untersucht werden muss. + +Für die Netzwerksperre mit den alten `document.write`-Popups wird Firefox verwendet. Playwright 1.63 blockiert diese Popups unter Chromium bereits durch aktiviertes Routing; das ist ein [bestätigter Playwright-Fehler](https://github.com/microsoft/playwright/issues/43496), kein Beleg für einen Speicherfehler der PHP-Anwendung. Der Ablauf wurde dafür nicht durch direkte API- oder Datenbank-Schreibzugriffe ersetzt. + +## Ergebnisse + +Vollständiger Serverlauf vom **26.10.2026**, mit `--create-order`: **15 ausgeführt, 13 bestanden, 2 fehlgeschlagen, 0 übersprungen**, Laufzeit 41 Sekunden. Beide Fehlschläge betreffen die oben dokumentierten JavaScript-Ausnahmen. Alle fachlichen Prüfungen des gespeicherten Auftrags waren erfolgreich. + +Angelegte und erhaltene Testaufträge: + +| Auftrag | Marker | Prüfung | +| --- | --- | --- | +| 21891310 | `UI-TEST-20261026T094639-670e98` | Erstlauf; Speicherung zusätzlich per lesender Datenbankabfrage bestätigt, frische Detailansicht geprüft | +| 21891311 | `UI-TEST-20261026T095313-b89f03` | Vollständiger Lauf; Serverbestätigung und Detailansicht geprüft | + +Beide verwenden 10,00 € Festpreis, Testkunde 1 als Bezahler, manuelle Disposition, „unvollständig“ und keine Kurierzuweisung. Sie wurden nicht abgeschlossen oder gelöscht. + +Geprüftes Container-Image: `sha256:2c1f4e0fd6450f43ddb46d60c2a6df30855a8588e165b1f2559fb0eda8d7ff35` (Tag `mcr.microsoft.com/playwright:v1.63.0-noble`). Im vollständigen Lauf wurde die Datenübertragung nicht simuliert; die Zugangsdaten kamen aus der interaktiven Eingabe. + +Nach jedem Lauf: `reports/html/index.html`, `reports/junit.xml`, `reports/results.json` und `test-results/` mit Screenshots. Die Liste angelegter Aufträge wird separat fortgeschrieben und nicht durch den nächsten Testlauf gelöscht. + +HTML-Bericht öffnen: + +```sh +npx playwright show-report reports/html +``` + +Der Rückgabecode bleibt bei einem offenen Anwendungsfehler ungleich null. Maßgeblich ist der letzte vollständige Lauf; ein gefilterter Wiederholungslauf überschreibt die Standardberichte. diff --git a/legacy-ui-tests/package-lock.json b/legacy-ui-tests/package-lock.json new file mode 100644 index 0000000..ce60471 --- /dev/null +++ b/legacy-ui-tests/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "votian-legacy-ui-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "votian-legacy-ui-tests", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "1.63.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/legacy-ui-tests/package.json b/legacy-ui-tests/package.json new file mode 100644 index 0000000..742c111 --- /dev/null +++ b/legacy-ui-tests/package.json @@ -0,0 +1,8 @@ +{ + "name": "votian-legacy-ui-tests", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { "test": "python3 run.py" }, + "devDependencies": { "@playwright/test": "1.63.0" } +} diff --git a/legacy-ui-tests/playwright.config.ts b/legacy-ui-tests/playwright.config.ts new file mode 100644 index 0000000..ae333b1 --- /dev/null +++ b/legacy-ui-tests/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +const report = (name: string) => fileURLToPath(new URL('./reports/' + name, import.meta.url)); + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + workers: 1, + retries: 0, + maxFailures: 0, + timeout: 45_000, + expect: { timeout: 10_000 }, + reporter: [ + ['list'], + ['html', { outputFolder: report('html'), open: 'never' }], + ['junit', { outputFile: report('junit.xml') }], + ['json', { outputFile: report('results.json') }], + ], + outputDir: './test-results', + use: { + baseURL: 'https://test.sb.assecutor.de', + // Chromium routing stalls document.write popups in Playwright 1.63 (#43496). + browserName: 'firefox', + headless: true, + viewport: { width: 1600, height: 1200 }, + locale: 'de-DE', + timezoneId: 'Europe/Berlin', + serviceWorkers: 'block', + screenshot: 'only-on-failure', + // A trace could contain login credentials and customer data. + trace: 'off', + video: 'off', + actionTimeout: 10_000, + navigationTimeout: 30_000, + }, +}); diff --git a/legacy-ui-tests/run-server.sh b/legacy-ui-tests/run-server.sh new file mode 100755 index 0000000..841195a --- /dev/null +++ b/legacy-ui-tests/run-server.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +test_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +test_image='mcr.microsoft.com/playwright:v1.63.0-noble' +terminal_flags=(-i) +if [[ -t 0 && -t 1 ]]; then terminal_flags+=(-t); fi +# Only this test directory is mounted. The browser accesses the PHP app via HTTPS. +exec docker run --rm --init --shm-size=512m "${terminal_flags[@]}" \ + --mount "type=bind,source=$test_dir,target=/tests" --workdir /tests \ + "$test_image" bash -c 'npm ci --ignore-scripts --no-audit --no-fund && exec python3 run.py "$@"' -- "$@" diff --git a/legacy-ui-tests/run.py b/legacy-ui-tests/run.py new file mode 100755 index 0000000..5e57294 --- /dev/null +++ b/legacy-ui-tests/run.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Prompt credentials without echo; never save them in source or reports.""" +from pathlib import Path +import getpass +import os +import subprocess +import sys + +suite = Path(__file__).resolve().parent +env = os.environ.copy() +arguments = sys.argv[1:] +if '--verify-order' in arguments: + position = arguments.index('--verify-order') + if len(arguments) < position + 3 or not arguments[position + 1].isdigit() or not arguments[position + 2].startswith('UI-TEST-'): + raise SystemExit('--verify-order benötigt AUFTRAGSNUMMER und UI-TEST-MARKER.') + env['VOTIAN_UI_VERIFY_ORDER_ID'], env['VOTIAN_UI_VERIFY_ORDER_MARKER'] = arguments[position + 1:position + 3] + del arguments[position:position + 3] +if '--create-order' in arguments: + if env.get('VOTIAN_UI_VERIFY_ORDER_ID'): + raise SystemExit('--create-order und --verify-order dürfen nicht kombiniert werden.') + arguments.remove('--create-order') + env['VOTIAN_UI_CREATE_ORDER'] = '1' + print('Speichert einen markierten, unvollständigen Testauftrag ohne Kurier.') +if not env.get('VOTIAN_UI_USERNAME'): + env['VOTIAN_UI_USERNAME'] = input('Votian-Testbenutzer: ') +if not env.get('VOTIAN_UI_PASSWORD'): + env['VOTIAN_UI_PASSWORD'] = getpass.getpass('Votian-Passwort (wird nicht gespeichert): ') +binary = suite / 'node_modules/.bin/playwright' +if not binary.exists(): + binary = suite.parent / 'node_modules/.bin/playwright' +if not binary.exists(): + raise SystemExit('Playwright fehlt. Zuerst npm ci im Projekt oder im Testverzeichnis ausführen.') +command = [str(binary), 'test', '-c', str(suite / 'playwright.config.ts'), *arguments] +try: + sys.exit(subprocess.call(command, cwd=suite, env=env)) +finally: + env.pop('VOTIAN_UI_PASSWORD', None) diff --git a/legacy-ui-tests/support/fixtures.ts b/legacy-ui-tests/support/fixtures.ts new file mode 100644 index 0000000..c52bde6 --- /dev/null +++ b/legacy-ui-tests/support/fixtures.ts @@ -0,0 +1,44 @@ +import { test as base, expect, type BrowserContext } from '@playwright/test'; +import { installGuard } from './network'; + +type State = Awaited>; +export const test = base.extend<{ guard: Awaited> }, { signedIn: State }>({ + signedIn: [async ({ browser }, use) => { + const username = process.env.VOTIAN_UI_USERNAME; + const password = process.env.VOTIAN_UI_PASSWORD; + if (!username || !password) throw new Error('Votian UI credentials required. Run python3 legacy-ui-tests/run.py.'); + const context = await browser.newContext({ locale: 'de-DE', serviceWorkers: 'block' }); + const guard = await installGuard(context, true); + try { + const page = await context.newPage(); + await page.goto('https://test.sb.assecutor.de/admin/login.php'); + const details = page.locator('details').filter({ has: page.locator('input[name="f_chk_account"]') }); + if (await details.count() && !(await details.getAttribute('open'))) await details.locator('summary').click(); + await page.locator('input[name="f_chk_account"]').fill(username); + await page.locator('input[name="f_chk_password"]').fill(password); + await page.locator('[name="f_submit"]').click(); + await expect(page).toHaveURL(/\/admin\/start\.php/); + expect(guard.errors, 'Legacy JavaScript errors').toEqual([]); + expect(guard.denied, 'Unexpected requests during login').toEqual([]); + const state = await context.storageState(); + await use(state); + } finally { + await context.close(); + } + }, { scope: 'worker' }], + storageState: async ({ signedIn }, use) => { await use(signedIn); }, + guard: [async ({ context }, use, info) => { + const guard = await installGuard(context); + await use(guard); + if (info.status !== info.expectedStatus) await info.attach('request-paths', { body: JSON.stringify([...new Set(guard.allowed)], null, 2), contentType: 'application/json' }); + if (guard.consoleErrors.length) await info.attach('console-diagnostics', { body: JSON.stringify(guard.consoleErrors, null, 2), contentType: 'application/json' }); + if (guard.knownErrors.length) await info.attach('known-opener-bug', { body: JSON.stringify(guard.knownErrors), contentType: 'application/json' }); + if (guard.errors.length) await info.attach('legacy-js-errors', { body: JSON.stringify(guard.errors), contentType: 'application/json' }); + if (guard.denied.length) { + await info.attach('blocked-request-paths', { body: JSON.stringify([...new Set(guard.denied)], null, 2), contentType: 'application/json' }); + } + expect(guard.errors, 'Legacy JavaScript errors').toEqual([]); + expect(guard.denied, 'Unexpected request (blocked before it reached the server)').toEqual([]); + }, { auto: true }], +}); +export { expect } from '@playwright/test'; diff --git a/legacy-ui-tests/support/network.ts b/legacy-ui-tests/support/network.ts new file mode 100644 index 0000000..b11f0e1 --- /dev/null +++ b/legacy-ui-tests/support/network.ts @@ -0,0 +1,144 @@ +import type { BrowserContext } from '@playwright/test'; +import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const ledgerDirectory = fileURLToPath(new URL('../reports/', import.meta.url)); +export function recordOrder(event: Record) { + mkdirSync(ledgerDirectory, { recursive: true }); + appendFileSync(ledgerDirectory + 'created-orders.jsonl', JSON.stringify({ time: new Date().toISOString(), ...event }) + '\n', { mode: 0o600 }); +} + +// The old form uses ISO-8859-1, including superscript station delimiters. +function formData(body: string) { + const utf8 = new URLSearchParams(body); + if (![...utf8.values()].some(value => value.includes('\uFFFD'))) return utf8; + const decode = (value: string) => new TextDecoder('windows-1252').decode(Buffer.from(value.replace(/\+/g, ' ').replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))), 'latin1')); + return new URLSearchParams(body.split('&').map(pair => { const index = pair.indexOf('='); return [decode(pair.slice(0, index)), decode(pair.slice(index + 1))]; })); +} + +function validateCreation(data: URLSearchParams, marker: string): string[] { + const problems: string[] = []; + const required = { toursubmit: 'Senden', getPriceOnly: '', jb_id: '', csc_id_payer: '878914', jb_orderer_hidden: marker, jb_status_manual: '1', jb_incomplete: '1', jb_multi_factor: '1', sendmail: '0', jb_fixprice: '10,00' }; + for (const [key, expected] of Object.entries(required)) { + if ((data.get(key) ?? '') !== expected) problems.push(key); + } + for (const key of ['cr_id_order', 'cr_id_order_hidden', 'cr_id_orders', 'jb_permanent', 'jb_multi', 'jb_globaljob', 'afterwards', 'jb_offer', 'jb_insurance', 'jb_id_parent', 'jb_id_org', 'jb_tourname', 'jb_tourname_hidden', 'tmp_add_jb_ids', 'tmp_add_sids', 'do_delete_suborder', 'jb_offer_mail_hidden', 'jb_cashpayer_mail_hidden']) { + if (!['', '0'].includes(data.get(key) ?? '')) problems.push(key); + } + if (!data.get('request_token')) problems.push('request_token missing'); + const stations = (data.get('jobList') ?? '').split('¹').map(row => row.split('²')).filter(row => [0, 1, 3, 4, 6, 7, 8].some(index => row[index])); + if (stations.length !== 2 || stations[0][0] !== '878914' || stations[1][0] !== '878915' || !stations.every(row => row.some(cell => cell.includes(marker)))) problems.push('exactly two marked test stations'); + return problems; +} + +const origin = 'https://test.sb.assecutor.de'; +const pages = new Set([ + '/', '/index.php', '/admin/login.php', '/admin/start.php', + '/jobs/job_edit.php', '/jobs/job_header.php', '/jobs/job_tour.php', + '/jobs/job_options.php', '/jobs/job_tour_list.php', + '/admin/cs_csc_list.php', + '/css/phoenix.css.php', '/css/navigation.css.php', + '/include/maps.googleapis.com_maps_api_js_places.php', +]); + +export async function installGuard(context: BrowserContext, login = false) { + const denied: string[] = []; + const allowed: string[] = []; + const errors: string[] = []; + const knownErrors: string[] = []; + const suppressed: string[] = []; + const consoleErrors: string[] = []; + let creationMarker: string | undefined; + let saveAttempts = 0; + const readableOrders = new Set(); + context.on('page', page => { + page.on('console', message => { if (['error', 'warning'].includes(message.type())) consoleErrors.push(message.text()); }); + page.on('pageerror', error => { + // Still fails the dedicated regression test; do not mask unrelated exceptions. + if (["Cannot read properties of null (reading 'top')", 'can\'t access property "top", top.opener is null'].includes(error.message) && /\/jobs\/job_edit\.php[^\n]*:13:\d+/.test(error.stack ?? '')) knownErrors.push(error.stack!); + else errors.push(error.stack ?? error.message); + }); + }); + await context.route('**/*', async route => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + const description = `${method} ${url.origin}${url.pathname} [${[...url.searchParams.keys()].join(',')}]`; + if (url.origin === origin && url.pathname === '/include/ajaxReqLib.php' && + url.searchParams.get('mode') === '500' && url.searchParams.get('value_03') === 'usr_state_newsticker') { + suppressed.push('News ticker acknowledgement'); + return route.fulfill({ status: 200, contentType: 'application/javascript', body: "retValue = '1';" }); + } + if (url.pathname === '/include/ajaxJsError.php' && url.origin === origin) { + errors.push(url.searchParams.get('message') ?? 'Legacy JavaScript error'); + return route.fulfill({ status: 204 }); + } + if (url.hostname === 'maps.googleapis.com' && url.pathname.endsWith('/gen_204')) { + suppressed.push('Google Maps telemetry'); + return route.fulfill({ status: 204 }); + } + let ok = false; + if (url.origin === origin) { + if (method === 'GET') { + ok = /\.(?:js|css|png|gif|jpe?g|svg|ico|woff2?|ttf)$/i.test(url.pathname) || pages.has(url.pathname); + // Never permit action parameters on otherwise readable pages. + for (const key of ['f_act', 'action', 'save', 'delete', 'sendmail', 'toursubmit']) { + if (url.searchParams.get(key) && !(url.pathname === '/admin/cs_csc_list.php' && key === 'f_act' && url.searchParams.get(key) === 'search')) ok = false; + } + if (url.pathname === '/include/ajaxReqJob.php') { + ok = [...url.searchParams.keys()].every(key => ['cs_id', 'csc_id', 'cs_id_photo', 'csc_id_ask_mail', 'cs_id_ask_mail', 'hq_id', 'vht_id', 'csc_id_payer_cash', '_'].includes(key)) && ['cs_id', 'csc_id', 'cs_id_photo', 'csc_id_ask_mail'].some(key => url.searchParams.has(key)); + } + if (url.pathname === '/include/ajaxReqDocument.php') ok = url.searchParams.get('mode') === '1'; + if (url.pathname === '/jobs/get_tournames.php') ok = [...url.searchParams.keys()].every(key => key === 'csc_id') && /^\d+$/.test(url.searchParams.get('csc_id') ?? ''); + if (url.pathname === '/admin/jb_detail.php') ok = [...url.searchParams.keys()].every(key => key === 'job_id') && readableOrders.has(url.searchParams.get('job_id') ?? ''); + if (url.pathname === '/include/ajaxReqGeneric.php') ok = [...url.searchParams.keys()].every(key => ['incFile', 'fun', 'par_01', 'retIdx', 'retParName', '_'].includes(key)) && url.searchParams.get('incFile') === 'inc_job.inc' && url.searchParams.get('fun') === 'getJobInternalRemarkItems' && readableOrders.has(url.searchParams.get('par_01') ?? '') && url.searchParams.get('retIdx') === '0' && url.searchParams.get('retParName') === '_RAW_TEXT_'; + if (url.pathname === '/include/ajaxReqLib.php') { + ok = ['2', '6'].includes(url.searchParams.get('mode') ?? ''); + if (!ok) denied.push('Library mode=' + url.searchParams.get('mode') + ' field=' + url.searchParams.get('value_03')); + } + } else if (method === 'POST' && url.pathname === '/jobs/job_options.php') { + const data = formData(request.postData() ?? ''); + ok = data.get('getPriceOnly') === '1' && data.get('sendmail') !== '1'; + if (!ok && creationMarker && saveAttempts === 0) { + const problems = validateCreation(data, creationMarker); + if (problems.length) denied.push('Order creation rejected: ' + problems.join(', ')); + else { + saveAttempts++; + recordOrder({ state: 'submit-attempt', marker: creationMarker }); + ok = true; + } + } + } else if (method === 'POST' && url.pathname === '/admin/cs_csc_list.php') { + const data = new URLSearchParams(request.postData() ?? ''); + ok = data.get('f_act') === 'search'; + } else if (login && method === 'POST' && url.pathname === '/admin/login.php') { + const data = new URLSearchParams(request.postData() ?? ''); + ok = data.get('f_act') === '' && data.has('f_chk_account') && data.has('f_chk_password'); + } + } else if (method === 'GET' && ['maps.googleapis.com', 'maps.gstatic.com', 'fonts.googleapis.com', 'fonts.gstatic.com'].includes(url.hostname)) { + ok = ['script', 'stylesheet', 'font', 'image'].includes(request.resourceType()); + } + if (ok) { + allowed.push(description); + await route.continue(); + } else { + denied.push(description); + await route.abort('blockedbyclient'); + } + }); + return { + denied, allowed, errors, knownErrors, suppressed, consoleErrors, + armCreation(marker: string) { + if (process.env.VOTIAN_UI_CREATE_ORDER !== '1' || !/^UI-TEST-\d{8}T\d{6}-[a-f0-9]{6}$/.test(marker) || creationMarker) throw new Error('Creation is not enabled or already armed'); + const ledger = ledgerDirectory + 'created-orders.jsonl'; + if (existsSync(ledger)) { + const events = readFileSync(ledger, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); + const unresolved = events.find(event => event.state === 'submit-attempt' && !events.some(later => later.marker === event.marker && later.state === 'server-confirmed')); + if (unresolved) throw new Error('Unconfirmed previous save for ' + unresolved.marker + '. Inspect the server before another creation; see reports/created-orders.jsonl.'); + } + creationMarker = marker; + }, + allowOrderRead(id: string) { if (!/^\d+$/.test(id)) throw new Error('Invalid order ID'); readableOrders.add(id); }, + get saveAttempts() { return saveAttempts; }, + }; +} diff --git a/legacy-ui-tests/support/order-page.ts b/legacy-ui-tests/support/order-page.ts new file mode 100644 index 0000000..33bd949 --- /dev/null +++ b/legacy-ui-tests/support/order-page.ts @@ -0,0 +1,70 @@ +import { expect, type Page } from '@playwright/test'; + +export async function openOrder(start: Page): Promise { + await start.goto('/admin/start.php'); + await start.locator('#auftraege a').click(); + const popup = start.waitForEvent('popup'); + await start.locator('#erfassung a').click(); + const order = await popup; + await order.waitForLoadState('load'); + await expect(order).toHaveTitle('AUFTRAGSERFASSUNG'); + await expect(order.frameLocator('frame[name="job_tour"]').locator('[name="von_cs_comp"]')).toBeVisible(); + await expect(order.frameLocator('frame[name="job_options"]').locator('[name="toursubmit"]')).toBeVisible(); + await expect(order.frameLocator('frame[name="job_tour"]').locator('[name="nameOfPayer"]')).toHaveValue(/Bezahler wählen/); + await expect(order.frameLocator('frame[name="job_tour_list"]').getByText('Tourendaten (Etappen)', { exact: true })).toBeVisible(); + await expect.poll(() => order.frame({ name: 'job_options' })!.evaluate(() => (window as any).isCurrentlySubmitting)).toBe(false); + return order; +} + +export function fields(order: Page) { + return { + tour: order.frameLocator('frame[name="job_tour"]'), + options: order.frameLocator('frame[name="job_options"]'), + list: order.frameLocator('frame[name="job_tour_list"]'), + }; +} + +/** Select an existing, explicitly named test cost centre through the real customer picker. */ +export async function selectTestCustomer(order: Page, button: string, name: 'Testkunde Assecutor 1' | 'Testkunde Assecutor 2') { + const popup = order.waitForEvent('popup', { predicate: candidate => candidate.url().includes('/admin/cs_csc_list.php') }); + await fields(order).tour.locator(`[name="${button}"]`).click(); + const search = await popup; + await search.locator('[name="f_cmp_comp"]').fill(name); + await Promise.all([ + search.waitForNavigation({waitUntil:'domcontentloaded'}), + search.getByRole('link', {name:'Suchen',exact:true}).first().click(), + ]); + const match = search.locator('a[href^="javascript:finishPage("]'); + await expect(match).toHaveCount(1); + const href = await match.getAttribute('href'); + expect(href).toContain(name); + const expectedCentre = name.endsWith('1') ? '878914' : '878915'; + expect(href).toMatch(new RegExp(`finishPage\\('\\d+','${expectedCentre}'`)); + await Promise.all([search.waitForEvent('close'), match.click()]); + await expect.poll(() => order.frame({ name: 'job_options' })!.evaluate(() => (window as any).isCurrentlySubmitting)).toBe(false); +} + +export async function fillTestOrder(order: Page, marker: string) { + await selectTestCustomer(order, 'vonFirmaButton', 'Testkunde Assecutor 1'); + await selectTestCustomer(order, 'nachFirmaButton', 'Testkunde Assecutor 2'); + await selectTestCustomer(order, 'bezahlerFirmaButton', 'Testkunde Assecutor 1'); + const {tour,options}=fields(order); + await expect(tour.locator('[name="nameOfPayer"]')).toHaveValue('Testkunde Assecutor 1'); + await tour.locator('[name="jb_orderer"]').fill(marker); + await tour.locator('[name="von_tr_remark"]').fill(marker + ' - automatischer Test, NICHT AUSFÜHREN'); + await tour.locator('[name="nach_tr_remark"]').fill(marker + ' - keine echte Beförderung'); + await options.locator('[name="jb_fixprice"]').fill('10,00'); + await options.locator('[name="jb_fixprice"]').press('Tab'); + await options.locator('[name="jb_markup"]').fill('0'); + await options.locator('[name="jb_markup"]').press('Tab'); + await options.locator('[name="jb_status_manual"]').check(); + await options.locator('[name="jb_incomplete"]').check(); + await expect(options.locator('[name="cr_id_order"]')).toHaveValue(''); + await expect(options.locator('[name="jb_globaljob"]')).not.toBeChecked(); + await expect(options.locator('[name="jb_insurance"]')).not.toBeChecked(); + await expect(options.locator('[name="jb_offer"]')).not.toBeChecked(); + await expect(options.locator('[name="afterwards"]')).not.toBeChecked(); + await expect(options.locator('[name="jb_multi_factor"]')).toHaveValue('1'); + await expect(options.locator('[name="toursubmit"]')).toBeEnabled(); + await expect.poll(() => order.frame({ name: 'job_options' })!.evaluate(() => ({ busy: (window as any).isCurrentlySubmitting, alert: (window as any).alertIsOpen })), { timeout: 35_000, message: 'Preisberechnung ist vor dem Absenden abgeschlossen' }).toEqual({ busy: false, alert: false }); +} diff --git a/legacy-ui-tests/tests/order-entry.spec.ts b/legacy-ui-tests/tests/order-entry.spec.ts new file mode 100644 index 0000000..c1bc9c6 --- /dev/null +++ b/legacy-ui-tests/tests/order-entry.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from '../support/fixtures'; +import { openOrder, fields, selectTestCustomer, fillTestOrder } from '../support/order-page'; + +test('Auftragserfassung startet ohne JavaScript-Ausnahme', async ({ page, guard }) => { + await openOrder(page); + expect(guard.knownErrors, 'job_edit.php greift trotz fehlendem window.opener auf top zu').toEqual([]); +}); + +test('Auftragserfassung öffnet vier Frames und verhindert leeres Absenden', async ({ page }) => { + const order = await openOrder(page); + expect(order.frames().map(f => f.name())).toEqual(expect.arrayContaining(['job_header', 'job_tour', 'job_options', 'job_tour_list'])); + const { tour, options } = fields(order); + await expect(tour.locator('[name="nameOfPayer"]')).toHaveValue(/Bezahler wählen/); + await expect(options.locator('[name="toursubmit"]')).toBeDisabled(); + await expect(options.locator('[name="jb_permanent"]')).toBeDisabled(); +}); + + +test('Bezahlerauswahl übernimmt die explizite Testkostenstelle', async ({ page }) => { + const order = await openOrder(page); + await selectTestCustomer(order, 'bezahlerFirmaButton', 'Testkunde Assecutor 1'); + await expect(fields(order).tour.locator('[name="nameOfPayer"]')).toHaveValue('Testkunde Assecutor 1'); +}); + +test('Auftragseingang Sonstiges blendet Zusatzfeld ein und räumt es wieder auf', async ({ page }) => { + const { tour } = fields(await openOrder(page)); + const detail = tour.locator('[name="jb_origin_other"]'); + await expect(detail).toBeHidden(); + await tour.locator('[title="Auftragseingang sonstiges"]').check(); + await expect(detail).toBeVisible(); + await detail.fill('UI-TEST ohne Speicherung'); + await tour.locator('[title="Auftragseingang per Telefon"]').check(); + await expect(detail).toBeHidden(); + await expect(detail).toHaveValue(''); +}); + +test('Angebot erzwingt manuelle Disposition und unvollständigen Status', async ({ page }) => { + const { options } = fields(await openOrder(page)); + await options.locator('[name="jb_offer"]').check(); + for (const name of ['jb_status_manual', 'jb_incomplete']) { + await expect(options.locator(`[name="${name}"]`)).toBeChecked(); + await expect(options.locator(`[name="${name}"]`)).toBeDisabled(); + } + for (const name of ['afterwards', 'jb_insurance', 'jb_globaljob']) { + await expect(options.locator(`[name="${name}"]`)).not.toBeChecked(); + await expect(options.locator(`[name="${name}"]`)).toBeDisabled(); + } + await options.locator('[name="jb_offer"]').uncheck(); + for (const name of ['afterwards', 'jb_insurance', 'jb_status_manual', 'jb_incomplete']) { + await expect(options.locator(`[name="${name}"]`)).toBeEnabled(); + } +}); + +test('Höherversicherung zeigt Warenwert und Tarif und setzt sie beim Abschalten zurück', async ({ page }) => { + const { options } = fields(await openOrder(page)); + const value = options.locator('[name="jb_value_of_goods"]'); + await expect(value).toBeHidden(); + await options.locator('[name="jb_insurance"]').check(); + await expect(value).toBeVisible(); + await value.fill('1234,56'); + await options.locator('[name="jb_insurance_rate"]').selectOption('1'); + await options.locator('[name="special_case"]').check(); + await options.locator('[name="jb_insurance"]').uncheck(); + await expect(value).toBeHidden(); + await expect(value).toHaveValue(''); + await expect(options.locator('[name="jb_insurance_rate"]')).toHaveValue('0'); + await expect(options.locator('[name="special_case"]')).not.toBeChecked(); +}); + +for (const [input, message] of [['abc', /Warenwert bitte/], ['500001', /500\.000/], ['12,345', /Warenwert bitte/]] as const) { + test(`Höherversicherung weist ungültigen Warenwert ${input} zurück`, async ({ page }) => { + const order = await openOrder(page); + const { options } = fields(order); + await options.locator('[name="jb_insurance"]').check(); + const value = options.locator('[name="jb_value_of_goods"]'); + await value.fill(input); + const alert = order.waitForEvent('dialog'); + const blur = value.press('Tab'); + const dialog = await alert; + expect(dialog.type()).toBe('alert'); + expect(dialog.message()).toMatch(message); + await dialog.accept(); + await blur; + await expect(value).toHaveValue(''); + await expect(options.locator('[name="toursubmit"]')).toBeDisabled(); + }); +} + +test('Stationswechsel vertauscht Firmen, Ansprechpartner und Hinweise', async ({ page }) => { + const { tour } = fields(await openOrder(page)); + for (const [name, value] of Object.entries({von_cs_comp:'UI-TEST Start', nach_cs_comp:'UI-TEST Ziel', von_tr_person:'Kontakt A', nach_tr_person:'Kontakt B', von_tr_remark:'Hinweis A', nach_tr_remark:'Hinweis B'})) { + await tour.locator(`[name="${name}"]`).fill(value); + } + await tour.locator('[name="switchButton"]').click(); + for (const [name, value] of Object.entries({von_cs_comp:'UI-TEST Ziel', nach_cs_comp:'UI-TEST Start', von_tr_person:'Kontakt B', nach_tr_person:'Kontakt A', von_tr_remark:'Hinweis B', nach_tr_remark:'Hinweis A'})) { + await expect(tour.locator(`[name="${name}"]`)).toHaveValue(value); + } +}); + +for (const [side, name] of [['von', 'waste1Button'], ['nach', 'waste2Button']] as const) { + test(`Löschen der ${side}-Station erhält die verbleibende Route`, async ({ page }) => { + const order = await openOrder(page); + const { tour } = fields(order); + await tour.locator('[name="von_cs_comp"]').fill('UI-TEST Start'); + await tour.locator('[name="nach_cs_comp"]').fill('UI-TEST Ziel'); + await tour.locator(`[name="${name}"]`).click(); + await expect(tour.locator('[name="von_cs_comp"]')).toHaveValue(side === 'von' ? 'UI-TEST Ziel' : 'UI-TEST Start'); + await expect(tour.locator('[name="nach_cs_comp"]')).toHaveValue(''); + }); +} + +test('Manuelle Disposition und unvollständig sind ohne Speichern auswählbar', async ({ page }) => { + const { options } = fields(await openOrder(page)); + await options.locator('[name="jb_status_manual"]').check(); + await options.locator('[name="jb_incomplete"]').check(); + await expect(options.locator('[name="jb_status_manual"]')).toBeChecked(); + await expect(options.locator('[name="jb_incomplete"]')).toBeChecked(); + await expect(options.locator('[name="toursubmit"]')).toBeDisabled(); +}); + + +test('Vollständiger Testauftrag wird absendebereit, aber noch nicht gespeichert', async ({ page }, info) => { + const order = await openOrder(page); + await fillTestOrder(order, 'UI-TEST-VORSCHAU'); + const {options,tour}=fields(order); + await expect(tour.locator('[name="von_cs_comp"]')).toHaveValue('Testkunde Assecutor 1'); + await expect(tour.locator('[name="nach_cs_comp"]')).toHaveValue('Testkunde Assecutor 2'); + await expect(options.locator('[name="jb_fixprice"]')).toHaveValue('10,00'); + await expect(options.locator('[name="csc_id_payer"]')).toHaveValue('878914'); + await expect(options.locator('[name="sendmail"]')).toHaveValue('0'); + await order.screenshot({path:info.outputPath('testauftrag-vorbereitet.png'),fullPage:true}); +}); diff --git a/legacy-ui-tests/tests/order-save.spec.ts b/legacy-ui-tests/tests/order-save.spec.ts new file mode 100644 index 0000000..f068872 --- /dev/null +++ b/legacy-ui-tests/tests/order-save.spec.ts @@ -0,0 +1,55 @@ +import { randomBytes } from 'node:crypto'; +import { test, expect } from '../support/fixtures'; +import { openOrder, fields, fillTestOrder } from '../support/order-page'; +import { recordOrder } from '../support/network'; + +test('Testauftrag speichern und in frischer Detailansicht wieder öffnen', async ({ page, context, guard }, info) => { + test.skip(process.env.VOTIAN_UI_CREATE_ORDER !== '1' && !process.env.VOTIAN_UI_VERIFY_ORDER_ID, 'Speichern nur mit --create-order; vorhandenen Testauftrag mit VERIFY_ORDER_ID prüfen.'); + test.setTimeout(90_000); + let id = process.env.VOTIAN_UI_VERIFY_ORDER_ID; + let marker = process.env.VOTIAN_UI_VERIFY_ORDER_MARKER; + if (!id) { + if (info.retry || info.repeatEachIndex) throw new Error('A save test must never be automatically retried or repeated.'); + marker = 'UI-TEST-' + new Date().toISOString().replace(/[-:]/g, '').slice(0, 15) + '-' + randomBytes(3).toString('hex'); + const order = await openOrder(page); + await fillTestOrder(order, marker); + expect(guard.denied, 'All lookups must finish before enabling a save').toEqual([]); + guard.armCreation(marker); + const unexpectedDialogs: string[] = []; + let savedId: string | undefined; + order.on('dialog', async dialog => { + const match = dialog.message().match(/^Der Auftrag wurde gespeichert!\s*Auftragsnummer:\s*(\d+)\s*$/); + if (dialog.type() === 'alert' && match) { + savedId = match[1]; + recordOrder({ state: 'server-confirmed', marker, id: savedId }); + await dialog.accept(); + } else { + unexpectedDialogs.push(dialog.type() + ': ' + dialog.message()); + await dialog.dismiss(); + } + }); + await fields(order).options.locator('[name="toursubmit"]').click(); + await expect.poll(() => Boolean(savedId || unexpectedDialogs.length), { timeout: 30_000, message: 'Server bestätigt gespeicherten Auftrag mit Nummer' }).toBe(true); + expect(unexpectedDialogs).toEqual([]); + expect(guard.denied).toEqual([]); + expect(savedId).toMatch(/^\d+$/); + expect(guard.saveAttempts).toBe(1); + id = savedId; + } + expect(marker, 'Marker des gespeicherten Testauftrags').toMatch(/^UI-TEST-/); + guard.allowOrderRead(id!); + const detail = await context.newPage(); + await detail.goto('/admin/jb_detail.php?job_id=' + id); + await expect(detail.locator('body')).toContainText(marker!); + await expect(detail.locator('body')).toContainText('Testkunde Assecutor 1'); + await expect(detail.locator('body')).toContainText('Testkunde Assecutor 2'); + await expect(detail.locator('body')).toContainText('NICHT AUSFÜHREN'); + await expect(detail.locator('body')).toContainText('keine echte Beförderung'); + await expect(detail.locator('tr:has(> td:text-is("Fixpreis"))')).toContainText('10,00'); + await expect(detail.locator('tr:has(> td:text-is("Firma")) > td')).toHaveText(['Firma', 'Testkunde Assecutor 1', 'Testkunde Assecutor 2']); + await expect(detail.getByText('UNFERTIG!', { exact: true })).toBeVisible(); + await expect(detail.getByRole('link', { name: 'Kurierhistorie', exact: true })).toHaveCount(0); + await info.attach('saved-test-order', { body: JSON.stringify({ id, marker, url: detail.url() }, null, 2), contentType: 'application/json' }); + await detail.screenshot({ path: info.outputPath('testauftrag-gespeichert.png'), fullPage: true }); + recordOrder({ state: 'ui-verified', marker, id }); +}); diff --git a/package.json b/package.json index 24c1bfb..c138b20 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "votian-ng", - "version": "1.0.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { "dev": "./dev.sh", "build": "./build.sh", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:legacy-ui": "python3 legacy-ui-tests/run.py" }, "devDependencies": { "@playwright/test": "1.63.0"