Erweitere VoTiAn NG um Auftrags-, Stamm- und Verwaltungsfunktionen

Ergänzt Auftragserfassung und -bearbeitung, Unternehmerbelege, Stornos, Lagerkorrekturen, Stammdaten, Abrechnung, CRM, Formulare, Berichte und Preisverwaltung auf Basis der aktuellen PHP-Anwendung.

Enthält Integrations- und Oberflächentests sowie dokumentierte Abgleiche und Prüfstände.
This commit is contained in:
Sven committed 2026-10-13 20:31:31 +02:00
1 parent 42c87282ee
commit 363bc27c26
190 files changed
+14815 -1857

No files matched your search

@@ -0,0 +1,27 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
import jakarta.mail.internet.InternetAddress;
import static de.stadtbote.votian.AdminSupport.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
@Service
class AppointmentNotifications {
enum Kind {NEW,CHANGED,CANCELLED}
record Draft(Kind kind,boolean participants,String external,String salutation,String greeting){}
private final DatabaseService db;private final AppointmentService appointments;private final AppointmentMailTransport transport;
private final Map<String,Pending> pending=new LinkedHashMap<>();
private static class Pending {long user,hq,appointment;String revision,from,reply,subject,body;List<String> recipients;Instant expires;Map<String,Object> result;boolean sending;}
AppointmentNotifications(DatabaseService db,AppointmentService appointments,AppointmentMailTransport transport){this.db=db;this.appointments=appointments;this.transport=transport;}
static String address(String raw){if(raw==null||raw.isBlank()||raw.length()>254||raw.contains("\r")||raw.contains("\n"))throw bad("Ungültige E-Mail-Adresse.");try{var a=new InternetAddress(raw.strip(),true);a.validate();if(a.getPersonal()!=null||!a.getAddress().contains("@"))throw bad("Bitte reine E-Mail-Adressen ohne Anzeigenamen eingeben.");return a.getAddress();}catch(jakarta.mail.internet.AddressException e){throw bad("Ungültige E-Mail-Adresse.");}}
Map<String,Object> preview(LegacyUser u,long appointment,Draft d){var row=appointments.appointment(u,appointment);AppointmentService.editable(u,row);if(d==null||d.kind()==null)throw bad("Benachrichtigungsart fehlt.");if(d.kind()==Kind.CANCELLED&&id(row,"usr_id")!=u.userId())u.require(3);if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert. SMTP_HOST in der .env ergänzen.");
String external=d.external()==null?"":d.external().strip();if(external.length()>2000)throw bad("Zu viele externe Adressen.");var recipients=new TreeMap<String,String>();var missing=new ArrayList<String>();String actor=address(text(one(db.queryForList("SELECT usr_email FROM user WHERE usr_id=? AND hq_id=?",u.userId(),u.headquartersId())),"usr_email"));
if(d.participants()){recipients.put(actor.toLowerCase(Locale.ROOT),actor);var ids=GroupAdministration.memberships(text(row,"ap_participants"));if(!ids.isEmpty()){var args=new ArrayList<Object>(ids);args.add(u.headquartersId());var found=new HashSet<Long>();for(var person:db.queryForList("SELECT DISTINCT u.usr_id,u.usr_email,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM user u JOIN mandatorheadquarters m ON m.hq_id=u.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE u.usr_id IN ("+CrmScope.marks(ids.size())+") AND own.hq_id=? AND own.md_id>0",args.toArray())){found.add(id(person,"usr_id"));try{String a=address(text(person,"usr_email"));recipients.put(a.toLowerCase(Locale.ROOT),a);}catch(org.springframework.web.server.ResponseStatusException e){missing.add(text(person,"label"));}}for(long id:ids)if(!found.contains(id))missing.add("Benutzer "+id+" (nicht verfügbar)");}}
if(!external.isEmpty())for(String part:external.split("[,;]",-1)){String a=address(part);recipients.put(a.toLowerCase(Locale.ROOT),a);}if(recipients.isEmpty()||recipients.size()>100)throw bad("Bitte 1 bis 100 Empfänger auswählen.");
String salutation=d.salutation()==null?"":d.salutation().strip(),greeting=d.greeting()==null?"":d.greeting().strip();if(salutation.length()>1000||greeting.length()>1000)throw bad("Anrede oder Grußformel ist zu lang.");String label=switch(d.kind()){case NEW->"Neuer Termin";case CHANGED->"Termin geändert";case CANCELLED->"Termin abgesagt";};if(dateTime(row,"ap_execdate")==null)throw bad("Der gespeicherte Termin hat keinen gültigen Beginn.");String start=dateTime(row,"ap_execdate").format(DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm"));var p=new Pending();p.user=u.userId();p.hq=u.headquartersId();p.appointment=appointment;p.revision=revision(AppointmentService.version(row));p.from=transport.from.isBlank()?actor:address(transport.from);p.reply=actor;p.subject=label+": "+start;p.body=salutation+"\n\n"+label+"\nBeginn: "+start+"\nEnde: "+(dateTime(row,"ap_enddate")==null?"offen":dateTime(row,"ap_enddate").format(DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm")))+"\n\n"+text(row,"ap_text")+"\n\n"+greeting;p.recipients=List.copyOf(recipients.values());p.expires=Instant.now().plusSeconds(900);String token=UUID.randomUUID().toString();synchronized(pending){pending.values().removeIf(v->v.expires.isBefore(Instant.now())&&!v.sending);if(pending.size()>=1000)throw bad("Zu viele Versandvorschauen. Bitte später versuchen.");pending.put(token,p);}return fields("token",token,"from",p.from,"replyTo",p.reply,"recipients",p.recipients,"missing",missing,"subject",p.subject,"body",p.body);}
Map<String,Object> send(LegacyUser u,String token){u.require(0);u.require(11);Pending p;synchronized(pending){p=pending.get(token);if(p==null||p.user!=u.userId()||p.hq!=u.headquartersId()||p.expires.isBefore(Instant.now()))throw bad("Versandvorschau abgelaufen. Bitte neu erstellen.");if(p.result!=null)return p.result;if(p.sending)throw bad("Versand läuft bereits. Bitte warten.");var row=appointments.appointment(u,p.appointment);AppointmentService.editable(u,row);unchanged(AppointmentService.version(row),p.revision);p.sending=true;}
Map<String,Object> result;try{transport.send(p.from,p.reply,p.recipients,p.subject,p.body);result=fields("sent",true,"message","E-Mail vom SMTP-Server angenommen. Der Termin bleibt gespeichert.");}catch(org.springframework.mail.MailException e){result=fields("sent",false,"message","Der Versand konnte nicht vollständig bestätigt werden. Der Termin bleibt gespeichert. Bitte Zustellung prüfen, bevor Sie eine neue Nachricht senden.");}synchronized(pending){p.result=result;p.sending=false;}return result;}
}