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

Migriert die weiterhin benötigten Zeitaufgaben einschließlich SOAP-Importen, Betriebsstatus und Wiederanlaufkontrolle. Aktiviert 115 native Spring-Tasks im Docker-Image ohne Cron-Daemon oder PHP und dokumentiert die entfallenen HHA-Aufrufe. Ergänzt PHP-/UI-Testwerkzeuge und aktualisiert den Projektstand auf Version 0.2.0.
This commit is contained in:
Sven committed 2026-10-27 09:53:23 +01:00
1 parent 781a2b82dc
commit 9de2358480
57 files changed
+3251 -7

No files matched your search

+2 -1
View File
@@ -2,9 +2,10 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<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("&","&amp;")).append("</DevtyhdIdK></item>");
return xml.append("</ItDevicetype>").append(mode==0?"<IvActualFg>X</IvActualFg>":"").append("<IvTimestamp>").append(since).append("</IvTimestamp></urn:").append(operation(mode)).append("></soap:Body></soap:Envelope>").toString();
}
static boolean allowed(String organization,String type){return Set.of("HHA","HDAG").contains(organization)&&(TYPES.contains(type)||type.equals("H+W"));}
static String serial(String value){return value.strip().replaceFirst("^0+","");}
static Integer moneyField(String type,String position){
if(position.isEmpty())return null;
if(type.equals("BEK"))return 5;if(type.equals("MEK"))return 6;
if(type.equals("ZGS")&&Set.of("0","1","2").contains(position))return 7+Integer.parseInt(position);
if(type.equals("RGS")&&Set.of("10","11","12","13","14","15").contains(position))return Integer.parseInt(position);
return null;
}
static BigDecimal amount(String value){try{var n=new BigDecimal(value.replace(',','.'));if(n.precision()>24||n.scale()>8)throw new IllegalArgumentException();return n;}catch(RuntimeException e){throw bad("Ungültiger PTnova-Bestand.");}}
static List<Element> children(Element parent,String name){
var result=new ArrayList<Element>();
for(Node n=parent.getFirstChild();n!=null;n=n.getNextSibling())if(n instanceof Element e&&name.equals(e.getLocalName()))result.add(e);
return result;
}
static Element child(Element parent,String name,boolean required){
var matches=children(parent,name);if(matches.size()>1||required&&matches.isEmpty())throw bad("Unvollständige oder mehrdeutige PTnova-Antwort: "+name);
return matches.isEmpty()?null:matches.getFirst();
}
static String text(Element parent,String name){var e=child(parent,name,false);return e==null?"":e.getTextContent().strip();}
static List<Element> items(Element parent,String name){var e=child(parent,name,false);return e==null?List.of():children(e,"item");}
static Payload parse(byte[] bytes,int mode){
if(bytes.length>32*1024*1024)throw bad("PTnova-Antwort ist zu groß.");
try{
var factory=DocumentBuilderFactory.newInstance();factory.setNamespaceAware(true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl",true);
factory.setFeature("http://xml.org/sax/features/external-general-entities",false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities",false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD,"");factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA,"");
factory.setXIncludeAware(false);factory.setExpandEntityReferences(false);
var builder=factory.newDocumentBuilder();builder.setErrorHandler(new org.xml.sax.helpers.DefaultHandler(){@Override public void fatalError(org.xml.sax.SAXParseException e)throws org.xml.sax.SAXException{throw e;}});
var root=builder.parse(new ByteArrayInputStream(bytes)).getDocumentElement();
if(!"Envelope".equals(root.getLocalName())||!SOAP.equals(root.getNamespaceURI()))throw bad("Keine gültige SOAP-1.1-Antwort.");
var body=child(root,"Body",true);if(child(body,"Fault",false)!=null)throw bad("SAP hat einen SOAP-Fehler gemeldet. Lesemarke bleibt unverändert.");
var response=child(body,operation(mode)+"Response",true);
if(!SAP.equals(response.getNamespaceURI()))throw bad("Unerwarteter SAP-Namensraum.");
var devices=new ArrayList<Device>();var messages=new ArrayList<Message>();var statuses=new ArrayList<Status>();
if(mode==1){
var data=child(response,"EsMasterdata",true);child(data,"Devicetypes",true);
for(var type:items(data,"Devicetypes")){
String kind=text(type,"DevtyhdId");if(!allowed(text(type,"OrgunhdIdPr"),kind))continue;
for(var device:items(type,"Devices")){String serial=serial(text(device,"DevichdId"));if(!serial.isEmpty())devices.add(new Device(kind,serial));}
for(var release:items(type,"Swreleases"))for(var message:items(release,"Messages")){
String code=text(message,"DevsrmsgId"),description=text(message,"Description");
if(!code.isEmpty()&&!description.isEmpty())messages.add(new Message(kind,code,description,text(message,"Msgtext")));
}
}
}else{
var data=child(response,"EsStatusdata",true);child(data,"Statusdata",true);
for(var status:items(data,"Statusdata")){
String type=text(status,"DevtyhdId"),serial=serial(text(status,"DevichdId"));
if(!allowed(text(status,"OrgunhdIdPr"),type)||serial.isEmpty())continue;
var date=timestamp(text(status,"TstmpDevsthd")).toLocalDate();var notices=new ArrayList<Notice>();
for(var n:items(status,"Devstopn")){
String end=text(n,"TstmpFinishd"),code=text(n,"DevsrmsgId");
if(!code.isEmpty()&&(end.isEmpty()||end.equals("00000000000000")))notices.add(new Notice(code,text(n,"DevCatMf")));
}
var money=new LinkedHashMap<Integer,BigDecimal>();
for(var m:items(status,"Devstmny")){var field=moneyField(text(m,"CashboxType"),text(m,"PositionDesc"));if(field!=null)money.put(field,amount(text(m,"NominalAmnt")));}
BigDecimal cards=null;
if(Set.of("STU","STH","STB","STVS").contains(type))for(var material:items(status,"Devstmat"))if(text(material,"MatUnitExt").equals("KARTEN"))cards=amount(text(material,"NominalStock"));
statuses.add(new Status(type,serial,date,List.copyOf(notices),Collections.unmodifiableMap(money),cards));
}
}
return new Payload(List.copyOf(devices),List.copyOf(messages),List.copyOf(statuses));
}catch(org.springframework.web.server.ResponseStatusException e){throw e;}catch(Exception e){throw bad("PTnova-Antwort konnte nicht sicher gelesen werden.");}
}
}
@@ -0,0 +1,108 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static de.stadtbote.votian.AdminSupport.bad;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.LegacyCronSoapProtocol.*;
/** Persists the fields that the PHP importer actually writes (not its inactive blocks). */
final class LegacyCronSoapStore {
final DatabaseService db;final BusinessSupport support;final long hq,container;final LocalDateTime now;int skipped;
LegacyCronSoapStore(DatabaseService db,BusinessSupport support,long hq,long container,LocalDateTime now){this.db=db;this.support=support;this.hq=hq;this.container=container;this.now=now;}
Map<String,Object> optional(List<Map<String,Object>> rows,String what){if(rows.size()>1)throw bad("Mehrdeutige PTnova-Zuordnung: "+what);return rows.isEmpty()?null:rows.getFirst();}
Map<String,Object> article(String type){return optional(db.queryForList("SELECT at_id FROM article WHERE at_match=? AND hq_id IN (0,?)",type,hq),"Artikeltyp");}
Map<String,Object> unit(String serial){
var item=optional(db.queryForList("SELECT ati_id,at_id,stk_id,ati_serialno FROM articleitem WHERE ati_serialno=?",serial),"Seriennummer");
if(item!=null)stock(id(item,"stk_id"));return item;
}
Map<String,Object> stock(long stock){
var row=optional(db.queryForList("SELECT stk_id,stk_pre_id,stk_path,hq_id FROM stock WHERE stk_id=?",stock),"Lagerort");
if(row==null||id(row,"hq_id")!=0&&id(row,"hq_id")!=hq)throw bad("PTnova-Lagerort fehlt oder gehört zu einer anderen Niederlassung.");return row;
}
int master(Payload payload){
int count=0;
for(var device:payload.devices()){
if(unit(device.serial())!=null)continue;
var article=article(device.type());if(article==null){skipped++;continue;}
stock(container);long parent=parent();
long type=id(article,"at_id");
long item=db.insert("articleitem",fields("at_id",type,"stk_id",container,"ati_serialno",device.serial(),"ati_data_01","","ati_data_02","","ati_data_03","","ati_data_04",now,"ati_data_21",""));
createStock(parent,item,"FA"+device.serial(),false);
var quantities=db.queryForList("SELECT stkat_itemquantity FROM stockarticle WHERE stk_id=? AND at_id=?",container,type);
if(quantities.size()>1)throw bad("Mehrdeutiger PTnova-Lagerbestand.");
if(quantities.isEmpty())db.update("INSERT INTO stockarticle (stk_id,at_id,stkat_quantity,stkat_itemquantity) VALUES (?,?,1,1)",container,type);
else db.update("UPDATE stockarticle SET stkat_quantity=COALESCE(stkat_itemquantity,0)+1,stkat_itemquantity=COALESCE(stkat_itemquantity,0)+1 WHERE stk_id=? AND at_id=?",container,type);
count++;
}
for(var message:payload.messages()){
var article=article(message.type());if(article==null){skipped++;continue;}long type=id(article,"at_id");
var matches=db.queryForList("SELECT atm_code FROM articlemessage WHERE at_id=? AND atm_code=?",type,message.code());
if(matches.size()>1)throw bad("Mehrdeutiger PTnova-Betriebsmeldungscode.");
if(matches.isEmpty())db.update("INSERT INTO articlemessage (at_id,atm_code,atm_desc,atm_text,atm_prio) VALUES (?,?,?,?,0)",type,message.code(),message.description(),message.text());
else db.update("UPDATE articlemessage SET atm_desc=?,atm_text=? WHERE at_id=? AND atm_code=?",message.description(),message.text(),type,message.code());
count++;
}
return count;
}
long parent(){
var catalog=new OrderEntryCatalog(db);String value=catalog.parameter(hq,"SERVICEUNIT_STOCK_PARENT_ID");
if(!value.matches("[0-9]+"))value=catalog.parameter(0,"SERVICEUNIT_STOCK_PARENT_ID");
long parent=0;if(value.matches("[0-9]+"))try{parent=Long.parseLong(value);}catch(NumberFormatException e){throw bad("Ungültiges Serviceunit-Hauptlager.");}
if(parent>0){stock(parent);return parent;}
var root=optional(db.queryForList("SELECT stk_id FROM stock WHERE hq_id=? AND stk_pre_id=0 AND stk_obj_type='ati' AND stk_obj_id=0 AND stk_name='SYSTEM-SERVICEUNIT'",hq),"Serviceunit-Hauptlager");
if(root!=null)return id(root,"stk_id");
parent=createStock(0,0,"SYSTEM-SERVICEUNIT",true);
for(var entry:Map.of("MASK_STK_DATAFIELDHEADLINES_","01,02,03,04,05,06,07,08,09,10,11,12,13,14,15","MASK_STK_DATAFIELDS_","0").entrySet())
db.insert("parameter",fields("emp_id",0,"hq_id",0,"par_key",entry.getKey()+parent,"par_value",entry.getValue(),"par_text",""));
return parent;
}
long createStock(long parent,long item,String name,boolean root){
String path="";if(parent>0){var row=stock(parent);path=(text(row,"stk_path").isEmpty()?"//":text(row,"stk_path"))+parent+"//";}
long id=db.insert("stock",fields("hq_id",hq,"stk_pre_id",parent,"stk_obj_type","ati","stk_obj_id",item,"stk_name",name,"stk_path",path,"stk_barcode",name,"ad_id",0,"cs_id",0,"stk_visible",1,"stk_readonly",root?1:0,"stk_modifytime",now));
support.audit(new LegacyUser(0,0,hq,"PTnova",""),0,0,0,87,"PTnova-Lager angelegt · STK_ID="+id+" · PARENT="+parent,UUID.randomUUID().toString());return id;
}
int status(Payload payload){
int count=0;
for(var status:payload.statuses()){
var item=unit(status.serial());if(item==null){skipped++;continue;}
long article=id(item,"at_id"),stock=id(item,"stk_id"),itemId=id(item,"ati_id");
var codes=new LinkedHashSet<String>();
for(var notice:status.notices()){
String description=notice.description();
if(description.isEmpty()||description.equals("keine Angabe")){
var message=optional(db.queryForList("SELECT atm_desc FROM articlemessage WHERE at_id=? AND atm_code=?",article,notice.code()),"Betriebsmeldung");
description=message==null?"":text(message,"atm_desc");
}
if(!description.isEmpty())codes.add(notice.code());else skipped++;
}
var values=new LinkedHashMap<String,Object>();values.put("ati_data_17",String.join(",",codes));values.put("ati_data_29","");
var date=status.date().atTime(3,0);
for(var amount:status.money().entrySet()){values.put(String.format("ati_data_%02d",amount.getKey()),amount.getValue().toPlainString());}
if(!status.money().isEmpty()){values.put("ati_data_04",date);values.put("ati_data_30","");}
if(status.cards()!=null)values.put("ati_data_09",status.cards().toPlainString());
BusinessSupport.update(db,"articleitem","ati_id",itemId,values);
if(!status.money().isEmpty()||status.cards()!=null){
String table=support.historySchema+".articleitemhistory";
var history=db.queryForList("SELECT atih_serialno FROM "+table+" WHERE at_id=? AND stk_id=? AND atih_serialno=? AND atih_data_04=?",article,stock,status.serial(),date);
if(history.size()>1)throw bad("Mehrdeutige PTnova-Tageshistorie.");
var amounts=new LinkedHashMap<String,Object>();for(var amount:status.money().entrySet())amounts.put(String.format("atih_data_%02d",amount.getKey()),amount.getValue().toPlainString());
if(!status.money().isEmpty())amounts.put("atih_createtime",now);
if(status.cards()!=null)amounts.put("atih_data_09",status.cards().toPlainString());
if(!history.isEmpty()){
var arguments=new ArrayList<Object>(amounts.values());Collections.addAll(arguments,article,stock,status.serial(),date);
db.update("UPDATE "+table+" SET "+String.join(",",amounts.keySet().stream().map(key->key+"=?").toList())+" WHERE at_id=? AND stk_id=? AND atih_serialno=? AND atih_data_04=?",arguments.toArray());
}
else if(!status.money().isEmpty()){
var row=fields("at_id",article,"stk_id",stock,"atih_serialno",status.serial(),"atih_data_01","","atih_data_02","","atih_data_03","","atih_data_04",date);row.putAll(amounts);BusinessSupport.insertWithoutKey(db,table,row);
}
// PHP updates card-only history only if a money/history row already exists.
}
count++;
}
return count;
}
}
@@ -0,0 +1,76 @@
package de.stadtbote.votian;
import org.springframework.stereotype.Component;
import org.springframework.core.env.Environment;
import org.springframework.context.event.EventListener;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.scheduling.support.CronExpression;
import jakarta.annotation.PreDestroy;
import java.time.*;
import java.util.*;
import java.util.function.Function;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Explicit crontab migration. This scheduler never invokes PHP, a shell or a legacy HTTP endpoint. */
@Component
class LegacyCronTasks {
static final ZoneId ZONE=ZoneId.of("Europe/Berlin");
record Task(String id,String label,String group,String cron,String source,Function<Instant,LegacyCronRunner.Result> action) {}
final LegacyCronRunner runner;final Environment env;final List<Task> tasks=new ArrayList<>();
final ThreadPoolTaskScheduler scheduler=new ThreadPoolTaskScheduler();
final Map<String,String> schedulingErrors=new java.util.concurrent.ConcurrentHashMap<>();
final Set<String> enabled;
LegacyCronTasks(LegacyCronRunner runner,LegacyCronCompliance compliance,LegacyCronBusiness business,LegacyCronExports exports,LegacyCronFiles files,LegacyCronReplication replication,LegacyCronSoap soap,Environment env){
this.runner=runner;this.env=env;
enabled=new LinkedHashSet<>();for(String s:env.getProperty("app.legacy-cron.jobs","").split(","))if(!s.isBlank())enabled.add(s.strip());
add("courier-logout","Abmeldung nach automatischem Auftragsentzug","courier-logout","0 */30 * * * *","tools/logout_cr_by_autorevokes.php",slot->business.logout());
for(long hq=1;hq<=11;hq++)for(var rule:LegacyCronCompliance.RULES){if(rule.script()==1715&&hq!=8)continue;final long branch=hq;
add("stb-"+hq+"-"+rule.script(),rule.label()+" · NL "+hq,"stb-compliance","0 0 4 * * MON-FRI","tools/auto_trigger_check_metafield_mtfk_"+rule.script()+".php",slot->compliance.run(branch,rule,slot.atZone(ZONE).toLocalDate()));
}
for(var branch:LegacyCronExports.BRANCHES)for(int i=1;i<=3;i++){final int category=i;
add("sb-export-"+branch.hq()+"-"+i,LegacyCronExports.profile(i)+" · NL "+branch.hq(),"sb-master-export","0 45 20 * * *","tools/cron_export_SB.php",slot->exports.run(branch,category,slot));
}
add("hha-safebags","HHA · Vergessene Safebags","hha-safebags","0 40 9 * * *","illt/cron_special_HHA.php",slot->business.safebags(env.getProperty("app.legacy-cron.hha-mail-to",""),env.getProperty("app.legacy-cron.hha-mail-bcc","")));
add("mysql-status","MySQL-Status · Anwendungsdatenbank","mysql-status","0 */5 * * * *","sysadmin/votian_admin/mysql_status.php",slot->business.mysqlStatus());
add("file-maintenance","Altsystem-Dateiwartung","file-maintenance","0 1 0 * * *","sysadmin/votian_admin/do_bzip2.php",slot->files.run(slot.atZone(ZONE).toLocalDate()));
add("replication-monitor","Replikationsüberwachung","replication-monitor","0 */15 * * * *","sysadmin/cron_monitor_replication.php",slot->replication.run(slot.atZone(ZONE).toLocalDate()));
add("hha-soap-master","HHA · PTnova-Stammdaten","hha-soap-master","0 10 5 * * *","sysadmin/soap/soap.php · Modus 1",slot->soap.run(1));
add("hha-soap-status","HHA · PTnova-Statusdaten","hha-soap-status","0 20 5 * * *","sysadmin/soap/soap.php · Modus 0",slot->soap.run(0));
var known=Set.of("hha-soap-master","hha-soap-status","courier-logout","stb-compliance","sb-master-export","hha-safebags","mysql-status","file-maintenance","replication-monitor");
if(!known.containsAll(enabled))throw new IllegalArgumentException("Unbekannte native Cron-Gruppe: "+enabled.stream().filter(s->!known.contains(s)).toList());
scheduler.setPoolSize(2);scheduler.setThreadNamePrefix("legacy-cron-");scheduler.setRemoveOnCancelPolicy(true);
scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
scheduler.setWaitForTasksToCompleteOnShutdown(true);scheduler.setAwaitTerminationSeconds(30);
}
void add(String id,String label,String group,String defaultCron,String source,Function<Instant,LegacyCronRunner.Result> action){
String cron=env.getProperty("app.legacy-cron.schedules."+group,defaultCron);CronExpression.parse(cron);
tasks.add(new Task(id,label,group,cron,source,action));
}
@EventListener(ApplicationReadyEvent.class)
public void start(){scheduler.initialize();for(var task:tasks)if(enabled.contains(task.group())){
var scheduled=new java.util.concurrent.atomic.AtomicReference<Instant>();var trigger=new CronTrigger(task.cron(),ZONE);
scheduler.schedule(()->execute(task,scheduled.get()),context->{var next=trigger.nextExecution(context);scheduled.set(next);return next;});
}
org.slf4j.LoggerFactory.getLogger(LegacyCronTasks.class).info("Spring-Tasks: {} von {} eingeplant ({}; Gruppen: {}).",scheduler.getScheduledThreadPoolExecutor().getQueue().size(),tasks.size(),ZONE,String.join(",",enabled));
}
void execute(Task task,Instant slot){try{runner.execute(task.id(),slot,()->task.action().apply(slot));schedulingErrors.remove(task.id());}
catch(RuntimeException e){schedulingErrors.put(task.id(),"Task konnte nicht sicher protokolliert werden. Datenbankverbindung und Laufbeleg prüfen.");}}
@PreDestroy public void stop(){scheduler.shutdown();}
List<Map<String,Object>> view(){
var rows=new ArrayList<Map<String,Object>>();var now=ZonedDateTime.now(ZONE);
Map<String,List<Map<String,Object>>> stored=Map.of();String readError="";
try{stored=runner.db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcron' AND gdc_obj_id=0").stream().collect(java.util.stream.Collectors.groupingBy(r->LegacyMapping.text(r,"gdc_gen_fieldname")));}
catch(RuntimeException e){readError="Cron-Laufbelege konnten nicht gelesen werden.";}
for(var t:tasks){boolean active=enabled.contains(t.group());LegacyCronRunner.Run run=null;String error=schedulingErrors.getOrDefault(t.id(),"");
if(!readError.isEmpty())error=readError;
else try{run=runner.decode(t.id(),stored.getOrDefault(t.id(),List.of()));}catch(RuntimeException e){error="Cron-Laufbeleg konnte nicht gelesen werden.";}
var next=active?CronExpression.parse(t.cron()).next(now):null;
rows.add(fields("id",t.id(),"label",t.label(),"group",t.group(),"cron",t.cron(),"zone",ZONE.toString(),"source",t.source(),"enabled",active,"state",!error.isEmpty()?"ERROR":!active?"DISABLED":run==null?"WAITING":run.result().state(),"message",!error.isEmpty()?error:run==null?"Wartet auf den nächsten Termin.":run.result().message(),"lastRun",run==null?"":run.finished(),"slot",run==null?"":run.slot(),"processed",run==null?0:run.result().processed(),"nextRun",next==null?"":next.toInstant().toString()));
}
return rows;
}
// The two remaining HHA wrappers were explicitly retired by the operator on 2026-10-27.
static List<Map<String,Object>> unresolved(){return List.of();}
}
@@ -15,6 +15,8 @@ import static de.stadtbote.votian.OrderEntryService.fields;
@RestController
@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("&lt;Test"));
var business=new LegacyCronBusiness(db,support,mail);assertThat(business.logout().processed()).isEqualTo(1);
assertThat(business.logout().processed()).isZero();assertThat(db.queryForObject("SELECT COUNT(*) FROM phoenix_pda.commandexec",Integer.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT cr_locationzipcode FROM courier WHERE cr_id=3",String.class)).isEqualTo("12345"); // occupied even without postal travel-time data
assertThat(db.queryForObject("SELECT cr_locationzipcode FROM courier WHERE cr_id=2",String.class)).isEqualTo("12345");
assertThat(db.queryForObject("SELECT TIMESTAMPDIFF(HOUR,cmde_exec_time,cmde_exec_timelimit) FROM phoenix_pda.commandexec LIMIT 1",Integer.class)).isEqualTo(16);
assertThat(business.mysqlStatus().processed()).isGreaterThan(0);assertThat(db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_obj_type='ngdbstatus'",Integer.class)).isEqualTo(1);
}
}
@@ -0,0 +1,78 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.*;
import java.math.BigDecimal;
import java.util.*;
import java.util.function.Consumer;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.*;
import static de.stadtbote.votian.LegacyCronSoapProtocol.*;
public final class LegacyCronSoapIntegrationAssertions {
static final String SCHEMA="crm_qa_soap";
static final LocalDateTime NOW=LocalDateTime.of(2026,10,26,5,10);
public static void verify(DatabaseService db,Consumer<String> ddl){
for(String sql:List.of(
"CREATE TABLE headquarters(hq_id INT PRIMARY KEY,hq_disabled INT DEFAULT 0) ENGINE=InnoDB",
"CREATE TABLE article(at_id INT PRIMARY KEY,hq_id INT,at_match VARCHAR(100)) ENGINE=MyISAM",
"CREATE TABLE articlemessage(at_id INT,atm_code VARCHAR(50),atm_desc VARCHAR(255),atm_text TEXT,atm_prio INT) ENGINE=MyISAM",
"CREATE TABLE genericdatacontainer(gdc_id INT AUTO_INCREMENT PRIMARY KEY,gdc_obj_type VARCHAR(10),gdc_obj_id INT,gdc_gen_fieldname VARCHAR(50),gdc_context TEXT,gdc_content MEDIUMTEXT) ENGINE=InnoDB",
"CREATE TABLE parameter(par_id INT AUTO_INCREMENT PRIMARY KEY,emp_id INT,hq_id INT,par_key VARCHAR(100),par_value TEXT,par_text TEXT) ENGINE=MyISAM",
"CREATE TABLE stock(stk_id INT AUTO_INCREMENT PRIMARY KEY,hq_id INT,stk_pre_id INT,stk_obj_type VARCHAR(10),stk_obj_id INT,stk_name VARCHAR(30),stk_path VARCHAR(255),stk_barcode VARCHAR(50),ad_id INT,cs_id INT,stk_visible INT,stk_readonly INT,stk_modifytime DATETIME) ENGINE=MyISAM",
"CREATE TABLE stockarticle(stk_id INT,at_id INT,stkat_quantity INT,stkat_itemquantity INT) ENGINE=MyISAM"))ddl.accept(sql);
StringBuilder item=new StringBuilder("CREATE TABLE articleitem(ati_id INT AUTO_INCREMENT PRIMARY KEY,at_id INT,stk_id INT,ati_serialno VARCHAR(255)");
StringBuilder history=new StringBuilder("CREATE TABLE articleitemhistory(atih_id INT AUTO_INCREMENT PRIMARY KEY,at_id INT,stk_id INT,atih_serialno VARCHAR(255),atih_createtime DATETIME");
for(int n=1;n<=30;n++){item.append(String.format(",ati_data_%02d VARCHAR(50) DEFAULT ''",n));history.append(String.format(",atih_data_%02d VARCHAR(50) DEFAULT ''",n));}
ddl.accept(item+") ENGINE=MyISAM");ddl.accept(history+") ENGINE=MyISAM");
ddl.accept("INSERT INTO headquarters VALUES(1,0),(2,0)");ddl.accept("INSERT INTO article VALUES(10,1,'STU'),(11,0,'H&W'),(12,2,'STU')");
ddl.accept("INSERT INTO stock(stk_id,hq_id,stk_pre_id,stk_path) VALUES(1000,1,0,''),(2000,2,0,'')");
var support=new BusinessSupport(db,new ObjectMapper().findAndRegisterModules(),SCHEMA);
var soap=new LegacyCronSoap(db,support,new MockEnvironment());
var master=new Payload(List.of(new Device("STU","42"),new Device("STU","43"),new Device("MISSING","44")),List.of(new Message("STU","E01","Tür offen","Prüfen")),List.of());
assertThat(soap.apply(1,1,1000,master,"",NOW).processed()).isEqualTo(3);
assertThat(soap.checkpoint(1,1)).isEqualTo("20261026051000");
assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitem",Integer.class)).isEqualTo(2);
assertThat(db.queryForObject("SELECT COUNT(*) FROM stock WHERE stk_obj_type='ati'",Integer.class)).isEqualTo(3);
assertThat(db.queryForObject("SELECT COUNT(*) FROM parameter",Integer.class)).isEqualTo(2);
var root=db.queryForObject("SELECT stk_id FROM stock WHERE stk_name='SYSTEM-SERVICEUNIT'",Long.class);
assertThat(db.queryForObject("SELECT stk_path FROM stock WHERE stk_name='FA42'",String.class)).isEqualTo("//"+root+"//");
assertThat(db.queryForObject("SELECT stkat_itemquantity FROM stockarticle",Integer.class)).isEqualTo(2);
// Replayed imports keep existing units, stocks and counts stable; messages are refreshed.
soap.apply(1,1,1000,master,soap.checkpoint(1,1),NOW.plusMinutes(1));
assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitem",Integer.class)).isEqualTo(2);
assertThat(db.queryForObject("SELECT COUNT(*) FROM articlemessage",Integer.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT stkat_quantity FROM stockarticle",Integer.class)).isEqualTo(2);
var state=new Status("STU","42",NOW.toLocalDate(),List.of(new Notice("E01","keine Angabe"),new Notice("E01","Tür offen"),new Notice("UNKNOWN","")),Map.of(5,new BigDecimal("12.50"),9,new BigDecimal("9")),new BigDecimal("120"));
var payload=new Payload(List.of(),List.of(),List.of(state));
soap.apply(0,1,1000,payload,"",NOW.plusMinutes(10));
assertThat(db.queryForObject("SELECT ati_data_17 FROM articleitem WHERE ati_serialno='42'",String.class)).isEqualTo("E01");
assertThat(db.queryForObject("SELECT ati_data_05 FROM articleitem WHERE ati_serialno='42'",String.class)).isEqualTo("12.50");
assertThat(db.queryForObject("SELECT ati_data_09 FROM articleitem WHERE ati_serialno='42'",String.class)).isEqualTo("120");
assertThat(db.queryForObject("SELECT atih_data_04 FROM articleitemhistory",String.class)).isEqualTo("2026-10-26 03:00:00");
assertThat(db.queryForObject("SELECT atih_data_09 FROM articleitemhistory",String.class)).isEqualTo("120");
soap.apply(0,1,1000,payload,soap.checkpoint(1,0),NOW.plusMinutes(11));
assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitemhistory",Integer.class)).isEqualTo(1);
var cards=new Payload(List.of(),List.of(),List.of(new Status("STU","43",NOW.toLocalDate(),List.of(),Map.of(),BigDecimal.TEN)));
soap.apply(0,1,1000,cards,soap.checkpoint(1,0),NOW.plusMinutes(12));
assertThat(db.queryForObject("SELECT ati_data_09 FROM articleitem WHERE ati_serialno='43'",String.class)).isEqualTo("10");
assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitemhistory",Integer.class)).isEqualTo(1); // no cash history exists for 43
// Preflight failure does not advance the checkpoint.
String before=soap.checkpoint(1,1);
assertThatThrownBy(()->soap.apply(1,1,2000,new Payload(List.of(new Device("STU","45")),List.of(),List.of()),before,NOW.plusHours(1))).hasMessageContaining("Teilwirkungen");
assertThat(soap.checkpoint(1,1)).isEqualTo(before);
assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitem WHERE ati_serialno='45'",Integer.class)).isZero();
// A late MyISAM error can leave a unit written; retain old checkpoint and require review.
ddl.accept("INSERT INTO stockarticle VALUES(1000,10,2,2)");
var runner=new LegacyCronRunner(db,support);var slot=NOW.atZone(LegacyCronTasks.ZONE).toInstant();
var bad=new Payload(List.of(new Device("STU","46")),List.of(),List.of());
assertThat(runner.execute("soap-partial",slot,()->soap.apply(1,1,1000,bad,before,NOW.plusHours(1))).state()).isEqualTo("UNCERTAIN");
assertThat(soap.checkpoint(1,1)).isEqualTo(before);
assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitem WHERE ati_serialno='46'",Integer.class)).isEqualTo(1);
assertThat(runner.execute("soap-partial",slot.plusSeconds(86400),()->{throw new AssertionError("Must not retry");}).state()).isEqualTo("UNCERTAIN");
// Failed parsing/network paths never enter a write transaction or advance the mark.
var broken=new LegacyCronSoap(db,support,new MockEnvironment(),java.net.http.HttpClient.newHttpClient(),Clock.fixed(NOW.plusHours(2).atZone(LegacyCronTasks.ZONE).toInstant(),LegacyCronTasks.ZONE)){@Override byte[] fetch(int mode,String since){return "<broken/>".getBytes(java.nio.charset.StandardCharsets.UTF_8);}};
assertThatThrownBy(()->broken.run(1)).hasMessageContaining("SOAP");assertThat(soap.checkpoint(1,1)).isEqualTo(before);
}
}
@@ -0,0 +1,87 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import java.time.*;
import java.net.http.*;
import java.nio.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.LegacyCronSoapProtocol.*;
class LegacyCronSoapTest {
static byte[] response(int mode,String data){return ("<s:Envelope xmlns:s='"+SOAP+"'><s:Body><p:"+operation(mode)+"Response xmlns:p='"+SAP+"'>"+data+"</p:"+operation(mode)+"Response></s:Body></s:Envelope>").getBytes(StandardCharsets.UTF_8);}
@Test void requestAndCheckpointMatchPhp(){
String request=request(0,"20261025000000");
assertThat(request).contains("<IvActualFg>X</IvActualFg>","H&amp;W","STVS","20261025000000");
assertThat(request(1,"20261025000000")).doesNotContain("IvActualFg");
assertThat(action(1)).endsWith(":ZPTN_WS_TOURENPLANUNG:ZPtnWstpMasterdataRequest");
var now=LocalDateTime.of(2026,10,26,5,10);
assertThat(since("",now)).isEqualTo("20261025000000");
assertThat(since("20261024051000",now)).isEqualTo("20261025000000");
assertThat(since("20261025051000",now)).isEqualTo("20261025051000");
assertThatThrownBy(()->since("20261027051000",now)).hasMessageContaining("Zukunft");
assertThatThrownBy(()->timestamp("20260230051000")).hasMessageContaining("Zeitstempel");
assertThatThrownBy(()->request(2,"20261025000000")).hasMessageContaining("Modus");
}
@Test void masterReadsRepeatedAndSingletonEntriesAndFiltersOrganizations(){
var result=parse(response(1,"""
<EsMasterdata><Devicetypes><item><OrgunhdIdPr>HHA</OrgunhdIdPr><DevtyhdId>H&amp;W</DevtyhdId>
<Devices><item><DevichdId>000042</DevichdId></item><item><DevichdId>000043</DevichdId></item></Devices>
<Swreleases><item><Messages><item><DevsrmsgId>E01</DevsrmsgId><Description>Tür offen</Description><Msgtext>Prüfen</Msgtext></item></Messages></item></Swreleases>
</item><item><OrgunhdIdPr>OTHER</OrgunhdIdPr><DevtyhdId>STU</DevtyhdId><Devices><item><DevichdId>99</DevichdId></item></Devices></item>
</Devicetypes></EsMasterdata>"""),1);
assertThat(result.devices()).containsExactly(new Device("H&W","42"),new Device("H&W","43"));
assertThat(result.messages()).containsExactly(new Message("H&W","E01","Tür offen","Prüfen"));
assertThat(parse(response(1,"<EsMasterdata><Devicetypes/></EsMasterdata>"),1).devices()).isEmpty();
}
@Test void statusUsesNominalAmountOpenNoticesAndCardStock(){
var result=parse(response(0,"""
<EsStatusdata><Statusdata><item><OrgunhdIdPr>HDAG</OrgunhdIdPr><DevtyhdId>STU</DevtyhdId><DevichdId>000042</DevichdId><TstmpDevsthd>20261026110000</TstmpDevsthd>
<Devstopn><item><DevsrmsgId>E01</DevsrmsgId><DevCatMf>keine Angabe</DevCatMf><TstmpFinishd>00000000000000</TstmpFinishd></item>
<item><DevsrmsgId>E02</DevsrmsgId><TstmpFinishd>20261026100000</TstmpFinishd></item></Devstopn>
<Devstmny><item><CashboxType>BEK</CashboxType><PositionDesc>1</PositionDesc><NominalAmnt>12,50</NominalAmnt><NominalQnty>999</NominalQnty></item>
<item><CashboxType>RGS</CashboxType><PositionDesc>15</PositionDesc><NominalAmnt>3</NominalAmnt></item></Devstmny>
<Devstmat><item><MatUnitExt>KARTEN</MatUnitExt><NominalStock>120</NominalStock></item></Devstmat>
</item></Statusdata></EsStatusdata>"""),0).statuses().getFirst();
assertThat(result.serial()).isEqualTo("42");assertThat(result.date()).isEqualTo(LocalDate.of(2026,10,26));
assertThat(result.notices()).containsExactly(new Notice("E01","keine Angabe"));
assertThat(result.money()).containsEntry(5,new BigDecimal("12.50")).containsEntry(15,new BigDecimal("3"));
assertThat(result.cards()).isEqualByComparingTo("120");
assertThat(moneyField("ZGS","0")).isEqualTo(7);assertThat(moneyField("ZGS","2")).isEqualTo(9);
assertThat(moneyField("MEK","1")).isEqualTo(6);assertThat(moneyField("BEK","")).isNull();assertThat(moneyField("RGS","9")).isNull();
}
@Test void malformedFaultAndEntityPayloadsNeverBecomeEmptySuccess(){
for(String xml:List.of("garbage","<!DOCTYPE x [<!ENTITY y SYSTEM 'file:///etc/passwd'>]><x>&y;</x>","<s:Envelope xmlns:s='"+SOAP+"'><s:Body><s:Fault><faultstring>secret</faultstring></s:Fault></s:Body></s:Envelope>"))
assertThatThrownBy(()->parse(xml.getBytes(StandardCharsets.UTF_8),1)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).hasMessageNotContaining("secret");
assertThatThrownBy(()->parse(response(1,"<EsMasterdata/>"),1)).hasMessageContaining("Devicetypes");
assertThatThrownBy(()->parse(response(0,"<EsStatusdata><Statusdata/></EsStatusdata>"),1)).hasMessageContaining("MasterdataResponse");
assertThatThrownBy(()->amount("NaN")).hasMessageContaining("Bestand");
}
@Test void boundedBodyCancelsOversizedResponseBeforeBufferingIt(){
var body=LegacyCronSoap.limitedBody();var subscription=mock(Flow.Subscription.class);body.onSubscribe(subscription);
var buffer=ByteBuffer.allocate(1024*1024);
for(int i=0;i<33;i++)body.onNext(List.of(buffer.duplicate()));
verify(subscription).cancel();assertThat(body.getBody().toCompletableFuture()).isCompletedExceptionally();
}
static MockEnvironment env(){return new MockEnvironment().withProperty("app.legacy-cron.soap.url","https://sap.example.invalid/import").withProperty("app.legacy-cron.soap.user","qa").withProperty("app.legacy-cron.soap.password","test-only");}
@SuppressWarnings("unchecked") @Test void fetchUsesConfiguredHttpsAndDoesNotAcceptRedirectsOrServerErrors()throws Exception{
var db=mock(DatabaseService.class);var http=mock(HttpClient.class);var env=env();
var soap=new LegacyCronSoap(db,mock(BusinessSupport.class),env,http,Clock.systemUTC());
var response=(HttpResponse<byte[]>)mock(HttpResponse.class);when(response.statusCode()).thenReturn(200);when(response.body()).thenReturn(new byte[]{1});
when(http.sendAsync(any(HttpRequest.class),any(HttpResponse.BodyHandler.class))).thenReturn(CompletableFuture.completedFuture(response));
assertThat(soap.fetch(1,"20261025000000")).containsExactly((byte)1);
var capture=org.mockito.ArgumentCaptor.forClass(HttpRequest.class);verify(http).sendAsync(capture.capture(),any(HttpResponse.BodyHandler.class));
assertThat(capture.getValue().uri().toString()).isEqualTo("https://sap.example.invalid/import");assertThat(capture.getValue().headers().firstValue("SOAPAction").orElseThrow()).isEqualTo(action(1));
for(int status:List.of(302,401,500)){when(response.statusCode()).thenReturn(status);assertThatThrownBy(()->soap.fetch(0,"20261025000000")).hasMessageContaining("HTTP "+status);}
verifyNoInteractions(db);
for(String url:List.of("http://sap.example.invalid","https://user:secret@sap.example.invalid","https://sap.example.invalid/#fragment",""))assertThatThrownBy(()->LegacyCronSoap.endpoint(url)).hasMessageContaining("HTTPS");
env.setProperty("app.legacy-cron.soap.password","");assertThatThrownBy(()->soap.fetch(1,"20261025000000")).hasMessageContaining("Zugangsdaten");
}
}
@@ -0,0 +1,154 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.scheduling.support.CronExpression;
import java.time.*;
import java.nio.file.*;
import java.nio.file.attribute.FileTime;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class LegacyCronTest {
static LegacyCronTasks tasks(LegacyCronRunner runner,MockEnvironment env){return new LegacyCronTasks(runner,mock(LegacyCronCompliance.class),mock(LegacyCronBusiness.class),mock(LegacyCronExports.class),mock(LegacyCronFiles.class),mock(LegacyCronReplication.class),mock(LegacyCronSoap.class),env);}
@Test void onlyActivePhpBranchesAndScriptsAreRegistered(){
var t=tasks(mock(LegacyCronRunner.class),new MockEnvironment());
assertThat(t.tasks).hasSize(115); // 78 mail checks, 30 exports, seven other scheduled jobs
assertThat(t.enabled).isEmpty();
assertThat(t.tasks.stream().filter(x->x.group().startsWith("hha-soap")).map(LegacyCronTasks.Task::cron)).containsExactly("0 10 5 * * *","0 20 5 * * *");
assertThat(LegacyCronTasks.unresolved()).isEmpty();
assertThat(t.tasks.stream().filter(x->x.id().endsWith("-1715"))).extracting(LegacyCronTasks.Task::id).containsExactly("stb-8-1715");
assertThat(t.tasks.stream().filter(x->x.group().equals("sb-master-export"))).hasSize(30).noneMatch(x->x.id().startsWith("sb-export-90-")||x.id().startsWith("sb-export-10-"));
assertThat(t.tasks.stream().map(LegacyCronTasks.Task::id).distinct().count()).isEqualTo(t.tasks.size());
assertThatThrownBy(()->tasks(mock(LegacyCronRunner.class),new MockEnvironment().withProperty("app.legacy-cron.jobs","typo"))).isInstanceOf(IllegalArgumentException.class);
}
@Test void containerGroupsOverrideLegacySettingAndExplicitEmptyDisablesScheduling()throws Exception{
var yaml=new org.springframework.boot.env.YamlPropertySourceLoader().load("application",new org.springframework.core.io.ClassPathResource("application.yml"));
var env=new MockEnvironment().withProperty("LEGACY_CRON_JOBS","mysql-status");
yaml.forEach(env.getPropertySources()::addLast);
assertThat(tasks(mock(LegacyCronRunner.class),env).enabled).containsExactly("mysql-status");
env.setProperty("SPRING_TASK_GROUPS","hha-soap-master,hha-soap-status");
assertThat(tasks(mock(LegacyCronRunner.class),env).enabled).containsExactly("hha-soap-master","hha-soap-status");
env.setProperty("SPRING_TASK_GROUPS","");
assertThat(tasks(mock(LegacyCronRunner.class),env).enabled).isEmpty();
}
@Test void allNativeGroupsAreScheduledWithoutExecutingActionsAtStartup(){
var runner=mock(LegacyCronRunner.class);var env=new MockEnvironment();
var inventory=tasks(runner,env);var groups=inventory.tasks.stream().map(LegacyCronTasks.Task::group).distinct().toList();
env.setProperty("app.legacy-cron.jobs",String.join(",",groups));
for(String group:groups)env.setProperty("app.legacy-cron.schedules."+group,"0 0 0 1 1 *");
var enabled=tasks(runner,env);
try{enabled.start();assertThat(enabled.scheduler.getScheduledThreadPoolExecutor().getQueue()).hasSize(115);verifyNoInteractions(runner);}
finally{enabled.stop();}
assertThat(enabled.scheduler.getScheduledThreadPoolExecutor().isTerminated()).isTrue();
}
@Test void schedulesUseBerlinAndSkipWeekends(){
var tasks=tasks(mock(LegacyCronRunner.class),new MockEnvironment());var zone=LegacyCronTasks.ZONE;
var friday=ZonedDateTime.of(2026,10,23,4,0,0,0,zone);
assertThat(CronExpression.parse(tasks.tasks.stream().filter(t->t.group().equals("stb-compliance")).findFirst().orElseThrow().cron()).next(friday)).isEqualTo(ZonedDateTime.of(2026,10,26,4,0,0,0,zone));
assertThat(CronExpression.parse("0 45 20 * * *").next(friday)).isEqualTo(friday.withHour(20).withMinute(45));
var transition=ZonedDateTime.of(2026,3,28,20,45,0,0,zone);
assertThat(CronExpression.parse("0 45 20 * * *").next(transition).getOffset()).isEqualTo(ZoneOffset.ofHours(2));
}
@Test void delayedExecutionRetainsTheScheduledSlot(){
var runner=mock(LegacyCronRunner.class);var tasks=tasks(runner,new MockEnvironment());var calls=new ArrayList<Instant>();
var task=new LegacyCronTasks.Task("x","x","x","0 0 4 * * *","x",slot->{calls.add(slot);return LegacyCronRunner.Result.ok(1);});
doAnswer(a->{java.util.function.Supplier<?> action=a.getArgument(2);return action.get();}).when(runner).execute(anyString(),any(),any());
var slot=Instant.parse("2026-10-26T03:00:00Z");tasks.execute(task,slot);assertThat(calls).containsExactly(slot);
}
@Test void monitoringBatchesReceiptsAndUsesBrowserCompatibleTimestamps(){
var db=mock(DatabaseService.class);var runner=new LegacyCronRunner(db,new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper()));
var service=tasks(runner,new MockEnvironment().withProperty("app.legacy-cron.jobs","mysql-status"));
var active=service.view().stream().filter(r->r.get("id").equals("mysql-status")).findFirst().orElseThrow();
assertThat(active.get("state")).isEqualTo("WAITING");assertThatCode(()->Instant.parse(active.get("nextRun").toString())).doesNotThrowAnyException();
verify(db,times(1)).queryForList(anyString());
}
static class MemoryRunner extends LegacyCronRunner {
Run saved;
MemoryRunner(DatabaseService db){super(db,new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper()));}
@Override Run find(String id){return saved;}
@Override void save(Run r,boolean insert){saved=r;}
}
@Test void persistentClaimPreventsReplayAndBlocksLaterRunsAfterUncertainSmtp(){
var db=mock(DatabaseService.class);doAnswer(a->{java.util.function.Supplier<?> f=a.getArgument(1);return f.get();}).when(db).cronOperation(anyString(),any());
var r=new MemoryRunner(db);var count=new AtomicInteger();var slot=Instant.parse("2026-10-26T03:00:00Z");
r.execute("mail",slot,()->{count.incrementAndGet();return new LegacyCronRunner.Result("UNCERTAIN","SMTP",1);});
var restarted=new MemoryRunner(db);restarted.saved=r.saved;
restarted.execute("mail",slot,()->{count.incrementAndGet();return LegacyCronRunner.Result.ok(1);});
restarted.execute("mail",slot.plusSeconds(86400),()->{count.incrementAndGet();return LegacyCronRunner.Result.ok(1);});
assertThat(count).hasValue(1);assertThat(restarted.saved.result().state()).isEqualTo("UNCERTAIN");
restarted.saved=new LegacyCronRunner.Run("mail",slot.toString(),"","",new LegacyCronRunner.Result("REVIEWED","",0));
restarted.execute("mail",slot.plusSeconds(86400),()->{count.incrementAndGet();return LegacyCronRunner.Result.ok(1);});assertThat(count).hasValue(2);
}
@Test void lockBusyAndReceiptWriteFailureNeverInvokeBusinessAction(){
var db=mock(DatabaseService.class);var action=mock(java.util.function.Supplier.class);var r=new MemoryRunner(db);
r.execute("mail",Instant.now(),action);verifyNoInteractions(action);
doAnswer(a->{java.util.function.Supplier<?> f=a.getArgument(1);return f.get();}).when(db).cronOperation(anyString(),any());
var broken=new MemoryRunner(db){@Override void save(Run r,boolean insert){throw new IllegalStateException("DB down");}};
assertThatThrownBy(()->broken.execute("mail",Instant.now(),action)).isInstanceOf(IllegalStateException.class);verifyNoInteractions(action);
}
@Test void compliancePreservesOtherFiltersAndPhpMonthOverflow(){
assertThat(LegacyCronCompliance.filter(",VA,PBSG,FZ,","PBSG",false)).isEqualTo(",VA,FZ,");
assertThat(LegacyCronCompliance.filter(",VA,PBSG,","PBSG",true)).isEqualTo(",VA,PBSG,");
assertThat(LegacyCronCompliance.expiry("31.08.2026",6)).isEqualTo(LocalDate.of(2027,3,3));
assertThatThrownBy(()->LegacyCronCompliance.expiry("31.02.2026",0)).hasMessageContaining("Ungültiges Datum");
var date=LocalDate.of(2026,10,26);
assertThat(LegacyCronCompliance.color(date.minusDays(1),date,false)).isEqualTo("FF0000");
assertThat(LegacyCronCompliance.color(date,date,false)).isEqualTo("FF6666");
assertThat(LegacyCronCompliance.color(date.plusDays(181),date,true)).isEmpty();
assertThat(LegacyCronCompliance.RULES.stream().filter(r->r.script()==1629).findFirst().orElseThrow().field()).isEqualTo(441);
}
@Test void mysqlRatesDoNotInventDeltasAfterAReset(){
var current=Map.of("Uptime","600","Questions","1200","Slow_queries","3","Qcache_hits","20","Com_select","80");
var first=LegacyCronBusiness.rates(current,Map.of());assertThat(first.get("qps")).isEqualTo("2.0");assertThat(first.get("qps_now")).isEmpty();assertThat(first.get("Ratio")).isEqualTo("20.0");
assertThat(LegacyCronBusiness.rates(current,Map.of("Uptime","700","Questions","2000")).get("qps_now")).isEmpty();
assertThat(LegacyCronBusiness.rates(current,Map.of("Uptime","300","Questions","300")).get("qps_now")).isEqualTo("3.0");
}
@Test void exportFailureAfterStartingStorageRequiresReviewBeforeTheNextRun(){
var db=mock(DatabaseService.class);var exports=mock(MasterExports.class);var runs=mock(MasterExportRuns.class);var transfer=mock(MasterExportTransfer.class);
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(a->a.<String>getArgument(0).contains("FROM user")?List.of(fields("usr_id",100001,"emp_id",1,"usr_name","Cron","emp_rights","1111111")):List.of(fields("expp_id",12)));
var state=new MasterExports.State(1,List.of(),List.of(),Map.of());when(exports.state(any(),eq(1),eq(12L))).thenReturn(state);
var selection=new MasterExportData.Selection(List.of(),List.of(Map.of("cmp_id",1)),List.of(),0);
when(exports.snapshot(any(),any())).thenReturn(new MasterExports.Snapshot(state,selection,"payload","revision"));
when(runs.start(any(LegacyUser.class),any())).thenThrow(AdminSupport.bad("partial storage failure"));
var result=new LegacyCronExports(db,exports,runs,transfer).run(LegacyCronExports.BRANCHES.getFirst(),1,Instant.parse("2026-10-26T19:45:00Z"));
assertThat(result.state()).isEqualTo("UNCERTAIN");verifyNoInteractions(transfer);
}
@TempDir Path root;
@Test void fileMaintenanceCompressesOnlyInactiveFilesAndDoesNotFollowLinks()throws Exception{
var bin=Files.createDirectory(root.resolve("bin"));var logs=Files.createDirectory(root.resolve("logs"));var temp=Files.createDirectory(root.resolve("temp"));
var old=Files.writeString(bin.resolve("mps2-bin.000001"),"old");var active=Files.writeString(bin.resolve("mps2-bin.000002"),"active");
Files.setLastModifiedTime(old,FileTime.fromMillis(1000));Files.setLastModifiedTime(active,FileTime.fromMillis(2000));
Files.writeString(logs.resolve("app_202609.log"),"past month");Files.writeString(logs.resolve("app_202610.log"),"current");
var outside=Files.writeString(root.resolve("preserve"),"keep");Files.createSymbolicLink(temp.resolve("link"),outside);Files.writeString(temp.resolve("download"),"delete");
var env=new MockEnvironment().withProperty("app.legacy-cron.files.binlog-directories",bin.toRealPath().toString()).withProperty("app.legacy-cron.files.log-directories",logs.toRealPath().toString()).withProperty("app.legacy-cron.files.temporary-directories",temp.toRealPath().toString());
var result=new LegacyCronFiles(env).run(LocalDate.of(2026,10,26));assertThat(result.processed()).isEqualTo(3);
assertThat(old).doesNotExist();assertThat(bin.resolve("mps2-bin.000001.bz2")).exists();assertThat(active).hasContent("active");
assertThat(logs.resolve("app_202609.log.bz2")).exists();assertThat(logs.resolve("app_202610.log")).hasContent("current");assertThat(outside).hasContent("keep");assertThat(Files.isSymbolicLink(temp.resolve("link"))).isTrue();
assertThat(new LegacyCronFiles(env).run(LocalDate.of(2026,10,26)).processed()).isZero();
}
@Test void compressionRefusesConflictingArchiveAndOverlappingCleanupRoots()throws Exception{
var file=Files.writeString(root.resolve("history.log"),"original");Files.writeString(root.resolve("history.log.bz2"),"invalid archive");
assertThatThrownBy(()->LegacyCronFiles.compress(file)).isInstanceOf(java.io.IOException.class);assertThat(file).hasContent("original");
var dir=root.toRealPath().toString();var env=new MockEnvironment().withProperty("app.legacy-cron.files.log-directories",dir).withProperty("app.legacy-cron.files.temporary-directories",dir);
assertThatThrownBy(()->new LegacyCronFiles(env).run(LocalDate.now())).hasMessageContaining("überlappen");assertThat(file).exists();
}
@Test void replicaCountersKeepPhpThresholdsAndAlertOnUnavailableReplica(){
var db=mock(DatabaseService.class);var mail=mock(AppointmentMailTransport.class);when(mail.configured()).thenReturn(true);
var env=new MockEnvironment().withProperty("app.legacy-cron.replication.targets","{\"STB\":[\"primary:3306\",\"replica:3306\"]}").withProperty("app.legacy-cron.replication.user","qa").withProperty("app.legacy-cron.replication.mail-to","qa@example.invalid");
var service=new LegacyCronReplication(db,new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper()),mail,env);
when(db.cronReplicaCount(eq("primary:3306"),any(),any(),any(),any())).thenReturn(10L);when(db.cronReplicaCount(eq("replica:3306"),any(),any(),any(),any())).thenReturn(6L);
assertThat(service.run(LocalDate.of(2026,10,26)).state()).isEqualTo("OK");verify(mail,never()).sendOrderHtml(any(),any(),any(),any(),any(),any());
assertThat(LegacyCronReplication.GROUPS.get("INT").tolerance()).isEqualTo(7);
org.springframework.test.util.ReflectionTestUtils.setField(mail,"from","sender@example.invalid");
when(db.cronReplicaCount(eq("replica:3306"),any(),any(),any(),any())).thenThrow(new IllegalStateException("password=not-for-output"));
var failed=service.run(LocalDate.of(2026,10,26));assertThat(failed.state()).isEqualTo("ERROR");assertThat(failed.message()).contains("nicht erreichbar").doesNotContain("password");
verify(mail).sendOrderHtml(eq("sender@example.invalid"),eq(List.of("qa@example.invalid")),eq(List.of()),eq(List.of()),anyString(),contains("nicht erreichbar"));
}
}
@@ -44,6 +44,25 @@ class DatabaseServiceTest {
}
@org.junit.jupiter.api.AfterEach void closeFixturePool(){if(integrationPool!=null)integrationPool.close();}
@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 {