Portiere PHP-Cronjobs auf Spring-Tasks und ergänze Legacy-Tests

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.
This commit is contained in:
Sven committed 2026-10-27 09:53:23 +01:00
1 parent 781a2b82dc
commit 9de2358480
57 files changed
+3251 -7

No files matched your search

+37
View File
@@ -1,4 +1,41 @@
# Als .env im Projektstamm speichern und die vorhandenen Datenbankwerte eintragen. # 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. # Prozessumgebungsvariablen haben Vorrang vor dieser Datei.
# Für docker run --env-file .env: Werte ohne umschließende Anführungszeichen. # 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. # $, #, Backslashes und = in Werten bleiben wörtlich erhalten. Nicht per Shell source laden.
+4 -1
View File
@@ -4,7 +4,10 @@ WORKDIR /app
ARG JAR_FILE=backend/target/*.jar ARG JAR_FILE=backend/target/*.jar
COPY --chown=10001:10001 ${JAR_FILE} app.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 USER 10001:10001
EXPOSE 8080 EXPOSE 8080
+9 -1
View File
@@ -116,6 +116,10 @@ Die neue Anwendung ist standardmäßig nur lokal erreichbar. Für einen Serverbe
## Prüfungen ## 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 ```bash
python3 -m unittest discover -s tests -p 'test_*.py' -v python3 -m unittest discover -s tests -p 'test_*.py' -v
cd backend && ./mvnw test 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. 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).
+2 -1
View File
@@ -2,9 +2,10 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.16</version><relativePath/></parent> <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.16</version><relativePath/></parent>
<groupId>de.stadtbote</groupId><artifactId>votian-backend</artifactId><version>0.1.2</version> <groupId>de.stadtbote</groupId><artifactId>votian-backend</artifactId><version>0.2.0</version>
<name>Votian NG Stadtbote</name><properties><java.version>21</java.version></properties> <name>Votian NG Stadtbote</name><properties><java.version>21</java.version></properties>
<dependencies> <dependencies>
<dependency><groupId>org.apache.commons</groupId><artifactId>commons-compress</artifactId><version>1.28.0</version></dependency>
<dependency><groupId>commons-net</groupId><artifactId>commons-net</artifactId><version>3.13.0</version></dependency> <dependency><groupId>commons-net</groupId><artifactId>commons-net</artifactId><version>3.13.0</version></dependency>
<dependency><groupId>com.github.mwiede</groupId><artifactId>jsch</artifactId><version>2.28.5</version></dependency> <dependency><groupId>com.github.mwiede</groupId><artifactId>jsch</artifactId><version>2.28.5</version></dependency>
<dependency><groupId>com.google.zxing</groupId><artifactId>core</artifactId><version>3.5.4</version></dependency> <dependency><groupId>com.google.zxing</groupId><artifactId>core</artifactId><version>3.5.4</version></dependency>
@@ -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<byte[]> 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_fieldname<? ORDER BY gdc_id LIMIT 10001",from.toString(),to.toString());
if(rows.size()>10000)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<Map<String,String>>(){});
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<String,Object> 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;
}
}
@@ -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("<h2>HHA :: Vermeintlich vergessene Safebags</h2><table><tr><th>Verkaufsstelle</th><th>Safebag</th><th>Datum</th><th>Betrag</th><th>Mitarbeiter</th></tr>");
for(var r:rows){body.append("<tr>");for(String k:List.of("csc_name","atih_serialno","atih_data_04","atih_data_05"))body.append("<td>").append(LegacyCronCompliance.html(text(r,k))).append("</td>");body.append("<td>").append(LegacyCronCompliance.html(text(r,"usr_name")+", "+text(r,"usr_firstname"))).append("</td></tr>");}body.append("</table>");
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<String,String>();
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<String,String> prior=Map.of();
if(!previous.isEmpty())try{prior=support.json.readValue(text(previous.getFirst(),"gdc_content"),new com.fasterxml.jackson.core.type.TypeReference<Map<String,String>>(){});}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<?",at.minus(Duration.ofDays(62)).toString());return null;});
return LegacyCronRunner.Result.ok(metrics.size());
}
static String ratio(long value,long period,double factor){return period<=0||value<0?"":String.format(Locale.ROOT,"%.1f",value*factor/period);}
static long metric(Map<String,String> values,String key){try{return Long.parseLong(values.getOrDefault(key,"0"));}catch(NumberFormatException e){throw bad("Ungültiger MySQL-Statuszähler.");}}
static Map<String,String> rates(Map<String,String> current,Map<String,String> previous){
long uptime=metric(current,"Uptime"),elapsed=previous.isEmpty()?0:uptime-metric(previous,"Uptime");
var result=new LinkedHashMap<String,String>();
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;
}
}
@@ -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<Rule> 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<String>();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<Map<String,Object>> 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<Long>();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<Change>();var body=new StringBuilder("<h2>"+html(rule.label())+"</h2><table><tr><th>Niederlassung</th><th>Nr.</th><th>Name / Firma</th><th>Zusatz</th><th>Datum</th></tr>");
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("<tr style=\"background-color:#").append(color).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("<td>").append(html(value)).append("</td>");
body.append("</tr>");
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("</table>");
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);
}
}
@@ -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<Branch> 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);}
}
}
@@ -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<Path> paths(String value) throws IOException {
var result=new ArrayList<Path>();
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<Path>();all.addAll(bin);all.addAll(logs);all.addAll(purge);
for(int i=0;i<all.size();i++)for(int j=i+1;j<all.size();j++)if(all.get(i).startsWith(all.get(j))||all.get(j).startsWith(all.get(i)))throw bad("Cron-Dateipfade dürfen sich nicht überlappen.");
int count=0;
for(Path directory:bin){List<Path> 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()<newest){compress(p);count++;}
}
String month="_"+today.minusMonths(1).format(DateTimeFormatter.ofPattern("yyyyMM"));
for(Path directory:logs){List<Path> 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);}
}
}
@@ -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<String,Group> 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<String,List<String>> targets(){
try{
var groups=support.json.readValue(env.getProperty("app.legacy-cron.replication.targets","{}"),new com.fasterxml.jackson.core.type.TypeReference<Map<String,List<String>>>(){});
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<String>();int count=0;
for(var entry:new TreeMap<>(targets).entrySet()){
var group=GROUPS.get(entry.getKey());Long primary=null;
for(int i=0;i<entry.getValue().size();i++){
try{long value=db.cronReplicaCount(entry.getValue().get(i),user,password,group.metric(),day);count++;
if(i==0)primary=value;else if(primary!=null&&primary-value>group.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+"]","<p>"+LegacyCronCompliance.html(text)+"</p>");}
catch(RuntimeException e){return new LegacyCronRunner.Result("UNCERTAIN","Replikationsabweichung erkannt; Alarmübergabe nicht bestätigt.",count);}
return new LegacyCronRunner.Result("ERROR",text,count);
}
}
@@ -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<Map<String,Object>> 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<Result> 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;
});
}
}
@@ -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<byte[]> limitedBody(){
return new HttpResponse.BodySubscriber<>(){
final HttpResponse.BodySubscriber<byte[]> delegate=HttpResponse.BodySubscribers.ofByteArray();
java.util.concurrent.Flow.Subscription subscription;long bytes;boolean failed;
public CompletionStage<byte[]> getBody(){return delegate.getBody();}
public void onSubscribe(java.util.concurrent.Flow.Subscription value){subscription=value;delegate.onSubscribe(value);}
public void onNext(List<ByteBuffer> 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);}
}
}
@@ -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<String> 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<Notice> notices,Map<Integer,BigDecimal> money,BigDecimal cards) {}
record Payload(List<Device> devices,List<Message> messages,List<Status> 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("<soap:Envelope xmlns:soap=\""+SOAP+"\" xmlns:urn=\""+SAP+"\"><soap:Header/><soap:Body><urn:"+operation(mode)+"><ItDevicetype>");
for(String type:TYPES)xml.append("<item><OrgunhdIdPrK>HHA</OrgunhdIdPrK><DevtyhdIdK>").append(type.replace("&","&amp;")).append("</DevtyhdIdK></item>");
return xml.append("</ItDevicetype>").append(mode==0?"<IvActualFg>X</IvActualFg>":"").append("<IvTimestamp>").append(since).append("</IvTimestamp></urn:").append(operation(mode)).append("></soap:Body></soap:Envelope>").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<Element> children(Element parent,String name){
var result=new ArrayList<Element>();
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<Element> 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<Device>();var messages=new ArrayList<Message>();var statuses=new ArrayList<Status>();
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<Notice>();
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<Integer,BigDecimal>();
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.");}
}
}
@@ -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<String,Object> optional(List<Map<String,Object>> rows,String what){if(rows.size()>1)throw bad("Mehrdeutige PTnova-Zuordnung: "+what);return rows.isEmpty()?null:rows.getFirst();}
Map<String,Object> article(String type){return optional(db.queryForList("SELECT at_id FROM article WHERE at_match=? AND hq_id IN (0,?)",type,hq),"Artikeltyp");}
Map<String,Object> 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<String,Object> 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<String>();
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<String,Object>();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<String,Object>();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<Object>(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;
}
}
@@ -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<Instant,LegacyCronRunner.Result> action) {}
final LegacyCronRunner runner;final Environment env;final List<Task> tasks=new ArrayList<>();
final ThreadPoolTaskScheduler scheduler=new ThreadPoolTaskScheduler();
final Map<String,String> schedulingErrors=new java.util.concurrent.ConcurrentHashMap<>();
final Set<String> 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<Instant,LegacyCronRunner.Result> 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<Instant>();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<Map<String,Object>> view(){
var rows=new ArrayList<Map<String,Object>>();var now=ZonedDateTime.now(ZONE);
Map<String,List<Map<String,Object>>> 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<Map<String,Object>> unresolved(){return List.of();}
}
@@ -15,6 +15,8 @@ import static de.stadtbote.votian.OrderEntryService.fields;
@RestController @RestController
@RequestMapping("/api/administration/operations") @RequestMapping("/api/administration/operations")
class OperationsMonitoring { class OperationsMonitoring {
@org.springframework.beans.factory.annotation.Autowired(required=false)
LegacyCronTasks cron;
final DatabaseService db; final DatabaseService db;
final BusinessSupport support; final BusinessSupport support;
final OrderCompletionProcessing completion; 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."); if(!allowed(u))throw new AccessDeniedException("Der Betriebsstatus benötigt die Mitarbeiterverwaltung in der ursprünglichen Niederlassung.");
var now=clock.instant(); var now=clock.instant();
return fields("at",now.toString(),"startedAt",started.toString(),"headquartersId",u.headquartersId(), 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()))); "database",read(()->database(u.headquartersId())),"history",read(()->history(u.headquartersId())));
} }
Map<String,Object> read(Supplier<Map<String,Object>> source) { Map<String,Object> read(Supplier<Map<String,Object>> source) {
@@ -319,6 +319,56 @@ public class DatabaseService {
} }
} }
/** A connection-scoped mutex for one native cron task, shared by all NG instances. */
public <T> T cronOperation(String task, Supplier<T> work) {
if(!task.matches("[a-z0-9-]{1,60}")) throw new IllegalArgumentException("Invalid cron task");
return jdbc.execute((org.springframework.jdbc.core.ConnectionCallback<T>) 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> T cronSoapWriteTransaction(String historySchema,Supplier<T> 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<Map<String,Object>> 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<?";
case "ORDERS" -> "SELECT COUNT(*) FROM job WHERE COALESCE(jb_storno,0)=0 AND jb_ordertime>=? AND jb_ordertime<?";
case "ARTICLES" -> "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<?";
default -> 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. */ /** Coordinate NG file operations across processes without holding table locks during SFTP. */
public <T> T documentOperation(java.util.function.Supplier<T> work) { public <T> T documentOperation(java.util.function.Supplier<T> work) {
return jdbc.execute((org.springframework.jdbc.core.ConnectionCallback<T>) connection -> { return jdbc.execute((org.springframework.jdbc.core.ConnectionCallback<T>) connection -> {
@@ -41,6 +41,35 @@ spring:
serialization: serialization:
write-dates-as-timestamps: false write-dates-as-timestamps: false
app: 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: completion:
enabled: ${ORDER_COMPLETION_WORKER_ENABLED:false} enabled: ${ORDER_COMPLETION_WORKER_ENABLED:false}
php-stopped: ${ORDER_COMPLETION_PHP_STOPPED:false} php-stopped: ${ORDER_COMPLETION_PHP_STOPPED:false}
@@ -34,6 +34,17 @@ class ApiSecurityTest {
@MockitoBean DatabaseService database; @MockitoBean DatabaseService database;
@MockitoBean LoginActivity loginActivity; @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 { @Test void customerAddressPropagationRequiresSessionRightsAndCsrf() throws Exception {
String base="/api/master/customers/1/address-propagation"; String base="/api/master/customers/1/address-propagation";
var anon=csrf(null);var session=authenticated("existing-user","existing-password"); var anon=csrf(null);var session=authenticated("existing-user","existing-password");
@@ -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 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();} 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<String> ddl)throws Exception{ public static void verify(DatabaseService db,Consumer<String> 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"); 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(()->{ 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));} 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));}
@@ -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<String> 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","<Test "+i+">","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("&lt;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);
}
}
@@ -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<String> 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 "<broken/>".getBytes(java.nio.charset.StandardCharsets.UTF_8);}};
assertThatThrownBy(()->broken.run(1)).hasMessageContaining("SOAP");assertThat(soap.checkpoint(1,1)).isEqualTo(before);
}
}
@@ -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 ("<s:Envelope xmlns:s='"+SOAP+"'><s:Body><p:"+operation(mode)+"Response xmlns:p='"+SAP+"'>"+data+"</p:"+operation(mode)+"Response></s:Body></s:Envelope>").getBytes(StandardCharsets.UTF_8);}
@Test void requestAndCheckpointMatchPhp(){
String request=request(0,"20261025000000");
assertThat(request).contains("<IvActualFg>X</IvActualFg>","H&amp;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,"""
<EsMasterdata><Devicetypes><item><OrgunhdIdPr>HHA</OrgunhdIdPr><DevtyhdId>H&amp;W</DevtyhdId>
<Devices><item><DevichdId>000042</DevichdId></item><item><DevichdId>000043</DevichdId></item></Devices>
<Swreleases><item><Messages><item><DevsrmsgId>E01</DevsrmsgId><Description>Tür offen</Description><Msgtext>Prüfen</Msgtext></item></Messages></item></Swreleases>
</item><item><OrgunhdIdPr>OTHER</OrgunhdIdPr><DevtyhdId>STU</DevtyhdId><Devices><item><DevichdId>99</DevichdId></item></Devices></item>
</Devicetypes></EsMasterdata>"""),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,"<EsMasterdata><Devicetypes/></EsMasterdata>"),1).devices()).isEmpty();
}
@Test void statusUsesNominalAmountOpenNoticesAndCardStock(){
var result=parse(response(0,"""
<EsStatusdata><Statusdata><item><OrgunhdIdPr>HDAG</OrgunhdIdPr><DevtyhdId>STU</DevtyhdId><DevichdId>000042</DevichdId><TstmpDevsthd>20261026110000</TstmpDevsthd>
<Devstopn><item><DevsrmsgId>E01</DevsrmsgId><DevCatMf>keine Angabe</DevCatMf><TstmpFinishd>00000000000000</TstmpFinishd></item>
<item><DevsrmsgId>E02</DevsrmsgId><TstmpFinishd>20261026100000</TstmpFinishd></item></Devstopn>
<Devstmny><item><CashboxType>BEK</CashboxType><PositionDesc>1</PositionDesc><NominalAmnt>12,50</NominalAmnt><NominalQnty>999</NominalQnty></item>
<item><CashboxType>RGS</CashboxType><PositionDesc>15</PositionDesc><NominalAmnt>3</NominalAmnt></item></Devstmny>
<Devstmat><item><MatUnitExt>KARTEN</MatUnitExt><NominalStock>120</NominalStock></item></Devstmat>
</item></Statusdata></EsStatusdata>"""),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","<!DOCTYPE x [<!ENTITY y SYSTEM 'file:///etc/passwd'>]><x>&y;</x>","<s:Envelope xmlns:s='"+SOAP+"'><s:Body><s:Fault><faultstring>secret</faultstring></s:Fault></s:Body></s:Envelope>"))
assertThatThrownBy(()->parse(xml.getBytes(StandardCharsets.UTF_8),1)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).hasMessageNotContaining("secret");
assertThatThrownBy(()->parse(response(1,"<EsMasterdata/>"),1)).hasMessageContaining("Devicetypes");
assertThatThrownBy(()->parse(response(0,"<EsStatusdata><Statusdata/></EsStatusdata>"),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<byte[]>)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");
}
}
@@ -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<Instant>();
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.<String>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"));
}
}
@@ -44,6 +44,25 @@ class DatabaseServiceTest {
} }
@org.junit.jupiter.api.AfterEach void closeFixturePool(){if(integrationPool!=null)integrationPool.close();} @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 @Test
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa") @org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
void operationsMonitoringAgainstDisposableLocalDatabase() throws Exception { void operationsMonitoringAgainstDisposableLocalDatabase() throws Exception {
+235
View File
@@ -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":"<slot aus Betriebsstatus>","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.
+1 -1
View File
@@ -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. - Originale Tarife, Preisgültigkeiten, Rabatte, Zuschläge, Kurierabrechnung, Rechnungserstellung und Buchhaltungsexporte.
- Kostenstellenhierarchie, separate Fahrzeuge, Daueraufträge, Tourvorlagen, Lager/Artikel und kundenspezifische Schnittstellen. - Kostenstellenhierarchie, separate Fahrzeuge, Daueraufträge, Tourvorlagen, Lager/Artikel und kundenspezifische Schnittstellen.
- Kunden-/Kurierportale, zusätzliche Niederlassungsrechte, Archiv- und Log-Datenbanken. - 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. 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.
+16
View File
@@ -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<string,number>}; type Worker={id:string;label:string;configured:boolean;state:string;message:string;scope:string;lastRun:string;ageSeconds:number;intervalSeconds:number;values:Record<string,number>};
type Source<T>={available:boolean;message?:string;data?:T}; type Source<T>={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={ type Snapshot={
at:string;startedAt:string;headquartersId:number;workers:Worker[]; 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}[]}>; 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}}>; 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}>; 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'); const number=(value:number|null|undefined)=>value==null?'—':value.toLocaleString('de-DE');
export function OperationsMonitoring({refresh}:{refresh:number}){ export function OperationsMonitoring({refresh}:{refresh:number}){
const [data,setData]=useState<Snapshot|null>(null),[error,setError]=useState(''),[loading,setLoading]=useState(true),[version,setVersion]=useState(0); const [data,setData]=useState<Snapshot|null>(null),[error,setError]=useState(''),[loading,setLoading]=useState(true),[version,setVersion]=useState(0);
const [checkedTasks,setCheckedTasks]=useState<Set<string>>(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); useEffect(()=>{const controller=new AbortController();setData(null);setError('');setLoading(true);
request<Snapshot>('/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);}); request<Snapshot>('/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(); return()=>controller.abort();
@@ -31,6 +35,18 @@ export function OperationsMonitoring({refresh}:{refresh:number}){
<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Dienst</th><th>Status</th><th>Letzter abgeschlossener Lauf</th><th>Ergebnis des letzten Laufs</th></tr></thead><tbody> <div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Dienst</th><th>Status</th><th>Letzter abgeschlossener Lauf</th><th>Ergebnis des letzten Laufs</th></tr></thead><tbody>
{data.workers.map(w=><tr key={w.id}><td><strong>{w.label}</strong><p>{w.scope}</p><small>Prüfabstand: {w.intervalSeconds} Sekunden nach Laufende</small></td><td><strong>{labels[w.state]||w.state}</strong><p>{w.message}</p></td><td>{date(w.lastRun)}</td><td>{w.lastRun?<dl>{Object.entries(w.values).map(([key,value])=><div key={key}><dt>{counters[key]||key}</dt><dd>{number(value)}</dd></div>)}</dl>:'—'}</td></tr>)} {data.workers.map(w=><tr key={w.id}><td><strong>{w.label}</strong><p>{w.scope}</p><small>Prüfabstand: {w.intervalSeconds} Sekunden nach Laufende</small></td><td><strong>{labels[w.state]||w.state}</strong><p>{w.message}</p></td><td>{date(w.lastRun)}</td><td>{w.lastRun?<dl>{Object.entries(w.values).map(([key,value])=><div key={key}><dt>{counters[key]||key}</dt><dd>{number(value)}</dd></div>)}</dl>:'—'}</td></tr>)}
</tbody></table></div> </tbody></table></div>
{data.cron&&<>
<h3>Zeitgesteuerte Aufgaben</h3>
<p>Feste Termine nach Europe/Berlin. Laufbelege bleiben nach einem Neustart erhalten. Unbestätigte Vorgänge werden nicht automatisch wiederholt.</p>
{[...new Set(data.cron.items.map(t=>t.group))].map(group=><details key={group}><summary>{group} · {data.cron!.items.filter(t=>t.group===group&&t.enabled).length} aktiv / {data.cron!.items.filter(t=>t.group===group).length} Aufgaben</summary>
<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Aufgabe</th><th>Status</th><th>Letzter Lauf</th><th>Nächster Termin</th></tr></thead><tbody>
{data.cron!.items.filter(t=>t.group===group).map(t=><tr key={t.id}><td><strong>{t.label}</strong><p>{t.source}</p></td><td><strong>{labels[t.state]||({WAITING:'Wartet',RUNNING:'Gestartet / Abschluss offen',UNCERTAIN:'Ergebnis prüfen',REVIEWED:'Geprüft'} as Record<string,string>)[t.state]||t.state}</strong><p>{t.message}</p><small>{number(t.processed)} verarbeitet</small>
{['RUNNING','UNCERTAIN'].includes(t.state)&&<div><label><input type="checkbox" checked={checkedTasks.has(t.id)} onChange={e=>setCheckedTasks(before=>{const next=new Set(before);if(e.target.checked)next.add(t.id);else next.delete(t.id);return next;})}/>Bisherige Teilwirkungen und Versandprotokolle geprüft</label><button className="button" disabled={!checkedTasks.has(t.id)||!!reviewing} onClick={()=>reviewTask(t)}>Nächsten Termin freigeben</button></div>}
</td><td>{date(t.lastRun)}</td><td>{t.nextRun?date(t.nextRun):'Ausgeschaltet'}<p><code>{t.cron}</code> · {t.zone}</p></td></tr>)}
</tbody></table></div>
</details>)}
{data.cron.unresolved.length>0&&<details><summary>Noch nicht umgestellte Cron-Aufgaben ({data.cron.unresolved.length})</summary><ul>{data.cron.unresolved.map(t=><li key={t.source}><strong>{t.source}</strong>: {t.reason}</li>)}</ul></details>}
</>}
<h3>Gemeinsame Lebenszeichen</h3> <h3>Gemeinsame Lebenszeichen</h3>
<p>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.</p> <p>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.</p>
{!data.signals.available?<p role="status">{data.signals.message}</p>:data.signals.data&&<> {!data.signals.available?<p role="status">{data.signals.message}</p>:data.signals.data&&<>
+3
View File
@@ -0,0 +1,3 @@
/tools/*.phar
/reports/
/.phpunit.cache/
+123
View File
@@ -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.
+30
View File
@@ -0,0 +1,30 @@
<?php
if (PHP_SAPI !== 'cli') {
throw new RuntimeException('Tests may only run through PHP CLI.');
}
require __DIR__ . '/support/LegacyFunctions.php';
require __DIR__ . '/support/Fixtures.php';
require __DIR__ . '/support/LegacyTestCase.php';
date_default_timezone_set('Europe/Berlin');
$root = getenv('VOTIAN_SOURCE_ROOT');
if (!$root || !is_dir($root)) {
throw new RuntimeException('Set VOTIAN_SOURCE_ROOT to the current server html directory.');
}
$selection = [
'include/calendar.inc.php' => ['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');
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
bootstrap="bootstrap.php" cacheResult="false" colors="true"
failOnWarning="true" failOnRisky="true" failOnDeprecation="true">
<testsuites>
<testsuite name="Votian PHP unit tests"><directory>tests</directory></testsuite>
</testsuites>
<php><ini name="date.timezone" value="Europe/Berlin"/></php>
</phpunit>
+27
View File
@@ -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 "$@"
+18
View File
@@ -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"
+90
View File
@@ -0,0 +1,90 @@
<?php
final class FixtureState
{
public static array $parameters = [];
public static array $serviceCalls = [];
public static array $writes = [];
public static array $logs = [];
public static function reset(): void
{
self::$parameters = [];
self::$serviceCalls = [];
self::$writes = [];
self::$logs = [];
$GLOBALS['db'] = new FixtureDatabase();
$GLOBALS['hq_id'] = 7;
$GLOBALS['phpVersion'] = '8.2';
$GLOBALS['ph'] = [];
}
}
final class FixtureDatabase
{
public array $expected = [];
public function expect(string $method, string $sql, mixed $result): void
{
$this->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); }
@@ -0,0 +1,94 @@
<?php
/** Load only named top-level functions, never the application's startup code. */
final class LegacyFunctions
{
public static function load(string $root, array $selection): array
{
$manifest = [];
foreach ($selection as $relative => $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;
}
}
@@ -0,0 +1,15 @@
<?php
abstract class LegacyTestCase extends PHPUnit\Framework\TestCase
{
protected function setUp(): void
{
FixtureState::reset();
}
protected function tearDown(): void
{
self::assertSame([], $GLOBALS['db']->expected, 'Unconsumed database expectations');
self::assertSame([], FixtureState::$serviceCalls, 'Unconsumed service expectations');
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class CalendarTest extends LegacyTestCase
{
#[DataProvider('leapYears')]
public function testGregorianLeapYears(int $year, bool $expected): void
{
self::assertSame($expected, leapYear($year));
}
public static function leapYears(): array
{
return ['ordinary' => [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]]];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class ConditionTest extends LegacyTestCase
{
#[DataProvider('conditions')]
public function testComparisonOperators(mixed $value, string $operator, mixed $compare, bool $expected): void
{
self::assertSame($expected, mcCheckCondition($value, $operator, $compare));
}
public static function conditions(): array
{
return [[5, '==', '5', true], [5, '==', 6, false], [5, '!=', 6, true],
[5, '!=', '5', false], [4, '<', 5, true], [5, '<', 5, false],
[6, '>', 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]];
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class FormattingTest extends LegacyTestCase
{
#[DataProvider('formats')]
public function testLegacyOutput(string $value, string $type, string $format, string $decimals, string $expected): void
{
self::assertSame($expected, formatOutput($value, $type, $format, $decimals));
}
public static function formats(): array
{
return [
'empty cell' => ['', 'varchar', '1', '', '&nbsp;'],
'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 <value>', 'number', '1', '2', 'bad <value>'],
'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', '', '&nbsp;'],
'boolean true' => ['1', 'bool', '1', '', '<img src="../images/circle.png" height="8" width="8">'],
'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']];
}
}
@@ -0,0 +1,95 @@
<?php
final class InvoiceDiscountTest extends LegacyTestCase
{
private function rows(array $rows, float $selfDiscount = 0, ?float $original = null): FixtureRows
{
$GLOBALS['db']->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));
}
}
@@ -0,0 +1,44 @@
<?php
final class LegacyFunctionsTest extends PHPUnit\Framework\TestCase
{
public function testOnlySelectedTopLevelFunctionIsExtracted(): void
{
$source = <<<'SOURCE'
<?php
file_put_contents('/never-execute', 'startup');
// function wanted() { wrong }
class Example { function wanted() { return 'method'; } }
function unwanted() { return '}'; }
function wanted($name) { /* } */ return "{$name}: }"; }
SOURCE;
$found = LegacyFunctions::extract($source, ['wanted']);
self::assertSame(['wanted'], array_keys($found));
self::assertSame('function wanted($name) { /* } */ return "{$name}: }"; }', $found['wanted']['code']);
self::assertSame(6, $found['wanted']['line']);
}
public function testMissingFunctionFailsClosed(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function different() {}', ['wanted']);
}
public function testNestedFunctionCannotBeMistakenForGlobalDeclaration(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function outer() { function wanted() {} }', ['wanted']);
}
public function testIncludesCannotLoadApplicationBootstrap(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function wanted() { require "db.php"; }', ['wanted']);
}
public function testDuplicateDefinitionsFailClosed(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function wanted() {} function wanted() {}', ['wanted']);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class PricingTest extends LegacyTestCase
{
private const DATE = '2026-10-26 10:00:00';
#[DataProvider('prices')]
public function testCustomerAndStandardTariffs(array $customer, ?array $standard, array $expected): void
{
$this->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<br>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<br>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<br>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<br>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);
}
}
@@ -0,0 +1,58 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class RecurringOrderTest extends LegacyTestCase
{
#[DataProvider('schedules')]
public function testNextOrderDate(array $date, int $rhythm, string $weekdays, array $holidays, array $expected): void
{
$GLOBALS['ph'] = $holidays;
$actual = getNextOrderDate(...[...$date, $rhythm, $weekdays, false, 2028, 12, 31]);
self::assertSame($expected, array_map('intval', $actual));
self::assertTrue(checkdate((int)$actual[1], (int)$actual[2], (int)$actual[0]));
}
public static function schedules(): array
{
return [
'weekdays skip weekend' => [[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));
}
}
+6
View File
@@ -0,0 +1,6 @@
/reports/
/test-results/
/.auth/
/node_modules/
.env*
!.env.example
+95
View File
@@ -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.
+60
View File
@@ -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"
}
}
}
}
+8
View File
@@ -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" }
}
+36
View File
@@ -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,
},
});
+10
View File
@@ -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 "$@"' -- "$@"
+37
View File
@@ -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)
+44
View File
@@ -0,0 +1,44 @@
import { test as base, expect, type BrowserContext } from '@playwright/test';
import { installGuard } from './network';
type State = Awaited<ReturnType<BrowserContext['storageState']>>;
export const test = base.extend<{ guard: Awaited<ReturnType<typeof installGuard>> }, { 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';
+144
View File
@@ -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<string, unknown>) {
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<string>();
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; },
};
}
+70
View File
@@ -0,0 +1,70 @@
import { expect, type Page } from '@playwright/test';
export async function openOrder(start: Page): Promise<Page> {
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 });
}
+132
View File
@@ -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});
});
+55
View File
@@ -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 });
});
+3 -2
View File
@@ -1,12 +1,13 @@
{ {
"name": "votian-ng", "name": "votian-ng",
"version": "1.0.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "./dev.sh", "dev": "./dev.sh",
"build": "./build.sh", "build": "./build.sh",
"test:e2e": "playwright test" "test:e2e": "playwright test",
"test:legacy-ui": "python3 legacy-ui-tests/run.py"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.63.0" "@playwright/test": "1.63.0"