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:
1 parent
781a2b82dc
commit
9de2358480
57 files changed
+3251
-7
No files matched your search
@@ -1,4 +1,41 @@
|
||||
# Als .env im Projektstamm speichern und die vorhandenen Datenbankwerte eintragen.
|
||||
# Native Spring-Tasks (siehe docs/CRON-MIGRATION.md); kein Cron-Daemon erforderlich.
|
||||
# Das Docker-Image aktiviert alle neun Gruppen über SPRING_TASK_GROUPS.
|
||||
# Optional die Image-Vorgabe überschreiben, z. B. nur Diagnose:
|
||||
# SPRING_TASK_GROUPS=mysql-status
|
||||
# Alle zeitgesteuerten Gruppen ausdrücklich deaktivieren: SPRING_TASK_GROUPS=
|
||||
# Gruppen können einzeln mit Komma gewählt werden:
|
||||
# courier-logout,stb-compliance,sb-master-export,hha-safebags,mysql-status,file-maintenance,replication-monitor,hha-soap-master,hha-soap-status
|
||||
# Kompatibilitätsfallback für direkten Java-Start ohne SPRING_TASK_GROUPS.
|
||||
LEGACY_CRON_JOBS=
|
||||
LEGACY_CRON_HHA_MAIL_TO=
|
||||
LEGACY_CRON_HHA_MAIL_BCC=
|
||||
# PTnova: HTTPS-Endpunkt und Zugänge der zu dieser NG-Instanz gehörenden SAP-Umgebung.
|
||||
LEGACY_CRON_SOAP_URL=
|
||||
LEGACY_CRON_SOAP_USER=
|
||||
LEGACY_CRON_SOAP_PASSWORD=
|
||||
LEGACY_CRON_SOAP_HEADQUARTERS=1
|
||||
LEGACY_CRON_SOAP_CONTAINER_STOCK=1000
|
||||
# JSON: Gruppen STB/HTG/HTM/HTM_OLD/HHA/INT/MUH -> [Primärserver:Port,Replikat:Port,...]
|
||||
LEGACY_CRON_REPLICATION_TARGETS={}
|
||||
LEGACY_CRON_REPLICATION_USER=
|
||||
LEGACY_CRON_REPLICATION_PASSWORD=
|
||||
LEGACY_CRON_REPLICATION_MAIL_TO=
|
||||
# Ausschließlich dedizierte, eingebundene Altverzeichnisse; mit Komma trennen.
|
||||
# TEMPORARY: alle regulären Dateien werden wie in PHP rekursiv entfernt!
|
||||
LEGACY_CRON_BINLOG_DIRECTORIES=
|
||||
LEGACY_CRON_LOG_DIRECTORIES=
|
||||
LEGACY_CRON_TEMPORARY_DIRECTORIES=
|
||||
# Die Vorgabezeiten stehen in application.yml, jeweils Europe/Berlin (Spring: sechs Felder).
|
||||
# LEGACY_CRON_SOAP_MASTER_SCHEDULE=0 10 5 * * *
|
||||
# LEGACY_CRON_SOAP_STATUS_SCHEDULE=0 20 5 * * *
|
||||
# LEGACY_CRON_LOGOUT_SCHEDULE=0 */30 * * * *
|
||||
# LEGACY_CRON_STB_MAIL_SCHEDULE=0 0 4 * * MON-FRI
|
||||
# LEGACY_CRON_SB_EXPORT_SCHEDULE=0 45 20 * * *
|
||||
# LEGACY_CRON_HHA_SAFEBAG_SCHEDULE=0 40 9 * * *
|
||||
# LEGACY_CRON_DB_STATUS_SCHEDULE=0 */5 * * * *
|
||||
# LEGACY_CRON_FILES_SCHEDULE=0 1 0 * * *
|
||||
# LEGACY_CRON_REPLICATION_SCHEDULE=0 */15 * * * *
|
||||
# Prozessumgebungsvariablen haben Vorrang vor dieser Datei.
|
||||
# Für docker run --env-file .env: Werte ohne umschließende Anführungszeichen.
|
||||
# $, #, Backslashes und = in Werten bleiben wörtlich erhalten. Nicht per Shell source laden.
|
||||
|
||||
+4
-1
@@ -4,7 +4,10 @@ WORKDIR /app
|
||||
ARG JAR_FILE=backend/target/*.jar
|
||||
COPY --chown=10001:10001 ${JAR_FILE} app.jar
|
||||
|
||||
ENV SERVER_ADDRESS=0.0.0.0
|
||||
# Native Spring scheduling only; Java remains PID 1, with no PHP or cron daemon.
|
||||
# Override SPRING_TASK_GROUPS to select groups; an explicit empty value disables them.
|
||||
ENV SERVER_ADDRESS=0.0.0.0 \
|
||||
SPRING_TASK_GROUPS=courier-logout,stb-compliance,sb-master-export,hha-safebags,mysql-status,file-maintenance,replication-monitor,hha-soap-master,hha-soap-status
|
||||
USER 10001:10001
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -116,6 +116,10 @@ Die neue Anwendung ist standardmäßig nur lokal erreichbar. Für einen Serverbe
|
||||
|
||||
## Prüfungen
|
||||
|
||||
Die [Unit-Tests der PHP-Altanwendung](legacy-php-tests/README.md) prüfen den aktuellen Serverquellstand in einem separaten Container mit dem Image der Altanwendung. Auf `172.16.0.158` starten sie mit `/data/0/votian-test/tests/unit/run-server.sh`. Testumfang, Isolation und bekannte Kalenderfehler sind in der verlinkten Anleitung dokumentiert.
|
||||
|
||||
Die [Oberflächentests der PHP-Altanwendung](legacy-ui-tests/README.md) prüfen die echte Auftragserfassung auf `test.sb.assecutor.de` mit Playwright und Firefox. Start auf dem Server: `/data/0/votian-test/tests/ui/run-server.sh`; lokal: `npm run test:legacy-ui`. Ein tatsächlicher Speichertest wird mit `--create-order` aktiviert und verwendet ausschließlich die bezeichneten Assecutor-Testkunden.
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||
cd backend && ./mvnw test
|
||||
@@ -158,4 +162,8 @@ Der Angebotslauf liest die Fachparameter der Dispositionsniederlassung: Aktivier
|
||||
|
||||
Verwaltung → Betriebsstatus zeigt die sechs nativen Automatikdienste mit Aktivierung, Umfang, letztem abgeschlossenen Lauf und Fehler-/Ergebniszahlen. Die Laufwerte gelten für die gesamte NG-Instanz und beginnen nach einem Neustart neu. Ein Lauf ohne Abschluss seit mehr als drei Minuten wird als überfällig gekennzeichnet; daraus folgt nicht automatisch, dass der Prozess beendet ist. Gemeinsame `keepalive`-Zeitstempel können von PHP oder NG stammen und sind kein Nachweis der Zuständigkeit.
|
||||
|
||||
Auftrags-/Kurierkennzahlen und Geräte-Abschlussmeldungen sind auf die ursprüngliche Niederlassung begrenzt. Erforderlich ist das vorhandene Mitarbeiterverwaltungsrecht (Position 3). Die Ansicht liest ausschließlich; sie startet keine Dienste und versendet keine Nachrichten. Fehlende Datenquellen werden einzeln als nicht verfügbar angezeigt.
|
||||
Auftrags-/Kurierkennzahlen und Geräte-Abschlussmeldungen sind auf die ursprüngliche Niederlassung begrenzt. Erforderlich ist das vorhandene Mitarbeiterverwaltungsrecht (Position 3). Das Laden der Ansicht liest ausschließlich; es startet keine Dienste und versendet keine Nachrichten. Die gesonderte Prüffreigabe eines unterbrochenen Cron-Laufs wird protokolliert und erlaubt dessen nächsten regulären Termin. Fehlende Datenquellen werden einzeln als nicht verfügbar angezeigt.
|
||||
|
||||
### Zeitgesteuerte Spring-Tasks
|
||||
|
||||
Die bestätigte PHP-Crontab wird über native Spring-Tasks abgelöst. Das Docker-Image aktiviert beim Start alle neun Gruppen (115 Tasks) über `SPRING_TASK_GROUPS`; feste Termine gelten nach Europe/Berlin. Der Container startet ausschließlich Java und benötigt weder Cron-Daemon noch PHP. `SPRING_TASK_GROUPS` kann auf einzelne Gruppen oder einen leeren Wert gesetzt werden. Beim direkten Java-Start bleibt `LEGACY_CRON_JOBS` als Fallback erhalten. Der Betriebsstatus zeigt dauerhafte Laufbelege. Die beiden nicht mehr benötigten HHA-Aufrufe entfallen nach Betreiberentscheidung vom 27.10.2026; damit sind alle weiterhin benötigten PHP-Cron-Aufrufe implementiert. Zeitpläne, Konfiguration, Änderungen gegenüber PHP und Wiederanlauf nach einem unterbrochenen Versand stehen in [CRON-MIGRATION.md](docs/CRON-MIGRATION.md).
|
||||
+2
-1
@@ -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">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.16</version><relativePath/></parent>
|
||||
<groupId>de.stadtbote</groupId><artifactId>votian-backend</artifactId><version>0.1.2</version>
|
||||
<groupId>de.stadtbote</groupId><artifactId>votian-backend</artifactId><version>0.2.0</version>
|
||||
<name>Votian NG Stadtbote</name><properties><java.version>21</java.version></properties>
|
||||
<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>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>
|
||||
|
||||
@@ -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("&","&")).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
|
||||
@RequestMapping("/api/administration/operations")
|
||||
class OperationsMonitoring {
|
||||
@org.springframework.beans.factory.annotation.Autowired(required=false)
|
||||
LegacyCronTasks cron;
|
||||
final DatabaseService db;
|
||||
final BusinessSupport support;
|
||||
final OrderCompletionProcessing completion;
|
||||
@@ -85,7 +87,7 @@ class OperationsMonitoring {
|
||||
if(!allowed(u))throw new AccessDeniedException("Der Betriebsstatus benötigt die Mitarbeiterverwaltung in der ursprünglichen Niederlassung.");
|
||||
var now=clock.instant();
|
||||
return fields("at",now.toString(),"startedAt",started.toString(),"headquartersId",u.headquartersId(),
|
||||
"workers",workers(now),"signals",read(this::signals),
|
||||
"workers",workers(now),"cron",cron==null?null:fields("items",cron.view(),"unresolved",LegacyCronTasks.unresolved()),"signals",read(this::signals),
|
||||
"database",read(()->database(u.headquartersId())),"history",read(()->history(u.headquartersId())));
|
||||
}
|
||||
Map<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. */
|
||||
public <T> T documentOperation(java.util.function.Supplier<T> work) {
|
||||
return jdbc.execute((org.springframework.jdbc.core.ConnectionCallback<T>) connection -> {
|
||||
|
||||
@@ -41,6 +41,35 @@ spring:
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
app:
|
||||
legacy-cron:
|
||||
jobs: ${SPRING_TASK_GROUPS:${LEGACY_CRON_JOBS:}}
|
||||
hha-mail-to: ${LEGACY_CRON_HHA_MAIL_TO:}
|
||||
hha-mail-bcc: ${LEGACY_CRON_HHA_MAIL_BCC:}
|
||||
soap:
|
||||
url: ${LEGACY_CRON_SOAP_URL:}
|
||||
user: ${LEGACY_CRON_SOAP_USER:}
|
||||
password: ${LEGACY_CRON_SOAP_PASSWORD:}
|
||||
headquarters: ${LEGACY_CRON_SOAP_HEADQUARTERS:1}
|
||||
container-stock: ${LEGACY_CRON_SOAP_CONTAINER_STOCK:1000}
|
||||
replication:
|
||||
targets: '${LEGACY_CRON_REPLICATION_TARGETS:{}}'
|
||||
user: ${LEGACY_CRON_REPLICATION_USER:}
|
||||
password: ${LEGACY_CRON_REPLICATION_PASSWORD:}
|
||||
mail-to: ${LEGACY_CRON_REPLICATION_MAIL_TO:}
|
||||
files:
|
||||
binlog-directories: ${LEGACY_CRON_BINLOG_DIRECTORIES:}
|
||||
log-directories: ${LEGACY_CRON_LOG_DIRECTORIES:}
|
||||
temporary-directories: ${LEGACY_CRON_TEMPORARY_DIRECTORIES:}
|
||||
schedules:
|
||||
hha-soap-master: ${LEGACY_CRON_SOAP_MASTER_SCHEDULE:0 10 5 * * *}
|
||||
hha-soap-status: ${LEGACY_CRON_SOAP_STATUS_SCHEDULE:0 20 5 * * *}
|
||||
courier-logout: ${LEGACY_CRON_LOGOUT_SCHEDULE:0 */30 * * * *}
|
||||
stb-compliance: ${LEGACY_CRON_STB_MAIL_SCHEDULE:0 0 4 * * MON-FRI}
|
||||
sb-master-export: ${LEGACY_CRON_SB_EXPORT_SCHEDULE:0 45 20 * * *}
|
||||
hha-safebags: ${LEGACY_CRON_HHA_SAFEBAG_SCHEDULE:0 40 9 * * *}
|
||||
mysql-status: ${LEGACY_CRON_DB_STATUS_SCHEDULE:0 */5 * * * *}
|
||||
file-maintenance: ${LEGACY_CRON_FILES_SCHEDULE:0 1 0 * * *}
|
||||
replication-monitor: ${LEGACY_CRON_REPLICATION_SCHEDULE:0 */15 * * * *}
|
||||
completion:
|
||||
enabled: ${ORDER_COMPLETION_WORKER_ENABLED:false}
|
||||
php-stopped: ${ORDER_COMPLETION_PHP_STOPPED:false}
|
||||
|
||||
@@ -34,6 +34,17 @@ class ApiSecurityTest {
|
||||
@MockitoBean DatabaseService database;
|
||||
@MockitoBean LoginActivity loginActivity;
|
||||
|
||||
@Test void cronReviewAndStatusRequireSessionRightsAndCsrf() throws Exception {
|
||||
mvc.perform(get("/api/administration/cron/database-status?month=2026-10")).andExpect(status().isUnauthorized());
|
||||
var session=authenticated("existing-user","existing-password");
|
||||
mvc.perform(post("/api/administration/cron/mysql-status/review").session(session).contentType("application/json").content("{\"reviewed\":true}")).andExpect(status().isForbidden());
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("no-cron-rights"),any(),any())).thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","no-cron-rights","emp_rights","00000000")));
|
||||
var denied=authenticated("no-cron-rights","pw");var token=csrf(denied);
|
||||
mvc.perform(get("/api/administration/cron/database-status?month=2026-10").session(denied)).andExpect(status().isForbidden());
|
||||
mvc.perform(post("/api/administration/cron/mysql-status/review").session(denied).header(token.header(),token.token()).contentType("application/json").content("{\"reviewed\":true}")).andExpect(status().isForbidden());
|
||||
verify(database,never()).cronOperation(anyString(),any());
|
||||
}
|
||||
|
||||
@Test void customerAddressPropagationRequiresSessionRightsAndCsrf() throws Exception {
|
||||
String base="/api/master/customers/1/address-propagation";
|
||||
var anon=csrf(null);var session=authenticated("existing-user","existing-password");
|
||||
|
||||
@@ -11,6 +11,8 @@ public final class CustomerPortalIntegrationAssertions {
|
||||
static void setting(DatabaseService db,long hq,long employee,String key,String value){write(db,"DELETE FROM parameter WHERE hq_id=? AND emp_id=? AND par_key=?",hq,employee,key);write(db,"INSERT INTO parameter (hq_id,emp_id,par_key,par_value) VALUES (?,?,?,?)",hq,employee,key,value);}
|
||||
static LegacyUser login(DatabaseService db,String account){return (LegacyUser)new LegacyAuthenticationProvider(db).authenticate(org.springframework.security.authentication.UsernamePasswordAuthenticationToken.unauthenticated(account,"CustomerTest9")).getPrincipal();}
|
||||
public static void verify(DatabaseService db,Consumer<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");
|
||||
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));}
|
||||
|
||||
@@ -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("<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&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&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();}
|
||||
|
||||
@Test
|
||||
@EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
|
||||
void legacyCronSoapAgainstDisposableLocalDatabase() {
|
||||
var admin=new JdbcTemplate(fixtureDataSource());admin.execute("CREATE DATABASE crm_qa_soap");
|
||||
var ds=new DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL").replace("/crm_qa","/crm_qa_soap"),"root","isolated-test-only");
|
||||
var jdbc=new JdbcTemplate(ds);
|
||||
try {de.stadtbote.votian.LegacyCronSoapIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds)),jdbc::execute);}
|
||||
finally {admin.execute("DROP DATABASE crm_qa_soap");}
|
||||
}
|
||||
|
||||
@Test
|
||||
@EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
|
||||
void legacyCronAgainstDisposableLocalDatabase() throws Exception {
|
||||
var ds=fixtureDataSource();var jdbc=new JdbcTemplate(ds);
|
||||
try(var connection=ds.getConnection()){org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(connection,new org.springframework.core.io.ClassPathResource("business-schema.sql"));}
|
||||
for(String sql:de.stadtbote.votian.LegacyCronIntegrationAssertions.setup())jdbc.execute(sql);
|
||||
de.stadtbote.votian.LegacyCronIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
|
||||
void operationsMonitoringAgainstDisposableLocalDatabase() throws Exception {
|
||||
|
||||
@@ -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
@@ -2822,6 +2822,6 @@ Die folgenden Punkte beschreiben die ursprüngliche erste Ausbaustufe. Zahlreich
|
||||
- Originale Tarife, Preisgültigkeiten, Rabatte, Zuschläge, Kurierabrechnung, Rechnungserstellung und Buchhaltungsexporte.
|
||||
- Kostenstellenhierarchie, separate Fahrzeuge, Daueraufträge, Tourvorlagen, Lager/Artikel und kundenspezifische Schnittstellen.
|
||||
- Kunden-/Kurierportale, zusätzliche Niederlassungsrechte, Archiv- und Log-Datenbanken.
|
||||
- Konfiguration der tatsächlich vorhandenen PHP-Basisadresse `LEGACY_APP_URL`, um noch nicht portierte Fachabläufe gezielt aufzurufen.
|
||||
- Konfiguration der tatsächlich vorauf dem testdenen PHP-Basisadresse `LEGACY_APP_URL`, um noch nicht portierte Fachabläufe gezielt aufzurufen.
|
||||
|
||||
Die Detailanalyse steht in `ALTSYSTEM-ANALYSE.md`. Es gibt keine neue Datenbank, keine automatische Schemaänderung und keinen Seed-Mechanismus. Die bisherige PHP-Anwendung wurde durch diese Umsetzung nicht verändert.
|
||||
@@ -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 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={
|
||||
at:string;startedAt:string;headquartersId:number;workers:Worker[];
|
||||
cron?:{items:CronTask[];unresolved:{source:string;cron:string;reason:string}[]};
|
||||
signals:Source<{databaseTime:string;items:{id:string;label:string;state:string;at:string;ageSeconds:number|null}[]}>;
|
||||
database:Source<{day:string;jobs:{total:number;latest:number|null;finished:number;activeToday:number;active:number};couriers:{available:number;reserved:number}}>;
|
||||
history:Source<{day:string;deviceCompletions:number}>;
|
||||
@@ -16,6 +18,8 @@ const date=(value:string)=>value?new Date(value).toLocaleString('de-DE'):'Noch k
|
||||
const number=(value:number|null|undefined)=>value==null?'—':value.toLocaleString('de-DE');
|
||||
export function OperationsMonitoring({refresh}:{refresh:number}){
|
||||
const [data,setData]=useState<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);
|
||||
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();
|
||||
@@ -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>
|
||||
{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>
|
||||
{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>
|
||||
<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&&<>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/tools/*.phar
|
||||
/reports/
|
||||
/.phpunit.cache/
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Executable
+27
@@ -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 "$@"
|
||||
Executable
+18
@@ -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"
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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]]];
|
||||
}
|
||||
}
|
||||
@@ -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]];
|
||||
}
|
||||
}
|
||||
@@ -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', '', ' '],
|
||||
'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', '', ' '],
|
||||
'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']);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/reports/
|
||||
/test-results/
|
||||
/.auth/
|
||||
/node_modules/
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -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.
|
||||
Generated
+60
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Executable
+10
@@ -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 "$@"' -- "$@"
|
||||
Executable
+37
@@ -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)
|
||||
@@ -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';
|
||||
@@ -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; },
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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});
|
||||
});
|
||||
@@ -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
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "votian-ng",
|
||||
"version": "1.0.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "./dev.sh",
|
||||
"build": "./build.sh",
|
||||
"test:e2e": "playwright test"
|
||||
"test:e2e": "playwright test",
|
||||
"test:legacy-ui": "python3 legacy-ui-tests/run.py"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0"
|
||||
|
||||
Reference in new issue
Block a user