diff --git a/.env.example b/.env.example index f6d630a..807f398 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.vscode/settings.json b/.vscode/settings.json index 0aa82d7..6d510e8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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"] } diff --git a/Dockerfile b/Dockerfile index 5d3fe7b..0c75b03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index be361a9..1d2c7be 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/backend/pom.xml b/backend/pom.xml index 71fdba8..fbc4255 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -2,10 +2,9 @@ 4.0.0 org.springframework.bootspring-boot-starter-parent3.5.16 - de.stadtbotevotian-backend0.2.0 + de.stadtbotevotian-backend0.2.1 Votian NG Stadtbote21 - org.apache.commonscommons-compress1.28.0 commons-netcommons-net3.13.0 com.github.mwiedejsch2.28.5 com.google.zxingcore3.5.4 @@ -20,5 +19,36 @@ org.springframework.bootspring-boot-starter-testtest org.springframework.securityspring-security-testtest - src/main/resources../frontend/diststaticorg.springframework.bootspring-boot-maven-plugin + + + src/main/resources + ${project.build.directory}/frontend-resources + + + org.springframework.bootspring-boot-maven-plugin + + org.apache.maven.pluginsmaven-antrun-plugin3.1.0 + + stage-frontendgenerate-resourcesrun + + + + + + + + + + + + org.eclipse.m2elifecycle-mapping1.0.0 + + + org.apache.maven.pluginsmaven-antrun-plugin + [3.1.0,)run + + falsetrue + + + diff --git a/backend/src/main/java/de/stadtbote/votian/AccountTwoFactor.java b/backend/src/main/java/de/stadtbote/votian/AccountTwoFactor.java index 9228fc9..5a48d9a 100644 --- a/backend/src/main/java/de/stadtbote/votian/AccountTwoFactor.java +++ b/backend/src/main/java/de/stadtbote/votian/AccountTwoFactor.java @@ -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]";} diff --git a/backend/src/main/java/de/stadtbote/votian/AccountingExportData.java b/backend/src/main/java/de/stadtbote/votian/AccountingExportData.java index 30b530b..bf1497a 100644 --- a/backend/src/main/java/de/stadtbote/votian/AccountingExportData.java +++ b/backend/src/main/java/de/stadtbote/votian/AccountingExportData.java @@ -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 { diff --git a/backend/src/main/java/de/stadtbote/votian/AllergoImport.java b/backend/src/main/java/de/stadtbote/votian/AllergoImport.java index 4f753df..72f1964 100644 --- a/backend/src/main/java/de/stadtbote/votian/AllergoImport.java +++ b/backend/src/main/java/de/stadtbote/votian/AllergoImport.java @@ -26,9 +26,9 @@ class AllergoImport { static Address address(List 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 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();var out=new ArrayList();for(int n=1;n();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;i8000)throw bad("Zeile "+line+": Sendungsbemerkung zu lang.");out.add(new Shipment(line,transaction,sender,recipient,text));}return List.copyOf(out);} Map 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();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.>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 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();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.>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 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 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 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>();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
();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 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>();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
();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);});} } diff --git a/backend/src/main/java/de/stadtbote/votian/ApiController.java b/backend/src/main/java/de/stadtbote/votian/ApiController.java index 02c7c6f..fd2689a 100644 --- a/backend/src/main/java/de/stadtbote/votian/ApiController.java +++ b/backend/src/main/java/de/stadtbote/votian/ApiController.java @@ -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(); } diff --git a/backend/src/main/java/de/stadtbote/votian/AppointmentMailTransport.java b/backend/src/main/java/de/stadtbote/votian/AppointmentMailTransport.java index ec57b0c..affb4d1 100644 --- a/backend/src/main/java/de/stadtbote/votian/AppointmentMailTransport.java +++ b/backend/src/main/java/de/stadtbote/votian/AppointmentMailTransport.java @@ -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 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 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 to,java.util.List cc,java.util.List 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 cc,java.util.List 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();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();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 recipients,java.util.List cc,java.util.List bcc,String subject,String html){sendOrderHtml(from,recipients,cc,bcc,subject,html,"",null);} void sendOrderHtml(String from,java.util.List recipients,java.util.List cc,java.util.List 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();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);} } } diff --git a/backend/src/main/java/de/stadtbote/votian/AppointmentService.java b/backend/src/main/java/de/stadtbote/votian/AppointmentService.java index da3093d..84b6d4c 100644 --- a/backend/src/main/java/de/stadtbote/votian/AppointmentService.java +++ b/backend/src/main/java/de/stadtbote/votian/AppointmentService.java @@ -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>(); for(int n=1;n<=4;n++) { - var counts=new TreeMap();for(var row:rows)counts.merge(id(row,"ap_category_"+n),id(row,"total"),Long::sum); + var counts=new TreeMap();for(var row:rows)counts.merge(id(row,"ap_category_"+n),id(row,"total"),(entryValue, otherValue) -> Long.sum(entryValue, otherValue)); var labels=new HashMap();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())); } diff --git a/backend/src/main/java/de/stadtbote/votian/ArticleAdministration.java b/backend/src/main/java/de/stadtbote/votian/ArticleAdministration.java index 153e21f..c864873 100644 --- a/backend/src/main/java/de/stadtbote/votian/ArticleAdministration.java +++ b/backend/src/main/java/de/stadtbote/votian/ArticleAdministration.java @@ -69,8 +69,8 @@ class ArticleAdministration { var changes=new ArrayList>();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> descendants(long root){var rows=new ArrayList>();var visited=new HashSet();var queue=new ArrayDeque();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();indegree.put(root,0);var edges=new HashSet();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();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();indegree.put(root,0);var edges=new HashSet();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();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 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 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); diff --git a/backend/src/main/java/de/stadtbote/votian/ArticleColumns.java b/backend/src/main/java/de/stadtbote/votian/ArticleColumns.java index 51bcae1..6803e06 100644 --- a/backend/src/main/java/de/stadtbote/votian/ArticleColumns.java +++ b/backend/src/main/java/de/stadtbote/votian/ArticleColumns.java @@ -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> 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> 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> 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()));} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMail.java b/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMail.java index 8d30691..1cef977 100644 --- a/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMail.java +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMail.java @@ -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> view(Map job,LocalDateTime now){var out=new ArrayList>();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> view(Map job,LocalDateTime now){var out=new ArrayList>();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."); diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMailContent.java b/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMailContent.java index 2e87256..fac5bc3 100644 --- a/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMailContent.java +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMailContent.java @@ -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("

");if(!link.isBlank())body.append("");body.append("\"Logo\"");if(!link.isBlank())body.append("");body.append("

");warnings.add("Das Logo wird beim Empfänger von der bisherigen Anwendungsadresse geladen."); + body.append("

");if(!link.isBlank())body.append("");body.append("\"Logo\"");if(!link.isBlank())body.append("");body.append("

");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("
").append(rich(c.local(key))).append("
"); diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticOfferPolicy.java b/backend/src/main/java/de/stadtbote/votian/AutomaticOfferPolicy.java index 0c1aa2d..a61b54c 100644 --- a/backend/src/main/java/de/stadtbote/votian/AutomaticOfferPolicy.java +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticOfferPolicy.java @@ -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 blockers){boolean eligible(){return blockers.isEmpty();}} record Plan(String state,String message,LocalDateTime startsAt,int iterations,boolean reserved,boolean onlyFavoured,boolean ignoreFavoured,List candidates){ List rounds(){if(reserved)return List.of("RESERVED");var r=new ArrayList();if(!ignoreFavoured)for(int i=0;iround.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 j,Settings s,LocalDateTime now){ if(id(j,"jb_status")!=8)return "Der Auftrag steht nicht im Autoranking."; diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticOffers.java b/backend/src/main/java/de/stadtbote/votian/AutomaticOffers.java index 6b2b488..be34d2c 100644 --- a/backend/src/main/java/de/stadtbote/votian/AutomaticOffers.java +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticOffers.java @@ -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 values){return FleetAssignment.tokens(csv).stream().anyMatch(values::contains);} List> customers(List 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 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 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 view(Authentication auth,@PathVariable long order){return view(ApiController.user(auth),order);} Map 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>(); - 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)); diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticOrderMailPolicy.java b/backend/src/main/java/de/stadtbote/votian/AutomaticOrderMailPolicy.java index 50149b9..be689a2 100644 --- a/backend/src/main/java/de/stadtbote/votian/AutomaticOrderMailPolicy.java +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticOrderMailPolicy.java @@ -48,7 +48,7 @@ final class AutomaticOrderMailPolicy { String state="WAITING",reason="Auftragsstatus oder Versandzeitraum passt noch nicht.";var recipients=new LinkedHashMap();var sources=new ArrayList();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.";}} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticStationMail.java b/backend/src/main/java/de/stadtbote/votian/AutomaticStationMail.java index a6bcfe8..ccbbf7f 100644 --- a/backend/src/main/java/de/stadtbote/votian/AutomaticStationMail.java +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticStationMail.java @@ -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>();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; } diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticStationMailContent.java b/backend/src/main/java/de/stadtbote/votian/AutomaticStationMailContent.java index bd89a33..7083238 100644 --- a/backend/src/main/java/de/stadtbote/votian/AutomaticStationMailContent.java +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticStationMailContent.java @@ -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("

");if(!link.isBlank())body.append("");body.append("\"Logo\"");if(!link.isBlank())body.append("");body.append("

");warnings.add("Das Logo wird beim Empfänger von der bisherigen Anwendungsadresse geladen."); + body.append("

");if(!link.isBlank())body.append("");body.append("\"Logo\"");if(!link.isBlank())body.append("");body.append("

");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("
").append(rich(c.local(key))).append("
"); diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticTakenMail.java b/backend/src/main/java/de/stadtbote/votian/AutomaticTakenMail.java index 3631e13..68ea00c 100644 --- a/backend/src/main/java/de/stadtbote/votian/AutomaticTakenMail.java +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticTakenMail.java @@ -57,7 +57,7 @@ class AutomaticTakenMail { } Map view(Map 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){ diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticTrackingMail.java b/backend/src/main/java/de/stadtbote/votian/AutomaticTrackingMail.java index 1b2478e..76a2bc6 100644 --- a/backend/src/main/java/de/stadtbote/votian/AutomaticTrackingMail.java +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticTrackingMail.java @@ -53,8 +53,8 @@ class AutomaticTrackingMail { var rows=new ArrayList>();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; } diff --git a/backend/src/main/java/de/stadtbote/votian/BatchInvoiceRefresh.java b/backend/src/main/java/de/stadtbote/votian/BatchInvoiceRefresh.java index dd53bef..f119b19 100644 --- a/backend/src/main/java/de/stadtbote/votian/BatchInvoiceRefresh.java +++ b/backend/src/main/java/de/stadtbote/votian/BatchInvoiceRefresh.java @@ -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); } diff --git a/backend/src/main/java/de/stadtbote/votian/BillingService.java b/backend/src/main/java/de/stadtbote/votian/BillingService.java index 4012902..00b0403 100644 --- a/backend/src/main/java/de/stadtbote/votian/BillingService.java +++ b/backend/src/main/java/de/stadtbote/votian/BillingService.java @@ -55,14 +55,14 @@ public class BillingService { var byCenter=new TreeMap>>();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>(); 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 line(Map 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 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>)current.get("rows");Set 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 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();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 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>)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);} } diff --git a/backend/src/main/java/de/stadtbote/votian/BusinessController.java b/backend/src/main/java/de/stadtbote/votian/BusinessController.java index 5d9f4bc..45c2e61 100644 --- a/backend/src/main/java/de/stadtbote/votian/BusinessController.java +++ b/backend/src/main/java/de/stadtbote/votian/BusinessController.java @@ -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 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 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 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));} } diff --git a/backend/src/main/java/de/stadtbote/votian/CashExportPlan.java b/backend/src/main/java/de/stadtbote/votian/CashExportPlan.java index 06196e4..b0c4fb7 100644 --- a/backend/src/main/java/de/stadtbote/votian/CashExportPlan.java +++ b/backend/src/main/java/de/stadtbote/votian/CashExportPlan.java @@ -44,6 +44,6 @@ public final class CashExportPlan { var existing=new ArrayList>();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))); });} } diff --git a/backend/src/main/java/de/stadtbote/votian/Cashflow.java b/backend/src/main/java/de/stadtbote/votian/Cashflow.java index 9b7e034..d251132 100644 --- a/backend/src/main/java/de/stadtbote/votian/Cashflow.java +++ b/backend/src/main/java/de/stadtbote/votian/Cashflow.java @@ -22,7 +22,7 @@ public final class Cashflow { static final List 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> rows,String column){return rows.stream().map(r->net(r.get(column))).reduce(BigDecimal.ZERO,BigDecimal::add);} + static BigDecimal sum(List> rows,String column){return rows.stream().map(r->net(r.get(column))).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue));} List> headquarters(LegacyUser u){u.require(26);var result=new ArrayList>(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>>();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 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 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());} } diff --git a/backend/src/main/java/de/stadtbote/votian/CashflowDocuments.java b/backend/src/main/java/de/stadtbote/votian/CashflowDocuments.java index d645d75..7f4aa00 100644 --- a/backend/src/main/java/de/stadtbote/votian/CashflowDocuments.java +++ b/backend/src/main/java/de/stadtbote/votian/CashflowDocuments.java @@ -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 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 pdf(Authentication a,@RequestBody Request r){return response(pdf(snapshot(ApiController.user(a),r)),"application/pdf","Kasse.pdf");} - static ResponseEntity 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 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 row,String key){var list=(List>)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 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();} diff --git a/backend/src/main/java/de/stadtbote/votian/CashflowMail.java b/backend/src/main/java/de/stadtbote/votian/CashflowMail.java index 6636fd9..77522ea 100644 --- a/backend/src/main/java/de/stadtbote/votian/CashflowMail.java +++ b/backend/src/main/java/de/stadtbote/votian/CashflowMail.java @@ -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(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_bookingtime0 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(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_bookingtime0 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 options(Authentication a,@RequestBody Cashflow.Query q){return options(ApiController.user(a),q);} diff --git a/backend/src/main/java/de/stadtbote/votian/ContactService.java b/backend/src/main/java/de/stadtbote/votian/ContactService.java index fc691c9..d15b781 100644 --- a/backend/src/main/java/de/stadtbote/votian/ContactService.java +++ b/backend/src/main/java/de/stadtbote/votian/ContactService.java @@ -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>();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(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>)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>)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>)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> fields){return String.join(" ",fields.stream().filter(f->Set.of("Name","Vorname").contains(text(f,"label"))).map(f->text(f,"value")).toList());} Map 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();var before=cid==null?List.>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> 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(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")));} diff --git a/backend/src/main/java/de/stadtbote/votian/ContactVcard.java b/backend/src/main/java/de/stadtbote/votian/ContactVcard.java index f631d25..2a705dc 100644 --- a/backend/src/main/java/de/stadtbote/votian/ContactVcard.java +++ b/backend/src/main/java/de/stadtbote/votian/ContactVcard.java @@ -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 fields,String... names){return fields.stream().filter(f->Arrays.asList(names).contains(label(f.label()))).map(Field::value).findFirst().orElse("");} + static String named(List 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 fields,List indexes,int position){return position)detail.get("schema");var defs=(List>)schema.get("fields");var values=(Map)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(); 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()); } diff --git a/backend/src/main/java/de/stadtbote/votian/CourierBourse.java b/backend/src/main/java/de/stadtbote/votian/CourierBourse.java index f8c8ba7..f12d5d3 100644 --- a/backend/src/main/java/de/stadtbote/votian/CourierBourse.java +++ b/backend/src/main/java/de/stadtbote/votian/CourierBourse.java @@ -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")); diff --git a/backend/src/main/java/de/stadtbote/votian/CourierDisposition.java b/backend/src/main/java/de/stadtbote/votian/CourierDisposition.java index cdad39d..96eec73 100644 --- a/backend/src/main/java/de/stadtbote/votian/CourierDisposition.java +++ b/backend/src/main/java/de/stadtbote/votian/CourierDisposition.java @@ -52,7 +52,7 @@ class CourierDisposition { String warning="";List 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.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(); 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"))){ diff --git a/backend/src/main/java/de/stadtbote/votian/CourierDispositionReplanning.java b/backend/src/main/java/de/stadtbote/votian/CourierDispositionReplanning.java index bbb053a..d6de4e1 100644 --- a/backend/src/main/java/de/stadtbote/votian/CourierDispositionReplanning.java +++ b/backend/src/main/java/de/stadtbote/votian/CourierDispositionReplanning.java @@ -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(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(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();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();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>>(); - 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(); 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 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(()->{ diff --git a/backend/src/main/java/de/stadtbote/votian/CourierDispositionRollcard.java b/backend/src/main/java/de/stadtbote/votian/CourierDispositionRollcard.java index 343f58c..6ca4678 100644 --- a/backend/src/main/java/de/stadtbote/votian/CourierDispositionRollcard.java +++ b/backend/src/main/java/de/stadtbote/votian/CourierDispositionRollcard.java @@ -18,7 +18,7 @@ class CourierDispositionRollcard { @GetMapping Map 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 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 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."); diff --git a/backend/src/main/java/de/stadtbote/votian/CourierPortal.java b/backend/src/main/java/de/stadtbote/votian/CourierPortal.java index 4bed807..7ccc7bb 100644 --- a/backend/src/main/java/de/stadtbote/votian/CourierPortal.java +++ b/backend/src/main/java/de/stadtbote/votian/CourierPortal.java @@ -51,7 +51,7 @@ class CourierPortal { } Map query(LegacyUser u,Query q){return db.readTransaction(()->{ var s=scope(u);check(q,s);var jobs=jobs(s,q);var result=new ArrayList>();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()))); diff --git a/backend/src/main/java/de/stadtbote/votian/CourierPortalHistory.java b/backend/src/main/java/de/stadtbote/votian/CourierPortalHistory.java index 633710b..927f487 100644 --- a/backend/src/main/java/de/stadtbote/votian/CourierPortalHistory.java +++ b/backend/src/main/java/de/stadtbote/votian/CourierPortalHistory.java @@ -26,7 +26,7 @@ class CourierPortalHistory { String clean=raw.replace("","").replace("","");var lines=new ArrayList(); 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); } } diff --git a/backend/src/main/java/de/stadtbote/votian/CourierPortalScope.java b/backend/src/main/java/de/stadtbote/votian/CourierPortalScope.java index ff8acc9..17555bd 100644 --- a/backend/src/main/java/de/stadtbote/votian/CourierPortalScope.java +++ b/backend/src/main/java/de/stadtbote/votian/CourierPortalScope.java @@ -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 { diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerCancellation.java b/backend/src/main/java/de/stadtbote/votian/CustomerCancellation.java index a560808..543ab10 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerCancellation.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCancellation.java @@ -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();portal.orderCustomers(u,scope).forEach(cs->centers.addAll(cs.allowed()));var args=new ArrayList(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); diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerCopy.java b/backend/src/main/java/de/stadtbote/votian/CustomerCopy.java index 618f632..af976ac 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerCopy.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCopy.java @@ -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)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 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"; diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerCostCenters.java b/backend/src/main/java/de/stadtbote/votian/CustomerCostCenters.java index 06c2676..350d202 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerCostCenters.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCostCenters.java @@ -99,7 +99,7 @@ class CustomerCostCenters { var refs="DELETE".equals(c.kind())?references():List.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()){ diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerCourierRelations.java b/backend/src/main/java/de/stadtbote/votian/CustomerCourierRelations.java index 45682dd..043cbd2 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerCourierRelations.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCourierRelations.java @@ -35,7 +35,7 @@ class CustomerCourierRelations { @PostMapping("/operations/{token}") Map 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 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 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> 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> 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(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();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>();for(var t:c.targets()){var r=customers.computeIfAbsent(t.customer(),this::rows);unchanged(r,t.revision());} + validate(u,c);var customers=new TreeMap>();for(var t:c.targets()){var r=customers.computeIfAbsent(t.customer(),entryValue -> this.rows(entryValue));unchanged(r,t.revision());} var after=new TreeMap>();customers.forEach((k,v)->after.put(k,new ArrayList<>(v)));var commissions=new ArrayList();var descriptions=new ArrayList(); 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();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();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 sorted(List rows){return new ArrayList<>(rows.stream().filter(r->r.relation()==1).sorted(Comparator.comparingInt(Row::position).thenComparingLong(Row::courier)).toList());} + static List sorted(List 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 all,List ordered){for(int k=0;k0)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 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());});} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerCreationDefaults.java b/backend/src/main/java/de/stadtbote/votian/CustomerCreationDefaults.java index c7fe825..eec5904 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerCreationDefaults.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCreationDefaults.java @@ -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."); diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerEntrySettings.java b/backend/src/main/java/de/stadtbote/votian/CustomerEntrySettings.java index ba22403..a34fd83 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerEntrySettings.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerEntrySettings.java @@ -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();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"); diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerEntryTours.java b/backend/src/main/java/de/stadtbote/votian/CustomerEntryTours.java index 092c307..a0ace60 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerEntryTours.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerEntryTours.java @@ -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.");} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerExportFiles.java b/backend/src/main/java/de/stadtbote/votian/CustomerExportFiles.java index 9575183..f947ad6 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerExportFiles.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerExportFiles.java @@ -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 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. diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerExportFormat.java b/backend/src/main/java/de/stadtbote/votian/CustomerExportFormat.java index 476e3ab..7f89bed 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerExportFormat.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerExportFormat.java @@ -41,7 +41,7 @@ final class CustomerExportFormat { } static String render(Settings s,List fields,List> 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();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();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();for(var r:rules){var f=lookup.get(r.field());String v=wipe(row.get(f.key())); diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerInvoiceAddress.java b/backend/src/main/java/de/stadtbote/votian/CustomerInvoiceAddress.java index e8e706c..fa0492d 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerInvoiceAddress.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerInvoiceAddress.java @@ -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. */ diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderDocument.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderDocument.java index 46b007b..8fcb0d6 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerOrderDocument.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderDocument.java @@ -13,7 +13,7 @@ final class CustomerOrderDocument { for(Object warning:(List)evidence.getOrDefault("warnings",List.of()))out.append("

").append(escape(warning)).append("

");out.append("

Leistungen

");for(var s:rows(evidence,"services"))out.append("");out.append("
KostenstelleLeistungBetrag ").append(escape(evidence.get("serviceBasis"))).append("
").append(escape(s.get("center"))).append("").append(escape(s.get("name"))).append("").append(escape(pending?"Preis in Prüfung":money(decimal(s,"price")))).append("

Leistungssumme: ").append(escape(pending?"Preis in Prüfung":money(decimal(evidence,"serviceTotal")))).append("

"); if(evidence.get("toll")!=null)out.append("

Maut: ").append(escape(money(decimal(evidence,"toll")))).append(" · Gesamt einschließlich Maut: ").append(escape(pending?"Preis in Prüfung":money(decimal(evidence,"totalWithToll")))).append("

"); var stations=rows(evidence,"stations");var stops=rows(detail,"stops");for(int i=0;iStation").append(Boolean.TRUE.equals(evidence.get("stationNumbers"))?" "+id(s,"sequence"):"").append("").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");for(Object w:(List)e.get("warnings"))out.append(row("Hinweis",Objects.toString(w,"")));}out.append("
Unterschrift
");if(i a)articleTable(out,(Map)a);} + if(iUnterschrift");for(Object w:(List)e.get("warnings"))out.append(row("Hinweis",Objects.toString(w,"")));}out.append("");if(i a)articleTable(out,TypedValues.map(a, String.class, Object.class));} if(!text(detail,"invoiceText").isBlank())out.append("

Rechnungstext

").append(escape(text(detail,"invoiceText")).replace("\n","
")).append("

");return out.append("").toString(); } @SuppressWarnings("unchecked") static void articleTable(StringBuilder out,Map table){ diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderEdit.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderEdit.java index 852b72e..a5bcbc9 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerOrderEdit.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderEdit.java @@ -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 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 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 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 centers(CustomerPortalScope.Context c){var ids=new TreeSet();c.customers().values().forEach(cs->ids.addAll(cs.allowed()));return ids;} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderHistory.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderHistory.java index b85caa0..3ec2971 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerOrderHistory.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderHistory.java @@ -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.");} });} } diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderLabels.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderLabels.java index 0937e89..6db0b87 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerOrderLabels.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderLabels.java @@ -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 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 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> options(LegacyUser u,long order){return db.readTransaction(()->{portal.detail(u,new CustomerPortal.Detail(order));return authorizedOptions(u);});} - List> authorizedOptions(LegacyUser u){var out=new ArrayList>();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> authorizedOptions(LegacyUser u){var out=new ArrayList>();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 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();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()); diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderPhotos.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderPhotos.java index 9c8f1a7..b144969 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerOrderPhotos.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderPhotos.java @@ -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. */ diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderRoute.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderRoute.java index 096058d..8c5c9c4 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerOrderRoute.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderRoute.java @@ -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 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 unavailable(String message){return fields("enabled",true,"available",false,"message",message);} } diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderTexts.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderTexts.java index 77ffb0f..3d07e1f 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerOrderTexts.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderTexts.java @@ -77,7 +77,7 @@ class CustomerOrderTexts { var p=prepare(s,c);for(int n=0;ntext(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); }); } } diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerPhotoSettings.java b/backend/src/main/java/de/stadtbote/votian/CustomerPhotoSettings.java index 9cc0bae..7751ba1 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerPhotoSettings.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerPhotoSettings.java @@ -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))); } diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerPortal.java b/backend/src/main/java/de/stadtbote/votian/CustomerPortal.java index 5e15fd1..3520911 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerPortal.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerPortal.java @@ -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 parts(String value){return Arrays.stream(value.split(",",-1)).map(String::strip).toList();} + static List parts(String value){return Arrays.stream(value.split(",",-1)).map(entryValue -> entryValue.strip()).toList();} Map 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);} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerReferences.java b/backend/src/main/java/de/stadtbote/votian/CustomerReferences.java index a7daa9d..5aa2af4 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerReferences.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerReferences.java @@ -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(List.of(hq)); var terms=new ArrayList(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); diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerServiceDocuments.java b/backend/src/main/java/de/stadtbote/votian/CustomerServiceDocuments.java index c733e23..c70efb8 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerServiceDocuments.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerServiceDocuments.java @@ -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()); diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerServices.java b/backend/src/main/java/de/stadtbote/votian/CustomerServices.java index 34c8339..7750c91 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerServices.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerServices.java @@ -24,8 +24,8 @@ class CustomerServices { private List 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> choices(LegacyUser u,boolean types){String table=types?"servicetype":"service",prefix=types?"srvt":"srv";var result=new ArrayList>();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> 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 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 requested,List before,List> 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 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 requested,List before,List> 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 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)); diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerStammblatt.java b/backend/src/main/java/de/stadtbote/votian/CustomerStammblatt.java index b53b9e7..050f19c 100644 --- a/backend/src/main/java/de/stadtbote/votian/CustomerStammblatt.java +++ b/backend/src/main/java/de/stadtbote/votian/CustomerStammblatt.java @@ -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 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 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."); diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerWorkflowManagement.java b/backend/src/main/java/de/stadtbote/votian/CustomerWorkflowManagement.java new file mode 100644 index 0000000..cf7fa8f --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerWorkflowManagement.java @@ -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 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 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);} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/DispositionJobCards.java b/backend/src/main/java/de/stadtbote/votian/DispositionJobCards.java index e019c64..05d93d8 100644 --- a/backend/src/main/java/de/stadtbote/votian/DispositionJobCards.java +++ b/backend/src/main/java/de/stadtbote/votian/DispositionJobCards.java @@ -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 { diff --git a/backend/src/main/java/de/stadtbote/votian/DocumentStore.java b/backend/src/main/java/de/stadtbote/votian/DocumentStore.java index 5c679cf..71eaeea 100644 --- a/backend/src/main/java/de/stadtbote/votian/DocumentStore.java +++ b/backend/src/main/java/de/stadtbote/votian/DocumentStore.java @@ -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; diff --git a/backend/src/main/java/de/stadtbote/votian/EmployeeCostCenterColumns.java b/backend/src/main/java/de/stadtbote/votian/EmployeeCostCenterColumns.java index 6dfca98..895e3d8 100644 --- a/backend/src/main/java/de/stadtbote/votian/EmployeeCostCenterColumns.java +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeCostCenterColumns.java @@ -30,7 +30,7 @@ class EmployeeCostCenterColumns { static boolean writable(List> 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> 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> 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();boolean invalid=keys.length>21; for(int i=0;i50){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 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); });} diff --git a/backend/src/main/java/de/stadtbote/votian/EmployeeHistory.java b/backend/src/main/java/de/stadtbote/votian/EmployeeHistory.java index 0e023d3..252025e 100644 --- a/backend/src/main/java/de/stadtbote/votian/EmployeeHistory.java +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeHistory.java @@ -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();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();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();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.");} } diff --git a/backend/src/main/java/de/stadtbote/votian/EmployeeParallelLists.java b/backend/src/main/java/de/stadtbote/votian/EmployeeParallelLists.java index 86d637b..b91ec98 100644 --- a/backend/src/main/java/de/stadtbote/votian/EmployeeParallelLists.java +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeParallelLists.java @@ -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); }); diff --git a/backend/src/main/java/de/stadtbote/votian/EmployeeStockRights.java b/backend/src/main/java/de/stadtbote/votian/EmployeeStockRights.java index 6f75a8a..f72b092 100644 --- a/backend/src/main/java/de/stadtbote/votian/EmployeeStockRights.java +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeStockRights.java @@ -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(); diff --git a/backend/src/main/java/de/stadtbote/votian/EvaluationService.java b/backend/src/main/java/de/stadtbote/votian/EvaluationService.java index 72157cf..0c99e56 100644 --- a/backend/src/main/java/de/stadtbote/votian/EvaluationService.java +++ b/backend/src/main/java/de/stadtbote/votian/EvaluationService.java @@ -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> defs,Map 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 rendering(LegacyUser u,long hq,long object){var result=super.rendering(u,hq,object);var data=new LinkedHashMap<>((Map)result.get("data"));var values=new LinkedHashMap<>((Map)data.get("values"));var defs=(List>)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 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; } } diff --git a/backend/src/main/java/de/stadtbote/votian/ExtrasPortalSettings.java b/backend/src/main/java/de/stadtbote/votian/ExtrasPortalSettings.java index 0496288..e9c8f3b 100644 --- a/backend/src/main/java/de/stadtbote/votian/ExtrasPortalSettings.java +++ b/backend/src/main/java/de/stadtbote/votian/ExtrasPortalSettings.java @@ -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 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> 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());} diff --git a/backend/src/main/java/de/stadtbote/votian/ExtrasPzmZones.java b/backend/src/main/java/de/stadtbote/votian/ExtrasPzmZones.java index d2fa18a..5e32ebd 100644 --- a/backend/src/main/java/de/stadtbote/votian/ExtrasPzmZones.java +++ b/backend/src/main/java/de/stadtbote/votian/ExtrasPzmZones.java @@ -37,7 +37,7 @@ class ExtrasPzmZones { static List decode(JsonNode data,Map names){ if(data==null||!data.isArray()||data.size()>500)throw unavailable("Der PZM-Dienst hat keine gültige Zonenliste geliefert.");var result=new ArrayList();var ids=new HashSet(); 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 zones(String endpoint,long hq){return decode(client.get(endpoint,"Der PZM-Zonendienst"),labels(hq));} static String revisionFor(long hq,String endpoint,List 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 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 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 save(LegacyUser u,Command c){ @@ -64,7 +64,7 @@ class ExtrasPzmZones { Map 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>();for(int i=0;i 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(defs.stream().map(Field::key).toList()); + var defs=definitions(type);var args=new ArrayList(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();var warnings=new ArrayList();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();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."); diff --git a/backend/src/main/java/de/stadtbote/votian/ExtrasStatistics.java b/backend/src/main/java/de/stadtbote/votian/ExtrasStatistics.java index aa6460d..7f99cd6 100644 --- a/backend/src/main/java/de/stadtbote/votian/ExtrasStatistics.java +++ b/backend/src/main/java/de/stadtbote/votian/ExtrasStatistics.java @@ -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 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 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);} } diff --git a/backend/src/main/java/de/stadtbote/votian/FilterAdministration.java b/backend/src/main/java/de/stadtbote/votian/FilterAdministration.java index e5b9484..a52ce2a 100644 --- a/backend/src/main/java/de/stadtbote/votian/FilterAdministration.java +++ b/backend/src/main/java/de/stadtbote/votian/FilterAdministration.java @@ -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; diff --git a/backend/src/main/java/de/stadtbote/votian/FleetMapContext.java b/backend/src/main/java/de/stadtbote/votian/FleetMapContext.java index d1faf21..918e8d3 100644 --- a/backend/src/main/java/de/stadtbote/votian/FleetMapContext.java +++ b/backend/src/main/java/de/stadtbote/votian/FleetMapContext.java @@ -36,7 +36,7 @@ public class FleetMapContext { Map ranking=reason.isEmpty()?candidates.list(u,job):Map.of(); var own=new HashSet();db.queryForList("SELECT cr_id FROM courier WHERE hq_id=?",u.headquartersId()).forEach(r->own.add(id(r,"cr_id"))); var items=new ArrayList>();int rank=0; - for(var value:(List>)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); } diff --git a/backend/src/main/java/de/stadtbote/votian/GroupDeletion.java b/backend/src/main/java/de/stadtbote/votian/GroupDeletion.java index be63114..2308a1c 100644 --- a/backend/src/main/java/de/stadtbote/votian/GroupDeletion.java +++ b/backend/src/main/java/de/stadtbote/votian/GroupDeletion.java @@ -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();p.members().forEach(m->counts.merge(m.kind(),1,Integer::sum)); + var counts=new LinkedHashMap();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()); }); } diff --git a/backend/src/main/java/de/stadtbote/votian/HeadquartersSettings.java b/backend/src/main/java/de/stadtbote/votian/HeadquartersSettings.java index 2cfb7ee..2d9376f 100644 --- a/backend/src/main/java/de/stadtbote/votian/HeadquartersSettings.java +++ b/backend/src/main/java/de/stadtbote/votian/HeadquartersSettings.java @@ -94,7 +94,7 @@ class HeadquartersSettings { });} Map 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();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){ diff --git a/backend/src/main/java/de/stadtbote/votian/HistoricalOrders.java b/backend/src/main/java/de/stadtbote/votian/HistoricalOrders.java index 311c2b2..be53c80 100644 --- a/backend/src/main/java/de/stadtbote/votian/HistoricalOrders.java +++ b/backend/src/main/java/de/stadtbote/votian/HistoricalOrders.java @@ -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") diff --git a/backend/src/main/java/de/stadtbote/votian/HistoricalStatistics.java b/backend/src/main/java/de/stadtbote/votian/HistoricalStatistics.java index 30bcc0e..b084010 100644 --- a/backend/src/main/java/de/stadtbote/votian/HistoricalStatistics.java +++ b/backend/src/main/java/de/stadtbote/votian/HistoricalStatistics.java @@ -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 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(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); diff --git a/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsGroups.java b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsGroups.java index e6d747a..7f7f662 100644 --- a/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsGroups.java +++ b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsGroups.java @@ -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> members,boolean children,List 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>)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>(); diff --git a/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsMasters.java b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsMasters.java index f59b1ae..3c59fc3 100644 --- a/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsMasters.java +++ b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsMasters.java @@ -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>(); diff --git a/backend/src/main/java/de/stadtbote/votian/InvoiceCsvExport.java b/backend/src/main/java/de/stadtbote/votian/InvoiceCsvExport.java index 55810b1..0a9f6be 100644 --- a/backend/src/main/java/de/stadtbote/votian/InvoiceCsvExport.java +++ b/backend/src/main/java/de/stadtbote/votian/InvoiceCsvExport.java @@ -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 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 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 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> 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> 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;i0)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();} diff --git a/backend/src/main/java/de/stadtbote/votian/InvoiceDocuments.java b/backend/src/main/java/de/stadtbote/votian/InvoiceDocuments.java index a0ad01c..c8f622c 100644 --- a/backend/src/main/java/de/stadtbote/votian/InvoiceDocuments.java +++ b/backend/src/main/java/de/stadtbote/votian/InvoiceDocuments.java @@ -29,7 +29,7 @@ class InvoiceDocuments { record Part(long id,String html,Map summary){} record Snapshot(String revision,List parts,List warnings){} @PostMapping("/preview") Object preview(Authentication a,@RequestBody Request r){return preview(ApiController.user(a),r);} - @PostMapping("/document.pdf") ResponseEntity 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 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 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 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);}} } diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronBusiness.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronBusiness.java index 9b4114a..117b9da 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyCronBusiness.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronBusiness.java @@ -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("

HHA :: Vermeintlich vergessene Safebags

"); - for(var r:rows){body.append("");for(String k:List.of("csc_name","atih_serialno","atih_data_04","atih_data_05"))body.append("");body.append("");}body.append("
VerkaufsstelleSafebagDatumBetragMitarbeiter
").append(LegacyCronCompliance.html(text(r,k))).append("").append(LegacyCronCompliance.html(text(r,"usr_name")+", "+text(r,"usr_firstname"))).append("
"); - String from=new OrderEntryCatalog(db).parameter(1,"MAIL_SENDER_ADDRESS");if(from.isBlank())from=mail.from;from=AppointmentNotifications.address(from); - try{mail.sendOrderHtml(from,recipients,List.of(),hidden,"HHA :: Vermeintlich vergessene Safebags",body.toString());} - catch(RuntimeException e){return new LegacyCronRunner.Result("UNCERTAIN","SMTP-Übergabe des Safebag-Berichts nicht bestätigt.",rows.size());} - return LegacyCronRunner.Result.ok(rows.size()); - } LegacyCronRunner.Result mysqlStatus(){ var metrics=new TreeMap(); for(var row:db.globalStatus()){String key=text(row,"Variable_name");if(Set.of("Bytes_received","Bytes_sent","Com_select","Qcache_hits","Questions","Slow_queries","Threads_connected","Uptime").contains(key))metrics.put(key,text(row,"Value"));} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronCompliance.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronCompliance.java index 7ca1548..2075191 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyCronCompliance.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronCompliance.java @@ -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 { diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronFiles.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronFiles.java deleted file mode 100644 index 4090de5..0000000 --- a/backend/src/main/java/de/stadtbote/votian/LegacyCronFiles.java +++ /dev/null @@ -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 paths(String value) throws IOException { - var result=new ArrayList(); - for(String raw:value.split(",")){if(raw.isBlank())continue;Path p=Path.of(raw.strip()).toAbsolutePath().normalize(); - if(p.getNameCount()<3||!Files.isDirectory(p,NOFOLLOW_LINKS)||!p.equals(p.toRealPath()))throw bad("Cron-Dateipfad muss ein eigenes vorhandenes Verzeichnis ohne symbolische Links sein."); - result.add(p); - }return result; - } - static boolean same(BasicFileAttributes a,BasicFileAttributes b){return a.size()==b.size()&&a.lastModifiedTime().equals(b.lastModifiedTime())&&Objects.equals(a.fileKey(),b.fileKey());} - static String digest(InputStream in) throws IOException {try(in){var md=java.security.MessageDigest.getInstance("SHA-256");byte[] b=new byte[65536];int n;while((n=in.read(b))!=-1)md.update(b,0,n);return HexFormat.of().formatHex(md.digest());}catch(java.security.NoSuchAlgorithmException e){throw new IllegalStateException(e);}} - static void compress(Path source) throws IOException { - var before=Files.readAttributes(source,BasicFileAttributes.class,NOFOLLOW_LINKS);if(!before.isRegularFile())return; - Path target=source.resolveSibling(source.getFileName()+".bz2"); - if(Files.exists(target,NOFOLLOW_LINKS)){ - if(!Files.isRegularFile(target,NOFOLLOW_LINKS))throw bad("Cron-Archivziel ist keine reguläre Datei."); - String original=digest(Files.newInputStream(source,NOFOLLOW_LINKS)); - String archived=digest(new BZip2CompressorInputStream(Files.newInputStream(target,NOFOLLOW_LINKS))); - if(!original.equals(archived))throw bad("Vorhandenes Cron-Archiv passt nicht zur Quelldatei; kein Überschreiben."); - }else{ - Path temp=Files.createTempFile(source.getParent(),".ng-cron-",".part"); - try{ - try(var input=Files.newInputStream(source,NOFOLLOW_LINKS);var output=new BZip2CompressorOutputStream(Files.newOutputStream(temp),9)){input.transferTo(output);} - try(var channel=FileChannel.open(temp,StandardOpenOption.WRITE)){channel.force(true);} - if(!same(before,Files.readAttributes(source,BasicFileAttributes.class,NOFOLLOW_LINKS)))throw bad("Quelldatei wurde während der Kompression geändert."); - // Atomic publication without overwrite; the temporary file is on the same filesystem. - Files.createLink(target,temp); - }finally{Files.deleteIfExists(temp);} - } - if(!same(before,Files.readAttributes(source,BasicFileAttributes.class,NOFOLLOW_LINKS)))throw bad("Quelldatei wurde während der Kompression geändert."); - Files.delete(source); - } - LegacyCronRunner.Result run(LocalDate today){ - try{ - var bin=paths(env.getProperty("app.legacy-cron.files.binlog-directories","")); - var logs=paths(env.getProperty("app.legacy-cron.files.log-directories","")); - var purge=paths(env.getProperty("app.legacy-cron.files.temporary-directories","")); - if(bin.isEmpty()&&logs.isEmpty()&&purge.isEmpty())throw bad("Keine Altdatei-Verzeichnisse für die Cron-Wartung konfiguriert."); - var all=new ArrayList();all.addAll(bin);all.addAll(logs);all.addAll(purge); - for(int i=0;i candidates;try(var stream=Files.list(directory)){candidates=stream.filter(p->Files.isRegularFile(p,NOFOLLOW_LINKS)&&p.getFileName().toString().matches("mps2-bin\\.0[0-9]+" )).toList();} - long newest=Long.MIN_VALUE;for(var p:candidates)newest=Math.max(newest,Files.getLastModifiedTime(p,NOFOLLOW_LINKS).toMillis()); - for(var p:candidates)if(Files.getLastModifiedTime(p,NOFOLLOW_LINKS).toMillis() candidates;try(var stream=Files.list(directory)){candidates=stream.filter(p->Files.isRegularFile(p,NOFOLLOW_LINKS)&&p.getFileName().toString().contains(month)&&p.getFileName().toString().endsWith(".log")).toList();}for(var p:candidates){compress(p);count++;}} - // PHP deletes all regular files recursively in these three temporary trees; links stay untouched. - for(Path directory:purge)try(var stream=Files.walk(directory)){for(var p:stream.filter(p->Files.isRegularFile(p,NOFOLLOW_LINKS)).toList()){Files.delete(p);count++;}} - return LegacyCronRunner.Result.ok(count); - }catch(IOException e){return new LegacyCronRunner.Result("UNCERTAIN","Dateiwartung unterbrochen; Quelldateien und Archive vor Freigabe prüfen.",0);} - } -} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronReplication.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronReplication.java index 2ba8512..a7046bb 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyCronReplication.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronReplication.java @@ -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 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> targets(){ try{ var groups=support.json.readValue(env.getProperty("app.legacy-cron.replication.targets","{}"),new com.fasterxml.jackson.core.type.TypeReference>>(){}); - if(groups==null||groups.isEmpty()||!GROUPS.keySet().containsAll(groups.keySet()))throw new IllegalArgumentException(); + 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();int count=0; - for(var entry:new TreeMap<>(targets).entrySet()){ - var group=GROUPS.get(entry.getKey());Long primary=null; - for(int i=0;igroup.tolerance())failures.add(entry.getKey()+" Replikat "+i+": "+(primary-value)+" Einträge Rückstand"); - }catch(RuntimeException e){failures.add(entry.getKey()+" "+(i==0?"Primärserver":"Replikat "+i)+": nicht erreichbar");} - } + var nodes=targets.get("STB");Long primary=null; + for(int i=0;iTOLERANCE)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); diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronSoap.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronSoap.java deleted file mode 100644 index 0989c50..0000000 --- a/backend/src/main/java/de/stadtbote/votian/LegacyCronSoap.java +++ /dev/null @@ -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 limitedBody(){ - return new HttpResponse.BodySubscriber<>(){ - final HttpResponse.BodySubscriber delegate=HttpResponse.BodySubscribers.ofByteArray(); - java.util.concurrent.Flow.Subscription subscription;long bytes;boolean failed; - public CompletionStage getBody(){return delegate.getBody();} - public void onSubscribe(java.util.concurrent.Flow.Subscription value){subscription=value;delegate.onSubscribe(value);} - public void onNext(List buffers){ - if(failed)return; - for(var buffer:buffers)bytes+=buffer.remaining(); - if(bytes>MAX_BYTES){failed=true;subscription.cancel();delegate.onError(new IOException("PTnova response exceeds limit"));return;} - delegate.onNext(buffers); - } - public void onError(Throwable error){if(!failed){failed=true;delegate.onError(error);}} - public void onComplete(){if(!failed)delegate.onComplete();} - }; - } - - String checkpoint(long hq,int mode){ - var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='hq' AND gdc_obj_id=? AND gdc_gen_fieldname='pt_nova' AND gdc_context=?",hq,Integer.toString(mode)); - if(rows.size()>1)throw bad("PTnova-Lesemarke ist mehrfach vorhanden.");return rows.isEmpty()?"":text(rows.getFirst(),"gdc_content"); - } - LegacyCronRunner.Result run(int mode){ - var result=db.cronOperation("hha-soap-import",()->{ - long hq=positive("headquarters",1),container=positive("container-stock",1000); - if(db.queryForList("SELECT hq_id FROM headquarters WHERE hq_id=? AND hq_disabled=0",hq).size()!=1)throw bad("PTnova-Niederlassung fehlt oder ist gesperrt."); - var started=LocalDateTime.now(clock).withNano(0);String before=checkpoint(hq,mode),since=since(before,started); - var payload=parse(fetch(mode,since),mode); - return apply(mode,hq,container,payload,before,started); - }); - return result==null?new LegacyCronRunner.Result("BLOCKED","Ein anderer PTnova-Import läuft bereits.",0):result; - } - LegacyCronRunner.Result apply(int mode,long hq,long container,Payload payload,String before,LocalDateTime started){ - // No network access while holding table locks. MyISAM partial effects must remain UNCERTAIN. - try{return db.cronSoapWriteTransaction(support.historySchema.replace("`",""),()->{ - if(!checkpoint(hq,mode).equals(before))throw bad("PTnova-Lesemarke wurde zwischenzeitlich verändert."); - var store=new LegacyCronSoapStore(db,support,hq,container,started); - int count=mode==1?store.master(payload):store.status(payload); - if(before.isEmpty()&&db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='hq' AND gdc_obj_id=? AND gdc_gen_fieldname='pt_nova' AND gdc_context=?",hq,Integer.toString(mode)).isEmpty()) - db.insert("genericdatacontainer",fields("gdc_obj_type","hq","gdc_obj_id",hq,"gdc_gen_fieldname","pt_nova","gdc_context",Integer.toString(mode),"gdc_content",started.format(STAMP))); - else db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_obj_type='hq' AND gdc_obj_id=? AND gdc_gen_fieldname='pt_nova' AND gdc_context=?",started.format(STAMP),hq,Integer.toString(mode)); - return new LegacyCronRunner.Result("OK","PTnova importiert: "+count+"; nicht zuordenbar/übersprungen: "+store.skipped+".",count); - });}catch(RuntimeException e){throw new IllegalStateException("PTnova-Import unterbrochen; mögliche Teilwirkungen vor erneutem Lauf prüfen.",e);} - } -} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapProtocol.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapProtocol.java deleted file mode 100644 index 7274a2f..0000000 --- a/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapProtocol.java +++ /dev/null @@ -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 TYPES=List.of("HUW","H&W","H&WB","HWB","STU","STH","STB","CAGU","STVS"); - static final DateTimeFormatter STAMP=DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withResolverStyle(ResolverStyle.STRICT); - record Device(String type,String serial) {} - record Message(String type,String code,String description,String text) {} - record Notice(String code,String description) {} - record Status(String type,String serial,LocalDate date,List notices,Map money,BigDecimal cards) {} - record Payload(List devices,List messages,List statuses) {} - - static String operation(int mode){if(mode!=0&&mode!=1)throw bad("Ungültiger SOAP-Modus.");return mode==1?"ZPtnWstpMasterdata":"ZPtnWstpStatusdata";} - static String action(int mode){return SAP+":ZPTN_WS_TOURENPLANUNG:"+operation(mode)+"Request";} - static LocalDateTime timestamp(String value){try{return LocalDateTime.parse(value,STAMP);}catch(RuntimeException e){throw bad("Ungültiger PTnova-Zeitstempel.");}} - static String since(String checkpoint,LocalDateTime now){ - var fallback=now.toLocalDate().minusDays(1).atStartOfDay(); - if(checkpoint!=null&&!checkpoint.isBlank()){ - var saved=timestamp(checkpoint); - if(saved.isAfter(now))throw bad("PTnova-Lesemarke liegt in der Zukunft."); - if(saved.isAfter(fallback))return checkpoint; - } - return fallback.format(STAMP); - } - static String request(int mode,String since){ - timestamp(since); - var xml=new StringBuilder(""); - for(String type:TYPES)xml.append("HHA").append(type.replace("&","&")).append(""); - return xml.append("").append(mode==0?"X":"").append("").append(since).append("").toString(); - } - static boolean allowed(String organization,String type){return Set.of("HHA","HDAG").contains(organization)&&(TYPES.contains(type)||type.equals("H+W"));} - static String serial(String value){return value.strip().replaceFirst("^0+","");} - static Integer moneyField(String type,String position){ - if(position.isEmpty())return null; - if(type.equals("BEK"))return 5;if(type.equals("MEK"))return 6; - if(type.equals("ZGS")&&Set.of("0","1","2").contains(position))return 7+Integer.parseInt(position); - if(type.equals("RGS")&&Set.of("10","11","12","13","14","15").contains(position))return Integer.parseInt(position); - return null; - } - static BigDecimal amount(String value){try{var n=new BigDecimal(value.replace(',','.'));if(n.precision()>24||n.scale()>8)throw new IllegalArgumentException();return n;}catch(RuntimeException e){throw bad("Ungültiger PTnova-Bestand.");}} - static List children(Element parent,String name){ - var result=new ArrayList(); - for(Node n=parent.getFirstChild();n!=null;n=n.getNextSibling())if(n instanceof Element e&&name.equals(e.getLocalName()))result.add(e); - return result; - } - static Element child(Element parent,String name,boolean required){ - var matches=children(parent,name);if(matches.size()>1||required&&matches.isEmpty())throw bad("Unvollständige oder mehrdeutige PTnova-Antwort: "+name); - return matches.isEmpty()?null:matches.getFirst(); - } - static String text(Element parent,String name){var e=child(parent,name,false);return e==null?"":e.getTextContent().strip();} - static List items(Element parent,String name){var e=child(parent,name,false);return e==null?List.of():children(e,"item");} - static Payload parse(byte[] bytes,int mode){ - if(bytes.length>32*1024*1024)throw bad("PTnova-Antwort ist zu groß."); - try{ - var factory=DocumentBuilderFactory.newInstance();factory.setNamespaceAware(true); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl",true); - factory.setFeature("http://xml.org/sax/features/external-general-entities",false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities",false); - factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD,"");factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA,""); - factory.setXIncludeAware(false);factory.setExpandEntityReferences(false); - var builder=factory.newDocumentBuilder();builder.setErrorHandler(new org.xml.sax.helpers.DefaultHandler(){@Override public void fatalError(org.xml.sax.SAXParseException e)throws org.xml.sax.SAXException{throw e;}}); - var root=builder.parse(new ByteArrayInputStream(bytes)).getDocumentElement(); - if(!"Envelope".equals(root.getLocalName())||!SOAP.equals(root.getNamespaceURI()))throw bad("Keine gültige SOAP-1.1-Antwort."); - var body=child(root,"Body",true);if(child(body,"Fault",false)!=null)throw bad("SAP hat einen SOAP-Fehler gemeldet. Lesemarke bleibt unverändert."); - var response=child(body,operation(mode)+"Response",true); - if(!SAP.equals(response.getNamespaceURI()))throw bad("Unerwarteter SAP-Namensraum."); - var devices=new ArrayList();var messages=new ArrayList();var statuses=new ArrayList(); - if(mode==1){ - var data=child(response,"EsMasterdata",true);child(data,"Devicetypes",true); - for(var type:items(data,"Devicetypes")){ - String kind=text(type,"DevtyhdId");if(!allowed(text(type,"OrgunhdIdPr"),kind))continue; - for(var device:items(type,"Devices")){String serial=serial(text(device,"DevichdId"));if(!serial.isEmpty())devices.add(new Device(kind,serial));} - for(var release:items(type,"Swreleases"))for(var message:items(release,"Messages")){ - String code=text(message,"DevsrmsgId"),description=text(message,"Description"); - if(!code.isEmpty()&&!description.isEmpty())messages.add(new Message(kind,code,description,text(message,"Msgtext"))); - } - } - }else{ - var data=child(response,"EsStatusdata",true);child(data,"Statusdata",true); - for(var status:items(data,"Statusdata")){ - String type=text(status,"DevtyhdId"),serial=serial(text(status,"DevichdId")); - if(!allowed(text(status,"OrgunhdIdPr"),type)||serial.isEmpty())continue; - var date=timestamp(text(status,"TstmpDevsthd")).toLocalDate();var notices=new ArrayList(); - for(var n:items(status,"Devstopn")){ - String end=text(n,"TstmpFinishd"),code=text(n,"DevsrmsgId"); - if(!code.isEmpty()&&(end.isEmpty()||end.equals("00000000000000")))notices.add(new Notice(code,text(n,"DevCatMf"))); - } - var money=new LinkedHashMap(); - for(var m:items(status,"Devstmny")){var field=moneyField(text(m,"CashboxType"),text(m,"PositionDesc"));if(field!=null)money.put(field,amount(text(m,"NominalAmnt")));} - BigDecimal cards=null; - if(Set.of("STU","STH","STB","STVS").contains(type))for(var material:items(status,"Devstmat"))if(text(material,"MatUnitExt").equals("KARTEN"))cards=amount(text(material,"NominalStock")); - statuses.add(new Status(type,serial,date,List.copyOf(notices),Collections.unmodifiableMap(money),cards)); - } - } - return new Payload(List.copyOf(devices),List.copyOf(messages),List.copyOf(statuses)); - }catch(org.springframework.web.server.ResponseStatusException e){throw e;}catch(Exception e){throw bad("PTnova-Antwort konnte nicht sicher gelesen werden.");} - } -} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapStore.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapStore.java deleted file mode 100644 index 7244a8b..0000000 --- a/backend/src/main/java/de/stadtbote/votian/LegacyCronSoapStore.java +++ /dev/null @@ -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 optional(List> rows,String what){if(rows.size()>1)throw bad("Mehrdeutige PTnova-Zuordnung: "+what);return rows.isEmpty()?null:rows.getFirst();} - Map article(String type){return optional(db.queryForList("SELECT at_id FROM article WHERE at_match=? AND hq_id IN (0,?)",type,hq),"Artikeltyp");} - Map unit(String serial){ - var item=optional(db.queryForList("SELECT ati_id,at_id,stk_id,ati_serialno FROM articleitem WHERE ati_serialno=?",serial),"Seriennummer"); - if(item!=null)stock(id(item,"stk_id"));return item; - } - Map stock(long stock){ - var row=optional(db.queryForList("SELECT stk_id,stk_pre_id,stk_path,hq_id FROM stock WHERE stk_id=?",stock),"Lagerort"); - if(row==null||id(row,"hq_id")!=0&&id(row,"hq_id")!=hq)throw bad("PTnova-Lagerort fehlt oder gehört zu einer anderen Niederlassung.");return row; - } - int master(Payload payload){ - int count=0; - for(var device:payload.devices()){ - if(unit(device.serial())!=null)continue; - var article=article(device.type());if(article==null){skipped++;continue;} - stock(container);long parent=parent(); - long type=id(article,"at_id"); - long item=db.insert("articleitem",fields("at_id",type,"stk_id",container,"ati_serialno",device.serial(),"ati_data_01","","ati_data_02","","ati_data_03","","ati_data_04",now,"ati_data_21","")); - createStock(parent,item,"FA"+device.serial(),false); - var quantities=db.queryForList("SELECT stkat_itemquantity FROM stockarticle WHERE stk_id=? AND at_id=?",container,type); - if(quantities.size()>1)throw bad("Mehrdeutiger PTnova-Lagerbestand."); - if(quantities.isEmpty())db.update("INSERT INTO stockarticle (stk_id,at_id,stkat_quantity,stkat_itemquantity) VALUES (?,?,1,1)",container,type); - else db.update("UPDATE stockarticle SET stkat_quantity=COALESCE(stkat_itemquantity,0)+1,stkat_itemquantity=COALESCE(stkat_itemquantity,0)+1 WHERE stk_id=? AND at_id=?",container,type); - count++; - } - for(var message:payload.messages()){ - var article=article(message.type());if(article==null){skipped++;continue;}long type=id(article,"at_id"); - var matches=db.queryForList("SELECT atm_code FROM articlemessage WHERE at_id=? AND atm_code=?",type,message.code()); - if(matches.size()>1)throw bad("Mehrdeutiger PTnova-Betriebsmeldungscode."); - if(matches.isEmpty())db.update("INSERT INTO articlemessage (at_id,atm_code,atm_desc,atm_text,atm_prio) VALUES (?,?,?,?,0)",type,message.code(),message.description(),message.text()); - else db.update("UPDATE articlemessage SET atm_desc=?,atm_text=? WHERE at_id=? AND atm_code=?",message.description(),message.text(),type,message.code()); - count++; - } - return count; - } - long parent(){ - var catalog=new OrderEntryCatalog(db);String value=catalog.parameter(hq,"SERVICEUNIT_STOCK_PARENT_ID"); - if(!value.matches("[0-9]+"))value=catalog.parameter(0,"SERVICEUNIT_STOCK_PARENT_ID"); - long parent=0;if(value.matches("[0-9]+"))try{parent=Long.parseLong(value);}catch(NumberFormatException e){throw bad("Ungültiges Serviceunit-Hauptlager.");} - if(parent>0){stock(parent);return parent;} - var root=optional(db.queryForList("SELECT stk_id FROM stock WHERE hq_id=? AND stk_pre_id=0 AND stk_obj_type='ati' AND stk_obj_id=0 AND stk_name='SYSTEM-SERVICEUNIT'",hq),"Serviceunit-Hauptlager"); - if(root!=null)return id(root,"stk_id"); - parent=createStock(0,0,"SYSTEM-SERVICEUNIT",true); - for(var entry:Map.of("MASK_STK_DATAFIELDHEADLINES_","01,02,03,04,05,06,07,08,09,10,11,12,13,14,15","MASK_STK_DATAFIELDS_","0").entrySet()) - db.insert("parameter",fields("emp_id",0,"hq_id",0,"par_key",entry.getKey()+parent,"par_value",entry.getValue(),"par_text","")); - return parent; - } - long createStock(long parent,long item,String name,boolean root){ - String path="";if(parent>0){var row=stock(parent);path=(text(row,"stk_path").isEmpty()?"//":text(row,"stk_path"))+parent+"//";} - long id=db.insert("stock",fields("hq_id",hq,"stk_pre_id",parent,"stk_obj_type","ati","stk_obj_id",item,"stk_name",name,"stk_path",path,"stk_barcode",name,"ad_id",0,"cs_id",0,"stk_visible",1,"stk_readonly",root?1:0,"stk_modifytime",now)); - support.audit(new LegacyUser(0,0,hq,"PTnova",""),0,0,0,87,"PTnova-Lager angelegt · STK_ID="+id+" · PARENT="+parent,UUID.randomUUID().toString());return id; - } - int status(Payload payload){ - int count=0; - for(var status:payload.statuses()){ - var item=unit(status.serial());if(item==null){skipped++;continue;} - long article=id(item,"at_id"),stock=id(item,"stk_id"),itemId=id(item,"ati_id"); - var codes=new LinkedHashSet(); - for(var notice:status.notices()){ - String description=notice.description(); - if(description.isEmpty()||description.equals("keine Angabe")){ - var message=optional(db.queryForList("SELECT atm_desc FROM articlemessage WHERE at_id=? AND atm_code=?",article,notice.code()),"Betriebsmeldung"); - description=message==null?"":text(message,"atm_desc"); - } - if(!description.isEmpty())codes.add(notice.code());else skipped++; - } - var values=new LinkedHashMap();values.put("ati_data_17",String.join(",",codes));values.put("ati_data_29",""); - var date=status.date().atTime(3,0); - for(var amount:status.money().entrySet()){values.put(String.format("ati_data_%02d",amount.getKey()),amount.getValue().toPlainString());} - if(!status.money().isEmpty()){values.put("ati_data_04",date);values.put("ati_data_30","");} - if(status.cards()!=null)values.put("ati_data_09",status.cards().toPlainString()); - BusinessSupport.update(db,"articleitem","ati_id",itemId,values); - if(!status.money().isEmpty()||status.cards()!=null){ - String table=support.historySchema+".articleitemhistory"; - var history=db.queryForList("SELECT atih_serialno FROM "+table+" WHERE at_id=? AND stk_id=? AND atih_serialno=? AND atih_data_04=?",article,stock,status.serial(),date); - if(history.size()>1)throw bad("Mehrdeutige PTnova-Tageshistorie."); - var amounts=new LinkedHashMap();for(var amount:status.money().entrySet())amounts.put(String.format("atih_data_%02d",amount.getKey()),amount.getValue().toPlainString()); - if(!status.money().isEmpty())amounts.put("atih_createtime",now); - if(status.cards()!=null)amounts.put("atih_data_09",status.cards().toPlainString()); - if(!history.isEmpty()){ - var arguments=new ArrayList(amounts.values());Collections.addAll(arguments,article,stock,status.serial(),date); - db.update("UPDATE "+table+" SET "+String.join(",",amounts.keySet().stream().map(key->key+"=?").toList())+" WHERE at_id=? AND stk_id=? AND atih_serialno=? AND atih_data_04=?",arguments.toArray()); - } - else if(!status.money().isEmpty()){ - var row=fields("at_id",article,"stk_id",stock,"atih_serialno",status.serial(),"atih_data_01","","atih_data_02","","atih_data_03","","atih_data_04",date);row.putAll(amounts);BusinessSupport.insertWithoutKey(db,table,row); - } - // PHP updates card-only history only if a money/history row already exists. - } - count++; - } - return count; - } -} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCronTasks.java b/backend/src/main/java/de/stadtbote/votian/LegacyCronTasks.java index be6cedc..19cbe28 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyCronTasks.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCronTasks.java @@ -22,7 +22,7 @@ class LegacyCronTasks { final ThreadPoolTaskScheduler scheduler=new ThreadPoolTaskScheduler(); final Map schedulingErrors=new java.util.concurrent.ConcurrentHashMap<>(); final Set enabled; - LegacyCronTasks(LegacyCronRunner runner,LegacyCronCompliance compliance,LegacyCronBusiness business,LegacyCronExports exports,LegacyCronFiles files,LegacyCronReplication replication,LegacyCronSoap soap,Environment env){ + 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 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();var trigger=new CronTrigger(task.cron(),ZONE); + var scheduled=new java.util.concurrent.atomic.AtomicReference();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> unresolved(){return List.of();} } diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyDistancePricing.java b/backend/src/main/java/de/stadtbote/votian/LegacyDistancePricing.java index 2ec67ef..09a9f46 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyDistancePricing.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyDistancePricing.java @@ -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();} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyHistoryMapping.java b/backend/src/main/java/de/stadtbote/votian/LegacyHistoryMapping.java index 2c54cd6..785822f 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyHistoryMapping.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyHistoryMapping.java @@ -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(); } } diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyOrderPricing.java b/backend/src/main/java/de/stadtbote/votian/LegacyOrderPricing.java index 5a88974..95a2b37 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyOrderPricing.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyOrderPricing.java @@ -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 components,java.util.function.Function field) { return components.stream().map(field).reduce(ZERO,BigDecimal::add); } + static BigDecimal sum(List components,java.util.function.Function 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"); diff --git a/backend/src/main/java/de/stadtbote/votian/LegacySessionRightsFilter.java b/backend/src/main/java/de/stadtbote/votian/LegacySessionRightsFilter.java index af24c0a..08a55e4 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacySessionRightsFilter.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacySessionRightsFilter.java @@ -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;} diff --git a/backend/src/main/java/de/stadtbote/votian/LoginActivity.java b/backend/src/main/java/de/stadtbote/votian/LoginActivity.java index 7843169..b37fbec 100644 --- a/backend/src/main/java/de/stadtbote/votian/LoginActivity.java +++ b/backend/src/main/java/de/stadtbote/votian/LoginActivity.java @@ -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 merge(List legacy,List pending){var events=new LinkedHashMap();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 merge(List legacy,List pending){var events=new LinkedHashMap();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 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,'{}')"; diff --git a/backend/src/main/java/de/stadtbote/votian/MasterData.java b/backend/src/main/java/de/stadtbote/votian/MasterData.java index e24be00..b4ae6b3 100644 --- a/backend/src/main/java/de/stadtbote/votian/MasterData.java +++ b/backend/src/main/java/de/stadtbote/votian/MasterData.java @@ -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()); diff --git a/backend/src/main/java/de/stadtbote/votian/MasterDirectoryScope.java b/backend/src/main/java/de/stadtbote/votian/MasterDirectoryScope.java index 0e94059..f056cd7 100644 --- a/backend/src/main/java/de/stadtbote/votian/MasterDirectoryScope.java +++ b/backend/src/main/java/de/stadtbote/votian/MasterDirectoryScope.java @@ -18,7 +18,7 @@ final class MasterDirectoryScope { if(rights.length()<=right||rights.charAt(right)!='1')throw denied(); var origin=db.queryForList("SELECT md_id FROM mandatorheadquarters WHERE hq_id=?",u.originalHeadquartersId()); if(origin.size()!=1||id(origin.getFirst(),"md_id")<=0)throw denied(); - boolean root=id(person,"hq_admin")==u.employeeId();var vector=new HashSet<>(Arrays.stream(text(person,"emp_headquarters").split(",")).map(String::strip).toList()); + boolean root=id(person,"hq_admin")==u.employeeId();var vector=new HashSet<>(Arrays.stream(text(person,"emp_headquarters").split(",")).map(entryValue -> entryValue.strip()).toList()); var matrix=db.queryForList("SELECT hq_id FROM employeerights WHERE emp_id=? AND rgt_id=?",u.employeeId(),right+1); var branches=db.queryForList("SELECT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id WHERE m.md_id=? AND COALESCE(h.hq_disabled,0)=0 AND (SELECT COUNT(*) FROM mandatorheadquarters allmd WHERE allmd.hq_id=h.hq_id)=1 ORDER BY h.hq_mnemonic,h.hq_id",id(origin.getFirst(),"md_id")); var allowed=branches.stream().filter(h->id(h,"id")==u.headquartersId()||root||vector.contains(Long.toString(id(h,"id")))&&matrix.stream().anyMatch(r->id(r,"hq_id")==id(h,"id"))).toList(); diff --git a/backend/src/main/java/de/stadtbote/votian/MasterDocuments.java b/backend/src/main/java/de/stadtbote/votian/MasterDocuments.java index 88e2100..7ffbc6f 100644 --- a/backend/src/main/java/de/stadtbote/votian/MasterDocuments.java +++ b/backend/src/main/java/de/stadtbote/votian/MasterDocuments.java @@ -33,7 +33,7 @@ class MasterDocuments { @GetMapping("/import-source") Object importSource(Authentication a,@PathVariable Kind kind,@PathVariable long object,@RequestParam(defaultValue="")String folder,@RequestParam String name,@RequestParam String revision,@RequestParam ImportFormat format){return importSource(ApiController.user(a),kind,object,folder,name,revision,format);} @PostMapping Object write(Authentication a,@PathVariable Kind kind,@PathVariable long object,@RequestBody Command command){return start(ApiController.user(a),kind,object,command);} @PostMapping("/operations/{token}/resume") Object resume(Authentication a,@PathVariable Kind kind,@PathVariable long object,@PathVariable String token){return resume(ApiController.user(a),kind,object,token);} - @GetMapping("/download") ResponseEntity download(Authentication a,@PathVariable Kind kind,@PathVariable long object,@RequestParam(defaultValue="")String folder,@RequestParam String name){var bytes=download(ApiController.user(a),kind,object,folder,name);return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(name,StandardCharsets.UTF_8).build().toString()).header("X-Content-Type-Options","nosniff").header(HttpHeaders.CACHE_CONTROL,"no-store").body(bytes);} + @GetMapping("/download") ResponseEntity download(Authentication a,@PathVariable Kind kind,@PathVariable long object,@RequestParam(defaultValue="")String folder,@RequestParam String name){var bytes=download(ApiController.user(a),kind,object,folder,name);return ResponseEntity.ok().contentType(java.util.Objects.requireNonNull(MediaType.APPLICATION_OCTET_STREAM)).header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(name,StandardCharsets.UTF_8).build().toString()).header("X-Content-Type-Options","nosniff").header(HttpHeaders.CACHE_CONTROL,"no-store").body(bytes);} String parameter(long hq,String key,String fallback){var rows=db.queryForList("SELECT hq_id,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key=? ORDER BY hq_id DESC",hq,key);for(long scope:new long[]{hq,0}){var match=rows.stream().filter(r->id(r,"hq_id")==scope).toList();if(match.size()>1)throw bad("Mehrdeutige Dokumentvorgabe: "+key);if(!match.isEmpty()&&!text(match.getFirst(),"par_value").isBlank())return text(match.getFirst(),"par_value");}return fallback;} int limit(long hq,String key,int fallback){String v=parameter(hq,key,Integer.toString(fallback));try{int n=Integer.parseInt(v);if(n<0||n>1000000)throw new NumberFormatException();return n;}catch(NumberFormatException e){throw bad("Ungültige Dateigrenze in der Dokumentkonfiguration.");}} Context context(LegacyUser u,Kind kind,long object){ @@ -100,7 +100,7 @@ class MasterDocuments { } Map view(LegacyUser u,Kind kind,long object,String folder){var c=context(u,kind,object);String directory=dir(c,folder);var pending=operations(u,kind,object);var result=fields("label",c.label(),"prefix",c.prefix(),"folder",folder,"canUpload",c.upload(),"canDownload",c.download(),"maxBytes",MAX_UPLOAD,"maxFiles",c.root()?-1:c.maxObject(),"configured",store.configured(),"items",List.of(),"folders",List.of(),"warning","","operations",pending.stream().filter(o->o.status().equals("PENDING")).map(o->fields("token",o.requestId(),"action",o.action(),"name",o.name(),"folder",o.folder())).toList(),"removed",pending.stream().filter(o->o.status().equals("DONE")&&o.action().equals("REMOVE")).map(o->fields("token",o.requestId(),"name",o.name(),"folder",o.folder())).toList()); if(!store.configured()){result.put("warning","Die gemeinsame Dokumentablage ist noch nicht konfiguriert.");return result;} - try(var fs=store.open()){if(!fs.exists(directory))return result;var entries=fs.list(directory);result.put("items",entries.stream().filter(e->!e.directory()&&!e.name().startsWith(".")&&belongs(c,e.name())).sorted(Comparator.comparing(DocumentStore.Entry::name)).map(e->fields("name",e.name(),"size",e.size(),"modified",e.modified(),"revision",e.size()>MAX_DOWNLOAD?"":revision(e,fs.read(join(directory,e.name()),MAX_DOWNLOAD)))).toList());result.put("folders",entries.stream().filter(e->e.directory()&&!e.name().startsWith(".")&&(kind!=Kind.general||c.root()||!folder.isEmpty()||!c.restricted().contains(e.name()))).map(DocumentStore.Entry::name).sorted().toList());var after=context(u,kind,object);if(!contextRevision(after).equals(contextRevision(c)))throw MasterDirectoryScope.denied();dir(after,folder);return result;} + try(var fs=store.open()){if(!fs.exists(directory))return result;var entries=fs.list(directory);result.put("items",entries.stream().filter(e->!e.directory()&&!e.name().startsWith(".")&&belongs(c,e.name())).sorted(Comparator.comparing(entryValue -> entryValue.name())).map(e->fields("name",e.name(),"size",e.size(),"modified",e.modified(),"revision",e.size()>MAX_DOWNLOAD?"":revision(e,fs.read(join(directory,e.name()),MAX_DOWNLOAD)))).toList());result.put("folders",entries.stream().filter(e->e.directory()&&!e.name().startsWith(".")&&(kind!=Kind.general||c.root()||!folder.isEmpty()||!c.restricted().contains(e.name()))).map(entryValue -> entryValue.name()).sorted().toList());var after=context(u,kind,object);if(!contextRevision(after).equals(contextRevision(c)))throw MasterDirectoryScope.denied();dir(after,folder);return result;} } byte[] download(LegacyUser u,Kind kind,long object,String folder,String name){var c=context(u,kind,object);if(!c.download())throw MasterDirectoryScope.denied();byte[] bytes;try(var fs=store.open()){bytes=fs.read(file(c,folder,name),MAX_DOWNLOAD);}var after=context(u,kind,object);if(!after.download()||!contextRevision(after).equals(contextRevision(c)))throw MasterDirectoryScope.denied();dir(after,folder);return bytes;} void importAccess(LegacyUser u,Kind kind,ImportFormat format){u.require(22);if(format==null)throw bad("Importformat fehlt.");if(format!=ImportFormat.ZIPCODE_NEIGHBOURS){if(kind!=Kind.customers)throw bad("Dieser Import benötigt einen zugeordneten Kunden.");u.require((format==ImportFormat.ZONE_JOBS||format==ImportFormat.TOURS||format==ImportFormat.DHL||format==ImportFormat.TIMS||format==ImportFormat.BMW||format==ImportFormat.ALLERGO)?7:2);}String live=text(one(db.queryForList("SELECT emp_rights FROM employee WHERE emp_id=? AND usr_id=?",u.employeeId(),u.userId())),"emp_rights");if(!EmployeeAdministration.bit(live,22)||format!=ImportFormat.ZIPCODE_NEIGHBOURS&&!EmployeeAdministration.bit(live,(format==ImportFormat.ZONE_JOBS||format==ImportFormat.TOURS||format==ImportFormat.DHL||format==ImportFormat.TIMS||format==ImportFormat.BMW||format==ImportFormat.ALLERGO)?7:2))throw MasterDirectoryScope.denied();} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterExportFiles.java b/backend/src/main/java/de/stadtbote/votian/MasterExportFiles.java index 2f1b37f..9565d26 100644 --- a/backend/src/main/java/de/stadtbote/votian/MasterExportFiles.java +++ b/backend/src/main/java/de/stadtbote/votian/MasterExportFiles.java @@ -16,9 +16,9 @@ class MasterExportFiles { @Override FileRef file(LegacyUser u,long file){var f=super.file(u,file);if(!allowed(u,f.name()))throw MasterDirectoryScope.denied();return f;} };removal=new CustomerExportRemoval(exports.db,exports.support,files,store){@Override void actor(LegacyUser u,long customer,Operation op){super.actor(u,customer,op);if(!allowed(u,op.snapshot().name()))throw MasterDirectoryScope.denied();}};} boolean allowed(LegacyUser u,String name){var catalog=new OrderEntryCatalog(exports.db);String extension=catalog.parameter(u.headquartersId(),"EXPORT_CAT_09_FILE_EXTENSION");if(extension.isBlank())extension=catalog.parameter(0,"EXPORT_CAT_09_FILE_EXTENSION");if(extension.isBlank())extension="ums";String lower=name.toLowerCase(Locale.ROOT),suffix="."+extension.toLowerCase(Locale.ROOT);if(!lower.endsWith(suffix)&&!lower.matches(".*"+java.util.regex.Pattern.quote(suffix)+"\\.[a-z0-9]{1,10}"))return true;var account=one(exports.db.queryForList("SELECT emp_rights FROM employee WHERE emp_id=? AND usr_id=?",u.employeeId(),u.userId()));return u.hasRight(26)&&EmployeeAdministration.bit(text(account,"emp_rights"),26);} - @GetMapping Object list(Authentication a,@RequestParam(defaultValue="0")int page){var u=ApiController.user(a);var result=files.list(u,page);result.put("files",((List>)result.get("files")).stream().filter(f->allowed(u,text(f,"name"))).toList());return result;} + @GetMapping Object list(Authentication a,@RequestParam(defaultValue="0")int page){var u=ApiController.user(a);var result=files.list(u,page);result.put("files",TypedValues.rows(result.get("files")).stream().filter(f->allowed(u,text(f,"name"))).toList());return result;} @GetMapping("/{file}/download") ResponseEntity download(Authentication a,@PathVariable long file){var v=files.download(ApiController.user(a),file);return InvoiceCsvExport.response(text(v,"name"),(byte[])v.get("data"),"application/octet-stream");} - @GetMapping("/removal") Object removed(Authentication a){var u=ApiController.user(a);var result=removal.list(u);result.put("operations",((List>)result.get("operations")).stream().filter(op->allowed(u,text(op,"name"))).toList());return result;} + @GetMapping("/removal") Object removed(Authentication a){var u=ApiController.user(a);var result=removal.list(u);result.put("operations",TypedValues.rows(result.get("operations")).stream().filter(op->allowed(u,text(op,"name"))).toList());return result;} @GetMapping("/removal/{file}") Object preview(Authentication a,@PathVariable long file){var u=ApiController.user(a);files.file(u,file);return removal.preview(u,file);} @PostMapping("/removal") Object remove(Authentication a,@RequestBody CustomerExportRemoval.Command c){var u=ApiController.user(a);return removal.remove(u,c);} @PostMapping("/removal/{requestId}/resume") Object resume(Authentication a,@PathVariable String requestId){return removal.resume(ApiController.user(a),requestId);} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterExportFormat.java b/backend/src/main/java/de/stadtbote/votian/MasterExportFormat.java index 2a7c2b8..56c6d98 100644 --- a/backend/src/main/java/de/stadtbote/votian/MasterExportFormat.java +++ b/backend/src/main/java/de/stadtbote/votian/MasterExportFormat.java @@ -7,7 +7,7 @@ final class MasterExportFormat { static String render(Settings s,List fields,List> rows,String delimiter,boolean headline,String modify){ 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();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();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();for(var r:rules){var f=lookup.get(r.field());String v=wipe(row.get(f.key())); diff --git a/backend/src/main/java/de/stadtbote/votian/MasterExportRuns.java b/backend/src/main/java/de/stadtbote/votian/MasterExportRuns.java index 7f3221a..5c07da0 100644 --- a/backend/src/main/java/de/stadtbote/votian/MasterExportRuns.java +++ b/backend/src/main/java/de/stadtbote/votian/MasterExportRuns.java @@ -29,7 +29,7 @@ class MasterExportRuns { void writable(LegacyUser u){exports.rights(u);if(!files.store.configured())throw bad("Die gemeinsame Exportablage ist noch nicht eingerichtet.");var c=new OrderEntryCatalog(db);files.encryption.policy(db,u);String path=c.parameter(0,"EXPORT_PATH");if(!path.isBlank()&&!path.replaceAll("/+$","").equals("../export/download"))throw bad("Die konfigurierte Exportablage muss abgeglichen werden.");} Operation decode(Map row){try{var op=support.json.readValue(text(row,"gdc_content"),Operation.class);token(op.requestId());if(op.command()==null||op.command().run()==null||op.bytes()<0||op.bytes()>CustomerExportFormat.MAX_OUTPUT||!op.digest().matches("[0-9a-f]{64}")||!Set.of("PENDING","DONE","CANCELLED").contains(op.status()))throw new IllegalArgumentException();return op;}catch(Exception e){throw bad("Der Exportvorgang ist unvollständig.");}} Operation find(LegacyUser u,String requestId){exports.rights(u);var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngmexport' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.headquartersId(),token(requestId));if(rows.isEmpty())return null;var op=decode(one(rows));if(op.actor()!=u.userId()||op.headquarters()!=u.headquartersId())throw MasterDirectoryScope.denied();return op;} - Map operations(LegacyUser u){return db.readTransaction(()->{exports.rights(u);var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngmexport' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id DESC LIMIT 101",u.headquartersId());if(rows.size()>100)throw bad("Mehr als 100 offene Exporte. Bitte die Vorgänge zuerst abschließen.");boolean can=true;String reason="";try{writable(u);}catch(org.springframework.web.server.ResponseStatusException e){can=false;reason=e.getReason();}long pendingArchives=db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_obj_type='ngarchive' AND gdc_context='PENDING' AND gdc_gen_fieldname IN (SELECT gdc_gen_fieldname FROM genericdatacontainer own_export WHERE own_export.gdc_obj_type='ngmexport' AND own_export.gdc_obj_id=?)",Long.class,u.headquartersId());if(pendingArchives>0)reason+=(reason.isBlank()?"":" ")+pendingArchives+" historische Auftragskopien sind gesichert; die Übertragung ins Archiv wird wiederholt.";return fields("canStore",can,"message",reason,"operations",rows.stream().map(this::decode).filter(op->op.actor()==u.userId()).map(op->fields("requestId",op.requestId(),"name",op.name(),"bytes",op.bytes(),"rows",op.rows(),"canResume",true)).toList());});} + Map operations(LegacyUser u){return db.readTransaction(()->{exports.rights(u);var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngmexport' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id DESC LIMIT 101",u.headquartersId());if(rows.size()>100)throw bad("Mehr als 100 offene Exporte. Bitte die Vorgänge zuerst abschließen.");boolean can=true;String reason="";try{writable(u);}catch(org.springframework.web.server.ResponseStatusException e){can=false;reason=java.util.Objects.requireNonNullElse(e.getReason(),"Der Export ist nicht verfügbar.");}long pendingArchives=db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_obj_type='ngarchive' AND gdc_context='PENDING' AND gdc_gen_fieldname IN (SELECT gdc_gen_fieldname FROM genericdatacontainer own_export WHERE own_export.gdc_obj_type='ngmexport' AND own_export.gdc_obj_id=?)",Long.class,u.headquartersId());if(pendingArchives>0)reason+=(reason.isBlank()?"":" ")+pendingArchives+" historische Auftragskopien sind gesichert; die Übertragung ins Archiv wird wiederholt.";return fields("canStore",can,"message",reason,"operations",rows.stream().map(this::decode).filter(op->op.actor()==u.userId()).map(op->fields("requestId",op.requestId(),"name",op.name(),"bytes",op.bytes(),"rows",op.rows(),"canResume",true)).toList());});} void freeName(LegacyUser u,String name,String requestId){if(!db.queryForList("SELECT expf_id FROM exportfiles WHERE hq_id=? AND cs_id=0 AND expf_name=? LIMIT 1",u.headquartersId(),name).isEmpty())throw bad("Dieser Exportdateiname ist bereits vergeben.");for(var row:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngmexport' AND gdc_obj_id=? AND gdc_context='PENDING'",u.headquartersId())){var op=decode(row);if(!op.requestId().equals(requestId)&&op.name().equalsIgnoreCase(name))throw bad("Ein offener Export verwendet diesen Dateinamen.");}} T transaction(boolean accounting,java.util.function.Supplier work){return accounting?db.accountingExportWriteTransaction(work):db.masterExportWriteTransaction(work);} Map start(LegacyUser u,Command c){ diff --git a/backend/src/main/java/de/stadtbote/votian/MasterExportTransfer.java b/backend/src/main/java/de/stadtbote/votian/MasterExportTransfer.java index 5e93060..2a88757 100644 --- a/backend/src/main/java/de/stadtbote/votian/MasterExportTransfer.java +++ b/backend/src/main/java/de/stadtbote/votian/MasterExportTransfer.java @@ -60,7 +60,7 @@ class MasterExportTransfer { void actor(LegacyUser u,Operation op){files.exports.rights(u);if(op.actor()!=u.userId()||op.snapshot().headquarters()!=u.headquartersId())throw MasterDirectoryScope.denied();if(!files.allowed(u,op.snapshot().name()))throw MasterDirectoryScope.denied();} Map result(Operation op){return fields("requestId",op.command().requestId(),"file",op.snapshot().file(),"name",op.snapshot().name(),"state",op.state(),"completed",Set.of("DONE","CANCELLED").contains(op.state()),"needsReceiptCheck",op.state().equals("PUBLISHING"));} Map options(LegacyUser u){return db.readTransaction(()->{var p=policy(u);var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngexpftp' AND gdc_obj_id=? AND gdc_context NOT IN ('DONE','CANCELLED') ORDER BY gdc_id DESC LIMIT 501",u.headquartersId());if(rows.size()>500)throw bad("Mehr als 500 offene Exportübertragungen. Bitte die Vorgänge prüfen.");return fields("canSend",p.send(),"canList",p.list(),"host",p.endpoint().host(),"directory",p.endpoint().directory(),"protocol","FTP","extensions",p.extensions(),"operations",rows.stream().map(this::decode).filter(op->op.actor()==u.userId()&&files.allowed(u,op.snapshot().name())).map(this::result).toList());});} - Map list(LegacyUser u){var p=policy(u);if(!p.list())throw bad("Die externe Exportdateiliste ist nicht freigegeben oder nicht eingerichtet.");List entries;try(var fs=remote.open(p.endpoint())){entries=fs.list();}if(!policy(u).stamp().equals(p.stamp()))throw MasterDocuments.conflict();return fields("files",entries.stream().filter(e->!e.directory()&&!e.name().startsWith(".")).sorted(Comparator.comparing(DocumentStore.Entry::name)).map(e->fields("name",e.name(),"size",e.size(),"modified",e.modified())).toList());} + Map list(LegacyUser u){var p=policy(u);if(!p.list())throw bad("Die externe Exportdateiliste ist nicht freigegeben oder nicht eingerichtet.");List entries;try(var fs=remote.open(p.endpoint())){entries=fs.list();}if(!policy(u).stamp().equals(p.stamp()))throw MasterDocuments.conflict();return fields("files",entries.stream().filter(e->!e.directory()&&!e.name().startsWith(".")).sorted(Comparator.comparing(entryValue -> entryValue.name())).map(e->fields("name",e.name(),"size",e.size(),"modified",e.modified())).toList());} Map preview(LegacyUser u,long file){var c=content(u,file);if(c.snapshot().uploaded()!=0)throw bad("Diese Exportdatei ist bereits als übertragen gekennzeichnet.");try(var fs=remote.open(c.policy().endpoint())){if(fs.find(c.snapshot().name())!=null)throw bad("Der Dateiname ist auf dem Exportserver bereits vorhanden. Er wird nicht überschrieben.");}if(!snapshot(u,file).equals(c.snapshot())||!policy(u).stamp().equals(c.policy().stamp()))throw MasterDocuments.conflict();return fields("file",file,"name",c.snapshot().name(),"bytes",c.bytes().length,"host",c.policy().endpoint().host(),"directory",c.policy().endpoint().directory(),"protocol","FTP","revision",c.version());} void save(Operation op){db.documentReceiptTransaction(()->{if(db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_obj_type='ngexpftp' AND gdc_gen_fieldname=?",support.encode(op),op.state(),op.command().requestId())!=1)throw MasterDocuments.conflict();return null;});} Map start(LegacyUser u,Command command){files.exports.rights(u);return db.documentOperation(()->{ diff --git a/backend/src/main/java/de/stadtbote/votian/MasterListColumns.java b/backend/src/main/java/de/stadtbote/votian/MasterListColumns.java index 1c54bb8..1362f12 100644 --- a/backend/src/main/java/de/stadtbote/votian/MasterListColumns.java +++ b/backend/src/main/java/de/stadtbote/votian/MasterListColumns.java @@ -34,7 +34,7 @@ class MasterListColumns { static String value(List> 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> rows,long hq,Kind kind){ String raw=value(rows,kind.parameter,hq);if(raw.isBlank())return new Layout(kind.defaults,""); - var parts=Arrays.stream(raw.split(",",-1)).map(String::strip).map(k->k.equals("cr_vh_sign")?"crvh_vh_sign":k).toList();var selected=parts.stream().filter(kind.keys::contains).limit(kind.keys.size()).toList(); + var parts=Arrays.stream(raw.split(",",-1)).map(entryValue -> entryValue.strip()).map(k->k.equals("cr_vh_sign")?"crvh_vh_sign":k).toList();var selected=parts.stream().filter(kind.keys::contains).limit(kind.keys.size()).toList(); boolean invalid=selected.size()!=parts.size();if(selected.isEmpty()){selected=kind.defaults;invalid=true;} return new Layout(selected,invalid?"Unbekannte alte Spalten werden nicht angezeigt. Bitte die Belegung vor dem Speichern prüfen.":""); } diff --git a/backend/src/main/java/de/stadtbote/votian/MessageAdministration.java b/backend/src/main/java/de/stadtbote/votian/MessageAdministration.java index 7c606b5..4c1327f 100644 --- a/backend/src/main/java/de/stadtbote/votian/MessageAdministration.java +++ b/backend/src/main/java/de/stadtbote/votian/MessageAdministration.java @@ -18,7 +18,7 @@ class MessageAdministration { final BusinessSupport audit; private final Map previews=new LinkedHashMap<>(); private static class Pending { - long user,hq,reply; int type; String subject,body,replyRevision,recipientRevision; + long user,hq,reply; int type; String subject,body,recipientRevision; Dispatch draft; List> recipients; Instant expires; boolean sending; Map result; } diff --git a/backend/src/main/java/de/stadtbote/votian/MetafieldRecords.java b/backend/src/main/java/de/stadtbote/votian/MetafieldRecords.java index 9076f7d..1d82708 100644 --- a/backend/src/main/java/de/stadtbote/votian/MetafieldRecords.java +++ b/backend/src/main/java/de/stadtbote/votian/MetafieldRecords.java @@ -30,7 +30,7 @@ abstract class MetafieldRecords { Map query(LegacyUser u,Query q){access(u);if(q==null||q.headquarters()==null||q.headquarters().isEmpty()||q.headquarters().size()>100||q.headquarters().stream().anyMatch(Objects::isNull))throw bad("Bitte Niederlassungen auswählen.");MessageAdministration.page(q.page());String search=q.search()==null?"":q.search();if(search.length()>100)throw bad("Suchbegriff ist zu lang.");var scopes=new TreeSet();for(long h:q.headquarters())scopes.add(checked(u,h));var args=new ArrayList(scopes);String where="ck.mtfc_id="+category+" AND ck.hq_id IN ("+marks(scopes.size())+")";if(!search.isBlank()){where+=" AND v.mtfv_value LIKE ?";args.add((q.prefix()?"":"%")+search.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%");} String from=" FROM metafieldvalue v JOIN metafieldcategorykey ck ON ck.mtfck_id=v.mtfck_id WHERE "+where;long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*) FROM (SELECT DISTINCT ck.hq_id,v.mtfv_id"+from+") matches",Long.class,args.toArray())).orElse(0L);var items=db.queryForList("SELECT DISTINCT ck.hq_id AS headquarters,v.mtfv_id AS id"+from+" ORDER BY v.mtfv_id DESC,ck.hq_id LIMIT 50 OFFSET "+q.page()*50,args.toArray());var result=new ArrayList>();for(var item:items){long hq=id(item,"headquarters");var defs=definitions(hq);var vals=values(raw(id(item,"id"),defs));result.add(fields("id",id(item,"id"),"headquarters",hq==0?u.headquartersId():hq,"global",hq==0,"fields",defs.stream().map(d->fields("label",text(d,"label"),"type",text(d,"type"),"value",vals.getOrDefault(id(d,"id"),""))).toList()));}return fields("items",result,"total",total); } - Map save(LegacyUser u,Long object,Save s){access(u);if(s==null||s.values()==null||s.values().size()>1000)throw bad("Formulardaten fehlen.");long scope=checked(u,s.headquarters());var defs=definitions(scope);unchanged(defs,s.schemaRevision());var keys=defs.stream().map(d->id(d,"id")).toList();if(!keys.containsAll(s.values().keySet()))throw bad("Unbekanntes Formularfeld.");var old=object==null?List.>of():raw(object,defs);if(object!=null){if(old.isEmpty())one(old);unchanged(old,s.revision());}var before=values(old);var next=new LinkedHashMap();for(var def:defs){long key=id(def,"id");String type=text(def,"type"),v=Objects.requireNonNullElse(s.values().getOrDefault(key,before.getOrDefault(key,"")),"");if(v.getBytes(StandardCharsets.UTF_8).length>65535)throw bad(text(def,"label")+": Feld ist zu lang.");if(!v.equals(before.getOrDefault(key,""))){v=validateValue(u,s.headquarters(),def,v);}next.put(key,v);}if(next.values().stream().allMatch(String::isBlank))throw bad("Bitte mindestens ein Formularfeld ausfüllen."); + Map save(LegacyUser u,Long object,Save s){access(u);if(s==null||s.values()==null||s.values().size()>1000)throw bad("Formulardaten fehlen.");long scope=checked(u,s.headquarters());var defs=definitions(scope);unchanged(defs,s.schemaRevision());var keys=defs.stream().map(d->id(d,"id")).toList();if(!keys.containsAll(s.values().keySet()))throw bad("Unbekanntes Formularfeld.");var old=object==null?List.>of():raw(object,defs);if(object!=null){if(old.isEmpty())one(old);unchanged(old,s.revision());}var before=values(old);var next=new LinkedHashMap();for(var def:defs){long key=id(def,"id");String v=Objects.requireNonNullElse(s.values().getOrDefault(key,before.getOrDefault(key,"")),"");if(v.getBytes(StandardCharsets.UTF_8).length>65535)throw bad(text(def,"label")+": Feld ist zu lang.");if(!v.equals(before.getOrDefault(key,""))){v=validateValue(u,s.headquarters(),def,v);}next.put(key,v);}if(next.values().stream().allMatch(entryValue -> entryValue.isBlank()))throw bad("Bitte mindestens ein Formularfeld ausfüllen."); validateValues(defs,next); return db.contactWriteTransaction(()->{unchanged(definitions(scope),s.schemaRevision());var current=object==null?List.>of():raw(object,defs);if(object!=null){if(current.isEmpty())one(current);unchanged(current,s.revision());}long saved=object==null?db.queryForObject("SELECT COALESCE(MAX(mtfv_id),0)+1 FROM metafieldvalue",Long.class):object;if(saved<=0||saved>Integer.MAX_VALUE)throw bad("Formular-ID-Bereich ausgeschöpft.");if(object!=null){var a=new ArrayList(List.of(saved));a.addAll(keys);db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(keys.size())+")",a.toArray());}for(var def:defs){long key=id(def,"id");String value=next.get(key);db.update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",key,saved,value);if(id(def,"history")==1){String previous=db.queryForList("SELECT mtfv_value FROM metafieldvaluehistory WHERE mtfck_id=? AND mtfv_id=? ORDER BY mtfvh_createtime DESC LIMIT 1",key,saved).stream().map(r->text(r,"mtfv_value")).findFirst().orElse("");if(!previous.equals(value))db.update("INSERT INTO metafieldvaluehistory (mtfck_id,mtfv_id,mtfv_value,mtfvh_createtime) VALUES (?,?,?,?)",key,saved,value,LocalDateTime.now(ZoneId.of("Europe/Berlin")));}}return fields("id",saved);}); } diff --git a/backend/src/main/java/de/stadtbote/votian/NewsletterDispatch.java b/backend/src/main/java/de/stadtbote/votian/NewsletterDispatch.java index 05b67dc..ecf29d3 100644 --- a/backend/src/main/java/de/stadtbote/votian/NewsletterDispatch.java +++ b/backend/src/main/java/de/stadtbote/votian/NewsletterDispatch.java @@ -46,7 +46,7 @@ class NewsletterDispatch { static Object[] concat(List a,List b){var all=new ArrayList(a);all.addAll(b);return all.toArray();} static String groupWhere(String column,int count){return String.join(" OR ",Collections.nCopies(count,column+" LIKE ?"));} static void addRows(List out,List> rows){if(rows.size()>5000)throw bad("Mehr als 5000 Empfänger. Bitte Auswahl eingrenzen.");for(var row:rows){String raw=text(row,"email");if(raw.isBlank())continue;for(String address:addresses(raw))out.add(new Recipient(address,text(row,"type"),id(row,"id"),text(row,"label"),""));}} - @SuppressWarnings("unchecked") Map preview(LegacyUser u,long object,Draft draft){newsletters.access(u);if(draft==null)throw bad("Versanddaten fehlen.");String subject=required(draft.subject(),200,"Betreff");if(subject.contains("\r")||subject.contains("\n"))throw bad("Ungültiger Betreff.");if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert.");var rendering=new LinkedHashMap<>(newsletters.rendering(u,draft.headquarters(),object));String template=text(rendering,"template");boolean personalized=template.contains("HASH");rendering.put("template",template.replace("HASH",HASH));if(text(rendering,"template").contains(""))throw bad("Die Vorlage enthält einen unbekannten Mail-Platzhalter.");String html=templates.render(rendering);var recipients=recipients(u,draft,personalized);var p=new Pending();p.actor=u;p.object=object;p.draft=draft;p.revision=text(rendering,"revision");p.recipientRevision=revision(recipients);p.recipients=recipients;p.html=html;p.cc=addresses(draft.cc());p.bcc=addresses(draft.bcc());if((!p.cc.isEmpty()||!p.bcc.isEmpty())&&!draft.copiesEveryMail())throw bad("Für Cc/Bcc bitte die Kopie bei jeder generierten Mail bestätigen.");p.reply=AppointmentNotifications.address(text(one(db.queryForList("SELECT usr_email FROM user WHERE usr_id=? AND hq_id=?",u.userId(),u.headquartersId())),"usr_email"));String sender=parameter(u.headquartersId(),"MAIL_NEWSLETTER_SENDER_ADDRESS");if(sender.isBlank())sender=parameter(u.headquartersId(),"MAIL_SENDER_ADDRESS");p.from=AppointmentNotifications.address(!transport.from.isBlank()?transport.from:sender.isBlank()?p.reply:sender);p.expires=Instant.now().plusSeconds(900);String token=UUID.randomUUID().toString();synchronized(pending){pending.values().removeIf(v->!v.running&&v.expires.isBefore(Instant.now()));if(pending.size()>=20)throw bad("Zu viele Newsletter-Vorschauen. Bitte später erneut versuchen.");pending.put(token,p);}return fields("token",token,"from",p.from,"replyTo",p.reply,"subject",subject,"recipients",recipients.stream().map(r->fields("address",r.address(),"label",r.label())).toList(),"cc",p.cc,"bcc",p.bcc,"personalized",personalized,"html",previewHtml(html));} + Map preview(LegacyUser u,long object,Draft draft){newsletters.access(u);if(draft==null)throw bad("Versanddaten fehlen.");String subject=required(draft.subject(),200,"Betreff");if(subject.contains("\r")||subject.contains("\n"))throw bad("Ungültiger Betreff.");if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert.");var rendering=new LinkedHashMap<>(newsletters.rendering(u,draft.headquarters(),object));String template=text(rendering,"template");boolean personalized=template.contains("HASH");rendering.put("template",template.replace("HASH",HASH));if(text(rendering,"template").contains(""))throw bad("Die Vorlage enthält einen unbekannten Mail-Platzhalter.");String html=templates.render(rendering);var recipients=recipients(u,draft,personalized);var p=new Pending();p.actor=u;p.object=object;p.draft=draft;p.revision=text(rendering,"revision");p.recipientRevision=revision(recipients);p.recipients=recipients;p.html=html;p.cc=addresses(draft.cc());p.bcc=addresses(draft.bcc());if((!p.cc.isEmpty()||!p.bcc.isEmpty())&&!draft.copiesEveryMail())throw bad("Für Cc/Bcc bitte die Kopie bei jeder generierten Mail bestätigen.");p.reply=AppointmentNotifications.address(text(one(db.queryForList("SELECT usr_email FROM user WHERE usr_id=? AND hq_id=?",u.userId(),u.headquartersId())),"usr_email"));String sender=parameter(u.headquartersId(),"MAIL_NEWSLETTER_SENDER_ADDRESS");if(sender.isBlank())sender=parameter(u.headquartersId(),"MAIL_SENDER_ADDRESS");p.from=AppointmentNotifications.address(!transport.from.isBlank()?transport.from:sender.isBlank()?p.reply:sender);p.expires=Instant.now().plusSeconds(900);String token=UUID.randomUUID().toString();synchronized(pending){pending.values().removeIf(v->!v.running&&v.expires.isBefore(Instant.now()));if(pending.size()>=20)throw bad("Zu viele Newsletter-Vorschauen. Bitte später erneut versuchen.");pending.put(token,p);}return fields("token",token,"from",p.from,"replyTo",p.reply,"subject",subject,"recipients",recipients.stream().map(r->fields("address",r.address(),"label",r.label())).toList(),"cc",p.cc,"bcc",p.bcc,"personalized",personalized,"html",previewHtml(html));} static String previewHtml(String html){var doc=org.jsoup.Jsoup.parse(html);doc.select("a").removeAttr("href");return doc.html();} Pending checked(LegacyUser u,String token){newsletters.access(u);Pending p;synchronized(pending){p=pending.get(token);}if(p==null||p.actor.userId()!=u.userId()||p.actor.headquartersId()!=u.headquartersId()||!p.running&&p.expires.isBefore(Instant.now()))throw bad("Versandvorschau ist abgelaufen oder durch einen Serverneustart nicht mehr verfügbar. Einen zuvor gestarteten Versand zuerst anhand der SMTP-Protokolle prüfen; er wird nicht automatisch wiederholt.");return p;} Map send(LegacyUser u,String token){var p=checked(u,token);synchronized(p){if(!p.started){var current=newsletters.rendering(u,p.draft.headquarters(),p.object);if(!Objects.equals(current.get("revision"),p.revision))throw bad("Newsletter oder Vorlage wurde inzwischen geändert. Bitte Vorschau neu erstellen.");unchanged(recipients(u,p.draft,p.html.contains(HASH)),p.recipientRevision);p.started=true;p.running=true;p.expires=Instant.now().plusSeconds(86400);executor.execute(()->deliver(p));}return status(p);}} diff --git a/backend/src/main/java/de/stadtbote/votian/NewsletterTemplate.java b/backend/src/main/java/de/stadtbote/votian/NewsletterTemplate.java index b3311cd..65e591b 100644 --- a/backend/src/main/java/de/stadtbote/votian/NewsletterTemplate.java +++ b/backend/src/main/java/de/stadtbote/votian/NewsletterTemplate.java @@ -4,7 +4,7 @@ import org.jsoup.safety.Safelist; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.util.HtmlUtils; -import java.net.URI; + import java.util.*; import java.util.regex.*; import static de.stadtbote.votian.AdminSupport.bad; @@ -18,7 +18,7 @@ class NewsletterTemplate { static String[] imageParts(String raw){if(raw==null||raw.isBlank())return new String[]{"","","",""};String[] p=raw.split(",",4);String name=p[0].strip();if(!name.matches("[\\p{L}\\p{N}_ .()-]{1,200}\\.(?i:png|jpe?g|gif|webp)")||name.contains(".."))throw bad("Bildname muss eine PNG-, JPG-, GIF- oder WebP-Datei ohne Pfadangabe sein.");for(int i=1;i4000))throw bad("Bildabmessungen müssen zwischen 1 und 4000 liegen.");return new String[]{name,p.length>1?p[1].strip():"",p.length>2?p[2].strip():"",p.length>3?p[3]:""};} static String escape(String s){return HtmlUtils.htmlEscape(s==null?"":s);} String image(String value){return image(value,false);} - String image(String value,boolean system){if(value.isBlank())return "";String[] p=imageParts(value);String local=system?null:images.data(p[0]);String src=local!=null?local:legacy+(system?"/images/external/":"/documents/images/")+org.springframework.web.util.UriUtils.encodePathSegment(p[0],java.nio.charset.StandardCharsets.UTF_8);return "\""+escape(p[3])+"\""+(p[1].isBlank()?"":"";} + String image(String value,boolean system){if(value.isBlank())return "";String[] p=imageParts(value);String local=system?null:images.data(p[0]);String src=local!=null?local:legacy+(system?"/images/external/":"/documents/images/")+org.springframework.web.util.UriUtils.encodePathSegment(java.util.Objects.requireNonNull(p[0]),java.util.Objects.requireNonNull(java.nio.charset.StandardCharsets.UTF_8));return "\""+escape(p[3])+"\""+(p[1].isBlank()?"":"";} String link(String value){if(value.isBlank())return "";try{String[] p=FormFieldSupport.link(value);return ""+escape(p[1])+"";}catch(org.springframework.web.server.ResponseStatusException e){return escape(value);}} static String number(String value,String type){ String raw=value.strip(); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderArticles.java b/backend/src/main/java/de/stadtbote/votian/OrderArticles.java index d6b129b..4082432 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderArticles.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderArticles.java @@ -115,7 +115,7 @@ public class OrderArticles { result.add(new Planned(movement,bundle,item,text(row,"stkmv_tan"),dateTime(row,"stkmv_datetime2"),data)); }return result; } - Request request(State s,List plan){var j=s.order().job();if(id(j,"jb_offer")!=0||id(j,"jb_permanent")!=0)throw bad("Lagerbuchungen benötigen einen Einzelauftrag ohne Angebotsstatus.");if(s.order().stops().isEmpty())throw bad("Der Auftrag besitzt keine Stationen.");for(var p:plan)validate(p.item());return support.json.convertValue(fields("payerCostCenterId",OrderRecalculation.payerId(s.order()),"mode","IMMEDIATE","copies",1,"stops",List.of(Map.of("name","Lagerbuchung","country","DE")),"articles",plan.stream().map(Planned::item).toList()),Request.class);} + Request request(State s,List plan){var j=s.order().job();if(id(j,"jb_offer")!=0||id(j,"jb_permanent")!=0)throw bad("Lagerbuchungen benötigen einen Einzelauftrag ohne Angebotsstatus.");if(s.order().stops().isEmpty())throw bad("Der Auftrag besitzt keine Stationen.");for(var p:plan)validate(p.item());return support.json.convertValue(fields("payerCostCenterId",OrderRecalculation.payerId(s.order()),"mode","IMMEDIATE","copies",1,"stops",List.of(Map.of("name","Lagerbuchung","country","DE")),"articles",plan.stream().map(entryValue -> entryValue.item()).toList()),Request.class);} Map preview(LegacyUser u,long id,Movement m){u.require(4);u.require(7);u.require(14);validate(m);return db.readTransaction(()->{var s=state(u,id,false);unchanged(s,m.revision());orders.writable(s.order());var plan=plan(s,m);var lines=inventory.quote(u,request(s,plan));return fields("item",plan.getFirst().item(),"plan",plan,"lines",lines,"fingerprint",revision(List.of(s,plan,lines)));});} Map move(LegacyUser u,long id,Movement m){u.require(4);u.require(7);u.require(14);validate(m);token(m.requestId()); support.transactional("headquarters","job","tour","tourarticle","tourarticleprocess","article","stock","stockarticle","articleitem","stockmove","genericdatacontainer","phoenix_pda.commandexec"); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCancellation.java b/backend/src/main/java/de/stadtbote/votian/OrderCancellation.java index e7bd3ce..3449f6f 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderCancellation.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderCancellation.java @@ -52,7 +52,7 @@ public class OrderCancellation { } List> rows(State s,BigDecimal fee,BigDecimal courierFee){ var result=new ArrayList>();int index=0;for(var d:s.documents()){ - long id=id(d.job(),"jb_id");BigDecimal toll=s.metadata().stream().filter(m->id(m,"gdc_obj_id")==id&&text(m,"gdc_gen_fieldname").equals("jb_toll")).map(m->new BigDecimal(text(m,"gdc_content"))).reduce(BigDecimal.ZERO,BigDecimal::add); + long id=id(d.job(),"jb_id");BigDecimal toll=s.metadata().stream().filter(m->id(m,"gdc_obj_id")==id&&text(m,"gdc_gen_fieldname").equals("jb_toll")).map(m->new BigDecimal(text(m,"gdc_content"))).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)); result.add(fields("id",id,"kind",index==0?"Hauptauftrag":id(d.job(),"jb_id_parent")==id(s.documents().getFirst().job(),"jb_id")?"Unterauftrag":"Gegenbeleg","courier",text(d.job(),"cr_sid"),"net",OrderOperations.customerNet(d),"creditNet",OrderOperations.customerNet(d).negate().add(index==0?fee:BigDecimal.ZERO),"creditCourierNet",decimal(d.job(),"jb_cr_price").negate().add(index==0?courierFee:BigDecimal.ZERO),"creditToll",toll.negate(),"payments",d.payments().stream().map(p->fields("id",id(p,"jbp_id"),"amount",decimal(p,"jbp_price"),"offset",decimal(p,"jbp_price").negate(),"exported",id(p,"jbpc_id")>0||!text(p,"jbp_export_time").isBlank(),"counter",id(p,"jbp_counter"),"mode",id(p,"jbp_mode"))).toList()));index++; }return result; } diff --git a/backend/src/main/java/de/stadtbote/votian/OrderChildInvoiceTexts.java b/backend/src/main/java/de/stadtbote/votian/OrderChildInvoiceTexts.java index 0733fb7..4bed14e 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderChildInvoiceTexts.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderChildInvoiceTexts.java @@ -1,6 +1,6 @@ package de.stadtbote.votian; -import de.stadtbote.votian.database.DatabaseService; + import java.util.*; import static de.stadtbote.votian.LegacyMapping.*; import static de.stadtbote.votian.AdminSupport.bad; diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCompletionChecks.java b/backend/src/main/java/de/stadtbote/votian/OrderCompletionChecks.java index c46c5eb..964d820 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderCompletionChecks.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderCompletionChecks.java @@ -8,6 +8,10 @@ import static de.stadtbote.votian.AdminSupport.bad; /** Current PHP check_jb_incomplete rules for central edits and final completion. */ final class OrderCompletionChecks { + static void requireManualRelease(Map before,Map values) { + if(id(before,"jb_incomplete")!=0&&id(values,"jb_incomplete")==0&&id(before,"cr_id")<=0) + throw bad("Die Nachbearbeitung kann erst aufgehoben werden, wenn dem Auftrag ein Transporteur zugeordnet ist."); + } static List reasons(Map job,Map customer,List> stops, List> prices,List> details,boolean customerAccount, boolean stationRequired,boolean everyStation,boolean sustainabilityRequired) { diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCompletionProcessing.java b/backend/src/main/java/de/stadtbote/votian/OrderCompletionProcessing.java index 43fc3a5..54f91c4 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderCompletionProcessing.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderCompletionProcessing.java @@ -54,7 +54,7 @@ final class OrderCompletionProcessing { BigDecimal km=calc!=null?calc:keep?OrderCompletionSustainability.stored(before.sustainability(),11):sustainability.routing.completedDistance(user,stops); String token=UUID.nameUUIDFromBytes(revision(before).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString(); var input=sustainability.sustainability.completedInput(user,job,token,before.sustainability().vehicle(),km,decimal(before.sustainability().order().job(),"jb_weight"));var result=sustainability.sustainability.resolveBatch(input); - String coordinates=before.routes().stream().noneMatch(r->r.type()==0)?"":String.join(";",sustainability.routing.completedPoints(stops).stream().map(OrderRoutingService.Point::station).toList()); + String coordinates=before.routes().stream().noneMatch(r->r.type()==0)?"":String.join(";",sustainability.routing.completedPoints(stops).stream().map(entryValue -> entryValue.station()).toList()); var routePlan=new OrderRouteCache.Plan(OrderRouteCache.stationRevision(before.sustainability().order().stops()),before.routes(),coordinates); return db.writeTransaction(()->{ lockHeadquarters(db,user);if(queue(job,true).isEmpty()&&pending(job,true).isEmpty())return false;var current=state(user,job,true);unchanged(current,revision(before)); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCustomerMailContent.java b/backend/src/main/java/de/stadtbote/votian/OrderCustomerMailContent.java index 4f4470c..649d1b8 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderCustomerMailContent.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderCustomerMailContent.java @@ -144,7 +144,7 @@ final class OrderCustomerMailContent { body.append("

").append(rich(mailText("MAIL_TEXT_REGARDS","Mit freundlichem Gruß,",false))).append("

").append(employeeName.isEmpty()?rich(local("MAIL_SALUTATION_TEXT")):employeeName+"
"+employeeContact).append("
"); String logo=local("IMG_LOGO_NAME");if(!logo.isBlank()&&!legacy.isBlank()){ if(!logo.matches("[A-Za-z0-9_. -]{1,150}")||logo.equals(".")||logo.equals(".."))throw bad("Der Dateiname des Mail-Logos ist ungültig."); - body.append("

\"Logo\"\"Logo\"

");warnings.add("Das Logo wird beim Empfänger von der bisherigen Anwendungsadresse geladen; die Vorschau lädt keine externen Bilder.");} body.append("

").append(rich(local("MAIL_FOOTER_WEB_PAGE"))).append("

"); if(local("MAIL_FOOTER_ENABLED").equals("1")){for(String key:List.of("MAIL_FOOTER_ADDRESS","MAIL_FOOTER_CONTACT","MAIL_FOOTER_IMPRESSUM"))body.append("
").append(rich(local(key))).append("
"); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderDeliveryData.java b/backend/src/main/java/de/stadtbote/votian/OrderDeliveryData.java index eb7adf6..a8994e3 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderDeliveryData.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderDeliveryData.java @@ -11,7 +11,7 @@ import java.util.List; import javax.imageio.ImageIO; import static de.stadtbote.votian.AdminSupport.*; import static de.stadtbote.votian.LegacyMapping.*; -import static de.stadtbote.votian.OrderEntryService.fields; + /** Read-only station evidence. Images are decoded in memory; no PHP temp files or remote URLs. */ final class OrderDeliveryData { diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryAccounting.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryAccounting.java index 37e0a92..32eeeac 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryAccounting.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryAccounting.java @@ -32,7 +32,7 @@ final class OrderEntryAccounting { int count=centers.size(),index=0; for(var entry:centers.entrySet()) { final int part=index++; - BigDecimal net=quote.pricePending()?portion(quote.net(),count,part):quote.components().stream().map(c -> portion(c.net(),count,part)).reduce(BigDecimal.ZERO,BigDecimal::add); + BigDecimal net=quote.pricePending()?portion(quote.net(),count,part):quote.components().stream().map(c -> portion(c.net(),count,part)).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)); BigDecimal toll=portion(nz(request.toll()),count,part),tax=money(net.add(toll).multiply(quote.taxRate()).movePointLeft(2)); allocations.add(new CostAllocation(entry.getKey(),text(entry.getValue(),"cmp_comp")+" · "+text(entry.getValue(),"csc_name"),net,toll,tax,net.add(toll).add(tax))); } @@ -50,7 +50,7 @@ final class OrderEntryAccounting { throw invalid("Bei Kurier-Unteraufträgen die einzelne Frachtvergütung und den Kurierzuschlag leerlassen. Es gelten die aufgeteilten Beträge und der Kundenzuschlag."); long payer=catalog.calculatorPayer(user); BigDecimal discount=catalog.enabled(user.headquartersId(),"INV_CHILDREN_DISCOUNT")?quote.discount():BigDecimal.ZERO; - BigDecimal service=quote.components().stream().filter(c -> c.name().equals("Servicepreis")).map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add); + BigDecimal service=quote.components().stream().filter(c -> c.name().equals("Servicepreis")).map(entryValue -> entryValue.price()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)); boolean taxOverride=catalog.enabled(0,"MASK_CHILDREN_TAX_HARDCODED"); var seen=new HashSet(); BigDecimal base=BigDecimal.ZERO; @@ -68,14 +68,14 @@ final class OrderEntryAccounting { ObjectNode tree=JSON.valueToTree(quote); tree.set("costAllocations",JSON.valueToTree(allocations));tree.set("courierAllocations",JSON.valueToTree(couriers));tree.set("articleLines",JSON.valueToTree(articles)); if(!allocations.isEmpty()) { - tree.put("tax",allocations.stream().map(CostAllocation::tax).reduce(BigDecimal.ZERO,BigDecimal::add)); - tree.put("gross",allocations.stream().map(CostAllocation::gross).reduce(BigDecimal.ZERO,BigDecimal::add)); + tree.put("tax",allocations.stream().map(entryValue -> entryValue.tax()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))); + tree.put("gross",allocations.stream().map(entryValue -> entryValue.gross()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))); } if(!couriers.isEmpty()) { - BigDecimal net=couriers.stream().map(CourierAllocation::net).reduce(BigDecimal.ZERO,BigDecimal::add); - tree.put("courierNet",net);tree.put("courierSubtotal",couriers.stream().map(CourierAllocation::subtotal).reduce(BigDecimal.ZERO,BigDecimal::add)); - tree.put("courierBase",couriers.stream().map(c -> c.subtotal().subtract(c.service())).reduce(BigDecimal.ZERO,BigDecimal::add)); - tree.put("courierService",couriers.stream().map(CourierAllocation::service).reduce(BigDecimal.ZERO,BigDecimal::add)); + BigDecimal net=couriers.stream().map(entryValue -> entryValue.net()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)); + tree.put("courierNet",net);tree.put("courierSubtotal",couriers.stream().map(entryValue -> entryValue.subtotal()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))); + tree.put("courierBase",couriers.stream().map(c -> c.subtotal().subtract(c.service())).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))); + tree.put("courierService",couriers.stream().map(entryValue -> entryValue.service()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))); tree.put("courierMarkup",quote.markup());tree.put("courierDeduction",BigDecimal.ZERO);tree.put("courierAgio",BigDecimal.ZERO); tree.put("courierLimitExceeded",nz(quote.courierLimit()).signum()>0 && net.compareTo(money(quote.net().multiply(quote.courierLimit()).movePointLeft(2)))>0); } diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryController.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryController.java index c96a67f..1ebdd10 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryController.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryController.java @@ -26,6 +26,6 @@ public class OrderEntryController { @PostMapping("/quote") public Quote quote(Authentication auth,@Valid @RequestBody Request request,@RequestParam(defaultValue="false") boolean refreshRoute) {return service.quote(user(auth),request,refreshRoute);} @PostMapping public ResponseEntity create(Authentication auth,@Valid @RequestBody Request request) { var result=service.create(user(auth),request); - return ResponseEntity.status(result.replayed()?200:201).location(URI.create("/api/orders/"+result.id())).body(result); + return ResponseEntity.status(result.replayed()?200:201).location(java.util.Objects.requireNonNull(URI.create("/api/orders/"+result.id()))).body(result); } } diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryExtras.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryExtras.java index 1d1301b..4a0dfbf 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryExtras.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryExtras.java @@ -27,7 +27,7 @@ final class OrderEntryExtras { if(request.payment()==Payment.INVOICE && !request.stops().getFirst().country().equals("DE") && text(payer,"cmp_stax_idno").isBlank()) throw invalid("Für eine Auslandsabholung auf Rechnung fehlt die Umsatzsteuer-ID des Zahlers im Kundenstamm."); if(!request.services().isEmpty() && request.pricing().mode()!=PriceMode.SERVICES)throw invalid("Für Leistungspositionen bitte die Preisermittlung Leistungskalkulation wählen."); - var filters=user.customerAccount()?new CustomerEntryAccess(db).state(user).filters().stream().map(r->text(r,"code")).toList():catalog.filters(user).stream().map(CourierFilter::code).toList(); + var filters=user.customerAccount()?new CustomerEntryAccess(db).state(user).filters().stream().map(r->text(r,"code")).toList():catalog.filters(user).stream().map(entryValue -> entryValue.code()).toList(); if(!filters.containsAll(request.courierFilters()))throw invalid("Ein gewählter Kurierfilter ist nicht mehr verfügbar."); for(Station stop:request.stops()) { if(stop.billingPostalId()!=null && db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_id=?",stop.billingPostalId()).isEmpty()) @@ -50,7 +50,7 @@ final class OrderEntryExtras { if(pair.length!=2 || !pair[0].trim().matches("[A-Z]{2}"))throw invalid("Die Länderfreigaben für die Kurierverteilung sind ungültig."); whitelist.put(pair[0].trim(),new HashSet<>(Arrays.asList(pair[1].split(",")))); } - var countries=request.stops().stream().map(Station::country).distinct().toList(); + var countries=request.stops().stream().map(entryValue -> entryValue.country()).distinct().toList(); var couriers=db.queryForList(""" SELECT DISTINCT c.cr_id,u.usr_country FROM courier c JOIN company cmp ON cmp.cmp_id=c.cmp_id JOIN couriervehicle cv ON cv.cr_id=c.cr_id JOIN user u ON u.usr_id=c.usr_id diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryMail.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryMail.java index a4103ad..46893d5 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryMail.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryMail.java @@ -15,10 +15,16 @@ import static de.stadtbote.votian.OrderEntryCatalog.invalid; @RestController @RequestMapping("/api/orders/entry/status-mail") class OrderEntryMail { - record Selection(@Size(max=2000) String recipients,@NotBlank @Pattern(regexp="[a-f0-9]{64}") String revision) {} + record Selection(@Size(max=2000) String recipients,@NotBlank @Pattern(regexp="[a-f0-9]{64}") String revision,Boolean replaceStandardRecipients) { + Selection(String recipients,String revision){this(recipients,revision,null);} + boolean replacesStandard(){return Boolean.TRUE.equals(replaceStandardRecipients);} + @Override public String toString(){return "Selection[recipients="+recipients+", revision="+revision+(replacesStandard()?", replaceStandardRecipients=true":"")+"]";} + } record Policy(List automaticRecipients,List events,List requestRecipients, boolean additionalAllowed,List warnings,String revision) {} - record Plan(List automaticRecipients,List additionalRecipients,List events,List warnings) {} + record Plan(List automaticRecipients,List additionalRecipients,List events,List warnings,boolean replaceStandardRecipients) { + Plan(List automaticRecipients,List additionalRecipients,List events,List warnings){this(automaticRecipients,additionalRecipients,events,warnings,false);} + } private static final ObjectMapper JSON=new ObjectMapper().findAndRegisterModules(); private final DatabaseService db;private final OrderEntryCatalog catalog; OrderEntryMail(DatabaseService db,OrderEntryCatalog catalog){this.db=db;this.catalog=catalog;} @@ -93,7 +99,9 @@ class OrderEntryMail { if(!policy.revision().equals(request.statusMail().revision()))throw OrderEntryService.conflict("Die Statusmail-Vorgaben wurden geändert. Bitte die Mailvorgaben neu laden und den Preis erneut berechnen."); var recipients=addresses(request.statusMail().recipients()); if(!recipients.isEmpty()&&(request.offer()||!policy.additionalAllowed()))throw invalid("Zusätzliche Statusmail-Empfänger sind für diesen Auftrag nicht verfügbar."); - return new Plan(request.offer()?List.of():policy.automaticRecipients(),recipients,request.offer()?List.of():policy.events(),policy.warnings()); + boolean replace=request.statusMail().replacesStandard(); + if(replace&&(recipients.isEmpty()||request.offer()||!policy.additionalAllowed()))throw invalid("Zum Ersetzen der Standardempfänger sind gültige Auftragsempfänger und eingeschaltete Statusmails erforderlich."); + return new Plan(request.offer()||replace?List.of():policy.automaticRecipients(),recipients,request.offer()?List.of():policy.events(),policy.warnings(),replace); } Quote apply(Quote quote,Plan plan){ if(plan==null)return quote; diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryModels.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryModels.java index 199aeb8..78e1017 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryModels.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryModels.java @@ -26,14 +26,17 @@ public final class OrderEntryModels { @Size(max=50) String contact, @Size(max=20) String phone, @Size(max=2000) String note, @Size(max=30) String reference, @Min(0) Integer photoWorkflow, @Positive Long billingPostalId, @Size(max=30) List<@Valid Attachment> attachments, - @Email @Size(max=60) String trackingEmail) { + @Email @Size(max=60) String trackingEmail, Boolean classicPhoto) { + public Station(Long costCenterId,Models.StopType type,String name,String street,String houseNumber,String postalCode,String city,String country,String contact,String phone,String note,String reference,Integer photoWorkflow,Long billingPostalId,List attachments,String trackingEmail) { + this(costCenterId,type,name,street,houseNumber,postalCode,city,country,contact,phone,note,reference,photoWorkflow,billingPostalId,attachments,trackingEmail,null); + } public Station { attachments=attachments==null?List.of():List.copyOf(attachments); } public Station(Long costCenterId,Models.StopType type,String name,String street,String houseNumber,String postalCode,String city,String country,String contact,String phone,String note,String reference,Integer photoWorkflow,Long billingPostalId,List attachments) { this(costCenterId,type,name,street,houseNumber,postalCode,city,country,contact,phone,note,reference,photoWorkflow,billingPostalId,attachments,null); } // The entry receipt hash historically uses record text. Keep absent new fields // byte-for-byte compatible, without replacing any user-supplied text. - @Override public String toString(){return "Station[costCenterId="+costCenterId+", type="+type+", name="+name+", street="+street+", houseNumber="+houseNumber+", postalCode="+postalCode+", city="+city+", country="+country+", contact="+contact+", phone="+phone+", note="+note+", reference="+reference+", photoWorkflow="+photoWorkflow+", billingPostalId="+billingPostalId+", attachments="+attachments+(trackingEmail==null?"":", trackingEmail="+trackingEmail)+"]";} + @Override public String toString(){return "Station[costCenterId="+costCenterId+", type="+type+", name="+name+", street="+street+", houseNumber="+houseNumber+", postalCode="+postalCode+", city="+city+", country="+country+", contact="+contact+", phone="+phone+", note="+note+", reference="+reference+", photoWorkflow="+photoWorkflow+", billingPostalId="+billingPostalId+", attachments="+attachments+(trackingEmail==null?"":", trackingEmail="+trackingEmail)+(classicPhoto==null?"":", classicPhoto="+classicPhoto)+"]";} } public record Attachment(@NotBlank @Size(max=255) String filename, @NotBlank @Size(max=1500) String viewUrl, @Size(max=200) String remark) {} @@ -177,9 +180,15 @@ public final class OrderEntryModels { public record ScheduleItem(LocalDateTime pickupAt,int count,BigDecimal net,BigDecimal gross,BigDecimal courierNet,String priceFingerprint) {} public record PhotoWorkflow(int number, String name, boolean defaultWorkflow, int min, int max, String stationScope, List positions) {} public record PhotoSet(int number, String name, boolean defaultSet, List workflows) {} - public record PhotoOptions(boolean configured, List sets) {} - public record PhotoPlan(PhotoSet set, List choices) {} - public record PhotoChoice(int station, int number, String name) {} + public record ClassicPhotos(boolean disabled,boolean required,int minimum,int maximum,boolean forceAllowed) {} + public record PhotoOptions(boolean configured, List sets,ClassicPhotos classic) { + public PhotoOptions(boolean configured,List sets){this(configured,sets,null);} + } + public record PhotoPlan(PhotoSet set, List choices,Boolean workflowEnabled) {public PhotoPlan(PhotoSet set,List choices){this(set,choices,null);}} + public record PhotoChoice(int station, int number, String name,Integer minimum,Integer maximum,boolean forced) { + public PhotoChoice(int station,int number,String name){this(station,number,name,null,null,false);} + public boolean classic(){return minimum!=null;} + } public record HeadquartersRoute(long id, String name, BigDecimal roundTripKm) {} public record RouteInfo(BigDecimal distanceKm, BigDecimal calculationKm, long durationSeconds, long dispatchHeadquartersId, String dispatchHeadquarters, long pricingHeadquartersId, diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryScopeFilter.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryScopeFilter.java index 417fcfd..28d7199 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryScopeFilter.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryScopeFilter.java @@ -13,7 +13,7 @@ import java.io.IOException; final class OrderEntryScopeFilter extends OncePerRequestFilter { private final OrderEntryHeadquarters headquarters;private final ObjectMapper json; OrderEntryScopeFilter(OrderEntryHeadquarters headquarters,ObjectMapper json){this.headquarters=headquarters;this.json=json;} - @Override protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain) throws ServletException,IOException { + @Override protected void doFilterInternal(@org.springframework.lang.NonNull HttpServletRequest request,@org.springframework.lang.NonNull HttpServletResponse response,@org.springframework.lang.NonNull FilterChain chain) throws ServletException,IOException { String selected=request.getHeader("X-Order-Headquarters"),path=request.getServletPath(); boolean entry=path.startsWith("/api/orders/entry/") || path.equals("/api/orders/quote") || path.equals("/api/orders") && request.getMethod().equals("POST"); if(selected==null || !entry || path.equals("/api/orders/entry/headquarters")){chain.doFilter(request,response);return;} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryService.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryService.java index 6311b68..4a0dba0 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryService.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryService.java @@ -93,6 +93,7 @@ public class OrderEntryService { if(replay(system,request,requestHash(request))!=null)throw conflict("Der Fortsetzungsbeleg existiert bereits, aber die Auftragskette wurde verändert."); var current=prepare(system,request,request.pickupAt()); if(id(current.payer(),"cs_id")!=id(prepared.payer(),"cs_id") || current.photoCustomer()!=prepared.photoCustomer())throw conflict("Der Dauerauftragszahler wurde geändert."); + photos.validateLocalPlan(request,current.photoCustomer(),selections); inventory.lock(system,request); var item=batch(system,request,current,route,selections,true,emission).items().getFirst(); if(!preview.fingerprint().equals(item.quote().fingerprint()))throw conflict("Die Dauerauftragskalkulation wurde inzwischen geändert."); @@ -134,6 +135,7 @@ public class OrderEntryService { new CentralEntryTracking(db).lock(request); var prepared=prepare(user,request); if(id(prepared.payer(),"cs_id")!=id(preview.payer(),"cs_id") || prepared.photoCustomer()!=preview.photoCustomer())throw conflict("Der Zahler hat sich geändert. Bitte die Vorschau neu laden."); + photos.validateLocalPlan(request,prepared.photoCustomer(),selections); inventory.lock(user,request); Batch batch=batch(user,request,prepared,route,selections,true,emission); if(request.quoteFingerprint()==null || !batch.summary().fingerprint().equals(request.quoteFingerprint())) @@ -155,6 +157,7 @@ public class OrderEntryService { previousIds[i]=createdItem.id(); } } + if(first==null)throw new IllegalStateException("Die Auftragsserie enthält keinen Auftrag."); if(ids.size()>1)extras.saveGroup(first.id(),ids); return new Created(first.id(),first.number(),first.legacyStatus(),false,first.pickupAt(),first.photoSyncPending(),ids); }); @@ -306,7 +309,7 @@ public class OrderEntryService { private Created persist(LegacyUser user,Request request,Prepared prepared,Quote quote,String requestHash,boolean continuation) { if(request.allCouriers()&&!CallCenterAccess.allCouriers(db,user))throw invalid("CC24: Die Verteilung an alle Kuriere ist in dieser Niederlassung nicht freigegeben."); long hq=user.headquartersId(); - boolean thirdCountry=OrderThirdCountry.outside(catalog.parameter(0,"EU_COUNTRYCODES"),request.stops().stream().map(Station::country).toList()); + boolean thirdCountry=OrderThirdCountry.outside(catalog.parameter(0,"EU_COUNTRYCODES"),request.stops().stream().map(entryValue -> entryValue.country()).toList()); boolean retrospective=request.mode()==Mode.RETROSPECTIVE,reservation=request.mode()==Mode.RESERVATION || request.mode()==Mode.RECURRING; boolean longhaul=quote.route()!=null && quote.route().longhaul(); List photoChoices=quote.photos()==null?List.of():quote.photos(); @@ -317,7 +320,7 @@ public class OrderEntryService { boolean global=request.allCouriers() || status==99; if(global)status=0; // Exclusion list is written atomically before the dispatcher can see this order. int desiredStatus=status; - if((quote.photoSet()!=null || !photoChoices.isEmpty()) && !retrospective)status=9; + if((quote.photoSet()!=null || photoChoices.stream().anyMatch(p->!p.classic())) && !retrospective)status=9; long payer=request.payment()==Payment.CASH?prepared.cashPayer():request.payerCostCenterId(); var job=fields("csc_id",0,"hq_id",hq,"hq_id_dispo",quote.route()==null?prepared.dispatchHq():quote.route().dispatchHeadquartersId(),"hq_id_sales",id(prepared.payer(),"hq_id"), "csc_id_payer",payer,"csc_id_payer_cash",request.payment()==Payment.CASH?request.payerCostCenterId():0, @@ -329,12 +332,14 @@ public class OrderEntryService { "jb_weight",nz(request.weight()),"jb_crvh_length",nz(request.length()),"jb_crvh_width",nz(request.width()), "jb_crvh_height",nz(request.height()),"jb_crvh_position",nz(request.positions()), "jb_fixprice",!quote.batch()?(quote.calculatedBase()!=null?quote.calculatedBase():request.pricing().mode()==PriceMode.FIXED?nz(request.pricing().fixedPrice()):BigDecimal.ZERO):BigDecimal.ZERO, - "jb_serviceprice",quote.components().stream().filter(c -> c.name().equals("Servicepreis")).map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add),"jb_totalprice",quote.net(),"jb_subtotalprice",quote.subtotal(), + "jb_serviceprice",quote.components().stream().filter(c -> c.name().equals("Servicepreis")).map(entryValue -> entryValue.price()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)),"jb_totalprice",quote.net(),"jb_subtotalprice",quote.subtotal(), "jb_cr_subprice",longhaul&&!retrospective?BigDecimal.ZERO:positive(request.pricing().courierPrice())?request.pricing().courierPrice():nz(quote.calculatedCourierBase()),"jb_cr_price",quote.courierNet(),"jb_markup",quote.markup(), "jb_sales_tax_rate",quote.taxRate(),"jb_sales_tax_rate_sign",quote.taxSign(),"jb_invmode",quote.invoiceMode(), "jb_postage",number(prepared.payer(),"cmp_postage"),"jb_incomplete",request.incomplete()?1:0,"jb_cr_fav",prepared.favoured()?1:0); + if(quote.statusMail()!=null&"e.statusMail().replaceStandardRecipients()) + job.putAll(fields("jb_automailsent",996,"jb_automailsent2",996,"jb_automailsent3",996)); if(request.pricing().mode()==PriceMode.SERVICES) { - job.put("jb_fixprice",quote.batch()?BigDecimal.ZERO:quote.components().stream().limit(request.services().size()).map(Component::subtotal).reduce(BigDecimal.ZERO,BigDecimal::add)); + job.put("jb_fixprice",quote.batch()?BigDecimal.ZERO:quote.components().stream().limit(request.services().size()).map(entryValue -> entryValue.subtotal()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))); job.put("jb_freetext_2",extras.calculatorText(user,request,quote)); } if(user.customerAccount())job.put("csc_id",id(CustomerPortalScope.verify(db,user),"csc_id")); @@ -354,9 +359,9 @@ public class OrderEntryService { job.put("hq_id_exec",hq);job.put("jb_longhaul_export",Boolean.TRUE.equals(request.bwv())?1:0);} String tourData=request.stops().stream().map(s -> s.name()+", "+s.street()+" "+s.houseNumber()+", "+s.postalCode()+" "+s.city()).reduce((a,b)->a+" | "+b).orElse(""); // This compact field is parsed by PHP as three parallel, semicolon-separated lists. - job.put("jb_tourdata",request.stops().stream().map(Station::postalCode).collect(java.util.stream.Collectors.joining(";"))+"|" - +request.stops().stream().map(Station::city).collect(java.util.stream.Collectors.joining(";"))+"|" - +request.stops().stream().map(Station::country).collect(java.util.stream.Collectors.joining(";"))); + job.put("jb_tourdata",request.stops().stream().map(entryValue -> entryValue.postalCode()).collect(java.util.stream.Collectors.joining(";"))+"|" + +request.stops().stream().map(entryValue -> entryValue.city()).collect(java.util.stream.Collectors.joining(";"))+"|" + +request.stops().stream().map(entryValue -> entryValue.country()).collect(java.util.stream.Collectors.joining(";"))); job.put("jb_incomplete",thirdCountry||isIncomplete(user,request,prepared.payer(),quote)?1:0); String filterNotes=dispatchDefaults(user,request,prepared,job); job.put("jb_invtext",tourData);job.put("jb_invtext_cr",tourData); @@ -400,7 +405,10 @@ public class OrderEntryService { tourIds.add(tourId); extras.attachments(tourId,stop.attachments()); final int stationIndex=i+1; - photoChoices.stream().filter(p -> p.station()==stationIndex).findFirst().ifPresent(p -> photoSelections.add(new OrderPhotoWorkflows.Selection(orderId,tourId,p.number()))); + photoChoices.stream().filter(p -> p.station()==stationIndex).findFirst().ifPresent(p -> { + if(p.classic())db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",tourId,"gdc_gen_fieldname","tr_photo","gdc_content",p.minimum()+"|"+p.maximum(),"gdc_context",orderId+(p.forced()?"|forced":""))); + else photoSelections.add(new OrderPhotoWorkflows.Selection(orderId,tourId,p.number())); + }); } writePricing(user,request,prepared,quote,orderId); inventory.persist(user,request,orderId,tourIds,prepared.now()); @@ -433,7 +441,7 @@ public class OrderEntryService { CustomerPendingPrice.save(db,orderId,quote.pricePending()); long payer=request.payment()==Payment.CASH?prepared.cashPayer():request.payerCostCenterId(); if(!quote.batch() && request.pricing().mode()==PriceMode.SERVICES)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,0,'Fixpreis',0,'',?,0)",orderId,payer, - quote.components().stream().limit(request.services().size()).map(Component::net).reduce(BigDecimal.ZERO,BigDecimal::add)); + quote.components().stream().limit(request.services().size()).map(entryValue -> entryValue.net()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))); if(!quote.batch())for(Component component:quote.components().stream().skip(request.pricing().mode()==PriceMode.SERVICES?request.services().size():0).toList())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,?,0,?,?,?) @@ -456,7 +464,7 @@ public class OrderEntryService { price(orderId,10,quote.courierSubtotal(),""); if(user.customerAccount()) { var access=customerAccess();BigDecimal selfDiscount=access.selfDiscount(user,id(CustomerPortalScope.verify(db,user),"cs_id")); - if(selfDiscount.signum()>0)price(orderId,13,selfDiscount,(quote.calculatedBase()!=null?LegacyOrderPricing.markup(quote.calculatedBase(),quote.markup()):quote.components().stream().map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add)).toPlainString()); + if(selfDiscount.signum()>0)price(orderId,13,selfDiscount,(quote.calculatedBase()!=null?LegacyOrderPricing.markup(quote.calculatedBase(),quote.markup()):quote.components().stream().map(entryValue -> entryValue.price()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))).toPlainString()); } LonghaulRemuneration.storeBasis(db,orderId,quote.longhaulBasis()); if(request.pricing().markup()!=null)price(orderId,8,quote.markup(),""); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryTemplates.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryTemplates.java index b0f0b25..5f78556 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryTemplates.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryTemplates.java @@ -88,6 +88,7 @@ public class OrderEntryTemplates { var copiedFilters=OrderTemplateFilters.copy(text(job,"jb_cr_filter"),text(job,"jb_cr_filter_opt"),catalog.parameter(user.headquartersId(),"MASK_MANDATORY_FILTERS")); result.put("courierFilters",copiedFilters); result.put("statusMailRecipients",meta.getOrDefault("jb_cashpayer_mail","")); + result.put("replaceStandardRecipients",id(job,"jb_automailsent")==996&&!text(meta,"jb_cashpayer_mail").isBlank()); result.put("origin",OrderEntryOrigins.copied(job));result.put("originOther",meta.getOrDefault("jb_origin_other","")); result.put("entryMode",modes.copyAccounting()||modes.laterAccounting()?"RETROSPECTIVE":"IMMEDIATE"); if(modes.copyAccounting()||!template&&!Set.of("","0").contains(text(job,"cr_sid"))) { diff --git a/backend/src/main/java/de/stadtbote/votian/OrderExecutionAccounting.java b/backend/src/main/java/de/stadtbote/votian/OrderExecutionAccounting.java index e18b70f..b68233d 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderExecutionAccounting.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderExecutionAccounting.java @@ -2,7 +2,7 @@ package de.stadtbote.votian; import de.stadtbote.votian.database.DatabaseService; import java.math.*; -import java.util.*; + import static de.stadtbote.votian.LegacyMapping.*; import static de.stadtbote.votian.AdminSupport.bad; diff --git a/backend/src/main/java/de/stadtbote/votian/OrderLabels.java b/backend/src/main/java/de/stadtbote/votian/OrderLabels.java index c407246..876c153 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderLabels.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderLabels.java @@ -16,7 +16,7 @@ final class OrderLabels { OrderLabels(DatabaseService db,CustomerOrderLabels labels){this.db=db;this.labels=labels;} @GetMapping Object options(Authentication a,@PathVariable long order){return options(ApiController.user(a),order);} @GetMapping("/{kind}") Object view(Authentication a,@PathVariable long order,@PathVariable CustomerOrderLabels.Kind kind){return view(ApiController.user(a),order,kind);} - @GetMapping("/{kind}/pdf") ResponseEntity pdf(Authentication a,@PathVariable long order,@PathVariable CustomerOrderLabels.Kind kind,@RequestParam String revision){return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=Etiketten-"+order+(kind==CustomerOrderLabels.Kind.RETURN?"-Retoure":"")+".pdf").body(pdf(ApiController.user(a),order,kind,revision));} + @GetMapping("/{kind}/pdf") ResponseEntity pdf(Authentication a,@PathVariable long order,@PathVariable CustomerOrderLabels.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==CustomerOrderLabels.Kind.RETURN?"-Retoure":"")+".pdf").body(pdf(ApiController.user(a),order,kind,revision));} @GetMapping("/{kind}/page") Object page(Authentication a,@PathVariable long order,@PathVariable CustomerOrderLabels.Kind kind,@RequestParam String revision,@RequestParam(defaultValue="0") int page){return CustomerOrderLabelPdf.page(pdf(ApiController.user(a),order,kind,revision),page);} LegacyUser access(LegacyUser actor,long order){ actor.require(4);var user=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());user.require(4); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderListAppearance.java b/backend/src/main/java/de/stadtbote/votian/OrderListAppearance.java index a50d5d9..9006056 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderListAppearance.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderListAppearance.java @@ -66,7 +66,7 @@ final class OrderListAppearance { for(String key:order.listCells.keySet())if((Set.of("jb_bsg","jb_hq_id_bsg","jb_hq_id_exec_bsg").contains(key)||credit&&Set.of("business_volume","business_volume_gross","jb_cr_price").contains(key))&&OrderListCells.decimal(order.listCells.get(key)).signum()<0)cell(cells,key,"","#A11924",false,"","Negativer Betrag / Deckungsbeitrag"); if(order.legacyStorno>0)cell(cells,"jb_storno","#FFD3D3","",false,"",order.legacyStatusLabel); if(offer)order.listCells.computeIfPresent("cr_sid",(k,v)->"Angebot"); - String filters=parameter(parameters,hq,"MASK_JOBLIST_VEHICLE_FILTER",true);var required=new HashSet<>(Arrays.asList(text(row,"jb_cr_filter").split(",")));var displayed=Arrays.stream(filters.split(",")).map(String::trim).filter(k->!k.isBlank()&&required.contains(k)).distinct().toList();if(!displayed.isEmpty())order.listCells.computeIfPresent("vht_value",(k,v)->v+" "+String.join(" ",displayed)); + String filters=parameter(parameters,hq,"MASK_JOBLIST_VEHICLE_FILTER",true);var required=new HashSet<>(Arrays.asList(text(row,"jb_cr_filter").split(",")));var displayed=Arrays.stream(filters.split(",")).map(entryValue -> entryValue.trim()).filter(k->!k.isBlank()&&required.contains(k)).distinct().toList();if(!displayed.isEmpty())order.listCells.computeIfPresent("vht_value",(k,v)->v+" "+String.join(" ",displayed)); if(id(row,"jb_id_parent")>0)cell(cells,"jb_id","","",false,"","Unterauftrag zu #"+id(row,"jb_id_parent")); return new Appearance(tone,label,cells,new ArrayList<>(warnings),done,firstDone); } diff --git a/backend/src/main/java/de/stadtbote/votian/OrderListCells.java b/backend/src/main/java/de/stadtbote/votian/OrderListCells.java index fefe886..1568187 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderListCells.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderListCells.java @@ -2,12 +2,12 @@ package de.stadtbote.votian; import de.stadtbote.votian.database.DatabaseService; import java.math.*; -import java.time.*; + import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; import static de.stadtbote.votian.LegacyMapping.*; -import static de.stadtbote.votian.OrderEntryService.fields; + /** Allowlisted SQL projections: sorting happens before pagination, without multiplying job rows. */ final class OrderListCells { @@ -78,7 +78,7 @@ final class OrderListCells { } static void verifySources(DatabaseService db,List orders,Layout layout){ if(orders.isEmpty())return;String marks=String.join(",",Collections.nCopies(orders.size(),"?"));var ids=orders.stream().map(o->(Object)o.id).toList(); - var keys=layout.columns().stream().map(Column::key).toList();var priceSorts=new ArrayList();if(keys.contains("jb_km"))priceSorts.add(11);if(keys.contains("jb_co2e"))priceSorts.add(12);if(keys.contains("jb_km_zone"))priceSorts.add(15);if(keys.contains("jb_cr_mrkp"))priceSorts.addAll(List.of(1,9)); + var keys=layout.columns().stream().map(entryValue -> entryValue.key()).toList();var priceSorts=new ArrayList();if(keys.contains("jb_km"))priceSorts.add(11);if(keys.contains("jb_co2e"))priceSorts.add(12);if(keys.contains("jb_km_zone"))priceSorts.add(15);if(keys.contains("jb_cr_mrkp"))priceSorts.addAll(List.of(1,9)); if(!priceSorts.isEmpty()){ var args=new ArrayList(ids);args.addAll(priceSorts); if(!db.queryForList("SELECT jb_id FROM jobprice WHERE jb_id IN ("+marks+") AND mt_sort IN ("+String.join(",",Collections.nCopies(priceSorts.size(),"?"))+") GROUP BY jb_id,mt_sort HAVING COUNT(*)>1 LIMIT 1",args.toArray()).isEmpty())throw AdminSupport.bad("Ein Auftrag enthält mehrfach belegte KM-, CO₂- oder Zuschlagswerte. Bitte den Bestand klären."); @@ -89,7 +89,7 @@ final class OrderListCells { if(!db.queryForList("SELECT gdc_obj_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id IN ("+marks+") AND gdc_gen_fieldname IN ("+String.join(",",Collections.nCopies(fields.size(),"?"))+") GROUP BY gdc_obj_id,gdc_gen_fieldname HAVING COUNT(*)>1 LIMIT 1",args.toArray()).isEmpty())throw AdminSupport.bad("Ein Auftrag enthält mehrfach belegte Maut- oder Montagewerte. Bitte den Bestand klären."); } } - static String projection(Layout layout){return layout.columns().stream().map(Column::key).distinct().map(k->", "+layout.expressions().get(k)+" AS list_"+k).collect(Collectors.joining());} + static String projection(Layout layout){return layout.columns().stream().map(entryValue -> entryValue.key()).distinct().map(k->", "+layout.expressions().get(k)+" AS list_"+k).collect(Collectors.joining());} static Sorting sorting(List columns,List> rows,long hq,Models.OrderStatus status,LegacyReadService.ListQuery q){ if(q.sort()!=null&&!q.sort().isBlank())return new Sorting(q.sort(),q.descending()); boolean taken=q.listStatus()!=null?q.listStatus()==1:status==Models.OrderStatus.IN_PROGRESS; diff --git a/backend/src/main/java/de/stadtbote/votian/OrderListColumns.java b/backend/src/main/java/de/stadtbote/votian/OrderListColumns.java index f91b6a8..00787ab 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderListColumns.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderListColumns.java @@ -38,7 +38,7 @@ class OrderListColumns { static String value(List> rows,String key,long hq){String v=EmployeeListPreferences.value(rows.stream().filter(r->id(r,"emp_id")>0).toList(),key,hq);return v.isBlank()?global(rows,key,hq):v;} static Layout layout(List> rows,long hq){ String raw=value(rows,PARAMETER,hq);if(raw.isBlank()){var d=new ArrayList<>(defaults(rows));if(value(rows,"MASK_JOBLIST_GET_COURIER_DATA",hq).equals("1"))d.addAll(List.of("cr_cmp_remark","cr_cmp_remark2"));return new Layout(d,"");} - var parts=Arrays.stream(raw.split(",",-1)).map(String::strip).toList();var selected=parts.stream().filter(KEYS::contains).limit(KEYS.size()).toList(); + var parts=Arrays.stream(raw.split(",",-1)).map(entryValue -> entryValue.strip()).toList();var selected=parts.stream().filter(KEYS::contains).limit(KEYS.size()).toList(); boolean invalid=selected.size()!=parts.size();if(selected.isEmpty()){selected=defaults(rows);invalid=true;} return new Layout(selected,invalid?"Unbekannte alte Spalten werden nicht angezeigt. Bitte die Belegung vor dem Speichern prüfen.":""); } diff --git a/backend/src/main/java/de/stadtbote/votian/OrderListFilters.java b/backend/src/main/java/de/stadtbote/votian/OrderListFilters.java index 3f0b46d..6449cab 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderListFilters.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderListFilters.java @@ -21,7 +21,7 @@ public class OrderListFilters { public OrderListFilters(DatabaseService db){this.db=db;} static Map parse(String raw){ if(raw==null||raw.isBlank())return Map.of();if(raw.length()>8000)throw bad("Die Listenfilter sind zu umfangreich."); - try{var node=JSON.readTree(raw);if(!node.isObject())throw bad("Ungültige Listenfilter.");var values=new LinkedHashMap();var it=node.fields();while(it.hasNext()){var e=it.next();if(!e.getValue().isTextual())throw bad("Listenfilter benötigen Textwerte.");values.put(e.getKey(),e.getValue().asText());}return validate(values);} + try{var node=JSON.readTree(raw);if(!node.isObject())throw bad("Ungültige Listenfilter.");var values=new LinkedHashMap();var it=node.properties().iterator();while(it.hasNext()){var e=it.next();if(!e.getValue().isTextual())throw bad("Listenfilter benötigen Textwerte.");values.put(e.getKey(),e.getValue().asText());}return validate(values);} catch(java.io.IOException e){throw bad("Die Listenfilter sind nicht lesbar.");} } static Map validate(Map input){ diff --git a/backend/src/main/java/de/stadtbote/votian/OrderListTotals.java b/backend/src/main/java/de/stadtbote/votian/OrderListTotals.java index 807ec5a..f7c0f94 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderListTotals.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderListTotals.java @@ -7,7 +7,7 @@ import static de.stadtbote.votian.LegacyMapping.id; /** Totals for the returned page, before display rounding; child jobs follow the PHP customer mode. */ record OrderListTotals(int entries,int included,int excludedChildren,boolean gross,BigDecimal customer,BigDecimal courier,BigDecimal bsg) { static OrderListTotals read(List> rows,OrderListCells.Layout layout){ - var keys=layout.columns().stream().map(OrderListCells.Column::key).toList(); + var keys=layout.columns().stream().map(entryValue -> entryValue.key()).toList(); boolean gross=layout.relatedCustomers();String customerKey=gross?"business_volume_gross":"business_volume"; boolean customerVisible=keys.contains(customerKey),courierVisible=!gross&&keys.contains("jb_cr_price"); BigDecimal customer=BigDecimal.ZERO,courier=BigDecimal.ZERO;int included=0; diff --git a/backend/src/main/java/de/stadtbote/votian/OrderOperations.java b/backend/src/main/java/de/stadtbote/votian/OrderOperations.java index c2a75d1..454f498 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderOperations.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderOperations.java @@ -46,6 +46,7 @@ public class OrderOperations { var stops=r.stations().stream().map(s->validateFields(STOP_FIELDS,"orders",s.values())).toList(); if(stops.size()>1&&(((Number)stops.getFirst().get("tr_ware_from_to")).intValue()==1||stops.stream().noneMatch(s->((Number)s.get("tr_ware_from_to")).intValue()!=2)))throw bad("Die Tour benötigt eine Abholung am Anfang und eine Zustellung."); transactional();db.writeTransaction(()->{lockHeadquarters(db,u);if(support.receipt(u,r.requestId(),List.of(id,r))!=null)return null;var s=snapshot(u,id,true);unchanged(s,r.revision());writable(s); + OrderCompletionChecks.requireManualRelease(s.job(),values); OrderMetadata.checkName(db,s.job(),OrderMetadata.settings(db,u.headquartersId()),text(values,"jb_tourname"),true); var thirdCountry=new OrderThirdCountry(this);var thirdCountryBefore=thirdCountry.state(s,true); var original=new HashMap>();s.stops().forEach(t->original.put(id(t,"tr_id"),t));var seen=new HashSet(); @@ -61,7 +62,7 @@ public class OrderOperations { if(newThirdCountry||OrderThirdCountry.pending(thirdCountryBefore)&&(OrderThirdCountry.eu(thirdCountryBefore.eu()).isEmpty()||OrderThirdCountry.outside(thirdCountryBefore.eu(),stops.stream().map(t->text(t,"ad_country")).toList())))values.put("jb_incomplete",1); // Preserve padded/generated invoice texts when another form field is edited. for(String key:List.of("jb_invtext","jb_invtext_cr"))if(Objects.toString(r.values().get(key),"").equals(text(s.job(),key)))values.put(key,text(s.job(),key)); - boolean invoiceNoteChanged=r.values().containsKey(OrderInvoiceNotes.FIELD)&&OrderInvoiceNotes.replace(db,id,s.invoiceNotes(),invoiceNote); + if(r.values().containsKey(OrderInvoiceNotes.FIELD))OrderInvoiceNotes.replace(db,id,s.invoiceNotes(),invoiceNote); new OrderMediationDeadline(db).move(s.job(),s.mediationDeadline(),dateTime(values,"jb_ordertime")); values.put("jb_version",id(s.job(),"jb_version")+1);update(db,"job","jb_id",id,values);if(id(s.job(),"jb_status")==1)command(u,id(s.job(),"cr_id"),"5",Long.toString(id)); if(OrderCourierAgio.refresh(db,id)){markInvoiceDirty(id);OrderExecutionAccounting.refreshCompleted(db,catalog,id);} @@ -121,7 +122,7 @@ public class OrderOperations { } support.audit(u,credit,0,0,1,"Gegenbuchung zu Auftrag #"+id,r.requestId());return credit; } - static BigDecimal customerNet(Snapshot s){return s.prices().isEmpty()?decimal(s.job(),"jb_totalprice"):s.prices().stream().map(BillingService::net).reduce(BigDecimal.ZERO,BigDecimal::add);} + static BigDecimal customerNet(Snapshot s){return s.prices().isEmpty()?decimal(s.job(),"jb_totalprice"):s.prices().stream().map(BillingService::net).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue));} LegacyOrderPricing.Tariff cancellationTariff(LegacyUser u,Snapshot s){ var payer=one(db.queryForList("SELECT cs_id FROM costcenter WHERE csc_id=?",id(s.job(),"csc_id_payer"))); var vehicle=one(db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",id(s.job(),"vht_id"))); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderPayments.java b/backend/src/main/java/de/stadtbote/votian/OrderPayments.java index 5d91e21..77c7efc 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderPayments.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderPayments.java @@ -54,7 +54,7 @@ public final class OrderPayments { } List> choices(String type,long id){var rows=db.queryForList("SELECT mt_sort,mt_value FROM metatype WHERE mt_type='payment_type' AND mt_objtype=? AND mt_objid=? ORDER BY mt_sort LIMIT 129",type,id);if(rows.size()>128||rows.stream().map(r->id(r,"mt_sort")).distinct().count()!=rows.size()||rows.stream().anyMatch(r->id(r,"mt_sort")<0||id(r,"mt_sort")>127))throw bad("Der Zahlartenkatalog ist ungültig oder mehrdeutig.");return rows;} static BigDecimal total(State s){return net(s.snapshot().job().get("jb_totalprice"));} - static BigDecimal paid(State s){return s.snapshot().payments().stream().map(p->net(p.get("jbp_price"))).reduce(BigDecimal.ZERO,BigDecimal::add);} + static BigDecimal paid(State s){return s.snapshot().payments().stream().map(p->net(p.get("jbp_price"))).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue));} static BigDecimal remaining(State s){return total(s).subtract(paid(s));} static BigDecimal gross(State s,BigDecimal amount){return amount.multiply(s.factor()).setScale(2,RoundingMode.HALF_UP);} static String label(State s,int mode){return s.choices().stream().filter(c->id(c,"mt_sort")==mode).map(c->text(c,"mt_value")).findFirst().orElse("Zahlart #"+mode);} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderPhotoWorkflows.java b/backend/src/main/java/de/stadtbote/votian/OrderPhotoWorkflows.java index 179d750..61cd457 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderPhotoWorkflows.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderPhotoWorkflows.java @@ -26,12 +26,14 @@ public class OrderPhotoWorkflows { public OrderPhotoWorkflows(DatabaseService db,OrderEntryCatalog catalog,OrderIntegrationClient client,ObjectMapper json) { this.db=db;this.catalog=catalog;this.client=client;this.json=json; } + boolean enabled(){return "true".equals(catalog.parameter(0,"TR_PHOTO_WORKFLOW"));} private String url() {return catalog.parameter(0,"URL_CS_PHOTO_WORKFLOW").trim().replaceAll("/+$","");} public PhotoOptions options(LegacyUser user,long costCenter) { user.require(7); return options(id(catalog.billingCenter(user,costCenter,false),"cs_id")); } PhotoOptions options(long customer) { + if(!enabled())return new PhotoOptions(false,List.of(),classic(customer)); String url=url(); if(url.isBlank())return new PhotoOptions(false,List.of()); List sets=readSets(url,customer); @@ -60,7 +62,7 @@ public class OrderPhotoWorkflows { } sets.add(new PhotoSet(set,item.path("name").asText(),item.path("default").asBoolean(),List.copyOf(flows))); } - if(sets.stream().filter(PhotoSet::defaultSet).count()>1)throw unavailable("Der Fotodienst hat mehrere Standardsets geliefert."); + if(sets.stream().filter(entryValue -> entryValue.defaultSet()).count()>1)throw unavailable("Der Fotodienst hat mehrere Standardsets geliefert."); return List.copyOf(sets); } private static boolean allowed(JsonNode node){return !node.hasNonNull("allowed") || node.path("allowed").isBoolean() && node.path("allowed").asBoolean();} @@ -82,21 +84,55 @@ public class OrderPhotoWorkflows { }; } PhotoPlan resolve(Request request,long customer) { - var options=options(customer);PhotoSet set; + var options=options(customer);if(options.classic()!=null)return classicPlan(request,options.classic()); + if(request.stops().stream().anyMatch(s->Boolean.TRUE.equals(s.classicPhoto())))throw invalid("Fotoworkflows sind jetzt eingeschaltet. Bitte die Fotoauswahl neu laden."); + PhotoSet set; if(request.photoSet()!=null)set=options.sets().stream().filter(v->v.number()==request.photoSet()).findFirst().orElseThrow(()->invalid("Das Foto-Set ist für diesen Kunden nicht mehr freigegeben. Bitte neu laden.")); - else set=options.sets().stream().filter(PhotoSet::defaultSet).findFirst().orElse(options.sets().isEmpty()?null:options.sets().getFirst()); + else set=options.sets().stream().filter(entryValue -> entryValue.defaultSet()).findFirst().orElse(options.sets().isEmpty()?null:options.sets().getFirst()); List choices=new ArrayList<>(); for(int i=0;i offered=set==null?List.of():set.workflows().stream().filter(f->offered(f,station.type())).toList(); String position=i==request.stops().size()-1?"LAST":i==0?"FIRST":"MIDDLE"; - var flow=selected==null?offered.stream().filter(f->f.positions().contains(position)).findFirst().orElseGet(()->offered.stream().filter(PhotoWorkflow::defaultWorkflow).findFirst().orElse(null)): + var flow=selected==null?offered.stream().filter(f->f.positions().contains(position)).findFirst().orElseGet(()->offered.stream().filter(entryValue -> entryValue.defaultWorkflow()).findFirst().orElse(null)): offered.stream().filter(f->f.number()==selected).findFirst().orElseThrow(()->invalid("Die Fotoauswahl passt nicht mehr zum Set oder zur Station. Bitte neu auswählen.")); if(flow!=null)choices.add(new PhotoChoice(i+1,flow.number(),flow.name())); } - return new PhotoPlan(set,List.copyOf(choices)); + return new PhotoPlan(set,List.copyOf(choices),true); + } + ClassicPhotos classic(long customer){ + var v=new CustomerEntryPhotos(db).config(customer); + return new ClassicPhotos(v.mode()!=null&&v.mode()==0,v.mode()!=null&&(v.mode()==1||v.mode()==3),v.minimum()==null?1:v.minimum(),v.maximum()==null?1:v.maximum(),v.mode()==null||v.mode()!=1); + } + PhotoPlan classicPlan(Request request,ClassicPhotos config){ + if(request.photoSet()!=null||request.stops().stream().anyMatch(s->s.photoWorkflow()!=null))throw invalid("Fotoworkflows sind ausgeschaltet. Bitte die Fotoauswahl neu laden."); + var choices=new ArrayList(); + for(int i=0;i0){ + int min=config.disabled()?0:config.minimum(),max=config.disabled()?0:config.maximum(); + choices.add(new PhotoChoice(i+1,0,max==0?"Fotos gesperrt":min+"–"+max+" Fotos",min,max,false)); + }else if("1".equals(catalog.parameter(0,"MASK_TR_PHOTO_FORCE"))&&config.forceAllowed()){ + String range=catalog.parameter(0,"MASK_MIN_MAX_TR_PHOTO_FORCE"); + if(range==null||!range.matches("[0-5]\\|[0-5]")||range.charAt(0)>range.charAt(2))throw invalid("Die allgemeine Fotoanzahl ist ungültig."); + int min=range.charAt(0)-'0',max=range.charAt(2)-'0';choices.add(new PhotoChoice(i+1,0,min+"–"+max+" Fotos (allgemeine Vorgabe)",min,max,true)); + } + } + return new PhotoPlan(null,List.copyOf(choices),false); + } + void validateLocalPlan(Request request,long customer,PhotoPlan plan){ + if(plan==null||plan.workflowEnabled()==null)return; + boolean active=enabled(); + if(active!=plan.workflowEnabled()||!active&&!classicPlan(request,classic(customer)).equals(plan))throw OrderEntryService.conflict("Die Fotovorgaben wurden inzwischen geändert. Bitte neu laden und den Preis erneut berechnen."); } Request continuation(Request request) { + if(!enabled()){ + var tree=(com.fasterxml.jackson.databind.node.ObjectNode)json.valueToTree(request); + var rows=db.queryForList("SELECT t.tr_sort FROM tour t JOIN genericdatacontainer g ON g.gdc_obj_type='tr' AND g.gdc_obj_id=t.tr_id AND g.gdc_gen_fieldname='tr_photo' WHERE t.jb_id=? AND g.gdc_content<>'0|0' AND g.gdc_context NOT LIKE '%|forced'",request.sourceOrderId()); + for(var row:rows){int index=(int)id(row,"tr_sort")-1;if(index>=0&&index storedSelections(long job) { - String endpoint=url();if(endpoint.isBlank())return Map.of(); + String endpoint=url();if(!enabled()||endpoint.isBlank())return Map.of(); var selected=new HashMap(); for(var item:result(client.get(endpoint+"/selection?jbId="+job,"Der Fotodienst"))) { if(!item.path("tr_id").asText().matches("[1-9][0-9]{0,17}") || !item.path("number").asText().matches("[0-9]{1,9}"))throw unavailable("Der Fotodienst hat eine ungültige gespeicherte Auswahl geliefert."); @@ -116,13 +152,19 @@ public class OrderPhotoWorkflows { } @SuppressWarnings("unchecked") void copyTemplate(long job,List> source,Map data) { + if(!enabled()){ + var stops=(List>)data.get("stops"); + var rows=db.queryForList("SELECT gdc_obj_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_gen_fieldname='tr_photo' AND (gdc_context=? OR gdc_context=?)",Long.toString(job),job+"|forced"); + for(int n=0;nid(r,"gdc_obj_id")==station&&!text(r,"gdc_content").equals("0|0")&&!text(r,"gdc_context").endsWith("|forced")))stops.get(n).put("classicPhoto",true);} + return; + } var selections=storedSelections(job);if(selections.isEmpty())return; var stops=(List>)data.get("stops");boolean copied=false; for(int n=0;nstops.stream().allMatch(stop->stop.get("photoWorkflow")==null || set.workflows().stream().anyMatch(flow->flow.number()==((Number)stop.get("photoWorkflow")).intValue() && offered(flow,Models.StopType.valueOf(stop.get("type").toString()))))).toList(); - var preferred=options.sets().stream().filter(PhotoSet::defaultSet).findFirst().orElse(options.sets().isEmpty()?null:options.sets().getFirst()); + var preferred=options.sets().stream().filter(entryValue -> entryValue.defaultSet()).findFirst().orElse(options.sets().isEmpty()?null:options.sets().getFirst()); if(preferred!=null&&compatible.contains(preferred))data.put("photoSet",preferred.number()); else if(compatible.size()==1)data.put("photoSet",compatible.getFirst().number()); else data.put("copyWarning",(Objects.toString(data.get("copyWarning"),"")+" Die gespeicherte Fotoauswahl passt nicht eindeutig zu den aktuellen Foto-Sets. Bitte Set und Stationsvorgaben prüfen.").strip()); @@ -161,6 +203,7 @@ public class OrderPhotoWorkflows { } /** A lost acknowledgement is recovered using GET /selection before retrying POST. */ void publish(long job) { + if(!enabled())return; // Keep queued transfers pending until workflows are enabled again. Claim claim=claim(job);if(claim==null)return; try { String url=url();if(url.isBlank())throw unavailable("Der Fotodienst ist nicht konfiguriert."); @@ -207,6 +250,7 @@ public class OrderPhotoWorkflows { } @Scheduled(initialDelay=60000,fixedDelay=30000) public void retryPending() { + if(!enabled())return; try { var rows=db.queryForList("SELECT gdc_obj_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_gen_fieldname='ng_photo_selection' AND (gdc_context='PENDING' OR (gdc_context LIKE 'SENDING:%' AND gdc_context{ var s=orders.snapshot(user,id,false); var options=catalog.options(user); - long tax=options.taxes().stream().filter(t->t.label().equals(text(s.job(),"jb_sales_tax_rate_sign"))).mapToLong(Tax::id).findFirst().orElse(0); + long tax=options.taxes().stream().filter(t->t.label().equals(text(s.job(),"jb_sales_tax_rate_sign"))).mapToLong(entryValue -> entryValue.id()).findFirst().orElse(0); PriceMode mode=s.prices().stream().anyMatch(p->text(p,"trs_srvt_name").equals("z"))?PriceMode.ZONE:positive(decimal(s.job(),"jb_fixprice"))?PriceMode.FIXED:PriceMode.TARIFF; var previous=db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_calculation_input'",id); var services=new OrderEntryTemplates(db,catalog,null).services(user,payerId(s),text(s.job(),"jb_freetext_2")); @@ -131,7 +131,7 @@ public class OrderRecalculation { Request r=context.request();var job=context.snapshot().job();long payer=id(job,"csc_id_payer"); db.update("DELETE FROM tourservice WHERE jb_id=?",id); int skip=r.pricing().mode()==PriceMode.SERVICES?r.services().size():0; - if(!q.batch()&&skip>0)component(id,payer,0,"Fixpreis","",q.components().stream().limit(skip).map(Component::net).reduce(BigDecimal.ZERO,BigDecimal::add),BigDecimal.ZERO); + if(!q.batch()&&skip>0)component(id,payer,0,"Fixpreis","",q.components().stream().limit(skip).map(entryValue -> entryValue.net()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)),BigDecimal.ZERO); if(!q.batch())for(var c:q.components().stream().skip(skip).toList())component(id,payer,c.sequence(),c.name(),c.type(),c.price(),c.discount()); if(r.pricing().mode()==PriceMode.ZONE&&!q.batch()){int n=0;for(var c:q.calculation())component(id,payer,++n,c.quantity().toPlainString()+" × "+c.name()+" à "+c.rate(),"z",BigDecimal.ZERO,BigDecimal.ZERO);} db.update("DELETE FROM jobprice WHERE jb_id=? AND (mt_sort<11 OR mt_sort=15 OR mt_sort=16)",id); @@ -141,8 +141,8 @@ public class OrderRecalculation { if(r.pricing().markup()!=null)price(id,8,q.markup());if(positive(q.courierAgio()))price(id,7,q.courierAgio()); db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_cost_split'",id); new OrderEntryAccounting(db,catalog).split(id,q); - BigDecimal fixed=q.batch()?BigDecimal.ZERO:q.calculatedBase()!=null?q.calculatedBase():r.pricing().mode()==PriceMode.FIXED?r.pricing().fixedPrice():skip>0?q.components().stream().limit(skip).map(Component::subtotal).reduce(BigDecimal.ZERO,BigDecimal::add):BigDecimal.ZERO; - var update=fields("vht_id",r.vehicleTypeId(),"jb_payment",r.payment()==Payment.CASH?2:r.costSplit()?1:0,"jb_fixprice",fixed,"jb_serviceprice",q.components().stream().filter(c->c.name().equals("Servicepreis")).map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add),"jb_totalprice",q.net(),"jb_subtotalprice",q.subtotal(),"jb_cr_price",q.courierNet(),"jb_cr_subprice",q.courierBase(),"jb_markup",q.markup(),"jb_invmode",q.invoiceMode(),"jb_sales_tax_rate",q.taxRate(),"jb_sales_tax_rate_sign",q.taxSign(),"jb_version",id(job,"jb_version")+1,"jb_freetext_2",skip>0?new OrderEntryExtras(db,catalog).calculatorText(user,r,q):""); + BigDecimal fixed=q.batch()?BigDecimal.ZERO:q.calculatedBase()!=null?q.calculatedBase():r.pricing().mode()==PriceMode.FIXED?r.pricing().fixedPrice():skip>0?q.components().stream().limit(skip).map(entryValue -> entryValue.subtotal()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)):BigDecimal.ZERO; + var update=fields("vht_id",r.vehicleTypeId(),"jb_payment",r.payment()==Payment.CASH?2:r.costSplit()?1:0,"jb_fixprice",fixed,"jb_serviceprice",q.components().stream().filter(c->c.name().equals("Servicepreis")).map(entryValue -> entryValue.price()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)),"jb_totalprice",q.net(),"jb_subtotalprice",q.subtotal(),"jb_cr_price",q.courierNet(),"jb_cr_subprice",q.courierBase(),"jb_markup",q.markup(),"jb_invmode",q.invoiceMode(),"jb_sales_tax_rate",q.taxRate(),"jb_sales_tax_rate_sign",q.taxSign(),"jb_version",id(job,"jb_version")+1,"jb_freetext_2",skip>0?new OrderEntryExtras(db,catalog).calculatorText(user,r,q):""); // Repricing does not silently confirm unrelated missing references, emissions or customer checks. if(q.batch())update.put("jb_incomplete",1); BusinessSupport.update(db,"job","jb_id",id,update); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderRoutingService.java b/backend/src/main/java/de/stadtbote/votian/OrderRoutingService.java index 9709381..2b34172 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderRoutingService.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderRoutingService.java @@ -115,7 +115,7 @@ public class OrderRoutingService { if(km.signum()<=0)throw unavailable("Eine Niederlassungsroute konnte nicht bestimmt werden. Bitte die Berechnung erneut versuchen."); compared.add(new HeadquartersRoute(id(h,"hq_id"),text(h,"hq_mnemonic"),km)); } - compared.sort(Comparator.comparing(HeadquartersRoute::roundTripKm).thenComparingLong(HeadquartersRoute::id)); + compared.sort(Comparator.comparing((HeadquartersRoute entryValue) -> entryValue.roundTripKm()).thenComparingLong(entryValue -> entryValue.id())); dispatch=compared.getFirst().id(); } long pricingHq=catalog.enabled(0,"BWV_ACTIVE")?dispatch:user.headquartersId(); @@ -148,6 +148,7 @@ public class OrderRoutingService { return kilometers(distance(route(request.stops().stream().map(this::geocode).toList(),user.headquartersId(),true,false))); } static final class StationAddressException extends org.springframework.web.server.ResponseStatusException { + private static final long serialVersionUID = -1441666281153459916L; final int station;final String addresses; StationAddressException(int station,String addresses,String reason){super(org.springframework.http.HttpStatus.BAD_REQUEST,"Station "+station+": "+reason);this.station=station;this.addresses=addresses;} } @@ -230,7 +231,7 @@ public class OrderRoutingService { JsonNode route(List points,long hq,boolean distanceOnly,boolean longhaul) {return route(points,hq,distanceOnly,longhaul,false,false);} JsonNode route(List points,long hq,boolean distanceOnly,boolean longhaul,boolean optimized,boolean fresh) { var payload=new LinkedHashMap(); - payload.put("stations",points.stream().map(Point::station).toList());payload.put("hq_id",hq);payload.put("jb_id",0); + payload.put("stations",points.stream().map(entryValue -> entryValue.station()).toList());payload.put("hq_id",hq);payload.put("jb_id",0); payload.put("traffic",false);payload.put("optimized",optimized);payload.put("distanceOnly",distanceOnly); String shortest=catalog.parameter(hq,"PZM_SHORTEST"); payload.put("shortest",!optimized && (longhaul || shortest.equals("1")));payload.put("ridiculousShortest",!optimized && !longhaul && shortest.equals("2")); @@ -296,7 +297,7 @@ public class OrderRoutingService { for(int last=input.keepLast()?n-1:1;last();indices.add(0); for(int i=1;i points.get(entryValue)).toList(),user.headquartersId(),false,false,true,false); JsonNode permutation=candidate.path("optimizedRoute"); var middle=new ArrayList(); if(permutation.isNull())for(int i=0;i save(Authentication a,@PathVariable long job,@RequestBody Command command){return save(ApiController.user(a),job,command);} Map save(LegacyUser u,long job,Command command){u.require(4);u.require(7);if(command==null||!command.confirmed()||command.revision()==null||!command.revision().matches("[a-f0-9]{64}"))throw bad("Bitte die Stationsbemerkung prüfen und bestätigen.");BusinessSupport.token(command.requestId());var input=input(command.input());support.transactional("headquarters","job","tour","genericdatacontainer"); db.writeTransaction(()->{lockHeadquarters(db,u);var intent=List.of("STATION_NOTES",job,command);if(support.receipt(u,command.requestId(),intent)!=null)return null;var s=state(u,job,true);unchanged(List.of(s,input),command.revision());editable(s,input);String oldNote=text(meta(s,"tr",input.station()),"gdc_content"); - if(!selected(s,input.station()).equals(input.items())||!oldNote.equals(input.note())){var entries=entries(text(meta(s,"jb",job),"gdc_content"));entries.put(Long.toString(input.station()),String.join(",",input.items().stream().map(Object::toString).toList()));String content=String.join("|",entries.entrySet().stream().map(e->e.getKey()+"="+e.getValue()).toList());replace(s,"jb",job,"int_rem_item",content,"");replace(s,"tr",input.station(),"tr_int_rem",input.note(),Long.toString(job));db.update("UPDATE job SET jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=? AND hq_id=?",job,u.headquartersId());support.audit(u,job,0,0,148,"STATION_ID="+input.station()+" REMARK_ITEMS="+input.items()+" REMARK_TEXT="+input.note(),command.requestId());} + if(!selected(s,input.station()).equals(input.items())||!oldNote.equals(input.note())){var entries=entries(text(meta(s,"jb",job),"gdc_content"));entries.put(Long.toString(input.station()),String.join(",",input.items().stream().map(entryValue -> entryValue.toString()).toList()));String content=String.join("|",entries.entrySet().stream().map(e->e.getKey()+"="+e.getValue()).toList());replace(s,"jb",job,"int_rem_item",content,"");replace(s,"tr",input.station(),"tr_int_rem",input.note(),Long.toString(job));db.update("UPDATE job SET jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=? AND hq_id=?",job,u.headquartersId());support.audit(u,job,0,0,148,"STATION_ID="+input.station()+" REMARK_ITEMS="+input.items()+" REMARK_TEXT="+input.note(),command.requestId());} support.receipt(u,command.requestId(),intent,job);return null; });return view(u,job); } diff --git a/backend/src/main/java/de/stadtbote/votian/OrderStationSorting.java b/backend/src/main/java/de/stadtbote/votian/OrderStationSorting.java index 139ac6e..43555b4 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderStationSorting.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderStationSorting.java @@ -32,7 +32,7 @@ public class OrderStationSorting { List> read(boolean lock,String sql,long id){return lock?db.lockRows(sql,id):db.queryForList(sql,id);} void writable(State s){orders.writable(s.order());if(!Set.of(0L,1L,8L,9L).contains(id(s.order().job(),"jb_status")))throw bad("Nur offene, angebotene oder angenommene Aufträge können neu sortiert werden.");} Map view(LegacyUser u,long id){return db.readTransaction(()->{ - var s=state(u,id,false);String warning="";try{writable(s);validate(s,s.order().stops().stream().map(t->id(t,"tr_id")).toList());}catch(org.springframework.web.server.ResponseStatusException e){warning=e.getReason();} + var s=state(u,id,false);String warning="";try{writable(s);validate(s,s.order().stops().stream().map(t->id(t,"tr_id")).toList());}catch(org.springframework.web.server.ResponseStatusException e){warning=java.util.Objects.requireNonNullElse(e.getReason(),"Die Stationsreihenfolge kann nicht geändert werden.");} return fields("id",id,"revision",revision(s),"canSave",warning.isEmpty(),"warning",warning,"stations",s.order().stops().stream().map(t->fields("id",id(t,"tr_id"),"position",id(t,"tr_sort"),"name",text(t,"tr_comp"),"additionalName",text(t,"tr_comp2"),"street",text(t,"ad_street"),"houseNumber",text(t,"tr_hsno"),"postalCode",text(t,"ad_zipcode"),"city",text(t,"ad_city"),"country",text(t,"ad_country"),"contact",text(t,"tr_person"),"phone",text(t,"tr_phone"),"completed",id(t,"tr_status")==1,"recipient",text(t,"tr_signname"))).toList(),"articles",s.articles().size(),"calculatorPositions",s.calculator().size(),"servicePositions",s.order().prices().stream().filter(p->id(p,"tr_sort")>0).count()); });} List> validate(State s,List ids){ @@ -78,7 +78,7 @@ public class OrderStationSorting { var stops=db.readTransaction(()->{var s=state(u,id,false);unchanged(s,r.revision());writable(s);return validate(s,r.stationIds());}); var input=stops.stream().map(t->new OrderEntryModels.Station(null,Models.StopType.NONE,text(t,"tr_comp"),text(t,"ad_street"),text(t,"tr_hsno"),text(t,"ad_zipcode"),text(t,"ad_city"),text(t,"ad_country").equals("D")?"DE":text(t,"ad_country"),"","","","",null,null,List.of())).toList(); var ids=new ArrayList<>(r.stationIds());OrderEntryModels.OptimizedRoute result=null; - if(optimize){result=routing.optimize(u,new OrderEntryModels.OptimizeInput(input,true));ids=new ArrayList<>(result.order().stream().map(r.stationIds()::get).toList());} + if(optimize){result=routing.optimize(u,new OrderEntryModels.OptimizeInput(input,true));ids=new ArrayList<>(result.order().stream().map(entryValue -> r.stationIds().get(entryValue)).toList());} var selected=new HashMap();for(int i=0;i MARKERS=List.of("jb_automailsent","jb_automailsent2","jb_automailsent3"); + record Command(String requestId,String revision,String recipients,boolean replaceStandardRecipients) {} + record View(String revision,String recipients,boolean replaceStandardRecipients,boolean canEdit,String reason) {} + record State(Map job,List> addresses,List> previous,List> deliveries) {} + private final DatabaseService db;private final BusinessSupport support; + OrderStatusMailSettings(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping View get(Authentication auth,@PathVariable long order){return view(ApiController.user(auth),order);} + @PostMapping View post(Authentication auth,@PathVariable long order,@RequestBody Command command){return save(ApiController.user(auth),order,command);} + State state(LegacyUser user,long order){ + user.require(4); + var job=one(db.queryForList("SELECT * FROM job WHERE jb_id=? AND hq_id=?",order,user.headquartersId())); + var addresses=db.queryForList("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_cashpayer_mail'",order); + var previous=db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_mail_replace_before'",order); + if(addresses.size()>1||previous.size()>1)throw bad("Die Statusmail-Einstellungen sind mehrdeutig."); + var deliveries=db.queryForList("SELECT gdc_context FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_context IN ('STARTED','UNCERTAIN')",order); + return new State(job,addresses,previous,deliveries); + } + static String blocked(State s){ + if(!s.deliveries().isEmpty()||MARKERS.stream().anyMatch(k->id(s.job(),k)==998))return "Ein Mailversand ist noch nicht sicher bestätigt."; + if(!s.addresses().isEmpty()&&Set.of("MAIL_SENT","MAIL_NOT_SENT").contains(text(s.addresses().getFirst(),"gdc_context")))return "Die zusätzlichen Empfänger wurden bereits abschließend verarbeitet."; + if(id(s.job(),"jb_offer")!=0||id(s.job(),"jb_storno")!=0||OrderOperations.exported(s.job()))return "Für diesen Auftrag können die Statusmail-Empfänger nicht mehr geändert werden."; + var lock=dateTime(s.job(),"jb_locktime");if(lock!=null&&lock.isAfter(java.time.LocalDateTime.now().minusMinutes(5)))return "Der Auftrag wird gerade in der alten Anwendung bearbeitet."; + return ""; + } + View view(LegacyUser u,long order){return db.readTransaction(()->{var s=state(u,order);String reason=blocked(s);return new View(revision(s),s.addresses().isEmpty()?"":text(s.addresses().getFirst(),"gdc_content"),id(s.job(),"jb_automailsent")==996,u.hasRight(7)&&reason.isBlank(),reason);});} + View save(LegacyUser u,long order,Command c){ + u.require(4);u.require(7);if(c==null)throw bad("Die Statusmail-Einstellungen fehlen.");BusinessSupport.token(c.requestId()); + String recipients=String.join(",",OrderEntryMail.addresses(c.recipients())); + if(c.replaceStandardRecipients()&&recipients.isBlank())throw bad("Bitte mindestens einen Ersatzempfänger angeben."); + support.transactional("job","genericdatacontainer"); + db.automaticOrderMailWriteTransaction(support.historySchema.replace("`",""),()->{ + if(support.receipt(u,c.requestId(),List.of(order,c))!=null)return null; + var s=state(u,order);if(!revision(s).equals(c.revision()))throw OrderEntryService.conflict("Der Versandstatus wurde inzwischen geändert. Bitte neu laden."); + String reason=blocked(s);if(!reason.isBlank())throw bad(reason); + var locks=db.queryForList("SELECT sp_content FROM "+support.historySchema+".semaphor WHERE sp_obj_type='jb' AND sp_obj_id=? AND sp_fieldname IN ('automailer','automailer2','automailer3','automailer4','automailer5')",order); + if(locks.stream().anyMatch(r->!text(r,"sp_content").isBlank()))throw bad("Ein PHP-Mailvorgang ist noch gesperrt. Bitte später erneut laden."); + var markers=new LinkedHashMap(); + if(c.replaceStandardRecipients()){ + if(s.previous().isEmpty())db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",order,"gdc_gen_fieldname","ng_mail_replace_before","gdc_content",String.join("|",MARKERS.stream().map(k->Long.toString(id(s.job(),k)==996?0:id(s.job(),k))).toList()),"gdc_context","")); + MARKERS.forEach(k->markers.put(k,996)); + }else{ + var previous=s.previous().isEmpty()?new String[]{"0","0","0"}:text(s.previous().getFirst(),"gdc_content").split("\\|",-1); + if(previous.length!=3||Arrays.stream(previous).anyMatch(v->!v.matches("[0-9]{1,3}")))throw bad("Die bisherigen Mailkennzeichen sind ungültig."); + for(int i=0;i!c.name().equals("Servicepreis")).map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add); + return base!=null?LegacyOrderPricing.markup(base,quote.markup()):quote.components().stream().filter(c->!c.name().equals("Servicepreis")).map(entryValue -> entryValue.price()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)); } } diff --git a/backend/src/main/java/de/stadtbote/votian/OrderTemplateFilters.java b/backend/src/main/java/de/stadtbote/votian/OrderTemplateFilters.java index f656fb4..b8317a7 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderTemplateFilters.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderTemplateFilters.java @@ -2,7 +2,7 @@ package de.stadtbote.votian; import java.util.*; /** get_saved_tour.php restores both filter classes, excluding the current global requirement. */ final class OrderTemplateFilters { - static List tokens(String value){return Arrays.stream(Objects.toString(value,"").split(",",-1)).map(String::trim).filter(s->!s.isEmpty()).distinct().toList();} + static List tokens(String value){return Arrays.stream(Objects.toString(value,"").split(",",-1)).map(entryValue -> entryValue.trim()).filter(s->!s.isEmpty()).distinct().toList();} static List copy(String required,String optional,String mandatory){var selected=new LinkedHashSet<>(tokens(required));selected.removeAll(tokens(mandatory));selected.addAll(tokens(optional));return List.copyOf(selected);} static String withoutLegacyNotes(String note){ // Only a complete legacy marker pair is generated text; preserve unmarked user prose. diff --git a/backend/src/main/java/de/stadtbote/votian/OrderTemplatePricing.java b/backend/src/main/java/de/stadtbote/votian/OrderTemplatePricing.java index ceba41e..6e529b0 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderTemplatePricing.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderTemplatePricing.java @@ -8,7 +8,9 @@ final class OrderTemplatePricing { static BigDecimal single(List> rows,String column,String label){if(rows.size()>1)throw invalid("Die gespeicherte "+label+" ist mehrdeutig. Bitte den Quellauftrag prüfen.");if(rows.isEmpty()||text(rows.getFirst(),column).isBlank())return null;try{return new BigDecimal(text(rows.getFirst(),column).replace(',','.'));}catch(NumberFormatException e){throw invalid("Die gespeicherte "+label+" ist ungültig. Bitte den Quellauftrag prüfen.");}} void apply(LegacyUser user,Map job,Map data,boolean template){ long jobId=id(job,"jb_id");var prices=db.queryForList("SELECT mt_sort,jbprc_price,jbprc_remark FROM jobprice WHERE jb_id=? AND mt_sort IN (8,9,11) ORDER BY mt_sort",jobId); - boolean currentZone=template&&!db.queryForList("SELECT trs_srv_name FROM tourservice WHERE jb_id=? AND trs_srvt_name='z' LIMIT 1",jobId).isEmpty(); + boolean fromZone=!db.queryForList("SELECT trs_srv_name FROM tourservice WHERE jb_id=? AND trs_srvt_name='z' LIMIT 1",jobId).isEmpty(); + data.put("fixedPriceFromZone",fromZone); + boolean currentZone=template&&fromZone; if(currentZone){data.put("fixedPrice",null);data.put("priceMode","ZONE");} if(catalog.enabled(user.headquartersId(),"MASK_CR_PRICE_MODE")){ data.put("markup",single(prices.stream().filter(r->id(r,"mt_sort")==8).toList(),"jbprc_price","Kundenzuschlagsvorgabe")); diff --git a/backend/src/main/java/de/stadtbote/votian/PaymentControls.java b/backend/src/main/java/de/stadtbote/votian/PaymentControls.java index 5804787..9d4c3c0 100644 --- a/backend/src/main/java/de/stadtbote/votian/PaymentControls.java +++ b/backend/src/main/java/de/stadtbote/votian/PaymentControls.java @@ -57,7 +57,7 @@ public final class PaymentControls { long installation=q.kind()==Kind.REPORT&&q.report()>1?service("MO"):0,exchange=q.kind()==Kind.REPORT&&q.report()==5?service("AT"):0; var rows=new ArrayList>();var collectionIds=new TreeSet();var countedJobs=new HashSet();BigDecimal total=BigDecimal.ZERO,courier=BigDecimal.ZERO,storage=BigDecimal.ZERO; for(var j:jobs){long job=id(j,"jb_id");var metadata=new HashMap>();for(var m:db.queryForList("SELECT gdc_gen_fieldname,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('info_0','jb_id_addmont_parent','jb_addmont','jb_service_job','jb_search') ORDER BY gdc_id",job)){String key=text(m,"gdc_gen_fieldname");if(metadata.put(key,m)!=null)throw bad("Auftrag #"+job+": mehrfach gespeichertes Zahlungsmerkmal "+key);} - var calculation=db.queryForList("SELECT srvt_name,jbc_amount,jbc_price,jbc_totalprice FROM jobcalculator WHERE jb_id=? ORDER BY srvt_name",job);BigDecimal price=meta(metadata,"jb_id_addmont_parent").isEmpty()?calculation.stream().filter(p->!text(p,"srvt_name").equals("0003:par_01")).map(p->money(amount(p,"jbc_totalprice"))).reduce(BigDecimal.ZERO,BigDecimal::add):amount(j,"jb_totalprice");BigDecimal warehouse=calculation.stream().filter(p->text(p,"srvt_name").equals("0003:par_01")).map(p->money(amount(p,"jbc_totalprice"))).reduce(BigDecimal.ZERO,BigDecimal::add); + var calculation=db.queryForList("SELECT srvt_name,jbc_amount,jbc_price,jbc_totalprice FROM jobcalculator WHERE jb_id=? ORDER BY srvt_name",job);BigDecimal price=meta(metadata,"jb_id_addmont_parent").isEmpty()?calculation.stream().filter(p->!text(p,"srvt_name").equals("0003:par_01")).map(p->money(amount(p,"jbc_totalprice"))).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)):amount(j,"jb_totalprice");BigDecimal warehouse=calculation.stream().filter(p->text(p,"srvt_name").equals("0003:par_01")).map(p->money(amount(p,"jbc_totalprice"))).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)); String order=meta(metadata,"info_0");if(order.isBlank()){var stations=db.queryForList("SELECT tr_commission_no FROM tour WHERE jb_id=? AND tr_sort=2 ORDER BY tr_id",job);order=stations.isEmpty()?"":text(stations.getFirst(),"tr_commission_no").replace(";","");}if(order.isBlank())order=meta(metadata,"jb_search"); var payments=db.queryForList("SELECT p.*,CAST(p.jbp_export_time AS CHAR) AS payment_export,pc.jbpc_bookingdate FROM jobpayment p LEFT JOIN jobpaymentcollection pc ON pc.jbpc_id=p.jbpc_id WHERE p.jb_id=? ORDER BY p.jbp_id",job); if(payments.isEmpty())payments=List.of(Map.of()); @@ -75,7 +75,7 @@ public final class PaymentControls { var owners=db.queryForList("SELECT pc.*,cs.cs_eid,cmp.cmp_comp FROM jobpaymentcollection pc JOIN customer cs ON cs.cs_id=pc.cs_id LEFT JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE pc.jbpc_id=? AND cs.hq_id=?",id,u.headquartersId());if(owners.size()!=1)return fields("id",id,"restricted",true);var pc=owners.getFirst(); // Never disclose a collection total that includes another branch's jobs. if(!db.queryForList("SELECT p.jbp_id FROM jobpayment p LEFT JOIN job j ON j.jb_id=p.jb_id WHERE p.jbpc_id=? AND (j.jb_id IS NULL OR j.hq_id<>?) LIMIT 1",id,u.headquartersId()).isEmpty())return fields("id",id,"restricted",true); - var jobs=db.queryForList("SELECT DISTINCT j.jb_id,j.jb_cr_price FROM jobpayment p JOIN job j ON j.jb_id=p.jb_id WHERE p.jbpc_id=? AND j.hq_id=? AND j.jb_export_time IS NOT NULL AND j.jb_export_time<>'9999-12-31 23:59:59'",id,u.headquartersId());BigDecimal freight=jobs.stream().map(j->amount(j,"jb_cr_price")).reduce(BigDecimal.ZERO,BigDecimal::add); + var jobs=db.queryForList("SELECT DISTINCT j.jb_id,j.jb_cr_price FROM jobpayment p JOIN job j ON j.jb_id=p.jb_id WHERE p.jbpc_id=? AND j.hq_id=? AND j.jb_export_time IS NOT NULL AND j.jb_export_time<>'9999-12-31 23:59:59'",id,u.headquartersId());BigDecimal freight=jobs.stream().map(j->amount(j,"jb_cr_price")).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue)); return fields("id",id,"restricted",false,"customer",text(pc,"cs_eid")+" · "+text(pc,"cmp_comp"),"mode",mode(id(pc,"jbpc_mode")),"booked",text(pc,"jbpc_bookingdate"),"exported",text(pc,"jbpc_export_time"),"price",amount(pc,"jbpc_price"),"freight",freight,"balance",amount(pc,"jbpc_price").subtract(freight)); } static String reason(int report,Map j,Map p,Map> m,List> calculation,BigDecimal price,long installation,long exchange){ diff --git a/backend/src/main/java/de/stadtbote/votian/PersonalInbox.java b/backend/src/main/java/de/stadtbote/votian/PersonalInbox.java index a4adcd6..4e4b817 100644 --- a/backend/src/main/java/de/stadtbote/votian/PersonalInbox.java +++ b/backend/src/main/java/de/stadtbote/votian/PersonalInbox.java @@ -40,7 +40,7 @@ class PersonalInbox { var rows=db.queryForList("SELECT tif_id,hq_id,emp_id,emp_id_creator,tif_createtime,tif_starttime,tif_endtime,tif_subject,tif_text,(SELECT CONCAT_WS(' ',user.usr_firstname,user.usr_name) FROM employee JOIN user ON user.usr_id=employee.usr_id WHERE employee.emp_id=emp_id_creator) AS author,("+unread()+") AS unread FROM "+ticker+" WHERE "+scope()+" ORDER BY tif_createtime DESC,tif_id DESC LIMIT ? OFFSET ?",s.watermark(),u.userId(),u.employeeId(),u.headquartersId(),now,now,PAGE,page*PAGE); return fields("enabled",true,"total",total,"unread",unread,"page",page,"pageSize",PAGE,"canHistory",u.hasRight(18),"items",rows.stream().map(r->fields("id",id(r,"tif_id"),"subject",text(r,"tif_subject"),"text",text(r,"tif_text"),"author",text(r,"author"),"created",dateTime(r,"tif_createtime"),"until",dateTime(r,"tif_endtime"),"unread",id(r,"unread")!=0,"revision",revisionOf(r))).toList()); });} - static void validate(Acknowledge c){if(c==null||c.messages()==null||c.messages().isEmpty()||c.messages().size()>PAGE||c.messages().stream().anyMatch(m->m==null||m.id()<=0||m.revision()==null||!m.revision().matches("[a-f0-9]{64}"))||c.messages().stream().map(Seen::id).distinct().count()!=c.messages().size())throw bad("Bitte höchstens 50 angezeigte Mitteilungen bestätigen.");BusinessSupport.token(c.requestId());} + static void validate(Acknowledge c){if(c==null||c.messages()==null||c.messages().isEmpty()||c.messages().size()>PAGE||c.messages().stream().anyMatch(m->m==null||m.id()<=0||m.revision()==null||!m.revision().matches("[a-f0-9]{64}"))||c.messages().stream().map(entryValue -> entryValue.id()).distinct().count()!=c.messages().size())throw bad("Bitte höchstens 50 angezeigte Mitteilungen bestätigen.");BusinessSupport.token(c.requestId());} Map acknowledge(LegacyUser u,Acknowledge c,LocalDateTime now){HeaderActivity.access(u);validate(c);support.transactional("genericdatacontainer");return db.employeeInboxWriteTransaction(ticker,()->{ var s=state(u);Long receipt=support.receipt(u,c.requestId(),c);if(receipt!=null)return fields("acknowledged",receipt); if(!s.enabled())throw bad("Die persönliche Mitteilungsanzeige ist nicht freigegeben."); diff --git a/backend/src/main/java/de/stadtbote/votian/PzmPriceComparison.java b/backend/src/main/java/de/stadtbote/votian/PzmPriceComparison.java index 24c3e15..7c0aa56 100644 --- a/backend/src/main/java/de/stadtbote/votian/PzmPriceComparison.java +++ b/backend/src/main/java/de/stadtbote/votian/PzmPriceComparison.java @@ -130,7 +130,7 @@ class PzmPriceComparison { return pricing.calculate(request,route,run.selection.customer(),vehicle,at,OrderEntryCatalog.number(customer,"cs_discount"),BigDecimal.ZERO); }); var lines=new ArrayList<>(price.lines().stream().filter(line->!line.name().equals("Aufrundung")).toList()); - BigDecimal raw=price.base().subtract(price.lines().stream().filter(line->line.name().equals("Aufrundung")).map(CalculationLine::amount).reduce(BigDecimal.ZERO,BigDecimal::add)); + BigDecimal raw=price.base().subtract(price.lines().stream().filter(line->line.name().equals("Aufrundung")).map(entryValue -> entryValue.amount()).reduce(BigDecimal.ZERO,(entryValue, otherValue) -> entryValue.add(otherValue))); BigDecimal markup=BigDecimal.ZERO; if(route.info().longhaul()){ String value=catalog.parameter(0,"BWV_AUSSERHALB_TSZ"); diff --git a/backend/src/main/java/de/stadtbote/votian/RecurringOrderContinuation.java b/backend/src/main/java/de/stadtbote/votian/RecurringOrderContinuation.java index ff271c1..b241ddd 100644 --- a/backend/src/main/java/de/stadtbote/votian/RecurringOrderContinuation.java +++ b/backend/src/main/java/de/stadtbote/votian/RecurringOrderContinuation.java @@ -115,7 +115,7 @@ final class RecurringOrderContinuation { var data=fields("mode","RECURRING","pickupAt",at,"recurrence",new Recurrence((int)id(j,"jb_permanent"),text(j,"jb_permanent2"),end==null?null:end.toLocalDate()),"payerCostCenterId",payer,"payment",id(j,"jb_payment")==2?"CASH":"INVOICE","cashStop",id(j,"jb_payment")==2?cash:null,"vehicleTypeId",id(j,"vht_id"),"courierVehicleId",vehicle,"stops",stops,"orderer",text(j,"jb_orderer").isBlank()?"Dauerauftrag":text(j,"jb_orderer"),"reference",reference,"notes",text(j,"jb_freetext_1"),"dispatchNote",text(j,"jb_dispoinfo"),"weight",decimal(j,"jb_weight"),"length",decimal(j,"jb_crvh_length"),"width",decimal(j,"jb_crvh_width"),"height",decimal(j,"jb_crvh_height"),"positions",decimal(j,"jb_crvh_position"),"distanceKm",manualDistance(s),"manualDispatch",s.manual(),"incomplete",id(j,"jb_incomplete")!=0,"longhaul",id(j,"jb_longhaul")==1?"AUTO":"ASAP","pricing",pricing,"requestId",UUID.nameUUIDFromBytes(("votian-recurring:"+hq+":"+job).getBytes(StandardCharsets.UTF_8)).toString(),"origin",1,"toll",amount(meta(s,"jb_toll")),"invoiceOnlyText",meta(s,"jb_hiddenFreetext_1"),"courierFilters",filters,"cancelIfUnavailable",id(j,"jb_waitstorno")!=0,"allCouriers",id(j,"jb_globaljob")==1&&hq!=1&&hq!=9,"acceptUnverifiedAddresses",true,"copies",1,"sourceOrderId",template,"insurance",insurance,"services",services,"costSplit",id(j,"jb_payment")==1); String recipients=meta(s,"jb_cashpayer_mail");if(!recipients.isBlank()){ var policy=new OrderEntryMail(db,catalog).policy(u,payer,id(j,"jb_payment")==2?Payment.CASH:Payment.INVOICE,(int)id(j,"vht_id")); - data.put("statusMail",new OrderEntryMail.Selection(recipients,policy.revision())); + data.put("statusMail",new OrderEntryMail.Selection(recipients,policy.revision(),id(j,"jb_automailsent")==996)); } return new Plan(s,json.convertValue(data,Request.class),AdminSupport.revision(s),catalog.enabled(hq,"MASK_KEEP_JB_TOURNAME"),wish); } diff --git a/backend/src/main/java/de/stadtbote/votian/RemoteFileStore.java b/backend/src/main/java/de/stadtbote/votian/RemoteFileStore.java index 7ca851e..d59154e 100644 --- a/backend/src/main/java/de/stadtbote/votian/RemoteFileStore.java +++ b/backend/src/main/java/de/stadtbote/votian/RemoteFileStore.java @@ -36,7 +36,7 @@ class RemoteFileStore { Session session;ChannelSftp channel;final String directory; Sftp(Endpoint endpoint){directory=RemoteFileStore.directory(endpoint.directory());try{ if(documents.knownHosts.isBlank())throw bad("Für SFTP fehlt die Datei mit den vertrauenswürdigen SSH-Hostschlüsseln."); - JSch jsch=new JSch();jsch.setKnownHosts(documents.knownHosts);session=jsch.getSession(endpoint.user(),endpoint.host(),endpoint.port());session.setPassword(endpoint.password());session.setConfig("StrictHostKeyChecking","yes");session.setConfig("PreferredAuthentications","password,keyboard-interactive");session.setTimeout(20000);session.connect(10000);channel=(ChannelSftp)session.openChannel("sftp");channel.connect(10000); + JSch jsch=new JSch();jsch.setKnownHosts(documents.knownHosts);session=jsch.getSession(endpoint.user(),endpoint.host(),endpoint.port());session.setPassword(endpoint.password().getBytes(java.nio.charset.StandardCharsets.UTF_8));session.setConfig("StrictHostKeyChecking","yes");session.setConfig("PreferredAuthentications","password,keyboard-interactive");session.setTimeout(20000);session.connect(10000);channel=(ChannelSftp)session.openChannel("sftp");channel.connect(10000); String current="";for(String part:directory.substring(1).split("/")){if(part.isEmpty())continue;current+="/"+part;var a=channel.lstat(current);if(a.isLink()||!a.isDir())throw bad("Das externe Verzeichnis ist keine freigegebene reguläre Ablage.");} }catch(RuntimeException e){close();throw e;}catch(Exception e){close();throw unavailable();}} String path(String n){return (directory.equals("/")?"":directory)+"/"+name(n);} diff --git a/backend/src/main/java/de/stadtbote/votian/RemoteTransfers.java b/backend/src/main/java/de/stadtbote/votian/RemoteTransfers.java index ec9aa6c..df827da 100644 --- a/backend/src/main/java/de/stadtbote/votian/RemoteTransfers.java +++ b/backend/src/main/java/de/stadtbote/votian/RemoteTransfers.java @@ -20,7 +20,7 @@ class RemoteTransfers { @PostMapping Object start(Authentication a,@PathVariable MasterDocuments.Kind kind,@PathVariable long object,@RequestBody Command command){return start(ApiController.user(a),kind,object,command);} @PostMapping("/{token}/resume") Object resume(Authentication a,@PathVariable MasterDocuments.Kind kind,@PathVariable long object,@PathVariable String token){return resume(ApiController.user(a),kind,object,token);} static String alias(String alias){if(alias==null||!alias.matches("[A-Za-z0-9_][A-Za-z0-9_-]{0,79}"))throw bad("Ungültige Serverauswahl.");return alias;} - List aliases(MasterDocuments.Context c){String list=c.transfer()==null?documents.parameter(c.headquarters(),"FTP_IMPORT_SERVERLIST",""):c.transfer().alias();if(list.length()>8000)throw bad("Die Serverliste ist zu lang.");return Arrays.stream(list.split(",")).map(String::strip).filter(s->!s.isEmpty()).map(RemoteTransfers::alias).distinct().filter(s->c.root()||!documents.parameter(0,"FTP_DISPLAY_ADMIN_ONLY_"+s,"0").equals("1")).toList();} + List aliases(MasterDocuments.Context c){String list=c.transfer()==null?documents.parameter(c.headquarters(),"FTP_IMPORT_SERVERLIST",""):c.transfer().alias();if(list.length()>8000)throw bad("Die Serverliste ist zu lang.");return Arrays.stream(list.split(",")).map(entryValue -> entryValue.strip()).filter(s->!s.isEmpty()).map(RemoteTransfers::alias).distinct().filter(s->c.root()||!documents.parameter(0,"FTP_DISPLAY_ADMIN_ONLY_"+s,"0").equals("1")).toList();} boolean accessible(MasterDocuments.Context c,String alias){return aliases(c).contains(alias)&&(c.root()||!documents.parameter(0,"FTP_ACCESS_ADMIN_ONLY_"+alias,"0").equals("1"));} RemoteFileStore.Endpoint endpoint(MasterDocuments.Context c,String alias,RemoteFileStore.Protocol protocol){alias(alias);if(protocol==null)throw bad("Bitte ein Übertragungsprotokoll auswählen.");if(!accessible(c,alias))throw MasterDirectoryScope.denied();long hq=c.headquarters(); String host=documents.parameter(hq,"FTP_SERVER_"+alias,""),user=documents.parameter(hq,"FTP_USER_"+alias,""),password=documents.parameter(hq,"FTP_PASSWORD_"+alias,""); @@ -39,7 +39,7 @@ class RemoteTransfers { void actor(LegacyUser u,MasterDocuments.Kind kind,long object,Operation op){if(op.user()!=u.userId()||op.headquarters()!=u.headquartersId()||op.kind()!=kind||op.object()!=object)throw MasterDirectoryScope.denied();} List operations(LegacyUser u,MasterDocuments.Kind kind,long object){return documents.db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngtransfer' AND gdc_obj_id=? AND gdc_context IN ('PENDING','REMOVED') ORDER BY gdc_id DESC LIMIT 501",object).stream().map(this::decode).filter(o->o.user()==u.userId()&&o.headquarters()==u.headquartersId()&&o.kind()==kind).toList();} Map options(LegacyUser u,MasterDocuments.Kind kind,long object){var c=documents.context(u,kind,object);var ops=operations(u,kind,object);return fields("servers",aliases(c).stream().map(s->fields("name",s,"accessible",accessible(c,s),"configured",!documents.parameter(c.headquarters(),"FTP_SERVER_"+s,"").isBlank())).toList(),"canWrite",c.upload(),"canSend",c.upload()&&c.download(),"maxBytes",MasterDocuments.MAX_UPLOAD,"operations",ops.stream().filter(o->o.status().equals("PENDING")).map(o->o.command()).toList(),"removed",ops.stream().filter(o->o.status().equals("REMOVED")).map(o->o.command()).toList());} - Map list(LegacyUser u,MasterDocuments.Kind kind,long object,String alias,RemoteFileStore.Protocol protocol){var c=documents.context(u,kind,object);var e=endpoint(c,alias,protocol);List entries;try(var fs=remote.open(e)){entries=fs.list();}var after=documents.context(u,kind,object);same(stamp(endpoint(after,alias,protocol)),stamp(e));return fields("alias",alias,"protocol",protocol,"directory",e.directory(),"items",entries.stream().filter(f->!f.directory()&&!f.name().startsWith(".")).sorted(Comparator.comparing(DocumentStore.Entry::name)).map(f->fields("name",f.name(),"size",f.size(),"modified",f.modified(),"revision",version(f),"localName",localName(c,f.name()),"receiveError",localName(c,f.name()).isEmpty()?"Dateityp oder Dateiname nicht für die Dokumentablage freigegeben.":"")).toList());} + Map list(LegacyUser u,MasterDocuments.Kind kind,long object,String alias,RemoteFileStore.Protocol protocol){var c=documents.context(u,kind,object);var e=endpoint(c,alias,protocol);List entries;try(var fs=remote.open(e)){entries=fs.list();}var after=documents.context(u,kind,object);same(stamp(endpoint(after,alias,protocol)),stamp(e));return fields("alias",alias,"protocol",protocol,"directory",e.directory(),"items",entries.stream().filter(f->!f.directory()&&!f.name().startsWith(".")).sorted(Comparator.comparing(entryValue -> entryValue.name())).map(f->fields("name",f.name(),"size",f.size(),"modified",f.modified(),"revision",version(f),"localName",localName(c,f.name()),"receiveError",localName(c,f.name()).isEmpty()?"Dateityp oder Dateiname nicht für die Dokumentablage freigegeben.":"")).toList());} static String localName(MasterDocuments.Context c,String name){try{return MasterDocuments.uploadName(c,name);}catch(RuntimeException e){return "";}} Object start(LegacyUser u,MasterDocuments.Kind kind,long object,Command command){return documents.db.documentOperation(()->startLocked(u,kind,object,command));} Object startLocked(LegacyUser u,MasterDocuments.Kind kind,long object,Command cmd){if(cmd==null)throw bad("Dateivorgang fehlt.");BusinessSupport.token(cmd.requestId());var c=documents.context(u,kind,object);if(!c.upload())throw MasterDirectoryScope.denied();var e=endpoint(c,cmd.alias(),cmd.protocol());MasterDocuments.dir(c,cmd.folder());DocumentStore.component(cmd.name()); diff --git a/backend/src/main/java/de/stadtbote/votian/ReportForms.java b/backend/src/main/java/de/stadtbote/votian/ReportForms.java index ac26b16..52a6cb1 100644 --- a/backend/src/main/java/de/stadtbote/votian/ReportForms.java +++ b/backend/src/main/java/de/stadtbote/votian/ReportForms.java @@ -4,7 +4,7 @@ import de.stadtbote.votian.database.DatabaseService; import org.jsoup.Jsoup; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; -import java.nio.charset.StandardCharsets; + import java.time.*; import java.util.*; import static de.stadtbote.votian.AdminSupport.*; @@ -20,7 +20,6 @@ class ReportForms { private final FormAdministration forms; private final NewsletterTemplate templates; private final FormFieldSupport special; - private static final Set EDITABLE=Set.of("varchar","text","checkbox","phone_business","phone_private","email_business","email_private","date","birthdate","time","select:crvh:crvh_sid"); record Save(String revision,String schemaRevision,String reportRevision,Map values) {} ReportForms(DatabaseService db,ReportAdministration reports,FormAdministration forms,NewsletterTemplate templates) { this.special=new FormFieldSupport(db);this.db=db;this.reports=reports;this.forms=forms;this.templates=templates; diff --git a/backend/src/main/java/de/stadtbote/votian/SalesAnalytics.java b/backend/src/main/java/de/stadtbote/votian/SalesAnalytics.java index 490e64f..f791d13 100644 --- a/backend/src/main/java/de/stadtbote/votian/SalesAnalytics.java +++ b/backend/src/main/java/de/stadtbote/votian/SalesAnalytics.java @@ -30,12 +30,12 @@ class SalesAnalytics { var published=new HashSet();for(var l:logs)published.add(id(l,"userId")+"/"+id(l,"customerId")+"/"+text(l,"description"));for(var l:pending)if(published.add(id(l,"userId")+"/"+id(l,"customerId")+"/"+text(l,"description")))logs.add(l); logs.sort(Comparator.comparing((Map l)->text(l,"headquarters")).thenComparing(l->text(l,"person")).thenComparing(l->text(l,"time"))); if(logs.size()>20000)throw bad("Zu viele Statuswechsel. Bitte Zeitraum eingrenzen.");var conversions=new ArrayList>();var summary=new LinkedHashMap>(); - for(var l:logs){var d=description(text(l,"description"));String old=d.getOrDefault("OLD","");String src=d.getOrDefault("SRC","");if(!"0".equals(d.get("NEW"))||!Set.of("1","2","3").contains(old)||!Set.of("jb","cs").contains(src))continue;var row=new LinkedHashMap<>(l);row.remove("description");row.put("source",src.equals("jb")?"Auftrag":"Kundenstamm");row.put("previousStatus",switch(old){case "1"->"Interessent";case "2"->"Potentialkunde";default->"Wunschkunde";});row.put("revenue",current.getOrDefault(id(l,"customerId"),BigDecimal.ZERO));conversions.add(row);String key=id(l,"userId")+"/"+text(l,"headquarters");var person=summary.computeIfAbsent(key,k->fields("person",text(l,"person"),"headquarters",text(l,"headquarters"),"counts",new LinkedHashMap()));var counts=(Map)person.get("counts");counts.merge(src+old,1L,Long::sum);} + for(var l:logs){var d=description(text(l,"description"));String old=d.getOrDefault("OLD","");String src=d.getOrDefault("SRC","");if(!"0".equals(d.get("NEW"))||!Set.of("1","2","3").contains(old)||!Set.of("jb","cs").contains(src))continue;var row=new LinkedHashMap<>(l);row.remove("description");row.put("source",src.equals("jb")?"Auftrag":"Kundenstamm");row.put("previousStatus",switch(old){case "1"->"Interessent";case "2"->"Potentialkunde";default->"Wunschkunde";});row.put("revenue",current.getOrDefault(id(l,"customerId"),BigDecimal.ZERO));conversions.add(row);String key=id(l,"userId")+"/"+text(l,"headquarters");var person=summary.computeIfAbsent(key,k->fields("person",text(l,"person"),"headquarters",text(l,"headquarters"),"counts",new LinkedHashMap()));var counts=TypedValues.map(person.get("counts"), String.class, Long.class);counts.merge(src+old,1L,(entryValue, otherValue) -> Long.sum(entryValue, otherValue));} var customers=db.queryForList("SELECT cs.cs_id AS customerId,cs.cs_eid AS customerNumber,co.cmp_comp AS company,CONCAT_WS(', ',u.usr_name,u.usr_firstname) AS person,u.usr_id AS userId,h.hq_mnemonic AS headquarters FROM customer cs JOIN company co ON co.cmp_id=cs.cmp_id JOIN employee e ON e.emp_id=co.emp_id_sales JOIN user u ON u.usr_id=e.usr_id JOIN headquarters h ON h.hq_id=cs.hq_id WHERE cs.hq_id IN ("+marks(h.size())+") ORDER BY h.hq_mnemonic,person,co.cmp_comp LIMIT 20001",h.toArray());if(customers.size()>20000)throw bad("Zu viele Kunden. Bitte Niederlassungen eingrenzen.");var growth=new ArrayList>();for(var c:customers){long cid=id(c,"customerId");var row=new LinkedHashMap<>(c);BigDecimal now=current.getOrDefault(cid,BigDecimal.ZERO),before=prior.getOrDefault(cid,BigDecimal.ZERO);row.put("current",now);row.put("previous",before);row.put("growth",now.subtract(before));growth.add(row);summary.computeIfAbsent(id(c,"userId")+"/"+text(c,"headquarters"),k->fields("person",text(c,"person"),"headquarters",text(c,"headquarters"),"counts",Map.of()));} return fields("conversions",conversions,"summary",summary.values(),"growth",growth,"from",q.from(),"to",q.to(),"previousFrom",previousYear(q.from()),"previousTo",previousYear(q.to()));});} static YearMonth month(String value){try{var m=YearMonth.parse(value);if(m.getYear()<1000||m.getYear()>9998)throw bad("Ungültiger Monat.");return m;}catch(DateTimeException|NullPointerException e){throw bad("Monat bitte als JJJJ-MM angeben.");}} private static String targetKey(YearMonth m){return "MOTIVATIONCOUNTER_"+m.toString().replace('-','_');} - Map motivation(LegacyUser u,String selected,List requested){var h=scope.checked(u,requested);var m=month(selected);var today=LocalDate.now(ZoneId.of("Europe/Berlin"));if(m.isAfter(YearMonth.from(today)))throw bad("Umsatzmonat liegt in der Zukunft.");LocalDate end=m.atEndOfMonth().isAfter(today)?today:m.atEndOfMonth();var args=new ArrayList(h);args.add(m.atDay(1).atStartOfDay());args.add(end.plusDays(1).atStartOfDay());String exclusion=catalog.parameter(0,"MOTIVATIONCOUNTER_EXCLUDED_CS");var excluded=exclusion==null?List.of():Arrays.stream(exclusion.split(",")).map(String::strip).filter(s->!s.isEmpty()).toList();if(excluded.size()>10000)throw bad("Zu viele ausgeschlossene Kunden im Parameter.");args.addAll(excluded); + Map motivation(LegacyUser u,String selected,List requested){var h=scope.checked(u,requested);var m=month(selected);var today=LocalDate.now(ZoneId.of("Europe/Berlin"));if(m.isAfter(YearMonth.from(today)))throw bad("Umsatzmonat liegt in der Zukunft.");LocalDate end=m.atEndOfMonth().isAfter(today)?today:m.atEndOfMonth();var args=new ArrayList(h);args.add(m.atDay(1).atStartOfDay());args.add(end.plusDays(1).atStartOfDay());String exclusion=catalog.parameter(0,"MOTIVATIONCOUNTER_EXCLUDED_CS");var excluded=exclusion==null?List.of():Arrays.stream(exclusion.split(",")).map(entryValue -> entryValue.strip()).filter(s->!s.isEmpty()).toList();if(excluded.size()>10000)throw bad("Zu viele ausgeschlossene Kunden im Parameter.");args.addAll(excluded); var amounts=db.queryForList("SELECT jb.hq_id AS id,COUNT(*) AS jobs,SUM(jb.jb_totalprice) AS amount FROM job jb JOIN costcenter csc ON csc.csc_id=jb.csc_id_payer JOIN customer cs ON cs.cs_id=csc.cs_id WHERE jb.hq_id IN ("+marks(h.size())+") AND jb.jb_status=2 AND (jb.jb_storno IS NULL OR jb.jb_storno IN (0,1,3)) AND jb.jb_finishtime>=? AND jb.jb_finishtime(h);targets.add(targetKey(m));var targetRows=db.queryForList("SELECT hq_id,par_value FROM parameter WHERE emp_id=0 AND hq_id IN ("+marks(h.size())+") AND par_key=?",targets.toArray());var hqs=scope.headquarters(u);var rows=new ArrayList>();for(long hq:h){var a=amounts.stream().filter(r->id(r,"id")==hq).findFirst().orElse(Map.of());var ts=targetRows.stream().filter(r->id(r,"hq_id")==hq).toList();if(ts.size()>1)throw bad("Mehrere Zielvorgaben für einen Monat. Bitte Parameter bereinigen.");BigDecimal target=ts.isEmpty()?BigDecimal.ZERO:amount(ts.getFirst(),"par_value"),actual=amount(a,"amount");rows.add(fields("id",hq,"label",hqs.stream().filter(r->id(r,"id")==hq).map(r->text(r,"label")).findFirst().orElse(""),"jobs",id(a,"jobs"),"actual",actual,"target",target,"remaining",target.subtract(actual),"percent",percent(actual,target)));} return fields("month",m.toString(),"through",end,"items",rows);} diff --git a/backend/src/main/java/de/stadtbote/votian/SavedTourManagement.java b/backend/src/main/java/de/stadtbote/votian/SavedTourManagement.java index 60ab089..c7b6c57 100644 --- a/backend/src/main/java/de/stadtbote/votian/SavedTourManagement.java +++ b/backend/src/main/java/de/stadtbote/votian/SavedTourManagement.java @@ -38,7 +38,7 @@ class SavedTourManagement { name=required(draft.name(),50,"Tourenname"); if(name.codePoints().anyMatch(Character::isISOControl))throw bad("Der Tourenname darf keine Steuerzeichen enthalten."); } else if(draft.name()!=null&&!draft.name().isEmpty())throw bad("Beim Entfernen darf kein neuer Tourenname angegeben sein."); - return new Draft(draft.customer(),draft.action(),draft.selection().stream().sorted(Comparator.comparingLong(Selection::id)).toList(),name); + return new Draft(draft.customer(),draft.action(),draft.selection().stream().sorted(Comparator.comparingLong(entryValue -> entryValue.id())).toList(),name); } Preview preview(LegacyUser u,Draft input){access(u);var draft=validate(input);return db.readTransaction(()->prepare(u,draft,false));} private int lockMinutes(LegacyUser u){ diff --git a/backend/src/main/java/de/stadtbote/votian/SecurityConfig.java b/backend/src/main/java/de/stadtbote/votian/SecurityConfig.java index d85f312..51ae0cf 100644 --- a/backend/src/main/java/de/stadtbote/votian/SecurityConfig.java +++ b/backend/src/main/java/de/stadtbote/votian/SecurityConfig.java @@ -9,7 +9,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; + import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.csrf.CsrfException; @@ -34,8 +34,8 @@ public class SecurityConfig { .csrf(csrf -> csrf.csrfTokenRepository(new HttpSessionCsrfTokenRepository())) .cors(cors -> {}) // Authorization belongs to any upstream Basic authentication, not to VoTiAn. - .httpBasic(AbstractHttpConfigurer::disable) - .requestCache(AbstractHttpConfigurer::disable) + .httpBasic(entryValue -> entryValue.disable()) + .requestCache(entryValue -> entryValue.disable()) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .sessionFixation(fixation -> fixation.changeSessionId())) @@ -99,7 +99,7 @@ public class SecurityConfig { @Bean CorsConfigurationSource cors(@Value("${app.cors-origins}") String origins) { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(Arrays.stream(origins.split(",")).map(String::trim).toList()); + config.setAllowedOrigins(Arrays.stream(origins.split(",")).map(entryValue -> entryValue.trim()).toList()); config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN", "X-Workspace-Headquarters", "X-Order-Headquarters")); config.setExposedHeaders(List.of("Content-Disposition", "X-Total-Count", "X-Result-Limit")); diff --git a/backend/src/main/java/de/stadtbote/votian/StatementPdf.java b/backend/src/main/java/de/stadtbote/votian/StatementPdf.java index 3a2d8dd..7d4885f 100644 --- a/backend/src/main/java/de/stadtbote/votian/StatementPdf.java +++ b/backend/src/main/java/de/stadtbote/votian/StatementPdf.java @@ -11,7 +11,7 @@ final class StatementPdf { private StatementPdf(){} static byte[] create(Map data,BillingService.Query query){ var lines=new ArrayList();lines.add(query.mode().equals("COURIER")?"Kurierabrechnung / Leistungsübersicht":"Rechnungsanlage / Leistungsübersicht");lines.add(query.from()+" bis "+query.to());lines.add(""); - for(var row:(List>)data.get("rows")){ + for(var row:TypedValues.rows(data.get("rows"))){ lines.add("Auftrag "+text(row,"id")+" Rechnung "+text(row,"invoice")+" Kurier "+text(row,"courier")); wrap(lines,text(row,"customer")+" / "+text(row,"centerName")); lines.add("Erledigt: "+text(row,"finished")+" Referenz: "+text(row,"reference")); diff --git a/backend/src/main/java/de/stadtbote/votian/StatisticsExclusions.java b/backend/src/main/java/de/stadtbote/votian/StatisticsExclusions.java index 3ae5946..678c0aa 100644 --- a/backend/src/main/java/de/stadtbote/votian/StatisticsExclusions.java +++ b/backend/src/main/java/de/stadtbote/votian/StatisticsExclusions.java @@ -5,7 +5,7 @@ final class StatisticsExclusions { record Plan(String where,List values,List summary){static Plan none(){return new Plan("",List.of(),List.of());}} static List configured(OrderEntryCatalog catalog){ var raw=catalog.parameter(0,"STATISTIC_EXCLUDED_CS");if(raw.isBlank())return List.of(); - var ids=Arrays.stream(raw.split(",",-1)).map(String::strip).filter(s->!s.isEmpty()).distinct().toList(); + var ids=Arrays.stream(raw.split(",",-1)).map(entryValue -> entryValue.strip()).filter(s->!s.isEmpty()).distinct().toList(); if(ids.size()>1000||ids.stream().anyMatch(s->s.length()>50||s.codePoints().anyMatch(Character::isISOControl)))throw bad("STATISTIC_EXCLUDED_CS enthält ungültige Kundennummern."); return ids; } diff --git a/backend/src/main/java/de/stadtbote/votian/StatisticsPrices.java b/backend/src/main/java/de/stadtbote/votian/StatisticsPrices.java index 564e8b1..8e18838 100644 --- a/backend/src/main/java/de/stadtbote/votian/StatisticsPrices.java +++ b/backend/src/main/java/de/stadtbote/votian/StatisticsPrices.java @@ -1,6 +1,6 @@ package de.stadtbote.votian; -import java.util.*; + import static de.stadtbote.votian.StatisticsService.*; import static de.stadtbote.votian.AdminSupport.bad; diff --git a/backend/src/main/java/de/stadtbote/votian/TourCsvImport.java b/backend/src/main/java/de/stadtbote/votian/TourCsvImport.java index d2e27c2..ba5d4cc 100644 --- a/backend/src/main/java/de/stadtbote/votian/TourCsvImport.java +++ b/backend/src/main/java/de/stadtbote/votian/TourCsvImport.java @@ -55,7 +55,7 @@ class TourCsvImport { } static List groups(Plan p){var byName=new LinkedHashMap>();for(var i:p.items())if(!i.existing())byName.computeIfAbsent(i.input().name(),k->new ArrayList<>()).add(i);var result=new ArrayList();for(var e:byName.entrySet()){if(e.getValue().size()>100)throw bad("Tour "+e.getKey()+": höchstens 100 Stationen je Auftrag.");result.add(new Group(e.getKey(),e.getValue().getFirst().input().date(),List.copyOf(e.getValue()),p.state().namedJobs().stream().filter(j->text(j,"jb_tourname").equals(e.getKey())).map(j->id(j,"jb_id")).toList()));}return List.copyOf(result);} Map preview(LegacyUser u,long customer,Command c){return preview(u,customer,c,Format.TOURS);} - Map preview(LegacyUser u,long customer,Command c,Format format){zone.access(u,customer);return db.readTransaction(()->{var p=plan(u,customer,c,format);return fields("revision",revision(p),"groups",groups(p),"existing",p.items().stream().filter(Item::existing).toList(),"newCount",p.items().stream().filter(i->!i.existing()).count(),"renamed",p.state().namedJobs());});} + Map preview(LegacyUser u,long customer,Command c,Format format){zone.access(u,customer);return db.readTransaction(()->{var p=plan(u,customer,c,format);return fields("revision",revision(p),"groups",groups(p),"existing",p.items().stream().filter(entryValue -> entryValue.existing()).toList(),"newCount",p.items().stream().filter(i->!i.existing()).count(),"renamed",p.state().namedJobs());});} Map saved(LegacyUser u,long customer,Command c){var r=one(db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngtourimp' 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(r,"gdc_content"),List.class));}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw bad("Importbestätigung nicht lesbar.");}} Map save(LegacyUser u,long customer,Command c){return save(u,customer,c,Format.TOURS);} Map save(LegacyUser u,long customer,Command c,Format format){zone.access(u,customer);if(c==null)throw bad("Importdaten fehlen.");BusinessSupport.token(c.requestId());var intent=List.of(format.name(),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(()->{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,format);unchanged(p,c.revision());var groups=groups(p);if(!p.state().namedJobs().isEmpty()&&!c.replaceNames())throw bad("Die Entfernung bestehender Tourennamen muss ausdrücklich bestätigt werden.");var jobs=new ArrayList>(); diff --git a/backend/src/main/java/de/stadtbote/votian/TrackingMailContent.java b/backend/src/main/java/de/stadtbote/votian/TrackingMailContent.java index 78bd1d8..a4554a5 100644 --- a/backend/src/main/java/de/stadtbote/votian/TrackingMailContent.java +++ b/backend/src/main/java/de/stadtbote/votian/TrackingMailContent.java @@ -35,7 +35,7 @@ final class TrackingMailContent { }else body.append("

").append(t.apply("an")).append(' ').append(escape(text(stop,"tr_comp"))).append("

").append(escape(address)).append("

").append(t.apply("ist unterwegs")).append('.'); String link=config.base()+"/tracking/tracking_ADSG.php?trackingID="+token; body.append("

").append(t.apply("Zur Verfolgung Ihrer Sendung betätigen Sie bitte nachstehenden Link")).append(":

").append(t.apply("Bitte hier klicken!")).append(""); - String image=config.base()+"/images/external/"+org.springframework.web.util.UriUtils.encodePathSegment(config.logo(),java.nio.charset.StandardCharsets.UTF_8); + String image=config.base()+"/images/external/"+org.springframework.web.util.UriUtils.encodePathSegment(java.util.Objects.requireNonNull(config.logo()),java.util.Objects.requireNonNull(java.nio.charset.StandardCharsets.UTF_8)); String size=(config.height().isBlank()?"":" height=\""+config.height()+"\"")+(config.width().isBlank()?"":" width=\""+config.width()+"\""); String html=""+t.apply("Sendungsverfolgung")+"
\"Logo\"
"+body+"
\"\"
"; return new Content(subject,html); diff --git a/backend/src/main/java/de/stadtbote/votian/TransferPortalScope.java b/backend/src/main/java/de/stadtbote/votian/TransferPortalScope.java index 222b4dd..d2cb471 100644 --- a/backend/src/main/java/de/stadtbote/votian/TransferPortalScope.java +++ b/backend/src/main/java/de/stadtbote/votian/TransferPortalScope.java @@ -15,9 +15,9 @@ final class TransferPortalScope { Grant found=null; for(String entry:configured.split("\\|",-1)){ if(entry.isBlank())continue; - String[] p=Arrays.stream(entry.split(",",-1)).map(String::strip).toArray(String[]::new); + String[] p=Arrays.stream(entry.split(",",-1)).map(entryValue -> entryValue.strip()).toArray(String[]::new); if(!p[0].equals(Integer.toString(type)))continue; - if(found!=null||p.length!=6||Arrays.stream(p).anyMatch(String::isBlank))throw MasterDirectoryScope.denied(); + if(found!=null||p.length!=6||Arrays.stream(p).anyMatch(entryValue -> entryValue.isBlank()))throw MasterDirectoryScope.denied(); try{ long hq=Long.parseLong(p[1]),object=Long.parseLong(p[4]);String kind=p[3].toUpperCase(Locale.ROOT); if(hq!=headquarters||object<=0||!Set.of("CS","CR","CRVH","JB","EMP","RAMP").contains(kind)||type==5&&!kind.equals("RAMP"))throw MasterDirectoryScope.denied(); diff --git a/backend/src/main/java/de/stadtbote/votian/TypedValues.java b/backend/src/main/java/de/stadtbote/votian/TypedValues.java new file mode 100644 index 0000000..e3d039e --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/TypedValues.java @@ -0,0 +1,41 @@ +package de.stadtbote.votian; + +import java.util.List; +import java.util.Map; + +/** Checked views of the map-based API payloads; retain their identity and mutability. */ +public final class TypedValues { + private TypedValues() {} + + @SuppressWarnings("unchecked") + public static List list(Object value, Class elementType) { + if (value == null) return null; + if (!(value instanceof List values)) throw new ClassCastException("Expected a list"); + for (Object element : values) elementType.cast(element); + return (List) values; + } + + @SuppressWarnings("unchecked") + public static Map map(Object value, Class keyType, Class valueType) { + if (value == null) return null; + if (!(value instanceof Map values)) throw new ClassCastException("Expected a map"); + values.forEach((key, entry) -> { keyType.cast(key); valueType.cast(entry); }); + return (Map) values; + } + + @SuppressWarnings("unchecked") + public static List> rows(Object value) { + if (value == null) return null; + if (!(value instanceof List values)) throw new ClassCastException("Expected a list of rows"); + for (Object row : values) map(row, String.class, Object.class); + return (List>) values; + } + + @SuppressWarnings("unchecked") + public static Map>> rowGroups(Object value) { + if (value == null) return null; + if (!(value instanceof Map values)) throw new ClassCastException("Expected grouped rows"); + values.forEach((key, entry) -> { String.class.cast(key); rows(entry); }); + return (Map>>) values; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/VehicleAvailability.java b/backend/src/main/java/de/stadtbote/votian/VehicleAvailability.java index 8a30cba..ca22252 100644 --- a/backend/src/main/java/de/stadtbote/votian/VehicleAvailability.java +++ b/backend/src/main/java/de/stadtbote/votian/VehicleAvailability.java @@ -36,7 +36,7 @@ final class VehicleAvailability { } static List blocks(List windows,List cells,int weekday,boolean favoured,boolean[] free,int earliest){ if(!favoured)return List.of();var zones=new HashMap();cells.stream().filter(c->c.weekday()==weekday).forEach(c->zones.put(c.daytime(),c.zone())); - var active=windows.stream().filter(w->!zones.getOrDefault(w.id(),"").equals("9999")).toList();int end=active.stream().mapToInt(Window::end).max().orElse(0);var out=new ArrayList(); + var active=windows.stream().filter(w->!zones.getOrDefault(w.id(),"").equals("9999")).toList();int end=active.stream().mapToInt(entryValue -> entryValue.end()).max().orElse(0);var out=new ArrayList(); for(var w:active){String zone=zones.getOrDefault(w.id(),"");var normal=runs(free,Math.max(w.start(),earliest),w.end());int max=0;for(int[] r:normal){out.add(new Block(w.id(),r[0],r[1],zone));max=Math.max(max,r[1]);} // PHP keeps an extra uninterrupted block crossing the end of a daytime. if(w.id()=earliest){for(int[] r:runs(free,Math.max(w.start(),earliest),end)){if(r[0]+r[1]>=w.end()&&r[1]>max){out.add(new Block(w.id(),r[0],r[1],zone));break;}}} diff --git a/backend/src/main/java/de/stadtbote/votian/VehicleCustomerRelations.java b/backend/src/main/java/de/stadtbote/votian/VehicleCustomerRelations.java index e8a0f52..04327cf 100644 --- a/backend/src/main/java/de/stadtbote/votian/VehicleCustomerRelations.java +++ b/backend/src/main/java/de/stadtbote/votian/VehicleCustomerRelations.java @@ -41,7 +41,7 @@ class VehicleCustomerRelations { result.add(fields("customer",customer,"label",text(eid,"cs_eid")+" · "+text(company,"cmp_comp")+" "+text(company,"cmp_comp2"),"relation",id(row,"csvh_relation"),"position",id(row,"csvh_sort"),"description",text(row,"csvh_description"),"schedule",schedule,"context",ctx,"warning",warning));} return fields("vehicle",vehicle,"sid",text(v,"crvh_sid"),"rows",result,"revision",revision(rows)); });} - static void validateSchedule(List cells,Context ctx){VehicleAvailability.encode(cells,new HashSet<>(ctx.windows().stream().map(VehicleAvailability.Window::id).toList()),new HashSet<>(ctx.zones().stream().map(r->text(r,"id")).toList()));} + static void validateSchedule(List cells,Context ctx){VehicleAvailability.encode(cells,new HashSet<>(ctx.windows().stream().map(entryValue -> entryValue.id()).toList()),new HashSet<>(ctx.zones().stream().map(r->text(r,"id")).toList()));} void validate(LegacyUser u,Command c){u.require(1);if(c==null||c.action()==null||!Set.of("ADD","UPDATE","REMOVE").contains(c.action()))throw bad("Unbekannte Zuordnungsaktion.");BusinessSupport.token(c.requestId());if(c.customers()==null||c.customers().isEmpty()||c.customers().size()>50||new HashSet<>(c.customers()).size()!=c.customers().size()||c.customers().stream().anyMatch(i->i==null||i<=0)||!c.action().equals("REMOVE")&&c.customers().size()!=1)throw bad("Bitte eine eindeutige Kundenauswahl treffen.");} Prepared prepare(LegacyUser u,Command c){ validate(u,c);var v=vehicle(u,c.vehicle());var before=rows(c.vehicle());unchanged(before,c.revision());var after=new ArrayList>();before.forEach(r->after.add(new LinkedHashMap<>(r)));var evidence=new ArrayList();evidence.add(v);evidence.add(before); @@ -52,7 +52,7 @@ class VehicleCustomerRelations { if(c.action().equals("REMOVE")){after.remove(old);}else{ 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."); Context ctx=context(cs,units);validateSchedule(c.schedule(),ctx);evidence.add(ctx); - String schedule=VehicleAvailability.encode(c.schedule(),new HashSet<>(ctx.windows().stream().map(VehicleAvailability.Window::id).toList()),new HashSet<>(ctx.zones().stream().map(r->text(r,"id")).toList())); + String schedule=VehicleAvailability.encode(c.schedule(),new HashSet<>(ctx.windows().stream().map(entryValue -> entryValue.id()).toList()),new HashSet<>(ctx.zones().stream().map(r->text(r,"id")).toList())); int position=old!=null&&id(old,"csvh_relation")==1?(int)id(old,"csvh_sort"):0; if(c.relation()==1&&position==0){Integer max=db.queryForObject("SELECT COALESCE(MAX(csvh_sort),0) FROM customervehicle WHERE cs_id=? AND csvh_relation=1",Integer.class,cs);position=max+1;} if(old!=null)after.remove(old);after.add(fields("cs_id",cs,"crvh_id",c.vehicle(),"csvh_relation",c.relation(),"csvh_day_times",schedule,"csvh_sort",c.relation()==1?position:0,"csvh_description",description)); diff --git a/backend/src/main/java/de/stadtbote/votian/VehicleMasterSettings.java b/backend/src/main/java/de/stadtbote/votian/VehicleMasterSettings.java index 9da7118..f94c6c0 100644 --- a/backend/src/main/java/de/stadtbote/votian/VehicleMasterSettings.java +++ b/backend/src/main/java/de/stadtbote/votian/VehicleMasterSettings.java @@ -11,6 +11,7 @@ import static de.stadtbote.votian.OrderEntryService.fields; /** Active technical and financial fields from the current courier_vehicle.php. */ final class VehicleMasterSettings { static final List FIELDS=List.of( + f("crvh_working_load_limit_lift","Tragkraft Hebemittel (kg)","Laderaum","integer",99999,false), f("crvh_drivetype","Antriebsart","Fahrzeug","integer",127,false), f("crvh_env_badge_level","Umweltstufe","Fahrzeug","integer",6,false), f("crvh_owner","Fahrzeugübernahme (Monat/Jahr)","Vertrag","month",0,false), @@ -29,7 +30,7 @@ final class VehicleMasterSettings { f("crvh_business_liability_insurance_date","Betriebshaftpflicht gültig bis","Versicherung und Gebühren","date",0,false), f("crvh_system_fee_amount","Systemgebühr (€)","Versicherung und Gebühren","money",999999,false), f("crvh_id_inv","Abrechnungsfahrzeug","Abrechnung","integer",Integer.MAX_VALUE,false)); - static final Set KEYS=FIELDS.stream().map(MasterData.Field::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); + static final Set KEYS=FIELDS.stream().map(entryValue -> entryValue.key()).collect(java.util.stream.Collectors.toUnmodifiableSet()); static final Set PRICES=Set.of("crvh_partner_commission","crvh_advertising_allowance","crvh_transport_insurance_amount","crvh_business_liability_insurance_amount","crvh_system_fee_amount"); static final Map RIGHTS=Map.of("crvh_partner_commission",41,"crvh_advertising_allowance",40,"crvh_transport_insurance_amount",40,"crvh_transport_insurance_date",40,"crvh_business_liability_insurance_amount",40,"crvh_business_liability_insurance_date",40,"crvh_system_fee_amount",40,"crvh_pda_usage",10,"crvh_special",13); final DatabaseService db; diff --git a/backend/src/main/java/de/stadtbote/votian/VehicleMultiDayPlanning.java b/backend/src/main/java/de/stadtbote/votian/VehicleMultiDayPlanning.java index 67fb3c5..dbe1c89 100644 --- a/backend/src/main/java/de/stadtbote/votian/VehicleMultiDayPlanning.java +++ b/backend/src/main/java/de/stadtbote/votian/VehicleMultiDayPlanning.java @@ -175,7 +175,7 @@ class VehicleMultiDayPlanning { if(c.target()==c.vehicle()&&id(j,"cr_id_order")>0){var assigned=one(db.queryForList("SELECT cr_id,cr_sid FROM courier WHERE cr_id=? AND hq_id=?",id(j,"cr_id_order"),u.headquartersId()));evidence.add(assigned);if(id(assigned,"cr_id")!=id(s.vehicle(),"cr_id")&&!text(assigned,"cr_sid").equals(text(s.vehicle(),"crvh_sid")))throw bad("Der Auftrag ist einem anderen Fahrzeug zugewiesen.");} vehicle(u,c.target(),evidence); 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(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 keys=new TreeSet(Comparator.comparingLong((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();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>>(); - 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(); 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=?",customer,u.headquartersId())));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()){ @@ -206,7 +206,7 @@ class VehicleMultiDayPlanning { 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 save(LegacyUser original,Command c,Long headquarters){ diff --git a/backend/src/main/java/de/stadtbote/votian/VehiclePlanning.java b/backend/src/main/java/de/stadtbote/votian/VehiclePlanning.java index 2bd06dc..31d9e7c 100644 --- a/backend/src/main/java/de/stadtbote/votian/VehiclePlanning.java +++ b/backend/src/main/java/de/stadtbote/votian/VehiclePlanning.java @@ -78,7 +78,7 @@ class VehiclePlanning { var articles=db.queryForList("SELECT trat_id,trat_weight FROM tourarticle WHERE jb_id=? ORDER BY trat_id",job);evidence.add(articles);for(var a:articles){var w=decimal(a,"trat_weight");if(w!=null){if(w.signum()<0)throw bad("Auftrag #"+job+" hat ein negatives Artikelgewicht.");movedWeight=movedWeight.add(w);}} } List entries=c.entries(); - if(moving){if(!entries.isEmpty())throw bad("Bei der Übertragung werden die gespeicherten Plantermine verwendet.");entries=new ArrayList<>();for(var j:source.jobs()){var time=dateTime(j,"jb_ordertime");if(time==null||!time.toLocalDate().equals(c.day()))throw bad("Ein Plantermin passt nicht zum ausgewählten Tag.");entries.add(new Entry(id(j,"jb_id"),time.toLocalTime().toString().substring(0,5)));}entries.sort(Comparator.comparing(Entry::time).thenComparingLong(Entry::job));} + if(moving){if(!entries.isEmpty())throw bad("Bei der Übertragung werden die gespeicherten Plantermine verwendet.");entries=new ArrayList<>();for(var j:source.jobs()){var time=dateTime(j,"jb_ordertime");if(time==null||!time.toLocalDate().equals(c.day()))throw bad("Ein Plantermin passt nicht zum ausgewählten Tag.");entries.add(new Entry(id(j,"jb_id"),time.toLocalTime().toString().substring(0,5)));}entries.sort(Comparator.comparing((Entry entryValue) -> entryValue.time()).thenComparingLong(entryValue -> entryValue.job()));} var placements=placements(entries,durations,source.units(),!moving);long destination=moving?c.target():c.vehicle();long[] before=grids.get(c.vehicle()).clone();Arrays.fill(grids.get(c.vehicle()),0);var dest=grids.get(destination);var changes=new ArrayList>(); var plannedStarts=new LinkedHashMap();for(var p:placements)plannedStarts.put(p.job(),new VehiclePlanningDependencies.Start(destination,c.day().atStartOfDay().plusMinutes(p.start()*60L/source.units()))); warnings.addAll(new VehiclePlanningDependencies(db).check(u,plannedStarts,source.units(),evidence)); diff --git a/backend/src/main/java/de/stadtbote/votian/VehicleRollcards.java b/backend/src/main/java/de/stadtbote/votian/VehicleRollcards.java index 112e156..4f95479 100644 --- a/backend/src/main/java/de/stadtbote/votian/VehicleRollcards.java +++ b/backend/src/main/java/de/stadtbote/votian/VehicleRollcards.java @@ -24,7 +24,7 @@ class VehicleRollcards { @GetMapping("/pdf") org.springframework.http.ResponseEntity pdf(Authentication a,@PathVariable long vehicle,@RequestParam LocalDate day,@RequestParam(defaultValue="ALL") String selection,@RequestParam String revision,@RequestParam(required=false) Long headquarters){ var data=get(a,vehicle,day,selection,headquarters); 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)); } Map view(LegacyUser u,long vehicle,LocalDate day,String selection){ VehicleDispositionScope.require(u);u.require(4);VehicleDisposition.date(day); diff --git a/backend/src/main/java/de/stadtbote/votian/VehicleTimelines.java b/backend/src/main/java/de/stadtbote/votian/VehicleTimelines.java index 236e729..329a918 100644 --- a/backend/src/main/java/de/stadtbote/votian/VehicleTimelines.java +++ b/backend/src/main/java/de/stadtbote/votian/VehicleTimelines.java @@ -53,10 +53,10 @@ class VehicleTimelines { var v=vehicles.stream().filter(r->id(r,"crvh_id")==id).findFirst().orElseThrow(); var a=availability.stream().filter(r->id(r,"crvh_id")==id).toList();var cells=slots.stream().filter(r->id(r,"crvh_id")==id).toList(); List segments=List.of();String warning=a.size()!=1?"Tagesbestand fehlt oder ist mehrdeutig.":""; - 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 e){warning=e.getReason();} + 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 e){warning=java.util.Objects.requireNonNullElse(e.getReason(),"Die Fahrzeugplanung kann nicht bearbeitet werden.");} if(warning.isEmpty()&&segments.stream().anyMatch(s->s.job()<0))warning="Der Tag enthält nicht zugängliche Aufträge."; boolean editable=u.hasRight(4)&&u.hasRight(7)&&!day.isBefore(today)&&warning.isEmpty()&&id(a.getFirst(),"vha_lock")==0&&(id(a.getFirst(),"vha_blocked")&1)==0; - var canMove=editable?segments.stream().map(VehicleDisposition.Segment::job).filter(movable::contains).distinct().sorted().toList():List.of(); + var canMove=editable?segments.stream().map(entryValue -> entryValue.job()).filter(movable::contains).distinct().sorted().toList():List.of(); rows.add(fields("id",id,"sid",text(v,"crvh_sid"),"registration",text(v,"crvh_vh_sign"),"warning",warning,"editable",editable,"weight",a.size()==1?decimal(a.getFirst(),"vha_weight"):null,"segments",segments,"movableJobs",canMove)); } return fields("day",day.toString(),"today",today.toString(),"headquarters",u.headquartersId(),"units",units,"defaultScale",defaultScale(u.headquartersId()),"rows",rows,"serverNow",serverNow.toString(),"allowPastDrops",new DispositionTimelineOptions(db).allowPastDrops(u.headquartersId()),"displayRange",new DispositionTimelineRange(db).read(u.originalHeadquartersId(),customer)); diff --git a/backend/src/main/java/de/stadtbote/votian/WarehouseBarcodes.java b/backend/src/main/java/de/stadtbote/votian/WarehouseBarcodes.java index 3ac5c97..6099d1c 100644 --- a/backend/src/main/java/de/stadtbote/votian/WarehouseBarcodes.java +++ b/backend/src/main/java/de/stadtbote/votian/WarehouseBarcodes.java @@ -26,13 +26,13 @@ class WarehouseBarcodes { if(kind.equals("COMMANDS"))labels.addAll(commands()); else for(long id:WarehouseAdministration.tree(s,stock,true))if(WarehouseInventory.readable(s,WarehouseAdministration.chain(map,id))){var row=map.get(id);labels.add(label(id,text(row,"stk_name"),text(row,"stk_barcode").trim()));} if(labels.size()>5000)throw bad("Für die Barcodeliste bitte einen Unterbereich mit höchstens 5000 Lagerorten auswählen."); - labels.sort(kind.equals("STOCKS")?Comparator.comparing(Label::name,String.CASE_INSENSITIVE_ORDER).thenComparingLong(Label::id):Comparator.comparingLong(Label::id)); + labels.sort(kind.equals("STOCKS")?Comparator.comparing((Label entryValue) -> entryValue.name(),String.CASE_INSENSITIVE_ORDER).thenComparingLong(entryValue -> entryValue.id()):Comparator.comparingLong(entryValue -> entryValue.id())); String title=kind.equals("STOCKS")?"Barcodes der Lagerorte · "+text(path.getFirst(),"stk_name"):"Scannerbefehle und Zifferncodes"; return fields("title",title,"labels",labels,"html",html(title,labels)); });} static List