Erweitere Workflows und Statusmails und bereinige Stadtbote-Tasks

Übernehme die PHP-Fachanpassungen für Foto-Workflows, Mail-Empfänger, Fahrzeugdaten, Nachbearbeitung und PZM-Festpreise.

Beschränke Cron-Funktionen auf Stadtbote, verbessere Java-Typsicherheit und Nullbehandlung und aktualisiere Tests und Dokumentation.

Erhöhe die Backend-Version auf 0.2.1 und passe Maven-Ressourcen sowie VS-Code-Projekteinstellungen an.
This commit is contained in:
Sven committed 2026-11-10 11:32:12 +01:00
1 parent 9de2358480
commit 33aa4b3682
476 files changed
+2726 -2899

No files matched your search

+8 -20
View File
@@ -1,40 +1,23 @@
# Als .env im Projektstamm speichern und die vorhandenen Datenbankwerte eintragen.
# Native Spring-Tasks (siehe docs/CRON-MIGRATION.md); kein Cron-Daemon erforderlich.
# Das Docker-Image aktiviert alle neun Gruppen über SPRING_TASK_GROUPS.
# Das Docker-Image aktiviert alle fünf Stadtbote-Gruppen über SPRING_TASK_GROUPS.
# Optional die Image-Vorgabe überschreiben, z. B. nur Diagnose:
# SPRING_TASK_GROUPS=mysql-status
# Alle zeitgesteuerten Gruppen ausdrücklich deaktivieren: SPRING_TASK_GROUPS=
# Gruppen können einzeln mit Komma gewählt werden:
# courier-logout,stb-compliance,sb-master-export,hha-safebags,mysql-status,file-maintenance,replication-monitor,hha-soap-master,hha-soap-status
# courier-logout,stb-compliance,sb-master-export,mysql-status,replication-monitor
# Kompatibilitätsfallback für direkten Java-Start ohne SPRING_TASK_GROUPS.
LEGACY_CRON_JOBS=
LEGACY_CRON_HHA_MAIL_TO=
LEGACY_CRON_HHA_MAIL_BCC=
# PTnova: HTTPS-Endpunkt und Zugänge der zu dieser NG-Instanz gehörenden SAP-Umgebung.
LEGACY_CRON_SOAP_URL=
LEGACY_CRON_SOAP_USER=
LEGACY_CRON_SOAP_PASSWORD=
LEGACY_CRON_SOAP_HEADQUARTERS=1
LEGACY_CRON_SOAP_CONTAINER_STOCK=1000
# JSON: Gruppen STB/HTG/HTM/HTM_OLD/HHA/INT/MUH -> [Primärserver:Port,Replikat:Port,...]
# JSON: STB -> [Primärserver:Port,Replikat:Port,...]; ausschließlich Stadtbote.
LEGACY_CRON_REPLICATION_TARGETS={}
LEGACY_CRON_REPLICATION_USER=
LEGACY_CRON_REPLICATION_PASSWORD=
LEGACY_CRON_REPLICATION_MAIL_TO=
# Ausschließlich dedizierte, eingebundene Altverzeichnisse; mit Komma trennen.
# TEMPORARY: alle regulären Dateien werden wie in PHP rekursiv entfernt!
LEGACY_CRON_BINLOG_DIRECTORIES=
LEGACY_CRON_LOG_DIRECTORIES=
LEGACY_CRON_TEMPORARY_DIRECTORIES=
# Die Vorgabezeiten stehen in application.yml, jeweils Europe/Berlin (Spring: sechs Felder).
# LEGACY_CRON_SOAP_MASTER_SCHEDULE=0 10 5 * * *
# LEGACY_CRON_SOAP_STATUS_SCHEDULE=0 20 5 * * *
# LEGACY_CRON_LOGOUT_SCHEDULE=0 */30 * * * *
# LEGACY_CRON_STB_MAIL_SCHEDULE=0 0 4 * * MON-FRI
# LEGACY_CRON_SB_EXPORT_SCHEDULE=0 45 20 * * *
# LEGACY_CRON_HHA_SAFEBAG_SCHEDULE=0 40 9 * * *
# LEGACY_CRON_DB_STATUS_SCHEDULE=0 */5 * * * *
# LEGACY_CRON_FILES_SCHEDULE=0 1 0 * * *
# LEGACY_CRON_REPLICATION_SCHEDULE=0 */15 * * * *
# Prozessumgebungsvariablen haben Vorrang vor dieser Datei.
# Für docker run --env-file .env: Werte ohne umschließende Anführungszeichen.
@@ -174,3 +157,8 @@ DEVICE_PHONE_SYNC_ENABLED=true
ORDER_COMPLETION_WORKER_ENABLED=false
ORDER_COMPLETION_PHP_STOPPED=false
ORDER_COMPLETION_HEADQUARTERS=
# Externe Foto-Workflow-Verwaltung; URL ohne Query. Leer: /wf/workflows/index.html am Ursprung von LEGACY_APP_URL.
WORKFLOW_MANAGEMENT_URL=
# Gemeinsamer HMAC-SHA256-Schlüssel des Workflow-Editors (nicht in Git speichern).
WORKFLOW_MANAGEMENT_SECRET=
+15
View File
@@ -1,5 +1,20 @@
{
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "automatic",
"java.import.exclusions": [
"**/node_modules/**",
"**/.metadata/**",
"**/archetype-resources/**",
"**/META-INF/maven/**",
"**/work/**"
],
"maven.excludedFolders": [
"**/.*",
"**/node_modules",
"**/target",
"**/bin",
"**/archetype-resources",
"**/work"
],
"git.ignoredRepositories": ["votian"]
}
+1 -1
View File
@@ -7,7 +7,7 @@ COPY --chown=10001:10001 ${JAR_FILE} app.jar
# Native Spring scheduling only; Java remains PID 1, with no PHP or cron daemon.
# Override SPRING_TASK_GROUPS to select groups; an explicit empty value disables them.
ENV SERVER_ADDRESS=0.0.0.0 \
SPRING_TASK_GROUPS=courier-logout,stb-compliance,sb-master-export,hha-safebags,mysql-status,file-maintenance,replication-monitor,hha-soap-master,hha-soap-status
SPRING_TASK_GROUPS=courier-logout,stb-compliance,sb-master-export,mysql-status,replication-monitor
USER 10001:10001
EXPOSE 8080
+3 -1
View File
@@ -105,6 +105,8 @@ Das Sitzungscookie ist `HttpOnly` und `SameSite=Lax`. Standardmäßig ist nach 3
|---|---|
| `DB_URL`, `DB_USER`, `DB_PASSWORD` | Datenbankzugang aus `.env`; Prozessumgebung hat Vorrang |
| `LEGACY_APP_URL` | Basis-URL der vorhandenen PHP-Anwendung, damit Aktionen die bestehenden Fachabläufe öffnen |
| `WORKFLOW_MANAGEMENT_URL` | URL des externen Foto-Workflow-Editors ohne Query; leer: `/wf/workflows/index.html` am Ursprung von `LEGACY_APP_URL` |
| `WORKFLOW_MANAGEMENT_SECRET` | Gemeinsamer HMAC-SHA256-Schlüssel des Workflow-Editors; ausschließlich als Laufzeit-Secret setzen |
| `PORT` | Spring-Boot-Port, Standard 8080 |
| `SERVER_ADDRESS` | Bind-Adresse, Standard `127.0.0.1` |
| `FORWARD_HEADERS_STRATEGY` | Auswertung der Proxy-Header, Standard `native` |
@@ -166,4 +168,4 @@ Auftrags-/Kurierkennzahlen und Geräte-Abschlussmeldungen sind auf die ursprüng
### Zeitgesteuerte Spring-Tasks
Die bestätigte PHP-Crontab wird über native Spring-Tasks abgelöst. Das Docker-Image aktiviert beim Start alle neun Gruppen (115 Tasks) über `SPRING_TASK_GROUPS`; feste Termine gelten nach Europe/Berlin. Der Container startet ausschließlich Java und benötigt weder Cron-Daemon noch PHP. `SPRING_TASK_GROUPS` kann auf einzelne Gruppen oder einen leeren Wert gesetzt werden. Beim direkten Java-Start bleibt `LEGACY_CRON_JOBS` als Fallback erhalten. Der Betriebsstatus zeigt dauerhafte Laufbelege. Die beiden nicht mehr benötigten HHA-Aufrufe entfallen nach Betreiberentscheidung vom 27.10.2026; damit sind alle weiterhin benötigten PHP-Cron-Aufrufe implementiert. Zeitpläne, Konfiguration, Änderungen gegenüber PHP und Wiederanlauf nach einem unterbrochenen Versand stehen in [CRON-MIGRATION.md](docs/CRON-MIGRATION.md).
Die bestätigte PHP-Crontab wird über native Spring-Tasks abgelöst. Das Docker-Image aktiviert beim Start alle fünf Stadtbote-Gruppen (111 Tasks) über `SPRING_TASK_GROUPS`; feste Termine gelten nach Europe/Berlin. Der Container startet ausschließlich Java und benötigt weder Cron-Daemon noch PHP. `SPRING_TASK_GROUPS` kann auf einzelne Gruppen oder einen leeren Wert gesetzt werden. Beim direkten Java-Start bleibt `LEGACY_CRON_JOBS` als Fallback erhalten. Der Betriebsstatus zeigt dauerhafte Laufbelege. HHA-/PTnova-Funktionen, fremde Replikationsgruppen und die Wartung alter PHP-Serverdateien wurden am 27.10.2026 entfernt. Die Replikationsüberwachung ist auf Stadtbote (`STB`) begrenzt. Zeitpläne, Konfiguration, Änderungen gegenüber PHP und Wiederanlauf nach einem unterbrochenen Versand stehen in [CRON-MIGRATION.md](docs/CRON-MIGRATION.md).
+33 -3
View File
@@ -2,10 +2,9 @@
<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.2.0</version>
<groupId>de.stadtbote</groupId><artifactId>votian-backend</artifactId><version>0.2.1</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>
@@ -20,5 +19,36 @@
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><resources><resource><directory>src/main/resources</directory></resource><resource><directory>../frontend/dist</directory><targetPath>static</targetPath></resource></resources><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
<build>
<resources>
<resource><directory>src/main/resources</directory></resource>
<resource><directory>${project.build.directory}/frontend-resources</directory></resource>
</resources>
<plugins>
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-antrun-plugin</artifactId><version>3.1.0</version>
<executions><execution>
<id>stage-frontend</id><phase>generate-resources</phase><goals><goal>run</goal></goals>
<configuration><target>
<!-- Keep Maven/Eclipse resources inside this project, while Vite owns frontend/dist. -->
<delete dir="${project.build.directory}/frontend-resources"/>
<copy todir="${project.build.directory}/frontend-resources/static">
<fileset dir="${project.basedir}/../frontend/dist" erroronmissingdir="false"/>
</copy>
</target></configuration>
</execution></executions>
</plugin>
</plugins>
<pluginManagement><plugins><plugin>
<groupId>org.eclipse.m2e</groupId><artifactId>lifecycle-mapping</artifactId><version>1.0.0</version>
<configuration><lifecycleMappingMetadata><pluginExecutions><pluginExecution>
<pluginExecutionFilter>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-antrun-plugin</artifactId>
<versionRange>[3.1.0,)</versionRange><goals><goal>run</goal></goals>
</pluginExecutionFilter>
<action><execute><runOnIncremental>false</runOnIncremental><runOnConfiguration>true</runOnConfiguration></execute></action>
</pluginExecution></pluginExecutions></lifecycleMappingMetadata></configuration>
</plugin></plugins></pluginManagement>
</build>
</project>
@@ -22,6 +22,7 @@ class AccountTwoFactor {
record Reset(String requestId,String revision,String password,String code,String account){@Override public String toString(){return "Reset[redacted]";}}
record Cancel(String setupId){}
static final class Pending implements java.io.Serializable {
private static final long serialVersionUID = 5618186135847531088L;
final long user;final String id,revision,secret,setupRequest;final Instant expires;int attempts;
Pending(long user,String id,String revision,String secret,String setupRequest,Instant expires){this.user=user;this.id=id;this.revision=revision;this.secret=secret;this.setupRequest=setupRequest;this.expires=expires;}
@Override public String toString(){return "Pending[redacted]";}
@@ -6,7 +6,7 @@ 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;
/** Central PHP billing exports 4/5/6. The field catalog describes output, never executable SQL. */
final class AccountingExportData {
@@ -26,9 +26,9 @@ class AllergoImport {
static Address address(List<String> v,boolean sender,int line){String name=value(v,sender?2:13,50,line),street=(value(v,sender?5:15,50,line)+" "+value(v,sender?6:16,50,line)).trim(),postal=value(v,sender?8:19,10,line),city=value(v,sender?7:17,50,line),country=value(v,sender?10:18,10,line).toUpperCase(Locale.ROOT);if(country.equals("D"))country="DE";if(name.isBlank()||street.isBlank()||street.length()>50||postal.isBlank()||city.isBlank()||!country.matches("[A-Z]{2}"))throw bad("Zeile "+line+": "+(sender?"Absender":"Empfänger")+" benötigt Name, Straße (maximal 50 Zeichen), PLZ, Ort und zweistelliges Land.");if(List.of(street,postal,city,country).stream().anyMatch(s->s.contains("|")||s.contains(";")))throw bad("Zeile "+line+": ungültiges Adresstrennzeichen.");return new Address(name,street,postal,city,country,value(v,sender?4:14,50,line),value(v,sender?11:20,20,line),sender?value(v,12,50,line):"");}
static List<Shipment> parse(String csv){if(csv==null||csv.getBytes(StandardCharsets.UTF_8).length>1_000_000)throw bad("CSV fehlt oder ist größer als 1 MB.");var rows=CustomerServiceImports.parse(csv);if(rows.size()<2||rows.getFirst().line()!=1)throw bad("Eine Kopfzeile und mindestens eine Sendung sind erforderlich.");if(rows.size()>501)throw bad("Höchstens 500 Sendungen je Datei.");var seen=new HashSet<String>();var out=new ArrayList<Shipment>();for(int n=1;n<rows.size();n++){var row=rows.get(n);var v=row.values();int line=row.line();if(v.size()!=46&&!(v.size()==47&&v.get(46).isEmpty()))throw bad("Zeile "+line+": 46 ALLERGO-Spalten und optional eine leere Abschlussspalte erwartet.");for(int i=0;i<46;i++)value(v,i,i==32?148:500,line);String transaction=TourCsvImport.clean(value(v,1,30,line));if(transaction.isBlank()||!seen.add(transaction))throw bad("Zeile "+line+": fehlende oder doppelte Transaktionskennung.");var sender=address(v,true,line);var recipient=address(v,false,line);var remark=new ArrayList<String>();String[] labels={"Sender ID","Kd-Nr. Abs.","Kd-Nr. Fz.","Gewichtseinheit","Frachtzahler","Versanddatum","Referenz"};int[] cols={3,9,21,22,23,24,25};for(int i=0;i<cols.length;i++)remark.add(labels[i]+": "+v.get(cols[i]));remark.add("Paketmaße (LxBxH): "+v.get(28)+" x "+v.get(27)+" x "+v.get(26));String[] tail={"Gewichtseinheit","Warengewicht","Produktbeschreibung","Rechnungs-Flag","Anz. Packstücke","Dokumentensendung","Verpackung","Serviceart","Paketnr. Versand-UN","Letzte Änderung"};int[] extra={30,31,32,33,34,35,36,37,40,45};for(int i=0;i<extra.length;i++)remark.add(tail[i]+": "+v.get(extra[i]));String text=String.join("\n",remark);if(text.length()>8000)throw bad("Zeile "+line+": Sendungsbemerkung zu lang.");out.add(new Shipment(line,transaction,sender,recipient,text));}return List.copyOf(out);}
Map<String,Object> options(LegacyUser u,long customer){tours.zone.access(u,customer);var state=tours.state(u,customer,TourCsvImport.Format.TOURS);return fields("customer",text(state.customer(),"cs_eid"),"payer",id(state.payer(),"csc_id"),"date",LocalDate.now(clock).toString(),"maxRows",500,"maxNew",99);}
Plan plan(LegacyUser u,long customer,Command c){if(c==null)throw bad("Importdaten fehlen.");BusinessSupport.token(c.requestId());if(!LocalDate.now(clock).equals(c.date()))throw bad("Der Importtag hat sich geändert. Bitte den Import neu öffnen.");var rows=parse(c.csv());var state=tours.state(u,customer,TourCsvImport.Format.TOURS);var keys=rows.stream().map(Shipment::transaction).toList();var markers=db.queryForList("SELECT g.gdc_id,g.gdc_obj_id,g.gdc_content,j.hq_id,cc.cs_id FROM genericdatacontainer g LEFT JOIN tour t ON t.tr_id=g.gdc_obj_id LEFT JOIN job j ON j.jb_id=t.jb_id LEFT JOIN costcenter cc ON cc.csc_id=j.csc_id_payer WHERE g.gdc_obj_type='tr' AND g.gdc_gen_fieldname='info_0' AND g.gdc_context='ALLERGO' AND g.gdc_content IN ("+String.join(",",Collections.nCopies(keys.size(),"?"))+") ORDER BY g.gdc_id",keys.toArray());var existing=new HashSet<String>();for(var m:markers){if(id(m,"hq_id")!=u.headquartersId()||id(m,"cs_id")!=customer)throw bad("Eine Transaktionskennung ist bereits außerhalb dieses Kunden belegt oder nicht eindeutig zugeordnet.");if(!existing.add(text(m,"gdc_content")))throw bad("Mehrdeutige vorhandene ALLERGO-Transaktionskennung.");}var items=rows.stream().map(s->new Item(s,existing.contains(s.transaction()))).toList();var fresh=items.stream().filter(i->!i.existing()).toList();if(fresh.size()>99)throw bad("Höchstens 99 neue Sendungen und eine Abholstation je Auftrag. Bitte Datei aufteilen.");if(fresh.stream().map(i->i.shipment().sender()).distinct().count()>1)throw bad("Unterschiedliche Absender in den neuen Sendungen. Bitte je Absender eine Datei importieren.");var named=fresh.isEmpty()?List.<Map<String,Object>>of():db.queryForList("SELECT j.jb_id,j.jb_tourname,j.jb_ordertime,j.csc_id_payer FROM job j JOIN costcenter cc ON cc.csc_id=j.csc_id_payer WHERE j.hq_id=? AND cc.cs_id=? AND j.jb_tourname='ALLERGO' ORDER BY j.jb_id LIMIT 5001",u.headquartersId(),customer);if(named.size()>5000)throw bad("Zu viele bestehende ALLERGO-Tourennamen.");return new Plan(u.headquartersId(),customer,c.date(),state,items,markers,named);}
Map<String,Object> preview(LegacyUser u,long customer,Command c){tours.zone.access(u,customer);return db.readTransaction(()->{var p=plan(u,customer,c);var fresh=p.items().stream().filter(i->!i.existing()).map(Item::shipment).toList();return fields("revision",revision(p),"date",p.date().toString(),"sender",fresh.isEmpty()?null:fresh.getFirst().sender(),"shipments",fresh,"existing",p.items().stream().filter(Item::existing).map(Item::shipment).toList(),"renamed",p.renamed());});}
Plan plan(LegacyUser u,long customer,Command c){if(c==null)throw bad("Importdaten fehlen.");BusinessSupport.token(c.requestId());if(!LocalDate.now(clock).equals(c.date()))throw bad("Der Importtag hat sich geändert. Bitte den Import neu öffnen.");var rows=parse(c.csv());var state=tours.state(u,customer,TourCsvImport.Format.TOURS);var keys=rows.stream().map(entryValue -> entryValue.transaction()).toList();var markers=db.queryForList("SELECT g.gdc_id,g.gdc_obj_id,g.gdc_content,j.hq_id,cc.cs_id FROM genericdatacontainer g LEFT JOIN tour t ON t.tr_id=g.gdc_obj_id LEFT JOIN job j ON j.jb_id=t.jb_id LEFT JOIN costcenter cc ON cc.csc_id=j.csc_id_payer WHERE g.gdc_obj_type='tr' AND g.gdc_gen_fieldname='info_0' AND g.gdc_context='ALLERGO' AND g.gdc_content IN ("+String.join(",",Collections.nCopies(keys.size(),"?"))+") ORDER BY g.gdc_id",keys.toArray());var existing=new HashSet<String>();for(var m:markers){if(id(m,"hq_id")!=u.headquartersId()||id(m,"cs_id")!=customer)throw bad("Eine Transaktionskennung ist bereits außerhalb dieses Kunden belegt oder nicht eindeutig zugeordnet.");if(!existing.add(text(m,"gdc_content")))throw bad("Mehrdeutige vorhandene ALLERGO-Transaktionskennung.");}var items=rows.stream().map(s->new Item(s,existing.contains(s.transaction()))).toList();var fresh=items.stream().filter(i->!i.existing()).toList();if(fresh.size()>99)throw bad("Höchstens 99 neue Sendungen und eine Abholstation je Auftrag. Bitte Datei aufteilen.");if(fresh.stream().map(i->i.shipment().sender()).distinct().count()>1)throw bad("Unterschiedliche Absender in den neuen Sendungen. Bitte je Absender eine Datei importieren.");var named=fresh.isEmpty()?List.<Map<String,Object>>of():db.queryForList("SELECT j.jb_id,j.jb_tourname,j.jb_ordertime,j.csc_id_payer FROM job j JOIN costcenter cc ON cc.csc_id=j.csc_id_payer WHERE j.hq_id=? AND cc.cs_id=? AND j.jb_tourname='ALLERGO' ORDER BY j.jb_id LIMIT 5001",u.headquartersId(),customer);if(named.size()>5000)throw bad("Zu viele bestehende ALLERGO-Tourennamen.");return new Plan(u.headquartersId(),customer,c.date(),state,items,markers,named);}
Map<String,Object> preview(LegacyUser u,long customer,Command c){tours.zone.access(u,customer);return db.readTransaction(()->{var p=plan(u,customer,c);var fresh=p.items().stream().filter(i->!i.existing()).map(entryValue -> entryValue.shipment()).toList();return fields("revision",revision(p),"date",p.date().toString(),"sender",fresh.isEmpty()?null:fresh.getFirst().sender(),"shipments",fresh,"existing",p.items().stream().filter(entryValue -> entryValue.existing()).map(entryValue -> entryValue.shipment()).toList(),"renamed",p.renamed());});}
Map<String,Object> saved(LegacyUser u,long customer,Command c){var row=one(db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngallergo' AND gdc_obj_id=? AND gdc_gen_fieldname=? AND gdc_context=?",u.userId(),c.requestId(),Long.toString(customer)));try{return fields("saved",true,"repeated",true,"jobs",support.json.readValue(text(row,"gdc_content"),List.class));}catch(com.fasterxml.jackson.core.JsonProcessingException ex){throw bad("Importbestätigung nicht lesbar.");}}
long station(LegacyUser u,long job,int sort,long center,Address a,boolean pickup,String remark){var found=db.queryForList("SELECT ad_id FROM address WHERE ad_street=? AND ad_zipcode=? AND ad_city=? AND ad_country=? ORDER BY ad_id LIMIT 1",a.street(),a.postal(),a.city(),a.country());long address=found.isEmpty()?db.insert("address",fields("ad_street",a.street(),"ad_zipcode",a.postal(),"ad_city",a.city(),"ad_country",a.country())):id(found.getFirst(),"ad_id");long tr=tours.zone.saveStation(job,sort,address,center,fields("name",a.name(),"house","."),"",a.phone(),remark);db.update("UPDATE tour SET tr_person=?,tr_signname=?,tr_status=? WHERE tr_id=?",a.contact(),pickup?a.signer():"",pickup?1:0,tr);return tr;}
Map<String,Object> save(LegacyUser u,long customer,Command c){tours.zone.access(u,customer);if(c==null)throw bad("Importdaten fehlen.");BusinessSupport.token(c.requestId());var intent=List.of("ALLERGO",customer,c);if(support.receipt(u,c.requestId(),intent)!=null)return saved(u,customer,c);support.transactional("job","tour","tourservice","address","genericdatacontainer","headquarters");return db.tourCsvImportTransaction(()->{tours.zone.access(u,customer);lockHeadquarters(db,u);if(support.receipt(u,c.requestId(),intent)!=null)return saved(u,customer,c);var p=plan(u,customer,c);unchanged(p,c.revision());if(!p.renamed().isEmpty()&&!c.replaceNames())throw bad("Die Übertragung bestehender ALLERGO-Tourennamen muss ausdrücklich bestätigt werden.");for(var old:p.renamed()){db.update("UPDATE job SET jb_tourname='' WHERE jb_id=? AND hq_id=? AND jb_tourname='ALLERGO'",id(old,"jb_id"),u.headquartersId());support.audit(u,id(old,"jb_id"),customer,0,103,"ALLERGO: Tourname an importierten Auftrag übertragen",c.requestId());}var fresh=p.items().stream().filter(i->!i.existing()).map(Item::shipment).toList();var jobs=new ArrayList<Map<String,Object>>();if(!fresh.isEmpty()){long payer=id(p.state().payer(),"csc_id"),job=OrderImportPersistence.create(db,u,payer,p.date().atTime(16,0),LocalDateTime.now(clock).withNano(0),"ALLERGO");var sender=fresh.getFirst().sender();station(u,job,1,payer,sender,true,"");int sort=1;var addresses=new ArrayList<Address>();addresses.add(sender);for(var row:fresh){long tr=station(u,job,++sort,id(p.state().external(),"csc_id"),row.recipient(),false,row.remark());addresses.add(row.recipient());db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,?,0,'Fixpreis',0,'',0,0)",job,payer,sort);db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",tr,"gdc_gen_fieldname","info_0","gdc_content",row.transaction(),"gdc_context","ALLERGO"));}String compact=addresses.stream().map(Address::postal).collect(java.util.stream.Collectors.joining(";"))+"|"+addresses.stream().map(Address::city).collect(java.util.stream.Collectors.joining(";"))+"|"+addresses.stream().map(Address::country).collect(java.util.stream.Collectors.joining(";"));db.update("UPDATE job SET jb_tourdata=? WHERE jb_id=?",compact,job);support.audit(u,job,customer,0,102,"B2B_IMPORT=1 | ALLERGO",c.requestId());jobs.add(fields("id",job,"name","ALLERGO","stations",sort));}db.insert("genericdatacontainer",fields("gdc_obj_type","ngallergo","gdc_obj_id",u.userId(),"gdc_gen_fieldname",c.requestId(),"gdc_content",support.encode(jobs),"gdc_context",Long.toString(customer)));support.receipt(u,c.requestId(),intent,jobs.size());return fields("saved",true,"repeated",false,"jobs",jobs);});}
Map<String,Object> save(LegacyUser u,long customer,Command c){tours.zone.access(u,customer);if(c==null)throw bad("Importdaten fehlen.");BusinessSupport.token(c.requestId());var intent=List.of("ALLERGO",customer,c);if(support.receipt(u,c.requestId(),intent)!=null)return saved(u,customer,c);support.transactional("job","tour","tourservice","address","genericdatacontainer","headquarters");return db.tourCsvImportTransaction(()->{tours.zone.access(u,customer);lockHeadquarters(db,u);if(support.receipt(u,c.requestId(),intent)!=null)return saved(u,customer,c);var p=plan(u,customer,c);unchanged(p,c.revision());if(!p.renamed().isEmpty()&&!c.replaceNames())throw bad("Die Übertragung bestehender ALLERGO-Tourennamen muss ausdrücklich bestätigt werden.");for(var old:p.renamed()){db.update("UPDATE job SET jb_tourname='' WHERE jb_id=? AND hq_id=? AND jb_tourname='ALLERGO'",id(old,"jb_id"),u.headquartersId());support.audit(u,id(old,"jb_id"),customer,0,103,"ALLERGO: Tourname an importierten Auftrag übertragen",c.requestId());}var fresh=p.items().stream().filter(i->!i.existing()).map(entryValue -> entryValue.shipment()).toList();var jobs=new ArrayList<Map<String,Object>>();if(!fresh.isEmpty()){long payer=id(p.state().payer(),"csc_id"),job=OrderImportPersistence.create(db,u,payer,p.date().atTime(16,0),LocalDateTime.now(clock).withNano(0),"ALLERGO");var sender=fresh.getFirst().sender();station(u,job,1,payer,sender,true,"");int sort=1;var addresses=new ArrayList<Address>();addresses.add(sender);for(var row:fresh){long tr=station(u,job,++sort,id(p.state().external(),"csc_id"),row.recipient(),false,row.remark());addresses.add(row.recipient());db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,?,0,'Fixpreis',0,'',0,0)",job,payer,sort);db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",tr,"gdc_gen_fieldname","info_0","gdc_content",row.transaction(),"gdc_context","ALLERGO"));}String compact=addresses.stream().map(entryValue -> entryValue.postal()).collect(java.util.stream.Collectors.joining(";"))+"|"+addresses.stream().map(entryValue -> entryValue.city()).collect(java.util.stream.Collectors.joining(";"))+"|"+addresses.stream().map(entryValue -> entryValue.country()).collect(java.util.stream.Collectors.joining(";"));db.update("UPDATE job SET jb_tourdata=? WHERE jb_id=?",compact,job);support.audit(u,job,customer,0,102,"B2B_IMPORT=1 | ALLERGO",c.requestId());jobs.add(fields("id",job,"name","ALLERGO","stations",sort));}db.insert("genericdatacontainer",fields("gdc_obj_type","ngallergo","gdc_obj_id",u.userId(),"gdc_gen_fieldname",c.requestId(),"gdc_content",support.encode(jobs),"gdc_context",Long.toString(customer)));support.receipt(u,c.requestId(),intent,jobs.size());return fields("saved",true,"repeated",false,"jobs",jobs);});}
}
@@ -85,7 +85,7 @@ public class ApiController {
csv=new StringBuilder("\uFEFFAuftrag;Status;Kunde-ID;Kurier-ID;Abholung;Referenz;Netto;Kurierpreis;MwSt\n");
for(DeliveryOrder o:result.orders())csv.append(csvCell(o.number)).append(';').append(csvCell(o.legacyStatusLabel)).append(';').append(csvCell(o.customerId)).append(';').append(csvCell(o.courierId)).append(';').append(csvCell(o.pickupAt)).append(';').append(csvCell(o.reference)).append(';').append(csvCell(o.netPrice)).append(';').append(csvCell(o.courierPrice)).append(';').append(csvCell(o.vatRate)).append('\n');
}
return ResponseEntity.ok().contentType(new MediaType("text","csv",StandardCharsets.UTF_8)).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=stadtbote-auftraege.csv").body(csv.toString().getBytes(StandardCharsets.UTF_8));
return ResponseEntity.ok().contentType(new MediaType("text","csv",java.util.Objects.requireNonNull(StandardCharsets.UTF_8))).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=stadtbote-auftraege.csv").body(csv.toString().getBytes(StandardCharsets.UTF_8));
}
static String csvCell(Object value) { String s=value==null?"":value.toString();String trimmed=s.stripLeading();if(!trimmed.isEmpty()&&"=+-@".indexOf(trimmed.charAt(0))>=0)s="'"+s;return '"'+s.replace("\"","\"\"")+'"'; }
@GetMapping("/invoices") public Object invoices(Authentication auth) { user(auth).require(5);throw unsupported(); }
@@ -8,27 +8,27 @@ class AppointmentMailTransport {
private final JavaMailSenderImpl sender=new JavaMailSenderImpl();
final String from;
AppointmentMailTransport(@Value("${app.mail.host:}")String host,@Value("${app.mail.port:25}")int port,@Value("${app.mail.username:}")String username,@Value("${app.mail.password:}")String password,@Value("${app.mail.starttls:true}")boolean tls,@Value("${app.mail.ssl:false}")boolean ssl,@Value("${app.mail.from:}")String from){sender.setHost(host);sender.setPort(port);sender.setUsername(username);sender.setPassword(password);sender.setDefaultEncoding("UTF-8");this.from=from;var p=sender.getJavaMailProperties();p.setProperty("mail.smtp.auth",Boolean.toString(!username.isBlank()));p.setProperty("mail.smtp.starttls.enable",Boolean.toString(tls));p.setProperty("mail.smtp.ssl.enable",Boolean.toString(ssl));p.setProperty("mail.smtp.ssl.checkserveridentity","true");p.setProperty("mail.smtp.connectiontimeout","10000");p.setProperty("mail.smtp.timeout","10000");p.setProperty("mail.smtp.writetimeout","10000");}
boolean configured(){return sender.getHost()!=null&&!sender.getHost().isBlank();}
boolean configured(){String host=sender.getHost();return host!=null&&!host.isBlank();}
void send(String from,String replyTo,java.util.List<String> recipients,String subject,String body){var msg=new SimpleMailMessage();msg.setFrom(from);msg.setReplyTo(replyTo);msg.setBcc(recipients.toArray(String[]::new));msg.setSubject(subject);msg.setText(body);sender.send(msg);}
void sendVcard(String from,java.util.List<String> recipients,String filename,byte[] content){
try{var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(from);helper.setReplyTo(from);helper.setTo(recipients.toArray(String[]::new));helper.setSubject("VCard");helper.setText("Die ausgewählte Kontaktkarte finden Sie im Anhang.",false);helper.addAttachment(filename,new org.springframework.core.io.ByteArrayResource(content),"text/vcard; charset=UTF-8");sender.send(mime);}
try{var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(java.util.Objects.requireNonNull(from));helper.setReplyTo(java.util.Objects.requireNonNull(from));helper.setTo(java.util.Objects.requireNonNull(recipients.toArray(String[]::new)));helper.setSubject("VCard");helper.setText("Die ausgewählte Kontaktkarte finden Sie im Anhang.",false);helper.addAttachment(java.util.Objects.requireNonNull(filename),new org.springframework.core.io.ByteArrayResource(java.util.Objects.requireNonNull(content)),"text/vcard; charset=UTF-8");sender.send(mime);}
catch(jakarta.mail.MessagingException e){throw new org.springframework.mail.MailPreparationException("Die vCard-Mail konnte nicht vorbereitet werden.",e);}
}
void sendPdf(String from,java.util.List<String> to,java.util.List<String> cc,java.util.List<String> bcc,String subject,String body,String filename,byte[] pdf){
try{var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(from);helper.setReplyTo(from);helper.setTo(to.toArray(String[]::new));if(!cc.isEmpty())helper.setCc(cc.toArray(String[]::new));if(!bcc.isEmpty())helper.setBcc(bcc.toArray(String[]::new));helper.setSubject(subject);helper.setText(body,false);helper.addAttachment(filename,new org.springframework.core.io.ByteArrayResource(pdf),"application/pdf");sender.send(mime);}
try{var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(java.util.Objects.requireNonNull(from));helper.setReplyTo(java.util.Objects.requireNonNull(from));helper.setTo(java.util.Objects.requireNonNull(to.toArray(String[]::new)));if(!cc.isEmpty())helper.setCc(java.util.Objects.requireNonNull(cc.toArray(String[]::new)));if(!bcc.isEmpty())helper.setBcc(java.util.Objects.requireNonNull(bcc.toArray(String[]::new)));helper.setSubject(java.util.Objects.requireNonNull(subject));helper.setText(java.util.Objects.requireNonNull(body),false);helper.addAttachment(java.util.Objects.requireNonNull(filename),new org.springframework.core.io.ByteArrayResource(java.util.Objects.requireNonNull(pdf)),"application/pdf");sender.send(mime);}
catch(jakarta.mail.MessagingException e){throw new org.springframework.mail.MailPreparationException("Rollkartenmail konnte nicht vorbereitet werden.",e);}
}
void sendHtml(String from,String replyTo,String recipient,java.util.List<String> cc,java.util.List<String> bcc,String subject,String html){
try {var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(from);helper.setReplyTo(replyTo);helper.setTo(recipient);if(!cc.isEmpty())helper.setCc(cc.toArray(String[]::new));if(!bcc.isEmpty())helper.setBcc(bcc.toArray(String[]::new));helper.setSubject(subject);
var document=org.jsoup.Jsoup.parse(html);var embedded=new java.util.LinkedHashMap<String,String>();for(var img:document.select("img[src]")){String data=img.attr("src");if(data.matches("data:image/(png|jpeg|gif);base64,[A-Za-z0-9+/=]+")){String cid="newsletter-image-"+embedded.size();embedded.put(cid,data);img.attr("src","cid:"+cid);}}helper.setText(document.text(),document.html());for(var item:embedded.entrySet()){String data=item.getValue();String type=data.substring(5,data.indexOf(';'));byte[] bytes=java.util.Base64.getDecoder().decode(data.substring(data.indexOf(',')+1));helper.addInline(item.getKey(),new org.springframework.core.io.ByteArrayResource(bytes),type);}sender.send(mime);
try {var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(java.util.Objects.requireNonNull(from));helper.setReplyTo(java.util.Objects.requireNonNull(replyTo));helper.setTo(java.util.Objects.requireNonNull(recipient));if(!cc.isEmpty())helper.setCc(java.util.Objects.requireNonNull(cc.toArray(String[]::new)));if(!bcc.isEmpty())helper.setBcc(java.util.Objects.requireNonNull(bcc.toArray(String[]::new)));helper.setSubject(java.util.Objects.requireNonNull(subject));
var document=org.jsoup.Jsoup.parse(html);var embedded=new java.util.LinkedHashMap<String,String>();for(var img:document.select("img[src]")){String data=img.attr("src");if(data.matches("data:image/(png|jpeg|gif);base64,[A-Za-z0-9+/=]+")){String cid="newsletter-image-"+embedded.size();embedded.put(cid,data);img.attr("src","cid:"+cid);}}helper.setText(java.util.Objects.requireNonNull(document.text()),java.util.Objects.requireNonNull(document.html()));for(var item:embedded.entrySet()){String data=item.getValue();String type=data.substring(5,data.indexOf(';'));byte[] bytes=java.util.Base64.getDecoder().decode(data.substring(data.indexOf(',')+1));helper.addInline(java.util.Objects.requireNonNull(item.getKey()),new org.springframework.core.io.ByteArrayResource(java.util.Objects.requireNonNull(bytes)),java.util.Objects.requireNonNull(type));}sender.send(mime);
}catch(jakarta.mail.MessagingException e){throw new org.springframework.mail.MailPreparationException("Newsletter konnte nicht vorbereitet werden.",e);}
}
void sendOrderHtml(String from,java.util.List<String> recipients,java.util.List<String> cc,java.util.List<String> bcc,String subject,String html){sendOrderHtml(from,recipients,cc,bcc,subject,html,"",null);}
void sendOrderHtml(String from,java.util.List<String> recipients,java.util.List<String> cc,java.util.List<String> bcc,String subject,String html,String filename,byte[] pdf){
try{var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(from);helper.setReplyTo(from);helper.setTo(recipients.toArray(String[]::new));if(!cc.isEmpty())helper.setCc(cc.toArray(String[]::new));if(!bcc.isEmpty())helper.setBcc(bcc.toArray(String[]::new));helper.setSubject(subject);
try{var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(java.util.Objects.requireNonNull(from));helper.setReplyTo(java.util.Objects.requireNonNull(from));helper.setTo(java.util.Objects.requireNonNull(recipients.toArray(String[]::new)));if(!cc.isEmpty())helper.setCc(java.util.Objects.requireNonNull(cc.toArray(String[]::new)));if(!bcc.isEmpty())helper.setBcc(java.util.Objects.requireNonNull(bcc.toArray(String[]::new)));helper.setSubject(java.util.Objects.requireNonNull(subject));
var document=org.jsoup.Jsoup.parse(html);var images=new java.util.LinkedHashMap<String,String>();for(var img:document.select("img[src]")){String data=img.attr("src");if(data.matches("data:image/(png|jpeg);base64,[A-Za-z0-9+/=]+")){String cid="order-evidence-"+images.size();images.put(cid,data);img.attr("src","cid:"+cid);}}
helper.setText(document.text(),document.html());for(var item:images.entrySet()){String data=item.getValue();helper.addInline(item.getKey(),new org.springframework.core.io.ByteArrayResource(java.util.Base64.getDecoder().decode(data.substring(data.indexOf(',')+1))),data.substring(5,data.indexOf(';')));}
if(pdf!=null)helper.addAttachment(filename,new org.springframework.core.io.ByteArrayResource(pdf),"application/pdf");sender.send(mime);
helper.setText(java.util.Objects.requireNonNull(document.text()),java.util.Objects.requireNonNull(document.html()));for(var item:images.entrySet()){String data=item.getValue();helper.addInline(java.util.Objects.requireNonNull(item.getKey()),new org.springframework.core.io.ByteArrayResource(java.util.Objects.requireNonNull(java.util.Base64.getDecoder().decode(data.substring(data.indexOf(',')+1)))),java.util.Objects.requireNonNull(data.substring(5,data.indexOf(';'))));}
if(pdf!=null)helper.addAttachment(java.util.Objects.requireNonNull(filename),new org.springframework.core.io.ByteArrayResource(pdf),"application/pdf");sender.send(mime);
}catch(jakarta.mail.MessagingException e){throw new org.springframework.mail.MailPreparationException("Auftragsmail konnte nicht vorbereitet werden.",e);}
}
}
@@ -174,7 +174,7 @@ public class AppointmentService {
if(rows.size()>5000)throw bad("Zu viele Statistikgruppen. Bitte Filter eingrenzen.");
var metadata=categories();var categories=new ArrayList<Map<String,Object>>();
for(int n=1;n<=4;n++) {
var counts=new TreeMap<Long,Long>();for(var row:rows)counts.merge(id(row,"ap_category_"+n),id(row,"total"),Long::sum);
var counts=new TreeMap<Long,Long>();for(var row:rows)counts.merge(id(row,"ap_category_"+n),id(row,"total"),(entryValue, otherValue) -> Long.sum(entryValue, otherValue));
var labels=new HashMap<Long,String>();for(var option:metadata.get("category"+n)){long id=id(option,"id");labels.put(id,text(option,"label"));counts.putIfAbsent(id,0L);}
categories.add(fields("category",n,"items",counts.entrySet().stream().map(e->fields("id",e.getKey(),"label",labels.getOrDefault(e.getKey(),"Unbekannt ("+e.getKey()+")"),"total",e.getValue())).toList()));
}
@@ -69,8 +69,8 @@ class ArticleAdministration {
var changes=new ArrayList<Map<String,Object>>();for(String k:after.keySet())if(!Objects.equals(before.get(k),after.get(k)))changes.add(fields("field",k,"before",before.get(k),"after",after.get(k)));if(changes.isEmpty())throw bad("Keine Änderungen vorhanden.");return new Plan(before,after,changes,evidence,action);
}
List<Map<String,Object>> descendants(long root){var rows=new ArrayList<Map<String,Object>>();var visited=new HashSet<Long>();var queue=new ArrayDeque<Long>();queue.add(root);while(!queue.isEmpty()){long next=queue.remove();if(!visited.add(next))continue;if(visited.size()>2000||rows.size()>10000)throw bad("Gebinde zu umfangreich für eine sichere Prüfung.");var children=db.queryForList("SELECT * FROM articlebundle WHERE at_id=? ORDER BY at_id2",next);rows.addAll(children);for(var r:children)queue.add(id(r,"at_id2"));}
var indegree=new HashMap<Long,Integer>();indegree.put(root,0);var edges=new HashSet<String>();for(var r:rows){long a=id(r,"at_id"),b=id(r,"at_id2");if(b<=0||id(r,"atb_bundlequantity")<=0||!edges.add(a+":"+b))throw bad("Ungültige oder doppelte Gebindebestandteile im Bestand.");indegree.putIfAbsent(a,0);indegree.merge(b,1,Integer::sum);}
var open=new ArrayDeque<Long>();indegree.forEach((k,v)->{if(v==0)open.add(k);});int count=0;while(!open.isEmpty()){long n=open.remove();count++;for(var r:rows)if(id(r,"at_id")==n){long child=id(r,"at_id2");if(indegree.merge(child,-1,Integer::sum)==0)open.add(child);}}if(count!=indegree.size())throw bad("Der Bestand enthält bereits einen Gebindekreislauf.");return rows;}
var indegree=new HashMap<Long,Integer>();indegree.put(root,0);var edges=new HashSet<String>();for(var r:rows){long a=id(r,"at_id"),b=id(r,"at_id2");if(b<=0||id(r,"atb_bundlequantity")<=0||!edges.add(a+":"+b))throw bad("Ungültige oder doppelte Gebindebestandteile im Bestand.");indegree.putIfAbsent(a,0);indegree.merge(b,1,(entryValue, otherValue) -> Integer.sum(entryValue, otherValue));}
var open=new ArrayDeque<Long>();indegree.forEach((k,v)->{if(v==0)open.add(k);});int count=0;while(!open.isEmpty()){long n=open.remove();count++;for(var r:rows)if(id(r,"at_id")==n){long child=id(r,"at_id2");if(indegree.merge(child,-1,(entryValue, otherValue) -> Integer.sum(entryValue, otherValue))==0)open.add(child);}}if(count!=indegree.size())throw bad("Der Bestand enthält bereits einen Gebindekreislauf.");return rows;}
Map<String,Object> preview(LegacyUser user,Long hq,Command c){return db.readTransaction(()->{var p=plan(user,scoped(user,hq),c);return fields("planRevision",revision(p),"changes",p.changes(),"action",p.action());});}
Map<String,Object> save(LegacyUser user,Long hq,Command c){scoped(user,hq);if(c==null)throw bad("Artikelvorgang fehlt.");BusinessSupport.token(c.requestId());support.transactional("article","articlebundle","articlegroupitem","genericdatacontainer");return db.articleWriteTransaction(user.warehouseAccount(),()->{var u=scoped(user,hq);Object intent=List.of("ARTICLE",c);Long receipt=support.receipt(u,c.requestId(),intent);if(receipt!=null)return fields("id",receipt,"repeated",true);var p=plan(user,u,c);unchanged(p,c.planRevision());long article=c.id();
if(p.action().equals("SAVE")){var v=validated(c.values());var values=new LinkedHashMap<>(p.after());values.remove("group");if(article==0){values.putAll(fields("hq_id",u.headquartersId(),"at_createtime",db.queryForObject("SELECT NOW()",java.sql.Timestamp.class),"atg_id",v.group()));article=db.insert("article",values);}else BusinessSupport.update(db,"article","at_id",article,values);
@@ -22,7 +22,7 @@ class ArticleColumns {
@GetMapping("/api/administration/employees/{employee}/article-columns") Object get(Authentication a,@PathVariable long employee){return view(ApiController.user(a),employee);}
@PutMapping("/api/administration/employees/{employee}/article-columns") Object put(Authentication a,@PathVariable long employee,@RequestBody MasterListColumns.Command c){return save(ApiController.user(a),employee,c);}
static List<Map<String,Object>> rows(DatabaseService db,LegacyUser u,long employee,long home,long md){return db.queryForList("SELECT par_id,par_key,par_value,par_text,md_id,hq_id,emp_id FROM parameter WHERE ((emp_id=? AND hq_id IN (?,?)) OR (emp_id=0 AND hq_id=0 AND md_id IN (0,?))) AND par_key=? ORDER BY emp_id,hq_id,par_id",employee,u.headquartersId(),home,md,KEY);}
static Layout layout(List<Map<String,Object>> rows,long hq){String raw=MasterListColumns.value(rows,KEY,hq);if(raw.isBlank())return new Layout(KEYS,"");var parts=Arrays.stream(raw.split(",",-1)).map(String::strip).toList();var selected=parts.stream().filter(KEYS::contains).limit(KEYS.size()).toList();return new Layout(selected.isEmpty()?KEYS:selected,selected.size()!=parts.size()?"Unbekannte alte Spalten werden nicht angezeigt. Bitte die Belegung prüfen.":"");}
static Layout layout(List<Map<String,Object>> rows,long hq){String raw=MasterListColumns.value(rows,KEY,hq);if(raw.isBlank())return new Layout(KEYS,"");var parts=Arrays.stream(raw.split(",",-1)).map(entryValue -> entryValue.strip()).toList();var selected=parts.stream().filter(KEYS::contains).limit(KEYS.size()).toList();return new Layout(selected.isEmpty()?KEYS:selected,selected.size()!=parts.size()?"Unbekannte alte Spalten werden nicht angezeigt. Bitte die Belegung prüfen.":"");}
static Layout runtime(DatabaseService db,LegacyUser u){long md=mandator(db,u);var rows=rows(db,u,u.employeeId(),u.originalHeadquartersId(),md);if(!MasterListColumns.writable(rows,md))throw bad("Die Artikellistenspalten sind mehrfach vorhanden oder falsch zugeordnet.");return layout(rows,u.headquartersId());}
State state(LegacyUser u,long employee){var a=new EmployeeListPreferences(db,support).state(u,employee);return new State(a,rows(db,u,employee,id(a.person(),"hq_id"),a.mandator()));}
static String version(State s){return revision(List.of(s.access().person(),s.access().access().actor(),s.access().access().root(),s.access().mandator(),s.rows()));}
@@ -40,7 +40,7 @@ class AutomaticMediationMail {
}
return new Decision(event,state,reason,List.copyOf(recipients),marker,customer,revision(List.of(job,event,sources,recipients)));
}
List<Map<String,Object>> view(Map<String,Object> job,LocalDateTime now){var out=new ArrayList<Map<String,Object>>();for(var event:Event.values()){try{var receipt=service.receipt(id(job,"hq_id"),id(job,"jb_id"),event.key());var d=receipt==null?decide(job,event,now):null;out.add(fields("key",event.key(),"event",event.label,"additional",false,"state",receipt==null?d.state():receipt.state(),"reason",receipt==null?d.reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?d.recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));}catch(RuntimeException e){out.add(fields("key",event.key(),"event",event.label,"additional",false,"state","BLOCKED","reason",AutomaticOrderMail.safe(e),"recipients",List.of(),"startedAt","","completedAt",""));}}return out;}
List<Map<String,Object>> view(Map<String,Object> job,LocalDateTime now){var out=new ArrayList<Map<String,Object>>();for(var event:Event.values()){try{var receipt=service.receipt(id(job,"hq_id"),id(job,"jb_id"),event.key());var d=receipt==null?decide(job,event,now):null;out.add(fields("key",event.key(),"event",event.label,"additional",false,"state",receipt==null?java.util.Objects.requireNonNull(d).state():receipt.state(),"reason",receipt==null?java.util.Objects.requireNonNull(d).reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?java.util.Objects.requireNonNull(d).recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));}catch(RuntimeException e){out.add(fields("key",event.key(),"event",event.label,"additional",false,"state","BLOCKED","reason",AutomaticOrderMail.safe(e),"recipients",List.of(),"startedAt","","completedAt",""));}}return out;}
Prepared prepare(long hq,long order,Event event,LocalDateTime now){
var job=service.job(hq,order);var decision=decide(job,event,now);if(!decision.state().equals("READY"))return null;
var locks=service.db.queryForList("SELECT sp_content FROM "+service.support.historySchema+".semaphor WHERE sp_obj_type='jb' AND sp_obj_id=? AND sp_fieldname=?",order,event.semaphore);if(locks.size()>1||locks.stream().anyMatch(r->!text(r,"sp_content").isBlank()))throw bad("Ein PHP-Vermittlungsmailvorgang ist noch gesperrt.");
@@ -22,7 +22,7 @@ final class AutomaticMediationMailContent {
if(!logo.isBlank()&&!height.isBlank()&&!width.isBlank()&&!c.legacy.isBlank()){
if(!logo.matches("[A-Za-z0-9_. -]{1,150}")||Set.of(".","..").contains(logo)||!height.matches("[1-9][0-9]{0,3}")||!width.matches("[1-9][0-9]{0,3}"))throw bad("Die Logo-Vorgaben der Vermittlungsmail sind ungültig.");
String web=c.local("MAIL_FOOTER_WEB_PAGE"),link=web.isBlank()?"":Objects.toString(ApiController.safeLegacyUrl(web.contains("://")?web:"http://"+web),"");if(!web.isBlank()&&link.isBlank())throw bad("Die Homepage der Mailfußzeile ist ungültig.");
body.append("<p>");if(!link.isBlank())body.append("<a href=\"").append(escape(link)).append("\">");body.append("<img alt=\"Logo\" src=\"").append(escape(c.legacy+"/images/external/"+org.springframework.web.util.UriUtils.encodePathSegment(logo,java.nio.charset.StandardCharsets.UTF_8))).append("\" width=\"").append(width).append("\" height=\"").append(height).append("\">");if(!link.isBlank())body.append("</a>");body.append("</p>");warnings.add("Das Logo wird beim Empfänger von der bisherigen Anwendungsadresse geladen.");
body.append("<p>");if(!link.isBlank())body.append("<a href=\"").append(escape(link)).append("\">");body.append("<img alt=\"Logo\" src=\"").append(escape(c.legacy+"/images/external/"+org.springframework.web.util.UriUtils.encodePathSegment(logo,java.util.Objects.requireNonNull(java.nio.charset.StandardCharsets.UTF_8)))).append("\" width=\"").append(width).append("\" height=\"").append(height).append("\">");if(!link.isBlank())body.append("</a>");body.append("</p>");warnings.add("Das Logo wird beim Empfänger von der bisherigen Anwendungsadresse geladen.");
}
if(c.local("MAIL_FOOTER_ENABLED").equals("1")){
for(String key:List.of("MAIL_FOOTER_ADDRESS","MAIL_FOOTER_CONTACT","MAIL_FOOTER_IMPRESSUM"))body.append("<div>").append(rich(c.local(key))).append("</div>");
@@ -15,7 +15,7 @@ final class AutomaticOfferPolicy {
record Candidate(long courier,long vehicle,String sid,String name,String postal,String availableAt,int group,String groupName,int challenges,List<String> blockers){boolean eligible(){return blockers.isEmpty();}}
record Plan(String state,String message,LocalDateTime startsAt,int iterations,boolean reserved,boolean onlyFavoured,boolean ignoreFavoured,List<Candidate> candidates){
List<String> rounds(){if(reserved)return List.of("RESERVED");var r=new ArrayList<String>();if(!ignoreFavoured)for(int i=0;i<iterations;i++)r.add("FAVOURITES");if(!onlyFavoured)for(int i=0;i<iterations;i++)r.add("AREAS");return r;}
Candidate chosen(String round){return candidates.stream().filter(Candidate::eligible).filter(c->round.equals("RESERVED")?c.group()==0:round.equals("FAVOURITES")?c.group()==1:c.group()>=2).findFirst().orElse(null);}
Candidate chosen(String round){return candidates.stream().filter(entryValue -> entryValue.eligible()).filter(c->round.equals("RESERVED")?c.group()==0:round.equals("FAVOURITES")?c.group()==1:c.group()>=2).findFirst().orElse(null);}
}
static String commonBlock(Map<String,Object> j,Settings s,LocalDateTime now){
if(id(j,"jb_status")!=8)return "Der Auftrag steht nicht im Autoranking.";
@@ -77,8 +77,8 @@ public class AutomaticOffers {
candidates.add(new Candidate(courier,id(v,"crvh_id"),text(v,"cr_sid"),text(v,"cmp_comp"),text(v,"cr_locationzipcode"),text(v,"cr_availabletime"),group,group==0?"Fester Reservierungskurier":group==1?"Bevorzugter Kurier":group==2?"Vermittlungsbereich":"Nachbarschaft "+(group-2),challenges,reasons));
}
if(reserved&&candidates.isEmpty())return reason(j,s,"BLOCKED","Der feste Reservierungskurier fehlt im Bestand.",starts);
candidates.sort(Comparator.comparingInt(Candidate::group).thenComparingInt(c->c.group()==1&&favStation.contains(c.courier())?0:1).thenComparing(Candidate::availableAt).thenComparingLong(Candidate::courier));
boolean any=candidates.stream().anyMatch(Candidate::eligible);return new State(j,s,new Plan("READY",any?"Ein geeigneter Kurier kann das nächste Angebot erhalten.":id(j,"jb_globaljob")==2?"Ohne geeigneten Favoriten wird der Auftrag allen Kurieren angeboten.":"Ohne geeigneten Kurier geht der Auftrag in die manuelle Vermittlung.",starts,iterations,reserved,only,ignored,List.copyOf(candidates)));
candidates.sort(Comparator.comparingInt((Candidate entryValue) -> entryValue.group()).thenComparingInt(c->c.group()==1&&favStation.contains(c.courier())?0:1).thenComparing(entryValue -> entryValue.availableAt()).thenComparingLong(entryValue -> entryValue.courier()));
boolean any=candidates.stream().anyMatch(entryValue -> entryValue.eligible());return new State(j,s,new Plan("READY",any?"Ein geeigneter Kurier kann das nächste Angebot erhalten.":id(j,"jb_globaljob")==2?"Ohne geeigneten Favoriten wird der Auftrag allen Kurieren angeboten.":"Ohne geeigneten Kurier geht der Auftrag in die manuelle Vermittlung.",starts,iterations,reserved,only,ignored,List.copyOf(candidates)));
}
static boolean intersects(String csv,Set<String> values){return FleetAssignment.tokens(csv).stream().anyMatch(values::contains);}
List<Map<String,Object>> customers(List<Long> centers){var ids=centers.stream().filter(x->x>0).distinct().toList();if(ids.isEmpty())return List.of();return db.queryForList("SELECT DISTINCT cs.cs_id,cs.cs_cr_fav_only FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id WHERE cc.csc_id IN ("+String.join(",",Collections.nCopies(ids.size(),"?"))+")",ids.toArray());}
@@ -95,7 +95,7 @@ public class AutomaticOffers {
if(mode.equals("3"))for(var area:db.queryForList("SELECT ga.srvpa_id,nan.srvpna_sort FROM serviceplzneighbourarea nan JOIN serviceplzarea ga ON ga.srvpa_id=nan.srvpa_id WHERE nan.hq_id=? AND ga.hq_id=? AND nan.srvp_id=?"+(reverse?"":" AND nan.srvpna_sort BETWEEN 1 AND ?")+" ORDER BY nan.srvpna_sort",reverse?new Object[]{hq,hq,zip}:new Object[]{hq,hq,zip,depth}))add(result,id(area,"srvpa_id"),2+(int)id(area,"srvpna_sort"));
return result;
}
void add(Map<String,Integer> result,long area,int rank){db.queryForList("SELECT p.srvp_plz FROM serviceplzareamapping m JOIN serviceplz p ON p.srvp_id=m.srvp_id WHERE m.srvpa_id=?",area).forEach(r->result.merge(text(r,"srvp_plz"),rank,Math::min));}
void add(Map<String,Integer> result,long area,int rank){db.queryForList("SELECT p.srvp_plz FROM serviceplzareamapping m JOIN serviceplz p ON p.srvp_id=m.srvp_id WHERE m.srvpa_id=?",area).forEach(r->result.merge(text(r,"srvp_plz"),rank,(entryValue, otherValue) -> Math.min(entryValue, otherValue)));}
}
Result step(long job,int round){return db.automaticOfferWriteTransaction(()->{
var state=state(job);if(state==null||!state.plan().state().equals("READY"))return Result.STOP;var p=state.plan();var rounds=p.rounds();var j=state.job();var s=state.settings();Candidate selected=round<rounds.size()?p.chosen(rounds.get(round)):null;var time=now();long hq=id(j,"hq_id_dispo");String token=UUID.randomUUID().toString();
@@ -39,7 +39,7 @@ class AutomaticOrderMail {
@GetMapping Map<String,Object> view(Authentication auth,@PathVariable long order){return view(ApiController.user(auth),order);}
Map<String,Object> view(LegacyUser actor,long order){actor.require(4);return db.readTransaction(()->{
var user=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());user.require(4);var job=job(user.headquartersId(),order);var policy=new AutomaticOrderMailPolicy(db);var settings=policy.settings();var now=now();var rows=new ArrayList<Map<String,Object>>();
for(var event:Event.values())for(boolean additional:List.of(false,true)){String key=(additional?"ADDITIONAL_":"STANDARD_")+event.name();try{var receipt=receipt(user.headquartersId(),order,key);var decision=receipt==null?policy.decide(job,event,additional,settings,now):null;rows.add(fields("key",key,"event",event.label,"additional",additional,"state",receipt==null?decision.state():receipt.state(),"reason",receipt==null?decision.reason():message(receipt.state()),"recipients",receipt==null?decision.recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));}catch(RuntimeException e){rows.add(fields("key",key,"event",event.label,"additional",additional,"state","BLOCKED","reason",safe(e),"recipients",List.of(),"startedAt","","completedAt",""));}}
for(var event:Event.values())for(boolean additional:List.of(false,true)){String key=(additional?"ADDITIONAL_":"STANDARD_")+event.name();try{var receipt=receipt(user.headquartersId(),order,key);var decision=receipt==null?policy.decide(job,event,additional,settings,now):null;rows.add(fields("key",key,"event",event.label,"additional",additional,"state",receipt==null?java.util.Objects.requireNonNull(decision).state():receipt.state(),"reason",receipt==null?java.util.Objects.requireNonNull(decision).reason():message(receipt.state()),"recipients",receipt==null?java.util.Objects.requireNonNull(decision).recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));}catch(RuntimeException e){rows.add(fields("key",key,"event",event.label,"additional",additional,"state","BLOCKED","reason",safe(e),"recipients",List.of(),"startedAt","","completedAt",""));}}
rows.addAll(new AutomaticStationMail(this).view(job,settings,now));
rows.addAll(new AutomaticMediationMail(this).view(job,now));
rows.add(new AutomaticTakenMail(this).view(job,settings,now));
@@ -48,7 +48,7 @@ final class AutomaticOrderMailPolicy {
String state="WAITING",reason="Auftragsstatus oder Versandzeitraum passt noch nicht.";var recipients=new LinkedHashMap<String,String>();var sources=new ArrayList<Object>();long marker=0;
if(statusEligible(event,job,settings,now,additional)){
state="READY";reason="Die PHP-Versandbedingungen sind erfüllt.";
if(!additional){String sent=text(job,event.field);if(sent.isBlank()||Set.of("998","999").contains(sent)||event==Event.COMPLETION&&sent.equals("997")){state="DONE";reason="Das bestehende Versandkennzeichen lässt keinen automatischen Versand zu.";}
if(!additional){String sent=text(job,event.field);if(sent.isBlank()||Set.of("996","998","999").contains(sent)||event==Event.COMPLETION&&sent.equals("997")){state="DONE";reason=sent.equals("996")?"Die Standardempfänger wurden für diesen Auftrag ersetzt.":"Das bestehende Versandkennzeichen lässt keinen automatischen Versand zu.";}
if(event!=Event.CREATED&&settings.excluded().contains(text(job,"cr_sid").toUpperCase(Locale.ROOT))){state="EXCLUDED";reason="Das Fahrzeug ist vom automatischen Statusversand ausgeschlossen.";}}
if(state.equals("READY")&&event!=Event.CREATED){var stops=db.queryForList("SELECT t.tr_id,t.tr_status FROM tour t WHERE t.jb_id=? AND t.tr_sort=1",id(job,"jb_id"));sources.add(stops);if(stops.size()!=1)throw bad("Die erste Auftragsstation ist nicht eindeutig.");if(id(stops.getFirst(),"tr_status")!=1){state="WAITING";reason="Die erste Station ist noch nicht erledigt.";}}
if(state.equals("READY")&&!additional&&event==Event.COMPLETION){var photos=db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='photos_last_station'",id(job,"jb_id"));sources.add(photos);if(photos.size()>1)throw bad("Die Fotovormerkung ist mehrdeutig.");String count=photos.isEmpty()?"":text(photos.getFirst(),"gdc_content").strip();if(!count.isEmpty()&&!count.matches("[0-9]{1,6}"))throw bad("Die Fotovormerkung ist ungültig.");if(!count.isEmpty()&&Integer.parseInt(count)>0&&!dateTime(job,"jb_finishtime").isBefore(now.minusMinutes(1))){state="WAITING";reason="Die Minute Wartezeit für Fotos der letzten Station läuft noch.";}}
@@ -33,7 +33,7 @@ class AutomaticStationMail {
if(!markers.isEmpty()){state="DONE";reason="Für diese Station besteht bereits ein PHP-Versandkennzeichen.";}
else if(eligible(job,stop,now)){
state="READY";reason="Die PHP-Bedingungen für die Stationsabschluss-Mail sind erfüllt.";
if(text(job,"jb_automailsent").isBlank()||Set.of("997","998","999").contains(text(job,"jb_automailsent"))){state="DONE";reason="Das Auftrags-Versandkennzeichen sperrt weitere Stationsmails.";}
if(text(job,"jb_automailsent").isBlank()||Set.of("996","997","998","999").contains(text(job,"jb_automailsent"))){state="DONE";reason="Das Auftrags-Versandkennzeichen sperrt weitere Stationsmails.";}
else if(settings.excluded().contains(text(job,"cr_sid").toUpperCase(Locale.ROOT))){state="EXCLUDED";reason="Das Fahrzeug ist vom automatischen Statusversand ausgeschlossen.";}
if(state.equals("READY")){
var photos=service.db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_photo'",station);sources.add(photos);
@@ -55,7 +55,7 @@ class AutomaticStationMail {
var rows=new ArrayList<Map<String,Object>>();long hq=id(job,"hq_id"),order=id(job,"jb_id");
for(var stop:stops(order)){long station=id(stop,"tr_id");String key=key(station),event="Station "+id(stop,"tr_sort");try{
var receipt=service.receipt(hq,order,key);var decision=receipt==null?decide(job,stop,settings,now):null;
rows.add(fields("key",key,"event",event,"additional",false,"state",receipt==null?decision.state():receipt.state(),"reason",receipt==null?decision.reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?decision.recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));
rows.add(fields("key",key,"event",event,"additional",false,"state",receipt==null?java.util.Objects.requireNonNull(decision).state():receipt.state(),"reason",receipt==null?java.util.Objects.requireNonNull(decision).reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?java.util.Objects.requireNonNull(decision).recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));
}catch(RuntimeException e){rows.add(fields("key",key,"event",event,"additional",false,"state","BLOCKED","reason",AutomaticOrderMail.safe(e),"recipients",List.of(),"startedAt","","completedAt",""));}}
return rows;
}
@@ -30,7 +30,7 @@ final class AutomaticStationMailContent {
if(!logo.isBlank()&&!c.legacy.isBlank()&&!height.isBlank()&&!width.isBlank()){
if(!logo.matches("[A-Za-z0-9_. -]{1,150}")||Set.of(".","..").contains(logo)||!height.matches("[1-9][0-9]{0,3}")||!width.matches("[1-9][0-9]{0,3}"))throw bad("Die Logo-Vorgaben der Stationsmail sind ungültig.");
String web=c.local("MAIL_FOOTER_WEB_PAGE"),link=web.isBlank()?"":Objects.toString(ApiController.safeLegacyUrl(web.contains("://")?web:"http://"+web),"");if(!web.isBlank()&&link.isBlank())throw bad("Die Homepage der Mailfußzeile ist ungültig.");
body.append("<p>");if(!link.isBlank())body.append("<a href=\"").append(escape(link)).append("\">");body.append("<img alt=\"Logo\" src=\"").append(escape(c.legacy+"/images/external/"+org.springframework.web.util.UriUtils.encodePathSegment(logo,java.nio.charset.StandardCharsets.UTF_8))).append("\" width=\"").append(width).append("\" height=\"").append(height).append("\">");if(!link.isBlank())body.append("</a>");body.append("</p>");warnings.add("Das Logo wird beim Empfänger von der bisherigen Anwendungsadresse geladen.");
body.append("<p>");if(!link.isBlank())body.append("<a href=\"").append(escape(link)).append("\">");body.append("<img alt=\"Logo\" src=\"").append(escape(c.legacy+"/images/external/"+org.springframework.web.util.UriUtils.encodePathSegment(logo,java.util.Objects.requireNonNull(java.nio.charset.StandardCharsets.UTF_8)))).append("\" width=\"").append(width).append("\" height=\"").append(height).append("\">");if(!link.isBlank())body.append("</a>");body.append("</p>");warnings.add("Das Logo wird beim Empfänger von der bisherigen Anwendungsadresse geladen.");
}
if(c.local("MAIL_FOOTER_ENABLED").equals("1")){
for(String key:List.of("MAIL_FOOTER_ADDRESS","MAIL_FOOTER_CONTACT","MAIL_FOOTER_IMPRESSUM"))body.append("<div>").append(rich(c.local(key))).append("</div>");
@@ -57,7 +57,7 @@ class AutomaticTakenMail {
}
Map<String,Object> view(Map<String,Object> job,AutomaticOrderMailPolicy.Settings settings,LocalDateTime now){
try{var receipt=service.receipt(id(job,"hq_id"),id(job,"jb_id"),KEY);var d=receipt==null?decide(job,settings,now):null;
return fields("key",KEY,"event",LABEL,"additional",false,"state",receipt==null?d.state():receipt.state(),"reason",receipt==null?d.reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?d.recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt());
return fields("key",KEY,"event",LABEL,"additional",false,"state",receipt==null?java.util.Objects.requireNonNull(d).state():receipt.state(),"reason",receipt==null?java.util.Objects.requireNonNull(d).reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?java.util.Objects.requireNonNull(d).recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt());
}catch(RuntimeException e){return fields("key",KEY,"event",LABEL,"additional",false,"state","BLOCKED","reason",AutomaticOrderMail.safe(e),"recipients",List.of(),"startedAt","","completedAt","");}
}
Prepared prepare(long hq,long order,LocalDateTime now,String token){
@@ -53,8 +53,8 @@ class AutomaticTrackingMail {
var rows=new ArrayList<Map<String,Object>>();long hq=id(job,"hq_id"),order=id(job,"jb_id");var stops=stops(order);
for(long station:stationIds(order)){String key=key(station),label="Tracking · Station "+id(one(stops.stream().filter(r->id(r,"tr_id")==station).toList()),"tr_sort");try{
var receipt=service.receipt(hq,order,key);var d=receipt==null?decide(job,stops,station,now):null;
if(receipt==null&&d.state().equals("READY"))prepare(hq,order,station,now); // configuration check only; no ETA or token side effects
rows.add(fields("key",key,"event",label,"additional",false,"recipientLabel","Stationsempfänger","state",receipt==null?d.state():receipt.state(),"reason",receipt==null?d.reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?d.recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));
if(receipt==null&&java.util.Objects.requireNonNull(d).state().equals("READY"))prepare(hq,order,station,now); // configuration check only; no ETA or token side effects
rows.add(fields("key",key,"event",label,"additional",false,"recipientLabel","Stationsempfänger","state",receipt==null?java.util.Objects.requireNonNull(d).state():receipt.state(),"reason",receipt==null?java.util.Objects.requireNonNull(d).reason():AutomaticOrderMail.message(receipt.state()),"recipients",receipt==null?java.util.Objects.requireNonNull(d).recipients():receipt.envelope().to(),"startedAt",receipt==null?"":receipt.startedAt(),"completedAt",receipt==null?"":receipt.completedAt()));
}catch(RuntimeException e){rows.add(fields("key",key,"event",label,"additional",false,"recipientLabel","Stationsempfänger","state","BLOCKED","reason",AutomaticOrderMail.safe(e),"recipients",List.of(),"startedAt","","completedAt",""));}}
return rows;
}
@@ -115,6 +115,7 @@ public class BatchInvoiceRefresh {
}
}
}
if(hasSelf&&selfRate.signum()!=0&&selfGross==null)throw bad("Die Basis des Selbsterfasserrabatts fehlt.");
BigDecimal freight=BigDecimal.ZERO,service=BigDecimal.ZERO,discount=BigDecimal.ZERO,serviceDiscount=BigDecimal.ZERO;boolean hasFreight=false,hasService=false;
String freightName="Fixpreis";
for(var p:s.prices()) {
@@ -128,7 +129,7 @@ public class BatchInvoiceRefresh {
BigDecimal freightNet=freight.multiply(BigDecimal.ONE.subtract(discount.movePointLeft(2))).setScale(2,RoundingMode.HALF_UP);
BigDecimal serviceNet=service.multiply(BigDecimal.ONE.subtract(serviceDiscount.movePointLeft(2))).setScale(2,RoundingMode.HALF_UP);
if(service.compareTo(decimal(j,"jb_serviceprice"))!=0||freightNet.add(serviceNet).compareTo(decimal(j,"jb_totalprice"))!=0)throw bad("Die gespeicherten Preispositionen passen nicht zur Auftragssumme. Bitte zuerst den Preis prüfen.");
if(hasSelf&&selfRate.signum()!=0) {
if(hasSelf&&selfRate.signum()!=0&&selfGross!=null) {
String markupRaw=text(j,"jb_markup");
if(markupRaw.isBlank())throw bad("Ungültiger Zuschlag.");
BigDecimal markupVal;
@@ -146,7 +147,7 @@ public class BatchInvoiceRefresh {
var row=new OrderBatches.Row(1,null,base.toString(),"",0,BigDecimal.ZERO,service,BigDecimal.ZERO,false,false);
var amounts=new LegacyOrderPricing.BatchAmounts(freight,BigDecimal.ZERO,BigDecimal.ZERO,discount,decimal(j,"jb_markup"),BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO);
BatchInvoiceTexts.SelfDiscount self=null;
if(hasSelf&&selfRate.signum()!=0) {
if(hasSelf&&selfRate.signum()!=0&&selfGross!=null) {
BigDecimal deduction=selfGross.multiply(selfRate.movePointLeft(2)).setScale(2,RoundingMode.HALF_UP);
self=new BatchInvoiceTexts.SelfDiscount(selfRate,selfGross,deduction);
}
@@ -55,14 +55,14 @@ public class BillingService {
var byCenter=new TreeMap<Long,List<Map<String,Object>>>();for(var p:prices)byCenter.computeIfAbsent(id(p,"csc_id"),k->new ArrayList<>()).add(p);
if(byCenter.isEmpty())byCenter.put(id(j,"csc_id_payer"),List.of());int index=0;for(var e:byCenter.entrySet()){BigDecimal share=OrderEntryAccounting.portion(toll,byCenter.size(),index++);if(!centers.isEmpty()&&!centers.contains(e.getKey()))continue;
var context=e.getValue().isEmpty()?j:e.getValue().getFirst();if(q.customer()!=null&&id(context,"cs_id")!=q.customer())continue;
BigDecimal amount=e.getValue().isEmpty()?nz(decimal(j,"jb_totalprice")):e.getValue().stream().map(BillingService::net).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal amount=e.getValue().isEmpty()?nz(decimal(j,"jb_totalprice")):e.getValue().stream().map(BillingService::net).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue));
var line=line(j,e.getKey(),text(context,"csc_name"),amount,share,text(j,"jb_invtext"));line.put("customer",text(context,"cmp_comp"));line.put("customerId",id(context,"cs_id"));line.put("components",e.getValue().stream().map(p->fields("name",text(p,"trs_srv_name")+" "+text(p,"trs_srvt_name"),"net",net(p))).toList());rows.add(line);
}
}
var terms=new HashMap<Long,Map<String,Object>>();
for(var row:rows){var term=terms.computeIfAbsent(id(row,"id"),key->OrderPaymentTerms.read(db,key));row.put("paymentTerm",text(term,"label"));row.put("paymentTermCode",term.getOrDefault("value",text(term,"label")));row.put("paymentTermSource",text(term,"source"));}
revisionData.add(terms);
BigDecimal net=rows.stream().map(r->(BigDecimal)r.get("net")).reduce(BigDecimal.ZERO,BigDecimal::add),tax=rows.stream().map(r->(BigDecimal)r.get("tax")).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal net=rows.stream().map(r->(BigDecimal)r.get("net")).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)),tax=rows.stream().map(r->(BigDecimal)r.get("tax")).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue));
var pending=db.queryForList("SELECT gdc_gen_fieldname AS token FROM genericdatacontainer WHERE gdc_obj_type='nginv' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id",u.headquartersId());
return fields("rows",rows,"net",money(net),"tax",money(tax),"gross",money(net.add(tax)),"revision",revision(revisionData),"pending",pending);
}
@@ -71,14 +71,14 @@ public class BillingService {
BigDecimal stored=nz(decimal(job,hq>100&&hq<=1000?"jb_cr_subprice":"jb_cr_price"));if(hq>1000)return money(stored.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2));
boolean mode=catalog.parameter(hq,"MASK_CR_PRICE_MODE").equals("1");String from=catalog.parameter(hq,"MASK_CR_PRICE_MODE_DATE");LocalDateTime at=dateTime(job,"jb_ordertime");boolean after=false;if(mode&&at!=null){try{after=CourierPriceModeDate.active(from,at,false);}catch(RuntimeException ignored){throw bad("MASK_CR_PRICE_MODE_DATE ist ungültig.");}}
if(stored.signum()!=0&&(hq>100||!mode)||mode&&after)return stored;
boolean subtract=catalog.parameter(hq,"INV_JB_CR_PRICE").equals("1");return prices.stream().map(p->{BigDecimal amount=net(p);return subtract?money(amount.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2)):amount;}).reduce(BigDecimal.ZERO,BigDecimal::add);
boolean subtract=catalog.parameter(hq,"INV_JB_CR_PRICE").equals("1");return prices.stream().map(p->{BigDecimal amount=net(p);return subtract?money(amount.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2)):amount;}).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue));
}
Map<String,Object> line(Map<String,Object> j,long center,String centerName,BigDecimal amount,BigDecimal toll,String description){BigDecimal net=money(amount.add(toll)),rate=nz(decimal(j,"jb_sales_tax_rate")),tax=money(net.multiply(rate).movePointLeft(2));return fields("id",id(j,"jb_id"),"center",center,"centerName",centerName,"customer",text(j,"cmp_comp"),"customerId",id(j,"cs_id"),"courier",text(j,"cr_sid"),"finished",dateTime(j,"jb_finishtime"),"ordered",dateTime(j,"jb_ordertime"),"booked",dateTime(j,"jb_booktime"),"reference",text(j,"jb_commission_no"),"description",description,"net",net,"toll",money(toll),"taxRate",rate,"tax",tax,"gross",net.add(tax),"storno",id(j,"jb_storno"),"incomplete",id(j,"jb_incomplete")==1,"exported",OrderOperations.exported(j),"invoice",text(j,"inv_no"),"invoiceDate",dateTime(j,"inv_datetime"),"invoicePresent",id(j,"ng_invoice_id")>0,"invoicePrevious",id(j,"ng_invoice_id")==0?null:new InvoiceValue((String)j.get("inv_no"),(String)j.get("ng_invoice_date")),"vehicleType",id(j,"vht_id"));}
public Map<String,Object> assign(LegacyUser u,Assign a){u.require(5);u.require(7);if(a==null)throw bad("Rechnungsangaben fehlen.");check(u,a.query());token(a.requestId());String number=required(a.number(),30,"Rechnungsnummer");if(a.date()==null||a.orders()==null||a.orders().isEmpty()||a.orders().size()>2000||a.orders().stream().anyMatch(id->id==null||id<=0)||new HashSet<>(a.orders()).size()!=a.orders().size())throw bad("Bitte Rechnungsdatum und eindeutige Aufträge auswählen.");if(!Set.of("CUSTOMER","INVOICE").contains(a.query().mode())||a.query().customer()==null)throw bad("Rechnungszuordnungen benötigen genau einen ausgewählten Kunden.");
if(a.replaceExisting()&&!a.query().association())throw bad("Vorhandene Rechnungsangaben können nur im Bereich Rechnungszuordnung korrigiert werden.");
support.transactional("headquarters","genericdatacontainer","job","tourservice");db.writeTransaction(()->{lockHeadquarters(db,u);var existing=operation(a.requestId());String hash=revision(List.of(u.userId(),u.headquartersId(),a));if(existing!=null){if(!decode(existing).hash().equals(hash))throw bad("Die Vorgangskennung wurde bereits für andere Daten verwendet.");return null;}
var current=statement(u,a.query());if(!current.get("revision").equals(a.revision()))throw bad("Abrechnungsdaten wurden geändert. Bitte neu laden.");
var lines=(List<Map<String,Object>>)current.get("rows");Set<Long> eligible=new HashSet<>();for(var row:lines)if(!(Boolean)row.get("incomplete")&&(a.query().association()||!(Boolean)row.get("exported"))&&(!(Boolean)row.get("invoicePresent")||a.replaceExisting()))eligible.add(id(row,"id"));
var lines=TypedValues.rows(current.get("rows"));Set<Long> eligible=new HashSet<>();for(var row:lines)if(!(Boolean)row.get("incomplete")&&(a.query().association()||!(Boolean)row.get("exported"))&&(!(Boolean)row.get("invoicePresent")||a.replaceExisting()))eligible.add(id(row,"id"));
var items=new ArrayList<Assignment>();for(long job:a.orders()){if(!eligible.contains(job))throw bad("Auftrag #"+job+" ist nicht für diese Zuordnung freigegeben. Bitte Auswahl und Korrekturfreigabe prüfen.");one(db.lockRows("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",job,u.headquartersId()));if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock'",job).isEmpty())throw bad("Für diesen Auftrag läuft bereits eine Rechnungszuordnung.");var previous=invoiceValue(job);var shown=lines.stream().filter(line->id(line,"id")==job).findFirst().orElseThrow();if(!Objects.equals(previous,shown.get("invoicePrevious")))throw bad("Rechnungsangaben wurden inzwischen geändert. Bitte neu laden.");if(previous!=null&&!a.replaceExisting())throw bad("Auftrag bereits zugeordnet. Korrektur bitte ausdrücklich freigeben.");var shares=db.queryForList("SELECT DISTINCT c.cs_id FROM tourservice t JOIN costcenter c ON c.csc_id=t.csc_id WHERE t.jb_id=?",job);if(shares.stream().anyMatch(part->id(part,"cs_id")!=a.query().customer()))throw bad("Auftrag #"+job+" verteilt Kosten auf mehrere Kunden. Eine einzelne Rechnungsnummer darf diese Anteile nicht zusammenfassen.");items.add(new Assignment(job,assignmentRevision(job),previous));db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname","ng_invoice_lock","gdc_content",a.requestId(),"gdc_context","PENDING"));}
if(!db.queryForList("SELECT i.jb_id FROM invoice i JOIN job j ON j.jb_id=i.jb_id LEFT JOIN costcenter c ON c.csc_id=j.csc_id_payer WHERE i.inv_no=? AND (j.hq_id<>? OR COALESCE(c.cs_id,0)<>?) LIMIT 1",number,u.headquartersId(),a.query().customer()).isEmpty())throw bad("Diese Rechnungsnummer gehört bereits zu einem anderen Kunden oder einer anderen Niederlassung.");
// MyISAM cannot roll back. Save a durable intent before touching the invoice table.
@@ -107,5 +107,5 @@ public class BillingService {
for(var item:op.items())db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock' AND gdc_content=?",item.id(),token);
support.audit(u,0,0,0,2,"Rechnungsnummer "+op.number()+" zugeordnet · "+op.items().size()+" Aufträge",token);db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(row,"gdc_id"));return fields("state","DONE","number",op.number());});}
public Map<String,Object> stop(LegacyUser u,String token){u.require(5);u.require(7);token(token);return db.writeTransaction(()->{lockHeadquarters(db,u);var row=operation(token);if(row==null||decode(row).hq()!=u.headquartersId())throw bad("Vorgang nicht gefunden.");if(text(row,"gdc_context").equals("DONE"))throw bad("Vorgang ist bereits abgeschlossen.");for(var item:decode(row).items())db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock' AND gdc_content=?",item.id(),token);db.update("UPDATE genericdatacontainer SET gdc_context='STOPPED' WHERE gdc_id=?",id(row,"gdc_id"));support.audit(u,0,0,0,2,"Rechnungszuordnung beendet; bereits gespeicherte Zuordnungen bleiben erhalten",token);return fields("state","STOPPED");});}
public byte[] csv(LegacyUser u,Query q){var data=query(u,q);StringBuilder csv=new StringBuilder("\uFEFFAuftrag;Kostenstelle;Kunde;Fahrer;Erledigt;Rechnungsnummer;Netto;MwSt;Brutto;Zahlungsbedingung;Zahlungsbedingung-Code;Text\n");for(var r:(List<Map<String,Object>>)data.get("rows")){for(String k:List.of("id","centerName","customer","courier","finished","invoice","net","tax","gross","paymentTerm","paymentTermCode","description"))csv.append(ApiController.csvCell(r.get(k))).append(';');csv.append('\n');}return csv.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);}
public byte[] csv(LegacyUser u,Query q){var data=query(u,q);StringBuilder csv=new StringBuilder("\uFEFFAuftrag;Kostenstelle;Kunde;Fahrer;Erledigt;Rechnungsnummer;Netto;MwSt;Brutto;Zahlungsbedingung;Zahlungsbedingung-Code;Text\n");for(var r:TypedValues.rows(data.get("rows"))){for(String k:List.of("id","centerName","customer","courier","finished","invoice","net","tax","gross","paymentTerm","paymentTermCode","description"))csv.append(ApiController.csvCell(r.get(k))).append(';');csv.append('\n');}return csv.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);}
}
@@ -3,7 +3,7 @@ package de.stadtbote.votian;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.Authentication;
import org.springframework.http.*;
import java.util.*;
@RestController
@RequestMapping("/api")
@@ -28,5 +28,5 @@ public class BusinessController {
@PostMapping("/billing/operations/{token}/resume") public Object resume(Authentication a,@PathVariable String token){return billing.resume(ApiController.user(a),token);}
@PostMapping("/billing/operations/{token}/stop") public Object stop(Authentication a,@PathVariable String token){return billing.stop(ApiController.user(a),token);}
@PostMapping("/billing/export.csv") public ResponseEntity<byte[]> csv(Authentication a,@RequestBody BillingService.Query q){return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=abrechnung.csv").body(billing.csv(ApiController.user(a),q));}
@PostMapping("/billing/statement.pdf") public ResponseEntity<byte[]> pdf(Authentication a,@RequestBody BillingService.Query q){return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=rechnungsanlage.pdf").body(StatementPdf.create(billing.query(ApiController.user(a),q),q));}
@PostMapping("/billing/statement.pdf") public ResponseEntity<byte[]> pdf(Authentication a,@RequestBody BillingService.Query q){return ResponseEntity.ok().contentType(java.util.Objects.requireNonNull(MediaType.APPLICATION_PDF)).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=rechnungsanlage.pdf").body(StatementPdf.create(billing.query(ApiController.user(a),q),q));}
}
@@ -44,6 +44,6 @@ public final class CashExportPlan {
var existing=new ArrayList<Map<String,Object>>();for(var collection:collections){var members=db.queryForList("SELECT p.jbp_id,p.jbp_price,p.jbp_export_time,p.jbp_mode,j.hq_id,cc.cs_id FROM jobpayment p LEFT JOIN job j ON j.jb_id=p.jb_id LEFT JOIN costcenter cc ON cc.csc_id=p.csc_id WHERE p.jbpc_id=? ORDER BY p.jbp_id LIMIT 10001",id(collection,"jbpc_id"));Cashflow.bound(members,10000);if(members.stream().anyMatch(p->id(p,"hq_id")!=u.headquartersId()||id(p,"cs_id")!=q.customer()))throw bad("Eine offene Sammlung enthält Zahlungen anderer Kunden oder Niederlassungen. Bitte die Zuordnung vor dem Export klären.");if(members.stream().anyMatch(OrderPayments::exportedPayment))throw bad("Eine offene Sammlung enthält bereits exportierte Zahlungen. Bitte die Zuordnung klären.");existing.add(fields("id",id(collection,"jbpc_id"),"date",text(collection,"jbpc_bookingdate"),"mode",id(collection,"jbpc_mode"),"invoice",text(collection,"jbpc_invtext"),"net",Cashflow.net(collection.get("jbpc_price")),"payments",members.stream().map(p->id(p,"jbp_id")).toList()));}
var profiles=db.queryForList("SELECT expp_id AS id,expp_name AS label FROM exportparameters WHERE expc_id=9 AND hq_id=? AND cs_id=0 ORDER BY expp_id",u.headquartersId());boolean catalog=db.queryForList("SELECT expc_id FROM exportcategory WHERE expc_id=9 AND expc_usrtype=1 AND expc_type=0").size()==1;long fieldsCount=db.queryForObject("SELECT COUNT(*) FROM exportcategoryfields WHERE expc_id=9",Long.class);
String notice=!catalog||fieldsCount==0||profiles.isEmpty()?"Das Dateiprofil samt Felddefinition für Kategorie 9 fehlt. Die Vorschau verändert keine Zahlungen; eine Exportdatei und Exportkennzeichnung sind noch nicht möglich.":"Das hinterlegte Dateiprofil kann jetzt geprüft werden. Erst der bestätigte Export erstellt Sammlungen und kennzeichnet die Zahlungen.";
return fields("query",q,"paymentFilter",input.paymentFilter(),"modes",selected,"special",special(selected),"groups",groups,"existing",existing,"net",groups.stream().map(Group::net).reduce(BigDecimal.ZERO,BigDecimal::add).add(Cashflow.sum(existing,"net")),"profiles",profiles,"notice",notice,"formatAvailable",catalog&&fieldsCount>0&&!profiles.isEmpty(),"revision",revision(List.of(input,customer,selected,texts,payments,collections,existing,profiles)));
return fields("query",q,"paymentFilter",input.paymentFilter(),"modes",selected,"special",special(selected),"groups",groups,"existing",existing,"net",groups.stream().map(entryValue -> entryValue.net()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)).add(Cashflow.sum(existing,"net")),"profiles",profiles,"notice",notice,"formatAvailable",catalog&&fieldsCount>0&&!profiles.isEmpty(),"revision",revision(List.of(input,customer,selected,texts,payments,collections,existing,profiles)));
});}
}
@@ -22,7 +22,7 @@ public final class Cashflow {
static final List<String> VIEWS=List.of("Kassenübersicht mit Umbuchungen","Einnahmen pro Auftrag","Einnahmen pro Auftrag und Zahlungsart","Einzelzahlungen","Einnahmen pro Zahlungsart");
static BigDecimal net(Object n){return OrderPayments.net(n);}
static BigDecimal gross(BigDecimal n){return n.multiply(new BigDecimal("1.19")).setScale(2,RoundingMode.HALF_UP);}
static BigDecimal sum(List<Map<String,Object>> rows,String column){return rows.stream().map(r->net(r.get(column))).reduce(BigDecimal.ZERO,BigDecimal::add);}
static BigDecimal sum(List<Map<String,Object>> rows,String column){return rows.stream().map(r->net(r.get(column))).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue));}
List<Map<String,Object>> headquarters(LegacyUser u){u.require(26);var result=new ArrayList<Map<String,Object>>(db.queryForList("SELECT hq_id AS id,hq_mnemonic AS label FROM headquarters WHERE hq_id=?",u.headquartersId()));if(u.hasRight(10))for(var row:db.queryForList("SELECT DISTINCT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE own.hq_id=? AND own.md_id>0 AND COALESCE(h.hq_disabled,0)=0 ORDER BY h.hq_mnemonic",u.headquartersId()))if(result.stream().noneMatch(r->id(r,"id")==id(row,"id")))result.add(row);return result;}
LegacyUser scope(LegacyUser u,long hq){u.require(26);if(hq<=0)hq=u.headquartersId();long selected=hq;if(headquarters(u).stream().noneMatch(r->id(r,"id")==selected))throw new org.springframework.security.access.AccessDeniedException("Diese Niederlassung ist für die Kasse nicht freigegeben.");return new LegacyUser(u.userId(),u.employeeId(),selected,u.username(),u.rights(),u.originalHeadquartersId(),u.accountType());}
long defaultCustomer(LegacyUser u){var rows=db.queryForList("SELECT par_value FROM parameter WHERE emp_id=? AND hq_id=? AND par_key='MASK_DISPOSITION_INIT_CS'",u.employeeId(),u.headquartersId());if(rows.size()>1)throw bad("Der Startkunde ist mehrfach hinterlegt.");return rows.isEmpty()?0:VehicleDispositionPreferences.number(text(rows.getFirst(),"par_value"));}
@@ -61,10 +61,10 @@ public final class Cashflow {
for(var j:jobs.values()){var selected=j.payments().stream().filter(p->selected(q,j,p)).toList();if(q.view()==1){if(!selected.isEmpty())result.add(row(j,selected,"Alle Zahlarten"));continue;}
var perMode=new TreeMap<Long,List<Map<String,Object>>>();for(var p:selected)perMode.computeIfAbsent(id(p,"jbp_mode"),x->new ArrayList<>()).add(p);
if(q.view()==2){perMode.forEach((m,p)->result.add(row(j,p,label(modes,m))));continue;}if(q.view()==3){for(var p:selected)result.add(row(j,List.of(p),label(modes,id(p,"jbp_mode"))));continue;}
for(var p:selected){String key;if(q.counter()==2){if(service(j))key="Serviceaufträge";else if((id(j.job(),"jb_service")&2)==2&&id(p,"jbp_counter")==0)key="Montage-Inkasso";else continue;}else key=Long.toString(id(p,"jbp_mode"));groups.computeIfAbsent(key,x->new ArrayList<>()).add(p);if(due.computeIfAbsent(key,x->new HashSet<>()).add(id(j.job(),"jb_id")))expected.merge(key,net(j.job().get("jb_subtotalprice")),BigDecimal::add);}
for(var p:selected){String key;if(q.counter()==2){if(service(j))key="Serviceaufträge";else if((id(j.job(),"jb_service")&2)==2&&id(p,"jbp_counter")==0)key="Montage-Inkasso";else continue;}else key=Long.toString(id(p,"jbp_mode"));groups.computeIfAbsent(key,x->new ArrayList<>()).add(p);if(due.computeIfAbsent(key,x->new HashSet<>()).add(id(j.job(),"jb_id")))expected.merge(key,net(j.job().get("jb_subtotalprice")),(entryValue, otherValue) -> entryValue.add(otherValue));}
}
if(q.view()==4){if(q.counter()==2){groups.putIfAbsent("Montage-Inkasso",List.of());groups.putIfAbsent("Serviceaufträge",List.of());}groups.forEach((key,p)->result.add(fields("job",0,"parent",0,"label",q.counter()==2?key:label(modes,Long.parseLong(key)),"paid",gross(sum(p,"jbp_price")),"due",key.equals("Serviceaufträge")?BigDecimal.ZERO:gross(expected.getOrDefault(key,BigDecimal.ZERO)),"remaining",BigDecimal.ZERO,"booked","","payments",List.of(),"collections",p.stream().map(r->id(r,"jbpc_id")).filter(v->v>0).distinct().sorted().toList(),"calculation",List.of(),"articles",List.of(),"reference","")));}
return result;
}
Map<String,Object> view(Data d){return fields("query",d.query(),"title",VIEWS.get(d.query().view()),"customer",d.customer(),"rows",d.rows(),"dayTotals",d.dayTotals(),"paid",sum(d.rows(),"paid"),"due",d.query().view()==2?gross(d.jobs().values().stream().map(j->net(j.job().get("jb_subtotalprice"))).reduce(BigDecimal.ZERO,BigDecimal::add)):sum(d.rows(),"due"),"modes",d.modes().stream().map(m->fields("id",id(m,"mt_sort"),"label",text(m,"mt_value"))).toList(),"revision",revision(d),"canEdit",d.canEdit());}
Map<String,Object> view(Data d){return fields("query",d.query(),"title",VIEWS.get(d.query().view()),"customer",d.customer(),"rows",d.rows(),"dayTotals",d.dayTotals(),"paid",sum(d.rows(),"paid"),"due",d.query().view()==2?gross(d.jobs().values().stream().map(j->net(j.job().get("jb_subtotalprice"))).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))):sum(d.rows(),"due"),"modes",d.modes().stream().map(m->fields("id",id(m,"mt_sort"),"label",text(m,"mt_value"))).toList(),"revision",revision(d),"canEdit",d.canEdit());}
}
@@ -20,10 +20,10 @@ public final class CashflowDocuments {
Cashflow.Data snapshot(LegacyUser u,Request r){if(r==null||r.revision()==null)throw bad("Bitte die Kassenübersicht neu laden.");return cash.db.readTransaction(()->{var d=cash.read(u,r.query());AdminSupport.unchanged(d,r.revision());return d;});}
@PostMapping("/export.csv") ResponseEntity<byte[]> csv(Authentication a,@RequestBody Request r){return response(csv(snapshot(ApiController.user(a),r)),"text/csv;charset=UTF-8","Kasse.csv");}
@PostMapping("/statement.pdf") ResponseEntity<byte[]> pdf(Authentication a,@RequestBody Request r){return response(pdf(snapshot(ApiController.user(a),r)),"application/pdf","Kasse.pdf");}
static ResponseEntity<byte[]> response(byte[] data,String type,String filename){return ResponseEntity.ok().contentType(MediaType.parseMediaType(type)).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=\""+filename+"\"").cacheControl(CacheControl.noStore()).body(data);}
static ResponseEntity<byte[]> response(byte[] data,String type,String filename){return ResponseEntity.ok().contentType(MediaType.parseMediaType(java.util.Objects.requireNonNull(type))).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=\""+filename+"\"").cacheControl(CacheControl.noStore()).body(data);}
static String amount(Object value){return Cashflow.net(value).setScale(2,java.math.RoundingMode.HALF_UP).toPlainString().replace('.',',');}
static byte[] csv(Cashflow.Data d){var csv=new StringBuilder("\uFEFFAuftrag;Interne Nummer;Hauptauftrag;Zahlart / Bereich;Einnahmen brutto;Soll brutto;Rest brutto;Buchung;Sammelnummern;Referenz;Auftragsposten;Artikel\r\n");for(var row:d.rows()){for(Object v:List.of(text(row,"displayNumber"),text(row,"job"),text(row,"parent"),text(row,"label"),amount(row.get("paid")),amount(row.get("due")),amount(row.get("remaining")),text(row,"booked"),text(row,"collections"),text(row,"reference"),details(row,"calculation"),details(row,"articles")))csv.append(ApiController.csvCell(v)).append(';');csv.setLength(csv.length()-1);csv.append("\r\n");}return csv.toString().getBytes(StandardCharsets.UTF_8);}
static String details(Map<String,Object> row,String key){var list=(List<Map<String,Object>>)row.get(key);if(list==null)return "";return list.stream().map(r->key.equals("calculation")?text(r,"srv_name")+" / "+text(r,"srvt_name")+": "+text(r,"jbc_amount")+" × "+amount(r.get("jbc_price"))+" = "+amount(r.get("jbc_totalprice"))+" netto":text(r,"trat_quantity")+" × "+text(r,"trat_name")+" · "+text(r,"trat_serialno")+" · "+text(r,"trat_remark")).reduce((a,b)->a+"\n"+b).orElse("");}
static String details(Map<String,Object> row,String key){var list=TypedValues.rows(row.get(key));if(list==null)return "";return list.stream().map(r->key.equals("calculation")?text(r,"srv_name")+" / "+text(r,"srvt_name")+": "+text(r,"jbc_amount")+" × "+amount(r.get("jbc_price"))+" = "+amount(r.get("jbc_totalprice"))+" netto":text(r,"trat_quantity")+" × "+text(r,"trat_name")+" · "+text(r,"trat_serialno")+" · "+text(r,"trat_remark")).reduce((a,b)->a+"\n"+b).orElse("");}
static byte[] pdf(Cashflow.Data data){try(var pdf=new Pdf()){
pdf.line("KASSE",18);pdf.line(Cashflow.VIEWS.get(data.query().view()),12);pdf.line(data.customer(),11);pdf.line(data.query().from()+" "+data.query().fromTime()+" bis "+data.query().to()+" "+data.query().toTime(),10);pdf.line("Niederlassung "+data.query().headquarters()+" · "+(data.query().counter()==1?"Counter":"Monteur-Inkasso"),10);pdf.line("Bruttobeträge nach PHP-Kassenregel: Nettobetrag × 1,19.",9);pdf.space();
for(var row:data.rows()){pdf.room(80);pdf.line((id(row,"job")>0?"Auftrag #"+text(row,"displayNumber")+(id(row,"parent")>0?" · Hauptauftrag #"+id(row,"parent"):"")+" · ":"")+text(row,"label"),11);pdf.line("Einnahmen "+amount(row.get("paid"))+" EUR · Soll "+amount(row.get("due"))+" EUR · Rest "+amount(row.get("remaining"))+" EUR",10);if(!text(row,"booked").isBlank())pdf.line("Buchung: "+text(row,"booked"),9);if(!text(row,"reference").isBlank())pdf.line("Referenz: "+text(row,"reference"),9);if(!Objects.toString(row.get("collections"),"[]").equals("[]"))pdf.line("Sammelnummern: "+row.get("collections"),9);for(String key:List.of("calculation","articles")){String text=details(row,key);if(!text.isEmpty()){pdf.line(key.equals("calculation")?"Auftragsposten (netto)":"Artikel",9);pdf.line(text,9);}}pdf.space();}
@@ -29,7 +29,7 @@ public final class CashflowMail {
static void textCheck(String subject,String body){if(subject==null||subject.isBlank()||subject.length()>250||subject.chars().anyMatch(Character::isISOControl))throw bad("Bitte einen Betreff mit höchstens 250 Zeichen ohne Steuerzeichen eingeben.");if(body==null||body.length()>20000||body.indexOf('\0')>=0)throw bad("Der Mailtext darf höchstens 20.000 Zeichen enthalten.");}
void exported(LegacyUser actor,Cashflow.Query q){
String modes="";if(q.customer()>0){modes=customerParameter(q,"EXPORT_CAT_09_JBPMODES_01_CS");if(modes.isBlank()){var u=cash.scope(actor,q.headquarters());long group=OrderPayments.group(cash.customer(u,q.customer()));if(group>0)modes=new OrderCustomerMailContent(cash.db,q.headquarters(),"").object("EXPORT_CAT_09_JBPMODES_01_GRP",group);}}
var args=new ArrayList<Object>(List.of(q.headquarters(),q.headquarters(),Cashflow.start(q),Cashflow.end(q)));String where="j.hq_id=? AND cs.hq_id=? AND p.jbp_bookingtime>=? AND p.jbp_bookingtime<? AND p.jbp_counter=1 AND p.jbpc_id=0 AND p.jbp_price<>0 AND (p.jbp_export_time IS NULL OR p.jbp_export_time='0000-00-00 00:00:00')";if(q.customer()>0){where+=" AND cs.cs_id=?";args.add(q.customer());}if(!modes.isBlank()){if(!modes.matches("\\s*\\d+(\\s*,\\s*\\d+)*\\s*"))throw bad("Die Export-Zahlarten für diesen Kunden sind ungültig.");var ids=Arrays.stream(modes.split(",")).map(String::trim).map(Integer::valueOf).distinct().toList();if(ids.size()>128||ids.stream().anyMatch(i->i<0||i>127))throw bad("Ungültige Export-Zahlarten.");where+=" AND p.jbp_mode IN ("+CrmScope.marks(ids.size())+")";args.addAll(ids);}
var args=new ArrayList<Object>(List.of(q.headquarters(),q.headquarters(),Cashflow.start(q),Cashflow.end(q)));String where="j.hq_id=? AND cs.hq_id=? AND p.jbp_bookingtime>=? AND p.jbp_bookingtime<? AND p.jbp_counter=1 AND p.jbpc_id=0 AND p.jbp_price<>0 AND (p.jbp_export_time IS NULL OR p.jbp_export_time='0000-00-00 00:00:00')";if(q.customer()>0){where+=" AND cs.cs_id=?";args.add(q.customer());}if(!modes.isBlank()){if(!modes.matches("\\s*\\d+(\\s*,\\s*\\d+)*\\s*"))throw bad("Die Export-Zahlarten für diesen Kunden sind ungültig.");var ids=Arrays.stream(modes.split(",")).map(entryValue -> entryValue.trim()).map(Integer::valueOf).distinct().toList();if(ids.size()>128||ids.stream().anyMatch(i->i<0||i>127))throw bad("Ungültige Export-Zahlarten.");where+=" AND p.jbp_mode IN ("+CrmScope.marks(ids.size())+")";args.addAll(ids);}
if(!cash.db.queryForList("SELECT p.jbp_id FROM jobpayment p JOIN job j ON j.jb_id=p.jb_id JOIN costcenter cc ON cc.csc_id=p.csc_id JOIN customer cs ON cs.cs_id=cc.cs_id WHERE "+where+" LIMIT 1",args.toArray()).isEmpty())throw bad("Für diesen Zeitraum sind noch Kassenzahlungen zu exportieren. Bitte zuerst den Einnahmenexport abschließen.");
}
@PostMapping("/options") Map<String,Object> options(Authentication a,@RequestBody Cashflow.Query q){return options(ApiController.user(a),q);}
@@ -35,8 +35,8 @@ class ContactService {
if(q.customerId()!=null){where+=" AND EXISTS (SELECT 1 FROM metafieldvalue cv JOIN metafieldcategorykey ck2 ON ck2.mtfck_id=cv.mtfck_id JOIN metafieldkey k2 ON k2.mtfk_id=ck2.mtfk_id WHERE cv.mtfv_id=v.mtfv_id AND ck2.mtfc_id=1 AND ck2.hq_id=ck.hq_id AND k2.mtfk_type='co2cs' AND cv.mtfv_value=?)";args.add(q.customerId().toString());}
var matches=db.queryForList("SELECT DISTINCT v.mtfv_id AS id,ck.hq_id AS headquartersId FROM metafieldvalue v JOIN metafieldcategorykey ck ON ck.mtfck_id=v.mtfck_id WHERE "+where+" ORDER BY v.mtfv_id DESC LIMIT 501",args.toArray());if(matches.size()>500)throw bad("Mehr als 500 Kontakte. Bitte Suche oder Niederlassungen eingrenzen.");
var presentation=ContactPresentation.settings(db);var result=new ArrayList<Map<String,Object>>();for(long hq:h){var ids=matches.stream().filter(m->id(m,"headquartersId")==hq).map(m->id(m,"id")).toList();if(ids.isEmpty())continue;var p=new ArrayList<Object>(List.of(hq));p.addAll(ids);var rows=db.queryForList("SELECT v.mtfv_id AS id,ck.mtfck_id AS fieldId,k.mtfk_name AS label,k.mtfk_type AS type,v.mtfv_value AS value FROM metafieldvalue v JOIN metafieldcategorykey ck ON ck.mtfck_id=v.mtfck_id JOIN metafieldkey k ON k.mtfk_id=ck.mtfk_id WHERE ck.mtfc_id=1 AND ck.hq_id=? AND v.mtfv_id IN ("+marks(ids.size())+") ORDER BY ck.mtfck_sort,ck.mtfck_id",p.toArray());var defs=definitions(hq);for(long cid:ids){var values=ContactPresentation.ordered(defs,rows.stream().filter(r->id(r,"id")==cid).toList());result.add(fields("id",cid,"headquartersId",hq,"fields",values,"header",presentation.header().isEmpty()?null:ContactPresentation.title(presentation,values),"cardFieldIds",ContactPresentation.body(presentation,values),"sortKey",ContactPresentation.sort(presentation,values,cid)));}}
var linked=customerLabels(u,result.stream().flatMap(r->((List<Map<String,Object>>)r.get("fields")).stream()).filter(f->text(f,"type").equals("co2cs")).map(f->text(f,"value")).toList());for(var r:result)for(var f:(List<Map<String,Object>>)r.get("fields"))if(text(f,"type").equals("co2cs"))f.put("displayValue",linked.getOrDefault(text(f,"value"),"Kunde "+text(f,"value")));
if(presentation.sort().isEmpty())result.sort(Comparator.comparing(r->contactName((List<Map<String,Object>>)r.get("fields")),String.CASE_INSENSITIVE_ORDER));else result.sort(Comparator.comparing(r->text(r,"sortKey")));result.forEach(r->r.remove("sortKey"));return result;
var linked=customerLabels(u,result.stream().flatMap(r->TypedValues.rows(r.get("fields")).stream()).filter(f->text(f,"type").equals("co2cs")).map(f->text(f,"value")).toList());for(var r:result)for(var f:TypedValues.rows(r.get("fields")))if(text(f,"type").equals("co2cs"))f.put("displayValue",linked.getOrDefault(text(f,"value"),"Kunde "+text(f,"value")));
if(presentation.sort().isEmpty())result.sort(Comparator.comparing(r->contactName(TypedValues.rows(r.get("fields"))),String.CASE_INSENSITIVE_ORDER));else result.sort(Comparator.comparing(r->text(r,"sortKey")));result.forEach(r->r.remove("sortKey"));return result;
}
static String contactName(List<Map<String,Object>> fields){return String.join(" ",fields.stream().filter(f->Set.of("Name","Vorname").contains(text(f,"label"))).map(f->text(f,"value")).toList());}
Map<String,Object> save(LegacyUser u,Long cid,Save s){if(s==null||s.values()==null)throw bad("Kontaktdaten fehlen.");scope.checked(u,List.of(s.headquarters()));var defs=definitions(s.headquarters());unchanged(defs,s.schemaRevision());if(defs.isEmpty()||s.values().size()>200)throw bad("Ungültige Kontaktfelder.");var known=defs.stream().map(d->id(d,"id")).toList();if(!known.containsAll(s.values().keySet()))throw bad("Unbekanntes Kontaktfeld.");var vals=new LinkedHashMap<Long,String>();var before=cid==null?List.<Map<String,Object>>of():raw(cid,defs);if(cid!=null){if(before.isEmpty())one(before);unchanged(before,s.revision());}
@@ -44,7 +44,7 @@ class ContactService {
if(modified)FormContactValues.validate(d,v);
if(modified&&(type.equals("date")||type.equals("birthdate"))&&!v.isBlank()){try{LocalDate.parse(v,DateTimeFormatter.ofPattern("dd.MM.uuuu").withResolverStyle(ResolverStyle.STRICT));}catch(DateTimeParseException e){throw bad(text(d,"label")+": Datum bitte als TT.MM.JJJJ eingeben.");}}
vals.put(id(d,"id"),v);}
if(vals.values().stream().allMatch(String::isBlank))throw bad("Bitte mindestens ein Kontaktfeld ausfüllen.");
if(vals.values().stream().allMatch(entryValue -> entryValue.isBlank()))throw bad("Bitte mindestens ein Kontaktfeld ausfüllen.");
return db.contactWriteTransaction(()->{unchanged(definitions(s.headquarters()),s.schemaRevision());List<Map<String,Object>> old=cid==null?List.of():raw(cid,defs);if(cid!=null){if(old.isEmpty())one(old);unchanged(old,s.revision());}long contact=cid==null?db.queryForObject("SELECT COALESCE(MAX(mtfv_id),0)+1 FROM metafieldvalue",Long.class):cid;if(contact<=0||contact>Integer.MAX_VALUE)throw bad("Kontakt-ID-Bereich ausgeschöpft.");
if(cid!=null){var a=new ArrayList<Object>(List.of(contact));a.addAll(known);db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(known.size())+")",a.toArray());}
for(var d:defs){long key=id(d,"id");String v=vals.get(key);db.update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",key,contact,v);String previous=id(d,"history")==1?db.queryForList("SELECT mtfv_value FROM metafieldvaluehistory WHERE mtfck_id=? AND mtfv_id=? ORDER BY mtfvh_createtime DESC LIMIT 1",key,contact).stream().map(r->text(r,"mtfv_value")).findFirst().orElse(""):v;if(id(d,"history")==1&&!previous.equals(v))db.update("INSERT INTO metafieldvaluehistory (mtfck_id,mtfv_id,mtfv_value,mtfvh_createtime) VALUES (?,?,?,?)",key,contact,v,LocalDateTime.now(ZoneId.of("Europe/Berlin")));}
@@ -17,7 +17,7 @@ final class ContactVcard {
return Arrays.stream(value.split(",")).map(Integer::parseInt).toList();
}
static String label(String value){return org.jsoup.Jsoup.parse(value==null?"":value).text().replace('\u00a0',' ').strip();}
static String named(List<Field> fields,String... names){return fields.stream().filter(f->Arrays.asList(names).contains(label(f.label()))).map(Field::value).findFirst().orElse("");}
static String named(List<Field> fields,String... names){return fields.stream().filter(f->Arrays.asList(names).contains(label(f.label()))).map(entryValue -> entryValue.value()).findFirst().orElse("");}
static String at(List<Field> fields,List<Integer> indexes,int position){return position<indexes.size()&&indexes.get(position)<fields.size()?fields.get(indexes.get(position)).value():"";}
static String escape(String s){return s.replace("\\","\\\\").replace("\r\n","\n").replace('\r','\n').replace("\n","\\n").replace(";","\\;").replace(",","\\,").replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]","");}
static String birthday(String value){try{return LocalDate.parse(value,DateTimeFormatter.ofPattern("dd.MM.uuuu").withResolverStyle(ResolverStyle.STRICT)).toString();}catch(DateTimeException e){return "";}}
@@ -30,7 +30,7 @@ class ContactVcards {
var detail=contacts.get(u,headquarters,contact);var schema=(Map<String,Object>)detail.get("schema");var defs=(List<Map<String,Object>>)schema.get("fields");var values=(Map<Long,String>)detail.get("values");
var fs=defs.stream().map(d->new ContactVcard.Field(text(d,"label"),text(d,"type"),values.getOrDefault(id(d,"id"),""))).toList();var companies=new ArrayList<String>();
var hqs=new CrmScope(db).headquarters(u).stream().map(r->id(r,"id")).toList();
for(String customer:fs.stream().filter(f->f.type().equals("co2cs")).map(ContactVcard.Field::value).filter(v->v.matches("[1-9][0-9]{0,9}")).distinct().toList()){
for(String customer:fs.stream().filter(f->f.type().equals("co2cs")).map(entryValue -> entryValue.value()).filter(v->v.matches("[1-9][0-9]{0,9}")).distinct().toList()){
var rows=db.queryForList("SELECT cs.hq_id,co.cmp_comp FROM customer cs JOIN company co ON co.cmp_id=cs.cmp_id WHERE cs.cs_id=? AND co.cmp_visible=1 AND co.cmp_authenticated=1",Long.parseLong(customer));
if(rows.size()>1)throw bad("Mehrdeutiger Firmenverweis im Kontakt.");if(rows.size()==1&&hqs.contains(id(rows.getFirst(),"hq_id")))companies.add(text(rows.getFirst(),"cmp_comp").strip());
}
@@ -28,7 +28,7 @@ class CourierBourse {
Context context(LegacyUser u){
var account=CourierPortalScope.verify(db,u);var catalog=new OrderEntryCatalog(db);long hq=u.headquartersId();if(!catalog.enabled(hq,"SYSTEM_BOURSE_MODE"))throw CourierPortal.denied();
var headquarters=db.queryForList(HQ_SQL,mandator(db,u)).stream().map(r->id(r,"hq_id")).toList();
String configured=catalog.parameter(hq,"MASK_BRSJB_LIST_COLS");var keys=configured.isBlank()?DEFAULT:Arrays.stream(configured.split(",")).map(String::strip).distinct().filter(LABELS::containsKey).toList();boolean unknown=!configured.isBlank()&&Arrays.stream(configured.split(",")).map(String::strip).anyMatch(k->!LABELS.containsKey(k));if(keys.isEmpty())keys=DEFAULT;
String configured=catalog.parameter(hq,"MASK_BRSJB_LIST_COLS");var keys=configured.isBlank()?DEFAULT:Arrays.stream(configured.split(",")).map(entryValue -> entryValue.strip()).distinct().filter(LABELS::containsKey).toList();boolean unknown=!configured.isBlank()&&Arrays.stream(configured.split(",")).map(entryValue -> entryValue.strip()).anyMatch(k->!LABELS.containsKey(k));if(keys.isEmpty())keys=DEFAULT;
int limit=100;try{int n=Integer.parseInt(catalog.parameter(hq,"MASK_BRSJB_LIST_BROWSE_MAX"));if(n>=100&&n<=1000)limit=n;}catch(NumberFormatException ignored){}
var params=db.queryForList("SELECT par_key,par_value,hq_id,emp_id FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN ('LATEST_TAKETIME_IN_MINUTES')",hq);
var expr=OrderListCells.expressions(params,u,false,false,9,null);expr.put("business_volume","jb.jb_totalprice");expr.put("jb_latesttaketime",expr.get("jb_latesttaketime").replace("tt.hq_id=jb.hq_id_dispo","tt.hq_id=jb.hq_id"));
@@ -52,7 +52,7 @@ class CourierDisposition {
String warning="";List<VehicleDisposition.Segment> segments=List.of();
try{segments=VehicleDisposition.segments(cells,day,units,known);if(cells.size()!=24*units)warning="Das Tagesraster ist unvollständig.";}catch(org.springframework.web.server.ResponseStatusException invalid){warning=invalid.getReason();}
var row=new LinkedHashMap<>(vehicle);var daily=a.size()==1?a.getFirst():Map.<String,Object>of();
var visibleIds=new HashSet<>(segments.stream().map(VehicleDisposition.Segment::job).filter(j->j>0).toList());
var visibleIds=new HashSet<>(segments.stream().map(entryValue -> entryValue.job()).filter(j->j>0).toList());
var unreserved=new ArrayList<VehicleDisposition.Segment>();
for(var j:jobs){long job=id(j,"jb_id");var ordered=dateTime(j,"jb_ordertime");
if(!visibleIds.contains(job)&&Set.of(1L,2L).contains(id(j,"jb_status"))&&ordered!=null&&ordered.toLocalDate().equals(day)&&text(j,"actual_sid").equals(text(vehicle,"sid"))){
@@ -62,13 +62,13 @@ class CourierDispositionReplanning {
if(!Set.of(0L,9L).contains(id(j,"jb_status"))||id(j,"jb_offer")!=0)throw bad("Nur offene oder reservierte Aufträge ohne Angebot können umgeplant werden.");
if(id(j,"hq_id_exec")>0&&id(j,"hq_id_exec")!=id(j,"hq_id")||id(j,"jb_longhaul")!=0||id(j,"jb_autoranking")!=0||id(j,"jb_globaljob")!=0)throw bad("Dieser Auftrag benötigt einen gesonderten Vermittlungsablauf.");new VehicleDisposition(db,support).writable(j,s.now(),evidence);
var target=row(u,c.target(),c.day());evidence.add(target);var targetOwner=one(db.queryForList("SELECT crvh_id,cr_id FROM couriervehicle WHERE crvh_id=?",c.target()));evidence.add(targetOwner);if(id(j,"cr_id")!=board.courier(u)&&id(targetOwner,"cr_id")!=board.courier(u))throw bad("Bei unveränderter Kurierzuordnung ist dieser Auftrag auf dem Zielfahrzeug nicht freigegeben.");
var after=chunks(c.startDay(),VehiclePlanning.start(c.time(),s.units()),s.followingStart(),id(j,"jb_timeunits"),s.maximum(),s.units());var keys=new TreeSet<Key>(Comparator.comparingLong(Key::vehicle).thenComparing(Key::day));s.before().forEach(r->keys.add(new Key(c.vehicle(),r.day())));after.forEach(r->keys.add(new Key(c.target(),r.day())));
var after=chunks(c.startDay(),VehiclePlanning.start(c.time(),s.units()),s.followingStart(),id(j,"jb_timeunits"),s.maximum(),s.units());var keys=new TreeSet<Key>(Comparator.comparingLong((VehicleMultiDayPlanning.Key entryValue) -> entryValue.vehicle()).thenComparing(entryValue -> entryValue.day()));s.before().forEach(r->keys.add(new Key(c.vehicle(),r.day())));after.forEach(r->keys.add(new Key(c.target(),r.day())));
var calendars=new LinkedHashMap<Key,Calendar>();for(var key:keys)calendars.put(key,calendar(u,key,s,evidence));for(var r:s.before()){var cells=calendars.get(new Key(c.vehicle(),r.day())).cells();for(int i=r.start();i<r.end();i++){if(cells[i]!=c.job())throw VehicleDisposition.conflict();cells[i]=0;}}
for(var r:after){var cal=calendars.get(new Key(c.target(),r.day()));for(int i=r.start();i<r.end();i++){if(cal.cells()[i]!=0)throw bad("Das Zielfahrzeug ist am "+r.day()+" um "+VehicleDisposition.time(i,s.units())+" belegt. Es wird nichts gespeichert.");cal.cells()[i]=c.job();}}
var planned=c.startDay().atStartOfDay().plusMinutes(after.getFirst().start()*60L/s.units());var starts=new LinkedHashMap<Long,VehiclePlanningDependencies.Start>();starts.put(c.job(),new VehiclePlanningDependencies.Start(c.target(),planned));
var warnings=new CourierDispositionPlanning(db,support).dependencies(u,List.of(j),s.units(),starts,evidence);if(c.target()==c.vehicle()&&after.equals(s.before())&&Objects.equals(dateTime(j,"jb_ordertime"),planned))throw bad("Keine Änderung vorhanden.");
var blocks=new LinkedHashMap<Key,List<List<Object>>>();
for(long vehicle:keys.stream().map(Key::vehicle).distinct().toList()){
for(long vehicle:keys.stream().map(entryValue -> entryValue.vehicle()).distinct().toList()){
var rows=relations.rows(vehicle);evidence.add(rows);var contexts=new LinkedHashMap<Long,VehicleCustomerRelations.Context>();
for(var r:rows){long customer=id(r,"cs_id");evidence.add(one(db.queryForList("SELECT cs_id,hq_id FROM customer WHERE cs_id=? AND hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?)",customer,mandator(db,u))));var ctx=relations.context(customer,s.units());evidence.add(ctx);contexts.put(customer,ctx);VehicleCustomerRelations.validateSchedule(VehicleAvailability.decode(text(r,"csvh_day_times")),ctx);if(id(r,"csvh_relation")<0||id(r,"csvh_relation")>1)throw bad("Ungültige Kundenbeziehung.");}
if(vehicle==c.target()){
@@ -90,7 +90,7 @@ class CourierDispositionReplanning {
warnings.addAll(p.warnings());
var capacityWarnings=VehicleCapacityWarning.messages(p.capacities());warnings.addAll(capacityWarnings);
if(s.articleWeight().signum()>0)warnings.add("Die Rest-Nutzlast wird je betroffenem Fahrzeugtag aus der Fahrzeugnutzlast abzüglich der Artikelgewichte aller dort reservierten Aufträge berechnet. Jeder Auftrag zählt pro Tag einmal; Stückzahlen vervielfachen das Positionsgewicht nicht.");
if(p.capacities().values().stream().anyMatch(Capacity::correction))warnings.add("Der gespeicherte Gewichtsbestand weicht von den heutigen Artikel- und Fahrzeugdaten ab. Die markierten Bestandsabweichungen werden beim Speichern ebenfalls berichtigt.");
if(p.capacities().values().stream().anyMatch(entryValue -> entryValue.correction()))warnings.add("Der gespeicherte Gewichtsbestand weicht von den heutigen Artikel- und Fahrzeugdaten ab. Die markierten Bestandsabweichungen werden beim Speichern ebenfalls berichtigt.");
return fields("capacityWarning",!capacityWarnings.isEmpty(),"planRevision",p.revision(),"articleWeight",s.articleWeight(),"before",describe(s.before(),s.units()),"after",describe(p.after(),s.units()),"beforeOrdertime",text(s.job(),"jb_ordertime"),"afterOrdertime",c.startDay()+" "+VehicleDisposition.time(p.after().getFirst().start(),s.units()),"days",p.calendars().entrySet().stream().map(e->{var capacity=p.capacities().get(e.getKey());return fields("vehicle",e.getKey().vehicle(),"day",e.getKey().day().toString(),"beforeWeight",capacity.stored(),"expectedWeight",capacity.expected(),"afterWeight",capacity.after(),"payload",capacity.payload(),"beforeLoad",capacity.beforeLoad(),"afterLoad",capacity.afterLoad(),"correction",capacity.correction(),"positions",id(e.getValue().availability(),"vha_position"));}).toList(),"warnings",warnings);
});}
Map<String,Object> save(LegacyUser u,Command c){validate(u,c);if(!c.confirmed())throw bad("Bitte alle Reservierungstage, Gewichtsänderungen und Hinweise bestätigen.");support.transactional("job","vehicledisposition","vehicleavailability","vehicleavailabilitytimeunits","genericdatacontainer");return db.courierReplanningWriteTransaction(()->{
@@ -18,7 +18,7 @@ class CourierDispositionRollcard {
@GetMapping Map<String,Object> get(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day,@RequestParam(defaultValue="ALL") String selection){return view(ApiController.user(a),vehicle,day,selection);}
@GetMapping("/pdf") org.springframework.http.ResponseEntity<byte[]> pdf(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day,@RequestParam(defaultValue="ALL") String selection,@RequestParam String revision){
var data=view(ApiController.user(a),vehicle,day,selection);if(!Objects.equals(data.get("revision"),revision))throw VehicleDisposition.conflict();
return org.springframework.http.ResponseEntity.ok().header("Cache-Control","no-store").header("Content-Disposition","attachment; filename=Rollkarte-"+vehicle+"-"+day+".pdf").contentType(org.springframework.http.MediaType.APPLICATION_PDF).body(VehicleRollcardPdf.create(data));
return org.springframework.http.ResponseEntity.ok().header("Cache-Control","no-store").header("Content-Disposition","attachment; filename=Rollkarte-"+vehicle+"-"+day+".pdf").contentType(java.util.Objects.requireNonNull(org.springframework.http.MediaType.APPLICATION_PDF)).body(VehicleRollcardPdf.create(data));
}
@SuppressWarnings("unchecked") Map<String,Object> view(LegacyUser u,long vehicle,LocalDate day,String selection){return db.readTransaction(()->{
if(vehicle<=0)throw bad("Fahrzeug fehlt.");VehicleDisposition.date(day);if(selection==null||!Set.of("ALL","RESERVED","ACCEPTED").contains(selection))throw bad("Ungültige Rollkartenauswahl.");
@@ -51,7 +51,7 @@ class CourierPortal {
}
Map<String,Object> query(LegacyUser u,Query q){return db.readTransaction(()->{
var s=scope(u);check(q,s);var jobs=jobs(s,q);var result=new ArrayList<Map<String,Object>>();BigDecimal normal=BigDecimal.ZERO,free=BigDecimal.ZERO;
var mandatory=Arrays.stream(new OrderEntryCatalog(db).parameter(u.headquartersId(),"MASK_MANDATORY_FILTERS").split(",")).map(String::strip).filter(v->!v.isEmpty()).collect(java.util.stream.Collectors.toSet());
var mandatory=Arrays.stream(new OrderEntryCatalog(db).parameter(u.headquartersId(),"MASK_MANDATORY_FILTERS").split(",")).map(entryValue -> entryValue.strip()).filter(v->!v.isEmpty()).collect(java.util.stream.Collectors.toSet());
for(var j:jobs){long jid=id(j,"jb_id");var creatorRows=db.queryForList("SELECT u.usr_name,c.csc_name FROM employee e JOIN user u ON u.usr_id=e.usr_id LEFT JOIN costcenter c ON c.csc_id=? WHERE e.usr_id=?",id(j,"csc_id"),id(j,"emp_id"));String creator=creatorRows.size()==1?(text(creatorRows.getFirst(),"csc_name")+", "+text(creatorRows.getFirst(),"usr_name")).replaceAll("^, ",""):"(System)";var stations=db.queryForList("SELECT t.tr_sort,t.tr_comp,t.tr_person,t.tr_remark,t.tr_hsno,a.ad_street,a.ad_zipcode,a.ad_city FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort",jid);
var services=db.queryForList("SELECT tr_sort,trs_srv_name,trs_srvt_name,trs_price,trs_discount FROM tourservice WHERE jb_id=? ORDER BY tr_sort,srv_id,srvt_id,csc_id",jid);
var vehicle=s.vehicles().stream().filter(v->text(v,"sid").equals(text(j,"cr_sid"))).toList();if(vehicle.size()!=1||id(vehicle.getFirst(),"cr_id")!=id(j,"cr_id"))throw bad("Auftrag #"+jid+": Die Fahrzeugzuordnung ist nicht eindeutig.");
@@ -65,7 +65,7 @@ class CourierPortal {
String vehicleType=types.isEmpty()?"":text(types.getFirst(),"mt_value");boolean fuelIncluded=detail.stream().anyMatch(p->Set.of(1L,9L).contains(id(p,"mt_sort"))&&!text(p,"jbprc_price").isBlank());
boolean freeColumn=s.commissionColumns()&&amount.commissionFree();if(freeColumn)free=free.add(amount.total());else normal=normal.add(amount.total());
String invoice=text(j,s.ownInvoiceText()?"jb_invtext_cr":"jb_invtext");var stops=stations.stream().map(t->fields("company",text(t,"tr_comp"),"address",java.util.stream.Stream.of((text(t,"ad_street")+" "+text(t,"tr_hsno")).strip(),(text(t,"ad_zipcode")+" "+text(t,"ad_city")).strip()).filter(v->!v.isEmpty()).collect(java.util.stream.Collectors.joining(", ")),"person",text(t,"tr_person"),"remark",text(t,"tr_remark"))).toList();
var filters=Arrays.stream(text(j,"jb_cr_filter").split(",")).map(String::strip).filter(v->!v.isEmpty()&&!mandatory.contains(v)).toList();
var filters=Arrays.stream(text(j,"jb_cr_filter").split(",")).map(entryValue -> entryValue.strip()).filter(v->!v.isEmpty()&&!mandatory.contains(v)).toList();
result.add(fields("id",jid,"creator",creator,"vehicle",s.parent()?text(j,"cr_sid"):"","ordered",dateTime(j,"jb_ordertime"),"finished",dateTime(j,"jb_finishtime"),"booked",dateTime(j,"jb_booktime"),"stops",id(j,"jb_storno")==3||id(j,"jb_storno")==4?List.of():stops,"correction",id(j,"jb_storno")==3||id(j,"jb_storno")==4?invoice:"","invoiceText",q.invoiceText()?invoice:"","filters",filters,"lines",amount.lines(),"vehicleType",vehicleType,"serviceFee",money(serviceFee),"fuelIncluded",fuelIncluded,"incomplete",id(j,"jb_incomplete")==1,"amount",amount.total(),"commissionFree",freeColumn,"remark",s.remarks()?text(j,"jb_cr_remark"):"","canRemark",s.remarks()&&!OrderOperations.exported(j),"revision",remarkRevision(s,j),"settlement",id(j,"hq_id")>1000));
}
BigDecimal fee=money(s.perJob().multiply(BigDecimal.valueOf(jobs.size())));
@@ -26,7 +26,7 @@ class CourierPortalHistory {
String clean=raw.replace("<postparser>","").replace("</postparser>","");var lines=new ArrayList<String>();
var labels=Map.ofEntries(Map.entry("ASSIGN_STATUS","Vermittlung"),Map.entry("ZIP_FREE_OLD","Alte Freimeldung"),Map.entry("ZIP_FREE","Freimeldung"),Map.entry("ZIP_JOB","Auftrags-PLZ"),Map.entry("ZIP_REAL","Geortete PLZ"),Map.entry("LRANK","Laderang"),Map.entry("CASH_STATION","Barzahlung"),Map.entry("REASON","Grund"),Map.entry("ZIPCODE","Rankingabfrage"),Map.entry("LOCOLD","Ortung veraltet"));
var translated=Map.ofEntries(Map.entry("RESERVED","Direkte Anforderung"),Map.entry("FAVOURED","Bevorzugt"),Map.entry("AREA","Hauptbereich"),Map.entry("NEIGHBOUR_AREA","Nachbarbereich"),Map.entry("QUIT","Client beendet ohne Ausloggen"),Map.entry("CONNLOST","Verbindungsabbruch"),Map.entry("Driver_Occupied","Fahrer besetzt"),Map.entry("Num_Of_Challenges_Reached","Maximale Anzahl Vermittlungsversuche erreicht"),Map.entry("No_Match_Vehicle_Type","Fahrzeugtyp passt nicht"),Map.entry("Third_Country_Check","Drittlandprüfung"),Map.entry("Quarantine_Check","Quarantäneprüfung"),Map.entry("Nationality_Check","Nationalitätenprüfung"));
for(String part:clean.split("\\|")){var p=part.strip().split("=",2);if(p.length!=2||!labels.containsKey(p[0])||p[1].length()>500)continue;String value=p[1].strip();if(value.isEmpty())continue;if(p[0].equals("ASSIGN_STATUS")&&value.matches("NEIGHBOUR_AREA_[2-7]"))value=value.substring(value.length()-1)+". Nachbarbereich";else value=translated.getOrDefault(value,value);lines.add(labels.get(p[0])+": "+org.springframework.web.util.HtmlUtils.htmlUnescape(value));}
for(String part:clean.split("\\|")){var p=part.strip().split("=",2);if(p.length!=2||!labels.containsKey(p[0])||p[1].length()>500)continue;String value=p[1].strip();if(value.isEmpty())continue;if(p[0].equals("ASSIGN_STATUS")&&value.matches("NEIGHBOUR_AREA_[2-7]"))value=value.substring(value.length()-1)+". Nachbarbereich";else value=translated.getOrDefault(value,value);lines.add(labels.get(p[0])+": "+org.springframework.web.util.HtmlUtils.htmlUnescape(java.util.Objects.requireNonNull(value)));}
return lines.isEmpty()?"Keine aufbereiteten Detailangaben verfügbar.":String.join("\n",lines);
}
}
@@ -2,7 +2,7 @@ package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
/** A transporteur login has no employee identity or headquarters privileges. */
final class CourierPortalScope {
@@ -21,7 +21,7 @@ class CustomerCancellation {
State state(LegacyUser u,long job){
var scope=CustomerPortalScope.context(db,u);scope.require(4);var portal=new CustomerPortal(db);var centers=new TreeSet<Long>();portal.orderCustomers(u,scope).forEach(cs->centers.addAll(cs.allowed()));var args=new ArrayList<Object>(centers);args.add(mandator(db,u));args.add(job);
var jobs=db.queryForList("SELECT jb.*,jb.jb_origin+0 AS ng_origin_code,("+portal.payer(u)+") AS visible_payer"+portal.membership(portal.payer(u),centers)+" AND jb.jb_id=?",args.toArray());if(jobs.size()!=1)throw CustomerPortalScope.denied();var row=new LinkedHashMap<>(jobs.getFirst());long payer=id(row,"visible_payer");row.remove("visible_payer");row.put("jb_origin",row.remove("ng_origin_code"));
long customer=scope.customers().values().stream().filter(cs->cs.allowed().contains(payer)).mapToLong(CustomerPortalScope.Customer::id).findFirst().orElseThrow(CustomerPortalScope::denied);
long customer=scope.customers().values().stream().filter(cs->cs.allowed().contains(payer)).mapToLong(entryValue -> entryValue.id()).findFirst().orElseThrow(CustomerPortalScope::denied);
var snap=new OrderOperations.Snapshot(row,db.queryForList("SELECT * FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",job),db.queryForList("SELECT * FROM tourservice WHERE jb_id=? ORDER BY tr_sort,csc_id,srv_id,srvt_id",job),db.queryForList("SELECT * FROM invoice WHERE jb_id=?",job),List.of(),db.queryForList("SELECT * FROM jobprice WHERE jb_id=? ORDER BY mt_sort,jbprc_reference",job));
boolean related=portal.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER");var meta=db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('ng_invoice_lock','jb_toll','ng_payment_term','ng_payment_term_label') ORDER BY gdc_id",job);
return new State(scope,customer,snap,meta,related?List.of():new BatchInvoiceExports(db,support).rows(job,id(row,"hq_id"),false),portal.enabled(0,"CUSTOMER_MASK_JOBLIST_CANCELLATION_ENABLED_"+customer),related);
@@ -40,14 +40,12 @@ final class CustomerCopy {
result.put("copySource",new Source(source,s.revision()));
result.put("copyLabel",String.join(" · ",List.of(text(s.owner(),"cs_eid"),text(s.owner(),"cmp_comp"))));
result.put("copyPayerRemoved",s.removePayer());
result.put("copyDetails",List.of("Stammblatt, Kundenerfassungs- und Mailvorgaben werden übernommen.","Kundennummer, Portalzugang, Vorgängerhistorie, Filter, Services, zusätzliche Kostenstellen und Dokumente werden nicht übernommen.","Foto-Workflow-Zuordnungen werden nach dem Speichern an den eingerichteten Fotodienst übertragen. Der Übertragungsstatus wird separat angezeigt."));
result.put("copyDetails",List.of("Stammblatt, Kundenerfassungs- und Mailvorgaben werden übernommen.","Kundennummer, Portalzugang, Vorgängerhistorie, Filter, Services, zusätzliche Kostenstellen und Dokumente werden nicht übernommen.","Foto-Sets und Workflows für den neuen Kunden werden nach dem Speichern in der Workflow-Verwaltung eingerichtet."));
result.remove("creationDefaults");
var type=(Map<String,Object>)result.get("customerType");type.put("storedType",id(values,"cmp_type"));
return result;
});
var photos=masters.photoSettings().copy(actor,source);var origin=(Source)draft.get("copySource");
db.readTransaction(()->{if(!state(access(actor),source,false).revision().equals(origin.revision()))throw CustomerPhotoSettings.conflict();return null;});
draft.put("copySource",new Source(source,origin.revision(),photos.revision()));return draft;
return draft;
}
State prepare(LegacyUser actor,Source source,Map<String,Object> values){
if(source==null||source.revision()==null||!source.revision().matches("[a-f0-9]{64}"))throw bad("Die Kopierquelle fehlt.");
@@ -62,7 +60,7 @@ final class CustomerCopy {
for(var row:state.parameters()){
String key=text(row,"par_key");if(key.equals(POLICY))continue;String value=text(row,"par_value");
if(key.equals(CustomerEntrySettings.MEASURES)){
var members=CustomerEntrySettings.measureIds(value);if(!members.contains(source))continue;members.add(customer);db.update("UPDATE parameter SET par_value=? WHERE par_id=?",String.join(",",members.stream().map(Object::toString).toList()),id(row,"par_id"));continue;
var members=CustomerEntrySettings.measureIds(value);if(!members.contains(source))continue;members.add(customer);db.update("UPDATE parameter SET par_value=? WHERE par_id=?",String.join(",",members.stream().map(entryValue -> entryValue.toString()).toList()),id(row,"par_id"));continue;
}
String prefix=PREFIXES.stream().filter(p->key.equals(p+source)).findFirst().orElseThrow();String destination=prefix+customer;
if(id(values,"cs_commission_no")==0&&(prefix.equals(CustomerEntrySettings.PARAMETERS.get("missingReferenceIncomplete"))||prefix.equals(CustomerEntrySettings.PARAMETERS.get("referenceEveryStation"))))value="0";
@@ -99,7 +99,7 @@ class CustomerCostCenters {
var refs="DELETE".equals(c.kind())?references():List.<Reference>of();
boolean repeated=db.readTransaction(()->{var s=state(u,c.customer(),c.center());s.context().require("ADDRESS".equals(c.kind())?3:1);return support.receipt(u,c.requestId(),intent)!=null;});
if(!repeated)db.readTransaction(()->plan(u,c,true,refs));
return db.customerCostCenterWriteTransaction(refs.stream().map(Reference::table).distinct().toList(),()->{
return db.customerCostCenterWriteTransaction(refs.stream().map(entryValue -> entryValue.table()).distinct().toList(),()->{
var s=state(u,c.customer(),c.center());s.context().require("ADDRESS".equals(c.kind())?3:1);Long done=support.receipt(u,c.requestId(),intent);if(done!=null)return fields("completed",true,"center",done);
var p=plan(u,c,false,refs);unchanged(planVersion(p),c.planRevision());long result=c.center();int operation;
switch(c.kind()){
@@ -35,7 +35,7 @@ class CustomerCourierRelations {
@PostMapping("/operations/{token}") Map<String,Object> resume(Authentication a,@PathVariable String token){return resume(ApiController.user(a),token);}
void access(LegacyUser u,String kind,long target){u.require(1);if(target<=0)throw bad("Ungültiger Stammdatensatz.");String table=kind.equals("customers")?"customer":kind.equals("couriers")?"courier":null;if(table==null)throw bad("Unbekannter Beziehungsbereich.");String key=kind.equals("customers")?"cs_id":"cr_id";one(db.queryForList("SELECT "+key+" FROM "+table+" WHERE "+key+"=? AND hq_id=?",target,u.headquartersId()));}
void pair(LegacyUser u,long customer,long courier){access(u,"customers",customer);access(u,"couriers",courier);}
List<Row> rows(long customer){var rows=db.queryForList("SELECT cs_id,cr_id,cscr_relation,cscr_sort,cscr_prov,cscr_description FROM customercourier WHERE cs_id=? ORDER BY cr_id LIMIT 1001",customer);if(rows.size()>1000)throw bad("Mehr als 1000 Beziehungen bei einem Kunden. Bitte den Bestand eingrenzen lassen.");var result=rows.stream().map(r->new Row(id(r,"cs_id"),id(r,"cr_id"),(int)id(r,"cscr_relation"),(int)id(r,"cscr_sort"),amount(r.get("cscr_prov")),(String)r.get("cscr_description"))).toList();if(result.stream().map(Row::courier).distinct().count()!=result.size())throw bad("Doppelte Kunden-/Transporteurbeziehungen im Bestand. Bitte zuerst klären.");return result;}
List<Row> rows(long customer){var rows=db.queryForList("SELECT cs_id,cr_id,cscr_relation,cscr_sort,cscr_prov,cscr_description FROM customercourier WHERE cs_id=? ORDER BY cr_id LIMIT 1001",customer);if(rows.size()>1000)throw bad("Mehr als 1000 Beziehungen bei einem Kunden. Bitte den Bestand eingrenzen lassen.");var result=rows.stream().map(r->new Row(id(r,"cs_id"),id(r,"cr_id"),(int)id(r,"cscr_relation"),(int)id(r,"cscr_sort"),amount(r.get("cscr_prov")),(String)r.get("cscr_description"))).toList();if(result.stream().map(entryValue -> entryValue.courier()).distinct().count()!=result.size())throw bad("Doppelte Kunden-/Transporteurbeziehungen im Bestand. Bitte zuerst klären.");return result;}
static String amount(Object value){return new BigDecimal(value.toString()).setScale(2,RoundingMode.HALF_UP).toPlainString();}
List<Map<String,Object>> pending(long hq){return db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcscr' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 101",hq);}
List<Map<String,Object>> operation(long hq,String token){return db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='ngcscr' AND gdc_obj_id=? AND gdc_gen_fieldname=?",hq,token);}
@@ -57,7 +57,7 @@ class CustomerCourierRelations {
List<History> history(long customer,long courier){var rows=db.queryForList("SELECT srvprch_obj_eid,srvprch_price,srvprch_validfrom FROM servicepricehistory WHERE srvprch_obj_type='cr' AND srvprch_obj_id=? AND srvprch_fieldname='cscr_prov' AND srvprch_context=? ORDER BY srvprch_validfrom DESC,srvprch_price,srvprch_obj_eid LIMIT 1001",courier,Long.toString(customer));if(rows.size()>1000)throw bad("Mehr als 1000 Provisionsstände. Bitte den Bestand prüfen lassen.");return rows.stream().map(r->new History(text(r,"srvprch_obj_eid"),amount(r.get("srvprch_price")),r.get("srvprch_validfrom").toString())).toList();}
void validate(LegacyUser u,Command c){u.require(1);if(c==null||c.action()==null||!Set.of("ADD","UPDATE","REMOVE","MOVE").contains(c.action()))throw bad("Unbekannte Beziehungsaktion.");token(c.requestId());if(c.targets()==null||c.targets().isEmpty()||c.targets().size()>100||!c.action().equals("REMOVE")&&c.targets().size()!=1)throw bad("Bitte 1 bis 100 Beziehungen auswählen.");var keys=new HashSet<String>();for(var t:c.targets()){if(t==null||!keys.add(t.customer()+":"+t.courier()))throw bad("Doppelte oder ungültige Auswahl.");pair(u,t.customer(),t.courier());}}
Prepared prepare(LegacyUser u,Command c){
validate(u,c);var customers=new TreeMap<Long,List<Row>>();for(var t:c.targets()){var r=customers.computeIfAbsent(t.customer(),this::rows);unchanged(r,t.revision());}
validate(u,c);var customers=new TreeMap<Long,List<Row>>();for(var t:c.targets()){var r=customers.computeIfAbsent(t.customer(),entryValue -> this.rows(entryValue));unchanged(r,t.revision());}
var after=new TreeMap<Long,List<Row>>();customers.forEach((k,v)->after.put(k,new ArrayList<>(v)));var commissions=new ArrayList<Commission>();var descriptions=new ArrayList<String>();
for(var t:c.targets()){
var rows=after.get(t.customer());var old=rows.stream().filter(r->r.courier()==t.courier()).findFirst().orElse(null);String name="Kunde #"+t.customer()+" / Transporteur #"+t.courier();
@@ -66,6 +66,7 @@ class CustomerCourierRelations {
if(old!=null&&old.relation()!=0&&old.relation()!=1)throw bad("Unbekannter Beziehungsstatus im Bestand. Bitte zuerst klären.");
if(c.action().equals("REMOVE")){rows.remove(old);descriptions.add(name+": Beziehung und Bemerkung entfernen. Vorhandene Provisionshistorie bleibt erhalten.");continue;}
if(c.action().equals("MOVE")){
if(old==null)throw bad("Die vorhandene Transporteurbeziehung fehlt.");
if(old.relation()!=1)throw bad("Nur bevorzugte Transporteure haben eine Rangfolge.");var favourites=sorted(rows);if(c.position()==null||c.position()<1||c.position()>favourites.size())throw bad("Ungültige Position in der Favoritenliste.");favourites.remove(old);favourites.add(c.position()-1,old);renumber(rows,favourites);descriptions.add(name+": bevorzugten Transporteur auf Rang "+c.position()+" setzen.");continue;
}
if(c.relation()==null||c.relation()<0||c.relation()>1)throw bad("Bitte Gesperrt oder Besonders erwünscht auswählen.");String description=c.description()==null?"":c.description().strip();if(description.length()>255||description.indexOf('\u0000')>=0)throw bad("Bemerkung darf höchstens 255 Zeichen enthalten.");
@@ -73,17 +74,17 @@ class CustomerCourierRelations {
if(c.commission()!=null){
String requested=rate(c.commission());if(!requested.equals(rate)){u.require(39);if(c.relation()!=1)throw bad("Provision kann nur bei bevorzugten Transporteuren geändert werden.");rate=requested;}
}
int position=old!=null&&old.relation()==1&&c.relation()==1?old.position():c.relation()==1?rows.stream().filter(r->r.relation()==1).mapToInt(Row::position).max().orElse(0)+1:0;
int position=old!=null&&old.relation()==1&&c.relation()==1?old.position():c.relation()==1?rows.stream().filter(r->r.relation()==1).mapToInt(entryValue -> entryValue.position()).max().orElse(0)+1:0;
var changed=new Row(t.customer(),t.courier(),c.relation(),position,rate,description);if(old!=null)rows.remove(old);rows.add(changed);
if(old==null&&!rate.equals("0.00")||old!=null&&!rate.equals(old.commission())){
var h=history(t.customer(),t.courier());String eid=text(one(db.queryForList("SELECT cr_eid FROM courier WHERE cr_id=? AND hq_id=?",t.courier(),u.headquartersId())),"cr_eid");commissions.add(new Commission(t.customer(),t.courier(),eid,rate,h));
}
descriptions.add(name+": "+(c.relation()==1?"Besonders erwünscht":"Gesperrt")+" · Bemerkung: "+(description.isBlank()?"–":description)+(c.relation()==1?" · Provision "+rate+" %":"")+".");
}
var changes=new ArrayList<Change>();for(var entry:after.entrySet()){renumber(entry.getValue(),sorted(entry.getValue()));entry.getValue().sort(Comparator.comparingLong(Row::courier));if(entry.getValue().size()>1000)throw bad("Höchstens 1000 Beziehungen je Kunde.");changes.add(new Change(entry.getKey(),customers.get(entry.getKey()),List.copyOf(entry.getValue())));}
var changes=new ArrayList<Change>();for(var entry:after.entrySet()){renumber(entry.getValue(),sorted(entry.getValue()));entry.getValue().sort(Comparator.comparingLong(entryValue -> entryValue.courier()));if(entry.getValue().size()>1000)throw bad("Höchstens 1000 Beziehungen je Kunde.");changes.add(new Change(entry.getKey(),customers.get(entry.getKey()),List.copyOf(entry.getValue())));}
return new Prepared(List.copyOf(changes),List.copyOf(commissions),List.copyOf(descriptions));
}
static List<Row> sorted(List<Row> rows){return new ArrayList<>(rows.stream().filter(r->r.relation()==1).sorted(Comparator.comparingInt(Row::position).thenComparingLong(Row::courier)).toList());}
static List<Row> sorted(List<Row> rows){return new ArrayList<>(rows.stream().filter(r->r.relation()==1).sorted(Comparator.comparingInt((Row entryValue) -> entryValue.position()).thenComparingLong(entryValue -> entryValue.courier())).toList());}
static void renumber(List<Row> all,List<Row> ordered){for(int k=0;k<ordered.size();k++){var r=ordered.get(k);all.set(all.indexOf(r),new Row(r.customer(),r.courier(),r.relation(),k+1,r.commission(),r.description()));}}
static String rate(String raw){try{raw=raw.trim().replace(',','.');if(!raw.matches("[0-9]{1,3}(\\.[0-9]{1,2})?"))throw new NumberFormatException();var value=new BigDecimal(raw);if(value.compareTo(BigDecimal.valueOf(100))>0)throw new NumberFormatException();return value.setScale(2).toPlainString();}catch(NumberFormatException e){throw bad("Provision muss zwischen 0 und 100 % liegen und darf höchstens zwei Nachkommastellen haben.");}}
Map<String,Object> preview(LegacyUser u,Command c){return db.readTransaction(()->{var prepared=prepare(u,c);ensureAvailable(u,prepared.changes());return fields("descriptions",prepared.descriptions(),"planRevision",revision(prepared),"commissionChanges",prepared.commissions().size());});}
@@ -29,7 +29,7 @@ record CustomerCreationDefaults(boolean automaticDispatch,boolean cargoRequired,
support.transactional("parameter");
var rows=db.lockRows("SELECT par_id,md_id,emp_id,hq_id,par_value FROM parameter WHERE par_key=? AND hq_id=?",CustomerEntrySettings.MEASURES,u.headquartersId());
if(rows.size()>1||rows.stream().anyMatch(r->id(r,"md_id")!=0||id(r,"emp_id")!=0))throw bad("Mehrdeutige Lademaß-Liste der Niederlassung.");
var ids=CustomerEntrySettings.measureIds(rows.isEmpty()?null:text(rows.getFirst(),"par_value"));ids.add(customer);String value=String.join(",",ids.stream().map(Object::toString).toList());
var ids=CustomerEntrySettings.measureIds(rows.isEmpty()?null:text(rows.getFirst(),"par_value"));ids.add(customer);String value=String.join(",",ids.stream().map(entryValue -> entryValue.toString()).toList());
if(rows.isEmpty())db.insert("parameter",fields("par_key",CustomerEntrySettings.MEASURES,"md_id",0,"hq_id",u.headquartersId(),"emp_id",0,"par_value",value,"par_text",""));else db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(rows.getFirst(),"par_id"));
String key=CustomerEntrySettings.PARAMETERS.get("cargoRequired")+customer;
if(!db.lockRows("SELECT par_id FROM parameter WHERE par_key=?",key).isEmpty())throw bad("Für die neue Kunden-ID existiert bereits eine Lademaß-Vorgabe. Bitte den Bestand klären.");
@@ -87,7 +87,7 @@ class CustomerEntrySettings {
db.update("UPDATE customer SET cs_jbedit=?,cs_ar=?,cs_commission_no=? WHERE cs_id=? AND hq_id=?",v.flags().get("entryEnabled")?1:0,v.flags().get("automaticDispatch")?1:0,v.flags().get("stationReference")?1:0,customer,u.headquartersId());
var desired=new LinkedHashMap<String,String>();for(var e:PARAMETERS.entrySet())desired.put(e.getValue()+customer,v.flags().get(e.getKey())?"1":"0");desired.put(COLOR+customer,v.color());
// Touch the shared HQ list only when this customer's membership changes.
if(!p.before().flags().get("cargo").equals(v.flags().get("cargo"))){var old=s.parameters().stream().filter(r->MEASURES.equals(text(r,"par_key"))).findFirst().orElse(null);var ids=measureIds(old==null?null:text(old,"par_value"));if(v.flags().get("cargo"))ids.add(customer);else ids.remove(customer);desired.put(MEASURES,String.join(",",ids.stream().map(Object::toString).toList()));}
if(!p.before().flags().get("cargo").equals(v.flags().get("cargo"))){var old=s.parameters().stream().filter(r->MEASURES.equals(text(r,"par_key"))).findFirst().orElse(null);var ids=measureIds(old==null?null:text(old,"par_value"));if(v.flags().get("cargo"))ids.add(customer);else ids.remove(customer);desired.put(MEASURES,String.join(",",ids.stream().map(entryValue -> entryValue.toString()).toList()));}
for(var e:desired.entrySet()){
String key=e.getKey(),value=e.getValue();var old=s.parameters().stream().filter(r->key.equals(text(r,"par_key"))).findFirst().orElse(null);
boolean defaultValue=key.equals(COLOR+customer)?value.equals("FFFFFF"):key.equals(MEASURES)?value.isEmpty():value.equals("0");
@@ -59,7 +59,7 @@ class CustomerEntryTours {
if(source.zone())warnings.add("Der gespeicherte Zonenpreis wird neu berechnet.");
else if(decimal(j,"jb_fixprice").signum()>0)warnings.add("Der gespeicherte Festpreis gilt für unveränderte Strecke, Fahrzeugart und Zahler. Änderungen führen zu einer neuen Preisermittlung.");
int type=(int)id(j,"vht_id");if(s.vehicleTypes().stream().noneMatch(t->id(t,"id")==id(j,"vht_id"))){type=s.defaultVehicleType();warnings.add("Der bisherige Fahrzeugtyp ist nicht mehr freigegeben. Der aktuelle Standardtyp wurde eingesetzt; der Preis wird neu berechnet.");}
String reference=text(j,"jb_commission_no");if(reference.isBlank()&&!text(j,"jb_tourname").isBlank())reference=source.stops().stream().map(Station::reference).filter(v->!v.isBlank()).findFirst().orElse("");
String reference=text(j,"jb_commission_no");if(reference.isBlank()&&!text(j,"jb_tourname").isBlank())reference=source.stops().stream().map(entryValue -> entryValue.reference()).filter(v->!v.isBlank()).findFirst().orElse("");
var originalFilters=new LinkedHashSet<>(CustomerPortal.parts(text(j,"jb_cr_filter")+","+text(j,"jb_cr_filter_opt")));originalFilters.remove("");var filters=originalFilters.stream().filter(f->s.filters().stream().anyMatch(r->text(r,"code").equals(f))).toList();if(filters.size()!=originalFilters.size())warnings.add("Nicht mehr freigegebene Kurierfilter wurden entfernt.");
var cr=new CustomerPortal(db).personal(u,"MODE_COPY_JOB");
Long vehicle=null;if(cr.equals("1")){var matches=s.vehicles().stream().filter(v->text(v,"label").equals(text(j,"cr_sid"))&&id(v,"type")==id(j,"vht_id")).toList();if(matches.size()==1)vehicle=id(matches.getFirst(),"id");else if(!text(j,"cr_sid").isBlank()&&!text(j,"cr_sid").equals("0"))warnings.add("Der bisherige Kurier ist nicht mehr auswählbar. Die Disposition erfolgt nach Verfügbarkeit.");}
@@ -22,7 +22,7 @@ class CustomerExportFiles {
@GetMapping Object list(Authentication a,@RequestParam(defaultValue="0")int page){return list(ApiController.user(a),page);}
@GetMapping("/{file}/download") ResponseEntity<byte[]> download(Authentication a,@PathVariable long file){
var value=download(ApiController.user(a),file);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename((String)value.get("name"),StandardCharsets.UTF_8).build().toString()).header("X-Content-Type-Options","nosniff").header(HttpHeaders.CACHE_CONTROL,"no-store").body((byte[])value.get("data"));
return ResponseEntity.ok().contentType(java.util.Objects.requireNonNull(MediaType.APPLICATION_OCTET_STREAM)).header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename((String)value.get("name"),StandardCharsets.UTF_8).build().toString()).header("X-Content-Type-Options","nosniff").header(HttpHeaders.CACHE_CONTROL,"no-store").body((byte[])value.get("data"));
}
Access access(LegacyUser u){var a=CustomerPortalScope.verify(db,u);if(!u.customerRight(7)||!EmployeeAdministration.bit(text(a,"emp_rights"),7))throw CustomerPortalScope.denied();
// Stored legacy files can contain all costcenters, independently of the current list selection.
@@ -41,7 +41,7 @@ final class CustomerExportFormat {
}
static String render(Settings s,List<Field> fields,List<Map<String,Object>> rows,String delimiter,boolean headline){
validate(s,fields);if(delimiter==null||delimiter.length()!=1||"\r\n\0".contains(delimiter))throw bad("Bitte genau ein Trennzeichen angeben.");
var lookup=new HashMap<Long,Field>();fields.forEach(f->lookup.put(f.id(),f));var rules=s.rules().stream().sorted(Comparator.comparingInt(Rule::sort)).toList();var out=new StringBuilder();
var lookup=new HashMap<Long,Field>();fields.forEach(f->lookup.put(f.id(),f));var rules=s.rules().stream().sorted(Comparator.comparingInt(entryValue -> entryValue.sort())).toList();var out=new StringBuilder();
if(!s.fileStart().isEmpty())out.append(s.fileStart()).append('\n');
if(headline){if(!s.headline().isEmpty())out.append(s.headline());else for(int n=0;n<rules.size();n++){var r=rules.get(n);out.append(lookup.get(r.field()).label());if(!r.noDelimiter()&&n<rules.size()-1)out.append(delimiter);}out.append('\n');}
int rowIndex=0;for(var row:rows){var values=new ArrayList<String>();for(var r:rules){var f=lookup.get(r.field());String v=wipe(row.get(f.key()));
@@ -3,7 +3,7 @@ import de.stadtbote.votian.database.DatabaseService;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import static de.stadtbote.votian.AdminSupport.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Read-only root invoice address comparison from customer_special.php. */
@@ -13,7 +13,7 @@ final class CustomerOrderDocument {
for(Object warning:(List<?>)evidence.getOrDefault("warnings",List.of()))out.append("<p>").append(escape(warning)).append("</p>");out.append("<h2>Leistungen</h2><table><thead><tr><th>Kostenstelle</th><th>Leistung</th><th>Betrag ").append(escape(evidence.get("serviceBasis"))).append("</th></tr></thead><tbody>");for(var s:rows(evidence,"services"))out.append("<tr><td>").append(escape(s.get("center"))).append("</td><td>").append(escape(s.get("name"))).append("</td><td>").append(escape(pending?"Preis in Prüfung":money(decimal(s,"price")))).append("</td></tr>");out.append("</tbody></table><p>Leistungssumme: ").append(escape(pending?"Preis in Prüfung":money(decimal(evidence,"serviceTotal")))).append("</p>");
if(evidence.get("toll")!=null)out.append("<p>Maut: ").append(escape(money(decimal(evidence,"toll")))).append(" · Gesamt einschließlich Maut: ").append(escape(pending?"Preis in Prüfung":money(decimal(evidence,"totalWithToll")))).append("</p>");
var stations=rows(evidence,"stations");var stops=rows(detail,"stops");for(int i=0;i<stops.size();i++){var s=stops.get(i);out.append("<h2>Station").append(Boolean.TRUE.equals(evidence.get("stationNumbers"))?" "+id(s,"sequence"):"").append("</h2><table>").append(row("Firma",text(s,"company"))).append(row("Zusatz",text(s,"additional"))).append(row("Person",text(s,"person"))).append(row("Adresse",text(s,"street")+" "+text(s,"houseNumber")+", "+text(s,"zip")+" "+text(s,"city")+" "+text(s,"country"))).append(row("Telefon",text(s,"phone"))).append(row("Kommissionsnummer",text(s,"commission"))).append(row("Bemerkung",text(s,"remark"))).append(row("Erledigung",time(s,"finished"))).append(row("Quittungsgeber",text(s,"recipient")));
if(i<stations.size()){var e=stations.get(i);for(var f:rows(e,"fields"))out.append(row(text(f,"label"),text(f,"value")));if(!text(e,"signature").isBlank())out.append("<tr><th>Unterschrift</th><td><img src=\"").append(escape(text(e,"signature"))).append("\"></td></tr>");for(Object w:(List<?>)e.get("warnings"))out.append(row("Hinweis",Objects.toString(w,"")));}out.append("</table>");if(i<stations.size()&&stations.get(i).get("articles") instanceof Map<?,?> a)articleTable(out,(Map<String,Object>)a);}
if(i<stations.size()){var e=stations.get(i);for(var f:rows(e,"fields"))out.append(row(text(f,"label"),text(f,"value")));if(!text(e,"signature").isBlank())out.append("<tr><th>Unterschrift</th><td><img src=\"").append(escape(text(e,"signature"))).append("\"></td></tr>");for(Object w:(List<?>)e.get("warnings"))out.append(row("Hinweis",Objects.toString(w,"")));}out.append("</table>");if(i<stations.size()&&stations.get(i).get("articles") instanceof Map<?,?> a)articleTable(out,TypedValues.map(a, String.class, Object.class));}
if(!text(detail,"invoiceText").isBlank())out.append("<h2>Rechnungstext</h2><p>").append(escape(text(detail,"invoiceText")).replace("\n","<br>")).append("</p>");return out.append("</body></html>").toString();
}
@SuppressWarnings("unchecked") static void articleTable(StringBuilder out,Map<String,Object> table){
@@ -3,7 +3,7 @@ package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.*;
import static de.stadtbote.votian.AdminSupport.*;
import static de.stadtbote.votian.LegacyMapping.*;
@@ -98,12 +98,12 @@ class CustomerOrderEdit {
void write(LegacyUser u,long job,Command c,Prepared p,Quote q){
var r=p.request();var old=p.state().order().job();var a=p.state().access();var e=p.entry();boolean longhaul=q.route()!=null&&q.route().longhaul();
long status=id(old,"jb_status");if(status==9&&!e.vehicle().isEmpty())status=0;if(r.manualDispatch()||longhaul)status=9;
var values=fields("jb_ordertime",e.at(),"jb_reserv",r.mode()==Mode.RESERVATION?1:id(old,"jb_reserv"),"vht_id",r.vehicleTypeId(),"csc_id_payer",r.payment()==Payment.CASH?e.cashPayer():r.payerCostCenterId(),"csc_id_payer_cash",r.payment()==Payment.CASH?r.payerCostCenterId():0,"jb_payment",r.payment()==Payment.CASH?2:r.costSplit()?1:0,"hq_id_sales",id(e.payer(),"hq_id"),"hq_id_dispo",q.route()==null?e.dispatchHq():q.route().dispatchHeadquartersId(),"jb_longhaul",longhaul?1:0,"jb_status",status,"cr_id_order",id(e.vehicle(),"cr_id"),"cr_sid",text(e.vehicle(),"crvh_sid"),"cr_id_permanent",text(e.vehicle(),"crvh_sid"),"jb_commission_no",c.draft().reference(),"jb_freetext_1",CustomerOrderTexts.replace(text(old,"jb_freetext_1"),Objects.toString(c.draft().notes(),""),10000,"Auftragstext"),"jb_fixprice",q.batch()?java.math.BigDecimal.ZERO:q.calculatedBase()!=null?q.calculatedBase():r.pricing().mode()==PriceMode.FIXED?r.pricing().fixedPrice():java.math.BigDecimal.ZERO,"jb_serviceprice",q.components().stream().filter(v->v.name().equals("Servicepreis")).map(Component::price).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add),"jb_subtotalprice",q.subtotal(),"jb_totalprice",q.net(),"jb_cr_subprice",longhaul?java.math.BigDecimal.ZERO:LegacyOrderPricing.positive(r.pricing().courierPrice())?r.pricing().courierPrice():LegacyOrderPricing.nz(q.calculatedCourierBase()),"jb_cr_price",q.courierNet(),"jb_markup",q.markup(),"jb_sales_tax_rate",q.taxRate(),"jb_sales_tax_rate_sign",q.taxSign(),"jb_invmode",q.invoiceMode(),"jb_postage",OrderEntryCatalog.number(e.payer(),"cmp_postage"),"jb_cr_fav",e.favoured()?1:0,"jb_incomplete",id(old,"jb_incomplete")!=0||entries.isIncomplete(u,r,e.payer(),q)?1:0,"jb_version",id(old,"jb_version")+1,"jb_locktime",null,"jb_lockuser",null);
var values=fields("jb_ordertime",e.at(),"jb_reserv",r.mode()==Mode.RESERVATION?1:id(old,"jb_reserv"),"vht_id",r.vehicleTypeId(),"csc_id_payer",r.payment()==Payment.CASH?e.cashPayer():r.payerCostCenterId(),"csc_id_payer_cash",r.payment()==Payment.CASH?r.payerCostCenterId():0,"jb_payment",r.payment()==Payment.CASH?2:r.costSplit()?1:0,"hq_id_sales",id(e.payer(),"hq_id"),"hq_id_dispo",q.route()==null?e.dispatchHq():q.route().dispatchHeadquartersId(),"jb_longhaul",longhaul?1:0,"jb_status",status,"cr_id_order",id(e.vehicle(),"cr_id"),"cr_sid",text(e.vehicle(),"crvh_sid"),"cr_id_permanent",text(e.vehicle(),"crvh_sid"),"jb_commission_no",c.draft().reference(),"jb_freetext_1",CustomerOrderTexts.replace(text(old,"jb_freetext_1"),Objects.toString(c.draft().notes(),""),10000,"Auftragstext"),"jb_fixprice",q.batch()?java.math.BigDecimal.ZERO:q.calculatedBase()!=null?q.calculatedBase():r.pricing().mode()==PriceMode.FIXED?r.pricing().fixedPrice():java.math.BigDecimal.ZERO,"jb_serviceprice",q.components().stream().filter(v->v.name().equals("Servicepreis")).map(entryValue -> entryValue.price()).reduce(java.math.BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)),"jb_subtotalprice",q.subtotal(),"jb_totalprice",q.net(),"jb_cr_subprice",longhaul?java.math.BigDecimal.ZERO:LegacyOrderPricing.positive(r.pricing().courierPrice())?r.pricing().courierPrice():LegacyOrderPricing.nz(q.calculatedCourierBase()),"jb_cr_price",q.courierNet(),"jb_markup",q.markup(),"jb_sales_tax_rate",q.taxRate(),"jb_sales_tax_rate_sign",q.taxSign(),"jb_invmode",q.invoiceMode(),"jb_postage",OrderEntryCatalog.number(e.payer(),"cmp_postage"),"jb_cr_fav",e.favoured()?1:0,"jb_incomplete",id(old,"jb_incomplete")!=0||entries.isIncomplete(u,r,e.payer(),q)?1:0,"jb_version",id(old,"jb_version")+1,"jb_locktime",null,"jb_lockuser",null);
if(a.acceptanceEnabled())values.put("jb_waittime",r.acceptUntil());
if(a.dimensions())values.putAll(fields("jb_weight",r.weight(),"jb_crvh_length",r.length(),"jb_crvh_width",r.width(),"jb_crvh_height",r.height(),"jb_crvh_position",r.positions()));
if(!a.dimensions())for(String key:List.of("jb_weight","jb_crvh_length","jb_crvh_width","jb_crvh_height","jb_crvh_position"))values.put(key,old.get(key));
entries.dispatchDefaults(u,r,e,values);for(String key:List.of("jb_cr_filter","jb_cr_filter_opt")){var filters=new LinkedHashSet<>(CustomerPortal.parts(text(values,key)));CustomerPortal.parts(text(old,key)).stream().filter(f->!Set.of("EU","AL","BWV","").contains(f)&&a.filters().stream().noneMatch(v->text(v,"code").equals(f))).forEach(filters::add);if(key.equals("jb_cr_filter_opt")&&longhaul)filters.add("BWV");values.put(key,String.join(",",filters));}
values.put("jb_tourdata",r.stops().stream().map(Station::postalCode).collect(java.util.stream.Collectors.joining(";"))+"|"+r.stops().stream().map(Station::city).collect(java.util.stream.Collectors.joining(";"))+"|"+r.stops().stream().map(Station::country).collect(java.util.stream.Collectors.joining(";")));
values.put("jb_tourdata",r.stops().stream().map(entryValue -> entryValue.postalCode()).collect(java.util.stream.Collectors.joining(";"))+"|"+r.stops().stream().map(entryValue -> entryValue.city()).collect(java.util.stream.Collectors.joining(";"))+"|"+r.stops().stream().map(entryValue -> entryValue.country()).collect(java.util.stream.Collectors.joining(";")));
var tracking=new CustomerEntryTracking(db);var trackingEmails=tracking.validate(a,c.draft());
var external=new OrderExternalStations(db).plan(u,r,e);
for(int n=0;n<r.stops().size();n++){
@@ -18,7 +18,7 @@ class CustomerOrderEvidence {
CustomerOrderEvidence(DatabaseService db,CustomerPortal portal,BusinessSupport support){this.db=db;this.portal=portal;this.data=new OrderDeliveryData(db,support.historySchema);}
@GetMapping("/evidence") Object view(Authentication a,@PathVariable long order){return view(ApiController.user(a),order);}
@GetMapping("/stations/{station}/photos/{key}") Object photo(Authentication a,@PathVariable long order,@PathVariable long station,@PathVariable String key){return photo(ApiController.user(a),order,station,key);}
@GetMapping("/pdf") ResponseEntity<byte[]> pdf(Authentication a,@PathVariable long order,@RequestParam String revision){return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=Auftrag-"+order+".pdf").body(pdf(ApiController.user(a),order,revision));}
@GetMapping("/pdf") ResponseEntity<byte[]> pdf(Authentication a,@PathVariable long order,@RequestParam String revision){return ResponseEntity.ok().contentType(java.util.Objects.requireNonNull(MediaType.APPLICATION_PDF)).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=Auftrag-"+order+".pdf").body(pdf(ApiController.user(a),order,revision));}
@GetMapping("/pdf-page") Object page(Authentication a,@PathVariable long order,@RequestParam String revision,@RequestParam(defaultValue="0") int page){return VehicleRollcardPdf.page(pdf(ApiController.user(a),order,revision),page);}
Map<String,Object> job(long order){return one(db.queryForList("SELECT jb_id,hq_id,csc_id_payer,jb_payment,jb_commission_no,jb_orderer,jb_outlay,jb_weight,jb_reserv,jb_taketime,jb_status,vht_id,cr_id FROM job WHERE jb_id=?",order));}
Set<Long> centers(CustomerPortalScope.Context c){var ids=new TreeSet<Long>();c.customers().values().forEach(cs->ids.addAll(cs.allowed()));return ids;}
@@ -26,7 +26,7 @@ class CustomerOrderHistory {
}
try{
var rows=db.queryForList("SELECT l.log_id,l.logo_id,l.log_createtime,o.logo_op_shortname,u.usr_name,u.usr_firstname,u.usr_phone FROM "+history+".log l JOIN "+history+".logoperation o ON o.logo_id=l.logo_id LEFT JOIN user u ON u.usr_id=l.usr_id AND u.hq_id IN ("+CourierBourse.HQ_SQL+") WHERE l.jb_id=? AND l.logo_id IN (1,10) AND l.hq_id IN ("+CourierBourse.HQ_SQL+") ORDER BY l.log_createtime,l.log_id LIMIT ? OFFSET ?",mandator,order,mandator,PAGE+1,page*PAGE);
return fields("mode","history","page",page,"hasNext",rows.size()>PAGE,"rows",rows.stream().limit(PAGE).map(r->fields("id",id(r,"log_id"),"action",org.springframework.web.util.HtmlUtils.htmlUnescape(text(r,"logo_op_shortname")),"at",dateTime(r,"log_createtime"),"name",text(r,"usr_name"),"firstName",text(r,"usr_firstname"),"phone",text(r,"usr_phone"),"email","")).toList());
return fields("mode","history","page",page,"hasNext",rows.size()>PAGE,"rows",rows.stream().limit(PAGE).map(r->fields("id",id(r,"log_id"),"action",org.springframework.web.util.HtmlUtils.htmlUnescape(java.util.Objects.requireNonNull(text(r,"logo_op_shortname"))),"at",dateTime(r,"log_createtime"),"name",text(r,"usr_name"),"firstName",text(r,"usr_firstname"),"phone",text(r,"usr_phone"),"email","")).toList());
}catch(org.springframework.dao.DataAccessException e){throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"Die Mitarbeiterhistorie ist derzeit nicht erreichbar.");}
});}
}
@@ -19,10 +19,10 @@ class CustomerOrderLabels {
CustomerOrderLabels(DatabaseService db,CustomerPortal portal,FormAdministration forms){this.db=db;this.portal=portal;this.forms=forms;}
@GetMapping Object options(Authentication a,@PathVariable long order){return options(ApiController.user(a),order);}
@GetMapping("/{kind}") Object view(Authentication a,@PathVariable long order,@PathVariable Kind kind){return view(ApiController.user(a),order,kind);}
@GetMapping("/{kind}/pdf") ResponseEntity<byte[]> pdf(Authentication a,@PathVariable long order,@PathVariable Kind kind,@RequestParam String revision){return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=Etiketten-"+order+(kind==Kind.RETURN?"-Retoure":"")+".pdf").body(pdf(ApiController.user(a),order,kind,revision));}
@GetMapping("/{kind}/pdf") ResponseEntity<byte[]> pdf(Authentication a,@PathVariable long order,@PathVariable Kind kind,@RequestParam String revision){return ResponseEntity.ok().contentType(java.util.Objects.requireNonNull(MediaType.APPLICATION_PDF)).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=Etiketten-"+order+(kind==Kind.RETURN?"-Retoure":"")+".pdf").body(pdf(ApiController.user(a),order,kind,revision));}
@GetMapping("/{kind}/page") Object page(Authentication a,@PathVariable long order,@PathVariable Kind kind,@RequestParam String revision,@RequestParam(defaultValue="0") int page){return CustomerOrderLabelPdf.page(pdf(ApiController.user(a),order,kind,revision),page);}
List<Map<String,Object>> options(LegacyUser u,long order){return db.readTransaction(()->{portal.detail(u,new CustomerPortal.Detail(order));return authorizedOptions(u);});}
List<Map<String,Object>> authorizedOptions(LegacyUser u){var out=new ArrayList<Map<String,Object>>();for(var kind:Kind.values()){String error="";try{configuration(u,kind);}catch(org.springframework.web.server.ResponseStatusException e){error=e.getReason();}out.add(fields("kind",kind,"label",kind==Kind.OUTBOUND?"Etiketten":"Etiketten Retoure","available",error.isBlank(),"message",error));}return out;}
List<Map<String,Object>> authorizedOptions(LegacyUser u){var out=new ArrayList<Map<String,Object>>();for(var kind:Kind.values()){String error="";try{configuration(u,kind);}catch(org.springframework.web.server.ResponseStatusException e){error=java.util.Objects.requireNonNullElse(e.getReason(),"Die Etiketten sind nicht verfügbar.");}out.add(fields("kind",kind,"label",kind==Kind.OUTBOUND?"Etiketten":"Etiketten Retoure","available",error.isBlank(),"message",error));}return out;}
Map<String,Object> configuration(LegacyUser u,Kind kind){long category=kind==Kind.OUTBOUND?302:303;var rows=db.queryForList("SELECT c.mtfc_id,c.mtfc_description,c.mtft_id,t.mtft_content FROM metafieldcategory c LEFT JOIN metafieldtemplate t ON t.mtft_id=c.mtft_id WHERE c.mtfc_id=?",category);if(rows.size()!=1||text(rows.getFirst(),"mtft_content").isBlank())throw bad("Die PHP-Vorlage für diese Etiketten ist nicht eingerichtet.");var row=rows.getFirst();String normalized=text(row,"mtft_content").replaceAll("\\s+"," ").strip();if(!VERIFIED_TEMPLATES.get(kind).equals(DocumentStore.digest(normalized.getBytes(java.nio.charset.StandardCharsets.UTF_8))))throw bad("Die Etikettenvorlage wurde geändert und benötigt einen erneuten Abgleich für die PDF-Ausgabe.");
var defs=db.queryForList(MetafieldRecords.definitionsSql(category),forms.fieldHeadquarters(u,category));var mapped=new HashMap<Long,String>();for(var d:defs)if(mapped.put(id(d,"keyId"),text(d,"type"))!=null)throw bad("Die Etikettenfelder sind mehrfach zugeordnet.");if(!mapped.equals(VERIFIED_FIELDS))throw bad("Die Feldzuordnung der Etiketten entspricht nicht der geprüften PHP-Vorlage.");
var cfg=new OrderCustomerMailContent(db,u.headquartersId(),"");String size=cfg.fallback("MASK_JB_LABEL_SIZE");if(size.isBlank())size="label60x40";var dimensions=OrderLabelPaperSize.read(size);return fields("category",category,"template",row,"fields",defs,"size",size,"width",dimensions.width(),"height",dimensions.height());
@@ -5,7 +5,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import static de.stadtbote.votian.AdminSupport.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Customer-owned mobile photos from job GDC and B2B photo blocks. No PHP temp files. */
@@ -37,7 +37,7 @@ class CustomerOrderRoute {
String url="https://www.google.de/maps/dir/"+encode(origin)+"/"+encode(destination)+"/";
return fields("enabled",true,"available",true,"source",source,"locatedAt",locatedAt,"destination",destination,"station",id(target,"tr_sort"),"url",url,"message","");
});}
static String encode(String value){return org.springframework.web.util.UriUtils.encodePathSegment(value,java.nio.charset.StandardCharsets.UTF_8);}
static String encode(String value){return org.springframework.web.util.UriUtils.encodePathSegment(java.util.Objects.requireNonNull(value),java.util.Objects.requireNonNull(java.nio.charset.StandardCharsets.UTF_8));}
static String address(Map<String,Object> stop){String street=text(stop,"ad_street"),zip=text(stop,"ad_zipcode"),city=text(stop,"ad_city");if(street.isBlank()||zip.isBlank()&&city.isBlank())return "";String country=text(stop,"ad_country");return (street+" "+text(stop,"tr_hsno")+", "+zip+" "+city+(country.isBlank()||Set.of("D","DE").contains(country.toUpperCase(Locale.ROOT))?"":", "+country)).strip();}
static Map<String,Object> unavailable(String message){return fields("enabled",true,"available",false,"message",message);}
}
@@ -77,7 +77,7 @@ class CustomerOrderTexts {
var p=prepare(s,c);for(int n=0;n<p.stops().size();n++)BusinessSupport.update(db,"tour","tr_id",c.stops().get(n).id(),p.stops().get(n));
p.job().put("jb_version",id(s.job(),"jb_version")+1);BusinessSupport.update(db,"job","jb_id",job,p.job());
if(s.metadata().stream().noneMatch(m->text(m,"gdc_gen_fieldname").equals("jb_invtext_dirty")))db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname","jb_invtext_dirty","gdc_content","1","gdc_context",LocalDateTime.now().toString()));
support.audit(u,job,s.customer(),0,163,"Kundenauftrag: Texte und Kontakte geändert | CUSTOMER_ORDER_TEXTS | "+String.join(", ",p.changes().stream().map(Change::field).toList()),c.requestId());support.receipt(u,c.requestId(),intent,job);return fields("id",job,"replayed",false);
support.audit(u,job,s.customer(),0,163,"Kundenauftrag: Texte und Kontakte geändert | CUSTOMER_ORDER_TEXTS | "+String.join(", ",p.changes().stream().map(entryValue -> entryValue.field()).toList()),c.requestId());support.receipt(u,c.requestId(),intent,job);return fields("id",job,"replayed",false);
});
}
}
@@ -9,7 +9,7 @@ import static de.stadtbote.votian.AdminSupport.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** PHP customer_special.php /sets/kunde and /settings/kunde, with durable delivery evidence. */
/** Legacy photo settings receipts: retained to reconcile already committed transfers. */
@RestController
@RequestMapping("/api/master/customers/{customer}/photo-settings")
class CustomerPhotoSettings {
@@ -30,10 +30,12 @@ class CustomerPhotoSettings {
final DatabaseService db;final BusinessSupport support;final OrderIntegrationClient client;
CustomerPhotoSettings(DatabaseService db,BusinessSupport support,OrderIntegrationClient client){this.db=db;this.support=support;this.client=client;}
@GetMapping View get(Authentication a,@PathVariable long customer){return view(ApiController.user(a),customer);}
@PostMapping("/preview") Preview preview(Authentication a,@PathVariable long customer,@RequestBody Draft draft){return preview(ApiController.user(a),customer,draft);}
@PostMapping Operation save(Authentication a,@PathVariable long customer,@RequestBody Command command){return save(ApiController.user(a),customer,command);}
@PostMapping("/preview") Preview preview(Authentication a,@PathVariable long customer,@RequestBody Draft draft){context(ApiController.user(a),customer);throw externalEditor();}
@PostMapping Operation save(Authentication a,@PathVariable long customer,@RequestBody Command command){var u=ApiController.user(a);context(u,customer);if(command==null)throw externalEditor();BusinessSupport.token(command.requestId());if(db.readTransaction(()->find(command.requestId(),false)).isEmpty())throw externalEditor();return save(u,customer,command);}
@PostMapping("/{token}/check") Operation check(Authentication a,@PathVariable long customer,@PathVariable String token){return deliver(ApiController.user(a),customer,token);}
static org.springframework.web.server.ResponseStatusException externalEditor(){return new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.GONE,"Foto-Sets und Workflows werden jetzt in der Workflow-Verwaltung gepflegt. Bitte den neuen Dialog öffnen.");}
LegacyUser access(LegacyUser actor){actor.require(0);actor.require(2);var current=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());current.require(0);current.require(2);return current;}
Context context(LegacyUser actor,long customer){
var u=access(actor);if(customer<0)throw bad("Ungültiger Kunde.");
@@ -57,7 +59,7 @@ class CustomerPhotoSettings {
if(flag(item,"default",false)){if(defaultSet!=0)throw bad("Mehrere Standard-Foto-Sets.");defaultSet=set;}
definitions.add(new SetDefinition(set,label(item),List.copyOf(flows)));selections.add(new Selection(set,List.copyOf(allowed),defaultFlow));
}
definitions.sort(Comparator.comparingInt(SetDefinition::number));selections.sort(Comparator.comparingInt(Selection::setNumber));
definitions.sort(Comparator.comparingInt(entryValue -> entryValue.number()));selections.sort(Comparator.comparingInt(entryValue -> entryValue.setNumber()));
var values=normalize(new Values(defaultSet,List.copyOf(selections)),definitions,false);
return new State(context,List.copyOf(definitions),values,revision(List.of(context.revision(),definitions,values)));
}
@@ -70,7 +70,7 @@ class CustomerPortal {
return new Layout(List.copyOf(columns.values()),expressions,unknown?"Einige alte Spezialspalten sind in dieser Ansicht noch nicht verfügbar.":"");
}
String preference(LegacyUser u,long customer,String suffix){String value=personal(u,"CUSTOMER_MASK_JOBLIST_"+suffix);return value.isBlank()?global(0,"CUSTOMER_MASK_JOBLIST_"+suffix+"_"+customer):value;}
static List<String> parts(String value){return Arrays.stream(value.split(",",-1)).map(String::strip).toList();}
static List<String> parts(String value){return Arrays.stream(value.split(",",-1)).map(entryValue -> entryValue.strip()).toList();}
Map<String,Object> options(LegacyUser u){return db.readTransaction(()->{var c=CustomerPortalScope.context(db,u);boolean bourse=bourse(u);long own=id(c.account(),"cs_id");String status=personal(u,"CUSTOMER_MASK_JOBLIST_JBSTATUS_DEFAULT"),descendants=personal(u,"CUSTOMER_MASK_JOBLIST_CSC_DEFAULT");return fields("name",c.customers().get(own).name(),"customer",own,"center",id(c.account(),"csc_id"),"bourse",bourse,"canEntry",entryAvailable(u,c),"canMap",enabled(0,"MASK_JB_MAP_VIEW_ENABLED_"+own),"canRead",c.can(4),"canStatement",c.can(5),"canStatistics",c.can(8),"canExport",c.can(7),"canNavigate",c.can(0),"status",status.equals("2")?2:bourse?9:0,"descendants",descendants.equals("2"),"from",LocalDate.now(),"to",LocalDate.now(),"customers",orderCustomers(u,c).stream().map(cs->fields("id",cs.id(),"label",cs.name(),"base",cs.base(),"centers",cs.centers())).toList(),"archiveAvailable",false);});}
boolean entryAvailable(LegacyUser u,CustomerPortalScope.Context c){var rows=db.queryForList("SELECT cs.cs_jbedit,h.hq_workmode FROM customer cs JOIN headquarters h ON h.hq_id=cs.hq_id WHERE cs.cs_id=? AND cs.hq_id=?",id(c.account(),"cs_id"),u.headquartersId());return rows.size()==1&&(id(rows.getFirst(),"hq_workmode")==0||id(rows.getFirst(),"cs_jbedit")!=0);}
@@ -38,7 +38,7 @@ class CustomerReferences {
return db.readTransaction(()->{var u=access(actor);var branches=scope(u);long hq=headquarters==0?u.headquartersId():headquarters;if(branches.stream().noneMatch(r->id(r,"id")==hq))throw MasterDirectoryScope.denied();
String filter=from(payer)+" AND cs.hq_id=?"+(hidden?"":" AND cmp.cmp_visible=1"+(payer?" AND cc.csc_visible=1":""))+(prospects?"":" AND cmp.cmp_type=0");var args=new ArrayList<Object>(List.of(hq));
var terms=new ArrayList<String>(List.of("cmp.cmp_comp","cmp.cmp_comp2","cmp.cmp_match","cs.cs_eid"));if(payer)terms.addAll(List.of("cc.csc_name","ca.cscad_comp","ad.ad_zipcode","ad.ad_city"));
filter+=" AND ("+String.join(" OR ",terms.stream().map(t->t+" LIKE ? ESCAPE '!'").toList())+")";for(String ignored:terms)args.add(term);
filter+=" AND ("+String.join(" OR ",terms.stream().map(t->t+" LIKE ? ESCAPE '!'").toList())+")";args.addAll(Collections.nCopies(terms.size(),term));
long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*)"+filter,Long.class,args.toArray())).orElse(0L);args.add(offset);
var rows=db.queryForList(columns(payer)+filter+" ORDER BY cmp.cmp_comp,cs.cs_eid,"+(payer?"cc.csc_name,cc.csc_id":"cs.cs_id")+" LIMIT 50 OFFSET ?",args.toArray());
return fields("items",rows.stream().map(CustomerReferences::display).toList(),"total",total,"offset",offset,"limit",50,"headquarters",branches,"selectedHeadquarters",hq);
@@ -93,8 +93,8 @@ class CustomerServiceDocuments {
access(u,customer);var receipt=List.of("customer-service-document",customer,kind,number,s);if(support.receipt(u,s.requestId(),receipt)!=null)return fields("saved",true);
unchanged(selection(kind,number),s.schemaRevision());var rows=rows(customer,kind);unchanged(rows,s.revision());
if(kind.equals("protocol")){
store(customer,rows,QUESTIONS,number,String.join(",",entries.stream().map(Entry::text).toList()));
store(customer,rows,MAIL,number,String.join(",",entries.stream().map(Entry::mail).toList()));
store(customer,rows,QUESTIONS,number,String.join(",",entries.stream().map(entryValue -> entryValue.text()).toList()));
store(customer,rows,MAIL,number,String.join(",",entries.stream().map(entryValue -> entryValue.mail()).toList()));
store(customer,rows,FOOTER,number,footer);
}else store(customer,rows,REASONS,number,String.join("§",entries.stream().map(e->e.code()+"$"+e.text()).toList()));
support.audit(u,0,customer,0,36,(kind.equals("protocol")?"Abnahmeprotokoll für Service "+number:"Abliefergründe · Liste "+(number+1))+" geändert ("+entries.size()+" Einträge).",s.requestId());
@@ -24,8 +24,8 @@ class CustomerServices {
private List<Assignment> assignments(long customer,boolean types){String table=types?"customerservicetype":"customerservice",key=types?"srvt_id":"srv_id",sort=types?"cssrvt_sort":"cssrv_sort";return db.queryForList("SELECT "+key+" AS id,"+sort+" AS sort FROM "+table+" WHERE cs_id=? ORDER BY "+sort+","+key,customer).stream().map(r->new Assignment(id(r,"id"),(int)id(r,"sort"))).toList();}
private List<Map<String,Object>> choices(LegacyUser u,boolean types){String table=types?"servicetype":"service",prefix=types?"srvt":"srv";var result=new ArrayList<Map<String,Object>>();for(int mode:List.of(0,1)){long hq=catalog.enabled(u.headquartersId(),mode==0?"MANDATOR_SERVICE_ENABLED":"MANDATOR_SERVICE2_ENABLED")?u.headquartersId():0;result.addAll(db.queryForList("SELECT "+prefix+"_id AS id,"+prefix+"_name AS label,"+prefix+"_mode AS mode FROM "+table+" WHERE hq_id=? AND cs_id=0 AND "+prefix+"_mode=? ORDER BY "+prefix+"_name,"+prefix+"_id",hq,mode));}return result;}
private List<Map<String,Object>> pending(long customer){return db.queryForList("SELECT gdc_gen_fieldname AS token,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcssrv' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id",customer);}
Map<String,Object> view(LegacyUser u,long customer){access(u,customer);return db.readTransaction(()->{var services=assignments(customer,false);var types=assignments(customer,true);return fields("services",services.stream().map(Assignment::id).toList(),"types",types.stream().map(Assignment::id).toList(),"serviceOptions",choices(u,false),"typeOptions",choices(u,true),"revision",revision(List.of(services,types)),"operations",pending(customer).stream().map(r->{var p=plan(text(r,"gdc_content"));return fields("token",text(r,"token"),"canResume",p.user()==u.userId()&&p.headquarters()==u.headquartersId());}).toList());});}
private void validate(List<Long> requested,List<Assignment> before,List<Map<String,Object>> choices){if(requested==null||requested.size()>500||requested.stream().anyMatch(i->i==null||i<=0)||new HashSet<>(requested).size()!=requested.size())throw bad("Ungültige oder doppelte Servicezuordnung.");var allowed=new HashSet<>(before.stream().map(Assignment::id).toList());choices.forEach(c->allowed.add(id(c,"id")));if(!allowed.containsAll(requested))throw bad("Eine Servicezuordnung ist nicht freigegeben.");}
Map<String,Object> view(LegacyUser u,long customer){access(u,customer);return db.readTransaction(()->{var services=assignments(customer,false);var types=assignments(customer,true);return fields("services",services.stream().map(entryValue -> entryValue.id()).toList(),"types",types.stream().map(entryValue -> entryValue.id()).toList(),"serviceOptions",choices(u,false),"typeOptions",choices(u,true),"revision",revision(List.of(services,types)),"operations",pending(customer).stream().map(r->{var p=plan(text(r,"gdc_content"));return fields("token",text(r,"token"),"canResume",p.user()==u.userId()&&p.headquarters()==u.headquartersId());}).toList());});}
private void validate(List<Long> requested,List<Assignment> before,List<Map<String,Object>> choices){if(requested==null||requested.size()>500||requested.stream().anyMatch(i->i==null||i<=0)||new HashSet<>(requested).size()!=requested.size())throw bad("Ungültige oder doppelte Servicezuordnung.");var allowed=new HashSet<>(before.stream().map(entryValue -> entryValue.id()).toList());choices.forEach(c->allowed.add(id(c,"id")));if(!allowed.containsAll(requested))throw bad("Eine Servicezuordnung ist nicht freigegeben.");}
Map<String,Object> save(LegacyUser u,long customer,Save s){access(u,customer);if(s==null)throw bad("Serviceauswahl fehlt.");token(s.requestId());support.transactional("customer","genericdatacontainer");
db.writeTransaction(()->{one(db.lockRows("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));var existing=operation(customer,s.requestId());if(!existing.isEmpty()){var p=plan(text(one(existing),"gdc_content"));if(p.user()!=u.userId()||p.headquarters()!=u.headquartersId()||!p.request().equals(s))throw bad("Diese Vorgangskennung gehört zu anderen Daten.");return null;}
if(!pending(customer).isEmpty())throw bad("Bitte zuerst den ausstehenden Speichervorgang abschließen.");var beforeServices=assignments(customer,false);var beforeTypes=assignments(customer,true);unchanged(List.of(beforeServices,beforeTypes),s.revision());validate(s.services(),beforeServices,choices(u,false));validate(s.types(),beforeTypes,choices(u,true));
@@ -20,7 +20,7 @@ class CustomerStammblatt {
final DatabaseService db;final MasterData masters;
CustomerStammblatt(DatabaseService db,MasterData masters){this.db=db;this.masters=masters;}
@PostMapping("/preview") Object preview(Authentication a,@PathVariable long customer,@RequestBody Preview request){return preview(ApiController.user(a),customer,request);}
@GetMapping("/document.pdf") ResponseEntity<byte[]> download(Authentication a,@PathVariable long customer,@RequestParam String revision){return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.APPLICATION_PDF).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=Kunde-"+customer+".pdf").body(pdf(ApiController.user(a),customer,revision));}
@GetMapping("/document.pdf") ResponseEntity<byte[]> download(Authentication a,@PathVariable long customer,@RequestParam String revision){return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(java.util.Objects.requireNonNull(MediaType.APPLICATION_PDF)).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=Kunde-"+customer+".pdf").body(pdf(ApiController.user(a),customer,revision));}
LegacyUser access(LegacyUser actor){actor.require(0);var u=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());u.require(0);return u;}
State state(LegacyUser actor,long customer){
if(customer<=0)throw bad("Bitte zuerst den Kunden speichern.");
@@ -0,0 +1,54 @@
package de.stadtbote.votian;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.util.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import static de.stadtbote.votian.AdminSupport.bad;
/** Signed launch contract used by PHP customer_special.php and workflowEditor. */
@RestController
@RequestMapping("/api/master/customers/{customer}/workflow-management")
class CustomerWorkflowManagement {
record View(String label,String url,long expiresAt,List<CustomerPhotoSettings.Operation> operations) {}
private final CustomerPhotoSettings photos;
private final String endpoint,secret;
private final Clock clock;
@org.springframework.beans.factory.annotation.Autowired
CustomerWorkflowManagement(CustomerPhotoSettings photos,
@Value("${app.workflow-management.url:}") String url,
@Value("${app.workflow-management.secret:}") String secret,
@Value("${app.legacy-url:}") String legacy){this(photos,url,secret,legacy,Clock.systemUTC());}
CustomerWorkflowManagement(CustomerPhotoSettings photos,String url,String secret,String legacy,Clock clock){
this.photos=photos;this.secret=secret;this.clock=clock;
this.endpoint=url.isBlank()?(legacy.isBlank()?"":URI.create(legacy).resolve("/wf/workflows/index.html").toString()):url;
}
@GetMapping ResponseEntity<View> get(Authentication auth,@PathVariable long customer){
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(view(ApiController.user(auth),customer));
}
View view(LegacyUser user,long customer){
if(customer<=0)throw bad("Bitte zuerst den Kunden anlegen.");
var context=photos.db.readTransaction(()->photos.context(user,customer));
var operations=photos.operations(user,customer);
if(!operations.isEmpty())return new View(context.label(),"",0,operations);
if(endpoint.isBlank()||secret.isBlank())throw bad("Die Workflow-Verwaltung benötigt ihre URL und den gemeinsamen Signaturschlüssel in der Server-Konfiguration.");
long expires=clock.instant().plus(Duration.ofHours(8)).getEpochSecond();
return new View(context.label(),link(endpoint,secret,customer,expires),expires,operations);
}
static String link(String endpoint,String secret,long customer,long expires){
URI uri;
try{uri=URI.create(endpoint);if(uri.getScheme()==null||!Set.of("http","https").contains(uri.getScheme())||uri.getHost()==null||uri.getUserInfo()!=null||uri.getFragment()!=null||uri.getQuery()!=null)throw new IllegalArgumentException();}
catch(IllegalArgumentException e){throw bad("Die URL der Workflow-Verwaltung ist ungültig.");}
try{
var mac=Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),"HmacSHA256"));
String signature=HexFormat.of().formatHex(mac.doFinal((customer+"|"+expires).getBytes(StandardCharsets.UTF_8)));
return uri+"?kundeId="+customer+"&exp="+expires+"&sig="+signature;
}catch(java.security.GeneralSecurityException e){throw new IllegalStateException("Der Workflow-Link konnte nicht signiert werden.",e);}
}
}
@@ -7,7 +7,7 @@ import java.text.DecimalFormatSymbols;
import java.util.*;
import static de.stadtbote.votian.AdminSupport.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Plain-text FDS cards. Callers must first resolve IDs through the current vehicle/day scope. */
final class DispositionJobCards {
@@ -56,7 +56,7 @@ class DocumentStore {
final Session session;final ChannelSftp sftp;final String base;
Remote(){Session sess=null;ChannelSftp channel=null;try{
var jsch=new JSch();jsch.setKnownHosts(knownHosts);if(!key.isBlank())jsch.addIdentity(key);
sess=jsch.getSession(user,host,port);if(!password.isBlank())sess.setPassword(password);sess.setConfig("StrictHostKeyChecking","yes");sess.setConfig("PreferredAuthentications",key.isBlank()?"password,keyboard-interactive":"publickey,password");sess.setTimeout(20000);sess.connect(10000);
sess=jsch.getSession(user,host,port);if(!password.isBlank())sess.setPassword(password.getBytes(java.nio.charset.StandardCharsets.UTF_8));sess.setConfig("StrictHostKeyChecking","yes");sess.setConfig("PreferredAuthentications",key.isBlank()?"password,keyboard-interactive":"publickey,password");sess.setTimeout(20000);sess.connect(10000);
channel=(ChannelSftp)sess.openChannel("sftp");channel.connect(10000);String configuredRoot=root.replaceAll("/+$","");if(!configuredRoot.startsWith("/"))throw unavailable();
// A trusted root may be a mount; all descendants are checked with lstat and never follow links.
base=channel.realpath(configuredRoot);if(!channel.lstat(base).isDir())throw unavailable();session=sess;sftp=channel;
@@ -30,7 +30,7 @@ class EmployeeCostCenterColumns {
static boolean writable(List<Map<String,Object>> rows,long md){return rows.stream().map(r->id(r,"emp_id")+":"+id(r,"hq_id")+":"+text(r,"par_key")).distinct().count()==rows.size()&&rows.stream().allMatch(r->id(r,"md_id")==0||id(r,"md_id")==md);}
static String value(List<Map<String,Object>> rows,String key,long hq){String v=EmployeeListPreferences.value(rows.stream().filter(r->id(r,"emp_id")>0).toList(),key,hq);return v.isBlank()?EmployeeListPreferences.value(rows.stream().filter(r->id(r,"emp_id")==0).toList(),key,0):v;}
static Layout layout(List<Map<String,Object>> rows,long hq){
String raw=value(rows,COLS,hq),widths=value(rows,LENGTHS,hq);if(raw.isBlank())raw=String.join(",",KEYS.subList(0,14));if(widths.isBlank())widths=String.join(",",LENGTH_DEFAULTS.stream().map(Object::toString).toList());
String raw=value(rows,COLS,hq),widths=value(rows,LENGTHS,hq);if(raw.isBlank())raw=String.join(",",KEYS.subList(0,14));if(widths.isBlank())widths=String.join(",",LENGTH_DEFAULTS.stream().map(entryValue -> entryValue.toString()).toList());
String[] keys=raw.split(",",-1),lengths=widths.split(",",-1);var columns=new ArrayList<Column>();boolean invalid=keys.length>21;
for(int i=0;i<Math.min(keys.length,21);i++){String key=keys[i].strip();if(!KEYS.contains(key)){invalid=true;continue;}String n=i<lengths.length?lengths[i].strip():"0";int length=n.matches("[0-9]{1,2}")?Integer.parseInt(n):0;if(length>50){length=0;invalid=true;}if(!n.matches("[0-9]{1,2}"))invalid=true;columns.add(new Column(key,length));}
if(columns.isEmpty()){for(int i=0;i<14;i++)columns.add(new Column(KEYS.get(i),LENGTH_DEFAULTS.get(i)));invalid=true;}
@@ -44,7 +44,7 @@ class EmployeeCostCenterColumns {
Map<String,Object> save(LegacyUser u,long employee,Command c){if(u.headquartersId()!=u.originalHeadquartersId())throw EmployeeAdministration.denied();validate(c);support.transactional("parameter","genericdatacontainer");return db.employeePreferencesWriteTransaction(()->{
var s=state(u,employee);var intent=List.of("EMPLOYEE_COST_CENTER_COLUMNS",employee,c);if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true);if(!writable(s.rows(),s.access().mandator()))throw bad("Die Spaltenparameter sind nicht eindeutig zugeordnet.");
if(!revisionOf(s).equals(c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Spalten wurden zwischenzeitlich geändert. Bitte neu laden.");
var stored=Map.of(COLS,String.join(",",c.columns().stream().map(Column::key).toList()),LENGTHS,String.join(",",c.columns().stream().map(x->Integer.toString(x.length())).toList()));
var stored=Map.of(COLS,String.join(",",c.columns().stream().map(entryValue -> entryValue.key()).toList()),LENGTHS,String.join(",",c.columns().stream().map(x->Integer.toString(x.length())).toList()));
for(var e:stored.entrySet()){var old=s.rows().stream().filter(r->id(r,"emp_id")==employee&&id(r,"hq_id")==u.headquartersId()&&text(r,"par_key").equals(e.getKey())).findFirst();if(old.isPresent())db.update("UPDATE parameter SET par_value=? WHERE par_id=?",e.getValue(),id(old.get(),"par_id"));else db.insert("parameter",fields("par_key",e.getKey(),"par_value",e.getValue(),"par_text","","emp_id",employee,"hq_id",u.headquartersId(),"md_id",s.access().mandator()));}
support.auditEmployee(u,employee,203,"COST_CENTER_COLUMNS_BEFORE="+support.encode(layout(s.rows(),u.headquartersId()).columns())+" | COST_CENTER_COLUMNS_AFTER="+support.encode(c.columns()),c.requestId());support.receipt(u,c.requestId(),intent,employee);return fields("completed",true);
});}
@@ -81,7 +81,7 @@ class EmployeeHistory {
var matcher=Pattern.compile("(?:^| \\| )"+key+"=").matcher(value);if(!matcher.find())continue;
try(var parser=support.json.createParser(value.substring(matcher.end()))){com.fasterxml.jackson.databind.JsonNode node=support.json.readTree(parser);String suffix=key.endsWith("BEFORE")?" (vorher)":" (nachher)";
if(node.path("roots").isArray()){var names=new ArrayList<String>();for(var n:node.path("roots"))if(n.isIntegralNumber()&&n.asLong()>0)names.add("#"+n.asLong());result.add("Hauptlager"+suffix+": "+(names.isEmpty()?"keine":String.join(", ",names)));}
if(node.path("subtrees").isObject()){var groups=node.path("subtrees").fields();while(groups.hasNext()){var entry=groups.next();if(!entry.getKey().matches("[0-9]+")||!entry.getValue().isArray())continue;var names=new ArrayList<String>();for(var n:entry.getValue())if(n.isIntegralNumber()&&n.asLong()>0)names.add("#"+n.asLong());result.add("Unterlager für #"+entry.getKey()+suffix+": "+String.join(", ",names));}}
if(node.path("subtrees").isObject()){var groups=node.path("subtrees").properties().iterator();while(groups.hasNext()){var entry=groups.next();if(!entry.getKey().matches("[0-9]+")||!entry.getValue().isArray())continue;var names=new ArrayList<String>();for(var n:entry.getValue())if(n.isIntegralNumber()&&n.asLong()>0)names.add("#"+n.asLong());result.add("Unterlager für #"+entry.getKey()+suffix+": "+String.join(", ",names));}}
for(String field:List.of("readOnly","articles"))if(node.path(field).isBoolean())result.add((field.equals("readOnly")?"Nur Leserechte":"Artikelverwaltung")+suffix+": "+(node.get(field).asBoolean()?"Ja":"Nein"));
}catch(Exception ignored){result.add("Lagerrechte geändert.");}
}
@@ -54,7 +54,7 @@ class EmployeeParallelLists {
if(!writable(s))throw bad("Die Listenvorgaben sind nicht eindeutig zugeordnet.");
if(!revisionOf(s).equals(c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Listenvorgaben wurden zwischenzeitlich geändert. Bitte neu laden.");
if(c.values().count()>max(s,u.headquartersId()))throw bad("Die konfigurierte maximale Listenanzahl wurde überschritten.");
var stored=Map.of(COUNT,Integer.toString(c.values().count()),STATUSES,String.join(",",c.values().statuses().stream().map(Object::toString).toList()));
var stored=Map.of(COUNT,Integer.toString(c.values().count()),STATUSES,String.join(",",c.values().statuses().stream().map(entryValue -> entryValue.toString()).toList()));
for(var e:stored.entrySet()){var old=s.rows().stream().filter(r->id(r,"emp_id")==employee&&id(r,"hq_id")==u.headquartersId()&&text(r,"par_key").equals(e.getKey())).findFirst();if(old.isPresent())db.update("UPDATE parameter SET par_value=? WHERE par_id=?",e.getValue(),id(old.get(),"par_id"));else db.insert("parameter",fields("par_key",e.getKey(),"par_value",e.getValue(),"par_text","","emp_id",employee,"hq_id",u.headquartersId(),"md_id",s.access().mandator()));}
support.auditEmployee(u,employee,203,"PARALLEL_LISTS_BEFORE="+support.encode(values(s,u.headquartersId()))+" | PARALLEL_LISTS_AFTER="+support.encode(c.values()),c.requestId());support.receipt(u,c.requestId(),intent,employee);return fields("completed",true);
});
@@ -59,7 +59,7 @@ class EmployeeStockRights {
var s=state(u,employee);if(!editable(u,s))throw EmployeeAdministration.denied();var intent=List.of("EMPLOYEE_STOCK_RIGHTS",employee,c);if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true);
if(!revisionOf(s).equals(c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Lagerrechte wurden zwischenzeitlich geändert. Bitte neu laden.");
var a=settings(s.actorRows(),u.headquartersId());var t=settings(s.targetRows(),s.targetHeadquarters());var rootIds=new LinkedHashSet<>(t.roots());var groups=new LinkedHashMap<>(t.subtrees());var map=index(s);var all=roots(s);
if(c.grants().size()!=all.size()||c.grants().stream().map(Grant::root).distinct().count()!=all.size()||!new HashSet<>(all).equals(new HashSet<>(c.grants().stream().map(Grant::root).toList())))throw bad("Die Hauptlagerauswahl ist unvollständig.");
if(c.grants().size()!=all.size()||c.grants().stream().map(entryValue -> entryValue.root()).distinct().count()!=all.size()||!new HashSet<>(all).equals(new HashSet<>(c.grants().stream().map(entryValue -> entryValue.root()).toList())))throw bad("Die Hauptlagerauswahl ist unvollständig.");
for(var g:c.grants()){
var selected=EmployeeAdministration.distinct(g.subtrees(),5000);var old=new HashSet<>(selection(t,g.root()));boolean changed=!selected.equals(old),enabledChanged=g.enabled()!=t.roots().contains(g.root());
if(enabledChanged&&!canRoot(s,a,g.root())||changed&&!canSub(s,a,t,g.root()))throw EmployeeAdministration.denied();
@@ -41,7 +41,7 @@ class EvaluationService extends MetafieldRecords {
static long number(String v){try{long n=Long.parseLong(v);if(n<0)throw new NumberFormatException();return n;}catch(NumberFormatException e){throw bad("Ungültiger Auswahlwert.");}}
static LocalDate parseDate(String v){try{return LocalDate.parse(v,DATE);}catch(DateTimeParseException e){throw bad("Ungültiges Datum (TT.MM.JJJJ).");}}
void validateValues(List<Map<String,Object>> defs,Map<Long,String> values){LocalDate begin=null,end=null;for(var d:defs){String v=values.get(id(d,"id"));if(v==null||v.isBlank())continue;if(text(d,"type").equals("date_begin"))begin=parseDate(v);if(text(d,"type").equals("date_end"))end=parseDate(v);}if(begin!=null&&end!=null&&end.isBefore(begin))throw bad("Das Enddatum liegt vor dem Beginn.");}
@Override Map<String,Object> rendering(LegacyUser u,long hq,long object){var result=super.rendering(u,hq,object);var data=new LinkedHashMap<>((Map<String,Object>)result.get("data"));var values=new LinkedHashMap<>((Map<Long,String>)data.get("values"));var defs=(List<Map<String,Object>>)result.get("definitions");for(var def:defs){long field=id(def,"id");String type=text(def,"type"),v=values.getOrDefault(field,"");if(v.isBlank())continue;if(type.equals("checkboxes_hq")){var names=headquarters(u).stream().collect(java.util.stream.Collectors.toMap(r->Long.toString(id(r,"id")),r->text(r,"label")));values.put(field,Arrays.stream(v.split(",")).map(x->names.getOrDefault(x,x)).collect(java.util.stream.Collectors.joining(", ")));}}
@Override Map<String,Object> rendering(LegacyUser u,long hq,long object){var result=super.rendering(u,hq,object);var data=new LinkedHashMap<>(TypedValues.map(result.get("data"), String.class, Object.class));var values=new LinkedHashMap<>(TypedValues.map(data.get("values"), Long.class, String.class));var defs=TypedValues.rows(result.get("definitions"));for(var def:defs){long field=id(def,"id");String type=text(def,"type"),v=values.getOrDefault(field,"");if(v.isBlank())continue;if(type.equals("checkboxes_hq")){var names=headquarters(u).stream().collect(java.util.stream.Collectors.toMap(r->Long.toString(id(r,"id")),r->text(r,"label")));values.put(field,Arrays.stream(v.split(",")).map(x->names.getOrDefault(x,x)).collect(java.util.stream.Collectors.joining(", ")));}}
data.put("values",values);result.put("data",data);return result;
}
}
@@ -77,9 +77,9 @@ class ExtrasPortalSettings {
return String.join(",",Arrays.stream(csv.split(",",-1)).filter(s->!s.strip().equals(id)).toList());
}
static ListColumns columns(List<String> values){
if(values.stream().allMatch(String::isEmpty))return new ListColumns(List.of(),List.of(),List.of());
if(values.stream().allMatch(entryValue -> entryValue.isEmpty()))return new ListColumns(List.of(),List.of(),List.of());
var f=Arrays.asList(values.get(0).split(",",-1));var t=Arrays.asList(values.get(1).split(",",-1));var a=Arrays.asList(values.get(2).split(",",-1));
if(f.size()!=t.size()||f.size()!=a.size()||f.stream().anyMatch(String::isBlank)||t.stream().anyMatch(String::isEmpty)||a.stream().anyMatch(String::isEmpty)||new HashSet<>(f).size()!=f.size())throw bad("Zusatzspalten, Titel und Ausrichtungen sind nicht eindeutig zugeordnet. Bitte den Bestand prüfen.");
if(f.size()!=t.size()||f.size()!=a.size()||f.stream().anyMatch(entryValue -> entryValue.isBlank())||t.stream().anyMatch(entryValue -> entryValue.isEmpty())||a.stream().anyMatch(entryValue -> entryValue.isEmpty())||new HashSet<>(f).size()!=f.size())throw bad("Zusatzspalten, Titel und Ausrichtungen sind nicht eindeutig zugeordnet. Bitte den Bestand prüfen.");
return new ListColumns(f,t,a);
}
static ListColumns effectiveColumns(List<Map<String,Object>> rows,long home){return columns(LIST_KEYS.stream().map(k->{String v=value(rows,k,0,"par_value");return v.isEmpty()&&home!=0?value(rows,k,home,"par_value"):v;}).toList());}
@@ -37,7 +37,7 @@ class ExtrasPzmZones {
static List<Zone> decode(JsonNode data,Map<String,String> names){
if(data==null||!data.isArray()||data.size()>500)throw unavailable("Der PZM-Dienst hat keine gültige Zonenliste geliefert.");var result=new ArrayList<Zone>();var ids=new HashSet<String>();
for(var row:data){String id=row.path("name").asText();if(!id.matches("[A-Za-z0-9_-]{1,100}")||!row.path("eap").isBoolean()||!ids.add(id))throw unavailable("Der PZM-Dienst hat ungültige oder doppelte Zonen geliefert.");result.add(new Zone(id,names.getOrDefault(id,id),!row.path("eap").booleanValue()));}
result.sort(Comparator.comparing(Zone::id));return List.copyOf(result);
result.sort(Comparator.comparing(entryValue -> entryValue.id()));return List.copyOf(result);
}
List<Zone> zones(String endpoint,long hq){return decode(client.get(endpoint,"Der PZM-Zonendienst"),labels(hq));}
static String revisionFor(long hq,String endpoint,List<Zone> zones){return revision(List.of(hq,endpoint,zones));}
@@ -49,7 +49,7 @@ class ExtrasPzmZones {
return new View(revisionFor(u.headquartersId(),endpoint,zones),zones,exceptions.stream().map(r->text(r,"cs_eid")+" · "+text(r,"cmp_comp")+" "+text(r,"cmp_comp2")).toList(),operations);
}
static void validate(Draft d){if(d==null||d.revision()==null||!d.revision().matches("[a-f0-9]{64}")||d.approach()==null||d.approach().size()>500||d.approach().values().stream().anyMatch(Objects::isNull))throw bad("Zonenauswahl oder Bestandsrevision fehlen.");}
Preview prepare(LegacyUser u,Draft d,String endpoint,List<Zone> before){validate(d);if(!revisionFor(u.headquartersId(),endpoint,before).equals(d.revision()))throw conflict();if(!d.approach().keySet().equals(new HashSet<>(before.stream().map(Zone::id).toList())))throw bad("Die Zonenauswahl ist unvollständig oder enthält unbekannte Zonen.");var changes=before.stream().filter(z->z.approach()!=d.approach().get(z.id())).map(z->new ExtrasRoutingSettings.Change(z.label(),z.approach()?"Anfahrt berechnen":"Keine Anfahrtsberechnung",d.approach().get(z.id())?"Anfahrt berechnen":"Keine Anfahrtsberechnung")).toList();return new Preview(revision(List.of(u.headquartersId(),endpoint,d)),changes);}
Preview prepare(LegacyUser u,Draft d,String endpoint,List<Zone> before){validate(d);if(!revisionFor(u.headquartersId(),endpoint,before).equals(d.revision()))throw conflict();if(!d.approach().keySet().equals(new HashSet<>(before.stream().map(entryValue -> entryValue.id()).toList())))throw bad("Die Zonenauswahl ist unvollständig oder enthält unbekannte Zonen.");var changes=before.stream().filter(z->z.approach()!=d.approach().get(z.id())).map(z->new ExtrasRoutingSettings.Change(z.label(),z.approach()?"Anfahrt berechnen":"Keine Anfahrtsberechnung",d.approach().get(z.id())?"Anfahrt berechnen":"Keine Anfahrtsberechnung")).toList();return new Preview(revision(List.of(u.headquartersId(),endpoint,d)),changes);}
static org.springframework.web.server.ResponseStatusException conflict(){return new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die PZM-Zonen wurden zwischenzeitlich geändert. Bitte den aktuellen Bestand prüfen.");}
Preview preview(LegacyUser u,Draft d){writeAccess(u);validate(d);if(!pending(u.headquartersId()).isEmpty())throw bad("Bitte zuerst den offenen PZM-Speichervorgang fortsetzen.");String endpoint=endpoint(u.headquartersId());return prepare(u,d,endpoint,zones(endpoint,u.headquartersId()));}
Map<String,Object> save(LegacyUser u,Command c){
@@ -64,7 +64,7 @@ class ExtrasPzmZones {
Map<String,Object> resume(LegacyUser u,String token){
writeAccess(u);BusinessSupport.token(token);support.transactional("genericdatacontainer");
return db.writeTransaction(()->{var r=one(db.lockRows("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='ngpzm' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.headquartersId(),token));var p=plan(r);if(p.user()!=u.userId()||p.headquarters()!=u.headquartersId())throw bad("Nur der ursprüngliche Bearbeiter darf diesen Vorgang fortsetzen.");if(text(r,"gdc_context").equals("DONE"))return fields("changed",p.preview().changes().size(),"repeated",true);
if(!p.endpoint().equals(endpoint(u.headquartersId())))throw bad("Die PZM-Dienstadresse wurde geändert. Bitte den offenen Vorgang zuerst klären.");var current=zones(p.endpoint(),u.headquartersId());if(!current.stream().map(Zone::id).toList().equals(p.before().stream().map(Zone::id).toList()))throw conflict();
if(!p.endpoint().equals(endpoint(u.headquartersId())))throw bad("Die PZM-Dienstadresse wurde geändert. Bitte den offenen Vorgang zuerst klären.");var current=zones(p.endpoint(),u.headquartersId());if(!current.stream().map(entryValue -> entryValue.id()).toList().equals(p.before().stream().map(entryValue -> entryValue.id()).toList()))throw conflict();
var updates=new ArrayList<Map<String,Object>>();for(int i=0;i<current.size();i++){var now=current.get(i);var before=p.before().get(i);var after=p.after().get(i);if(!now.equals(before)&&!now.equals(after))throw conflict();if(now.approach()!=after.approach())updates.add(fields("zone",now.id(),"eap",!after.approach()));}
if(!updates.isEmpty())client.post(p.endpoint(),updates,"Das Speichern der PZM-Zonen");
if(!zones(p.endpoint(),u.headquartersId()).equals(p.after()))throw unavailable("Die PZM-Änderung konnte noch nicht bestätigt werden. Bitte denselben Vorgang erneut prüfen.");
@@ -39,7 +39,7 @@ class ExtrasRoutingSettings {
@PostMapping Map<String,Object> save(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);}
View view(LegacyUser u,Type type){readAccess(u);return db.readTransaction(()->state(db,u,type).view());}
static State state(DatabaseService db,LegacyUser u,Type type){
var defs=definitions(type);var args=new ArrayList<Object>(defs.stream().map(Field::key).toList());
var defs=definitions(type);var args=new ArrayList<Object>(defs.stream().map(entryValue -> entryValue.key()).toList());
// HQ overrides are intentionally preserved. Scope and duplicates are inspected before writes.
var rows=db.queryForList("SELECT par_id,par_key,md_id,hq_id,emp_id,par_value,par_text FROM parameter WHERE par_key IN ("+CrmScope.marks(defs.size())+") ORDER BY par_key,par_id",args.toArray());
long hq=type==Type.PZM?u.headquartersId():0;var values=new LinkedHashMap<String,String>();var warnings=new ArrayList<String>();boolean editable=true;
@@ -65,10 +65,10 @@ class ExtrasRoutingSettings {
}
static Draft validate(Draft d){
if(d==null||d.type()==null||d.revision()==null||!d.revision().matches("[a-f0-9]{64}")||d.values()==null)throw bad("Vorgaben oder Bestandsrevision fehlen.");
var defs=definitions(d.type());if(!d.values().keySet().equals(new HashSet<>(defs.stream().map(Field::key).toList())))throw bad("Unbekannte oder fehlende Vorgaben.");
var defs=definitions(d.type());if(!d.values().keySet().equals(new HashSet<>(defs.stream().map(entryValue -> entryValue.key()).toList())))throw bad("Unbekannte oder fehlende Vorgaben.");
var values=new LinkedHashMap<String,String>();for(var f:defs)values.put(f.key(),normalize(f,d.values().get(f.key())));return new Draft(d.type(),d.revision(),values);
}
static String display(Field f,String value){return f.choices().stream().filter(c->c.value().equals(value)).map(Choice::label).findFirst().orElse(value.isEmpty()?"Keine Vorgabe":value+(f.unit().isEmpty()?"":" "+f.unit()));}
static String display(Field f,String value){return f.choices().stream().filter(c->c.value().equals(value)).map(entryValue -> entryValue.label()).findFirst().orElse(value.isEmpty()?"Keine Vorgabe":value+(f.unit().isEmpty()?"":" "+f.unit()));}
static Preview prepare(LegacyUser u,Draft d,State s){
if(!d.revision().equals(s.view().revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Vorgaben wurden zwischenzeitlich geändert. Bitte neu laden.");
if(!s.view().editable())throw bad("Mehrdeutige Parameterzuordnung. Bitte den Bestand zuerst klären.");
@@ -103,7 +103,7 @@ class ExtrasStatistics {
var stops=db.queryForList("SELECT t.jb_id,t.tr_sort,t.tr_finishtime,a.ad_zipcode,t.tr_commission_no,TIME_TO_SEC(TIMEDIFF(t.tr_finishtime,j.jb_ordertime)) AS seconds FROM tour t JOIN job j ON j.jb_id=t.jb_id LEFT JOIN address a ON a.ad_id=t.ad_id WHERE j.hq_id=? AND t.jb_id IN ("+CrmScope.marks(batch.size())+") AND t.tr_sort BETWEEN 1 AND 20 ORDER BY t.jb_id,t.tr_sort,t.tr_id",params.toArray());
for(var t:stops){var row=byId.get(id(t,"jb_id"));int n=(int)id(t,"tr_sort");if(row.containsKey("postal"+n))throw bad("Mehrere Stationen mit gleicher Position bei Auftrag "+id(t,"jb_id")+". Bitte den Bestand prüfen.");row.put("postal"+n,text(t,"ad_zipcode"));row.put("finished"+n,dateTime(t,"tr_finishtime"));row.put("reference"+n,text(t,"tr_commission_no"));Long seconds=t.get("seconds")==null?null:id(t,"seconds");row.put("elapsed"+n,duration(seconds));if(n==2)row.put("firstDeliverySeconds",seconds);}
}
rows.sort(Comparator.comparing((Map<String,Object> r)->Boolean.TRUE.equals(r.get("truck"))).thenComparing(r->(Long)r.get("firstDeliverySeconds"),Comparator.nullsFirst(Long::compareTo)).thenComparingLong(r->id(r,"id")));return rows;
rows.sort(Comparator.comparing((Map<String,Object> r)->Boolean.TRUE.equals(r.get("truck"))).thenComparing(r->(Long)r.get("firstDeliverySeconds"),Comparator.nullsFirst((entryValue, otherValue) -> entryValue.compareTo(otherValue))).thenComparingLong(r->id(r,"id")));return rows;
}
static String duration(Long seconds){if(seconds==null)return "";long abs=Math.abs(seconds);return (seconds<0?"-":"")+String.format(Locale.ROOT,"%02d:%02d:%02d",abs/3600,abs%3600/60,abs%60);}
}
@@ -53,7 +53,7 @@ class FilterAdministration {
}catch(RuntimeException e){return fields("token",token,"state","PENDING","message","Der Vorgang ist gesichert, aber noch nicht vollständig abgeschlossen. Bitte den vorhandenen Vorgang fortsetzen. Keinen zweiten Vorgang anlegen."+(e instanceof org.springframework.web.server.ResponseStatusException r&&r.getReason()!=null?" "+r.getReason():""));}
}
static final String TEMPLATE_SCOPE="(LENGTH(jb_tourname)>0 OR (jb_ordertime>=NOW() AND ((jb_storno<>3 AND jb_storno<>4) OR jb_storno IS NULL) AND jb_status<>2 AND (jb_permanent IN (4,5,6,7,8,9,10,11) OR (jb_permanent IN (1,2,3) AND jb_permanent2<>'0000000')) AND (jb_id_copy_permanent IS NULL OR jb_id_copy_permanent=0) AND (jp_permenddat>=NOW() OR jp_permenddat IS NULL OR jp_permenddat='0000-00-00 00:00:00')))";
static String withoutCode(String value,String code){var parts=Arrays.stream(value.split(",",-1)).filter(v->!v.equalsIgnoreCase(code)).toList();return parts.stream().allMatch(String::isEmpty)?"":String.join(",",parts);}
static String withoutCode(String value,String code){var parts=Arrays.stream(value.split(",",-1)).filter(v->!v.equalsIgnoreCase(code)).toList();return parts.stream().allMatch(entryValue -> entryValue.isEmpty())?"":String.join(",",parts);}
boolean cleanupTemplates(Operation op){
return db.filterTemplateCleanupWriteTransaction(()->{
var state=operationRow(op.token(),true);if(state==null)throw bad("Filtervorgang nicht gefunden.");if(text(state,"gdc_context").equals("DONE"))return true;
@@ -36,7 +36,7 @@ public class FleetMapContext {
Map<String,Object> ranking=reason.isEmpty()?candidates.list(u,job):Map.of();
var own=new HashSet<Long>();db.queryForList("SELECT cr_id FROM courier WHERE hq_id=?",u.headquartersId()).forEach(r->own.add(id(r,"cr_id")));
var items=new ArrayList<Map<String,Object>>();int rank=0;
for(var value:(List<Map<String,Object>>)ranking.getOrDefault("items",List.of())){
for(var value:TypedValues.rows(ranking.getOrDefault("items",List.of()))){
rank++;if(!own.contains(id(value,"courierId")))continue;
var item=new LinkedHashMap<>(value);item.put("rank",rank);items.add(item);
}
@@ -55,7 +55,7 @@ class GroupDeletion {
if(id(group,"grp_readonly")!=0||id(group,"grp_system")!=0)throw bad("Diese Systemgruppe ist gegen Änderungen geschützt.");
if(id(group,"hq_id")==0)user.require(10);
var p=new Plan(id,mandator(db,user),user.headquartersId(),user.userId(),revision(group),text(group,"grp_name"),members(user,id));
var counts=new LinkedHashMap<String,Integer>();p.members().forEach(m->counts.merge(m.kind(),1,Integer::sum));
var counts=new LinkedHashMap<String,Integer>();p.members().forEach(m->counts.merge(m.kind(),1,(entryValue, otherValue) -> Integer.sum(entryValue, otherValue)));
return fields("revision",revision(p),"total",p.members().size(),"counts",counts.entrySet().stream().map(e->fields("label",e.getKey().equals("MESSAGE_GROUP")?"Nachrichtengruppen":GroupAdministration.Kind.valueOf(e.getKey()).label,"count",e.getValue())).toList());
});
}
@@ -94,7 +94,7 @@ class HeadquartersSettings {
});}
Map<String,String> normalize(Section s,Draft draft){
if(s==null||s==Section.VEHICLES||draft==null||draft.revision()==null||!draft.revision().matches("[a-f0-9]{64}")||draft.values()==null)throw bad("Änderung und Bestandsrevision fehlen.");
var defs=FIELDS.get(s);if(!draft.values().keySet().equals(new HashSet<>(defs.stream().map(Field::key).toList())))throw bad("Die Angaben sind unvollständig oder enthalten unbekannte Felder.");
var defs=FIELDS.get(s);if(!draft.values().keySet().equals(new HashSet<>(defs.stream().map(entryValue -> entryValue.key()).toList())))throw bad("Die Angaben sind unvollständig oder enthalten unbekannte Felder.");
var values=new LinkedHashMap<String,String>();for(var f:defs){String value=draft.values().get(f.key());if(value==null)throw bad("Ein Feldwert fehlt.");values.put(f.key(),EmployeeAdministration.optional(value,f.maxLength(),f.label()));}return values;
}
Preview prepare(Snapshot s,Draft d){
@@ -1,6 +1,6 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;import org.springframework.security.core.Authentication;import org.springframework.web.bind.annotation.*;
import java.math.*;import java.time.*;import java.util.*;
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;
/** Explicit read-only historical projections; never dispatches archive IDs to live order mutation routes. */
@RestController @RequestMapping("/api/historical-orders")
@@ -30,10 +30,10 @@ final class HistoricalStatistics {
static void validate(Filters f){if(f==null)return;if(f.amount()==null||f.payment()==null||(f.timeFrom()==null)!=(f.timeTo()==null))throw bad("Die Statistikfilter sind unvollständig.");for(var n:Arrays.asList(f.vehicleType(),f.actualVehicleType(),f.orderType()))if(n!=null&&(n<0||n>32767))throw bad("Ungültiger Fahrzeug- oder Auftragstyp.");if(f.vehicleNumber()!=null&&(f.vehicleNumber().length()>10||f.vehicleNumber().codePoints().anyMatch(Character::isISOControl)))throw bad("Die Fahrzeugkennung darf höchstens zehn Zeichen enthalten.");StatisticsCalculation.validate(f.calculation());}
static Map<String,Object> read(HistoricalOrders.Selection s,String logSchema){
var q=s.query();var f=q.filters();validate(f);boolean calc=f!=null&&f.calculation()!=null;String at=date(q.basis());String where=s.where();var args=new ArrayList<Object>(s.args());
if(calc){if(!s.source().tables.contains("jobcalculatorhistory"))throw bad("Für diese Archivquelle fehlen die historischen Kalkulationspositionen.");where=where.replace(" FROM "+s.source().job()+" jb WHERE "," FROM "+s.source().job()+" jb JOIN jobcalculatorhistory calc ON calc.jb_id=jb.jb_id WHERE ");where+=StatisticsCalculation.filters(f.calculation(),args);}
if(calc){if(f==null)throw bad("Die Kalkulationsfilter fehlen.");if(!s.source().tables.contains("jobcalculatorhistory"))throw bad("Für diese Archivquelle fehlen die historischen Kalkulationspositionen.");where=where.replace(" FROM "+s.source().job()+" jb WHERE "," FROM "+s.source().job()+" jb JOIN jobcalculatorhistory calc ON calc.jb_id=jb.jb_id WHERE ");where+=StatisticsCalculation.filters(f.calculation(),args);}
var ex=extra(q);var advanced=advanced(q);boolean action=actions(q),pickup=ex!=null&&ex.pickup();
if((action?1:0)+(pickup?1:0)+(calc?1:0)>1)throw bad("Kalkulation, Aktionen und Abholdauer sind getrennte Auswertungsbasen.");
if(action){if(s.scope().customer())throw CustomerPortalScope.denied();if(ex.action().action()==null)throw bad("Bitte eine Transporteur-Aktion wählen.");var join=StatisticsCourierActions.events(ex.action(),f.timeFrom(),f.timeTo(),new StatisticsService.Interval(q.from(),q.to()),s.scope().headquarters(),log(s.source(),logSchema));where=where.replace(" FROM "+s.source().job()+" jb WHERE "," FROM "+join.text()+" STRAIGHT_JOIN "+s.source().job()+" jb ON jb.jb_id=actionEvent.jb_id WHERE ");args.addAll(0,join.values());at="actionEvent.occurredAt";
if(action){if(ex==null||f==null)throw bad("Die Aktionsfilter fehlen.");if(s.scope().customer())throw CustomerPortalScope.denied();if(ex.action().action()==null)throw bad("Bitte eine Transporteur-Aktion wählen.");var join=StatisticsCourierActions.events(ex.action(),f.timeFrom(),f.timeTo(),new StatisticsService.Interval(q.from(),q.to()),s.scope().headquarters(),log(s.source(),logSchema));where=where.replace(" FROM "+s.source().job()+" jb WHERE "," FROM "+join.text()+" STRAIGHT_JOIN "+s.source().job()+" jb ON jb.jb_id=actionEvent.jb_id WHERE ");args.addAll(0,join.values());at="actionEvent.occurredAt";
var allowed=s.source().db.queryForList("SELECT cr_id FROM courier WHERE hq_id IN ("+CrmScope.marks(s.scope().headquarters().size())+") ORDER BY cr_id LIMIT 20001",s.scope().headquarters().toArray()).stream().map(r->LegacyMapping.id(r,"cr_id")).toList();if(allowed.size()>20000)throw bad("Zu viele Transporteure für die historische Aktionsauswertung. Bitte eine Niederlassung wählen.");if(allowed.isEmpty())where+=" AND 1=0";else{where+=" AND actionEvent.cr_id IN ("+CrmScope.marks(allowed.size())+")";args.addAll(allowed);}}
if(pickup){if(!"PICKUP_DURATION".equals(q.group()))throw bad("Die Abholdauer benötigt die Gruppierung nach Zeitklassen.");}
if(ex!=null){where+=bounds(ex.weight(),"jb.jb_weight",args)+bounds(ex.length(),"jb.jb_crvh_length",args)+bounds(ex.width(),"jb.jb_crvh_width",args)+bounds(ex.height(),"jb.jb_crvh_height",args)+bounds(ex.positions(),"jb.jb_crvh_position",args);if(ex.retrospective()!=null){where+=" AND "+(ex.retrospective()?"":"NOT ")+"EXISTS(SELECT 1 FROM "+log(s.source(),logSchema)+" historicLog WHERE historicLog.jb_id=jb.jb_id AND historicLog.logo_id IN (1,25) AND historicLog.logo_description LIKE ?)";args.add("Nacherfassung%");}}
@@ -56,7 +56,7 @@ final class HistoricalStatistics {
if(courierDetails){selected.add(masters.expression("statisticsCourierId",selectArgs)+" AS statisticsCourierId");groups.add("statisticsCourierId");}
selected.add("COUNT(DISTINCT jb.jb_id) AS orders");selected.add("COUNT(*) AS positions");boolean prices=s.source().tables.contains("jobprice");
if(pickup){selected.add("NULL AS net");selected.add(pickup(String.join(",",StatisticsPickupTimes.columns()),s.source()));}
else if(calc){selected.add("COALESCE(SUM(CAST(calc.jbc_totalprice AS DECIMAL(18,4))),0) AS net");selected.add("COALESCE(SUM(calc.jbc_amount),0) AS quantity");}
else if(calc){if(f==null)throw bad("Die Kalkulationsfilter fehlen.");selected.add("COALESCE(SUM(CAST(calc.jbc_totalprice AS DECIMAL(18,4))),0) AS net");selected.add("COALESCE(SUM(calc.jbc_amount),0) AS quantity");}
else selected.addAll(HistoricalStatisticsMetrics.columns(advanced==null?null:advanced.prices(),s.scope().customer(),prices));
String direction=advanced!=null&&advanced.descending()?" DESC":" ASC";
args.addAll(0,selectArgs);var rows=s.source().read("SELECT "+String.join(",",selected)+where+" GROUP BY "+String.join(",",groups)+" ORDER BY "+String.join(",",groups.stream().map(g->g+direction).toList())+" LIMIT 5001",args.toArray());if(rows.size()>5000)throw bad("Mehr als 5000 Statistikzeilen. Bitte Zeitraum eingrenzen.");return fields("rows",StatisticsCourierDetails.decorate(s.source().db,List.of(fields("rows",HistoricalStatisticsMasters.decorate(s.source().db,rows,HistoricalStatisticsMasters.selection(q)))),s.scope().headquarters(),courierDetails).getFirst().get("rows"),"courierDetails",courierDetails,"from",q.from(),"to",q.to(),"masterNotices",masters.notices(),"customerDetails",HistoricalStatisticsMasters.selection(q)==null?null:HistoricalStatisticsMasters.selection(q).details(),"source",s.source().label(),"level",s.source().level,"targetStations",HistoricalStatisticsAddresses.targets(q),"calculation",calc,"action",action,"pickup",pickup,"download",s.scope().download(),"splits",advanced==null||advanced.splits()==null?List.of():advanced.splits(),"gross",advanced!=null&&advanced.prices()!=null&&advanced.prices().gross(),"auxiliaryPrices",prices,"basis",pickup?"Zeit von der Auftragsannahme bis zum Abschluss der eindeutigen ersten Station. Fehlende, mehrdeutige und negative Zeitwerte werden getrennt ausgewiesen.":action?"Historische Transporteur-Aktionen im Ereigniszeitraum; Aufträge werden je Gruppe einmal gezählt, Positionen zählen die ausgewählten Ereignisse. Wiederholte Ereignisse können den Auftragswert mehrfach beitragen.":calc?"Historische Kalkulationspositionen: Mengen und gespeicherte Positionswerte. Aufträge werden je Gruppe einmal gezählt; Frachtvergütung wird nicht auf Positionen verteilt.":"Gespeicherte historische Beträge. Fehlende Vergütungen werden nicht aus heutigen Provisionssätzen nachberechnet. Nicht vollständig belegte Kennzahlen bleiben leer.","historical",true);
@@ -6,7 +6,7 @@ import static de.stadtbote.votian.AdminSupport.bad;
/** Resolve PHP mixed groups in the primary database, then bind IDs/names on the archive connection. */
final class HistoricalStatisticsGroups {
record Plan(Map<String,List<Object>> members,boolean children,List<String> notices) {}
static Plan resolve(HistoricalOrders.Selection s){var a=HistoricalStatistics.advanced(s.query());var selection=a==null?null:a.groups();if(!StatisticsGroups.selected(selection))return new Plan(Map.of(),false,List.of());
static Plan resolve(HistoricalOrders.Selection s){var a=HistoricalStatistics.advanced(s.query());var selection=a==null?null:a.groups();if(selection==null||!StatisticsGroups.selected(selection))return new Plan(Map.of(),false,List.of());
var db=s.source().db;var catalog=new OrderEntryCatalog(db);var available=StatisticsGroups.options(db,catalog,s.user(),s.scope().headquarters());if(!Objects.equals(available.get("staticGroups"),selection.staticGroups()))throw bad("Der Gruppenmodus wurde geändert. Bitte die Statistik erneut öffnen.");
@SuppressWarnings("unchecked") var choices=(List<Map<String,Object>>)available.get("choices");var allowed=choices.stream().map(r->id(r,"id")).toList();if(!allowed.containsAll(selection.ids()))throw bad("Eine Gruppe ist nicht für diese Statistik freigegeben.");
var hqs=selection.staticGroups()?List.of(s.user().headquartersId()):s.scope().headquarters();var members=new LinkedHashMap<String,List<Object>>();
@@ -46,7 +46,7 @@ final class HistoricalStatisticsMasters {
queryArgs.addAll(hqs);queryArgs.addAll(hqs);
String predicate=" WHERE cc.csc_id IN ($KEYS) AND cs.hq_id IN ("+scope+")";
if(customerMembers!=null){if(groupPlan.children()&&!customerMembers.isEmpty()){predicate+=" AND (cs.cs_id IN ("+CrmScope.marks(customerMembers.size())+") OR cs.cs_id_parent IN ("+CrmScope.marks(customerMembers.size())+"))";queryArgs.addAll(customerMembers);queryArgs.addAll(customerMembers);}else predicate+=HistoricalStatisticsGroups.restrict("cs.cs_id",customerMembers,queryArgs);}
if(customerFilter){if(!blank(m.customerNumber())){predicate+=" AND "+(m.customerChildren()?"parentCs":"cs")+".cs_eid=?";queryArgs.add(m.customerNumber().strip());}predicate+=StatisticsCustomerCohort.filters(m.cohort(),queryArgs);}
if(customerFilter){if(m==null)throw bad("Die Kundenfilter fehlen.");if(!blank(m.customerNumber())){predicate+=" AND "+(m.customerChildren()?"parentCs":"cs")+".cs_eid=?";queryArgs.add(m.customerNumber().strip());}predicate+=StatisticsCustomerCohort.filters(m.cohort(),queryArgs);}
if(!excluded.isEmpty()){predicate+=" AND cs.cs_eid NOT IN ("+CrmScope.marks(excluded.size())+")";queryArgs.addAll(excluded);notices.add("Zentrale Kundenausschlüsse: "+excluded.size()+" Kundennummern.");}
// JOIN placeholders precede the bounded key list. Execute chunks with that exact parameter order.
int leading=(m!=null&&m.includeOldCustomers()?2:1)*hqs.size();var rows=new ArrayList<Map<String,Object>>();
@@ -27,7 +27,7 @@ class InvoiceCsvExport {
InvoiceCsvExport(DatabaseService db,BusinessSupport support,ExportFileStore store){this.db=db;this.support=support;this.store=store;}
@PostMapping("/preview") Object preview(Authentication a,@RequestBody Query q){return preview(ApiController.user(a),q);}
@PostMapping("/download") ResponseEntity<byte[]> download(Authentication a,@RequestBody Query q){var value=generate(ApiController.user(a),q);return response(text(value,"filename"),text(value,"content").getBytes(StandardCharsets.UTF_8),"text/csv;charset=UTF-8");}
static ResponseEntity<byte[]> response(String name,byte[] bytes,String type){return ResponseEntity.ok().contentType(MediaType.parseMediaType(type)).header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(name,StandardCharsets.UTF_8).build().toString()).header(HttpHeaders.CACHE_CONTROL,"no-store").header("X-Content-Type-Options","nosniff").body(bytes);}
static ResponseEntity<byte[]> response(String name,byte[] bytes,String type){return ResponseEntity.ok().contentType(MediaType.parseMediaType(java.util.Objects.requireNonNull(type))).header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(name,StandardCharsets.UTF_8).build().toString()).header(HttpHeaders.CACHE_CONTROL,"no-store").header("X-Content-Type-Options","nosniff").body(bytes);}
void rights(LegacyUser u){u.require(5);u.require(7);}
CustomerExportData.Scope scope(LegacyUser u,long customer){rights(u);var cs=one(db.queryForList("SELECT cs_id,csc_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));
var children=db.queryForList("SELECT cs_id,hq_id,csc_id FROM customer WHERE cs_id_parent=? AND cs_id<>? ORDER BY cs_id LIMIT 502",customer,customer);if(children.size()>501)throw bad("Der Kundenverbund ist zu groß.");
@@ -59,13 +59,13 @@ class InvoiceCsvExport {
String name=text(job,"usr_name").strip(),first=text(job,"usr_firstname").strip();values.add(name+(!name.isEmpty()&&!first.isEmpty()?", ":"")+first);for(String key:List.of("ad_street","ad_zipcode","ad_city"))values.add(text(address,key));values.add(text(job,"cmp_stax_idno").isBlank()?text(job,"cmp_tax_idno"):text(job,"cmp_stax_idno"));values.add(String.join(",",station.stream().map(t->text(t,"tr_commission_no")).toList()));
LocalDateTime start=null;if(!arrival.isEmpty())try{String raw=text(arrival.getFirst(),"gdc_content");start=LocalDateTime.parse(raw.substring(0,14),DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withResolverStyle(ResolverStyle.STRICT));}catch(RuntimeException e){warnings.add("Auftrag #"+id+": ungültige Ankunftszeit; Stationszeit wird verwendet.");}if(start==null)for(var t:station)if(id(t,"tr_sort")==1)start=dateTime(t,"tr_finishtime");values.add(clock(start));
LocalDateTime finish=dateTime(job,"jb_finishtime");if(!station.isEmpty()){var last=station.getLast();if(id(last,"tr_sort")<=maximum&&dateTime(last,"tr_finishtime")!=null)finish=dateTime(last,"tr_finishtime");}values.add(clock(finish));
var items=byCalc.getOrDefault(id,List.of());for(String key:List.of("Mindestpreis","Mehrkilometer#amount","Mehrkilometer","Nachtzuschlag","Wochenendzuschlag","Wartezeitzuschlag","Stopps")){boolean amount=key.endsWith("#amount");String service=key.replace("#amount","");values.add(items.isEmpty()?"0":number(sum(items,service,amount)));}var other=items.stream().filter(i->SERVICES.stream().noneMatch(n->n.equalsIgnoreCase(text(i,"srvt_name").stripTrailing()))&&BillingService.nz(decimal(i,"jbc_totalprice")).signum()>0).toList();values.add(items.isEmpty()?"0":number(other.stream().map(i->BillingService.nz(decimal(i,"jbc_totalprice"))).reduce(BigDecimal.ZERO,BigDecimal::add)));
var items=byCalc.getOrDefault(id,List.of());for(String key:List.of("Mindestpreis","Mehrkilometer#amount","Mehrkilometer","Nachtzuschlag","Wochenendzuschlag","Wartezeitzuschlag","Stopps")){boolean amount=key.endsWith("#amount");String service=key.replace("#amount","");values.add(items.isEmpty()?"0":number(sum(items,service,amount)));}var other=items.stream().filter(i->SERVICES.stream().noneMatch(n->n.equalsIgnoreCase(text(i,"srvt_name").stripTrailing()))&&BillingService.nz(decimal(i,"jbc_totalprice")).signum()>0).toList();values.add(items.isEmpty()?"0":number(other.stream().map(i->BillingService.nz(decimal(i,"jbc_totalprice"))).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))));
values.add(text(job,"jb_freetext_1").replace(";"," ").replace("'","").replace("\"","").replace('\r',' ').replace('\n',' ').strip());values.add(items.isEmpty()?"Fixpreis":String.join(", ",other.stream().map(i->"("+text(i,"jbc_amount")+" x) "+text(i,"srvt_name")).toList()));values.add(number(decimal(job,"jb_totalprice")));rows.add(List.copyOf(values));orders.add(fields("id",id,"ordered",job.get("jb_ordertime"),"finished",job.get("jb_finishtime"),"vehicle",text(job,"cr_sid"),"total",job.get("jb_totalprice")));
}
return new Snapshot(revision(fingerprint),scope,orders,rows,warnings);
});}
static String clock(LocalDateTime t){return t==null?"":t.format(DateTimeFormatter.ofPattern("HH:mm"));}
static BigDecimal sum(List<Map<String,Object>> items,String service,boolean amount){return items.stream().filter(i->text(i,"srvt_name").stripTrailing().equalsIgnoreCase(service)).map(i->BillingService.nz(decimal(i,amount?"jbc_amount":"jbc_totalprice"))).reduce(BigDecimal.ZERO,BigDecimal::add);}
static BigDecimal sum(List<Map<String,Object>> items,String service,boolean amount){return items.stream().filter(i->text(i,"srvt_name").stripTrailing().equalsIgnoreCase(service)).map(i->BillingService.nz(decimal(i,amount?"jbc_amount":"jbc_totalprice"))).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue));}
static String number(BigDecimal value){var f=new java.text.DecimalFormat("#,##0.00",java.text.DecimalFormatSymbols.getInstance(Locale.GERMANY));f.setRoundingMode(RoundingMode.HALF_UP);return f.format(BillingService.nz(value));}
static String cell(String value,boolean numeric){String clean=value.replace('\r',' ').replace('\n',' ').replace('\0',' ');if(!numeric&&clean.stripLeading().matches("^[=+@-].*"))clean="'"+clean;return clean.contains(";")||clean.contains("\"")?"\""+clean.replace("\"","\"\"")+"\"":clean;}
static String render(Snapshot snapshot){var content=new StringBuilder(String.join(";",HEADER)).append("\r\n");for(var row:snapshot.rows()){for(int i=0;i<row.size();i++){if(i>0)content.append(';');content.append(cell(row.get(i),i>=10&&i<=17||i==20));}content.append("\r\n");}if(content.toString().getBytes(StandardCharsets.UTF_8).length>CustomerExportFormat.MAX_OUTPUT)throw bad("CSV-Ausgabe größer als 10 MB. Bitte den Zeitraum eingrenzen.");return content.toString();}
@@ -29,7 +29,7 @@ class InvoiceDocuments {
record Part(long id,String html,Map<String,Object> summary){}
record Snapshot(String revision,List<Part> parts,List<String> warnings){}
@PostMapping("/preview") Object preview(Authentication a,@RequestBody Request r){return preview(ApiController.user(a),r);}
@PostMapping("/document.pdf") ResponseEntity<byte[]> download(Authentication a,@RequestBody Request r){return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=rechnungsauftraege.pdf").body(pdf(ApiController.user(a),r));}
@PostMapping("/document.pdf") ResponseEntity<byte[]> download(Authentication a,@RequestBody Request r){return ResponseEntity.ok().contentType(java.util.Objects.requireNonNull(MediaType.APPLICATION_PDF)).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=rechnungsauftraege.pdf").body(pdf(ApiController.user(a),r));}
void check(LegacyUser u,Request r){u.require(5);u.require(7);if(r==null||r.customer()<=0)throw bad("Bitte einen Kunden auswählen.");required(r.number(),30,"Rechnungsnummer");if(!Set.of("CENTER","ORDER").contains(Objects.toString(r.sort(),"")))throw bad("Ungültige Belegsortierung.");if(r.page()<0||r.page()>999)throw bad("Ungültige PDF-Seite.");}
Snapshot snapshot(LegacyUser u,Request r){check(u,r);return db.readTransaction(()->{
var customer=one(db.queryForList("SELECT cs.cs_id,cs.cs_eid,c.cmp_comp,c.cmp_comp2 FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.cs_id=? AND cs.hq_id=?",r.customer(),u.headquartersId()));
@@ -69,5 +69,5 @@ class InvoiceDocuments {
}catch(IOException e){throw new IllegalStateException("Die Rechnungsbelege konnten nicht erstellt werden.",e);}}
static void expected(Request r,Snapshot snapshot,boolean required){if((required||r.revision()!=null&&!r.revision().isBlank())&&!Objects.equals(r.revision(),snapshot.revision()))throw bad("Die Rechnungsaufträge wurden geändert. Bitte die Vorschau neu laden.");}
byte[] pdf(LegacyUser u,Request r){var snapshot=snapshot(u,r);expected(r,snapshot,true);byte[] pdf=render(snapshot);if(!snapshot(u,r).revision().equals(snapshot.revision()))throw bad("Die Rechnungsaufträge wurden während der Erstellung geändert. Bitte neu laden.");return pdf;}
Map<String,Object> preview(LegacyUser u,Request r){var snapshot=snapshot(u,r);expected(r,snapshot,false);byte[] bytes=render(snapshot);try(var pdf=Loader.loadPDF(bytes)){if(r.page()>=pdf.getNumberOfPages())throw bad("Die PDF-Seite existiert nicht.");var image=new PDFRenderer(pdf).renderImageWithDPI(r.page(),110);var out=new ByteArrayOutputStream();ImageIO.write(image,"png",out);if(!snapshot(u,r).revision().equals(snapshot.revision()))throw bad("Die Rechnungsaufträge wurden während der Vorschau geändert. Bitte neu laden.");return fields("revision",snapshot.revision(),"orders",snapshot.parts().stream().map(Part::summary).toList(),"warnings",snapshot.warnings(),"page",r.page(),"pages",pdf.getNumberOfPages(),"png",Base64.getEncoder().encodeToString(out.toByteArray()));}catch(IOException e){throw new IllegalStateException("Die PDF-Vorschau konnte nicht erstellt werden.",e);}}
Map<String,Object> preview(LegacyUser u,Request r){var snapshot=snapshot(u,r);expected(r,snapshot,false);byte[] bytes=render(snapshot);try(var pdf=Loader.loadPDF(bytes)){if(r.page()>=pdf.getNumberOfPages())throw bad("Die PDF-Seite existiert nicht.");var image=new PDFRenderer(pdf).renderImageWithDPI(r.page(),110);var out=new ByteArrayOutputStream();ImageIO.write(image,"png",out);if(!snapshot(u,r).revision().equals(snapshot.revision()))throw bad("Die Rechnungsaufträge wurden während der Vorschau geändert. Bitte neu laden.");return fields("revision",snapshot.revision(),"orders",snapshot.parts().stream().map(entryValue -> entryValue.summary()).toList(),"warnings",snapshot.warnings(),"page",r.page(),"pages",pdf.getNumberOfPages(),"png",Base64.getEncoder().encodeToString(out.toByteArray()));}catch(IOException e){throw new IllegalStateException("Die PDF-Vorschau konnte nicht erstellt werden.",e);}}
}
@@ -10,8 +10,8 @@ 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;}
final DatabaseService db;final BusinessSupport support;
LegacyCronBusiness(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
LegacyCronRunner.Result logout(){
support.transactional("courier","job","genericdatacontainer","phoenix_pda.commandexec");
String log=support.historySchema+".log";
@@ -30,18 +30,6 @@ class LegacyCronBusiness {
});}
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"));}
@@ -26,7 +26,7 @@ class LegacyCronCompliance {
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 String html(Object value) {return org.springframework.web.util.HtmlUtils.htmlEscape(java.util.Objects.requireNonNull(Objects.toString(value,"")));}
static LocalDate expiry(String raw,int months) {
if(raw.isBlank())return null;
try {
@@ -1,72 +0,0 @@
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);}
}
}
@@ -7,20 +7,19 @@ 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. */
/** Compare Stadtbote completion counts using the original four-order tolerance. */
@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));
static final int TOLERANCE=4;
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();
if(groups==null||!groups.keySet().equals(Set.of("STB")))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.");}
}catch(Exception e){throw bad("Stadtbote-Replikationsziele fehlen oder sind ungültig: ausschließlich STB mit Primärserver und Replikaten konfigurieren.");}
}
LegacyCronRunner.Result run(LocalDate day){
var targets=targets();var to=OrderEntryMail.addresses(env.getProperty("app.legacy-cron.replication.mail-to",""));
@@ -28,13 +27,11 @@ class LegacyCronReplication {
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");}
}
var nodes=targets.get("STB");Long primary=null;
for(int i=0;i<nodes.size();i++){
try{long value=db.cronReplicaCount(nodes.get(i),user,password,day);count++;
if(i==0)primary=value;else if(primary!=null&&primary-value>TOLERANCE)failures.add("STB Replikat "+i+": "+(primary-value)+" Einträge Rückstand");
}catch(RuntimeException e){failures.add("STB "+(i==0?"Primärserver":"Replikat "+i)+": nicht erreichbar");}
}
if(failures.isEmpty())return LegacyCronRunner.Result.ok(count);
String text=String.join("; ",failures);
@@ -1,89 +0,0 @@
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);}
}
}
@@ -1,110 +0,0 @@
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.");}
}
}
@@ -1,108 +0,0 @@
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;
}
}
@@ -22,7 +22,7 @@ class LegacyCronTasks {
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){
LegacyCronTasks(LegacyCronRunner runner,LegacyCronCompliance compliance,LegacyCronBusiness business,LegacyCronExports exports,LegacyCronReplication replication,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());
@@ -32,25 +32,21 @@ class LegacyCronTasks {
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");
add("replication-monitor","Stadtbote · Replikationsüberwachung","replication-monitor","0 */15 * * * *","sysadmin/cron_monitor_replication.php",slot->replication.run(slot.atZone(ZONE).toLocalDate()));
var known=Set.of("courier-logout","stb-compliance","sb-master-export","mysql-status","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);
String cron=env.getProperty("app.legacy-cron.schedules."+group,java.util.Objects.requireNonNull(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);
var scheduled=new java.util.concurrent.atomic.AtomicReference<Instant>();var trigger=new CronTrigger(java.util.Objects.requireNonNull(task.cron()),java.util.Objects.requireNonNull(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));
@@ -66,11 +62,10 @@ class LegacyCronTasks {
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;
var next=active?CronExpression.parse(java.util.Objects.requireNonNull(t.cron())).next(java.util.Objects.requireNonNull(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();}
}
@@ -42,7 +42,7 @@ final class LegacyDistancePricing {
String name=part.path("zone").asText();if(name.isBlank())throw invalid("Die Route enthält eine unbenannte Tarifzone.");
if(group.equals("extraDepartureCalculation"))name=name.replace("_edc","_eap");
String key=name.contains("_fix")?name+"#"+(unique++):name;
distances.merge(key,OrderRoutingService.metric(part,"distance"),BigDecimal::add);zoneKeys.put(key,name);
distances.merge(key,OrderRoutingService.metric(part,"distance"),(entryValue, otherValue) -> entryValue.add(otherValue));zoneKeys.put(key,name);
}
for(var entry:distances.entrySet()) {
String key=zoneKeys.get(entry.getKey()),name=names.getOrDefault(key,key);
@@ -94,8 +94,8 @@ final class LegacyDistancePricing {
return price.lines().stream().filter(line -> line.name().equals("Anhebung auf Mindestpreis"))
.map(line -> "[M] Mindestpreis "+money(line.rate()).toPlainString()).findFirst().orElse("");
}
BigDecimal kmRate=price.lines().stream().filter(line -> line.name().equals("Kilometer")).map(CalculationLine::rate).findFirst().orElse(BigDecimal.ZERO);
BigDecimal basic=price.lines().stream().filter(line -> line.name().equals("Grundpreis")).map(CalculationLine::amount).findFirst().orElse(BigDecimal.ZERO);
BigDecimal kmRate=price.lines().stream().filter(line -> line.name().equals("Kilometer")).map(entryValue -> entryValue.rate()).findFirst().orElse(BigDecimal.ZERO);
BigDecimal basic=price.lines().stream().filter(line -> line.name().equals("Grundpreis")).map(entryValue -> entryValue.amount()).findFirst().orElse(BigDecimal.ZERO);
return route.info().distanceKm().toPlainString()+"|"+kmRate.toPlainString()+"|"+basic.toPlainString();
}
private static BigDecimal cr(Tariff tariff,BigDecimal deduction){return tariff.courier().signum()==0?rawDiscount(tariff.price(),deduction):tariff.courier();}
@@ -121,6 +121,6 @@ final class LegacyHistoryMapping {
// This is a text projection, not HTML for rendering; consumers must continue using text nodes.
String text = BREAK.matcher(value).replaceAll("\n");
text = TAG.matcher(text).replaceAll("");
return HtmlUtils.htmlUnescape(text).replace('\u00a0', ' ').replace("\r\n", "\n").strip();
return HtmlUtils.htmlUnescape(java.util.Objects.requireNonNull(text)).replace('\u00a0', ' ').replace("\r\n", "\n").strip();
}
}
@@ -9,7 +9,7 @@ import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryCatalog.*;
@@ -80,8 +80,8 @@ public class LegacyOrderPricing {
} else if((input.mode()==PriceMode.FIXED || automatic) && !batch) {
if(!automatic && (input.fixedPrice()==null || input.fixedPrice().signum()<=0))
throw invalid("Ein Festpreis muss größer als 0 sein. Für die Tarifberechnung bitte Tarif wählen.");
BigDecimal base=automatic?autoPrice.base():input.fixedPrice();
BigDecimal cr=positive(input.courierPrice())?discount(input.courierPrice(),discount):automatic?discount(autoPrice.courier(),discount):discount(discount(base,discount),deduction);
BigDecimal base=autoPrice!=null?autoPrice.base():input.fixedPrice();
BigDecimal cr=positive(input.courierPrice())?discount(input.courierPrice(),discount):autoPrice!=null?discount(autoPrice.courier(),discount):discount(discount(base,discount),deduction);
courierBase=cr;
BigDecimal publicBase=markup(base,markup);
// jobs2 subtracts the rounded self-service amount before the fixed-price discount.
@@ -104,7 +104,7 @@ public class LegacyOrderPricing {
if(!tariff.found()&&!user.customerAccount()) throw invalid("Kein gültiger Tarif für Station "+i+" → "+(i+1)+". Bitte die Adressen oder den Festpreis prüfen.");
components.add(component(i,"PLZ "+from.postalCode()+" nach PLZ "+to.postalCode(),"",tariff,markup,courierMarkup,deduction,selfDiscount));
}
courierBase=sum(components,Component::courierSubtotal);
courierBase=sum(components,entryValue -> entryValue.courierSubtotal());
if(positive(input.courierPrice())) courierBase=input.courierPrice();
}
if(!request.services().isEmpty()) {
@@ -122,10 +122,10 @@ public class LegacyOrderPricing {
var extended=new Tariff(money(unit.multiply(item.quantity())),input.applyFixedDiscount()?discount:tariff.discount(),money(cr.multiply(item.quantity())),true);
components.add(component(0,item.quantity().stripTrailingZeros().toPlainString()+" × "+choice.service(),choice.type(),extended,markup,courierMarkup,deduction,selfDiscount));
}
courierBase=sum(components,Component::courierSubtotal);
courierBase=sum(components,entryValue -> entryValue.courierSubtotal());
}
BigDecimal net=sum(components,Component::net),subtotal=sum(components,Component::subtotal);
BigDecimal courierNet=sum(components,Component::courierNet),courierSubtotal=sum(components,Component::courierSubtotal);
BigDecimal net=sum(components,entryValue -> entryValue.net()),subtotal=sum(components,entryValue -> entryValue.subtotal());
BigDecimal courierNet=sum(components,entryValue -> entryValue.courierNet()),courierSubtotal=sum(components,entryValue -> entryValue.courierSubtotal());
if((input.mode()==PriceMode.TARIFF || input.mode()==PriceMode.SERVICES) && positive(input.courierPrice())) {
courierBase=input.courierPrice();courierSubtotal=input.courierPrice();courierNet=markup(courierSubtotal,courierMarkup);
}
@@ -395,7 +395,7 @@ public class LegacyOrderPricing {
static BigDecimal money(BigDecimal value) { return value.setScale(2,RoundingMode.HALF_UP); }
static BigDecimal discount(BigDecimal value,BigDecimal percent) { return money(value.multiply(HUNDRED.subtract(percent)).divide(HUNDRED)); }
static BigDecimal markup(BigDecimal value,BigDecimal percent) { return money(value.multiply(HUNDRED.add(percent)).divide(HUNDRED)); }
static BigDecimal sum(List<Component> components,java.util.function.Function<Component,BigDecimal> field) { return components.stream().map(field).reduce(ZERO,BigDecimal::add); }
static BigDecimal sum(List<Component> components,java.util.function.Function<Component,BigDecimal> field) { return components.stream().map(field).reduce(ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)); }
static String quoteEvidence(com.fasterxml.jackson.databind.node.ObjectNode tree) {
var copy=tree.deepCopy();
if(copy.path("route") instanceof com.fasterxml.jackson.databind.node.ObjectNode route)route.remove("view");
@@ -16,7 +16,7 @@ final class LegacySessionRightsFilter extends OncePerRequestFilter {
static final String SQL="SELECT user.usr_account,employee.emp_rights,"+LegacyCredentialState.EXPRESSION+" AS credential_stamp FROM user JOIN employee ON employee.usr_id=user.usr_id JOIN headquarters ON headquarters.hq_id=user.hq_id WHERE user.usr_id=? AND employee.emp_id=? AND user.hq_id=? AND user.usr_type=1 AND employee.csc_id=0 AND EXISTS (SELECT 1 FROM mandatorheadquarters WHERE mandatorheadquarters.hq_id=user.hq_id AND mandatorheadquarters.md_id>0)";
final DatabaseService db;final ObjectMapper json;
LegacySessionRightsFilter(DatabaseService db,ObjectMapper json){this.db=db;this.json=json;}
@Override protected void doFilterInternal(HttpServletRequest req,HttpServletResponse res,FilterChain chain) throws ServletException,IOException {
@Override protected void doFilterInternal(@org.springframework.lang.NonNull HttpServletRequest req,@org.springframework.lang.NonNull HttpServletResponse res,@org.springframework.lang.NonNull FilterChain chain) throws ServletException,IOException {
var original=SecurityContextHolder.getContext();var auth=original.getAuthentication();
String path=req.getServletPath();if(path.isEmpty())path=req.getRequestURI().substring(req.getContextPath().length());
if(!path.startsWith("/api/")||path.equals("/api/logout")||path.equals("/api/login")||auth==null||!auth.isAuthenticated()||!(auth.getPrincipal() instanceof LegacyUser u)){chain.doFilter(req,res);return;}
@@ -27,7 +27,7 @@ class LoginActivity {
}
record Event(String identity,LocalDateTime time){}
static String identity(long id,String description){var matcher=java.util.regex.Pattern.compile(" \\| NG=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$").matcher(description);return matcher.find()?"ng:"+matcher.group(1):"php:"+id;}
static List<LocalDateTime> merge(List<Event> legacy,List<Event> pending){var events=new LinkedHashMap<String,Event>();for(var e:legacy)events.put(e.identity(),e);for(var e:pending)events.putIfAbsent(e.identity(),e);return events.values().stream().map(Event::time).sorted(Comparator.reverseOrder()).limit(2).toList();}
static List<LocalDateTime> merge(List<Event> legacy,List<Event> pending){var events=new LinkedHashMap<String,Event>();for(var e:legacy)events.put(e.identity(),e);for(var e:pending)events.putIfAbsent(e.identity(),e);return events.values().stream().map(entryValue -> entryValue.time()).sorted(Comparator.reverseOrder()).limit(2).toList();}
static List<LocalDateTime> recent(DatabaseService db,BusinessSupport support,LegacyUser u,LocalDateTime from,LocalDateTime to){return db.readTransaction(()->{
var legacy=db.queryForList("SELECT log_id,log_createtime,logo_description FROM "+support.historySchema+".log WHERE logo_id IN (52,53) AND usr_id=? AND log_createtime>=? AND log_createtime<=? ORDER BY log_createtime DESC,log_id DESC LIMIT 2",u.userId(),from,to).stream().map(r->new Event(identity(id(r,"log_id"),text(r,"logo_description")),dateTime(r,"log_createtime"))).toList();
String value="IF(JSON_VALID(gdc_content),gdc_content,'{}')";
@@ -109,15 +109,12 @@ public class MasterData {
return new HomePlan(null,target==0||!text(old,"ad_zipcode").equals(text(clean,"ad_zipcode")));
}):new HomePlan(null,false);
if(plan.receipt()!=null)return view(u,kind,plan.receipt(),parent);
CustomerPhotoSettings.Copy photoCopy=null;
if(request.copySource()!=null){
var photos=photoSettings();photos.access(u);
new CustomerCopy(this).access(u);
Long receipt=db.readTransaction(()->support.receipt(u,request.requestId(),intent));
if(receipt!=null)return copyResult(u,receipt,request.requestId());
photoCopy=photos.copy(u,request.copySource().id());
if(!photoCopy.revision().equals(request.copySource().photoRevision()))throw CustomerPhotoSettings.conflict();
if(request.copySource().photoRevision()!=null)throw bad("Fotoeinstellungen werden jetzt in der Workflow-Verwaltung gepflegt. Bitte die Kundenkopie neu öffnen.");
}
final var copiedPhotos=photoCopy;
var home=plan.resolve()?routing.homePostalCenter(text(clean,"ad_zipcode")):null;
long saved=db.writeTransaction(()->{
// ExtIDs and SIDs are shared with PHP; lock matching index ranges as well as our creation mutex.
@@ -180,7 +177,6 @@ public class MasterData {
new VehicleAssociatedCourier(this).saved(u,target,result,old,clean,vehicleOwner,request.requestId());
new VehicleStock(db,support).saved(u,target,result,text(clean,"crvh_sid"),request.requestId());
}
if(copiedPhotos!=null)photoSettings().enqueueCopy(u,result,request.requestId(),copiedPhotos);
support.audit(u,0,kind.equals("customers")?result:kind.equals("centers")?parent:0,kind.equals("couriers")?result:kind.equals("vehicles")?parent:0,kind.equals("vehicles")?(target==0?85:86):csOperation(kind,target),"Stammdaten "+kind+" #"+result+" · "+(target==0?"Angelegt":"Bearbeitet")+" · Felder: "+String.join(",",clean.keySet())+(request.copySource()==null?"":" · Kopie von Kunde "+request.copySource().id())+(request.vehicleCopySource()==null?"":" · Kopie von Fahrzeug "+request.vehicleCopySource().id()),request.requestId());
support.receipt(u,request.requestId(),List.of(kind,target,parent,request),result);return result;
});if(kind.equals("customers"))new CustomerType(db,support).publishRequest(saved,request.requestId());return request.copySource()==null?view(u,kind,saved,parent):copyResult(u,saved,request.requestId());
Loaded 100 of 476 files, more files were not shown because too many files have changed in this diff. Show more